ready to test

Testing

Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
2026-06-22 10:06:58 +08:00
parent bf48c95467
commit 439bb33f77
189 changed files with 17559 additions and 686 deletions
+6
View File
@@ -0,0 +1,6 @@
node_modules
.env
*.log
package-lock.json
docker-compose.yml
.dockerignore
+119
View File
@@ -0,0 +1,119 @@
# ═══════════════════════════════════════════════════════════════════════════════
# $APP_NAME — Environment Variables Template
# Copy to .env and fill in all CHANGE_ME values before deploying.
#
# Self-hosted setup (recommended):
# 1. cp .env.example .env
# 2. Fill in every CHANGE_ME value below
# 3. npm install
# 4. npm start (or: pm2 start server.js --name $APP_NAME)
# 5. Point Nginx to PORT (see nginx.conf.example)
#
# Generate random secrets with:
# node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
# ═══════════════════════════════════════════════════════════════════════════════
# ── App ───────────────────────────────────────────────────────────────────────
APP_NAME=starr
NODE_ENV=production
ORIGIN_GUARD_DISABLED=false # set to true in dev to allow Postman/curl (ignored in production)
PORT=3024
APP_URL=https://api.yourdomain.com
APP_LOGO_URL=https://your-cdn.com/logo.png
FRONTEND_URL=https://yourdomain.com
# ── Database (PostgreSQL / CockroachDB) ──────────────────────────────────────
# For self-hosted PostgreSQL: DB_HOST=127.0.0.1 DB_PORT=5432
# For CockroachDB serverless: DB_HOST=<cluster>.cockroachlabs.cloud DB_PORT=26257
DB_HOST=CHANGE_ME
DB_PORT=5432
DB_NAME=CHANGE_ME
DB_USER=CHANGE_ME
DB_PASSWORD=CHANGE_ME
# DB_SSL=false → disable SSL (use when PostgreSQL is on the same Docker network)
# DB_SSL=true → enable SSL with rejectUnauthorized=false (default — for managed DBs)
DB_SSL=true
# ── Cache driver ──────────────────────────────────────────────────────────────
# Controls the store used by rate limiters and the CSRF session.
#
# memory → no Redis required. Safe for local development (single process).
# Rate limit counters reset on restart; CSRF sessions are in-process.
# Set this in your local .env — no Redis installation needed.
#
# redis → Redis or Valkey required. Use in production (multi-process safe,
# survives restarts). Requires REDIS_URL below.
#
CACHE_DRIVER=redis
# ── Redis / Valkey URL ────────────────────────────────────────────────────────
# Only read when CACHE_DRIVER=redis.
# Valkey is a drop-in Redis replacement — fully supported.
# Arch Linux / Valkey default port: 6380
# Ubuntu / Debian / macOS Redis default port: 6379
# Check your port: systemctl status valkey | grep "valkey-server"
# Managed TLS example: rediss://:<password>@<host>:6380
REDIS_URL=redis://127.0.0.1:6379
# ── JWT ───────────────────────────────────────────────────────────────────────
# Generate each secret independently — never reuse across fields.
JWT_SECRET=CHANGE_ME_32_BYTE_HEX
MEDIA_JWT_SECRET=CHANGE_ME_32_BYTE_HEX
JWT_EXPIRES_IN=15m
JWT_REFRESH_SECRET=CHANGE_ME_32_BYTE_HEX
JWT_REFRESH_EXPIRES_IN=7d
# ── CSRF & Cookies ────────────────────────────────────────────────────────────
SESSION_SECRET=CHANGE_ME_32_BYTE_HEX
# ── Google OAuth ──────────────────────────────────────────────────────────────
# console.cloud.google.com → Credentials → OAuth 2.0 Client ID
# Add https://api.yourdomain.com/api/auth/google/callback to Authorized redirect URIs
GOOGLE_CLIENT_ID=CHANGE_ME
GOOGLE_CLIENT_SECRET=CHANGE_ME
GOOGLE_CALLBACK_URL=https://api.yourdomain.com/api/auth/google/callback
# ── PayPal ────────────────────────────────────────────────────────────────────
# Switch PAYPAL_ENV from "sandbox" to "live" when ready for real payments.
# Live credentials come from developer.paypal.com → My Apps & Credentials → Live tab.
PAYPAL_CLIENT_ID=CHANGE_ME
PAYPAL_CLIENT_SECRET=CHANGE_ME
PAYPAL_ENV=live
# ── Email (SMTP) ──────────────────────────────────────────────────────────────
# Gmail: enable 2FA → generate an App Password at myaccount.google.com/apppasswords
# For higher volume use a transactional provider (Mailgun, Resend, SendGrid, etc.)
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USER=CHANGE_ME@gmail.com
SMTP_PASS=CHANGE_ME_APP_PASSWORD
EMAIL_FROM=CHANGE_ME@gmail.com
OTP_EXPIRY_MINUTES=10
# ── S3-compatible Storage (Garage self-hosted) ────────────────────────────────
# S3_ENDPOINT: internal address of your Garage node (e.g. http://127.0.0.1:3900
# if Garage runs on the same server, or http://<garage-host>:3900)
# S3_PUBLIC_URL: the public-facing base URL served by your reverse proxy
# (e.g. https://cdn.yourdomain.com)
S3_ENDPOINT=http://127.0.0.1:3900
S3_REGION=garage
S3_ACCESS_KEY=CHANGE_ME
S3_SECRET_KEY=CHANGE_ME
S3_BUCKET=CHANGE_ME
S3_PUBLIC_URL=https://cdn.yourdomain.com
# ── Chibisafe (optional — used alongside S3 for some asset types) ─────────────
# Replace the zrok tunnel URL with a stable domain pointing to your Chibisafe instance.
CHIBISAFE_BASE_URL=https://files.yourdomain.com
CHIBISAFE_API_KEY=CHANGE_ME
CHIBISAFE_ALBUM_AVATARS=CHANGE_ME_UUID
CHIBISAFE_ALBUM_IMAGES=CHANGE_ME_UUID
CHIBISAFE_ALBUM_VIDEOS=CHANGE_ME_UUID
CHIBISAFE_ALBUM_THUMBNAILS=CHANGE_ME_UUID
CHIBISAFE_ALBUM_DOCUMENTS=CHANGE_ME_UUID
CHIBISAFE_ALBUM_ARCHIVED=CHANGE_ME_UUID
# ── CORS ──────────────────────────────────────────────────────────────────────
# Comma-separated list of allowed origins. Must match FRONTEND_URL exactly.
ALLOWED_ORIGINS=https://yourdomain.com
+40 -1
View File
@@ -1,5 +1,44 @@
# ── Dependencies ──────────────────────────────────────────────────────────────
node_modules/
.env*
# ── Environment / Secrets ─────────────────────────────────────────────────────
.env
.env.local
.env.*.local
# Keep .env.example — it is safe and documents required variables
# ── SSL / TLS / Keys ──────────────────────────────────────────────────────────
*.pem
*.key
*.cert
*.crt
*.p12
*.pfx
# ── Logs ──────────────────────────────────────────────────────────────────────
*.log
logs/
npm-debug.log*
pnpm-debug.log*
yarn-debug.log*
# ── Build / Output ────────────────────────────────────────────────────────────
dist/
coverage/
# ── Uploads / Temp ────────────────────────────────────────────────────────────
uploads/
tmp/
temp/
# ── OS ────────────────────────────────────────────────────────────────────────
.DS_Store
Thumbs.db
desktop.ini
# ── Editors ───────────────────────────────────────────────────────────────────
.vscode/
.idea/
*.swp
*.swo
*.suo
+21
View File
@@ -0,0 +1,21 @@
FROM node:22-alpine
# Enable corepack and activate pnpm
RUN corepack enable && corepack prepare pnpm@11.3.0 --activate
# pm2 manages the process; sequelize-cli runs migrations on startup
RUN npm install -g pm2 sequelize-cli
WORKDIR /app
# Install production dependencies only
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile --prod
COPY . .
RUN chmod +x docker-entrypoint.sh
EXPOSE 3024
ENTRYPOINT ["./docker-entrypoint.sh"]
+27 -1
View File
@@ -163,7 +163,9 @@ npm start
| Variable | Description | Example |
|---|---|---|
| `APP_NAME` | Application name | `starr` |
| `NODE_ENV` | `development` or `production` | `development` |
| `ORIGIN_GUARD_DISABLED` | Set `true` to bypass originGuard (dev/Postman only; ignored in production) | `false` |
| `PORT` | HTTP port | `3000` |
| `APP_URL` | Frontend/app URL (used for CORS) | `http://localhost:3000` |
| `DB_HOST` | PostgreSQL host | `localhost` |
@@ -264,7 +266,30 @@ CSRF is provided via **csurf** (Double Submit Cookie pattern).
- Current password required before change
- All sessions invalidated after password change
### 6.5 RBAC Hierarchy
### 6.5 Origin Guard
All API routes (except `/api/health`) are protected by `originGuard.middleware.js`, a two-layer non-browser request filter:
| Layer | Header checked | Applies to | Blocks |
|---|---|---|---|
| 1 | `Sec-Fetch-Site` (must be present) | All methods | curl, Postman, Nikto, sqlmap, scanners |
| 2 | `Origin` (must be in `ALLOWED_ORIGINS`) | POST · PUT · PATCH · DELETE | Cross-origin mutation from unlisted domains |
**Development bypass** — set `ORIGIN_GUARD_DISABLED=true` in `.env` to allow Postman and other tools through without restarting with a different config. The bypass is **hard-locked off** when `NODE_ENV=production`, even if the flag is set.
```
# .env — enable for Postman testing
ORIGIN_GUARD_DISABLED=true
# .env — re-enable when done
ORIGIN_GUARD_DISABLED=false
```
> Note: BurpSuite running as a MITM proxy through a real browser is not blocked — the browser supplies all correct headers. Rate limiting and valid credentials are the only defences there.
---
### 6.6 RBAC Hierarchy
```
admin ──► can do everything
@@ -745,6 +770,7 @@ app.use(session({ store: new RedisStore({ client: redisClient }), ... }));
### Production Checklist
- [ ] Set `NODE_ENV=production`
- [ ] Set `ORIGIN_GUARD_DISABLED=false` (or remove the key entirely)
- [ ] Use strong `JWT_SECRET` (≥ 64 random characters)
- [ ] Enable HTTPS / TLS (set `cookie.secure: true`)
- [ ] Point `APP_URL` to your actual domain for CORS
+437
View File
@@ -0,0 +1,437 @@
# API Routes Reference
**Project:** star-auth-system (new_starr backend)
**Base URL:** `/api`
**Last updated:** 2026-06-20
---
## Legend
| Symbol | Meaning |
|--------|---------|
| 🔓 | Public — no authentication required |
| 🔑 | Requires valid JWT (`authenticate`) |
| 👤 | Requires role: `user / staff / admin` (`requireClient`) |
| 🧑‍💼 | Requires role: `staff / admin` (`requireStaff`) |
| 👑 | Requires role: `admin` only (`requireAdmin`) |
| 🌐 | `originGuard` exempt — accessible from any client (monitoring tools, browser media elements) |
| ⚡ | Rate-limited separately (`sensitiveOpsLimiter`) |
> All routes except Health and `GET /api/auth/google*` are protected by `originGuard`
> (requires `Sec-Fetch-Site` header; mutations also require `Origin` in allowlist).
---
## 🏥 Health
> 🌐 No auth · No originGuard — designed for monitoring tools, load balancers, Kubernetes probes
```
GET /api/health Dashboard — app info, system info, all service connections (human-readable)
GET /api/health/ready Readiness — compact 200/503 for machines (Kubernetes, deploy scripts)
```
---
## 🔐 Auth
> 🔓 Public — no authentication required unless noted
```
GET /api/auth/csrf-token CSRF token for cookie-based clients
POST /api/auth/register Create account (sends OTP email)
POST /api/auth/verify-otp Verify OTP → auto-login
POST /api/auth/resend-otp Resend OTP email
POST /api/auth/login Login with email + password
POST /api/auth/refresh Exchange refresh token for new access token
POST /api/auth/logout 🔑 Invalidate current session
POST /api/auth/change-password 🔑 ⚡ Change password
GET /api/auth/google Initiate Google OAuth
GET /api/auth/google/callback Google OAuth callback
GET /api/auth/google/failed OAuth failure fallback
```
---
## 👤 Client
> 🔑 👤 `authenticate → requireClient` applied to all routes below unless noted
### Profile & Sessions
```
GET /api/client/profile Own profile
PUT /api/client/profile Update own profile
POST /api/client/profile/avatar Upload avatar
DELETE /api/client/profile/avatar Delete avatar
GET /api/client/sessions Own active sessions
DELETE /api/client/sessions/:id Revoke a session
GET /api/client/achievements Own achievements
```
### Media
```
GET /api/client/media/stream/:token 🌐🔓 Stream S3 asset (JWT auth via URL param, no headers)
POST /api/client/media/token 🔑 Issue a short-lived media token for an S3 asset
```
### Notifications
```
GET /api/client/notifications/unseen 🔓* Unseen count (* softAuthenticate — returns 0 if unauthenticated)
GET /api/client/notifications Paginated notification list
PATCH /api/client/notifications/seen-all Mark all notifications seen
PATCH /api/client/notifications/:id/seen Mark one notification seen
```
### Tiers & Payments
```
GET /api/client/tiers/me My active tier
GET /api/client/tiers/me/history My tier history
GET /api/client/tiers/me/payments My payment history
GET /api/client/tiers/plans All available plans
POST /api/client/tiers/checkout/order ⚡ Create PayPal order (tier upgrade)
POST /api/client/tiers/checkout/capture ⚡ Capture PayPal payment
POST /api/client/tiers/checkout/cancel ⚡ Cancel PayPal order
POST /api/client/tiers/checkout/refund ⚡ Request refund
```
### Courses
```
GET /api/client/courses Paginated course list
GET /api/client/courses/uuid/:uuid Course by UUID
GET /api/client/courses/unit/uuid/:uuid Unit by UUID
GET /api/client/courses/unit/uuid/:uuid/lessons Lessons by unit UUID
GET /api/client/courses/lesson/uuid/:uuid Lesson by UUID
GET /api/client/courses/:courseId Single course
GET /api/client/courses/:courseId/units/:unitId Single unit
GET /api/client/courses/:courseId/units/:unitId/lessons/:lessonId Single lesson
GET /api/client/courses/:courseId/units/:unitId/quiz Unit quiz (no answers)
GET /api/client/courses/:courseId/assessment Course assessment (no answers)
POST /api/client/courses/:courseId/units/:unitId/quiz/:quizId/submit Submit unit quiz
POST /api/client/courses/:courseId/assessment/:assessmentId/submit Submit course assessment
```
### Course Purchases
```
GET /api/client/course-purchases My course purchases
POST /api/client/course-purchases/order ⚡ Create PayPal order (course purchase)
POST /api/client/course-purchases/capture ⚡ Capture payment
POST /api/client/course-purchases/cancel ⚡ Cancel order
```
### Groups & Tasks
```
GET /api/client/groups My groups
GET /api/client/groups/:groupId Single group
GET /api/client/groups/:groupId/task-lists Task lists (?status=ongoing|done|overdue)
GET /api/client/groups/:groupId/task-lists/:taskListId Single task list
GET /api/client/groups/:groupId/task-lists/:taskListId/tasks/:taskId Task + requirements + latest completion
GET /api/client/groups/:groupId/task-lists/:taskListId/tasks/:taskId/progress Full progress snapshot
GET /api/client/groups/:groupId/task-lists/:taskListId/tasks/:taskId/completions/latest Latest completion
GET /api/client/groups/:groupId/task-lists/:taskListId/tasks/:taskId/completions Full completion history
POST /api/client/groups/:groupId/task-lists/:taskListId/tasks/:taskId/completions ⚡ Submit task completion
GET /api/client/groups/:groupId/task-lists/:taskListId/tasks/:taskId/completions/:completionId/files/:fileId/stream Stream a completion file
GET /api/client/groups/:groupId/task-lists/:taskListId/tasks/:taskId/completions/:completionId/files/:fileId/download Download a completion file
POST /api/client/groups/:groupId/task-lists/:taskListId/tasks/:taskId/upload ⚡ Upload file to S3 (returns file metadata)
POST /api/client/groups/:groupId/task-lists/:taskListId/tasks/:taskId/requirements/:requirementId/visit ⚡ UPSERT link visit
POST /api/client/groups/:groupId/task-lists/:taskListId/tasks/:taskId/requirements/:requirementId/progress ⚡ UPSERT lesson progress
```
### Advertisements
```
GET /api/client/advertisements/active Active advertisement (?type=hero)
POST /api/client/advertisements/:advertisementId/click Track ad click
```
### Certificates
```
GET /api/client/certificates/:courseUuid Download course completion certificate
```
---
## 🧑‍💼 Staff
> 🔑 🧑‍💼 `authenticate → requireStaff`
```
GET /api/staff/users Paginated user list (non-admins only)
GET /api/staff/users/:id Single non-admin user
PUT /api/staff/users/:id/status Activate or deactivate a user
GET /api/staff/users/:id/sessions User's active sessions
```
---
## 👑 Admin
> 🔑 👑 `authenticate → requireAdmin → adminLimiter` applied to all routes below
### Dashboard
```
GET /api/admin/dashboard/users User stats overview
GET /api/admin/dashboard/groups Group stats overview
```
### Profile
```
GET /api/admin/profile Own admin profile
PUT /api/admin/profile Update own profile
POST /api/admin/profile/avatar Upload avatar
DELETE /api/admin/profile/avatar Delete avatar
```
### Users
```
GET /api/admin/users Paginated user list
GET /api/admin/users/field-values Filter field values (for dropdowns)
GET /api/admin/users/archived Archived/deactivated users
POST /api/admin/users/staff ⚡ Create a staff account
POST /api/admin/users/bulk/restore ⚡ Bulk restore users
DELETE /api/admin/users/bulk ⚡ Bulk deactivate users
GET /api/admin/users/:id Single user
PUT /api/admin/users/:id ⚡ Update user
DELETE /api/admin/users/:id ⚡ Deactivate user
POST /api/admin/users/:id/restore ⚡ Restore user
GET /api/admin/users/:id/sessions User's active sessions
DELETE /api/admin/users/:id/sessions/:sid ⚡ Terminate a user session
GET /api/admin/users/:id/achievements User's achievements
```
### Groups
```
GET /api/admin/groups Paginated group list
GET /api/admin/groups/field-values Filter field values
GET /api/admin/groups/archived Archived groups
POST /api/admin/groups/bulk/restore ⚡ Bulk restore groups
DELETE /api/admin/groups/bulk ⚡ Bulk deactivate groups
POST /api/admin/groups ⚡ Create group
GET /api/admin/groups/:gid Single group
PUT /api/admin/groups/:gid ⚡ Update group
PATCH /api/admin/groups/:gid/deactivate ⚡ Deactivate group
PATCH /api/admin/groups/:gid/restore ⚡ Restore group
GET /api/admin/groups/:gid/users Users in group
GET /api/admin/groups/:gid/users/add Users not yet in group (for add dialog)
POST /api/admin/groups/:gid/users ⚡ Add user to group
DELETE /api/admin/groups/:gid/users ⚡ Remove user from group
```
### Assets
```
GET /api/admin/assets Paginated asset list
GET /api/admin/assets/archived Archived assets
GET /api/admin/assets/field-values Filter field values
POST /api/admin/assets Upload asset (file + optional thumbnail)
DELETE /api/admin/assets/bulk ⚡ Bulk archive assets
PATCH /api/admin/assets/bulk-restore Bulk restore assets
GET /api/admin/assets/:assetId Single asset
PATCH /api/admin/assets/:assetId ⚡ Update asset
PATCH /api/admin/assets/:assetId/restore ⚡ Restore asset
DELETE /api/admin/assets/:assetId ⚡ Archive asset
```
### Courses
```
GET /api/admin/courses Paginated course list
POST /api/admin/courses Create course
GET /api/admin/courses/field-values Filter field values
GET /api/admin/courses/flat All courses (flat list, no pagination)
GET /api/admin/courses/units-flat All units (flat list)
GET /api/admin/courses/lessons-flat All lessons (flat list)
GET /api/admin/courses/archives Archived courses
DELETE /api/admin/courses/bulk Bulk archive courses
PATCH /api/admin/courses/restore/bulk Bulk restore courses
GET /api/admin/courses/archives/:courseId Archived course detail
GET /api/admin/courses/:courseId Single course
PUT /api/admin/courses/:courseId Update course
DELETE /api/admin/courses/:courseId Archive course
PATCH /api/admin/courses/:courseId/restore Restore course
GET /api/admin/courses/:courseId/instructors Course instructors
PUT /api/admin/courses/:courseId/instructors Sync instructors (replace)
GET /api/admin/courses/:courseId/prerequisites Course prerequisites
PUT /api/admin/courses/:courseId/prerequisites Sync prerequisites (replace)
GET /api/admin/courses/:courseId/field-values Unit filter field values
```
#### Course Assessment
```
GET /api/admin/courses/:courseId/assessment Assessment
POST /api/admin/courses/:courseId/assessment Create assessment
GET /api/admin/courses/:courseId/assessment/archives Archived assessment
PATCH /api/admin/courses/:courseId/assessment/:assessmentId Update assessment
DELETE /api/admin/courses/:courseId/assessment/:assessmentId Delete assessment
PATCH /api/admin/courses/:courseId/assessment/:assessmentId/restore Restore assessment
GET /api/admin/courses/:courseId/assessment/:assessmentId/questions Questions
POST /api/admin/courses/:courseId/assessment/:assessmentId/questions Create question
DELETE /api/admin/courses/:courseId/assessment/:assessmentId/questions/bulk Bulk archive questions
PATCH /api/admin/courses/:courseId/assessment/:assessmentId/questions/restore/bulk Bulk restore questions
GET /api/admin/courses/:courseId/assessment/:assessmentId/questions/archives/:questionId Archived question
PATCH /api/admin/courses/:courseId/assessment/:assessmentId/questions/:questionId Update question
DELETE /api/admin/courses/:courseId/assessment/:assessmentId/questions/:questionId Delete question
PATCH /api/admin/courses/:courseId/assessment/:assessmentId/questions/:questionId/restore Restore question
```
#### Units
```
GET /api/admin/courses/:courseId/units Unit list
POST /api/admin/courses/:courseId/units Create unit
GET /api/admin/courses/:courseId/units/archives Archived units
DELETE /api/admin/courses/:courseId/units/bulk Bulk archive units
PATCH /api/admin/courses/:courseId/units/restore/bulk Bulk restore units
GET /api/admin/courses/:courseId/units/archives/:unitId Archived unit detail
GET /api/admin/courses/:courseId/units/:unitId Single unit
PUT /api/admin/courses/:courseId/units/:unitId Update unit
DELETE /api/admin/courses/:courseId/units/:unitId Archive unit
PATCH /api/admin/courses/:courseId/units/:unitId/restore Restore unit
GET /api/admin/courses/:courseId/units/:unitId/field-values Lesson filter field values
```
#### Unit Quiz
```
GET /api/admin/courses/:courseId/units/:unitId/quiz Quiz
POST /api/admin/courses/:courseId/units/:unitId/quiz Create quiz
GET /api/admin/courses/:courseId/units/:unitId/quiz/archives Archived quiz
PATCH /api/admin/courses/:courseId/units/:unitId/quiz/:quizId Update quiz
DELETE /api/admin/courses/:courseId/units/:unitId/quiz/:quizId Delete quiz
PATCH /api/admin/courses/:courseId/units/:unitId/quiz/:quizId/restore Restore quiz
GET /api/admin/courses/:courseId/units/:unitId/quiz/:quizId/questions Questions
POST /api/admin/courses/:courseId/units/:unitId/quiz/:quizId/questions Create question
DELETE /api/admin/courses/:courseId/units/:unitId/quiz/:quizId/questions/bulk Bulk archive questions
PATCH /api/admin/courses/:courseId/units/:unitId/quiz/:quizId/questions/restore/bulk Bulk restore questions
GET /api/admin/courses/:courseId/units/:unitId/quiz/:quizId/questions/archives/:questionId Archived question
PATCH /api/admin/courses/:courseId/units/:unitId/quiz/:quizId/questions/:questionId Update question
DELETE /api/admin/courses/:courseId/units/:unitId/quiz/:quizId/questions/:questionId Delete question
PATCH /api/admin/courses/:courseId/units/:unitId/quiz/:quizId/questions/:questionId/restore Restore question
```
#### Lessons
```
GET /api/admin/courses/:courseId/units/:unitId/lessons Lesson list
POST /api/admin/courses/:courseId/units/:unitId/lessons Create lesson
GET /api/admin/courses/:courseId/units/:unitId/lessons/archives Archived lessons
DELETE /api/admin/courses/:courseId/units/:unitId/lessons/bulk Bulk archive lessons
PATCH /api/admin/courses/:courseId/units/:unitId/lessons/restore/bulk Bulk restore lessons
GET /api/admin/courses/:courseId/units/:unitId/lessons/archives/:lessonId Archived lesson detail
GET /api/admin/courses/:courseId/units/:unitId/lessons/:lessonId Single lesson
PUT /api/admin/courses/:courseId/units/:unitId/lessons/:lessonId Update lesson
DELETE /api/admin/courses/:courseId/units/:unitId/lessons/:lessonId Archive lesson
PATCH /api/admin/courses/:courseId/units/:unitId/lessons/:lessonId/restore Restore lesson
GET /api/admin/courses/:courseId/units/:unitId/lessons/:lessonId/page Lesson page content
PUT /api/admin/courses/:courseId/units/:unitId/lessons/:lessonId/page Upsert lesson page content
```
### Task Lists
```
GET /api/admin/task-lists Paginated task list collection
GET /api/admin/task-lists/archived Archived task lists
GET /api/admin/task-lists/field-values Filter field values
POST /api/admin/task-lists ⚡ Create task list
POST /api/admin/task-lists/bulk-archive ⚡ Bulk archive task lists
POST /api/admin/task-lists/bulk-restore ⚡ Bulk restore task lists
GET /api/admin/task-lists/:taskListId Single task list
PATCH /api/admin/task-lists/:taskListId ⚡ Update task list
DELETE /api/admin/task-lists/:taskListId ⚡ Archive task list
PATCH /api/admin/task-lists/:taskListId/restore ⚡ Restore task list
```
#### Task List → Groups
```
GET /api/admin/task-lists/:taskListId/groups Groups assigned to this task list
POST /api/admin/task-lists/:taskListId/groups/assign ⚡ Assign groups
POST /api/admin/task-lists/:taskListId/groups/unassign ⚡ Unassign groups
```
#### Task List → Tasks
```
GET /api/admin/task-lists/:taskListId/tasks Task list's tasks
GET /api/admin/task-lists/:taskListId/tasks/archived Archived tasks
GET /api/admin/task-lists/:taskListId/tasks/field-values Filter field values
POST /api/admin/task-lists/:taskListId/tasks ⚡ Create task
POST /api/admin/task-lists/:taskListId/tasks/bulk-archive ⚡ Bulk archive tasks
POST /api/admin/task-lists/:taskListId/tasks/bulk-restore ⚡ Bulk restore tasks
GET /api/admin/task-lists/:taskListId/tasks/:taskId Single task
PATCH /api/admin/task-lists/:taskListId/tasks/:taskId ⚡ Update task
DELETE /api/admin/task-lists/:taskListId/tasks/:taskId ⚡ Archive task
PATCH /api/admin/task-lists/:taskListId/tasks/:taskId/restore ⚡ Restore task
```
#### Task List → Tasks → Completions
```
GET /api/admin/task-lists/:taskListId/tasks/:taskId/completions All completions
GET /api/admin/task-lists/:taskListId/tasks/:taskId/completions/user/:userId Completions by user
POST /api/admin/task-lists/:taskListId/tasks/:taskId/completions/bulk-archive ⚡ Bulk archive
POST /api/admin/task-lists/:taskListId/tasks/:taskId/completions/bulk-restore ⚡ Bulk restore
GET /api/admin/task-lists/:taskListId/tasks/:taskId/completions/:completionId Single completion
DELETE /api/admin/task-lists/:taskListId/tasks/:taskId/completions/:completionId ⚡ Archive completion
PATCH /api/admin/task-lists/:taskListId/tasks/:taskId/completions/:completionId/restore ⚡ Restore completion
```
### Tiers
```
GET /api/admin/tiers Paginated tier/plan list
POST /api/admin/tiers Create plan
GET /api/admin/tiers/field-values Filter field values
POST /api/admin/tiers/bulk/archive Bulk archive plans
POST /api/admin/tiers/bulk/restore Bulk restore plans
GET /api/admin/tiers/payments All payments (paginated)
GET /api/admin/tiers/payments/field-values Payment filter field values
GET /api/admin/tiers/payments/:id Single payment record
GET /api/admin/tiers/users/:id/tiers Tiers assigned to a user
POST /api/admin/tiers/users/tiers/grant ⚡ Manually grant a tier to a user
PATCH /api/admin/tiers/users/tiers/:tid/revoke ⚡ Revoke a user's tier
GET /api/admin/tiers/:id Single plan
PUT /api/admin/tiers/:id Update plan
DELETE /api/admin/tiers/:id Archive plan
POST /api/admin/tiers/:id/restore Restore plan
GET /api/admin/tiers/:id/courses Courses in this plan
POST /api/admin/tiers/:id/courses Sync courses in plan (replace)
```
### Categories
```
GET /api/admin/categories All categories
POST /api/admin/categories Create category
GET /api/admin/categories/:id Single category
PUT /api/admin/categories/:id Update category
DELETE /api/admin/categories/:id Archive category
POST /api/admin/categories/:id/restore Restore category
```
### Products
```
GET /api/admin/products/courses/:courseId/product Course product pricing
PUT /api/admin/products/courses/:courseId/product Upsert course product
DELETE /api/admin/products/courses/:courseId/product Remove course product
GET /api/admin/products/courses/:courseId/categories Course categories
POST /api/admin/products/courses/:courseId/categories Sync course categories (replace)
```
### Advertisements
```
GET /api/admin/advertisements Paginated list
GET /api/admin/advertisements/archived Archived advertisements
GET /api/admin/advertisements/field-values Filter field values
POST /api/admin/advertisements Create advertisement
DELETE /api/admin/advertisements/bulk ⚡ Bulk archive
PATCH /api/admin/advertisements/bulk-restore Bulk restore
GET /api/admin/advertisements/:advertisementId Single advertisement
PATCH /api/admin/advertisements/:advertisementId ⚡ Update advertisement
PATCH /api/admin/advertisements/:advertisementId/restore ⚡ Restore advertisement
DELETE /api/admin/advertisements/:advertisementId ⚡ Archive advertisement
```
### Notifications
```
GET /api/admin/notifications Paginated notification list
GET /api/admin/notifications/unseen Unseen count
PATCH /api/admin/notifications/seen-all Mark all seen
PATCH /api/admin/notifications/:id/seen Mark one seen
```
---
## Route Count Summary
| Scope | Routes |
|---------|--------|
| Health | 2 |
| Auth | 11 |
| Client | 43 |
| Staff | 4 |
| Admin | 113 |
| **Total** | **173** |
+24
View File
@@ -0,0 +1,24 @@
The good news — no Google verification required for the scopes we use (openid email profile). Verification is only mandatory for sensitive scopes
like Gmail or Drive access. Basic OIDC login is exempt.
What you DO need for production:
1. Publish the app (remove testing restriction)
- Google Auth Platform → Audience → change from Testing to Production
- Right now only manually added test users can log in — publishing removes that limit
2. Add production URIs to the OAuth client
- Authorized JavaScript origins: add https://yourdomain.com and https://api.yourdomain.com
- Authorized redirect URIs: add https://api.yourdomain.com/api/auth/google/callback
3. Update production .env
GOOGLE_CALLBACK_URL=https://api.yourdomain.com/api/auth/google/callback
FRONTEND_URL=https://yourdomain.com
4. HTTPS is required — Google won't allow http:// redirect URIs in production (localhost is the only exception). Your production server needs SSL,
which you likely already have via Nginx.
5. Add Privacy Policy URL to the branding page — Google requires it when the app is published (even without verification). A simple /privacy page on
your frontend is enough.
That's the full checklist. The localhost dev setup stays untouched alongside the production URIs — Google allows multiple redirect URIs per client.
+26
View File
@@ -0,0 +1,26 @@
'use strict';
require('dotenv').config();
const useSSL = process.env.DB_SSL !== 'false';
const base = {
username: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
host: process.env.DB_HOST,
port: parseInt(process.env.DB_PORT, 10) || 26257,
dialect: 'postgres',
dialectOptions: {
...(useSSL && {
ssl: {
require: true,
rejectUnauthorized: false,
},
}),
},
};
module.exports = {
development: base,
production: base,
};
+8 -4
View File
@@ -14,6 +14,8 @@
require('dotenv').config();
const { Sequelize } = require('sequelize');
const useSSL = process.env.DB_SSL !== 'false';
const sequelize = new Sequelize(
process.env.DB_NAME,
process.env.DB_USER,
@@ -23,10 +25,12 @@ const sequelize = new Sequelize(
port: process.env.DB_PORT || 5432,
dialect: 'postgres',
dialectOptions: {
ssl: {
require: true,
rejectUnauthorized: false, // or provide CA cert if strict
},
...(useSSL && {
ssl: {
require: true,
rejectUnauthorized: false, // or provide CA cert if strict
},
}),
},
// logging: process.env.NODE_ENV === 'development' ? console.log : false,
logging: false,
-70
View File
@@ -1,70 +0,0 @@
/***********************************************************************************************************************************************************************
* File Name: passport.config.js
* Type of Program: Configuration
* Description: Passport.js strategy configuration.
* Registers the Google OAuth 2.0 strategy. On first login via Google
* a new user record is auto-created with reg_type="google".
* Returns a signed JWT payload after successful authentication.
* Author: rgrgogu
* Date Created: Oct. 6, 2025
***********************************************************************************************************************************************************************
* HOW TO USE:
* In server.js:
* require('./config/passport.config');
* app.use(passport.initialize());
* In routes:
* router.get('/google', passport.authenticate('google', { scope: ['profile', 'email'] }));
* router.get('/google/callback', passport.authenticate('google', { session: false }), handler);
***********************************************************************************************************************************************************************/
const passport = require('passport');
const { Strategy: GoogleStrategy } = require('passport-google-oauth20');
const mdl_Users = require('../models/users/users.mdl');
passport.use(
new GoogleStrategy(
{
clientID: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
callbackURL: process.env.GOOGLE_CALLBACK_URL,
},
async (accessToken, refreshToken, profile, done) => {
try {
const email = profile.emails?.[0]?.value;
if (!email) return done(new Error('No email returned from Google'), null);
let user = await mdl_Users.findOne({ where: { email } });
if (!user) {
// Auto-register Google users as 'user' acc_type
user = await mdl_Users.create({
email,
reg_type: 'google',
acc_type: 'user',
is_active: true,
is_verified: true, // Google accounts are pre-verified
personal_info: {
name: {
given_name: profile.name?.givenName || '',
last_name: profile.name?.familyName || '',
full_name: profile.displayName || '',
},
avatar: {
url: profile.photos?.[0]?.value || null,
},
},
});
}
if (!user.is_active) {
return done(null, false, { message: 'Account is deactivated.' });
}
return done(null, user);
} catch (err) {
return done(err, null);
}
}
)
);
module.exports = passport;
+25
View File
@@ -0,0 +1,25 @@
// Returns a connected Redis/Valkey client, or null when CACHE_DRIVER=memory.
// Consumers must check for null before using Redis-specific features.
// Use case: when project is going to have LiveView in future.
const useRedis = process.env.CACHE_DRIVER !== 'memory';
if (!useRedis) {
console.log('ℹ️ Cache driver: memory (Redis skipped)');
module.exports = null;
} else {
const { createClient } = require('redis');
const client = createClient({
url: process.env.REDIS_URL || 'redis://127.0.0.1:6379',
});
client.on('error', (err) => console.error('[Redis] Connection error:', err));
(async () => {
await client.connect();
console.log('✅ Redis connected.');
})();
module.exports = client;
}
@@ -0,0 +1,357 @@
// controllers/admin/advertisements.controller.js
const sequelize = require("../../config/db.config");
const Advertisement = require("../../models/advertisements/advertisements.mdl");
const mdl_Assets = require("../../models/assets/assets.mdl");
const mdl_Users = require('../../models/users/users.mdl');
const R = require('../../utils/response.util');
const { paginate } = require("../../utils/paginate.util");
const { adminExclude, jsonbSchemas, computedAttributes } = require("../../models/advertisements/advertisements.attributes");
const { getFieldValues } = require("../../utils/fieldValues.util");
const logActivity = require('../../utils/logActivity.util');
const { Op } = require('sequelize');
// ─── Helpers ──────────────────────────────────────────────────────────────────
const notDeleted = { deletedAt: null };
const ALLOWED_TYPES = ["hero", "banner", "popup", "sidebar"];
const ALLOWED_STATUSES = ["draft", "active", "scheduled", "expired", "archived"];
// ─── Status derivation ─────────────────────────────────────────────────────
// status is never trusted as manually-set truth — it's derived from is_active
// + start_date/end_date every time an advertisement is read or written.
// "archived" is the only status that bypasses derivation (set by archive/restore).
function deriveStatus(advertisement) {
if (advertisement.deletedAt) return "archived";
if (!advertisement.is_active) return "draft";
const now = new Date();
const start = advertisement.start_date ? new Date(advertisement.start_date) : null;
const end = advertisement.end_date ? new Date(advertisement.end_date) : null;
if (end && end < now) return "expired";
if (start && start > now) return "scheduled";
return "active";
}
const ALLOWED_CTA_VARIANTS = ["default", "outline"];
function normalizeCtas(ctas) {
if (!Array.isArray(ctas)) return [];
return ctas
.filter((c) => c && typeof c.label === "string" && typeof c.link === "string")
.slice(0, 2) // hard cap: max 2 CTAs per advertisement
.map((c, i) => ({
label: c.label.trim(),
link: c.link.trim(),
// First CTA defaults to "default" (primary), second to "outline" — but
// an explicit, valid variant from the client always wins.
variant: ALLOWED_CTA_VARIANTS.includes(c.variant) ? c.variant : (i === 0 ? "default" : "outline"),
}));
}
async function applyAdvertisementFields(advertisement, body) {
if (body.type !== undefined) {
if (!ALLOWED_TYPES.includes(body.type)) {
const err = new Error(`Invalid type. Must be one of: ${ALLOWED_TYPES.join(", ")}`);
err.status = 400;
throw err;
}
advertisement.type = body.type;
}
// status is intentionally NOT settable here — it's derived via deriveStatus()
// right before save, based on is_active + start_date/end_date.
if (body.badge_label !== undefined) advertisement.badge_label = body.badge_label;
if (body.headline !== undefined) advertisement.headline = body.headline;
if (body.description !== undefined) advertisement.description = body.description;
if (body.image_url !== undefined) advertisement.image_url = body.image_url;
if (body.image_asset_id !== undefined) {
if (body.image_asset_id === null) {
advertisement.image_asset_id = null;
} else {
const asset = await mdl_Assets.findOne({ where: { asset_id: body.image_asset_id, deletedAt: null } });
if (!asset) {
const err = new Error("Selected image asset was not found.");
err.status = 400;
throw err;
}
advertisement.image_asset_id = asset.asset_id;
}
}
if (body.ctas !== undefined) advertisement.ctas = normalizeCtas(body.ctas);
if (body.start_date !== undefined) advertisement.start_date = body.start_date || null;
if (body.end_date !== undefined) advertisement.end_date = body.end_date || null;
if (body.order !== undefined) advertisement.order = parseInt(body.order) || 0;
if (body.is_active !== undefined) advertisement.is_active = body.is_active === true || body.is_active === "true";
if (body.size !== undefined) {
if (body.size !== null && !["sm", "md", "lg"].includes(body.size)) {
const err = new Error(`Invalid size. Must be one of: sm, md, lg`);
err.status = 400;
throw err;
}
advertisement.size = body.size || null;
}
// Recompute status now that is_active/start_date/end_date are all up to date
advertisement.status = deriveStatus(advertisement);
}
// ─── GET ALL ──────────────────────────────────────────────────────────────────
exports.getAdvertisements = async (req, res) => {
try {
const result = await paginate(Advertisement, req, {
excludeAttributes: adminExclude,
jsonbSchemas,
computedAttributes,
context: "list",
auditOptions: { mdl_Users, parentAlias: 'Advertisement' },
findOptions: {
where: { ...notDeleted },
include: [{
model: mdl_Assets,
as: "image",
attributes: ["asset_id", "display_name", "file_url", "thumbnail_url"],
required: false,
}],
},
});
// Resync status on the way out — never trust what's stored, since
// start_date/end_date may have lapsed since the row was last saved.
if (Array.isArray(result?.data)) {
result.data = result.data.map((row) => ({ ...row, status: deriveStatus(row) }));
}
return R.success(res, "Advertisements retrieved.", result);
} catch (err) {
console.error("[ADVERTISEMENT][GET ALL]", err);
return R.error(res, "Could not retrieve advertisements.", 500);
}
};
// ─── GET ONE ──────────────────────────────────────────────────────────────────
exports.getAdvertisement = async (req, res) => {
try {
const { advertisementId } = req.params;
if (!advertisementId || advertisementId === "undefined") return R.error(res, "Invalid advertisement ID.", 400);
const advertisement = await Advertisement.findOne({
where: { advertisement_id: advertisementId, ...notDeleted },
include: [
{ model: mdl_Assets, as: "image", attributes: ["asset_id", "display_name", "file_url", "thumbnail_url"], required: false },
{ model: mdl_Users, as: "creator", attributes: ["user_id", "personal_info"], foreignKey: "createdBy" },
{ model: mdl_Users, as: "updater", attributes: ["user_id", "personal_info"], foreignKey: "updatedBy" },
],
});
if (!advertisement) return R.error(res, "Advertisement not found.", 404);
const json = advertisement.toJSON();
json.status = deriveStatus(json);
if (json.creator) {
json.creator = {
user_id: json.creator.user_id,
full_name: json.creator.personal_info?.name?.full_name ?? null,
};
}
if (json.updater) {
json.updater = {
user_id: json.updater.user_id,
full_name: json.updater.personal_info?.name?.full_name ?? null,
};
}
return R.success(res, "Advertisement retrieved.", { data: json });
} catch (err) {
console.error("[ADVERTISEMENT][GET ONE]", err);
return R.error(res, "Internal server error.", 500);
}
};
// ─── CREATE ───────────────────────────────────────────────────────────────────
exports.createAdvertisement = async (req, res) => {
try {
const { type, createdBy } = req.body;
if (!type) return R.error(res, "type is required.", 400);
if (!ALLOWED_TYPES.includes(type)) return R.error(res, `Invalid type. Must be one of: ${ALLOWED_TYPES.join(", ")}`, 400);
if (!createdBy) return R.error(res, "createdBy is required.", 400);
const t = await sequelize.transaction();
try {
const advertisement = await Advertisement.build({ type, createdBy });
await applyAdvertisementFields(advertisement, req.body);
await advertisement.save({ transaction: t });
await t.commit();
logActivity(req.user?.user_id, 'create_advertisement', { entityType: 'advertisement', entityId: advertisement.advertisement_id, details: { type: advertisement.type } });
return R.success(res, "Advertisement created.", { data: advertisement }, 201);
} catch (dbErr) {
try { await t.rollback(); } catch { /* connection gone */ }
throw dbErr;
}
} catch (err) {
console.error("[ADVERTISEMENT][CREATE]", err);
if (err.status) return R.error(res, err.message, err.status);
return R.error(res, "Internal server error.", 500);
}
};
// ─── UPDATE ───────────────────────────────────────────────────────────────────
exports.updateAdvertisement = async (req, res) => {
try {
const { advertisementId } = req.params;
if (!advertisementId || advertisementId === "undefined") return R.error(res, "Invalid advertisement ID.", 400);
const advertisement = await Advertisement.findOne({ where: { advertisement_id: advertisementId, ...notDeleted } });
if (!advertisement) return R.error(res, "Advertisement not found.", 404);
const t = await sequelize.transaction();
try {
await applyAdvertisementFields(advertisement, req.body);
advertisement.updatedBy = req.body.updatedBy ?? null;
await advertisement.save({ transaction: t });
await t.commit();
logActivity(req.user?.user_id, 'update_advertisement', { entityType: 'advertisement', entityId: Number(advertisementId) });
return R.success(res, "Advertisement updated.", { data: advertisement });
} catch (dbErr) {
try { await t.rollback(); } catch { /* gone */ }
throw dbErr;
}
} catch (err) {
console.error("[ADVERTISEMENT][UPDATE]", err);
if (err.status) return R.error(res, err.message, err.status);
return R.error(res, "Internal server error.", 500);
}
};
// ─── ARCHIVE (single) ─────────────────────────────────────────────────────────
exports.archiveAdvertisement = async (req, res) => {
try {
const { advertisementId } = req.params;
if (!advertisementId || advertisementId === "undefined") return R.error(res, "Invalid advertisement ID.", 400);
const advertisement = await Advertisement.findOne({ where: { advertisement_id: advertisementId, ...notDeleted } });
if (!advertisement) return R.error(res, "Advertisement not found.", 404);
await advertisement.update({ deletedBy: req.body.deletedBy ?? null });
await advertisement.destroy();
logActivity(req.user?.user_id, 'archive_advertisement', { entityType: 'advertisement', entityId: Number(advertisementId) });
return R.success(res, "Advertisement archived.");
} catch (err) {
console.error("[ADVERTISEMENT][ARCHIVE]", err);
return R.error(res, "Internal server error.", 500);
}
};
// ─── ARCHIVE (bulk) ───────────────────────────────────────────────────────────
exports.archiveAdvertisements = async (req, res) => {
try {
const { ids, deletedBy } = req.body;
if (!Array.isArray(ids) || !ids.length) return R.error(res, "ids must be a non-empty array.", 400);
const advertisements = await Advertisement.findAll({ where: { advertisement_id: { [Op.in]: ids }, ...notDeleted } });
if (!advertisements.length) return R.error(res, "No advertisements found.", 404);
const activeIds = advertisements.map((a) => a.advertisement_id);
await Advertisement.update({ deletedBy: deletedBy ?? null }, { where: { advertisement_id: { [Op.in]: activeIds } } });
await Advertisement.destroy({ where: { advertisement_id: { [Op.in]: activeIds } } });
logActivity(req.user?.user_id, 'bulk_archive_advertisements', { entityType: 'advertisement', details: { ids: activeIds, count: activeIds.length } });
return R.success(res, `${activeIds.length} advertisement(s) archived.`, {
archived_ids: activeIds,
skipped_ids: ids.filter((id) => !activeIds.includes(id)),
});
} catch (err) {
console.error("[ADVERTISEMENT][BULK ARCHIVE]", err);
return R.error(res, "Internal server error.", 500);
}
};
// ─── RESTORE (single) ─────────────────────────────────────────────────────────
exports.restoreAdvertisement = async (req, res) => {
try {
const { advertisementId } = req.params;
const advertisement = await Advertisement.findOne({ where: { advertisement_id: advertisementId }, paranoid: false });
if (!advertisement) return R.error(res, "Advertisement not found.", 404);
if (!advertisement.deletedAt) return R.error(res, "Advertisement is not archived.", 400);
await advertisement.restore();
await advertisement.update({ deletedBy: null });
logActivity(req.user?.user_id, 'restore_advertisement', { entityType: 'advertisement', entityId: Number(advertisementId) });
return R.success(res, "Advertisement restored.", { data: advertisement });
} catch (err) {
console.error("[ADVERTISEMENT][RESTORE]", err);
return R.error(res, "Internal server error.", 500);
}
};
// ─── RESTORE (bulk) ───────────────────────────────────────────────────────────
exports.restoreAdvertisements = async (req, res) => {
try {
const { ids } = req.body;
if (!Array.isArray(ids) || !ids.length) return R.error(res, "ids must be a non-empty array.", 400);
const advertisements = await Advertisement.findAll({ where: { advertisement_id: { [Op.in]: ids } }, paranoid: false });
if (!advertisements.length) return R.error(res, "No advertisements found.", 404);
const archived = advertisements.filter((a) => a.deletedAt);
if (!archived.length) return R.error(res, "All selected advertisements are already active.", 400);
const archivedIds = archived.map((a) => a.advertisement_id);
await Advertisement.restore({ where: { advertisement_id: { [Op.in]: archivedIds } } });
await Advertisement.update({ deletedBy: null }, { where: { advertisement_id: { [Op.in]: archivedIds } }, paranoid: false });
logActivity(req.user?.user_id, 'bulk_restore_advertisements', { entityType: 'advertisement', details: { ids: archivedIds, count: archivedIds.length } });
return R.success(res, `${archivedIds.length} advertisement(s) restored.`, {
restored_ids: archivedIds,
skipped_ids: ids.filter((id) => !archivedIds.includes(id)),
});
} catch (err) {
console.error("[ADVERTISEMENT][BULK RESTORE]", err);
return R.error(res, "Internal server error.", 500);
}
};
// ─── ARCHIVED LIST ────────────────────────────────────────────────────────────
exports.getArchivedAdvertisements = async (req, res) => {
try {
const result = await paginate(Advertisement, req, {
excludeAttributes: adminExclude,
jsonbSchemas,
computedAttributes,
context: "list",
auditOptions: { mdl_Users, parentAlias: 'Advertisement' },
findOptions: { paranoid: false, where: { deletedAt: { [Op.ne]: null } } },
});
return R.success(res, "Archived advertisements retrieved.", result);
} catch (err) {
console.error("[ADVERTISEMENT][GET ARCHIVED]", err);
return R.error(res, "Could not retrieve archived advertisements.", 500);
}
};
exports.getAdvertisementFieldValues = getFieldValues(Advertisement, "ADVERTISEMENT");
+253 -238
View File
@@ -5,15 +5,16 @@ const crypto = require("crypto");
const sequelize = require("../../config/db.config");
const Asset = require("../../models/assets/assets.mdl");
const chibi = require("../../services/chibisafe.service");
const s3 = require("../../services/s3.service");
const { extractVideoMeta } = require("../../services/ffprobe.service");
const R = require('../../utils/response.util');
const { paginate } = require("../../utils/paginate.util");
const { adminExclude, jsonbSchemas, computedAttributes, } = require("../../models/assets/assets.attributes");
const { adminExclude, jsonbSchemas, computedAttributes } = require("../../models/assets/assets.attributes");
const mdl_Users = require('../../models/users/users.mdl');
const { getFieldValues } = require("../../utils/fieldValues.util");
const logActivity = require('../../utils/logActivity.util');
const { Op, Sequelize } = require('sequelize');
const auditByFields = ['createdBy', 'updatedBy', 'deletedBy'];
const { Op } = require('sequelize');
// ─── Helpers ──────────────────────────────────────────────────────────────────
@@ -22,9 +23,8 @@ const notDeleted = { deletedAt: null };
function resolveFileType(mimeType = "") {
if (mimeType.startsWith("image/")) return "image";
if (mimeType.startsWith("video/")) return "video";
if (mimeType.startsWith("application/") || mimeType.startsWith("text/"))
return "document";
return "image";
if (mimeType.startsWith("audio/")) return "audio";
if (mimeType.startsWith("application/") || mimeType.startsWith("text/")) return "document";
}
function resolveExtension(originalName = "") {
@@ -41,31 +41,64 @@ function resolveResolution(width, height) {
if (h >= 2160) return "4K";
if (h >= 1440) return "1440p";
if (h >= 1080) return "1080p";
if (h >= 720) return "720p";
if (h >= 480) return "480p";
if (h >= 360) return "360p";
if (h >= 240) return "240p";
if (h >= 720) return "720p";
if (h >= 480) return "480p";
if (h >= 360) return "360p";
if (h >= 240) return "240p";
return `${width}x${height}`;
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
// ─── Provider resolver ────────────────────────────────────────────────────────
//
// Returns the correct service module based on storage_provider.
// Both chibi and s3 expose the same interface: uploadFile / deleteFile.
//
function getProvider(storageProvider) {
if (storageProvider === "s3") return s3;
if (storageProvider === "chibisafe") return chibi;
return null; // local / other — no remote provider needed
}
async function uploadToAlbum(file, ownerType) {
// ─── rollbackUploads ──────────────────────────────────────────────────────────
//
// Best-effort cleanup after a failed DB transaction.
// uploads: [{ key, provider }]
//
async function rollbackUploads(uploads = []) {
for (const { key, provider } of uploads) {
if (!key || !provider) continue;
const svc = getProvider(provider);
if (!svc) continue;
try {
await svc.deleteFile(key);
} catch (err) {
console.error(`[ASSET][ROLLBACK] Failed to delete "${key}" from "${provider}":`, err.message);
}
}
}
// ─── uploadToProvider ─────────────────────────────────────────────────────────
//
// Uploads a single file to the resolved provider.
// Returns { file_url, storage_key, mime_type, extension, checksum, file_type }
//
async function uploadToProvider(file, ownerType, storageProvider) {
const mime_type = file.mimetype;
const extension = resolveExtension(file.originalname);
const checksum = resolveChecksum(file.buffer);
const checksum = resolveChecksum(file.buffer);
const file_type = resolveFileType(mime_type);
const chibiResult = await chibi.uploadFile({
buffer: file.buffer,
const svc = getProvider(storageProvider);
const result = await svc.uploadFile({
buffer: file.buffer,
originalname: file.originalname,
mimetype: mime_type,
mimetype: mime_type,
ownerType,
});
return {
file_url: chibiResult.url,
chibi_uuid: chibiResult.uuid,
file_url: result.url,
storage_key: result.uuid, // chibisafe UUID or S3 key — both stored as storage_key in DB
mime_type,
extension,
checksum,
@@ -73,62 +106,64 @@ async function uploadToAlbum(file, ownerType) {
};
}
// ─── applyAssetUpdate ─────────────────────────────────────────────────────────
async function applyAssetUpdate(asset, file, body) {
const isThumbnailOnly = asset.file_type === "video" && !!file;
if (body.display_name !== undefined) asset.display_name = body.display_name;
if (body.description !== undefined) asset.description = body.description;
if (body.is_public !== undefined) asset.is_public = body.is_public === "true" || body.is_public === true;
if (body.description !== undefined) asset.description = body.description;
if (body.is_public !== undefined) asset.is_public = body.is_public === "true" || body.is_public === true;
asset.updatedBy = body.updatedBy ?? null;
if (file) {
if (isThumbnailOnly) {
asset.thumbnail_url = file.file_url;
asset.thumbnail_storage_key = file.chibi_uuid;
asset.thumbnail_url = file.file_url;
asset.thumbnail_storage_key = file.storage_key;
} else {
asset.original_name = file.originalname;
asset.file_url = file.file_url;
asset.file_size = file.size;
asset.mime_type = file.mime_type;
asset.extension = file.extension;
asset.checksum = file.checksum;
asset.file_type = file.file_type;
asset.storage_key = file.chibi_uuid;
asset.file_url = file.file_url;
asset.file_size = file.size;
asset.mime_type = file.mime_type;
asset.extension = file.extension;
asset.checksum = file.checksum;
asset.file_type = file.file_type;
asset.storage_key = file.storage_key;
const parsedWidth = body.width ? parseInt(body.width) : null;
const parsedWidth = body.width ? parseInt(body.width) : null;
const parsedHeight = body.height ? parseInt(body.height) : null;
if (parsedWidth || parsedHeight) {
asset.width = parsedWidth;
asset.height = parsedHeight;
asset.width = parsedWidth;
asset.height = parsedHeight;
asset.resolution = resolveResolution(parsedWidth, parsedHeight);
}
}
}
}
async function deleteOldFile(asset, oldStorageKey, newUuid) {
if (asset.storage_provider !== "chibisafe") return;
if (!oldStorageKey || oldStorageKey === newUuid) return;
// ─── deleteOldFile ────────────────────────────────────────────────────────────
async function deleteOldFile(storageProvider, oldStorageKey, newKey) {
if (!oldStorageKey || oldStorageKey === newKey) return;
const svc = getProvider(storageProvider);
if (!svc) return;
try {
await chibi.deleteFile(oldStorageKey);
await svc.deleteFile(oldStorageKey);
} catch (err) {
console.warn("[ASSET][REPLACE FILE] Old file cleanup failed:", err.message);
console.warn(`[ASSET][CLEANUP] Old file cleanup failed for "${oldStorageKey}":`, err.message);
}
}
/**
* Best-effort cleanup of Chibisafe files after a failed transaction.
* Never throws — the original error is what matters.
*/
async function rollbackChibiUploads(uuids = []) {
for (const uuid of uuids) {
if (!uuid) continue;
try {
await chibi.deleteFile(uuid);
} catch (cleanupErr) {
console.error(`[ASSET][ROLLBACK] Failed to delete Chibisafe file ${uuid}:`, cleanupErr.message);
}
}
// ─── Helper: hide S3 file_url from responses ──────────────────────────────────
//
// The raw S3 presigned/public URL is never sent to any browser.
// Admin viewers request a short-lived stream token instead
// (POST /api/admin/media/token → GET /api/client/media/stream/:token).
// Chibisafe assets keep their file_url (CDN public URL, no proxy needed).
//
function redactS3Url(asset) {
if (asset?.storage_provider === "s3") asset.file_url = null;
return asset;
}
// ─── GET ALL ──────────────────────────────────────────────────────────────────
@@ -141,11 +176,9 @@ exports.getAssets = async (req, res) => {
computedAttributes,
context: "list",
auditOptions: { mdl_Users, parentAlias: 'Asset' },
findOptions: {
where: { deletedAt: null },
},
findOptions: { where: { deletedAt: null } },
});
result.data = result.data.map(redactS3Url);
return R.success(res, "Assets retrieved.", result);
} catch (err) {
console.error("[ASSET][GET ALL]", err);
@@ -158,48 +191,35 @@ exports.getAssets = async (req, res) => {
exports.getAsset = async (req, res) => {
try {
const { assetId } = req.params;
if (!assetId || assetId === "undefined") {
return R.error(res, "Invalid asset ID.", 400);
}
if (!assetId || assetId === "undefined") return R.error(res, "Invalid asset ID.", 400);
const asset = await Asset.findOne({
where: { asset_id: assetId, ...notDeleted },
attributes: { exclude: ["storage_key", "storage_bucket"] },
include: [
{
model: mdl_Users,
as: "creator",
attributes: ["user_id", "personal_info"],
foreignKey: "createdBy",
},
{
model: mdl_Users,
as: "updater",
attributes: ["user_id", "personal_info"],
foreignKey: "updatedBy",
},
{ model: mdl_Users, as: "creator", attributes: ["user_id", "personal_info"], foreignKey: "createdBy" },
{ model: mdl_Users, as: "updater", attributes: ["user_id", "personal_info"], foreignKey: "updatedBy" },
],
});
if (!asset) return R.error(res, "Asset not found.", 404);
// ── Flatten creator / updater name from personal_info JSONB ──────────────
const json = asset.toJSON();
if (json.creator) {
json.creator = {
user_id: json.creator.user_id,
user_id: json.creator.user_id,
full_name: json.creator.personal_info?.name?.full_name ?? null,
};
}
if (json.updater) {
json.updater = {
user_id: json.updater.user_id,
user_id: json.updater.user_id,
full_name: json.updater.personal_info?.name?.full_name ?? null,
};
}
redactS3Url(json);
return R.success(res, "Asset retrieved.", { data: json });
} catch (err) {
console.error("[ASSET][GET ONE]", err);
@@ -212,31 +232,27 @@ exports.getAsset = async (req, res) => {
// ┌─────────────────────────────────────────────────────────────────────────┐
// │ TRANSACTION STRATEGY │
// │ │
// │ Phase 1 — SLOW WORK (no transaction, no DB connection held): │
// │ Phase 1 — SLOW WORK (outside transaction): │
// │ • Input validation │
// │ • ffprobe metadata extraction │
// │ • Chibisafe file upload → track UUID for rollback │
// │ • Chibisafe thumb upload → track UUID for rollback │
// │ • Provider upload (chibi or s3) → track for rollback │
// │ • Thumbnail upload → track for rollback │
// │ │
// │ Phase 2 — FAST WORK (transaction open for milliseconds only): │
// │ • BEGIN transaction │
// │ • Asset.create() │
// │ • COMMIT │
// │ Phase 2 — FAST WORK (transaction open milliseconds only): │
// │ • BEGIN → Asset.create() → COMMIT │
// │ │
// │ On any Phase 2 error: │
// │ On Phase 2 error: │
// │ • ROLLBACK transaction │
// │ • deleteFile() each tracked Chibisafe UUID (cleanup orphans) │
// │ • rollbackUploads([{ key, provider }]) to clean orphans │
// └─────────────────────────────────────────────────────────────────────────┘
//
// Expects multer.fields([{ name: "file", maxCount: 1 }, { name: "thumbnail", maxCount: 1 }])
exports.uploadAsset = async (req, res) => {
const uploadedChibiUuids = [];
const uploadedFiles = []; // [{ key, provider }]
try {
// ── Phase 1a: Validate inputs ─────────────────────────────────────────────
// ── Phase 1a: Validate ────────────────────────────────────────────────────
const file = req.files?.file?.[0];
const file = req.files?.file?.[0];
const thumbFile = req.files?.thumbnail?.[0];
if (!file) return R.error(res, "No file uploaded.", 400);
@@ -244,111 +260,116 @@ exports.uploadAsset = async (req, res) => {
const {
display_name,
description,
is_public = false,
storage_provider = "local",
is_public = false,
storage_provider = "chibisafe",
storage_bucket,
storage_key,
createdBy,
} = req.body;
if (!createdBy) {
return R.error(res, "createdBy is required.", 400);
}
if (!createdBy) return R.error(res, "createdBy is required.", 400);
const mime_type = file.mimetype;
const file_type = resolveFileType(mime_type);
const extension = resolveExtension(file.originalname);
const checksum = file.buffer ? resolveChecksum(file.buffer) : null;
const mime_type = file.mimetype;
const file_type = resolveFileType(mime_type);
const extension = resolveExtension(file.originalname);
const checksum = file.buffer ? resolveChecksum(file.buffer) : null;
const usesProvider = ["chibisafe", "s3"].includes(storage_provider);
if (file_type === "video" && !thumbFile) {
return R.error(res, "A thumbnail image is required for video uploads. Include it as the 'thumbnail' field.", 400);
return R.error(res, "A thumbnail image is required for video uploads.", 400);
}
if (storage_provider === "chibisafe" && !file.buffer) {
return R.error(res, "File buffer is required for Chibisafe uploads. Ensure multer uses memoryStorage.", 400);
if (usesProvider && !file.buffer) {
return R.error(res, "File buffer is required. Ensure multer uses memoryStorage.", 400);
}
// ── Phase 1b: Upload main file to Chibisafe ───────────────────────────────
// ── Phase 1b: Upload main file ────────────────────────────────────────────
let file_url = null;
let chibi_uuid = null;
let file_url = null;
let storage_key_resolved = null;
if (storage_provider === "chibisafe") {
const chibiResult = await chibi.uploadFile({
buffer: file.buffer,
if (usesProvider) {
const svc = getProvider(storage_provider);
const result = await svc.uploadFile({
buffer: file.buffer,
originalname: file.originalname,
mimetype: mime_type,
mimetype: mime_type,
ownerType: file_type,
});
file_url = chibiResult.url;
chibi_uuid = chibiResult.uuid;
uploadedChibiUuids.push(chibi_uuid);
file_url = result.url;
storage_key_resolved = result.uuid;
uploadedFiles.push({ key: storage_key_resolved, provider: storage_provider });
} else {
file_url = storage_provider === "local"
? `/uploads/${file.filename}`
: req.body.file_url;
if (!file_url) {
return R.error(res, "file_url is required for non-local storage.", 400);
}
if (!file_url) return R.error(res, "file_url is required for non-local storage.", 400);
}
// ── Phase 1c: ffprobe + thumbnail upload ──────────────────────────────────
// ── Phase 1c: ffprobe + thumbnail ─────────────────────────────────────────
let width = null;
let height = null;
let resolution = null;
let duration = null;
let frame_rate = null;
let bitrate = null;
let video_codec = null;
let audio_codec = null;
let width = null, height = null, resolution = null;
let duration = null, frame_rate = null, bitrate = null;
let video_codec = null, audio_codec = null;
let thumbnail_url = null;
if (file_type === "video") {
const meta = await extractVideoMeta({
buffer: file.buffer,
extension: extension || "mp4",
});
width = meta.width;
height = meta.height;
resolution = meta.resolution;
duration = meta.duration;
frame_rate = meta.frame_rate;
bitrate = meta.bitrate;
const meta = await extractVideoMeta({ buffer: file.buffer, extension: extension || "mp4" });
width = meta.width;
height = meta.height;
resolution = meta.resolution;
duration = meta.duration;
frame_rate = meta.frame_rate;
bitrate = meta.bitrate;
video_codec = meta.video_codec;
audio_codec = meta.audio_codec;
if (storage_provider === "chibisafe") {
if (!thumbFile.buffer) {
await rollbackChibiUploads(uploadedChibiUuids);
return R.error(res, "Thumbnail buffer is required. Ensure multer uses memoryStorage.", 400);
if (usesProvider) {
if (!thumbFile?.buffer) {
await rollbackUploads(uploadedFiles);
return R.error(res, "Thumbnail buffer is required.", 400);
}
const baseName = file.originalname.replace(/\.[^.]+$/, "");
const thumbResult = await chibi.uploadFile({
buffer: thumbFile.buffer,
const baseName = file.originalname.replace(/\.[^.]+$/, "");
const svc = getProvider(storage_provider);
const thumbResult = await svc.uploadFile({
buffer: thumbFile.buffer,
originalname: `thumb_${baseName}.${resolveExtension(thumbFile.originalname) || "jpg"}`,
mimetype: thumbFile.mimetype,
ownerType: "thumbnail",
mimetype: thumbFile.mimetype,
ownerType: "thumbnail",
});
thumbnail_url = thumbResult.url;
uploadedChibiUuids.push(thumbResult.uuid);
uploadedFiles.push({ key: thumbResult.uuid, provider: storage_provider });
} else {
thumbnail_url = thumbFile.filename
? `/uploads/${thumbFile.filename}`
: null;
thumbnail_url = thumbFile?.filename ? `/uploads/${thumbFile.filename}` : null;
}
} else if (file_type === "audio" && thumbFile) {
if (usesProvider) {
if (!thumbFile.buffer) {
await rollbackUploads(uploadedFiles);
return R.error(res, "Thumbnail buffer is required.", 400);
}
const baseName = file.originalname.replace(/\.[^.]+$/, "");
const svc = getProvider(storage_provider);
const thumbResult = await svc.uploadFile({
buffer: thumbFile.buffer,
originalname: `thumb_${baseName}.${resolveExtension(thumbFile.originalname) || "jpg"}`,
mimetype: thumbFile.mimetype,
ownerType: "thumbnail",
});
thumbnail_url = thumbResult.url;
uploadedFiles.push({ key: thumbResult.uuid, provider: storage_provider });
} else {
thumbnail_url = thumbFile?.filename ? `/uploads/${thumbFile.filename}` : null;
}
} else {
const parsedWidth = req.body.width ? parseInt(req.body.width) : null;
const parsedWidth = req.body.width ? parseInt(req.body.width) : null;
const parsedHeight = req.body.height ? parseInt(req.body.height) : null;
width = parsedWidth;
height = parsedHeight;
width = parsedWidth;
height = parsedHeight;
resolution = resolveResolution(parsedWidth, parsedHeight);
}
@@ -357,10 +378,10 @@ exports.uploadAsset = async (req, res) => {
const t = await sequelize.transaction();
try {
const asset = await Asset.create({
original_name: file.originalname,
display_name: display_name || file.originalname,
original_name: file.originalname,
display_name: display_name || file.originalname,
file_url,
file_size: file.size,
file_size: file.size,
mime_type,
extension,
checksum,
@@ -376,29 +397,87 @@ exports.uploadAsset = async (req, res) => {
thumbnail_url,
description,
storage_provider,
storage_bucket: storage_bucket || null,
storage_key: chibi_uuid || storage_key || file.filename,
storage_bucket: storage_bucket || (storage_provider === "s3" ? process.env.S3_BUCKET : null) || null,
storage_key: storage_key_resolved || storage_key || file.filename || null,
is_public,
createdBy,
}, { transaction: t });
await t.commit();
logActivity(req.user?.user_id, 'upload_asset', { entityType: 'asset', entityId: asset.asset_id, details: { display_name: asset.display_name, file_type: asset.file_type } });
return R.success(res, "Asset uploaded.", { data: asset }, 201);
} catch (dbErr) {
try { await t.rollback(); } catch { /* connection already gone */ }
await rollbackChibiUploads(uploadedChibiUuids);
try { await t.rollback(); } catch { /* connection gone */ }
await rollbackUploads(uploadedFiles);
throw dbErr;
}
} catch (err) {
console.error("[ASSET][UPLOAD]", err);
if (err.status) return R.error(res, err.message, err.status, { detail: err.chibiBody });
return R.error(res, "Internal server error.", 500);
}
};
if (err.status) {
return R.error(res, err.message, err.status, { detail: err.chibiBody });
// ─── UPDATE ───────────────────────────────────────────────────────────────────
exports.updateAsset = async (req, res) => {
let newUpload = null; // { key, provider }
try {
const { assetId } = req.params;
if (!assetId || assetId === "undefined") return R.error(res, "Invalid asset ID.", 400);
const asset = await Asset.findOne({ where: { asset_id: assetId, ...notDeleted } });
if (!asset) return R.error(res, "Asset not found.", 404);
const file = req.files?.file?.[0] ?? req.file ?? null;
const isVideo = asset.file_type === "video";
const isDocument = asset.file_type === "document";
if (isDocument && file) return R.error(res, "Document files cannot be replaced.", 400);
if (isVideo && file && !req.body.is_thumbnail) return R.error(res, "Video files cannot be replaced. Upload a new asset instead.", 400);
if (file && !file.buffer) return R.error(res, "File buffer is required.", 400);
const storageProvider = asset.storage_provider;
const usesProvider = ["chibisafe", "s3"].includes(storageProvider);
const oldStorageKey = isVideo ? asset.thumbnail_storage_key : asset.storage_key;
// ── Phase 1: Upload ───────────────────────────────────────────────────────
let uploaded = null;
if (file && usesProvider) {
const ownerType = isVideo ? "thumbnail" : resolveFileType(file.mimetype);
uploaded = await uploadToProvider(file, ownerType, storageProvider);
newUpload = { key: uploaded.storage_key, provider: storageProvider };
}
// ── Phase 2: DB update ────────────────────────────────────────────────────
const t = await sequelize.transaction();
try {
await applyAssetUpdate(asset, uploaded ? { ...file, ...uploaded } : null, req.body);
await asset.save({ transaction: t });
await t.commit();
} catch (dbErr) {
try { await t.rollback(); } catch { /* gone */ }
if (newUpload) await rollbackUploads([newUpload]);
throw dbErr;
}
// ── Phase 3: Cleanup old file ─────────────────────────────────────────────
if (uploaded) await deleteOldFile(storageProvider, oldStorageKey, uploaded.storage_key);
logActivity(req.user?.user_id, 'update_asset', { entityType: 'asset', entityId: Number(assetId) });
return R.success(res, "Asset updated.", { data: asset });
} catch (err) {
if (newUpload) await rollbackUploads([newUpload]);
console.error("[ASSET][UPDATE]", err);
if (err.status) return R.error(res, err.message, err.status, { detail: err.chibiBody });
return R.error(res, "Internal server error.", 500);
}
};
@@ -415,7 +494,7 @@ exports.archiveAsset = async (req, res) => {
await asset.update({ deletedBy: req.body.deletedBy ?? null });
await asset.destroy();
logActivity(req.user?.user_id, 'archive_asset', { entityType: 'asset', entityId: Number(assetId) });
return R.success(res, "Asset archived.");
} catch (err) {
console.error("[ASSET][ARCHIVE]", err);
@@ -435,12 +514,10 @@ exports.archiveAssets = async (req, res) => {
const activeIds = assets.map((a) => a.asset_id);
await Asset.update(
{ deletedBy: deletedBy ?? null },
{ where: { asset_id: { [Op.in]: activeIds } } }
);
await Asset.update({ deletedBy: deletedBy ?? null }, { where: { asset_id: { [Op.in]: activeIds } } });
await Asset.destroy({ where: { asset_id: { [Op.in]: activeIds } } });
logActivity(req.user?.user_id, 'bulk_archive_assets', { entityType: 'asset', details: { ids: activeIds, count: activeIds.length } });
return R.success(res, `${activeIds.length} asset(s) archived.`, {
archived_ids: activeIds,
skipped_ids: ids.filter((id) => !activeIds.includes(id)),
@@ -457,16 +534,13 @@ exports.restoreAsset = async (req, res) => {
try {
const { assetId } = req.params;
const asset = await Asset.findOne({
where: { asset_id: assetId },
paranoid: false,
});
if (!asset) return R.error(res, "Asset not found.", 404);
const asset = await Asset.findOne({ where: { asset_id: assetId }, paranoid: false });
if (!asset) return R.error(res, "Asset not found.", 404);
if (!asset.deletedAt) return R.error(res, "Asset is not archived.", 400);
await asset.restore();
await asset.update({ deletedBy: null });
logActivity(req.user?.user_id, 'restore_asset', { entityType: 'asset', entityId: Number(assetId) });
return R.success(res, "Asset restored.", { data: asset });
} catch (err) {
console.error("[ASSET][RESTORE]", err);
@@ -481,10 +555,7 @@ exports.restoreAssets = async (req, res) => {
const { ids } = req.body;
if (!Array.isArray(ids) || !ids.length) return R.error(res, "ids must be a non-empty array.", 400);
const assets = await Asset.findAll({
where: { asset_id: { [Op.in]: ids } },
paranoid: false,
});
const assets = await Asset.findAll({ where: { asset_id: { [Op.in]: ids } }, paranoid: false });
if (!assets.length) return R.error(res, "No assets found.", 404);
const archivedAssets = assets.filter((a) => a.deletedAt);
@@ -493,11 +564,9 @@ exports.restoreAssets = async (req, res) => {
const archivedIds = archivedAssets.map((a) => a.asset_id);
await Asset.restore({ where: { asset_id: { [Op.in]: archivedIds } } });
await Asset.update(
{ deletedBy: null },
{ where: { asset_id: { [Op.in]: archivedIds } }, paranoid: false }
);
await Asset.update({ deletedBy: null }, { where: { asset_id: { [Op.in]: archivedIds } }, paranoid: false });
logActivity(req.user?.user_id, 'bulk_restore_assets', { entityType: 'asset', details: { ids: archivedIds, count: archivedIds.length } });
return R.success(res, `${archivedIds.length} asset(s) restored.`, {
restored_ids: archivedIds,
skipped_ids: ids.filter((id) => !archivedIds.includes(id)),
@@ -508,58 +577,7 @@ exports.restoreAssets = async (req, res) => {
}
};
exports.updateAsset = async (req, res) => {
let newChibiUuid = null;
try {
const { assetId } = req.params;
if (!assetId || assetId === "undefined") return R.error(res, "Invalid asset ID.", 400);
const asset = await Asset.findOne({ where: { asset_id: assetId, ...notDeleted } });
if (!asset) return R.error(res, "Asset not found.", 404);
const file = req.files?.file?.[0] ?? req.file ?? null;
const isVideo = asset.file_type === "video";
const isDocument = asset.file_type === "document";
if (isDocument && file) return R.error(res, "Document files cannot be replaced.", 400);
if (isVideo && file && !req.body.is_thumbnail) return R.error(res, "Video files cannot be replaced. Upload a new asset instead.", 400);
if (file && !file.buffer) return R.error(res, "File buffer is required.", 400);
// ── Phase 1: Upload ───────────────────────────────────────────────────────
let uploaded = null;
let oldStorageKey = isVideo ? asset.thumbnail_storage_key : asset.storage_key;
if (file && asset.storage_provider === "chibisafe") {
const ownerType = isVideo ? "thumbnail" : resolveFileType(file.mimetype);
uploaded = await uploadToAlbum(file, ownerType);
newChibiUuid = uploaded.chibi_uuid;
}
// ── Phase 2: DB update ────────────────────────────────────────────────────
const t = await sequelize.transaction();
try {
await applyAssetUpdate(asset, uploaded ? { ...file, ...uploaded } : null, req.body);
await asset.save({ transaction: t });
await t.commit();
} catch (dbErr) {
try { await t.rollback(); } catch { /* gone */ }
if (newChibiUuid) await rollbackChibiUploads([newChibiUuid]);
throw dbErr;
}
// ── Phase 3: Cleanup ──────────────────────────────────────────────────────
if (uploaded) await deleteOldFile(asset, oldStorageKey, newChibiUuid);
return R.success(res, "Asset updated.", { data: asset });
} catch (err) {
if (newChibiUuid) await rollbackChibiUploads([newChibiUuid]);
console.error("[ASSET][REPLACE FILE]", err);
if (err.status) return R.error(res, err.message, err.status, { detail: err.chibiBody });
return R.error(res, "Internal server error.", 500);
}
};
// ─── ARCHIVED LIST ────────────────────────────────────────────────────────────
exports.getArchivedAssets = async (req, res) => {
try {
@@ -569,12 +587,9 @@ exports.getArchivedAssets = async (req, res) => {
computedAttributes,
context: "list",
auditOptions: { mdl_Users, parentAlias: 'Asset' },
findOptions: {
paranoid: false,
where: { deletedAt: { [Op.ne]: null } },
},
findOptions: { paranoid: false, where: { deletedAt: { [Op.ne]: null } } },
});
result.data = result.data.map(redactS3Url);
return R.success(res, "Archived assets retrieved.", result);
} catch (err) {
console.error("[ASSET][GET ARCHIVED]", err);
@@ -0,0 +1,89 @@
'use strict';
const mdl_Category = require('../../models/courses/categories.mdl');
const R = require('../../utils/response.util');
const logActivity = require('../../utils/logActivity.util');
const slugify = (str) =>
str.toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
exports.getCategories = async (req, res) => {
try {
const rows = await mdl_Category.findAll({ order: [['name', 'ASC']], paranoid: false });
return R.success(res, 'Categories retrieved.', rows);
} catch (err) {
console.error('[ADMIN][CATEGORIES][GET ALL]', err);
return R.error(res, 'Could not retrieve categories.', 500);
}
};
exports.getCategory = async (req, res) => {
try {
const row = await mdl_Category.findByPk(req.params.id, { paranoid: false });
if (!row) return R.error(res, 'Category not found.', 404);
return R.success(res, 'Category retrieved.', row);
} catch (err) {
console.error('[ADMIN][CATEGORIES][GET ONE]', err);
return R.error(res, 'Could not retrieve category.', 500);
}
};
exports.createCategory = async (req, res) => {
try {
const { name, description, is_active } = req.body;
if (!name) return R.error(res, 'name is required.', 400);
const slug = slugify(name);
const row = await mdl_Category.create({ name, slug, description: description ?? null, is_active: is_active ?? true });
logActivity(req.user?.user_id, 'create_category', { entityType: 'category', entityId: row.category_id, details: { name: row.name } });
return R.success(res, 'Category created.', row, 201);
} catch (err) {
if (err.name === 'SequelizeUniqueConstraintError')
return R.error(res, 'A category with that name already exists.', 409);
console.error('[ADMIN][CATEGORIES][CREATE]', err);
return R.error(res, 'Could not create category.', 500);
}
};
exports.updateCategory = async (req, res) => {
try {
const row = await mdl_Category.findByPk(req.params.id);
if (!row) return R.error(res, 'Category not found.', 404);
const { name, description, is_active } = req.body;
const slug = name ? slugify(name) : row.slug;
await row.update({ name: name ?? row.name, slug, description: description ?? row.description, is_active: is_active ?? row.is_active });
logActivity(req.user?.user_id, 'update_category', { entityType: 'category', entityId: row.category_id });
return R.success(res, 'Category updated.', row);
} catch (err) {
if (err.name === 'SequelizeUniqueConstraintError')
return R.error(res, 'A category with that name already exists.', 409);
console.error('[ADMIN][CATEGORIES][UPDATE]', err);
return R.error(res, 'Could not update category.', 500);
}
};
exports.archiveCategory = async (req, res) => {
try {
const row = await mdl_Category.findByPk(req.params.id);
if (!row) return R.error(res, 'Category not found.', 404);
await row.destroy();
logActivity(req.user?.user_id, 'archive_category', { entityType: 'category', entityId: row.category_id });
return R.success(res, 'Category archived.');
} catch (err) {
console.error('[ADMIN][CATEGORIES][ARCHIVE]', err);
return R.error(res, 'Could not archive category.', 500);
}
};
exports.restoreCategory = async (req, res) => {
try {
const row = await mdl_Category.findByPk(req.params.id, { paranoid: false });
if (!row) return R.error(res, 'Category not found.', 404);
await row.restore();
logActivity(req.user?.user_id, 'restore_category', { entityType: 'category', entityId: row.category_id });
return R.success(res, 'Category restored.', row);
} catch (err) {
console.error('[ADMIN][CATEGORIES][RESTORE]', err);
return R.error(res, 'Could not restore category.', 500);
}
};
@@ -0,0 +1,196 @@
/***********************************************************************************************************************************************************************
* File Name: course_reading_progress.controller.js (admin)
* Type of Program: Controller
* Description: Admin-facing endpoints for viewing course reading progress.
*
* GET /:courseId/reading-progress
* → one entry per user who has touched the course, with lesson/unit counts aggregated
*
* GET /:courseId/reading-progress/users/:userId
* → full lesson + unit breakdown for a single user (loaded on row expand)
*
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jun. 21, 2026
***********************************************************************************************************************************************************************/
'use strict';
const { Op } = require('sequelize');
const R = require('../../utils/response.util');
const CourseReadingProgress = require('../../models/courses/course_reading_progress.mdl');
const { Course, Unit, Lesson } = require('../../models/courses/courses.associations');
const mdl_Users = require('../../models/users/users.mdl');
const notDeleted = { deletedAt: null };
// =============================================================================
// ── LIST — all users who touched this course ──────────────────────────────────
// =============================================================================
// GET /admin/courses/:courseId/reading-progress
// Returns one summary row per user. Lesson + unit counts are derived by querying
// the course structure server-side so the totals are always accurate.
exports.getCourseReadingProgress = async (req, res) => {
try {
const { courseId } = req.params;
const course = await Course.findOne({
where: { course_id: courseId, ...notDeleted },
attributes: ['course_id'],
});
if (!course) return R.error(res, 'Course not found.', 404);
// Count total lessons and units in the course (structure totals)
const [units, allLessons] = await Promise.all([
Unit.findAll({
where: { course_id: courseId, ...notDeleted },
attributes: ['unit_id', 'uuid'],
}),
Lesson.findAll({
include: [{
model: Unit,
as: 'unit',
where: { course_id: courseId, ...notDeleted },
attributes: [],
required: true,
}],
where: { ...notDeleted },
attributes: ['lesson_id', 'uuid'],
}),
]);
const units_total = units.length;
const lessons_total = allLessons.length;
// All progress rows for this course, grouped per user
const rows = await CourseReadingProgress.findAll({
where: { course_id: courseId },
attributes: ['user_id', 'reference_id', 'type', 'status', 'last_accessed_at'],
order: [['last_accessed_at', 'DESC']],
});
if (!rows.length) return R.success(res, 'No reading progress for this course yet.', []);
// Aggregate per user
const userIds = [...new Set(rows.map((r) => r.user_id))];
const users = await mdl_Users.findAll({
where: { user_id: userIds },
attributes: ['user_id', 'email', 'personal_info'],
});
const userMap = Object.fromEntries(users.map((u) => [u.user_id, u]));
// Build per-user summary
const summaryMap = {};
for (const row of rows) {
if (!summaryMap[row.user_id]) {
summaryMap[row.user_id] = {
user_id: row.user_id,
course_status: null,
last_accessed_at: null,
lessons_completed: 0,
units_completed: 0,
};
}
const entry = summaryMap[row.user_id];
// Track latest access across all rows for this user
if (!entry.last_accessed_at || new Date(row.last_accessed_at) > new Date(entry.last_accessed_at)) {
entry.last_accessed_at = row.last_accessed_at;
}
if (row.type === 'course') entry.course_status = row.status;
if (row.type === 'unit' && row.status === 'completed') entry.units_completed++;
if (row.type === 'lesson' && row.status === 'completed') entry.lessons_completed++;
}
const result = Object.values(summaryMap).map((entry) => {
const u = userMap[entry.user_id];
return {
...entry,
user: {
email: u?.email ?? null,
full_name: u?.personal_info?.name?.full_name ?? null,
avatar_url: u?.personal_info?.avatar?.url ?? null,
},
units_total,
lessons_total,
// Fall back to in_progress if the course row hasn't been written yet
course_status: entry.course_status ?? 'in_progress',
};
});
// Sort: completed last, most recent first within each group
result.sort((a, b) => {
if (a.course_status !== b.course_status) {
return a.course_status === 'completed' ? 1 : -1;
}
return new Date(b.last_accessed_at) - new Date(a.last_accessed_at);
});
return R.success(res, 'Course reading progress retrieved.', result);
} catch (err) {
console.error('[ADMIN][COURSE READING PROGRESS][LIST]', err);
return R.error(res, 'Could not retrieve reading progress.', 500);
}
};
// =============================================================================
// ── DETAIL — single user's full lesson/unit breakdown ─────────────────────────
// =============================================================================
// GET /admin/courses/:courseId/reading-progress/users/:userId
// Loaded lazily when the admin expands a user row.
// Returns units with their lessons and the progress status per item.
exports.getUserReadingProgress = async (req, res) => {
try {
const { courseId, userId } = req.params;
const [units, progressRows] = await Promise.all([
Unit.findAll({
where: { course_id: courseId, ...notDeleted },
attributes: ['unit_id', 'uuid', 'title', 'order_index'],
include: [{
model: Lesson,
as: 'lessons',
where: notDeleted,
required: false,
attributes: ['lesson_id', 'uuid', 'title', 'order_index'],
}],
order: [
['order_index', 'ASC'],
[{ model: Lesson, as: 'lessons' }, 'order_index', 'ASC'],
],
}),
CourseReadingProgress.findAll({
where: { course_id: courseId, user_id: userId },
attributes: ['reference_id', 'type', 'status', 'completed_at', 'last_accessed_at'],
}),
]);
// Build a quick lookup: { [reference_id (uuid)]: status }
const progressMap = Object.fromEntries(
progressRows.map((r) => [r.reference_id, { status: r.status, completed_at: r.completed_at }])
);
const breakdown = units.map((unit) => ({
unit_id: unit.unit_id,
uuid: unit.uuid,
title: unit.title,
status: progressMap[unit.uuid]?.status ?? null,
lessons: (unit.lessons ?? []).map((lesson) => ({
lesson_id: lesson.lesson_id,
uuid: lesson.uuid,
title: lesson.title,
status: progressMap[lesson.uuid]?.status ?? null,
completed_at: progressMap[lesson.uuid]?.completed_at ?? null,
})),
}));
return R.success(res, 'User reading progress retrieved.', breakdown);
} catch (err) {
console.error('[ADMIN][COURSE READING PROGRESS][USER DETAIL]', err);
return R.error(res, 'Could not retrieve user reading progress.', 500);
}
};
+197 -9
View File
@@ -10,15 +10,17 @@ const { syncJunction } = require("../../utils/courses/junction.util");
const { archiveOne, archiveMany } = require("../../utils/courses/archive.util");
const { restoreOne, restoreMany } = require("../../utils/courses/restore.util");
const { getFieldValues } = require("../../utils/fieldValues.util");
const logActivity = require('../../utils/logActivity.util');
// ── Models ────────────────────────────────────────────────────────────────────
const {
Course, CourseProduct, CourseRole, CourseProductCategory: CourseProductCat,
Course, CourseProductCategory: CourseProductCat,
Unit, Lesson, LessonPage,
CourseObjective, LessonObjective,
CoursePrerequisite, CourseAssessment,
UnitQuiz, QuizQuestion, QuizOption,
CourseInstructor,
} = require("../../models/courses/courses.associations");
const mdl_Users = require("../../models/users/users.mdl");
@@ -94,7 +96,7 @@ exports.createCourse = async (req, res) => {
title, description, order_index,
course_code, level, subscription,
objectives = [],
product_ids = [], role_ids = [], category_ids = [],
category_ids = [],
createdBy,
} = req.body;
@@ -112,11 +114,10 @@ exports.createCourse = async (req, res) => {
}, { transaction: t });
await syncObjectivesCreate(CourseObjective, "course_id", course.course_id, objectives, t);
await syncJunction(CourseProduct, course.course_id, product_ids, "product_id", t);
await syncJunction(CourseRole, course.course_id, role_ids, "role_id", t);
await syncJunction(CourseProductCat, course.course_id, category_ids, "category_id", t);
await t.commit();
logActivity(req.user?.user_id, 'create_course', { entityType: 'course', entityId: course.course_id, details: { title: course.title } });
return R.success(res, "Course created.", { data: course }, 201);
} catch (err) {
await t.rollback();
@@ -135,7 +136,7 @@ exports.updateCourse = async (req, res) => {
const {
title, description, order_index,
course_code, level, subscription,
objectives, product_ids, role_ids, category_ids,
objectives, category_ids,
updatedBy,
} = req.body;
@@ -149,11 +150,10 @@ exports.updateCourse = async (req, res) => {
await course.save({ transaction: t });
if (objectives !== undefined) await syncObjectivesUpdate(CourseObjective, "course_id", courseId, objectives, t);
if (product_ids !== undefined) await syncJunction(CourseProduct, courseId, product_ids, "product_id", t);
if (role_ids !== undefined) await syncJunction(CourseRole, courseId, role_ids, "role_id", t);
if (category_ids !== undefined) await syncJunction(CourseProductCat, courseId, category_ids, "category_id", t);
await t.commit();
logActivity(req.user?.user_id, 'update_course', { entityType: 'course', entityId: Number(courseId), details: { title: course.title } });
return R.success(res, "Course updated.", { data: course });
} catch (err) {
await t.rollback();
@@ -169,6 +169,7 @@ exports.archiveCourse = async (req, res) => {
const record = await archiveOne(Course, { course_id: courseId, ...notDeleted }, req.user.user_id, t);
if (!record) return R.error(res, "Course not found.", 404);
await t.commit();
logActivity(req.user.user_id, 'archive_course', { entityType: 'course', entityId: Number(courseId) });
return R.success(res, "Course archived.");
} catch (err) {
await t.rollback();
@@ -185,6 +186,7 @@ exports.bulkArchiveCourses = async (req, res) => {
const count = await archiveMany(Course, "course_id", ids, req.user.user_id, t);
await t.commit();
logActivity(req.user.user_id, 'bulk_archive_courses', { entityType: 'course', details: { ids, count } });
return R.success(res, `${count} course${count !== 1 ? "s" : ""} archived.`);
} catch (err) {
await t.rollback();
@@ -231,6 +233,7 @@ exports.restoreCourse = async (req, res) => {
const record = await restoreOne(Course, { course_id: courseId }, req.user.user_id, t);
if (!record) return R.error(res, "Archived course not found.", 404);
await t.commit();
logActivity(req.user.user_id, 'restore_course', { entityType: 'course', entityId: Number(courseId) });
return R.success(res, "Course restored.", { data: record });
} catch (err) {
await t.rollback();
@@ -247,6 +250,7 @@ exports.bulkRestoreCourses = async (req, res) => {
const count = await restoreMany(Course, "course_id", ids, req.user.user_id, t);
await t.commit();
logActivity(req.user.user_id, 'bulk_restore_courses', { entityType: 'course', details: { ids, count } });
return R.success(res, `${count} course${count !== 1 ? "s" : ""} restored.`);
} catch (err) {
await t.rollback();
@@ -304,6 +308,7 @@ exports.syncPrerequisites = async (req, res) => {
}
await t.commit();
logActivity(req.user?.user_id, 'sync_prerequisites', { entityType: 'course', entityId: Number(courseId), details: { count: prerequisites.length } });
return R.success(res, "Prerequisites updated.");
} catch (err) {
await t.rollback();
@@ -380,6 +385,7 @@ exports.createUnit = async (req, res) => {
createdBy: createdBy ?? null,
});
logActivity(req.user?.user_id, 'create_unit', { entityType: 'unit', entityId: unit.unit_id, details: { title: unit.title } });
return R.success(res, "Unit created.", { data: unit }, 201);
} catch (err) {
console.error("[UNIT][CREATE]", err);
@@ -402,6 +408,7 @@ exports.updateUnit = async (req, res) => {
unit.updatedBy = updatedBy ?? null;
await unit.save();
logActivity(req.user?.user_id, 'update_unit', { entityType: 'unit', entityId: Number(unitId) });
return R.success(res, "Unit updated.", { data: unit });
} catch (err) {
console.error("[UNIT][UPDATE]", err);
@@ -416,6 +423,7 @@ exports.archiveUnit = async (req, res) => {
const record = await archiveOne(Unit, { unit_id: unitId, course_id: courseId, ...notDeleted }, req.user.user_id, t);
if (!record) return R.error(res, "Unit not found.", 404);
await t.commit();
logActivity(req.user.user_id, 'archive_unit', { entityType: 'unit', entityId: Number(unitId) });
return R.success(res, "Unit archived.");
} catch (err) {
await t.rollback();
@@ -436,6 +444,7 @@ exports.bulkArchiveUnits = async (req, res) => {
const count = await archiveMany(Unit, "unit_id", validIds, req.user.user_id, t);
await t.commit();
logActivity(req.user.user_id, 'bulk_archive_units', { entityType: 'unit', details: { ids: validIds, count } });
return R.success(res, `${count} unit${count !== 1 ? "s" : ""} archived.`);
} catch (err) {
await t.rollback();
@@ -491,6 +500,7 @@ exports.restoreUnit = async (req, res) => {
const record = await restoreOne(Unit, { unit_id: unitId, course_id: courseId, ...onlyDeleted }, req.user.user_id, t);
if (!record) return R.error(res, "Archived unit not found.", 404);
await t.commit();
logActivity(req.user.user_id, 'restore_unit', { entityType: 'unit', entityId: Number(unitId) });
return R.success(res, "Unit restored.", { data: record });
} catch (err) {
await t.rollback();
@@ -511,6 +521,7 @@ exports.bulkRestoreUnits = async (req, res) => {
const count = await restoreMany(Unit, "unit_id", validIds, req.user.user_id, t);
await t.commit();
logActivity(req.user.user_id, 'bulk_restore_units', { entityType: 'unit', details: { ids: validIds, count } });
return R.success(res, `${count} unit${count !== 1 ? "s" : ""} restored.`);
} catch (err) {
await t.rollback();
@@ -597,6 +608,7 @@ exports.createLesson = async (req, res) => {
await syncObjectivesCreate(LessonObjective, "lesson_id", lesson.lesson_id, objectives, t);
await t.commit();
logActivity(req.user?.user_id, 'create_lesson', { entityType: 'lesson', entityId: lesson.lesson_id, details: { title: lesson.title } });
return R.success(res, "Lesson created.", { data: lesson }, 201);
} catch (err) {
await t.rollback();
@@ -635,6 +647,7 @@ exports.updateLesson = async (req, res) => {
include: [{ model: LessonObjective, as: "objectives", order: [["order_index", "ASC"]] }],
});
logActivity(req.user?.user_id, 'update_lesson', { entityType: 'lesson', entityId: Number(lessonId) });
return R.success(res, "Lesson updated.", { data: updated });
} catch (err) {
await t.rollback();
@@ -655,6 +668,7 @@ exports.archiveLesson = async (req, res) => {
if (!record) return R.error(res, "Lesson not found.", 404);
await t.commit();
logActivity(req.user.user_id, 'archive_lesson', { entityType: 'lesson', entityId: Number(lessonId) });
return R.success(res, "Lesson archived.");
} catch (err) {
await t.rollback();
@@ -678,6 +692,7 @@ exports.bulkArchiveLessons = async (req, res) => {
const count = await archiveMany(Lesson, "lesson_id", validIds, req.user.user_id, t);
await t.commit();
logActivity(req.user.user_id, 'bulk_archive_lessons', { entityType: 'lesson', details: { ids: validIds, count } });
return R.success(res, `${count} lesson${count !== 1 ? "s" : ""} archived.`);
} catch (err) {
await t.rollback();
@@ -741,6 +756,7 @@ exports.restoreLesson = async (req, res) => {
const record = await restoreOne(Lesson, { lesson_id: lessonId, unit_id: unitId, ...onlyDeleted }, req.user.user_id, t);
if (!record) return R.error(res, "Archived lesson not found.", 404);
await t.commit();
logActivity(req.user.user_id, 'restore_lesson', { entityType: 'lesson', entityId: Number(lessonId) });
return R.success(res, "Lesson restored.", { data: record });
} catch (err) {
await t.rollback();
@@ -764,6 +780,7 @@ exports.bulkRestoreLessons = async (req, res) => {
const count = await restoreMany(Lesson, "lesson_id", validIds, req.user.user_id, t);
await t.commit();
logActivity(req.user.user_id, 'bulk_restore_lessons', { entityType: 'lesson', details: { ids: validIds, count } });
return R.success(res, `${count} lesson${count !== 1 ? "s" : ""} restored.`);
} catch (err) {
await t.rollback();
@@ -811,6 +828,7 @@ exports.upsertLessonPage = async (req, res) => {
console.error("[LESSON PAGE][DURATION]", durErr);
}
logActivity(req.user?.user_id, 'upsert_lesson_page', { entityType: 'lesson', entityId: Number(lessonId) });
return R.success(
res,
created ? "Lesson page created." : "Lesson page updated.",
@@ -871,6 +889,7 @@ exports.createQuiz = async (req, res) => {
createdBy: createdBy ?? null,
});
logActivity(req.user?.user_id, 'create_quiz', { entityType: 'quiz', entityId: quiz.quiz_id });
return R.success(res, "Quiz created.", { data: quiz }, 201);
} catch (err) {
console.error("[QUIZ][CREATE]", err);
@@ -894,6 +913,7 @@ exports.updateQuiz = async (req, res) => {
quiz.updatedBy = updatedBy ?? null;
await quiz.save();
logActivity(req.user?.user_id, 'update_quiz', { entityType: 'quiz', entityId: Number(quizId) });
return R.success(res, "Quiz updated.", { data: quiz });
} catch (err) {
console.error("[QUIZ][UPDATE]", err);
@@ -908,6 +928,7 @@ exports.deleteQuiz = async (req, res) => {
const record = await archiveOne(UnitQuiz, { quiz_id: quizId, unit_id: unitId, ...notDeleted }, req.body.deletedBy, t);
if (!record) return R.error(res, "Quiz not found.", 404);
await t.commit();
logActivity(req.user?.user_id, 'archive_quiz', { entityType: 'quiz', entityId: Number(quizId) });
return R.success(res, "Quiz archived.");
} catch (err) {
await t.rollback();
@@ -942,6 +963,7 @@ exports.restoreQuiz = async (req, res) => {
const record = await restoreOne(UnitQuiz, { quiz_id: quizId, unit_id: unitId, ...onlyDeleted }, req.body.restoredBy, t);
if (!record) return R.error(res, "Archived quiz not found.", 404);
await t.commit();
logActivity(req.user?.user_id, 'restore_quiz', { entityType: 'quiz', entityId: Number(quizId) });
return R.success(res, "Quiz restored.", { data: record });
} catch (err) {
await t.rollback();
@@ -1034,6 +1056,7 @@ exports.createQuestion = async (req, res) => {
include: [{ model: QuizOption, as: "options", order: [["order_index", "ASC"]] }],
});
logActivity(req.user?.user_id, 'create_question', { entityType: 'question', entityId: created.question_id });
return R.success(res, "Question created.", { data: created }, 201);
} catch (err) {
await t.rollback();
@@ -1081,6 +1104,7 @@ exports.updateQuestion = async (req, res) => {
include: [{ model: QuizOption, as: "options", order: [["order_index", "ASC"]] }],
});
logActivity(req.user?.user_id, 'update_question', { entityType: 'question', entityId: Number(questionId) });
return R.success(res, "Question updated.", { data: updated });
} catch (err) {
await t.rollback();
@@ -1096,6 +1120,7 @@ exports.deleteQuestion = async (req, res) => {
const record = await archiveOne(QuizQuestion, { question_id: questionId, ...notDeleted }, req.body.deletedBy, t);
if (!record) return R.error(res, "Question not found.", 404);
await t.commit();
logActivity(req.user?.user_id, 'archive_question', { entityType: 'question', entityId: Number(questionId) });
return R.success(res, "Question archived.");
} catch (err) {
await t.rollback();
@@ -1127,6 +1152,7 @@ exports.restoreQuestion = async (req, res) => {
const record = await restoreOne(QuizQuestion, { question_id: questionId, ...onlyDeleted }, req.body.restoredBy, t);
if (!record) return R.error(res, "Archived question not found.", 404);
await t.commit();
logActivity(req.user?.user_id, 'restore_question', { entityType: 'question', entityId: Number(questionId) });
return R.success(res, "Question restored.", { data: record });
} catch (err) {
await t.rollback();
@@ -1152,6 +1178,7 @@ exports.bulkArchiveQuestions = async (req, res) => {
const count = await archiveMany(QuizQuestion, "question_id", validIds, deletedBy, t);
await t.commit();
logActivity(req.user?.user_id, 'bulk_archive_questions', { entityType: 'question', details: { ids: validIds, count } });
return R.success(res, `${count} question${count !== 1 ? "s" : ""} archived.`);
} catch (err) {
await t.rollback();
@@ -1177,6 +1204,7 @@ exports.bulkRestoreQuestions = async (req, res) => {
const count = await restoreMany(QuizQuestion, "question_id", validIds, restoredBy, t);
await t.commit();
logActivity(req.user?.user_id, 'bulk_restore_questions', { entityType: 'question', details: { ids: validIds, count } });
return R.success(res, `${count} question${count !== 1 ? "s" : ""} restored.`);
} catch (err) {
await t.rollback();
@@ -1227,10 +1255,11 @@ exports.createAssessment = async (req, res) => {
is_required: is_required ?? false,
passing_score: passing_score ?? 70,
time_limit_minutes: time_limit_minutes ?? null,
max_questions: max_questions ?? null, // ← add
max_questions: max_questions ?? null,
createdBy: createdBy ?? null,
});
logActivity(req.user?.user_id, 'create_assessment', { entityType: 'assessment', entityId: assessment.assessment_id });
return R.success(res, "Assessment created.", { data: assessment }, 201);
} catch (err) {
console.error("[ASSESSMENT][CREATE]", err);
@@ -1257,6 +1286,7 @@ exports.updateAssessment = async (req, res) => {
assessment.updatedBy = updatedBy ?? null;
await assessment.save();
logActivity(req.user?.user_id, 'update_assessment', { entityType: 'assessment', entityId: Number(assessmentId) });
return R.success(res, "Assessment updated.", { data: assessment });
} catch (err) {
console.error("[ASSESSMENT][UPDATE]", err);
@@ -1271,6 +1301,7 @@ exports.deleteAssessment = async (req, res) => {
const record = await archiveOne(CourseAssessment, { assessment_id: assessmentId, course_id: courseId, ...notDeleted }, req.body.deletedBy, t);
if (!record) return R.error(res, "Assessment not found.", 404);
await t.commit();
logActivity(req.user?.user_id, 'archive_assessment', { entityType: 'assessment', entityId: Number(assessmentId) });
return R.success(res, "Assessment archived.");
} catch (err) {
await t.rollback();
@@ -1305,6 +1336,7 @@ exports.restoreAssessment = async (req, res) => {
const record = await restoreOne(CourseAssessment, { assessment_id: assessmentId, course_id: courseId, ...onlyDeleted }, req.body.restoredBy, t);
if (!record) return R.error(res, "Archived assessment not found.", 404);
await t.commit();
logActivity(req.user?.user_id, 'restore_assessment', { entityType: 'assessment', entityId: Number(assessmentId) });
return R.success(res, "Assessment restored.", { data: record });
} catch (err) {
await t.rollback();
@@ -1315,4 +1347,160 @@ exports.restoreAssessment = async (req, res) => {
exports.getCourseFieldValues = getFieldValues(Course, "COURSE");
exports.getUnitFieldValues = getFieldValues(Unit, "UNIT");
exports.getLessonFieldValues = getFieldValues(Lesson, "LESSON");
exports.getLessonFieldValues = getFieldValues(Lesson, "LESSON");
// ── Flat lists for requirement builder dropdowns ───────────────────────────────
// Returns lightweight id+title arrays (no pagination) used when building task requirements.
exports.getCoursesFlat = async (req, res) => {
try {
const data = await Course.findAll({
where: notDeleted,
attributes: ["uuid", "title"],
order: [["title", "ASC"]],
});
return R.success(res, "Courses retrieved.", data);
} catch (err) {
console.error("[COURSE][GET FLAT]", err);
return R.error(res, "Could not retrieve courses.", 500);
}
};
exports.getUnitsFlat = async (req, res) => {
try {
const rows = await Unit.findAll({
where: notDeleted,
attributes: ["uuid", "title", "order_index"],
include: [{
model: Course,
as: "course",
attributes: ["title"],
}],
order: [
[{ model: Course, as: "course" }, "title", "ASC"],
["order_index", "ASC"],
],
});
const data = rows.map((u) => ({
uuid: u.uuid,
title: u.title,
order_index: u.order_index ?? 0,
course_title: u.course?.title ?? "",
}));
return R.success(res, "Units retrieved.", data);
} catch (err) {
console.error("[UNIT][GET FLAT]", err);
return R.error(res, "Could not retrieve units.", 500);
}
};
exports.getLessonsFlat = async (req, res) => {
try {
const rows = await Lesson.findAll({
where: notDeleted,
attributes: ["uuid", "title", "order_index"],
include: [{
model: Unit,
as: "unit",
attributes: ["title", "order_index"],
include: [{
model: Course,
as: "course",
attributes: ["title"],
}],
}],
order: [
[{ model: Unit, as: "unit" }, { model: Course, as: "course" }, "title", "ASC"],
[{ model: Unit, as: "unit" }, "order_index", "ASC"],
["order_index", "ASC"],
],
});
const data = rows.map((l) => ({
uuid: l.uuid,
title: l.title,
order_index: l.order_index ?? 0,
unit_title: l.unit?.title ?? "",
unit_order: l.unit?.order_index ?? 0,
course_title: l.unit?.course?.title ?? "",
}));
return R.success(res, "Lessons retrieved.", data);
} catch (err) {
console.error("[LESSON][GET FLAT]", err);
return R.error(res, "Could not retrieve lessons.", 500);
}
};
// ══════════════════════════════════════════════════════════════════════════════
// COURSE INSTRUCTORS
// ══════════════════════════════════════════════════════════════════════════════
exports.getInstructors = async (req, res) => {
try {
const { courseId } = req.params;
const instructors = await CourseInstructor.findAll({
where: { course_id: courseId },
order: [["order_index", "ASC"]],
include: [{
model: mdl_Users,
as: "user",
attributes: ["user_id", "email", "acc_type", "personal_info"],
required: false,
}],
});
return R.success(res, "Instructors retrieved.", { data: instructors });
} catch (err) {
console.error("[INSTRUCTOR][GET]", err);
return R.error(res, "Could not retrieve instructors.", 500);
}
};
exports.syncInstructors = async (req, res) => {
const t = await sequelize.transaction();
try {
const { courseId } = req.params;
const { instructors = [] } = req.body;
const actor_id = req.user?.user_id ?? null;
const course = await Course.findOne({ where: { course_id: courseId, ...notDeleted } });
if (!course) return R.error(res, "Course not found.", 404);
// Validate any linked user_ids are staff or admin
const linkedIds = instructors.map(i => i.user_id).filter(Boolean);
if (linkedIds.length) {
const validUsers = await mdl_Users.findAll({
where: { user_id: linkedIds, acc_type: ["staff", "admin"], deletedAt: null },
attributes: ["user_id"],
});
const validSet = new Set(validUsers.map(u => String(u.user_id)));
const invalid = linkedIds.find(id => !validSet.has(String(id)));
if (invalid) {
await t.rollback();
return R.error(res, `User ${invalid} is not a staff or admin account.`, 422);
}
}
await CourseInstructor.destroy({ where: { course_id: courseId }, transaction: t });
if (instructors.length) {
await CourseInstructor.bulkCreate(
instructors.map((inst, i) => ({
course_id: courseId,
user_id: inst.user_id ?? null,
display_name: inst.display_name,
order_index: inst.order_index ?? i,
created_by: actor_id,
})),
{ transaction: t }
);
}
await t.commit();
logActivity(actor_id, 'sync_instructors', { entityType: 'course', entityId: Number(courseId), details: { count: instructors.length } });
return R.success(res, "Instructors updated.");
} catch (err) {
await t.rollback();
console.error("[INSTRUCTOR][SYNC]", err);
return R.error(res, "Could not update instructors.", 500);
}
};
@@ -0,0 +1,284 @@
# Advertisements Controller Documentation
**File:** `controllers/admin/advertisements.controller.js`
**Base URL:** `/api/admin/advertisements`
**Guards:** `authenticate → requireAdmin() → adminLimiter`
---
## Table of Contents
- [Get All Advertisements](#get-all-advertisements)
- [Get Single Advertisement](#get-single-advertisement)
- [Create Advertisement](#create-advertisement)
- [Update Advertisement](#update-advertisement)
- [Archive Advertisement](#archive-advertisement)
- [Bulk Archive Advertisements](#bulk-archive-advertisements)
- [Restore Advertisement](#restore-advertisement)
- [Bulk Restore Advertisements](#bulk-restore-advertisements)
- [Get Archived Advertisements](#get-archived-advertisements)
- [Get Field Values](#get-field-values)
---
## Status Derivation
Status is **never** trusted as stored — it is recomputed on every read and write:
| Condition | Derived Status |
|-----------|----------------|
| `deletedAt` is set | `archived` |
| `is_active = false` | `draft` |
| `end_date` < now | `expired` |
| `start_date` > now | `scheduled` |
| Otherwise | `active` |
`archived` is the only status that bypasses derivation (set explicitly by archive/restore).
---
## CTAs
Each advertisement supports up to **2 CTAs**:
```json
[
{ "label": "Learn More", "link": "/courses", "variant": "default" },
{ "label": "Sign Up", "link": "/register", "variant": "outline" }
]
```
- First CTA defaults to `"default"` variant; second defaults to `"outline"`.
- An explicit valid variant from the client (`"default"` or `"outline"`) always wins.
- Items beyond 2 are silently discarded.
---
## Get All Advertisements
**`GET /api/admin/advertisements`**
Returns a paginated list of active (non-deleted) advertisements. Status is resynced on the way out.
### Query Parameters
| Parameter | Type | Required | Description |
|-----------|--------|----------|-------------|
| page | number | No | Default: `1` |
| limit | number | No | Default: `10`, max: `1000` |
| filters | array | No | JSON array of filter objects |
| sort | array | No | JSON array of sort objects |
### Response `200`
```json
{
"status": "success",
"message": "Advertisements retrieved.",
"data": [...],
"pagination": { "page": 1, "limit": 10, "total": 5, "totalPages": 1 }
}
```
---
## Get Single Advertisement
**`GET /api/admin/advertisements/:advertisementId`**
Returns one advertisement with its `image` asset and audit user info.
### Response `200`
```json
{
"status": "success",
"message": "Advertisement retrieved.",
"data": {
"advertisement_id": 1,
"uuid": "...",
"type": "hero",
"status": "active",
"badge_label": "New",
"headline": "Welcome",
"description": "...",
"image_url": null,
"image_asset_id": 12,
"image": { "asset_id": 12, "display_name": "hero.jpg", "file_url": "...", "thumbnail_url": "..." },
"ctas": [{ "label": "Start", "link": "/start", "variant": "default" }],
"start_date": "2026-01-01T00:00:00.000Z",
"end_date": null,
"order": 0,
"is_active": true,
"size": null,
"click_count": 0,
"creator": { "user_id": 1, "full_name": "Admin User" },
"updater": null,
"createdAt": "2026-01-01T00:00:00.000Z",
"updatedAt": "2026-01-01T00:00:00.000Z"
}
}
```
### Error Responses
| Status | Message |
|--------|---------|
| `400` | `Invalid advertisement ID.` |
| `404` | `Advertisement not found.` |
---
## Create Advertisement
**`POST /api/admin/advertisements`**
### Request Body
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `type` | string | **Yes** | `hero`, `banner`, `popup`, `sidebar` |
| `createdBy` | number | **Yes** | User ID of creator |
| `badge_label` | string | No | Small label shown on the ad |
| `headline` | string | No | Main heading |
| `description` | string | No | Body text |
| `image_url` | string | No | Direct image URL |
| `image_asset_id` | number | No | FK to `assets` table |
| `ctas` | array | No | Up to 2 CTA objects `[{ label, link, variant }]` |
| `start_date` | date | No | ISO date string |
| `end_date` | date | No | ISO date string |
| `order` | number | No | Display order. Default: `0` |
| `is_active` | boolean | No | Default: `true` |
| `size` | string | No | `sm`, `md`, `lg` — banner only |
### Response `201`
```json
{
"status": "success",
"message": "Advertisement created.",
"data": { ... }
}
```
### Error Responses
| Status | Message |
|--------|---------|
| `400` | `type is required.` |
| `400` | `Invalid type. Must be one of: hero, banner, popup, sidebar` |
| `400` | `createdBy is required.` |
| `400` | `Invalid size. Must be one of: sm, md, lg` |
---
## Update Advertisement
**`PATCH /api/admin/advertisements/:advertisementId`**
Partial update. Only fields present in the body are changed. Status is recomputed after all fields are applied.
### Request Body
Same optional fields as Create. Does not accept `type` once set. Accepts `updatedBy`.
### Response `200`
```json
{ "status": "success", "message": "Advertisement updated.", "data": { ... } }
```
---
## Archive Advertisement
**`DELETE /api/admin/advertisements/:advertisementId`**
Soft-deletes the advertisement (`deletedAt` set, `status` → `archived`).
### Response `200`
```json
{ "status": "success", "message": "Advertisement archived." }
```
---
## Bulk Archive Advertisements
**`DELETE /api/admin/advertisements/bulk`**
Rate-limited. Soft-deletes multiple advertisements.
### Request Body
```json
{ "ids": [1, 2, 3], "deletedBy": 1 }
```
### Response `200`
```json
{
"status": "success",
"message": "3 advertisement(s) archived.",
"archived_ids": [1, 2, 3],
"skipped_ids": []
}
```
---
## Restore Advertisement
**`PATCH /api/admin/advertisements/:advertisementId/restore`**
Restores a soft-deleted advertisement.
### Response `200`
```json
{ "status": "success", "message": "Advertisement restored.", "data": { ... } }
```
---
## Bulk Restore Advertisements
**`PATCH /api/admin/advertisements/bulk-restore`**
### Request Body
```json
{ "ids": [1, 2] }
```
### Response `200`
```json
{
"status": "success",
"message": "2 advertisement(s) restored.",
"restored_ids": [1, 2],
"skipped_ids": []
}
```
---
## Get Archived Advertisements
**`GET /api/admin/advertisements/archived`**
Returns paginated list of soft-deleted advertisements.
### Response `200`
```json
{
"status": "success",
"message": "Archived advertisements retrieved.",
"data": [...],
"pagination": { ... }
}
```
---
## Get Field Values
**`GET /api/admin/advertisements/field-values`**
Returns distinct values for filterable advertisement fields. Used by DataTable filter dropdowns.
### Response `200`
```json
{
"status": "success",
"message": "Field values retrieved.",
"data": { "type": ["hero", "banner"], "status": ["active", "draft"] }
}
```
@@ -0,0 +1,171 @@
# Categories Controller Documentation
**File:** `controllers/admin/categories.controller.js`
**Base URL:** `/api/admin/categories`
**Guards:** `authenticate → requireAdmin() → adminLimiter`
---
## Table of Contents
- [Get All Categories](#get-all-categories)
- [Get Single Category](#get-single-category)
- [Create Category](#create-category)
- [Update Category](#update-category)
- [Archive Category](#archive-category)
- [Restore Category](#restore-category)
---
## Notes
- `slug` is auto-generated from `name` on create and update: lowercased, trimmed, non-alphanumeric runs replaced with `-`.
- `slug` and `name` must be **unique** across all categories (including archived ones).
- `paranoid: false` is used on GET All and GET One, so archived categories are visible.
---
## Get All Categories
**`GET /api/admin/categories`**
Returns all categories ordered alphabetically by name. Includes archived rows.
### Response `200`
```json
{
"status": "success",
"message": "Categories retrieved.",
"data": [
{
"id": 1,
"name": "Business",
"slug": "business",
"description": "Business courses",
"is_active": true,
"createdAt": "2026-01-01T00:00:00.000Z",
"updatedAt": "2026-01-01T00:00:00.000Z",
"deletedAt": null
}
]
}
```
---
## Get Single Category
**`GET /api/admin/categories/:id`**
Returns one category. Includes archived.
### Response `200`
```json
{
"status": "success",
"message": "Category retrieved.",
"data": { "id": 1, "name": "Business", "slug": "business", ... }
}
```
### Error Responses
| Status | Message |
|--------|---------|
| `404` | `Category not found.` |
---
## Create Category
**`POST /api/admin/categories`**
### Request Body
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `name` | string | **Yes** | Display name. Must be unique. |
| `description` | string | No | Optional description. |
| `is_active` | boolean | No | Default: `true` |
### Response `201`
```json
{
"status": "success",
"message": "Category created.",
"data": { "id": 2, "name": "Design", "slug": "design", "is_active": true, ... }
}
```
### Error Responses
| Status | Message |
|--------|---------|
| `400` | `name is required.` |
| `409` | `A category with that name already exists.` |
---
## Update Category
**`PUT /api/admin/categories/:id`**
Full update. Only non-`null`/`undefined` fields are changed. `slug` is re-generated if `name` changes.
### Request Body
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `name` | string | No | New display name. |
| `description` | string | No | |
| `is_active` | boolean | No | |
### Response `200`
```json
{
"status": "success",
"message": "Category updated.",
"data": { ... }
}
```
### Error Responses
| Status | Message |
|--------|---------|
| `404` | `Category not found.` |
| `409` | `A category with that name already exists.` |
---
## Archive Category
**`DELETE /api/admin/categories/:id`**
Soft-deletes the category (`deletedAt` set).
### Response `200`
```json
{ "status": "success", "message": "Category archived." }
```
### Error Responses
| Status | Message |
|--------|---------|
| `404` | `Category not found.` |
---
## Restore Category
**`POST /api/admin/categories/:id/restore`**
Restores a soft-deleted category.
### Response `200`
```json
{
"status": "success",
"message": "Category restored.",
"data": { ... }
}
```
### Error Responses
| Status | Message |
|--------|---------|
| `404` | `Category not found.` |
+843
View File
@@ -0,0 +1,843 @@
# Courses Controller Documentation
**File:** `controllers/admin/courses.controller.js`
**Base URL:** `/api/admin/courses`
**Guards:** `authenticate → requireAdmin() → adminLimiter`
---
## Table of Contents
- [Courses](#courses)
- [Get All Courses](#get-all-courses)
- [Get Single Course](#get-single-course)
- [Create Course](#create-course)
- [Update Course](#update-course)
- [Archive Course](#archive-course)
- [Bulk Archive Courses](#bulk-archive-courses)
- [Restore Course](#restore-course)
- [Bulk Restore Courses](#bulk-restore-courses)
- [Get Archived Courses](#get-archived-courses)
- [Get Archived Course](#get-archived-course)
- [Flat Lists (dropdowns)](#flat-lists)
- [Get Course Field Values](#get-course-field-values)
- [Course Instructors](#course-instructors)
- [Get Instructors](#get-instructors)
- [Sync Instructors](#sync-instructors)
- [Course Prerequisites](#course-prerequisites)
- [Get Prerequisites](#get-prerequisites)
- [Sync Prerequisites](#sync-prerequisites)
- [Course Assessment](#course-assessment)
- [Get Assessment](#get-assessment)
- [Create Assessment](#create-assessment)
- [Update Assessment](#update-assessment)
- [Archive/Restore Assessment](#archiverestore-assessment)
- [Quiz Questions](#quiz-questions)
- [Get Questions](#get-questions)
- [Create Question](#create-question)
- [Update Question](#update-question)
- [Archive/Restore Question](#archiverestore-question)
- [Bulk Archive/Restore Questions](#bulk-archiverestore-questions)
- [Units](#units)
- [Get All Units](#get-all-units)
- [Get Single Unit](#get-single-unit)
- [Create Unit](#create-unit)
- [Update Unit](#update-unit)
- [Archive/Restore Unit](#archiverestore-unit)
- [Bulk Archive/Restore Units](#bulk-archiverestore-units)
- [Get Unit Field Values](#get-unit-field-values)
- [Unit Quiz](#unit-quiz)
- [Get Quiz](#get-quiz)
- [Create Quiz](#create-quiz)
- [Update Quiz](#update-quiz)
- [Archive/Restore Quiz](#archiverestore-quiz)
- [Lessons](#lessons)
- [Get All Lessons](#get-all-lessons)
- [Get Single Lesson](#get-single-lesson)
- [Create Lesson](#create-lesson)
- [Update Lesson](#update-lesson)
- [Archive/Restore Lesson](#archiverestore-lesson)
- [Bulk Archive/Restore Lessons](#bulk-archiverestore-lessons)
- [Get Lesson Field Values](#get-lesson-field-values)
- [Lesson Page](#lesson-page)
- [Get Lesson Page](#get-lesson-page)
- [Upsert Lesson Page](#upsert-lesson-page)
- [Course Reading Progress](#course-reading-progress)
- [Get Course Reading Progress](#get-course-reading-progress)
- [Get User Reading Progress](#get-user-reading-progress)
---
## Course Hierarchy
```
Course
├── CourseObjective[]
├── CoursePrerequisite[]
├── CourseAssessment (one)
│ └── QuizQuestion[] → QuizOption[]
├── CourseInstructor[]
└── Unit[]
├── UnitQuiz (one)
│ └── QuizQuestion[] → QuizOption[]
└── Lesson[]
├── LessonObjective[]
└── LessonPage (one) { blocks: [] }
```
---
## Courses
### Get All Courses
**`GET /api/admin/courses`**
Returns paginated active courses.
### Query Parameters
| Parameter | Type | Required | Description |
|-----------|--------|----------|-------------|
| `page` | number | No | Default: `1` |
| `limit` | number | No | Default: `10` |
| `filters` | array | No | JSON filter array |
| `sort` | array | No | JSON sort array |
### Response `200`
```json
{
"status": "success",
"message": "Courses retrieved.",
"data": [...],
"pagination": { "page": 1, "limit": 10, "total": 12, "totalPages": 2 }
}
```
---
### Get Single Course
**`GET /api/admin/courses/:courseId`**
Returns the full course tree: units (with lessons and quiz), objectives, prerequisites, and assessment.
### Response `200`
```json
{
"status": "success",
"message": "Course retrieved.",
"data": {
"course_id": 1,
"uuid": "...",
"title": "Advanced JavaScript",
"description": "...",
"course_code": "JS-201",
"order_index": 0,
"level": "advanced",
"subscription": "premium",
"duration_seconds": 7200,
"objectives": [{ "objective_id": 1, "text": "Understand closures", "order_index": 0 }],
"prerequisites": [],
"assessment": { ... },
"units": [
{
"unit_id": 1, "title": "Closures", "order_index": 0,
"lessons": [{ "lesson_id": 1, "title": "What is a closure?", "order_index": 0 }],
"quiz": { ... }
}
]
}
}
```
---
### Create Course
**`POST /api/admin/courses`**
Creates a course with optional objectives and category assignments in a single transaction.
### Request Body
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `title` | string | **Yes** | Course title |
| `description` | string | No | |
| `course_code` | string | No | Unique course identifier |
| `order_index` | number | No | Default: `0` |
| `level` | string | No | `beginner`, `intermediate`, `advanced` |
| `subscription` | string | No | `free`, `premium`. Default: `free` |
| `objectives` | array | No | `[{ text, order_index }]` |
| `category_ids` | array | No | Category IDs to assign |
| `createdBy` | number | No | Creator user ID |
### Response `201`
```json
{
"status": "success",
"message": "Course created.",
"data": { "course_id": 5, "title": "Advanced JavaScript", ... }
}
```
---
### Update Course
**`PUT /api/admin/courses/:courseId`**
Updates course fields. When `objectives` or `category_ids` are provided, they replace the existing sets.
### Request Body (all optional)
`title`, `description`, `order_index`, `course_code`, `level`, `subscription`, `objectives`, `category_ids`, `updatedBy`
---
### Archive Course
**`DELETE /api/admin/courses/:courseId`**
Soft-deletes the course.
### Response `200`
```json
{ "status": "success", "message": "Course archived." }
```
---
### Bulk Archive Courses
**`DELETE /api/admin/courses/bulk`**
### Request Body
```json
{ "ids": [1, 2, 3] }
```
---
### Restore Course
**`PATCH /api/admin/courses/:courseId/restore`**
---
### Bulk Restore Courses
**`PATCH /api/admin/courses/restore/bulk`**
### Request Body
```json
{ "ids": [1, 2] }
```
---
### Get Archived Courses
**`GET /api/admin/courses/archives`**
---
### Get Archived Course
**`GET /api/admin/courses/archives/:courseId`**
---
### Flat Lists
Lightweight endpoints that return `uuid + title` arrays (no pagination). Used by the task requirement builder dropdowns.
**`GET /api/admin/courses/flat`** — all active courses: `[{ uuid, title }]`
**`GET /api/admin/courses/units-flat`** — all active units: `[{ uuid, title, order_index, course_title }]`
**`GET /api/admin/courses/lessons-flat`** — all active lessons: `[{ uuid, title, order_index, unit_title, unit_order, course_title }]`
---
### Get Course Field Values
**`GET /api/admin/courses/field-values`**
---
## Course Instructors
### Get Instructors
**`GET /api/admin/courses/:courseId/instructors`**
Returns instructors ordered by `order_index`. Includes linked `users` (staff/admin accounts) when `user_id` is set.
### Response `200`
```json
{
"status": "success",
"message": "Instructors retrieved.",
"data": [
{
"id": 1,
"course_id": 5,
"user_id": 3,
"display_name": "Dr. Jane Smith",
"order_index": 0,
"user": { "user_id": 3, "email": "jane@example.com", "acc_type": "staff", "personal_info": { ... } }
}
]
}
```
---
### Sync Instructors
**`PUT /api/admin/courses/:courseId/instructors`**
Replaces the full instructor list for a course in a single transaction.
- Validates any `user_id` values — they must be `staff` or `admin` accounts.
- Passing an empty array clears all instructors.
### Request Body
```json
{
"instructors": [
{ "user_id": 3, "display_name": "Dr. Jane Smith", "order_index": 0 },
{ "user_id": null, "display_name": "External Contributor", "order_index": 1 }
]
}
```
---
## Course Prerequisites
### Get Prerequisites
**`GET /api/admin/courses/:courseId/prerequisites`**
Returns prerequisites ordered by `order_index`.
### Response `200`
```json
{
"status": "success",
"message": "Prerequisites retrieved.",
"data": [
{ "prereq_id": 1, "course_id": 5, "ref_type": "course", "ref_id": 2, "order_index": 0 }
]
}
```
---
### Sync Prerequisites
**`PUT /api/admin/courses/:courseId/prerequisites`**
Replaces the full prerequisite list. Valid `ref_type` values: `course`, `unit`, `lesson`.
### Request Body
```json
{
"prerequisites": [
{ "ref_type": "course", "ref_id": 2 },
{ "ref_type": "unit", "ref_id": 7 }
]
}
```
---
## Course Assessment
One assessment per course. Assessment questions are shared with unit quizzes via polymorphic `assessment_id` / `quiz_id` fields.
### Get Assessment
**`GET /api/admin/courses/:courseId/assessment`**
Returns the assessment with its questions and options.
### Response `200`
```json
{
"status": "success",
"message": "Assessment retrieved.",
"data": {
"assessment_id": 1,
"uuid": "...",
"course_id": 5,
"title": "Final Exam",
"is_required": true,
"passing_score": 80,
"time_limit_minutes": 60,
"max_questions": 20,
"questions": [
{
"question_id": 1, "type": "multiple_choice", "question": "What is a closure?",
"explanation": "...", "points": 2, "order_index": 0,
"options": [
{ "option_id": 1, "text": "A function + its outer scope", "is_correct": true, "order_index": 0 }
]
}
]
}
}
```
---
### Create Assessment
**`POST /api/admin/courses/:courseId/assessment`**
Only one assessment per course. Returns `409` if one already exists.
### Request Body
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `title` | string | No | |
| `is_required` | boolean | No | Default: `false` |
| `passing_score` | number | No | Default: `70` |
| `time_limit_minutes` | number | No | `null` = no limit |
| `max_questions` | number | No | `null` = show all |
| `createdBy` | number | No | |
---
### Update Assessment
**`PATCH /api/admin/courses/:courseId/assessment/:assessmentId`**
---
### Archive/Restore Assessment
**`DELETE /api/admin/courses/:courseId/assessment/:assessmentId`** — archive
**`PATCH /api/admin/courses/:courseId/assessment/:assessmentId/restore`** — restore
**`GET /api/admin/courses/:courseId/assessment/archives`** — get archived assessment
---
## Quiz Questions
Shared by both **Unit Quizzes** and **Course Assessments**. The parent is determined by the route:
- Under a unit quiz: `/:courseId/units/:unitId/quiz/:quizId/questions`
- Under an assessment: `/:courseId/assessment/:assessmentId/questions`
### Question Types
| Type | Options Required |
|------|----------------|
| `true_false` | Auto-generated `[True, False]` if `options` is empty |
| `multiple_choice` | Exactly one `is_correct: true` option |
| `multi_select` | One or more `is_correct: true` options |
### Get Questions
**`GET .../:parentId/questions`**
Returns questions with options, ordered by `order_index`.
---
### Create Question
**`POST .../:parentId/questions`**
Creates a question and its options in a transaction. `true_false` questions auto-generate `[True, False]` options if `options` is omitted.
### Request Body
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `type` | string | **Yes** | `true_false`, `multiple_choice`, `multi_select` |
| `question` | string | **Yes** | Question text |
| `explanation` | string | No | Shown after answer |
| `order_index` | number | No | Default: `0` |
| `points` | number | No | Default: `1` |
| `options` | array | No | `[{ text, is_correct, order_index }]` |
| `createdBy` | number | No | |
### Response `201`
```json
{
"status": "success",
"message": "Question created.",
"data": { "question_id": 1, "type": "multiple_choice", "options": [...] }
}
```
---
### Update Question
**`PATCH .../:parentId/questions/:questionId`**
When `options` is provided, the full option set is replaced (destroy + re-insert).
---
### Archive/Restore Question
**`DELETE .../:parentId/questions/:questionId`** — archive
**`PATCH .../:parentId/questions/:questionId/restore`** — restore
**`GET .../:parentId/questions/archives/:questionId`** — get archived question
---
### Bulk Archive/Restore Questions
**`DELETE .../:parentId/questions/bulk`** — bulk archive
**`PATCH .../:parentId/questions/restore/bulk`** — bulk restore
### Request Body
```json
{ "ids": [1, 2, 3], "deletedBy": 1 }
```
---
## Units
### Get All Units
**`GET /api/admin/courses/:courseId/units`**
Returns paginated active units for a course, ordered by `order_index`.
---
### Get Single Unit
**`GET /api/admin/courses/:courseId/units/:unitId`**
Returns a unit with its lessons and quiz.
### Response `200`
```json
{
"status": "success",
"message": "Unit retrieved.",
"data": {
"unit_id": 1, "uuid": "...", "course_id": 5,
"title": "Introduction", "description": "...",
"order_index": 0, "duration_seconds": 1800,
"lessons": [...],
"quiz": { ... }
}
}
```
---
### Create Unit
**`POST /api/admin/courses/:courseId/units`**
### Request Body
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `title` | string | **Yes** | |
| `description` | string | No | |
| `order` | number | No | Default: `0` |
| `createdBy` | number | No | |
---
### Update Unit
**`PUT /api/admin/courses/:courseId/units/:unitId`**
### Request Body (all optional)
`title`, `description`, `order`, `updatedBy`
---
### Archive/Restore Unit
**`DELETE /api/admin/courses/:courseId/units/:unitId`** — archive
**`PATCH /api/admin/courses/:courseId/units/:unitId/restore`** — restore
**`GET /api/admin/courses/:courseId/units/archives`** — list archived
**`GET /api/admin/courses/:courseId/units/archives/:unitId`** — get one archived
---
### Bulk Archive/Restore Units
**`DELETE /api/admin/courses/:courseId/units/bulk`** — bulk archive
**`PATCH /api/admin/courses/:courseId/units/restore/bulk`** — bulk restore
```json
{ "ids": [1, 2] }
```
---
### Get Unit Field Values
**`GET /api/admin/courses/:courseId/field-values`**
---
## Unit Quiz
One quiz per unit. Shares `QuizQuestion` / `QuizOption` with course assessments (via `quiz_id` FK).
### Get Quiz
**`GET /api/admin/courses/:courseId/units/:unitId/quiz`**
Returns the quiz with questions and options.
---
### Create Quiz
**`POST /api/admin/courses/:courseId/units/:unitId/quiz`**
Only one quiz per unit. Returns `409` if one already exists.
### Request Body
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `title` | string | No | |
| `is_required` | boolean | No | Default: `false` |
| `passing_score` | number | No | Default: `70` |
| `max_questions` | number | No | `null` = show all |
| `createdBy` | number | No | |
---
### Update Quiz
**`PATCH /api/admin/courses/:courseId/units/:unitId/quiz/:quizId`**
### Request Body (all optional)
`title`, `is_required`, `passing_score`, `max_questions`, `updatedBy`
---
### Archive/Restore Quiz
**`DELETE /api/admin/courses/:courseId/units/:unitId/quiz/:quizId`** — archive
**`PATCH /api/admin/courses/:courseId/units/:unitId/quiz/:quizId/restore`** — restore
**`GET /api/admin/courses/:courseId/units/:unitId/quiz/archives`** — get archived quiz
---
## Lessons
### Get All Lessons
**`GET /api/admin/courses/:courseId/units/:unitId/lessons`**
Returns paginated active lessons, ordered by `order_index`.
---
### Get Single Lesson
**`GET /api/admin/courses/:courseId/units/:unitId/lessons/:lessonId`**
Returns a lesson with its page (blocks) and objectives.
### Response `200`
```json
{
"status": "success",
"message": "Lesson retrieved.",
"data": {
"lesson_id": 1, "uuid": "...", "unit_id": 1,
"title": "What is a closure?", "description": "...",
"duration_seconds": 900, "order_index": 0,
"page": { "page_id": 1, "lesson_id": 1, "blocks": [...] },
"objectives": [{ "objective_id": 1, "text": "Understand closures", "order_index": 0 }],
"unit": { "unit_id": 1, "course_id": 5, ... }
}
}
```
---
### Create Lesson
**`POST /api/admin/courses/:courseId/units/:unitId/lessons`**
Creates a lesson, an empty `LessonPage` (blocks: `[]`), and optional objectives in a single transaction.
### Request Body
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `title` | string | **Yes** | |
| `description` | string | No | |
| `order` | number | No | Default: `0` |
| `objectives` | array | No | `[{ text, order_index }]` |
| `createdBy` | number | No | |
---
### Update Lesson
**`PUT /api/admin/courses/:courseId/units/:unitId/lessons/:lessonId`**
When `objectives` is provided, the full set is replaced.
### Request Body (all optional)
`title`, `description`, `order`, `objectives`, `updatedBy`
---
### Archive/Restore Lesson
**`DELETE /api/admin/courses/:courseId/units/:unitId/lessons/:lessonId`** — archive
**`PATCH /api/admin/courses/:courseId/units/:unitId/lessons/:lessonId/restore`** — restore
**`GET /api/admin/courses/:courseId/units/:unitId/lessons/archives`** — list archived
**`GET /api/admin/courses/:courseId/units/:unitId/lessons/archives/:lessonId`** — get one archived
---
### Bulk Archive/Restore Lessons
**`DELETE /api/admin/courses/:courseId/units/:unitId/lessons/bulk`** — bulk archive
**`PATCH /api/admin/courses/:courseId/units/:unitId/lessons/restore/bulk`** — bulk restore
```json
{ "ids": [1, 2] }
```
---
### Get Lesson Field Values
**`GET /api/admin/courses/:courseId/units/:unitId/field-values`**
---
## Lesson Page
Each lesson has exactly **one** page. A page is created automatically when a lesson is created (with empty `blocks`).
### Get Lesson Page
**`GET /api/admin/courses/:courseId/units/:unitId/lessons/:lessonId/page`**
### Response `200`
```json
{
"status": "success",
"message": "Lesson page retrieved.",
"data": {
"page_id": 1,
"lesson_id": 3,
"blocks": [
{ "type": "text", "content": "A closure is..." },
{ "type": "video", "asset_id": 12, "duration_seconds": 300 }
]
}
}
```
---
### Upsert Lesson Page
**`PUT /api/admin/courses/:courseId/units/:unitId/lessons/:lessonId/page`**
Creates or replaces the lesson page's block content. After saving, `duration_seconds` on the lesson (and its ancestor unit and course) is automatically recomputed from video blocks.
### Request Body
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `blocks` | array | **Yes** | Block array |
| `updatedBy` | number | No | |
### Response `200` (updated) / `201` (created)
```json
{
"status": "success",
"message": "Lesson page updated.",
"data": { ... }
}
```
### Error Responses
| Status | Message |
|--------|---------|
| `400` | `blocks must be an array.` |
| `404` | `Lesson not found.` |
---
## Course Reading Progress
> These endpoints are served from `controllers/admin/course_reading_progress.controller.js` but mounted on the `/api/admin/courses` router.
### Get Course Reading Progress
**`GET /api/admin/courses/:courseId/reading-progress`**
Returns one summary row per user who has touched the course. Includes lesson/unit completion counts derived from the live course structure (not from stored counters).
Sorted: in-progress users first (most recent access first), completed users last.
### Response `200`
```json
{
"status": "success",
"message": "Course reading progress retrieved.",
"data": [
{
"user_id": 42,
"course_status": "in_progress",
"last_accessed_at": "2026-06-21T09:00:00.000Z",
"lessons_completed": 3,
"units_completed": 1,
"user": {
"email": "user@example.com",
"full_name": "Jane Doe",
"avatar_url": "https://..."
},
"units_total": 4,
"lessons_total": 12
}
]
}
```
---
### Get User Reading Progress
**`GET /api/admin/courses/:courseId/reading-progress/users/:userId`**
Loaded lazily when the admin expands a user row. Returns the full unit → lesson breakdown with progress status per item.
### Response `200`
```json
{
"status": "success",
"message": "User reading progress retrieved.",
"data": [
{
"unit_id": 1, "uuid": "...", "title": "Introduction",
"status": "completed",
"lessons": [
{
"lesson_id": 1, "uuid": "...", "title": "What is a closure?",
"status": "completed",
"completed_at": "2026-06-20T10:00:00.000Z"
},
{
"lesson_id": 2, "uuid": "...", "title": "Closure examples",
"status": null,
"completed_at": null
}
]
}
]
}
```
@@ -0,0 +1,90 @@
# Dashboard Controller Documentation
**File:** `controllers/admin/dashboard.controller.js`
**Base URL:** `/api/admin/dashboard`
**Guards:** `authenticate → requireAdmin() → adminLimiter`
---
## Table of Contents
- [Users Dashboard](#users-dashboard)
- [Groups Dashboard](#groups-dashboard)
---
## Users Dashboard
**`GET /api/admin/dashboard/users`**
Returns summary stats and breakdowns for all users. All counts run in parallel.
### Response `200`
```json
{
"status": "success",
"message": "Users dashboard data fetched.",
"data": {
"stats": [
{ "key": "total", "label": "Total Users", "value": 100 },
{ "key": "active", "label": "Active Users", "value": 85 },
{ "key": "inactive", "label": "Inactive Users", "value": 10 },
{ "key": "verified", "label": "Verified", "value": 80 },
{ "key": "archived", "label": "Archived", "value": 5 }
],
"breakdowns": [
{
"key": "acc_type",
"label": "By Account Type",
"data": [
{ "label": "user", "value": 90 },
{ "label": "staff", "value": 8 },
{ "label": "admin", "value": 2 }
]
},
{
"key": "reg_type",
"label": "By Registration Type",
"data": [
{ "label": "system", "value": 75 },
{ "label": "google", "value": 25 }
]
}
]
}
}
```
---
## Groups Dashboard
**`GET /api/admin/dashboard/groups`**
Returns summary stats and a top-10 groups-by-member-count breakdown.
### Response `200`
```json
{
"status": "success",
"message": "Groups dashboard data fetched.",
"data": {
"stats": [
{ "key": "total", "label": "Total Groups", "value": 20 },
{ "key": "active", "label": "Active Groups", "value": 18 },
{ "key": "inactive", "label": "Inactive Groups", "value": 0 },
{ "key": "archived", "label": "Archived", "value": 2 },
{ "key": "empty", "label": "Empty Groups", "value": 3 }
],
"breakdowns": [
{
"key": "top_groups",
"label": "Top Groups by Members",
"data": [
{ "label": "Engineering", "value": 42 },
{ "label": "Marketing", "value": 30 }
]
}
]
}
}
```
@@ -0,0 +1,188 @@
# Landing Pages / Page Builder — Schema Documentation
**Migrations:** `41b`, `42`, `43`, `44`, `45`, `49`
**Base URL (planned):** `/api/admin/pages`
**Guards:** `authenticate → requireAdmin() → adminLimiter`
> **TODO:** Controller, service, and routes for this module have not been created yet.
---
## Table of Contents
- [Overview](#overview)
- [Schema](#schema)
- [pages](#pages)
- [landing_pages](#landing_pages)
- [page_sections](#page_sections)
- [page_blocks](#page_blocks)
- [page_templates](#page_templates)
- [page_user_groups](#page_user_groups)
- [Relationships](#relationships)
- [Design Decisions](#design-decisions)
- [ENUM Types](#enum-types)
---
## Overview
The page builder uses a generalized `pages` root table as the single owner of all `page_sections`. This means sections and blocks are not locked to `landing_pages` — any future page type (`lesson_page`, `course_page`, etc.) can reuse the same section/block system by linking to `pages`.
**Hierarchy:**
```
pages
├── landing_pages (1:1 via page_id)
└── page_sections (1:N via page_id)
└── page_blocks (1:N via page_section_id)
page_templates — standalone reusable layout snapshots
page_user_groups — controls which groups can see a page
```
---
## Schema
### `pages`
Root identity table. Every page type gets a row here first.
| Column | Type | Constraints | Description |
|------------|------------|----------------------|------------------------------------|
| id | BIGSERIAL | PK | |
| type | page_type | NOT NULL | Discriminator for the page type |
| created_at | TIMESTAMPTZ | NOT NULL DEFAULT now() | |
| updated_at | TIMESTAMPTZ | NOT NULL DEFAULT now() | |
**Indexes:** `idx_pages_type` on `(type)`
---
### `landing_pages`
Landing-page-specific metadata. One-to-one with `pages`.
| Column | Type | Constraints | Description |
|------------------|--------------------|------------------------------------|------------------------------|
| id | BIGSERIAL | PK | |
| page_id | BIGINT | NOT NULL, UNIQUE, FK → pages(id) | Link to root pages table |
| title | VARCHAR(255) | NOT NULL | |
| slug | VARCHAR(255) | NOT NULL, UNIQUE | URL path segment |
| meta_title | VARCHAR(255) | | SEO title override |
| meta_description | TEXT | | SEO description |
| status | landing_page_status | NOT NULL DEFAULT 'draft' | |
| published_at | TIMESTAMPTZ | | Set when first published |
| created_by | BIGINT | FK → users(user_id) SET NULL | Admin who created the page |
| created_at | TIMESTAMPTZ | NOT NULL DEFAULT now() | |
| updated_at | TIMESTAMPTZ | NOT NULL DEFAULT now() | |
**Indexes:** `idx_landing_pages_status` on `(status)`
---
### `page_sections`
Ordered sections within any page. References `pages`, not `landing_pages`.
| Column | Type | Constraints | Description |
|------------|------------------|--------------------------------|------------------------------------|
| id | BIGSERIAL | PK | |
| page_id | BIGINT | NOT NULL, FK → pages(id) | Belongs to a page (any type) |
| type | page_section_type | NOT NULL | Section layout type |
| label | VARCHAR(255) | | Admin-facing label |
| position | INT | NOT NULL DEFAULT 0 | Display order |
| settings | JSONB | | Background, padding, layout, etc. |
| is_visible | BOOLEAN | NOT NULL DEFAULT true | Toggle section visibility |
| created_at | TIMESTAMPTZ | NOT NULL DEFAULT now() | |
| updated_at | TIMESTAMPTZ | NOT NULL DEFAULT now() | |
**Indexes:** `idx_page_sections_page_id` on `(page_id)`, `idx_page_sections_order` on `(page_id, position)`
---
### `page_blocks`
Content blocks within a section.
| Column | Type | Constraints | Description |
|-----------------|----------------|------------------------------------|----------------------------------------|
| id | BIGSERIAL | PK | |
| page_section_id | BIGINT | NOT NULL, FK → page_sections(id) | |
| type | page_block_type | NOT NULL | Block content type |
| content | JSONB | | Payload — varies by type (see below) |
| position | INT | NOT NULL DEFAULT 0 | Display order within section |
| settings | JSONB | | Block-level style overrides |
| created_at | TIMESTAMPTZ | NOT NULL DEFAULT now() | |
| updated_at | TIMESTAMPTZ | NOT NULL DEFAULT now() | |
**Indexes:** `idx_page_blocks_section_id` on `(page_section_id)`, `idx_page_blocks_order` on `(page_section_id, position)`
**`content` shape by block type:**
| type | content fields |
|--------|-----------------------------------------|
| text | `{ body: string }` |
| image | `{ src: string, alt: string }` |
| button | `{ label: string, href: string, variant: string }` |
| video | `{ src: string, autoplay: boolean }` |
| form | `{ form_id: number }` |
| spacer | `{ height: number }` |
---
### `page_templates`
Reusable layout snapshots. Stored as a full JSONB dump of sections + blocks — not live FK references.
| Column | Type | Constraints | Description |
|---------------|-------------|------------------------|--------------------------------------|
| id | BIGSERIAL | PK | |
| name | VARCHAR(255) | NOT NULL | Template display name |
| thumbnail_url | TEXT | | Preview image URL |
| structure | JSONB | | Full snapshot of sections and blocks |
| created_at | TIMESTAMPTZ | NOT NULL DEFAULT now() | |
---
### `page_user_groups`
Junction table — controls which user groups can access a page.
| Column | Type | Constraints | Description |
|------------|-------------|--------------------------------------|-------------|
| page_id | BIGINT | PK, FK → pages(id) ON DELETE CASCADE | |
| group_id | BIGINT | PK, FK → user_groups(group_id) | |
| created_at | TIMESTAMPTZ | NOT NULL DEFAULT now() | |
**Indexes:** `idx_page_user_groups_group_id` on `(group_id)`
**Primary Key:** composite `(page_id, group_id)`
---
## Relationships
```
pages 1 ──── 1 landing_pages
pages 1 ──── N page_sections
pages N ──── N user_groups (via page_user_groups)
page_sections 1 ──── N page_blocks
```
Cascade deletes flow top-down: deleting a `pages` row removes its `landing_pages` record, all its `page_sections`, and all nested `page_blocks` automatically.
---
## Design Decisions
- **`pages` as root** — sections belong to `pages`, not directly to `landing_pages`. Adding a new page type (e.g. `course_page`) only requires a new detail table + adding its value to the `page_type` ENUM. No changes to `page_sections` or `page_blocks`.
- **`page_templates.structure` is a snapshot** — templates store a JSONB copy of sections/blocks, not live FK references. This keeps templates stable when source pages are edited.
- **`page_sections.page_id`** points to `pages(id)` directly, giving sections access to any page type without schema changes.
---
## ENUM Types
| Type | Values |
|---------------------|------------------------------------------------------------------------|
| `page_type` | `landing_page`, `lesson_page`, `course_page` |
| `landing_page_status` | `draft`, `published`, `archived` |
| `page_section_type` | `hero`, `features`, `cta`, `testimonials`, `faq`, `pricing`, `gallery`, `custom` |
| `page_block_type` | `text`, `image`, `button`, `video`, `form`, `spacer` |
@@ -0,0 +1,125 @@
# Notifications Controller Documentation
**File:** `controllers/admin/notification.controller.js`
**Base URL:** `/api/admin/notifications`
**Guards:** `authenticate → requireAdmin() → adminLimiter`
---
## Table of Contents
- [Get All Notifications](#get-all-notifications)
- [Get Unseen Count](#get-unseen-count)
- [Mark One as Seen](#mark-one-as-seen)
- [Mark All as Seen](#mark-all-as-seen)
---
## Notes
- `admin_notifications` are **system-wide** — not scoped to a user. All admins see the same feed.
- Records are never deleted — seen/unseen state is toggled only.
- Notifications are created internally (e.g., task overdue events) — no POST endpoint is exposed.
---
## Get All Notifications
**`GET /api/admin/notifications`**
Returns paginated notifications, newest first.
### Query Parameters
| Parameter | Type | Required | Description |
|-----------|--------|----------|-------------|
| `page` | number | No | Default: `1` |
| `limit` | number | No | Default: `20`, max: `50` |
### Response `200`
```json
{
"status": "success",
"message": "Notifications fetched.",
"data": {
"notifications": [
{
"notification_id": 1,
"type": "task_overdue",
"title": "Tasks Overdue",
"message": "5 tasks are now overdue.",
"data": { "count": 5 },
"seen": false,
"seen_at": null,
"createdAt": "2026-06-19T10:00:00.000Z",
"updatedAt": "2026-06-19T10:00:00.000Z"
}
],
"pagination": {
"page": 1,
"limit": 20,
"total": 10,
"pages": 1
}
}
}
```
---
## Get Unseen Count
**`GET /api/admin/notifications/unseen`**
Returns a count of unseen notifications. Used for the bell badge.
### Response `200`
```json
{
"status": "success",
"message": "Unseen count fetched.",
"data": { "count": 3 }
}
```
---
## Mark One as Seen
**`PATCH /api/admin/notifications/:id/seen`**
Marks a single notification as seen and sets `seen_at` to the current timestamp.
### Response `200`
```json
{
"status": "success",
"message": "Notification marked as seen.",
"data": {
"notification_id": 1,
"seen": true,
"seen_at": "2026-06-21T10:00:00.000Z",
...
}
}
```
### Error Responses
| Status | Message |
|--------|---------|
| `404` | `Notification not found.` |
---
## Mark All as Seen
**`PATCH /api/admin/notifications/seen-all`**
Marks all unseen notifications as seen in a single update.
### Response `200`
```json
{
"status": "success",
"message": "7 notification(s) marked as seen.",
"data": { "count": 7 }
}
```
+147
View File
@@ -0,0 +1,147 @@
# Products Controller Documentation
**File:** `controllers/admin/products.controller.js`
**Base URL:** `/api/admin/products`
**Guards:** `authenticate → requireAdmin() → adminLimiter`
---
## Table of Contents
- [Get Course Product](#get-course-product)
- [Upsert Course Product](#upsert-course-product)
- [Remove Course Product](#remove-course-product)
- [Get Course Categories](#get-course-categories)
- [Sync Course Categories](#sync-course-categories)
---
## Notes
- Each course has **at most one** product listing (`products` table, unique on `course_id`).
- Upsert restores a soft-deleted product if one exists rather than creating a duplicate.
- Categories are managed via the `course_product_categories` junction table (many-to-many between `courses` and `categories`).
- Syncing categories replaces the full set — it is a replace-all, not an append.
---
## Get Course Product
**`GET /api/admin/products/courses/:courseId/product`**
Returns the product listing for a course, or `null` if none exists. Includes archived products (`paranoid: false`).
### Response `200`
```json
{
"status": "success",
"message": "Product retrieved.",
"data": {
"id": 1,
"course_id": 5,
"name": "Advanced JavaScript",
"description": "Full course access",
"price": "49.99",
"currency": "USD",
"access_days": 365,
"is_active": true,
"createdAt": "2026-01-01T00:00:00.000Z",
"updatedAt": "2026-01-01T00:00:00.000Z",
"deletedAt": null
}
}
```
---
## Upsert Course Product
**`PUT /api/admin/products/courses/:courseId/product`**
Creates the product if it does not exist. If a soft-deleted product exists, it is restored and updated. If an active product exists, it is updated.
### Request Body
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `name` | string | **Yes** | Product name |
| `price` | number | **Yes** | Price (decimal, e.g. `49.99`) |
| `description` | string | No | |
| `currency` | string | No | ISO 4217 code. Default: `USD` |
| `access_days` | number | No | Days of access after purchase. `null` = lifetime |
| `is_active` | boolean | No | Default: `true` |
### Response `200` (updated) / `201` (created)
```json
{
"status": "success",
"message": "Product updated.",
"data": { ... }
}
```
### Error Responses
| Status | Message |
|--------|---------|
| `400` | `name and price are required.` |
---
## Remove Course Product
**`DELETE /api/admin/products/courses/:courseId/product`**
Soft-deletes the course product.
### Response `200`
```json
{ "status": "success", "message": "Product removed." }
```
### Error Responses
| Status | Message |
|--------|---------|
| `404` | `Product not found.` |
---
## Get Course Categories
**`GET /api/admin/products/courses/:courseId/categories`**
Returns the list of categories assigned to a course.
### Response `200`
```json
{
"status": "success",
"message": "Course categories retrieved.",
"data": [
{ "id": 1, "name": "Business", "slug": "business", "is_active": true },
{ "id": 3, "name": "Design", "slug": "design", "is_active": true }
]
}
```
### Error Responses
| Status | Message |
|--------|---------|
| `404` | `Course not found.` |
---
## Sync Course Categories
**`POST /api/admin/products/courses/:courseId/categories`**
Replaces the full set of category assignments for a course. All existing assignments are removed first, then the new set is inserted.
### Request Body
```json
{ "category_ids": [1, 3, 7] }
```
Pass an empty array to clear all category assignments.
### Response `200`
```json
{ "status": "success", "message": "Course categories updated." }
```
+148
View File
@@ -0,0 +1,148 @@
# Profile Controller Documentation (Admin)
**File:** `controllers/admin/profile.controller.js`
**Base URL:** `/api/admin/profile`
**Guards:** `authenticate → requireAdmin() → adminLimiter`
---
## Table of Contents
- [Get Profile](#get-profile)
- [Update Profile](#update-profile)
- [Upload Avatar](#upload-avatar)
- [Delete Avatar](#delete-avatar)
---
## Notes
- All endpoints are self-service — they operate on the **currently authenticated admin** (`req.user.user_id`).
- `password`, `otp_code`, and `otp_expires_at` are always excluded from responses.
- Avatar files are stored in S3 via `s3.service`. The old avatar is deleted before upload.
---
## Get Profile
**`GET /api/admin/profile`**
Returns the authenticated admin's full user record.
### Response `200`
```json
{
"status": "success",
"message": "Profile retrieved.",
"data": {
"user_id": 1,
"email": "admin@example.com",
"is_active": true,
"is_verified": true,
"reg_type": "system",
"acc_type": "admin",
"personal_info": {
"name": {
"given_name": "Kenneth",
"middle_name": null,
"last_name": "Obsequio",
"extension_name": null,
"full_name": "Kenneth Obsequio"
},
"occupation": null,
"addresses": [],
"phone_number": [],
"date_of_birth": null,
"avatar": {
"url": "https://...",
"uuid": "storage-key",
"name": "avatar.jpg",
"mime_type": "image/jpeg",
"size": 204800
}
},
"createdAt": "2025-10-06T00:00:00.000Z",
"updatedAt": "2026-06-18T00:00:00.000Z"
}
}
```
---
## Update Profile
**`PUT /api/admin/profile`**
Deep-merges `personal_info` — top-level keys and `name` sub-keys are merged separately. Existing keys not present in the request body are preserved.
### Request Body
```json
{
"personal_info": {
"name": {
"given_name": "Kenneth",
"last_name": "Obsequio"
},
"occupation": "Software Engineer",
"date_of_birth": "1995-05-15"
}
}
```
### Response `200`
```json
{
"status": "success",
"message": "Profile updated.",
"data": { ... }
}
```
---
## Upload Avatar
**`POST /api/admin/profile/avatar`**
Uploads (or replaces) the admin's avatar via `multipart/form-data`.
- Old avatar is deleted from S3 before uploading the new one.
- Avatar metadata is stored in `personal_info.avatar`.
### Request
`Content-Type: multipart/form-data`
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `file` | file | **Yes** | Image file (handled by `avatar_upload.middleware`) |
### Response `200`
```json
{
"status": "success",
"message": "Avatar updated.",
"data": { ... }
}
```
### Error Responses
| Status | Message |
|--------|---------|
| `400` | `No file provided.` |
---
## Delete Avatar
**`DELETE /api/admin/profile/avatar`**
Removes the admin's avatar from S3 and sets `personal_info.avatar` to `null`.
### Response `200`
```json
{ "status": "success", "message": "Avatar removed." }
```
### Error Responses
| Status | Message |
|--------|---------|
| `404` | `No avatar to remove.` |
+563
View File
@@ -0,0 +1,563 @@
# Tasks Controller Documentation
**File:** `controllers/admin/task.controller.js` + `controllers/admin/task_completion.controller.js`
**Base URL:** `/api/admin/task-lists`
**Guards:** `authenticate → requireAdmin() → adminLimiter`
---
## Table of Contents
- [Task Lists](#task-lists)
- [Get All Task Lists](#get-all-task-lists)
- [Get Single Task List](#get-single-task-list)
- [Create Task List](#create-task-list)
- [Update Task List](#update-task-list)
- [Archive Task List](#archive-task-list)
- [Restore Task List](#restore-task-list)
- [Bulk Archive Task Lists](#bulk-archive-task-lists)
- [Bulk Restore Task Lists](#bulk-restore-task-lists)
- [Get Archived Task Lists](#get-archived-task-lists)
- [Get Task List Field Values](#get-task-list-field-values)
- [Task List Groups](#task-list-groups)
- [Get Assigned Groups](#get-assigned-groups)
- [Assign Groups](#assign-groups)
- [Unassign Groups](#unassign-groups)
- [Tasks](#tasks)
- [Get All Tasks](#get-all-tasks)
- [Get Single Task](#get-single-task)
- [Create Task](#create-task)
- [Update Task](#update-task)
- [Archive Task](#archive-task)
- [Restore Task](#restore-task)
- [Bulk Archive Tasks](#bulk-archive-tasks)
- [Bulk Restore Tasks](#bulk-restore-tasks)
- [Get Archived Tasks](#get-archived-tasks)
- [Get Task Field Values](#get-task-field-values)
- [Completions](#completions)
- [Get All Completions](#get-all-completions)
- [Get Single Completion](#get-single-completion)
- [Get Completions by User](#get-completions-by-user)
- [Archive Completion](#archive-completion)
- [Restore Completion](#restore-completion)
- [Bulk Archive Completions](#bulk-archive-completions)
- [Bulk Restore Completions](#bulk-restore-completions)
---
## Data Model
```
TaskList ──< Task ──< TaskRequirement
│
└──< TaskListGroup >── UserGroup
```
- A **TaskList** is a named container of tasks, visible to assigned user groups.
- A **Task** belongs to one TaskList and has 0-N requirements.
- A **TaskRequirement** specifies what a user must do (visit a link, upload a file, or read a course/unit/lesson).
- Completions are submitted by **clients only** — admins can view and archive/restore them.
---
## Task Requirement Types
| `type` | Required Fields | Description |
|--------|----------------|-------------|
| `visit_link` | `link_url`, `link_label` | User must visit a URL |
| `upload_file` | `allowed_file_types`, `max_file_count` | User must upload files |
| `read_course` | `reference_id` (course UUID), `reference_label` | User must complete a course |
| `read_unit` | `reference_id` (unit UUID), `reference_label` | User must complete a unit |
| `read_lesson` | `reference_id` (lesson UUID), `reference_label` | User must complete a lesson |
---
## Task Lists
### Get All Task Lists
**`GET /api/admin/task-lists`**
Returns paginated active task lists.
### Query Parameters
| Parameter | Type | Required | Description |
|-----------|--------|----------|-------------|
| `page` | number | No | Default: `1` |
| `limit` | number | No | Default: `10` |
| `filters` | array | No | JSON filter array |
| `sort` | array | No | JSON sort array |
### Response `200`
```json
{
"status": "success",
"message": "Task lists retrieved.",
"data": [...],
"pagination": { "page": 1, "limit": 10, "total": 5, "totalPages": 1 }
}
```
---
### Get Single Task List
**`GET /api/admin/task-lists/:taskListId`**
Returns a task list with its full task tree (tasks → requirements) and assigned groups. Includes archived items (`paranoid: false`).
### Response `200`
```json
{
"status": "success",
"message": "Task list retrieved.",
"data": {
"task_list_id": "uuid-...",
"name": "Onboarding Q1",
"description": "...",
"group_count": 2,
"groups": [
{ "group_id": 1, "name": "Engineering" }
],
"tasks": [
{
"task_id": "uuid-...",
"name": "Read the handbook",
"deadline": "2026-07-01T00:00:00.000Z",
"status": "pending",
"requirements": [
{ "requirement_id": "uuid-...", "type": "read_lesson", "reference_id": "uuid-..." }
]
}
],
"createdAt": "...",
"updatedAt": "..."
}
}
```
---
### Create Task List
**`POST /api/admin/task-lists`**
Rate-limited.
### Request Body
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `name` | string | **Yes** | Unique list name |
| `description` | string | No | |
### Response `201`
```json
{
"status": "success",
"message": "Task list created successfully.",
"data": { ... }
}
```
---
### Update Task List
**`PATCH /api/admin/task-lists/:taskListId`**
Rate-limited.
### Request Body
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `name` | string | No | |
| `description` | string | No | |
### Response `200`
```json
{ "status": "success", "message": "Task list updated successfully.", "data": { ... } }
```
---
### Archive Task List
**`DELETE /api/admin/task-lists/:taskListId`**
Rate-limited. Soft-deletes the task list.
### Response `200`
```json
{ "status": "success", "message": "Task list archived successfully." }
```
---
### Restore Task List
**`PATCH /api/admin/task-lists/:taskListId/restore`**
Rate-limited.
### Response `200`
```json
{ "status": "success", "message": "Task list restored successfully.", "data": { ... } }
```
---
### Bulk Archive Task Lists
**`POST /api/admin/task-lists/bulk-archive`**
Rate-limited.
### Request Body
```json
{ "ids": ["uuid-1", "uuid-2"] }
```
### Response `200`
```json
{
"status": "success",
"message": "2 task list(s) archived successfully.",
"archived_ids": [...],
"skipped_ids": []
}
```
---
### Bulk Restore Task Lists
**`POST /api/admin/task-lists/bulk-restore`**
Rate-limited. Same body shape as bulk archive.
---
### Get Archived Task Lists
**`GET /api/admin/task-lists/archived`**
Returns paginated soft-deleted task lists.
---
### Get Task List Field Values
**`GET /api/admin/task-lists/field-values`**
Returns distinct filterable field values for task lists.
---
## Task List Groups
### Get Assigned Groups
**`GET /api/admin/task-lists/:taskListId/groups`**
Returns all user groups currently assigned to the task list, including assignment metadata.
### Response `200`
```json
{
"status": "success",
"message": "Task list groups retrieved.",
"data": [
{
"id": "uuid-...",
"task_list_id": "uuid-...",
"group_id": 1,
"assignedAt": "2026-06-01T00:00:00.000Z",
"assignedBy": 1,
"group": { "group_id": 1, "name": "Engineering" }
}
]
}
```
---
### Assign Groups
**`POST /api/admin/task-lists/:taskListId/groups/assign`**
Rate-limited. Upsert-style — already-assigned groups are silently skipped.
### Request Body
```json
{ "group_ids": [1, 2, 3] }
```
### Response `201`
```json
{
"status": "success",
"message": "2 group(s) assigned.",
"assigned_ids": [2, 3],
"already_assigned_ids": [1],
"invalid_ids": []
}
```
---
### Unassign Groups
**`POST /api/admin/task-lists/:taskListId/groups/unassign`**
Rate-limited. Hard-deletes the junction rows (assignments are not soft-deleted).
### Request Body
```json
{ "group_ids": [2, 3] }
```
### Response `200`
```json
{
"status": "success",
"message": "2 group(s) unassigned.",
"unassigned_ids": [2, 3],
"skipped_ids": []
}
```
---
## Tasks
### Get All Tasks
**`GET /api/admin/task-lists/:taskListId/tasks`**
Returns paginated tasks under a task list.
### Query Parameters
Standard pagination + `filters` + `sort`.
---
### Get Single Task
**`GET /api/admin/task-lists/:taskListId/tasks/:taskId`**
Returns a task with its requirements and the parent task list (including assigned groups).
### Response `200`
```json
{
"status": "success",
"message": "Task retrieved.",
"data": {
"task_id": "uuid-...",
"task_list_id": "uuid-...",
"name": "Complete orientation",
"description": "...",
"deadline": "2026-07-01T00:00:00.000Z",
"status": "pending",
"requirements": [
{
"requirement_id": "uuid-...",
"type": "upload_file",
"allowed_file_types": ["application/pdf"],
"max_file_count": 1,
"order": 0
}
],
"taskList": { "task_list_id": "...", "name": "Onboarding Q1", "groups": [...] }
}
}
```
---
### Create Task
**`POST /api/admin/task-lists/:taskListId/tasks`**
Rate-limited. Creates a task and optionally its requirements in a single transaction.
### Request Body
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `name` | string | **Yes** | Task name |
| `description` | string | No | |
| `deadline` | date | No | ISO date string |
| `requirements` | array | No | Array of requirement objects (see Requirement Types above) |
### Response `201`
```json
{
"status": "success",
"message": "Task created successfully.",
"data": { "task_id": "uuid-...", "name": "...", "requirements": [...] }
}
```
---
### Update Task
**`PATCH /api/admin/task-lists/:taskListId/tasks/:taskId`**
Rate-limited. When `requirements` is provided, the full set is replaced (soft-delete + re-insert).
> **Note:** Incoming requirement objects must **not** include `requirement_id` — the server always generates fresh IDs to avoid collisions with soft-deleted rows.
### Request Body
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `name` | string | No | |
| `description` | string | No | |
| `deadline` | date | No | |
| `status` | string | No | `pending`, `in_progress`, `completed`, `overdue` |
| `requirements` | array | No | Full replacement set |
---
### Archive Task
**`DELETE /api/admin/task-lists/:taskListId/tasks/:taskId`**
Rate-limited. Soft-deletes the task.
---
### Restore Task
**`PATCH /api/admin/task-lists/:taskListId/tasks/:taskId/restore`**
Rate-limited.
---
### Bulk Archive Tasks
**`POST /api/admin/task-lists/:taskListId/tasks/bulk-archive`**
Rate-limited.
### Request Body
```json
{ "ids": ["uuid-1", "uuid-2"] }
```
---
### Bulk Restore Tasks
**`POST /api/admin/task-lists/:taskListId/tasks/bulk-restore`**
Rate-limited.
---
### Get Archived Tasks
**`GET /api/admin/task-lists/:taskListId/tasks/archived`**
---
### Get Task Field Values
**`GET /api/admin/task-lists/:taskListId/tasks/field-values`**
---
## Completions
Completions are created by clients only. Admins can view and archive/restore them.
Each completion may have multiple attached files (`task_completion_files`).
---
### Get All Completions
**`GET /api/admin/task-lists/:taskListId/tasks/:taskId/completions`**
Returns paginated completions for a task, with submitting user info and files.
### Response `200`
```json
{
"status": "success",
"message": "Completions retrieved.",
"data": [
{
"completion_id": "uuid-...",
"task_id": "uuid-...",
"user_id": 42,
"note": "Attached the signed form.",
"submitted_at": "2026-06-15T10:00:00.000Z",
"user": { "user_id": 42, "email": "user@example.com", "name": "Jane Doe" },
"files": [
{
"file_id": "uuid-...",
"file_url": "https://...",
"file_name": "signed_form.pdf",
"file_size": 204800,
"mime_type": "application/pdf"
}
]
}
],
"pagination": { ... }
}
```
---
### Get Single Completion
**`GET /api/admin/task-lists/:taskListId/tasks/:taskId/completions/:completionId`**
Returns a single completion with user and files.
---
### Get Completions by User
**`GET /api/admin/task-lists/:taskListId/tasks/:taskId/completions/user/:userId`**
Returns all completions for a specific user on a specific task.
---
### Archive Completion
**`DELETE /api/admin/task-lists/:taskListId/tasks/:taskId/completions/:completionId`**
Rate-limited. Soft-deletes a completion.
---
### Restore Completion
**`PATCH /api/admin/task-lists/:taskListId/tasks/:taskId/completions/:completionId/restore`**
Rate-limited.
---
### Bulk Archive Completions
**`POST /api/admin/task-lists/:taskListId/tasks/:taskId/completions/bulk-archive`**
Rate-limited.
### Request Body
```json
{ "ids": ["uuid-1", "uuid-2"] }
```
---
### Bulk Restore Completions
**`POST /api/admin/task-lists/:taskListId/tasks/:taskId/completions/bulk-restore`**
Rate-limited.
+406
View File
@@ -0,0 +1,406 @@
# Tiers Controller Documentation
**File:** `controllers/admin/tiers.controller.js`
**Base URL:** `/api/admin/tiers`
**Guards:** `authenticate → requireAdmin() → adminLimiter`
---
## Table of Contents
- [Tier Plans](#tier-plans)
- [Get All Plans](#get-all-plans)
- [Get Single Plan](#get-single-plan)
- [Create Plan](#create-plan)
- [Update Plan](#update-plan)
- [Archive Plan](#archive-plan)
- [Bulk Archive Plans](#bulk-archive-plans)
- [Restore Plan](#restore-plan)
- [Bulk Restore Plans](#bulk-restore-plans)
- [Get Plan Field Values](#get-plan-field-values)
- [Plan Courses](#plan-courses)
- [Get Plan Courses](#get-plan-courses)
- [Sync Plan Courses](#sync-plan-courses)
- [User Tiers](#user-tiers)
- [Get User Tiers](#get-user-tiers)
- [Grant Tier](#grant-tier)
- [Revoke Tier](#revoke-tier)
- [Payments](#payments)
- [Get All Payments](#get-all-payments)
- [Get Single Payment](#get-single-payment)
- [Get Payment Field Values](#get-payment-field-values)
---
## Notes
- Pending payments older than **60 minutes** are automatically expired before any payment list/detail call.
- Revoking a tier immediately creates a new `free` tier row for the user (auto-downgrade).
- `plan_id` is serialized as a string in create responses to avoid BigInt overflow in JS.
- A plan's `tier` field cannot be updated — only `label`, `duration_days`, `price`, `currency`, and `is_active`.
---
## Tier Plans
### Get All Plans
**`GET /api/admin/tiers`**
Returns paginated plans. Pass `?archived=true` to see soft-deleted plans instead.
### Query Parameters
| Parameter | Type | Required | Description |
|-----------|---------|----------|-------------|
| `archived` | boolean | No | `true` to show archived plans only |
| `page` | number | No | Default: `1` |
| `limit` | number | No | Default: `10` |
### Response `200`
```json
{
"status": "success",
"message": "Plans retrieved.",
"data": [...],
"pagination": { "page": 1, "limit": 10, "total": 4, "totalPages": 1 }
}
```
---
### Get Single Plan
**`GET /api/admin/tiers/:id`**
### Response `200`
```json
{
"status": "success",
"message": "Plan retrieved.",
"data": {
"plan_id": 1,
"tier": "premium",
"label": "Premium Monthly",
"duration_days": 30,
"price": "9.99",
"currency": "USD",
"is_active": true,
"createdAt": "...",
"updatedAt": "..."
}
}
```
---
### Create Plan
**`POST /api/admin/tiers`**
### Request Body
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `tier` | string | **Yes** | `premium` or `exclusive` |
| `label` | string | **Yes** | Human-readable plan name |
| `duration_days` | number | **Yes** | Access duration in days |
| `price` | number | **Yes** | Plan price (decimal) |
| `currency` | string | No | ISO 4217. Default: `USD` |
### Response `201`
```json
{
"status": "success",
"message": "Plan created.",
"data": { "plan_id": "5", "tier": "premium", ... }
}
```
---
### Update Plan
**`PUT /api/admin/tiers/:id`**
Only `label`, `duration_days`, `price`, `currency`, and `is_active` are updatable.
### Response `200`
```json
{ "status": "success", "message": "Plan updated.", "data": { ... } }
```
---
### Archive Plan
**`DELETE /api/admin/tiers/:id`**
Sets `is_active = false` then soft-deletes.
### Response `200`
```json
{ "status": "success", "message": "Plan archived successfully." }
```
---
### Bulk Archive Plans
**`POST /api/admin/tiers/bulk/archive`**
### Request Body
```json
{ "ids": [1, 2] }
```
### Response `200`
```json
{
"status": "success",
"message": "2 plan(s) archived successfully.",
"archived_ids": [1, 2],
"skipped_ids": []
}
```
---
### Restore Plan
**`POST /api/admin/tiers/:id/restore`**
Restores plan and sets `is_active = true`.
### Response `200`
```json
{ "status": "success", "message": "Plan restored successfully." }
```
---
### Bulk Restore Plans
**`POST /api/admin/tiers/bulk/restore`**
### Request Body
```json
{ "ids": [1, 2] }
```
### Response `200`
```json
{
"status": "success",
"message": "2 plan(s) restored successfully.",
"restored_ids": [1, 2],
"skipped_ids": []
}
```
---
### Get Plan Field Values
**`GET /api/admin/tiers/field-values`**
Returns distinct filterable field values for tier plans.
---
## Plan Courses
### Get Plan Courses
**`GET /api/admin/tiers/:id/courses`**
Returns the list of courses linked to this plan.
### Response `200`
```json
{
"status": "success",
"message": "Plan courses retrieved.",
"data": [
{ "course_id": 1, "title": "Intro to Python", "course_code": "PY-101", "subscription": "premium", "level": "beginner" }
]
}
```
---
### Sync Plan Courses
**`POST /api/admin/tiers/:id/courses`**
Replaces the full set of courses for this plan. Removes all existing assignments first, then inserts the new set. A course can only belong to one plan at a time — existing assignments to other plans are cleared automatically.
### Request Body
```json
{ "course_ids": [1, 2, 3] }
```
Pass `[]` to remove all courses from the plan.
### Response `200`
```json
{ "status": "success", "message": "Plan courses updated." }
```
---
## User Tiers
### Get User Tiers
**`GET /api/admin/tiers/users/:id/tiers`**
Returns the full tier history for a user, newest first. Includes `grantedByUser` and `revokedByUser` info.
### Response `200`
```json
{
"status": "success",
"message": "User tiers retrieved.",
"data": [
{
"tier_id": 3,
"user_id": 42,
"tier": "premium",
"status": "active",
"starts_at": "2026-06-01T00:00:00.000Z",
"expires_at": "2026-07-01T00:00:00.000Z",
"granted_by": 1,
"revoked_by": null,
"revoked_at": null,
"notes": "Trial promotion",
"grantedByUser": { "user_id": 1, "email": "admin@example.com" },
"revokedByUser": null
}
]
}
```
---
### Grant Tier
**`POST /api/admin/tiers/users/tiers/grant`**
- Expires all currently active tiers for the user before creating the new one.
- `expires_at` is calculated as `now + plan.duration_days`.
### Request Body
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `user_id` | number | **Yes** | Target user |
| `tier` | string | **Yes** | `premium` or `exclusive` |
| `plan_id` | number | **Yes** | Must match the plan's tier |
| `notes` | string | No | Optional admin note |
### Response `201`
```json
{
"status": "success",
"message": "Tier granted.",
"data": { "tier_id": 4, "user_id": 42, "tier": "premium", "status": "active", ... }
}
```
### Error Responses
| Status | Message |
|--------|---------|
| `400` | `user_id, tier, and plan_id are required.` |
| `400` | `Plan tier mismatch.` |
| `404` | `User not found.` |
| `404` | `Plan not found or inactive.` |
---
### Revoke Tier
**`PATCH /api/admin/tiers/users/tiers/:tid/revoke`**
- Sets the tier's status to `revoked` and records `revoked_by` / `revoked_at`.
- Automatically creates a new `free` tier row for the user (auto-downgrade note: `"Auto-downgrade after revoke."`).
### Response `200`
```json
{ "status": "success", "message": "Tier revoked. User downgraded to free." }
```
### Error Responses
| Status | Message |
|--------|---------|
| `400` | `Tier is not active.` |
| `404` | `Tier record not found.` |
---
## Payments
### Get All Payments
**`GET /api/admin/tiers/payments`**
Returns paginated payment records. Stale pending payments (>60 min old) are expired before the query runs.
### Query Parameters
| Parameter | Type | Required | Description |
|-----------|--------|----------|-------------|
| `page` | number | No | Default: `1` |
| `limit` | number | No | Default: `10` |
| `filters` | array | No | JSON filter array |
### Response `200`
```json
{
"status": "success",
"message": "Payments retrieved.",
"data": [
{
"payment_id": 1,
"user_id": 42,
"plan_id": 1,
"status": "completed",
"amount": "9.99",
"currency": "USD",
"promo_code": null,
"discount": "0.00",
"provider": "paypal",
"paid_at": "2026-06-01T10:00:00.000Z",
"user": { "user_id": 42, "email": "user@example.com" },
"plan": { "plan_id": 1, "label": "Premium Monthly", "tier": "premium", "duration_days": 30 }
}
],
"pagination": { ... }
}
```
---
### Get Single Payment
**`GET /api/admin/tiers/payments/:id`**
Returns full payment detail including `user`, `plan`, and `tier` associations.
### Response `200`
```json
{
"status": "success",
"message": "Payment retrieved.",
"data": {
"payment_id": 1,
"provider_payload": { "order_id": "...", "capture_id": "...", ... },
"user": { ... },
"plan": { ... },
"tier": { ... }
}
}
```
---
### Get Payment Field Values
**`GET /api/admin/tiers/payments/field-values`**
Returns distinct filterable field values for payments. `provider_payload` is excluded.
@@ -0,0 +1,129 @@
# User Activity Controller Documentation
**File:** `controllers/admin/user_activity.controller.js`
**Base URL:** `/api/admin/activity` and `/api/admin/users/:id/activity`
**Guards:** `authenticate → requireAdmin() → adminLimiter`
---
## Table of Contents
- [Get Global Activity Feed](#get-global-activity-feed)
- [Get Per-User Activity](#get-per-user-activity)
---
## Notes
- `user_activity` rows are **never soft-deleted** — this is an append-only audit log.
- `created_at` is the timestamp column (no `createdAt` alias — Sequelize `timestamps: false`).
- The global feed endpoint joins the `users` table and enriches each row with `full_name` and `avatar_url`.
- The per-user endpoint skips the join for performance since user context is already known.
---
## Activity Object Shape
```json
{
"activity_id": 1,
"user_id": 42,
"email": "user@example.com",
"full_name": "Kenneth Obsequio",
"avatar_url": "https://...",
"acc_type": "admin",
"action": "login",
"entity_type": "session",
"entity_id": 7,
"details": { "session_id": 7, "reg_type": "system" },
"created_at": "2026-06-21T08:00:00.000Z"
}
```
### Common `action` Values
| Action | Entity Type | Details Keys |
|--------|-------------|--------------|
| `login` | `session` | `session_id`, `reg_type` |
| `deactivate_user` | `user` | `target_email` |
| `lesson_read` | `lesson` | `lesson_uuid`, `status` |
| `submit_task` | `task` | `task_id` |
| `set_user_status` | `user` | `is_active` |
| `create_course` | `course` | `title` |
| `update_course` | `course` | `title` |
| `archive_course` | `course` | — |
| `create_task_list` | `task_list` | `name` |
| `create_advertisement` | `advertisement` | `type` |
| `grant_tier` | `tier` | `user_id`, `tier`, `plan_id` |
| `revoke_tier` | `tier` | `user_id`, `tier` |
---
## Get Global Activity Feed
**`GET /api/admin/activity`**
Returns a paginated, reverse-chronological feed of all user activity across the system.
### Query Parameters
| Parameter | Type | Required | Description |
|-----------|--------|----------|-------------|
| `page` | number | No | Default: `1` |
| `limit` | number | No | Default: `20`, max: `100` |
| `action` | string | No | Filter by exact action string (e.g. `login`) |
| `from` | date | No | ISO date — `created_at >= from` |
| `to` | date | No | ISO date — `created_at <= to` |
### Response `200`
```json
{
"status": "success",
"message": "Activity feed retrieved.",
"data": {
"total": 500,
"page": 1,
"totalPages": 25,
"activities": [ ... ]
}
}
```
---
## Get Per-User Activity
**`GET /api/admin/users/:id/activity`**
Returns a paginated, reverse-chronological activity log for a single user.
### Query Parameters
Same as global feed (`page`, `limit`, `action`, `from`, `to`).
### Response `200`
```json
{
"status": "success",
"message": "User activity retrieved.",
"data": {
"total": 45,
"page": 1,
"totalPages": 3,
"activities": [
{
"activity_id": 1,
"user_id": 42,
"session_id": null,
"action": "update_course",
"entity_type": "course",
"entity_id": 3,
"details": { "title": "Advanced JS" },
"created_at": "2026-06-21T09:00:00.000Z"
}
]
}
}
```
### Error Responses
| Status | Message |
|--------|---------|
| `400` | `Invalid User ID.` |
| `404` | `User not found.` |
+90
View File
@@ -0,0 +1,90 @@
/***********************************************************************************************************************************************************************
* File Name: media.controller.js (admin)
* Type of Program: Controller
* Description: Issues short-lived JWT stream tokens for admin asset preview.
* Works identically to the client media token flow but is scoped to
* admin-authenticated requests and allows any asset regardless of
* is_public. The stream endpoint (/api/client/media/stream/:token)
* is shared — the JWT payload shape is identical.
*
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jun. 22, 2026
***********************************************************************************************************************************************************************/
"use strict";
const jwt = require("jsonwebtoken");
const R = require("../../utils/response.util");
const mdl_Assets = require("../../models/assets/assets.mdl");
const s3 = require("../../services/s3.service");
const MEDIA_SECRET = process.env.MEDIA_JWT_SECRET ?? process.env.JWT_SECRET;
const TOKEN_TTL_SEC = 30 * 60; // 30 min — admin preview session
const SUPPORTED_TYPES = ["video", "audio", "document", "image"];
function resolveIp(req) {
const forwarded = req.headers["x-forwarded-for"];
if (forwarded) return forwarded.split(",")[0].trim();
return req.ip ?? req.socket?.remoteAddress ?? "unknown";
}
// ─── POST /admin/media/token ──────────────────────────────────────────────────
exports.issueToken = async (req, res) => {
try {
const { asset_id } = req.body;
if (!asset_id) return R.error(res, "asset_id is required.", 400);
const asset = await mdl_Assets.findOne({
where: { asset_id, deletedAt: null },
attributes: ["asset_id", "file_type", "storage_provider", "storage_key", "mime_type", "thumbnail_storage_key"],
});
if (!asset) return R.error(res, "Asset not found.", 404);
if (!SUPPORTED_TYPES.includes(asset.file_type)) {
return R.error(res, `Asset type "${asset.file_type}" is not supported.`, 400);
}
if (asset.storage_provider !== "s3") {
return R.error(res, "Token flow is for S3 assets only. Use the raw file_url for other providers.", 400);
}
const ip = resolveIp(req);
const token = jwt.sign(
{
asset_id,
user_id: req.user.user_id,
storage_key: asset.storage_key,
file_type: asset.file_type,
mime_type: asset.mime_type,
ip,
},
MEDIA_SECRET,
{ expiresIn: TOKEN_TTL_SEC }
);
// ── Presign thumbnail URL so the browser can load it directly ─────────────
let thumbnail_url = null;
if (asset.thumbnail_storage_key) {
try {
thumbnail_url = await s3.getSignedDownloadUrl(asset.thumbnail_storage_key, TOKEN_TTL_SEC);
} catch {
// Non-fatal — thumbnail is cosmetic
}
}
return R.success(res, "Token issued.", {
token,
provider: "s3",
file_type: asset.file_type,
thumbnail_url,
});
} catch (err) {
console.error("[ADMIN][MEDIA][TOKEN]", err);
return R.error(res, "Could not issue media token.", 500);
}
};
@@ -0,0 +1,79 @@
/***********************************************************************************************************************************************************************
* File Name : notification.controller.js
* Type : Controller (Admin)
* Description : Admin notification management.
* GET /admin/notifications — paginated list, newest first
* GET /admin/notifications/unseen — unseen count only
* PATCH /admin/notifications/:id/seen — mark one as seen
* PATCH /admin/notifications/seen-all — mark all as seen
*
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jun. 19, 2026
***********************************************************************************************************************************************************************/
const AdminNotification = require('../../models/notifications/admin_notification.mdl');
const R = require('../../utils/response.util');
// ─── GET /admin/notifications ─────────────────────────────────────────────────
async function list(req, res) {
try {
const page = Math.max(1, parseInt(req.query.page) || 1);
const limit = Math.min(50, parseInt(req.query.limit) || 20);
const offset = (page - 1) * limit;
const { count, rows } = await AdminNotification.findAndCountAll({
order: [['createdAt', 'DESC']],
limit,
offset,
});
return R.success(res, 'Notifications fetched.', {
notifications: rows,
pagination: { page, limit, total: count, pages: Math.ceil(count / limit) },
});
} catch (err) {
console.error('[NOTIFICATION] list error:', err);
return R.error(res, 'Failed to fetch notifications.');
}
}
// ─── GET /admin/notifications/unseen ─────────────────────────────────────────
async function unseenCount(req, res) {
try {
const count = await AdminNotification.count({ where: { seen: false } });
return R.success(res, 'Unseen count fetched.', { count });
} catch (err) {
console.error('[NOTIFICATION] unseenCount error:', err);
return R.error(res, 'Failed to fetch unseen count.');
}
}
// ─── PATCH /admin/notifications/:id/seen ─────────────────────────────────────
async function markSeen(req, res) {
try {
const notification = await AdminNotification.findByPk(req.params.id);
if (!notification) return R.error(res, 'Notification not found.', 404);
await notification.update({ seen: true, seen_at: new Date() });
return R.success(res, 'Notification marked as seen.', notification);
} catch (err) {
console.error('[NOTIFICATION] markSeen error:', err);
return R.error(res, 'Failed to mark notification as seen.');
}
}
// ─── PATCH /admin/notifications/seen-all ─────────────────────────────────────
async function markAllSeen(req, res) {
try {
const now = new Date();
const [count] = await AdminNotification.update(
{ seen: true, seen_at: now },
{ where: { seen: false } }
);
return R.success(res, `${count} notification(s) marked as seen.`, { count });
} catch (err) {
console.error('[NOTIFICATION] markAllSeen error:', err);
return R.error(res, 'Failed to mark all notifications as seen.');
}
}
module.exports = { list, unseenCount, markSeen, markAllSeen };
+91
View File
@@ -0,0 +1,91 @@
'use strict';
const mdl_Product = require('../../models/courses/products.mdl');
const mdl_Category = require('../../models/courses/categories.mdl');
const { Course, CourseProductCategory: mdl_CourseProductCategory } = require('../../models/courses/courses.mdl');
const R = require('../../utils/response.util');
const logActivity = require('../../utils/logActivity.util');
// ─── PRODUCT (per course) ─────────────────────────────────────────────────────
exports.getCourseProduct = async (req, res) => {
try {
const product = await mdl_Product.findOne({ where: { course_id: req.params.courseId }, paranoid: false });
return R.success(res, 'Product retrieved.', product ?? null);
} catch (err) {
console.error('[ADMIN][PRODUCTS][GET]', err);
return R.error(res, 'Could not retrieve product.', 500);
}
};
exports.upsertCourseProduct = async (req, res) => {
try {
const { courseId } = req.params;
const { name, description, price, currency, access_days, is_active } = req.body;
if (!name || price == null) return R.error(res, 'name and price are required.', 400);
const existing = await mdl_Product.findOne({ where: { course_id: courseId }, paranoid: false });
if (existing) {
if (existing.deletedAt) await existing.restore();
await existing.update({ name, description: description ?? null, price, currency: currency ?? 'USD', access_days: access_days ?? null, is_active: is_active ?? true });
logActivity(req.user?.user_id, 'upsert_course_product', { entityType: 'product', details: { course_id: courseId, name } });
return R.success(res, 'Product updated.', existing);
}
const product = await mdl_Product.create({ course_id: courseId, name, description: description ?? null, price, currency: currency ?? 'USD', access_days: access_days ?? null, is_active: is_active ?? true });
logActivity(req.user?.user_id, 'upsert_course_product', { entityType: 'product', entityId: product.product_id, details: { course_id: courseId, name } });
return R.success(res, 'Product created.', product, 201);
} catch (err) {
console.error('[ADMIN][PRODUCTS][UPSERT]', err);
return R.error(res, 'Could not save product.', 500);
}
};
exports.removeCourseProduct = async (req, res) => {
try {
const product = await mdl_Product.findOne({ where: { course_id: req.params.courseId } });
if (!product) return R.error(res, 'Product not found.', 404);
await product.destroy();
logActivity(req.user?.user_id, 'remove_course_product', { entityType: 'product', details: { course_id: req.params.courseId } });
return R.success(res, 'Product removed.');
} catch (err) {
console.error('[ADMIN][PRODUCTS][REMOVE]', err);
return R.error(res, 'Could not remove product.', 500);
}
};
// ─── CATEGORIES (per course) ──────────────────────────────────────────────────
exports.getCourseCategories = async (req, res) => {
try {
const course = await Course.findByPk(req.params.courseId, {
include: [{ model: mdl_Category, as: 'categories', through: { attributes: [] } }],
});
if (!course) return R.error(res, 'Course not found.', 404);
return R.success(res, 'Course categories retrieved.', course.categories ?? []);
} catch (err) {
console.error('[ADMIN][PRODUCTS][GET CATEGORIES]', err);
return R.error(res, 'Could not retrieve course categories.', 500);
}
};
exports.syncCourseCategories = async (req, res) => {
try {
const { courseId } = req.params;
const { category_ids = [] } = req.body;
await mdl_CourseProductCategory.destroy({ where: { course_id: courseId } });
if (category_ids.length > 0) {
await mdl_CourseProductCategory.bulkCreate(
category_ids.map((id) => ({ course_id: courseId, category_id: id }))
);
}
logActivity(req.user?.user_id, 'sync_course_categories', { entityType: 'product', details: { course_id: courseId, category_ids, count: category_ids.length } });
return R.success(res, 'Course categories updated.');
} catch (err) {
console.error('[ADMIN][PRODUCTS][SYNC CATEGORIES]', err);
return R.error(res, 'Could not sync course categories.', 500);
}
};
+123
View File
@@ -0,0 +1,123 @@
/***********************************************************************************************************************************************************************
* File Name: profile.controller.js (admin)
* Type of Program: Controller
* Description: Self-service profile management for admin users.
* All routes require: authenticate → requireAdmin()
*
* Endpoints:
* GET /api/admin/profile → view own profile
* PUT /api/admin/profile → update personal_info
* POST /api/admin/profile/avatar → upload / replace avatar
* DELETE /api/admin/profile/avatar → remove avatar
*
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jun. 18, 2026
***********************************************************************************************************************************************************************/
'use strict';
const mdl_Users = require('../../models/users/users.mdl');
const R = require('../../utils/response.util');
const { uploadFile, deleteFile } = require('../../services/s3.service');
// ─── GET own profile ───────────────────────────────────────────────────────────
exports.getProfile = async (req, res) => {
try {
const user = await mdl_Users.findByPk(req.user.user_id, {
attributes: { exclude: ['password', 'otp_code', 'otp_expires_at'] },
});
return R.success(res, 'Profile retrieved.', user);
} catch (err) {
return R.error(res, 'Could not retrieve profile.', 500);
}
};
// ─── PUT update own profile ────────────────────────────────────────────────────
exports.updateProfile = async (req, res) => {
try {
const user = await mdl_Users.findByPk(req.user.user_id);
const { personal_info } = req.body;
const merged = {
...(user.personal_info || {}),
...(personal_info || {}),
name: {
...((user.personal_info?.name) || {}),
...((personal_info?.name) || {}),
},
};
await user.update({ personal_info: merged });
const updated = await mdl_Users.findByPk(req.user.user_id, {
attributes: { exclude: ['password', 'otp_code', 'otp_expires_at'] },
});
return R.success(res, 'Profile updated.', updated);
} catch (err) {
console.error('[ADMIN] updateProfile error:', err);
return R.error(res, 'Profile update failed.', 500);
}
};
// ─── POST upload own avatar ────────────────────────────────────────────────────
exports.uploadAvatar = async (req, res) => {
try {
if (!req.file) return R.error(res, 'No file provided.', 400);
const user = await mdl_Users.findByPk(req.user.user_id);
// Remove old avatar from S3 before replacing
const oldKey = user.personal_info?.avatar?.uuid;
if (oldKey) await deleteFile(oldKey).catch(() => {});
const { url, uuid } = await uploadFile({
buffer: req.file.buffer,
originalname: req.file.originalname,
mimetype: req.file.mimetype,
ownerType: 'avatar',
});
const merged = {
...(user.personal_info || {}),
avatar: {
url,
uuid,
name: req.file.originalname,
mime_type: req.file.mimetype,
size: req.file.size,
},
};
await user.update({ personal_info: merged });
const updated = await mdl_Users.findByPk(req.user.user_id, {
attributes: { exclude: ['password', 'otp_code', 'otp_expires_at'] },
});
return R.success(res, 'Avatar updated.', updated);
} catch (err) {
console.error('[ADMIN] uploadAvatar error:', err);
return R.error(res, 'Avatar upload failed.', 500);
}
};
// ─── DELETE remove own avatar ──────────────────────────────────────────────────
exports.deleteAvatar = async (req, res) => {
try {
const user = await mdl_Users.findByPk(req.user.user_id);
const key = user.personal_info?.avatar?.uuid;
if (!key) return R.error(res, 'No avatar to remove.', 404);
await deleteFile(key).catch(() => {});
const merged = { ...(user.personal_info || {}), avatar: null };
await user.update({ personal_info: merged });
return R.success(res, 'Avatar removed.');
} catch (err) {
console.error('[ADMIN] deleteAvatar error:', err);
return R.error(res, 'Could not remove avatar.', 500);
}
};
+51 -13
View File
@@ -20,6 +20,7 @@ const { filterableFields } = require('../../models/task/task.attributes');
const { getFieldValues } = require('../../utils/fieldValues.util');
const { archiveOne, archiveMany } = require("../../utils/courses/archive.util");
const { restoreOne, restoreMany } = require("../../utils/courses/restore.util");
const logActivity = require('../../utils/logActivity.util');
// ─── Allowed filter/sort fields ───────────────────────────────────────────────
const TASK_LIST_FIELDS = ['name', 'description', 'createdAt', 'updatedAt', 'deletedAt'];
@@ -118,6 +119,7 @@ exports.createTaskList = async (req, res) => {
updatedBy: req.user.user_id,
});
logActivity(req.user.user_id, 'create_task_list', { entityType: 'task_list', entityId: taskList.task_list_id, details: { name: taskList.name } });
return R.success(res, 'Task list created successfully.', taskList, 201);
} catch (err) {
console.error('[ADMIN][CREATE TASK LIST]', err);
@@ -138,6 +140,7 @@ exports.updateTaskList = async (req, res) => {
await taskList.update({ name, description, updatedBy: req.user.user_id });
logActivity(req.user.user_id, 'update_task_list', { entityType: 'task_list', entityId: taskList.task_list_id });
return R.success(res, 'Task list updated successfully.', taskList);
} catch (err) {
console.error('[ADMIN][UPDATE TASK LIST]', err);
@@ -152,12 +155,13 @@ exports.archiveTaskList = async (req, res) => {
try {
const { taskListId } = req.params;
const record = await archiveOne(TaskList, { task_list_id: taskListId },
const record = await archiveOne(TaskList, { task_list_id: taskListId },
req.user.user_id, t
);
if (!record) { await t.rollback(); return R.error(res, 'Task list not found.', 404); }
await t.commit();
logActivity(req.user.user_id, 'archive_task_list', { entityType: 'task_list', entityId: Number(taskListId) });
return R.success(res, 'Task list archived successfully.');
} catch (err) {
await t.rollback();
@@ -177,6 +181,7 @@ exports.restoreTaskList = async (req, res) => {
if (!record) { await t.rollback(); return R.error(res, 'Task list not found or not archived.', 404); }
await t.commit();
logActivity(req.user.user_id, 'restore_task_list', { entityType: 'task_list', entityId: Number(taskListId) });
return R.success(res, 'Task list restored successfully.', record);
} catch (err) {
await t.rollback();
@@ -203,6 +208,7 @@ exports.bulkArchiveTaskLists = async (req, res) => {
const count = await archiveMany(TaskList, 'task_list_id', activeIds, req.user.user_id, t);
await t.commit();
logActivity(req.user.user_id, 'bulk_archive_task_lists', { entityType: 'task_list', details: { ids: activeIds, count } });
return R.success(res, `${count} task list(s) archived successfully.`, {
archived_ids: activeIds,
skipped_ids: ids.filter((id) => !activeIds.includes(id)),
@@ -232,6 +238,7 @@ exports.bulkRestoreTaskLists = async (req, res) => {
const count = await restoreMany(TaskList, 'task_list_id', deletedIds, req.user.user_id, t);
await t.commit();
logActivity(req.user.user_id, 'bulk_restore_task_lists', { entityType: 'task_list', details: { ids: deletedIds, count } });
return R.success(res, `${count} task list(s) restored successfully.`, {
restored_ids: deletedIds,
skipped_ids: ids.filter((id) => !deletedIds.includes(id)),
@@ -330,6 +337,7 @@ exports.assignGroups = async (req, res) => {
}
await t.commit();
logActivity(req.user.user_id, 'assign_groups', { entityType: 'task_list', entityId: Number(taskListId), details: { group_ids: newIds } });
return R.success(res, `${newIds.length} group(s) assigned.`, {
assigned_ids: newIds,
already_assigned_ids: existingIds,
@@ -384,6 +392,7 @@ exports.unassignGroups = async (req, res) => {
});
await t.commit();
logActivity(req.user.user_id, 'unassign_groups', { entityType: 'task_list', entityId: Number(taskListId), details: { group_ids: existingIds } });
return R.success(res, `${existingIds.length} group(s) unassigned.`, {
unassigned_ids: existingIds,
skipped_ids: skippedIds,
@@ -437,7 +446,6 @@ exports.getTask = async (req, res) => {
{
model: TaskRequirement,
as: 'requirements',
paranoid: false,
attributes: { exclude: adminExclude },
order: [['order', 'ASC']],
},
@@ -515,6 +523,7 @@ exports.createTask = async (req, res) => {
}],
});
logActivity(req.user.user_id, 'create_task', { entityType: 'task', entityId: task.task_id, details: { name: task.name } });
return R.success(res, 'Task created successfully.', full, 201);
} catch (err) {
await t.rollback();
@@ -546,6 +555,23 @@ exports.updateTask = async (req, res) => {
{ transaction: t }
);
// ─────────────────────────────────────────────────────────────────────────────
// PATCH: exports.updateTask in task.controller.js (admin)
//
// BUG: TaskRequirement.destroy({ where: { task_id }, force: false }) is a
// SOFT delete (paranoid: true) — the row stays in the table with deletedAt
// set, still occupying its requirement_id primary key slot. The subsequent
// bulkCreate spread `...r`, which still carried the OLD requirement_id from
// the requirement object the frontend sent back (since RequirementBuilder.jsx
// initializes from the previously-fetched requirements, including their IDs).
// Inserting a new row with that same requirement_id collides with the
// soft-deleted row still sitting on that PK → SequelizeUniqueConstraintError.
//
// FIX: strip requirement_id (and any timestamp fields) from each incoming
// requirement before building reqRows, so bulkCreate always lets the model's
// defaultValue: DataTypes.UUIDV4 generate a fresh ID for the replacement set.
// ─────────────────────────────────────────────────────────────────────────────
if (Array.isArray(requirements)) {
// soft-delete existing requirements then insert fresh ones
await TaskRequirement.destroy({
@@ -555,17 +581,24 @@ exports.updateTask = async (req, res) => {
});
if (requirements.length) {
const reqRows = requirements.map((r, i) => ({
...r,
task_id: task.task_id,
order: r.order ?? i,
reference_id: r.reference_id || null, // '' → null (UUID column)
reference_label: r.reference_label || null, // '' → null
link_url: r.link_url || null,
link_label: r.link_label || null,
createdBy: req.user.user_id,
updatedBy: req.user.user_id,
}));
const reqRows = requirements.map((r, i) => {
// Strip requirement_id and timestamps — these are server-owned.
// Reusing requirement_id here would collide with the soft-deleted
// row still occupying that primary key.
const { requirement_id, createdAt, updatedAt, deletedAt, ...rest } = r;
return {
...rest,
task_id: task.task_id,
order: rest.order ?? i,
reference_id: rest.reference_id || null, // '' → null (UUID column)
reference_label: rest.reference_label || null, // '' → null
link_url: rest.link_url || null,
link_label: rest.link_label || null,
createdBy: req.user.user_id,
updatedBy: req.user.user_id,
};
});
await TaskRequirement.bulkCreate(reqRows, { transaction: t });
}
}
@@ -582,6 +615,7 @@ exports.updateTask = async (req, res) => {
}],
});
logActivity(req.user.user_id, 'update_task', { entityType: 'task', entityId: Number(taskId) });
return R.success(res, 'Task updated successfully.', full);
} catch (err) {
await t.rollback();
@@ -654,6 +688,7 @@ exports.archiveTask = async (req, res) => {
if (!record) { await t.rollback(); return R.error(res, 'Task not found.', 404); }
await t.commit();
logActivity(req.user.user_id, 'archive_task', { entityType: 'task', entityId: Number(taskId) });
return R.success(res, 'Task archived successfully.');
} catch (err) {
await t.rollback();
@@ -678,6 +713,7 @@ exports.restoreTask = async (req, res) => {
if (!record) { await t.rollback(); return R.error(res, 'Task not found or not archived.', 404); }
await t.commit();
logActivity(req.user.user_id, 'restore_task', { entityType: 'task', entityId: Number(taskId) });
return R.success(res, 'Task restored successfully.', record);
} catch (err) {
await t.rollback();
@@ -705,6 +741,7 @@ exports.bulkArchiveTasks = async (req, res) => {
const count = await archiveMany(Task, 'task_id', activeIds, req.user.user_id, t);
await t.commit();
logActivity(req.user.user_id, 'bulk_archive_tasks', { entityType: 'task', details: { ids: activeIds, count } });
return R.success(res, `${count} task(s) archived successfully.`, {
archived_ids: activeIds,
skipped_ids: ids.filter((id) => !activeIds.includes(id)),
@@ -738,6 +775,7 @@ exports.bulkRestoreTasks = async (req, res) => {
const count = await restoreMany(Task, 'task_id', deletedIds, req.user.user_id, t);
await t.commit();
logActivity(req.user.user_id, 'bulk_restore_tasks', { entityType: 'task', details: { ids: deletedIds, count } });
return R.success(res, `${count} task(s) restored successfully.`, {
restored_ids: deletedIds,
skipped_ids: ids.filter((id) => !deletedIds.includes(id)),
@@ -0,0 +1,281 @@
/***********************************************************************************************************************************************************************
* File Name: task_completion.controller.js (admin)
* Type of Program: Controller
* Description: Admin-level task completion management.
* Admins can view all completions per task, view a single completion,
* and archive/restore completions. Completions are created by clients only.
*
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jun. 13, 2026
***********************************************************************************************************************************************************************/
const { Op, Sequelize } = require('sequelize');
const sequelize = require('../../config/db.config');
const { TaskCompletion, TaskCompletionFile } = require('../../models/task/task_completion.mdl');
const { Task } = require('../../models/task/task.mdl');
const mdl_Users = require('../../models/users/users.mdl');
const { adminExclude } = require('../../models/task/task_completion.attributes');
const R = require('../../utils/response.util');
const { paginate } = require('../../utils/paginate.util');
const { archiveOne, archiveMany } = require('../../utils/courses/archive.util');
const { restoreOne, restoreMany } = require('../../utils/courses/restore.util');
const logActivity = require('../../utils/logActivity.util');
// ─── Allowed filter/sort fields ───────────────────────────────────────────────
const COMPLETION_FIELDS = ['submitted_at', 'createdAt', 'updatedAt', 'deletedAt'];
// ─── Reusable include: completion files ───────────────────────────────────────
// separate: true → Sequelize fetches files in a second SELECT ... WHERE completion_id IN (...)
// instead of a JOIN, which avoids the subquery alias conflict that occurs when
// paginate applies LIMIT/OFFSET alongside a hasMany include.
const FILES_INCLUDE = {
model: TaskCompletionFile,
as: 'files',
attributes: { exclude: adminExclude },
paranoid: false,
separate: true,
order: [['createdAt', 'ASC']],
};
// ─── Reusable include: submitting user ────────────────────────────────────────
const USER_INCLUDE = {
model: mdl_Users,
as: 'user',
attributes: [
'user_id',
'email', // ← direct column, fine as-is
[
Sequelize.literal(`("user"."personal_info"->'name'->>'full_name')`),
'name',
],
],
};
// =============================================================================
// ── COMPLETIONS (nested under task-list → task) ───────────────────────────────
// =============================================================================
// ─── GET ALL ──────────────────────────────────────────────────────────────────
// GET /admin/task-lists/:taskListId/tasks/:taskId/completions
exports.getCompletions = async (req, res) => {
try {
const { taskListId, taskId } = req.params;
const task = await Task.findOne({ where: { task_id: taskId, task_list_id: taskListId } });
if (!task) return R.error(res, 'Task not found.', 404);
const result = await paginate(TaskCompletion, req, {
excludeAttributes: adminExclude,
jsonbSchemas: {},
computedAttributes: [],
auditOptions: { mdl_Users, parentAlias: 'TaskCompletion' },
allowedFields: COMPLETION_FIELDS,
findOptions: {
where: { task_id: taskId },
include: [USER_INCLUDE, FILES_INCLUDE],
},
});
return R.success(res, 'Completions retrieved.', result);
} catch (err) {
console.error('[ADMIN][GET ALL COMPLETIONS]', err);
return R.error(res, 'Could not retrieve completions.', 500);
}
};
// ─── GET ONE ──────────────────────────────────────────────────────────────────
// GET /admin/task-lists/:taskListId/tasks/:taskId/completions/:completionId
exports.getCompletion = async (req, res) => {
try {
const { taskListId, taskId, completionId } = req.params;
const task = await Task.findOne({ where: { task_id: taskId, task_list_id: taskListId } });
if (!task) return R.error(res, 'Task not found.', 404);
const completion = await TaskCompletion.findOne({
where: { completion_id: completionId, task_id: taskId },
attributes: { exclude: adminExclude },
paranoid: false,
include: [USER_INCLUDE, FILES_INCLUDE],
});
if (!completion) return R.error(res, 'Completion not found.', 404);
return R.success(res, 'Completion retrieved.', completion);
} catch (err) {
console.error('[ADMIN][GET COMPLETION]', err);
return R.error(res, 'Could not retrieve completion.', 500);
}
};
// ─── GET ALL BY USER ──────────────────────────────────────────────────────────
// GET /admin/task-lists/:taskListId/tasks/:taskId/completions/user/:userId
exports.getCompletionsByUser = async (req, res) => {
try {
const { taskListId, taskId, userId } = req.params;
const task = await Task.findOne({ where: { task_id: taskId, task_list_id: taskListId } });
if (!task) return R.error(res, 'Task not found.', 404);
const result = await paginate(TaskCompletion, req, {
excludeAttributes: adminExclude,
jsonbSchemas: {},
computedAttributes: [],
auditOptions: { mdl_Users, parentAlias: 'TaskCompletion' },
allowedFields: COMPLETION_FIELDS,
findOptions: {
where: { task_id: taskId, user_id: userId },
include: [FILES_INCLUDE],
},
});
return R.success(res, 'User completions retrieved.', result);
} catch (err) {
console.error('[ADMIN][GET COMPLETIONS BY USER]', err);
return R.error(res, 'Could not retrieve user completions.', 500);
}
};
// ─── ARCHIVE ──────────────────────────────────────────────────────────────────
// DELETE /admin/task-lists/:taskListId/tasks/:taskId/completions/:completionId
exports.archiveCompletion = async (req, res) => {
const t = await sequelize.transaction();
try {
const { taskListId, taskId, completionId } = req.params;
const task = await Task.findOne({ where: { task_id: taskId, task_list_id: taskListId }, transaction: t });
if (!task) { await t.rollback(); return R.error(res, 'Task not found.', 404); }
const record = await archiveOne(
TaskCompletion,
{ completion_id: completionId, task_id: taskId },
req.user.user_id,
t
);
if (!record) { await t.rollback(); return R.error(res, 'Completion not found.', 404); }
await t.commit();
logActivity(req.user.user_id, 'archive_completion', { entityType: 'task_completion', entityId: Number(completionId), details: { task_id: taskId } });
return R.success(res, 'Completion archived successfully.');
} catch (err) {
await t.rollback();
console.error('[ADMIN][ARCHIVE COMPLETION]', err);
return R.error(res, 'Could not archive completion.', 500);
}
};
// ─── RESTORE ──────────────────────────────────────────────────────────────────
// PATCH /admin/task-lists/:taskListId/tasks/:taskId/completions/:completionId/restore
exports.restoreCompletion = async (req, res) => {
const t = await sequelize.transaction();
try {
const { taskListId, taskId, completionId } = req.params;
const record = await restoreOne(
TaskCompletion,
{ completion_id: completionId, task_id: taskId, deletedAt: { [Op.not]: null } },
req.user.user_id,
t
);
if (!record) { await t.rollback(); return R.error(res, 'Completion not found or not archived.', 404); }
await t.commit();
logActivity(req.user.user_id, 'restore_completion', { entityType: 'task_completion', entityId: Number(completionId), details: { task_id: taskId } });
return R.success(res, 'Completion restored successfully.', record);
} catch (err) {
await t.rollback();
console.error('[ADMIN][RESTORE COMPLETION]', err);
return R.error(res, 'Could not restore completion.', 500);
}
};
// ─── BULK ARCHIVE ─────────────────────────────────────────────────────────────
// POST /admin/task-lists/:taskListId/tasks/:taskId/completions/bulk-archive
exports.bulkArchiveCompletions = async (req, res) => {
const t = await sequelize.transaction();
try {
const { taskListId, taskId } = req.params;
const { ids } = req.body;
if (!Array.isArray(ids) || !ids.length)
return R.error(res, 'No completion IDs provided.', 400);
const task = await Task.findOne({ where: { task_id: taskId, task_list_id: taskListId } });
if (!task) { await t.rollback(); return R.error(res, 'Task not found.', 404); }
const completions = await TaskCompletion.findAll({
where: { completion_id: ids, task_id: taskId },
});
if (!completions.length) return R.error(res, 'No completions found.', 404);
const activeIds = completions
.filter((c) => !c.deletedAt)
.map((c) => c.completion_id);
if (!activeIds.length)
return R.error(res, 'All selected completions are already archived.', 400);
const count = await archiveMany(TaskCompletion, 'completion_id', activeIds, req.user.user_id, t);
await t.commit();
logActivity(req.user.user_id, 'bulk_archive_completions', { entityType: 'task_completion', details: { ids: activeIds, count, task_id: taskId } });
return R.success(res, `${count} completion(s) archived successfully.`, {
archived_ids: activeIds,
skipped_ids: ids.filter((id) => !activeIds.includes(id)),
});
} catch (err) {
await t.rollback();
console.error('[ADMIN][BULK ARCHIVE COMPLETIONS]', err);
return R.error(res, 'Could not archive completions.', 500);
}
};
// ─── BULK RESTORE ─────────────────────────────────────────────────────────────
// POST /admin/task-lists/:taskListId/tasks/:taskId/completions/bulk-restore
exports.bulkRestoreCompletions = async (req, res) => {
const t = await sequelize.transaction();
try {
const { taskListId, taskId } = req.params;
const { ids } = req.body;
if (!Array.isArray(ids) || !ids.length)
return R.error(res, 'No completion IDs provided.', 400);
const task = await Task.findOne({ where: { task_id: taskId, task_list_id: taskListId } });
if (!task) { await t.rollback(); return R.error(res, 'Task not found.', 404); }
const completions = await TaskCompletion.findAll({
where: { completion_id: ids, task_id: taskId },
paranoid: false,
});
if (!completions.length) return R.error(res, 'No completions found.', 404);
const deletedIds = completions
.filter((c) => c.deletedAt)
.map((c) => c.completion_id);
if (!deletedIds.length)
return R.error(res, 'All selected completions are already active.', 400);
const count = await restoreMany(TaskCompletion, 'completion_id', deletedIds, req.user.user_id, t);
await t.commit();
logActivity(req.user.user_id, 'bulk_restore_completions', { entityType: 'task_completion', details: { ids: deletedIds, count, task_id: taskId } });
return R.success(res, `${count} completion(s) restored successfully.`, {
restored_ids: deletedIds,
skipped_ids: ids.filter((id) => !deletedIds.includes(id)),
});
} catch (err) {
await t.rollback();
console.error('[ADMIN][BULK RESTORE COMPLETIONS]', err);
return R.error(res, 'Could not restore completions.', 500);
}
};
+400
View File
@@ -0,0 +1,400 @@
/***********************************************************************************************************************************************************************
* File Name: tiers.controller.js (admin)
* Type of Program: Controller
* Description: Admin-level tier and plan management.
* - CRUD + archive/restore for tier_plans
* - View/grant/revoke user tiers
* - Paginated payments list
* Author: rgrgogu
* Date Created: Jun. 6, 2026
***********************************************************************************************************************************************************************/
const { Op } = require('sequelize');
const mdl_TierPlans = require('../../models/tiers/tier_plans.mdl');
const mdl_UserTiers = require('../../models/tiers/user_tiers.mdl');
const mdl_Payments = require('../../models/tiers/payments.mdl');
const mdl_Users = require('../../models/users/users.mdl');
const mdl_PlanCourses = require('../../models/tiers/plan_courses.mdl');
const { Course } = require('../../models/courses/courses.mdl');
require('../../models/tiers/tier.associations');
const R = require('../../utils/response.util');
const { paginate } = require('../../utils/paginate.util');
const { getFieldValues } = require('../../utils/fieldValues.util');
const logActivity = require('../../utils/logActivity.util');
const {
excludeAttributes: plansExclude,
jsonbSchemas: plansSchemas,
computedAttributes: plansComputed,
} = require('../../models/tiers/tier_plans.attributes');
const {
excludeAttributes: paymentsExclude,
jsonbSchemas: paymentsSchemas,
computedAttributes: paymentsComputed,
} = require('../../models/tiers/payments.attributes');
const PENDING_PAYMENT_EXPIRY_MINUTES = 60;
const expireStalePendingPayments = async () => {
const expiresBefore = new Date(Date.now() - PENDING_PAYMENT_EXPIRY_MINUTES * 60 * 1000);
await mdl_Payments.update(
{ status: 'expired' },
{ where: { status: 'pending', createdAt: { [Op.lt]: expiresBefore } } }
);
};
// ─── PLANS ────────────────────────────────────────────────────────────────────
exports.getPlans = async (req, res) => {
try {
const archived = req.query.archived === 'true';
const result = await paginate(mdl_TierPlans, req, {
excludeAttributes: plansExclude,
jsonbSchemas: plansSchemas,
computedAttributes: plansComputed,
context: archived ? 'archived' : 'list',
findOptions: archived ? {
paranoid: false,
where: { deletedAt: { [Op.ne]: null } },
} : {},
});
return R.success(res, 'Plans retrieved.', result);
} catch (err) {
console.error('[ADMIN][GET PLANS]', err);
return R.error(res, 'Could not retrieve plans.', 500);
}
};
exports.getPlan = async (req, res) => {
try {
const plan = await mdl_TierPlans.findByPk(req.params.id);
if (!plan) return R.error(res, 'Plan not found.', 404);
return R.success(res, 'Plan retrieved.', plan);
} catch (err) {
console.error('[ADMIN][GET PLAN]', err);
return R.error(res, 'Could not retrieve plan.', 500);
}
};
exports.createPlan = async (req, res) => {
try {
const { tier, label, duration_days, price, currency } = req.body;
if (!tier || !label || !duration_days || !price)
return R.error(res, 'tier, label, duration_days, and price are required.', 400);
const plan = await mdl_TierPlans.create({ tier, label, duration_days, price, currency });
const plain = plan.get({ plain: true });
logActivity(req.user?.user_id, 'create_tier_plan', { entityType: 'tier_plan', details: { label: plain.label, tier: plain.tier } });
// Serialize plan_id as string — CockroachDB BigInts exceed JS Number.MAX_SAFE_INTEGER
return R.success(res, 'Plan created.', { ...plain, plan_id: String(plain.plan_id) }, 201);
} catch (err) {
console.error('[ADMIN][CREATE PLAN]', err);
return R.error(res, 'Could not create plan.', 500);
}
};
exports.updatePlan = async (req, res) => {
try {
const plan = await mdl_TierPlans.findByPk(req.params.id);
if (!plan) return R.error(res, 'Plan not found.', 404);
const allowed = ['label', 'duration_days', 'price', 'currency', 'is_active'];
const updates = {};
allowed.forEach((k) => { if (req.body[k] !== undefined) updates[k] = req.body[k]; });
await plan.update(updates);
logActivity(req.user?.user_id, 'update_tier_plan', { entityType: 'tier_plan', details: { label: plan.label } });
return R.success(res, 'Plan updated.', plan);
} catch (err) {
console.error('[ADMIN][UPDATE PLAN]', err);
return R.error(res, 'Could not update plan.', 500);
}
};
exports.archivePlan = async (req, res) => {
try {
const plan = await mdl_TierPlans.findByPk(req.params.id);
if (!plan) return R.error(res, 'Plan not found.', 404);
if (plan.deletedAt) return R.error(res, 'Plan is already archived.', 400);
await plan.update({ is_active: false });
await plan.destroy();
logActivity(req.user?.user_id, 'archive_tier_plan', { entityType: 'tier_plan', details: { label: plan.label } });
return R.success(res, 'Plan archived successfully.');
} catch (err) {
console.error('[ADMIN][ARCHIVE PLAN]', err);
return R.error(res, 'Could not archive plan.', 500);
}
};
exports.bulkArchivePlans = async (req, res) => {
try {
const { ids } = req.body;
if (!Array.isArray(ids) || !ids.length)
return R.error(res, 'No plan IDs provided.', 400);
const plans = await mdl_TierPlans.findAll({ where: { plan_id: ids } });
if (!plans.length) return R.error(res, 'No plans found.', 404);
const activePlans = plans.filter((p) => !p.deletedAt);
if (!activePlans.length)
return R.error(res, 'All selected plans are already archived.', 400);
const activeIds = activePlans.map((p) => p.plan_id);
await mdl_TierPlans.update({ is_active: false }, { where: { plan_id: activeIds } });
await mdl_TierPlans.destroy({ where: { plan_id: activeIds } });
logActivity(req.user?.user_id, 'bulk_archive_tier_plans', { entityType: 'tier_plan', details: { ids: activeIds, count: activeIds.length } });
return R.success(res, `${activeIds.length} plan(s) archived successfully.`, {
archived_ids: activeIds,
skipped_ids: ids.filter((id) => !activeIds.includes(id)),
});
} catch (err) {
console.error('[ADMIN][BULK ARCHIVE PLANS]', err);
return R.error(res, 'Could not archive plans.', 500);
}
};
exports.restorePlan = async (req, res) => {
try {
const plan = await mdl_TierPlans.findOne({
where: { plan_id: req.params.id }, paranoid: false,
});
if (!plan) return R.error(res, 'Plan not found.', 404);
if (!plan.deletedAt) return R.error(res, 'Plan is not archived.', 400);
await plan.restore();
await plan.update({ is_active: true });
logActivity(req.user?.user_id, 'restore_tier_plan', { entityType: 'tier_plan', details: { label: plan.label } });
return R.success(res, 'Plan restored successfully.');
} catch (err) {
console.error('[ADMIN][RESTORE PLAN]', err);
return R.error(res, 'Could not restore plan.', 500);
}
};
exports.bulkRestorePlans = async (req, res) => {
try {
const { ids } = req.body;
if (!Array.isArray(ids) || !ids.length)
return R.error(res, 'No plan IDs provided.', 400);
const plans = await mdl_TierPlans.findAll({ where: { plan_id: ids }, paranoid: false });
if (!plans.length) return R.error(res, 'No plans found.', 404);
const archivedPlans = plans.filter((p) => p.deletedAt);
if (!archivedPlans.length)
return R.error(res, 'All selected plans are already active.', 400);
const archivedIds = archivedPlans.map((p) => p.plan_id);
await mdl_TierPlans.restore({ where: { plan_id: archivedIds } });
await mdl_TierPlans.update({ is_active: true }, { where: { plan_id: archivedIds }, paranoid: false });
logActivity(req.user?.user_id, 'bulk_restore_tier_plans', { entityType: 'tier_plan', details: { ids: archivedIds, count: archivedIds.length } });
return R.success(res, `${archivedIds.length} plan(s) restored successfully.`, {
restored_ids: archivedIds,
skipped_ids: ids.filter((id) => !archivedIds.includes(id)),
});
} catch (err) {
console.error('[ADMIN][BULK RESTORE PLANS]', err);
return R.error(res, 'Could not restore plans.', 500);
}
};
exports.getPlanFieldValues = getFieldValues(mdl_TierPlans, 'TIER_PLAN', {
blockedFields: [],
});
// ─── USER TIERS ───────────────────────────────────────────────────────────────
exports.getUserTiers = async (req, res) => {
try {
const tiers = await mdl_UserTiers.findAll({
where: { user_id: req.params.id },
include: [
{ model: mdl_Users, as: 'grantedByUser', attributes: ['user_id', 'email'] },
{ model: mdl_Users, as: 'revokedByUser', attributes: ['user_id', 'email'] },
],
order: [['createdAt', 'DESC']],
});
return R.success(res, 'User tiers retrieved.', tiers);
} catch (err) {
console.error('[ADMIN][GET USER TIERS]', err);
return R.error(res, 'Could not retrieve user tiers.', 500);
}
};
exports.grantTier = async (req, res) => {
try {
const { user_id, tier, plan_id, notes } = req.body;
if (!user_id || !tier || !plan_id)
return R.error(res, 'user_id, tier, and plan_id are required.', 400);
const user = await mdl_Users.findByPk(user_id);
if (!user) return R.error(res, 'User not found.', 404);
const plan = await mdl_TierPlans.findByPk(plan_id);
if (!plan || !plan.is_active) return R.error(res, 'Plan not found or inactive.', 404);
if (plan.tier !== tier) return R.error(res, 'Plan tier mismatch.', 400);
await mdl_UserTiers.update(
{ status: 'expired' },
{ where: { user_id, status: 'active' } }
);
const startsAt = new Date();
const expiresAt = new Date(startsAt.getTime() + plan.duration_days * 86400000);
const newTier = await mdl_UserTiers.create({
user_id, tier, status: 'active',
starts_at: startsAt,
expires_at: expiresAt,
granted_by: req.user.user_id,
notes,
});
logActivity(req.user.user_id, 'grant_tier', { entityType: 'tier', details: { user_id, tier, plan_id } });
return R.success(res, 'Tier granted.', newTier, 201);
} catch (err) {
console.error('[ADMIN][GRANT TIER]', err);
return R.error(res, 'Could not grant tier.', 500);
}
};
exports.revokeTier = async (req, res) => {
try {
const tierRecord = await mdl_UserTiers.findByPk(req.params.tid);
if (!tierRecord) return R.error(res, 'Tier record not found.', 404);
if (tierRecord.status !== 'active') return R.error(res, 'Tier is not active.', 400);
await tierRecord.update({
status: 'revoked',
revoked_by: req.user.user_id,
revoked_at: new Date(),
});
await mdl_UserTiers.create({
user_id: tierRecord.user_id,
tier: 'free',
status: 'active',
starts_at: new Date(),
expires_at: null,
granted_by: req.user.user_id,
notes: 'Auto-downgrade after revoke.',
});
logActivity(req.user.user_id, 'revoke_tier', { entityType: 'tier', details: { user_id: tierRecord.user_id, tier: tierRecord.tier } });
return R.success(res, 'Tier revoked. User downgraded to free.');
} catch (err) {
console.error('[ADMIN][REVOKE TIER]', err);
return R.error(res, 'Could not revoke tier.', 500);
}
};
// ─── PAYMENTS ─────────────────────────────────────────────────────────────────
exports.getPayments = async (req, res) => {
try {
await expireStalePendingPayments();
const result = await paginate(mdl_Payments, req, {
excludeAttributes: paymentsExclude,
jsonbSchemas: paymentsSchemas,
computedAttributes: paymentsComputed,
context: 'list',
findOptions: {
include: [
{ model: mdl_Users, as: 'user', attributes: ['user_id', 'email'] },
{ model: mdl_TierPlans, as: 'plan', attributes: ['plan_id', 'label', 'tier', 'duration_days'] },
],
},
});
return R.success(res, 'Payments retrieved.', result);
} catch (err) {
console.error('[ADMIN][GET PAYMENTS]', err);
return R.error(res, 'Could not retrieve payments.', 500);
}
};
exports.getPayment = async (req, res) => {
try {
await expireStalePendingPayments();
const payment = await mdl_Payments.findByPk(req.params.id, {
include: [
{ model: mdl_Users, as: 'user', attributes: ['user_id', 'email'] },
{ model: mdl_TierPlans, as: 'plan' },
{ model: mdl_UserTiers, as: 'tier' },
],
});
if (!payment) return R.error(res, 'Payment not found.', 404);
return R.success(res, 'Payment retrieved.', payment);
} catch (err) {
console.error('[ADMIN][GET PAYMENT]', err);
return R.error(res, 'Could not retrieve payment.', 500);
}
};
exports.getPaymentFieldValues = getFieldValues(mdl_Payments, 'PAYMENT', {
blockedFields: ['provider_payload'],
});
// ─── PLAN COURSES ─────────────────────────────────────────────────────────────
exports.getPlanCourses = async (req, res) => {
try {
const entries = await mdl_PlanCourses.findAll({
where: { plan_id: req.params.id },
include: [{
model: Course,
as: 'course',
attributes: ['course_id', 'title', 'course_code', 'subscription', 'level'],
}],
order: [['createdAt', 'ASC']],
});
return R.success(res, 'Plan courses retrieved.', entries.map(e => e.course));
} catch (err) {
console.error('[ADMIN][GET PLAN COURSES]', err);
return R.error(res, 'Could not retrieve plan courses.', 500);
}
};
exports.syncPlanCourses = async (req, res) => {
try {
const { id } = req.params;
const { course_ids = [] } = req.body;
if (!Array.isArray(course_ids))
return R.error(res, 'course_ids must be an array.', 400);
const plan = await mdl_TierPlans.findByPk(id);
if (!plan) return R.error(res, 'Plan not found.', 404);
// Remove all courses from this plan
await mdl_PlanCourses.destroy({ where: { plan_id: id } });
if (course_ids.length) {
// course_id has a UNIQUE constraint — clear any orphaned/other-plan assignments
// for these courses before inserting, so the insert isn't silently skipped
await mdl_PlanCourses.destroy({ where: { course_id: course_ids } });
await mdl_PlanCourses.bulkCreate(
course_ids.map(course_id => ({ plan_id: id, course_id })),
);
}
logActivity(req.user?.user_id, 'sync_plan_courses', { entityType: 'tier_plan', details: { plan_id: id, course_ids, count: course_ids.length } });
return R.success(res, 'Plan courses updated.');
} catch (err) {
console.error('[ADMIN][SYNC PLAN COURSES]', err);
return R.error(res, 'Could not update plan courses.', 500);
}
};
@@ -0,0 +1,128 @@
/***********************************************************************************************************************************************************************
* File Name: user_activity.controller.js (admin)
* Type of Program: Controller
* Description: Admin-only view of the user_activity log.
*
* GET /api/admin/activity → global paginated activity feed (all users)
* GET /api/admin/users/:id/activity → paginated activity for a single user
*
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jun. 21, 2026
***********************************************************************************************************************************************************************/
const { Op } = require('sequelize');
const mdl_UserActivity = require('../../models/users/user_activity.mdl');
const mdl_Users = require('../../models/users/users.mdl');
const R = require('../../utils/response.util');
const USER_ATTRS = [
'user_id', 'email', 'acc_type',
// full_name from JSONB — resolved in the association below
];
// ─── Shared query builder ─────────────────────────────────────────────────────
function buildWhere(query, extraWhere = {}) {
const where = { ...extraWhere };
if (query.action)
where.action = query.action;
if (query.from || query.to) {
where.created_at = {};
if (query.from) where.created_at[Op.gte] = new Date(query.from);
if (query.to) where.created_at[Op.lte] = new Date(query.to);
}
return where;
}
// ─── GET GLOBAL ACTIVITY FEED ─────────────────────────────────────────────────
exports.getActivity = async (req, res) => {
try {
const page = Math.max(1, parseInt(req.query.page) || 1);
const limit = Math.min(100, parseInt(req.query.limit) || 20);
const offset = (page - 1) * limit;
const where = buildWhere(req.query);
const { count, rows } = await mdl_UserActivity.findAndCountAll({
where,
include: [{
model: mdl_Users,
as: 'user',
attributes: ['user_id', 'email', 'acc_type', 'personal_info'],
}],
order: [['created_at', 'DESC']],
limit,
offset,
});
const data = rows.map(formatRow);
return R.success(res, 'Activity feed retrieved.', {
total: count,
page,
totalPages: Math.ceil(count / limit),
activities: data,
});
} catch (err) {
console.error('[ADMIN][GET ACTIVITY FEED]', err);
return R.error(res, 'Could not retrieve activity feed.', 500);
}
};
// ─── GET PER-USER ACTIVITY ────────────────────────────────────────────────────
exports.getUserActivity = async (req, res) => {
try {
const { id } = req.params;
if (!id || id === 'undefined') return R.error(res, 'Invalid User ID.', 400);
const user = await mdl_Users.findByPk(id, { attributes: ['user_id'] });
if (!user) return R.error(res, 'User not found.', 404);
const page = Math.max(1, parseInt(req.query.page) || 1);
const limit = Math.min(100, parseInt(req.query.limit) || 20);
const offset = (page - 1) * limit;
const where = buildWhere(req.query, { user_id: id });
const { count, rows } = await mdl_UserActivity.findAndCountAll({
where,
order: [['created_at', 'DESC']],
limit,
offset,
});
return R.success(res, 'User activity retrieved.', {
total: count,
page,
totalPages: Math.ceil(count / limit),
activities: rows,
});
} catch (err) {
console.error('[ADMIN][GET USER ACTIVITY]', err);
return R.error(res, 'Could not retrieve user activity.', 500);
}
};
// ─── Helpers ──────────────────────────────────────────────────────────────────
function formatRow(row) {
const r = row.toJSON();
const info = r.user?.personal_info;
return {
activity_id: r.activity_id,
user_id: r.user_id,
email: r.user?.email ?? null,
full_name: info?.name?.full_name ?? null,
avatar_url: info?.avatar?.url ?? null,
acc_type: r.user?.acc_type ?? null,
action: r.action,
entity_type: r.entity_type,
entity_id: r.entity_id,
details: r.details,
created_at: r.created_at,
};
}
@@ -23,6 +23,7 @@ const { getFieldValues } = require('../../utils/fieldValues.util');
const { excludeAttributes: usersExclude, jsonbSchemas: usersSchemas } = require('../../models/users/users.attributes');
const { excludeAttributes: groupExclude, jsonbSchemas: groupSchemas, computedAttributes: groupComputed } = require('../../models/users/user_groups.attributes');
const logActivity = require('../../utils/logActivity.util');
// ─── Helper — generate a unique group code ────────────────────────────────────
/**
@@ -110,6 +111,7 @@ exports.createGroup = async (req, res) => {
createdBy: req.user.user_id,
});
logActivity(req.user.user_id, 'create_group', { entityType: 'group', entityId: group.group_id, details: { name: group.name, group_code: group.group_code } });
return R.success(res, 'Group created.', group, 201);
} catch (err) {
console.error('[ADMIN][CREATE GROUP]', err);
@@ -141,6 +143,7 @@ exports.updateGroup = async (req, res) => {
group.updatedBy = req.user.user_id;
await group.save();
logActivity(req.user.user_id, 'update_group', { entityType: 'group', entityId: group.group_id });
return R.success(res, 'Group updated.', group);
} catch (err) {
console.error('[ADMIN][UPDATE GROUP]', err);
@@ -158,6 +161,7 @@ exports.deactivateGroup = async (req, res) => {
await group.update({ is_active: false, updatedBy: req.user.user_id, deletedBy: req.user.user_id });
await group.destroy();
logActivity(req.user.user_id, 'deactivate_group', { entityType: 'group', entityId: group.group_id });
return R.success(res, 'Group deactivated.');
} catch (err) {
console.error('[ADMIN][DEACTIVATE GROUP]', err);
@@ -175,6 +179,7 @@ exports.restoreGroup = async (req, res) => {
await group.restore();
await group.update({ is_active: true, updatedBy: req.user.user_id, deletedBy: null });
logActivity(req.user.user_id, 'restore_group', { entityType: 'group', entityId: group.group_id });
return R.success(res, 'Group restored.');
} catch (err) {
console.error('[ADMIN][RESTORE GROUP]', err);
@@ -204,6 +209,7 @@ exports.bulkDeactivateGroups = async (req, res) => {
);
await mdl_UserGroups.destroy({ where: { group_id: activeIds } });
logActivity(req.user.user_id, 'bulk_deactivate_groups', { entityType: 'group', details: { ids: activeIds, count: activeIds.length } });
return R.success(res, `${activeIds.length} group(s) deactivated successfully.`, {
deactivated_ids: activeIds,
skipped_ids: ids.filter((id) => !activeIds.includes(id)),
@@ -236,6 +242,7 @@ exports.bulkRestoreGroups = async (req, res) => {
{ where: { group_id: deletedIds }, paranoid: false }
);
logActivity(req.user.user_id, 'bulk_restore_groups', { entityType: 'group', details: { ids: deletedIds, count: deletedIds.length } });
return R.success(res, `${deletedIds.length} group(s) restored successfully.`, {
restored_ids: deletedIds,
skipped_ids: ids.filter((id) => !deletedIds.includes(id)),
@@ -346,6 +353,7 @@ exports.addUserToGroup = async (req, res) => {
{ ignoreDuplicates: true }
);
logActivity(req.user.user_id, 'add_user_to_group', { entityType: 'group', entityId: Number(group_id), details: { user_ids } });
return R.success(res, 'Users added to group.');
} catch (err) {
console.error('[ADMIN][ADD USERS TO GROUP]', err);
@@ -376,6 +384,7 @@ exports.removeUserFromGroup = async (req, res) => {
);
await mdl_UserGroupMembers.destroy({ where: { user_id: user_ids, group_id } });
logActivity(req.user.user_id, 'remove_user_from_group', { entityType: 'group', entityId: Number(group_id), details: { user_ids } });
return R.success(res, 'Users removed from group.');
} catch (err) {
console.error('[ADMIN][REMOVE USERS FROM GROUP]', err);
+34 -1
View File
@@ -13,9 +13,11 @@ const crypto = require('crypto');
const mdl_Users = require('../../models/users/users.mdl');
const mdl_UserSessions = require('../../models/users/user_sessions.mdl');
const mdl_Achievements = require('../../models/users/achievements.mdl');
const { mdl_UserGroups, mdl_UserGroupMembers } = require('../../models/users/user_groups.mdl');
const sendEmail = require('../../services/email.service');
const { sendEmail } = require('../../services/email.service');
const logActivity = require('../../utils/logActivity.util');
const R = require('../../utils/response.util');
const { paginate } = require('../../utils/paginate.util');
const { enrichPersonalInfo } = require('../../utils/personalInfo.util');
@@ -119,6 +121,8 @@ exports.addStaffUser = async (req, res) => {
},
});
logActivity(req.user.user_id, 'create_staff', { entityType: 'user', entityId: user.user_id, details: { email } });
return R.success(res, 'Staff user created successfully.', {
user_id: user.user_id,
email: user.email,
@@ -150,6 +154,9 @@ exports.updateUser = async (req, res) => {
updates.updatedBy = req.user.user_id;
await user.update(updates);
logActivity(req.user.user_id, 'update_user', { entityType: 'user', entityId: Number(req.params.id) });
const updated = await mdl_Users.findByPk(req.params.id, { attributes: { exclude: EXCLUDED } });
return R.success(res, 'User updated.', updated);
} catch (err) {
@@ -178,6 +185,8 @@ exports.deactivateUser = async (req, res) => {
{ where: { user_id: req.params.id } }
);
logActivity(req.user.user_id, 'deactivate_user', { entityType: 'user', entityId: Number(req.params.id) });
return R.success(res, 'User deactivated successfully.');
} catch (err) {
console.error('[ADMIN][DEACTIVATE USER]', err);
@@ -238,6 +247,8 @@ exports.restoreUser = async (req, res) => {
await user.restore();
await user.update({ is_active: true, updatedBy: req.user.user_id, deletedBy: null });
logActivity(req.user.user_id, 'restore_user', { entityType: 'user', entityId: Number(req.params.id) });
return R.success(res, 'User restored successfully.');
} catch (err) {
console.error('[ADMIN][RESTORE USER]', err);
@@ -304,6 +315,8 @@ exports.terminateSession = async (req, res) => {
logout_info: { date: new Date().toISOString(), forced_by: req.user.user_id },
});
logActivity(req.user.user_id, 'terminate_session', { entityType: 'session', entityId: session.session_id });
return R.success(res, 'Session terminated.');
} catch (err) {
console.error('[ADMIN][TERMINATE SESSION]', err);
@@ -350,4 +363,24 @@ exports.getArchivedUsers = async (req, res) => {
console.error('[ADMIN][GET ARCHIVED USERS]', err);
return R.error(res, 'Could not retrieve archived users.', 500);
}
};
// ─── GET USER ACHIEVEMENTS ────────────────────────────────────────────────────
exports.getUserAchievements = async (req, res) => {
try {
const { id } = req.params;
const user = await mdl_Users.findByPk(id, { attributes: ['user_id'] });
if (!user) return R.error(res, 'User not found.', 404);
const achievements = await mdl_Achievements.findAll({
where: { user_id: id },
order: [['granted_at', 'DESC']],
});
return R.success(res, 'Achievements retrieved.', achievements);
} catch (err) {
console.error('[ADMIN][GET USER ACHIEVEMENTS]', err);
return R.error(res, 'Could not retrieve achievements.', 500);
}
};
+149 -30
View File
@@ -30,19 +30,19 @@ const mdl_Users = require('../models/users/users.mdl');
const mdl_UserSessions = require('../models/users/user_sessions.mdl');
const { mdl_UserGroups, mdl_UserGroupMembers } = require('../models/users/user_groups.mdl')
const { generateTokens, verifyRefreshToken, hashToken, shouldRotateRefreshToken } = require('../utils/token.util');
const { generateState, generateNonce, generatePKCE, buildAuthUrl, exchangeCode, verifyIdToken } = require('../utils/google_oidc.util');
const { generateOTP, getOTPExpiry, isOTPExpired } = require('../utils/otp.util');
const sendEmail = require('../services/email.service');
const { onUserRegistered } = require('../services/achievements.service');
const AdminNotification = require('../models/notifications/admin_notification.mdl');
const UserNotification = require('../models/notifications/user_notification.mdl');
const { NOTIFICATION_REGISTRY } = require('../data/notifications.data');
const { sendEmail } = require('../services/email.service');
const buildSessionInfo = require('../utils/session_info.util');
const logActivity = require('../utils/logActivity.util');
const R = require('../utils/response.util');
const EXCLUDED = ['password', 'otp_code', 'otp_expires_at', 'must_change_password', 'password_expires_at', 'createdAt', 'updatedAt', 'deletedAt', 'createdBy', 'updatedBy', 'deletedBy'];
// ─── Helpers ───────────────────────────────────────────────────────────────────
const buildLoginInfo = (req) => ({
date: new Date().toISOString(),
ip_address: req.ip,
device_info: req.headers['user-agent'] || 'unknown',
});
const safeUser = (user, extraExclude = []) => {
const u = user.toJSON ? user.toJSON() : { ...user };
@@ -100,7 +100,18 @@ exports.register = async (req, res) => {
await sendEmail({ to: email, type: 'OTP', data: { otp } });
await transaction.commit();
// Fire-and-forget: notify admins about the new group registration
if (group) {
AdminNotification.create({
...NOTIFICATION_REGISTRY.user_registration.build({
groupName: group.name,
groupCode: group.group_code,
userEmail: email,
}),
}).catch(err => console.error('[AUTH] Failed to emit user_registration notification:', err));
}
return R.success(res, 'Registration successful. Please check your email for the OTP.', {
email: user.email,
}, 201);
@@ -131,15 +142,38 @@ exports.verifyOTP = async (req, res) => {
const { accessToken, refreshToken } = generateTokens(user);
const session = await mdl_UserSessions.create({
user_id: user.user_id,
login_info: buildLoginInfo(req),
login_info: await buildSessionInfo(req),
refresh_token_hash: hashToken(refreshToken),
is_active: true,
}, { transaction });
await sendEmail({ to: email, type: "WELCOME", data: { name: email } });
await transaction.commit();
// Fire-and-forget: activity log
logActivity(user.user_id, 'register');
// Fire-and-forget: achievements, welcome email, notification (do not block the response)
onUserRegistered(user.user_id)
.catch(err => console.error('[AUTH] Failed to grant achievements:', err));
sendEmail({ to: email, type: "WELCOME", data: { name: email } })
.catch(err => console.error('[AUTH] Failed to send welcome email:', err));
mdl_UserGroupMembers.findOne({
where: { user_id: user.user_id },
include: [{ model: mdl_UserGroups, attributes: ['name', 'group_code'] }],
}).then(membership => {
const grp = membership?.UserGroup;
return UserNotification.create({
user_id: user.user_id,
...NOTIFICATION_REGISTRY.welcome.build({
groupName: grp?.name ?? null,
groupCode: grp?.group_code ?? null,
accType: user.acc_type,
}),
});
}).catch(err => console.error('[AUTH] Failed to emit welcome notification:', err));
res.cookie('refreshToken', refreshToken, {
httpOnly: true, // ← JS cannot read this
secure: process.env.NODE_ENV === 'production',
@@ -202,11 +236,13 @@ exports.login = async (req, res) => {
const { accessToken, refreshToken } = generateTokens(user);
const session = await mdl_UserSessions.create({
user_id: user.user_id,
login_info: buildLoginInfo(req),
login_info: await buildSessionInfo(req),
refresh_token_hash: hashToken(refreshToken),
is_active: true,
});
logActivity(user.user_id, 'login', { entityType: 'session', entityId: Number(session.session_id) });
res.cookie('refreshToken', refreshToken, {
httpOnly: true, // ← JS cannot read this
secure: process.env.NODE_ENV === 'production',
@@ -225,28 +261,107 @@ exports.login = async (req, res) => {
}
};
// ─── Google OAuth Callback ─────────────────────────────────────────────────────
// ─── Google OIDC — Redirect ────────────────────────────────────────────────────
// Generates state, nonce, and PKCE verifier, stores them in a signed httpOnly
// cookie, then redirects the browser to Google's authorization endpoint.
exports.googleRedirect = (req, res) => {
const state = generateState();
const nonce = generateNonce();
const { codeVerifier, codeChallenge } = generatePKCE();
// SameSite=Lax is required: the cookie must survive the cross-site redirect
// back from Google (top-level GET navigations are allowed under Lax).
res.cookie('_oauth', JSON.stringify({ state, nonce, codeVerifier }), {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 10 * 60 * 1000, // 10 minutes — enough time to complete the flow
signed: true,
});
return res.redirect(buildAuthUrl(state, nonce, codeChallenge));
};
// ─── Google OIDC — Callback ────────────────────────────────────────────────────
// Verifies state (CSRF), exchanges the authorization code, verifies the ID token
// (signature + nonce), finds or creates the user, sets the refresh cookie, then
// redirects the browser to the frontend callback page.
exports.googleCallback = async (req, res) => {
const FRONTEND_URL = process.env.FRONTEND_URL;
const CALLBACK_PAGE = `${FRONTEND_URL}/auth/callback/google`;
try {
const user = req.user; // set by passport
const { code, state, error } = req.query;
if (error) {
return res.redirect(`${CALLBACK_PAGE}?error=${encodeURIComponent(error)}`);
}
// Read and immediately clear the oauth state cookie.
const rawCookie = req.signedCookies['_oauth'];
res.clearCookie('_oauth');
if (!rawCookie) return res.redirect(`${CALLBACK_PAGE}?error=session_expired`);
const { state: expectedState, nonce, codeVerifier } = JSON.parse(rawCookie);
if (!state || state !== expectedState) {
return res.redirect(`${CALLBACK_PAGE}?error=state_mismatch`);
}
// Exchange authorization code → { id_token, access_token, ... }
const tokens = await exchangeCode(code, codeVerifier);
// Verify ID token signature, audience, expiry, and nonce.
const payload = await verifyIdToken(tokens.id_token, nonce);
// Find or auto-create the user.
let user = await mdl_Users.findOne({ where: { email: payload.email } });
if (!user) {
user = await mdl_Users.create({
email: payload.email,
reg_type: 'google',
acc_type: 'user',
is_active: true,
is_verified: true,
personal_info: {
name: {
given_name: payload.given_name ?? '',
last_name: payload.family_name ?? '',
full_name: payload.name ?? '',
},
avatar: { url: payload.picture ?? null },
},
});
}
if (!user.is_active) {
return res.redirect(`${CALLBACK_PAGE}?error=account_deactivated`);
}
const { accessToken, refreshToken } = generateTokens(user);
await mdl_UserSessions.create({
user_id: user.user_id,
login_info: buildLoginInfo(req),
const googleSession = await mdl_UserSessions.create({
user_id: user.user_id,
login_info: await buildSessionInfo(req),
refresh_token_hash: hashToken(refreshToken),
is_active: true,
is_active: true,
});
// In a real SPA: redirect with tokens in query or set httpOnly cookie
return R.success(res, 'Google login successful.', {
accessToken,
refreshToken,
user: safeUser(user),
logActivity(user.user_id, 'login', { entityType: 'session', entityId: Number(googleSession.session_id), details: { reg_type: 'google' } });
res.cookie('refreshToken', refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: process.env.NODE_ENV === 'production' ? 'strict' : 'lax',
maxAge: 7 * 24 * 60 * 60 * 1000,
});
// Redirect to the frontend; App.jsx's restoreSession() will pick up the
// refresh cookie and complete the login automatically.
return res.redirect(CALLBACK_PAGE);
} catch (err) {
console.error('[AUTH] googleCallback error:', err);
return R.error(res, 'Google authentication failed.', 500);
console.error('[AUTH] googleCallback OIDC error:', err);
return res.redirect(`${process.env.FRONTEND_URL}/auth/callback/google?error=auth_failed`);
}
};
@@ -270,7 +385,7 @@ exports.refreshToken = async (req, res) => {
// Check if refresh token is expired
if (!shouldRotateRefreshToken(decoded)) {
const { accessToken } = generateTokens(user);
return R.success(res, 'Token refreshed.', { accessToken, user: safeUser(user) });
return R.success(res, 'Token refreshed.', { accessToken, session_id: session.session_id, user: safeUser(user) });
}
// ─── Rotate refresh token ───────────────────────────────────────────────────
@@ -284,7 +399,7 @@ exports.refreshToken = async (req, res) => {
maxAge: 7 * 24 * 60 * 60 * 1000,
});
return R.success(res, 'Token refreshed.', { ...tokens, user: safeUser(user) });
return R.success(res, 'Token refreshed.', { ...tokens, session_id: session.session_id, user: safeUser(user) });
} catch (err) {
console.error('[AUTH] refresh token error:', err);
return R.error(res, 'Invalid or expired refresh token.', 401);
@@ -297,11 +412,13 @@ exports.logout = async (req, res) => {
const { session_id } = req.body;
if (session_id) {
await mdl_UserSessions.update(
{ is_active: false, logout_info: buildLoginInfo(req) },
{ is_active: false, logout_info: await buildSessionInfo(req) },
{ where: { session_id, user_id: req.user.user_id } }
);
}
logActivity(req.user.user_id, 'logout', { entityType: 'session', entityId: session_id ? Number(session_id) : null });
res.clearCookie('refreshToken')
res.clearCookie('_csrf')
@@ -330,6 +447,8 @@ exports.changePassword = async (req, res) => {
// Invalidate all sessions to force re-login
await mdl_UserSessions.update({ is_active: false }, { where: { user_id: user.user_id } });
logActivity(user.user_id, 'password_change');
return R.success(res, 'Password changed. All sessions have been invalidated. Please log in again.');
} catch (err) {
console.error('[AUTH] changePassword error:', err);
@@ -0,0 +1,100 @@
// controllers/client/advertisements.controller.js
const Advertisement = require("../../models/advertisements/advertisements.mdl");
const mdl_Assets = require("../../models/assets/assets.mdl");
const R = require('../../utils/response.util');
const { Op } = require('sequelize');
const ALLOWED_TYPES = ["hero", "banner", "popup", "sidebar"];
// ─── Status derivation ─────────────────────────────────────────────────────
// Mirrors admin controller's deriveStatus — single source of truth for what
// "live right now" means. Kept duplicated rather than shared to avoid a
// cross-boundary import between admin and client controllers.
function deriveStatus(advertisement) {
if (advertisement.deletedAt) return "archived";
if (!advertisement.is_active) return "draft";
const now = new Date();
const start = advertisement.start_date ? new Date(advertisement.start_date) : null;
const end = advertisement.end_date ? new Date(advertisement.end_date) : null;
if (end && end < now) return "expired";
if (start && start > now) return "scheduled";
return "active";
}
// ─── GET ACTIVE ───────────────────────────────────────────────────────────────
//
// Resolves the single highest-priority live advertisement for a given placement
// type. "Live" means is_active = true AND within start_date/end_date window —
// computed the same way as deriveStatus, but expressed as a SQL WHERE clause
// here since we want the DB to do the filtering/ordering, not JS.
//
// GET /api/client/advertisements/active?type=hero
//
exports.getActiveAdvertisement = async (req, res) => {
try {
const { type } = req.query;
if (!type) return R.error(res, "type is required.", 400);
if (!ALLOWED_TYPES.includes(type)) return R.error(res, `Invalid type. Must be one of: ${ALLOWED_TYPES.join(", ")}`, 400);
const now = new Date();
const advertisement = await Advertisement.findOne({
where: {
type,
is_active: true,
deletedAt: null,
[Op.and]: [
{ [Op.or]: [{ start_date: null }, { start_date: { [Op.lte]: now } }] },
{ [Op.or]: [{ end_date: null }, { end_date: { [Op.gte]: now } }] },
],
},
order: [["order", "ASC"], ["createdAt", "DESC"]],
include: [{
model: mdl_Assets,
as: "image",
attributes: ["asset_id", "display_name", "file_url", "thumbnail_url"],
required: false,
}],
attributes: { exclude: ["createdBy", "updatedBy", "deletedBy", "deletedAt", "image_asset_id"] },
});
if (!advertisement) return R.success(res, "No active advertisement for this placement.", { data: null });
const json = advertisement.toJSON();
json.status = deriveStatus(json); // will always be "active" given the WHERE clause, but kept for shape consistency
return R.success(res, "Active advertisement retrieved.", { data: json });
} catch (err) {
console.error("[CLIENT][ADVERTISEMENT][GET ACTIVE]", err);
return R.error(res, "Could not retrieve advertisement.", 500);
}
};
// ─── TRACK CLICK ──────────────────────────────────────────────────────────────
//
// POST /api/client/advertisements/:advertisementId/click
// Fire-and-forget increment. Never blocks or surfaces errors to the user —
// a failed click tracking call should never disrupt navigation to the CTA link.
//
exports.trackClick = async (req, res) => {
try {
const { advertisementId } = req.params;
if (!advertisementId || advertisementId === "undefined") return R.error(res, "Invalid advertisement ID.", 400);
const advertisement = await Advertisement.findOne({ where: { advertisement_id: advertisementId, deletedAt: null } });
if (!advertisement) return R.success(res, "Advertisement not found, skipped.", { data: null });
await advertisement.increment("click_count");
return R.success(res, "Click tracked.", { data: { click_count: advertisement.click_count + 1 } });
} catch (err) {
console.error("[CLIENT][ADVERTISEMENT][TRACK CLICK]", err);
// Still respond 200-ish/success shape — click tracking failures shouldn't surface to the user.
return R.success(res, "Click tracking failed silently.", { data: null });
}
};
@@ -0,0 +1,214 @@
/***********************************************************************************************************************************************************************
* File Name: certificate.controller.js (client)
* Type of Program: Controller
* Description: Issues a PDF certificate for a completed course.
* A certificate is available only when the user has passed the course assessment.
* Certificate records are persisted (findOrCreate) so the same cert_no/ref_no is
* returned on every subsequent download.
*
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jun. 18, 2026
***********************************************************************************************************************************************************************/
'use strict';
const R = require('../../utils/response.util');
const mdl_Users = require('../../models/users/users.mdl');
const mdl_Achievements = require('../../models/users/achievements.mdl');
const { generateCertificate } = require('../../services/certificate.service');
const { formatDuration } = require('../../utils/duration.util');
const UserNotification = require('../../models/notifications/user_notification.mdl');
const { NOTIFICATION_REGISTRY } = require('../../data/notifications.data');
const {
Course,
CourseAssessment,
QuizAttempt,
Certificate,
CourseInstructor,
} = require('../../models/courses/courses.associations');
const notDeleted = { deletedAt: null };
// ─── Helpers ───────────────────────────────────────────────────────────────────
// cert_no format: YYYYMM-{userId:6}-{userCertSeq:5}
// userCertSeq = how many certs this user will have after this insert
async function buildCertNo(userId) {
const count = await Certificate.count({ where: { user_id: userId } });
const seq = String(count + 1).padStart(5, '0');
const uid = String(userId).padStart(6, '0');
const now = new Date();
const YYYYMM = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}`;
return `${YYYYMM}-${uid}-${seq}`;
}
// ref_no format: PP-YYYYMM-{globalSeq:5} (unique across all certs)
async function buildRefNo() {
const count = await Certificate.count();
const seq = String(count + 1).padStart(5, '0');
const now = new Date();
const YYYYMM = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}`;
return `PP-${YYYYMM}-${seq}`;
}
function formatInstructors(rows) {
const names = rows.map(r => r.display_name);
if (names.length === 0) return '';
if (names.length === 1) return names[0];
if (names.length === 2) return `${names[0]} and ${names[1]}`;
return `${names[0]}, ${names[1]} and et. al`;
}
// ─── GET /api/client/certificates/:courseUuid ──────────────────────────────────
exports.getCertificate = async (req, res) => {
try {
const { courseUuid } = req.params;
const user_id = req.user.user_id;
// ── 1. Resolve course ──────────────────────────────────────────────────────
const course = await Course.findOne({
where: { uuid: courseUuid, ...notDeleted },
attributes: ['course_id', 'title', 'course_code', 'duration_seconds'],
include: [
{
model: CourseAssessment,
as: 'assessment',
attributes: ['assessment_id'],
required: false,
},
{
model: CourseInstructor,
as: 'instructors',
attributes: ['display_name', 'order_index'],
required: false,
order: [['order_index', 'ASC']],
},
],
});
if (!course) return R.error(res, 'Course not found.', 404);
if (!course.assessment) {
return R.error(res, 'This course does not have an assessment — no certificate available.', 404);
}
// ── 2. Verify the user passed ──────────────────────────────────────────────
const passedAttempt = await QuizAttempt.findOne({
where: {
user_id,
assessment_id: course.assessment.assessment_id,
passed: true,
},
order: [['createdAt', 'DESC']],
attributes: ['score', 'createdAt'],
});
if (!passedAttempt) {
return R.error(res, 'Certificate not available — course assessment not passed yet.', 403);
}
// ── 3. Get user's name ─────────────────────────────────────────────────────
const user = await mdl_Users.findByPk(user_id, { attributes: ['personal_info'] });
const fullName = user?.personal_info?.name?.full_name?.trim() || 'Participant';
// ── 4. Resolve or create the certificate record ────────────────────────────
const [cert, created] = await Certificate.findOrCreate({
where: { user_id, course_id: course.course_id },
defaults: {
cert_no: await buildCertNo(user_id),
ref_no: await buildRefNo(),
instructors: formatInstructors(course.instructors ?? []),
score: passedAttempt.score ?? null,
length_str: formatDuration(course.duration_seconds),
issued_at: passedAttempt.createdAt,
},
});
// Always use live instructors from course_instructors table for the PDF.
// Keep the snapshot in sync so it reflects the current state.
const liveInstructors = formatInstructors(course.instructors ?? []);
if (liveInstructors !== (cert.instructors ?? '')) {
await cert.update({ instructors: liveInstructors });
}
// ── 5. On first issue: fire notification + achievements ────────────────────
if (created) {
// Certificate issued notification
UserNotification.create({
user_id,
...NOTIFICATION_REGISTRY.certificate_issued.build({
courseTitle: course.title,
courseUuid,
}),
}).catch(err => console.error('[CERTIFICATE] Failed to emit notification:', err));
// Per-course completion achievement
mdl_Achievements.findOrCreate({
where: { user_id, key: `course_completed_${courseUuid}` },
defaults: {
type: 'milestone',
label: 'Certificate of Completion',
description: course.title,
granted_at: passedAttempt.createdAt,
metadata: { courseTitle: course.title, courseUuid },
},
}).catch(err => console.error('[CERTIFICATE] Failed to grant course achievement:', err));
// First-course achievement (only if this is their very first certificate)
const totalCerts = await Certificate.count({ where: { user_id } });
if (totalCerts === 1) {
mdl_Achievements.findOrCreate({
where: { user_id, key: 'first_course_completed' },
defaults: {
type: 'milestone',
label: 'First Course Completed',
description: 'Completed your very first course on Philproperties.',
granted_at: passedAttempt.createdAt,
metadata: { courseTitle: course.title, courseUuid },
},
}).catch(err => console.error('[CERTIFICATE] Failed to grant first-course achievement:', err));
}
}
// ── 6. Format issued date as MM/DD/YY HH:MM AM/PM ────────────────────────
const issuedDate = new Date(cert.issued_at);
const dateStr = new Intl.DateTimeFormat('en-US', {
month: '2-digit',
day: '2-digit',
year: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: true,
}).format(issuedDate);
// ── 7. Generate PDF ────────────────────────────────────────────────────────
const pdf = await generateCertificate({
name: fullName,
course: course.title,
date: dateStr,
cert_no: cert.cert_no,
ref_no: cert.ref_no,
instructors: liveInstructors,
length: cert.length_str ?? '',
});
// ── 8. Stream response ─────────────────────────────────────────────────────
const nameParts = fullName.trim().split(/\s+/);
const lastName = nameParts.length > 1 ? nameParts[nameParts.length - 1] : nameParts[0];
const firstName = nameParts.length > 1 ? nameParts.slice(0, -1).join(' ') : '';
const safeTitle = course.title.replace(/[/\\:*?"<>|]/g, '').trim();
const filename = `${lastName},${firstName}_${safeTitle}_${cert.cert_no}.pdf`;
res.set({
'Content-Type': 'application/pdf',
'Content-Disposition': `attachment; filename="${filename}"`,
'Content-Length': pdf.length,
});
return res.send(pdf);
} catch (err) {
console.error('[CLIENT][CERTIFICATE]', err);
return R.error(res, 'Could not generate certificate.', 500);
}
};
@@ -0,0 +1,157 @@
'use strict';
const mdl_CoursePurchase = require('../../models/courses/course_purchases.mdl');
const mdl_Product = require('../../models/courses/products.mdl');
const paypal = require('../../services/paypal.service');
const R = require('../../utils/response.util');
// ─── CREATE ORDER ─────────────────────────────────────────────────────────────
exports.createCourseOrder = async (req, res) => {
try {
const { product_id } = req.body;
if (!product_id) return R.error(res, 'product_id is required.', 400);
const product = await mdl_Product.findOne({ where: { id: product_id, is_active: true } });
if (!product) return R.error(res, 'Product not found or inactive.', 404);
// Block if user already has an active completed purchase for this product
const existing = await mdl_CoursePurchase.findOne({
where: { user_id: req.user.user_id, product_id, status: 'completed' },
});
if (existing) {
const stillActive = !existing.expires_at || new Date(existing.expires_at) > new Date();
if (stillActive) return R.error(res, 'You already have active access to this course.', 409);
}
const ppOrder = await paypal.createOrder({
amount: Number(product.price).toFixed(2),
currency: product.currency,
referenceId: `user_${req.user.user_id}_product_${product_id}`,
returnUrl: `${process.env.FRONTEND_URL}/course/${product.course_id}/checkout`,
cancelUrl: `${process.env.FRONTEND_URL}/course/${product.course_id}/checkout?cancelled=true`,
});
const approvalUrl = ppOrder.links?.find((l) => l.rel === 'approve')?.href ?? null;
const expiresAt = product.access_days
? new Date(Date.now() + product.access_days * 86400000)
: null;
const purchase = await mdl_CoursePurchase.create({
user_id: req.user.user_id,
product_id,
amount: product.price,
currency: product.currency,
status: 'pending',
provider: 'paypal',
expires_at: expiresAt,
provider_payload: { order_id: ppOrder.id, approval_url: approvalUrl },
});
return R.success(res, 'Order created.', {
purchase_id: purchase.id,
order_id: ppOrder.id,
approval_url: approvalUrl,
amount: product.price,
currency: product.currency,
}, 201);
} catch (err) {
console.error('[CLIENT][COURSE PURCHASE][CREATE ORDER]', err);
return R.error(res, 'Could not create order.', 500);
}
};
// ─── CAPTURE ORDER ────────────────────────────────────────────────────────────
exports.captureCourseOrder = async (req, res) => {
try {
const { order_id } = req.body;
if (!order_id) return R.error(res, 'order_id is required.', 400);
const purchase = await mdl_CoursePurchase.findOne({
where: { user_id: req.user.user_id, status: 'pending', provider: 'paypal' },
include: [{ model: mdl_Product, as: 'product' }],
order: [['createdAt', 'DESC']],
});
if (!purchase || purchase.provider_payload?.order_id !== order_id)
return R.error(res, 'Pending purchase not found.', 404);
let captureData;
try {
captureData = await paypal.captureOrder(order_id);
} catch (ppErr) {
await purchase.update({
status: 'failed',
provider_payload: { ...purchase.provider_payload, error: ppErr?.response?.data ?? {} },
});
return R.error(res, 'PayPal capture failed.', 402);
}
const capture = captureData.purchase_units?.[0]?.payments?.captures?.[0];
await purchase.update({
status: 'completed',
paid_at: new Date(),
provider_payload: {
...purchase.provider_payload,
capture_id: capture?.id,
payer_id: captureData.payer?.payer_id,
capture: captureData,
},
});
return R.success(res, 'Payment successful. Course access granted.', {
purchase_id: purchase.id,
expires_at: purchase.expires_at,
course_id: purchase.product.course_id,
});
} catch (err) {
console.error('[CLIENT][COURSE PURCHASE][CAPTURE]', err);
return R.error(res, 'Could not capture order.', 500);
}
};
// ─── CANCEL ORDER ─────────────────────────────────────────────────────────────
exports.cancelCourseOrder = async (req, res) => {
try {
const { order_id } = req.body;
if (!order_id) return R.error(res, 'order_id is required.', 400);
const purchase = await mdl_CoursePurchase.findOne({
where: { user_id: req.user.user_id, status: 'pending', provider: 'paypal' },
order: [['createdAt', 'DESC']],
});
if (!purchase || purchase.provider_payload?.order_id !== order_id)
return R.error(res, 'Pending purchase not found.', 404);
await purchase.update({
status: 'cancelled',
provider_payload: { ...purchase.provider_payload, cancelled_at: new Date().toISOString() },
});
return R.success(res, 'Purchase cancelled.');
} catch (err) {
console.error('[CLIENT][COURSE PURCHASE][CANCEL]', err);
return R.error(res, 'Could not cancel purchase.', 500);
}
};
// ─── MY PURCHASES ─────────────────────────────────────────────────────────────
exports.getMyPurchases = async (req, res) => {
try {
const purchases = await mdl_CoursePurchase.findAll({
where: { user_id: req.user.user_id },
include: [{ model: mdl_Product, as: 'product', attributes: ['id', 'name', 'course_id', 'access_days'] }],
attributes: { exclude: ['provider_payload'] },
order: [['createdAt', 'DESC']],
});
return R.success(res, 'Purchases retrieved.', purchases);
} catch (err) {
console.error('[CLIENT][COURSE PURCHASE][GET MINE]', err);
return R.error(res, 'Could not retrieve purchases.', 500);
}
};
@@ -0,0 +1,312 @@
/***********************************************************************************************************************************************************************
* File Name: course_reading_progress.controller.js (client)
* Type of Program: Controller
* Description: Tracks user reading progress through a course hierarchy (course → unit → lesson).
*
* GET /client/courses/in-progress
* → courses where the current user has status = 'in_progress', with lesson counts
*
* GET /client/courses/:courseId/progress
* → returns all progress rows for this user + course (flat, frontend builds the map)
*
* POST /client/courses/:courseId/units/:unitId/lessons/:lessonId/progress
* → UPSERT lesson + derives + UPSERTs parent unit + course in one transaction
*
* Body (POST):
* { status: 'in_progress' | 'completed' }
* Defaults to 'in_progress' if omitted (on first visit).
*
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jun. 21, 2026
***********************************************************************************************************************************************************************/
'use strict';
const R = require('../../utils/response.util');
const logActivity = require('../../utils/logActivity.util');
const { upsertLessonRead } = require('../../services/course_reading_progress.service');
const CourseReadingProgress = require('../../models/courses/course_reading_progress.mdl');
const Certificate = require('../../models/courses/certificate.mdl');
const {
Course, Unit, Lesson,
UnitQuiz, CourseAssessment, QuizAttempt,
} = require('../../models/courses/courses.associations');
const notDeleted = { deletedAt: null };
// =============================================================================
// ── IN-PROGRESS COURSES — profile learning progress card ──────────────────────
// =============================================================================
// GET /client/courses/in-progress
// Returns courses the user has started but not yet completed (no certificate).
// Includes both:
// • reading in_progress → still working through lessons
// • reading completed → finished lessons but quiz / assessment still pending
// Excludes any course where the user already holds a certificate.
exports.getMyInProgressCourses = async (req, res) => {
try {
const userId = req.user.user_id;
// All course-level progress rows for this user (any reading status)
const courseRows = await CourseReadingProgress.findAll({
where: { user_id: userId, type: 'course' },
attributes: ['course_id', 'status', 'last_accessed_at'],
include: [{
model: Course,
as: 'course',
attributes: ['course_id', 'title'],
where: notDeleted,
required: true,
}],
order: [['last_accessed_at', 'DESC']],
});
if (!courseRows.length) return R.success(res, 'No courses in progress.', []);
// Courses the user has already earned a certificate for — exclude these
const certificates = await Certificate.findAll({
where: { user_id: userId },
attributes: ['course_id'],
});
const certSet = new Set(certificates.map((c) => String(c.course_id)));
const pending = courseRows.filter((r) => !certSet.has(String(r.course_id)));
if (!pending.length) return R.success(res, 'No courses in progress.', []);
const result = await Promise.all(pending.map(async (row) => {
const courseId = row.course_id;
const readingDone = row.status === 'completed';
const [lessons_total, lessons_completed] = await Promise.all([
Lesson.count({
include: [{
model: Unit,
as: 'unit',
where: { course_id: courseId, ...notDeleted },
required: true,
}],
where: notDeleted,
}),
CourseReadingProgress.count({
where: { user_id: userId, course_id: courseId, type: 'lesson', status: 'completed' },
}),
]);
// Only compute pending quiz/assessment detail when all lessons are read
let pending_quizzes = [];
let pending_assessment = null;
if (readingDone) {
// All unit quizzes in this course
const unitQuizzes = await UnitQuiz.findAll({
attributes: ['quiz_id', 'title', 'is_required', 'passing_score'],
include: [{
model: Unit,
as: 'unit',
attributes: ['unit_id', 'title'],
where: { course_id: courseId, ...notDeleted },
required: true,
}],
where: notDeleted,
});
for (const quiz of unitQuizzes) {
const [hasPassed, attemptCount] = await Promise.all([
QuizAttempt.findOne({ where: { user_id: userId, quiz_id: quiz.quiz_id, passed: true } }),
QuizAttempt.count({ where: { user_id: userId, quiz_id: quiz.quiz_id } }),
]);
if (!hasPassed) {
pending_quizzes.push({
quiz_id: quiz.quiz_id,
title: quiz.title,
unit_title: quiz.unit.title,
is_required: quiz.is_required,
passing_score: quiz.passing_score,
attempt_count: attemptCount,
});
}
}
// Course assessment
const assessment = await CourseAssessment.findOne({
attributes: ['assessment_id', 'title', 'is_required', 'passing_score'],
where: { course_id: courseId },
});
if (assessment) {
const [hasPassed, attemptCount] = await Promise.all([
QuizAttempt.findOne({ where: { user_id: userId, assessment_id: assessment.assessment_id, passed: true } }),
QuizAttempt.count({ where: { user_id: userId, assessment_id: assessment.assessment_id } }),
]);
if (!hasPassed) {
pending_assessment = {
assessment_id: assessment.assessment_id,
title: assessment.title,
is_required: assessment.is_required,
passing_score: assessment.passing_score,
attempt_count: attemptCount,
};
}
}
}
return {
course_id: courseId,
title: row.course.title,
reading_status: row.status,
lessons_total,
lessons_completed,
last_accessed_at: row.last_accessed_at,
pending_quizzes,
pending_assessment,
};
}));
return R.success(res, 'In-progress courses retrieved.', result);
} catch (err) {
console.error('[CLIENT][COURSE READING PROGRESS][IN PROGRESS]', err);
return R.error(res, 'Could not retrieve in-progress courses.', 500);
}
};
// =============================================================================
// ── GET PROGRESS SUMMARY ──────────────────────────────────────────────────────
// =============================================================================
// GET /client/courses/:courseId/progress/summary
// Returns a compact progress snapshot: lesson counts + percentage + course status.
// Used by the ReadCourse block to render the inline progress bar without needing
// the full flat row list.
exports.getCourseProgressSummary = async (req, res) => {
try {
const { courseId } = req.params;
const userId = req.user.user_id;
const course = await Course.findOne({
where: { course_id: courseId, ...notDeleted },
attributes: ['course_id'],
});
if (!course) return R.error(res, 'Course not found.', 404);
const [lessons_total, lessons_completed, courseRow] = await Promise.all([
Lesson.count({
include: [{
model: Unit,
as: 'unit',
where: { course_id: courseId, ...notDeleted },
required: true,
}],
where: notDeleted,
}),
CourseReadingProgress.count({
where: { user_id: userId, course_id: courseId, type: 'lesson', status: 'completed' },
}),
CourseReadingProgress.findOne({
where: { user_id: userId, course_id: courseId, type: 'course' },
attributes: ['status'],
}),
]);
const percent = lessons_total > 0 ? Math.round((lessons_completed / lessons_total) * 100) : 0;
return R.success(res, 'Progress summary retrieved.', {
lessons_total,
lessons_completed,
percent,
status: courseRow?.status ?? null,
});
} catch (err) {
console.error('[CLIENT][COURSE READING PROGRESS][SUMMARY]', err);
return R.error(res, 'Could not retrieve progress summary.', 500);
}
};
// =============================================================================
// ── GET PROGRESS SNAPSHOT ─────────────────────────────────────────────────────
// =============================================================================
// GET /client/courses/:courseId/progress
// Returns all progress rows for this user+course.
// Frontend uses this to decorate the sidebar (completed checkmarks, locked states, etc.)
exports.getCourseProgress = async (req, res) => {
try {
const { courseId } = req.params;
const userId = req.user.user_id;
const course = await Course.findOne({
where: { course_id: courseId, ...notDeleted },
attributes: ['course_id'],
});
if (!course) return R.error(res, 'Course not found.', 404);
const rows = await CourseReadingProgress.findAll({
where: { user_id: userId, course_id: courseId },
attributes: ['progress_id', 'reference_id', 'type', 'status', 'completed_at', 'last_accessed_at'],
});
return R.success(res, 'Course progress retrieved.', rows);
} catch (err) {
console.error('[CLIENT][COURSE READING PROGRESS][GET]', err);
return R.error(res, 'Could not retrieve course progress.', 500);
}
};
// =============================================================================
// ── UPSERT LESSON PROGRESS ────────────────────────────────────────────────────
// =============================================================================
// POST /client/courses/:courseId/units/:unitId/lessons/:lessonId/progress
// Body: { status: 'in_progress' | 'completed' }
//
// Flow:
// 1. Resolve course / unit / lesson to get their UUIDs
// 2. Delegate to upsertLessonRead — handles lesson + unit + course in one tx
exports.upsertLessonProgress = async (req, res) => {
try {
const { courseId, unitId, lessonId } = req.params;
const userId = req.user.user_id;
const status = req.body.status === 'completed' ? 'completed' : 'in_progress';
const [course, unit, lesson] = await Promise.all([
Course.findOne({
where: { course_id: courseId, ...notDeleted },
attributes: ['course_id', 'uuid'],
}),
Unit.findOne({
where: { unit_id: unitId, course_id: courseId, ...notDeleted },
attributes: ['unit_id', 'uuid'],
}),
Lesson.findOne({
where: { lesson_id: lessonId, unit_id: unitId, ...notDeleted },
attributes: ['lesson_id', 'uuid'],
}),
]);
if (!course) return R.error(res, 'Course not found.', 404);
if (!unit) return R.error(res, 'Unit not found.', 404);
if (!lesson) return R.error(res, 'Lesson not found.', 404);
const result = await upsertLessonRead(userId, {
courseId: course.course_id,
courseUuid: course.uuid,
unitId: unit.unit_id,
unitUuid: unit.uuid,
lessonUuid: lesson.uuid,
lessonStatus: status,
});
logActivity(userId, 'lesson_read', {
entityType: 'lesson',
entityId: lesson.lesson_id,
details: { lesson_uuid: lesson.uuid, status },
});
return R.success(res, 'Progress updated.', result, 200);
} catch (err) {
console.error('[CLIENT][COURSE READING PROGRESS][UPSERT]', err);
return R.error(res, 'Could not update progress.', 500);
}
};
+727
View File
@@ -0,0 +1,727 @@
/***********************************************************************************************************************************************************************
* File Name: courses.controller.js (client)
* Type of Program: Controller
* Description: User-facing course endpoints (read-only).
* Access rules:
* - All courses are returned in the list (for upsell visibility)
* - Each course has is_locked: boolean based on the user's active tier
* - free / no active tier → unassigned courses are open; plan courses are locked
* - premium (active tier) → unassigned + courses under their plan are open
* - getCourse still enforces hard 403 on locked access
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jun. 7, 2026
***********************************************************************************************************************************************************************/
"use strict";
const { Op } = require("sequelize");
const R = require("../../utils/response.util");
const mdl_UserTiers = require("../../models/tiers/user_tiers.mdl");
const mdl_PlanCourses = require("../../models/tiers/plan_courses.mdl");
const mdl_TierPlans = require("../../models/tiers/tier_plans.mdl");
const mdl_CoursePurchase = require("../../models/courses/course_purchases.mdl");
const mdl_Product = require("../../models/courses/products.mdl");
const mdl_Category = require("../../models/courses/categories.mdl");
const {
Course,
Unit, Lesson, LessonPage,
CourseObjective, LessonObjective,
CoursePrerequisite, CourseAssessment,
UnitQuiz, QuizQuestion, QuizOption, QuizAttempt
} = require("../../models/courses/courses.associations");
const { gradeSubmission } = require("../../utils/courses/grading.util");
const { shuffleOptions, getAttemptStatus, MAX_ATTEMPTS } = require("../../utils/courses/quiz_security.util");
const { onCourseCompleted } = require('../../services/achievements.service')
const notDeleted = { deletedAt: null };
const COURSE_LIST_ATTRS = [
"course_id", "uuid", "title", "description",
"course_code", "level", "subscription",
"duration_seconds", "order_index",
];
// Strip correct-answer data before sending quiz questions to the client
function sanitizeQuestions(questions = []) {
return questions.map((q) => {
const plain = q.toJSON ? q.toJSON() : { ...q };
plain.options = (plain.options ?? []).map(({ is_correct: _drop, ...o }) => o);
delete plain.explanation;
return plain;
});
}
// Resolve the caller's active tier (returns null if free/expired)
async function getActiveTier(user_id) {
return mdl_UserTiers.findOne({
where: { user_id, status: "active" },
order: [["createdAt", "DESC"]],
});
}
// ─── COURSES (all visible, is_locked per user tier) ───────────────────────────
exports.getCourses = async (req, res) => {
try {
const { category } = req.query; // optional slug filter
const activeTier = await getActiveTier(req.user.user_id);
const userTier = activeTier?.tier ?? 'free';
const tierRank = { free: 0, premium: 1, exclusive: 2 };
const userRank = tierRank[userTier] ?? 0;
// Fetch all completed purchases for this user (for has_purchased check)
const myPurchases = await mdl_CoursePurchase.findAll({
where: { user_id: req.user.user_id, status: 'completed' },
include: [{ model: mdl_Product, as: 'product', attributes: ['course_id', 'access_days'] }],
attributes: ['id', 'expires_at', 'product_id'],
});
const purchasedCourseIds = new Set(
myPurchases
.filter((p) => !p.expires_at || new Date(p.expires_at) > new Date())
.map((p) => String(p.product?.course_id))
);
// Build category filter
const categoryInclude = {
model: mdl_Category,
as: 'categories',
through: { attributes: [] },
attributes: ['id', 'name', 'slug'],
required: !!category,
...(category ? { where: { slug: category } } : {}),
};
const courses = await Course.findAll({
where: { ...notDeleted },
attributes: COURSE_LIST_ATTRS,
include: [
{
model: mdl_PlanCourses,
as: 'planCourse',
required: false,
attributes: ['id', 'plan_id'],
include: [{ model: mdl_TierPlans, as: 'plan', attributes: ['tier'] }],
},
{
model: mdl_Product,
as: 'product',
required: false,
attributes: ['id', 'name', 'price', 'currency', 'access_days', 'is_active'],
paranoid: false,
},
categoryInclude,
],
order: [['order_index', 'ASC'], ['title', 'ASC']],
});
const result = courses.map((c) => {
const plain = c.toJSON();
const planCourse = plain.planCourse;
const plan_tier = planCourse?.plan?.tier ?? null;
const has_purchased = purchasedCourseIds.has(String(plain.course_id));
let is_locked = false;
if (plan_tier && plan_tier !== 'free') {
const reqRank = tierRank[plan_tier] ?? 0;
if (userRank < reqRank && !has_purchased) is_locked = true;
}
delete plain.planCourse;
return { ...plain, is_locked, plan_tier, has_purchased };
});
return R.success(res, "Courses retrieved.", result);
} catch (err) {
console.error("[CLIENT][COURSES][GET ALL]", err);
return R.error(res, "Could not retrieve courses.", 500);
}
};
// ─── COURSE DETAIL (hard access check) ───────────────────────────────────────
exports.getCourse = async (req, res) => {
try {
const { courseId } = req.params;
// Access check — tier OR individual purchase
const activeTier = await getActiveTier(req.user.user_id);
const planCourse = await mdl_PlanCourses.findOne({ where: { course_id: courseId } });
let plan = null;
if (planCourse) {
plan = await mdl_TierPlans.findByPk(planCourse.plan_id, { attributes: ['tier'] });
const requiredTier = plan?.tier ?? 'free';
const tierRank = { free: 0, premium: 1, exclusive: 2 };
const userRank = tierRank[activeTier?.tier ?? 'free'] ?? 0;
const reqRank = tierRank[requiredTier] ?? 0;
if (userRank < reqRank) {
// Check individual purchase as fallback
const product = await mdl_Product.findOne({ where: { course_id: courseId } });
const hasPurchase = product && await mdl_CoursePurchase.findOne({
where: {
user_id: req.user.user_id, product_id: product.id, status: 'completed',
[Op.or]: [{ expires_at: null }, { expires_at: { [Op.gt]: new Date() } }],
},
});
if (!hasPurchase) return R.error(res, "You do not have access to this course.", 403);
}
}
const course = await Course.findOne({
where: { course_id: courseId, ...notDeleted },
attributes: COURSE_LIST_ATTRS,
include: [
{
model: Unit, as: "units",
where: notDeleted, required: false,
attributes: [
"unit_id", "uuid", "title", "description",
"order_index", "duration_seconds",
],
include: [
{
model: Lesson, as: "lessons",
where: notDeleted, required: false,
attributes: [
"lesson_id", "uuid", "title", "description",
"order_index", "duration_seconds",
],
},
{
model: UnitQuiz, as: "quiz",
required: false,
attributes: [
"quiz_id", "uuid", "title",
"is_required", "passing_score", "max_questions",
],
},
],
},
{
model: CourseObjective, as: "objectives",
required: false,
attributes: ["objective_id", "text", "order_index"],
},
{
model: CoursePrerequisite, as: "prerequisites",
required: false,
attributes: ["prereq_id", "ref_type", "ref_id", "order_index"],
},
{
model: CourseAssessment, as: "assessment",
required: false,
attributes: [
"assessment_id", "uuid", "title",
"is_required", "passing_score",
"time_limit_minutes", "max_questions",
],
},
],
order: [
[{ model: Unit, as: "units" }, "order_index", "ASC"],
[{ model: Unit, as: "units" }, { model: Lesson, as: "lessons" }, "order_index", "ASC"],
[{ model: CourseObjective, as: "objectives" }, "order_index", "ASC"],
[{ model: CoursePrerequisite, as: "prerequisites" }, "order_index", "ASC"],
],
});
if (!course) return R.error(res, "Course not found.", 404);
const plain = course.toJSON();
// Attach has_passed to each unit's quiz in one query
const quizIds = plain.units
?.map((u) => u.quiz?.quiz_id)
.filter(Boolean) ?? [];
if (quizIds.length) {
const passedQuizAttempts = await QuizAttempt.findAll({
where: { quiz_id: quizIds, user_id: req.user.user_id, passed: true },
attributes: ["quiz_id"],
});
const passedSet = new Set(passedQuizAttempts.map((a) => String(a.quiz_id)));
plain.units = plain.units.map((u) => ({
...u,
quiz: u.quiz ? { ...u.quiz, has_passed: passedSet.has(String(u.quiz.quiz_id)) } : null,
}));
}
let is_completed = false;
if (plain.assessment) {
const passedAttempt = await QuizAttempt.findOne({
where: { assessment_id: plain.assessment.assessment_id, user_id: req.user.user_id, passed: true },
});
is_completed = !!passedAttempt;
}
plain.is_completed = is_completed;
const plan_tier = plan?.tier ?? null;
// Attach product info and purchase status for the buy-course flow
const product = await mdl_Product.findOne({
where: { course_id: courseId, is_active: true },
attributes: ['id', 'name', 'price', 'currency', 'access_days'],
});
const hasPurchase = product && await mdl_CoursePurchase.findOne({
where: {
user_id: req.user.user_id, product_id: product.id, status: 'completed',
[Op.or]: [{ expires_at: null }, { expires_at: { [Op.gt]: new Date() } }],
},
});
return R.success(res, "Course retrieved.", {
...plain,
plan_tier,
product: product ?? null,
has_purchased: !!hasPurchase,
});
} catch (err) {
console.error("[CLIENT][COURSES][GET ONE]", err);
return R.error(res, "Could not retrieve course.", 500);
}
};
// ─── UNIT ─────────────────────────────────────────────────────────────────────
exports.getUnit = async (req, res) => {
try {
const { courseId, unitId } = req.params;
const unit = await Unit.findOne({
where: { unit_id: unitId, course_id: courseId, ...notDeleted },
attributes: [
"unit_id", "uuid", "title", "description",
"order_index", "duration_seconds",
],
include: [
{
model: Lesson, as: "lessons",
where: notDeleted, required: false,
attributes: [
"lesson_id", "uuid", "title", "description",
"order_index", "duration_seconds",
],
},
{
model: UnitQuiz, as: "quiz",
required: false,
attributes: [
"quiz_id", "uuid", "title",
"is_required", "passing_score", "max_questions",
],
},
],
order: [[{ model: Lesson, as: "lessons" }, "order_index", "ASC"]],
});
if (!unit) return R.error(res, "Unit not found.", 404);
return R.success(res, "Unit retrieved.", unit);
} catch (err) {
console.error("[CLIENT][UNIT][GET ONE]", err);
return R.error(res, "Could not retrieve unit.", 500);
}
};
// ─── LESSON ───────────────────────────────────────────────────────────────────
exports.getLesson = async (req, res) => {
try {
const { courseId, unitId, lessonId } = req.params;
const lesson = await Lesson.findOne({
where: { lesson_id: lessonId, unit_id: unitId, ...notDeleted },
attributes: [
"lesson_id", "uuid", "unit_id", "title",
"description", "order_index", "duration_seconds",
],
include: [
{
model: Unit, as: "unit",
where: { course_id: courseId, ...notDeleted },
attributes: [],
},
{
model: LessonPage, as: "page",
required: false,
attributes: ["page_id", "blocks"],
},
{
model: LessonObjective, as: "objectives",
required: false,
attributes: ["objective_id", "text", "order_index"],
},
],
order: [[{ model: LessonObjective, as: "objectives" }, "order_index", "ASC"]],
});
if (!lesson) return R.error(res, "Lesson not found.", 404);
return R.success(res, "Lesson retrieved.", lesson);
} catch (err) {
console.error("[CLIENT][LESSON][GET ONE]", err);
return R.error(res, "Could not retrieve lesson.", 500);
}
};
// ─── QUIZ (no answers) ────────────────────────────────────────────────────────
exports.getUnitQuiz = async (req, res) => {
try {
const { courseId, unitId } = req.params;
const unit = await Unit.findOne({ where: { unit_id: unitId, course_id: courseId, ...notDeleted } });
if (!unit) return R.error(res, "Unit not found.", 404);
const quiz = await UnitQuiz.findOne({
where: { unit_id: unitId, ...notDeleted },
attributes: [
"quiz_id", "uuid", "title",
"is_required", "passing_score", "max_questions",
],
include: [{
model: QuizQuestion, as: "questions",
where: notDeleted, required: false,
attributes: ["question_id", "uuid", "type", "question", "order_index", "points"],
include: [{
model: QuizOption, as: "options",
attributes: ["option_id", "text", "order_index"],
}],
}],
order: [[{ model: QuizQuestion, as: "questions" }, "order_index", "ASC"]],
});
if (!quiz) return R.error(res, "Quiz not found.", 404);
const plain = quiz.toJSON();
plain.questions = shuffleOptions(sanitizeQuestions(plain.questions ?? []));
const attempts = await QuizAttempt.findAll({
where: { quiz_id: quiz.quiz_id, user_id: req.user.user_id },
attributes: ["attempt_id", "attempt_number", "score", "passing_score", "passed", "createdAt"],
});
const status = getAttemptStatus(attempts);
plain.attempt_count = status.attempt_count;
plain.has_passed = status.has_passed;
plain.best_attempt = status.best_attempt;
plain.attempts_remaining = status.attempts_remaining;
plain.cooldown_until = status.cooldown_until;
plain.window_reset_at = status.window_reset_at;
plain.can_attempt = status.can_attempt;
return R.success(res, "Quiz retrieved.", plain);
} catch (err) {
console.error("[CLIENT][QUIZ][GET]", err);
return R.error(res, "Could not retrieve quiz.", 500);
}
};
// ─── ASSESSMENT (no answers) ──────────────────────────────────────────────────
exports.getCourseAssessment = async (req, res) => {
try {
const { courseId } = req.params;
const assessment = await CourseAssessment.findOne({
where: { course_id: courseId, ...notDeleted },
attributes: [
"assessment_id", "uuid", "title",
"is_required", "passing_score",
"time_limit_minutes", "max_questions",
],
include: [{
model: QuizQuestion, as: "questions",
where: notDeleted, required: false,
attributes: ["question_id", "uuid", "type", "question", "order_index", "points"],
include: [{
model: QuizOption, as: "options",
attributes: ["option_id", "text", "order_index"],
}],
}],
order: [[{ model: QuizQuestion, as: "questions" }, "order_index", "ASC"]],
});
if (!assessment) return R.error(res, "Assessment not found.", 404);
const plain = assessment.toJSON();
plain.questions = shuffleOptions(sanitizeQuestions(plain.questions ?? []));
const attempts = await QuizAttempt.findAll({
where: { assessment_id: assessment.assessment_id, user_id: req.user.user_id },
attributes: ["attempt_id", "attempt_number", "score", "passing_score", "passed", "createdAt"],
});
const status = getAttemptStatus(attempts);
plain.attempt_count = status.attempt_count;
plain.has_passed = status.has_passed;
plain.best_attempt = status.best_attempt;
plain.attempts_remaining = status.attempts_remaining;
plain.cooldown_until = status.cooldown_until;
plain.window_reset_at = status.window_reset_at;
plain.can_attempt = status.can_attempt;
return R.success(res, "Assessment retrieved.", plain);
} catch (err) {
console.error("[CLIENT][ASSESSMENT][GET]", err);
return R.error(res, "Could not retrieve assessment.", 500);
}
};
// ─── QUIZ SUBMIT ──────────────────────────────────────────────────────────────
exports.submitUnitQuiz = async (req, res) => {
try {
const { courseId, unitId, quizId } = req.params;
const { answers = {} } = req.body;
const user_id = req.user.user_id;
const unit = await Unit.findOne({ where: { unit_id: unitId, course_id: courseId, ...notDeleted } });
if (!unit) return R.error(res, "Unit not found.", 404);
const quiz = await UnitQuiz.findOne({
where: { quiz_id: quizId, unit_id: unitId, ...notDeleted },
include: [{
model: QuizQuestion, as: "questions",
where: notDeleted, required: false,
include: [{ model: QuizOption, as: "options" }],
}],
});
if (!quiz) return R.error(res, "Quiz not found.", 404);
const priorAttempts = await QuizAttempt.findAll({
where: { quiz_id: quiz.quiz_id, user_id },
attributes: ["attempt_id", "score", "passed", "createdAt"],
});
const status = getAttemptStatus(priorAttempts);
if (!status.can_attempt) {
if (status.cooldown_until) {
return R.error(res, "Please wait a bit before retaking this quiz — check the quiz screen for when you can try again.", 429);
}
return R.error(res, `You've used all ${MAX_ATTEMPTS} attempts for this ${ATTEMPT_WINDOW_HOURS}-hour window — check the quiz screen for when it resets.`, 429);
}
const { totalPoints, earnedPoints, score } = gradeSubmission(quiz.questions ?? [], answers);
const passed = score >= (quiz.passing_score ?? 70);
const attempt = await QuizAttempt.create({
user_id,
quiz_id: quiz.quiz_id,
course_id: courseId,
attempt_number: priorAttempts.length + 1,
answers,
total_points: totalPoints,
earned_points: earnedPoints,
score,
passing_score: quiz.passing_score ?? 70,
passed,
});
return R.success(res, "Quiz submitted.", {
attempt_id: attempt.attempt_id,
attempt_number: attempt.attempt_number,
score,
passed,
passing_score: attempt.passing_score,
total_points: totalPoints,
earned_points: earnedPoints,
attempts_remaining: Math.max(0, status.attempts_remaining - 1),
});
} catch (err) {
console.error("[CLIENT][QUIZ][SUBMIT]", err);
return R.error(res, "Could not submit quiz.", 500);
}
};
exports.submitCourseAssessment = async (req, res) => {
try {
const { courseId, assessmentId } = req.params;
const { answers = {} } = req.body;
const user_id = req.user.user_id;
const assessment = await CourseAssessment.findOne({
where: { assessment_id: assessmentId, course_id: courseId, ...notDeleted },
include: [{
model: QuizQuestion, as: "questions",
where: notDeleted, required: false,
include: [{ model: QuizOption, as: "options" }],
}],
});
if (!assessment) return R.error(res, "Assessment not found.", 404);
const priorAttempts = await QuizAttempt.findAll({
where: { assessment_id: assessment.assessment_id, user_id },
attributes: ["attempt_id", "score", "passed", "createdAt"],
});
const status = getAttemptStatus(priorAttempts);
if (!status.can_attempt) {
if (status.cooldown_until) {
return R.error(res, "Please wait a bit before retaking this quiz — check the quiz screen for when you can try again.", 429);
}
return R.error(res, `You've used all ${MAX_ATTEMPTS} attempts for this ${ATTEMPT_WINDOW_HOURS}-hour window — check the quiz screen for when it resets.`, 429);
}
const { totalPoints, earnedPoints, score } = gradeSubmission(assessment.questions ?? [], answers);
const passed = score >= (assessment.passing_score ?? 70);
const attempt = await QuizAttempt.create({
user_id,
assessment_id: assessment.assessment_id,
course_id: courseId,
attempt_number: priorAttempts.length + 1,
answers,
total_points: totalPoints,
earned_points: earnedPoints,
score,
passing_score: assessment.passing_score ?? 70,
passed,
});
let course_completed = false;
if (passed) {
course_completed = true;
const course = await Course.findOne({ where: { course_id: courseId }, attributes: ["course_id", "title"] });
const totalCompleted = await QuizAttempt.count({
where: { user_id, passed: true, assessment_id: { [Op.ne]: null } },
distinct: true,
col: "assessment_id",
});
await onCourseCompleted(user_id, courseId, totalCompleted, course?.title ?? null);
}
return R.success(res, "Assessment submitted.", {
attempt_id: attempt.attempt_id,
attempt_number: attempt.attempt_number,
score,
passed,
passing_score: attempt.passing_score,
total_points: totalPoints,
earned_points: earnedPoints,
attempts_remaining: Math.max(0, status.attempts_remaining - 1),
course_completed,
});
} catch (err) {
console.error("[CLIENT][ASSESSMENT][SUBMIT]", err);
return R.error(res, "Could not submit assessment.", 500);
}
};
// ─── UUID LOOKUPS (task requirement detail blocks) ────────────────────────────
exports.getCourseByUuid = async (req, res) => {
try {
const { uuid } = req.params;
const course = await Course.findOne({
where: { uuid, ...notDeleted },
attributes: ["course_id", "uuid", "title", "description", "level", "subscription"],
});
if (!course) return R.error(res, "Course not found.", 404);
return R.success(res, "Course retrieved.", course);
} catch (err) {
console.error("[CLIENT][COURSES][BY UUID]", err);
return R.error(res, "Could not retrieve course.", 500);
}
};
exports.getUnitByUuid = async (req, res) => {
try {
const { uuid } = req.params;
const unit = await Unit.findOne({
where: { uuid, ...notDeleted },
attributes: ["unit_id", "uuid", "title", "description"],
include: [{ model: Course, as: "course", attributes: ["course_id", "title"] }],
});
if (!unit) return R.error(res, "Unit not found.", 404);
return R.success(res, "Unit retrieved.", unit);
} catch (err) {
console.error("[CLIENT][UNITS][BY UUID]", err);
return R.error(res, "Could not retrieve unit.", 500);
}
};
exports.getLessonsByUnitUuid = async (req, res) => {
try {
const { uuid } = req.params;
const unit = await Unit.findOne({
where: { uuid, ...notDeleted },
attributes: ["unit_id", "uuid", "title", "description", "order_index"],
include: [
{ model: Course, as: "course", attributes: ["course_id", "title"] },
{
model: Lesson,
as: "lessons",
where: notDeleted,
required: false,
attributes: ["lesson_id", "uuid", "title", "description", "order_index"],
include: [{ model: LessonPage, as: "page", attributes: ["blocks"], required: false }],
order: [["order_index", "ASC"]],
},
],
});
if (!unit) return R.error(res, "Unit not found.", 404);
const lessons = (unit.lessons ?? [])
.sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0))
.map((l) => ({
lesson_id: l.lesson_id,
uuid: l.uuid,
title: l.title,
description: l.description,
order_index: l.order_index ?? 0,
blocks: l.page?.blocks ?? [],
}));
return R.success(res, "Unit lessons retrieved.", {
unit_id: unit.unit_id,
uuid: unit.uuid,
title: unit.title,
description: unit.description,
course: unit.course ?? null,
lessons,
});
} catch (err) {
console.error("[CLIENT][UNITS][LESSONS BY UUID]", err);
return R.error(res, "Could not retrieve unit lessons.", 500);
}
};
exports.getLessonByUuid = async (req, res) => {
try {
const { uuid } = req.params;
const lesson = await Lesson.findOne({
where: { uuid, ...notDeleted },
attributes: ["lesson_id", "uuid", "title", "description"],
include: [
{
model: LessonPage,
as: "page",
attributes: ["blocks"],
required: false,
},
{
model: Unit,
as: "unit",
attributes: ["unit_id", "title", "order_index"],
include: [{ model: Course, as: "course", attributes: ["course_id", "title"] }],
},
],
});
if (!lesson) return R.error(res, "Lesson not found.", 404);
const data = {
lesson_id: lesson.lesson_id,
uuid: lesson.uuid,
title: lesson.title,
description: lesson.description,
blocks: lesson.page?.blocks ?? [],
unit: lesson.unit ?? null,
};
return R.success(res, "Lesson retrieved.", data);
} catch (err) {
console.error("[CLIENT][LESSONS][BY UUID]", err);
return R.error(res, "Could not retrieve lesson.", 500);
}
};
+240
View File
@@ -0,0 +1,240 @@
/***********************************************************************************************************************************************************************
* File Name: media.controller.js (client)
* Type of Program: Controller
* Description: Secure media delivery for S3/Garage assets only.
*
* Chibisafe assets use their raw file_url directly — no token needed.
* The block content already has the URL saved at CMS time (handleSelect).
*
* S3 Flow:
* 1. POST /client/media/token { asset_id }
* → validates tier access
* → signs JWT with user_id + IP binding
* → returns { token, provider: "s3", file_type }
*
* 2. Browser sets <video/audio src> = API_BASE + "/client/media/stream/" + token
* → Express verifies JWT
* → Checks IP matches the one that issued the token
* → Generates 60s pre-signed Garage URL, proxies bytes
* → Real S3 URL never reaches the browser
*
* Protection layers:
* 1. JWT signature — token can't be forged
* 2. 5-min TTL — token expires quickly
* 3. IP binding — token is useless if shared with another machine
* 4. Token tracking — tokens are tracked; logged after first use
* (range requests from the same token are allowed
* since the browser reuses the token for seeking)
*
* Supported file_type values: video, audio, document, image
*
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jun. 12, 2026
***********************************************************************************************************************************************************************/
"use strict";
const https = require("https");
const http = require("http");
const jwt = require("jsonwebtoken");
const R = require("../../utils/response.util");
const mdl_Assets = require("../../models/assets/assets.mdl");
const s3 = require("../../services/s3.service");
const MEDIA_SECRET = process.env.MEDIA_JWT_SECRET ?? process.env.JWT_SECRET;
const TOKEN_TTL_SEC = 4 * 60 * 60; // 4 hours — token must outlive the longest video
const SUPPORTED_TYPES = ["video", "audio", "document", "image"];
// ─── In-memory token tracker ──────────────────────────────────────────────────
//
// Tracks tokens that have been used at least once.
// Allows reuse within TTL for range requests (browser seeking reuses the token).
// Auto-cleans after TTL to prevent unbounded memory growth.
// In production with multiple server instances, replace with Redis.
//
const activeTokens = new Map(); // token → { firstUsed, ip }
function trackToken(token, ip) {
if (activeTokens.has(token)) return; // already tracked, allow reuse
activeTokens.set(token, { firstUsed: Date.now(), ip });
setTimeout(() => activeTokens.delete(token), TOKEN_TTL_SEC * 1000);
}
// ─── Helper: resolve client IP ───────────────────────────────────────────────
function resolveIp(req) {
// x-forwarded-for is set by reverse proxies (nginx, Caddy, Cloudflare)
const forwarded = req.headers["x-forwarded-for"];
if (forwarded) return forwarded.split(",")[0].trim();
return req.ip ?? req.socket?.remoteAddress ?? "unknown";
}
// ─── Helper: pipe S3 pre-signed URL to response (Range-aware) ────────────────
function pipeRemoteStream(remoteUrl, req, res) {
const parsed = new URL(remoteUrl);
const transport = parsed.protocol === "https:" ? https : http;
const proxyHeaders = { "User-Agent": "StarrMediaProxy/1.0" };
if (req.headers.range) proxyHeaders["Range"] = req.headers.range;
const proxyReq = transport.request(remoteUrl, { headers: proxyHeaders }, (proxyRes) => {
const status = proxyRes.statusCode === 206 ? 206 : 200;
[
"content-type",
"content-length",
"content-range",
"accept-ranges",
"last-modified",
"etag",
"content-disposition",
].forEach((h) => {
if (proxyRes.headers[h]) res.setHeader(h, proxyRes.headers[h]);
});
res.setHeader("Cache-Control", "no-store");
res.setHeader("X-Content-Type-Options", "nosniff");
res.status(status);
proxyRes.pipe(res);
});
proxyReq.on("error", (err) => {
console.error("[CLIENT][MEDIA][PROXY] Stream error:", err.message);
if (!res.headersSent) res.status(502).json({ message: "Stream unavailable." });
});
req.on("close", () => proxyReq.destroy());
proxyReq.end();
}
// ─── POST /client/media/token ─────────────────────────────────────────────────
//
// S3 assets only — Chibisafe assets use their raw file_url directly.
// Returns: { token, provider: "s3", file_type }
exports.issueToken = async (req, res) => {
try {
const { asset_id } = req.body;
if (!asset_id) return R.error(res, "asset_id is required.", 400);
const asset = await mdl_Assets.findOne({
where: { asset_id, deletedAt: null },
attributes: ["asset_id", "file_type", "storage_provider", "storage_key", "mime_type", "thumbnail_storage_key"],
});
if (!asset) return R.error(res, "Asset not found.", 404);
if (!SUPPORTED_TYPES.includes(asset.file_type)) {
return R.error(res, `Asset type "${asset.file_type}" is not supported.`, 400);
}
if (asset.storage_provider !== "s3") {
return R.error(res, "Token flow is for S3 assets only. Use the raw file_url for other providers.", 400);
}
// ── Bind token to the requester's IP ──────────────────────────────────────
const ip = resolveIp(req);
const token = jwt.sign(
{
asset_id,
user_id: req.user.user_id,
storage_key: asset.storage_key,
file_type: asset.file_type,
mime_type: asset.mime_type,
ip, // ← IP binding — verified on every stream request
},
MEDIA_SECRET,
{ expiresIn: TOKEN_TTL_SEC }
);
// ── Presign thumbnail URL so the browser can load it directly ─────────────
let thumbnail_url = null;
if (asset.thumbnail_storage_key) {
try {
thumbnail_url = await s3.getSignedDownloadUrl(asset.thumbnail_storage_key, TOKEN_TTL_SEC);
} catch {
// Non-fatal — thumbnail is cosmetic
}
}
return R.success(res, "Token issued.", {
token,
provider: "s3",
file_type: asset.file_type,
thumbnail_url,
});
} catch (err) {
console.error("[CLIENT][MEDIA][TOKEN]", err);
return R.error(res, "Could not issue media token.", 500);
}
};
// ─── GET /client/media/stream/:token ─────────────────────────────────────────
//
// Called ONLY by the browser's <video>/<audio>/document element.
// Never called via axios — that would consume the stream as JSON.
//
// Protection checks (in order):
// 1. JWT signature valid
// 2. Token not expired (TTL enforced by JWT)
// 3. Requester IP matches the IP that issued the token
//
// Range requests for the same token are allowed (browser seeking).
exports.streamAsset = async (req, res) => {
const { token } = req.params;
// ── CORS ──────────────────────────────────────────────────────────────────
const allowedOrigin = process.env.FRONTEND_URL ?? "http://localhost:5173";
res.setHeader("Access-Control-Allow-Origin", allowedOrigin);
res.setHeader("Access-Control-Allow-Credentials", "true");
res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Range, Authorization");
res.setHeader("Access-Control-Expose-Headers", "Content-Range, Content-Length, Accept-Ranges, Content-Disposition");
if (req.method === "OPTIONS") return res.sendStatus(204);
// ── Block direct browser navigation ──────────────────────────────────────
// Sec-Fetch-Mode is "navigate" when a user pastes the URL into the address
// bar or opens it in a new tab. Legitimate <video src> requests use "no-cors"
// and fetch() calls use "cors" — both are allowed.
const fetchMode = req.headers["sec-fetch-mode"];
if (fetchMode === "navigate") {
return res.status(401).json({ message: "Unauthorized." });
}
// ── Verify JWT ────────────────────────────────────────────────────────────
let payload;
try {
payload = jwt.verify(token, MEDIA_SECRET);
} catch {
return res.status(401).json({ message: "Invalid or expired media token." });
}
const { storage_key, ip: tokenIp } = payload;
if (!storage_key) return res.status(401).json({ message: "Unauthorized." });
// ── IP binding check ──────────────────────────────────────────────────────
const requestIp = resolveIp(req);
if (tokenIp && requestIp !== tokenIp) {
console.warn(`[CLIENT][MEDIA][STREAM] IP mismatch — token: ${tokenIp}, request: ${requestIp}`);
return res.status(403).json({ message: "Token IP mismatch." });
}
// ── Track token (allow reuse for range requests) ──────────────────────────
trackToken(token, requestIp);
// ── Generate pre-signed URL and proxy bytes ───────────────────────────────
let presignedUrl;
try {
presignedUrl = await s3.getSignedDownloadUrl(storage_key, 60);
} catch (err) {
console.error("[CLIENT][MEDIA][STREAM] Pre-sign failed:", err.message);
return res.status(500).json({ message: "Could not resolve media stream." });
}
return pipeRemoteStream(presignedUrl, req, res);
};
@@ -0,0 +1,86 @@
/***********************************************************************************************************************************************************************
* File Name : notification.controller.js
* Type : Controller (Client)
* Description : Per-user notification management.
* GET /client/notifications — paginated list for the auth user
* GET /client/notifications/unseen — unseen count
* PATCH /client/notifications/:id/seen — mark one as seen
* PATCH /client/notifications/seen-all — mark all as seen
*
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jun. 19, 2026
***********************************************************************************************************************************************************************/
const UserNotification = require('../../models/notifications/user_notification.mdl');
const R = require('../../utils/response.util');
// ─── GET /client/notifications ────────────────────────────────────────────────
async function list(req, res) {
try {
const userId = req.user.user_id;
const page = Math.max(1, parseInt(req.query.page) || 1);
const limit = Math.min(50, parseInt(req.query.limit) || 20);
const offset = (page - 1) * limit;
const { count, rows } = await UserNotification.findAndCountAll({
where: { user_id: userId },
order: [['createdAt', 'DESC']],
limit,
offset,
});
return R.success(res, 'Notifications fetched.', {
notifications: rows,
pagination: { page, limit, total: count, pages: Math.ceil(count / limit) },
});
} catch (err) {
console.error('[CLIENT NOTIFICATION] list error:', err);
return R.error(res, 'Failed to fetch notifications.');
}
}
// ─── GET /client/notifications/unseen ────────────────────────────────────────
async function unseenCount(req, res) {
if (!req.user) return R.success(res, 'Unseen count fetched.', { count: null });
try {
const count = await UserNotification.count({
where: { user_id: req.user.user_id, seen: false },
});
return R.success(res, 'Unseen count fetched.', { count });
} catch (err) {
console.error('[CLIENT NOTIFICATION] unseenCount error:', err);
return R.error(res, 'Failed to fetch unseen count.');
}
}
// ─── PATCH /client/notifications/:id/seen ────────────────────────────────────
async function markSeen(req, res) {
try {
const notification = await UserNotification.findOne({
where: { notification_id: req.params.id, user_id: req.user.user_id },
});
if (!notification) return R.error(res, 'Notification not found.', 404);
await notification.update({ seen: true, seen_at: new Date() });
return R.success(res, 'Notification marked as seen.', notification);
} catch (err) {
console.error('[CLIENT NOTIFICATION] markSeen error:', err);
return R.error(res, 'Failed to mark notification as seen.');
}
}
// ─── PATCH /client/notifications/seen-all ────────────────────────────────────
async function markAllSeen(req, res) {
try {
const now = new Date();
const [count] = await UserNotification.update(
{ seen: true, seen_at: now },
{ where: { user_id: req.user.user_id, seen: false } }
);
return R.success(res, `${count} notification(s) marked as seen.`, { count });
} catch (err) {
console.error('[CLIENT NOTIFICATION] markAllSeen error:', err);
return R.error(res, 'Failed to mark all notifications as seen.');
}
}
module.exports = { list, unseenCount, markSeen, markAllSeen };
+95 -7
View File
@@ -1,23 +1,28 @@
/***********************************************************************************************************************************************************************
* File Name: profile.controller.js (client)
* Type of Program: Controller
* Description: Self-service profile management for CLIENT users.
* Description: Self-service profile management for all end users.
* All routes require: authenticate → requireClient()
*
* Endpoints:
* GET /api/client/profile → view own profile
* PUT /api/client/profile → update personal_info
* GET /api/client/sessions → view own active sessions
* DELETE /api/client/sessions/:id → revoke a specific session
* GET /api/client/profile → view own profile
* PUT /api/client/profile → update personal_info
* GET /api/client/sessions → view own active sessions
* DELETE /api/client/sessions/:id → revoke a specific session
* GET /api/client/achievements → view own achievements
*
* Author: rgrgogu
* Date Created: Oct. 6, 2025
***********************************************************************************************************************************************************************/
const mdl_Users = require('../../models/users/users.mdl');
const mdl_UserSessions = require('../../models/users/user_sessions.mdl');
const mdl_Achievements = require('../../models/users/achievements.mdl');
const logActivity = require('../../utils/logActivity.util');
const R = require('../../utils/response.util');
const { uploadFile, deleteFile } = require('../../services/s3.service');
// ─── GET own profile ───────────────────────────────────────────────────────────
exports.getProfile = async (req, res) => {
try {
const user = await mdl_Users.findByPk(req.user.user_id, {
@@ -30,6 +35,7 @@ exports.getProfile = async (req, res) => {
};
// ─── PUT update own profile ────────────────────────────────────────────────────
exports.updateProfile = async (req, res) => {
try {
const user = await mdl_Users.findByPk(req.user.user_id);
@@ -51,6 +57,8 @@ exports.updateProfile = async (req, res) => {
attributes: { exclude: ['password', 'otp_code', 'otp_expires_at'] },
});
logActivity(req.user.user_id, 'update_profile');
return R.success(res, 'Profile updated.', updated);
} catch (err) {
console.error('[CLIENT] updateProfile error:', err);
@@ -59,11 +67,12 @@ exports.updateProfile = async (req, res) => {
};
// ─── GET own sessions ──────────────────────────────────────────────────────────
exports.getSessions = async (req, res) => {
try {
const sessions = await mdl_UserSessions.findAll({
where: { user_id: req.user.user_id, is_active: true },
order: [['createdAt', 'DESC']],
where: { user_id: req.user.user_id, is_active: true },
order: [['createdAt', 'DESC']],
attributes: { exclude: ['refresh_token_hash'] },
});
return R.success(res, 'Sessions retrieved.', sessions);
@@ -73,6 +82,7 @@ exports.getSessions = async (req, res) => {
};
// ─── DELETE revoke a session ───────────────────────────────────────────────────
exports.revokeSession = async (req, res) => {
try {
const session = await mdl_UserSessions.findOne({
@@ -85,8 +95,86 @@ exports.revokeSession = async (req, res) => {
logout_info: { date: new Date().toISOString(), ip_address: req.ip },
});
logActivity(req.user.user_id, 'revoke_session', { entityType: 'session', entityId: session.session_id });
return R.success(res, 'Session revoked.');
} catch (err) {
return R.error(res, 'Could not revoke session.', 500);
}
};
// ─── POST upload own avatar ────────────────────────────────────────────────────
exports.uploadAvatar = async (req, res) => {
try {
if (!req.file) return R.error(res, 'No file provided.', 400);
const user = await mdl_Users.findByPk(req.user.user_id);
// Remove old avatar from S3 before replacing
const oldKey = user.personal_info?.avatar?.uuid;
if (oldKey) await deleteFile(oldKey).catch(() => {});
const { url, uuid } = await uploadFile({
buffer: req.file.buffer,
originalname: req.file.originalname,
mimetype: req.file.mimetype,
ownerType: 'avatar',
});
const merged = {
...(user.personal_info || {}),
avatar: {
url,
uuid,
name: req.file.originalname,
mime_type: req.file.mimetype,
size: req.file.size,
},
};
await user.update({ personal_info: merged });
const updated = await mdl_Users.findByPk(req.user.user_id, {
attributes: { exclude: ['password', 'otp_code', 'otp_expires_at'] },
});
return R.success(res, 'Avatar updated.', updated);
} catch (err) {
console.error('[CLIENT] uploadAvatar error:', err);
return R.error(res, 'Avatar upload failed.', 500);
}
};
// ─── DELETE remove own avatar ──────────────────────────────────────────────────
exports.deleteAvatar = async (req, res) => {
try {
const user = await mdl_Users.findByPk(req.user.user_id);
const key = user.personal_info?.avatar?.uuid;
if (!key) return R.error(res, 'No avatar to remove.', 404);
await deleteFile(key).catch(() => {});
const merged = { ...(user.personal_info || {}), avatar: null };
await user.update({ personal_info: merged });
return R.success(res, 'Avatar removed.');
} catch (err) {
console.error('[CLIENT] deleteAvatar error:', err);
return R.error(res, 'Could not remove avatar.', 500);
}
};
// ─── GET own achievements ──────────────────────────────────────────────────────
exports.getAchievements = async (req, res) => {
try {
const achievements = await mdl_Achievements.findAll({
where: { user_id: req.user.user_id },
order: [['granted_at', 'DESC']],
});
return R.success(res, 'Achievements retrieved.', achievements);
} catch (err) {
return R.error(res, 'Could not retrieve achievements.', 500);
}
};
+682
View File
@@ -0,0 +1,682 @@
/***********************************************************************************************************************************************************************
* File Name: task.controller.js (client)
* Type of Program: Controller
* Description: Client-level task access.
* Users can view groups they belong to, task lists assigned to those
* groups, tasks within those lists, and submit work for tasks.
* Read-only except for completions.
*
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jun. 13, 2026
***********************************************************************************************************************************************************************/
const { Op } = require('sequelize');
const sequelize = require('../../config/db.config');
const { Task, TaskList, TaskRequirement, TaskListGroup } = require('../../models/task/task.mdl');
const { TaskCompletion, TaskCompletionFile } = require('../../models/task/task_completion.mdl');
const { TaskLinkVisit, TaskProgress } = require('../../models/task/task_progress.mdl');
const { mdl_UserGroups, mdl_UserGroupMembers } = require('../../models/users/user_groups.mdl');
const mdl_Users = require('../../models/users/users.mdl');
const { userExclude } = require('../../models/task/task.attributes');
const { clientExclude } = require('../../models/task/task_completion.attributes');
const logActivity = require('../../utils/logActivity.util');
const R = require('../../utils/response.util');
// =============================================================================
// ── GROUPS ────────────────────────────────────────────────────────────────────
// =============================================================================
// ─── GET MY GROUPS ────────────────────────────────────────────────────────────
// GET /client/groups
// Returns all active groups the authenticated user belongs to.
exports.getMyGroups = async (req, res) => {
try {
const groups = await mdl_UserGroups.findAll({
include: [
{
model: mdl_Users,
as: 'members',
where: { user_id: req.user.user_id },
attributes: [],
through: {
model: mdl_UserGroupMembers,
attributes: ['joined_at'],
where: { deletedAt: null },
},
},
],
where: { is_active: true },
attributes: ['group_id', 'name', 'group_code', 'description'],
order: [['name', 'ASC']],
});
return R.success(res, 'Groups retrieved.', groups);
} catch (err) {
console.error('[CLIENT][GET MY GROUPS]', err);
return R.error(res, 'Could not retrieve groups.', 500);
}
};
// ─── GET ONE GROUP ────────────────────────────────────────────────────────────
// GET /client/groups/:groupId
// Returns group info — verifies the user is a member before responding.
exports.getMyGroup = async (req, res) => {
try {
const { groupId } = req.params;
const group = await mdl_UserGroups.findOne({
where: { group_id: groupId, is_active: true },
attributes: ['group_id', 'name', 'group_code', 'description'],
include: [
{
model: mdl_Users,
as: 'members',
where: { user_id: req.user.user_id },
attributes: [],
through: {
model: mdl_UserGroupMembers,
attributes: [],
where: { deletedAt: null },
},
},
],
});
if (!group) return R.error(res, 'Group not found or you are not a member.', 404);
return R.success(res, 'Group retrieved.', group);
} catch (err) {
console.error('[CLIENT][GET MY GROUP]', err);
return R.error(res, 'Could not retrieve group.', 500);
}
};
// =============================================================================
// ── TASK LISTS ────────────────────────────────────────────────────────────────
// =============================================================================
// ─── Helper: verify user is member of group ───────────────────────────────────
const isMember = async (userId, groupId) => {
const membership = await mdl_UserGroupMembers.findOne({
where: { user_id: userId, group_id: groupId, deletedAt: null },
});
return !!membership;
};
// ─────────────────────────────────────────────────────────────────────────────
// REPLACEMENT: getGroupTaskList in task.controller.js (client)
//
// GET /client/groups/:groupId/task-lists/:taskListId?status=ongoing|done|overdue
//
// has_completed is now computed per-task as: ALL of the task's requirements
// individually have a completion signal — matching RequirementsStatusPanel's
// "Overall progress: X / Y done" logic exactly.
//
// Per-requirement-type completion:
// upload_file → task has at least one TaskCompletion (binary, task-level)
// visit_link → a TaskLinkVisit exists for THIS requirement_id
// read_course/
// read_unit/
// read_lesson → a TaskProgress with completed=true exists for THIS
// requirement_id (+ reference_id)
//
// Task bucket:
// done → every requirement passes its check above
// (a task with zero requirements is vacuously "ongoing", per
// earlier spec — zero requirements should not normally happen)
// overdue → not done AND task.deadline < now
// ongoing → otherwise
// ─────────────────────────────────────────────────────────────────────────────
exports.getGroupTaskList = async (req, res) => {
try {
const { groupId, taskListId } = req.params;
const { status } = req.query; // optional: 'ongoing' | 'done' | 'overdue'
const userId = req.user.user_id;
const member = await isMember(userId, groupId);
if (!member) return R.error(res, 'Group not found or you are not a member.', 403);
const taskList = await TaskList.findOne({
where: { task_list_id: taskListId },
attributes: { exclude: userExclude },
include: [
{
model: mdl_UserGroups,
as: 'groups',
where: { group_id: groupId },
attributes: [],
through: { model: TaskListGroup, attributes: [] },
},
{
model: Task,
as: 'tasks',
required: false,
attributes: { exclude: userExclude },
include: [{
model: TaskRequirement,
as: 'requirements',
attributes: { exclude: userExclude },
order: [['order', 'ASC']],
}],
order: [['createdAt', 'ASC']],
},
],
});
if (!taskList) return R.error(res, 'Task list not found or not assigned to your group.', 404);
const json = taskList.toJSON();
const tasks = json.tasks ?? [];
const taskIds = tasks.map((t) => t.task_id);
// ── Fetch user's completion signals for these tasks ────────────────────
const [completions, linkVisits, progressRows] = await Promise.all([
taskIds.length
? TaskCompletion.findAll({
where: { task_id: { [Op.in]: taskIds }, user_id: userId },
attributes: ['task_id'],
})
: [],
taskIds.length
? TaskLinkVisit.findAll({
where: { task_id: { [Op.in]: taskIds }, user_id: userId },
attributes: ['task_id', 'requirement_id'],
})
: [],
taskIds.length
? TaskProgress.findAll({
where: { task_id: { [Op.in]: taskIds }, user_id: userId, completed: true },
attributes: ['task_id', 'requirement_id', 'reference_id'],
})
: [],
]);
// ── Lookup sets/maps for per-requirement checks ─────────────────────────
const tasksWithCompletion = new Set(completions.map((c) => c.task_id));
const visitedRequirementIds = new Set(linkVisits.map((v) => v.requirement_id));
const completedProgressKeys = new Set(
progressRows.map((p) => `${p.requirement_id}:${p.reference_id}`)
);
const now = Date.now();
// ── Bucket each task by per-requirement completion ──────────────────────
const bucketedTasks = tasks.map((task) => {
const requirements = task.requirements ?? [];
const allRequirementsDone = requirements.length > 0 && requirements.every((r) => {
switch (r.type) {
case 'upload_file':
return tasksWithCompletion.has(task.task_id);
case 'visit_link':
return visitedRequirementIds.has(r.requirement_id);
case 'read_course':
case 'read_unit':
case 'read_lesson':
return completedProgressKeys.has(`${r.requirement_id}:${r.reference_id}`);
default:
return true; // unknown requirement types don't block completion
}
});
const has_completed = allRequirementsDone;
let bucket;
if (has_completed) {
bucket = 'done';
} else if (task.deadline && new Date(task.deadline).getTime() < now) {
bucket = 'overdue';
} else {
bucket = 'ongoing';
}
return { ...task, has_completed, _bucket: bucket };
});
// ── Filter by requested status, strip internal _bucket field ──────────
const filteredTasks = status
? bucketedTasks.filter((t) => t._bucket === status)
: bucketedTasks;
json.tasks = filteredTasks.map(({ _bucket, ...rest }) => rest);
return R.success(res, 'Task list retrieved.', json);
} catch (err) {
console.error('[CLIENT][GET GROUP TASK LIST]', err);
return R.error(res, 'Could not retrieve task list.', 500);
}
};
// ─────────────────────────────────────────────────────────────────────────────
// REPLACEMENT: getGroupTaskLists in task.controller.js (client) — plural
//
// GET /client/groups/:groupId/task-lists?status=ongoing|done|overdue
//
// Updated to match getGroupTaskList (singular): has_completed per task now
// means ALL of that task's requirements individually have a completion signal
// (not just "any"), matching RequirementsStatusPanel's "X / Y done" logic.
//
// TaskList bucket (based on per-task has_completed, computed below):
// TaskList has zero tasks → Ongoing (nothing to do yet)
// ALL tasks have has_completed → Done
// NOT all done AND any incomplete
// task has deadline < now → Overdue
// Otherwise → Ongoing
// ─────────────────────────────────────────────────────────────────────────────
exports.getGroupTaskLists = async (req, res) => {
try {
const { groupId } = req.params;
const { status } = req.query; // optional: 'ongoing' | 'done' | 'overdue'
const userId = req.user.user_id;
const member = await isMember(userId, groupId);
if (!member) return R.error(res, 'Group not found or you are not a member.', 403);
// ── Fetch ALL task lists assigned to this group, no status filter ─────
const taskLists = await TaskList.findAll({
include: [
{
model: mdl_UserGroups,
as: 'groups',
where: { group_id: groupId },
attributes: [],
through: { model: TaskListGroup, attributes: [] },
},
{
model: Task,
as: 'tasks',
required: false,
attributes: { exclude: userExclude },
include: [
{
model: TaskRequirement,
as: 'requirements',
attributes: { exclude: userExclude },
order: [['order', 'ASC']],
},
],
order: [['createdAt', 'ASC']],
},
],
attributes: { exclude: userExclude },
order: [['createdAt', 'ASC']],
});
// ── Gather all task_ids across the group's task lists ──────────────────
const allTasks = taskLists.flatMap((tl) => tl.tasks ?? []);
const taskIds = allTasks.map((t) => t.task_id);
// ── Fetch user's completion signals for these tasks ────────────────────
const [completions, linkVisits, progressRows] = await Promise.all([
taskIds.length
? TaskCompletion.findAll({
where: { task_id: { [Op.in]: taskIds }, user_id: userId },
attributes: ['task_id'],
})
: [],
taskIds.length
? TaskLinkVisit.findAll({
where: { task_id: { [Op.in]: taskIds }, user_id: userId },
attributes: ['task_id', 'requirement_id'],
})
: [],
taskIds.length
? TaskProgress.findAll({
where: { task_id: { [Op.in]: taskIds }, user_id: userId, completed: true },
attributes: ['task_id', 'requirement_id', 'reference_id'],
})
: [],
]);
// ── Lookup sets/maps for per-requirement checks ─────────────────────────
const tasksWithCompletion = new Set(completions.map((c) => c.task_id));
const visitedRequirementIds = new Set(linkVisits.map((v) => v.requirement_id));
const completedProgressKeys = new Set(
progressRows.map((p) => `${p.requirement_id}:${p.reference_id}`)
);
const now = Date.now();
// ── Compute per-task has_completed via per-requirement checks ──────────
const computeHasCompleted = (task) => {
const requirements = task.requirements ?? [];
if (requirements.length === 0) return false; // vacuously not done
return requirements.every((r) => {
switch (r.type) {
case 'upload_file':
return tasksWithCompletion.has(task.task_id);
case 'visit_link':
return visitedRequirementIds.has(r.requirement_id);
case 'read_course':
case 'read_unit':
case 'read_lesson':
return completedProgressKeys.has(`${r.requirement_id}:${r.reference_id}`);
default:
return true;
}
});
};
// ── Bucket each task list based on per-task has_completed ───────────────
const bucketed = taskLists.map((tl) => {
const json = tl.toJSON();
const tasks = json.tasks ?? [];
tasks.forEach((task) => {
task.has_completed = computeHasCompleted(task);
});
let bucket;
if (tasks.length === 0) {
bucket = 'ongoing';
} else {
const allDone = tasks.every((t) => t.has_completed);
if (allDone) {
bucket = 'done';
} else {
const anyOverdue = tasks.some((t) =>
!t.has_completed && t.deadline && new Date(t.deadline).getTime() < now
);
bucket = anyOverdue ? 'overdue' : 'ongoing';
}
}
return { ...json, tasks, _bucket: bucket };
});
// ── Filter by requested status, then strip internal _bucket field ──────
const filtered = status
? bucketed.filter((tl) => tl._bucket === status)
: bucketed;
const data = filtered.map(({ _bucket, ...rest }) => rest);
return R.success(res, 'Task lists retrieved.', data);
} catch (err) {
console.error('[CLIENT][GET GROUP TASK LISTS]', err);
return R.error(res, 'Could not retrieve task lists.', 500);
}
};
// =============================================================================
// ── TASKS ─────────────────────────────────────────────────────────────────────
// =============================================================================
// ─── GET ONE TASK ─────────────────────────────────────────────────────────────
// GET /client/groups/:groupId/task-lists/:taskListId/tasks/:taskId
//
// Returns the task with its requirements + the user's latest completion.
exports.getTask = async (req, res) => {
try {
const { groupId, taskListId, taskId } = req.params;
const member = await isMember(req.user.user_id, groupId);
if (!member) return R.error(res, 'Group not found or you are not a member.', 403);
const task = await Task.findOne({
where: { task_id: taskId, task_list_id: taskListId },
attributes: { exclude: userExclude },
include: [
{
model: TaskList,
as: 'taskList',
attributes: { exclude: userExclude },
include: [
{
model: mdl_UserGroups,
as: 'groups',
where: { group_id: groupId },
attributes: [],
through: { model: TaskListGroup, attributes: [] },
},
],
},
{
model: TaskRequirement,
as: 'requirements',
attributes: { exclude: userExclude },
order: [['order', 'ASC']],
},
{
// Latest completion by this user
model: TaskCompletion,
as: 'completions',
where: { user_id: req.user.user_id },
required: false,
attributes: { exclude: clientExclude },
include: [{
model: TaskCompletionFile,
as: 'files',
attributes: { exclude: clientExclude },
separate: true,
order: [['createdAt', 'ASC']],
}],
order: [['submitted_at', 'DESC']],
limit: 1,
},
],
});
if (!task) return R.error(res, 'Task not found or not accessible.', 404);
// Flatten: expose latest_completion directly instead of array
const data = task.toJSON();
data.latest_completion = data.completions?.[0] ?? null;
delete data.completions;
return R.success(res, 'Task retrieved.', data);
} catch (err) {
console.error('[CLIENT][GET TASK]', err);
return R.error(res, 'Could not retrieve task.', 500);
}
};
// =============================================================================
// ── SUBMISSIONS ───────────────────────────────────────────────────────────────
// =============================================================================
// ─── GET MY SUBMISSIONS FOR A TASK ───────────────────────────────────────────
// GET /client/groups/:groupId/task-lists/:taskListId/tasks/:taskId/completions
// Returns all past completions by this user for this task (newest first).
exports.getMySubmissions = async (req, res) => {
try {
const { groupId, taskListId, taskId } = req.params;
const member = await isMember(req.user.user_id, groupId);
if (!member) return R.error(res, 'Group not found or you are not a member.', 403);
const task = await Task.findOne({ where: { task_id: taskId, task_list_id: taskListId } });
if (!task) return R.error(res, 'Task not found.', 404);
const completions = await TaskCompletion.findAll({
where: { task_id: taskId, user_id: req.user.user_id },
attributes: { exclude: clientExclude },
include: [{
model: TaskCompletionFile,
as: 'files',
attributes: { exclude: clientExclude },
}],
order: [['submitted_at', 'DESC']],
});
return R.success(res, 'Completions retrieved.', completions);
} catch (err) {
console.error('[CLIENT][GET MY SUBMISSIONS]', err);
return R.error(res, 'Could not retrieve completions.', 500);
}
};
// ─── SUBMIT ───────────────────────────────────────────────────────────────────
//
// Adds validation against the task's `upload_file` TaskRequirement:
// - allowed_file_types: array of uppercase extensions (e.g. ["PDF","DOCX",...])
// - max_file_count: integer cap on number of files per completion
//
// Validation happens BEFORE creating the TaskCompletion row, at submit time only
// (not at /upload). If validation fails, the transaction is rolled back and a
// 400 is returned — the already-uploaded files remain orphaned in S3, which is
// acceptable per current design (no cleanup-on-reject requirement).
exports.submitTask = async (req, res) => {
const t = await sequelize.transaction();
try {
const { groupId, taskListId, taskId } = req.params;
const { note, files = [] } = req.body;
const member = await isMember(req.user.user_id, groupId);
if (!member) { await t.rollback(); return R.error(res, 'Group not found or you are not a member.', 403); }
const task = await Task.findOne({ where: { task_id: taskId, task_list_id: taskListId }, transaction: t });
if (!task) { await t.rollback(); return R.error(res, 'Task not found.', 404); }
if (!files.length) {
await t.rollback();
return R.error(res, 'At least one file is required to submit.', 400);
}
// Validate file entries have required fields
const invalid = files.some((f) => !f.file_url || !f.file_name);
if (invalid) {
await t.rollback();
return R.error(res, 'Each file must have file_url and file_name.', 400);
}
// ── Validate against upload_file requirement (if defined) ──────────────
const uploadRequirement = await TaskRequirement.findOne({
where: { task_id: taskId, type: 'upload_file' },
transaction: t,
});
if (uploadRequirement) {
// ── max_file_count ───────────────────────────────────────────────────
const maxFiles = uploadRequirement.max_file_count;
if (maxFiles && files.length > maxFiles) {
await t.rollback();
return R.error(
res,
`You can only submit up to ${maxFiles} file${maxFiles !== 1 ? 's' : ''} for this task.`,
400
);
}
// ── allowed_file_types ───────────────────────────────────────────────
const allowedTypes = (uploadRequirement.allowed_file_types ?? [])
.map((ext) => String(ext).toUpperCase());
if (allowedTypes.length) {
const rejected = files.filter((f) => {
const ext = (f.file_name.split('.').pop() ?? '').toUpperCase();
return !allowedTypes.includes(ext);
});
if (rejected.length) {
await t.rollback();
const rejectedNames = rejected.map((f) => f.file_name).join(', ');
return R.error(
res,
`These files are not allowed: ${rejectedNames}. Allowed types: ${allowedTypes.join(', ')}.`,
400
);
}
}
}
// ── Create completion ──────────────────────────────────────────────────
const completion = await TaskCompletion.create({
task_id: taskId,
user_id: req.user.user_id,
note: note || null,
submitted_at: new Date(),
createdBy: req.user.user_id,
updatedBy: req.user.user_id,
}, { transaction: t });
const fileRows = files.map((f) => ({
completion_id: completion.completion_id,
file_url: f.file_url,
file_name: f.file_name,
file_size: f.file_size ?? null,
mime_type: f.mime_type ?? null,
storage_key: f.storage_key ?? null,
createdBy: req.user.user_id,
updatedBy: req.user.user_id,
}));
await TaskCompletionFile.bulkCreate(fileRows, { transaction: t });
await t.commit();
// Return full completion with files
const full = await TaskCompletion.findByPk(completion.completion_id, {
attributes: { exclude: clientExclude },
include: [{
model: TaskCompletionFile,
as: 'files',
attributes: { exclude: clientExclude },
}],
});
logActivity(req.user.user_id, 'submit_task', {
entityType: 'task',
entityId: Number(taskId),
});
return R.success(res, 'Task submitted successfully.', full, 201);
} catch (err) {
await t.rollback();
console.error('[CLIENT][SUBMIT TASK]', err);
return R.error(res, 'Could not submit task.', 500);
}
};
// ─────────────────────────────────────────────────────────────────────────────
// ADD THIS to the bottom of task.controller.js (client)
// ─────────────────────────────────────────────────────────────────────────────
// ─── GET LATEST COMPLETION ────────────────────────────────────────────────────
// GET /client/groups/:groupId/task-lists/:taskListId/tasks/:taskId/completions/latest
// Returns only the most recent completion for this user on this task.
// Returns null if the user has not submitted yet — that is valid.
exports.getLatestCompletion = async (req, res) => {
try {
const { groupId, taskListId, taskId } = req.params;
const member = await isMember(req.user.user_id, groupId);
if (!member) return R.error(res, 'Group not found or you are not a member.', 403);
const task = await Task.findOne({
where: { task_id: taskId, task_list_id: taskListId },
});
if (!task) return R.error(res, 'Task not found.', 404);
const completion = await TaskCompletion.findOne({
where: { task_id: taskId, user_id: req.user.user_id },
attributes: { exclude: clientExclude },
include: [{
model: TaskCompletionFile,
as: 'files',
attributes: { exclude: clientExclude },
separate: true,
order: [['createdAt', 'ASC']],
}],
order: [['submitted_at', 'DESC']],
});
return R.success(res, 'Latest completion retrieved.', completion ?? null);
} catch (err) {
console.error('[CLIENT][GET LATEST COMPLETION]', err);
return R.error(res, 'Could not retrieve latest completion.', 500);
}
};
@@ -0,0 +1,172 @@
/***********************************************************************************************************************************************************************
* File Name: task_download.controller.js (client)
* Type of Program: Controller
* Description: Proxies file downloads for task completion attachments through
* the backend, so the raw Garage/S3 URL is never exposed to the
* browser. Sets Content-Disposition: attachment with the original
* filename.
*
* storage_key is DERIVED from file_url at request time (no schema
* change needed) by stripping the known S3_PUBLIC_URL + bucket
* prefix, since both are constants defined in s3.service.js / .env.
*
* Route: GET /client/groups/:groupId/task-lists/:taskListId/tasks/:taskId/completions/:completionId/files/:fileId/download
*
* Access: only the completion's owner (req.user.user_id === completion.user_id)
* can download — admin downloads go through a separate admin route.
*
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jun. 15, 2026
***********************************************************************************************************************************************************************/
const { Task, TaskList, TaskListGroup } = require('../../models/task/task.mdl');
const { TaskCompletion, TaskCompletionFile } = require('../../models/task/task_completion.mdl');
const { mdl_UserGroups, mdl_UserGroupMembers } = require('../../models/users/user_groups.mdl');
const { getObjectStream } = require('../../services/s3.service');
const R = require('../../utils/response.util');
// ─── Helper: verify user is a member of the group ─────────────────────────────
const isMember = async (userId, groupId) => {
const membership = await mdl_UserGroupMembers.findOne({
where: { user_id: userId, group_id: groupId, deletedAt: null },
});
return !!membership;
};
// ─── Helper: derive S3 storage_key from a public file_url ─────────────────────
// Strips "{S3_PUBLIC_URL}/{S3_BUCKET}/" prefix, leaving e.g. "images/uuid.jpg"
const deriveStorageKey = (fileUrl) => {
const publicUrl = (process.env.S3_PUBLIC_URL || '').replace(/\/$/, '');
const bucket = process.env.S3_BUCKET || 'philproperties';
const prefix = `${publicUrl}/${bucket}/`;
if (fileUrl && fileUrl.startsWith(prefix)) {
return fileUrl.slice(prefix.length);
}
return null;
};
// =============================================================================
// ── STREAM FILE (inline preview — no Content-Disposition: attachment) ────────
// =============================================================================
//
// GET /client/groups/:groupId/task-lists/:taskListId/tasks/:taskId/completions/:completionId/files/:fileId/stream
//
// Used by FilePreview.jsx for <img>/<video>/<audio>/<iframe> src — proxies the
// object inline so the raw Garage/S3 URL never appears, but does NOT force
// download (no Content-Disposition: attachment).
exports.streamCompletionFile = async (req, res) => {
try {
const { groupId, taskListId, taskId, completionId, fileId } = req.params;
const member = await isMember(req.user.user_id, groupId);
if (!member) return R.error(res, 'Group not found or you are not a member.', 403);
const task = await Task.findOne({
where: { task_id: taskId, task_list_id: taskListId },
include: [{
model: TaskList,
as: 'taskList',
required: true,
include: [{
model: mdl_UserGroups,
as: 'groups',
where: { group_id: groupId },
required: true,
attributes: [],
through: { model: TaskListGroup, attributes: [] },
}],
}],
});
if (!task) return R.error(res, 'Task not found or not accessible.', 404);
const completion = await TaskCompletion.findOne({
where: { completion_id: completionId, task_id: taskId, user_id: req.user.user_id },
});
if (!completion) return R.error(res, 'Completion not found.', 404);
const file = await TaskCompletionFile.findOne({
where: { file_id: fileId, completion_id: completionId },
});
if (!file) return R.error(res, 'File not found.', 404);
const storageKey = deriveStorageKey(file.file_url);
if (!storageKey) {
return R.error(res, 'This file cannot be previewed (unrecognized storage URL).', 422);
}
const { stream, contentType, contentLength } = await getObjectStream(storageKey);
res.setHeader('Content-Type', contentType || file.mime_type || 'application/octet-stream');
if (contentLength) res.setHeader('Content-Length', contentLength);
// No Content-Disposition — browser renders inline based on Content-Type
stream.pipe(res);
} catch (err) {
console.error('[CLIENT][STREAM COMPLETION FILE]', err);
return R.error(res, 'Could not load file.', 500);
}
};
// =============================================================================
// ── DOWNLOAD FILE ──────────────────────────────────────────────────────────────
// =============================================================================
exports.downloadCompletionFile = async (req, res) => {
try {
const { groupId, taskListId, taskId, completionId, fileId } = req.params;
// ── Validate member ───────────────────────────────────────────────────
const member = await isMember(req.user.user_id, groupId);
if (!member) return R.error(res, 'Group not found or you are not a member.', 403);
// ── Validate task belongs to task list + group ────────────────────────
const task = await Task.findOne({
where: { task_id: taskId, task_list_id: taskListId },
include: [{
model: TaskList,
as: 'taskList',
required: true,
include: [{
model: mdl_UserGroups,
as: 'groups',
where: { group_id: groupId },
required: true,
attributes: [],
through: { model: TaskListGroup, attributes: [] },
}],
}],
});
if (!task) return R.error(res, 'Task not found or not accessible.', 404);
// ── Validate completion belongs to this user + task ───────────────────
const completion = await TaskCompletion.findOne({
where: { completion_id: completionId, task_id: taskId, user_id: req.user.user_id },
});
if (!completion) return R.error(res, 'Completion not found.', 404);
// ── Validate file belongs to completion ───────────────────────────────
const file = await TaskCompletionFile.findOne({
where: { file_id: fileId, completion_id: completionId },
});
if (!file) return R.error(res, 'File not found.', 404);
// ── Derive storage_key from file_url ──────────────────────────────────
const storageKey = deriveStorageKey(file.file_url);
if (!storageKey) {
return R.error(res, 'This file cannot be downloaded (unrecognized storage URL).', 422);
}
// ── Stream from S3/Garage ──────────────────────────────────────────────
const { stream, contentType, contentLength } = await getObjectStream(storageKey);
res.setHeader('Content-Type', contentType || file.mime_type || 'application/octet-stream');
if (contentLength) res.setHeader('Content-Length', contentLength);
res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(file.file_name)}"`);
stream.pipe(res);
} catch (err) {
console.error('[CLIENT][DOWNLOAD COMPLETION FILE]', err);
return R.error(res, 'Could not download file.', 500);
}
};
@@ -0,0 +1,371 @@
/***********************************************************************************************************************************************************************
* File Name: task_progress.controller.js (client)
* Type of Program: Controller
* Description: Client-side progress tracking via UPSERT for all requirement types.
*
* GET /client/groups/:groupId/task-lists/:taskListId/tasks/:taskId/progress
* → returns full progress snapshot: { link_visits, progress }
*
* POST /client/groups/:groupId/task-lists/:taskListId/tasks/:taskId/requirements/:requirementId/visit
* → UPSERT TaskLinkVisit (visit_link)
*
* POST /client/groups/:groupId/task-lists/:taskListId/tasks/:taskId/requirements/:requirementId/progress
* → UPSERT TaskProgress (read_lesson) + derives read_unit + read_course
*
* UPSERT keys:
* TaskLinkVisit : (requirement_id, user_id)
* TaskProgress : (requirement_id, user_id, reference_id)
*
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jun. 13, 2026
***********************************************************************************************************************************************************************/
const sequelize = require('../../config/db.config');
const { Task, TaskRequirement } = require('../../models/task/task.mdl');
const { TaskLinkVisit, TaskProgress } = require('../../models/task/task_progress.mdl');
const { mdl_UserGroupMembers } = require('../../models/users/user_groups.mdl');
const logActivity = require('../../utils/logActivity.util');
const R = require('../../utils/response.util');
// ─── Helper: verify user is member of group ───────────────────────────────────
const isMember = async (userId, groupId) => {
const membership = await mdl_UserGroupMembers.findOne({
where: { user_id: userId, group_id: groupId, deletedAt: null },
});
return !!membership;
};
// ─── Helper: verify requirement belongs to task ───────────────────────────────
const getRequirement = async (requirementId, taskId) => {
return TaskRequirement.findOne({
where: { requirement_id: requirementId, task_id: taskId },
});
};
// ─── Helper: derive unit completion ──────────────────────────────────────────
// Unit is complete when ALL read_lesson progress rows under this unit requirement
// for this user are marked completed.
const deriveUnitCompletion = async (userId, unitRequirementId, t) => {
const rows = await TaskProgress.findAll({
where: {
requirement_id: unitRequirementId,
user_id: userId,
type: 'read_lesson',
},
transaction: t,
});
if (!rows.length) return false;
return rows.every((r) => r.completed);
};
// ─── Helper: derive course completion ────────────────────────────────────────
// Course is complete when ALL read_unit progress rows under this course requirement
// for this user are marked completed.
const deriveCourseCompletion = async (userId, courseRequirementId, t) => {
const rows = await TaskProgress.findAll({
where: {
requirement_id: courseRequirementId,
user_id: userId,
type: 'read_unit',
},
transaction: t,
});
if (!rows.length) return false;
return rows.every((r) => r.completed);
};
// =============================================================================
// ── GET FULL PROGRESS SNAPSHOT ────────────────────────────────────────────────
// =============================================================================
// GET /client/groups/:groupId/task-lists/:taskListId/tasks/:taskId/progress
// Called once on ViewTaskDetails mount.
// Returns { link_visits: [], progress: [] } — frontend builds lookup maps from these.
exports.getTaskProgress = async (req, res) => {
try {
const { groupId, taskListId, taskId } = req.params;
const member = await isMember(req.user.user_id, groupId);
if (!member) return R.error(res, 'Group not found or you are not a member.', 403);
const task = await Task.findOne({
where: { task_id: taskId, task_list_id: taskListId },
});
if (!task) return R.error(res, 'Task not found.', 404);
const [linkVisits, progress] = await Promise.all([
TaskLinkVisit.findAll({
where: { task_id: taskId, user_id: req.user.user_id },
attributes: ['visit_id', 'requirement_id', 'visited_at'],
}),
TaskProgress.findAll({
where: { task_id: taskId, user_id: req.user.user_id },
attributes: ['progress_id', 'requirement_id', 'reference_id', 'type', 'completed', 'completed_at'],
}),
]);
return R.success(res, 'Task progress retrieved.', { link_visits: linkVisits, progress });
} catch (err) {
console.error('[CLIENT][GET TASK PROGRESS]', err);
return R.error(res, 'Could not retrieve task progress.', 500);
}
};
// =============================================================================
// ── VISIT LINK (UPSERT) ───────────────────────────────────────────────────────
// =============================================================================
// POST /client/groups/:groupId/task-lists/:taskListId/tasks/:taskId/requirements/:requirementId/visit
//
// UPSERT on (requirement_id, user_id):
// First visit → INSERT new row
// Revisit → UPDATE visited_at to NOW()
exports.visitLink = async (req, res) => {
const t = await sequelize.transaction();
try {
const { groupId, taskListId, taskId, requirementId } = req.params;
const member = await isMember(req.user.user_id, groupId);
if (!member) { await t.rollback(); return R.error(res, 'Group not found or you are not a member.', 403); }
const requirement = await getRequirement(requirementId, taskId);
if (!requirement) { await t.rollback(); return R.error(res, 'Requirement not found.', 404); }
if (requirement.type !== 'visit_link') {
await t.rollback();
return R.error(res, 'Requirement is not a visit_link type.', 400);
}
const now = new Date();
const [record, created] = await TaskLinkVisit.upsert(
{
task_id: taskId,
requirement_id: requirementId,
user_id: req.user.user_id,
visited_at: now,
createdBy: req.user.user_id,
updatedBy: req.user.user_id,
},
{
conflictFields: ['requirement_id', 'user_id'],
returning: true,
transaction: t,
}
);
await t.commit();
if (created) {
logActivity(req.user.user_id, 'visit_link', {
entityType: 'task',
entityId: Number(taskId),
details: { requirement_id: requirementId },
});
}
return R.success(
res,
created ? 'Link visited.' : 'Link visit updated.',
{ requirement_id: requirementId, visited_at: now },
created ? 201 : 200
);
} catch (err) {
await t.rollback();
console.error('[CLIENT][VISIT LINK]', err);
return R.error(res, 'Could not record link visit.', 500);
}
};
// =============================================================================
// ── UPDATE LESSON PROGRESS (UPSERT — derives unit + course) ──────────────────
// =============================================================================
// POST /client/groups/:groupId/task-lists/:taskListId/tasks/:taskId/requirements/:requirementId/progress
//
// Body:
// {
// reference_id : UUID — lesson_id being marked
// completed : boolean
// unit_requirement_id? : UUID — read_unit requirement this lesson belongs to
// course_requirement_id?: UUID — read_course requirement this unit belongs to
// }
//
// Flow:
// 1. UPSERT lesson progress row
// 2. If unit_requirement_id provided → derive unit completion → UPSERT unit row
// 3. If course_requirement_id provided → derive course completion → UPSERT course row
exports.updateProgress = async (req, res) => {
const t = await sequelize.transaction();
try {
const { groupId, taskListId, taskId, requirementId } = req.params;
const { reference_id, completed, unit_requirement_id, course_requirement_id } = req.body;
if (!reference_id || completed === undefined) {
await t.rollback();
return R.error(res, 'reference_id and completed are required.', 400);
}
const member = await isMember(req.user.user_id, groupId);
if (!member) { await t.rollback(); return R.error(res, 'Group not found or you are not a member.', 403); }
const requirement = await getRequirement(requirementId, taskId);
if (!requirement) { await t.rollback(); return R.error(res, 'Requirement not found.', 404); }
const ALLOWED = ['read_lesson', 'read_unit', 'read_course'];
if (!ALLOWED.includes(requirement.type)) {
await t.rollback();
return R.error(res, `Cannot update progress for requirement type: ${requirement.type}.`, 400);
}
const now = new Date();
const userId = req.user.user_id;
// ── Direct UPSERT for read_unit / read_course ─────────────────────────
if (requirement.type === 'read_unit' || requirement.type === 'read_course') {
await TaskProgress.upsert(
{
task_id: taskId,
requirement_id: requirementId,
user_id: userId,
reference_id,
type: requirement.type,
completed: !!completed,
completed_at: completed ? now : null,
createdBy: userId,
updatedBy: userId,
},
{
conflictFields: ['requirement_id', 'user_id', 'reference_id'],
transaction: t,
}
);
await t.commit();
return R.success(res, 'Progress updated.', {
requirement_id: requirementId,
reference_id,
completed: !!completed,
completed_at: completed ? now : null,
});
}
// ── 1. UPSERT lesson ──────────────────────────────────────────────────
await TaskProgress.upsert(
{
task_id: taskId,
requirement_id: requirementId,
user_id: userId,
reference_id,
type: 'read_lesson',
completed: !!completed,
completed_at: completed ? now : null,
createdBy: userId,
updatedBy: userId,
},
{
conflictFields: ['requirement_id', 'user_id', 'reference_id'],
transaction: t,
}
);
// ── 2. Derive + UPSERT unit ───────────────────────────────────────────
if (unit_requirement_id) {
const unitReq = await getRequirement(unit_requirement_id, taskId);
if (unitReq && unitReq.type === 'read_unit') {
const unitDone = await deriveUnitCompletion(userId, unit_requirement_id, t);
await TaskProgress.upsert(
{
task_id: taskId,
requirement_id: unit_requirement_id,
user_id: userId,
reference_id: unitReq.reference_id,
type: 'read_unit',
completed: unitDone,
completed_at: unitDone ? now : null,
createdBy: userId,
updatedBy: userId,
},
{
conflictFields: ['requirement_id', 'user_id', 'reference_id'],
transaction: t,
}
);
// ── 3. Derive + UPSERT course ─────────────────────────────────
if (course_requirement_id) {
const courseReq = await getRequirement(course_requirement_id, taskId);
if (courseReq && courseReq.type === 'read_course') {
const courseDone = await deriveCourseCompletion(userId, course_requirement_id, t);
await TaskProgress.upsert(
{
task_id: taskId,
requirement_id: course_requirement_id,
user_id: userId,
reference_id: courseReq.reference_id,
type: 'read_course',
completed: courseDone,
completed_at: courseDone ? now : null,
createdBy: userId,
updatedBy: userId,
},
{
conflictFields: ['requirement_id', 'user_id', 'reference_id'],
transaction: t,
}
);
}
}
}
}
await t.commit();
return R.success(res, 'Progress updated.', {
requirement_id: requirementId,
reference_id,
completed: !!completed,
completed_at: completed ? now : null,
});
} catch (err) {
await t.rollback();
console.error('[CLIENT][UPDATE PROGRESS]', err);
return R.error(res, 'Could not update progress.', 500);
}
};
// ─────────────────────────────────────────────────────────────────────────────
// NOTE: getLatestCompletion lives in task.controller.js as it shares
// the isMember + Task lookup pattern already established there.
// Add this function to the BOTTOM of task.controller.js:
//
// exports.getLatestCompletion = async (req, res) => {
// try {
// const { groupId, taskListId, taskId } = req.params;
//
// const member = await isMember(req.user.user_id, groupId);
// if (!member) return R.error(res, 'Group not found or you are not a member.', 403);
//
// const task = await Task.findOne({ where: { task_id: taskId, task_list_id: taskListId } });
// if (!task) return R.error(res, 'Task not found.', 404);
//
// const completion = await TaskCompletion.findOne({
// where: { task_id: taskId, user_id: req.user.user_id },
// attributes: { exclude: clientExclude },
// include: [{
// model: TaskCompletionFile,
// as: 'files',
// attributes: { exclude: clientExclude },
// separate: true,
// order: [['createdAt', 'ASC']],
// }],
// order: [['submitted_at', 'DESC']],
// });
//
// return R.success(res, 'Latest completion retrieved.', completion ?? null);
// } catch (err) {
// console.error('[CLIENT][GET LATEST COMPLETION]', err);
// return R.error(res, 'Could not retrieve latest completion.', 500);
// }
// };
@@ -0,0 +1,118 @@
/***********************************************************************************************************************************************************************
* File Name: task_upload.controller.js (client)
* Type of Program: Controller
* Description: Handles file uploads for task completion attachments.
* Files are uploaded to S3 (Garage) via s3.service.js.
* Returns file metadata for use in the completion submit payload.
*
* This is intentionally separate from the completion submit endpoint
* so the client can upload files first, then submit completion with
* the returned file references — matching the two-step flow in
* ViewTaskDetails.jsx handleSubmit().
*
* Route: POST /client/groups/:groupId/task-lists/:taskListId/tasks/:taskId/upload
*
* Author: rgrgogu
* Date Created: Jun. 13, 2026
***********************************************************************************************************************************************************************/
const { Task, TaskList, TaskListGroup } = require('../../models/task/task.mdl');
const { mdl_UserGroups, mdl_UserGroupMembers } = require('../../models/users/user_groups.mdl');
const { uploadFile } = require('../../services/s3.service');
const R = require('../../utils/response.util');
// ─── Helper: verify user is a member of the group ─────────────────────────────
const isMember = async (userId, groupId) => {
const membership = await mdl_UserGroupMembers.findOne({
where: { user_id: userId, group_id: groupId, deletedAt: null },
});
return !!membership;
};
// ─── Helper: verify task belongs to task list AND is assigned to this group ───
const getAccessibleTask = async (groupId, taskListId, taskId) => {
return Task.findOne({
where: { task_id: taskId, task_list_id: taskListId },
include: [
{
model: TaskList,
as: 'taskList',
required: true,
include: [
{
model: mdl_UserGroups,
as: 'groups',
where: { group_id: groupId },
required: true,
attributes: [],
through: { model: TaskListGroup, attributes: [] },
},
],
},
],
});
};
// ─── Resolve S3 ownerType from mime type ──────────────────────────────────────
const resolveOwnerType = (mimetype = '') => {
if (mimetype.startsWith('image/')) return 'image';
if (mimetype.startsWith('video/')) return 'video';
if (mimetype.startsWith('audio/')) return 'audio';
return 'document';
};
// =============================================================================
// ── UPLOAD FILE ───────────────────────────────────────────────────────────────
// =============================================================================
// POST /client/groups/:groupId/task-lists/:taskListId/tasks/:taskId/upload
//
// Accepts: multipart/form-data
// file — single file field (multer attaches to req.file)
//
// Returns:
// {
// file_url : "https://garage.philproperties.com/philproperties/documents/uuid.pdf",
// file_name : "social_media_slides.pdf",
// file_size : 2400000,
// mime_type : "application/pdf",
// storage_key: "documents/uuid.pdf" ← for admin reference / future delete
// }
exports.uploadTaskFile = async (req, res) => {
try {
const { groupId, taskListId, taskId } = req.params;
// ── Validate member ───────────────────────────────────────────────────
const member = await isMember(req.user.user_id, groupId);
if (!member) return R.error(res, 'Group not found or you are not a member.', 403);
// ── Validate task accessibility ───────────────────────────────────────
const task = await getAccessibleTask(groupId, taskListId, taskId);
if (!task) return R.error(res, 'Task not found or not accessible.', 404);
// ── Validate file presence ────────────────────────────────────────────
if (!req.file) return R.error(res, 'No file provided.', 400);
const { buffer, originalname, mimetype, size } = req.file;
const ownerType = resolveOwnerType(mimetype);
// ── Upload to S3 ──────────────────────────────────────────────────────
const { url, uuid: storage_key } = await uploadFile({
buffer,
originalname,
mimetype,
ownerType,
});
return R.success(res, 'File uploaded successfully.', {
file_url: url,
file_name: originalname,
file_size: size,
mime_type: mimetype,
storage_key,
}, 201);
} catch (err) {
console.error('[CLIENT][UPLOAD TASK FILE]', err);
return R.error(res, 'Could not upload file.', 500);
}
};
+340
View File
@@ -0,0 +1,340 @@
/***********************************************************************************************************************************************************************
* File Name: tiers.controller.js (client)
* Type of Program: Controller
* Description: User-facing tier and payment endpoints.
* - View active tier + history
* - Browse active plans (with courses per plan)
* - PayPal redirect checkout (create order → capture → cancel)
* - View own payment history
* Author: rgrgogu
* Date Created: Jun. 6, 2026
* Modified: Jun. 9, 2026
***********************************************************************************************************************************************************************/
const mdl_TierPlans = require('../../models/tiers/tier_plans.mdl');
const mdl_UserTiers = require('../../models/tiers/user_tiers.mdl');
const mdl_Payments = require('../../models/tiers/payments.mdl');
const mdl_PlanCourses = require('../../models/tiers/plan_courses.mdl');
const { onTierActivated } = require('../../services/achievements.service');
const { Course } = require('../../models/courses/courses.mdl');
const paypal = require('../../services/paypal.service');
const R = require('../../utils/response.util');
require('../../models/tiers/tier.associations');
// ─── Promo codes ──────────────────────────────────────────────────────────────
const PROMO_CODES = {
PHIL10: 10, // $10 flat discount
};
const calculateCheckoutAmount = (price, promoCode) => {
const subtotalCents = Math.round(Number(price) * 100);
const normalizedCode = promoCode?.trim?.().toUpperCase?.() ?? null;
const discountCents = normalizedCode && PROMO_CODES[normalizedCode]
? Math.min(PROMO_CODES[normalizedCode] * 100, subtotalCents)
: 0;
const totalCents = Math.max(subtotalCents - discountCents, 0);
return {
promoCode: discountCents > 0 ? normalizedCode : null,
subtotal: (subtotalCents / 100).toFixed(2),
discount: (discountCents / 100).toFixed(2),
total: (totalCents / 100).toFixed(2),
};
};
// ─── MY TIER ──────────────────────────────────────────────────────────────────
exports.getMyTier = async (req, res) => {
try {
const tier = await mdl_UserTiers.findOne({
where: { user_id: req.user.user_id, status: 'active' },
order: [['createdAt', 'DESC']],
});
return R.success(res, 'Active tier retrieved.', tier ?? { tier: 'free', status: 'active' });
} catch (err) {
console.error('[CLIENT][GET MY TIER]', err);
return R.error(res, 'Could not retrieve tier.', 500);
}
};
exports.getMyTierHistory = async (req, res) => {
try {
const history = await mdl_UserTiers.findAll({
where: { user_id: req.user.user_id },
order: [['createdAt', 'DESC']],
});
return R.success(res, 'Tier history retrieved.', history);
} catch (err) {
console.error('[CLIENT][GET MY TIER HISTORY]', err);
return R.error(res, 'Could not retrieve tier history.', 500);
}
};
// ─── PLANS (with courses) ─────────────────────────────────────────────────────
exports.getPlans = async (req, res) => {
try {
const plans = await mdl_TierPlans.findAll({
where: { is_active: true },
order: [['tier', 'ASC'], ['duration_days', 'ASC']],
attributes: ['plan_id', 'tier', 'label', 'duration_days', 'price', 'currency'],
include: [{
model: Course,
as: 'courses',
attributes: ['course_id', 'uuid', 'title', 'course_code', 'level', 'duration_seconds'],
through: { attributes: [] },
}],
});
const result = plans.map((p) => {
const plain = p.toJSON();
plain.course_count = plain.courses?.length ?? 0;
return plain;
});
return R.success(res, 'Plans retrieved.', result);
} catch (err) {
console.error('[CLIENT][GET PLANS]', err);
return R.error(res, 'Could not retrieve plans.', 500);
}
};
// ─── PAYPAL CHECKOUT ──────────────────────────────────────────────────────────
exports.createOrder = async (req, res) => {
try {
const { plan_id, promo_code } = req.body;
if (!plan_id) return R.error(res, 'plan_id is required.', 400);
const plan = await mdl_TierPlans.findOne({ where: { plan_id, is_active: true } });
if (!plan) return R.error(res, 'Plan not found or inactive.', 404);
const checkout = calculateCheckoutAmount(plan.price, promo_code);
if (Number(checkout.total) <= 0)
return R.error(res, 'PayPal checkout requires a payable amount.', 400);
const ppOrder = await paypal.createOrder({
amount: checkout.total,
currency: plan.currency,
referenceId: `user_${req.user.user_id}_plan_${plan_id}`,
});
// Extract PayPal approval URL from links array
const approvalUrl = ppOrder.links?.find((l) => l.rel === 'approve')?.href ?? null;
const payment = await mdl_Payments.create({
user_id: req.user.user_id,
plan_id,
status: 'pending',
amount: checkout.total,
currency: plan.currency,
promo_code: checkout.promoCode,
discount: checkout.discount,
provider: 'paypal',
provider_payload: {
order_id: ppOrder.id,
approval_url: approvalUrl,
checkout: {
subtotal: checkout.subtotal,
discount: checkout.discount,
promo_code: checkout.promoCode,
},
},
});
return R.success(res, 'Order created.', {
payment_id: payment.payment_id,
order_id: ppOrder.id,
approval_url: approvalUrl,
amount: checkout.total,
currency: plan.currency,
promo_code: checkout.promoCode,
discount: checkout.discount,
}, 201);
} catch (err) {
console.error('[CLIENT][CREATE ORDER]', err);
return R.error(res, 'Could not create order.', 500);
}
};
exports.captureOrder = async (req, res) => {
try {
const { order_id } = req.body;
if (!order_id) return R.error(res, 'order_id is required.', 400);
const payment = await mdl_Payments.findOne({
where: {
status: 'pending',
provider: 'paypal',
user_id: req.user.user_id,
},
include: [{ model: mdl_TierPlans, as: 'plan' }],
order: [['createdAt', 'DESC']],
});
// Match by order_id inside provider_payload
if (!payment || payment.provider_payload?.order_id !== order_id)
return R.error(res, 'Pending payment not found.', 404);
let captureData;
try {
captureData = await paypal.captureOrder(order_id);
} catch (ppErr) {
await payment.update({
status: 'failed',
provider_payload: {
...payment.provider_payload,
error: ppErr?.response?.data ?? {},
},
});
return R.error(res, 'PayPal capture failed.', 402);
}
const capture = captureData.purchase_units?.[0]?.payments?.captures?.[0];
// Expire current active tier
await mdl_UserTiers.update(
{ status: 'expired' },
{ where: { user_id: req.user.user_id, status: 'active' } }
);
const startsAt = new Date();
const expiresAt = new Date(startsAt.getTime() + payment.plan.duration_days * 86400000);
const newTier = await mdl_UserTiers.create({
user_id: req.user.user_id,
tier: payment.plan.tier,
plan_id: payment.plan_id,
status: 'active',
starts_at: startsAt,
expires_at: expiresAt,
granted_by: null,
});
await payment.update({
status: 'completed',
tier_id: newTier.tier_id,
paid_at: new Date(),
provider_payload: {
...payment.provider_payload,
capture_id: capture?.id,
payer_id: captureData.payer?.payer_id,
capture: captureData,
},
});
// await grantAchievement(req.user.user_id, payment.plan.tier);
await onTierActivated(req.user.user_id, newTier.tier);
return R.success(res, 'Payment successful. Tier activated.', {
tier: newTier.tier,
expires_at: newTier.expires_at,
});
} catch (err) {
console.error('[CLIENT][CAPTURE ORDER]', err);
return R.error(res, 'Could not capture order.', 500);
}
};
exports.cancelOrder = async (req, res) => {
try {
const { order_id } = req.body;
if (!order_id) return R.error(res, 'order_id is required.', 400);
const payment = await mdl_Payments.findOne({
where: { user_id: req.user.user_id, status: 'pending', provider: 'paypal' },
order: [['createdAt', 'DESC']],
});
if (!payment || payment.provider_payload?.order_id !== order_id)
return R.error(res, 'Pending payment not found.', 404);
await payment.update({
status: 'cancelled',
provider_payload: {
...payment.provider_payload,
cancelled_at: new Date().toISOString(),
cancelled_by: 'payer',
},
});
return R.success(res, 'Payment cancelled.');
} catch (err) {
console.error('[CLIENT][CANCEL ORDER]', err);
return R.error(res, 'Could not cancel payment.', 500);
}
};
// ─── MY PAYMENTS ──────────────────────────────────────────────────────────────
exports.getMyPayments = async (req, res) => {
try {
const payments = await mdl_Payments.findAll({
where: { user_id: req.user.user_id },
include: [{ model: mdl_TierPlans, as: 'plan', attributes: ['label', 'tier', 'duration_days'] }],
attributes: { exclude: ['provider_payload'] },
order: [['createdAt', 'DESC']],
});
return R.success(res, 'Payment history retrieved.', payments);
} catch (err) {
console.error('[CLIENT][GET MY PAYMENTS]', err);
return R.error(res, 'Could not retrieve payment history.', 500);
}
};
// — add refundOrder export ────────────────
exports.refundOrder = async (req, res) => {
try {
const user_id = req.user.user_id;
// Get the active tier
const activeTier = await mdl_UserTiers.findOne({
where: { user_id, status: 'active' },
order: [['createdAt', 'DESC']],
});
if (!activeTier) return R.error(res, 'No active tier to refund.', 404);
// Get the completed payment for this tier
const payment = await mdl_Payments.findOne({
where: { user_id, tier_id: activeTier.tier_id, status: 'completed' },
order: [['paid_at', 'DESC']],
});
if (!payment) return R.error(res, 'No completed payment found for this tier.', 404);
// Get capture_id from provider_payload
const captureId = payment.provider_payload?.capture_id;
if (!captureId) return R.error(res, 'Capture ID not found. Cannot process refund.', 400);
// Call PayPal refund API
let refundData;
try {
refundData = await paypal.refundCapture(captureId, payment.amount, payment.currency);
} catch (ppErr) {
console.error('[CLIENT][REFUND] PayPal error:', ppErr?.response?.data);
return R.error(res, 'PayPal refund failed. Please try again.', 402);
}
// Update payment status to refunded
await payment.update({
status: 'refunded',
provider_payload: {
...payment.provider_payload,
refund: refundData,
refunded_at: new Date().toISOString(),
},
});
// Cancel tier at end of period — keep access until expires_at
await activeTier.update({ status: 'revoked' });
return R.success(res, 'Refund processed successfully. Your access will remain until the end of the billing period.', {
refund_id: refundData.id,
status: refundData.status,
expires_at: activeTier.expires_at,
});
} catch (err) {
console.error('[CLIENT][REFUND]', err);
return R.error(res, 'Could not process refund.', 500);
}
};
+40
View File
@@ -0,0 +1,40 @@
/***********************************************************************************************************************************************************************
* File Name: health.controller.js
* Type of Program: Controller
* Description: HTTP layer for health check endpoints. No check logic lives here —
* all checks and payload assembly are handled by services/health.service.js.
*
* GET /api/health → dashboard (rich human-readable, runs all checks)
* GET /api/health/ready → readiness (compact machine-readable, 200 or 503)
*
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jun. 20, 2026
***********************************************************************************************************************************************************************/
"use strict";
const { runDashboard, runReadiness } = require('../services/health.service');
// ─── GET /api/health ─────────────────────────────────────────────────────────
//
// Human-readable dashboard — runs all checks and returns full context:
// app metadata, system info, and per-service connection details.
//
// Used by: developers, monitoring dashboards, manual inspection.
exports.dashboard = async (_req, res) => {
const { httpStatus, body } = await runDashboard();
res.status(httpStatus).json(body);
};
// ─── GET /api/health/ready ───────────────────────────────────────────────────
//
// Machine-readable readiness check — compact response, meaningful HTTP status.
// HTTP 200 → healthy or degraded (safe to route traffic)
// HTTP 503 → unhealthy (critical dependency down; do not route traffic here)
//
// Used by: Kubernetes readiness probe, load balancers, deployment gate scripts.
exports.readiness = async (_req, res) => {
const { httpStatus, body } = await runReadiness();
res.status(httpStatus).json(body);
};
+85
View File
@@ -0,0 +1,85 @@
/***********************************************************************************************************************************************************************
* File Name: media.controller.js (public)
* Type of Program: Controller
* Description: Issues stream tokens for publicly accessible S3 assets — no auth required.
* Guards:
* 1. Asset must exist and not be deleted
* 2. is_public must be true — private assets are always rejected (403)
* 3. storage_provider must be "s3" — Chibisafe uses its raw CDN URL
* Token shape is identical to the client/admin token flows so the
* shared stream endpoint (/api/client/media/stream/:token) accepts it.
* No user_id is embedded — the token is anonymous.
* IP is still bound so a leaked token is useless on another machine.
*
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jun. 22, 2026
***********************************************************************************************************************************************************************/
"use strict";
const jwt = require("jsonwebtoken");
const R = require("../../utils/response.util");
const mdl_Assets = require("../../models/assets/assets.mdl");
const MEDIA_SECRET = process.env.MEDIA_JWT_SECRET ?? process.env.JWT_SECRET;
const TOKEN_TTL_SEC = 4 * 60 * 60; // 4 hours — matches client TTL
const SUPPORTED_TYPES = ["video", "audio", "document", "image"];
function resolveIp(req) {
const forwarded = req.headers["x-forwarded-for"];
if (forwarded) return forwarded.split(",")[0].trim();
return req.ip ?? req.socket?.remoteAddress ?? "unknown";
}
// ─── GET /public/media/token?asset_id=X ──────────────────────────────────────
exports.issueToken = async (req, res) => {
try {
const asset_id = req.query.asset_id ?? req.body?.asset_id;
if (!asset_id) return R.error(res, "asset_id is required.", 400);
const asset = await mdl_Assets.findOne({
where: { asset_id, deletedAt: null },
attributes: ["asset_id", "file_type", "storage_provider", "storage_key", "mime_type", "is_public"],
});
if (!asset) return R.error(res, "Asset not found.", 404);
// Private assets are never served through the public endpoint
if (!asset.is_public) return R.error(res, "Forbidden.", 403);
if (!SUPPORTED_TYPES.includes(asset.file_type)) {
return R.error(res, `Asset type "${asset.file_type}" is not supported.`, 400);
}
if (asset.storage_provider !== "s3") {
return R.error(res, "Token flow is for S3 assets only. Use the raw file_url for other providers.", 400);
}
const ip = resolveIp(req);
const token = jwt.sign(
{
asset_id,
// no user_id — anonymous public token
storage_key: asset.storage_key,
file_type: asset.file_type,
mime_type: asset.mime_type,
ip,
},
MEDIA_SECRET,
{ expiresIn: TOKEN_TTL_SEC }
);
return R.success(res, "Token issued.", {
token,
provider: "s3",
file_type: asset.file_type,
});
} catch (err) {
console.error("[PUBLIC][MEDIA][TOKEN]", err);
return R.error(res, "Could not issue media token.", 500);
}
};
+8
View File
@@ -17,6 +17,7 @@
const { Op } = require('sequelize');
const mdl_Users = require('../../models/users/users.mdl');
const mdl_UserSessions = require('../../models/users/user_sessions.mdl');
const logActivity = require('../../utils/logActivity.util');
const R = require('../../utils/response.util');
const EXCLUDED = ['password', 'otp_code', 'otp_expires_at'];
@@ -75,6 +76,13 @@ exports.setUserStatus = async (req, res) => {
if (!user) return R.error(res, 'User not found or operation not permitted.', 404);
await user.update({ is_active });
logActivity(req.user.user_id, 'set_user_status', {
entityType: 'user',
entityId: Number(req.params.id),
metadata: { is_active },
});
return R.success(res, `User ${is_active ? 'activated' : 'deactivated'}.`);
} catch (err) {
return R.error(res, 'Could not update user status.', 500);
+45
View File
@@ -0,0 +1,45 @@
/***********************************************************************************************************************************************************************
* File Name : admin.cron.js
* Type : Cron Registry — Admin side
* Description : Aggregates and registers every admin-facing scheduled job.
* Each job module in cron/jobs/ exports { name, schedule, run }:
* name {string} – unique identifier, used in logs
* schedule {string} – standard cron expression (node-cron)
* run {function} – async () => void, the actual work
*
* To add a new admin-side cron job:
* 1. Create cron/jobs/yourJob.cron.js exporting
* { name, schedule, run }
* 2. require() it below and add it to the `jobs` array
* That's the only wiring required — server.js never needs to
* change when admin-side jobs are added/removed.
*
* Currently registered:
* - taskOverdue (cron/jobs/taskOverdue.cron.js)
*
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jun. 17, 2026
***********************************************************************************************************************************************************************/
const cron = require('node-cron');
const taskOverdue = require('./jobs/task_overdue.cron');
// ─── Registry — add future admin-side cron jobs here ─────────────────────────
const jobs = [
taskOverdue,
];
// ─── Boot all registered admin-side jobs ──────────────────────────────────────
function startAdminCronJobs() {
const registered = [];
jobs.forEach(({ name, schedule, run }) => {
if (!cron.validate(schedule)) {
console.error(`[CRON][ADMIN] Invalid schedule for "${name}": "${schedule}" — skipped.`);
return;
}
cron.schedule(schedule, run);
registered.push({ name, scope: 'ADMIN', schedule });
});
return registered;
}
module.exports = { startAdminCronJobs };
+36
View File
@@ -0,0 +1,36 @@
/***********************************************************************************************************************************************************************
* File Name : client.cron.js
* Type : Cron Registry — Client side
* Description : Aggregates and registers every client-facing scheduled job.
* Same shape as admin.cron.js — each job module exports
* { name, schedule, run }, listed in the `jobs` array below.
*
* Currently registered:
* - userNotifications (cron/jobs/user_notifications.cron.js)
*
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jun. 17, 2026
***********************************************************************************************************************************************************************/
const cron = require('node-cron');
const userNotifications = require('./jobs/user_notifications.cron');
// ─── Registry — add future client-side cron jobs here ────────────────────────
const jobs = [
userNotifications,
];
// ─── Boot all registered client-side jobs ─────────────────────────────────────
function startClientCronJobs() {
const registered = [];
jobs.forEach(({ name, schedule, run }) => {
if (!cron.validate(schedule)) {
console.error(`[CRON][CLIENT] Invalid schedule for "${name}": "${schedule}" — skipped.`);
return;
}
cron.schedule(schedule, run);
registered.push({ name, scope: 'CLIENT', schedule });
});
return registered;
}
module.exports = { startClientCronJobs };
+68
View File
@@ -0,0 +1,68 @@
/***********************************************************************************************************************************************************************
* File Name : task_overdue.cron.js
* Type : Cron Job
* Description : Flips Task.status to 'overdue' once its deadline has passed,
* provided it isn't already 'completed' or 'overdue'. Purely
* an admin-facing lifecycle label — does NOT touch
* TaskCompletion/TaskLinkVisit/TaskProgress, does NOT affect
* per-user completion signals or client-side Ongoing/Done/
* Overdue bucketing, and does NOT block late submissions.
*
* Safety pattern:
* - Task.update is the primary operation and must always succeed.
* - AdminNotification.create is secondary — wrapped in its own
* try/catch so a notification failure never rolls back or
* suppresses the status flip. If it fails once, the next
* hourly run will insert its own summary for that batch.
*
* Schedule : Every hour, on the hour ("0 * * * *"). Registered by
* cron/admin.cron.js, not scheduled here directly.
*
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jun. 17, 2026
* Modified : Jun. 19, 2026
***********************************************************************************************************************************************************************/
const { Op } = require('sequelize');
const { Task } = require('../../models/task/task.mdl');
const AdminNotification = require('../../models/notifications/admin_notification.mdl');
const { NOTIFICATION_REGISTRY } = require('../../data/notifications.data');
// ─── The actual sweep ────────────────────────────────────────────────────────
async function run() {
// ── 1. Primary: flip task statuses ───────────────────────────────────────
let affectedCount = 0;
try {
[affectedCount] = await Task.update(
{ status: 'overdue' },
{
where: {
deadline: { [Op.lt]: new Date() },
status: { [Op.notIn]: ['completed', 'overdue'] },
},
}
);
} catch (err) {
console.error('[CRON][TASK OVERDUE] Failed to update task statuses:', err);
return;
}
if (affectedCount === 0) return;
console.log(`[CRON][TASK OVERDUE] Marked ${affectedCount} task(s) as overdue.`);
// ── 2. Secondary: admin notification — isolated, never blocks step 1 ─────
try {
await AdminNotification.create(
NOTIFICATION_REGISTRY.task_overdue.build({ count: affectedCount })
);
} catch (err) {
console.error('[CRON][TASK OVERDUE] Failed to insert admin notification:', err);
}
}
module.exports = {
name: 'taskOverdue',
schedule: '0 * * * *',
run,
};
+92
View File
@@ -0,0 +1,92 @@
/***********************************************************************************************************************************************************************
* File Name : user_task_overdue_notify.cron.js
* Type : Cron Job
* Description : Emits a UserNotification for every user who belongs to a group
* assigned to a task list that contains a task that JUST flipped
* to 'overdue' in the last hour.
*
* Runs 5 minutes after the admin taskOverdue cron (which fires at
* the top of each hour) so the status flips are already committed
* before this job queries them.
*
* "Just flipped" = status is 'overdue' AND updatedAt is within
* the last 65 minutes (1-hour window + 5-min drift buffer).
* This prevents re-notifying users for tasks that were already
* overdue before this run.
*
* Schedule : 5 minutes past every hour ("5 * * * *"). Registered by
* cron/client.cron.js.
*
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jun. 19, 2026
***********************************************************************************************************************************************************************/
const { Op, QueryTypes } = require('sequelize');
const sequelize = require('../../config/db.config');
const { Task } = require('../../models/task/task.mdl');
const UserNotification = require('../../models/notifications/user_notification.mdl');
const { NOTIFICATION_REGISTRY } = require('../../data/notifications.data');
const WINDOW_MS = 65 * 60 * 1000; // 65-minute lookback
async function run() {
// ── 1. Find tasks that flipped to overdue in the last 65 minutes ──────────
let recentlyOverdue;
try {
recentlyOverdue = await Task.findAll({
attributes: ['task_id', 'task_list_id', 'name'],
where: {
status: 'overdue',
updatedAt: { [Op.gte]: new Date(Date.now() - WINDOW_MS) },
},
raw: true,
});
} catch (err) {
console.error('[CRON][USER NOTIFY] Failed to query recently overdue tasks:', err);
return;
}
if (recentlyOverdue.length === 0) return;
console.log(`[CRON][USER NOTIFY] ${recentlyOverdue.length} recently overdue task(s) — resolving affected users.`);
// ── 2. Resolve affected users via task_list_groups → user_group_members ───
try {
const taskListIds = [...new Set(recentlyOverdue.map(t => t.task_list_id))];
const affectedUsers = await sequelize.query(
`SELECT DISTINCT ugm.user_id
FROM task_list_groups tlg
JOIN user_group_members ugm ON ugm.group_id = tlg.group_id
AND ugm."deletedAt" IS NULL
WHERE tlg.task_list_id IN (:taskListIds)`,
{ replacements: { taskListIds }, type: QueryTypes.SELECT }
);
if (affectedUsers.length === 0) return;
const count = recentlyOverdue.length;
const now = new Date();
const notify = NOTIFICATION_REGISTRY.user_task_overdue.build({ count, task_list_ids: taskListIds });
await UserNotification.bulkCreate(
affectedUsers.map(({ user_id }) => ({
user_id,
...notify,
seen: false,
createdAt: now,
updatedAt: now,
})),
{ validate: false }
);
console.log(`[CRON][USER NOTIFY] Notified ${affectedUsers.length} user(s) about ${count} overdue task(s).`);
} catch (err) {
console.error('[CRON][USER NOTIFY] Failed to emit user notifications:', err);
}
}
module.exports = {
name: 'userNotifications',
schedule: '5 * * * *',
run,
};
+119
View File
@@ -0,0 +1,119 @@
/***********************************************************************************************************************************************************************
* File Name: achievements.data.js
* Type of Program: Data
* Description: Static registry of all available achievements (badges and milestones).
* Add a new achievement here — no other code changes needed.
*
* Available keys:
* Badge:
* early_access — registered before Dec 31, 2026
* premium_first_time — first premium tier purchase
* exclusive_first_time — first exclusive tier purchase
* Milestone:
* first_course_completed — completed first course
* courses_completed_5 — completed 5 courses
* courses_completed_10 — completed 10 courses
* perfect_quiz_score — perfect score on a quiz
* profile_completed — filled out full profile
* first_referral — referred a user
*
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jun. 19, 2026
***********************************************************************************************************************************************************************/
'use strict';
// ─── Early Access cutoff ──────────────────────────────────────────────────────
const EARLY_ACCESS_CUTOFF = new Date('2026-12-31T23:59:59Z');
// ─── Achievement Registry ─────────────────────────────────────────────────────
// To add a new achievement — add an entry here. No other code changes needed.
//
// trigger categories (for documentation only, not enforced):
// auth — registration / login events
// tier — subscription purchase events
// course — course / lesson / quiz events
// profile — profile completion events
// social — referral / community events
const ACHIEVEMENT_REGISTRY = {
// ── Auth ───────────────────────────────────────────────────────────────────
early_access: {
key: 'early_access',
type: 'badge',
label: 'Early Access',
description: 'Registered during the Philproperties beta period (before Dec 31, 2026).',
trigger: 'auth',
},
// ── Tier ───────────────────────────────────────────────────────────────────
premium_first_time: {
key: 'premium_first_time',
type: 'badge',
label: 'Premium Member',
description: 'Purchased a Premium tier plan for the first time.',
trigger: 'tier',
},
exclusive_first_time: {
key: 'exclusive_first_time',
type: 'badge',
label: 'Exclusive Member',
description: 'Purchased an Exclusive tier plan for the first time.',
trigger: 'tier',
},
// ── Course ─────────────────────────────────────────────────────────────────
first_course_completed: {
key: 'first_course_completed',
type: 'milestone',
label: 'First Course Completed',
description: 'Completed your very first course on Philproperties.',
trigger: 'course',
},
courses_completed_5: {
key: 'courses_completed_5',
type: 'milestone',
label: 'Learning Streak',
description: 'Completed 5 courses.',
trigger: 'course',
},
courses_completed_10: {
key: 'courses_completed_10',
type: 'milestone',
label: 'Knowledge Builder',
description: 'Completed 10 courses.',
trigger: 'course',
},
perfect_quiz_score: {
key: 'perfect_quiz_score',
type: 'milestone',
label: 'Perfect Score',
description: 'Achieved a perfect score on a quiz.',
trigger: 'course',
},
// ── Profile ────────────────────────────────────────────────────────────────
profile_completed: {
key: 'profile_completed',
type: 'milestone',
label: 'Profile Complete',
description: 'Filled out all personal profile information.',
trigger: 'profile',
},
// ── Social ─────────────────────────────────────────────────────────────────
first_referral: {
key: 'first_referral',
type: 'milestone',
label: 'Referral Champion',
description: 'Successfully referred a user to Philproperties.',
trigger: 'social',
},
};
module.exports = {
EARLY_ACCESS_CUTOFF,
ACHIEVEMENT_REGISTRY,
};
+47 -74
View File
@@ -1,116 +1,89 @@
const CLOSING = `Regards,\nPhilproperties IT Team\n\nThis is an automated message from STARR System. Please do not reply.`;
const build = (body) => `${body.trim()}\n\n${CLOSING}`;
export const emailTemplates = {
OTP: ({ otp, expiryMinutes = 10 }) => ({
subject: "Email OTP Verification - STARR System",
title: "Email Verification",
body: `
<p>Dear User,</p>
text: build(`
Dear User,
<p>Please use the One-Time Password (OTP) below to verify your email address.
This code is valid for <strong>${expiryMinutes} minutes</strong>.</p>
Please use the One-Time Password (OTP) below to verify your email address. This code is valid for ${expiryMinutes} minutes.
<div style="margin:24px 0;font-size:32px;font-weight:bold;letter-spacing:8px;text-align:center;color:#1d4ed8">
${otp}
</div>
${otp}
<p style="font-size:13px;color:#6b7280">
For security reasons, please do not share this code with anyone.
If you did not request this, please contact the administrator.
</p>
`,
For security reasons, please do not share this code with anyone. If you did not request this, please contact the administrator.
`),
}),
WELCOME: ({ name }) => ({
subject: "Welcome to STARR System",
title: `Welcome Aboard!`,
body: `
<p>Dear ${name},</p>
text: build(`
Dear ${name},
<p>We are pleased to welcome you to the STARR System. Your account has been successfully created and is now ready for use.</p>
We are pleased to welcome you to the STARR System. Your account has been successfully created and is now ready for use.
<p>You may now access your dashboard and begin using the available services.</p>
You may now access your dashboard and begin using the available services.
<p style="margin-top:16px">We look forward to supporting you.</p>
`,
We look forward to supporting you.
`),
}),
PASSWORD_CHANGED: () => ({
subject: "Password Update Confirmation - STARR System",
title: "Password Successfully Updated",
body: `
<p>Dear User,</p>
text: build(`
Dear User,
<p>This is to confirm that your account password has been successfully changed.</p>
This is to confirm that your account password has been successfully changed.
<p>If you did not perform this action, please reset your password immediately or contact support.</p>
If you did not perform this action, please reset your password immediately or contact support.
<p style="margin-top:16px">For your security, we recommend using a strong and unique password.</p>
`,
For your security, we recommend using a strong and unique password.
`),
}),
ADDED_TO_GROUP: ({ groupName }) => ({
subject: "Group Assignment Notification",
title: "Added to Group",
body: `
<p>Dear User,</p>
subject: "Group Assignment Notification - STARR System",
text: build(`
Dear User,
<p>You have been assigned to the group <strong>${groupName}</strong> in the STARR System.</p>
You have been assigned to the group "${groupName}" in the STARR System.
<p>This assignment grants you access to shared resources and collaboration tools within the group.</p>
This assignment grants you access to shared resources and collaboration tools within the group.
<p style="margin-top:16px">Please log in to your account to view group details.</p>
`,
Please log in to your account to view group details.
`),
}),
TASK_ASSIGNED: ({ taskTitle, dueDate }) => ({
subject: "New Task Assignment",
title: "Task Assigned",
body: `
<p>Dear User,</p>
subject: "New Task Assignment - STARR System",
text: build(`
Dear User,
<p>You have been assigned a new task in the STARR System.</p>
You have been assigned a new task in the STARR System.
<div style="margin:16px 0;padding:12px;border:1px solid #e5e7eb;border-radius:6px">
<strong>Task:</strong> ${taskTitle}
</div>
Task: ${taskTitle}
Due Date: ${dueDate}
<p><strong>Due Date:</strong> ${dueDate}</p>
<p style="margin-top:16px">Kindly ensure completion within the specified timeframe.</p>
`,
Kindly ensure completion within the specified timeframe.
`),
}),
ADD_STAFF: ({ name, email, password, expiryHours = 24 }) => ({
subject: "Your Staff Account Has Been Created - STARR System",
title: "Staff Account Created",
body: `
<p>Dear ${name},</p>
text: build(`
Dear ${name},
<p>Your staff account has been successfully created in the STARR System.
Below are your login credentials:</p>
Your staff account has been successfully created in the STARR System. Below are your login credentials:
<div style="margin:24px 0;padding:16px;border:1px solid #e5e7eb;border-radius:6px;background:#f9fafb">
<table style="width:100%;font-size:14px">
<tr>
<td style="padding:6px 0;color:#6b7280;width:100px"><strong>Email</strong></td>
<td style="padding:6px 0">${email}</td>
</tr>
<tr>
<td style="padding:6px 0;color:#6b7280"><strong>Password</strong></td>
<td style="padding:6px 0;font-size:18px;color:#1d4ed8">${password}</td>
</tr>
</table>
</div>
Email: ${email}
Password: ${password}
<p>⚠️ This temporary password is valid for <strong>${expiryHours} hours</strong>.
You will be required to change it upon first login.</p>
This temporary password is valid for ${expiryHours} hours. You will be required to change it upon first login.
<p>If you did not request this account or believe this was created in error,
please contact your administrator immediately to have it deactivated.</p>
If you did not request this account or believe this was created in error, please contact your administrator immediately to have it deactivated.
<p style="margin-top:16px;font-size:13px;color:#6b7280">
For security, please do not share your credentials with anyone.
If you need a new password, contact your administrator.
</p>
`,
For security, please do not share your credentials with anyone. If you need a new password, contact your administrator.
`),
}),
};
};
+182
View File
@@ -0,0 +1,182 @@
/***********************************************************************************************************************************************************************
* File Name: notifications.data.js
* Type of Program: Data
* Description: Central registry of all notification types for both admin and
* client (user) notifications.
*
* Each entry describes one notification type:
* type {string} — stored in the DB 'type' column
* scope {string} — 'admin' | 'user' | 'both'
* trigger {string} — what fires it (cron | event | manual)
* build {function} — takes a data payload, returns the object
* ready to pass to AdminNotification.create()
* or UserNotification.create() / bulkCreate()
*
* To add a new notification type:
* 1. Add an entry in the relevant section below.
* 2. Call NOTIFICATION_REGISTRY.<key>.build(data) at the trigger
* site (controller, cron, service).
* No other changes needed.
*
* Current types:
* Admin : task_overdue
* User : user_task_overdue, achievement, course_unlocked,
* certificate_issued, task_reminder, announcement
*
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jun. 19, 2026
***********************************************************************************************************************************************************************/
'use strict';
const NOTIFICATION_REGISTRY = {
// ─────────────────────────────────────────────────────────────────────────
// ADMIN notifications (scope: 'admin')
// ─────────────────────────────────────────────────────────────────────────
// ── Task ──────────────────────────────────────────────────────────────────
task_overdue: {
type: 'task_overdue',
scope: 'admin',
trigger: 'cron',
build({ count, task_list_ids = [] }) {
return {
type: 'task_overdue',
title: 'Tasks Overdue',
message: `${count} task${count === 1 ? ' was' : 's were'} automatically marked as overdue.`,
data: { count, task_list_ids },
};
},
},
// ── User Registration ─────────────────────────────────────────────────────
user_registration: {
type: 'user_registration',
scope: 'admin',
trigger: 'event',
build({ groupName, groupCode, userEmail }) {
return {
type: 'user_registration',
title: 'New User Registered',
message: `A new user registered in ${groupName}.`,
data: { groupName, groupCode, userEmail },
};
},
},
// ─────────────────────────────────────────────────────────────────────────
// USER notifications (scope: 'user')
// ─────────────────────────────────────────────────────────────────────────
// ── Task ──────────────────────────────────────────────────────────────────
user_task_overdue: {
type: 'task',
scope: 'user',
trigger: 'cron',
build({ count, task_list_ids = [] }) {
const label = count === 1 ? '1 task has' : `${count} tasks have`;
return {
type: 'task',
title: 'Tasks Overdue',
message: `${label} passed their deadline and been marked as overdue.`,
data: { count, task_list_ids },
};
},
},
task_reminder: {
type: 'task',
scope: 'user',
trigger: 'cron',
build({ taskName, deadline }) {
return {
type: 'task',
title: 'Task Deadline Approaching',
message: `"${taskName}" is due on ${new Date(deadline).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })}.`,
data: { taskName, deadline },
};
},
},
// ── Achievement ───────────────────────────────────────────────────────────
achievement: {
type: 'achievement',
scope: 'user',
trigger: 'event',
build({ label, description, key }) {
return {
type: 'achievement',
title: label,
message: description,
data: { key },
};
},
},
// ── Course ────────────────────────────────────────────────────────────────
course_unlocked: {
type: 'course',
scope: 'user',
trigger: 'event',
build({ courseTitle }) {
return {
type: 'course',
title: 'New Course Available',
message: `"${courseTitle}" has been added to your learning library.`,
data: { courseTitle },
};
},
},
certificate_issued: {
type: 'course',
scope: 'user',
trigger: 'event',
build({ courseTitle, courseUuid }) {
return {
type: 'course',
title: 'Certificate Issued',
message: `Congratulations! Your certificate for "${courseTitle}" is ready.`,
data: { courseTitle, courseUuid },
};
},
},
// ── Welcome ───────────────────────────────────────────────────────────────
welcome: {
type: 'announcement',
scope: 'user',
trigger: 'event',
build({ groupName, groupCode, accType }) {
const greeting = accType === 'admin'
? 'Welcome, Administrator!'
: accType === 'staff'
? 'Welcome to the Philproperties team!'
: 'Welcome to Philproperties!';
return {
type: 'announcement',
title: 'Welcome to Philproperties',
message: groupName ? `${greeting} You have been added to ${groupName}.` : greeting,
data: { groupName, groupCode, accType },
};
},
},
// ── Platform ──────────────────────────────────────────────────────────────
announcement: {
type: 'announcement',
scope: 'user',
trigger: 'manual',
build({ title, body }) {
return {
type: 'announcement',
title,
message: body,
data: {},
};
},
},
};
module.exports = { NOTIFICATION_REGISTRY };
@@ -0,0 +1,30 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('users', {
user_id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
email: { type: Sequelize.STRING(255), allowNull: false, unique: true },
password: { type: Sequelize.TEXT },
is_active: { type: Sequelize.BOOLEAN, defaultValue: true },
is_verified: { type: Sequelize.BOOLEAN, defaultValue: false },
reg_type: { type: Sequelize.ENUM('google', 'system'), defaultValue: 'system' },
acc_type: { type: Sequelize.ENUM('admin', 'staff', 'user'), defaultValue: 'user' },
personal_info: { type: Sequelize.JSONB, defaultValue: null },
must_change_password: { type: Sequelize.BOOLEAN, defaultValue: false, allowNull: false },
password_expires_at: { type: Sequelize.DATE, allowNull: true },
otp_code: { type: Sequelize.STRING(6), allowNull: true },
otp_expires_at: { type: Sequelize.DATE, allowNull: true },
createdBy: { type: Sequelize.BIGINT, allowNull: true },
updatedBy: { type: Sequelize.BIGINT, allowNull: true },
deletedBy: { type: Sequelize.BIGINT, allowNull: true },
createdAt: { type: Sequelize.DATE, allowNull: false },
updatedAt: { type: Sequelize.DATE, allowNull: false },
deletedAt: { type: Sequelize.DATE, allowNull: true },
});
},
async down(queryInterface) {
await queryInterface.dropTable('users');
},
};
@@ -0,0 +1,47 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('assets', {
asset_id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
uuid: { type: Sequelize.UUID, defaultValue: Sequelize.UUIDV4, allowNull: false, unique: true },
original_name: { type: Sequelize.STRING(255), allowNull: false },
display_name: { type: Sequelize.STRING(255), allowNull: false },
file_url: { type: Sequelize.STRING(512), allowNull: false },
file_size: { type: Sequelize.BIGINT, allowNull: false },
mime_type: { type: Sequelize.STRING(100), allowNull: false },
extension: { type: Sequelize.STRING(20) },
checksum: { type: Sequelize.STRING(64) },
file_type: { type: Sequelize.ENUM('avatar', 'document', 'video', 'image'), allowNull: false, defaultValue: 'image' },
width: { type: Sequelize.INTEGER },
height: { type: Sequelize.INTEGER },
duration: { type: Sequelize.FLOAT },
resolution: { type: Sequelize.STRING(20) },
frame_rate: { type: Sequelize.FLOAT },
bitrate: { type: Sequelize.BIGINT },
video_codec: { type: Sequelize.STRING(50) },
audio_codec: { type: Sequelize.STRING(50) },
thumbnail_url: { type: Sequelize.STRING(512) },
description: { type: Sequelize.TEXT },
storage_provider: { type: Sequelize.ENUM('local', 's3', 'gcs', 'cloudinary', 'chibisafe', 'other'), defaultValue: 'local' },
storage_bucket: { type: Sequelize.STRING(255) },
storage_key: { type: Sequelize.STRING(512) },
is_public: { type: Sequelize.BOOLEAN, defaultValue: false },
createdBy: { type: Sequelize.BIGINT, allowNull: true },
updatedBy: { type: Sequelize.BIGINT, allowNull: true },
deletedBy: { type: Sequelize.BIGINT, allowNull: true },
createdAt: { type: Sequelize.DATE, allowNull: false },
updatedAt: { type: Sequelize.DATE, allowNull: false },
deletedAt: { type: Sequelize.DATE, allowNull: true },
});
await queryInterface.addIndex('assets', ['uuid']);
await queryInterface.addIndex('assets', ['createdBy']);
await queryInterface.addIndex('assets', ['file_type']);
await queryInterface.addIndex('assets', ['deletedAt']);
},
async down(queryInterface) {
await queryInterface.dropTable('assets');
},
};
@@ -0,0 +1,26 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('user_sessions', {
session_id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
user_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'users', key: 'user_id' }, onUpdate: 'CASCADE', onDelete: 'CASCADE' },
login_info: { type: Sequelize.JSONB, allowNull: true },
logout_info: { type: Sequelize.JSONB, allowNull: true },
refresh_token_hash: { type: Sequelize.TEXT, allowNull: true },
is_active: { type: Sequelize.BOOLEAN, defaultValue: true },
createdBy: { type: Sequelize.BIGINT, allowNull: true },
updatedBy: { type: Sequelize.BIGINT, allowNull: true },
deletedBy: { type: Sequelize.BIGINT, allowNull: true },
createdAt: { type: Sequelize.DATE, allowNull: false },
updatedAt: { type: Sequelize.DATE, allowNull: false },
deletedAt: { type: Sequelize.DATE, allowNull: true },
});
await queryInterface.addIndex('user_sessions', ['user_id']);
},
async down(queryInterface) {
await queryInterface.dropTable('user_sessions');
},
};
@@ -0,0 +1,23 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('user_groups', {
group_id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
name: { type: Sequelize.STRING(50), allowNull: false },
group_code: { type: Sequelize.STRING(50), allowNull: true, unique: true },
description: { type: Sequelize.TEXT },
is_active: { type: Sequelize.BOOLEAN, defaultValue: true },
createdBy: { type: Sequelize.BIGINT, allowNull: true },
updatedBy: { type: Sequelize.BIGINT, allowNull: true },
deletedBy: { type: Sequelize.BIGINT, allowNull: true },
createdAt: { type: Sequelize.DATE, allowNull: false },
updatedAt: { type: Sequelize.DATE, allowNull: false },
deletedAt: { type: Sequelize.DATE, allowNull: true },
});
},
async down(queryInterface) {
await queryInterface.dropTable('user_groups');
},
};
@@ -0,0 +1,24 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('user_group_members', {
group_id: { type: Sequelize.BIGINT, primaryKey: true, references: { model: 'user_groups', key: 'group_id' }, onDelete: 'CASCADE' },
user_id: { type: Sequelize.BIGINT, primaryKey: true, references: { model: 'users', key: 'user_id' }, onDelete: 'CASCADE' },
joined_at: { type: Sequelize.DATE, defaultValue: Sequelize.NOW },
createdBy: { type: Sequelize.BIGINT, allowNull: true },
updatedBy: { type: Sequelize.BIGINT, allowNull: true },
deletedBy: { type: Sequelize.BIGINT, allowNull: true },
createdAt: { type: Sequelize.DATE, allowNull: false },
updatedAt: { type: Sequelize.DATE, allowNull: false },
deletedAt: { type: Sequelize.DATE, allowNull: true },
});
await queryInterface.addIndex('user_group_members', ['user_id']);
await queryInterface.addIndex('user_group_members', ['group_id']);
},
async down(queryInterface) {
await queryInterface.dropTable('user_group_members');
},
};
@@ -0,0 +1,26 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('achievements', {
achievement_id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
user_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'users', key: 'user_id' }, onDelete: 'CASCADE' },
type: { type: Sequelize.ENUM('badge', 'milestone'), allowNull: false, defaultValue: 'badge' },
key: { type: Sequelize.STRING(100), allowNull: false },
label: { type: Sequelize.STRING(255), allowNull: false },
description: { type: Sequelize.TEXT, allowNull: true },
granted_by: { type: Sequelize.BIGINT, allowNull: true },
granted_at: { type: Sequelize.DATE, allowNull: false, defaultValue: Sequelize.NOW },
metadata: { type: Sequelize.JSONB, allowNull: true, defaultValue: {} },
createdAt: { type: Sequelize.DATE, allowNull: false },
updatedAt: { type: Sequelize.DATE, allowNull: false },
});
await queryInterface.addIndex('achievements', ['user_id']);
await queryInterface.addIndex('achievements', { fields: ['user_id', 'key'], unique: true, name: 'uq_achievement_user_key' });
},
async down(queryInterface) {
await queryInterface.dropTable('achievements');
},
};
@@ -0,0 +1,31 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('courses', {
course_id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
uuid: { type: Sequelize.UUID, defaultValue: Sequelize.UUIDV4, allowNull: false, unique: true },
title: { type: Sequelize.STRING(255), allowNull: false },
description: { type: Sequelize.TEXT, allowNull: true },
course_code: { type: Sequelize.STRING(50), allowNull: true, unique: true },
order_index: { type: Sequelize.INTEGER, allowNull: false, defaultValue: 0 },
level: { type: Sequelize.ENUM('beginner', 'intermediate', 'advanced'), allowNull: true },
subscription: { type: Sequelize.ENUM('free', 'premium'), allowNull: false, defaultValue: 'free' },
duration_seconds: { type: Sequelize.INTEGER, allowNull: true, defaultValue: 0 },
createdBy: { type: Sequelize.BIGINT, allowNull: true },
updatedBy: { type: Sequelize.BIGINT, allowNull: true },
deletedBy: { type: Sequelize.BIGINT, allowNull: true },
createdAt: { type: Sequelize.DATE, allowNull: false },
updatedAt: { type: Sequelize.DATE, allowNull: false },
deletedAt: { type: Sequelize.DATE, allowNull: true },
});
await queryInterface.addIndex('courses', ['uuid']);
await queryInterface.addIndex('courses', ['subscription']);
await queryInterface.addIndex('courses', ['deletedAt']);
},
async down(queryInterface) {
await queryInterface.dropTable('courses');
},
};
@@ -0,0 +1,35 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('course_products', {
id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
course_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'courses', key: 'course_id' }, onDelete: 'CASCADE' },
product_id: { type: Sequelize.BIGINT, allowNull: false },
createdAt: { type: Sequelize.DATE, allowNull: false },
updatedAt: { type: Sequelize.DATE, allowNull: false },
});
await queryInterface.createTable('course_roles', {
id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
course_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'courses', key: 'course_id' }, onDelete: 'CASCADE' },
role_id: { type: Sequelize.BIGINT, allowNull: false },
createdAt: { type: Sequelize.DATE, allowNull: false },
updatedAt: { type: Sequelize.DATE, allowNull: false },
});
await queryInterface.createTable('course_product_categories', {
id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
course_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'courses', key: 'course_id' }, onDelete: 'CASCADE' },
category_id: { type: Sequelize.BIGINT, allowNull: false },
createdAt: { type: Sequelize.DATE, allowNull: false },
updatedAt: { type: Sequelize.DATE, allowNull: false },
});
},
async down(queryInterface) {
await queryInterface.dropTable('course_product_categories');
await queryInterface.dropTable('course_roles');
await queryInterface.dropTable('course_products');
},
};
@@ -0,0 +1,21 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('course_objectives', {
objective_id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
course_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'courses', key: 'course_id' }, onDelete: 'CASCADE' },
text: { type: Sequelize.TEXT, allowNull: false },
order_index: { type: Sequelize.INTEGER, defaultValue: 0 },
createdAt: { type: Sequelize.DATE, allowNull: false },
updatedAt: { type: Sequelize.DATE, allowNull: false },
deletedAt: { type: Sequelize.DATE, allowNull: true },
});
await queryInterface.addIndex('course_objectives', ['course_id']);
},
async down(queryInterface) {
await queryInterface.dropTable('course_objectives');
},
};
@@ -0,0 +1,21 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('course_prerequisites', {
prereq_id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
course_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'courses', key: 'course_id' }, onDelete: 'CASCADE' },
ref_type: { type: Sequelize.ENUM('course', 'unit', 'lesson'), allowNull: false },
ref_id: { type: Sequelize.BIGINT, allowNull: false },
order_index: { type: Sequelize.INTEGER, defaultValue: 0 },
createdAt: { type: Sequelize.DATE, allowNull: false },
updatedAt: { type: Sequelize.DATE, allowNull: false },
});
await queryInterface.addIndex('course_prerequisites', ['course_id']);
},
async down(queryInterface) {
await queryInterface.dropTable('course_prerequisites');
},
};
@@ -0,0 +1,26 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('course_assessments', {
assessment_id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
uuid: { type: Sequelize.UUID, defaultValue: Sequelize.UUIDV4, unique: true },
course_id: { type: Sequelize.BIGINT, allowNull: false, unique: true, references: { model: 'courses', key: 'course_id' }, onDelete: 'CASCADE' },
title: { type: Sequelize.STRING(255), allowNull: true },
is_required: { type: Sequelize.BOOLEAN, defaultValue: false },
passing_score: { type: Sequelize.INTEGER, defaultValue: 70 },
time_limit_minutes: { type: Sequelize.INTEGER, allowNull: true },
max_questions: { type: Sequelize.INTEGER, allowNull: true },
createdBy: { type: Sequelize.BIGINT, allowNull: true },
updatedBy: { type: Sequelize.BIGINT, allowNull: true },
deletedBy: { type: Sequelize.BIGINT, allowNull: true },
createdAt: { type: Sequelize.DATE, allowNull: false },
updatedAt: { type: Sequelize.DATE, allowNull: false },
deletedAt: { type: Sequelize.DATE, allowNull: true },
});
},
async down(queryInterface) {
await queryInterface.dropTable('course_assessments');
},
};
@@ -0,0 +1,30 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('units', {
unit_id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
uuid: { type: Sequelize.UUID, defaultValue: Sequelize.UUIDV4, allowNull: false, unique: true },
course_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'courses', key: 'course_id' }, onDelete: 'CASCADE' },
title: { type: Sequelize.STRING(255), allowNull: false },
description: { type: Sequelize.TEXT, allowNull: true },
order_index: { type: Sequelize.INTEGER, allowNull: false, defaultValue: 0 },
duration_seconds: { type: Sequelize.INTEGER, allowNull: true, defaultValue: 0 },
createdBy: { type: Sequelize.BIGINT, allowNull: true },
updatedBy: { type: Sequelize.BIGINT, allowNull: true },
deletedBy: { type: Sequelize.BIGINT, allowNull: true },
createdAt: { type: Sequelize.DATE, allowNull: false },
updatedAt: { type: Sequelize.DATE, allowNull: false },
deletedAt: { type: Sequelize.DATE, allowNull: true },
});
await queryInterface.addIndex('units', ['uuid']);
await queryInterface.addIndex('units', ['course_id']);
await queryInterface.addIndex('units', ['order_index']);
await queryInterface.addIndex('units', ['deletedAt']);
},
async down(queryInterface) {
await queryInterface.dropTable('units');
},
};
@@ -0,0 +1,25 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('unit_quizzes', {
quiz_id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
uuid: { type: Sequelize.UUID, defaultValue: Sequelize.UUIDV4, unique: true },
unit_id: { type: Sequelize.BIGINT, allowNull: false, unique: true, references: { model: 'units', key: 'unit_id' }, onDelete: 'CASCADE' },
title: { type: Sequelize.STRING(255), allowNull: true },
is_required: { type: Sequelize.BOOLEAN, defaultValue: false },
passing_score: { type: Sequelize.INTEGER, defaultValue: 70 },
max_questions: { type: Sequelize.INTEGER, allowNull: true },
createdBy: { type: Sequelize.BIGINT, allowNull: true },
updatedBy: { type: Sequelize.BIGINT, allowNull: true },
deletedBy: { type: Sequelize.BIGINT, allowNull: true },
createdAt: { type: Sequelize.DATE, allowNull: false },
updatedAt: { type: Sequelize.DATE, allowNull: false },
deletedAt: { type: Sequelize.DATE, allowNull: true },
});
},
async down(queryInterface) {
await queryInterface.dropTable('unit_quizzes');
},
};
@@ -0,0 +1,30 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('lessons', {
lesson_id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
uuid: { type: Sequelize.UUID, defaultValue: Sequelize.UUIDV4, allowNull: false, unique: true },
unit_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'units', key: 'unit_id' }, onDelete: 'CASCADE' },
title: { type: Sequelize.STRING(255), allowNull: false },
description: { type: Sequelize.TEXT, allowNull: true },
duration_seconds: { type: Sequelize.INTEGER, allowNull: true, defaultValue: 0 },
order_index: { type: Sequelize.INTEGER, allowNull: false, defaultValue: 0 },
createdBy: { type: Sequelize.BIGINT, allowNull: true },
updatedBy: { type: Sequelize.BIGINT, allowNull: true },
deletedBy: { type: Sequelize.BIGINT, allowNull: true },
createdAt: { type: Sequelize.DATE, allowNull: false },
updatedAt: { type: Sequelize.DATE, allowNull: false },
deletedAt: { type: Sequelize.DATE, allowNull: true },
});
await queryInterface.addIndex('lessons', ['uuid']);
await queryInterface.addIndex('lessons', ['unit_id']);
await queryInterface.addIndex('lessons', ['order_index']);
await queryInterface.addIndex('lessons', ['deletedAt']);
},
async down(queryInterface) {
await queryInterface.dropTable('lessons');
},
};
@@ -0,0 +1,19 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('lesson_pages', {
page_id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
lesson_id: { type: Sequelize.BIGINT, allowNull: false, unique: true, references: { model: 'lessons', key: 'lesson_id' }, onDelete: 'CASCADE' },
blocks: { type: Sequelize.JSONB, allowNull: false, defaultValue: [] },
createdBy: { type: Sequelize.BIGINT, allowNull: true },
updatedBy: { type: Sequelize.BIGINT, allowNull: true },
createdAt: { type: Sequelize.DATE, allowNull: false },
updatedAt: { type: Sequelize.DATE, allowNull: false },
});
},
async down(queryInterface) {
await queryInterface.dropTable('lesson_pages');
},
};
@@ -0,0 +1,21 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('lesson_objectives', {
objective_id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
lesson_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'lessons', key: 'lesson_id' }, onDelete: 'CASCADE' },
text: { type: Sequelize.TEXT, allowNull: false },
order_index: { type: Sequelize.INTEGER, defaultValue: 0 },
createdAt: { type: Sequelize.DATE, allowNull: false },
updatedAt: { type: Sequelize.DATE, allowNull: false },
deletedAt: { type: Sequelize.DATE, allowNull: true },
});
await queryInterface.addIndex('lesson_objectives', ['lesson_id']);
},
async down(queryInterface) {
await queryInterface.dropTable('lesson_objectives');
},
};
@@ -0,0 +1,29 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('quiz_questions', {
question_id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
uuid: { type: Sequelize.UUID, defaultValue: Sequelize.UUIDV4, unique: true },
quiz_id: { type: Sequelize.BIGINT, allowNull: true, references: { model: 'unit_quizzes', key: 'quiz_id' }, onDelete: 'CASCADE' },
assessment_id: { type: Sequelize.BIGINT, allowNull: true, references: { model: 'course_assessments', key: 'assessment_id' }, onDelete: 'CASCADE' },
type: { type: Sequelize.ENUM('true_false', 'multiple_choice', 'multi_select'), allowNull: false },
question: { type: Sequelize.TEXT, allowNull: false },
explanation: { type: Sequelize.TEXT, allowNull: true },
order_index: { type: Sequelize.INTEGER, defaultValue: 0 },
points: { type: Sequelize.INTEGER, defaultValue: 1 },
createdBy: { type: Sequelize.BIGINT, allowNull: true },
deletedBy: { type: Sequelize.BIGINT, allowNull: true },
createdAt: { type: Sequelize.DATE, allowNull: false },
updatedAt: { type: Sequelize.DATE, allowNull: false },
deletedAt: { type: Sequelize.DATE, allowNull: true },
});
await queryInterface.addIndex('quiz_questions', ['quiz_id']);
await queryInterface.addIndex('quiz_questions', ['assessment_id']);
},
async down(queryInterface) {
await queryInterface.dropTable('quiz_questions');
},
};
@@ -0,0 +1,21 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('quiz_options', {
option_id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
question_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'quiz_questions', key: 'question_id' }, onDelete: 'CASCADE' },
text: { type: Sequelize.TEXT, allowNull: false },
is_correct: { type: Sequelize.BOOLEAN, defaultValue: false },
order_index: { type: Sequelize.INTEGER, defaultValue: 0 },
createdAt: { type: Sequelize.DATE, allowNull: false },
updatedAt: { type: Sequelize.DATE, allowNull: false },
});
await queryInterface.addIndex('quiz_options', ['question_id']);
},
async down(queryInterface) {
await queryInterface.dropTable('quiz_options');
},
};
@@ -0,0 +1,28 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('quiz_attempts', {
attempt_id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
uuid: { type: Sequelize.UUID, defaultValue: Sequelize.UUIDV4, unique: true },
user_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'users', key: 'user_id' }, onDelete: 'CASCADE' },
quiz_id: { type: Sequelize.BIGINT, allowNull: true, references: { model: 'unit_quizzes', key: 'quiz_id' }, onDelete: 'SET NULL' },
assessment_id: { type: Sequelize.BIGINT, allowNull: true, references: { model: 'course_assessments', key: 'assessment_id' }, onDelete: 'SET NULL' },
answers: { type: Sequelize.JSONB, allowNull: false, defaultValue: {} },
total_points: { type: Sequelize.INTEGER, allowNull: false, defaultValue: 0 },
earned_points: { type: Sequelize.INTEGER, allowNull: false, defaultValue: 0 },
score: { type: Sequelize.INTEGER, allowNull: false, defaultValue: 0 },
passed: { type: Sequelize.BOOLEAN, allowNull: false, defaultValue: false },
createdAt: { type: Sequelize.DATE, allowNull: false },
updatedAt: { type: Sequelize.DATE, allowNull: false },
});
await queryInterface.addIndex('quiz_attempts', ['user_id']);
await queryInterface.addIndex('quiz_attempts', ['quiz_id']);
await queryInterface.addIndex('quiz_attempts', ['assessment_id']);
},
async down(queryInterface) {
await queryInterface.dropTable('quiz_attempts');
},
};
@@ -0,0 +1,22 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('tier_plans', {
plan_id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
tier: { type: Sequelize.ENUM('premium', 'exclusive'), allowNull: false },
label: { type: Sequelize.STRING(100), allowNull: false },
duration_days: { type: Sequelize.INTEGER, allowNull: false },
price: { type: Sequelize.DECIMAL(10, 2), allowNull: false },
currency: { type: Sequelize.CHAR(3), allowNull: false, defaultValue: 'USD' },
is_active: { type: Sequelize.BOOLEAN, allowNull: false, defaultValue: true },
createdAt: { type: Sequelize.DATE, allowNull: false },
updatedAt: { type: Sequelize.DATE, allowNull: false },
deletedAt: { type: Sequelize.DATE, allowNull: true },
});
},
async down(queryInterface) {
await queryInterface.dropTable('tier_plans');
},
};
@@ -0,0 +1,19 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('plan_courses', {
id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
plan_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'tier_plans', key: 'plan_id' }, onDelete: 'CASCADE' },
course_id: { type: Sequelize.BIGINT, allowNull: false, unique: true, references: { model: 'courses', key: 'course_id' }, onDelete: 'CASCADE' },
createdAt: { type: Sequelize.DATE, allowNull: false },
updatedAt: { type: Sequelize.DATE, allowNull: false },
});
await queryInterface.addIndex('plan_courses', ['plan_id']);
},
async down(queryInterface) {
await queryInterface.dropTable('plan_courses');
},
};
@@ -0,0 +1,27 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('user_tiers', {
tier_id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
user_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'users', key: 'user_id' }, onDelete: 'CASCADE' },
tier: { type: Sequelize.ENUM('free', 'premium', 'exclusive'), allowNull: false, defaultValue: 'free' },
status: { type: Sequelize.ENUM('active', 'expired', 'revoked'), allowNull: false, defaultValue: 'active' },
starts_at: { type: Sequelize.DATE, allowNull: false, defaultValue: Sequelize.NOW },
expires_at: { type: Sequelize.DATE, allowNull: true },
granted_by: { type: Sequelize.BIGINT, allowNull: true },
revoked_by: { type: Sequelize.BIGINT, allowNull: true },
revoked_at: { type: Sequelize.DATE, allowNull: true },
notes: { type: Sequelize.TEXT, allowNull: true },
createdAt: { type: Sequelize.DATE, allowNull: false },
updatedAt: { type: Sequelize.DATE, allowNull: false },
});
await queryInterface.addIndex('user_tiers', ['user_id']);
await queryInterface.addIndex('user_tiers', ['status']);
},
async down(queryInterface) {
await queryInterface.dropTable('user_tiers');
},
};
@@ -0,0 +1,34 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('payments', {
payment_id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
user_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'users', key: 'user_id' }, onDelete: 'CASCADE' },
plan_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'tier_plans', key: 'plan_id' }, onDelete: 'RESTRICT' },
tier_id: { type: Sequelize.BIGINT, allowNull: true, references: { model: 'user_tiers', key: 'tier_id' }, onDelete: 'SET NULL' },
status: { type: Sequelize.ENUM('pending', 'completed', 'failed', 'cancelled', 'expired', 'refunded'), allowNull: false, defaultValue: 'pending' },
amount: { type: Sequelize.DECIMAL(10, 2), allowNull: false },
currency: { type: Sequelize.STRING(10), allowNull: false, defaultValue: 'USD' },
promo_code: { type: Sequelize.STRING(50), allowNull: true },
discount: { type: Sequelize.DECIMAL(10, 2), allowNull: false, defaultValue: 0.00 },
provider: { type: Sequelize.STRING(50), allowNull: false, defaultValue: 'paypal' },
provider_payload: { type: Sequelize.JSONB, allowNull: true, defaultValue: {} },
paid_at: { type: Sequelize.DATE, allowNull: true },
createdBy: { type: Sequelize.BIGINT, allowNull: true },
updatedBy: { type: Sequelize.BIGINT, allowNull: true },
deletedBy: { type: Sequelize.BIGINT, allowNull: true },
createdAt: { type: Sequelize.DATE, allowNull: false },
updatedAt: { type: Sequelize.DATE, allowNull: false },
deletedAt: { type: Sequelize.DATE, allowNull: true },
});
await queryInterface.addIndex('payments', ['user_id']);
await queryInterface.addIndex('payments', ['status']);
await queryInterface.addIndex('payments', ['plan_id']);
},
async down(queryInterface) {
await queryInterface.dropTable('payments');
},
};
@@ -0,0 +1,40 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('advertisements', {
advertisement_id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
uuid: { type: Sequelize.UUID, defaultValue: Sequelize.UUIDV4, allowNull: false, unique: true },
type: { type: Sequelize.ENUM('hero', 'banner', 'popup', 'sidebar'), allowNull: false },
status: { type: Sequelize.ENUM('draft', 'active', 'scheduled', 'expired', 'archived'), allowNull: false, defaultValue: 'draft' },
badge_label: { type: Sequelize.STRING(100) },
headline: { type: Sequelize.STRING(255) },
description: { type: Sequelize.TEXT },
image_url: { type: Sequelize.STRING(512) },
image_asset_id: { type: Sequelize.BIGINT, allowNull: true, references: { model: 'assets', key: 'asset_id' }, onDelete: 'SET NULL' },
ctas: { type: Sequelize.JSONB, allowNull: false, defaultValue: [] },
start_date: { type: Sequelize.DATE },
end_date: { type: Sequelize.DATE },
order: { type: Sequelize.INTEGER, allowNull: false, defaultValue: 0 },
is_active: { type: Sequelize.BOOLEAN, allowNull: false, defaultValue: true },
size: { type: Sequelize.ENUM('sm', 'md', 'lg'), allowNull: true },
click_count: { type: Sequelize.INTEGER, allowNull: false, defaultValue: 0 },
createdBy: { type: Sequelize.BIGINT, allowNull: true },
updatedBy: { type: Sequelize.BIGINT, allowNull: true },
deletedBy: { type: Sequelize.BIGINT, allowNull: true },
createdAt: { type: Sequelize.DATE, allowNull: false },
updatedAt: { type: Sequelize.DATE, allowNull: false },
deletedAt: { type: Sequelize.DATE, allowNull: true },
});
await queryInterface.addIndex('advertisements', ['uuid']);
await queryInterface.addIndex('advertisements', ['type']);
await queryInterface.addIndex('advertisements', ['status']);
await queryInterface.addIndex('advertisements', ['is_active']);
await queryInterface.addIndex('advertisements', ['deletedAt']);
},
async down(queryInterface) {
await queryInterface.dropTable('advertisements');
},
};
@@ -0,0 +1,21 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('task_lists', {
task_list_id: { type: Sequelize.UUID, defaultValue: Sequelize.UUIDV4, primaryKey: true },
name: { type: Sequelize.STRING, allowNull: false },
description: { type: Sequelize.TEXT, allowNull: true },
createdBy: { type: Sequelize.INTEGER, allowNull: true },
updatedBy: { type: Sequelize.INTEGER, allowNull: true },
deletedBy: { type: Sequelize.INTEGER, allowNull: true },
createdAt: { type: Sequelize.DATE, allowNull: false },
updatedAt: { type: Sequelize.DATE, allowNull: false },
deletedAt: { type: Sequelize.DATE, allowNull: true },
});
},
async down(queryInterface) {
await queryInterface.dropTable('task_lists');
},
};
@@ -0,0 +1,21 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('task_list_groups', {
id: { type: Sequelize.UUID, defaultValue: Sequelize.UUIDV4, primaryKey: true },
task_list_id: { type: Sequelize.UUID, allowNull: false, references: { model: 'task_lists', key: 'task_list_id' }, onDelete: 'CASCADE' },
group_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'user_groups', key: 'group_id' }, onDelete: 'CASCADE' },
assignedAt: { type: Sequelize.DATE, defaultValue: Sequelize.NOW, allowNull: false },
assignedBy: { type: Sequelize.BIGINT, allowNull: true },
});
await queryInterface.addIndex('task_list_groups', { fields: ['task_list_id', 'group_id'], unique: true, name: 'uq_task_list_group' });
await queryInterface.addIndex('task_list_groups', { fields: ['task_list_id'], name: 'idx_tlg_task_list_id' });
await queryInterface.addIndex('task_list_groups', { fields: ['group_id'], name: 'idx_tlg_group_id' });
},
async down(queryInterface) {
await queryInterface.dropTable('task_list_groups');
},
};
@@ -0,0 +1,27 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('tasks', {
task_id: { type: Sequelize.UUID, defaultValue: Sequelize.UUIDV4, primaryKey: true },
task_list_id: { type: Sequelize.UUID, allowNull: false, references: { model: 'task_lists', key: 'task_list_id' }, onDelete: 'CASCADE' },
name: { type: Sequelize.STRING, allowNull: false },
description: { type: Sequelize.TEXT, allowNull: true },
deadline: { type: Sequelize.DATE, allowNull: true },
status: { type: Sequelize.ENUM('pending', 'in_progress', 'completed', 'overdue'), defaultValue: 'pending', allowNull: false },
createdBy: { type: Sequelize.INTEGER, allowNull: true },
updatedBy: { type: Sequelize.INTEGER, allowNull: true },
deletedBy: { type: Sequelize.INTEGER, allowNull: true },
createdAt: { type: Sequelize.DATE, allowNull: false },
updatedAt: { type: Sequelize.DATE, allowNull: false },
deletedAt: { type: Sequelize.DATE, allowNull: true },
});
await queryInterface.addIndex('tasks', ['task_list_id']);
await queryInterface.addIndex('tasks', ['status']);
},
async down(queryInterface) {
await queryInterface.dropTable('tasks');
},
};
@@ -0,0 +1,30 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('task_requirements', {
requirement_id: { type: Sequelize.UUID, defaultValue: Sequelize.UUIDV4, primaryKey: true },
task_id: { type: Sequelize.UUID, allowNull: false, references: { model: 'tasks', key: 'task_id' }, onDelete: 'CASCADE' },
type: { type: Sequelize.ENUM('visit_link', 'upload_file', 'read_course', 'read_unit', 'read_lesson'), allowNull: false },
link_url: { type: Sequelize.STRING, allowNull: true },
link_label: { type: Sequelize.STRING, allowNull: true },
allowed_file_types: { type: Sequelize.JSONB, allowNull: true },
max_file_count: { type: Sequelize.INTEGER, allowNull: true, defaultValue: 1 },
reference_id: { type: Sequelize.UUID, allowNull: true },
reference_label: { type: Sequelize.STRING, allowNull: true },
order: { type: Sequelize.INTEGER, defaultValue: 0 },
createdBy: { type: Sequelize.INTEGER, allowNull: true },
updatedBy: { type: Sequelize.INTEGER, allowNull: true },
deletedBy: { type: Sequelize.INTEGER, allowNull: true },
createdAt: { type: Sequelize.DATE, allowNull: false },
updatedAt: { type: Sequelize.DATE, allowNull: false },
deletedAt: { type: Sequelize.DATE, allowNull: true },
});
await queryInterface.addIndex('task_requirements', ['task_id']);
},
async down(queryInterface) {
await queryInterface.dropTable('task_requirements');
},
};
@@ -0,0 +1,27 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('task_completions', {
completion_id: { type: Sequelize.UUID, defaultValue: Sequelize.UUIDV4, primaryKey: true },
task_id: { type: Sequelize.UUID, allowNull: false, references: { model: 'tasks', key: 'task_id' }, onDelete: 'CASCADE' },
user_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'users', key: 'user_id' }, onDelete: 'CASCADE' },
note: { type: Sequelize.TEXT, allowNull: true },
submitted_at: { type: Sequelize.DATE, defaultValue: Sequelize.NOW, allowNull: false },
createdBy: { type: Sequelize.BIGINT, allowNull: true },
updatedBy: { type: Sequelize.BIGINT, allowNull: true },
deletedBy: { type: Sequelize.BIGINT, allowNull: true },
createdAt: { type: Sequelize.DATE, allowNull: false },
updatedAt: { type: Sequelize.DATE, allowNull: false },
deletedAt: { type: Sequelize.DATE, allowNull: true },
});
await queryInterface.addIndex('task_completions', { fields: ['task_id'], name: 'idx_tc_task_id' });
await queryInterface.addIndex('task_completions', { fields: ['user_id'], name: 'idx_tc_user_id' });
await queryInterface.addIndex('task_completions', { fields: ['task_id', 'user_id'], name: 'idx_tc_task_user' });
},
async down(queryInterface) {
await queryInterface.dropTable('task_completions');
},
};
@@ -0,0 +1,27 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('task_completion_files', {
file_id: { type: Sequelize.UUID, defaultValue: Sequelize.UUIDV4, primaryKey: true },
completion_id: { type: Sequelize.UUID, allowNull: false, references: { model: 'task_completions', key: 'completion_id' }, onDelete: 'CASCADE' },
file_url: { type: Sequelize.STRING, allowNull: false },
file_name: { type: Sequelize.STRING, allowNull: false },
file_size: { type: Sequelize.INTEGER, allowNull: true },
mime_type: { type: Sequelize.STRING, allowNull: true },
storage_key: { type: Sequelize.STRING(500), allowNull: true },
createdBy: { type: Sequelize.BIGINT, allowNull: true },
updatedBy: { type: Sequelize.BIGINT, allowNull: true },
deletedBy: { type: Sequelize.BIGINT, allowNull: true },
createdAt: { type: Sequelize.DATE, allowNull: false },
updatedAt: { type: Sequelize.DATE, allowNull: false },
deletedAt: { type: Sequelize.DATE, allowNull: true },
});
await queryInterface.addIndex('task_completion_files', { fields: ['completion_id'], name: 'idx_tcf_completion_id' });
},
async down(queryInterface) {
await queryInterface.dropTable('task_completion_files');
},
};
@@ -0,0 +1,26 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('task_link_visits', {
visit_id: { type: Sequelize.UUID, defaultValue: Sequelize.UUIDV4, primaryKey: true },
task_id: { type: Sequelize.UUID, allowNull: false, references: { model: 'tasks', key: 'task_id' }, onDelete: 'CASCADE' },
requirement_id: { type: Sequelize.UUID, allowNull: false, references: { model: 'task_requirements', key: 'requirement_id' }, onDelete: 'CASCADE' },
user_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'users', key: 'user_id' }, onDelete: 'CASCADE' },
visited_at: { type: Sequelize.DATE, defaultValue: Sequelize.NOW, allowNull: false },
createdBy: { type: Sequelize.BIGINT, allowNull: true },
updatedBy: { type: Sequelize.BIGINT, allowNull: true },
createdAt: { type: Sequelize.DATE, allowNull: false },
updatedAt: { type: Sequelize.DATE, allowNull: false },
});
await queryInterface.addIndex('task_link_visits', { fields: ['requirement_id', 'user_id'], unique: true, name: 'uq_tlv_requirement_user' });
await queryInterface.addIndex('task_link_visits', { fields: ['task_id'], name: 'idx_tlv_task_id' });
await queryInterface.addIndex('task_link_visits', { fields: ['user_id'], name: 'idx_tlv_user_id' });
await queryInterface.addIndex('task_link_visits', { fields: ['requirement_id'], name: 'idx_tlv_requirement_id' });
},
async down(queryInterface) {
await queryInterface.dropTable('task_link_visits');
},
};
@@ -0,0 +1,30 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('task_progress', {
progress_id: { type: Sequelize.UUID, defaultValue: Sequelize.UUIDV4, primaryKey: true },
task_id: { type: Sequelize.UUID, allowNull: false, references: { model: 'tasks', key: 'task_id' }, onDelete: 'CASCADE' },
requirement_id: { type: Sequelize.UUID, allowNull: false, references: { model: 'task_requirements', key: 'requirement_id' }, onDelete: 'CASCADE' },
user_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'users', key: 'user_id' }, onDelete: 'CASCADE' },
reference_id: { type: Sequelize.UUID, allowNull: false },
type: { type: Sequelize.ENUM('read_course', 'read_unit', 'read_lesson'), allowNull: false },
completed: { type: Sequelize.BOOLEAN, defaultValue: false, allowNull: false },
completed_at: { type: Sequelize.DATE, allowNull: true },
createdBy: { type: Sequelize.BIGINT, allowNull: true },
updatedBy: { type: Sequelize.BIGINT, allowNull: true },
createdAt: { type: Sequelize.DATE, allowNull: false },
updatedAt: { type: Sequelize.DATE, allowNull: false },
});
await queryInterface.addIndex('task_progress', { fields: ['requirement_id', 'user_id', 'reference_id'], unique: true, name: 'uq_tp_requirement_user_reference' });
await queryInterface.addIndex('task_progress', { fields: ['task_id'], name: 'idx_tp_task_id' });
await queryInterface.addIndex('task_progress', { fields: ['user_id'], name: 'idx_tp_user_id' });
await queryInterface.addIndex('task_progress', { fields: ['requirement_id'], name: 'idx_tp_requirement_id' });
await queryInterface.addIndex('task_progress', { fields: ['type'], name: 'idx_tp_type' });
},
async down(queryInterface) {
await queryInterface.dropTable('task_progress');
},
};
@@ -0,0 +1,25 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('admin_notifications', {
notification_id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
type: { type: Sequelize.STRING(64), allowNull: false },
title: { type: Sequelize.STRING(255), allowNull: false },
message: { type: Sequelize.TEXT, allowNull: false },
data: { type: Sequelize.JSONB, allowNull: true, defaultValue: null },
seen: { type: Sequelize.BOOLEAN, allowNull: false, defaultValue: false },
seen_at: { type: Sequelize.DATE, allowNull: true, defaultValue: null },
createdAt: { type: Sequelize.DATE, allowNull: false },
updatedAt: { type: Sequelize.DATE, allowNull: false },
});
await queryInterface.addIndex('admin_notifications', { fields: ['seen'], name: 'idx_an_seen' });
await queryInterface.addIndex('admin_notifications', { fields: ['type'], name: 'idx_an_type' });
await queryInterface.addIndex('admin_notifications', { fields: ['createdAt'], name: 'idx_an_created_at' });
},
async down(queryInterface) {
await queryInterface.dropTable('admin_notifications');
},
};
@@ -0,0 +1,27 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('user_notifications', {
notification_id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
user_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'users', key: 'user_id' }, onDelete: 'CASCADE' },
type: { type: Sequelize.STRING(64), allowNull: false },
title: { type: Sequelize.STRING(255), allowNull: false },
message: { type: Sequelize.TEXT, allowNull: false },
data: { type: Sequelize.JSONB, allowNull: true, defaultValue: null },
seen: { type: Sequelize.BOOLEAN, allowNull: false, defaultValue: false },
seen_at: { type: Sequelize.DATE, allowNull: true, defaultValue: null },
createdAt: { type: Sequelize.DATE, allowNull: false },
updatedAt: { type: Sequelize.DATE, allowNull: false },
});
await queryInterface.addIndex('user_notifications', { fields: ['user_id'], name: 'idx_un_user_id' });
await queryInterface.addIndex('user_notifications', { fields: ['user_id', 'seen'], name: 'idx_un_seen' });
await queryInterface.addIndex('user_notifications', { fields: ['type'], name: 'idx_un_type' });
await queryInterface.addIndex('user_notifications', { fields: ['createdAt'], name: 'idx_un_created_at' });
},
async down(queryInterface) {
await queryInterface.dropTable('user_notifications');
},
};
@@ -0,0 +1,20 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('categories', {
id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
name: { type: Sequelize.STRING(100), allowNull: false, unique: true },
slug: { type: Sequelize.STRING(120), allowNull: false, unique: true },
description: { type: Sequelize.TEXT, allowNull: true },
is_active: { type: Sequelize.BOOLEAN, allowNull: false, defaultValue: true },
createdAt: { type: Sequelize.DATE, allowNull: false },
updatedAt: { type: Sequelize.DATE, allowNull: false },
deletedAt: { type: Sequelize.DATE, allowNull: true },
});
},
async down(queryInterface) {
await queryInterface.dropTable('categories');
},
};
@@ -0,0 +1,25 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('products', {
id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
course_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'courses', key: 'course_id' }, onDelete: 'CASCADE' },
name: { type: Sequelize.STRING(200), allowNull: false },
description: { type: Sequelize.TEXT, allowNull: true },
price: { type: Sequelize.DECIMAL(10, 2), allowNull: false },
currency: { type: Sequelize.STRING(10), allowNull: false, defaultValue: 'USD' },
access_days: { type: Sequelize.INTEGER, allowNull: true }, // null = lifetime access
is_active: { type: Sequelize.BOOLEAN, allowNull: false, defaultValue: true },
createdAt: { type: Sequelize.DATE, allowNull: false },
updatedAt: { type: Sequelize.DATE, allowNull: false },
deletedAt: { type: Sequelize.DATE, allowNull: true },
});
await queryInterface.addIndex('products', ['course_id']);
},
async down(queryInterface) {
await queryInterface.dropTable('products');
},
};
@@ -0,0 +1,29 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('course_purchases', {
id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
user_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'users', key: 'user_id' }, onDelete: 'CASCADE' },
product_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'products', key: 'id' }, onDelete: 'RESTRICT' },
amount: { type: Sequelize.DECIMAL(10, 2), allowNull: false },
currency: { type: Sequelize.STRING(10), allowNull: false, defaultValue: 'USD' },
status: { type: Sequelize.ENUM('pending', 'completed', 'failed', 'cancelled', 'refunded'), allowNull: false, defaultValue: 'pending' },
provider: { type: Sequelize.STRING(50), allowNull: false, defaultValue: 'paypal' },
provider_payload: { type: Sequelize.JSONB, allowNull: true, defaultValue: {} },
expires_at: { type: Sequelize.DATE, allowNull: true }, // null = lifetime access
paid_at: { type: Sequelize.DATE, allowNull: true },
createdAt: { type: Sequelize.DATE, allowNull: false },
updatedAt: { type: Sequelize.DATE, allowNull: false },
deletedAt: { type: Sequelize.DATE, allowNull: true },
});
await queryInterface.addIndex('course_purchases', ['user_id']);
await queryInterface.addIndex('course_purchases', ['product_id']);
await queryInterface.addIndex('course_purchases', ['status']);
},
async down(queryInterface) {
await queryInterface.dropTable('course_purchases');
},
};
@@ -0,0 +1,28 @@
'use strict';
// Wires up the bare product_id and category_id columns that were left
// as plain integers in migration 000008.
module.exports = {
async up(queryInterface) {
await queryInterface.addConstraint('course_products', {
fields: ['product_id'],
type: 'foreign key',
name: 'fk_course_products_product_id',
references: { table: 'products', field: 'id' },
onDelete: 'CASCADE',
});
await queryInterface.addConstraint('course_product_categories', {
fields: ['category_id'],
type: 'foreign key',
name: 'fk_course_product_categories_category_id',
references: { table: 'categories', field: 'id' },
onDelete: 'CASCADE',
});
},
async down(queryInterface) {
await queryInterface.removeConstraint('course_products', 'fk_course_products_product_id');
await queryInterface.removeConstraint('course_product_categories', 'fk_course_product_categories_category_id');
},
};
@@ -0,0 +1,31 @@
'use strict';
module.exports = {
async up(queryInterface) {
await queryInterface.dropTable('file_uploads', { cascade: true });
await queryInterface.dropTable('course_roles', { cascade: true });
await queryInterface.dropTable('course_products', { cascade: true });
},
async down(queryInterface, Sequelize) {
await queryInterface.createTable('file_uploads', {
id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
createdAt: { type: Sequelize.DATE, allowNull: false },
updatedAt: { type: Sequelize.DATE, allowNull: false },
});
await queryInterface.createTable('course_roles', {
id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
course_id: { type: Sequelize.BIGINT, allowNull: false },
role_id: { type: Sequelize.BIGINT, allowNull: false },
createdAt: { type: Sequelize.DATE, allowNull: false },
updatedAt: { type: Sequelize.DATE, allowNull: false },
});
await queryInterface.createTable('course_products', {
id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
course_id: { type: Sequelize.BIGINT, allowNull: false },
product_id: { type: Sequelize.BIGINT, allowNull: false },
createdAt: { type: Sequelize.DATE, allowNull: false },
updatedAt: { type: Sequelize.DATE, allowNull: false },
});
},
};

Some files were not shown because too many files have changed in this diff Show More