chore: relocate backend into apps/api ahead of monorepo merge
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
.env
|
||||
*.log
|
||||
package-lock.json
|
||||
docker-compose.yml
|
||||
.dockerignore
|
||||
@@ -0,0 +1,101 @@
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# $APP_NAME — Environment Variables Template (LOCAL DEVELOPMENT)
|
||||
# Points at the team's shared dev infra (CockroachDB Cloud, Garage, Google
|
||||
# OAuth, PayPal Sandbox, Gmail) — nothing to stand up locally. The only
|
||||
# CHANGE_ME values left are the 4 per-machine secrets (JWT*/SESSION_SECRET,
|
||||
# auto-generated by new_starr_setup's wizard) and the optional Chibisafe block.
|
||||
#
|
||||
# Local development setup:
|
||||
# 1. cp .env-development .env
|
||||
# 2. Fill in the remaining CHANGE_ME values below (or let the setup wizard do it)
|
||||
# 3. npm install
|
||||
# 4. npm run dev (nodemon, auto-restarts on file change)
|
||||
#
|
||||
# For a production / self-hosted deployment (Docker Compose, Redis, TLS),
|
||||
# use .env-production instead — see docker-compose.yml.
|
||||
#
|
||||
# Generate random secrets with:
|
||||
# node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
# ── App ───────────────────────────────────────────────────────────────────────
|
||||
APP_NAME=starr
|
||||
NODE_ENV=development
|
||||
ORIGIN_GUARD_DISABLED=true # allows Postman/curl in dev; hard-locked false when NODE_ENV=production
|
||||
PORT=3024
|
||||
APP_URL=http://localhost:3024
|
||||
APP_LOGO_URL=https://cq5as7pc73.ufs.sh/f/pHNnzIw3VjcgjEJ1kFubDgYdvnt1cCAMyRjfOIGiUXPmZF3W
|
||||
FRONTEND_URL=http://localhost:5173
|
||||
|
||||
# ── Database (PostgreSQL) ─────────────────────────────────────────────────────
|
||||
# Shared team CockroachDB Cloud cluster — SSL is on by default (see
|
||||
# config/db.config.js: useSSL is true unless DB_SSL=false is set explicitly).
|
||||
DB_HOST=philproperties-16426.j77.aws-ap-southeast-1.cockroachlabs.cloud
|
||||
DB_PORT=26257
|
||||
DB_NAME=star-philpro
|
||||
DB_USER=lash
|
||||
DB_PASSWORD=K5mXTLyL_FrVGOHLHpzU4g
|
||||
DB_FORCE_SYNC=false # never drop tables — even in dev, unless you mean it
|
||||
|
||||
# ── Cache driver ──────────────────────────────────────────────────────────────
|
||||
# memory → no Redis/Valkey needed. Safe default for a single local process.
|
||||
# Rate limit counters and CSRF sessions reset on restart — fine for dev.
|
||||
CACHE_DRIVER=memory
|
||||
|
||||
# ── JWT ───────────────────────────────────────────────────────────────────────
|
||||
# Per-machine secrets — never reuse across fields or share between devs.
|
||||
# new_starr_setup's wizard auto-generates fresh values for these on first run;
|
||||
# if filling this in by hand instead, generate each independently:
|
||||
# node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||
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 ────────────────────────────────────────────────────────────
|
||||
# Also per-machine — auto-generated by the setup wizard, see note above.
|
||||
SESSION_SECRET=CHANGE_ME_32_BYTE_HEX
|
||||
|
||||
# ── Google OAuth ──────────────────────────────────────────────────────────────
|
||||
# Shared team OAuth client (console.cloud.google.com → Credentials).
|
||||
GOOGLE_CLIENT_ID=379154949440-atchm4lurp9c1k23vbsjvgnqod2o9s5c.apps.googleusercontent.com
|
||||
GOOGLE_CLIENT_SECRET=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
|
||||
GOOGLE_CALLBACK_URL=http://localhost:3024/api/auth/google/callback
|
||||
|
||||
# ── PayPal ────────────────────────────────────────────────────────────────────
|
||||
# Shared team Sandbox app: developer.paypal.com → Sandbox tab.
|
||||
PAYPAL_CLIENT_ID=AYjb-DmhvvCagQM0SZxw-sfz30Qb8D-dBPr-wmvq80zOKmfxUrKykv-h85Kzp_Z5YmCX4T06DlaH2-zK
|
||||
PAYPAL_CLIENT_SECRET=EDkmLa47T0H5QtKdO8VfKdEDh--tuzP0Q2c_RFKASvdIJvs2j051v7j2hW0f8JxNDpjW8XyixE4JCxV4
|
||||
PAYPAL_ENV=sandbox
|
||||
|
||||
# ── Email (Gmail API — OAuth2, over HTTPS) ────────────────────────────────────
|
||||
# Reuses the same OAuth client as GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRET above.
|
||||
# Shared team refresh token — same value the deployed app uses.
|
||||
GMAIL_REFRESH_TOKEN=1//0ebfPPkFoe9piCgYIARAAGA4SNwF-L9IrsTIWK1dWFRa2dmc33jd94_u5IA-LpTKQweSi3NODT78UhX91OH25ewE_riCT-DjaQg8
|
||||
EMAIL_FROM=services.philpro@gmail.com
|
||||
|
||||
OTP_EXPIRY_MINUTES=10
|
||||
|
||||
# ── S3-compatible Storage (Garage) ────────────────────────────────────────────
|
||||
# Shared team Garage instance. Uses the public HTTPS endpoint here (not a
|
||||
# loopback/WireGuard address) so it works on a fresh machine with no tunnel.
|
||||
S3_ENDPOINT=https://media.star-philpro-media.space
|
||||
S3_REGION=garage
|
||||
S3_ACCESS_KEY=GK1c811a656fafd5ae444eea7b747c4869
|
||||
S3_SECRET_KEY=76001b32d753375bd1fc22c9389b590021143b6fbe9d1688d8ed4dba5f004965
|
||||
S3_BUCKET=philproperties
|
||||
S3_PUBLIC_URL=https://media.star-philpro-media.space
|
||||
|
||||
# ── Chibisafe (optional — used alongside S3 for some asset types) ────────────
|
||||
CHIBISAFE_BASE_URL=CHANGE_ME
|
||||
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 ──────────────────────────────────────────────────────────────────────
|
||||
ALLOWED_ORIGINS=http://localhost:5173,http://localhost:3024
|
||||
@@ -0,0 +1,130 @@
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# $APP_NAME — Environment Variables Template (PRODUCTION / SELF-HOSTED)
|
||||
# Copy to .env and fill in all CHANGE_ME values before deploying.
|
||||
#
|
||||
# Self-hosted setup (recommended):
|
||||
# 1. cp .env-production .env
|
||||
# 2. Fill in every CHANGE_ME value below
|
||||
# 3. docker compose up -d (see docker-compose.yml)
|
||||
# — or, without Docker: npm install && npm start
|
||||
# (or: pm2 start server.js --name $APP_NAME, with Nginx in front)
|
||||
#
|
||||
# For local development, use .env-development instead (memory cache, no
|
||||
# Docker/Redis/TLS required).
|
||||
#
|
||||
# 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 (Gmail API — OAuth2, over HTTPS) ────────────────────────────────────
|
||||
# Raw SMTP (port 25/465/587) is blocked outbound on Render and several other
|
||||
# PaaS hosts, so sending goes through the Gmail REST API over HTTPS instead.
|
||||
# Reuses the same OAuth client as GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRET above —
|
||||
# just enable the Gmail API on that project, then run:
|
||||
# node scripts/get_gmail_refresh_token.js
|
||||
# and paste the printed value below.
|
||||
GMAIL_REFRESH_TOKEN=CHANGE_ME
|
||||
EMAIL_FROM=CHANGE_ME@gmail.com
|
||||
|
||||
OTP_EXPIRY_MINUTES=10
|
||||
|
||||
# ── S3-compatible Storage (Garage self-hosted) ────────────────────────────────
|
||||
# Garage is bundled in docker-compose.yml — no separate install needed.
|
||||
#
|
||||
# Docker setup: S3_ENDPOINT=http://garage:3900 (use the service name)
|
||||
# Bare-metal: S3_ENDPOINT=http://127.0.0.1:3900
|
||||
#
|
||||
# S3_PUBLIC_URL: public-facing URL served by your reverse proxy
|
||||
# (e.g. Caddy/Nginx → https://cdn.yourdomain.com → garage:3900)
|
||||
#
|
||||
# GARAGE_RPC_SECRET: shared secret for Garage RPC.
|
||||
# Generate with: openssl rand -hex 32
|
||||
S3_ENDPOINT=http://garage: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
|
||||
GARAGE_RPC_SECRET=CHANGE_ME_32_BYTE_HEX
|
||||
|
||||
# ── Chibisafe (optional — used alongside S3 for some asset types) ─────────────
|
||||
# Set CHIBISAFE_BASE_URL to the public 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
|
||||
@@ -0,0 +1,10 @@
|
||||
'use strict';
|
||||
|
||||
const path = require('path');
|
||||
|
||||
module.exports = {
|
||||
'config': path.resolve('config', 'database.cjs'),
|
||||
'models-path': path.resolve('database', 'models'),
|
||||
'migrations-path': path.resolve('database', 'migrations'),
|
||||
'seeders-path': path.resolve('database', 'seeders'),
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
FROM pandoc/typst:latest-alpine AS typst
|
||||
|
||||
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
|
||||
|
||||
# Typst — compiles certificate.typ into the downloadable PDF certificate.
|
||||
# Not available as an apk package, so lift the binary out of pandoc/typst.
|
||||
COPY --from=typst /usr/bin/typst /usr/local/bin/typst
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install production dependencies only
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN chmod +x docker-entrypoint.sh
|
||||
|
||||
EXPOSE 3024
|
||||
|
||||
ENTRYPOINT ["./docker-entrypoint.sh"]
|
||||
@@ -0,0 +1,18 @@
|
||||
# STAR Phase 1 — Tier 1 Auth System
|
||||
## Technical Documentation
|
||||
|
||||
**Version:** 1.0.0
|
||||
**Date:** October 6, 2025
|
||||
**Stack:** Node.js · Express · Sequelize (PostgreSQL) · JWT · Google OAuth 2.0 · Nodemailer
|
||||
**Deployment** DigitalOcean Droplet (3 Droplets for API + S3 and Postgres)
|
||||
|
||||
## TODO
|
||||
Branchings and Tags (Backups)
|
||||
```
|
||||
main (protected) → prod
|
||||
qas (protected) → staging
|
||||
dev (protected*) → integration/playground
|
||||
feature/xyz → short-lived, deleted after merge
|
||||
hotfix/xyz → for urgent prod patches
|
||||
+ tags (v1.0.9...) → released snapshots
|
||||
```
|
||||
@@ -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** |
|
||||
@@ -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) || 5432,
|
||||
dialect: 'postgres',
|
||||
dialectOptions: {
|
||||
...(useSSL && {
|
||||
ssl: {
|
||||
require: true,
|
||||
rejectUnauthorized: false,
|
||||
},
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
development: base,
|
||||
production: base,
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name: db.config.js
|
||||
* Type of Program: Configuration
|
||||
* Description: Sequelize PostgreSQL connection instance.
|
||||
* Reads credentials from environment variables and exposes a single
|
||||
* authenticated Sequelize instance used across all models.
|
||||
* Author: rgrgogu
|
||||
* Date Created: Oct. 6, 2025
|
||||
***********************************************************************************************************************************************************************
|
||||
* HOW TO USE:
|
||||
* const sequelize = require('./config/db.config');
|
||||
* // Then use sequelize.define() or import your models directly.
|
||||
***********************************************************************************************************************************************************************/
|
||||
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,
|
||||
process.env.DB_PASSWORD,
|
||||
{
|
||||
host: process.env.DB_HOST,
|
||||
port: process.env.DB_PORT || 5432,
|
||||
dialect: 'postgres',
|
||||
dialectOptions: {
|
||||
...(useSSL && {
|
||||
ssl: {
|
||||
require: true,
|
||||
rejectUnauthorized: false, // or provide CA cert if strict
|
||||
},
|
||||
}),
|
||||
},
|
||||
// logging: process.env.NODE_ENV === 'development' ? console.log : false,
|
||||
logging: false,
|
||||
pool: {
|
||||
max: 10,
|
||||
min: 0,
|
||||
acquire: 30000,
|
||||
idle: 10000,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
module.exports = sequelize;
|
||||
@@ -0,0 +1,17 @@
|
||||
metadata_dir = "/var/lib/garage/meta"
|
||||
data_dir = "/var/lib/garage/data"
|
||||
|
||||
# Single-node setup. Increase replication_factor if you add more Garage nodes.
|
||||
replication_factor = 1
|
||||
|
||||
# RPC — used for node-to-node and CLI-to-daemon communication.
|
||||
# rpc_secret is read from GARAGE_RPC_SECRET env var (set in .env).
|
||||
rpc_bind_addr = "0.0.0.0:3901"
|
||||
rpc_public_addr = "garage:3901"
|
||||
|
||||
[s3_api]
|
||||
s3_region = "garage"
|
||||
api_bind_addr = "0.0.0.0:3900"
|
||||
|
||||
[admin]
|
||||
api_bind_addr = "0.0.0.0:3903"
|
||||
@@ -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,126 @@
|
||||
'use strict';
|
||||
|
||||
const mdl_AchievementDefinitions = require('../../models/users/achievement_definitions.mdl');
|
||||
const CourseAchievement = require('../../models/courses/course_achievement.mdl');
|
||||
const R = require('../../utils/response.util');
|
||||
const logActivity = require('../../utils/logActivity.util');
|
||||
|
||||
const VALID_TYPES = ['badge', 'milestone'];
|
||||
|
||||
// ─── GET /admin/achievements ──────────────────────────────────────────────────
|
||||
|
||||
exports.getAchievements = async (req, res) => {
|
||||
try {
|
||||
const achievements = await mdl_AchievementDefinitions.findAll({
|
||||
order: [['createdAt', 'ASC']],
|
||||
});
|
||||
return R.success(res, 'Achievements retrieved.', achievements);
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][GET ACHIEVEMENTS]', err);
|
||||
return R.error(res, 'Could not retrieve achievements.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── GET /admin/achievements/:id ──────────────────────────────────────────────
|
||||
|
||||
exports.getAchievement = async (req, res) => {
|
||||
try {
|
||||
const achievement = await mdl_AchievementDefinitions.findByPk(req.params.id);
|
||||
if (!achievement) return R.error(res, 'Achievement not found.', 404);
|
||||
return R.success(res, 'Achievement retrieved.', achievement);
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][GET ACHIEVEMENT]', err);
|
||||
return R.error(res, 'Could not retrieve achievement.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── POST /admin/achievements ─────────────────────────────────────────────────
|
||||
|
||||
exports.createAchievement = async (req, res) => {
|
||||
try {
|
||||
const { key, type, label, description, icon, trigger, is_active } = req.body;
|
||||
if (!key || !label) return R.error(res, 'key and label are required.', 400);
|
||||
if (type && !VALID_TYPES.includes(type)) return R.error(res, `type must be one of: ${VALID_TYPES.join(', ')}.`, 400);
|
||||
|
||||
const exists = await mdl_AchievementDefinitions.findOne({ where: { key } });
|
||||
if (exists) return R.error(res, `An achievement with key "${key}" already exists.`, 409);
|
||||
|
||||
const achievement = await mdl_AchievementDefinitions.create({
|
||||
key,
|
||||
type: type || 'badge',
|
||||
label,
|
||||
description: description ?? null,
|
||||
icon: icon || null,
|
||||
trigger: trigger || null,
|
||||
is_active: is_active !== undefined ? !!is_active : true,
|
||||
is_system: false, // only seed data may be system-protected
|
||||
});
|
||||
|
||||
logActivity(req.user?.user_id, 'create_achievement', { entityType: 'achievement', details: { key, label } });
|
||||
|
||||
return R.success(res, 'Achievement created.', achievement, 201);
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][CREATE ACHIEVEMENT]', err);
|
||||
return R.error(res, 'Could not create achievement.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── PUT /admin/achievements/:id ──────────────────────────────────────────────
|
||||
|
||||
exports.updateAchievement = async (req, res) => {
|
||||
try {
|
||||
const achievement = await mdl_AchievementDefinitions.findByPk(req.params.id);
|
||||
if (!achievement) return R.error(res, 'Achievement not found.', 404);
|
||||
|
||||
const { key, type, label, description, icon, trigger, is_active } = req.body;
|
||||
|
||||
if (achievement.is_system && key !== undefined && key !== achievement.key)
|
||||
return R.error(res, 'The key of a system achievement cannot be changed.', 400);
|
||||
if (achievement.is_system && type !== undefined && type !== achievement.type)
|
||||
return R.error(res, 'The type of a system achievement cannot be changed.', 400);
|
||||
if (type && !VALID_TYPES.includes(type)) return R.error(res, `type must be one of: ${VALID_TYPES.join(', ')}.`, 400);
|
||||
|
||||
if (!achievement.is_system && key !== undefined && key !== achievement.key) {
|
||||
const exists = await mdl_AchievementDefinitions.findOne({ where: { key } });
|
||||
if (exists) return R.error(res, `An achievement with key "${key}" already exists.`, 409);
|
||||
}
|
||||
|
||||
await achievement.update({
|
||||
key: (!achievement.is_system && key !== undefined) ? key : achievement.key,
|
||||
type: (!achievement.is_system && type !== undefined) ? type : achievement.type,
|
||||
label: label ?? achievement.label,
|
||||
description: description !== undefined ? (description || null) : achievement.description,
|
||||
icon: icon !== undefined ? (icon || null) : achievement.icon,
|
||||
trigger: trigger !== undefined ? (trigger || null) : achievement.trigger,
|
||||
is_active: is_active !== undefined ? !!is_active : achievement.is_active,
|
||||
});
|
||||
|
||||
logActivity(req.user?.user_id, 'update_achievement', { entityType: 'achievement', details: { id: achievement.achievement_definition_id, key: achievement.key } });
|
||||
|
||||
return R.success(res, 'Achievement updated.', achievement);
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][UPDATE ACHIEVEMENT]', err);
|
||||
return R.error(res, 'Could not update achievement.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── DELETE /admin/achievements/:id ───────────────────────────────────────────
|
||||
|
||||
exports.deleteAchievement = async (req, res) => {
|
||||
try {
|
||||
const achievement = await mdl_AchievementDefinitions.findByPk(req.params.id);
|
||||
if (!achievement) return R.error(res, 'Achievement not found.', 404);
|
||||
if (achievement.is_system) return R.error(res, 'Built-in system achievements cannot be deleted.', 400);
|
||||
|
||||
const assignedCourses = await CourseAchievement.count({ where: { achievement_key: achievement.key } });
|
||||
if (assignedCourses > 0)
|
||||
return R.error(res, `Cannot delete — ${assignedCourses} course(s) still reference this achievement. Unassign it first.`, 409);
|
||||
|
||||
await achievement.destroy();
|
||||
logActivity(req.user?.user_id, 'delete_achievement', { entityType: 'achievement', details: { key: achievement.key } });
|
||||
return R.success(res, 'Achievement deleted.');
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][DELETE ACHIEVEMENT]', err);
|
||||
return R.error(res, 'Could not delete achievement.', 500);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,573 @@
|
||||
// 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 mediaToken = require("../../services/mediaToken.service");
|
||||
const R = require('../../utils/response.util');
|
||||
const { paginate } = require("../../utils/paginate.util");
|
||||
const { adminExclude, jsonbSchemas, computedAttributes } = require("../../models/advertisements/advertisements.attributes");
|
||||
const { PLACEMENT_MAP, PLACEMENT_KEYS } = require("../../models/advertisements/advertisements.placements");
|
||||
const { getFieldValues } = require("../../utils/fieldValues.util");
|
||||
const logActivity = require('../../utils/logActivity.util');
|
||||
|
||||
const { Op } = require('sequelize');
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const notDeleted = { deletedAt: null };
|
||||
|
||||
const ALLOWED_STATUSES = ["draft", "active", "scheduled", "expired", "archived"];
|
||||
|
||||
// Fields needed off the associated Asset to render a preview AND (for S3 assets)
|
||||
// mint a stream token — storage_key is stripped again in attachImageStreamToken
|
||||
// before the row is ever sent out.
|
||||
const AD_IMAGE_ATTRIBUTES = ["asset_id", "display_name", "file_url", "thumbnail_url", "storage_provider", "storage_key", "file_type", "mime_type"];
|
||||
|
||||
// ─── Media proxying ─────────────────────────────────────────────────────────
|
||||
// Mirrors controllers/admin/assets.controller.js's redactS3Url/attachStreamTokens.
|
||||
// Private (S3-backed) advertisement images must never expose a raw file_url to
|
||||
// the browser — mint a short-lived stream token instead so the frontend resolves
|
||||
// it through GET /api/client/media/stream/:token. Public/chibisafe images keep
|
||||
// their direct file_url (no proxy needed).
|
||||
async function attachImageStreamToken(image, req) {
|
||||
if (!image || image.storage_provider !== "s3" || !mediaToken.SUPPORTED_TYPES.includes(image.file_type)) {
|
||||
return image;
|
||||
}
|
||||
const ip = mediaToken.resolveIp(req);
|
||||
const { token } = await mediaToken.issueForAsset(image, req.user?.user_id, ip);
|
||||
image.stream_token = token;
|
||||
image.file_url = null;
|
||||
image.thumbnail_url = null;
|
||||
delete image.storage_key;
|
||||
return image;
|
||||
}
|
||||
|
||||
// ─── 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";
|
||||
}
|
||||
|
||||
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(),
|
||||
// Variant is always derived from position — first CTA is "default"
|
||||
// (primary), second is "outline" — not user-selectable, so any
|
||||
// client-sent variant is ignored.
|
||||
variant: i === 0 ? "default" : "outline",
|
||||
}));
|
||||
}
|
||||
|
||||
// Hard cap: max 2 badge labels per advertisement (matches MAX_BADGE_LABELS on the frontend)
|
||||
function normalizeBadgeLabels(labels) {
|
||||
if (!Array.isArray(labels)) return [];
|
||||
return labels
|
||||
.filter((l) => typeof l === "string" && l.trim().length > 0)
|
||||
.map((l) => l.trim())
|
||||
.slice(0, 2);
|
||||
}
|
||||
|
||||
async function applyAdvertisementFields(advertisement, body) {
|
||||
// placement is the only settable "where" — type/format is always derived
|
||||
// from the placement's registry entry, never accepted directly from the body.
|
||||
if (body.placement !== undefined) {
|
||||
const entry = PLACEMENT_MAP[body.placement];
|
||||
if (!entry) {
|
||||
const err = new Error(`Invalid placement. Must be one of: ${PLACEMENT_KEYS.join(", ")}`);
|
||||
err.status = 400;
|
||||
throw err;
|
||||
}
|
||||
advertisement.placement = body.placement;
|
||||
advertisement.type = entry.format;
|
||||
}
|
||||
|
||||
// status is intentionally NOT settable here — it's derived via deriveStatus()
|
||||
// right before save, based on is_active + start_date/end_date.
|
||||
|
||||
// content_mode is no longer an admin choice (the Image Only / Text with
|
||||
// Image toggle was removed — every ad now carries the same mandatory
|
||||
// badge/headline/description/image/link shape) — like type/format, it's
|
||||
// fixed server-side rather than trusted from the request body. Legacy
|
||||
// "image" mode rows keep that value until next edited.
|
||||
advertisement.content_mode = "content";
|
||||
|
||||
if (body.badge_labels !== undefined) advertisement.badge_labels = normalizeBadgeLabels(body.badge_labels);
|
||||
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.redirect_link !== undefined) advertisement.redirect_link = body.redirect_link || null;
|
||||
if (body.landing_page !== undefined) advertisement.landing_page = body.landing_page || null;
|
||||
|
||||
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 file 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", "xl"].includes(body.size)) {
|
||||
const err = new Error(`Invalid size. Must be one of: sm, md, lg, xl`);
|
||||
err.status = 400;
|
||||
throw err;
|
||||
}
|
||||
advertisement.size = body.size || null;
|
||||
}
|
||||
|
||||
// Every ad now carries the same mandatory shape — badge label(s), headline,
|
||||
// description, image, and a single link — enforced here as defense-in-depth
|
||||
// alongside the frontend's Zod schema. The Admin Add/Edit Advertisement
|
||||
// forms always submit the full shape, so this only ever fires on malformed
|
||||
// requests — it does not retroactively touch existing incomplete rows,
|
||||
// it just blocks saving one until it's brought up to the new shape.
|
||||
if (!advertisement.badge_labels?.length) {
|
||||
const err = new Error("At least one badge label is required.");
|
||||
err.status = 400;
|
||||
throw err;
|
||||
}
|
||||
if (!advertisement.headline?.trim()) {
|
||||
const err = new Error("Headline is required.");
|
||||
err.status = 400;
|
||||
throw err;
|
||||
}
|
||||
if (!advertisement.description?.trim()) {
|
||||
const err = new Error("Description is required.");
|
||||
err.status = 400;
|
||||
throw err;
|
||||
}
|
||||
if (!advertisement.image_asset_id) {
|
||||
const err = new Error("Image is required.");
|
||||
err.status = 400;
|
||||
throw err;
|
||||
}
|
||||
if (!advertisement.redirect_link?.trim()) {
|
||||
const err = new Error("Link is required.");
|
||||
err.status = 400;
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Recompute status now that is_active/start_date/end_date are all up to date
|
||||
advertisement.status = deriveStatus(advertisement);
|
||||
}
|
||||
|
||||
// ─── GET ALL ──────────────────────────────────────────────────────────────────
|
||||
|
||||
// Keeps the stored `status` column in sync with deriveStatus() before the
|
||||
// filtered query runs — status is otherwise only recomputed on individual
|
||||
// row reads, so filtering by status (e.g. "expired") would miss rows whose
|
||||
// start_date/end_date lapsed since they were last saved.
|
||||
async function syncDerivedStatuses() {
|
||||
await sequelize.query(`
|
||||
UPDATE advertisements
|
||||
SET status = CASE
|
||||
WHEN is_active = false THEN 'draft'
|
||||
WHEN end_date IS NOT NULL AND end_date < NOW() THEN 'expired'
|
||||
WHEN start_date IS NOT NULL AND start_date > NOW() THEN 'scheduled'
|
||||
ELSE 'active'
|
||||
END
|
||||
WHERE "deletedAt" IS NULL
|
||||
`);
|
||||
}
|
||||
|
||||
exports.getAdvertisements = async (req, res) => {
|
||||
try {
|
||||
await syncDerivedStatuses();
|
||||
|
||||
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: AD_IMAGE_ATTRIBUTES,
|
||||
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 = await Promise.all(result.data.map(async (row) => {
|
||||
if (row.image) await attachImageStreamToken(row.image, req);
|
||||
return { ...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: AD_IMAGE_ATTRIBUTES, 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.image) await attachImageStreamToken(json.image, req);
|
||||
|
||||
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 { placement, createdBy } = req.body;
|
||||
|
||||
if (!placement) return R.error(res, "placement is required.", 400);
|
||||
const entry = PLACEMENT_MAP[placement];
|
||||
if (!entry) return R.error(res, `Invalid placement. Must be one of: ${PLACEMENT_KEYS.join(", ")}`, 400);
|
||||
if (!createdBy) return R.error(res, "createdBy is required.", 400);
|
||||
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const advertisement = await Advertisement.build({ placement, type: entry.format, createdBy });
|
||||
await applyAdvertisementFields(advertisement, req.body);
|
||||
|
||||
// No manual "order" input in the UI anymore — new ads always append to
|
||||
// the end of their placement's priority list rather than colliding at 0.
|
||||
if (req.body.order === undefined) {
|
||||
advertisement.order = await Advertisement.count({ where: { placement, ...notDeleted }, transaction: t });
|
||||
}
|
||||
|
||||
await advertisement.save({ transaction: t });
|
||||
await t.commit();
|
||||
|
||||
logActivity(req.user?.user_id, 'create_advertisement', { entityType: 'advertisement', entityId: advertisement.advertisement_id, details: { placement: advertisement.placement, 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);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── REORDER ──────────────────────────────────────────────────────────────────
|
||||
// `order` is scoped per placement (mirrors controllers/client/advertisements.controller.js's
|
||||
// getActiveAdvertisement[List], which picks the lowest `order` within a placement to show
|
||||
// first) — Move Up/Down swaps position among siblings sharing the same placement, then
|
||||
// re-sequences the whole group to 0..n-1. Re-sequencing (not just swapping the two `order`
|
||||
// values) is what makes this self-healing against legacy ties, since every ad defaulted to
|
||||
// order: 0 before this feature existed — a plain swap between two tied rows would no-op.
|
||||
exports.reorderAdvertisement = async (req, res) => {
|
||||
try {
|
||||
const { advertisementId } = req.params;
|
||||
const { direction } = req.body;
|
||||
if (!["up", "down"].includes(direction)) return R.error(res, "direction must be 'up' or 'down'.", 400);
|
||||
|
||||
const advertisement = await Advertisement.findOne({ where: { advertisement_id: advertisementId, ...notDeleted } });
|
||||
if (!advertisement) return R.error(res, "Advertisement not found.", 404);
|
||||
|
||||
const group = await Advertisement.findAll({
|
||||
where: { placement: advertisement.placement, ...notDeleted },
|
||||
order: [["order", "ASC"], ["createdAt", "DESC"]],
|
||||
});
|
||||
|
||||
const index = group.findIndex((a) => a.advertisement_id === advertisement.advertisement_id);
|
||||
const swapWith = direction === "up" ? index - 1 : index + 1;
|
||||
if (swapWith < 0 || swapWith >= group.length) {
|
||||
return R.error(res, `This ad is already at the ${direction === "up" ? "top" : "bottom"} of its placement.`, 400);
|
||||
}
|
||||
|
||||
[group[index], group[swapWith]] = [group[swapWith], group[index]];
|
||||
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
await Promise.all(group.map((a, i) => a.update({ order: i }, { transaction: t })));
|
||||
await t.commit();
|
||||
} catch (dbErr) {
|
||||
try { await t.rollback(); } catch { /* connection gone */ }
|
||||
throw dbErr;
|
||||
}
|
||||
|
||||
logActivity(req.user?.user_id, 'reorder_advertisement', {
|
||||
entityType: 'advertisement', entityId: Number(advertisementId),
|
||||
details: { placement: advertisement.placement, direction },
|
||||
});
|
||||
return R.success(res, "Order updated.", {
|
||||
data: { updates: group.map((a) => ({ advertisement_id: a.advertisement_id, order: a.order })) },
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[ADVERTISEMENT][REORDER]", err);
|
||||
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);
|
||||
|
||||
// Freeze the derived status (e.g. "expired") onto the row before it goes
|
||||
// paranoid — the archived list trusts this stored value as-is and never
|
||||
// re-derives it, so "Remove Expired" would otherwise miss ads that had
|
||||
// already lapsed at the moment an admin manually archived them.
|
||||
await advertisement.update({ deletedBy: req.body.deletedBy ?? null, status: deriveStatus(advertisement) });
|
||||
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);
|
||||
|
||||
// Same status-freeze as the single-archive path — resync each row's
|
||||
// status right before it goes paranoid so "Remove Expired" can trust it.
|
||||
await Promise.all(advertisements.map((a) =>
|
||||
a.update({ deletedBy: deletedBy ?? null, status: deriveStatus(a) })
|
||||
));
|
||||
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: "archived",
|
||||
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");
|
||||
|
||||
// ─── PERMANENT DELETE (single) ────────────────────────────────────────────────
|
||||
|
||||
exports.permanentlyDeleteAdvertisement = 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 must be archived before it can be permanently deleted.", 400);
|
||||
|
||||
await advertisement.destroy({ force: true });
|
||||
logActivity(req.user?.user_id, 'permanently_delete_advertisement', { entityType: 'advertisement', entityId: Number(advertisementId) });
|
||||
return R.success(res, "Advertisement permanently deleted.");
|
||||
} catch (err) {
|
||||
console.error("[ADVERTISEMENT][PERMANENT DELETE]", err);
|
||||
return R.error(res, "Could not permanently delete advertisement.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── PERMANENT DELETE (bulk) ───────────────────────────────────────────────────
|
||||
|
||||
exports.permanentlyDeleteAdvertisements = 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 must be archived before they can be permanently deleted.", 400);
|
||||
|
||||
const archivedIds = archived.map((a) => a.advertisement_id);
|
||||
|
||||
await Advertisement.destroy({ where: { advertisement_id: { [Op.in]: archivedIds } }, force: true });
|
||||
|
||||
logActivity(req.user?.user_id, 'bulk_permanently_delete_advertisements', { entityType: 'advertisement', details: { ids: archivedIds, count: archivedIds.length } });
|
||||
return R.success(res, `${archivedIds.length} advertisement(s) permanently deleted.`, {
|
||||
deleted_ids: archivedIds,
|
||||
skipped_ids: ids.filter((id) => !archivedIds.includes(id)),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[ADVERTISEMENT][BULK PERMANENT DELETE]", err);
|
||||
return R.error(res, "Could not permanently delete advertisements.", 500);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,850 @@
|
||||
// controllers/admin/assets.controller.js
|
||||
|
||||
const path = require("path");
|
||||
const fs = require("fs");
|
||||
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 mediaToken = require("../../services/mediaToken.service");
|
||||
const { extractVideoMeta } = require("../../services/ffprobe.service");
|
||||
const ffmpegSvc = require("../../services/ffmpeg.service");
|
||||
const assetTranscode = require("../../services/assetTranscode.service");
|
||||
const R = require('../../utils/response.util');
|
||||
const { paginate } = require("../../utils/paginate.util");
|
||||
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 } = require('sequelize');
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const notDeleted = { deletedAt: null };
|
||||
|
||||
// List queries keep storage_key selected (unlike adminExclude) so
|
||||
// attachStreamTokens can sign a stream token server-side without a second
|
||||
// query — it's deleted from every row before the response is sent.
|
||||
const LIST_QUERY_EXCLUDE = adminExclude.filter((f) => f !== "storage_key");
|
||||
|
||||
// ─── In-memory list cache (no Redis yet) ───────────────────────────────────────
|
||||
// Short TTL just to absorb bursts of identical GET /admin/assets calls — e.g.
|
||||
// AssetPickerSheet being opened/closed repeatedly with the same filters — so
|
||||
// Postgres isn't re-queried on every toggle. Cleared on any mutation below.
|
||||
// Single-process only; fine for one instance, won't stay consistent across
|
||||
// multiple app instances without a shared store like Redis.
|
||||
const LIST_CACHE_TTL_MS = 20_000;
|
||||
const listCache = new Map(); // queryKey -> { result, expiresAt }
|
||||
|
||||
function listCacheKey(req) {
|
||||
return JSON.stringify({
|
||||
page: req.query.page, limit: req.query.limit,
|
||||
filters: req.query.filters, sort: req.query.sort,
|
||||
});
|
||||
}
|
||||
|
||||
function invalidateListCache() { listCache.clear(); }
|
||||
|
||||
function resolveFileType(mimeType = "") {
|
||||
if (mimeType.startsWith("image/")) return "image";
|
||||
if (mimeType.startsWith("video/")) return "video";
|
||||
if (mimeType.startsWith("audio/")) return "audio";
|
||||
if (mimeType.startsWith("application/") || mimeType.startsWith("text/")) return "document";
|
||||
}
|
||||
|
||||
function resolveExtension(originalName = "") {
|
||||
return path.extname(originalName).replace(".", "").toLowerCase() || null;
|
||||
}
|
||||
|
||||
function resolveResolution(width, height) {
|
||||
if (!width || !height) return null;
|
||||
const h = Math.min(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";
|
||||
return `${width}x${height}`;
|
||||
}
|
||||
|
||||
// ─── 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
|
||||
}
|
||||
|
||||
// ─── 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── finalizeReplacementUpload ─────────────────────────────────────────────────
|
||||
//
|
||||
// Used by updateAsset() when replacing an asset's file (or a video's
|
||||
// thumbnail): the browser already PUT the new file straight to storage via a
|
||||
// presigned URL (see presignAssetUpload) — this reads back what actually
|
||||
// landed there (HeadObjectCommand, no download) instead of ever buffering the
|
||||
// file through this backend, the same strategy finalizeAssetFromStorage()
|
||||
// uses for brand-new assets.
|
||||
// Returns { file_url, storage_key, mime_type, extension, checksum, file_type, originalname }
|
||||
//
|
||||
async function finalizeReplacementUpload(storage_key, original_name, mimetype, storageProvider) {
|
||||
const svc = getProvider(storageProvider);
|
||||
if (!svc || !svc.getFileMetadata) {
|
||||
throw Object.assign(new Error("Presigned uploads are only supported for S3 storage."), { status: 400 });
|
||||
}
|
||||
|
||||
const meta = await svc.getFileMetadata(storage_key);
|
||||
const mime_type = meta.mimetype || mimetype || "application/octet-stream";
|
||||
|
||||
return {
|
||||
file_url: await svc.buildPublicUrl(storage_key),
|
||||
storage_key,
|
||||
mime_type,
|
||||
extension: resolveExtension(original_name || storage_key),
|
||||
checksum: meta.checksum,
|
||||
file_type: resolveFileType(mime_type),
|
||||
originalname: original_name || storage_key,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── 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;
|
||||
asset.updatedBy = body.updatedBy ?? null;
|
||||
|
||||
if (file) {
|
||||
if (isThumbnailOnly) {
|
||||
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.storage_key;
|
||||
|
||||
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.resolution = resolveResolution(parsedWidth, parsedHeight);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── deleteOldFile ────────────────────────────────────────────────────────────
|
||||
|
||||
async function deleteOldFile(storageProvider, oldStorageKey, newKey) {
|
||||
if (!oldStorageKey || oldStorageKey === newKey) return;
|
||||
const svc = getProvider(storageProvider);
|
||||
if (!svc) return;
|
||||
try {
|
||||
await svc.deleteFile(oldStorageKey);
|
||||
} catch (err) {
|
||||
console.warn(`[ASSET][CLEANUP] Old file cleanup failed for "${oldStorageKey}":`, err.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;
|
||||
}
|
||||
|
||||
// ─── attachStreamTokens ─────────────────────────────────────────────────────
|
||||
//
|
||||
// Embeds a stream_token (+ presigned thumbnail_url) directly into each S3 row
|
||||
// so pickers/tables reading the list can render thumbnails immediately instead
|
||||
// of firing a second POST /admin/media/tokens round-trip and waiting on it.
|
||||
// storage_key is kept out of the DB attribute exclude list (unlike the rest of
|
||||
// adminExclude) purely so it's available here to sign the token — it's still
|
||||
// stripped from every row before the response goes out.
|
||||
//
|
||||
// Operates on shallow copies: `result.data` is shared with listCache, and
|
||||
// mutating those rows in place would delete storage_key from the cached
|
||||
// objects, breaking token issuance for the next request that hits the cache.
|
||||
//
|
||||
async function attachStreamTokens(rows, req) {
|
||||
const ip = mediaToken.resolveIp(req);
|
||||
const userId = req.user?.user_id;
|
||||
|
||||
return Promise.all(rows.map(async (original) => {
|
||||
const row = { ...original };
|
||||
const eligible = row.storage_provider === "s3" && mediaToken.SUPPORTED_TYPES.includes(row.file_type);
|
||||
|
||||
if (eligible) {
|
||||
const { token, thumbnail_url } = await mediaToken.issueForAsset(row, userId, ip);
|
||||
row.stream_token = token;
|
||||
if (thumbnail_url) row.thumbnail_url = thumbnail_url;
|
||||
}
|
||||
|
||||
delete row.storage_key;
|
||||
return row;
|
||||
}));
|
||||
}
|
||||
|
||||
// ─── GET ALL ──────────────────────────────────────────────────────────────────
|
||||
|
||||
exports.getAssets = async (req, res) => {
|
||||
try {
|
||||
const key = listCacheKey(req);
|
||||
const cached = listCache.get(key);
|
||||
let result;
|
||||
|
||||
if (cached && Date.now() < cached.expiresAt) {
|
||||
result = cached.result;
|
||||
} else {
|
||||
result = await paginate(Asset, req, {
|
||||
excludeAttributes: LIST_QUERY_EXCLUDE,
|
||||
jsonbSchemas,
|
||||
computedAttributes,
|
||||
context: "list",
|
||||
auditOptions: { mdl_Users, parentAlias: 'Asset' },
|
||||
findOptions: { where: { deletedAt: null } },
|
||||
});
|
||||
result.data = result.data.map(redactS3Url);
|
||||
listCache.set(key, { result, expiresAt: Date.now() + LIST_CACHE_TTL_MS });
|
||||
}
|
||||
|
||||
const data = await attachStreamTokens(result.data, req);
|
||||
return R.success(res, "Files retrieved.", { ...result, data });
|
||||
} catch (err) {
|
||||
console.error("[ASSET][GET ALL]", err);
|
||||
return R.error(res, "Could not retrieve files.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── GET ONE ──────────────────────────────────────────────────────────────────
|
||||
|
||||
exports.getAsset = async (req, res) => {
|
||||
try {
|
||||
const { assetId } = req.params;
|
||||
if (!assetId || assetId === "undefined") return R.error(res, "Invalid file ID.", 400);
|
||||
|
||||
const asset = await Asset.findOne({
|
||||
where: { asset_id: assetId, ...notDeleted },
|
||||
// storage_key stays selected here (unlike the list query) so it's
|
||||
// available below to sign a stream token — stripped before the response.
|
||||
attributes: { exclude: ["storage_bucket"] },
|
||||
include: [
|
||||
{ model: mdl_Users, as: "creator", attributes: ["user_id", "email", "personal_info"], foreignKey: "createdBy" },
|
||||
{ model: mdl_Users, as: "updater", attributes: ["user_id", "email", "personal_info"], foreignKey: "updatedBy" },
|
||||
],
|
||||
});
|
||||
|
||||
if (!asset) return R.error(res, "File not found.", 404);
|
||||
|
||||
const json = asset.toJSON();
|
||||
|
||||
// Falls back to email when full_name hasn't been filled in — better than
|
||||
// surfacing the raw numeric user_id in the admin UI.
|
||||
if (json.creator) {
|
||||
json.creator = {
|
||||
user_id: json.creator.user_id,
|
||||
full_name: json.creator.personal_info?.name?.full_name || json.creator.email || null,
|
||||
};
|
||||
}
|
||||
if (json.updater) {
|
||||
json.updater = {
|
||||
user_id: json.updater.user_id,
|
||||
full_name: json.updater.personal_info?.name?.full_name || json.updater.email || null,
|
||||
};
|
||||
}
|
||||
|
||||
if (json.storage_provider === "s3" && mediaToken.SUPPORTED_TYPES.includes(json.file_type)) {
|
||||
const ip = mediaToken.resolveIp(req);
|
||||
const { token, thumbnail_url } = await mediaToken.issueForAsset(json, req.user?.user_id, ip);
|
||||
json.stream_token = token;
|
||||
if (thumbnail_url) json.thumbnail_url = thumbnail_url;
|
||||
}
|
||||
delete json.storage_key;
|
||||
|
||||
redactS3Url(json);
|
||||
return R.success(res, "File retrieved.", { data: json });
|
||||
} catch (err) {
|
||||
console.error("[ASSET][GET ONE]", err);
|
||||
return R.error(res, "Internal server error.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── UPLOAD (shared core) ──────────────────────────────────────────────────────
|
||||
//
|
||||
// ┌─────────────────────────────────────────────────────────────────────────┐
|
||||
// │ PRESIGNED-UPLOAD STRATEGY │
|
||||
// │ │
|
||||
// │ The browser already PUT the file's bytes straight to storage via a │
|
||||
// │ presigned URL (see presignAssetUpload below) — this backend never │
|
||||
// │ buffers or even touches them (this is what removes the old 500MB │
|
||||
// │ multer-memoryStorage RAM ceiling entirely, regardless of file size). │
|
||||
// │ Finalizing an asset from an already-uploaded object is just: │
|
||||
// │ • HeadObjectCommand → real file_size/mime_type/checksum (=ETag) │
|
||||
// │ • ffprobe by URL → video/audio metadata only, no download │
|
||||
// │ • BEGIN → Asset.create() → COMMIT │
|
||||
// │ │
|
||||
// │ On any error: rollbackUploads([{ key, provider }]) deletes the │
|
||||
// │ already-uploaded object(s) — same cleanup as before, just always │
|
||||
// │ covering both file + thumbnail upfront, since both already exist in │
|
||||
// │ storage by the time this runs (the browser uploaded them first). │
|
||||
// └─────────────────────────────────────────────────────────────────────────┘
|
||||
//
|
||||
// Thumbnails are optional for both video and audio — a video/audio asset can
|
||||
// land with thumbnail_url null and pick one up later via the existing
|
||||
// "thumbnail-only" path in updateAsset().
|
||||
//
|
||||
async function finalizeAssetFromStorage({ storage_key, thumbnail_storage_key, original_name, body, user }) {
|
||||
const {
|
||||
display_name,
|
||||
description,
|
||||
is_public = false,
|
||||
storage_provider = "s3",
|
||||
storage_bucket,
|
||||
createdBy,
|
||||
} = body;
|
||||
|
||||
const uploadedFiles = [{ key: storage_key, provider: storage_provider }];
|
||||
if (thumbnail_storage_key) uploadedFiles.push({ key: thumbnail_storage_key, provider: storage_provider });
|
||||
|
||||
try {
|
||||
if (!storage_key) throw Object.assign(new Error("storage_key is required."), { status: 400 });
|
||||
if (!createdBy) throw Object.assign(new Error("createdBy is required."), { status: 400 });
|
||||
|
||||
const svc = getProvider(storage_provider);
|
||||
if (!svc || !svc.getFileMetadata) {
|
||||
throw Object.assign(new Error("Presigned uploads are only supported for S3 storage."), { status: 400 });
|
||||
}
|
||||
|
||||
let meta;
|
||||
try {
|
||||
meta = await svc.getFileMetadata(storage_key);
|
||||
} catch {
|
||||
throw Object.assign(new Error("Uploaded file not found in storage — the upload may have failed or expired."), { status: 400 });
|
||||
}
|
||||
|
||||
// S3's own Content-Type is authoritative when present, but browsers only
|
||||
// send one automatically when the File object's own .type is non-empty —
|
||||
// fall back to whatever the client reported at presign time, and finally
|
||||
// to a generic default, rather than ever letting a NOT NULL column see
|
||||
// null here (mime_type also drives file_type below, so a null here would
|
||||
// misclassify the asset entirely, not just leave a field blank).
|
||||
const mime_type = meta.mimetype || body.mimetype || "application/octet-stream";
|
||||
const file_type = resolveFileType(mime_type);
|
||||
const extension = resolveExtension(original_name || storage_key);
|
||||
const file_url = await svc.buildPublicUrl(storage_key);
|
||||
|
||||
// ── ffprobe (video/audio only) ────────────────────────────────────────────
|
||||
|
||||
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" || file_type === "audio") {
|
||||
const probeUrl = await svc.getSignedDownloadUrl(storage_key);
|
||||
const videoMeta = await extractVideoMeta({ url: probeUrl });
|
||||
width = videoMeta.width;
|
||||
height = videoMeta.height;
|
||||
resolution = videoMeta.resolution;
|
||||
duration = videoMeta.duration;
|
||||
frame_rate = videoMeta.frame_rate;
|
||||
bitrate = videoMeta.bitrate;
|
||||
video_codec = videoMeta.video_codec;
|
||||
audio_codec = videoMeta.audio_codec;
|
||||
|
||||
if (thumbnail_storage_key) {
|
||||
thumbnail_url = await svc.buildPublicUrl(thumbnail_storage_key);
|
||||
} else if (file_type === "video") {
|
||||
// No client-provided thumbnail — grab a frame from the video itself so
|
||||
// the asset doesn't sit with no preview at all in every picker/library
|
||||
// grid. Best-effort: a failure here must not fail the whole upload.
|
||||
let framePath = null;
|
||||
try {
|
||||
framePath = await ffmpegSvc.extractFrameThumbnail(probeUrl, duration);
|
||||
const uploaded = await svc.uploadStream({
|
||||
stream: fs.createReadStream(framePath),
|
||||
originalname: `${(original_name || "thumb").replace(/\.[^.]+$/, "")}.jpg`,
|
||||
mimetype: "image/jpeg",
|
||||
ownerType: "thumbnail", // → thumbnails/ prefix, same as manually-uploaded thumbnails
|
||||
});
|
||||
thumbnail_storage_key = uploaded.uuid;
|
||||
thumbnail_url = uploaded.url;
|
||||
uploadedFiles.push({ key: thumbnail_storage_key, provider: storage_provider }); // rollback cleanup on later failure
|
||||
} catch (err) {
|
||||
console.warn(`[ASSET][THUMBNAIL] Auto-generate failed for "${storage_key}":`, err.message);
|
||||
// leave thumbnail_url null — same fallback as before, admin can add one manually later
|
||||
} finally {
|
||||
if (framePath) fs.promises.unlink(framePath).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
} else {
|
||||
const parsedWidth = body.width ? parseInt(body.width) : null;
|
||||
const parsedHeight = body.height ? parseInt(body.height) : null;
|
||||
width = parsedWidth;
|
||||
height = parsedHeight;
|
||||
resolution = resolveResolution(parsedWidth, parsedHeight);
|
||||
}
|
||||
|
||||
// ── DB insert ──────────────────────────────────────────────────────────────
|
||||
|
||||
// .mov/.mkv videos load slowly in-browser (moov/Cues index at the end of
|
||||
// the file) — flag them for the background remux job (see
|
||||
// assetTranscode.service.js) fired below, right after commit.
|
||||
const needsTranscode = storage_provider === "s3" && file_type === "video" && ffmpegSvc.needsRemux(extension);
|
||||
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const asset = await Asset.create({
|
||||
original_name: original_name || storage_key,
|
||||
display_name: display_name || original_name || storage_key,
|
||||
file_url,
|
||||
file_size: meta.size,
|
||||
mime_type,
|
||||
extension,
|
||||
checksum: meta.checksum,
|
||||
file_type,
|
||||
width,
|
||||
height,
|
||||
resolution,
|
||||
duration,
|
||||
frame_rate,
|
||||
bitrate,
|
||||
video_codec,
|
||||
audio_codec,
|
||||
thumbnail_url,
|
||||
thumbnail_storage_key,
|
||||
description,
|
||||
storage_provider,
|
||||
storage_bucket: storage_bucket || (storage_provider === "s3" ? process.env.S3_BUCKET : null) || null,
|
||||
storage_key,
|
||||
is_public,
|
||||
createdBy,
|
||||
transcode_status: needsTranscode ? "pending" : "none",
|
||||
}, { transaction: t });
|
||||
|
||||
await t.commit();
|
||||
logActivity(user?.user_id, 'upload_asset', { entityType: 'asset', entityId: asset.asset_id, details: { display_name: asset.display_name, file_type: asset.file_type } });
|
||||
|
||||
if (needsTranscode) {
|
||||
assetTranscode.transcodeAsset(asset).catch((err) => {
|
||||
console.error("[ASSET][TRANSCODE] Background remux failed to start:", err.message);
|
||||
});
|
||||
}
|
||||
|
||||
return asset;
|
||||
|
||||
} catch (dbErr) {
|
||||
try { await t.rollback(); } catch { /* connection gone */ }
|
||||
await rollbackUploads(uploadedFiles);
|
||||
throw dbErr;
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
await rollbackUploads(uploadedFiles);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── PRESIGN UPLOAD ─────────────────────────────────────────────────────────
|
||||
//
|
||||
// Mints a short-lived presigned PUT URL so the browser can upload the file's
|
||||
// bytes directly to storage — this backend never buffers them. Called once
|
||||
// for the main file, and once more for a thumbnail if the admin picked one
|
||||
// (see s3.service.js#presignUpload for the key-naming convention).
|
||||
//
|
||||
exports.presignAssetUpload = async (req, res) => {
|
||||
try {
|
||||
const { filename, mimetype, file_type, size = 0, storage_provider = "s3" } = req.body;
|
||||
if (!filename) return R.error(res, "filename is required.", 400);
|
||||
|
||||
const svc = getProvider(storage_provider);
|
||||
if (!svc || !svc.presignUpload) {
|
||||
return R.error(res, "Presigned uploads are only supported for S3 storage.", 400);
|
||||
}
|
||||
|
||||
const ownerType = file_type || resolveFileType(mimetype || "") || "document";
|
||||
// Result is either { key, uploadUrl } or, above the single-PUT ceiling,
|
||||
// { key, multipart: true, uploadId, partSize, parts } — see
|
||||
// s3.service.js#presignUpload. The client branches on `multipart`.
|
||||
const presigned = await svc.presignUpload(filename, ownerType, Number(size) || 0);
|
||||
return R.success(res, "Presigned URL generated.", presigned);
|
||||
|
||||
} catch (err) {
|
||||
console.error("[ASSET][PRESIGN]", err);
|
||||
return R.error(res, "Could not generate upload URL.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── COMPLETE / ABORT MULTIPART ─────────────────────────────────────────────
|
||||
//
|
||||
// Only used above presignAssetUpload's single-PUT ceiling (see
|
||||
// s3.service.js's MULTIPART_THRESHOLD) — the browser PUTs every part
|
||||
// directly, then calls complete-multipart with the ETags each part's PUT
|
||||
// response returned. abort-multipart is the failure-path cleanup (a part
|
||||
// exhausted its retries, or the admin cancelled) so an abandoned multipart
|
||||
// upload doesn't linger as orphaned storage forever.
|
||||
//
|
||||
exports.completeMultipartAssetUpload = async (req, res) => {
|
||||
try {
|
||||
const { storage_key, uploadId, parts, storage_provider = "s3" } = req.body;
|
||||
if (!storage_key || !uploadId || !Array.isArray(parts) || !parts.length) {
|
||||
return R.error(res, "storage_key, uploadId, and parts are required.", 400);
|
||||
}
|
||||
|
||||
const svc = getProvider(storage_provider);
|
||||
if (!svc || !svc.completeMultipartUpload) {
|
||||
return R.error(res, "Multipart uploads are only supported for S3 storage.", 400);
|
||||
}
|
||||
|
||||
await svc.completeMultipartUpload(storage_key, uploadId, parts);
|
||||
return R.success(res, "Multipart upload completed.", {});
|
||||
|
||||
} catch (err) {
|
||||
console.error("[ASSET][COMPLETE MULTIPART]", err);
|
||||
return R.error(res, "Could not complete multipart upload.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.abortMultipartAssetUpload = async (req, res) => {
|
||||
try {
|
||||
const { storage_key, uploadId, storage_provider = "s3" } = req.body;
|
||||
if (!storage_key || !uploadId) return R.error(res, "storage_key and uploadId are required.", 400);
|
||||
|
||||
const svc = getProvider(storage_provider);
|
||||
if (svc?.abortMultipartUpload) {
|
||||
try {
|
||||
await svc.abortMultipartUpload(storage_key, uploadId);
|
||||
} catch (err) {
|
||||
// Best-effort, same tolerance as rollbackUploads() — an already-gone
|
||||
// or already-completed upload isn't worth failing the request over.
|
||||
console.error(`[ASSET][ABORT MULTIPART] Failed to abort "${storage_key}":`, err.message);
|
||||
}
|
||||
}
|
||||
return R.success(res, "Multipart upload aborted.", {});
|
||||
|
||||
} catch (err) {
|
||||
console.error("[ASSET][ABORT MULTIPART]", err);
|
||||
return R.error(res, "Could not abort multipart upload.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── UPLOAD (finalize) ──────────────────────────────────────────────────────
|
||||
//
|
||||
// Called once the browser's direct-to-storage PUT(s) have completed. Body is
|
||||
// plain JSON — no file bytes here, just the storage key(s) presignAssetUpload
|
||||
// handed back plus asset metadata. Also the endpoint the bulk queue
|
||||
// (UploadQueueContext.jsx) calls once per file, reusing this single-asset
|
||||
// path instead of a separate batch endpoint.
|
||||
//
|
||||
exports.uploadAsset = async (req, res) => {
|
||||
try {
|
||||
const { storage_key, thumbnail_storage_key, original_name } = req.body;
|
||||
const asset = await finalizeAssetFromStorage({ storage_key, thumbnail_storage_key, original_name, body: req.body, user: req.user });
|
||||
invalidateListCache();
|
||||
return R.success(res, "File uploaded.", { data: asset }, 201);
|
||||
|
||||
} 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);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── 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 file ID.", 400);
|
||||
|
||||
const asset = await Asset.findOne({ where: { asset_id: assetId, ...notDeleted } });
|
||||
if (!asset) return R.error(res, "File not found.", 404);
|
||||
|
||||
// Browser already PUT the replacement file straight to storage via a
|
||||
// presigned URL (see presignAssetUpload) — this is plain JSON, no
|
||||
// multer/file buffer involved, same pattern as POST /admin/assets.
|
||||
const { storage_key, original_name, mimetype } = req.body;
|
||||
const isVideo = asset.file_type === "video";
|
||||
const isDocument = asset.file_type === "document";
|
||||
|
||||
if (isDocument && storage_key) return R.error(res, "Document files cannot be replaced.", 400);
|
||||
if (isVideo && storage_key && !req.body.is_thumbnail) return R.error(res, "Video files cannot be replaced. Upload a new file instead.", 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 (storage_key && usesProvider) {
|
||||
uploaded = await finalizeReplacementUpload(storage_key, original_name, mimetype, storageProvider);
|
||||
newUpload = { key: uploaded.storage_key, provider: storageProvider };
|
||||
}
|
||||
|
||||
// ── Phase 2: DB update ────────────────────────────────────────────────────
|
||||
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
await applyAssetUpdate(asset, uploaded, 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);
|
||||
|
||||
invalidateListCache();
|
||||
logActivity(req.user?.user_id, 'update_asset', { entityType: 'asset', entityId: Number(assetId) });
|
||||
return R.success(res, "File 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);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── ARCHIVE (single) ─────────────────────────────────────────────────────────
|
||||
|
||||
exports.archiveAsset = async (req, res) => {
|
||||
try {
|
||||
const { assetId } = req.params;
|
||||
if (!assetId || assetId === "undefined") return R.error(res, "Invalid file ID.", 400);
|
||||
|
||||
const asset = await Asset.findOne({ where: { asset_id: assetId, ...notDeleted } });
|
||||
if (!asset) return R.error(res, "File not found.", 404);
|
||||
|
||||
await asset.update({ deletedBy: req.body.deletedBy ?? null });
|
||||
await asset.destroy();
|
||||
invalidateListCache();
|
||||
logActivity(req.user?.user_id, 'archive_asset', { entityType: 'asset', entityId: Number(assetId) });
|
||||
return R.success(res, "File archived.");
|
||||
} catch (err) {
|
||||
console.error("[ASSET][ARCHIVE]", err);
|
||||
return R.error(res, "Internal server error.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── ARCHIVE (bulk) ───────────────────────────────────────────────────────────
|
||||
|
||||
exports.archiveAssets = 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 assets = await Asset.findAll({ where: { asset_id: { [Op.in]: ids }, ...notDeleted } });
|
||||
if (!assets.length) return R.error(res, "No files found.", 404);
|
||||
|
||||
const activeIds = assets.map((a) => a.asset_id);
|
||||
|
||||
await Asset.update({ deletedBy: deletedBy ?? null }, { where: { asset_id: { [Op.in]: activeIds } } });
|
||||
await Asset.destroy({ where: { asset_id: { [Op.in]: activeIds } } });
|
||||
|
||||
invalidateListCache();
|
||||
logActivity(req.user?.user_id, 'bulk_archive_assets', { entityType: 'asset', details: { ids: activeIds, count: activeIds.length } });
|
||||
return R.success(res, `${activeIds.length} file(s) archived.`, {
|
||||
archived_ids: activeIds,
|
||||
skipped_ids: ids.filter((id) => !activeIds.includes(id)),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[ASSET][BULK ARCHIVE]", err);
|
||||
return R.error(res, "Internal server error.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── RESTORE (single) ─────────────────────────────────────────────────────────
|
||||
|
||||
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, "File not found.", 404);
|
||||
if (!asset.deletedAt) return R.error(res, "File is not archived.", 400);
|
||||
|
||||
await asset.restore();
|
||||
await asset.update({ deletedBy: null });
|
||||
invalidateListCache();
|
||||
logActivity(req.user?.user_id, 'restore_asset', { entityType: 'asset', entityId: Number(assetId) });
|
||||
return R.success(res, "File restored.", { data: asset });
|
||||
} catch (err) {
|
||||
console.error("[ASSET][RESTORE]", err);
|
||||
return R.error(res, "Internal server error.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── RESTORE (bulk) ───────────────────────────────────────────────────────────
|
||||
|
||||
exports.restoreAssets = 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 assets = await Asset.findAll({ where: { asset_id: { [Op.in]: ids } }, paranoid: false });
|
||||
if (!assets.length) return R.error(res, "No files found.", 404);
|
||||
|
||||
const archivedAssets = assets.filter((a) => a.deletedAt);
|
||||
if (!archivedAssets.length) return R.error(res, "All selected files are already active.", 400);
|
||||
|
||||
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 });
|
||||
|
||||
invalidateListCache();
|
||||
logActivity(req.user?.user_id, 'bulk_restore_assets', { entityType: 'asset', details: { ids: archivedIds, count: archivedIds.length } });
|
||||
return R.success(res, `${archivedIds.length} file(s) restored.`, {
|
||||
restored_ids: archivedIds,
|
||||
skipped_ids: ids.filter((id) => !archivedIds.includes(id)),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[ASSET][BULK RESTORE]", err);
|
||||
return R.error(res, "Internal server error.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── PERMANENT DELETE (single) ─────────────────────────────────────────────────
|
||||
|
||||
exports.permanentlyDeleteAsset = 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, "File not found.", 404);
|
||||
if (!asset.deletedAt) return R.error(res, "File must be archived before it can be permanently deleted.", 400);
|
||||
|
||||
const { storage_provider, storage_key, thumbnail_storage_key } = asset;
|
||||
|
||||
await asset.destroy({ force: true });
|
||||
|
||||
const svc = getProvider(storage_provider);
|
||||
if (svc) {
|
||||
if (storage_key) {
|
||||
try { await svc.deleteFile(storage_key); }
|
||||
catch (err) { console.warn(`[ASSET][PERMANENT DELETE] File cleanup failed for "${storage_key}":`, err.message); }
|
||||
}
|
||||
if (thumbnail_storage_key) {
|
||||
try { await svc.deleteFile(thumbnail_storage_key); }
|
||||
catch (err) { console.warn(`[ASSET][PERMANENT DELETE] Thumbnail cleanup failed for "${thumbnail_storage_key}":`, err.message); }
|
||||
}
|
||||
}
|
||||
|
||||
invalidateListCache();
|
||||
logActivity(req.user?.user_id, 'permanently_delete_asset', { entityType: 'asset', entityId: Number(assetId) });
|
||||
return R.success(res, "File permanently deleted.");
|
||||
} catch (err) {
|
||||
console.error("[ASSET][PERMANENT DELETE]", err);
|
||||
return R.error(res, "Internal server error.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── PERMANENT DELETE (bulk) ───────────────────────────────────────────────────
|
||||
|
||||
exports.permanentlyDeleteAssets = 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 assets = await Asset.findAll({ where: { asset_id: { [Op.in]: ids } }, paranoid: false });
|
||||
if (!assets.length) return R.error(res, "No files found.", 404);
|
||||
|
||||
const archivedAssets = assets.filter((a) => a.deletedAt);
|
||||
if (!archivedAssets.length) return R.error(res, "All selected files must be archived before they can be permanently deleted.", 400);
|
||||
|
||||
const archivedIds = archivedAssets.map((a) => a.asset_id);
|
||||
|
||||
await Asset.destroy({ where: { asset_id: { [Op.in]: archivedIds } }, force: true });
|
||||
|
||||
for (const asset of archivedAssets) {
|
||||
const svc = getProvider(asset.storage_provider);
|
||||
if (!svc) continue;
|
||||
if (asset.storage_key) {
|
||||
try { await svc.deleteFile(asset.storage_key); }
|
||||
catch (err) { console.warn(`[ASSET][PERMANENT DELETE] File cleanup failed for "${asset.storage_key}":`, err.message); }
|
||||
}
|
||||
if (asset.thumbnail_storage_key) {
|
||||
try { await svc.deleteFile(asset.thumbnail_storage_key); }
|
||||
catch (err) { console.warn(`[ASSET][PERMANENT DELETE] Thumbnail cleanup failed for "${asset.thumbnail_storage_key}":`, err.message); }
|
||||
}
|
||||
}
|
||||
|
||||
invalidateListCache();
|
||||
logActivity(req.user?.user_id, 'bulk_permanently_delete_assets', { entityType: 'asset', details: { ids: archivedIds, count: archivedIds.length } });
|
||||
return R.success(res, `${archivedIds.length} file(s) permanently deleted.`, {
|
||||
deleted_ids: archivedIds,
|
||||
skipped_ids: ids.filter((id) => !archivedIds.includes(id)),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[ASSET][BULK PERMANENT DELETE]", err);
|
||||
return R.error(res, "Internal server error.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── ARCHIVED LIST ────────────────────────────────────────────────────────────
|
||||
|
||||
exports.getArchivedAssets = async (req, res) => {
|
||||
try {
|
||||
const result = await paginate(Asset, req, {
|
||||
excludeAttributes: adminExclude,
|
||||
jsonbSchemas,
|
||||
computedAttributes,
|
||||
context: "list",
|
||||
auditOptions: { mdl_Users, parentAlias: 'Asset' },
|
||||
findOptions: { paranoid: false, where: { deletedAt: { [Op.ne]: null } } },
|
||||
});
|
||||
result.data = result.data.map(redactS3Url);
|
||||
return R.success(res, "Archived files retrieved.", result);
|
||||
} catch (err) {
|
||||
console.error("[ASSET][GET ARCHIVED]", err);
|
||||
return R.error(res, "Could not retrieve archived files.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.getAssetFieldValues = getFieldValues(Asset, "ASSET");
|
||||
@@ -0,0 +1,219 @@
|
||||
'use strict';
|
||||
const { Op } = require('sequelize');
|
||||
const mdl_Category = require('../../models/courses/categories.mdl');
|
||||
const mdl_Users = require('../../models/users/users.mdl');
|
||||
const { CourseProductCategory } = require('../../models/courses/courses.mdl');
|
||||
const R = require('../../utils/response.util');
|
||||
const logActivity = require('../../utils/logActivity.util');
|
||||
const { paginate } = require('../../utils/paginate.util');
|
||||
const {
|
||||
excludeAttributes: categoriesExclude,
|
||||
jsonbSchemas: categoriesSchemas,
|
||||
} = require('../../models/courses/categories.attributes');
|
||||
|
||||
const slugify = (str) =>
|
||||
str.toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
|
||||
|
||||
exports.getCategories = async (req, res) => {
|
||||
try {
|
||||
const archived = req.query.archived === 'true';
|
||||
const result = await paginate(mdl_Category, req, {
|
||||
excludeAttributes: categoriesExclude,
|
||||
jsonbSchemas: categoriesSchemas,
|
||||
context: archived ? 'archived' : 'list',
|
||||
auditOptions: { mdl_Users, parentAlias: 'Category' },
|
||||
findOptions: archived
|
||||
? { paranoid: false, where: { deletedAt: { [Op.ne]: null } } }
|
||||
: {},
|
||||
});
|
||||
return R.success(res, 'Categories retrieved.', result);
|
||||
} 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,
|
||||
createdBy: req.body.createdBy ?? req.user?.user_id ?? null,
|
||||
});
|
||||
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,
|
||||
updatedBy: req.body.updatedBy ?? req.user?.user_id ?? null,
|
||||
});
|
||||
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.update({ deletedBy: req.user?.user_id ?? null, is_active: false });
|
||||
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);
|
||||
if (!row.deletedAt) return R.error(res, 'Category is not archived.', 400);
|
||||
await row.restore();
|
||||
await row.update({ deletedBy: null, is_active: true });
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
exports.bulkArchiveCategories = async (req, res) => {
|
||||
try {
|
||||
const { ids } = req.body;
|
||||
if (!Array.isArray(ids) || !ids.length) return R.error(res, 'No category IDs provided.', 400);
|
||||
|
||||
const rows = await mdl_Category.findAll({ where: { id: ids } });
|
||||
if (!rows.length) return R.error(res, 'No categories found.', 404);
|
||||
|
||||
const activeIds = rows.map((r) => r.id);
|
||||
await Promise.all(rows.map((r) => r.update({ deletedBy: req.user?.user_id ?? null, is_active: false })));
|
||||
await mdl_Category.destroy({ where: { id: activeIds } });
|
||||
|
||||
logActivity(req.user?.user_id, 'bulk_archive_categories', { entityType: 'category', details: { ids: activeIds, count: activeIds.length } });
|
||||
return R.success(res, `${activeIds.length} categor${activeIds.length !== 1 ? 'ies' : 'y'} archived.`, {
|
||||
archived_ids: activeIds,
|
||||
skipped_ids: ids.filter((id) => !activeIds.includes(id)),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][CATEGORIES][BULK ARCHIVE]', err);
|
||||
return R.error(res, 'Could not archive categories.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.getCategoryPermanentDeleteImpact = 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);
|
||||
const course_count = await CourseProductCategory.count({ where: { category_id: req.params.id } });
|
||||
return R.success(res, 'Category permanent-delete impact retrieved.', { course_count });
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][CATEGORIES][PERMANENT DELETE IMPACT]', err);
|
||||
return R.error(res, 'Could not retrieve category permanent-delete impact.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.permanentlyDeleteCategory = 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);
|
||||
if (!row.deletedAt) return R.error(res, 'Category must be archived before it can be permanently deleted.', 400);
|
||||
// course_product_categories.category_id has no DB-level cascade (only course_id does) —
|
||||
// clean up the junction rows explicitly or they'd be left orphaned.
|
||||
await CourseProductCategory.destroy({ where: { category_id: row.id } });
|
||||
await row.destroy({ force: true });
|
||||
logActivity(req.user?.user_id, 'permanently_delete_category', { entityType: 'category', entityId: row.id, details: { name: row.name } });
|
||||
return R.success(res, 'Category permanently deleted.');
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][CATEGORIES][PERMANENT DELETE]', err);
|
||||
return R.error(res, 'Could not permanently delete category.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.bulkRestoreCategories = async (req, res) => {
|
||||
try {
|
||||
const { ids } = req.body;
|
||||
if (!Array.isArray(ids) || !ids.length) return R.error(res, 'No category IDs provided.', 400);
|
||||
|
||||
const rows = await mdl_Category.findAll({ where: { id: ids }, paranoid: false });
|
||||
if (!rows.length) return R.error(res, 'No categories found.', 404);
|
||||
|
||||
const archivedRows = rows.filter((r) => r.deletedAt);
|
||||
if (!archivedRows.length) return R.error(res, 'All selected categories are already active.', 400);
|
||||
|
||||
const archivedIds = archivedRows.map((r) => r.id);
|
||||
await mdl_Category.restore({ where: { id: archivedIds } });
|
||||
await mdl_Category.update({ deletedBy: null, is_active: true }, { where: { id: archivedIds }, paranoid: false });
|
||||
|
||||
logActivity(req.user?.user_id, 'bulk_restore_categories', { entityType: 'category', details: { ids: archivedIds, count: archivedIds.length } });
|
||||
return R.success(res, `${archivedIds.length} categor${archivedIds.length !== 1 ? 'ies' : 'y'} restored.`, {
|
||||
restored_ids: archivedIds,
|
||||
skipped_ids: ids.filter((id) => !archivedIds.includes(id)),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][CATEGORIES][BULK RESTORE]', err);
|
||||
return R.error(res, 'Could not restore categories.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.bulkPermanentlyDeleteCategories = async (req, res) => {
|
||||
try {
|
||||
const { ids } = req.body;
|
||||
if (!Array.isArray(ids) || !ids.length) return R.error(res, 'No category IDs provided.', 400);
|
||||
|
||||
const rows = await mdl_Category.findAll({ where: { id: ids }, paranoid: false });
|
||||
if (!rows.length) return R.error(res, 'No categories found.', 404);
|
||||
|
||||
const archivedRows = rows.filter((r) => r.deletedAt);
|
||||
if (!archivedRows.length) return R.error(res, 'All selected categories must be archived before they can be permanently deleted.', 400);
|
||||
|
||||
const archivedIds = archivedRows.map((r) => r.id);
|
||||
await CourseProductCategory.destroy({ where: { category_id: archivedIds } });
|
||||
await mdl_Category.destroy({ where: { id: archivedIds }, force: true });
|
||||
|
||||
logActivity(req.user?.user_id, 'bulk_permanently_delete_categories', { entityType: 'category', details: { ids: archivedIds, count: archivedIds.length } });
|
||||
return R.success(res, `${archivedIds.length} categor${archivedIds.length !== 1 ? 'ies' : 'y'} permanently deleted.`, {
|
||||
deleted_ids: archivedIds,
|
||||
skipped_ids: ids.filter((id) => !archivedIds.includes(id)),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][CATEGORIES][BULK PERMANENT DELETE]', err);
|
||||
return R.error(res, 'Could not permanently delete categories.', 500);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,161 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name: completion_requirements.controller.js (admin)
|
||||
* Type of Program: Controller
|
||||
* Description: Admin CRUD for CompletionRequirement rows — what counts as "complete" for a
|
||||
* given course/unit/lesson. One shared implementation per entity_type, mounted at
|
||||
* nested routes (routes/admin/courses.routes.js) AND standalone-library routes
|
||||
* (routes/admin/units.routes.js, routes/admin/lessons.routes.js), matching the
|
||||
* existing dual-mount convention already used for quiz routes.
|
||||
*
|
||||
* getXRequirements — GET, list configured rows for one entity.
|
||||
* syncXRequirements — PUT, soft-delete-and-recreate the full set for one entity (same pattern
|
||||
* as controllers/admin/task.controller.js#updateTask, proactively applying
|
||||
* its documented fix: strip requirement_id/timestamps before bulkCreate so
|
||||
* the fresh rows never collide with the just-soft-deleted PKs). Validates
|
||||
* each row's `type` against the registry's validEntityTypes for this
|
||||
* entity_type, and rejects duplicate types on one entity — validation the
|
||||
* Task requirements system doesn't have.
|
||||
*
|
||||
* When a pass_quiz requirement is added/removed on a unit/course, the corresponding
|
||||
* UnitQuiz.is_required / CourseAssessment.is_required is flipped in lockstep, so the
|
||||
* existing sequential quiz-lock (which reads is_required) and the new completion
|
||||
* requirement never disagree with each other.
|
||||
*
|
||||
* Author: Kenneth Obsequio (@lash0000)
|
||||
* Date Created: Jul. 14, 2026
|
||||
***********************************************************************************************************************************************************************/
|
||||
'use strict';
|
||||
|
||||
const R = require('../../utils/response.util');
|
||||
const sequelize = require('../../config/db.config');
|
||||
const CompletionRequirement = require('../../models/courses/completion_requirement.mdl');
|
||||
const { Course, Unit, Lesson, UnitQuiz, CourseAssessment } = require('../../models/courses/courses.associations');
|
||||
const { VALID_ENTITY_TYPES } = require('../../utils/courses/completion_requirements.registry');
|
||||
|
||||
const notDeleted = { deletedAt: null };
|
||||
|
||||
const ENTITY_MODELS = {
|
||||
course: { model: Course, pk: 'course_id', label: 'Course' },
|
||||
unit: { model: Unit, pk: 'unit_id', label: 'Unit' },
|
||||
lesson: { model: Lesson, pk: 'lesson_id', label: 'Lesson' },
|
||||
};
|
||||
|
||||
async function fetchRequirements(entityType, entityId, t) {
|
||||
return CompletionRequirement.findAll({
|
||||
where: { entity_type: entityType, entity_id: entityId },
|
||||
order: [['order', 'ASC']],
|
||||
transaction: t,
|
||||
});
|
||||
}
|
||||
|
||||
// ─── GET ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function getRequirements(entityType, entityId, req, res) {
|
||||
try {
|
||||
const { model, pk, label } = ENTITY_MODELS[entityType];
|
||||
const entity = await model.findOne({ where: { [pk]: entityId, ...notDeleted } });
|
||||
if (!entity) return R.error(res, `${label} not found.`, 404);
|
||||
|
||||
const rows = await fetchRequirements(entityType, entityId);
|
||||
return R.success(res, 'Completion requirements retrieved.', rows);
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][COMPLETION REQUIREMENTS][GET]', err);
|
||||
return R.error(res, 'Could not retrieve completion requirements.', 500);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── PUT (soft-delete-and-recreate) ──────────────────────────────────────────
|
||||
|
||||
async function syncRequirements(entityType, entityId, req, res) {
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const { model, pk, label } = ENTITY_MODELS[entityType];
|
||||
const entity = await model.findOne({ where: { [pk]: entityId, ...notDeleted }, transaction: t });
|
||||
if (!entity) {
|
||||
await t.rollback();
|
||||
return R.error(res, `${label} not found.`, 404);
|
||||
}
|
||||
|
||||
const requirements = Array.isArray(req.body.requirements) ? req.body.requirements : [];
|
||||
|
||||
// Server-side validation the Task requirements system doesn't have: type must be a
|
||||
// known type, valid for this entity_type, and configured at most once per entity.
|
||||
const seenTypes = new Set();
|
||||
for (const r of requirements) {
|
||||
const allowedEntityTypes = VALID_ENTITY_TYPES[r.type];
|
||||
if (!allowedEntityTypes) {
|
||||
await t.rollback();
|
||||
return R.error(res, `Unknown requirement type "${r.type}".`, 400);
|
||||
}
|
||||
if (!allowedEntityTypes.includes(entityType)) {
|
||||
await t.rollback();
|
||||
return R.error(res, `"${r.type}" cannot be configured on a ${entityType}.`, 400);
|
||||
}
|
||||
if (seenTypes.has(r.type)) {
|
||||
await t.rollback();
|
||||
return R.error(res, `Duplicate "${r.type}" requirement — only one per entity is allowed.`, 400);
|
||||
}
|
||||
seenTypes.add(r.type);
|
||||
}
|
||||
|
||||
// Soft-delete existing rows, then bulkCreate the replacement set. Requirement_id and
|
||||
// timestamps are stripped from each incoming row (server-owned) so the fresh insert
|
||||
// never collides with the just-soft-deleted row still occupying that requirement_id PK —
|
||||
// see controllers/admin/task.controller.js#updateTask for the bug this proactively avoids.
|
||||
await CompletionRequirement.destroy({
|
||||
where: { entity_type: entityType, entity_id: entityId },
|
||||
force: false,
|
||||
transaction: t,
|
||||
});
|
||||
|
||||
if (requirements.length) {
|
||||
const rows = requirements.map((r, i) => {
|
||||
const { requirement_id, createdAt, updatedAt, deletedAt, entity_type, entity_id, ...rest } = r;
|
||||
return {
|
||||
...rest,
|
||||
entity_type: entityType,
|
||||
entity_id: entityId,
|
||||
order: rest.order ?? i,
|
||||
min_percent: rest.type === 'watch_percent'
|
||||
? Math.min(100, Math.max(1, Math.round(Number(rest.min_percent)) || 100))
|
||||
: null,
|
||||
button_label: rest.type === 'manual_complete' ? (rest.button_label || null) : null,
|
||||
is_required: rest.is_required ?? true,
|
||||
createdBy: req.user.user_id,
|
||||
updatedBy: req.user.user_id,
|
||||
};
|
||||
});
|
||||
await CompletionRequirement.bulkCreate(rows, { transaction: t });
|
||||
}
|
||||
|
||||
// Reconcile the pass_quiz requirement with the pre-existing UnitQuiz/CourseAssessment
|
||||
// is_required flag, so ModifyQuiz.jsx's "required to proceed" toggle and this new
|
||||
// completion-requirements editor never disagree about the same underlying boolean.
|
||||
const hasPassQuiz = requirements.some((r) => r.type === 'pass_quiz');
|
||||
if (entityType === 'unit') {
|
||||
await UnitQuiz.update({ is_required: hasPassQuiz }, { where: { unit_id: entityId }, transaction: t });
|
||||
} else if (entityType === 'course') {
|
||||
await CourseAssessment.update({ is_required: hasPassQuiz }, { where: { course_id: entityId }, transaction: t });
|
||||
}
|
||||
|
||||
await t.commit();
|
||||
|
||||
const rows = await fetchRequirements(entityType, entityId);
|
||||
return R.success(res, 'Completion requirements updated.', rows);
|
||||
} catch (err) {
|
||||
await t.rollback();
|
||||
console.error('[ADMIN][COMPLETION REQUIREMENTS][SYNC]', err);
|
||||
return R.error(res, 'Could not update completion requirements.', 500);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Route-bound exports ──────────────────────────────────────────────────────
|
||||
|
||||
exports.getCourseRequirements = (req, res) => getRequirements('course', req.params.courseId, req, res);
|
||||
exports.syncCourseRequirements = (req, res) => syncRequirements('course', req.params.courseId, req, res);
|
||||
|
||||
exports.getUnitRequirements = (req, res) => getRequirements('unit', req.params.unitId, req, res);
|
||||
exports.syncUnitRequirements = (req, res) => syncRequirements('unit', req.params.unitId, req, res);
|
||||
|
||||
exports.getLessonRequirements = (req, res) => getRequirements('lesson', req.params.lessonId, req, res);
|
||||
exports.syncLessonRequirements = (req, res) => syncRequirements('lesson', req.params.lessonId, req, res);
|
||||
@@ -0,0 +1,240 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* 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, CourseUnit,
|
||||
UnitQuiz, CourseAssessment, QuizAttempt,
|
||||
} = require('../../models/courses/courses.associations');
|
||||
const mdl_Users = require('../../models/users/users.mdl');
|
||||
const { countCourseLessons, getCourseUnitIds, flattenUnits } = require('../../utils/courses/hierarchy.util');
|
||||
const { resolveAvatarUrl } = require('../../utils/resolveAvatar.util');
|
||||
|
||||
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 via junctions)
|
||||
const [unitIds, lessons_total] = await Promise.all([
|
||||
getCourseUnitIds(courseId),
|
||||
countCourseLessons(courseId),
|
||||
]);
|
||||
|
||||
const units_total = unitIds.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]));
|
||||
|
||||
// Quiz/assessment gating — mirrors controllers/client/course_reading_progress
|
||||
// .controller.js#getMyInProgressCourses: a unit quiz or course assessment that
|
||||
// exists but hasn't been passed yet is why a user with 1/1 lessons read can still
|
||||
// be 'in_progress'. Batched once per course across all users in this list.
|
||||
const unitQuizzes = unitIds.length
|
||||
? await UnitQuiz.findAll({ where: { unit_id: unitIds, ...notDeleted }, attributes: ['quiz_id'] })
|
||||
: [];
|
||||
const quizIds = unitQuizzes.map((q) => q.quiz_id);
|
||||
const assessment = await CourseAssessment.findOne({
|
||||
where: { course_id: courseId },
|
||||
attributes: ['assessment_id'],
|
||||
});
|
||||
|
||||
const passedQuizIdsByUser = new Map();
|
||||
const passedAssessmentUserIds = new Set();
|
||||
if (quizIds.length || assessment) {
|
||||
const passedAttempts = await QuizAttempt.findAll({
|
||||
where: {
|
||||
user_id: userIds,
|
||||
passed: true,
|
||||
[Op.or]: [
|
||||
...(quizIds.length ? [{ quiz_id: quizIds }] : []),
|
||||
...(assessment ? [{ assessment_id: assessment.assessment_id }] : []),
|
||||
],
|
||||
},
|
||||
attributes: ['user_id', 'quiz_id', 'assessment_id'],
|
||||
});
|
||||
for (const attempt of passedAttempts) {
|
||||
if (attempt.quiz_id) {
|
||||
if (!passedQuizIdsByUser.has(attempt.user_id)) passedQuizIdsByUser.set(attempt.user_id, new Set());
|
||||
passedQuizIdsByUser.get(attempt.user_id).add(attempt.quiz_id);
|
||||
}
|
||||
if (attempt.assessment_id) passedAssessmentUserIds.add(attempt.user_id);
|
||||
}
|
||||
}
|
||||
|
||||
// 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 = await Promise.all(Object.values(summaryMap).map(async (entry) => {
|
||||
const u = userMap[entry.user_id];
|
||||
const avatar = await resolveAvatarUrl(u?.personal_info?.avatar);
|
||||
return {
|
||||
...entry,
|
||||
user: {
|
||||
email: u?.email ?? null,
|
||||
full_name: u?.personal_info?.name?.full_name ?? null,
|
||||
avatar_stream_token: avatar?.stream_token ?? 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',
|
||||
quizzes_pending: quizIds.length - (passedQuizIdsByUser.get(entry.user_id)?.size ?? 0),
|
||||
assessment_pending: !!assessment && !passedAssessmentUserIds.has(entry.user_id),
|
||||
};
|
||||
}));
|
||||
|
||||
// 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 [unitRows, progressRows] = await Promise.all([
|
||||
Unit.findAll({
|
||||
where: notDeleted,
|
||||
attributes: ['unit_id', 'uuid', 'title'],
|
||||
include: [
|
||||
{
|
||||
model: CourseUnit,
|
||||
as: 'courseLinks',
|
||||
where: { course_id: courseId },
|
||||
required: true,
|
||||
attributes: ['order_index'],
|
||||
},
|
||||
{
|
||||
model: Lesson,
|
||||
as: 'lessons',
|
||||
where: notDeleted,
|
||||
required: false,
|
||||
attributes: ['lesson_id', 'uuid', 'title'],
|
||||
through: { attributes: ['order_index'] },
|
||||
},
|
||||
],
|
||||
}),
|
||||
CourseReadingProgress.findAll({
|
||||
where: { course_id: courseId, user_id: userId },
|
||||
attributes: ['reference_id', 'type', 'status', 'completed_at', 'last_accessed_at'],
|
||||
}),
|
||||
]);
|
||||
|
||||
// Sort by junction order (course-level, then unit-level for lessons)
|
||||
const units = flattenUnits(unitRows.map((u) => {
|
||||
const plain = u.toJSON();
|
||||
plain.CourseUnit = { order_index: plain.courseLinks?.[0]?.order_index ?? 0 };
|
||||
delete plain.courseLinks;
|
||||
return plain;
|
||||
}));
|
||||
|
||||
// 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);
|
||||
}
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,125 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name: dashboard.controller.js (admin)
|
||||
* Type of Program: Controller
|
||||
* Description: Admin dashboard — users and groups stats + breakdowns.
|
||||
*
|
||||
* Author: rgrgogu
|
||||
* Date Created: Oct. 6, 2025
|
||||
***********************************************************************************************************************************************************************/
|
||||
const { Op, fn, col, literal } = require('sequelize');
|
||||
const mdl_Users = require('../../models/users/users.mdl');
|
||||
const { mdl_UserGroups, mdl_UserGroupMembers } = require('../../models/users/user_groups.mdl');
|
||||
|
||||
// ─── USERS DASHBOARD ──────────────────────────────────────────────────────────
|
||||
|
||||
exports.getUsersDashboard = async (req, res) => {
|
||||
try {
|
||||
const [
|
||||
totalUsers,
|
||||
activeUsers,
|
||||
archivedUsers,
|
||||
accTypeBreakdown,
|
||||
regTypeBreakdown,
|
||||
] = await Promise.all([
|
||||
mdl_Users.count({ paranoid: false }),
|
||||
mdl_Users.count({ where: { is_active: true } }),
|
||||
mdl_Users.count({ where: { deletedAt: { [Op.ne]: null } }, paranoid: false }),
|
||||
mdl_Users.findAll({
|
||||
attributes: ['acc_type', [fn('COUNT', col('user_id')), 'count']],
|
||||
group: ['acc_type'],
|
||||
raw: true,
|
||||
}),
|
||||
mdl_Users.findAll({
|
||||
attributes: ['reg_type', [fn('COUNT', col('user_id')), 'count']],
|
||||
group: ['reg_type'],
|
||||
raw: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
return res.status(200).json({
|
||||
status: 'success',
|
||||
message: 'Users dashboard data fetched.',
|
||||
data: {
|
||||
stats: [
|
||||
{ key: 'total', label: 'Total Users', value: totalUsers },
|
||||
{ key: 'active', label: 'Active Users', value: activeUsers },
|
||||
{ key: 'inactive', label: 'Inactive Users', value: totalUsers - archivedUsers - activeUsers },
|
||||
],
|
||||
breakdowns: [
|
||||
{
|
||||
key: 'acc_type',
|
||||
label: 'By Account Type',
|
||||
data: accTypeBreakdown.map((r) => ({ label: r.acc_type, value: parseInt(r.count, 10) })),
|
||||
},
|
||||
{
|
||||
key: 'reg_type',
|
||||
label: 'By Registration Type',
|
||||
data: regTypeBreakdown.map((r) => ({ label: r.reg_type, value: parseInt(r.count, 10) })),
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][DASHBOARD][USERS]', err);
|
||||
return res.status(500).json({ status: 'error', message: 'Could not fetch users dashboard data.' });
|
||||
}
|
||||
};
|
||||
|
||||
// ─── GROUPS DASHBOARD ─────────────────────────────────────────────────────────
|
||||
|
||||
exports.getGroupsDashboard = async (req, res) => {
|
||||
try {
|
||||
const [
|
||||
totalGroups,
|
||||
activeGroups,
|
||||
archivedGroups,
|
||||
memberCountBreakdown,
|
||||
] = await Promise.all([
|
||||
mdl_UserGroups.count({ paranoid: false }),
|
||||
mdl_UserGroups.count({ where: { is_active: true } }),
|
||||
mdl_UserGroups.count({ where: { deletedAt: { [Op.ne]: null } }, paranoid: false }),
|
||||
mdl_UserGroupMembers.findAll({
|
||||
attributes: [
|
||||
'group_id',
|
||||
[fn('COUNT', col('user_id')), 'member_count'],
|
||||
],
|
||||
where: { deletedAt: null },
|
||||
include: [{
|
||||
model: mdl_UserGroups,
|
||||
attributes: ['name'],
|
||||
where: { is_active: true },
|
||||
}],
|
||||
group: ['UserGroupMember.group_id', 'UserGroup.group_id', 'UserGroup.name'],
|
||||
order: [[literal('member_count'), 'DESC']],
|
||||
limit: 10,
|
||||
raw: true,
|
||||
nest: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
return res.status(200).json({
|
||||
status: 'success',
|
||||
message: 'Groups dashboard data fetched.',
|
||||
data: {
|
||||
stats: [
|
||||
{ key: 'total', label: 'Total Groups', value: totalGroups },
|
||||
{ key: 'active', label: 'Active Groups', value: activeGroups },
|
||||
{ key: 'inactive', label: 'Inactive Groups', value: totalGroups - archivedGroups - activeGroups },
|
||||
],
|
||||
breakdowns: [
|
||||
{
|
||||
key: 'top_groups',
|
||||
label: 'Top Groups by Members',
|
||||
data: memberCountBreakdown.map((r) => ({
|
||||
label: r.UserGroup?.name ?? `Group ${r.group_id}`,
|
||||
value: parseInt(r.member_count, 10),
|
||||
})),
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][DASHBOARD][GROUPS]', err);
|
||||
return res.status(500).json({ status: 'error', message: 'Could not fetch groups dashboard data.' });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,310 @@
|
||||
# Advertisements Controller Documentation
|
||||
|
||||
**File:** `controllers/admin/advertisements.controller.js`
|
||||
**Base URL:** `/api/admin/advertisements`
|
||||
**Guards:** `authenticate → requireAdmin() → adminLimiter`
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
- [Get All Advertisements](#get-all-advertisements)
|
||||
- [Get Single Advertisement](#get-single-advertisement)
|
||||
- [Create Advertisement](#create-advertisement)
|
||||
- [Update Advertisement](#update-advertisement)
|
||||
- [Archive Advertisement](#archive-advertisement)
|
||||
- [Bulk Archive Advertisements](#bulk-archive-advertisements)
|
||||
- [Restore Advertisement](#restore-advertisement)
|
||||
- [Bulk Restore Advertisements](#bulk-restore-advertisements)
|
||||
- [Get Archived Advertisements](#get-archived-advertisements)
|
||||
- [Get Field Values](#get-field-values)
|
||||
|
||||
---
|
||||
|
||||
## Placement Registry
|
||||
|
||||
Every advertisement belongs to a `placement` — a page + position slug drawn from a fixed
|
||||
registry (`models/advertisements/advertisements.placements.js`). The placement determines
|
||||
the advertisement's `type` (visual format) automatically; `type` is **never** accepted from
|
||||
the client and is denormalized from the placement on every write.
|
||||
|
||||
| Placement key | Page | Position | Format |
|
||||
|---|---|---|---|
|
||||
| `dashboard.hero` | Dashboard | Hero (top of page) | `hero` |
|
||||
| `dashboard.popup` | Dashboard | Popup (on load) | `popup` |
|
||||
| `course_list.banner` | Courses | Banner (above course grid) | `banner` |
|
||||
| `course_details.banner` | Course Details | Banner (below hero) | `banner` |
|
||||
| `course_details.sidebar` | Course Details | Sidebar (beside course content) | `sidebar` |
|
||||
| `plans.banner` | Plans | Banner (above plan cards) | `banner` |
|
||||
|
||||
Adding a new placement is a one-line addition to that registry file plus wiring the
|
||||
corresponding client page to fetch/render it — nothing else needs to change.
|
||||
|
||||
---
|
||||
|
||||
## Status Derivation
|
||||
|
||||
Status is **never** trusted as stored — it is recomputed on every read and write:
|
||||
|
||||
| Condition | Derived Status |
|
||||
|-----------|----------------|
|
||||
| `deletedAt` is set | `archived` |
|
||||
| `is_active = false` | `draft` |
|
||||
| `end_date` < now | `expired` |
|
||||
| `start_date` > now | `scheduled` |
|
||||
| Otherwise | `active` |
|
||||
|
||||
`archived` is the only status that bypasses derivation (set explicitly by archive/restore).
|
||||
|
||||
---
|
||||
|
||||
## CTAs
|
||||
|
||||
Each advertisement supports up to **2 CTAs**:
|
||||
|
||||
```json
|
||||
[
|
||||
{ "label": "Learn More", "link": "/courses", "variant": "default" },
|
||||
{ "label": "Sign Up", "link": "/register", "variant": "outline" }
|
||||
]
|
||||
```
|
||||
|
||||
- First CTA defaults to `"default"` variant; second defaults to `"outline"`.
|
||||
- An explicit valid variant from the client (`"default"` or `"outline"`) always wins.
|
||||
- Items beyond 2 are silently discarded.
|
||||
|
||||
---
|
||||
|
||||
## Get All Advertisements
|
||||
|
||||
**`GET /api/admin/advertisements`**
|
||||
|
||||
Returns a paginated list of active (non-deleted) advertisements. Status is resynced on the way out.
|
||||
|
||||
### Query Parameters
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|--------|----------|-------------|
|
||||
| page | number | No | Default: `1` |
|
||||
| limit | number | No | Default: `10`, max: `1000` |
|
||||
| filters | array | No | JSON array of filter objects |
|
||||
| sort | array | No | JSON array of sort objects |
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Advertisements retrieved.",
|
||||
"data": [...],
|
||||
"pagination": { "page": 1, "limit": 10, "total": 5, "totalPages": 1 }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Get Single Advertisement
|
||||
|
||||
**`GET /api/admin/advertisements/:advertisementId`**
|
||||
|
||||
Returns one advertisement with its `image` asset and audit user info.
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Advertisement retrieved.",
|
||||
"data": {
|
||||
"advertisement_id": 1,
|
||||
"uuid": "...",
|
||||
"placement": "dashboard.hero",
|
||||
"type": "hero",
|
||||
"status": "active",
|
||||
"badge_label": "New",
|
||||
"headline": "Welcome",
|
||||
"description": "...",
|
||||
"image_url": null,
|
||||
"image_asset_id": 12,
|
||||
"image": { "asset_id": 12, "display_name": "hero.jpg", "file_url": "...", "thumbnail_url": "..." },
|
||||
"ctas": [{ "label": "Start", "link": "/start", "variant": "default" }],
|
||||
"start_date": "2026-01-01T00:00:00.000Z",
|
||||
"end_date": null,
|
||||
"order": 0,
|
||||
"is_active": true,
|
||||
"size": null,
|
||||
"click_count": 0,
|
||||
"creator": { "user_id": 1, "full_name": "Admin User" },
|
||||
"updater": null,
|
||||
"createdAt": "2026-01-01T00:00:00.000Z",
|
||||
"updatedAt": "2026-01-01T00:00:00.000Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Error Responses
|
||||
| Status | Message |
|
||||
|--------|---------|
|
||||
| `400` | `Invalid advertisement ID.` |
|
||||
| `404` | `Advertisement not found.` |
|
||||
|
||||
---
|
||||
|
||||
## Create Advertisement
|
||||
|
||||
**`POST /api/admin/advertisements`**
|
||||
|
||||
### Request Body
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `placement` | string | **Yes** | A placement registry key, e.g. `dashboard.hero` — see [Placement Registry](#placement-registry). Determines `type` automatically. |
|
||||
| `createdBy` | number | **Yes** | User ID of creator |
|
||||
| `badge_label` | string | No | Small label shown on the ad |
|
||||
| `headline` | string | No | Main heading |
|
||||
| `description` | string | No | Body text |
|
||||
| `image_url` | string | No | Direct image URL |
|
||||
| `image_asset_id` | number | No | FK to `assets` table |
|
||||
| `ctas` | array | No | Up to 2 CTA objects `[{ label, link, variant }]` |
|
||||
| `start_date` | date | No | ISO date string |
|
||||
| `end_date` | date | No | ISO date string |
|
||||
| `order` | number | No | Display order. Default: `0` |
|
||||
| `is_active` | boolean | No | Default: `true` |
|
||||
| `size` | string | No | `sm`, `md`, `lg` — banner only |
|
||||
|
||||
### Response `201`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Advertisement created.",
|
||||
"data": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
### Error Responses
|
||||
| Status | Message |
|
||||
|--------|---------|
|
||||
| `400` | `placement is required.` |
|
||||
| `400` | `Invalid placement. Must be one of: dashboard.hero, dashboard.popup, ...` |
|
||||
| `400` | `createdBy is required.` |
|
||||
| `400` | `Invalid size. Must be one of: sm, md, lg` |
|
||||
|
||||
---
|
||||
|
||||
## Update Advertisement
|
||||
|
||||
**`PATCH /api/admin/advertisements/:advertisementId`**
|
||||
|
||||
Partial update. Only fields present in the body are changed. Status is recomputed after all fields are applied.
|
||||
|
||||
### Request Body
|
||||
Same optional fields as Create. `type` is never accepted — it's always derived from `placement`. Accepts `updatedBy`.
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{ "status": "success", "message": "Advertisement updated.", "data": { ... } }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Archive Advertisement
|
||||
|
||||
**`DELETE /api/admin/advertisements/:advertisementId`**
|
||||
|
||||
Soft-deletes the advertisement (`deletedAt` set, `status` → `archived`).
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{ "status": "success", "message": "Advertisement archived." }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Bulk Archive Advertisements
|
||||
|
||||
**`DELETE /api/admin/advertisements/bulk`**
|
||||
|
||||
Rate-limited. Soft-deletes multiple advertisements.
|
||||
|
||||
### Request Body
|
||||
```json
|
||||
{ "ids": [1, 2, 3], "deletedBy": 1 }
|
||||
```
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "3 advertisement(s) archived.",
|
||||
"archived_ids": [1, 2, 3],
|
||||
"skipped_ids": []
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Restore Advertisement
|
||||
|
||||
**`PATCH /api/admin/advertisements/:advertisementId/restore`**
|
||||
|
||||
Restores a soft-deleted advertisement.
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{ "status": "success", "message": "Advertisement restored.", "data": { ... } }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Bulk Restore Advertisements
|
||||
|
||||
**`PATCH /api/admin/advertisements/bulk-restore`**
|
||||
|
||||
### Request Body
|
||||
```json
|
||||
{ "ids": [1, 2] }
|
||||
```
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "2 advertisement(s) restored.",
|
||||
"restored_ids": [1, 2],
|
||||
"skipped_ids": []
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Get Archived Advertisements
|
||||
|
||||
**`GET /api/admin/advertisements/archived`**
|
||||
|
||||
Returns paginated list of soft-deleted advertisements.
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Archived advertisements retrieved.",
|
||||
"data": [...],
|
||||
"pagination": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Get Field Values
|
||||
|
||||
**`GET /api/admin/advertisements/field-values`**
|
||||
|
||||
Returns distinct values for filterable advertisement fields. Used by DataTable filter dropdowns.
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Field values retrieved.",
|
||||
"data": {
|
||||
"type": ["hero", "banner", "popup", "sidebar"],
|
||||
"placement": ["dashboard.hero", "dashboard.popup", "course_list.banner"],
|
||||
"status": ["active", "draft"]
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,428 @@
|
||||
# Assets API
|
||||
|
||||
Base path: `/api/admin/assets`
|
||||
Controller: `controllers/admin/assets.controller.js`
|
||||
Storage: Chibisafe (CDN) + PostgreSQL via Sequelize
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
### Multer setup
|
||||
|
||||
The upload and update-thumbnail endpoints use `multer.fields()` — make sure your route file is configured with `memoryStorage`:
|
||||
|
||||
```js
|
||||
const multer = require("multer");
|
||||
const upload = multer({ storage: multer.memoryStorage() });
|
||||
|
||||
// Upload
|
||||
router.post("/", upload.fields([{ name: "file", maxCount: 1 }, { name: "thumbnail", maxCount: 1 }]), assetsCtrl.uploadAsset);
|
||||
|
||||
// Update thumbnail
|
||||
router.patch("/:assetId/thumbnail", upload.fields([{ name: "thumbnail", maxCount: 1 }]), assetsCtrl.updateThumbnail);
|
||||
```
|
||||
|
||||
### Environment variables
|
||||
|
||||
```env
|
||||
CHIBISAFE_BASE_URL=https://cdn.yourdomain.com
|
||||
CHIBISAFE_API_KEY=your-api-key
|
||||
|
||||
CHIBISAFE_ALBUM_AVATARS=uuid
|
||||
CHIBISAFE_ALBUM_VIDEOS=uuid
|
||||
CHIBISAFE_ALBUM_DOCUMENTS=uuid
|
||||
CHIBISAFE_ALBUM_THUMBNAILS=uuid
|
||||
CHIBISAFE_ALBUM_ARCHIVED=uuid
|
||||
```
|
||||
|
||||
### Album routing
|
||||
|
||||
`owner_type` is the single source of truth for which Chibisafe album a file lands in:
|
||||
|
||||
| `owner_type` | Chibisafe album | Intended use |
|
||||
|---|---|---|
|
||||
| `avatar` | avatars | Profile pictures |
|
||||
| `video` | videos | Course / content videos |
|
||||
| `document` | documents | PDF, DOCX, PPT, TXT, etc. |
|
||||
| `thumbnail` | thumbnails | Set automatically — do not send manually |
|
||||
| `image` | *(none)* | General-purpose images |
|
||||
| anything else | *(none)* | Unclassified |
|
||||
|
||||
---
|
||||
|
||||
## Endpoints
|
||||
|
||||
---
|
||||
|
||||
### GET `/`
|
||||
|
||||
List all assets (paginated).
|
||||
|
||||
**Query params**
|
||||
|
||||
| Param | Required | Description |
|
||||
|---|---|---|
|
||||
| `page` | optional | Page number. Default: `1` |
|
||||
| `limit` | optional | Items per page. Default: `10`, max: `1000` |
|
||||
| `filters` | optional | JSON array of filter objects passed to `buildQuery` |
|
||||
| `sort` | optional | JSON array of sort objects passed to `buildQuery` |
|
||||
|
||||
**Response `200`**
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "Assets retrieved.",
|
||||
"data": [...],
|
||||
"pagination": {
|
||||
"page": 1,
|
||||
"limit": 10,
|
||||
"totalRecords": 42,
|
||||
"totalPages": 5,
|
||||
"hasPrevPage": false,
|
||||
"hasNextPage": true
|
||||
},
|
||||
"attributes": [...]
|
||||
}
|
||||
```
|
||||
|
||||
Soft-deleted assets are excluded automatically. Hidden fields (per `adminExclude`): `checksum`, `storage_bucket`, `storage_key`, `deletedBy`.
|
||||
|
||||
---
|
||||
|
||||
### GET `/:assetId`
|
||||
|
||||
Get a single asset by primary key.
|
||||
|
||||
**URL params**
|
||||
|
||||
| Param | Required | Description |
|
||||
|---|---|---|
|
||||
| `assetId` | required | Asset primary key (BIGINT) |
|
||||
|
||||
**Responses**
|
||||
|
||||
| Status | Description |
|
||||
|---|---|
|
||||
| `200` | `{ data: asset }` |
|
||||
| `400` | Invalid asset ID |
|
||||
| `404` | Asset not found |
|
||||
| `500` | Internal server error |
|
||||
|
||||
---
|
||||
|
||||
### POST `/`
|
||||
|
||||
Upload a new asset.
|
||||
|
||||
**Content-Type:** `multipart/form-data`
|
||||
|
||||
#### File fields
|
||||
|
||||
| Field | Required | Description |
|
||||
|---|---|---|
|
||||
| `file` | **required** | The main asset (image, video, document, etc.) |
|
||||
| `thumbnail` | **required if video** | Cover image for the video. Ignored for non-video files. |
|
||||
|
||||
#### Text fields
|
||||
|
||||
| Field | Required | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `uploadedBy` | **required** | — | User ID (BIGINT) of the uploader |
|
||||
| `storage_provider` | **required** | — | `chibisafe` \| `local` \| `s3` \| `gcs` \| `cloudinary` |
|
||||
| `owner_type` | optional | `null` | Determines album routing: `avatar`, `video`, `document`, `image` |
|
||||
| `owner_id` | optional | `null` | ID of the owning entity (course ID, user ID, etc.) |
|
||||
| `display_name` | optional | original filename | Human-readable name shown in the UI |
|
||||
| `description` | optional | `null` | Free-text description |
|
||||
| `is_public` | optional | `false` | `true` \| `false` |
|
||||
| `access_level` | optional | `private` | `public` \| `private` \| `restricted` |
|
||||
| `storage_bucket` | optional | `null` | Bucket name (S3 / GCS only) |
|
||||
| `storage_key` | optional | `null` | Override storage key. Auto-set for Chibisafe (uses Chibisafe file UUID). |
|
||||
| `file_url` | conditional | — | Required when `storage_provider` is not `local` or `chibisafe` |
|
||||
| `width` | optional (non-video) | `null` | Image/document width in px. Ignored for videos. |
|
||||
| `height` | optional (non-video) | `null` | Image/document height in px. Ignored for videos. |
|
||||
|
||||
#### Auto-extracted fields (videos only — do not send)
|
||||
|
||||
These are extracted server-side via **ffprobe** and will override anything the client sends:
|
||||
|
||||
| Field | Source | Example |
|
||||
|---|---|---|
|
||||
| `width` | ffprobe | `1920` |
|
||||
| `height` | ffprobe | `1080` |
|
||||
| `resolution` | derived | `1080p`, `720p`, `4K` |
|
||||
| `duration` | ffprobe | `281.49` (seconds) |
|
||||
| `frame_rate` | ffprobe | `23.976` (fps) |
|
||||
| `bitrate` | ffprobe | `447933` (bps) |
|
||||
| `video_codec` | ffprobe | `H.264`, `H.265`, `AV1`, `VP9` |
|
||||
| `audio_codec` | ffprobe | `AAC`, `MP3`, `Opus` |
|
||||
| `thumbnail_url` | Chibisafe upload | CDN URL of the uploaded thumbnail |
|
||||
|
||||
#### Transaction strategy
|
||||
|
||||
```
|
||||
Phase 1 (no DB connection held — slow I/O):
|
||||
├─ Validate inputs
|
||||
├─ Upload main file to Chibisafe → track UUID for rollback
|
||||
├─ Run ffprobe on video buffer → extract metadata
|
||||
└─ Upload thumbnail to Chibisafe → track UUID for rollback
|
||||
|
||||
Phase 2 (transaction open ~milliseconds):
|
||||
└─ Asset.create() → commit
|
||||
|
||||
On Phase 2 failure:
|
||||
└─ rollback DB + deleteFile() all tracked Chibisafe UUIDs
|
||||
```
|
||||
|
||||
**Responses**
|
||||
|
||||
| Status | Description |
|
||||
|---|---|
|
||||
| `201` | `{ data: asset }` — fully populated asset record |
|
||||
| `400` | Missing `file`, `uploadedBy`, or `thumbnail` (for videos); buffer issues |
|
||||
| `500` | DB or Chibisafe error — Chibisafe uploads are cleaned up automatically |
|
||||
|
||||
#### Example — video upload (Postman)
|
||||
|
||||
```
|
||||
POST /api/admin/assets
|
||||
Content-Type: multipart/form-data
|
||||
|
||||
file → (attach .mp4)
|
||||
thumbnail → (attach .jpg)
|
||||
uploadedBy → 1
|
||||
storage_provider → chibisafe
|
||||
owner_type → video
|
||||
owner_id → 10
|
||||
display_name → Intro to React
|
||||
is_public → true
|
||||
access_level → public
|
||||
```
|
||||
|
||||
#### Example — avatar upload
|
||||
|
||||
```
|
||||
POST /api/admin/assets
|
||||
Content-Type: multipart/form-data
|
||||
|
||||
file → (attach .jpg)
|
||||
uploadedBy → 1
|
||||
storage_provider → chibisafe
|
||||
owner_type → avatar
|
||||
owner_id → 5
|
||||
```
|
||||
|
||||
#### Example — document upload
|
||||
|
||||
```
|
||||
POST /api/admin/assets
|
||||
Content-Type: multipart/form-data
|
||||
|
||||
file → (attach .pdf)
|
||||
uploadedBy → 1
|
||||
storage_provider → chibisafe
|
||||
owner_type → document
|
||||
owner_id → 7
|
||||
display_name → Module 1 Handout
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### PATCH `/:assetId/thumbnail`
|
||||
|
||||
Replace the thumbnail image of an existing asset by uploading a new file.
|
||||
|
||||
**Content-Type:** `multipart/form-data`
|
||||
|
||||
**URL params**
|
||||
|
||||
| Param | Required | Description |
|
||||
|---|---|---|
|
||||
| `assetId` | **required** | Asset primary key |
|
||||
|
||||
**File field**
|
||||
|
||||
| Field | Required | Description |
|
||||
|---|---|---|
|
||||
| `thumbnail` | **required** | New thumbnail image file |
|
||||
|
||||
**How it works**
|
||||
|
||||
1. Uploads the new thumbnail to Chibisafe (thumbnails album).
|
||||
2. Updates `thumbnail_url` on the asset record.
|
||||
3. Deletes the old thumbnail from Chibisafe (best-effort — non-fatal if it fails).
|
||||
|
||||
> **Note:** Old thumbnail cleanup requires a `thumbnail_storage_key` column on the Asset model to track the previous Chibisafe file UUID. Without it, the old thumbnail remains on Chibisafe but the DB record is updated correctly.
|
||||
|
||||
**Responses**
|
||||
|
||||
| Status | Description |
|
||||
|---|---|
|
||||
| `200` | `{ data: asset }` — updated asset with new `thumbnail_url` |
|
||||
| `400` | No thumbnail file attached |
|
||||
| `404` | Asset not found |
|
||||
| `500` | Internal server error |
|
||||
|
||||
---
|
||||
|
||||
### PUT `/:assetId`
|
||||
|
||||
Update asset metadata. **File uploads are blocked on this endpoint.**
|
||||
|
||||
**Content-Type:** `application/json`
|
||||
|
||||
**URL params**
|
||||
|
||||
| Param | Required | Description |
|
||||
|---|---|---|
|
||||
| `assetId` | **required** | Asset primary key |
|
||||
|
||||
**Body** — all fields optional, send only what changes
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `display_name` | string | New display name |
|
||||
| `description` | string | New description |
|
||||
| `owner_type` | string | New owner type |
|
||||
| `owner_id` | number | New owner entity ID |
|
||||
| `is_public` | boolean | `true` \| `false` |
|
||||
| `access_level` | string | `public` \| `private` \| `restricted` |
|
||||
| `thumbnail_url` | string | Manually replace thumbnail URL (use PATCH `/thumbnail` to upload a file instead) |
|
||||
| `width` | number | Width in px. Re-derives `resolution` automatically. |
|
||||
| `height` | number | Height in px. Re-derives `resolution` automatically. |
|
||||
| `duration` | number | Duration in seconds |
|
||||
| `frame_rate` | number | fps |
|
||||
| `bitrate` | number | bps |
|
||||
| `video_codec` | string | e.g. `H.264` |
|
||||
| `audio_codec` | string | e.g. `AAC` |
|
||||
|
||||
**Responses**
|
||||
|
||||
| Status | Description |
|
||||
|---|---|
|
||||
| `200` | `{ data: asset }` |
|
||||
| `400` | Invalid ID or file attached to request |
|
||||
| `404` | Asset not found |
|
||||
| `500` | Internal server error |
|
||||
|
||||
---
|
||||
|
||||
### DELETE `/:assetId`
|
||||
|
||||
Soft-delete a single asset.
|
||||
|
||||
Sets `deletedAt` on the DB record and moves the file to the **archived** album on Chibisafe (best-effort — non-fatal if Chibisafe is unavailable).
|
||||
|
||||
**Content-Type:** `application/json`
|
||||
|
||||
**URL params**
|
||||
|
||||
| Param | Required | Description |
|
||||
|---|---|---|
|
||||
| `assetId` | **required** | Asset primary key |
|
||||
|
||||
**Body**
|
||||
|
||||
| Field | Required | Description |
|
||||
|---|---|---|
|
||||
| `deletedBy` | optional | User ID performing the delete |
|
||||
|
||||
**Responses**
|
||||
|
||||
| Status | Description |
|
||||
|---|---|
|
||||
| `200` | Asset deleted |
|
||||
| `400` | Invalid asset ID |
|
||||
| `404` | Asset not found |
|
||||
| `500` | Internal server error |
|
||||
|
||||
---
|
||||
|
||||
### DELETE `/bulk`
|
||||
|
||||
Soft-delete multiple assets in one call.
|
||||
|
||||
All matching Chibisafe files are moved to the **archived** album in a single API call.
|
||||
|
||||
**Content-Type:** `application/json`
|
||||
|
||||
**Body**
|
||||
|
||||
| Field | Required | Description |
|
||||
|---|---|---|
|
||||
| `ids` | **required** | Non-empty array of asset IDs: `[1, 2, 3]` |
|
||||
| `deletedBy` | optional | User ID performing the delete |
|
||||
|
||||
**Responses**
|
||||
|
||||
| Status | Description |
|
||||
|---|---|
|
||||
| `200` | `N asset(s) deleted` |
|
||||
| `400` | `ids` missing or empty |
|
||||
| `500` | Internal server error |
|
||||
|
||||
---
|
||||
|
||||
### POST `/:assetId/restore`
|
||||
|
||||
Restore a soft-deleted asset.
|
||||
|
||||
Clears `deletedAt` and `deletedBy` on the DB record, then moves the file on Chibisafe from the **archived** album back to its home album based on `owner_type`:
|
||||
|
||||
| `owner_type` | Moved back to |
|
||||
|---|---|
|
||||
| `video` | videos album |
|
||||
| `avatar` | avatars album |
|
||||
| `document` | documents album |
|
||||
| `image` / anything else | no move (no dedicated album) |
|
||||
|
||||
The Chibisafe move is best-effort — a failed move will not block or roll back the DB restore.
|
||||
|
||||
**URL params**
|
||||
|
||||
| Param | Required | Description |
|
||||
|---|---|---|
|
||||
| `assetId` | **required** | Asset primary key (must be soft-deleted) |
|
||||
|
||||
**Body:** none required.
|
||||
|
||||
**Responses**
|
||||
|
||||
| Status | Description |
|
||||
|---|---|
|
||||
| `200` | `{ data: asset }` — Asset restored |
|
||||
| `404` | Asset not found or not deleted |
|
||||
| `500` | Internal server error |
|
||||
|
||||
---
|
||||
|
||||
## Response shape
|
||||
|
||||
All responses use `R.success` / `R.error` from `response.util`:
|
||||
|
||||
```json
|
||||
// success
|
||||
{
|
||||
"message": "Asset uploaded.",
|
||||
"data": { ... }
|
||||
}
|
||||
|
||||
// error
|
||||
{
|
||||
"message": "Asset not found.",
|
||||
"status": 404
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Related files
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `models/assets/assets.mdl.js` | Sequelize model |
|
||||
| `models/assets/assets.attributes.js` | Exclude sets, paginate config |
|
||||
| `services/chibisafe.service.js` | Chibisafe API wrapper (upload, delete, archive, album) |
|
||||
| `services/ffprobe.service.js` | ffprobe metadata extraction for videos |
|
||||
| `utils/paginate.util.js` | Paginated `findAndCountAll` used by `getAssets` |
|
||||
| `utils/response.util.js` | `R.success` / `R.error` response helpers |
|
||||
@@ -0,0 +1,171 @@
|
||||
# Categories Controller Documentation
|
||||
|
||||
**File:** `controllers/admin/categories.controller.js`
|
||||
**Base URL:** `/api/admin/categories`
|
||||
**Guards:** `authenticate → requireAdmin() → adminLimiter`
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
- [Get All Categories](#get-all-categories)
|
||||
- [Get Single Category](#get-single-category)
|
||||
- [Create Category](#create-category)
|
||||
- [Update Category](#update-category)
|
||||
- [Archive Category](#archive-category)
|
||||
- [Restore Category](#restore-category)
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- `slug` is auto-generated from `name` on create and update: lowercased, trimmed, non-alphanumeric runs replaced with `-`.
|
||||
- `slug` and `name` must be **unique** across all categories (including archived ones).
|
||||
- `paranoid: false` is used on GET All and GET One, so archived categories are visible.
|
||||
|
||||
---
|
||||
|
||||
## Get All Categories
|
||||
|
||||
**`GET /api/admin/categories`**
|
||||
|
||||
Returns all categories ordered alphabetically by name. Includes archived rows.
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Categories retrieved.",
|
||||
"data": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Business",
|
||||
"slug": "business",
|
||||
"description": "Business courses",
|
||||
"is_active": true,
|
||||
"createdAt": "2026-01-01T00:00:00.000Z",
|
||||
"updatedAt": "2026-01-01T00:00:00.000Z",
|
||||
"deletedAt": null
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Get Single Category
|
||||
|
||||
**`GET /api/admin/categories/:id`**
|
||||
|
||||
Returns one category. Includes archived.
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Category retrieved.",
|
||||
"data": { "id": 1, "name": "Business", "slug": "business", ... }
|
||||
}
|
||||
```
|
||||
|
||||
### Error Responses
|
||||
| Status | Message |
|
||||
|--------|---------|
|
||||
| `404` | `Category not found.` |
|
||||
|
||||
---
|
||||
|
||||
## Create Category
|
||||
|
||||
**`POST /api/admin/categories`**
|
||||
|
||||
### Request Body
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `name` | string | **Yes** | Display name. Must be unique. |
|
||||
| `description` | string | No | Optional description. |
|
||||
| `is_active` | boolean | No | Default: `true` |
|
||||
|
||||
### Response `201`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Category created.",
|
||||
"data": { "id": 2, "name": "Design", "slug": "design", "is_active": true, ... }
|
||||
}
|
||||
```
|
||||
|
||||
### Error Responses
|
||||
| Status | Message |
|
||||
|--------|---------|
|
||||
| `400` | `name is required.` |
|
||||
| `409` | `A category with that name already exists.` |
|
||||
|
||||
---
|
||||
|
||||
## Update Category
|
||||
|
||||
**`PUT /api/admin/categories/:id`**
|
||||
|
||||
Full update. Only non-`null`/`undefined` fields are changed. `slug` is re-generated if `name` changes.
|
||||
|
||||
### Request Body
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `name` | string | No | New display name. |
|
||||
| `description` | string | No | |
|
||||
| `is_active` | boolean | No | |
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Category updated.",
|
||||
"data": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
### Error Responses
|
||||
| Status | Message |
|
||||
|--------|---------|
|
||||
| `404` | `Category not found.` |
|
||||
| `409` | `A category with that name already exists.` |
|
||||
|
||||
---
|
||||
|
||||
## Archive Category
|
||||
|
||||
**`DELETE /api/admin/categories/:id`**
|
||||
|
||||
Soft-deletes the category (`deletedAt` set).
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{ "status": "success", "message": "Category archived." }
|
||||
```
|
||||
|
||||
### Error Responses
|
||||
| Status | Message |
|
||||
|--------|---------|
|
||||
| `404` | `Category not found.` |
|
||||
|
||||
---
|
||||
|
||||
## Restore Category
|
||||
|
||||
**`POST /api/admin/categories/:id/restore`**
|
||||
|
||||
Restores a soft-deleted category.
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Category restored.",
|
||||
"data": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
### Error Responses
|
||||
| Status | Message |
|
||||
|--------|---------|
|
||||
| `404` | `Category not found.` |
|
||||
@@ -0,0 +1,843 @@
|
||||
# Courses Controller Documentation
|
||||
|
||||
**File:** `controllers/admin/courses.controller.js`
|
||||
**Base URL:** `/api/admin/courses`
|
||||
**Guards:** `authenticate → requireAdmin() → adminLimiter`
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
- [Courses](#courses)
|
||||
- [Get All Courses](#get-all-courses)
|
||||
- [Get Single Course](#get-single-course)
|
||||
- [Create Course](#create-course)
|
||||
- [Update Course](#update-course)
|
||||
- [Archive Course](#archive-course)
|
||||
- [Bulk Archive Courses](#bulk-archive-courses)
|
||||
- [Restore Course](#restore-course)
|
||||
- [Bulk Restore Courses](#bulk-restore-courses)
|
||||
- [Get Archived Courses](#get-archived-courses)
|
||||
- [Get Archived Course](#get-archived-course)
|
||||
- [Flat Lists (dropdowns)](#flat-lists)
|
||||
- [Get Course Field Values](#get-course-field-values)
|
||||
- [Course Instructors](#course-instructors)
|
||||
- [Get Instructors](#get-instructors)
|
||||
- [Sync Instructors](#sync-instructors)
|
||||
- [Course Prerequisites](#course-prerequisites)
|
||||
- [Get Prerequisites](#get-prerequisites)
|
||||
- [Sync Prerequisites](#sync-prerequisites)
|
||||
- [Course Assessment](#course-assessment)
|
||||
- [Get Assessment](#get-assessment)
|
||||
- [Create Assessment](#create-assessment)
|
||||
- [Update Assessment](#update-assessment)
|
||||
- [Archive/Restore Assessment](#archiverestore-assessment)
|
||||
- [Quiz Questions](#quiz-questions)
|
||||
- [Get Questions](#get-questions)
|
||||
- [Create Question](#create-question)
|
||||
- [Update Question](#update-question)
|
||||
- [Archive/Restore Question](#archiverestore-question)
|
||||
- [Bulk Archive/Restore Questions](#bulk-archiverestore-questions)
|
||||
- [Units](#units)
|
||||
- [Get All Units](#get-all-units)
|
||||
- [Get Single Unit](#get-single-unit)
|
||||
- [Create Unit](#create-unit)
|
||||
- [Update Unit](#update-unit)
|
||||
- [Archive/Restore Unit](#archiverestore-unit)
|
||||
- [Bulk Archive/Restore Units](#bulk-archiverestore-units)
|
||||
- [Get Unit Field Values](#get-unit-field-values)
|
||||
- [Unit Quiz](#unit-quiz)
|
||||
- [Get Quiz](#get-quiz)
|
||||
- [Create Quiz](#create-quiz)
|
||||
- [Update Quiz](#update-quiz)
|
||||
- [Archive/Restore Quiz](#archiverestore-quiz)
|
||||
- [Lessons](#lessons)
|
||||
- [Get All Lessons](#get-all-lessons)
|
||||
- [Get Single Lesson](#get-single-lesson)
|
||||
- [Create Lesson](#create-lesson)
|
||||
- [Update Lesson](#update-lesson)
|
||||
- [Archive/Restore Lesson](#archiverestore-lesson)
|
||||
- [Bulk Archive/Restore Lessons](#bulk-archiverestore-lessons)
|
||||
- [Get Lesson Field Values](#get-lesson-field-values)
|
||||
- [Lesson Page](#lesson-page)
|
||||
- [Get Lesson Page](#get-lesson-page)
|
||||
- [Upsert Lesson Page](#upsert-lesson-page)
|
||||
- [Course Reading Progress](#course-reading-progress)
|
||||
- [Get Course Reading Progress](#get-course-reading-progress)
|
||||
- [Get User Reading Progress](#get-user-reading-progress)
|
||||
|
||||
---
|
||||
|
||||
## Course Hierarchy
|
||||
|
||||
```
|
||||
Course
|
||||
├── CourseObjective[]
|
||||
├── CoursePrerequisite[]
|
||||
├── CourseAssessment (one)
|
||||
│ └── QuizQuestion[] → QuizOption[]
|
||||
├── CourseInstructor[]
|
||||
└── Unit[]
|
||||
├── UnitQuiz (one)
|
||||
│ └── QuizQuestion[] → QuizOption[]
|
||||
└── Lesson[]
|
||||
├── LessonObjective[]
|
||||
└── LessonPage (one) { blocks: [] }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Courses
|
||||
|
||||
### Get All Courses
|
||||
|
||||
**`GET /api/admin/courses`**
|
||||
|
||||
Returns paginated active courses.
|
||||
|
||||
### Query Parameters
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|--------|----------|-------------|
|
||||
| `page` | number | No | Default: `1` |
|
||||
| `limit` | number | No | Default: `10` |
|
||||
| `filters` | array | No | JSON filter array |
|
||||
| `sort` | array | No | JSON sort array |
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Courses retrieved.",
|
||||
"data": [...],
|
||||
"pagination": { "page": 1, "limit": 10, "total": 12, "totalPages": 2 }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Get Single Course
|
||||
|
||||
**`GET /api/admin/courses/:courseId`**
|
||||
|
||||
Returns the full course tree: units (with lessons and quiz), objectives, prerequisites, and assessment.
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Course retrieved.",
|
||||
"data": {
|
||||
"course_id": 1,
|
||||
"uuid": "...",
|
||||
"title": "Advanced JavaScript",
|
||||
"description": "...",
|
||||
"course_code": "JS-201",
|
||||
"order_index": 0,
|
||||
"level": "advanced",
|
||||
"subscription": "premium",
|
||||
"duration_seconds": 7200,
|
||||
"objectives": [{ "objective_id": 1, "text": "Understand closures", "order_index": 0 }],
|
||||
"prerequisites": [],
|
||||
"assessment": { ... },
|
||||
"units": [
|
||||
{
|
||||
"unit_id": 1, "title": "Closures", "order_index": 0,
|
||||
"lessons": [{ "lesson_id": 1, "title": "What is a closure?", "order_index": 0 }],
|
||||
"quiz": { ... }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Create Course
|
||||
|
||||
**`POST /api/admin/courses`**
|
||||
|
||||
Creates a course with optional objectives and category assignments in a single transaction.
|
||||
|
||||
### Request Body
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `title` | string | **Yes** | Course title |
|
||||
| `description` | string | No | |
|
||||
| `course_code` | string | No | Unique course identifier |
|
||||
| `order_index` | number | No | Default: `0` |
|
||||
| `level` | string | No | `beginner`, `intermediate`, `advanced` |
|
||||
| `subscription` | string | No | `free`, `premium`. Default: `free` |
|
||||
| `objectives` | array | No | `[{ text, order_index }]` |
|
||||
| `category_ids` | array | No | Category IDs to assign |
|
||||
| `createdBy` | number | No | Creator user ID |
|
||||
|
||||
### Response `201`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Course created.",
|
||||
"data": { "course_id": 5, "title": "Advanced JavaScript", ... }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Update Course
|
||||
|
||||
**`PUT /api/admin/courses/:courseId`**
|
||||
|
||||
Updates course fields. When `objectives` or `category_ids` are provided, they replace the existing sets.
|
||||
|
||||
### Request Body (all optional)
|
||||
`title`, `description`, `order_index`, `course_code`, `level`, `subscription`, `objectives`, `category_ids`, `updatedBy`
|
||||
|
||||
---
|
||||
|
||||
### Archive Course
|
||||
|
||||
**`DELETE /api/admin/courses/:courseId`**
|
||||
|
||||
Soft-deletes the course.
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{ "status": "success", "message": "Course archived." }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Bulk Archive Courses
|
||||
|
||||
**`DELETE /api/admin/courses/bulk`**
|
||||
|
||||
### Request Body
|
||||
```json
|
||||
{ "ids": [1, 2, 3] }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Restore Course
|
||||
|
||||
**`PATCH /api/admin/courses/:courseId/restore`**
|
||||
|
||||
---
|
||||
|
||||
### Bulk Restore Courses
|
||||
|
||||
**`PATCH /api/admin/courses/restore/bulk`**
|
||||
|
||||
### Request Body
|
||||
```json
|
||||
{ "ids": [1, 2] }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Get Archived Courses
|
||||
|
||||
**`GET /api/admin/courses/archives`**
|
||||
|
||||
---
|
||||
|
||||
### Get Archived Course
|
||||
|
||||
**`GET /api/admin/courses/archives/:courseId`**
|
||||
|
||||
---
|
||||
|
||||
### Flat Lists
|
||||
|
||||
Lightweight endpoints that return `uuid + title` arrays (no pagination). Used by the task requirement builder dropdowns.
|
||||
|
||||
**`GET /api/admin/courses/flat`** — all active courses: `[{ uuid, title }]`
|
||||
|
||||
**`GET /api/admin/courses/units-flat`** — all active units: `[{ uuid, title, order_index, course_title }]`
|
||||
|
||||
**`GET /api/admin/courses/lessons-flat`** — all active lessons: `[{ uuid, title, order_index, unit_title, unit_order, course_title }]`
|
||||
|
||||
---
|
||||
|
||||
### Get Course Field Values
|
||||
|
||||
**`GET /api/admin/courses/field-values`**
|
||||
|
||||
---
|
||||
|
||||
## Course Instructors
|
||||
|
||||
### Get Instructors
|
||||
|
||||
**`GET /api/admin/courses/:courseId/instructors`**
|
||||
|
||||
Returns instructors ordered by `order_index`. Includes linked `users` (staff/admin accounts) when `user_id` is set.
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Instructors retrieved.",
|
||||
"data": [
|
||||
{
|
||||
"id": 1,
|
||||
"course_id": 5,
|
||||
"user_id": 3,
|
||||
"display_name": "Dr. Jane Smith",
|
||||
"order_index": 0,
|
||||
"user": { "user_id": 3, "email": "jane@example.com", "acc_type": "staff", "personal_info": { ... } }
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Sync Instructors
|
||||
|
||||
**`PUT /api/admin/courses/:courseId/instructors`**
|
||||
|
||||
Replaces the full instructor list for a course in a single transaction.
|
||||
|
||||
- Validates any `user_id` values — they must be `staff` or `admin` accounts.
|
||||
- Passing an empty array clears all instructors.
|
||||
|
||||
### Request Body
|
||||
```json
|
||||
{
|
||||
"instructors": [
|
||||
{ "user_id": 3, "display_name": "Dr. Jane Smith", "order_index": 0 },
|
||||
{ "user_id": null, "display_name": "External Contributor", "order_index": 1 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Course Prerequisites
|
||||
|
||||
### Get Prerequisites
|
||||
|
||||
**`GET /api/admin/courses/:courseId/prerequisites`**
|
||||
|
||||
Returns prerequisites ordered by `order_index`.
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Prerequisites retrieved.",
|
||||
"data": [
|
||||
{ "prereq_id": 1, "course_id": 5, "ref_type": "course", "ref_id": 2, "order_index": 0 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Sync Prerequisites
|
||||
|
||||
**`PUT /api/admin/courses/:courseId/prerequisites`**
|
||||
|
||||
Replaces the full prerequisite list. Valid `ref_type` values: `course`, `unit`, `lesson`.
|
||||
|
||||
### Request Body
|
||||
```json
|
||||
{
|
||||
"prerequisites": [
|
||||
{ "ref_type": "course", "ref_id": 2 },
|
||||
{ "ref_type": "unit", "ref_id": 7 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Course Assessment
|
||||
|
||||
One assessment per course. Assessment questions are shared with unit quizzes via polymorphic `assessment_id` / `quiz_id` fields.
|
||||
|
||||
### Get Assessment
|
||||
|
||||
**`GET /api/admin/courses/:courseId/assessment`**
|
||||
|
||||
Returns the assessment with its questions and options.
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Assessment retrieved.",
|
||||
"data": {
|
||||
"assessment_id": 1,
|
||||
"uuid": "...",
|
||||
"course_id": 5,
|
||||
"title": "Final Exam",
|
||||
"is_required": true,
|
||||
"passing_score": 80,
|
||||
"time_limit_minutes": 60,
|
||||
"max_questions": 20,
|
||||
"questions": [
|
||||
{
|
||||
"question_id": 1, "type": "multiple_choice", "question": "What is a closure?",
|
||||
"explanation": "...", "points": 2, "order_index": 0,
|
||||
"options": [
|
||||
{ "option_id": 1, "text": "A function + its outer scope", "is_correct": true, "order_index": 0 }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Create Assessment
|
||||
|
||||
**`POST /api/admin/courses/:courseId/assessment`**
|
||||
|
||||
Only one assessment per course. Returns `409` if one already exists.
|
||||
|
||||
### Request Body
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `title` | string | No | |
|
||||
| `is_required` | boolean | No | Default: `false` |
|
||||
| `passing_score` | number | No | Default: `70` |
|
||||
| `time_limit_minutes` | number | No | `null` = no limit |
|
||||
| `max_questions` | number | No | `null` = show all |
|
||||
| `createdBy` | number | No | |
|
||||
|
||||
---
|
||||
|
||||
### Update Assessment
|
||||
|
||||
**`PATCH /api/admin/courses/:courseId/assessment/:assessmentId`**
|
||||
|
||||
---
|
||||
|
||||
### Archive/Restore Assessment
|
||||
|
||||
**`DELETE /api/admin/courses/:courseId/assessment/:assessmentId`** — archive
|
||||
**`PATCH /api/admin/courses/:courseId/assessment/:assessmentId/restore`** — restore
|
||||
**`GET /api/admin/courses/:courseId/assessment/archives`** — get archived assessment
|
||||
|
||||
---
|
||||
|
||||
## Quiz Questions
|
||||
|
||||
Shared by both **Unit Quizzes** and **Course Assessments**. The parent is determined by the route:
|
||||
|
||||
- Under a unit quiz: `/:courseId/units/:unitId/quiz/:quizId/questions`
|
||||
- Under an assessment: `/:courseId/assessment/:assessmentId/questions`
|
||||
|
||||
### Question Types
|
||||
|
||||
| Type | Options Required |
|
||||
|------|----------------|
|
||||
| `true_false` | Auto-generated `[True, False]` if `options` is empty |
|
||||
| `multiple_choice` | Exactly one `is_correct: true` option |
|
||||
| `multi_select` | One or more `is_correct: true` options |
|
||||
|
||||
### Get Questions
|
||||
|
||||
**`GET .../:parentId/questions`**
|
||||
|
||||
Returns questions with options, ordered by `order_index`.
|
||||
|
||||
---
|
||||
|
||||
### Create Question
|
||||
|
||||
**`POST .../:parentId/questions`**
|
||||
|
||||
Creates a question and its options in a transaction. `true_false` questions auto-generate `[True, False]` options if `options` is omitted.
|
||||
|
||||
### Request Body
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `type` | string | **Yes** | `true_false`, `multiple_choice`, `multi_select` |
|
||||
| `question` | string | **Yes** | Question text |
|
||||
| `explanation` | string | No | Shown after answer |
|
||||
| `order_index` | number | No | Default: `0` |
|
||||
| `points` | number | No | Default: `1` |
|
||||
| `options` | array | No | `[{ text, is_correct, order_index }]` |
|
||||
| `createdBy` | number | No | |
|
||||
|
||||
### Response `201`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Question created.",
|
||||
"data": { "question_id": 1, "type": "multiple_choice", "options": [...] }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Update Question
|
||||
|
||||
**`PATCH .../:parentId/questions/:questionId`**
|
||||
|
||||
When `options` is provided, the full option set is replaced (destroy + re-insert).
|
||||
|
||||
---
|
||||
|
||||
### Archive/Restore Question
|
||||
|
||||
**`DELETE .../:parentId/questions/:questionId`** — archive
|
||||
**`PATCH .../:parentId/questions/:questionId/restore`** — restore
|
||||
**`GET .../:parentId/questions/archives/:questionId`** — get archived question
|
||||
|
||||
---
|
||||
|
||||
### Bulk Archive/Restore Questions
|
||||
|
||||
**`DELETE .../:parentId/questions/bulk`** — bulk archive
|
||||
**`PATCH .../:parentId/questions/restore/bulk`** — bulk restore
|
||||
|
||||
### Request Body
|
||||
```json
|
||||
{ "ids": [1, 2, 3], "deletedBy": 1 }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Units
|
||||
|
||||
### Get All Units
|
||||
|
||||
**`GET /api/admin/courses/:courseId/units`**
|
||||
|
||||
Returns paginated active units for a course, ordered by `order_index`.
|
||||
|
||||
---
|
||||
|
||||
### Get Single Unit
|
||||
|
||||
**`GET /api/admin/courses/:courseId/units/:unitId`**
|
||||
|
||||
Returns a unit with its lessons and quiz.
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Unit retrieved.",
|
||||
"data": {
|
||||
"unit_id": 1, "uuid": "...", "course_id": 5,
|
||||
"title": "Introduction", "description": "...",
|
||||
"order_index": 0, "duration_seconds": 1800,
|
||||
"lessons": [...],
|
||||
"quiz": { ... }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Create Unit
|
||||
|
||||
**`POST /api/admin/courses/:courseId/units`**
|
||||
|
||||
### Request Body
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `title` | string | **Yes** | |
|
||||
| `description` | string | No | |
|
||||
| `order` | number | No | Default: `0` |
|
||||
| `createdBy` | number | No | |
|
||||
|
||||
---
|
||||
|
||||
### Update Unit
|
||||
|
||||
**`PUT /api/admin/courses/:courseId/units/:unitId`**
|
||||
|
||||
### Request Body (all optional)
|
||||
`title`, `description`, `order`, `updatedBy`
|
||||
|
||||
---
|
||||
|
||||
### Archive/Restore Unit
|
||||
|
||||
**`DELETE /api/admin/courses/:courseId/units/:unitId`** — archive
|
||||
**`PATCH /api/admin/courses/:courseId/units/:unitId/restore`** — restore
|
||||
**`GET /api/admin/courses/:courseId/units/archives`** — list archived
|
||||
**`GET /api/admin/courses/:courseId/units/archives/:unitId`** — get one archived
|
||||
|
||||
---
|
||||
|
||||
### Bulk Archive/Restore Units
|
||||
|
||||
**`DELETE /api/admin/courses/:courseId/units/bulk`** — bulk archive
|
||||
**`PATCH /api/admin/courses/:courseId/units/restore/bulk`** — bulk restore
|
||||
|
||||
```json
|
||||
{ "ids": [1, 2] }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Get Unit Field Values
|
||||
|
||||
**`GET /api/admin/courses/:courseId/field-values`**
|
||||
|
||||
---
|
||||
|
||||
## Unit Quiz
|
||||
|
||||
One quiz per unit. Shares `QuizQuestion` / `QuizOption` with course assessments (via `quiz_id` FK).
|
||||
|
||||
### Get Quiz
|
||||
|
||||
**`GET /api/admin/courses/:courseId/units/:unitId/quiz`**
|
||||
|
||||
Returns the quiz with questions and options.
|
||||
|
||||
---
|
||||
|
||||
### Create Quiz
|
||||
|
||||
**`POST /api/admin/courses/:courseId/units/:unitId/quiz`**
|
||||
|
||||
Only one quiz per unit. Returns `409` if one already exists.
|
||||
|
||||
### Request Body
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `title` | string | No | |
|
||||
| `is_required` | boolean | No | Default: `false` |
|
||||
| `passing_score` | number | No | Default: `70` |
|
||||
| `max_questions` | number | No | `null` = show all |
|
||||
| `createdBy` | number | No | |
|
||||
|
||||
---
|
||||
|
||||
### Update Quiz
|
||||
|
||||
**`PATCH /api/admin/courses/:courseId/units/:unitId/quiz/:quizId`**
|
||||
|
||||
### Request Body (all optional)
|
||||
`title`, `is_required`, `passing_score`, `max_questions`, `updatedBy`
|
||||
|
||||
---
|
||||
|
||||
### Archive/Restore Quiz
|
||||
|
||||
**`DELETE /api/admin/courses/:courseId/units/:unitId/quiz/:quizId`** — archive
|
||||
**`PATCH /api/admin/courses/:courseId/units/:unitId/quiz/:quizId/restore`** — restore
|
||||
**`GET /api/admin/courses/:courseId/units/:unitId/quiz/archives`** — get archived quiz
|
||||
|
||||
---
|
||||
|
||||
## Lessons
|
||||
|
||||
### Get All Lessons
|
||||
|
||||
**`GET /api/admin/courses/:courseId/units/:unitId/lessons`**
|
||||
|
||||
Returns paginated active lessons, ordered by `order_index`.
|
||||
|
||||
---
|
||||
|
||||
### Get Single Lesson
|
||||
|
||||
**`GET /api/admin/courses/:courseId/units/:unitId/lessons/:lessonId`**
|
||||
|
||||
Returns a lesson with its page (blocks) and objectives.
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Lesson retrieved.",
|
||||
"data": {
|
||||
"lesson_id": 1, "uuid": "...", "unit_id": 1,
|
||||
"title": "What is a closure?", "description": "...",
|
||||
"duration_seconds": 900, "order_index": 0,
|
||||
"page": { "page_id": 1, "lesson_id": 1, "blocks": [...] },
|
||||
"objectives": [{ "objective_id": 1, "text": "Understand closures", "order_index": 0 }],
|
||||
"unit": { "unit_id": 1, "course_id": 5, ... }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Create Lesson
|
||||
|
||||
**`POST /api/admin/courses/:courseId/units/:unitId/lessons`**
|
||||
|
||||
Creates a lesson, an empty `LessonPage` (blocks: `[]`), and optional objectives in a single transaction.
|
||||
|
||||
### Request Body
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `title` | string | **Yes** | |
|
||||
| `description` | string | No | |
|
||||
| `order` | number | No | Default: `0` |
|
||||
| `objectives` | array | No | `[{ text, order_index }]` |
|
||||
| `createdBy` | number | No | |
|
||||
|
||||
---
|
||||
|
||||
### Update Lesson
|
||||
|
||||
**`PUT /api/admin/courses/:courseId/units/:unitId/lessons/:lessonId`**
|
||||
|
||||
When `objectives` is provided, the full set is replaced.
|
||||
|
||||
### Request Body (all optional)
|
||||
`title`, `description`, `order`, `objectives`, `updatedBy`
|
||||
|
||||
---
|
||||
|
||||
### Archive/Restore Lesson
|
||||
|
||||
**`DELETE /api/admin/courses/:courseId/units/:unitId/lessons/:lessonId`** — archive
|
||||
**`PATCH /api/admin/courses/:courseId/units/:unitId/lessons/:lessonId/restore`** — restore
|
||||
**`GET /api/admin/courses/:courseId/units/:unitId/lessons/archives`** — list archived
|
||||
**`GET /api/admin/courses/:courseId/units/:unitId/lessons/archives/:lessonId`** — get one archived
|
||||
|
||||
---
|
||||
|
||||
### Bulk Archive/Restore Lessons
|
||||
|
||||
**`DELETE /api/admin/courses/:courseId/units/:unitId/lessons/bulk`** — bulk archive
|
||||
**`PATCH /api/admin/courses/:courseId/units/:unitId/lessons/restore/bulk`** — bulk restore
|
||||
|
||||
```json
|
||||
{ "ids": [1, 2] }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Get Lesson Field Values
|
||||
|
||||
**`GET /api/admin/courses/:courseId/units/:unitId/field-values`**
|
||||
|
||||
---
|
||||
|
||||
## Lesson Page
|
||||
|
||||
Each lesson has exactly **one** page. A page is created automatically when a lesson is created (with empty `blocks`).
|
||||
|
||||
### Get Lesson Page
|
||||
|
||||
**`GET /api/admin/courses/:courseId/units/:unitId/lessons/:lessonId/page`**
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Lesson page retrieved.",
|
||||
"data": {
|
||||
"page_id": 1,
|
||||
"lesson_id": 3,
|
||||
"blocks": [
|
||||
{ "type": "text", "content": "A closure is..." },
|
||||
{ "type": "video", "asset_id": 12, "duration_seconds": 300 }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Upsert Lesson Page
|
||||
|
||||
**`PUT /api/admin/courses/:courseId/units/:unitId/lessons/:lessonId/page`**
|
||||
|
||||
Creates or replaces the lesson page's block content. After saving, `duration_seconds` on the lesson (and its ancestor unit and course) is automatically recomputed from video blocks.
|
||||
|
||||
### Request Body
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `blocks` | array | **Yes** | Block array |
|
||||
| `updatedBy` | number | No | |
|
||||
|
||||
### Response `200` (updated) / `201` (created)
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Lesson page updated.",
|
||||
"data": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
### Error Responses
|
||||
| Status | Message |
|
||||
|--------|---------|
|
||||
| `400` | `blocks must be an array.` |
|
||||
| `404` | `Lesson not found.` |
|
||||
|
||||
---
|
||||
|
||||
## Course Reading Progress
|
||||
|
||||
> These endpoints are served from `controllers/admin/course_reading_progress.controller.js` but mounted on the `/api/admin/courses` router.
|
||||
|
||||
### Get Course Reading Progress
|
||||
|
||||
**`GET /api/admin/courses/:courseId/reading-progress`**
|
||||
|
||||
Returns one summary row per user who has touched the course. Includes lesson/unit completion counts derived from the live course structure (not from stored counters).
|
||||
|
||||
Sorted: in-progress users first (most recent access first), completed users last.
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Course reading progress retrieved.",
|
||||
"data": [
|
||||
{
|
||||
"user_id": 42,
|
||||
"course_status": "in_progress",
|
||||
"last_accessed_at": "2026-06-21T09:00:00.000Z",
|
||||
"lessons_completed": 3,
|
||||
"units_completed": 1,
|
||||
"user": {
|
||||
"email": "user@example.com",
|
||||
"full_name": "Jane Doe",
|
||||
"avatar_url": "https://..."
|
||||
},
|
||||
"units_total": 4,
|
||||
"lessons_total": 12
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Get User Reading Progress
|
||||
|
||||
**`GET /api/admin/courses/:courseId/reading-progress/users/:userId`**
|
||||
|
||||
Loaded lazily when the admin expands a user row. Returns the full unit → lesson breakdown with progress status per item.
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "User reading progress retrieved.",
|
||||
"data": [
|
||||
{
|
||||
"unit_id": 1, "uuid": "...", "title": "Introduction",
|
||||
"status": "completed",
|
||||
"lessons": [
|
||||
{
|
||||
"lesson_id": 1, "uuid": "...", "title": "What is a closure?",
|
||||
"status": "completed",
|
||||
"completed_at": "2026-06-20T10:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"lesson_id": 2, "uuid": "...", "title": "Closure examples",
|
||||
"status": null,
|
||||
"completed_at": null
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,90 @@
|
||||
# Dashboard Controller Documentation
|
||||
|
||||
**File:** `controllers/admin/dashboard.controller.js`
|
||||
**Base URL:** `/api/admin/dashboard`
|
||||
**Guards:** `authenticate → requireAdmin() → adminLimiter`
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
- [Users Dashboard](#users-dashboard)
|
||||
- [Groups Dashboard](#groups-dashboard)
|
||||
|
||||
---
|
||||
|
||||
## Users Dashboard
|
||||
|
||||
**`GET /api/admin/dashboard/users`**
|
||||
|
||||
Returns summary stats and breakdowns for all users. All counts run in parallel.
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Users dashboard data fetched.",
|
||||
"data": {
|
||||
"stats": [
|
||||
{ "key": "total", "label": "Total Users", "value": 100 },
|
||||
{ "key": "active", "label": "Active Users", "value": 85 },
|
||||
{ "key": "inactive", "label": "Inactive Users", "value": 10 },
|
||||
{ "key": "verified", "label": "Verified", "value": 80 },
|
||||
{ "key": "archived", "label": "Archived", "value": 5 }
|
||||
],
|
||||
"breakdowns": [
|
||||
{
|
||||
"key": "acc_type",
|
||||
"label": "By Account Type",
|
||||
"data": [
|
||||
{ "label": "user", "value": 90 },
|
||||
{ "label": "staff", "value": 8 },
|
||||
{ "label": "admin", "value": 2 }
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "reg_type",
|
||||
"label": "By Registration Type",
|
||||
"data": [
|
||||
{ "label": "system", "value": 75 },
|
||||
{ "label": "google", "value": 25 }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Groups Dashboard
|
||||
|
||||
**`GET /api/admin/dashboard/groups`**
|
||||
|
||||
Returns summary stats and a top-10 groups-by-member-count breakdown.
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Groups dashboard data fetched.",
|
||||
"data": {
|
||||
"stats": [
|
||||
{ "key": "total", "label": "Total Groups", "value": 20 },
|
||||
{ "key": "active", "label": "Active Groups", "value": 18 },
|
||||
{ "key": "inactive", "label": "Inactive Groups", "value": 0 },
|
||||
{ "key": "archived", "label": "Archived", "value": 2 },
|
||||
{ "key": "empty", "label": "Empty Groups", "value": 3 }
|
||||
],
|
||||
"breakdowns": [
|
||||
{
|
||||
"key": "top_groups",
|
||||
"label": "Top Groups by Members",
|
||||
"data": [
|
||||
{ "label": "Engineering", "value": 42 },
|
||||
{ "label": "Marketing", "value": 30 }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,188 @@
|
||||
# Landing Pages / Page Builder — Schema Documentation
|
||||
|
||||
**Migrations:** `41b`, `42`, `43`, `44`, `45`, `49`
|
||||
**Base URL (planned):** `/api/admin/pages`
|
||||
**Guards:** `authenticate → requireAdmin() → adminLimiter`
|
||||
|
||||
> **TODO:** Controller, service, and routes for this module have not been created yet.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
- [Overview](#overview)
|
||||
- [Schema](#schema)
|
||||
- [pages](#pages)
|
||||
- [landing_pages](#landing_pages)
|
||||
- [page_sections](#page_sections)
|
||||
- [page_blocks](#page_blocks)
|
||||
- [page_templates](#page_templates)
|
||||
- [page_user_groups](#page_user_groups)
|
||||
- [Relationships](#relationships)
|
||||
- [Design Decisions](#design-decisions)
|
||||
- [ENUM Types](#enum-types)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
The page builder uses a generalized `pages` root table as the single owner of all `page_sections`. This means sections and blocks are not locked to `landing_pages` — any future page type (`lesson_page`, `course_page`, etc.) can reuse the same section/block system by linking to `pages`.
|
||||
|
||||
**Hierarchy:**
|
||||
```
|
||||
pages
|
||||
├── landing_pages (1:1 via page_id)
|
||||
└── page_sections (1:N via page_id)
|
||||
└── page_blocks (1:N via page_section_id)
|
||||
|
||||
page_templates — standalone reusable layout snapshots
|
||||
page_user_groups — controls which groups can see a page
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Schema
|
||||
|
||||
### `pages`
|
||||
|
||||
Root identity table. Every page type gets a row here first.
|
||||
|
||||
| Column | Type | Constraints | Description |
|
||||
|------------|------------|----------------------|------------------------------------|
|
||||
| id | BIGSERIAL | PK | |
|
||||
| type | page_type | NOT NULL | Discriminator for the page type |
|
||||
| created_at | TIMESTAMPTZ | NOT NULL DEFAULT now() | |
|
||||
| updated_at | TIMESTAMPTZ | NOT NULL DEFAULT now() | |
|
||||
|
||||
**Indexes:** `idx_pages_type` on `(type)`
|
||||
|
||||
---
|
||||
|
||||
### `landing_pages`
|
||||
|
||||
Landing-page-specific metadata. One-to-one with `pages`.
|
||||
|
||||
| Column | Type | Constraints | Description |
|
||||
|------------------|--------------------|------------------------------------|------------------------------|
|
||||
| id | BIGSERIAL | PK | |
|
||||
| page_id | BIGINT | NOT NULL, UNIQUE, FK → pages(id) | Link to root pages table |
|
||||
| title | VARCHAR(255) | NOT NULL | |
|
||||
| slug | VARCHAR(255) | NOT NULL, UNIQUE | URL path segment |
|
||||
| meta_title | VARCHAR(255) | | SEO title override |
|
||||
| meta_description | TEXT | | SEO description |
|
||||
| status | landing_page_status | NOT NULL DEFAULT 'draft' | |
|
||||
| published_at | TIMESTAMPTZ | | Set when first published |
|
||||
| created_by | BIGINT | FK → users(user_id) SET NULL | Admin who created the page |
|
||||
| created_at | TIMESTAMPTZ | NOT NULL DEFAULT now() | |
|
||||
| updated_at | TIMESTAMPTZ | NOT NULL DEFAULT now() | |
|
||||
|
||||
**Indexes:** `idx_landing_pages_status` on `(status)`
|
||||
|
||||
---
|
||||
|
||||
### `page_sections`
|
||||
|
||||
Ordered sections within any page. References `pages`, not `landing_pages`.
|
||||
|
||||
| Column | Type | Constraints | Description |
|
||||
|------------|------------------|--------------------------------|------------------------------------|
|
||||
| id | BIGSERIAL | PK | |
|
||||
| page_id | BIGINT | NOT NULL, FK → pages(id) | Belongs to a page (any type) |
|
||||
| type | page_section_type | NOT NULL | Section layout type |
|
||||
| label | VARCHAR(255) | | Admin-facing label |
|
||||
| position | INT | NOT NULL DEFAULT 0 | Display order |
|
||||
| settings | JSONB | | Background, padding, layout, etc. |
|
||||
| is_visible | BOOLEAN | NOT NULL DEFAULT true | Toggle section visibility |
|
||||
| created_at | TIMESTAMPTZ | NOT NULL DEFAULT now() | |
|
||||
| updated_at | TIMESTAMPTZ | NOT NULL DEFAULT now() | |
|
||||
|
||||
**Indexes:** `idx_page_sections_page_id` on `(page_id)`, `idx_page_sections_order` on `(page_id, position)`
|
||||
|
||||
---
|
||||
|
||||
### `page_blocks`
|
||||
|
||||
Content blocks within a section.
|
||||
|
||||
| Column | Type | Constraints | Description |
|
||||
|-----------------|----------------|------------------------------------|----------------------------------------|
|
||||
| id | BIGSERIAL | PK | |
|
||||
| page_section_id | BIGINT | NOT NULL, FK → page_sections(id) | |
|
||||
| type | page_block_type | NOT NULL | Block content type |
|
||||
| content | JSONB | | Payload — varies by type (see below) |
|
||||
| position | INT | NOT NULL DEFAULT 0 | Display order within section |
|
||||
| settings | JSONB | | Block-level style overrides |
|
||||
| created_at | TIMESTAMPTZ | NOT NULL DEFAULT now() | |
|
||||
| updated_at | TIMESTAMPTZ | NOT NULL DEFAULT now() | |
|
||||
|
||||
**Indexes:** `idx_page_blocks_section_id` on `(page_section_id)`, `idx_page_blocks_order` on `(page_section_id, position)`
|
||||
|
||||
**`content` shape by block type:**
|
||||
| type | content fields |
|
||||
|--------|-----------------------------------------|
|
||||
| text | `{ body: string }` |
|
||||
| image | `{ src: string, alt: string }` |
|
||||
| button | `{ label: string, href: string, variant: string }` |
|
||||
| video | `{ src: string, autoplay: boolean }` |
|
||||
| form | `{ form_id: number }` |
|
||||
| spacer | `{ height: number }` |
|
||||
|
||||
---
|
||||
|
||||
### `page_templates`
|
||||
|
||||
Reusable layout snapshots. Stored as a full JSONB dump of sections + blocks — not live FK references.
|
||||
|
||||
| Column | Type | Constraints | Description |
|
||||
|---------------|-------------|------------------------|--------------------------------------|
|
||||
| id | BIGSERIAL | PK | |
|
||||
| name | VARCHAR(255) | NOT NULL | Template display name |
|
||||
| thumbnail_url | TEXT | | Preview image URL |
|
||||
| structure | JSONB | | Full snapshot of sections and blocks |
|
||||
| created_at | TIMESTAMPTZ | NOT NULL DEFAULT now() | |
|
||||
|
||||
---
|
||||
|
||||
### `page_user_groups`
|
||||
|
||||
Junction table — controls which user groups can access a page.
|
||||
|
||||
| Column | Type | Constraints | Description |
|
||||
|------------|-------------|--------------------------------------|-------------|
|
||||
| page_id | BIGINT | PK, FK → pages(id) ON DELETE CASCADE | |
|
||||
| group_id | BIGINT | PK, FK → user_groups(group_id) | |
|
||||
| created_at | TIMESTAMPTZ | NOT NULL DEFAULT now() | |
|
||||
|
||||
**Indexes:** `idx_page_user_groups_group_id` on `(group_id)`
|
||||
**Primary Key:** composite `(page_id, group_id)`
|
||||
|
||||
---
|
||||
|
||||
## Relationships
|
||||
|
||||
```
|
||||
pages 1 ──── 1 landing_pages
|
||||
pages 1 ──── N page_sections
|
||||
pages N ──── N user_groups (via page_user_groups)
|
||||
page_sections 1 ──── N page_blocks
|
||||
```
|
||||
|
||||
Cascade deletes flow top-down: deleting a `pages` row removes its `landing_pages` record, all its `page_sections`, and all nested `page_blocks` automatically.
|
||||
|
||||
---
|
||||
|
||||
## Design Decisions
|
||||
|
||||
- **`pages` as root** — sections belong to `pages`, not directly to `landing_pages`. Adding a new page type (e.g. `course_page`) only requires a new detail table + adding its value to the `page_type` ENUM. No changes to `page_sections` or `page_blocks`.
|
||||
- **`page_templates.structure` is a snapshot** — templates store a JSONB copy of sections/blocks, not live FK references. This keeps templates stable when source pages are edited.
|
||||
- **`page_sections.page_id`** points to `pages(id)` directly, giving sections access to any page type without schema changes.
|
||||
|
||||
---
|
||||
|
||||
## ENUM Types
|
||||
|
||||
| Type | Values |
|
||||
|---------------------|------------------------------------------------------------------------|
|
||||
| `page_type` | `landing_page`, `lesson_page`, `course_page` |
|
||||
| `landing_page_status` | `draft`, `published`, `archived` |
|
||||
| `page_section_type` | `hero`, `features`, `cta`, `testimonials`, `faq`, `pricing`, `gallery`, `custom` |
|
||||
| `page_block_type` | `text`, `image`, `button`, `video`, `form`, `spacer` |
|
||||
@@ -0,0 +1,125 @@
|
||||
# Notifications Controller Documentation
|
||||
|
||||
**File:** `controllers/admin/notification.controller.js`
|
||||
**Base URL:** `/api/admin/notifications`
|
||||
**Guards:** `authenticate → requireAdmin() → adminLimiter`
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
- [Get All Notifications](#get-all-notifications)
|
||||
- [Get Unseen Count](#get-unseen-count)
|
||||
- [Mark One as Seen](#mark-one-as-seen)
|
||||
- [Mark All as Seen](#mark-all-as-seen)
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- `admin_notifications` are **system-wide** — not scoped to a user. All admins see the same feed.
|
||||
- Records are never deleted — seen/unseen state is toggled only.
|
||||
- Notifications are created internally (e.g., task overdue events) — no POST endpoint is exposed.
|
||||
|
||||
---
|
||||
|
||||
## Get All Notifications
|
||||
|
||||
**`GET /api/admin/notifications`**
|
||||
|
||||
Returns paginated notifications, newest first.
|
||||
|
||||
### Query Parameters
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|--------|----------|-------------|
|
||||
| `page` | number | No | Default: `1` |
|
||||
| `limit` | number | No | Default: `20`, max: `50` |
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Notifications fetched.",
|
||||
"data": {
|
||||
"notifications": [
|
||||
{
|
||||
"notification_id": 1,
|
||||
"type": "task_overdue",
|
||||
"title": "Tasks Overdue",
|
||||
"message": "5 tasks are now overdue.",
|
||||
"data": { "count": 5 },
|
||||
"seen": false,
|
||||
"seen_at": null,
|
||||
"createdAt": "2026-06-19T10:00:00.000Z",
|
||||
"updatedAt": "2026-06-19T10:00:00.000Z"
|
||||
}
|
||||
],
|
||||
"pagination": {
|
||||
"page": 1,
|
||||
"limit": 20,
|
||||
"total": 10,
|
||||
"pages": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Get Unseen Count
|
||||
|
||||
**`GET /api/admin/notifications/unseen`**
|
||||
|
||||
Returns a count of unseen notifications. Used for the bell badge.
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Unseen count fetched.",
|
||||
"data": { "count": 3 }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Mark One as Seen
|
||||
|
||||
**`PATCH /api/admin/notifications/:id/seen`**
|
||||
|
||||
Marks a single notification as seen and sets `seen_at` to the current timestamp.
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Notification marked as seen.",
|
||||
"data": {
|
||||
"notification_id": 1,
|
||||
"seen": true,
|
||||
"seen_at": "2026-06-21T10:00:00.000Z",
|
||||
...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Error Responses
|
||||
| Status | Message |
|
||||
|--------|---------|
|
||||
| `404` | `Notification not found.` |
|
||||
|
||||
---
|
||||
|
||||
## Mark All as Seen
|
||||
|
||||
**`PATCH /api/admin/notifications/seen-all`**
|
||||
|
||||
Marks all unseen notifications as seen in a single update.
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "7 notification(s) marked as seen.",
|
||||
"data": { "count": 7 }
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,147 @@
|
||||
# Products Controller Documentation
|
||||
|
||||
**File:** `controllers/admin/products.controller.js`
|
||||
**Base URL:** `/api/admin/products`
|
||||
**Guards:** `authenticate → requireAdmin() → adminLimiter`
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
- [Get Course Product](#get-course-product)
|
||||
- [Upsert Course Product](#upsert-course-product)
|
||||
- [Remove Course Product](#remove-course-product)
|
||||
- [Get Course Categories](#get-course-categories)
|
||||
- [Sync Course Categories](#sync-course-categories)
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- Each course has **at most one** product listing (`products` table, unique on `course_id`).
|
||||
- Upsert restores a soft-deleted product if one exists rather than creating a duplicate.
|
||||
- Categories are managed via the `course_product_categories` junction table (many-to-many between `courses` and `categories`).
|
||||
- Syncing categories replaces the full set — it is a replace-all, not an append.
|
||||
|
||||
---
|
||||
|
||||
## Get Course Product
|
||||
|
||||
**`GET /api/admin/products/courses/:courseId/product`**
|
||||
|
||||
Returns the product listing for a course, or `null` if none exists. Includes archived products (`paranoid: false`).
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Product retrieved.",
|
||||
"data": {
|
||||
"id": 1,
|
||||
"course_id": 5,
|
||||
"name": "Advanced JavaScript",
|
||||
"description": "Full course access",
|
||||
"price": "49.99",
|
||||
"currency": "USD",
|
||||
"access_days": 365,
|
||||
"is_active": true,
|
||||
"createdAt": "2026-01-01T00:00:00.000Z",
|
||||
"updatedAt": "2026-01-01T00:00:00.000Z",
|
||||
"deletedAt": null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Upsert Course Product
|
||||
|
||||
**`PUT /api/admin/products/courses/:courseId/product`**
|
||||
|
||||
Creates the product if it does not exist. If a soft-deleted product exists, it is restored and updated. If an active product exists, it is updated.
|
||||
|
||||
### Request Body
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `name` | string | **Yes** | Product name |
|
||||
| `price` | number | **Yes** | Price (decimal, e.g. `49.99`) |
|
||||
| `description` | string | No | |
|
||||
| `currency` | string | No | ISO 4217 code. Default: `USD` |
|
||||
| `access_days` | number | No | Days of access after purchase. `null` = lifetime |
|
||||
| `is_active` | boolean | No | Default: `true` |
|
||||
|
||||
### Response `200` (updated) / `201` (created)
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Product updated.",
|
||||
"data": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
### Error Responses
|
||||
| Status | Message |
|
||||
|--------|---------|
|
||||
| `400` | `name and price are required.` |
|
||||
|
||||
---
|
||||
|
||||
## Remove Course Product
|
||||
|
||||
**`DELETE /api/admin/products/courses/:courseId/product`**
|
||||
|
||||
Soft-deletes the course product.
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{ "status": "success", "message": "Product removed." }
|
||||
```
|
||||
|
||||
### Error Responses
|
||||
| Status | Message |
|
||||
|--------|---------|
|
||||
| `404` | `Product not found.` |
|
||||
|
||||
---
|
||||
|
||||
## Get Course Categories
|
||||
|
||||
**`GET /api/admin/products/courses/:courseId/categories`**
|
||||
|
||||
Returns the list of categories assigned to a course.
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Course categories retrieved.",
|
||||
"data": [
|
||||
{ "id": 1, "name": "Business", "slug": "business", "is_active": true },
|
||||
{ "id": 3, "name": "Design", "slug": "design", "is_active": true }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Error Responses
|
||||
| Status | Message |
|
||||
|--------|---------|
|
||||
| `404` | `Course not found.` |
|
||||
|
||||
---
|
||||
|
||||
## Sync Course Categories
|
||||
|
||||
**`POST /api/admin/products/courses/:courseId/categories`**
|
||||
|
||||
Replaces the full set of category assignments for a course. All existing assignments are removed first, then the new set is inserted.
|
||||
|
||||
### Request Body
|
||||
```json
|
||||
{ "category_ids": [1, 3, 7] }
|
||||
```
|
||||
|
||||
Pass an empty array to clear all category assignments.
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{ "status": "success", "message": "Course categories updated." }
|
||||
```
|
||||
@@ -0,0 +1,148 @@
|
||||
# Profile Controller Documentation (Admin)
|
||||
|
||||
**File:** `controllers/admin/profile.controller.js`
|
||||
**Base URL:** `/api/admin/profile`
|
||||
**Guards:** `authenticate → requireAdmin() → adminLimiter`
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
- [Get Profile](#get-profile)
|
||||
- [Update Profile](#update-profile)
|
||||
- [Upload Avatar](#upload-avatar)
|
||||
- [Delete Avatar](#delete-avatar)
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- All endpoints are self-service — they operate on the **currently authenticated admin** (`req.user.user_id`).
|
||||
- `password`, `otp_code`, and `otp_expires_at` are always excluded from responses.
|
||||
- Avatar files are stored in S3 via `s3.service`. The old avatar is deleted before upload.
|
||||
|
||||
---
|
||||
|
||||
## Get Profile
|
||||
|
||||
**`GET /api/admin/profile`**
|
||||
|
||||
Returns the authenticated admin's full user record.
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Profile retrieved.",
|
||||
"data": {
|
||||
"user_id": 1,
|
||||
"email": "admin@example.com",
|
||||
"is_active": true,
|
||||
"is_verified": true,
|
||||
"reg_type": "system",
|
||||
"acc_type": "admin",
|
||||
"personal_info": {
|
||||
"name": {
|
||||
"given_name": "Kenneth",
|
||||
"middle_name": null,
|
||||
"last_name": "Obsequio",
|
||||
"extension_name": null,
|
||||
"full_name": "Kenneth Obsequio"
|
||||
},
|
||||
"occupation": null,
|
||||
"addresses": [],
|
||||
"phone_number": [],
|
||||
"date_of_birth": null,
|
||||
"avatar": {
|
||||
"url": "https://...",
|
||||
"uuid": "storage-key",
|
||||
"name": "avatar.jpg",
|
||||
"mime_type": "image/jpeg",
|
||||
"size": 204800
|
||||
}
|
||||
},
|
||||
"createdAt": "2025-10-06T00:00:00.000Z",
|
||||
"updatedAt": "2026-06-18T00:00:00.000Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Update Profile
|
||||
|
||||
**`PUT /api/admin/profile`**
|
||||
|
||||
Deep-merges `personal_info` — top-level keys and `name` sub-keys are merged separately. Existing keys not present in the request body are preserved.
|
||||
|
||||
### Request Body
|
||||
```json
|
||||
{
|
||||
"personal_info": {
|
||||
"name": {
|
||||
"given_name": "Kenneth",
|
||||
"last_name": "Obsequio"
|
||||
},
|
||||
"occupation": "Software Engineer",
|
||||
"date_of_birth": "1995-05-15"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Profile updated.",
|
||||
"data": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Upload Avatar
|
||||
|
||||
**`POST /api/admin/profile/avatar`**
|
||||
|
||||
Uploads (or replaces) the admin's avatar via `multipart/form-data`.
|
||||
|
||||
- Old avatar is deleted from S3 before uploading the new one.
|
||||
- Avatar metadata is stored in `personal_info.avatar`.
|
||||
|
||||
### Request
|
||||
`Content-Type: multipart/form-data`
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `file` | file | **Yes** | Image file (handled by `avatar_upload.middleware`) |
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Avatar updated.",
|
||||
"data": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
### Error Responses
|
||||
| Status | Message |
|
||||
|--------|---------|
|
||||
| `400` | `No file provided.` |
|
||||
|
||||
---
|
||||
|
||||
## Delete Avatar
|
||||
|
||||
**`DELETE /api/admin/profile/avatar`**
|
||||
|
||||
Removes the admin's avatar from S3 and sets `personal_info.avatar` to `null`.
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{ "status": "success", "message": "Avatar removed." }
|
||||
```
|
||||
|
||||
### Error Responses
|
||||
| Status | Message |
|
||||
|--------|---------|
|
||||
| `404` | `No avatar to remove.` |
|
||||
@@ -0,0 +1,563 @@
|
||||
# Tasks Controller Documentation
|
||||
|
||||
**File:** `controllers/admin/task.controller.js` + `controllers/admin/task_completion.controller.js`
|
||||
**Base URL:** `/api/admin/task-lists`
|
||||
**Guards:** `authenticate → requireAdmin() → adminLimiter`
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
- [Task Lists](#task-lists)
|
||||
- [Get All Task Lists](#get-all-task-lists)
|
||||
- [Get Single Task List](#get-single-task-list)
|
||||
- [Create Task List](#create-task-list)
|
||||
- [Update Task List](#update-task-list)
|
||||
- [Archive Task List](#archive-task-list)
|
||||
- [Restore Task List](#restore-task-list)
|
||||
- [Bulk Archive Task Lists](#bulk-archive-task-lists)
|
||||
- [Bulk Restore Task Lists](#bulk-restore-task-lists)
|
||||
- [Get Archived Task Lists](#get-archived-task-lists)
|
||||
- [Get Task List Field Values](#get-task-list-field-values)
|
||||
- [Task List Groups](#task-list-groups)
|
||||
- [Get Assigned Groups](#get-assigned-groups)
|
||||
- [Assign Groups](#assign-groups)
|
||||
- [Unassign Groups](#unassign-groups)
|
||||
- [Tasks](#tasks)
|
||||
- [Get All Tasks](#get-all-tasks)
|
||||
- [Get Single Task](#get-single-task)
|
||||
- [Create Task](#create-task)
|
||||
- [Update Task](#update-task)
|
||||
- [Archive Task](#archive-task)
|
||||
- [Restore Task](#restore-task)
|
||||
- [Bulk Archive Tasks](#bulk-archive-tasks)
|
||||
- [Bulk Restore Tasks](#bulk-restore-tasks)
|
||||
- [Get Archived Tasks](#get-archived-tasks)
|
||||
- [Get Task Field Values](#get-task-field-values)
|
||||
- [Completions](#completions)
|
||||
- [Get All Completions](#get-all-completions)
|
||||
- [Get Single Completion](#get-single-completion)
|
||||
- [Get Completions by User](#get-completions-by-user)
|
||||
- [Archive Completion](#archive-completion)
|
||||
- [Restore Completion](#restore-completion)
|
||||
- [Bulk Archive Completions](#bulk-archive-completions)
|
||||
- [Bulk Restore Completions](#bulk-restore-completions)
|
||||
|
||||
---
|
||||
|
||||
## Data Model
|
||||
|
||||
```
|
||||
TaskList ──< Task ──< TaskRequirement
|
||||
│
|
||||
└──< TaskListGroup >── UserGroup
|
||||
```
|
||||
|
||||
- A **TaskList** is a named container of tasks, visible to assigned user groups.
|
||||
- A **Task** belongs to one TaskList and has 0-N requirements.
|
||||
- A **TaskRequirement** specifies what a user must do (visit a link, upload a file, or read a course/unit/lesson).
|
||||
- Completions are submitted by **clients only** — admins can view and archive/restore them.
|
||||
|
||||
---
|
||||
|
||||
## Task Requirement Types
|
||||
|
||||
| `type` | Required Fields | Description |
|
||||
|--------|----------------|-------------|
|
||||
| `visit_link` | `link_url`, `link_label` | User must visit a URL |
|
||||
| `upload_file` | `allowed_file_types`, `max_file_count` | User must upload files |
|
||||
| `read_course` | `reference_id` (course UUID), `reference_label` | User must complete a course |
|
||||
| `read_unit` | `reference_id` (unit UUID), `reference_label` | User must complete a unit |
|
||||
| `read_lesson` | `reference_id` (lesson UUID), `reference_label` | User must complete a lesson |
|
||||
|
||||
---
|
||||
|
||||
## Task Lists
|
||||
|
||||
### Get All Task Lists
|
||||
|
||||
**`GET /api/admin/task-lists`**
|
||||
|
||||
Returns paginated active task lists.
|
||||
|
||||
### Query Parameters
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|--------|----------|-------------|
|
||||
| `page` | number | No | Default: `1` |
|
||||
| `limit` | number | No | Default: `10` |
|
||||
| `filters` | array | No | JSON filter array |
|
||||
| `sort` | array | No | JSON sort array |
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Task lists retrieved.",
|
||||
"data": [...],
|
||||
"pagination": { "page": 1, "limit": 10, "total": 5, "totalPages": 1 }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Get Single Task List
|
||||
|
||||
**`GET /api/admin/task-lists/:taskListId`**
|
||||
|
||||
Returns a task list with its full task tree (tasks → requirements) and assigned groups. Includes archived items (`paranoid: false`).
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Task list retrieved.",
|
||||
"data": {
|
||||
"task_list_id": "uuid-...",
|
||||
"name": "Onboarding Q1",
|
||||
"description": "...",
|
||||
"group_count": 2,
|
||||
"groups": [
|
||||
{ "group_id": 1, "name": "Engineering" }
|
||||
],
|
||||
"tasks": [
|
||||
{
|
||||
"task_id": "uuid-...",
|
||||
"name": "Read the handbook",
|
||||
"deadline": "2026-07-01T00:00:00.000Z",
|
||||
"status": "pending",
|
||||
"requirements": [
|
||||
{ "requirement_id": "uuid-...", "type": "read_lesson", "reference_id": "uuid-..." }
|
||||
]
|
||||
}
|
||||
],
|
||||
"createdAt": "...",
|
||||
"updatedAt": "..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Create Task List
|
||||
|
||||
**`POST /api/admin/task-lists`**
|
||||
|
||||
Rate-limited.
|
||||
|
||||
### Request Body
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `name` | string | **Yes** | Unique list name |
|
||||
| `description` | string | No | |
|
||||
|
||||
### Response `201`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Task list created successfully.",
|
||||
"data": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Update Task List
|
||||
|
||||
**`PATCH /api/admin/task-lists/:taskListId`**
|
||||
|
||||
Rate-limited.
|
||||
|
||||
### Request Body
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `name` | string | No | |
|
||||
| `description` | string | No | |
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{ "status": "success", "message": "Task list updated successfully.", "data": { ... } }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Archive Task List
|
||||
|
||||
**`DELETE /api/admin/task-lists/:taskListId`**
|
||||
|
||||
Rate-limited. Soft-deletes the task list.
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{ "status": "success", "message": "Task list archived successfully." }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Restore Task List
|
||||
|
||||
**`PATCH /api/admin/task-lists/:taskListId/restore`**
|
||||
|
||||
Rate-limited.
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{ "status": "success", "message": "Task list restored successfully.", "data": { ... } }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Bulk Archive Task Lists
|
||||
|
||||
**`POST /api/admin/task-lists/bulk-archive`**
|
||||
|
||||
Rate-limited.
|
||||
|
||||
### Request Body
|
||||
```json
|
||||
{ "ids": ["uuid-1", "uuid-2"] }
|
||||
```
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "2 task list(s) archived successfully.",
|
||||
"archived_ids": [...],
|
||||
"skipped_ids": []
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Bulk Restore Task Lists
|
||||
|
||||
**`POST /api/admin/task-lists/bulk-restore`**
|
||||
|
||||
Rate-limited. Same body shape as bulk archive.
|
||||
|
||||
---
|
||||
|
||||
### Get Archived Task Lists
|
||||
|
||||
**`GET /api/admin/task-lists/archived`**
|
||||
|
||||
Returns paginated soft-deleted task lists.
|
||||
|
||||
---
|
||||
|
||||
### Get Task List Field Values
|
||||
|
||||
**`GET /api/admin/task-lists/field-values`**
|
||||
|
||||
Returns distinct filterable field values for task lists.
|
||||
|
||||
---
|
||||
|
||||
## Task List Groups
|
||||
|
||||
### Get Assigned Groups
|
||||
|
||||
**`GET /api/admin/task-lists/:taskListId/groups`**
|
||||
|
||||
Returns all user groups currently assigned to the task list, including assignment metadata.
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Task list groups retrieved.",
|
||||
"data": [
|
||||
{
|
||||
"id": "uuid-...",
|
||||
"task_list_id": "uuid-...",
|
||||
"group_id": 1,
|
||||
"assignedAt": "2026-06-01T00:00:00.000Z",
|
||||
"assignedBy": 1,
|
||||
"group": { "group_id": 1, "name": "Engineering" }
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Assign Groups
|
||||
|
||||
**`POST /api/admin/task-lists/:taskListId/groups/assign`**
|
||||
|
||||
Rate-limited. Upsert-style — already-assigned groups are silently skipped.
|
||||
|
||||
### Request Body
|
||||
```json
|
||||
{ "group_ids": [1, 2, 3] }
|
||||
```
|
||||
|
||||
### Response `201`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "2 group(s) assigned.",
|
||||
"assigned_ids": [2, 3],
|
||||
"already_assigned_ids": [1],
|
||||
"invalid_ids": []
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Unassign Groups
|
||||
|
||||
**`POST /api/admin/task-lists/:taskListId/groups/unassign`**
|
||||
|
||||
Rate-limited. Hard-deletes the junction rows (assignments are not soft-deleted).
|
||||
|
||||
### Request Body
|
||||
```json
|
||||
{ "group_ids": [2, 3] }
|
||||
```
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "2 group(s) unassigned.",
|
||||
"unassigned_ids": [2, 3],
|
||||
"skipped_ids": []
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tasks
|
||||
|
||||
### Get All Tasks
|
||||
|
||||
**`GET /api/admin/task-lists/:taskListId/tasks`**
|
||||
|
||||
Returns paginated tasks under a task list.
|
||||
|
||||
### Query Parameters
|
||||
Standard pagination + `filters` + `sort`.
|
||||
|
||||
---
|
||||
|
||||
### Get Single Task
|
||||
|
||||
**`GET /api/admin/task-lists/:taskListId/tasks/:taskId`**
|
||||
|
||||
Returns a task with its requirements and the parent task list (including assigned groups).
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Task retrieved.",
|
||||
"data": {
|
||||
"task_id": "uuid-...",
|
||||
"task_list_id": "uuid-...",
|
||||
"name": "Complete orientation",
|
||||
"description": "...",
|
||||
"deadline": "2026-07-01T00:00:00.000Z",
|
||||
"status": "pending",
|
||||
"requirements": [
|
||||
{
|
||||
"requirement_id": "uuid-...",
|
||||
"type": "upload_file",
|
||||
"allowed_file_types": ["application/pdf"],
|
||||
"max_file_count": 1,
|
||||
"order": 0
|
||||
}
|
||||
],
|
||||
"taskList": { "task_list_id": "...", "name": "Onboarding Q1", "groups": [...] }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Create Task
|
||||
|
||||
**`POST /api/admin/task-lists/:taskListId/tasks`**
|
||||
|
||||
Rate-limited. Creates a task and optionally its requirements in a single transaction.
|
||||
|
||||
### Request Body
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `name` | string | **Yes** | Task name |
|
||||
| `description` | string | No | |
|
||||
| `deadline` | date | No | ISO date string |
|
||||
| `requirements` | array | No | Array of requirement objects (see Requirement Types above) |
|
||||
|
||||
### Response `201`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Task created successfully.",
|
||||
"data": { "task_id": "uuid-...", "name": "...", "requirements": [...] }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Update Task
|
||||
|
||||
**`PATCH /api/admin/task-lists/:taskListId/tasks/:taskId`**
|
||||
|
||||
Rate-limited. When `requirements` is provided, the full set is replaced (soft-delete + re-insert).
|
||||
|
||||
> **Note:** Incoming requirement objects must **not** include `requirement_id` — the server always generates fresh IDs to avoid collisions with soft-deleted rows.
|
||||
|
||||
### Request Body
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `name` | string | No | |
|
||||
| `description` | string | No | |
|
||||
| `deadline` | date | No | |
|
||||
| `status` | string | No | `pending`, `in_progress`, `completed`, `overdue` |
|
||||
| `requirements` | array | No | Full replacement set |
|
||||
|
||||
---
|
||||
|
||||
### Archive Task
|
||||
|
||||
**`DELETE /api/admin/task-lists/:taskListId/tasks/:taskId`**
|
||||
|
||||
Rate-limited. Soft-deletes the task.
|
||||
|
||||
---
|
||||
|
||||
### Restore Task
|
||||
|
||||
**`PATCH /api/admin/task-lists/:taskListId/tasks/:taskId/restore`**
|
||||
|
||||
Rate-limited.
|
||||
|
||||
---
|
||||
|
||||
### Bulk Archive Tasks
|
||||
|
||||
**`POST /api/admin/task-lists/:taskListId/tasks/bulk-archive`**
|
||||
|
||||
Rate-limited.
|
||||
|
||||
### Request Body
|
||||
```json
|
||||
{ "ids": ["uuid-1", "uuid-2"] }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Bulk Restore Tasks
|
||||
|
||||
**`POST /api/admin/task-lists/:taskListId/tasks/bulk-restore`**
|
||||
|
||||
Rate-limited.
|
||||
|
||||
---
|
||||
|
||||
### Get Archived Tasks
|
||||
|
||||
**`GET /api/admin/task-lists/:taskListId/tasks/archived`**
|
||||
|
||||
---
|
||||
|
||||
### Get Task Field Values
|
||||
|
||||
**`GET /api/admin/task-lists/:taskListId/tasks/field-values`**
|
||||
|
||||
---
|
||||
|
||||
## Completions
|
||||
|
||||
Completions are created by clients only. Admins can view and archive/restore them.
|
||||
|
||||
Each completion may have multiple attached files (`task_completion_files`).
|
||||
|
||||
---
|
||||
|
||||
### Get All Completions
|
||||
|
||||
**`GET /api/admin/task-lists/:taskListId/tasks/:taskId/completions`**
|
||||
|
||||
Returns paginated completions for a task, with submitting user info and files.
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Completions retrieved.",
|
||||
"data": [
|
||||
{
|
||||
"completion_id": "uuid-...",
|
||||
"task_id": "uuid-...",
|
||||
"user_id": 42,
|
||||
"note": "Attached the signed form.",
|
||||
"submitted_at": "2026-06-15T10:00:00.000Z",
|
||||
"user": { "user_id": 42, "email": "user@example.com", "name": "Jane Doe" },
|
||||
"files": [
|
||||
{
|
||||
"file_id": "uuid-...",
|
||||
"file_url": "https://...",
|
||||
"file_name": "signed_form.pdf",
|
||||
"file_size": 204800,
|
||||
"mime_type": "application/pdf"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"pagination": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Get Single Completion
|
||||
|
||||
**`GET /api/admin/task-lists/:taskListId/tasks/:taskId/completions/:completionId`**
|
||||
|
||||
Returns a single completion with user and files.
|
||||
|
||||
---
|
||||
|
||||
### Get Completions by User
|
||||
|
||||
**`GET /api/admin/task-lists/:taskListId/tasks/:taskId/completions/user/:userId`**
|
||||
|
||||
Returns all completions for a specific user on a specific task.
|
||||
|
||||
---
|
||||
|
||||
### Archive Completion
|
||||
|
||||
**`DELETE /api/admin/task-lists/:taskListId/tasks/:taskId/completions/:completionId`**
|
||||
|
||||
Rate-limited. Soft-deletes a completion.
|
||||
|
||||
---
|
||||
|
||||
### Restore Completion
|
||||
|
||||
**`PATCH /api/admin/task-lists/:taskListId/tasks/:taskId/completions/:completionId/restore`**
|
||||
|
||||
Rate-limited.
|
||||
|
||||
---
|
||||
|
||||
### Bulk Archive Completions
|
||||
|
||||
**`POST /api/admin/task-lists/:taskListId/tasks/:taskId/completions/bulk-archive`**
|
||||
|
||||
Rate-limited.
|
||||
|
||||
### Request Body
|
||||
```json
|
||||
{ "ids": ["uuid-1", "uuid-2"] }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Bulk Restore Completions
|
||||
|
||||
**`POST /api/admin/task-lists/:taskListId/tasks/:taskId/completions/bulk-restore`**
|
||||
|
||||
Rate-limited.
|
||||
@@ -0,0 +1,406 @@
|
||||
# Tiers Controller Documentation
|
||||
|
||||
**File:** `controllers/admin/tiers.controller.js`
|
||||
**Base URL:** `/api/admin/tiers`
|
||||
**Guards:** `authenticate → requireAdmin() → adminLimiter`
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
- [Tier Plans](#tier-plans)
|
||||
- [Get All Plans](#get-all-plans)
|
||||
- [Get Single Plan](#get-single-plan)
|
||||
- [Create Plan](#create-plan)
|
||||
- [Update Plan](#update-plan)
|
||||
- [Archive Plan](#archive-plan)
|
||||
- [Bulk Archive Plans](#bulk-archive-plans)
|
||||
- [Restore Plan](#restore-plan)
|
||||
- [Bulk Restore Plans](#bulk-restore-plans)
|
||||
- [Get Plan Field Values](#get-plan-field-values)
|
||||
- [Plan Courses](#plan-courses)
|
||||
- [Get Plan Courses](#get-plan-courses)
|
||||
- [Sync Plan Courses](#sync-plan-courses)
|
||||
- [User Tiers](#user-tiers)
|
||||
- [Get User Tiers](#get-user-tiers)
|
||||
- [Grant Tier](#grant-tier)
|
||||
- [Revoke Tier](#revoke-tier)
|
||||
- [Payments](#payments)
|
||||
- [Get All Payments](#get-all-payments)
|
||||
- [Get Single Payment](#get-single-payment)
|
||||
- [Get Payment Field Values](#get-payment-field-values)
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- Pending payments older than **60 minutes** are automatically expired before any payment list/detail call.
|
||||
- Revoking a tier immediately creates a new `free` tier row for the user (auto-downgrade).
|
||||
- `plan_id` is serialized as a string in create responses to avoid BigInt overflow in JS.
|
||||
- A plan's `tier` field cannot be updated — only `label`, `duration_days`, `price`, `currency`, and `is_active`.
|
||||
|
||||
---
|
||||
|
||||
## Tier Plans
|
||||
|
||||
### Get All Plans
|
||||
|
||||
**`GET /api/admin/tiers`**
|
||||
|
||||
Returns paginated plans. Pass `?archived=true` to see soft-deleted plans instead.
|
||||
|
||||
### Query Parameters
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|---------|----------|-------------|
|
||||
| `archived` | boolean | No | `true` to show archived plans only |
|
||||
| `page` | number | No | Default: `1` |
|
||||
| `limit` | number | No | Default: `10` |
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Plans retrieved.",
|
||||
"data": [...],
|
||||
"pagination": { "page": 1, "limit": 10, "total": 4, "totalPages": 1 }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Get Single Plan
|
||||
|
||||
**`GET /api/admin/tiers/:id`**
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Plan retrieved.",
|
||||
"data": {
|
||||
"plan_id": 1,
|
||||
"tier": "premium",
|
||||
"label": "Premium Monthly",
|
||||
"duration_days": 30,
|
||||
"price": "9.99",
|
||||
"currency": "USD",
|
||||
"is_active": true,
|
||||
"createdAt": "...",
|
||||
"updatedAt": "..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Create Plan
|
||||
|
||||
**`POST /api/admin/tiers`**
|
||||
|
||||
### Request Body
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `tier` | string | **Yes** | `premium` or `exclusive` |
|
||||
| `label` | string | **Yes** | Human-readable plan name |
|
||||
| `duration_days` | number | **Yes** | Access duration in days |
|
||||
| `price` | number | **Yes** | Plan price (decimal) |
|
||||
| `currency` | string | No | ISO 4217. Default: `USD` |
|
||||
|
||||
### Response `201`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Plan created.",
|
||||
"data": { "plan_id": "5", "tier": "premium", ... }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Update Plan
|
||||
|
||||
**`PUT /api/admin/tiers/:id`**
|
||||
|
||||
Only `label`, `duration_days`, `price`, `currency`, and `is_active` are updatable.
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{ "status": "success", "message": "Plan updated.", "data": { ... } }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Archive Plan
|
||||
|
||||
**`DELETE /api/admin/tiers/:id`**
|
||||
|
||||
Sets `is_active = false` then soft-deletes.
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{ "status": "success", "message": "Plan archived successfully." }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Bulk Archive Plans
|
||||
|
||||
**`POST /api/admin/tiers/bulk/archive`**
|
||||
|
||||
### Request Body
|
||||
```json
|
||||
{ "ids": [1, 2] }
|
||||
```
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "2 plan(s) archived successfully.",
|
||||
"archived_ids": [1, 2],
|
||||
"skipped_ids": []
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Restore Plan
|
||||
|
||||
**`POST /api/admin/tiers/:id/restore`**
|
||||
|
||||
Restores plan and sets `is_active = true`.
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{ "status": "success", "message": "Plan restored successfully." }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Bulk Restore Plans
|
||||
|
||||
**`POST /api/admin/tiers/bulk/restore`**
|
||||
|
||||
### Request Body
|
||||
```json
|
||||
{ "ids": [1, 2] }
|
||||
```
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "2 plan(s) restored successfully.",
|
||||
"restored_ids": [1, 2],
|
||||
"skipped_ids": []
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Get Plan Field Values
|
||||
|
||||
**`GET /api/admin/tiers/field-values`**
|
||||
|
||||
Returns distinct filterable field values for tier plans.
|
||||
|
||||
---
|
||||
|
||||
## Plan Courses
|
||||
|
||||
### Get Plan Courses
|
||||
|
||||
**`GET /api/admin/tiers/:id/courses`**
|
||||
|
||||
Returns the list of courses linked to this plan.
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Plan courses retrieved.",
|
||||
"data": [
|
||||
{ "course_id": 1, "title": "Intro to Python", "course_code": "PY-101", "subscription": "premium", "level": "beginner" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Sync Plan Courses
|
||||
|
||||
**`POST /api/admin/tiers/:id/courses`**
|
||||
|
||||
Replaces the full set of courses for this plan. Removes all existing assignments first, then inserts the new set. A course can only belong to one plan at a time — existing assignments to other plans are cleared automatically.
|
||||
|
||||
### Request Body
|
||||
```json
|
||||
{ "course_ids": [1, 2, 3] }
|
||||
```
|
||||
|
||||
Pass `[]` to remove all courses from the plan.
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{ "status": "success", "message": "Plan courses updated." }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## User Tiers
|
||||
|
||||
### Get User Tiers
|
||||
|
||||
**`GET /api/admin/tiers/users/:id/tiers`**
|
||||
|
||||
Returns the full tier history for a user, newest first. Includes `grantedByUser` and `revokedByUser` info.
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "User tiers retrieved.",
|
||||
"data": [
|
||||
{
|
||||
"tier_id": 3,
|
||||
"user_id": 42,
|
||||
"tier": "premium",
|
||||
"status": "active",
|
||||
"starts_at": "2026-06-01T00:00:00.000Z",
|
||||
"expires_at": "2026-07-01T00:00:00.000Z",
|
||||
"granted_by": 1,
|
||||
"revoked_by": null,
|
||||
"revoked_at": null,
|
||||
"notes": "Trial promotion",
|
||||
"grantedByUser": { "user_id": 1, "email": "admin@example.com" },
|
||||
"revokedByUser": null
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Grant Tier
|
||||
|
||||
**`POST /api/admin/tiers/users/tiers/grant`**
|
||||
|
||||
- Expires all currently active tiers for the user before creating the new one.
|
||||
- `expires_at` is calculated as `now + plan.duration_days`.
|
||||
|
||||
### Request Body
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `user_id` | number | **Yes** | Target user |
|
||||
| `tier` | string | **Yes** | `premium` or `exclusive` |
|
||||
| `plan_id` | number | **Yes** | Must match the plan's tier |
|
||||
| `notes` | string | No | Optional admin note |
|
||||
|
||||
### Response `201`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Tier granted.",
|
||||
"data": { "tier_id": 4, "user_id": 42, "tier": "premium", "status": "active", ... }
|
||||
}
|
||||
```
|
||||
|
||||
### Error Responses
|
||||
| Status | Message |
|
||||
|--------|---------|
|
||||
| `400` | `user_id, tier, and plan_id are required.` |
|
||||
| `400` | `Plan tier mismatch.` |
|
||||
| `404` | `User not found.` |
|
||||
| `404` | `Plan not found or inactive.` |
|
||||
|
||||
---
|
||||
|
||||
### Revoke Tier
|
||||
|
||||
**`PATCH /api/admin/tiers/users/tiers/:tid/revoke`**
|
||||
|
||||
- Sets the tier's status to `revoked` and records `revoked_by` / `revoked_at`.
|
||||
- Automatically creates a new `free` tier row for the user (auto-downgrade note: `"Auto-downgrade after revoke."`).
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{ "status": "success", "message": "Tier revoked. User downgraded to free." }
|
||||
```
|
||||
|
||||
### Error Responses
|
||||
| Status | Message |
|
||||
|--------|---------|
|
||||
| `400` | `Tier is not active.` |
|
||||
| `404` | `Tier record not found.` |
|
||||
|
||||
---
|
||||
|
||||
## Payments
|
||||
|
||||
### Get All Payments
|
||||
|
||||
**`GET /api/admin/tiers/payments`**
|
||||
|
||||
Returns paginated payment records. Stale pending payments (>60 min old) are expired before the query runs.
|
||||
|
||||
### Query Parameters
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|--------|----------|-------------|
|
||||
| `page` | number | No | Default: `1` |
|
||||
| `limit` | number | No | Default: `10` |
|
||||
| `filters` | array | No | JSON filter array |
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Payments retrieved.",
|
||||
"data": [
|
||||
{
|
||||
"payment_id": 1,
|
||||
"user_id": 42,
|
||||
"plan_id": 1,
|
||||
"status": "completed",
|
||||
"amount": "9.99",
|
||||
"currency": "USD",
|
||||
"promo_code": null,
|
||||
"discount": "0.00",
|
||||
"provider": "paypal",
|
||||
"paid_at": "2026-06-01T10:00:00.000Z",
|
||||
"user": { "user_id": 42, "email": "user@example.com" },
|
||||
"plan": { "plan_id": 1, "label": "Premium Monthly", "tier": "premium", "duration_days": 30 }
|
||||
}
|
||||
],
|
||||
"pagination": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Get Single Payment
|
||||
|
||||
**`GET /api/admin/tiers/payments/:id`**
|
||||
|
||||
Returns full payment detail including `user`, `plan`, and `tier` associations.
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Payment retrieved.",
|
||||
"data": {
|
||||
"payment_id": 1,
|
||||
"provider_payload": { "order_id": "...", "capture_id": "...", ... },
|
||||
"user": { ... },
|
||||
"plan": { ... },
|
||||
"tier": { ... }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Get Payment Field Values
|
||||
|
||||
**`GET /api/admin/tiers/payments/field-values`**
|
||||
|
||||
Returns distinct filterable field values for payments. `provider_payload` is excluded.
|
||||
@@ -0,0 +1,129 @@
|
||||
# User Activity Controller Documentation
|
||||
|
||||
**File:** `controllers/admin/user_activity.controller.js`
|
||||
**Base URL:** `/api/admin/activity` and `/api/admin/users/:id/activity`
|
||||
**Guards:** `authenticate → requireAdmin() → adminLimiter`
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
- [Get Global Activity Feed](#get-global-activity-feed)
|
||||
- [Get Per-User Activity](#get-per-user-activity)
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- `user_activity` rows are **never soft-deleted** — this is an append-only audit log.
|
||||
- `created_at` is the timestamp column (no `createdAt` alias — Sequelize `timestamps: false`).
|
||||
- The global feed endpoint joins the `users` table and enriches each row with `full_name` and `avatar_url`.
|
||||
- The per-user endpoint skips the join for performance since user context is already known.
|
||||
|
||||
---
|
||||
|
||||
## Activity Object Shape
|
||||
|
||||
```json
|
||||
{
|
||||
"activity_id": 1,
|
||||
"user_id": 42,
|
||||
"email": "user@example.com",
|
||||
"full_name": "Kenneth Obsequio",
|
||||
"avatar_url": "https://...",
|
||||
"acc_type": "admin",
|
||||
"action": "login",
|
||||
"entity_type": "session",
|
||||
"entity_id": 7,
|
||||
"details": { "session_id": 7, "reg_type": "system" },
|
||||
"created_at": "2026-06-21T08:00:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
### Common `action` Values
|
||||
| Action | Entity Type | Details Keys |
|
||||
|--------|-------------|--------------|
|
||||
| `login` | `session` | `session_id`, `reg_type` |
|
||||
| `deactivate_user` | `user` | `target_email` |
|
||||
| `lesson_read` | `lesson` | `lesson_uuid`, `status` |
|
||||
| `submit_task` | `task` | `task_id` |
|
||||
| `set_user_status` | `user` | `is_active` |
|
||||
| `create_course` | `course` | `title` |
|
||||
| `update_course` | `course` | `title` |
|
||||
| `archive_course` | `course` | — |
|
||||
| `create_task_list` | `task_list` | `name` |
|
||||
| `create_advertisement` | `advertisement` | `type` |
|
||||
| `grant_tier` | `tier` | `user_id`, `tier`, `plan_id` |
|
||||
| `revoke_tier` | `tier` | `user_id`, `tier` |
|
||||
|
||||
---
|
||||
|
||||
## Get Global Activity Feed
|
||||
|
||||
**`GET /api/admin/activity`**
|
||||
|
||||
Returns a paginated, reverse-chronological feed of all user activity across the system.
|
||||
|
||||
### Query Parameters
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|--------|----------|-------------|
|
||||
| `page` | number | No | Default: `1` |
|
||||
| `limit` | number | No | Default: `20`, max: `100` |
|
||||
| `action` | string | No | Filter by exact action string (e.g. `login`) |
|
||||
| `from` | date | No | ISO date — `created_at >= from` |
|
||||
| `to` | date | No | ISO date — `created_at <= to` |
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Activity feed retrieved.",
|
||||
"data": {
|
||||
"total": 500,
|
||||
"page": 1,
|
||||
"totalPages": 25,
|
||||
"activities": [ ... ]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Get Per-User Activity
|
||||
|
||||
**`GET /api/admin/users/:id/activity`**
|
||||
|
||||
Returns a paginated, reverse-chronological activity log for a single user.
|
||||
|
||||
### Query Parameters
|
||||
Same as global feed (`page`, `limit`, `action`, `from`, `to`).
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "User activity retrieved.",
|
||||
"data": {
|
||||
"total": 45,
|
||||
"page": 1,
|
||||
"totalPages": 3,
|
||||
"activities": [
|
||||
{
|
||||
"activity_id": 1,
|
||||
"user_id": 42,
|
||||
"session_id": null,
|
||||
"action": "update_course",
|
||||
"entity_type": "course",
|
||||
"entity_id": 3,
|
||||
"details": { "title": "Advanced JS" },
|
||||
"created_at": "2026-06-21T09:00:00.000Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Error Responses
|
||||
| Status | Message |
|
||||
|--------|---------|
|
||||
| `400` | `Invalid User ID.` |
|
||||
| `404` | `User not found.` |
|
||||
@@ -0,0 +1,491 @@
|
||||
# User Groups Controller Documentation
|
||||
|
||||
**File:** `controllers/admin/user_groups.controller.js`
|
||||
**Base URL:** `/api/admin/groups`
|
||||
**Guards:** `authenticate → requireAdmin() → adminLimiter`
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
- [Get All Groups](#get-all-groups)
|
||||
- [Get Single Group](#get-single-group)
|
||||
- [Create Group](#create-group)
|
||||
- [Update Group](#update-group)
|
||||
- [Deactivate Group](#deactivate-group)
|
||||
- [Bulk Deactivate Groups](#bulk-deactivate-groups)
|
||||
- [Restore Group](#restore-group)
|
||||
- [Bulk Restore Groups](#bulk-restore-groups)
|
||||
- [Get Archived Groups](#get-archived-groups)
|
||||
- [Get Group Field Values](#get-group-field-values)
|
||||
- [Get Users In Group](#get-users-in-group)
|
||||
- [Get Users Not In Group](#get-users-not-in-group)
|
||||
- [Add Users To Group](#add-users-to-group)
|
||||
- [Remove Users From Group](#remove-users-from-group)
|
||||
|
||||
---
|
||||
|
||||
## Get All Groups
|
||||
|
||||
**`GET /api/admin/groups`**
|
||||
|
||||
Returns a paginated list of active groups.
|
||||
|
||||
### Query Parameters
|
||||
| Parameter | Type | Required | Description |
|
||||
|----------|--------|----------|--------------------------------------------------|
|
||||
| page | number | No | Page number. Default: `1` |
|
||||
| limit | number | No | Records per page. Default: `20` |
|
||||
| search | string | No | Search across group fields |
|
||||
| sort_by | string | No | Column to sort by. Default: `createdAt` |
|
||||
| sort_dir | string | No | Sort direction: `ASC` or `DESC`. Default: `DESC` |
|
||||
| filters | array | No | Column filters from DataTable |
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Groups retrieved.",
|
||||
"data": {
|
||||
"rows": [
|
||||
{
|
||||
"group_id": 1,
|
||||
"name": "Administrators",
|
||||
"description": "Full access group.",
|
||||
"is_active": true,
|
||||
"member_count": 5,
|
||||
"createdBy": 1,
|
||||
"updatedBy": null,
|
||||
"deletedBy": null,
|
||||
"createdAt": "2025-01-01T00:00:00.000Z",
|
||||
"updatedAt": "2025-01-01T00:00:00.000Z",
|
||||
"deletedAt": null
|
||||
}
|
||||
],
|
||||
"pagination": {
|
||||
"total": 10,
|
||||
"page": 1,
|
||||
"limit": 20,
|
||||
"totalPages": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Get Single Group
|
||||
|
||||
**`GET /api/admin/groups/:gid`**
|
||||
|
||||
Returns a single group with a paginated list of its members.
|
||||
|
||||
### Path Parameters
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|--------|----------|-------------|
|
||||
| gid | number | Yes | Group ID |
|
||||
|
||||
### Query Parameters
|
||||
Same pagination/filter params as [Get All Groups](#get-all-groups) — applied to the members list.
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Group retrieved.",
|
||||
"data": {
|
||||
"group": {
|
||||
"group_id": 1,
|
||||
"name": "Administrators",
|
||||
"description": "Full access group.",
|
||||
"is_active": true,
|
||||
"createdAt": "2025-01-01T00:00:00.000Z",
|
||||
"updatedAt": "2025-01-01T00:00:00.000Z"
|
||||
},
|
||||
"members": {
|
||||
"rows": [ ...users ],
|
||||
"pagination": { ... }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Response `404`
|
||||
```json
|
||||
{
|
||||
"status": "error",
|
||||
"message": "Group not found."
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Create Group
|
||||
|
||||
**`POST /api/admin/groups`**
|
||||
|
||||
Creates a new user group.
|
||||
|
||||
### Request Body `application/json`
|
||||
| Field | Type | Required | Description |
|
||||
|------------|--------|----------|--------------------|
|
||||
| name | string | Yes | Group name |
|
||||
| description | string | No | Group description |
|
||||
|
||||
### Response `201`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Group created.",
|
||||
"data": {
|
||||
"group_id": 1,
|
||||
"name": "Administrators",
|
||||
"description": "Full access group.",
|
||||
"is_active": true,
|
||||
"createdBy": 1,
|
||||
"createdAt": "2025-01-01T00:00:00.000Z",
|
||||
"updatedAt": "2025-01-01T00:00:00.000Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Response `400`
|
||||
```json
|
||||
{
|
||||
"status": "error",
|
||||
"message": "Group name is required."
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Update Group
|
||||
|
||||
**`PUT /api/admin/groups/:gid`**
|
||||
|
||||
Updates a group's name or description.
|
||||
|
||||
### Path Parameters
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|--------|----------|-------------|
|
||||
| gid | number | Yes | Group ID |
|
||||
|
||||
### Request Body `application/json`
|
||||
| Field | Type | Required | Description |
|
||||
|------------|--------|----------|------------------------|
|
||||
| name | string | No | Updated group name |
|
||||
| description | string | No | Updated description |
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Group updated.",
|
||||
"data": { ...group }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Deactivate Group
|
||||
|
||||
**`PATCH /api/admin/groups/:gid/deactivate`**
|
||||
|
||||
Soft deletes a group by setting `deletedAt` and `is_active: false`.
|
||||
|
||||
### Path Parameters
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|--------|----------|-------------|
|
||||
| gid | number | Yes | Group ID |
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Group deactivated."
|
||||
}
|
||||
```
|
||||
|
||||
### Response `400`
|
||||
```json
|
||||
{
|
||||
"status": "error",
|
||||
"message": "Group is already deactivated."
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Bulk Deactivate Groups
|
||||
|
||||
**`DELETE /api/admin/groups/bulk`**
|
||||
|
||||
Soft deletes multiple groups at once.
|
||||
Already-deactivated groups are skipped and reported.
|
||||
|
||||
### Request Body `application/json`
|
||||
| Field | Type | Required | Description |
|
||||
|-------|----------|----------|---------------------------|
|
||||
| ids | number[] | Yes | Array of group IDs |
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "3 group(s) deactivated successfully.",
|
||||
"data": {
|
||||
"deactivated_ids": [1, 2, 3],
|
||||
"skipped_ids": [4]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Restore Group
|
||||
|
||||
**`PATCH /api/admin/groups/:gid/restore`**
|
||||
|
||||
Restores a soft-deleted group by clearing `deletedAt` and setting `is_active: true`.
|
||||
|
||||
### Path Parameters
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|--------|----------|-------------|
|
||||
| gid | number | Yes | Group ID |
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Group restored."
|
||||
}
|
||||
```
|
||||
|
||||
### Response `400`
|
||||
```json
|
||||
{
|
||||
"status": "error",
|
||||
"message": "Group is already active."
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Bulk Restore Groups
|
||||
|
||||
**`POST /api/admin/groups/bulk/restore`**
|
||||
|
||||
Restores multiple soft-deleted groups at once.
|
||||
Already-active groups are skipped and reported.
|
||||
|
||||
### Request Body `application/json`
|
||||
| Field | Type | Required | Description |
|
||||
|-------|----------|----------|---------------------------|
|
||||
| ids | number[] | Yes | Array of group IDs |
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "3 group(s) restored successfully.",
|
||||
"data": {
|
||||
"restored_ids": [1, 2, 3],
|
||||
"skipped_ids": [4]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Get Archived Groups
|
||||
|
||||
**`GET /api/admin/groups/archived`**
|
||||
|
||||
Returns a paginated list of soft-deleted groups.
|
||||
|
||||
### Query Parameters
|
||||
Same as [Get All Groups](#get-all-groups).
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Archived groups retrieved.",
|
||||
"data": {
|
||||
"rows": [ ...soft-deleted groups ],
|
||||
"pagination": { ... }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Get Group Field Values
|
||||
|
||||
**`GET /api/admin/groups/field-values`**
|
||||
|
||||
Returns distinct values for a given column — used to populate filter dropdowns in the DataTable.
|
||||
Supports regular columns, date fields, and audit fields.
|
||||
JSONB fields are not supported for groups.
|
||||
|
||||
### Query Parameters
|
||||
| Parameter | Type | Required | Description |
|
||||
|----------|--------|----------|--------------------|
|
||||
| field | string | Yes | Column name |
|
||||
|
||||
### Supported Field Types
|
||||
| Type | Example | Returns |
|
||||
|----------|-------------|--------------------------------|
|
||||
| Regular | `is_active` | Distinct values |
|
||||
| Date | `createdAt` | Distinct dates (no time) |
|
||||
| Audit by | `createdBy` | Full names of referenced users |
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Field values retrieved.",
|
||||
"data": ["true", "false"]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Get Users In Group
|
||||
|
||||
**`GET /api/admin/groups/:gid/users`**
|
||||
|
||||
Returns all current members of a group with their `user_id` and `full_name`.
|
||||
|
||||
### Path Parameters
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|--------|----------|-------------|
|
||||
| gid | number | Yes | Group ID |
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Group members fetched.",
|
||||
"data": [
|
||||
{ "user_id": 1, "full_name": "John Doe" },
|
||||
{ "user_id": 2, "full_name": "Jane Smith" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Get Users Not In Group
|
||||
|
||||
**`GET /api/admin/groups/:gid/users/add`**
|
||||
|
||||
Returns all users who are **not** currently members of the group.
|
||||
Used to populate the Add Members sheet.
|
||||
|
||||
### Path Parameters
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|--------|----------|-------------|
|
||||
| gid | number | Yes | Group ID |
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Users fetched.",
|
||||
"data": [
|
||||
{ "user_id": 3, "full_name": "Alice Johnson" },
|
||||
{ "user_id": 4, "full_name": "Bob Williams" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Add Users To Group
|
||||
|
||||
**`POST /api/admin/groups/:gid/users`**
|
||||
|
||||
Adds one or more users to a group.
|
||||
If a user was previously removed (soft-deleted membership), their membership is restored instead of duplicated.
|
||||
|
||||
### Path Parameters
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|--------|----------|-------------|
|
||||
| gid | number | Yes | Group ID |
|
||||
|
||||
### Request Body `application/json`
|
||||
| Field | Type | Required | Description |
|
||||
|---------|----------|----------|------------------------------|
|
||||
| user_ids | number[] | Yes | Array of user IDs to add |
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Users added to group."
|
||||
}
|
||||
```
|
||||
|
||||
### Response `404`
|
||||
```json
|
||||
{
|
||||
"status": "error",
|
||||
"message": "Users not found: 5, 6"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Remove Users From Group
|
||||
|
||||
**`DELETE /api/admin/groups/:gid/users`**
|
||||
|
||||
Removes one or more users from a group via soft delete on the membership record.
|
||||
|
||||
### Path Parameters
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|--------|----------|-------------|
|
||||
| gid | number | Yes | Group ID |
|
||||
|
||||
### Request Body `application/json`
|
||||
| Field | Type | Required | Description |
|
||||
|---------|----------|---------|---------------------------------|
|
||||
| user_ids | number[] | Yes | Array of user IDs to remove |
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Users removed from group."
|
||||
}
|
||||
```
|
||||
|
||||
### Response `404`
|
||||
```json
|
||||
{
|
||||
"status": "error",
|
||||
"message": "Memberships not found for users: 5, 6"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Responses
|
||||
|
||||
All endpoints return the following on server error:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "error",
|
||||
"message": "Internal server error."
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- **Soft delete** — deactivation sets `deletedAt` + `is_active: false`. Groups are excluded from all queries unless explicitly queried with `paranoid: false`.
|
||||
- **Membership soft delete** — removing a user from a group soft-deletes the membership record. Re-adding the user restores the record rather than creating a duplicate.
|
||||
- **Audit fields** — `createdBy`, `updatedBy`, `deletedBy` store the `user_id` of the admin who performed the action.
|
||||
- **JSONB** — group fields do not support JSONB dot-notation filtering unlike users.
|
||||
@@ -0,0 +1,463 @@
|
||||
# Users Controller Documentation
|
||||
|
||||
**File:** `controllers/admin/users.controller.js`
|
||||
**Base URL:** `/api/admin/users`
|
||||
**Guards:** `authenticate → requireAdmin() → adminLimiter`
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
- [Get All Users](#get-all-users)
|
||||
- [Get Single User](#get-single-user)
|
||||
- [Add Staff User](#add-staff-user)
|
||||
- [Update User](#update-user)
|
||||
- [Deactivate User](#deactivate-user)
|
||||
- [Bulk Deactivate Users](#bulk-deactivate-users)
|
||||
- [Restore User](#restore-user)
|
||||
- [Bulk Restore Users](#bulk-restore-users)
|
||||
- [Get Archived Users](#get-archived-users)
|
||||
- [Get User Field Values](#get-user-field-values)
|
||||
- [Get User Sessions](#get-user-sessions)
|
||||
- [Terminate Session](#terminate-session)
|
||||
|
||||
---
|
||||
|
||||
## Get All Users
|
||||
|
||||
**`GET /api/admin/users`**
|
||||
|
||||
Returns a paginated list of active users.
|
||||
|
||||
### Query Parameters
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|--------|----------|--------------------------------------------------|
|
||||
| page | number | No | Page number. Default: `1` |
|
||||
| limit | number | No | Records per page. Default: `20` |
|
||||
| search | string | No | Search across user fields |
|
||||
| sort_by | string | No | Column to sort by. Default: `createdAt` |
|
||||
| sort_dir | string | No | Sort direction: `ASC` or `DESC`. Default: `DESC` |
|
||||
| filters | array | No | Column filters from DataTable |
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Users retrieved.",
|
||||
"data": {
|
||||
"rows": [
|
||||
{
|
||||
"user_id": 1,
|
||||
"email": "john@example.com",
|
||||
"acc_type": "admin",
|
||||
"reg_type": "system",
|
||||
"is_active": true,
|
||||
"is_verified": true,
|
||||
"personal_info": {
|
||||
"name": {
|
||||
"given_name": "John",
|
||||
"middle_name": null,
|
||||
"last_name": "Doe",
|
||||
"extension_name": null,
|
||||
"full_name": "John Doe"
|
||||
},
|
||||
"date_of_birth": null,
|
||||
"occupation": null,
|
||||
"addresses": [],
|
||||
"phone_number": []
|
||||
},
|
||||
"groups": [],
|
||||
"createdAt": "2025-01-01T00:00:00.000Z",
|
||||
"updatedAt": "2025-01-01T00:00:00.000Z",
|
||||
"deletedAt": null
|
||||
}
|
||||
],
|
||||
"pagination": {
|
||||
"total": 100,
|
||||
"page": 1,
|
||||
"limit": 20,
|
||||
"totalPages": 5
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Get Single User
|
||||
|
||||
**`GET /api/admin/users/:id`**
|
||||
|
||||
Returns a single user with their group memberships.
|
||||
|
||||
### Path Parameters
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|--------|----------|-------------|
|
||||
| id | number | Yes | User ID |
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "User retrieved.",
|
||||
"data": {
|
||||
"user_id": 1,
|
||||
"email": "john@example.com",
|
||||
"acc_type": "admin",
|
||||
"is_active": true,
|
||||
"is_verified": true,
|
||||
"personal_info": { ... },
|
||||
"groups": [
|
||||
{ "group_id": 1, "name": "Administrators" }
|
||||
],
|
||||
"createdAt": "2025-01-01T00:00:00.000Z",
|
||||
"updatedAt": "2025-01-01T00:00:00.000Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Response `404`
|
||||
```json
|
||||
{
|
||||
"status": "error",
|
||||
"message": "User not found."
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Add Staff User
|
||||
|
||||
**`POST /api/admin/users/staff`**
|
||||
|
||||
Creates a new staff user with an auto-generated temporary password.
|
||||
A welcome email is sent with the credentials and a 24-hour expiry notice.
|
||||
The user is forced to change their password on first login.
|
||||
|
||||
### Request Body `application/json`
|
||||
| Field | Type | Required | Description |
|
||||
|-----------------------------|--------|----------|--------------------------------------|
|
||||
| email | string | Yes | Staff user email address |
|
||||
| personal_info.name.given_name | string | Yes | First name |
|
||||
| personal_info.name.last_name | string | Yes | Last name |
|
||||
| personal_info.name.middle_name | string | No | Middle name |
|
||||
| personal_info.name.extension_name | string | No | Extension name e.g. `Jr.` |
|
||||
| personal_info.date_of_birth | string | No | Date of birth |
|
||||
| personal_info.occupation | string | No | Occupation |
|
||||
|
||||
### Response `201`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Staff user created successfully.",
|
||||
"data": {
|
||||
"user_id": 5,
|
||||
"email": "staff@example.com",
|
||||
"acc_type": "staff"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Response `409`
|
||||
```json
|
||||
{
|
||||
"status": "error",
|
||||
"message": "Email is already in use."
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Update User
|
||||
|
||||
**`PUT /api/admin/users/:id`**
|
||||
|
||||
Updates a user's account type, active status, or personal information.
|
||||
Admins cannot change their own `acc_type`.
|
||||
|
||||
### Path Parameters
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|--------|----------|-------------|
|
||||
| id | number | Yes | User ID |
|
||||
|
||||
### Request Body `application/json`
|
||||
| Field | Type | Required | Description |
|
||||
|--------------|---------|----------|----------------------------------------------|
|
||||
| acc_type | string | No | `admin`, `staff`, `user` |
|
||||
| is_active | boolean | No | Active status |
|
||||
| personal_info | object | No | Personal information object |
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "User updated.",
|
||||
"data": { ...user }
|
||||
}
|
||||
```
|
||||
|
||||
### Response `400`
|
||||
```json
|
||||
{
|
||||
"status": "error",
|
||||
"message": "Admins cannot change their own role."
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Deactivate User
|
||||
|
||||
**`DELETE /api/admin/users/:id`**
|
||||
|
||||
Soft deletes a user by setting `deletedAt` and `is_active: false`.
|
||||
All active sessions are force-terminated.
|
||||
Admins cannot deactivate their own account.
|
||||
|
||||
### Path Parameters
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|--------|----------|-------------|
|
||||
| id | number | Yes | User ID |
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "User deactivated successfully."
|
||||
}
|
||||
```
|
||||
|
||||
### Response `400`
|
||||
```json
|
||||
{
|
||||
"status": "error",
|
||||
"message": "You cannot deactivate your own account."
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Bulk Deactivate Users
|
||||
|
||||
**`DELETE /api/admin/users/bulk`**
|
||||
|
||||
Soft deletes multiple users at once.
|
||||
Already-deactivated users are skipped and reported.
|
||||
All active sessions for deactivated users are force-terminated.
|
||||
|
||||
### Request Body `application/json`
|
||||
| Field | Type | Required | Description |
|
||||
|-------|----------|----------|--------------------------|
|
||||
| ids | number[] | Yes | Array of user IDs |
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "3 user(s) deactivated successfully.",
|
||||
"data": {
|
||||
"deactivated_ids": [1, 2, 3],
|
||||
"skipped_ids": [4]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Restore User
|
||||
|
||||
**`POST /api/admin/users/:id/restore`**
|
||||
|
||||
Restores a soft-deleted user by clearing `deletedAt` and setting `is_active: true`.
|
||||
|
||||
### Path Parameters
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|--------|----------|-------------|
|
||||
| id | number | Yes | User ID |
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "User restored successfully."
|
||||
}
|
||||
```
|
||||
|
||||
### Response `400`
|
||||
```json
|
||||
{
|
||||
"status": "error",
|
||||
"message": "User is not deactivated."
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Bulk Restore Users
|
||||
|
||||
**`POST /api/admin/users/bulk/restore`**
|
||||
|
||||
Restores multiple soft-deleted users at once.
|
||||
Already-active users are skipped and reported.
|
||||
|
||||
### Request Body `application/json`
|
||||
| Field | Type | Required | Description |
|
||||
|-------|----------|----------|--------------------------|
|
||||
| ids | number[] | Yes | Array of user IDs |
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "3 user(s) restored successfully.",
|
||||
"data": {
|
||||
"restored_ids": [1, 2, 3],
|
||||
"skipped_ids": [4]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Get Archived Users
|
||||
|
||||
**`GET /api/admin/users/archived`**
|
||||
|
||||
Returns a paginated list of soft-deleted users.
|
||||
|
||||
### Query Parameters
|
||||
Same as [Get All Users](#get-all-users).
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Archived users retrieved.",
|
||||
"data": {
|
||||
"rows": [ ...soft-deleted users ],
|
||||
"pagination": { ... }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Get User Field Values
|
||||
|
||||
**`GET /api/admin/users/field-values`**
|
||||
|
||||
Returns distinct values for a given column — used to populate filter dropdowns in the DataTable.
|
||||
Supports regular columns, date fields, audit fields, and JSONB dot-notation.
|
||||
|
||||
### Query Parameters
|
||||
| Parameter | Type | Required | Description |
|
||||
|----------|--------|----------|------------------------------------------------------|
|
||||
| field | string | Yes | Column name or JSONB path e.g. `personal_info.name.given_name` |
|
||||
|
||||
### Supported Field Types
|
||||
| Type | Example | Returns |
|
||||
|-------------|----------------------------------|--------------------------------|
|
||||
| Regular | `acc_type` | Distinct string values |
|
||||
| Date | `createdAt` | Distinct dates (no time) |
|
||||
| Audit by | `createdBy` | Full names of referenced users |
|
||||
| JSONB | `personal_info.name.given_name` | Distinct JSONB path values |
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Field values retrieved.",
|
||||
"data": ["admin", "staff", "user"]
|
||||
}
|
||||
```
|
||||
|
||||
### Response `400`
|
||||
```json
|
||||
{
|
||||
"status": "error",
|
||||
"message": "Invalid or restricted field."
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Get User Sessions
|
||||
|
||||
**`GET /api/admin/users/:id/sessions`**
|
||||
|
||||
Returns all sessions for a specific user, ordered by most recent.
|
||||
|
||||
### Path Parameters
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|--------|----------|-------------|
|
||||
| id | number | Yes | User ID |
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Sessions retrieved.",
|
||||
"data": [
|
||||
{
|
||||
"session_id": 1,
|
||||
"user_id": 1,
|
||||
"is_active": true,
|
||||
"ip_address": "192.168.1.1",
|
||||
"user_agent": "Mozilla/5.0...",
|
||||
"createdAt": "2025-01-01T00:00:00.000Z",
|
||||
"logout_info": null
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Terminate Session
|
||||
|
||||
**`DELETE /api/admin/users/:id/sessions/:sid`**
|
||||
|
||||
Force-terminates a specific user session.
|
||||
|
||||
### Path Parameters
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|--------|----------|--------------|
|
||||
| id | number | Yes | User ID |
|
||||
| sid | number | Yes | Session ID |
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Session terminated."
|
||||
}
|
||||
```
|
||||
|
||||
### Response `404`
|
||||
```json
|
||||
{
|
||||
"status": "error",
|
||||
"message": "Session not found."
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Responses
|
||||
|
||||
All endpoints return the following on server error:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "error",
|
||||
"message": "Internal server error."
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- **Excluded fields** — `password`, `otp_code`, `otp_expires_at`, `must_change_password`, `password_expires_at` are never returned in any response.
|
||||
- **Soft delete** — deactivation sets `deletedAt` + `is_active: false`. Users are excluded from all queries unless explicitly queried with `paranoid: false`.
|
||||
- **Session termination** — deactivating a user (single or bulk) always force-terminates all their active sessions.
|
||||
- **Audit fields** — `createdBy`, `updatedBy`, `deletedBy` store the `user_id` of the admin who performed the action.
|
||||
@@ -0,0 +1,530 @@
|
||||
"use strict";
|
||||
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name: lessons.controller.js (admin)
|
||||
* Type of Program: Controller
|
||||
* Description: Standalone Lesson library — Lessons live independently of Units.
|
||||
*
|
||||
* /admin/lessons → library CRUD (list / create / update / archive / restore / permanent delete)
|
||||
* /admin/lessons/:lessonId/page → the lesson's block content (unchanged contract)
|
||||
*
|
||||
* Membership in a unit is a unit_lessons row (managed from the unit editor /
|
||||
* course builder); archiving here removes the Lesson from every unit at once.
|
||||
*
|
||||
* Author: Kenneth Obsequio (@lash0000)
|
||||
* Date Created: Jul. 7, 2026 (junction revamp — Units/Lessons run independently)
|
||||
***********************************************************************************************************************************************************************/
|
||||
|
||||
const { Op } = require("sequelize");
|
||||
const sequelize = require("../../config/db.config");
|
||||
const R = require("../../utils/response.util");
|
||||
const { paginate } = require("../../utils/paginate.util");
|
||||
const { recomputeDurations, recomputeUnitDuration, recomputeCourseDuration } = require("../../utils/duration.util");
|
||||
const { syncObjectivesCreate, syncObjectivesUpdate } = require("../../utils/courses/objectives.util");
|
||||
const { archiveOne, archiveMany } = require("../../utils/courses/archive.util");
|
||||
const { restoreOne, restoreMany } = require("../../utils/courses/restore.util");
|
||||
const { permanentDeleteOne, permanentDeleteMany } = require("../../utils/courses/permanentDelete.util");
|
||||
const { getFieldValues } = require("../../utils/fieldValues.util");
|
||||
const { nextOrderIndex } = require("../../utils/courses/hierarchy.util");
|
||||
const logActivity = require("../../utils/logActivity.util");
|
||||
|
||||
// ── Models ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const {
|
||||
Unit, Lesson, LessonPage,
|
||||
CourseUnit, UnitLesson,
|
||||
LessonObjective,
|
||||
} = require("../../models/courses/courses.associations");
|
||||
|
||||
const mdl_Users = require("../../models/users/users.mdl");
|
||||
|
||||
const notDeleted = { deletedAt: null };
|
||||
const onlyDeleted = { deletedAt: { [Op.not]: null } };
|
||||
|
||||
// Refresh every unit this lesson is attached to + the courses above them.
|
||||
async function recomputeParentDurations(lessonId) {
|
||||
const links = await UnitLesson.findAll({ where: { lesson_id: lessonId }, attributes: ["unit_id"] });
|
||||
const unitIds = [...new Set(links.map((l) => String(l.unit_id)))];
|
||||
for (const unitId of unitIds) await recomputeUnitDuration(unitId);
|
||||
if (unitIds.length) {
|
||||
const courseLinks = await CourseUnit.findAll({ where: { unit_id: unitIds }, attributes: ["course_id"] });
|
||||
for (const courseId of new Set(courseLinks.map((l) => String(l.course_id)))) {
|
||||
await recomputeCourseDuration(courseId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const LESSON_LIST_COMPUTED = [
|
||||
{
|
||||
// Not its own column — consumed by the Title cell on the frontend to
|
||||
// prefix "(UNIT)" when a lesson is already attached to at least one Unit.
|
||||
key: "unit_count",
|
||||
label: "Unit Count",
|
||||
type: "number",
|
||||
hidden: true,
|
||||
filterable: false,
|
||||
literal: `(
|
||||
SELECT CAST(COUNT(*) AS INTEGER)
|
||||
FROM unit_lessons ul
|
||||
JOIN units u ON u.unit_id = ul.unit_id AND u."deletedAt" IS NULL
|
||||
WHERE ul.lesson_id = "Lesson"."lesson_id"
|
||||
)`,
|
||||
},
|
||||
{
|
||||
key: "course_count",
|
||||
label: "Affiliated",
|
||||
type: "number",
|
||||
order: 2, // 1: Title, 2: Affiliated, 3: Course Status — see lessons.mdl.js
|
||||
literal: `(
|
||||
SELECT CAST(COUNT(DISTINCT c.course_id) AS INTEGER)
|
||||
FROM unit_lessons ul
|
||||
JOIN units u ON u.unit_id = ul.unit_id AND u."deletedAt" IS NULL
|
||||
JOIN course_units cu ON cu.unit_id = u.unit_id
|
||||
JOIN courses c ON c.course_id = cu.course_id AND c."deletedAt" IS NULL
|
||||
WHERE ul.lesson_id = "Lesson"."lesson_id"
|
||||
)`,
|
||||
},
|
||||
{
|
||||
key: "course_status",
|
||||
label: "Course Status",
|
||||
type: "text",
|
||||
order: 3, // 1: Title, 2: Affiliated, 3: Course Status — see lessons.mdl.js
|
||||
hidden: true,
|
||||
filterable: false,
|
||||
literal: `(
|
||||
CASE
|
||||
WHEN NOT EXISTS (
|
||||
SELECT 1 FROM unit_lessons ul
|
||||
JOIN units u ON u.unit_id = ul.unit_id AND u."deletedAt" IS NULL
|
||||
JOIN course_units cu ON cu.unit_id = u.unit_id
|
||||
JOIN courses c ON c.course_id = cu.course_id AND c."deletedAt" IS NULL
|
||||
WHERE ul.lesson_id = "Lesson"."lesson_id"
|
||||
) THEN 'standalone'
|
||||
WHEN EXISTS (
|
||||
SELECT 1 FROM unit_lessons ul
|
||||
JOIN units u ON u.unit_id = ul.unit_id AND u."deletedAt" IS NULL
|
||||
JOIN course_units cu ON cu.unit_id = u.unit_id
|
||||
JOIN courses c ON c.course_id = cu.course_id AND c."deletedAt" IS NULL
|
||||
WHERE ul.lesson_id = "Lesson"."lesson_id" AND c.status = 'published'
|
||||
) THEN 'published'
|
||||
ELSE 'draft'
|
||||
END
|
||||
)`,
|
||||
},
|
||||
{
|
||||
// Not its own column — consumed by the frontend's Subscription cell so a
|
||||
// lesson gated only through an affiliated course (own `subscription` is
|
||||
// NULL) still shows a tier instead of "-". Distinct tiers across every
|
||||
// affiliated course, comma-joined (a lesson can reach several courses
|
||||
// through several units, each possibly at a different tier).
|
||||
key: "course_subscription",
|
||||
label: "Course Subscription",
|
||||
type: "text",
|
||||
hidden: true,
|
||||
filterable: false,
|
||||
literal: `(
|
||||
SELECT STRING_AGG(sub.subscription, ', ')
|
||||
FROM (
|
||||
SELECT DISTINCT c.subscription
|
||||
FROM unit_lessons ul
|
||||
JOIN units u ON u.unit_id = ul.unit_id AND u."deletedAt" IS NULL
|
||||
JOIN course_units cu ON cu.unit_id = u.unit_id
|
||||
JOIN courses c ON c.course_id = cu.course_id AND c."deletedAt" IS NULL
|
||||
WHERE ul.lesson_id = "Lesson"."lesson_id"
|
||||
) sub
|
||||
)`,
|
||||
},
|
||||
];
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
// LESSON LIBRARY
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
exports.getLessons = async (req, res) => {
|
||||
try {
|
||||
const result = await paginate(Lesson, req, {
|
||||
auditOptions: { mdl_Users, parentAlias: "Lesson" },
|
||||
context: "list",
|
||||
computedAttributes: LESSON_LIST_COMPUTED,
|
||||
findOptions: {
|
||||
where: { ...notDeleted },
|
||||
order: [["createdAt", "DESC"]],
|
||||
},
|
||||
});
|
||||
return R.success(res, "Lessons retrieved.", result);
|
||||
} catch (err) {
|
||||
console.error("[LESSON LIB][GET ALL]", err);
|
||||
return R.error(res, "Could not retrieve lessons.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
// Lightweight list for attach pickers
|
||||
exports.getLessonsFlat = async (req, res) => {
|
||||
try {
|
||||
const rows = await sequelize.query(`
|
||||
SELECT
|
||||
l.lesson_id, l.uuid, l.title, l.description, l.duration_seconds,
|
||||
(SELECT CAST(COUNT(*) AS INTEGER) FROM unit_lessons ul
|
||||
JOIN units u ON u.unit_id = ul.unit_id AND u."deletedAt" IS NULL
|
||||
WHERE ul.lesson_id = l.lesson_id) AS unit_count
|
||||
FROM lessons l
|
||||
WHERE l."deletedAt" IS NULL
|
||||
ORDER BY l.title ASC
|
||||
`, { type: sequelize.QueryTypes.SELECT });
|
||||
return R.success(res, "Lessons retrieved.", rows);
|
||||
} catch (err) {
|
||||
console.error("[LESSON LIB][GET FLAT]", err);
|
||||
return R.error(res, "Could not retrieve lessons.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
// Bundle picker (Tier Plan wizard) — mirrors getCoursesBySubscription in
|
||||
// controllers/admin/courses.controller.js.
|
||||
exports.getLessonsBySubscription = async (req, res) => {
|
||||
try {
|
||||
const { slug } = req.query;
|
||||
if (!slug) return R.error(res, 'slug query param is required.', 400);
|
||||
|
||||
const rows = await Lesson.findAll({
|
||||
where: { ...notDeleted, subscription: slug },
|
||||
attributes: ['lesson_id', 'title', 'description', 'subscription'],
|
||||
order: [['title', 'ASC']],
|
||||
});
|
||||
|
||||
// A lesson may belong to any number of other plans (Tier Plans v2, silent
|
||||
// duplication across bundles is intentional) — no conflict to report here.
|
||||
const data = rows.map((l) => l.toJSON());
|
||||
|
||||
return R.success(res, 'Lessons retrieved.', data);
|
||||
} catch (err) {
|
||||
console.error('[LESSON LIB][BY SUBSCRIPTION]', err);
|
||||
return R.error(res, 'Could not retrieve lessons.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.getLesson = async (req, res) => {
|
||||
try {
|
||||
const { lessonId } = req.params;
|
||||
|
||||
const lesson = await Lesson.findOne({
|
||||
where: { lesson_id: lessonId, ...notDeleted },
|
||||
include: [
|
||||
{ model: LessonPage, as: "page", required: false },
|
||||
{ model: LessonObjective, as: "objectives", required: false, order: [["order_index", "ASC"]] },
|
||||
{ model: Unit, as: "units", where: notDeleted, required: false, attributes: ["unit_id", "uuid", "title"], through: { attributes: ["order_index"] } },
|
||||
],
|
||||
});
|
||||
|
||||
if (!lesson) return R.error(res, "Lesson not found.", 404);
|
||||
return R.success(res, "Lesson retrieved.", { data: lesson.toJSON() });
|
||||
} catch (err) {
|
||||
console.error("[LESSON LIB][GET ONE]", err);
|
||||
return R.error(res, "Could not retrieve lesson.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.createLesson = async (req, res) => {
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const { title, description, subscription, unit_id, order, objectives = [], createdBy } = req.body;
|
||||
if (!title) return R.error(res, "Title is required.", 400);
|
||||
|
||||
const lesson = await Lesson.create({
|
||||
title,
|
||||
subscription: subscription || null,
|
||||
description: description ?? null,
|
||||
duration_seconds: 0,
|
||||
createdBy: createdBy ?? req.user?.user_id ?? null,
|
||||
}, { transaction: t });
|
||||
|
||||
await LessonPage.create({
|
||||
lesson_id: lesson.lesson_id,
|
||||
blocks: [],
|
||||
createdBy: createdBy ?? req.user?.user_id ?? null,
|
||||
}, { transaction: t });
|
||||
|
||||
await syncObjectivesCreate(LessonObjective, "lesson_id", lesson.lesson_id, objectives, t);
|
||||
|
||||
// Optional immediate attach — lets the unit editor create-and-attach in one call
|
||||
if (unit_id) {
|
||||
const unit = await Unit.findOne({ where: { unit_id, ...notDeleted }, transaction: t });
|
||||
if (!unit) {
|
||||
await t.rollback();
|
||||
return R.error(res, "Unit not found.", 404);
|
||||
}
|
||||
const order_index = order ?? await nextOrderIndex(UnitLesson, { unit_id }, t);
|
||||
await UnitLesson.create({
|
||||
unit_id,
|
||||
lesson_id: lesson.lesson_id,
|
||||
order_index,
|
||||
createdBy: createdBy ?? req.user?.user_id ?? null,
|
||||
}, { transaction: t });
|
||||
}
|
||||
|
||||
await t.commit();
|
||||
logActivity(req.user?.user_id, "create_lesson", { entityType: "lesson", entityId: lesson.lesson_id, details: { title: lesson.title, attached_unit_id: unit_id ?? null } });
|
||||
return R.success(res, "Lesson created.", { data: lesson }, 201);
|
||||
} catch (err) {
|
||||
await t.rollback();
|
||||
console.error("[LESSON LIB][CREATE]", err);
|
||||
return R.error(res, "Could not create lesson.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.updateLesson = async (req, res) => {
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const { lessonId } = req.params;
|
||||
|
||||
const lesson = await Lesson.findOne({ where: { lesson_id: lessonId, ...notDeleted } });
|
||||
if (!lesson) return R.error(res, "Lesson not found.", 404);
|
||||
|
||||
const { title, description, subscription, objectives, updatedBy } = req.body;
|
||||
|
||||
if (title !== undefined) lesson.title = title;
|
||||
if (description !== undefined) lesson.description = description;
|
||||
if (subscription !== undefined) lesson.subscription = subscription || null;
|
||||
lesson.updatedBy = updatedBy ?? req.user?.user_id ?? null;
|
||||
await lesson.save({ transaction: t });
|
||||
|
||||
if (objectives !== undefined) {
|
||||
await syncObjectivesUpdate(LessonObjective, "lesson_id", lessonId, objectives, t);
|
||||
}
|
||||
|
||||
await t.commit();
|
||||
|
||||
const updated = await Lesson.findOne({
|
||||
where: { lesson_id: lessonId },
|
||||
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();
|
||||
console.error("[LESSON LIB][UPDATE]", err);
|
||||
return R.error(res, "Could not update lesson.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.archiveLesson = async (req, res) => {
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const { lessonId } = req.params;
|
||||
const record = await archiveOne(Lesson, { lesson_id: lessonId, ...notDeleted }, req.user.user_id, t);
|
||||
if (!record) return R.error(res, "Lesson not found.", 404);
|
||||
await t.commit();
|
||||
try { await recomputeParentDurations(lessonId); } catch (durErr) { console.error("[LESSON LIB][ARCHIVE][DURATION]", durErr); }
|
||||
logActivity(req.user.user_id, "archive_lesson", { entityType: "lesson", entityId: Number(lessonId) });
|
||||
return R.success(res, "Lesson archived.");
|
||||
} catch (err) {
|
||||
await t.rollback();
|
||||
console.error("[LESSON LIB][ARCHIVE]", err);
|
||||
return R.error(res, "Could not archive lesson.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.bulkArchiveLessons = async (req, res) => {
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const { ids = [] } = req.body;
|
||||
if (!ids.length) return R.error(res, "No IDs provided.", 400);
|
||||
|
||||
const lessons = await Lesson.findAll({ where: { lesson_id: ids, ...notDeleted } });
|
||||
const validIds = lessons.map((l) => l.lesson_id);
|
||||
|
||||
const count = await archiveMany(Lesson, "lesson_id", validIds, req.user.user_id, t);
|
||||
await t.commit();
|
||||
for (const id of validIds) {
|
||||
try { await recomputeParentDurations(id); } catch (durErr) { console.error("[LESSON LIB][BULK ARCHIVE][DURATION]", durErr); }
|
||||
}
|
||||
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();
|
||||
console.error("[LESSON LIB][BULK ARCHIVE]", err);
|
||||
return R.error(res, "Could not archive lessons.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.getArchivedLessons = async (req, res) => {
|
||||
try {
|
||||
const result = await paginate(Lesson, req, {
|
||||
auditOptions: { mdl_Users, parentAlias: "Lesson" },
|
||||
context: "archived",
|
||||
findOptions: {
|
||||
where: { ...onlyDeleted },
|
||||
paranoid: false,
|
||||
order: [["deletedAt", "DESC"]],
|
||||
},
|
||||
});
|
||||
return R.success(res, "Archived lessons retrieved.", result);
|
||||
} catch (err) {
|
||||
console.error("[LESSON LIB][GET ARCHIVES]", err);
|
||||
return R.error(res, "Could not retrieve archived lessons.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.getArchivedLesson = async (req, res) => {
|
||||
try {
|
||||
const { lessonId } = req.params;
|
||||
const lesson = await Lesson.findOne({
|
||||
where: { lesson_id: lessonId, ...onlyDeleted },
|
||||
paranoid: false,
|
||||
});
|
||||
if (!lesson) return R.error(res, "Archived lesson not found.", 404);
|
||||
return R.success(res, "Archived lesson retrieved.", { data: lesson.toJSON() });
|
||||
} catch (err) {
|
||||
console.error("[LESSON LIB][GET ARCHIVE ONE]", err);
|
||||
return R.error(res, "Could not retrieve archived lesson.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.restoreLesson = async (req, res) => {
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const { lessonId } = req.params;
|
||||
const record = await restoreOne(Lesson, { lesson_id: lessonId, ...onlyDeleted }, req.user.user_id, t);
|
||||
if (!record) return R.error(res, "Archived lesson not found.", 404);
|
||||
await t.commit();
|
||||
try { await recomputeParentDurations(lessonId); } catch (durErr) { console.error("[LESSON LIB][RESTORE][DURATION]", durErr); }
|
||||
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();
|
||||
console.error("[LESSON LIB][RESTORE]", err);
|
||||
return R.error(res, "Could not restore lesson.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.bulkRestoreLessons = async (req, res) => {
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const { ids = [] } = req.body;
|
||||
if (!ids.length) return R.error(res, "No IDs provided.", 400);
|
||||
|
||||
const lessons = await Lesson.findAll({ where: { lesson_id: ids, ...onlyDeleted }, paranoid: false });
|
||||
const validIds = lessons.map((l) => l.lesson_id);
|
||||
|
||||
const count = await restoreMany(Lesson, "lesson_id", validIds, req.user.user_id, t);
|
||||
await t.commit();
|
||||
for (const id of validIds) {
|
||||
try { await recomputeParentDurations(id); } catch (durErr) { console.error("[LESSON LIB][BULK RESTORE][DURATION]", durErr); }
|
||||
}
|
||||
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();
|
||||
console.error("[LESSON LIB][BULK RESTORE]", err);
|
||||
return R.error(res, "Could not restore lessons.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.getLessonPermanentDeleteImpact = async (req, res) => {
|
||||
try {
|
||||
const { lessonId } = req.params;
|
||||
const unitCount = await UnitLesson.count({ where: { lesson_id: lessonId } });
|
||||
return R.success(res, "Impact retrieved.", { unitCount });
|
||||
} catch (err) {
|
||||
console.error("[LESSON LIB][PERMANENT DELETE IMPACT]", err);
|
||||
return R.error(res, "Could not retrieve impact.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.permanentlyDeleteLesson = async (req, res) => {
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const { lessonId } = req.params;
|
||||
const record = await permanentDeleteOne(Lesson, { lesson_id: lessonId }, t);
|
||||
if (record === null) return R.error(res, "Lesson not found.", 404);
|
||||
if (record === false) return R.error(res, "Lesson must be archived before it can be permanently deleted.", 400);
|
||||
|
||||
await UnitLesson.destroy({ where: { lesson_id: lessonId }, transaction: t });
|
||||
|
||||
await t.commit();
|
||||
logActivity(req.user.user_id, "permanently_delete_lesson", { entityType: "lesson", entityId: Number(lessonId) });
|
||||
return R.success(res, "Lesson permanently deleted.");
|
||||
} catch (err) {
|
||||
await t.rollback();
|
||||
console.error("[LESSON LIB][PERMANENT DELETE]", err);
|
||||
return R.error(res, "Could not permanently delete lesson.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.bulkPermanentlyDeleteLessons = async (req, res) => {
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const { ids = [] } = req.body;
|
||||
if (!ids.length) return R.error(res, "No IDs provided.", 400);
|
||||
|
||||
const lessons = await Lesson.findAll({ where: { lesson_id: ids }, paranoid: false });
|
||||
const validIds = lessons.map((l) => l.lesson_id);
|
||||
|
||||
const count = await permanentDeleteMany(Lesson, "lesson_id", validIds, t);
|
||||
await UnitLesson.destroy({ where: { lesson_id: validIds }, transaction: t });
|
||||
|
||||
await t.commit();
|
||||
logActivity(req.user.user_id, "bulk_permanently_delete_lessons", { entityType: "lesson", details: { ids: validIds, count } });
|
||||
return R.success(res, `${count} lesson${count !== 1 ? "s" : ""} permanently deleted.`);
|
||||
} catch (err) {
|
||||
await t.rollback();
|
||||
console.error("[LESSON LIB][BULK PERMANENT DELETE]", err);
|
||||
return R.error(res, "Could not permanently delete lessons.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.getLessonFieldValues = getFieldValues(Lesson, "LESSON");
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
// LESSON PAGE (same contract as before — keyed by lessonId only)
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
exports.getLessonPage = async (req, res) => {
|
||||
try {
|
||||
const { lessonId } = req.params;
|
||||
const page = await LessonPage.findOne({ where: { lesson_id: lessonId } });
|
||||
if (!page) return R.error(res, "Lesson page not found.", 404);
|
||||
return R.success(res, "Lesson page retrieved.", { data: page });
|
||||
} catch (err) {
|
||||
console.error("[LESSON LIB][PAGE][GET]", err);
|
||||
return R.error(res, "Could not retrieve lesson page.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.upsertLessonPage = async (req, res) => {
|
||||
try {
|
||||
const { lessonId } = req.params;
|
||||
const { blocks } = req.body;
|
||||
|
||||
if (!Array.isArray(blocks)) return R.error(res, "blocks must be an array.", 400);
|
||||
|
||||
const lesson = await Lesson.findOne({ where: { lesson_id: lessonId, ...notDeleted } });
|
||||
if (!lesson) return R.error(res, "Lesson not found.", 404);
|
||||
|
||||
const [page, created] = await LessonPage.upsert({
|
||||
lesson_id: lessonId,
|
||||
blocks,
|
||||
updatedBy: req.body.updatedBy ?? req.user?.user_id ?? null,
|
||||
createdBy: req.body.updatedBy ?? req.user?.user_id ?? null,
|
||||
}, { returning: true });
|
||||
|
||||
try {
|
||||
// Pass the blocks we just wrote directly instead of re-reading the page —
|
||||
// avoids depending on read-after-write visibility of the upsert we just did.
|
||||
await recomputeDurations(lessonId, blocks);
|
||||
} catch (durErr) {
|
||||
console.error("[LESSON LIB][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.",
|
||||
{ data: page },
|
||||
created ? 201 : 200,
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("[LESSON LIB][PAGE][UPSERT]", err);
|
||||
return R.error(res, "Could not save lesson page.", 500);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,97 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* 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 { Op } = require("sequelize");
|
||||
|
||||
const R = require("../../utils/response.util");
|
||||
const mdl_Assets = require("../../models/assets/assets.mdl");
|
||||
const mediaToken = require("../../services/mediaToken.service");
|
||||
|
||||
// ─── 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, "File not found.", 404);
|
||||
|
||||
if (!mediaToken.SUPPORTED_TYPES.includes(asset.file_type)) {
|
||||
return R.error(res, `File type "${asset.file_type}" is not supported.`, 400);
|
||||
}
|
||||
|
||||
if (asset.storage_provider !== "s3") {
|
||||
return R.error(res, "Token flow is for S3 files only. Use the raw file_url for other providers.", 400);
|
||||
}
|
||||
|
||||
const ip = mediaToken.resolveIp(req);
|
||||
const { token, thumbnail_url } = await mediaToken.issueForAsset(asset, req.user.user_id, ip);
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── POST /admin/media/tokens (batch) ───────────────────────────────────────
|
||||
//
|
||||
// Accepts { asset_ids: [id, ...] } — S3 assets only, max 50.
|
||||
// Returns { tokens: { [asset_id]: token } }
|
||||
// One round-trip instead of N per-card requests.
|
||||
|
||||
exports.issueTokensBatch = async (req, res) => {
|
||||
try {
|
||||
const { asset_ids } = req.body;
|
||||
if (!Array.isArray(asset_ids) || !asset_ids.length)
|
||||
return R.error(res, "asset_ids must be a non-empty array.", 400);
|
||||
if (asset_ids.length > 50)
|
||||
return R.error(res, "Maximum 50 asset_ids per batch.", 400);
|
||||
|
||||
const assets = await mdl_Assets.findAll({
|
||||
where: {
|
||||
asset_id: { [Op.in]: asset_ids },
|
||||
storage_provider: "s3",
|
||||
deletedAt: null,
|
||||
},
|
||||
attributes: ["asset_id", "file_type", "storage_key", "mime_type", "thumbnail_storage_key"],
|
||||
});
|
||||
|
||||
const ip = mediaToken.resolveIp(req);
|
||||
const tokens = {};
|
||||
const thumbnails = {};
|
||||
|
||||
await Promise.all(assets.map(async (asset) => {
|
||||
const { token, thumbnail_url } = await mediaToken.issueForAsset(asset, req.user.user_id, ip);
|
||||
tokens[String(asset.asset_id)] = token;
|
||||
if (thumbnail_url) thumbnails[String(asset.asset_id)] = thumbnail_url;
|
||||
}));
|
||||
|
||||
return R.success(res, "Tokens issued.", { tokens, thumbnails });
|
||||
} catch (err) {
|
||||
console.error("[ADMIN][MEDIA][TOKENS BATCH]", err);
|
||||
return R.error(res, "Could not issue media tokens.", 500);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,136 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* 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
|
||||
* GET /admin/notifications/sticky — current sticky announcement, if any
|
||||
* 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 mdl_Assets = require('../../models/assets/assets.mdl');
|
||||
const mediaToken = require('../../services/mediaToken.service');
|
||||
const R = require('../../utils/response.util');
|
||||
const { notInFutureOrExpired } = require('../../utils/notificationVisibility.util');
|
||||
|
||||
const STICKY_LIMIT = 2;
|
||||
const IMAGE_INCLUDE = {
|
||||
model: mdl_Assets,
|
||||
as: 'image',
|
||||
attributes: ["asset_id", "display_name", "file_url", "thumbnail_url", "storage_provider", "storage_key", "file_type", "mime_type"],
|
||||
required: false,
|
||||
};
|
||||
|
||||
// Mirrors controllers/admin/advertisements.controller.js's attachImageStreamToken.
|
||||
async function attachImageStreamToken(image, req) {
|
||||
if (!image || image.storage_provider !== 's3' || !mediaToken.SUPPORTED_TYPES.includes(image.file_type)) {
|
||||
return image;
|
||||
}
|
||||
const ip = mediaToken.resolveIp(req);
|
||||
const { token } = await mediaToken.issueForAsset(image, req.user?.user_id, ip);
|
||||
image.stream_token = token;
|
||||
image.file_url = null;
|
||||
image.thumbnail_url = null;
|
||||
delete image.storage_key;
|
||||
return image;
|
||||
}
|
||||
|
||||
// ─── 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,
|
||||
where: { show_in_notifications: true, ...notInFutureOrExpired() },
|
||||
});
|
||||
|
||||
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, show_in_notifications: true, ...notInFutureOrExpired() } });
|
||||
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.');
|
||||
}
|
||||
}
|
||||
|
||||
// ─── GET /admin/notifications/sticky ──────────────────────────────────────────
|
||||
// Not user-scoped, same as list()/unseenCount() above — one shared sticky
|
||||
// banner for every admin. Whoever dismisses it first dismisses it for all.
|
||||
async function stickyAnnouncement(req, res) {
|
||||
try {
|
||||
const rows = await AdminNotification.findAll({
|
||||
where: {
|
||||
seen: false,
|
||||
show_in_sticky: true,
|
||||
type: 'announcement',
|
||||
...notInFutureOrExpired(),
|
||||
},
|
||||
include: [IMAGE_INCLUDE],
|
||||
order: [['createdAt', 'DESC']],
|
||||
limit: STICKY_LIMIT,
|
||||
});
|
||||
|
||||
const notifications = await Promise.all(rows.map(async (row) => {
|
||||
const json = row.toJSON();
|
||||
if (json.image) await attachImageStreamToken(json.image, req);
|
||||
return json;
|
||||
}));
|
||||
|
||||
return R.success(res, 'Sticky alerts fetched.', { announcements: notifications });
|
||||
} catch (err) {
|
||||
console.error('[NOTIFICATION] stickyAnnouncement error:', err);
|
||||
return R.error(res, 'Failed to fetch sticky announcement.');
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 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, stickyAnnouncement, markSeen, markAllSeen };
|
||||
@@ -0,0 +1,740 @@
|
||||
// controllers/admin/notificationBroadcasts.controller.js
|
||||
|
||||
const sequelize = require("../../config/db.config");
|
||||
const NotificationBroadcast = require("../../models/notifications/notification_broadcast.mdl");
|
||||
const AdminNotification = require("../../models/notifications/admin_notification.mdl");
|
||||
const UserNotification = require("../../models/notifications/user_notification.mdl");
|
||||
const mdl_Users = require('../../models/users/users.mdl');
|
||||
const mdl_Assets = require('../../models/assets/assets.mdl');
|
||||
const { TaskList } = require('../../models/task/task.mdl');
|
||||
const { mdl_UserGroupMembers } = require('../../models/users/user_groups.mdl');
|
||||
const { Course } = require('../../models/courses/courses.mdl');
|
||||
const mdl_TierPlans = require('../../models/tiers/tier_plans.mdl');
|
||||
const mediaToken = require("../../services/mediaToken.service");
|
||||
const R = require('../../utils/response.util');
|
||||
const { paginate } = require("../../utils/paginate.util");
|
||||
const { adminExclude, jsonbSchemas, computedAttributes } = require("../../models/notifications/notification_broadcast.attributes");
|
||||
const logActivity = require('../../utils/logActivity.util');
|
||||
const { NOTIFICATION_REGISTRY } = require('../../data/notifications.data');
|
||||
const { notInFutureOrExpired } = require('../../utils/notificationVisibility.util');
|
||||
const {
|
||||
ALLOWED_TARGET_TYPES,
|
||||
SCOPED_TARGET_TYPES,
|
||||
validateTargetId,
|
||||
resolveTaskListUserGroups,
|
||||
resolveTargetUserIds,
|
||||
} = require('../../utils/audienceResolver.util');
|
||||
|
||||
const { Op } = require('sequelize');
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const notDeleted = { deletedAt: null };
|
||||
|
||||
// Fields needed off the associated Asset to render the shared sticky banner
|
||||
// preview AND (for S3 assets) mint a stream token — mirrors advertisements.controller.js.
|
||||
const IMAGE_ATTRIBUTES = ["asset_id", "display_name", "file_url", "thumbnail_url", "storage_provider", "storage_key", "file_type", "mime_type"];
|
||||
const IMAGE_INCLUDE = { model: mdl_Assets, as: "image", attributes: IMAGE_ATTRIBUTES, required: false };
|
||||
|
||||
// Mirrors controllers/admin/advertisements.controller.js's attachImageStreamToken
|
||||
// — kept duplicated rather than shared (same rationale used there).
|
||||
async function attachImageStreamToken(image, req) {
|
||||
if (!image || image.storage_provider !== "s3" || !mediaToken.SUPPORTED_TYPES.includes(image.file_type)) {
|
||||
return image;
|
||||
}
|
||||
const ip = mediaToken.resolveIp(req);
|
||||
const { token } = await mediaToken.issueForAsset(image, req.user?.user_id, ip);
|
||||
image.stream_token = token;
|
||||
image.file_url = null;
|
||||
image.thumbnail_url = null;
|
||||
delete image.storage_key;
|
||||
return image;
|
||||
}
|
||||
|
||||
// "Active sticky" = live in the rotating sticky banner right now: sent,
|
||||
// show_in_sticky, not archived, and within its own start/end window. Caps the
|
||||
// bar at 3 concurrent slots (see sendBroadcast/updateBroadcast below).
|
||||
async function countActiveSticky(excludeId = null) {
|
||||
return NotificationBroadcast.count({
|
||||
where: {
|
||||
status: 'sent',
|
||||
show_in_sticky: true,
|
||||
...notDeleted,
|
||||
...notInFutureOrExpired(),
|
||||
...(excludeId ? { broadcast_id: { [Op.ne]: excludeId } } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const MAX_ACTIVE_STICKY = 2;
|
||||
const ACTIVE_STICKY_CAP_MESSAGE = `Maximum of ${MAX_ACTIVE_STICKY} active sticky alerts right now — this stays in Draft until one ends or is archived.`;
|
||||
|
||||
async function validateImageAssetId(image_asset_id) {
|
||||
if (!image_asset_id) return null;
|
||||
const asset = await mdl_Assets.findOne({ where: { asset_id: image_asset_id, deletedAt: null } });
|
||||
if (!asset) {
|
||||
const err = new Error("Selected image file was not found.");
|
||||
err.status = 400;
|
||||
throw err;
|
||||
}
|
||||
return asset.asset_id;
|
||||
}
|
||||
|
||||
// Pushes a visibility change into the already-fanned-out per-recipient rows
|
||||
// (admin_notifications/user_notifications) so archive/restore take effect
|
||||
// immediately for anyone currently seeing the alert — same rationale as the
|
||||
// content/display propagation in updateBroadcast below, just for the two
|
||||
// visibility flags. `where` is a raw SQL fragment + its replacements so this
|
||||
// can target either a single broadcast_id or an IN-list.
|
||||
async function propagateNotificationVisibility(where, { show_in_sticky, show_in_notifications }, transaction) {
|
||||
for (const table of ['admin_notifications', 'user_notifications']) {
|
||||
await sequelize.query(
|
||||
`UPDATE ${table} SET show_in_sticky = :show_in_sticky, show_in_notifications = :show_in_notifications WHERE ${where.sql}`,
|
||||
{ replacements: { show_in_sticky, show_in_notifications, ...where.replacements }, transaction }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function applyBroadcastFields(broadcast, body) {
|
||||
if (body.title !== undefined) broadcast.title = body.title;
|
||||
if (body.message !== undefined) broadcast.message = body.message || null;
|
||||
if (body.link_url !== undefined) broadcast.link_url = body.link_url?.trim() || null;
|
||||
if (body.link_label !== undefined) broadcast.link_label = body.link_label?.trim() || null;
|
||||
if (body.color !== undefined) broadcast.color = body.color || 'indigo';
|
||||
if (body.image_asset_id !== undefined) broadcast.image_asset_id = await validateImageAssetId(body.image_asset_id);
|
||||
|
||||
if (body.start_date !== undefined) broadcast.start_date = body.start_date || null;
|
||||
if (body.end_date !== undefined) broadcast.end_date = body.end_date || null;
|
||||
if (broadcast.start_date && broadcast.end_date && new Date(broadcast.start_date) > new Date(broadcast.end_date)) {
|
||||
const err = new Error("Start date must be before end date.");
|
||||
err.status = 400;
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (body.show_in_sticky !== undefined) broadcast.show_in_sticky = !!body.show_in_sticky;
|
||||
if (body.show_in_notifications !== undefined) broadcast.show_in_notifications = !!body.show_in_notifications;
|
||||
|
||||
if (body.target_type !== undefined) {
|
||||
if (!ALLOWED_TARGET_TYPES.includes(body.target_type)) {
|
||||
const err = new Error(`Invalid target_type. Must be one of: ${ALLOWED_TARGET_TYPES.join(", ")}`);
|
||||
err.status = 400;
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (SCOPED_TARGET_TYPES.includes(body.target_type)) {
|
||||
if (!body.target_id) {
|
||||
const err = new Error("target_id is required for this target_type.");
|
||||
err.status = 400;
|
||||
throw err;
|
||||
}
|
||||
await validateTargetId(body.target_type, body.target_id);
|
||||
broadcast.target_id = String(body.target_id);
|
||||
} else {
|
||||
broadcast.target_id = null;
|
||||
}
|
||||
|
||||
broadcast.target_type = body.target_type;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Target resolution ────────────────────────────────────────────────────────
|
||||
// task_list/course/tier_plan resolution now lives in utils/audienceResolver.util.js
|
||||
// (resolveTargetUserIds, resolveTaskListUserGroups — imported above) so email
|
||||
// broadcasts resolve the same targets identically.
|
||||
|
||||
// Enrich one or many broadcast rows with a human-readable target_label.
|
||||
async function attachTargetLabels(rows) {
|
||||
const list = Array.isArray(rows) ? rows : [rows];
|
||||
const idsByType = { task_list: [], course: [], tier_plan: [] };
|
||||
list.forEach((r) => { if (SCOPED_TARGET_TYPES.includes(r.target_type) && r.target_id) idsByType[r.target_type].push(r.target_id); });
|
||||
|
||||
const [taskLists, courses, plans] = await Promise.all([
|
||||
idsByType.task_list.length ? TaskList.findAll({ where: { task_list_id: { [Op.in]: idsByType.task_list } }, attributes: ['task_list_id', 'name'], paranoid: false }) : [],
|
||||
idsByType.course.length ? Course.findAll({ where: { uuid: { [Op.in]: idsByType.course } }, attributes: ['uuid', 'title'], paranoid: false }) : [],
|
||||
idsByType.tier_plan.length ? mdl_TierPlans.findAll({ where: { plan_id: { [Op.in]: idsByType.tier_plan } }, attributes: ['plan_id', 'label'], paranoid: false }) : [],
|
||||
]);
|
||||
|
||||
const taskListMap = Object.fromEntries(taskLists.map((t) => [t.task_list_id, t.name]));
|
||||
const courseMap = Object.fromEntries(courses.map((c) => [c.uuid, c.title]));
|
||||
const planMap = Object.fromEntries(plans.map((p) => [String(p.plan_id), p.label]));
|
||||
|
||||
list.forEach((r) => {
|
||||
if (r.target_type === 'task_list') r.target_label = taskListMap[r.target_id] ?? null;
|
||||
else if (r.target_type === 'course') r.target_label = courseMap[r.target_id] ?? null;
|
||||
else if (r.target_type === 'tier_plan') r.target_label = planMap[r.target_id] ?? null;
|
||||
else r.target_label = null;
|
||||
});
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
// ─── GET ALL ──────────────────────────────────────────────────────────────────
|
||||
|
||||
exports.getBroadcasts = async (req, res) => {
|
||||
try {
|
||||
const result = await paginate(NotificationBroadcast, req, {
|
||||
excludeAttributes: adminExclude,
|
||||
jsonbSchemas,
|
||||
computedAttributes,
|
||||
context: "list",
|
||||
auditOptions: { mdl_Users, parentAlias: 'NotificationBroadcast' },
|
||||
findOptions: { where: { ...notDeleted } },
|
||||
});
|
||||
|
||||
if (Array.isArray(result?.data)) await attachTargetLabels(result.data);
|
||||
|
||||
return R.success(res, "Alerts retrieved.", result);
|
||||
} catch (err) {
|
||||
console.error("[NOTIFICATION BROADCAST][GET ALL]", err);
|
||||
return R.error(res, "Could not retrieve alerts.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── GET ONE ──────────────────────────────────────────────────────────────────
|
||||
|
||||
exports.getBroadcast = async (req, res) => {
|
||||
try {
|
||||
const { broadcastId } = req.params;
|
||||
if (!broadcastId || broadcastId === "undefined") return R.error(res, "Invalid broadcast ID.", 400);
|
||||
|
||||
const broadcast = await NotificationBroadcast.findOne({
|
||||
where: { broadcast_id: broadcastId, ...notDeleted },
|
||||
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" },
|
||||
IMAGE_INCLUDE,
|
||||
],
|
||||
});
|
||||
|
||||
if (!broadcast) return R.error(res, "Alert not found.", 404);
|
||||
|
||||
const json = broadcast.toJSON();
|
||||
if (json.image) await attachImageStreamToken(json.image, req);
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
await attachTargetLabels(json);
|
||||
|
||||
return R.success(res, "Alert retrieved.", { data: json });
|
||||
} catch (err) {
|
||||
console.error("[NOTIFICATION BROADCAST][GET ONE]", err);
|
||||
return R.error(res, "Internal server error.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── CREATE ───────────────────────────────────────────────────────────────────
|
||||
|
||||
exports.createBroadcast = async (req, res) => {
|
||||
try {
|
||||
const {
|
||||
title,
|
||||
message,
|
||||
link_url,
|
||||
link_label,
|
||||
color,
|
||||
image_asset_id,
|
||||
target_type,
|
||||
target_id,
|
||||
createdBy,
|
||||
show_in_sticky,
|
||||
show_in_notifications,
|
||||
start_date,
|
||||
end_date,
|
||||
} = req.body;
|
||||
|
||||
if (!title) return R.error(res, "title is required.", 400);
|
||||
if (!target_type) return R.error(res, "target_type is required.", 400);
|
||||
if (!ALLOWED_TARGET_TYPES.includes(target_type)) return R.error(res, `Invalid target_type. Must be one of: ${ALLOWED_TARGET_TYPES.join(", ")}`, 400);
|
||||
if (SCOPED_TARGET_TYPES.includes(target_type) && !target_id) return R.error(res, "target_id is required for this target_type.", 400);
|
||||
if (!createdBy) return R.error(res, "createdBy is required.", 400);
|
||||
|
||||
const showSticky = show_in_sticky ?? false;
|
||||
const showNotifs = show_in_notifications ?? true;
|
||||
if (!showSticky && !showNotifs) {
|
||||
return R.error(res, "At least one of show_in_sticky or show_in_notifications must be enabled.", 400);
|
||||
}
|
||||
if (showSticky && showNotifs) {
|
||||
return R.error(res, "Choose only one: Sticky or Notifications.", 400);
|
||||
}
|
||||
if (showNotifs && !message) {
|
||||
return R.error(res, "message is required for Notifications alerts.", 400);
|
||||
}
|
||||
|
||||
if (start_date && end_date && new Date(start_date) > new Date(end_date)) {
|
||||
return R.error(res, "Start date must be before end date.", 400);
|
||||
}
|
||||
|
||||
if (SCOPED_TARGET_TYPES.includes(target_type)) await validateTargetId(target_type, target_id);
|
||||
|
||||
const validatedImageAssetId = await validateImageAssetId(image_asset_id);
|
||||
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const broadcast = await NotificationBroadcast.build({
|
||||
title,
|
||||
message: message || null,
|
||||
link_url: link_url?.trim() || null,
|
||||
link_label: link_label?.trim() || null,
|
||||
color: color || 'indigo',
|
||||
image_asset_id: validatedImageAssetId,
|
||||
start_date: start_date || null,
|
||||
end_date: end_date || null,
|
||||
createdBy,
|
||||
status: 'draft',
|
||||
target_type,
|
||||
target_id: SCOPED_TARGET_TYPES.includes(target_type) ? String(target_id) : null,
|
||||
show_in_sticky: showSticky,
|
||||
show_in_notifications: showNotifs,
|
||||
});
|
||||
await broadcast.save({ transaction: t });
|
||||
await t.commit();
|
||||
|
||||
logActivity(req.user?.user_id, 'create_notification_broadcast', { entityType: 'notification_broadcast', entityId: broadcast.broadcast_id, details: { target_type } });
|
||||
return R.success(res, "Alert created.", { data: broadcast }, 201);
|
||||
} catch (dbErr) {
|
||||
try { await t.rollback(); } catch { /* connection gone */ }
|
||||
throw dbErr;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[NOTIFICATION BROADCAST][CREATE]", err);
|
||||
if (err.status) return R.error(res, err.message, err.status);
|
||||
return R.error(res, "Internal server error.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── UPDATE ───────────────────────────────────────────────────────────────────
|
||||
|
||||
exports.updateBroadcast = async (req, res) => {
|
||||
try {
|
||||
const { broadcastId } = req.params;
|
||||
if (!broadcastId || broadcastId === "undefined") return R.error(res, "Invalid broadcast ID.", 400);
|
||||
|
||||
const broadcast = await NotificationBroadcast.findOne({ where: { broadcast_id: broadcastId, ...notDeleted } });
|
||||
if (!broadcast) return R.error(res, "Notification broadcast not found.", 404);
|
||||
|
||||
const wasActiveSticky = broadcast.status === 'sent' && broadcast.show_in_sticky;
|
||||
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
await applyBroadcastFields(broadcast, req.body);
|
||||
if (!broadcast.show_in_sticky && !broadcast.show_in_notifications) {
|
||||
const err = new Error("At least one of show_in_sticky or show_in_notifications must be enabled.");
|
||||
err.status = 400;
|
||||
throw err;
|
||||
}
|
||||
if (broadcast.show_in_sticky && broadcast.show_in_notifications) {
|
||||
const err = new Error("Choose only one: Sticky or Notifications.");
|
||||
err.status = 400;
|
||||
throw err;
|
||||
}
|
||||
if (broadcast.show_in_notifications && !broadcast.message) {
|
||||
const err = new Error("message is required for Notifications alerts.");
|
||||
err.status = 400;
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Editing a live broadcast to newly flip on show_in_sticky is the same
|
||||
// "activate a sticky slot" action as sendBroadcast — must respect the
|
||||
// same 3-slot cap, or it's a trivial bypass.
|
||||
if (broadcast.status === 'sent' && broadcast.show_in_sticky && !wasActiveSticky) {
|
||||
if ((await countActiveSticky(broadcast.broadcast_id)) >= MAX_ACTIVE_STICKY) {
|
||||
const err = new Error(ACTIVE_STICKY_CAP_MESSAGE);
|
||||
err.status = 409;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
broadcast.updatedBy = req.body.updatedBy ?? null;
|
||||
await broadcast.save({ transaction: t });
|
||||
|
||||
// Already-sent broadcasts have per-recipient rows created at send time
|
||||
// (see sendBroadcast) — propagate content/display edits into them so
|
||||
// changes show up immediately for anyone currently seeing it. Target/
|
||||
// audience fields are deliberately NOT propagated (see plan notes):
|
||||
// recipients were already resolved, and task_list's per-user groupId
|
||||
// deep-link (stored in each row's own `data`) must not be clobbered.
|
||||
if (broadcast.status === 'sent') {
|
||||
const propagated = {
|
||||
title: broadcast.title,
|
||||
// admin_notifications/user_notifications.message stays NOT NULL —
|
||||
// sticky-mode broadcasts have a null message here, so fall back to "".
|
||||
message: broadcast.message || "",
|
||||
color: broadcast.color,
|
||||
image_asset_id: broadcast.image_asset_id,
|
||||
show_in_sticky: broadcast.show_in_sticky,
|
||||
show_in_notifications: broadcast.show_in_notifications,
|
||||
start_date: broadcast.start_date,
|
||||
end_date: broadcast.end_date,
|
||||
linkUrl: broadcast.link_url,
|
||||
linkLabel: broadcast.link_label,
|
||||
broadcastId: broadcast.broadcast_id,
|
||||
};
|
||||
|
||||
for (const table of ['admin_notifications', 'user_notifications']) {
|
||||
await sequelize.query(
|
||||
`UPDATE ${table}
|
||||
SET title = :title, message = :message, color = :color, image_asset_id = :image_asset_id,
|
||||
show_in_sticky = :show_in_sticky, show_in_notifications = :show_in_notifications,
|
||||
start_date = :start_date, end_date = :end_date,
|
||||
data = data || jsonb_build_object('linkUrl', :linkUrl, 'linkLabel', :linkLabel)
|
||||
WHERE broadcast_id = :broadcastId`,
|
||||
{ replacements: propagated, transaction: t }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await t.commit();
|
||||
|
||||
logActivity(req.user?.user_id, 'update_notification_broadcast', { entityType: 'notification_broadcast', entityId: Number(broadcastId) });
|
||||
return R.success(res, "Alert updated.", { data: broadcast });
|
||||
} catch (dbErr) {
|
||||
try { await t.rollback(); } catch { /* gone */ }
|
||||
throw dbErr;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[NOTIFICATION BROADCAST][UPDATE]", err);
|
||||
if (err.status) return R.error(res, err.message, err.status);
|
||||
return R.error(res, "Internal server error.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── SEND ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
exports.sendBroadcast = async (req, res) => {
|
||||
try {
|
||||
const { broadcastId } = req.params;
|
||||
if (!broadcastId || broadcastId === "undefined") return R.error(res, "Invalid broadcast ID.", 400);
|
||||
|
||||
const broadcast = await NotificationBroadcast.findOne({ where: { broadcast_id: broadcastId, ...notDeleted } });
|
||||
if (!broadcast) return R.error(res, "Alert not found.", 404);
|
||||
if (broadcast.status !== 'draft') return R.error(res, "Only draft broadcasts can be sent.", 400);
|
||||
|
||||
if (broadcast.show_in_sticky && (await countActiveSticky(broadcast.broadcast_id)) >= MAX_ACTIVE_STICKY) {
|
||||
return R.error(res, ACTIVE_STICKY_CAP_MESSAGE, 409);
|
||||
}
|
||||
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const now = new Date();
|
||||
let recipientCount = 0;
|
||||
|
||||
const targetType = broadcast.target_type;
|
||||
const targetId = broadcast.target_id;
|
||||
const showInSticky = !!broadcast.show_in_sticky;
|
||||
const showInNotifications = !!broadcast.show_in_notifications;
|
||||
|
||||
const baseNotify = NOTIFICATION_REGISTRY.broadcast.build({
|
||||
title: broadcast.title,
|
||||
message: broadcast.message || "",
|
||||
targetType,
|
||||
targetId,
|
||||
linkUrl: broadcast.link_url,
|
||||
linkLabel: broadcast.link_label,
|
||||
});
|
||||
|
||||
if (targetType === 'admin' || targetType === 'both') {
|
||||
await AdminNotification.create(
|
||||
{ ...baseNotify, seen: false, show_in_sticky: showInSticky, show_in_notifications: showInNotifications, color: broadcast.color, image_asset_id: broadcast.image_asset_id, start_date: broadcast.start_date, end_date: broadcast.end_date, broadcast_id: broadcast.broadcast_id },
|
||||
{ transaction: t }
|
||||
);
|
||||
recipientCount += 1;
|
||||
}
|
||||
|
||||
let userIds = [];
|
||||
let groupByUser = {}; // only populated for task_list — one group_id per user, for deep-linking
|
||||
|
||||
if (targetType === 'user' || targetType === 'both') {
|
||||
const users = await mdl_Users.findAll({
|
||||
attributes: ['user_id'],
|
||||
where: { acc_type: 'user', deletedAt: null },
|
||||
raw: true,
|
||||
transaction: t,
|
||||
});
|
||||
userIds = users.map((u) => String(u.user_id));
|
||||
} else if (targetType === 'task_list') {
|
||||
groupByUser = await resolveTaskListUserGroups(targetId);
|
||||
userIds = Object.keys(groupByUser);
|
||||
} else if (SCOPED_TARGET_TYPES.includes(targetType)) {
|
||||
userIds = await resolveTargetUserIds(targetType, targetId);
|
||||
}
|
||||
|
||||
if (userIds.length) {
|
||||
await UserNotification.bulkCreate(
|
||||
userIds.map((user_id) => ({
|
||||
user_id,
|
||||
...(targetType === 'task_list'
|
||||
? NOTIFICATION_REGISTRY.broadcast.build({
|
||||
title: broadcast.title, message: broadcast.message || "", targetType, targetId,
|
||||
groupId: groupByUser[user_id] ?? null,
|
||||
linkUrl: broadcast.link_url,
|
||||
linkLabel: broadcast.link_label,
|
||||
})
|
||||
: baseNotify),
|
||||
seen: false,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
show_in_sticky: showInSticky,
|
||||
show_in_notifications: showInNotifications,
|
||||
color: broadcast.color,
|
||||
image_asset_id: broadcast.image_asset_id,
|
||||
start_date: broadcast.start_date,
|
||||
end_date: broadcast.end_date,
|
||||
broadcast_id: broadcast.broadcast_id,
|
||||
})),
|
||||
{ validate: false, transaction: t }
|
||||
);
|
||||
}
|
||||
recipientCount += userIds.length;
|
||||
|
||||
broadcast.status = 'sent';
|
||||
broadcast.sent_at = now;
|
||||
broadcast.recipient_count = recipientCount;
|
||||
await broadcast.save({ transaction: t });
|
||||
await t.commit();
|
||||
|
||||
logActivity(req.user?.user_id, 'send_notification_broadcast', { entityType: 'notification_broadcast', entityId: Number(broadcastId), details: { target_type: targetType, target_id: broadcast.target_id, recipient_count: recipientCount } });
|
||||
return R.success(res, "Alert sent.", { data: broadcast });
|
||||
} catch (dbErr) {
|
||||
try { await t.rollback(); } catch { /* gone */ }
|
||||
throw dbErr;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[NOTIFICATION BROADCAST][SEND]", err);
|
||||
if (err.status) return R.error(res, err.message, err.status);
|
||||
return R.error(res, "Internal server error.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── ARCHIVE (single) ─────────────────────────────────────────────────────────
|
||||
|
||||
exports.archiveBroadcast = async (req, res) => {
|
||||
try {
|
||||
const { broadcastId } = req.params;
|
||||
if (!broadcastId || broadcastId === "undefined") return R.error(res, "Invalid broadcast ID.", 400);
|
||||
|
||||
const broadcast = await NotificationBroadcast.findOne({ where: { broadcast_id: broadcastId, ...notDeleted } });
|
||||
if (!broadcast) return R.error(res, "Alert not found.", 404);
|
||||
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
await broadcast.update({ deletedBy: req.body.deletedBy ?? null }, { transaction: t });
|
||||
await broadcast.destroy({ transaction: t });
|
||||
await propagateNotificationVisibility(
|
||||
{ sql: 'broadcast_id = :broadcastId', replacements: { broadcastId: broadcast.broadcast_id } },
|
||||
{ show_in_sticky: false, show_in_notifications: false },
|
||||
t
|
||||
);
|
||||
await t.commit();
|
||||
} catch (dbErr) {
|
||||
try { await t.rollback(); } catch { /* gone */ }
|
||||
throw dbErr;
|
||||
}
|
||||
|
||||
logActivity(req.user?.user_id, 'archive_notification_broadcast', { entityType: 'notification_broadcast', entityId: Number(broadcastId) });
|
||||
return R.success(res, "Alert archived.");
|
||||
} catch (err) {
|
||||
console.error("[NOTIFICATION BROADCAST][ARCHIVE]", err);
|
||||
return R.error(res, "Internal server error.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── ARCHIVE (bulk) ───────────────────────────────────────────────────────────
|
||||
|
||||
exports.archiveBroadcasts = 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 broadcasts = await NotificationBroadcast.findAll({ where: { broadcast_id: { [Op.in]: ids }, ...notDeleted } });
|
||||
if (!broadcasts.length) return R.error(res, "No notification broadcasts found.", 404);
|
||||
|
||||
const activeIds = broadcasts.map((b) => b.broadcast_id);
|
||||
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
await NotificationBroadcast.update({ deletedBy: deletedBy ?? null }, { where: { broadcast_id: { [Op.in]: activeIds } }, transaction: t });
|
||||
await NotificationBroadcast.destroy({ where: { broadcast_id: { [Op.in]: activeIds } }, transaction: t });
|
||||
await propagateNotificationVisibility(
|
||||
{ sql: 'broadcast_id IN (:activeIds)', replacements: { activeIds } },
|
||||
{ show_in_sticky: false, show_in_notifications: false },
|
||||
t
|
||||
);
|
||||
await t.commit();
|
||||
} catch (dbErr) {
|
||||
try { await t.rollback(); } catch { /* gone */ }
|
||||
throw dbErr;
|
||||
}
|
||||
|
||||
logActivity(req.user?.user_id, 'bulk_archive_notification_broadcasts', { entityType: 'notification_broadcast', details: { ids: activeIds, count: activeIds.length } });
|
||||
return R.success(res, `${activeIds.length} notification broadcast(s) archived.`, {
|
||||
archived_ids: activeIds,
|
||||
skipped_ids: ids.filter((id) => !activeIds.includes(id)),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[NOTIFICATION BROADCAST][BULK ARCHIVE]", err);
|
||||
return R.error(res, "Internal server error.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── RESTORE (single) ─────────────────────────────────────────────────────────
|
||||
|
||||
exports.restoreBroadcast = async (req, res) => {
|
||||
try {
|
||||
const { broadcastId } = req.params;
|
||||
|
||||
const broadcast = await NotificationBroadcast.findOne({ where: { broadcast_id: broadcastId }, paranoid: false });
|
||||
if (!broadcast) return R.error(res, "Alert not found.", 404);
|
||||
if (!broadcast.deletedAt) return R.error(res, "Alert is not archived.", 400);
|
||||
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
await broadcast.restore({ transaction: t });
|
||||
await broadcast.update({ deletedBy: null }, { transaction: t });
|
||||
// Drafts never had per-recipient rows created — only propagate for
|
||||
// broadcasts that were actually sent.
|
||||
if (broadcast.status === 'sent') {
|
||||
await propagateNotificationVisibility(
|
||||
{ sql: 'broadcast_id = :broadcastId', replacements: { broadcastId: broadcast.broadcast_id } },
|
||||
{ show_in_sticky: broadcast.show_in_sticky, show_in_notifications: broadcast.show_in_notifications },
|
||||
t
|
||||
);
|
||||
}
|
||||
await t.commit();
|
||||
} catch (dbErr) {
|
||||
try { await t.rollback(); } catch { /* gone */ }
|
||||
throw dbErr;
|
||||
}
|
||||
|
||||
logActivity(req.user?.user_id, 'restore_notification_broadcast', { entityType: 'notification_broadcast', entityId: Number(broadcastId) });
|
||||
return R.success(res, "Alert restored.", { data: broadcast });
|
||||
} catch (err) {
|
||||
console.error("[NOTIFICATION BROADCAST][RESTORE]", err);
|
||||
return R.error(res, "Internal server error.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── RESTORE (bulk) ───────────────────────────────────────────────────────────
|
||||
|
||||
exports.restoreBroadcasts = 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 broadcasts = await NotificationBroadcast.findAll({ where: { broadcast_id: { [Op.in]: ids } }, paranoid: false });
|
||||
if (!broadcasts.length) return R.error(res, "No notification broadcasts found.", 404);
|
||||
|
||||
const archived = broadcasts.filter((b) => b.deletedAt);
|
||||
if (!archived.length) return R.error(res, "All selected notification broadcasts are already active.", 400);
|
||||
|
||||
const archivedIds = archived.map((b) => b.broadcast_id);
|
||||
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
await NotificationBroadcast.restore({ where: { broadcast_id: { [Op.in]: archivedIds } }, transaction: t });
|
||||
await NotificationBroadcast.update({ deletedBy: null }, { where: { broadcast_id: { [Op.in]: archivedIds } }, paranoid: false, transaction: t });
|
||||
|
||||
// Visibility can differ per broadcast, so this can't be a single flat
|
||||
// UPDATE like the archive side — loop and restore each one's own
|
||||
// show_in_sticky/show_in_notifications values.
|
||||
for (const b of archived) {
|
||||
if (b.status !== 'sent') continue;
|
||||
await propagateNotificationVisibility(
|
||||
{ sql: 'broadcast_id = :broadcastId', replacements: { broadcastId: b.broadcast_id } },
|
||||
{ show_in_sticky: b.show_in_sticky, show_in_notifications: b.show_in_notifications },
|
||||
t
|
||||
);
|
||||
}
|
||||
await t.commit();
|
||||
} catch (dbErr) {
|
||||
try { await t.rollback(); } catch { /* gone */ }
|
||||
throw dbErr;
|
||||
}
|
||||
|
||||
logActivity(req.user?.user_id, 'bulk_restore_notification_broadcasts', { entityType: 'notification_broadcast', details: { ids: archivedIds, count: archivedIds.length } });
|
||||
return R.success(res, `${archivedIds.length} notification broadcast(s) restored.`, {
|
||||
restored_ids: archivedIds,
|
||||
skipped_ids: ids.filter((id) => !archivedIds.includes(id)),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[NOTIFICATION BROADCAST][BULK RESTORE]", err);
|
||||
return R.error(res, "Internal server error.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── ARCHIVED LIST ────────────────────────────────────────────────────────────
|
||||
|
||||
exports.getArchivedBroadcasts = async (req, res) => {
|
||||
try {
|
||||
const result = await paginate(NotificationBroadcast, req, {
|
||||
excludeAttributes: adminExclude,
|
||||
jsonbSchemas,
|
||||
computedAttributes,
|
||||
context: "list",
|
||||
auditOptions: { mdl_Users, parentAlias: 'NotificationBroadcast' },
|
||||
findOptions: { paranoid: false, where: { deletedAt: { [Op.ne]: null } } },
|
||||
});
|
||||
return R.success(res, "Archived alerts retrieved.", result);
|
||||
} catch (err) {
|
||||
console.error("[NOTIFICATION BROADCAST][GET ARCHIVED]", err);
|
||||
return R.error(res, "Could not retrieve archived alerts.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── PERMANENT DELETE (single) ────────────────────────────────────────────────
|
||||
|
||||
exports.permanentlyDeleteBroadcast = async (req, res) => {
|
||||
try {
|
||||
const { broadcastId } = req.params;
|
||||
|
||||
const broadcast = await NotificationBroadcast.findOne({ where: { broadcast_id: broadcastId }, paranoid: false });
|
||||
if (!broadcast) return R.error(res, "Alert not found.", 404);
|
||||
if (!broadcast.deletedAt) return R.error(res, "Alert must be archived before it can be permanently deleted.", 400);
|
||||
|
||||
await broadcast.destroy({ force: true });
|
||||
logActivity(req.user?.user_id, 'permanently_delete_notification_broadcast', { entityType: 'notification_broadcast', entityId: Number(broadcastId) });
|
||||
return R.success(res, "Alert permanently deleted.");
|
||||
} catch (err) {
|
||||
console.error("[NOTIFICATION BROADCAST][PERMANENT DELETE]", err);
|
||||
return R.error(res, "Could not permanently delete alert.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── PERMANENT DELETE (bulk) ───────────────────────────────────────────────────
|
||||
|
||||
exports.permanentlyDeleteBroadcasts = 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 broadcasts = await NotificationBroadcast.findAll({ where: { broadcast_id: { [Op.in]: ids } }, paranoid: false });
|
||||
if (!broadcasts.length) return R.error(res, "No alerts found.", 404);
|
||||
|
||||
const archived = broadcasts.filter((b) => b.deletedAt);
|
||||
if (!archived.length) return R.error(res, "All selected alerts must be archived before they can be permanently deleted.", 400);
|
||||
|
||||
const archivedIds = archived.map((b) => b.broadcast_id);
|
||||
|
||||
await NotificationBroadcast.destroy({ where: { broadcast_id: { [Op.in]: archivedIds } }, force: true });
|
||||
|
||||
logActivity(req.user?.user_id, 'bulk_permanently_delete_notification_broadcasts', { entityType: 'notification_broadcast', details: { ids: archivedIds, count: archivedIds.length } });
|
||||
return R.success(res, `${archivedIds.length} alert(s) permanently deleted.`, {
|
||||
deleted_ids: archivedIds,
|
||||
skipped_ids: ids.filter((id) => !archivedIds.includes(id)),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[NOTIFICATION BROADCAST][BULK PERMANENT DELETE]", err);
|
||||
return R.error(res, "Could not permanently delete alerts.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
// controllers/admin/notificationSettings.controller.js
|
||||
|
||||
const CronNotificationSetting = require('../../models/notifications/cron_notification_setting.mdl');
|
||||
const R = require('../../utils/response.util');
|
||||
const logActivity = require('../../utils/logActivity.util');
|
||||
const { CRON_PRESETS, CRON_PRESET_BY_EXPRESSION } = require('../../data/cronPresets.data');
|
||||
const { rescheduleJob, getOrCreateSetting } = require('../../cron/cronRegistry.util');
|
||||
|
||||
// ─── Job registry — which cron scope owns each job (for defaults + labels) ────
|
||||
const JOBS = {
|
||||
taskOverdue: { schedule: '0 * * * *', label: 'Task Alerts (Admin)', description: 'Automatically marks expired tasks as overdue or completed, and notifies admins.' },
|
||||
userNotifications: { schedule: '5 * * * *', label: 'Task Alerts (Users)', description: 'Notifies affected users when their tasks are automatically marked overdue or completed.' },
|
||||
issueCertificates: { schedule: '0 * * * *', label: 'Certificate Issued', description: 'Notifies users when a course certificate is ready.' },
|
||||
expireUserTiers: { schedule: '* * * * *', label: 'Tier Expired', description: 'Notifies users when their subscription tier expires.' },
|
||||
};
|
||||
|
||||
// Jobs whose behavior can be tuned via target_status, and the values each accepts.
|
||||
const TARGET_STATUS_OPTIONS = ['overdue', 'completed'];
|
||||
const TARGET_STATUS_JOBS = ['taskOverdue'];
|
||||
|
||||
// ─── GET ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
exports.getSettings = async (req, res) => {
|
||||
try {
|
||||
const rows = [];
|
||||
for (const [job_name, meta] of Object.entries(JOBS)) {
|
||||
const row = await getOrCreateSetting(job_name, meta.schedule);
|
||||
rows.push({
|
||||
job_name,
|
||||
enabled: row.enabled,
|
||||
schedule: row.schedule,
|
||||
preset: CRON_PRESET_BY_EXPRESSION[row.schedule] ?? null,
|
||||
target_status: TARGET_STATUS_JOBS.includes(job_name)
|
||||
? (row.target_status ?? 'completed')
|
||||
: null,
|
||||
label: meta.label,
|
||||
description: meta.description,
|
||||
updatedAt: row.updatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
return R.success(res, 'Notification settings retrieved.', rows);
|
||||
} catch (err) {
|
||||
console.error('[NOTIFICATION SETTINGS][GET]', err);
|
||||
return R.error(res, 'Could not retrieve notification settings.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── UPDATE ───────────────────────────────────────────────────────────────────
|
||||
|
||||
exports.updateSetting = async (req, res) => {
|
||||
try {
|
||||
const { jobName } = req.params;
|
||||
const { enabled, preset, target_status, updatedBy } = req.body;
|
||||
|
||||
if (!JOBS[jobName]) return R.error(res, `Unknown job "${jobName}".`, 404);
|
||||
|
||||
const row = await CronNotificationSetting.findOne({ where: { job_name: jobName } });
|
||||
if (!row) return R.error(res, 'Setting not found.', 404);
|
||||
|
||||
if (enabled !== undefined) row.enabled = enabled === true || enabled === 'true';
|
||||
|
||||
if (target_status !== undefined) {
|
||||
if (!TARGET_STATUS_JOBS.includes(jobName)) {
|
||||
return R.error(res, `"target_status" is not configurable for job "${jobName}".`, 400);
|
||||
}
|
||||
if (!TARGET_STATUS_OPTIONS.includes(target_status)) {
|
||||
return R.error(res, `Invalid target_status. Must be one of: ${TARGET_STATUS_OPTIONS.join(', ')}`, 400);
|
||||
}
|
||||
row.target_status = target_status;
|
||||
}
|
||||
|
||||
if (preset !== undefined) {
|
||||
const schedule = CRON_PRESETS[preset];
|
||||
if (!schedule) return R.error(res, `Invalid preset. Must be one of: ${Object.keys(CRON_PRESETS).join(', ')}`, 400);
|
||||
row.schedule = schedule;
|
||||
|
||||
try {
|
||||
rescheduleJob(jobName, schedule);
|
||||
} catch (err) {
|
||||
console.error('[NOTIFICATION SETTINGS][RESCHEDULE]', err);
|
||||
return R.error(res, `Saved, but failed to reschedule the live job: ${err.message}`, 500);
|
||||
}
|
||||
}
|
||||
|
||||
row.updatedBy = updatedBy ?? null;
|
||||
await row.save();
|
||||
|
||||
logActivity(req.user?.user_id, 'update_notification_setting', { entityType: 'cron_notification_setting', entityId: jobName, details: { enabled: row.enabled, schedule: row.schedule, target_status: row.target_status } });
|
||||
return R.success(res, 'Notification setting updated.', { data: row });
|
||||
} catch (err) {
|
||||
console.error('[NOTIFICATION SETTINGS][UPDATE]', err);
|
||||
return R.error(res, 'Internal server error.', 500);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,121 @@
|
||||
'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 (generic, keyed by purchasable_type + purchasable_id) ───────────
|
||||
// Course/Unit/Lesson each get their own thin route + exported handler below,
|
||||
// all delegating to these so the CRUD logic isn't tripled across the three
|
||||
// content types — see routes/admin/products.routes.js.
|
||||
|
||||
async function getProductFor(purchasable_type, purchasable_id) {
|
||||
return mdl_Product.findOne({ where: { purchasable_type, purchasable_id }, paranoid: false });
|
||||
}
|
||||
|
||||
async function upsertProductFor(purchasable_type, purchasable_id, body, adminUserId) {
|
||||
const { name, description, price, currency, access_days, is_active } = body;
|
||||
if (!name || price == null) {
|
||||
const err = new Error('name and price are required.');
|
||||
err.status = 400;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const existing = await mdl_Product.findOne({ where: { purchasable_type, purchasable_id }, 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(adminUserId, `upsert_${purchasable_type}_product`, { entityType: 'product', entityId: existing.id, details: { purchasable_type, purchasable_id, name } });
|
||||
return { product: existing, created: false };
|
||||
}
|
||||
|
||||
const product = await mdl_Product.create({ purchasable_type, purchasable_id, name, description: description ?? null, price, currency: currency ?? 'USD', access_days: access_days ?? null, is_active: is_active ?? true });
|
||||
logActivity(adminUserId, `upsert_${purchasable_type}_product`, { entityType: 'product', entityId: product.id, details: { purchasable_type, purchasable_id, name } });
|
||||
return { product, created: true };
|
||||
}
|
||||
|
||||
async function removeProductFor(purchasable_type, purchasable_id, adminUserId) {
|
||||
const product = await mdl_Product.findOne({ where: { purchasable_type, purchasable_id } });
|
||||
if (!product) return false;
|
||||
await product.destroy();
|
||||
logActivity(adminUserId, `remove_${purchasable_type}_product`, { entityType: 'product', details: { purchasable_type, purchasable_id } });
|
||||
return true;
|
||||
}
|
||||
|
||||
function makeProductHandlers(purchasable_type, paramName) {
|
||||
return {
|
||||
get: async (req, res) => {
|
||||
try {
|
||||
const product = await getProductFor(purchasable_type, req.params[paramName]);
|
||||
return R.success(res, 'Product retrieved.', product ?? null);
|
||||
} catch (err) {
|
||||
console.error(`[ADMIN][PRODUCTS][GET][${purchasable_type}]`, err);
|
||||
return R.error(res, 'Could not retrieve product.', 500);
|
||||
}
|
||||
},
|
||||
upsert: async (req, res) => {
|
||||
try {
|
||||
const { product, created } = await upsertProductFor(purchasable_type, req.params[paramName], req.body, req.user?.user_id);
|
||||
return R.success(res, created ? 'Product created.' : 'Product updated.', product, created ? 201 : 200);
|
||||
} catch (err) {
|
||||
if (err.status === 400) return R.error(res, err.message, 400);
|
||||
console.error(`[ADMIN][PRODUCTS][UPSERT][${purchasable_type}]`, err);
|
||||
return R.error(res, 'Could not save product.', 500);
|
||||
}
|
||||
},
|
||||
remove: async (req, res) => {
|
||||
try {
|
||||
const removed = await removeProductFor(purchasable_type, req.params[paramName], req.user?.user_id);
|
||||
if (!removed) return R.error(res, 'Product not found.', 404);
|
||||
return R.success(res, 'Product removed.');
|
||||
} catch (err) {
|
||||
console.error(`[ADMIN][PRODUCTS][REMOVE][${purchasable_type}]`, err);
|
||||
return R.error(res, 'Could not remove product.', 500);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const courseProductHandlers = makeProductHandlers('course', 'courseId');
|
||||
|
||||
exports.getCourseProduct = courseProductHandlers.get;
|
||||
exports.upsertCourseProduct = courseProductHandlers.upsert;
|
||||
exports.removeCourseProduct = courseProductHandlers.remove;
|
||||
|
||||
// ─── 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);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,105 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* 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 { replaceUserAvatar, removeUserAvatar } = require('../../services/avatar.service');
|
||||
const { resolveUserAvatar } = require('../../utils/resolveAvatar.util');
|
||||
|
||||
// ─── 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.', await resolveUserAvatar(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.', await resolveUserAvatar(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);
|
||||
|
||||
const avatarMeta = await replaceUserAvatar(user, req.file);
|
||||
|
||||
const merged = { ...(user.personal_info || {}), avatar: avatarMeta };
|
||||
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.', await resolveUserAvatar(updated));
|
||||
} catch (err) {
|
||||
if (err.status === 400) return R.error(res, err.message, 400);
|
||||
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);
|
||||
|
||||
await removeUserAvatar(user);
|
||||
|
||||
const merged = { ...(user.personal_info || {}), avatar: null };
|
||||
await user.update({ personal_info: merged });
|
||||
|
||||
return R.success(res, 'Avatar removed.');
|
||||
} catch (err) {
|
||||
if (err.status === 404) return R.error(res, err.message, 404);
|
||||
console.error('[ADMIN] deleteAvatar error:', err);
|
||||
return R.error(res, 'Could not remove avatar.', 500);
|
||||
}
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,347 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* 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 UserNotification = require('../../models/notifications/user_notification.mdl');
|
||||
const { NOTIFICATION_REGISTRY } = require('../../data/notifications.data');
|
||||
const { checkTaskCompletion, fireTaskCompletedEvent } = require('../client/task.controller');
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── REVIEW ───────────────────────────────────────────────────────────────────
|
||||
// PATCH /admin/task-lists/:taskListId/tasks/:taskId/completions/:completionId/review
|
||||
// Approves/rejects a submission for a requirement flagged requires_review.
|
||||
// Notifies the submitting learner via the existing template pattern (same shape
|
||||
// as updateTask's task_requirements_updated notify block).
|
||||
|
||||
exports.reviewSubmission = async (req, res) => {
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const { taskListId, taskId, completionId } = req.params;
|
||||
const { status, review_note } = req.body;
|
||||
|
||||
if (!['approved', 'rejected'].includes(status)) {
|
||||
await t.rollback();
|
||||
return R.error(res, 'status must be "approved" or "rejected".', 400);
|
||||
}
|
||||
|
||||
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 completion = await TaskCompletion.findOne({
|
||||
where: { completion_id: completionId, task_id: taskId },
|
||||
transaction: t,
|
||||
});
|
||||
if (!completion) { await t.rollback(); return R.error(res, 'Completion not found.', 404); }
|
||||
|
||||
const wasComplete = status === 'approved' ? await checkTaskCompletion(completion.user_id, taskId) : false;
|
||||
|
||||
await completion.update({
|
||||
status,
|
||||
review_note: review_note || null,
|
||||
reviewed_by: req.user.user_id,
|
||||
reviewed_at: new Date(),
|
||||
updatedBy: req.user.user_id,
|
||||
}, { transaction: t });
|
||||
|
||||
await t.commit();
|
||||
|
||||
if (status === 'approved' && !wasComplete && await checkTaskCompletion(completion.user_id, taskId)) {
|
||||
fireTaskCompletedEvent(completion.user_id, taskId); // fire-and-forget
|
||||
}
|
||||
|
||||
logActivity(req.user.user_id, 'review_task_submission', {
|
||||
entityType: 'task_completion', entityId: completionId, details: { task_id: taskId, status },
|
||||
});
|
||||
|
||||
try {
|
||||
const notify = NOTIFICATION_REGISTRY.task_submission_reviewed.build({
|
||||
taskName: task.name, status, review_note: review_note || null,
|
||||
});
|
||||
await UserNotification.create({ user_id: completion.user_id, ...notify, seen: false });
|
||||
} catch (notifyErr) {
|
||||
console.error('[ADMIN][REVIEW SUBMISSION][NOTIFY]', notifyErr);
|
||||
}
|
||||
|
||||
return R.success(res, 'Submission reviewed.', completion);
|
||||
} catch (err) {
|
||||
await t.rollback();
|
||||
console.error('[ADMIN][REVIEW SUBMISSION]', err);
|
||||
return R.error(res, 'Could not review submission.', 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);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,138 @@
|
||||
'use strict';
|
||||
|
||||
const mdl_TierCategories = require('../../models/tiers/tier_categories.mdl');
|
||||
const mdl_TierPlans = require('../../models/tiers/tier_plans.mdl');
|
||||
const Asset = require('../../models/assets/assets.mdl');
|
||||
const R = require('../../utils/response.util');
|
||||
const logActivity = require('../../utils/logActivity.util');
|
||||
|
||||
require('../../models/tiers/tier.associations');
|
||||
|
||||
const ASSET_ATTRS = ['asset_id', 'file_url', 'display_name', 'mime_type'];
|
||||
|
||||
const withBadge = [{ model: Asset, as: 'badgeAsset', attributes: ASSET_ATTRS, required: false }];
|
||||
|
||||
// ─── GET /admin/tiers/categories ─────────────────────────────────────────────
|
||||
|
||||
exports.getCategories = async (req, res) => {
|
||||
try {
|
||||
const categories = await mdl_TierCategories.findAll({
|
||||
include: withBadge,
|
||||
order: [['rank', 'ASC']],
|
||||
});
|
||||
return R.success(res, 'Subscription categories retrieved.', categories);
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][GET TIER CATEGORIES]', err);
|
||||
return R.error(res, 'Could not retrieve subscription categories.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── GET /admin/tiers/categories/:id ─────────────────────────────────────────
|
||||
|
||||
exports.getCategory = async (req, res) => {
|
||||
try {
|
||||
const cat = await mdl_TierCategories.findByPk(req.params.id, { include: withBadge });
|
||||
if (!cat) return R.error(res, 'Subscription category not found.', 404);
|
||||
return R.success(res, 'Subscription category retrieved.', cat);
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][GET TIER CATEGORY]', err);
|
||||
return R.error(res, 'Could not retrieve subscription category.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── POST /admin/tiers/categories ────────────────────────────────────────────
|
||||
|
||||
exports.createCategory = async (req, res) => {
|
||||
try {
|
||||
const { slug, name, description, rank, color, badge_asset_id, badge_icon, badge_label, is_special } = req.body;
|
||||
if (!slug || !name) return R.error(res, 'slug and name are required.', 400);
|
||||
|
||||
const parsedRank = Number(rank ?? 1);
|
||||
if (parsedRank <= 0) return R.error(res, 'Non-default subscription categories must have rank greater than 0.', 400);
|
||||
|
||||
const exists = await mdl_TierCategories.findOne({ where: { slug } });
|
||||
if (exists) return R.error(res, `A subscription category with slug "${slug}" already exists.`, 409);
|
||||
|
||||
const cat = await mdl_TierCategories.create({
|
||||
slug, name,
|
||||
description: description ?? null,
|
||||
rank: parsedRank,
|
||||
color: color || 'purple',
|
||||
badge_asset_id: badge_asset_id || null,
|
||||
badge_icon: badge_icon || null,
|
||||
badge_label: badge_label ?? null,
|
||||
is_default: false,
|
||||
is_active: true,
|
||||
is_special: !!is_special,
|
||||
});
|
||||
|
||||
logActivity(req.user?.user_id, 'create_tier_category', { entityType: 'tier_category', details: { slug, name } });
|
||||
|
||||
const result = await mdl_TierCategories.findByPk(cat.tier_category_id, { include: withBadge });
|
||||
return R.success(res, 'Subscription category created.', result, 201);
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][CREATE TIER CATEGORY]', err);
|
||||
return R.error(res, 'Could not create subscription category.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── PUT /admin/tiers/categories/:id ─────────────────────────────────────────
|
||||
|
||||
exports.updateCategory = async (req, res) => {
|
||||
try {
|
||||
const cat = await mdl_TierCategories.findByPk(req.params.id);
|
||||
if (!cat) return R.error(res, 'Subscription category not found.', 404);
|
||||
|
||||
const { name, description, rank, color, badge_asset_id, badge_icon, badge_label, is_active, is_special } = req.body;
|
||||
|
||||
if (!cat.is_default && rank !== undefined) {
|
||||
const parsedRank = Number(rank);
|
||||
if (parsedRank <= 0) return R.error(res, 'Non-default subscription categories must have rank greater than 0.', 400);
|
||||
}
|
||||
|
||||
await cat.update({
|
||||
name: name ?? cat.name,
|
||||
description: description !== undefined ? (description || null) : cat.description,
|
||||
rank: rank !== undefined ? Number(rank) : cat.rank,
|
||||
color: color !== undefined ? (color || cat.color) : cat.color,
|
||||
badge_asset_id: badge_asset_id !== undefined ? (badge_asset_id || null) : cat.badge_asset_id,
|
||||
badge_icon: badge_icon !== undefined ? (badge_icon || null) : cat.badge_icon,
|
||||
badge_label: badge_label !== undefined ? (badge_label || null) : cat.badge_label,
|
||||
// Default category (free) cannot be deactivated
|
||||
is_active: (!cat.is_default && is_active !== undefined) ? is_active : cat.is_active,
|
||||
is_special: is_special !== undefined ? !!is_special : cat.is_special,
|
||||
});
|
||||
|
||||
logActivity(req.user?.user_id, 'update_tier_category', { entityType: 'tier_category', details: { id: cat.tier_category_id, slug: cat.slug } });
|
||||
|
||||
const result = await mdl_TierCategories.findByPk(cat.tier_category_id, { include: withBadge });
|
||||
return R.success(res, 'Subscription category updated.', result);
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][UPDATE TIER CATEGORY]', err);
|
||||
return R.error(res, 'Could not update subscription category.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── DELETE /admin/tiers/categories/:id ──────────────────────────────────────
|
||||
|
||||
exports.deleteCategory = async (req, res) => {
|
||||
try {
|
||||
const cat = await mdl_TierCategories.findByPk(req.params.id);
|
||||
if (!cat) return R.error(res, 'Subscription category not found.', 404);
|
||||
if (cat.is_default) return R.error(res, 'The default (Free) subscription category cannot be deleted.', 400);
|
||||
|
||||
// Block deletion if active plans still reference this category
|
||||
const activePlans = await mdl_TierPlans.count({
|
||||
where: { tier_category_id: cat.tier_category_id, is_active: true },
|
||||
});
|
||||
if (activePlans > 0)
|
||||
return R.error(res, `Cannot delete — ${activePlans} active plan(s) belong to this category. Archive or reassign them first.`, 409);
|
||||
|
||||
await cat.destroy();
|
||||
logActivity(req.user?.user_id, 'delete_tier_category', { entityType: 'tier_category', details: { slug: cat.slug } });
|
||||
return R.success(res, 'Subscription category deleted.');
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][DELETE TIER CATEGORY]', err);
|
||||
return R.error(res, 'Could not delete subscription category.', 500);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,174 @@
|
||||
'use strict';
|
||||
|
||||
const mdl_TierPlans = require('../../models/tiers/tier_plans.mdl');
|
||||
const mdl_PaymentPolicies = require('../../models/tiers/payment_policies.mdl');
|
||||
const mdl_SystemBadges = require('../../models/system_badges/system_badges.mdl');
|
||||
const Asset = require('../../models/assets/assets.mdl');
|
||||
const R = require('../../utils/response.util');
|
||||
const logActivity = require('../../utils/logActivity.util');
|
||||
|
||||
require('../../models/tiers/tier.associations');
|
||||
|
||||
const ASSET_ATTRS = ['asset_id', 'file_url', 'display_name', 'mime_type'];
|
||||
|
||||
// ─── PAYMENT POLICIES ─────────────────────────────────────────────────────────
|
||||
|
||||
const VALID_PROMO_TYPES = new Set(['flat', 'percent']);
|
||||
const VALID_WINDOW_UNITS = new Set(['minutes', 'hours', 'days']);
|
||||
|
||||
function validatePromoRules(rules) {
|
||||
if (!Array.isArray(rules)) return 'promo_rules must be an array.';
|
||||
for (const r of rules) {
|
||||
if (!r.code || typeof r.code !== 'string') return 'Each promo rule must have a code string.';
|
||||
if (!VALID_PROMO_TYPES.has(r.type)) return `Invalid promo type "${r.type}". Must be 'flat' or 'percent'.`;
|
||||
if (!r.value || Number(r.value) <= 0) return 'Promo rule value must be a positive number.';
|
||||
if (r.max_uses != null && (!Number.isInteger(r.max_uses) || r.max_uses < 1))
|
||||
return 'max_uses must be a positive integer.';
|
||||
if (r.expires_at != null && isNaN(new Date(r.expires_at).getTime()))
|
||||
return 'expires_at must be a valid ISO date string.';
|
||||
if (r.min_amount != null && Number(r.min_amount) < 0)
|
||||
return 'min_amount must be a non-negative number.';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function validateRefundPolicy(rp) {
|
||||
if (typeof rp !== 'object' || rp === null || Array.isArray(rp))
|
||||
return 'refund_policy must be an object.';
|
||||
if (rp.allowed != null && typeof rp.allowed !== 'boolean')
|
||||
return 'refund_policy.allowed must be a boolean.';
|
||||
if (rp.window_unit != null && !VALID_WINDOW_UNITS.has(rp.window_unit))
|
||||
return `refund_policy.window_unit must be 'minutes', 'hours', or 'days'.`;
|
||||
if (rp.window_value != null && (typeof rp.window_value !== 'number' || rp.window_value <= 0))
|
||||
return 'refund_policy.window_value must be a positive number.';
|
||||
return null;
|
||||
}
|
||||
|
||||
exports.getPaymentPolicy = async (req, res) => {
|
||||
try {
|
||||
const plan = await mdl_TierPlans.findByPk(req.params.planId);
|
||||
if (!plan) return R.error(res, 'Plan not found.', 404);
|
||||
|
||||
const policy = await mdl_PaymentPolicies.findOne({ where: { plan_id: req.params.planId } });
|
||||
return R.success(res, 'Payment policy retrieved.', policy ?? null);
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][GET PAYMENT POLICY]', err);
|
||||
return R.error(res, 'Could not retrieve payment policy.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.upsertPaymentPolicy = async (req, res) => {
|
||||
try {
|
||||
const { planId } = req.params;
|
||||
|
||||
const plan = await mdl_TierPlans.findByPk(planId);
|
||||
if (!plan) return R.error(res, 'Plan not found.', 404);
|
||||
|
||||
const { promo_rules, refund_policy, allowed_providers } = req.body;
|
||||
|
||||
if (promo_rules !== undefined) {
|
||||
const err = validatePromoRules(promo_rules);
|
||||
if (err) return R.error(res, err, 400);
|
||||
}
|
||||
|
||||
if (refund_policy !== undefined) {
|
||||
const err = validateRefundPolicy(refund_policy);
|
||||
if (err) return R.error(res, err, 400);
|
||||
}
|
||||
|
||||
if (allowed_providers !== undefined) {
|
||||
if (!Array.isArray(allowed_providers) || !allowed_providers.every((p) => typeof p === 'string'))
|
||||
return R.error(res, 'allowed_providers must be an array of provider name strings.', 400);
|
||||
}
|
||||
|
||||
let existing = await mdl_PaymentPolicies.findOne({ where: { plan_id: planId } });
|
||||
|
||||
const DEFAULTS = { allowed: true, window_value: 5, window_unit: 'minutes', reason_required: false };
|
||||
|
||||
const payload = {
|
||||
plan_id: planId,
|
||||
promo_rules: promo_rules !== undefined ? promo_rules : (existing?.promo_rules ?? []),
|
||||
refund_policy: refund_policy !== undefined ? refund_policy : (existing?.refund_policy ?? DEFAULTS),
|
||||
allowed_providers: allowed_providers !== undefined ? allowed_providers : (existing?.allowed_providers ?? ['paypal']),
|
||||
};
|
||||
|
||||
if (!existing) {
|
||||
existing = await mdl_PaymentPolicies.create(payload);
|
||||
logActivity(req.user?.user_id, 'create_payment_policy', { entityType: 'payment_policy', details: { plan_id: planId } });
|
||||
} else {
|
||||
await existing.update(payload);
|
||||
logActivity(req.user?.user_id, 'update_payment_policy', { entityType: 'payment_policy', details: { plan_id: planId } });
|
||||
}
|
||||
|
||||
const result = await mdl_PaymentPolicies.findOne({ where: { plan_id: planId } });
|
||||
return R.success(res, 'Payment policy saved.', result);
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][UPSERT PAYMENT POLICY]', err);
|
||||
return R.error(res, 'Could not save payment policy.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── SYSTEM BADGES ────────────────────────────────────────────────────────────
|
||||
|
||||
exports.getSystemBadges = async (req, res) => {
|
||||
try {
|
||||
const badges = await mdl_SystemBadges.findAll({
|
||||
include: [{ model: Asset, as: 'asset', attributes: ASSET_ATTRS, required: false }],
|
||||
order: [['key', 'ASC']],
|
||||
});
|
||||
return R.success(res, 'System badges retrieved.', badges);
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][GET SYSTEM BADGES]', err);
|
||||
return R.error(res, 'Could not retrieve system badges.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.getSystemBadge = async (req, res) => {
|
||||
try {
|
||||
const badge = await mdl_SystemBadges.findOne({
|
||||
where: { key: req.params.key },
|
||||
include: [{ model: Asset, as: 'asset', attributes: ASSET_ATTRS, required: false }],
|
||||
});
|
||||
if (!badge) return R.error(res, 'System badge not found.', 404);
|
||||
return R.success(res, 'System badge retrieved.', badge);
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][GET SYSTEM BADGE]', err);
|
||||
return R.error(res, 'Could not retrieve system badge.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.upsertSystemBadge = async (req, res) => {
|
||||
try {
|
||||
const { key } = req.params;
|
||||
const { asset_id, label, description, information, active_from, active_until } = req.body;
|
||||
|
||||
let existing = await mdl_SystemBadges.findOne({ where: { key } });
|
||||
|
||||
const payload = {
|
||||
key,
|
||||
asset_id: asset_id !== undefined ? (asset_id || null) : existing?.asset_id ?? null,
|
||||
label: label ?? existing?.label ?? key,
|
||||
description: description ?? existing?.description ?? null,
|
||||
information: information ?? existing?.information ?? null,
|
||||
active_from: active_from !== undefined ? (active_from || null) : existing?.active_from ?? null,
|
||||
active_until: active_until !== undefined ? (active_until || null) : existing?.active_until ?? null,
|
||||
};
|
||||
|
||||
if (!existing) {
|
||||
existing = await mdl_SystemBadges.create(payload);
|
||||
logActivity(req.user?.user_id, 'create_system_badge', { entityType: 'system_badge', details: { key } });
|
||||
} else {
|
||||
await existing.update(payload);
|
||||
logActivity(req.user?.user_id, 'update_system_badge', { entityType: 'system_badge', details: { key } });
|
||||
}
|
||||
|
||||
const result = await mdl_SystemBadges.findOne({
|
||||
where: { key },
|
||||
include: [{ model: Asset, as: 'asset', attributes: ASSET_ATTRS, required: false }],
|
||||
});
|
||||
return R.success(res, 'System badge saved.', result);
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][UPSERT SYSTEM BADGE]', err);
|
||||
return R.error(res, 'Could not save system badge.', 500);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,714 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* 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, ForeignKeyConstraintError } = require('sequelize');
|
||||
|
||||
const mdl_TierCategories = require('../../models/tiers/tier_categories.mdl');
|
||||
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 mdl_PlanUnits = require('../../models/tiers/plan_units.mdl');
|
||||
const mdl_PlanLessons = require('../../models/tiers/plan_lessons.mdl');
|
||||
const { Course } = require('../../models/courses/courses.mdl');
|
||||
const Unit = require('../../models/courses/units.mdl');
|
||||
const Lesson = require('../../models/courses/lessons.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 { snapshotPlanGrants } = require('../../services/tierGrants.service');
|
||||
const { revokePlanSubscriberAccess, revokePlanSubscriberAccessBulk } = require('../../services/planAccess.service');
|
||||
|
||||
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 cc = require('currency-codes');
|
||||
|
||||
const PENDING_PAYMENT_EXPIRY_MINUTES = 60;
|
||||
|
||||
// ─── CURRENCIES ───────────────────────────────────────────────────────────────
|
||||
|
||||
exports.getCurrencies = (req, res) => {
|
||||
const list = cc.codes().map((code) => {
|
||||
const entry = cc.code(code);
|
||||
return { code: entry.code, name: entry.currency };
|
||||
}).sort((a, b) => a.code.localeCompare(b.code));
|
||||
return R.success(res, 'OK', list);
|
||||
};
|
||||
|
||||
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',
|
||||
auditOptions: { mdl_Users, parentAlias: 'TierPlan' },
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
const DURATION_UNIT_TO_DAYS = { minute: 1 / 1440, hour: 1 / 24, day: 1, month: 30, year: 365 };
|
||||
|
||||
function computeDurationDays(value, unit) {
|
||||
const multiplier = DURATION_UNIT_TO_DAYS[unit] ?? 1;
|
||||
return parseFloat(value) * multiplier;
|
||||
}
|
||||
|
||||
exports.createPlan = async (req, res) => {
|
||||
try {
|
||||
const { tier_category_id, label, description, features, duration_value, duration_unit = 'day', price, currency, createdBy, status } = req.body;
|
||||
if (!tier_category_id || !label || !duration_value || !price)
|
||||
return R.error(res, 'tier_category_id, label, duration_value, and price are required.', 400);
|
||||
|
||||
const category = await mdl_TierCategories.findByPk(tier_category_id);
|
||||
if (!category || !category.is_active)
|
||||
return R.error(res, 'Subscription category not found or inactive.', 404);
|
||||
|
||||
if (category.is_default)
|
||||
return R.error(res, 'Plans cannot be created under the default (Free) subscription. Free access is automatic.', 400);
|
||||
|
||||
const duration_days = computeDurationDays(duration_value, duration_unit);
|
||||
|
||||
const plan = await mdl_TierPlans.create({
|
||||
tier_category_id: category.tier_category_id,
|
||||
tier: category.slug,
|
||||
label, description, features, duration_days, duration_unit, price, currency,
|
||||
status: status ?? 'draft',
|
||||
createdBy: createdBy ?? req.user?.user_id ?? null,
|
||||
});
|
||||
const plain = plan.get({ plain: true });
|
||||
logActivity(req.user?.user_id, 'create_tier_plan', { entityType: 'tier_plan', details: { label: plain.label, tier: plain.tier } });
|
||||
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', 'description', 'features', 'price', 'currency', 'is_active', 'is_recommended', 'status', 'tier_category_id'];
|
||||
const updates = {};
|
||||
for (const k of allowed) {
|
||||
if (req.body[k] !== undefined) updates[k] = req.body[k];
|
||||
}
|
||||
updates.updatedBy = req.body.updatedBy ?? req.user?.user_id ?? null;
|
||||
|
||||
// Recompute duration_days when value or unit changes
|
||||
const { duration_value, duration_unit } = req.body;
|
||||
if (duration_value !== undefined) {
|
||||
const unit = duration_unit ?? plan.duration_unit ?? 'day';
|
||||
updates.duration_days = computeDurationDays(duration_value, unit);
|
||||
updates.duration_unit = unit;
|
||||
} else if (duration_unit !== undefined) {
|
||||
updates.duration_unit = duration_unit;
|
||||
}
|
||||
|
||||
// If tier_category_id is being changed, sync the tier slug
|
||||
if (updates.tier_category_id) {
|
||||
const category = await mdl_TierCategories.findByPk(updates.tier_category_id);
|
||||
if (!category || !category.is_active)
|
||||
return R.error(res, 'Subscription category not found or inactive.', 404);
|
||||
if (category.is_default)
|
||||
return R.error(res, 'Plans cannot be moved to the Free subscription category.', 400);
|
||||
updates.tier = category.slug;
|
||||
}
|
||||
|
||||
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.getPlanImpact = async (req, res) => {
|
||||
try {
|
||||
const plan = await mdl_TierPlans.findByPk(req.params.id);
|
||||
if (!plan) return R.error(res, 'Plan not found.', 404);
|
||||
|
||||
const active_subscriber_count = await mdl_UserTiers.count({
|
||||
where: { plan_id: req.params.id, status: 'active' },
|
||||
});
|
||||
|
||||
return R.success(res, 'Plan impact retrieved.', { active_subscriber_count });
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][GET PLAN IMPACT]', err);
|
||||
return R.error(res, 'Could not retrieve plan impact.', 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, deletedBy: req.user?.user_id ?? null });
|
||||
await plan.destroy();
|
||||
|
||||
// Archiving always force-revokes current subscribers' access (no refund) —
|
||||
// fires tier_plan_access_revoked (see revokePlanSubscriberAccess), not the
|
||||
// old "access unaffected" tier_plan_archived notice, since that's no
|
||||
// longer true.
|
||||
let revoked_user_count = 0;
|
||||
try {
|
||||
({ revoked_user_count } = await revokePlanSubscriberAccess(plan, req.user?.user_id ?? null));
|
||||
} catch (revokeErr) {
|
||||
console.error('[ADMIN][ARCHIVE PLAN][REVOKE ACCESS]', revokeErr);
|
||||
}
|
||||
|
||||
logActivity(req.user?.user_id, 'archive_tier_plan', { entityType: 'tier_plan', details: { label: plan.label, revoked_user_count } });
|
||||
return R.success(res, 'Plan archived successfully.', { revoked_user_count });
|
||||
} 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, deletedBy: req.user?.user_id ?? null }, { where: { plan_id: activeIds } });
|
||||
await mdl_TierPlans.destroy({ where: { plan_id: activeIds } });
|
||||
|
||||
// Archiving always force-revokes current subscribers' access (no refund) —
|
||||
// one batched call across all selected plans (each still fires its own
|
||||
// tier_plan_access_revoked with its own label) instead of one revoke call
|
||||
// per plan, so this stays O(1) DB round trips regardless of selection size.
|
||||
let revoked_user_count = 0;
|
||||
try {
|
||||
({ revoked_user_count } = await revokePlanSubscriberAccessBulk(activePlans, req.user?.user_id ?? null));
|
||||
} catch (revokeErr) {
|
||||
console.error('[ADMIN][BULK ARCHIVE PLANS][REVOKE ACCESS]', revokeErr);
|
||||
}
|
||||
|
||||
logActivity(req.user?.user_id, 'bulk_archive_tier_plans', { entityType: 'tier_plan', details: { ids: activeIds, count: activeIds.length, revoked_user_count } });
|
||||
return R.success(res, `${activeIds.length} plan(s) archived successfully.`, {
|
||||
archived_ids: activeIds,
|
||||
skipped_ids: ids.filter((id) => !activeIds.includes(id)),
|
||||
revoked_user_count,
|
||||
});
|
||||
} 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.getPlanPermanentDeleteImpact = 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);
|
||||
|
||||
const active_subscriber_count = await mdl_UserTiers.count({
|
||||
where: { plan_id: req.params.id, status: 'active' },
|
||||
});
|
||||
const payment_count = await mdl_Payments.count({ where: { plan_id: req.params.id } });
|
||||
|
||||
return R.success(res, 'Plan permanent-delete impact retrieved.', { active_subscriber_count, payment_count });
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][GET PLAN PERMANENT DELETE IMPACT]', err);
|
||||
return R.error(res, 'Could not retrieve plan permanent-delete impact.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.permanentlyDeletePlan = 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 must be archived before it can be permanently deleted.', 400);
|
||||
|
||||
// A plan may still have active subscribers if it was archived before the
|
||||
// auto-revoke-on-archive behavior existed, or if revoking failed the
|
||||
// first time — permanently deleting it must not leave them with orphaned
|
||||
// access (user_tiers.plan_id would just go NULL on delete, not revoke).
|
||||
const { revoked_user_count } = await revokePlanSubscriberAccess(plan, req.user?.user_id ?? null);
|
||||
|
||||
// payments.plan_id is RESTRICT at the DB level (payments are paranoid/soft-deleted
|
||||
// by default) — a plan can't be force-destroyed while payment rows still
|
||||
// reference it, so those rows are force-destroyed first. This permanently
|
||||
// erases that plan's payment/billing history; there is no undo.
|
||||
const deleted_payment_count = await mdl_Payments.destroy({ where: { plan_id: plan.plan_id }, force: true });
|
||||
|
||||
await plan.destroy({ force: true });
|
||||
logActivity(req.user?.user_id, 'permanently_delete_tier_plan', { entityType: 'tier_plan', details: { label: plan.label, deleted_payment_count, revoked_user_count } });
|
||||
return R.success(res, 'Plan permanently deleted.', { deleted_payment_count, revoked_user_count });
|
||||
} catch (err) {
|
||||
if (err instanceof ForeignKeyConstraintError) {
|
||||
return R.error(res, 'Cannot delete: this plan still has records on file referencing it.', 400);
|
||||
}
|
||||
console.error('[ADMIN][PERMANENT DELETE PLAN]', err);
|
||||
return R.error(res, 'Could not permanently delete plan.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.bulkPermanentlyDeletePlans = 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 must be archived before they can be permanently deleted.', 400);
|
||||
|
||||
const archivedIds = archivedPlans.map((p) => p.plan_id);
|
||||
|
||||
// Same reasoning as the single-delete path above: revoke any remaining
|
||||
// active subscribers (each plan still gets its own label on the
|
||||
// notification/email) before the records are gone for good — batched in
|
||||
// one call across all selected plans instead of one call per plan.
|
||||
const { revoked_user_count } = await revokePlanSubscriberAccessBulk(archivedPlans, req.user?.user_id ?? null);
|
||||
|
||||
// payments.plan_id is RESTRICT at the DB level (payments are paranoid/soft-deleted
|
||||
// by default) — plans can't be force-destroyed while payment rows still
|
||||
// reference them, so those rows are force-destroyed first. This permanently
|
||||
// erases these plans' payment/billing history; there is no undo.
|
||||
const deleted_payment_count = await mdl_Payments.destroy({ where: { plan_id: archivedIds }, force: true });
|
||||
|
||||
await mdl_TierPlans.destroy({ where: { plan_id: archivedIds }, force: true });
|
||||
|
||||
logActivity(req.user?.user_id, 'bulk_permanently_delete_tier_plans', { entityType: 'tier_plan', details: { ids: archivedIds, count: archivedIds.length, deleted_payment_count, revoked_user_count } });
|
||||
return R.success(res, `${archivedIds.length} plan(s) permanently deleted.`, {
|
||||
deleted_ids: archivedIds,
|
||||
skipped_ids: ids.filter((id) => !archivedIds.includes(id)),
|
||||
deleted_payment_count,
|
||||
revoked_user_count,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof ForeignKeyConstraintError) {
|
||||
return R.error(res, 'Cannot delete: one or more selected plans still have records on file referencing them.', 400);
|
||||
}
|
||||
console.error('[ADMIN][BULK PERMANENT DELETE PLANS]', err);
|
||||
return R.error(res, 'Could not permanently delete 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 subscriptions retrieved.', tiers);
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][GET USER TIERS]', err);
|
||||
return R.error(res, 'Could not retrieve user subscriptions.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.grantTier = async (req, res) => {
|
||||
try {
|
||||
const { user_id, plan_id, notes } = req.body;
|
||||
if (!user_id || !plan_id)
|
||||
return R.error(res, 'user_id 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);
|
||||
const tier = plan.tier;
|
||||
|
||||
// Keyed on plan_id, not tier — a user may already hold a different plan
|
||||
// at this same tier slug (Tier Plans v2 allows multiple concurrently
|
||||
// active plans per tier); only re-granting the exact same plan is blocked.
|
||||
const existingActive = await mdl_UserTiers.findOne({
|
||||
where: { user_id, plan_id, status: 'active' },
|
||||
});
|
||||
if (existingActive) {
|
||||
return R.error(res, `User already has this plan active until ${existingActive.expires_at}.`, 409);
|
||||
}
|
||||
|
||||
const startsAt = new Date();
|
||||
const expiresAt = new Date(startsAt.getTime() + plan.duration_days * 86400000);
|
||||
|
||||
const newTier = await mdl_UserTiers.create({
|
||||
user_id, tier, plan_id, status: 'active',
|
||||
starts_at: startsAt,
|
||||
expires_at: expiresAt,
|
||||
granted_by: req.user.user_id,
|
||||
notes,
|
||||
});
|
||||
|
||||
await snapshotPlanGrants(newTier, plan_id);
|
||||
|
||||
logActivity(req.user.user_id, 'grant_tier', { entityType: 'tier', details: { user_id, tier, plan_id } });
|
||||
return R.success(res, 'Subscription granted.', newTier, 201);
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][GRANT TIER]', err);
|
||||
return R.error(res, 'Could not grant subscription.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.revokeTier = async (req, res) => {
|
||||
try {
|
||||
const tierRecord = await mdl_UserTiers.findByPk(req.params.tid);
|
||||
if (!tierRecord) return R.error(res, 'Subscription record not found.', 404);
|
||||
if (tierRecord.status !== 'active') return R.error(res, 'Subscription is not active.', 400);
|
||||
|
||||
await tierRecord.update({
|
||||
status: 'revoked',
|
||||
revoked_by: req.user.user_id,
|
||||
revoked_at: new Date(),
|
||||
});
|
||||
|
||||
// Only fall back to free if the user has no other concurrently active tier —
|
||||
// revoking one subscription shouldn't drop them below a tier they still hold.
|
||||
const remainingActive = await mdl_UserTiers.count({
|
||||
where: { user_id: tierRecord.user_id, status: 'active' },
|
||||
});
|
||||
|
||||
if (remainingActive === 0) {
|
||||
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, remainingActive === 0 ? 'Subscription revoked. User downgraded to free.' : 'Subscription revoked.');
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][REVOKE TIER]', err);
|
||||
return R.error(res, 'Could not revoke subscription.', 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) {
|
||||
// A course may already belong to other plans — that's allowed (Tier Plans v2,
|
||||
// silent duplication across bundles), so we only ever touch this plan's own rows.
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── PLAN UNITS ───────────────────────────────────────────────────────────────
|
||||
// Mirrors PLAN COURSES above — bundling/display only, not an access-control
|
||||
// mechanism (see canAccessUnit in controllers/client/courses.controller.js).
|
||||
|
||||
exports.getPlanUnits = async (req, res) => {
|
||||
try {
|
||||
const entries = await mdl_PlanUnits.findAll({
|
||||
where: { plan_id: req.params.id },
|
||||
include: [{
|
||||
model: Unit,
|
||||
as: 'unit',
|
||||
attributes: ['unit_id', 'title', 'subscription'],
|
||||
}],
|
||||
order: [['createdAt', 'ASC']],
|
||||
});
|
||||
return R.success(res, 'Plan units retrieved.', entries.map(e => e.unit));
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][GET PLAN UNITS]', err);
|
||||
return R.error(res, 'Could not retrieve plan units.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.syncPlanUnits = async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { unit_ids = [] } = req.body;
|
||||
|
||||
if (!Array.isArray(unit_ids))
|
||||
return R.error(res, 'unit_ids must be an array.', 400);
|
||||
|
||||
const plan = await mdl_TierPlans.findByPk(id);
|
||||
if (!plan) return R.error(res, 'Plan not found.', 404);
|
||||
|
||||
await mdl_PlanUnits.destroy({ where: { plan_id: id } });
|
||||
|
||||
if (unit_ids.length) {
|
||||
// A unit may already belong to other plans — that's allowed (Tier Plans v2,
|
||||
// silent duplication across bundles), so we only ever touch this plan's own rows.
|
||||
await mdl_PlanUnits.bulkCreate(
|
||||
unit_ids.map(unit_id => ({ plan_id: id, unit_id })),
|
||||
);
|
||||
}
|
||||
|
||||
logActivity(req.user?.user_id, 'sync_plan_units', { entityType: 'tier_plan', details: { plan_id: id, unit_ids, count: unit_ids.length } });
|
||||
return R.success(res, 'Plan units updated.');
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][SYNC PLAN UNITS]', err);
|
||||
return R.error(res, 'Could not update plan units.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── PLAN LESSONS ─────────────────────────────────────────────────────────────
|
||||
// Mirrors PLAN COURSES above — bundling/display only, not an access-control
|
||||
// mechanism (see canAccessLesson in controllers/client/courses.controller.js).
|
||||
|
||||
exports.getPlanLessons = async (req, res) => {
|
||||
try {
|
||||
const entries = await mdl_PlanLessons.findAll({
|
||||
where: { plan_id: req.params.id },
|
||||
include: [{
|
||||
model: Lesson,
|
||||
as: 'lesson',
|
||||
attributes: ['lesson_id', 'title', 'subscription'],
|
||||
}],
|
||||
order: [['createdAt', 'ASC']],
|
||||
});
|
||||
return R.success(res, 'Plan lessons retrieved.', entries.map(e => e.lesson));
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][GET PLAN LESSONS]', err);
|
||||
return R.error(res, 'Could not retrieve plan lessons.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.syncPlanLessons = async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { lesson_ids = [] } = req.body;
|
||||
|
||||
if (!Array.isArray(lesson_ids))
|
||||
return R.error(res, 'lesson_ids must be an array.', 400);
|
||||
|
||||
const plan = await mdl_TierPlans.findByPk(id);
|
||||
if (!plan) return R.error(res, 'Plan not found.', 404);
|
||||
|
||||
await mdl_PlanLessons.destroy({ where: { plan_id: id } });
|
||||
|
||||
if (lesson_ids.length) {
|
||||
// A lesson may already belong to other plans — that's allowed (Tier Plans v2,
|
||||
// silent duplication across bundles), so we only ever touch this plan's own rows.
|
||||
await mdl_PlanLessons.bulkCreate(
|
||||
lesson_ids.map(lesson_id => ({ plan_id: id, lesson_id })),
|
||||
);
|
||||
}
|
||||
|
||||
logActivity(req.user?.user_id, 'sync_plan_lessons', { entityType: 'tier_plan', details: { plan_id: id, lesson_ids, count: lesson_ids.length } });
|
||||
return R.success(res, 'Plan lessons updated.');
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][SYNC PLAN LESSONS]', err);
|
||||
return R.error(res, 'Could not update plan lessons.', 500);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,816 @@
|
||||
"use strict";
|
||||
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name: units.controller.js (admin)
|
||||
* Type of Program: Controller
|
||||
* Description: Standalone Unit library — Units live independently of Courses.
|
||||
*
|
||||
* /admin/units → library CRUD (list / create / update / archive / restore / permanent delete)
|
||||
* /admin/units/:unitId/lessons → attach / detach / reorder standalone Lessons on this Unit
|
||||
* /admin/units/:unitId/quiz → the Unit's quiz (travels with the Unit into every course it's attached to)
|
||||
*
|
||||
* Membership in a course is a course_units row (managed from the course builder);
|
||||
* archiving here removes the Unit from every course view at once.
|
||||
*
|
||||
* Author: Kenneth Obsequio (@lash0000)
|
||||
* Date Created: Jul. 7, 2026 (junction revamp — Units/Lessons run independently)
|
||||
***********************************************************************************************************************************************************************/
|
||||
|
||||
const { Op } = require("sequelize");
|
||||
const sequelize = require("../../config/db.config");
|
||||
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 { permanentDeleteOne, permanentDeleteMany } = require("../../utils/courses/permanentDelete.util");
|
||||
const { getFieldValues } = require("../../utils/fieldValues.util");
|
||||
const { flattenLessons, nextOrderIndex, reorderJunction } = require("../../utils/courses/hierarchy.util");
|
||||
const { recomputeUnitDuration, recomputeCourseDuration } = require("../../utils/duration.util");
|
||||
const { syncObjectivesCreate } = require("../../utils/courses/objectives.util");
|
||||
const logActivity = require("../../utils/logActivity.util");
|
||||
|
||||
// ── Models ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const {
|
||||
Course, Unit, Lesson, LessonPage,
|
||||
CourseUnit, UnitLesson, LessonObjective,
|
||||
UnitQuiz, QuizQuestion, QuizOption,
|
||||
UnitReadingProgress, LessonReadingProgress,
|
||||
} = require("../../models/courses/courses.associations");
|
||||
const CompletionRequirement = require("../../models/courses/completion_requirement.mdl");
|
||||
const { VALID_ENTITY_TYPES } = require("../../utils/courses/completion_requirements.registry");
|
||||
|
||||
const mdl_Users = require("../../models/users/users.mdl");
|
||||
|
||||
const notDeleted = { deletedAt: null };
|
||||
const onlyDeleted = { deletedAt: { [Op.not]: null } };
|
||||
|
||||
// Refresh duration of every course this unit is attached to (post-archive/restore).
|
||||
async function recomputeParentCourseDurations(unitId) {
|
||||
const links = await CourseUnit.findAll({ where: { unit_id: unitId }, attributes: ["course_id"] });
|
||||
for (const courseId of new Set(links.map((l) => String(l.course_id)))) {
|
||||
await recomputeCourseDuration(courseId);
|
||||
}
|
||||
}
|
||||
|
||||
const UNIT_LIST_COMPUTED = [
|
||||
{
|
||||
key: "quiz_id",
|
||||
label: "Quiz ID",
|
||||
type: "text",
|
||||
literal: `(SELECT quiz_id FROM unit_quizzes WHERE unit_id = "Unit"."unit_id" AND "deletedAt" IS NULL LIMIT 1)`,
|
||||
},
|
||||
{
|
||||
key: "lesson_count",
|
||||
label: "Lessons",
|
||||
type: "number",
|
||||
literal: `(
|
||||
SELECT CAST(COUNT(*) AS INTEGER)
|
||||
FROM unit_lessons ul
|
||||
JOIN lessons l ON l.lesson_id = ul.lesson_id AND l."deletedAt" IS NULL
|
||||
WHERE ul.unit_id = "Unit"."unit_id"
|
||||
)`,
|
||||
},
|
||||
{
|
||||
// Not its own column — consumed by the Title cell on the frontend to
|
||||
// show a "Course" badge when a unit is already attached to at least one Course.
|
||||
key: "course_count",
|
||||
label: "Affiliated",
|
||||
type: "number",
|
||||
order: 2, // 1: Title, 2: Affiliated, 3: Subscription, 4: Duration — see units.mdl.js
|
||||
hidden: true,
|
||||
literal: `(
|
||||
SELECT CAST(COUNT(*) AS INTEGER)
|
||||
FROM course_units cu
|
||||
JOIN courses c ON c.course_id = cu.course_id AND c."deletedAt" IS NULL
|
||||
WHERE cu.unit_id = "Unit"."unit_id"
|
||||
)`,
|
||||
},
|
||||
{
|
||||
key: "course_status",
|
||||
label: "Course Status",
|
||||
type: "text",
|
||||
order: 3, // 1: Title, 2: Affiliated, 3: Course Status, 4: Subscription, 5: Duration — see units.mdl.js
|
||||
hidden: true,
|
||||
filterable: false,
|
||||
literal: `(
|
||||
CASE
|
||||
WHEN NOT EXISTS (
|
||||
SELECT 1 FROM course_units cu
|
||||
JOIN courses c ON c.course_id = cu.course_id AND c."deletedAt" IS NULL
|
||||
WHERE cu.unit_id = "Unit"."unit_id"
|
||||
) THEN 'standalone'
|
||||
WHEN EXISTS (
|
||||
SELECT 1 FROM course_units cu
|
||||
JOIN courses c ON c.course_id = cu.course_id AND c."deletedAt" IS NULL
|
||||
WHERE cu.unit_id = "Unit"."unit_id" AND c.status = 'published'
|
||||
) THEN 'published'
|
||||
ELSE 'draft'
|
||||
END
|
||||
)`,
|
||||
},
|
||||
{
|
||||
// Not its own column — consumed by the frontend's Subscription cell so a
|
||||
// unit gated only through an affiliated course (own `subscription` is
|
||||
// NULL) still shows a tier instead of "-". Distinct tiers across every
|
||||
// affiliated course, comma-joined (a unit can sit in several courses at
|
||||
// different tiers).
|
||||
key: "course_subscription",
|
||||
label: "Course Subscription",
|
||||
type: "text",
|
||||
hidden: true,
|
||||
filterable: false,
|
||||
literal: `(
|
||||
SELECT STRING_AGG(sub.subscription, ', ')
|
||||
FROM (
|
||||
SELECT DISTINCT c.subscription
|
||||
FROM course_units cu
|
||||
JOIN courses c ON c.course_id = cu.course_id AND c."deletedAt" IS NULL
|
||||
WHERE cu.unit_id = "Unit"."unit_id"
|
||||
) sub
|
||||
)`,
|
||||
},
|
||||
];
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
// UNIT LIBRARY
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
exports.getUnits = async (req, res) => {
|
||||
try {
|
||||
const result = await paginate(Unit, req, {
|
||||
auditOptions: { mdl_Users, parentAlias: "Unit" },
|
||||
context: "list",
|
||||
computedAttributes: UNIT_LIST_COMPUTED,
|
||||
findOptions: {
|
||||
where: { ...notDeleted },
|
||||
order: [["createdAt", "DESC"]],
|
||||
},
|
||||
});
|
||||
return R.success(res, "Units retrieved.", result);
|
||||
} catch (err) {
|
||||
console.error("[UNIT LIB][GET ALL]", err);
|
||||
return R.error(res, "Could not retrieve units.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
// Lightweight list for attach pickers: { unit_id, uuid, title, lesson_count, course_count, course_title }
|
||||
exports.getUnitsFlat = async (req, res) => {
|
||||
try {
|
||||
const rows = await sequelize.query(`
|
||||
SELECT
|
||||
u.unit_id, u.uuid, u.title, u.description, u.duration_seconds,
|
||||
(SELECT CAST(COUNT(*) AS INTEGER) FROM unit_lessons ul
|
||||
JOIN lessons l ON l.lesson_id = ul.lesson_id AND l."deletedAt" IS NULL
|
||||
WHERE ul.unit_id = u.unit_id) AS lesson_count,
|
||||
(SELECT CAST(COUNT(*) AS INTEGER) FROM course_units cu
|
||||
JOIN courses c ON c.course_id = cu.course_id AND c."deletedAt" IS NULL
|
||||
WHERE cu.unit_id = u.unit_id) AS course_count,
|
||||
(SELECT c.title FROM course_units cu
|
||||
JOIN courses c ON c.course_id = cu.course_id AND c."deletedAt" IS NULL
|
||||
WHERE cu.unit_id = u.unit_id
|
||||
LIMIT 1) AS course_title
|
||||
FROM units u
|
||||
WHERE u."deletedAt" IS NULL
|
||||
ORDER BY u.title ASC
|
||||
`, { type: sequelize.QueryTypes.SELECT });
|
||||
return R.success(res, "Units retrieved.", rows);
|
||||
} catch (err) {
|
||||
console.error("[UNIT LIB][GET FLAT]", err);
|
||||
return R.error(res, "Could not retrieve units.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
// Bundle picker (Tier Plan wizard) — mirrors getCoursesBySubscription in
|
||||
// controllers/admin/courses.controller.js.
|
||||
exports.getUnitsBySubscription = async (req, res) => {
|
||||
try {
|
||||
const { slug } = req.query;
|
||||
if (!slug) return R.error(res, 'slug query param is required.', 400);
|
||||
|
||||
const rows = await Unit.findAll({
|
||||
where: { ...notDeleted, subscription: slug },
|
||||
attributes: ['unit_id', 'title', 'description', 'subscription'],
|
||||
order: [['title', 'ASC']],
|
||||
});
|
||||
|
||||
// A unit may belong to any number of other plans (Tier Plans v2, silent
|
||||
// duplication across bundles is intentional) — no conflict to report here.
|
||||
const data = rows.map((u) => u.toJSON());
|
||||
|
||||
return R.success(res, 'Units retrieved.', data);
|
||||
} catch (err) {
|
||||
console.error('[UNIT LIB][BY SUBSCRIPTION]', err);
|
||||
return R.error(res, 'Could not retrieve units.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.getUnit = async (req, res) => {
|
||||
try {
|
||||
const { unitId } = req.params;
|
||||
|
||||
const unit = await Unit.findOne({
|
||||
where: { unit_id: unitId, ...notDeleted },
|
||||
include: [
|
||||
{ model: Lesson, as: "lessons", where: notDeleted, required: false, through: { attributes: ["order_index"] } },
|
||||
{ model: UnitQuiz, as: "quiz", required: false },
|
||||
{ model: Course, as: "courses", where: notDeleted, required: false, attributes: ["course_id", "uuid", "title", "subscription"], through: { attributes: ["order_index"] } },
|
||||
],
|
||||
});
|
||||
|
||||
if (!unit) return R.error(res, "Unit not found.", 404);
|
||||
|
||||
const plain = unit.toJSON();
|
||||
plain.lessons = flattenLessons(plain.lessons);
|
||||
return R.success(res, "Unit retrieved.", { data: plain });
|
||||
} catch (err) {
|
||||
console.error("[UNIT LIB][GET ONE]", err);
|
||||
return R.error(res, "Could not retrieve unit.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.createUnit = async (req, res) => {
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const { title, description, subscription, course_id, order, createdBy } = req.body;
|
||||
if (!title) return R.error(res, "Title is required.", 400);
|
||||
|
||||
const unit = await Unit.create({
|
||||
title,
|
||||
subscription: subscription || null,
|
||||
description: description ?? null,
|
||||
duration_seconds: 0,
|
||||
createdBy: createdBy ?? req.user?.user_id ?? null,
|
||||
}, { transaction: t });
|
||||
|
||||
// Optional immediate attach — lets the course builder create-and-attach in one call
|
||||
if (course_id) {
|
||||
const course = await Course.findOne({ where: { course_id, ...notDeleted }, transaction: t });
|
||||
if (!course) {
|
||||
await t.rollback();
|
||||
return R.error(res, "Course not found.", 404);
|
||||
}
|
||||
const order_index = order ?? await nextOrderIndex(CourseUnit, { course_id }, t);
|
||||
await CourseUnit.create({
|
||||
course_id,
|
||||
unit_id: unit.unit_id,
|
||||
order_index,
|
||||
createdBy: createdBy ?? req.user?.user_id ?? null,
|
||||
}, { transaction: t });
|
||||
}
|
||||
|
||||
await t.commit();
|
||||
logActivity(req.user?.user_id, "create_unit", { entityType: "unit", entityId: unit.unit_id, details: { title: unit.title, attached_course_id: course_id ?? null } });
|
||||
return R.success(res, "Unit created.", { data: unit }, 201);
|
||||
} catch (err) {
|
||||
await t.rollback();
|
||||
console.error("[UNIT LIB][CREATE]", err);
|
||||
return R.error(res, "Could not create unit.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
// One consolidated call: creates the unit, its lessons (with objectives + page
|
||||
// blocks), and attaches each lesson to the unit — all in a single transaction.
|
||||
// Body: { title, description, subscription,
|
||||
// lessons: [{ title, description, objectives?: string[], blocks?: [] }],
|
||||
// createdBy }
|
||||
const CREATE_UNIT_FULL_LIMITS = { maxLessons: 50, maxObjectives: 20, maxBlocks: 100 };
|
||||
|
||||
exports.createUnitFull = async (req, res) => {
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const { title, description, subscription, lessons = [], createdBy } = req.body;
|
||||
|
||||
if (!title) return R.error(res, "Title is required.", 400);
|
||||
if (!Array.isArray(lessons)) return R.error(res, "Lessons must be a list.", 400);
|
||||
if (lessons.length > CREATE_UNIT_FULL_LIMITS.maxLessons) {
|
||||
return R.error(res, `A unit can have at most ${CREATE_UNIT_FULL_LIMITS.maxLessons} lessons.`, 400);
|
||||
}
|
||||
|
||||
for (const lessonInput of lessons) {
|
||||
if (!lessonInput?.title) return R.error(res, "Each lesson needs a title.", 400);
|
||||
if (lessonInput.objectives !== undefined && !Array.isArray(lessonInput.objectives)) {
|
||||
return R.error(res, "Lesson objectives must be a list.", 400);
|
||||
}
|
||||
if (lessonInput.objectives?.length > CREATE_UNIT_FULL_LIMITS.maxObjectives) {
|
||||
return R.error(res, `A lesson can have at most ${CREATE_UNIT_FULL_LIMITS.maxObjectives} objectives.`, 400);
|
||||
}
|
||||
if (lessonInput.blocks !== undefined && !Array.isArray(lessonInput.blocks)) {
|
||||
return R.error(res, "Lesson page blocks must be a list.", 400);
|
||||
}
|
||||
if (lessonInput.blocks?.length > CREATE_UNIT_FULL_LIMITS.maxBlocks) {
|
||||
return R.error(res, `A lesson page can have at most ${CREATE_UNIT_FULL_LIMITS.maxBlocks} blocks.`, 400);
|
||||
}
|
||||
if (lessonInput.requirements !== undefined) {
|
||||
if (!Array.isArray(lessonInput.requirements)) return R.error(res, "Lesson requirements must be a list.", 400);
|
||||
const seenTypes = new Set();
|
||||
for (const r of lessonInput.requirements) {
|
||||
const allowedEntityTypes = VALID_ENTITY_TYPES[r.type];
|
||||
if (!allowedEntityTypes) return R.error(res, `Unknown requirement type "${r.type}".`, 400);
|
||||
if (!allowedEntityTypes.includes("lesson")) return R.error(res, `"${r.type}" cannot be configured on a lesson.`, 400);
|
||||
if (seenTypes.has(r.type)) return R.error(res, `Duplicate "${r.type}" requirement — only one per lesson is allowed.`, 400);
|
||||
seenTypes.add(r.type);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const by = createdBy ?? req.user?.user_id ?? null;
|
||||
|
||||
const unit = await Unit.create({
|
||||
title,
|
||||
subscription: subscription || null,
|
||||
description: description ?? null,
|
||||
duration_seconds: 0,
|
||||
createdBy: by,
|
||||
}, { transaction: t });
|
||||
|
||||
for (let i = 0; i < lessons.length; i++) {
|
||||
const lessonInput = lessons[i];
|
||||
|
||||
const lesson = await Lesson.create({
|
||||
title: lessonInput.title,
|
||||
description: lessonInput.description ?? null,
|
||||
duration_seconds: 0,
|
||||
createdBy: by,
|
||||
}, { transaction: t });
|
||||
|
||||
await LessonPage.create({
|
||||
lesson_id: lesson.lesson_id,
|
||||
blocks: lessonInput.blocks ?? [],
|
||||
createdBy: by,
|
||||
}, { transaction: t });
|
||||
|
||||
await syncObjectivesCreate(LessonObjective, "lesson_id", lesson.lesson_id, lessonInput.objectives ?? [], t);
|
||||
|
||||
if (lessonInput.requirements?.length) {
|
||||
const rows = lessonInput.requirements.map((r, ri) => ({
|
||||
entity_type: "lesson",
|
||||
entity_id: lesson.lesson_id,
|
||||
type: r.type,
|
||||
order: r.order ?? ri,
|
||||
min_percent: r.type === "watch_percent" ? (r.min_percent ?? 100) : null,
|
||||
button_label: r.type === "manual_complete" ? (r.button_label || null) : null,
|
||||
is_required: r.is_required ?? true,
|
||||
createdBy: by,
|
||||
updatedBy: by,
|
||||
}));
|
||||
await CompletionRequirement.bulkCreate(rows, { transaction: t });
|
||||
}
|
||||
|
||||
await UnitLesson.create({
|
||||
unit_id: unit.unit_id,
|
||||
lesson_id: lesson.lesson_id,
|
||||
order_index: i,
|
||||
createdBy: by,
|
||||
}, { transaction: t });
|
||||
}
|
||||
|
||||
await t.commit();
|
||||
|
||||
if (lessons.length) {
|
||||
try { await recomputeUnitDuration(unit.unit_id); }
|
||||
catch (durErr) { console.error("[UNIT LIB][CREATE FULL][DURATION]", durErr); }
|
||||
}
|
||||
|
||||
logActivity(req.user?.user_id, "create_unit", { entityType: "unit", entityId: unit.unit_id, details: { title: unit.title, lessons: lessons.length } });
|
||||
return R.success(res, "Unit created.", { data: unit }, 201);
|
||||
} catch (err) {
|
||||
await t.rollback();
|
||||
console.error("[UNIT LIB][CREATE FULL]", err);
|
||||
return R.error(res, "Could not create unit.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.updateUnit = async (req, res) => {
|
||||
try {
|
||||
const { unitId } = req.params;
|
||||
|
||||
const unit = await Unit.findOne({ where: { unit_id: unitId, ...notDeleted } });
|
||||
if (!unit) return R.error(res, "Unit not found.", 404);
|
||||
|
||||
const { title, description, subscription, updatedBy } = req.body;
|
||||
|
||||
if (title !== undefined) unit.title = title;
|
||||
if (description !== undefined) unit.description = description;
|
||||
if (subscription !== undefined) unit.subscription = subscription || null;
|
||||
unit.updatedBy = updatedBy ?? req.user?.user_id ?? 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 LIB][UPDATE]", err);
|
||||
return R.error(res, "Could not update unit.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.getUnitArchiveImpact = async (req, res) => {
|
||||
try {
|
||||
const { unitId } = req.params;
|
||||
|
||||
const [completionCount, progressCount, courseCount] = await Promise.all([
|
||||
UnitReadingProgress.count({ where: { unit_id: unitId, status: "completed" } }),
|
||||
LessonReadingProgress.count({ where: { unit_id: unitId }, distinct: true, col: "user_id" }),
|
||||
CourseUnit.count({ where: { unit_id: unitId } }),
|
||||
]);
|
||||
|
||||
return R.success(res, "Impact retrieved.", { completionCount, progressCount, courseCount });
|
||||
} catch (err) {
|
||||
console.error("[UNIT LIB][ARCHIVE IMPACT]", err);
|
||||
return R.error(res, "Could not retrieve impact.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.archiveUnit = async (req, res) => {
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const { unitId } = req.params;
|
||||
const record = await archiveOne(Unit, { unit_id: unitId, ...notDeleted }, req.user.user_id, t);
|
||||
if (!record) return R.error(res, "Unit not found.", 404);
|
||||
await t.commit();
|
||||
try { await recomputeParentCourseDurations(unitId); } catch (durErr) { console.error("[UNIT LIB][ARCHIVE][DURATION]", durErr); }
|
||||
logActivity(req.user.user_id, "archive_unit", { entityType: "unit", entityId: Number(unitId) });
|
||||
return R.success(res, "Unit archived.");
|
||||
} catch (err) {
|
||||
await t.rollback();
|
||||
console.error("[UNIT LIB][ARCHIVE]", err);
|
||||
return R.error(res, "Could not archive unit.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.bulkArchiveUnits = async (req, res) => {
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const { ids = [] } = req.body;
|
||||
if (!ids.length) return R.error(res, "No IDs provided.", 400);
|
||||
|
||||
const units = await Unit.findAll({ where: { unit_id: ids, ...notDeleted } });
|
||||
const validIds = units.map((u) => u.unit_id);
|
||||
|
||||
const count = await archiveMany(Unit, "unit_id", validIds, req.user.user_id, t);
|
||||
await t.commit();
|
||||
for (const id of validIds) {
|
||||
try { await recomputeParentCourseDurations(id); } catch (durErr) { console.error("[UNIT LIB][BULK ARCHIVE][DURATION]", durErr); }
|
||||
}
|
||||
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();
|
||||
console.error("[UNIT LIB][BULK ARCHIVE]", err);
|
||||
return R.error(res, "Could not archive units.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.getArchivedUnits = async (req, res) => {
|
||||
try {
|
||||
const result = await paginate(Unit, req, {
|
||||
auditOptions: { mdl_Users, parentAlias: "Unit" },
|
||||
context: "archived",
|
||||
findOptions: {
|
||||
where: { ...onlyDeleted },
|
||||
paranoid: false,
|
||||
order: [["deletedAt", "DESC"]],
|
||||
},
|
||||
});
|
||||
return R.success(res, "Archived units retrieved.", result);
|
||||
} catch (err) {
|
||||
console.error("[UNIT LIB][GET ARCHIVES]", err);
|
||||
return R.error(res, "Could not retrieve archived units.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.getArchivedUnit = async (req, res) => {
|
||||
try {
|
||||
const { unitId } = req.params;
|
||||
const unit = await Unit.findOne({
|
||||
where: { unit_id: unitId, ...onlyDeleted },
|
||||
paranoid: false,
|
||||
});
|
||||
if (!unit) return R.error(res, "Archived unit not found.", 404);
|
||||
return R.success(res, "Archived unit retrieved.", { data: unit.toJSON() });
|
||||
} catch (err) {
|
||||
console.error("[UNIT LIB][GET ARCHIVE ONE]", err);
|
||||
return R.error(res, "Could not retrieve archived unit.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.restoreUnit = async (req, res) => {
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const { unitId } = req.params;
|
||||
const record = await restoreOne(Unit, { unit_id: unitId, ...onlyDeleted }, req.user.user_id, t);
|
||||
if (!record) return R.error(res, "Archived unit not found.", 404);
|
||||
await t.commit();
|
||||
try { await recomputeParentCourseDurations(unitId); } catch (durErr) { console.error("[UNIT LIB][RESTORE][DURATION]", durErr); }
|
||||
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();
|
||||
console.error("[UNIT LIB][RESTORE]", err);
|
||||
return R.error(res, "Could not restore unit.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.bulkRestoreUnits = async (req, res) => {
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const { ids = [] } = req.body;
|
||||
if (!ids.length) return R.error(res, "No IDs provided.", 400);
|
||||
|
||||
const units = await Unit.findAll({ where: { unit_id: ids, ...onlyDeleted }, paranoid: false });
|
||||
const validIds = units.map((u) => u.unit_id);
|
||||
|
||||
const count = await restoreMany(Unit, "unit_id", validIds, req.user.user_id, t);
|
||||
await t.commit();
|
||||
for (const id of validIds) {
|
||||
try { await recomputeParentCourseDurations(id); } catch (durErr) { console.error("[UNIT LIB][BULK RESTORE][DURATION]", durErr); }
|
||||
}
|
||||
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();
|
||||
console.error("[UNIT LIB][BULK RESTORE]", err);
|
||||
return R.error(res, "Could not restore units.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.getUnitPermanentDeleteImpact = async (req, res) => {
|
||||
try {
|
||||
const { unitId } = req.params;
|
||||
|
||||
const [lessonCount, courseCount] = await Promise.all([
|
||||
UnitLesson.count({ where: { unit_id: unitId } }),
|
||||
CourseUnit.count({ where: { unit_id: unitId } }),
|
||||
]);
|
||||
|
||||
return R.success(res, "Impact retrieved.", { lessonCount, courseCount });
|
||||
} catch (err) {
|
||||
console.error("[UNIT LIB][PERMANENT DELETE IMPACT]", err);
|
||||
return R.error(res, "Could not retrieve impact.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.permanentlyDeleteUnit = async (req, res) => {
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const { unitId } = req.params;
|
||||
const record = await permanentDeleteOne(Unit, { unit_id: unitId }, t);
|
||||
if (record === null) return R.error(res, "Unit not found.", 404);
|
||||
if (record === false) return R.error(res, "Unit must be archived before it can be permanently deleted.", 400);
|
||||
|
||||
// Junction rows don't cascade from a paranoid destroy — clean them explicitly
|
||||
await CourseUnit.destroy({ where: { unit_id: unitId }, transaction: t });
|
||||
await UnitLesson.destroy({ where: { unit_id: unitId }, transaction: t });
|
||||
|
||||
await t.commit();
|
||||
logActivity(req.user.user_id, "permanently_delete_unit", { entityType: "unit", entityId: Number(unitId) });
|
||||
return R.success(res, "Unit permanently deleted.");
|
||||
} catch (err) {
|
||||
await t.rollback();
|
||||
console.error("[UNIT LIB][PERMANENT DELETE]", err);
|
||||
return R.error(res, "Could not permanently delete unit.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.bulkPermanentlyDeleteUnits = async (req, res) => {
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const { ids = [] } = req.body;
|
||||
if (!ids.length) return R.error(res, "No IDs provided.", 400);
|
||||
|
||||
const units = await Unit.findAll({ where: { unit_id: ids }, paranoid: false });
|
||||
const validIds = units.map((u) => u.unit_id);
|
||||
|
||||
const count = await permanentDeleteMany(Unit, "unit_id", validIds, t);
|
||||
await CourseUnit.destroy({ where: { unit_id: validIds }, transaction: t });
|
||||
await UnitLesson.destroy({ where: { unit_id: validIds }, transaction: t });
|
||||
|
||||
await t.commit();
|
||||
logActivity(req.user.user_id, "bulk_permanently_delete_units", { entityType: "unit", details: { ids: validIds, count } });
|
||||
return R.success(res, `${count} unit${count !== 1 ? "s" : ""} permanently deleted.`);
|
||||
} catch (err) {
|
||||
await t.rollback();
|
||||
console.error("[UNIT LIB][BULK PERMANENT DELETE]", err);
|
||||
return R.error(res, "Could not permanently delete units.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.getUnitFieldValues = getFieldValues(Unit, "UNIT");
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
// UNIT ⇄ LESSON MEMBERSHIP (attach / detach / reorder)
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
// POST /admin/units/:unitId/lessons { lesson_ids: [..] } — append existing lessons
|
||||
exports.attachLessons = async (req, res) => {
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const { unitId } = req.params;
|
||||
const { lesson_ids = [] } = req.body;
|
||||
if (!lesson_ids.length) return R.error(res, "lesson_ids is required.", 400);
|
||||
|
||||
const unit = await Unit.findOne({ where: { unit_id: unitId, ...notDeleted }, transaction: t });
|
||||
if (!unit) return R.error(res, "Unit not found.", 404);
|
||||
|
||||
const lessons = await Lesson.findAll({ where: { lesson_id: lesson_ids, ...notDeleted }, transaction: t });
|
||||
if (lessons.length !== lesson_ids.length) {
|
||||
await t.rollback();
|
||||
return R.error(res, "One or more lessons were not found.", 404);
|
||||
}
|
||||
|
||||
const existing = await UnitLesson.findAll({ where: { unit_id: unitId, lesson_id: lesson_ids }, transaction: t });
|
||||
const existingSet = new Set(existing.map((r) => String(r.lesson_id)));
|
||||
const toAttach = lesson_ids.filter((id) => !existingSet.has(String(id)));
|
||||
|
||||
let order = await nextOrderIndex(UnitLesson, { unit_id: unitId }, t);
|
||||
await UnitLesson.bulkCreate(
|
||||
toAttach.map((lesson_id) => ({
|
||||
unit_id: unitId,
|
||||
lesson_id,
|
||||
order_index: order++,
|
||||
createdBy: req.user?.user_id ?? null,
|
||||
})),
|
||||
{ transaction: t }
|
||||
);
|
||||
|
||||
await t.commit();
|
||||
try { await recomputeUnitDuration(unitId); await recomputeParentCourseDurations(unitId); } catch (durErr) { console.error("[UNIT LIB][ATTACH LESSONS][DURATION]", durErr); }
|
||||
logActivity(req.user?.user_id, "attach_lessons", { entityType: "unit", entityId: Number(unitId), details: { lesson_ids: toAttach } });
|
||||
return R.success(res, `${toAttach.length} lesson${toAttach.length !== 1 ? "s" : ""} attached.`, { attached: toAttach, skipped: lesson_ids.filter((id) => existingSet.has(String(id))) });
|
||||
} catch (err) {
|
||||
await t.rollback();
|
||||
console.error("[UNIT LIB][ATTACH LESSONS]", err);
|
||||
return R.error(res, "Could not attach lessons.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
// DELETE /admin/units/:unitId/lessons/:lessonId — detach (lesson survives in the library)
|
||||
exports.detachLesson = async (req, res) => {
|
||||
try {
|
||||
const { unitId, lessonId } = req.params;
|
||||
|
||||
const removed = await UnitLesson.destroy({ where: { unit_id: unitId, lesson_id: lessonId } });
|
||||
if (!removed) return R.error(res, "Lesson is not attached to this unit.", 404);
|
||||
|
||||
try { await recomputeUnitDuration(unitId); await recomputeParentCourseDurations(unitId); } catch (durErr) { console.error("[UNIT LIB][DETACH LESSON][DURATION]", durErr); }
|
||||
logActivity(req.user?.user_id, "detach_lesson", { entityType: "unit", entityId: Number(unitId), details: { lesson_id: Number(lessonId) } });
|
||||
return R.success(res, "Lesson detached.");
|
||||
} catch (err) {
|
||||
console.error("[UNIT LIB][DETACH LESSON]", err);
|
||||
return R.error(res, "Could not detach lesson.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
// PUT /admin/units/:unitId/lessons/order { lesson_ids: [orderedIds] }
|
||||
exports.reorderLessons = async (req, res) => {
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const { unitId } = req.params;
|
||||
const { lesson_ids = [] } = req.body;
|
||||
if (!lesson_ids.length) return R.error(res, "lesson_ids is required.", 400);
|
||||
|
||||
const unit = await Unit.findOne({ where: { unit_id: unitId, ...notDeleted }, transaction: t });
|
||||
if (!unit) return R.error(res, "Unit not found.", 404);
|
||||
|
||||
await reorderJunction(UnitLesson, "unit_id", unitId, "lesson_id", lesson_ids, t);
|
||||
await t.commit();
|
||||
logActivity(req.user?.user_id, "reorder_lessons", { entityType: "unit", entityId: Number(unitId), details: { lesson_ids } });
|
||||
return R.success(res, "Lesson order updated.");
|
||||
} catch (err) {
|
||||
await t.rollback();
|
||||
console.error("[UNIT LIB][REORDER LESSONS]", err);
|
||||
return R.error(res, "Could not reorder lessons.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
// UNIT QUIZ (1:1 with the Unit — travels with it into every attached course)
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
exports.getQuiz = async (req, res) => {
|
||||
try {
|
||||
const { unitId } = req.params;
|
||||
|
||||
const unit = await Unit.findOne({ where: { unit_id: unitId, ...notDeleted } });
|
||||
if (!unit) return R.error(res, "Unit not found.", 404);
|
||||
|
||||
const quiz = await UnitQuiz.findOne({
|
||||
where: { unit_id: unitId, ...notDeleted },
|
||||
include: [{
|
||||
model: QuizQuestion, as: "questions",
|
||||
where: notDeleted, required: false,
|
||||
include: [{ model: QuizOption, as: "options", order: [["order_index", "ASC"]] }],
|
||||
}],
|
||||
});
|
||||
|
||||
if (!quiz) return R.error(res, "Quiz not found.", 404);
|
||||
return R.success(res, "Quiz retrieved.", { data: quiz });
|
||||
} catch (err) {
|
||||
console.error("[UNIT LIB][QUIZ][GET]", err);
|
||||
return R.error(res, "Could not retrieve quiz.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.createQuiz = async (req, res) => {
|
||||
try {
|
||||
const { unitId } = req.params;
|
||||
const { title, is_required, passing_score, max_questions, shuffle_questions, createdBy } = req.body;
|
||||
|
||||
const unit = await Unit.findOne({ where: { unit_id: unitId, ...notDeleted } });
|
||||
if (!unit) return R.error(res, "Unit not found.", 404);
|
||||
|
||||
const existing = await UnitQuiz.findOne({ where: { unit_id: unitId, ...notDeleted } });
|
||||
if (existing) return R.error(res, "Quiz already exists for this unit.", 409);
|
||||
|
||||
const quiz = await UnitQuiz.create({
|
||||
unit_id: unitId,
|
||||
title: title ?? null,
|
||||
is_required: is_required ?? false,
|
||||
passing_score: passing_score ?? 70,
|
||||
max_questions: max_questions ?? null,
|
||||
shuffle_questions: shuffle_questions ?? false,
|
||||
createdBy: createdBy ?? req.user?.user_id ?? 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("[UNIT LIB][QUIZ][CREATE]", err);
|
||||
return R.error(res, "Could not create quiz.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.updateQuiz = async (req, res) => {
|
||||
try {
|
||||
const { unitId, quizId } = req.params;
|
||||
const { title, is_required, passing_score, max_questions, shuffle_questions, updatedBy } = req.body;
|
||||
|
||||
const quiz = await UnitQuiz.findOne({ where: { quiz_id: quizId, unit_id: unitId, ...notDeleted } });
|
||||
if (!quiz) return R.error(res, "Quiz not found.", 404);
|
||||
|
||||
if (title !== undefined) quiz.title = title;
|
||||
if (is_required !== undefined) quiz.is_required = is_required;
|
||||
if (passing_score !== undefined) quiz.passing_score = passing_score;
|
||||
if (max_questions !== undefined) quiz.max_questions = max_questions;
|
||||
if (shuffle_questions !== undefined) quiz.shuffle_questions = shuffle_questions;
|
||||
quiz.updatedBy = updatedBy ?? req.user?.user_id ?? 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("[UNIT LIB][QUIZ][UPDATE]", err);
|
||||
return R.error(res, "Could not update quiz.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.deleteQuiz = async (req, res) => {
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const { unitId, quizId } = req.params;
|
||||
const record = await archiveOne(UnitQuiz, { quiz_id: quizId, unit_id: unitId, ...notDeleted }, req.user?.user_id, 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();
|
||||
console.error("[UNIT LIB][QUIZ][ARCHIVE]", err);
|
||||
return R.error(res, "Could not archive quiz.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.getArchivedQuiz = async (req, res) => {
|
||||
try {
|
||||
const { unitId } = req.params;
|
||||
|
||||
const unit = await Unit.findOne({ where: { unit_id: unitId }, paranoid: false });
|
||||
if (!unit) return R.error(res, "Unit not found.", 404);
|
||||
|
||||
const quiz = await UnitQuiz.findOne({
|
||||
where: { unit_id: unitId, ...onlyDeleted },
|
||||
paranoid: false,
|
||||
});
|
||||
if (!quiz) return R.error(res, "Archived quiz not found.", 404);
|
||||
return R.success(res, "Archived quiz retrieved.", { data: quiz.toJSON() });
|
||||
} catch (err) {
|
||||
console.error("[UNIT LIB][QUIZ][GET ARCHIVE]", err);
|
||||
return R.error(res, "Could not retrieve archived quiz.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.restoreQuiz = async (req, res) => {
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const { unitId, quizId } = req.params;
|
||||
const record = await restoreOne(UnitQuiz, { quiz_id: quizId, unit_id: unitId, ...onlyDeleted }, req.user?.user_id, 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();
|
||||
console.error("[UNIT LIB][QUIZ][RESTORE]", err);
|
||||
return R.error(res, "Could not restore quiz.", 500);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,138 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* 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 { resolveAvatarUrl } = require('../../utils/resolveAvatar.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) {
|
||||
// `to` arrives as a date-only string (e.g. "2026-06-04"), which parses
|
||||
// to that day's UTC midnight — an Op.lte against midnight excludes
|
||||
// every event that happened later the same day. Push it to the last
|
||||
// instant of that calendar day instead.
|
||||
const to = new Date(query.to);
|
||||
to.setUTCHours(23, 59, 59, 999);
|
||||
where.created_at[Op.lte] = 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 = await Promise.all(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 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
async function formatRow(row) {
|
||||
const r = row.toJSON();
|
||||
const info = r.user?.personal_info;
|
||||
const avatar = await resolveAvatarUrl(info?.avatar);
|
||||
return {
|
||||
activity_id: r.activity_id,
|
||||
user_id: r.user_id,
|
||||
email: r.user?.email ?? null,
|
||||
full_name: info?.name?.full_name ?? null,
|
||||
avatar_stream_token: avatar?.stream_token ?? 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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,461 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name: user_groups.controller.js (admin)
|
||||
* Type of Program: Controller
|
||||
* Description: Admin-level user group management — CRUD + membership.
|
||||
*
|
||||
* Author: rgrgogu
|
||||
* Date Created: Oct. 6, 2025
|
||||
***********************************************************************************************************************************************************************
|
||||
* Change History:
|
||||
* DATE AUTHOR LOG DESCRIPTION
|
||||
* Oct. 6, 2025 rgrgogu 001 Initial creation - STAR Phase 1
|
||||
* May 23, 2026 rgrgogu 002 Added group_code to createGroup / updateGroup; added generateGroupCode helper
|
||||
***********************************************************************************************************************************************************************/
|
||||
const sequelize = require('../../config/db.config');
|
||||
const { Op, Sequelize } = require('sequelize');
|
||||
|
||||
const mdl_Users = require('../../models/users/users.mdl');
|
||||
const { mdl_UserGroups, mdl_UserGroupMembers } = require('../../models/users/user_groups.mdl');
|
||||
|
||||
const R = require('../../utils/response.util');
|
||||
const { paginate } = require('../../utils/paginate.util');
|
||||
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');
|
||||
const { dropDefaultGroupMembership, reconcileDefaultGroup } = require('../../utils/defaultGroup.util');
|
||||
|
||||
// ─── Helper — generate a unique group code ────────────────────────────────────
|
||||
/**
|
||||
* Generates a unique group_code in the format: <SLUG>-<4-char hex>
|
||||
* e.g. "SALES-A3F1", "ONBOARD-Q1-9C2D"
|
||||
* Retries up to 5 times in the unlikely event of a collision.
|
||||
*/
|
||||
const generateGroupCode = async (name) => {
|
||||
const slug = name.toUpperCase().trim().replace(/\s+/g, '-').replace(/[^A-Z0-9\-]/g, '').slice(0, 20);
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const suffix = Math.random().toString(16).slice(2, 6).toUpperCase();
|
||||
const code = `${slug}-${suffix}`;
|
||||
const exists = await mdl_UserGroups.findOne({ where: { group_code: code }, paranoid: false });
|
||||
if (!exists) return code;
|
||||
}
|
||||
throw new Error('Could not generate a unique group code after 5 attempts.');
|
||||
};
|
||||
|
||||
// ─── GET ALL ──────────────────────────────────────────────────────────────────
|
||||
exports.getGroups = async (req, res) => {
|
||||
try {
|
||||
const result = await paginate(mdl_UserGroups, req, {
|
||||
excludeAttributes: groupExclude,
|
||||
jsonbSchemas: groupSchemas,
|
||||
computedAttributes: groupComputed,
|
||||
context: 'list',
|
||||
auditOptions: { mdl_Users, parentAlias: 'UserGroup' },
|
||||
});
|
||||
|
||||
return R.success(res, 'Groups retrieved.', result);
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][GET GROUPS]', err);
|
||||
return R.error(res, 'Could not retrieve groups.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── GET ONE ──────────────────────────────────────────────────────────────────
|
||||
exports.getGroup = async (req, res) => {
|
||||
try {
|
||||
const group = await mdl_UserGroups.findByPk(req.params.gid);
|
||||
if (!group) return R.error(res, 'Group not found.', 404);
|
||||
|
||||
const members = await paginate(mdl_Users, req, {
|
||||
excludeAttributes: usersExclude,
|
||||
jsonbSchemas: usersSchemas,
|
||||
jsonbColumn: 'personal_info',
|
||||
auditOptions: { mdl_Users, parentAlias: 'User' },
|
||||
context: 'list',
|
||||
findOptions: {
|
||||
include: [{
|
||||
model: mdl_UserGroupMembers,
|
||||
where: { group_id: req.params.gid },
|
||||
attributes: [],
|
||||
required: true,
|
||||
}],
|
||||
},
|
||||
});
|
||||
|
||||
return R.success(res, 'Group retrieved.', { group, members });
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][GET GROUP]', err);
|
||||
return R.error(res, 'Could not retrieve group.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── CREATE ───────────────────────────────────────────────────────────────────
|
||||
exports.createGroup = async (req, res) => {
|
||||
try {
|
||||
const { name, description, group_code } = req.body;
|
||||
if (!name) return R.error(res, 'Group name is required.', 400);
|
||||
|
||||
// Use provided group_code (uppercased via model hook), otherwise auto-generate
|
||||
const code = group_code
|
||||
? group_code.toUpperCase().trim()
|
||||
: await generateGroupCode(name);
|
||||
|
||||
// Check uniqueness explicitly so we return a clear error message
|
||||
const duplicate = await mdl_UserGroups.findOne({ where: { group_code: code }, paranoid: false });
|
||||
if (duplicate) return R.error(res, `Group code "${code}" is already in use.`, 409);
|
||||
|
||||
const group = await mdl_UserGroups.create({
|
||||
name,
|
||||
description,
|
||||
group_code: code,
|
||||
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);
|
||||
return R.error(res, 'Could not create group.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── UPDATE ───────────────────────────────────────────────────────────────────
|
||||
exports.updateGroup = async (req, res) => {
|
||||
try {
|
||||
const group = await mdl_UserGroups.findByPk(req.params.gid);
|
||||
if (!group) return R.error(res, 'Group not found.', 404);
|
||||
|
||||
const { name, description, group_code } = req.body;
|
||||
|
||||
if (name !== undefined) group.name = name;
|
||||
if (description !== undefined) group.description = description;
|
||||
|
||||
if (group_code !== undefined) {
|
||||
const code = group_code.toUpperCase().trim();
|
||||
const duplicate = await mdl_UserGroups.findOne({
|
||||
where: { group_code: code, group_id: { [Op.ne]: group.group_id } },
|
||||
paranoid: false,
|
||||
});
|
||||
if (duplicate) return R.error(res, `Group code "${code}" is already in use.`, 409);
|
||||
group.group_code = code;
|
||||
}
|
||||
|
||||
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);
|
||||
return R.error(res, 'Could not update group.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── DEACTIVATE ───────────────────────────────────────────────────────────────
|
||||
exports.deactivateGroup = async (req, res) => {
|
||||
try {
|
||||
const group = await mdl_UserGroups.findByPk(req.params.gid);
|
||||
if (!group) return R.error(res, 'Group not found.', 404);
|
||||
if (!group.is_active) return R.error(res, 'Group is already deactivated.', 400);
|
||||
|
||||
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);
|
||||
return R.error(res, 'Could not deactivate group.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── RESTORE ──────────────────────────────────────────────────────────────────
|
||||
exports.restoreGroup = async (req, res) => {
|
||||
try {
|
||||
const group = await mdl_UserGroups.findOne({ where: { group_id: req.params.gid }, paranoid: false });
|
||||
if (!group) return R.error(res, 'Group not found.', 404);
|
||||
if (group.is_active) return R.error(res, 'Group is already active.', 400);
|
||||
|
||||
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);
|
||||
return R.error(res, 'Could not restore group.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── BULK DEACTIVATE ──────────────────────────────────────────────────────────
|
||||
exports.bulkDeactivateGroups = async (req, res) => {
|
||||
try {
|
||||
const { ids } = req.body;
|
||||
if (!Array.isArray(ids) || !ids.length)
|
||||
return R.error(res, 'No group IDs provided.', 400);
|
||||
|
||||
const groups = await mdl_UserGroups.findAll({ where: { group_id: ids } });
|
||||
if (!groups.length) return R.error(res, 'No groups found.', 404);
|
||||
|
||||
const activeGroups = groups.filter((g) => g.is_active && !g.deletedAt);
|
||||
if (!activeGroups.length)
|
||||
return R.error(res, 'All selected groups are already deactivated.', 400);
|
||||
|
||||
const activeIds = activeGroups.map((g) => g.group_id);
|
||||
|
||||
await mdl_UserGroups.update(
|
||||
{ is_active: false, deletedBy: req.user.user_id },
|
||||
{ where: { group_id: activeIds } }
|
||||
);
|
||||
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)),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][BULK DEACTIVATE GROUPS]', err);
|
||||
return R.error(res, 'Could not deactivate groups.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── BULK RESTORE ─────────────────────────────────────────────────────────────
|
||||
exports.bulkRestoreGroups = async (req, res) => {
|
||||
try {
|
||||
const { ids } = req.body;
|
||||
if (!Array.isArray(ids) || !ids.length)
|
||||
return R.error(res, 'No group IDs provided.', 400);
|
||||
|
||||
const groups = await mdl_UserGroups.findAll({ where: { group_id: ids }, paranoid: false });
|
||||
if (!groups.length) return R.error(res, 'No groups found.', 404);
|
||||
|
||||
const deletedGroups = groups.filter((g) => g.deletedAt);
|
||||
if (!deletedGroups.length)
|
||||
return R.error(res, 'All selected groups are already active.', 400);
|
||||
|
||||
const deletedIds = deletedGroups.map((g) => g.group_id);
|
||||
|
||||
await mdl_UserGroups.restore({ where: { group_id: deletedIds } });
|
||||
await mdl_UserGroups.update(
|
||||
{ is_active: true, updatedBy: req.user.user_id, deletedBy: null },
|
||||
{ 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)),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][BULK RESTORE GROUPS]', err);
|
||||
return R.error(res, 'Could not restore groups.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── PERMANENT DELETE ─────────────────────────────────────────────────────────
|
||||
exports.permanentlyDeleteGroup = async (req, res) => {
|
||||
try {
|
||||
const group = await mdl_UserGroups.findOne({ where: { group_id: req.params.gid }, paranoid: false });
|
||||
if (!group) return R.error(res, 'Group not found.', 404);
|
||||
if (!group.deletedAt) return R.error(res, 'Group must be deactivated before it can be permanently deleted.', 400);
|
||||
|
||||
await group.destroy({ force: true });
|
||||
|
||||
logActivity(req.user.user_id, 'permanently_delete_group', { entityType: 'group', entityId: group.group_id });
|
||||
return R.success(res, 'Group permanently deleted.');
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][PERMANENT DELETE GROUP]', err);
|
||||
return R.error(res, 'Could not permanently delete group.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── BULK PERMANENT DELETE ────────────────────────────────────────────────────
|
||||
exports.bulkPermanentlyDeleteGroups = async (req, res) => {
|
||||
try {
|
||||
const { ids } = req.body;
|
||||
if (!Array.isArray(ids) || !ids.length)
|
||||
return R.error(res, 'No group IDs provided.', 400);
|
||||
|
||||
const groups = await mdl_UserGroups.findAll({ where: { group_id: ids }, paranoid: false });
|
||||
if (!groups.length) return R.error(res, 'No groups found.', 404);
|
||||
|
||||
const deletedGroups = groups.filter((g) => g.deletedAt);
|
||||
if (!deletedGroups.length)
|
||||
return R.error(res, 'All selected groups must be deactivated before they can be permanently deleted.', 400);
|
||||
|
||||
const deletedIds = deletedGroups.map((g) => g.group_id);
|
||||
|
||||
await mdl_UserGroups.destroy({ where: { group_id: deletedIds }, force: true });
|
||||
|
||||
logActivity(req.user.user_id, 'bulk_permanently_delete_groups', { entityType: 'group', details: { ids: deletedIds, count: deletedIds.length } });
|
||||
return R.success(res, `${deletedIds.length} group(s) permanently deleted.`, {
|
||||
deleted_ids: deletedIds,
|
||||
skipped_ids: ids.filter((id) => !deletedIds.includes(id)),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][BULK PERMANENT DELETE GROUPS]', err);
|
||||
return R.error(res, 'Could not permanently delete groups.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── ARCHIVED ─────────────────────────────────────────────────────────────────
|
||||
exports.getArchivedGroups = async (req, res) => {
|
||||
try {
|
||||
const result = await paginate(mdl_UserGroups, req, {
|
||||
excludeAttributes: groupExclude,
|
||||
jsonbSchemas: groupSchemas,
|
||||
computedAttributes: groupComputed,
|
||||
context: 'archived',
|
||||
auditOptions: { mdl_Users, parentAlias: 'UserGroup' },
|
||||
findOptions: {
|
||||
paranoid: false,
|
||||
where: { deletedAt: { [Op.ne]: null }, is_active: false },
|
||||
},
|
||||
});
|
||||
|
||||
return R.success(res, 'Archived groups retrieved.', result);
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][GET ARCHIVED GROUPS]', err);
|
||||
return R.error(res, 'Could not retrieve archived groups.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── FIELD VALUES ─────────────────────────────────────────────────────────────
|
||||
exports.getGroupFieldValues = getFieldValues(mdl_UserGroups, 'GROUP', {
|
||||
blockedFields: ['deletedAt'],
|
||||
paranoid: false,
|
||||
});
|
||||
|
||||
// ─── MEMBERSHIP ───────────────────────────────────────────────────────────────
|
||||
exports.getUsersNotInGroup = async (req, res) => {
|
||||
try {
|
||||
const { gid: group_id } = req.params;
|
||||
|
||||
// Exclude users already in THIS group
|
||||
const members = await mdl_UserGroupMembers.findAll({ where: { group_id }, attributes: ['user_id'] });
|
||||
const memberIds = members.map((m) => m.user_id);
|
||||
|
||||
const users = await mdl_Users.findAll({
|
||||
where: {
|
||||
user_id: { [Op.notIn]: memberIds.length ? memberIds : [0] },
|
||||
acc_type: 'user', // exclude staff/admin — only regular users can be added to a group
|
||||
},
|
||||
attributes: [
|
||||
'user_id',
|
||||
[Sequelize.literal(`("User"."personal_info"->'name'->>'full_name')`), 'full_name'],
|
||||
// All active groups this user belongs to (excluding NOGRP), comma-separated
|
||||
[
|
||||
Sequelize.literal(`(
|
||||
SELECT STRING_AGG(ug.name || ' (' || ug.group_code || ')', ', ' ORDER BY ug.name)
|
||||
FROM user_group_members ugm
|
||||
JOIN user_groups ug ON ug.group_id = ugm.group_id
|
||||
WHERE ugm.user_id = "User".user_id
|
||||
AND ugm."deletedAt" IS NULL
|
||||
AND ug."deletedAt" IS NULL
|
||||
AND ug.group_code != 'NOGRP'
|
||||
)`),
|
||||
'current_group',
|
||||
],
|
||||
],
|
||||
});
|
||||
|
||||
return R.success(res, 'Users fetched.', users);
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][GET USERS NOT IN GROUP]', err);
|
||||
return R.error(res, 'Could not fetch users.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.getUsersInGroup = async (req, res) => {
|
||||
try {
|
||||
const { gid: group_id } = req.params;
|
||||
|
||||
const group = await mdl_UserGroups.findByPk(group_id, {
|
||||
include: [{
|
||||
model: mdl_Users,
|
||||
as: 'members',
|
||||
through: { attributes: [] },
|
||||
attributes: [
|
||||
'user_id',
|
||||
[Sequelize.literal(`("members"."personal_info"->'name'->>'full_name')`), 'full_name'],
|
||||
],
|
||||
}],
|
||||
});
|
||||
|
||||
if (!group) return R.error(res, 'Group not found.', 404);
|
||||
return R.success(res, 'Group members fetched.', group.members);
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][GET USERS IN GROUP]', err);
|
||||
return R.error(res, 'Could not fetch group members.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.addUserToGroup = async (req, res) => {
|
||||
try {
|
||||
const { gid: group_id } = req.params;
|
||||
const { user_ids } = req.body;
|
||||
|
||||
if (!Array.isArray(user_ids) || !user_ids.length)
|
||||
return R.error(res, 'No users provided.', 400);
|
||||
|
||||
const existingUsers = await mdl_Users.findAll({ where: { user_id: user_ids }, attributes: ['user_id'] });
|
||||
const existingIds = existingUsers.map((u) => u.user_id);
|
||||
const notFound = user_ids.filter((id) => !existingIds.includes(id));
|
||||
|
||||
if (notFound.length)
|
||||
return R.error(res, `Users not found: ${notFound.join(', ')}`, 404);
|
||||
|
||||
await mdl_UserGroupMembers.restore({ where: { user_id: user_ids, group_id } });
|
||||
await mdl_UserGroupMembers.update(
|
||||
{ deletedBy: null, updatedBy: req.user.user_id },
|
||||
{ where: { user_id: user_ids, group_id }, paranoid: false }
|
||||
);
|
||||
await mdl_UserGroupMembers.bulkCreate(
|
||||
user_ids.map((user_id) => ({ user_id, group_id, createdBy: req.user.user_id })),
|
||||
{ ignoreDuplicates: true }
|
||||
);
|
||||
|
||||
await dropDefaultGroupMembership(user_ids, group_id, { updatedBy: req.user.user_id });
|
||||
|
||||
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);
|
||||
return R.error(res, 'Could not add users to group.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.removeUserFromGroup = async (req, res) => {
|
||||
try {
|
||||
const { gid: group_id } = req.params;
|
||||
const { user_ids } = req.body;
|
||||
|
||||
if (!Array.isArray(user_ids) || !user_ids.length)
|
||||
return R.error(res, 'No users provided.', 400);
|
||||
|
||||
const existingMembers = await mdl_UserGroupMembers.findAll({
|
||||
where: { user_id: user_ids, group_id }, attributes: ['user_id'],
|
||||
});
|
||||
const existingIds = existingMembers.map((m) => m.user_id);
|
||||
const notFound = user_ids.filter((id) => !existingIds.includes(id));
|
||||
|
||||
if (notFound.length)
|
||||
return R.error(res, `Memberships not found for users: ${notFound.join(', ')}`, 404);
|
||||
|
||||
await mdl_UserGroupMembers.update(
|
||||
{ deletedBy: req.user.user_id },
|
||||
{ where: { user_id: user_ids, group_id } }
|
||||
);
|
||||
await mdl_UserGroupMembers.destroy({ where: { user_id: user_ids, group_id } });
|
||||
|
||||
await reconcileDefaultGroup(user_ids, { createdBy: req.user.user_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);
|
||||
return R.error(res, 'Could not remove users from group.', 500);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,939 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name: users.controller.js (admin)
|
||||
* Type of Program: Controller
|
||||
* Description: Admin-level user management — full CRUD on any user.
|
||||
*
|
||||
* Author: rgrgogu
|
||||
* Date Created: Oct. 6, 2025
|
||||
***********************************************************************************************************************************************************************/
|
||||
const sequelize = require('../../config/db.config');
|
||||
const { Op, Sequelize } = require('sequelize');
|
||||
const bcrypt = require('bcryptjs');
|
||||
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_UserBans = require('../../models/users/user_bans.mdl');
|
||||
const { mdl_UserGroups, mdl_UserGroupMembers } = require('../../models/users/user_groups.mdl');
|
||||
const mdl_UserTiers = require('../../models/tiers/user_tiers.mdl');
|
||||
const mdl_Payments = require('../../models/tiers/payments.mdl');
|
||||
const mdl_QuizAttempt = require('../../models/courses/quiz_attempt.mdl');
|
||||
|
||||
const { sendEmail } = require('../../services/email.service');
|
||||
const trustedDevice = require('../../services/trustedDevice.service');
|
||||
const { fmtDate } = require('../../utils/datetime.util');
|
||||
const logActivity = require('../../utils/logActivity.util');
|
||||
const R = require('../../utils/response.util');
|
||||
const { paginate } = require('../../utils/paginate.util');
|
||||
const { enrichPersonalInfo } = require('../../utils/personalInfo.util');
|
||||
const { getFieldValues } = require("../../utils/fieldValues.util");
|
||||
const { resolveUserAvatar } = require('../../utils/resolveAvatar.util');
|
||||
|
||||
const { excludeAttributes: usersExclude, jsonbSchemas: usersSchemas, computedAttributes: userComputed } = require('../../models/users/users.attributes');
|
||||
|
||||
const EXCLUDED = ['password', 'otp_code', 'otp_expires_at', 'must_change_password', 'password_expires_at'];
|
||||
const auditByFields = ['createdBy', 'updatedBy', 'deletedBy'];
|
||||
|
||||
// ─── GROUPS FILTER ────────────────────────────────────────────────────────────
|
||||
//
|
||||
// "groups" is a M2M association, not a real column on `users` — buildQuery's
|
||||
// generic Sequelize.col()-based filtering can't touch it. Pull any `groups`
|
||||
// filter out of the request's filter list (so paginate() never sees a field
|
||||
// it can't resolve) and turn it into an EXISTS subquery instead. EXISTS keeps
|
||||
// the LEFT JOIN group data on each row intact (all of a user's groups still
|
||||
// show up), while only matching users who belong to at least one of the
|
||||
// selected groups.
|
||||
function extractGroupsFilter(req) {
|
||||
let filters = [];
|
||||
try { filters = JSON.parse(req.query.filters || '[]'); } catch { filters = []; }
|
||||
|
||||
const groupsFilter = filters.find((f) => f.id === 'groups');
|
||||
const remaining = filters.filter((f) => f.id !== 'groups');
|
||||
req.query.filters = JSON.stringify(remaining);
|
||||
|
||||
if (!groupsFilter?.value) return null;
|
||||
|
||||
const groupIds = (Array.isArray(groupsFilter.value) ? groupsFilter.value : [groupsFilter.value])
|
||||
.map((v) => parseInt(v, 10))
|
||||
.filter((v) => !Number.isNaN(v));
|
||||
|
||||
return groupIds.length ? groupIds : null;
|
||||
}
|
||||
|
||||
function groupsExistsWhere(groupIds) {
|
||||
if (!groupIds) return undefined;
|
||||
return Sequelize.literal(`EXISTS (
|
||||
SELECT 1 FROM "user_group_members" ugm
|
||||
WHERE ugm.user_id = "User"."user_id"
|
||||
AND ugm."deletedAt" IS NULL
|
||||
AND ugm.group_id IN (${groupIds.join(',')})
|
||||
)`);
|
||||
}
|
||||
|
||||
// ─── GET ALL ──────────────────────────────────────────────────────────────────
|
||||
|
||||
exports.getUsers = async (req, res) => {
|
||||
try {
|
||||
const groupIds = extractGroupsFilter(req);
|
||||
|
||||
const result = await paginate(mdl_Users, req, {
|
||||
excludeAttributes: usersExclude,
|
||||
jsonbSchemas: usersSchemas,
|
||||
jsonbColumn: 'personal_info',
|
||||
computedAttributes: userComputed,
|
||||
context: "list",
|
||||
auditOptions: { mdl_Users, parentAlias: 'User' },
|
||||
findOptions: {
|
||||
where: groupsExistsWhere(groupIds),
|
||||
include: [{
|
||||
model: mdl_UserGroups,
|
||||
as: 'groups',
|
||||
through: { attributes: [] }, // hide junction columns
|
||||
attributes: ['group_id', 'name', 'group_code'],
|
||||
required: false, // LEFT JOIN — users with no group still appear
|
||||
}],
|
||||
},
|
||||
});
|
||||
|
||||
return R.success(res, 'Users retrieved.', result);
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][GET ALL USERS]', err);
|
||||
return R.error(res, 'Could not retrieve users.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── GET ONE ──────────────────────────────────────────────────────────────────
|
||||
|
||||
exports.getUser = 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: { exclude: EXCLUDED },
|
||||
include: [{
|
||||
model: mdl_UserGroups,
|
||||
as: 'groups',
|
||||
through: { attributes: [] },
|
||||
}],
|
||||
});
|
||||
|
||||
if (!user) return R.error(res, 'User not found.', 404);
|
||||
return R.success(res, 'User retrieved.', await resolveUserAvatar(user));
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][GET USER]', err);
|
||||
return R.error(res, 'Could not retrieve user.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── ADD STAFF ────────────────────────────────────────────────────────────────
|
||||
|
||||
exports.addStaffUser = async (req, res) => {
|
||||
try {
|
||||
const { email, personal_info = {} } = req.body;
|
||||
|
||||
if (!email)
|
||||
return R.error(res, 'Email is required.', 400);
|
||||
|
||||
if (!personal_info?.name?.given_name || !personal_info?.name?.last_name)
|
||||
return R.error(res, 'First name and last name are required.', 400);
|
||||
|
||||
const existing = await mdl_Users.findOne({ where: { email } });
|
||||
if (existing) return R.error(res, 'Email is already in use.', 409);
|
||||
|
||||
const plainPassword = crypto.randomBytes(8).toString('base64url').slice(0, 12);
|
||||
const hashed = await bcrypt.hash(plainPassword, 12);
|
||||
const passwordExpiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000);
|
||||
const enriched = enrichPersonalInfo(personal_info);
|
||||
const fullName = enriched?.name?.full_name ?? email;
|
||||
|
||||
const user = await mdl_Users.create({
|
||||
email,
|
||||
password: hashed,
|
||||
password_expires_at: passwordExpiresAt,
|
||||
must_change_password: true,
|
||||
acc_type: 'staff',
|
||||
reg_type: 'system',
|
||||
is_active: true,
|
||||
is_verified: true,
|
||||
createdBy: req.user.user_id,
|
||||
personal_info: enriched,
|
||||
});
|
||||
|
||||
await sendEmail({
|
||||
to: email, type: 'ADD_STAFF', data: {
|
||||
name: fullName, email, password: plainPassword, expiryHours: 24,
|
||||
},
|
||||
});
|
||||
|
||||
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,
|
||||
acc_type: user.acc_type,
|
||||
}, 201);
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][ADD STAFF USER]', err);
|
||||
return R.error(res, 'Could not create staff user.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── UPDATE ───────────────────────────────────────────────────────────────────
|
||||
|
||||
exports.updateUser = async (req, res) => {
|
||||
try {
|
||||
const user = await mdl_Users.findByPk(req.params.id);
|
||||
if (!user) return R.error(res, 'User not found.', 404);
|
||||
|
||||
const allowed = ['acc_type', 'is_active', 'personal_info'];
|
||||
const updates = {};
|
||||
allowed.forEach((k) => { if (req.body[k] !== undefined) updates[k] = req.body[k]; });
|
||||
|
||||
if (Number(req.params.id) === req.user.user_id && updates.acc_type !== undefined)
|
||||
return R.error(res, 'Admins cannot change their own role.', 400);
|
||||
|
||||
if (updates.personal_info)
|
||||
updates.personal_info = enrichPersonalInfo(updates.personal_info);
|
||||
|
||||
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) {
|
||||
console.error('[ADMIN][UPDATE USER]', err);
|
||||
return R.error(res, 'Could not update user.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── DEACTIVATE (soft delete) ─────────────────────────────────────────────────
|
||||
|
||||
exports.deactivateUser = async (req, res) => {
|
||||
try {
|
||||
if (Number(req.params.id) === req.user.user_id)
|
||||
return R.error(res, 'You cannot deactivate your own account.', 400);
|
||||
|
||||
const user = await mdl_Users.findByPk(req.params.id);
|
||||
if (!user) return R.error(res, 'User not found.', 404);
|
||||
if (!user.is_active && user.deletedAt)
|
||||
return R.error(res, 'User is already deactivated.', 400);
|
||||
|
||||
await user.update({ is_active: false, deletedBy: req.user.user_id });
|
||||
await user.destroy();
|
||||
|
||||
await mdl_UserGroupMembers.update(
|
||||
{ deletedBy: req.user.user_id },
|
||||
{ where: { user_id: req.params.id } }
|
||||
);
|
||||
await mdl_UserGroupMembers.destroy({ where: { user_id: req.params.id } });
|
||||
|
||||
await mdl_UserSessions.update(
|
||||
{ is_active: false, logout_info: { date: new Date().toISOString(), forced_by: req.user.user_id } },
|
||||
{ where: { user_id: req.params.id } }
|
||||
);
|
||||
await trustedDevice.revokeAllForUser(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);
|
||||
return R.error(res, 'Could not deactivate user.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── BULK DEACTIVATE ──────────────────────────────────────────────────────────
|
||||
|
||||
exports.bulkDeactivateUsers = async (req, res) => {
|
||||
try {
|
||||
const { ids } = req.body;
|
||||
if (!Array.isArray(ids) || !ids.length)
|
||||
return R.error(res, 'No user IDs provided.', 400);
|
||||
|
||||
if (ids.includes(req.user.user_id))
|
||||
return R.error(res, 'You cannot deactivate your own account.', 400);
|
||||
|
||||
const users = await mdl_Users.findAll({ where: { user_id: ids } });
|
||||
if (!users.length) return R.error(res, 'No users found.', 404);
|
||||
|
||||
const activeUsers = users.filter((u) => u.is_active && !u.deletedAt);
|
||||
if (!activeUsers.length)
|
||||
return R.error(res, 'All selected users are already deactivated.', 400);
|
||||
|
||||
const activeIds = activeUsers.map((u) => u.user_id);
|
||||
|
||||
await mdl_Users.update(
|
||||
{ is_active: false, deletedBy: req.user.user_id },
|
||||
{ where: { user_id: activeIds } }
|
||||
);
|
||||
await mdl_Users.destroy({ where: { user_id: activeIds } });
|
||||
|
||||
await mdl_UserGroupMembers.update(
|
||||
{ deletedBy: req.user.user_id },
|
||||
{ where: { user_id: activeIds } }
|
||||
);
|
||||
await mdl_UserGroupMembers.destroy({ where: { user_id: activeIds } });
|
||||
|
||||
await mdl_UserSessions.update(
|
||||
{ is_active: false, logout_info: { date: new Date().toISOString(), forced_by: req.user.user_id } },
|
||||
{ where: { user_id: activeIds } }
|
||||
);
|
||||
await trustedDevice.revokeAllForUser(activeIds);
|
||||
|
||||
return R.success(res, `${activeIds.length} user(s) deactivated successfully.`, {
|
||||
deactivated_ids: activeIds,
|
||||
skipped_ids: ids.filter((id) => !activeIds.includes(id)),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][BULK DEACTIVATE USERS]', err);
|
||||
return R.error(res, 'Could not deactivate users.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── RESTORE ──────────────────────────────────────────────────────────────────
|
||||
|
||||
exports.restoreUser = async (req, res) => {
|
||||
try {
|
||||
const user = await mdl_Users.findOne({
|
||||
where: { user_id: req.params.id }, paranoid: false,
|
||||
});
|
||||
if (!user) return R.error(res, 'User not found.', 404);
|
||||
if (!user.deletedAt) return R.error(res, 'User is not deactivated.', 400);
|
||||
|
||||
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);
|
||||
return R.error(res, 'Could not restore user.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── BULK RESTORE ─────────────────────────────────────────────────────────────
|
||||
|
||||
exports.bulkRestoreUsers = async (req, res) => {
|
||||
try {
|
||||
const { ids } = req.body;
|
||||
if (!Array.isArray(ids) || !ids.length)
|
||||
return R.error(res, 'No user IDs provided.', 400);
|
||||
|
||||
const users = await mdl_Users.findAll({ where: { user_id: ids }, paranoid: false });
|
||||
if (!users.length) return R.error(res, 'No users found.', 404);
|
||||
|
||||
const deletedUsers = users.filter((u) => u.deletedAt);
|
||||
if (!deletedUsers.length)
|
||||
return R.error(res, 'All selected users are already active.', 400);
|
||||
|
||||
const deletedIds = deletedUsers.map((u) => u.user_id);
|
||||
|
||||
await mdl_Users.restore({ where: { user_id: deletedIds } });
|
||||
await mdl_Users.update(
|
||||
{ is_active: true, updatedBy: req.user.user_id, deletedBy: null },
|
||||
{ where: { user_id: deletedIds }, paranoid: false }
|
||||
);
|
||||
|
||||
return R.success(res, `${deletedIds.length} user(s) restored successfully.`, {
|
||||
restored_ids: deletedIds,
|
||||
skipped_ids: ids.filter((id) => !deletedIds.includes(id)),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][BULK RESTORE USERS]', err);
|
||||
return R.error(res, 'Could not restore users.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── PERMANENT DELETE ─────────────────────────────────────────────────────────
|
||||
//
|
||||
// The live DB has several FKs on users.user_id that are NO ACTION rather than
|
||||
// the CASCADE the migration source claims (schema drift) — achievements,
|
||||
// quiz_attempts, user_tiers, and payments all block a hard delete unless their
|
||||
// rows for this user are removed first. Also null out the "who did this"
|
||||
// actor columns (granted_by/revoked_by/lifted_by) on OTHER users' records,
|
||||
// since those aren't this user's own data and shouldn't be deleted.
|
||||
//
|
||||
// UserBan.banned_by is NOT NULL (a ban record must always keep its banner),
|
||||
// so a user who has ever banned someone can't be nulled out or hard-deleted —
|
||||
// getBannerBlockedIds() below identifies those and callers must exclude them.
|
||||
async function purgeUserDependents(userIds, t) {
|
||||
await mdl_UserGroupMembers.destroy({ where: { user_id: userIds }, force: true, transaction: t });
|
||||
await mdl_Achievements.destroy({ where: { user_id: userIds }, transaction: t });
|
||||
await mdl_QuizAttempt.destroy({ where: { user_id: userIds }, transaction: t });
|
||||
await mdl_UserTiers.destroy({ where: { user_id: userIds }, transaction: t });
|
||||
await mdl_Payments.destroy({ where: { user_id: userIds }, transaction: t });
|
||||
|
||||
await mdl_Achievements.update({ granted_by: null }, { where: { granted_by: userIds }, transaction: t });
|
||||
await mdl_UserTiers.update({ granted_by: null }, { where: { granted_by: userIds }, transaction: t });
|
||||
await mdl_UserTiers.update({ revoked_by: null }, { where: { revoked_by: userIds }, transaction: t });
|
||||
await mdl_UserBans.update({ lifted_by: null }, { where: { lifted_by: userIds }, transaction: t });
|
||||
}
|
||||
|
||||
async function getBannerBlockedIds(userIds, t) {
|
||||
const bans = await mdl_UserBans.findAll({
|
||||
where: { banned_by: userIds }, attributes: ['banned_by'], group: ['banned_by'], transaction: t,
|
||||
});
|
||||
return bans.map((b) => b.banned_by);
|
||||
}
|
||||
|
||||
exports.permanentlyDeleteUser = async (req, res) => {
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
if (Number(req.params.id) === req.user.user_id) {
|
||||
await t.rollback();
|
||||
return R.error(res, 'You cannot permanently delete your own account.', 400);
|
||||
}
|
||||
|
||||
const user = await mdl_Users.findOne({
|
||||
where: { user_id: req.params.id }, paranoid: false, transaction: t,
|
||||
});
|
||||
if (!user) { await t.rollback(); return R.error(res, 'User not found.', 404); }
|
||||
if (!user.deletedAt) { await t.rollback(); return R.error(res, 'User must be deactivated before it can be permanently deleted.', 400); }
|
||||
|
||||
const bannerBlockedIds = await getBannerBlockedIds([user.user_id], t);
|
||||
if (bannerBlockedIds.length) {
|
||||
await t.rollback();
|
||||
return R.error(res, 'Cannot delete: this user has banned other users, and ban records must keep their banner on file.', 400);
|
||||
}
|
||||
|
||||
await purgeUserDependents([user.user_id], t);
|
||||
await user.destroy({ force: true, transaction: t });
|
||||
|
||||
await t.commit();
|
||||
|
||||
logActivity(req.user.user_id, 'permanently_delete_user', { entityType: 'user', entityId: Number(req.params.id) });
|
||||
|
||||
return R.success(res, 'User permanently deleted.');
|
||||
} catch (err) {
|
||||
await t.rollback();
|
||||
console.error('[ADMIN][PERMANENT DELETE USER]', err);
|
||||
return R.error(res, 'Could not permanently delete user.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── BULK PERMANENT DELETE ────────────────────────────────────────────────────
|
||||
|
||||
exports.bulkPermanentlyDeleteUsers = async (req, res) => {
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const { ids } = req.body;
|
||||
if (!Array.isArray(ids) || !ids.length) {
|
||||
await t.rollback();
|
||||
return R.error(res, 'No user IDs provided.', 400);
|
||||
}
|
||||
|
||||
if (ids.includes(req.user.user_id)) {
|
||||
await t.rollback();
|
||||
return R.error(res, 'You cannot permanently delete your own account.', 400);
|
||||
}
|
||||
|
||||
const users = await mdl_Users.findAll({ where: { user_id: ids }, paranoid: false, transaction: t });
|
||||
if (!users.length) { await t.rollback(); return R.error(res, 'No users found.', 404); }
|
||||
|
||||
const deletedUsers = users.filter((u) => u.deletedAt);
|
||||
if (!deletedUsers.length) {
|
||||
await t.rollback();
|
||||
return R.error(res, 'All selected users must be deactivated before they can be permanently deleted.', 400);
|
||||
}
|
||||
|
||||
const archivedIds = deletedUsers.map((u) => u.user_id);
|
||||
const bannerBlockedIds = await getBannerBlockedIds(archivedIds, t);
|
||||
const deletedIds = archivedIds.filter((id) => !bannerBlockedIds.includes(id));
|
||||
|
||||
if (!deletedIds.length) {
|
||||
await t.rollback();
|
||||
return R.error(res, 'Cannot delete: all selected users have banned other users, and ban records must keep their banner on file.', 400);
|
||||
}
|
||||
|
||||
await purgeUserDependents(deletedIds, t);
|
||||
await mdl_Users.destroy({ where: { user_id: deletedIds }, force: true, transaction: t });
|
||||
|
||||
await t.commit();
|
||||
|
||||
logActivity(req.user.user_id, 'bulk_permanently_delete_users', {
|
||||
entityType: 'user',
|
||||
details: { ids: deletedIds, count: deletedIds.length },
|
||||
});
|
||||
|
||||
return R.success(res, `${deletedIds.length} user(s) permanently deleted.`, {
|
||||
deleted_ids: deletedIds,
|
||||
skipped_ids: ids.filter((id) => !deletedIds.includes(id)),
|
||||
});
|
||||
} catch (err) {
|
||||
await t.rollback();
|
||||
console.error('[ADMIN][BULK PERMANENT DELETE USERS]', err);
|
||||
return R.error(res, 'Could not permanently delete users.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── SESSIONS ─────────────────────────────────────────────────────────────────
|
||||
|
||||
exports.getUserSessions = async (req, res) => {
|
||||
try {
|
||||
const sessions = await mdl_UserSessions.findAll({
|
||||
where: { user_id: req.params.id },
|
||||
attributes: { exclude: ['refresh_token_hash'] },
|
||||
order: [['createdAt', 'DESC']],
|
||||
});
|
||||
return R.success(res, 'Sessions retrieved.', sessions);
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][GET USER SESSIONS]', err);
|
||||
return R.error(res, 'Could not retrieve sessions.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.terminateSession = async (req, res) => {
|
||||
try {
|
||||
const session = await mdl_UserSessions.findByPk(req.params.sid);
|
||||
if (!session) return R.error(res, 'Session not found.', 404);
|
||||
|
||||
await session.update({
|
||||
is_active: false,
|
||||
logout_info: { date: new Date().toISOString(), forced_by: req.user.user_id },
|
||||
});
|
||||
await trustedDevice.revokeBySessionId(session.session_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);
|
||||
return R.error(res, 'Could not terminate session.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── FIELD VALUES ─────────────────────────────────────────────────────────────
|
||||
|
||||
const getUserFieldValuesBase = getFieldValues(mdl_Users, "USER", {
|
||||
blockedFields: ["password", "otp_code", "otp_expires_at", "personal_info"],
|
||||
extraDateFields: ["modifiedAt", "ban_expires_at"],
|
||||
allowJsonb: true,
|
||||
selfJoin: true,
|
||||
});
|
||||
|
||||
// "groups" isn't a Users model attribute, so the generic getFieldValues()
|
||||
// helper can't resolve it — special-case it here, delegate everything else.
|
||||
exports.getUserFieldValues = async (req, res) => {
|
||||
if (req.query.field === 'groups') {
|
||||
const groups = await mdl_UserGroups.findAll({
|
||||
attributes: [['group_id', 'value'], ['name', 'label']],
|
||||
order: [['name', 'ASC']],
|
||||
raw: true,
|
||||
});
|
||||
return R.success(res, 'Field values retrieved.', groups);
|
||||
}
|
||||
|
||||
return getUserFieldValuesBase(req, res);
|
||||
};
|
||||
|
||||
// ─── ARCHIVED ─────────────────────────────────────────────────────────────────
|
||||
|
||||
// ─── GET ARCHIVED USERS ───────────────────────────────────────────────────────
|
||||
exports.getArchivedUsers = async (req, res) => {
|
||||
try {
|
||||
const groupIds = extractGroupsFilter(req);
|
||||
const archivedWhere = { deletedAt: { [Op.ne]: null }, is_active: false };
|
||||
const groupsWhere = groupsExistsWhere(groupIds);
|
||||
|
||||
const result = await paginate(mdl_Users, req, {
|
||||
excludeAttributes: usersExclude,
|
||||
jsonbSchemas: usersSchemas,
|
||||
jsonbColumn: 'personal_info',
|
||||
computedAttributes: userComputed,
|
||||
context: 'archived',
|
||||
auditOptions: { mdl_Users, parentAlias: 'User' },
|
||||
findOptions: {
|
||||
paranoid: false,
|
||||
where: groupsWhere ? { [Op.and]: [archivedWhere, groupsWhere] } : archivedWhere,
|
||||
include: [{
|
||||
model: mdl_UserGroups,
|
||||
as: 'groups',
|
||||
through: { attributes: [] },
|
||||
attributes: ['group_id', 'name', 'group_code'],
|
||||
required: false,
|
||||
}],
|
||||
},
|
||||
});
|
||||
|
||||
return R.success(res, 'Archived users retrieved.', result);
|
||||
} catch (err) {
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── BAN USER ─────────────────────────────────────────────────────────────────
|
||||
|
||||
exports.banUser = async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
if (Number(id) === req.user.user_id)
|
||||
return R.error(res, 'You cannot ban your own account.', 400);
|
||||
|
||||
const user = await mdl_Users.findByPk(id);
|
||||
if (!user) return R.error(res, 'User not found.', 404);
|
||||
if (user.is_banned) return R.error(res, 'User is already banned.', 400);
|
||||
|
||||
const { reason, ban_type, expires_at } = req.body;
|
||||
|
||||
if (!reason?.trim()) return R.error(res, 'Ban reason is required.', 400);
|
||||
if (!['temporary', 'permanent'].includes(ban_type))
|
||||
return R.error(res, 'Invalid ban type. Must be "temporary" or "permanent".', 400);
|
||||
if (ban_type === 'temporary' && !expires_at)
|
||||
return R.error(res, 'Expiry date is required for temporary bans.', 400);
|
||||
if (ban_type === 'temporary' && new Date(expires_at) <= new Date())
|
||||
return R.error(res, 'Expiry date must be in the future.', 400);
|
||||
|
||||
const banExpiresAt = ban_type === 'temporary' ? new Date(expires_at) : null;
|
||||
|
||||
await sequelize.transaction(async (t) => {
|
||||
await mdl_UserBans.create({
|
||||
user_id: id,
|
||||
banned_by: req.user.user_id,
|
||||
reason: reason.trim(),
|
||||
ban_type,
|
||||
banned_at: new Date(),
|
||||
expires_at: banExpiresAt,
|
||||
}, { transaction: t });
|
||||
|
||||
await user.update({ is_banned: true, ban_expires_at: banExpiresAt }, { transaction: t });
|
||||
|
||||
await mdl_UserSessions.update(
|
||||
{ is_active: false, logout_info: { date: new Date().toISOString(), forced_by: req.user.user_id } },
|
||||
{ where: { user_id: id }, transaction: t }
|
||||
);
|
||||
await trustedDevice.revokeAllForUser(id);
|
||||
});
|
||||
|
||||
logActivity(req.user.user_id, 'ban_user', {
|
||||
entityType: 'user',
|
||||
entityId: Number(id),
|
||||
details: { reason: reason.trim(), ban_type, expires_at: banExpiresAt },
|
||||
});
|
||||
|
||||
sendEmail({
|
||||
to: user.email,
|
||||
type: 'BANNED',
|
||||
data: {
|
||||
name: user.personal_info?.name?.full_name ?? 'User',
|
||||
email: user.email,
|
||||
date: fmtDate(new Date()),
|
||||
reason: reason.trim(),
|
||||
ban_type,
|
||||
},
|
||||
}).catch((err) => console.error('[ADMIN][BAN USER] Email failed:', err));
|
||||
|
||||
return R.success(res, 'User banned successfully.');
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][BAN USER]', err);
|
||||
return R.error(res, 'Could not ban user.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── UNBAN USER ───────────────────────────────────────────────────────────────
|
||||
|
||||
exports.unbanUser = async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const user = await mdl_Users.findByPk(id);
|
||||
if (!user) return R.error(res, 'User not found.', 404);
|
||||
if (!user.is_banned) return R.error(res, 'User is not currently banned.', 400);
|
||||
|
||||
const { lift_reason } = req.body;
|
||||
|
||||
const activeBan = await mdl_UserBans.findOne({
|
||||
where: { user_id: id, is_lifted: false },
|
||||
order: [['banned_at', 'DESC']],
|
||||
});
|
||||
|
||||
await sequelize.transaction(async (t) => {
|
||||
if (activeBan) {
|
||||
await activeBan.update({
|
||||
is_lifted: true,
|
||||
lifted_at: new Date(),
|
||||
lifted_by: req.user.user_id,
|
||||
lift_reason: lift_reason?.trim() || null,
|
||||
}, { transaction: t });
|
||||
}
|
||||
|
||||
await user.update({ is_banned: false, ban_expires_at: null }, { transaction: t });
|
||||
});
|
||||
|
||||
logActivity(req.user.user_id, 'unban_user', { entityType: 'user', entityId: Number(id) });
|
||||
|
||||
sendEmail({
|
||||
to: user.email,
|
||||
type: 'BAN_LIFTED',
|
||||
data: {
|
||||
name: user.personal_info?.name?.full_name ?? 'User',
|
||||
email: user.email,
|
||||
date: fmtDate(new Date()),
|
||||
},
|
||||
}).catch((err) => console.error('[ADMIN][UNBAN USER] Email failed:', err));
|
||||
|
||||
return R.success(res, 'User unbanned successfully.');
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][UNBAN USER]', err);
|
||||
return R.error(res, 'Could not unban user.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── MAKE ADMIN ───────────────────────────────────────────────────────────────
|
||||
|
||||
exports.makeAdmin = async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
if (Number(id) === req.user.user_id)
|
||||
return R.error(res, 'You cannot change your own role.', 400);
|
||||
|
||||
const user = await mdl_Users.findByPk(id);
|
||||
if (!user) return R.error(res, 'User not found.', 404);
|
||||
if (user.acc_type === 'admin')
|
||||
return R.error(res, 'User is already an administrator.', 400);
|
||||
|
||||
await user.update({ acc_type: 'admin', updatedBy: req.user.user_id });
|
||||
|
||||
logActivity(req.user.user_id, 'make_admin', { entityType: 'user', entityId: Number(id) });
|
||||
|
||||
const fullName = user.personal_info?.name?.full_name ?? 'User';
|
||||
sendEmail({
|
||||
to: user.email,
|
||||
type: 'MADE_ADMIN',
|
||||
data: { name: fullName.split(' ')[0], email: user.email },
|
||||
}).catch((err) => console.error('[ADMIN][MAKE ADMIN] Email failed:', err));
|
||||
|
||||
return R.success(res, 'User promoted to Administrator.');
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][MAKE ADMIN]', err);
|
||||
return R.error(res, 'Could not update user role.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── DEMOTE ADMIN ─────────────────────────────────────────────────────────────
|
||||
|
||||
exports.demoteAdmin = async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
if (Number(id) === req.user.user_id)
|
||||
return R.error(res, 'You cannot change your own role.', 400);
|
||||
|
||||
const user = await mdl_Users.findByPk(id);
|
||||
if (!user) return R.error(res, 'User not found.', 404);
|
||||
if (user.acc_type !== 'admin')
|
||||
return R.error(res, 'User is not an administrator.', 400);
|
||||
|
||||
await user.update({ acc_type: 'user', updatedBy: req.user.user_id });
|
||||
|
||||
logActivity(req.user.user_id, 'demote_admin', { entityType: 'user', entityId: Number(id) });
|
||||
|
||||
const fullName = user.personal_info?.name?.full_name ?? 'User';
|
||||
sendEmail({
|
||||
to: user.email,
|
||||
type: 'DEMOTED_ADMIN',
|
||||
data: { name: fullName.split(' ')[0], email: user.email },
|
||||
}).catch((err) => console.error('[ADMIN][DEMOTE ADMIN] Email failed:', err));
|
||||
|
||||
return R.success(res, 'Administrator access removed.');
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][DEMOTE ADMIN]', err);
|
||||
return R.error(res, 'Could not update user role.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── BULK BAN ─────────────────────────────────────────────────────────────────
|
||||
|
||||
exports.bulkBanUsers = async (req, res) => {
|
||||
try {
|
||||
const { ids, reason, ban_type, expires_at } = req.body;
|
||||
|
||||
if (!Array.isArray(ids) || !ids.length)
|
||||
return R.error(res, 'No user IDs provided.', 400);
|
||||
if (ids.includes(req.user.user_id))
|
||||
return R.error(res, 'You cannot ban your own account.', 400);
|
||||
if (!reason?.trim()) return R.error(res, 'Ban reason is required.', 400);
|
||||
if (!['temporary', 'permanent'].includes(ban_type))
|
||||
return R.error(res, 'Invalid ban type.', 400);
|
||||
if (ban_type === 'temporary' && !expires_at)
|
||||
return R.error(res, 'Expiry date is required for temporary bans.', 400);
|
||||
if (ban_type === 'temporary' && new Date(expires_at) <= new Date())
|
||||
return R.error(res, 'Expiry date must be in the future.', 400);
|
||||
|
||||
const users = await mdl_Users.findAll({ where: { user_id: ids } });
|
||||
if (!users.length) return R.error(res, 'No users found.', 404);
|
||||
|
||||
const unbannedUsers = users.filter((u) => !u.is_banned);
|
||||
if (!unbannedUsers.length)
|
||||
return R.error(res, 'All selected users are already banned.', 400);
|
||||
|
||||
const targetIds = unbannedUsers.map((u) => u.user_id);
|
||||
const banExpiresAt = ban_type === 'temporary' ? new Date(expires_at) : null;
|
||||
const now = new Date();
|
||||
|
||||
await sequelize.transaction(async (t) => {
|
||||
await mdl_UserBans.bulkCreate(
|
||||
targetIds.map((uid) => ({
|
||||
user_id: uid,
|
||||
banned_by: req.user.user_id,
|
||||
reason: reason.trim(),
|
||||
ban_type,
|
||||
banned_at: now,
|
||||
expires_at: banExpiresAt,
|
||||
})),
|
||||
{ transaction: t }
|
||||
);
|
||||
|
||||
await mdl_Users.update(
|
||||
{ is_banned: true, ban_expires_at: banExpiresAt },
|
||||
{ where: { user_id: targetIds }, transaction: t }
|
||||
);
|
||||
|
||||
await mdl_UserSessions.update(
|
||||
{ is_active: false, logout_info: { date: now.toISOString(), forced_by: req.user.user_id } },
|
||||
{ where: { user_id: targetIds }, transaction: t }
|
||||
);
|
||||
await trustedDevice.revokeAllForUser(targetIds);
|
||||
});
|
||||
|
||||
const dateStr = fmtDate(new Date());
|
||||
unbannedUsers.forEach((u) => {
|
||||
sendEmail({
|
||||
to: u.email,
|
||||
type: 'BANNED',
|
||||
data: {
|
||||
name: u.personal_info?.name?.full_name ?? 'User',
|
||||
email: u.email,
|
||||
date: dateStr,
|
||||
reason: reason.trim(),
|
||||
ban_type,
|
||||
},
|
||||
}).catch((err) => console.error('[ADMIN][BULK BAN] Email failed:', u.email, err));
|
||||
});
|
||||
|
||||
return R.success(res, `${targetIds.length} user(s) banned successfully.`, {
|
||||
banned_ids: targetIds,
|
||||
skipped_ids: ids.filter((id) => !targetIds.includes(Number(id))),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][BULK BAN USERS]', err);
|
||||
return R.error(res, 'Could not ban users.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── GET USER BAN HISTORY ─────────────────────────────────────────────────────
|
||||
|
||||
exports.getUserBans = 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 bans = await mdl_UserBans.findAll({
|
||||
where: { user_id: id },
|
||||
include: [
|
||||
{ model: mdl_Users, as: 'banner', attributes: ['user_id', 'email', 'personal_info'] },
|
||||
{ model: mdl_Users, as: 'lifter', attributes: ['user_id', 'email', 'personal_info'] },
|
||||
],
|
||||
order: [['banned_at', 'DESC']],
|
||||
});
|
||||
|
||||
return R.success(res, 'Ban history retrieved.', bans);
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][GET USER BANS]', err);
|
||||
return R.error(res, 'Could not retrieve ban history.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── BULK UNBAN ───────────────────────────────────────────────────────────────
|
||||
|
||||
exports.bulkUnbanUsers = async (req, res) => {
|
||||
try {
|
||||
const { ids, lift_reason } = req.body;
|
||||
|
||||
if (!Array.isArray(ids) || !ids.length)
|
||||
return R.error(res, 'No user IDs provided.', 400);
|
||||
|
||||
const users = await mdl_Users.findAll({ where: { user_id: ids } });
|
||||
if (!users.length) return R.error(res, 'No users found.', 404);
|
||||
|
||||
const bannedUsers = users.filter((u) => u.is_banned);
|
||||
if (!bannedUsers.length)
|
||||
return R.error(res, 'None of the selected users are currently banned.', 400);
|
||||
|
||||
const targetIds = bannedUsers.map((u) => u.user_id);
|
||||
const now = new Date();
|
||||
|
||||
await sequelize.transaction(async (t) => {
|
||||
await mdl_UserBans.update(
|
||||
{
|
||||
is_lifted: true,
|
||||
lifted_at: now,
|
||||
lifted_by: req.user.user_id,
|
||||
lift_reason: lift_reason?.trim() || null,
|
||||
},
|
||||
{ where: { user_id: targetIds, is_lifted: false }, transaction: t }
|
||||
);
|
||||
|
||||
await mdl_Users.update(
|
||||
{ is_banned: false, ban_expires_at: null },
|
||||
{ where: { user_id: targetIds }, transaction: t }
|
||||
);
|
||||
});
|
||||
|
||||
logActivity(req.user.user_id, 'bulk_unban_users', {
|
||||
entityType: 'user',
|
||||
details: { unban_ids: targetIds },
|
||||
});
|
||||
|
||||
const dateStr = fmtDate(now);
|
||||
bannedUsers.forEach((u) => {
|
||||
sendEmail({
|
||||
to: u.email,
|
||||
type: 'BAN_LIFTED',
|
||||
data: {
|
||||
name: u.personal_info?.name?.full_name ?? 'User',
|
||||
email: u.email,
|
||||
date: dateStr,
|
||||
},
|
||||
}).catch((err) => console.error('[ADMIN][BULK UNBAN] Email failed:', u.email, err));
|
||||
});
|
||||
|
||||
return R.success(res, `${targetIds.length} user(s) unbanned successfully.`, {
|
||||
unbanned_ids: targetIds,
|
||||
skipped_ids: ids.filter((id) => !targetIds.includes(Number(id))),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[ADMIN][BULK UNBAN USERS]', err);
|
||||
return R.error(res, 'Could not unban users.', 500);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,838 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name: auth.controller.js
|
||||
* Type of Program: Controller
|
||||
* Description: Handles all authentication flows. Every credential path — system
|
||||
* registration, system login, and Google OAuth — funnels through
|
||||
* the same OTP gate before tokens are issued:
|
||||
* 1. System Registration → OTP email → verifyOTP (first-time) → tokens
|
||||
* 2. System Login → OTP email → verifyOTP (routine) → tokens
|
||||
* 3. Google OAuth callback → OTP email → verifyOTP (either) → tokens
|
||||
* 4. Token Refresh
|
||||
* 5. Logout (invalidates session)
|
||||
* 6. OTP Resend (only valid while an OTP is pending)
|
||||
* 7. Change Password
|
||||
*
|
||||
* verifyOTP is the single place tokens/sessions are minted — it
|
||||
* branches on the user's is_verified flag *before* the update to
|
||||
* decide whether this is a first-time pass (fires welcome email/
|
||||
* achievements/notifications) or a routine login OTP (skips them).
|
||||
*
|
||||
* Author: rgrgogu
|
||||
* Date Created: Oct. 6, 2025
|
||||
* Date Modified: Jul. 4, 2026 — mandatory OTP on every login (Kenneth Obsequio)
|
||||
***********************************************************************************************************************************************************************
|
||||
* HOW TO USE:
|
||||
* Mount via routes/auth.routes.js
|
||||
* POST /api/auth/register
|
||||
* POST /api/auth/verify-otp
|
||||
* POST /api/auth/resend-otp
|
||||
* POST /api/auth/login
|
||||
* POST /api/auth/refresh
|
||||
* POST /api/auth/logout
|
||||
* GET /api/auth/google
|
||||
* GET /api/auth/google/callback
|
||||
***********************************************************************************************************************************************************************/
|
||||
const bcrypt = require('bcryptjs');
|
||||
const crypto = require('crypto');
|
||||
const sequelize = require('../config/db.config')
|
||||
const mdl_Users = require('../models/users/users.mdl');
|
||||
const mdl_UserSessions = require('../models/users/user_sessions.mdl');
|
||||
const { checkAccountStatus } = require('../services/accountStatus.service');
|
||||
const { mdl_UserGroups, mdl_UserGroupMembers } = require('../models/users/user_groups.mdl')
|
||||
const { NOGRP_CODE, getDefaultGroupId, enrollDefaultGroup, switchFromNogrpByCode } = require('../utils/defaultGroup.util');
|
||||
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 { 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 trustedDevice = require('../services/trustedDevice.service');
|
||||
const { resolveUserAvatar } = require('../utils/resolveAvatar.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'];
|
||||
|
||||
const safeUser = async (user, extraExclude = []) => {
|
||||
const u = user.toJSON ? user.toJSON() : { ...user };
|
||||
|
||||
[...EXCLUDED, ...extraExclude].forEach((key) => delete u[key]);
|
||||
|
||||
return resolveUserAvatar(u);
|
||||
};
|
||||
|
||||
const setRefreshCookie = (res, refreshToken) => {
|
||||
res.cookie('refreshToken', refreshToken, {
|
||||
httpOnly: true, // ← JS cannot read this
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
// 'none' (not 'strict') in production — frontend (Vercel) and this API
|
||||
// (Render) are cross-site, so 'strict'/'lax' silently drop the cookie on
|
||||
// every fetch/XHR refresh call. 'none' requires secure:true (set above).
|
||||
sameSite: process.env.NODE_ENV === 'production' ? 'none' : 'lax',
|
||||
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
|
||||
});
|
||||
};
|
||||
|
||||
// The Google OAuth callback can't carry its outcome (otpRequired, ban details,
|
||||
// errors) as a query string on the redirect without flashing it in the address
|
||||
// bar — the browser lands on that literal URL before any frontend JS runs, so
|
||||
// client-side scrubbing is always at least a frame too late. Instead, the
|
||||
// outcome is stashed in a short-lived signed cookie and handed to the frontend
|
||||
// only when it explicitly asks for it via GET /auth/google/result.
|
||||
const GOOGLE_RESULT_COOKIE = '_googleAuthResult';
|
||||
|
||||
const setGoogleResultCookie = (res, payload) => {
|
||||
res.cookie(GOOGLE_RESULT_COOKIE, JSON.stringify(payload), {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
// Set right after a top-level redirect (survives 'lax'), but read back via
|
||||
// a cross-site fetch from GET /auth/google/result — 'lax' drops it there
|
||||
// in production since frontend (Vercel) and this API (Render) are cross-site.
|
||||
sameSite: process.env.NODE_ENV === 'production' ? 'none' : 'lax',
|
||||
maxAge: 2 * 60 * 1000, // 2 minutes — just long enough for the callback redirect to land
|
||||
signed: true,
|
||||
});
|
||||
};
|
||||
|
||||
// ─── Mint Session ──────────────────────────────────────────────────────────────
|
||||
// Shared by verifyOTP and the trusted-device fast path in login/googleCallback —
|
||||
// the only two places tokens/sessions get minted.
|
||||
const mintSession = async (req, user, { transaction } = {}) => {
|
||||
const wasVerified = user.is_verified;
|
||||
|
||||
await user.update({ is_verified: true, otp_code: null, otp_expires_at: null }, { transaction });
|
||||
|
||||
const { accessToken, refreshToken } = generateTokens(user);
|
||||
const session = await mdl_UserSessions.create({
|
||||
user_id: user.user_id,
|
||||
login_info: await buildSessionInfo(req),
|
||||
refresh_token_hash: hashToken(refreshToken),
|
||||
is_active: true,
|
||||
}, { transaction });
|
||||
|
||||
return { accessToken, refreshToken, session, wasVerified };
|
||||
};
|
||||
|
||||
// ─── Register ──────────────────────────────────────────────────────────────────
|
||||
exports.register = async (req, res) => {
|
||||
const transaction = await sequelize.transaction();
|
||||
|
||||
try {
|
||||
const { email, password, personal_info, group_code, confirm_resume } = req.body;
|
||||
|
||||
// ── Duplicate check ───────────────────────────────────────────────────────
|
||||
// A verified account owns the email outright — hard block. An unverified
|
||||
// one is just an abandoned attempt (e.g. dropped connection before the OTP
|
||||
// step completed, or the client retried after a failed send) — the same
|
||||
// person retrying should be able to resume it rather than dead-end here.
|
||||
// The client must explicitly confirm_resume (after the user accepts a
|
||||
// confirmation dialog) before we overwrite that abandoned attempt's data.
|
||||
const existing = await mdl_Users.findOne({ where: { email } });
|
||||
if (existing) {
|
||||
if (existing.is_verified) {
|
||||
await transaction.rollback();
|
||||
return R.error(res, 'Email is already registered.', 409);
|
||||
}
|
||||
if (existing.reg_type !== 'system') {
|
||||
await transaction.rollback();
|
||||
return R.error(res, 'This email is linked to a Google account. Please sign in with Google instead.', 409, { google: true });
|
||||
}
|
||||
|
||||
if (!confirm_resume) {
|
||||
await transaction.rollback();
|
||||
return R.error(
|
||||
res,
|
||||
'An account with this email already has a pending verification. Resend the code and continue?',
|
||||
409,
|
||||
{ pendingVerification: true },
|
||||
);
|
||||
}
|
||||
|
||||
const hashed = await bcrypt.hash(password, 12);
|
||||
const otp = generateOTP();
|
||||
|
||||
await existing.update({
|
||||
password: hashed,
|
||||
personal_info: personal_info ?? existing.personal_info,
|
||||
otp_code: otp,
|
||||
otp_expires_at: getOTPExpiry(),
|
||||
}, { transaction });
|
||||
|
||||
await transaction.commit();
|
||||
|
||||
sendEmail({ to: email, type: 'OTP', data: { otp } })
|
||||
.catch(err => console.error('[AUTH] Failed to send OTP email:', err));
|
||||
|
||||
return R.success(res, 'Registration successful. Please check your email for the OTP.', {
|
||||
email: existing.email,
|
||||
}, 201);
|
||||
}
|
||||
|
||||
// ── Validate group_code if provided ───────────────────────────────────────
|
||||
let group = null;
|
||||
if (group_code) {
|
||||
group = await mdl_UserGroups.findOne({
|
||||
where: { group_code: group_code.toUpperCase().trim(), is_active: true },
|
||||
});
|
||||
if (!group) return R.error(res, 'Invalid or inactive group code.', 400);
|
||||
}
|
||||
|
||||
// ── Resolve enroll target (explicit group or NOGRP fallback) ──────────────
|
||||
const enrollGroupId = group?.group_id ?? await getDefaultGroupId();
|
||||
|
||||
// ── Create user ───────────────────────────────────────────────────────────
|
||||
const hashed = await bcrypt.hash(password, 12);
|
||||
const otp = generateOTP();
|
||||
|
||||
const user = await mdl_Users.create({
|
||||
email,
|
||||
password: hashed,
|
||||
otp_code: otp,
|
||||
otp_expires_at: getOTPExpiry(),
|
||||
is_active: true,
|
||||
is_verified: false,
|
||||
reg_type: 'system',
|
||||
acc_type: 'user',
|
||||
personal_info: personal_info ?? null,
|
||||
// System registration's Personal Info step already collects the exact
|
||||
// fields the /intro flow asks for (name, birthday, occupation, phone) —
|
||||
// Google signups only get name+avatar from the ID token and still need it.
|
||||
needs_intro: false,
|
||||
createdBy: null,
|
||||
}, { transaction });
|
||||
|
||||
// ── Enroll into group ─────────────────────────────────────────────────────
|
||||
if (enrollGroupId) {
|
||||
await mdl_UserGroupMembers.create({
|
||||
group_id: enrollGroupId,
|
||||
user_id: user.user_id,
|
||||
createdBy: null,
|
||||
}, { transaction });
|
||||
}
|
||||
|
||||
await transaction.commit();
|
||||
|
||||
// Fire-and-forget: don't let an SMTP hiccup or template issue roll back
|
||||
// an otherwise-successful registration — resendOTP covers redelivery.
|
||||
sendEmail({ to: email, type: 'OTP', data: { otp } })
|
||||
.catch(err => console.error('[AUTH] Failed to send OTP email:', err));
|
||||
|
||||
// Fire-and-forget: notify admins — explicit group or NOGRP fallback
|
||||
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));
|
||||
} else if (enrollGroupId) {
|
||||
AdminNotification.create(NOTIFICATION_REGISTRY.nogrp_user_registered.build({
|
||||
userEmail: email,
|
||||
regType: 'system',
|
||||
}))
|
||||
.catch(err => console.error('[AUTH] Failed to emit nogrp_user_registered notification:', err));
|
||||
}
|
||||
|
||||
return R.success(res, 'Registration successful. Please check your email for the OTP.', {
|
||||
email: user.email,
|
||||
}, 201);
|
||||
} catch (err) {
|
||||
await transaction.rollback();
|
||||
console.error('[AUTH] register error:', err);
|
||||
return R.error(res, 'Registration failed.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Verify OTP ────────────────────────────────────────────────────────────────
|
||||
exports.verifyOTP = async (req, res) => {
|
||||
const transaction = await sequelize.transaction();
|
||||
|
||||
try {
|
||||
const { email, otp } = req.body;
|
||||
|
||||
const user = await mdl_Users.findOne({ where: { email } });
|
||||
if (!user) return R.error(res, 'User not found.', 404);
|
||||
|
||||
const storedOTP = Buffer.from(user.otp_code ?? '');
|
||||
const givenOTP = Buffer.from(otp ?? '');
|
||||
if (storedOTP.length !== givenOTP.length || !crypto.timingSafeEqual(storedOTP, givenOTP))
|
||||
return R.error(res, 'Invalid OTP.', 400);
|
||||
if (isOTPExpired(user.otp_expires_at)) return R.error(res, 'OTP has expired. Please request a new one.', 400);
|
||||
|
||||
// wasVerified (captured inside mintSession, before its update) tells us
|
||||
// whether this is the account's very first OTP pass (system registration
|
||||
// or first-ever Google login) or a routine login OTP — only the former
|
||||
// fires the welcome/achievements bundle.
|
||||
const { accessToken, refreshToken, session, wasVerified } = await mintSession(req, user, { transaction });
|
||||
|
||||
await transaction.commit();
|
||||
|
||||
// Fire-and-forget: activity log
|
||||
logActivity(user.user_id, wasVerified ? 'login' : 'register', { entityType: 'session', entityId: Number(session.session_id) });
|
||||
|
||||
if (!wasVerified) {
|
||||
// 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(async membership => {
|
||||
const grp = membership?.UserGroup;
|
||||
const now = new Date();
|
||||
const notifications = [
|
||||
{
|
||||
user_id: user.user_id,
|
||||
...NOTIFICATION_REGISTRY.welcome.build({
|
||||
groupName: grp?.name ?? null,
|
||||
groupCode: grp?.group_code ?? null,
|
||||
accType: user.acc_type,
|
||||
groupId: membership?.group_id ?? null,
|
||||
}),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
];
|
||||
if (grp?.group_code === NOGRP_CODE) {
|
||||
notifications.push({
|
||||
user_id: user.user_id,
|
||||
...NOTIFICATION_REGISTRY.nogrp_welcome.build(),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
return UserNotification.bulkCreate(notifications, { validate: false });
|
||||
}).catch(err => console.error('[AUTH] Failed to emit welcome notification:', err));
|
||||
}
|
||||
|
||||
// Clearing an OTP is what marks a device trusted going forward — this is
|
||||
// the only place trust is first granted (the login/googleCallback fast
|
||||
// path only ever rolls an existing trust window forward).
|
||||
await trustedDevice.issueOrRefresh(res, user.user_id, trustedDevice.getFingerprintHash(req), session.session_id);
|
||||
|
||||
setRefreshCookie(res, refreshToken);
|
||||
|
||||
return R.success(res, wasVerified ? 'Login successful.' : 'Email verified successfully. You are now logged in.', {
|
||||
accessToken,
|
||||
session_id: session.session_id,
|
||||
user: await safeUser(user),
|
||||
});
|
||||
} catch (err) {
|
||||
await transaction.rollback();
|
||||
console.error('[AUTH] verifyOTP error:', err);
|
||||
return R.error(res, 'OTP verification failed.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Resend OTP ────────────────────────────────────────────────────────────────
|
||||
exports.resendOTP = async (req, res) => {
|
||||
const transaction = await sequelize.transaction();
|
||||
|
||||
try {
|
||||
const { email } = req.body;
|
||||
const user = await mdl_Users.findOne({ where: { email } });
|
||||
if (!user) return R.error(res, 'User not found.', 404);
|
||||
// Only a resend, never a first send — otp_code is only ever populated by
|
||||
// register/login/googleCallback, each of which already proved credential
|
||||
// ownership. Without this guard, resendOTP would let anyone force a fresh
|
||||
// OTP for an arbitrary verified account without ever knowing its password.
|
||||
if (!user.otp_code) return R.error(res, 'No pending verification. Please log in again.', 400);
|
||||
|
||||
const otp = generateOTP();
|
||||
await user.update({ otp_code: otp, otp_expires_at: getOTPExpiry() }, { transaction });
|
||||
|
||||
await transaction.commit();
|
||||
|
||||
// Fire-and-forget: don't let an SMTP hiccup or template issue roll back
|
||||
// the already-persisted OTP refresh.
|
||||
sendEmail({ to: email, type: user.is_verified ? 'LOGIN_OTP' : 'OTP', data: { otp } })
|
||||
.catch(err => console.error('[AUTH] Failed to send OTP email:', err));
|
||||
|
||||
return R.success(res, 'A new OTP has been sent to your email.');
|
||||
} catch (err) {
|
||||
await transaction.rollback();
|
||||
console.error('[AUTH] resendOTP error:', err);
|
||||
return R.error(res, 'Could not resend OTP.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── System Login ──────────────────────────────────────────────────────────────
|
||||
exports.login = async (req, res) => {
|
||||
try {
|
||||
const { email, password, group_code } = req.body;
|
||||
|
||||
const user = await mdl_Users.findOne({ where: { email } });
|
||||
if (!user) return R.error(res, 'Invalid credentials.', 401);
|
||||
if (user.reg_type === 'google') return R.error(res, 'Please log in with Google.', 400);
|
||||
|
||||
const status = await checkAccountStatus(user);
|
||||
if (!status.ok) {
|
||||
if (status.code === 'deactivated') return R.error(res, 'Account is deactivated.', 403);
|
||||
return R.error(res, 'Your account has been suspended.', 403, {
|
||||
banned: true,
|
||||
reason: status.reason,
|
||||
ban_type: status.ban_type,
|
||||
ban_expires_at: status.ban_expires_at,
|
||||
});
|
||||
}
|
||||
|
||||
const match = await bcrypt.compare(password, user.password);
|
||||
if (!match) return R.error(res, 'Invalid credentials.', 401);
|
||||
|
||||
// Password confirmed the account is genuinely theirs — same bar register
|
||||
// uses before enrolling into a group, so an invite link followed by
|
||||
// "already have an account? sign in" moves a NOGRP user into the group
|
||||
// right here rather than dead-ending on an invite link that only works
|
||||
// for brand-new accounts.
|
||||
if (group_code) {
|
||||
await switchFromNogrpByCode(user.user_id, group_code)
|
||||
.catch(err => console.error('[AUTH] login: Failed to switch NOGRP membership:', err));
|
||||
}
|
||||
|
||||
// If this device already cleared an OTP recently and
|
||||
// its trust window hasn't lapsed or been revoked, skip the OTP gate
|
||||
// entirely — otherwise fall through to the usual fresh-OTP flow. Tokens
|
||||
// are only ever minted via mintSession (called here or from verifyOTP).
|
||||
const fingerprintHash = trustedDevice.getFingerprintHash(req);
|
||||
const trusted = await trustedDevice.findValid(user.user_id, req.cookies[trustedDevice.COOKIE_NAME], fingerprintHash);
|
||||
|
||||
if (trusted) {
|
||||
const { accessToken, refreshToken, session } = await mintSession(req, user, {});
|
||||
await trustedDevice.issueOrRefresh(res, user.user_id, fingerprintHash, session.session_id);
|
||||
setRefreshCookie(res, refreshToken);
|
||||
|
||||
logActivity(user.user_id, 'login', { entityType: 'session', entityId: Number(session.session_id) });
|
||||
|
||||
return R.success(res, 'Login successful.', {
|
||||
otpRequired: false,
|
||||
accessToken,
|
||||
session_id: session.session_id,
|
||||
user: await safeUser(user),
|
||||
});
|
||||
}
|
||||
|
||||
const otp = generateOTP();
|
||||
await user.update({ otp_code: otp, otp_expires_at: getOTPExpiry() });
|
||||
|
||||
sendEmail({ to: email, type: 'LOGIN_OTP', data: { otp } })
|
||||
.catch(err => console.error('[AUTH] Failed to send login OTP email:', err));
|
||||
|
||||
return R.success(res, 'OTP sent to your email. Please verify to complete login.', {
|
||||
otpRequired: true,
|
||||
email: user.email,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[AUTH] login error:', err);
|
||||
return R.error(res, 'Login failed.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── 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();
|
||||
// Carried through to the callback below — an invite link's group_code has
|
||||
// to survive the round trip to Google and back, so it rides in the same
|
||||
// short-lived signed cookie as state/nonce/codeVerifier.
|
||||
const group_code = typeof req.query.group_code === 'string' ? req.query.group_code.trim() : null;
|
||||
|
||||
// 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, group_code }), {
|
||||
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 { code, state, error } = req.query;
|
||||
|
||||
if (error) {
|
||||
setGoogleResultCookie(res, { error });
|
||||
return res.redirect(CALLBACK_PAGE);
|
||||
}
|
||||
|
||||
// Read and immediately clear the oauth state cookie.
|
||||
const rawCookie = req.signedCookies['_oauth'];
|
||||
res.clearCookie('_oauth');
|
||||
|
||||
if (!rawCookie) {
|
||||
setGoogleResultCookie(res, { error: 'session_expired' });
|
||||
return res.redirect(CALLBACK_PAGE);
|
||||
}
|
||||
|
||||
const { state: expectedState, nonce, codeVerifier, group_code } = JSON.parse(rawCookie);
|
||||
|
||||
if (!state || state !== expectedState) {
|
||||
setGoogleResultCookie(res, { error: 'state_mismatch' });
|
||||
return res.redirect(CALLBACK_PAGE);
|
||||
}
|
||||
|
||||
// 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.
|
||||
// Use paranoid:false so soft-deleted rows are visible — if one is blocking the email slot, free it first.
|
||||
let user = await mdl_Users.findOne({ where: { email: payload.email }, paranoid: false });
|
||||
if (user?.deletedAt) {
|
||||
await user.update({ email: `deleted_${user.user_id}@deleted.invalid` });
|
||||
user = null;
|
||||
}
|
||||
|
||||
// A system (manual) registration signing in with Google for the first time
|
||||
// gets folded into that same account rather than blocked or duplicated.
|
||||
// From this point on the account is Google-only — mirrors the existing
|
||||
// rule that blocks Google accounts from manual login/password reset.
|
||||
let justLinkedGoogle = false;
|
||||
if (user && user.reg_type === 'system') {
|
||||
justLinkedGoogle = true;
|
||||
await user.update({ reg_type: 'google' });
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
user = await mdl_Users.create({
|
||||
email: payload.email,
|
||||
reg_type: 'google',
|
||||
acc_type: 'user',
|
||||
is_active: true,
|
||||
is_verified: false,
|
||||
needs_intro: true,
|
||||
personal_info: {
|
||||
name: {
|
||||
given_name: payload.given_name ?? '',
|
||||
last_name: payload.family_name ?? '',
|
||||
full_name: payload.name ?? '',
|
||||
},
|
||||
avatar: { url: payload.picture ?? null },
|
||||
},
|
||||
}, { transaction: t });
|
||||
|
||||
await enrollDefaultGroup(user.user_id, { transaction: t });
|
||||
|
||||
await t.commit();
|
||||
|
||||
// Welcome email/achievements/welcome-notification are deferred to
|
||||
// verifyOTP's first-time branch now (this account isn't verified yet —
|
||||
// it still has to complete the same OTP gate as a system registration).
|
||||
AdminNotification.create(NOTIFICATION_REGISTRY.nogrp_user_registered.build({
|
||||
userEmail: payload.email,
|
||||
regType: 'google',
|
||||
}))
|
||||
.catch(err => console.error('[AUTH] googleCallback: Failed to emit nogrp_user_registered notification:', err));
|
||||
} catch (err) {
|
||||
await t.rollback();
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
const status = await checkAccountStatus(user);
|
||||
if (!status.ok) {
|
||||
if (status.code === 'deactivated') {
|
||||
setGoogleResultCookie(res, { error: 'account_deactivated' });
|
||||
return res.redirect(CALLBACK_PAGE);
|
||||
}
|
||||
setGoogleResultCookie(res, {
|
||||
error: 'account_banned',
|
||||
reason: status.reason ?? null,
|
||||
ban_type: status.ban_type ?? null,
|
||||
expires_at: status.ban_expires_at ? new Date(status.ban_expires_at).toISOString() : null,
|
||||
});
|
||||
return res.redirect(CALLBACK_PAGE);
|
||||
}
|
||||
|
||||
// Identity is confirmed by Google itself — same bar as a password match on
|
||||
// the system login path — so a NOGRP user (brand-new or returning) riding
|
||||
// in on an invite link gets moved into that group right here.
|
||||
if (group_code) {
|
||||
await switchFromNogrpByCode(user.user_id, group_code)
|
||||
.catch(err => console.error('[AUTH] googleCallback: Failed to switch NOGRP membership:', err));
|
||||
}
|
||||
|
||||
// Every Google sign-in (new or returning account) still has to clear the
|
||||
// same OTP gate as a manual login, unless this device already cleared one
|
||||
// recently and its trust window hasn't lapsed or been revoked — same
|
||||
// fast path as the manual login controller. A brand-new Google account
|
||||
// can never have a trusted device yet, so this naturally falls through
|
||||
// to the OTP branch below for first-time sign-ins.
|
||||
const fingerprintHash = trustedDevice.getFingerprintHash(req);
|
||||
const trusted = await trustedDevice.findValid(user.user_id, req.cookies[trustedDevice.COOKIE_NAME], fingerprintHash);
|
||||
|
||||
if (trusted) {
|
||||
const { refreshToken, session } = await mintSession(req, user, {});
|
||||
await trustedDevice.issueOrRefresh(res, user.user_id, fingerprintHash, session.session_id);
|
||||
setRefreshCookie(res, refreshToken);
|
||||
|
||||
logActivity(user.user_id, 'login', { entityType: 'session', entityId: Number(session.session_id) });
|
||||
|
||||
// Normally no cookie is needed here — the refresh cookie set above is
|
||||
// itself the signal, and the frontend just calls restoreSession(). The
|
||||
// one exception is the just-linked flag, which restoreSession() has no
|
||||
// way to surface on its own.
|
||||
if (justLinkedGoogle) {
|
||||
setGoogleResultCookie(res, { justLinkedGoogle: true });
|
||||
}
|
||||
return res.redirect(CALLBACK_PAGE);
|
||||
}
|
||||
|
||||
const otp = generateOTP();
|
||||
await user.update({ otp_code: otp, otp_expires_at: getOTPExpiry() });
|
||||
|
||||
sendEmail({ to: user.email, type: 'LOGIN_OTP', data: { otp } })
|
||||
.catch(err => console.error('[AUTH] googleCallback: Failed to send login OTP email:', err));
|
||||
|
||||
setGoogleResultCookie(res, { otpRequired: true, email: user.email, justLinkedGoogle });
|
||||
return res.redirect(CALLBACK_PAGE);
|
||||
} catch (err) {
|
||||
console.error('[AUTH] googleCallback OIDC error:', err);
|
||||
setGoogleResultCookie(res, { error: 'auth_failed' });
|
||||
return res.redirect(`${process.env.FRONTEND_URL}/auth/callback/google`);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Google OIDC — Result handoff ─────────────────────────────────────────────
|
||||
// Single-use: reads and immediately clears the cookie stashed by googleCallback.
|
||||
// Returns {} when nothing is pending (trusted-device path — frontend should
|
||||
// just call restoreSession(), since the real refresh cookie was already set).
|
||||
exports.googleResult = (req, res) => {
|
||||
const raw = req.signedCookies[GOOGLE_RESULT_COOKIE];
|
||||
res.clearCookie(GOOGLE_RESULT_COOKIE);
|
||||
|
||||
if (!raw) return R.success(res, 'No pending Google auth result.', {});
|
||||
|
||||
try {
|
||||
return R.success(res, 'Pending Google auth result.', JSON.parse(raw));
|
||||
} catch (_) {
|
||||
return R.success(res, 'No pending Google auth result.', {});
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Refresh Token ─────────────────────────────────────────────────────────────
|
||||
exports.refreshToken = async (req, res) => {
|
||||
try {
|
||||
const refreshToken = req.cookies.refreshToken;
|
||||
if (!refreshToken) return R.error(res, 'Refresh token is required.', 400);
|
||||
|
||||
const decoded = verifyRefreshToken(refreshToken);
|
||||
const tokenHash = hashToken(refreshToken);
|
||||
|
||||
const session = await mdl_UserSessions.findOne({
|
||||
where: { user_id: decoded.user_id, refresh_token_hash: tokenHash, is_active: true },
|
||||
});
|
||||
if (!session) return R.error(res, 'Session is invalid or expired. Please log in again.', 401);
|
||||
|
||||
const user = await mdl_Users.findByPk(decoded.user_id);
|
||||
if (!user) return R.error(res, 'User not found or deactivated.', 401);
|
||||
|
||||
const status = await checkAccountStatus(user);
|
||||
if (!status.ok) {
|
||||
if (status.code === 'deactivated') return R.error(res, 'User not found or deactivated.', 401);
|
||||
return R.error(res, 'Your account has been suspended.', 403, { banned: true });
|
||||
}
|
||||
|
||||
// Check if refresh token is expired
|
||||
if (!shouldRotateRefreshToken(decoded)) {
|
||||
const { accessToken } = generateTokens(user);
|
||||
return R.success(res, 'Token refreshed.', { accessToken, session_id: session.session_id, user: await safeUser(user) });
|
||||
}
|
||||
|
||||
// ─── Rotate refresh token ───────────────────────────────────────────────────
|
||||
const tokens = generateTokens(user);
|
||||
await session.update({ refresh_token_hash: hashToken(tokens.refreshToken) });
|
||||
|
||||
setRefreshCookie(res, tokens.refreshToken);
|
||||
|
||||
return R.success(res, 'Token refreshed.', { accessToken: tokens.accessToken, session_id: session.session_id, user: await safeUser(user) });
|
||||
} catch (err) {
|
||||
console.error('[AUTH] refresh token error:', err);
|
||||
return R.error(res, 'Invalid or expired refresh token.', 401);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Logout ────────────────────────────────────────────────────────────────────
|
||||
exports.logout = async (req, res) => {
|
||||
try {
|
||||
const { session_id } = req.body;
|
||||
if (session_id) {
|
||||
await mdl_UserSessions.update(
|
||||
{ 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 });
|
||||
|
||||
// Ordinary logout intentionally does NOT touch trusted_devices or clear
|
||||
// device_trust: expires_at (rolling 30-day window) is what ends the
|
||||
// OTP-skip, not the act of logging out. Clearing/revoking here would
|
||||
// force OTP on the very next login on the same device, which defeats
|
||||
// the point of trusted_devices. Trust is only force-revoked elsewhere
|
||||
// for actual security events — password change/reset, admin ban/
|
||||
// deactivate/force-logout, or a specific session being terminated
|
||||
// (see trustedDevice.service.js: revokeAllForUser / revokeBySessionId).
|
||||
|
||||
res.clearCookie('refreshToken')
|
||||
res.clearCookie('_csrf')
|
||||
|
||||
return R.success(res, 'Logged out successfully.');
|
||||
} catch (err) {
|
||||
console.error('[AUTH] logout error:', err);
|
||||
return R.error(res, 'Logout failed.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Change Password ───────────────────────────────────────────────────────────
|
||||
exports.changePassword = async (req, res) => {
|
||||
try {
|
||||
const { current_password, new_password } = req.body;
|
||||
const user = await mdl_Users.findByPk(req.user.user_id);
|
||||
|
||||
if (user.reg_type === 'google')
|
||||
return R.error(res, 'Google accounts cannot change passwords here.', 400);
|
||||
|
||||
const match = await bcrypt.compare(current_password, user.password);
|
||||
if (!match) return R.error(res, 'Current password is incorrect.', 400);
|
||||
|
||||
const hashed = await bcrypt.hash(new_password, 12);
|
||||
await user.update({ password: hashed });
|
||||
|
||||
// Invalidate all sessions to force re-login
|
||||
await mdl_UserSessions.update({ is_active: false }, { where: { user_id: user.user_id } });
|
||||
await trustedDevice.revokeAllForUser(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);
|
||||
return R.error(res, 'Password change failed.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Forgot Password — Request OTP ─────────────────────────────────────────────
|
||||
// Same procedure for every acc_type (admin/staff/user) — only reg_type matters.
|
||||
// Response is identical whether the email is unknown or deactivated/suspended —
|
||||
// only a real, eligible account actually gets an OTP — EXCEPT for Google-linked
|
||||
// accounts, which get an explicit "use Google sign-in" dialog by design (accepted
|
||||
// tradeoff: this does reveal that a given email is a Google-linked account).
|
||||
exports.forgotPassword = async (req, res) => {
|
||||
try {
|
||||
const { email } = req.body;
|
||||
const genericMessage = 'If an account exists for this email, a reset code has been sent.';
|
||||
|
||||
const user = await mdl_Users.findOne({ where: { email } });
|
||||
|
||||
if (user && user.reg_type === 'google') {
|
||||
return R.error(
|
||||
res,
|
||||
'This account was created using Google. Sign in with Google instead — there’s no password to reset for accounts created this way.',
|
||||
400,
|
||||
{ google: true },
|
||||
);
|
||||
}
|
||||
|
||||
if (user) {
|
||||
const status = await checkAccountStatus(user);
|
||||
if (status.ok) {
|
||||
const otp = generateOTP();
|
||||
await user.update({ otp_code: otp, otp_expires_at: getOTPExpiry() });
|
||||
|
||||
sendEmail({ to: email, type: 'RESET_PASSWORD_OTP', data: { otp } })
|
||||
.catch(err => console.error('[AUTH] Failed to send reset-password OTP email:', err));
|
||||
}
|
||||
}
|
||||
|
||||
return R.success(res, genericMessage, { email });
|
||||
} catch (err) {
|
||||
console.error('[AUTH] forgotPassword error:', err);
|
||||
return R.error(res, 'Could not process request.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Forgot Password — Verify OTP only (step 2 of 3) ───────────────────────────
|
||||
// Checks the code without consuming it or touching the password, so the reset
|
||||
// flow can gate the "new password" step behind a verified code. resetPassword
|
||||
// re-checks the same OTP when the password is actually submitted.
|
||||
exports.verifyResetOTP = async (req, res) => {
|
||||
try {
|
||||
const { email, otp } = req.body;
|
||||
const genericError = 'Invalid or expired code.';
|
||||
|
||||
const user = await mdl_Users.findOne({ where: { email } });
|
||||
if (!user || user.reg_type === 'google') return R.error(res, genericError, 400);
|
||||
|
||||
const storedOTP = Buffer.from(user.otp_code ?? '');
|
||||
const givenOTP = Buffer.from(otp ?? '');
|
||||
if (storedOTP.length !== givenOTP.length || !crypto.timingSafeEqual(storedOTP, givenOTP))
|
||||
return R.error(res, genericError, 400);
|
||||
if (isOTPExpired(user.otp_expires_at)) return R.error(res, genericError, 400);
|
||||
|
||||
return R.success(res, 'Code verified.');
|
||||
} catch (err) {
|
||||
console.error('[AUTH] verifyResetOTP error:', err);
|
||||
return R.error(res, 'Could not process request.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Forgot Password — Reset with OTP ──────────────────────────────────────────
|
||||
// Same generic error for unknown email / Google-linked / wrong OTP / expired OTP
|
||||
// so this endpoint can't be used to enumerate accounts either.
|
||||
exports.resetPassword = async (req, res) => {
|
||||
try {
|
||||
const { email, otp, new_password } = req.body;
|
||||
const genericError = 'Invalid or expired code.';
|
||||
|
||||
const user = await mdl_Users.findOne({ where: { email } });
|
||||
if (!user || user.reg_type === 'google') return R.error(res, genericError, 400);
|
||||
|
||||
const storedOTP = Buffer.from(user.otp_code ?? '');
|
||||
const givenOTP = Buffer.from(otp ?? '');
|
||||
if (storedOTP.length !== givenOTP.length || !crypto.timingSafeEqual(storedOTP, givenOTP))
|
||||
return R.error(res, genericError, 400);
|
||||
if (isOTPExpired(user.otp_expires_at)) return R.error(res, genericError, 400);
|
||||
|
||||
const hashed = await bcrypt.hash(new_password, 12);
|
||||
await user.update({ password: hashed, otp_code: null, otp_expires_at: null });
|
||||
|
||||
// Invalidate all sessions — same login procedure (credentials → OTP) applies next time
|
||||
await mdl_UserSessions.update({ is_active: false }, { where: { user_id: user.user_id } });
|
||||
await trustedDevice.revokeAllForUser(user.user_id);
|
||||
|
||||
logActivity(user.user_id, 'password_reset');
|
||||
|
||||
sendEmail({ to: email, type: 'PASSWORD_CHANGED', data: {} })
|
||||
.catch(err => console.error('[AUTH] Failed to send password-changed email:', err));
|
||||
|
||||
return R.success(res, 'Password reset successful. Please log in with your new password.');
|
||||
} catch (err) {
|
||||
console.error('[AUTH] resetPassword error:', err);
|
||||
return R.error(res, 'Password reset failed.', 500);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,246 @@
|
||||
// controllers/client/advertisements.controller.js
|
||||
|
||||
const Advertisement = require("../../models/advertisements/advertisements.mdl");
|
||||
const mdl_Assets = require("../../models/assets/assets.mdl");
|
||||
const mediaToken = require("../../services/mediaToken.service");
|
||||
const R = require('../../utils/response.util');
|
||||
const { PLACEMENT_MAP } = require("../../models/advertisements/advertisements.placements");
|
||||
|
||||
const { Op } = require('sequelize');
|
||||
|
||||
const AD_IMAGE_INCLUDE = {
|
||||
model: mdl_Assets,
|
||||
as: "image",
|
||||
attributes: ["asset_id", "display_name", "file_url", "thumbnail_url", "storage_provider", "storage_key", "file_type", "mime_type"],
|
||||
required: false,
|
||||
};
|
||||
|
||||
const AD_CLIENT_EXCLUDE = ["createdBy", "updatedBy", "deletedBy", "deletedAt", "image_asset_id"];
|
||||
|
||||
// ─── Media proxying ─────────────────────────────────────────────────────────
|
||||
// Mirrors controllers/admin/advertisements.controller.js's attachImageStreamToken.
|
||||
// Kept duplicated rather than shared to avoid a cross-boundary import between
|
||||
// admin and client controllers (same rationale as deriveStatus above). Private
|
||||
// (S3-backed) images never expose a raw file_url — the frontend resolves the
|
||||
// stream_token through GET /api/client/media/stream/:token instead.
|
||||
async function attachImageStreamToken(image, req) {
|
||||
if (!image || image.storage_provider !== "s3" || !mediaToken.SUPPORTED_TYPES.includes(image.file_type)) {
|
||||
return image;
|
||||
}
|
||||
const ip = mediaToken.resolveIp(req);
|
||||
const { token } = await mediaToken.issueForAsset(image, req.user?.user_id, ip);
|
||||
image.stream_token = token;
|
||||
image.file_url = null;
|
||||
image.thumbnail_url = null;
|
||||
delete image.storage_key;
|
||||
return image;
|
||||
}
|
||||
|
||||
// ─── 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";
|
||||
}
|
||||
|
||||
// ─── Live window helper ─────────────────────────────────────────────────────
|
||||
// "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.
|
||||
function liveWhere(extra) {
|
||||
const now = new Date();
|
||||
return {
|
||||
...extra,
|
||||
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 } }] },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// ─── GET ACTIVE ───────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Resolves the single highest-priority live advertisement for a given placement.
|
||||
//
|
||||
// GET /api/client/advertisements/active?placement=dashboard.hero
|
||||
//
|
||||
exports.getActiveAdvertisement = async (req, res) => {
|
||||
try {
|
||||
const { placement } = req.query;
|
||||
|
||||
if (!placement) return R.error(res, "placement is required.", 400);
|
||||
if (!PLACEMENT_MAP[placement]) return R.error(res, "Invalid placement.", 400);
|
||||
|
||||
const advertisement = await Advertisement.findOne({
|
||||
where: liveWhere({ placement }),
|
||||
order: [["order", "ASC"], ["createdAt", "DESC"]],
|
||||
include: [AD_IMAGE_INCLUDE],
|
||||
attributes: { exclude: AD_CLIENT_EXCLUDE },
|
||||
});
|
||||
|
||||
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
|
||||
|
||||
if (json.image) await attachImageStreamToken(json.image, req);
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── GET ACTIVE (list) ──────────────────────────────────────────────────────
|
||||
//
|
||||
// Resolves every live advertisement for a single placement, ordered by
|
||||
// priority — used by carousel-style slots (e.g. dashboard.hero) that rotate
|
||||
// through several ads instead of showing only the single highest-priority one.
|
||||
//
|
||||
// GET /api/client/advertisements/active-list?placement=dashboard.hero&limit=8
|
||||
//
|
||||
exports.getActiveAdvertisementList = async (req, res) => {
|
||||
try {
|
||||
const { placement } = req.query;
|
||||
|
||||
if (!placement) return R.error(res, "placement is required.", 400);
|
||||
if (!PLACEMENT_MAP[placement]) return R.error(res, "Invalid placement.", 400);
|
||||
|
||||
const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 8, 1), 20);
|
||||
|
||||
const advertisements = await Advertisement.findAll({
|
||||
where: liveWhere({ placement }),
|
||||
order: [["order", "ASC"], ["createdAt", "DESC"]],
|
||||
include: [AD_IMAGE_INCLUDE],
|
||||
attributes: { exclude: AD_CLIENT_EXCLUDE },
|
||||
limit,
|
||||
});
|
||||
|
||||
const data = [];
|
||||
for (const ad of advertisements) {
|
||||
const json = ad.toJSON();
|
||||
json.status = deriveStatus(json);
|
||||
if (json.image) await attachImageStreamToken(json.image, req);
|
||||
data.push(json);
|
||||
}
|
||||
|
||||
return R.success(res, "Active advertisements retrieved.", { data });
|
||||
} catch (err) {
|
||||
console.error("[CLIENT][ADVERTISEMENT][GET ACTIVE LIST]", err);
|
||||
return R.error(res, "Could not retrieve advertisements.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── GET ACTIVE (batch) ─────────────────────────────────────────────────────
|
||||
//
|
||||
// Resolves the highest-priority live advertisement for each of several
|
||||
// placements in a single round-trip — pages that need multiple simultaneous
|
||||
// slots (e.g. dashboard.hero + dashboard.popup) use this instead of N calls
|
||||
// to /active.
|
||||
//
|
||||
// GET /api/client/advertisements/active-batch?placements=dashboard.hero,dashboard.popup
|
||||
//
|
||||
exports.getActiveAdvertisements = async (req, res) => {
|
||||
try {
|
||||
const raw = req.query.placements;
|
||||
const placements = (Array.isArray(raw) ? raw : String(raw ?? "").split(","))
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (!placements.length) return R.error(res, "placements is required.", 400);
|
||||
|
||||
const invalid = placements.filter((p) => !PLACEMENT_MAP[p]);
|
||||
if (invalid.length) return R.error(res, `Invalid placement(s): ${invalid.join(", ")}`, 400);
|
||||
|
||||
const advertisements = await Advertisement.findAll({
|
||||
where: liveWhere({ placement: { [Op.in]: placements } }),
|
||||
order: [["order", "ASC"], ["createdAt", "DESC"]],
|
||||
include: [AD_IMAGE_INCLUDE],
|
||||
attributes: { exclude: AD_CLIENT_EXCLUDE },
|
||||
});
|
||||
|
||||
// Keep only the highest-priority row per placement (order ASC, createdAt DESC already applied).
|
||||
const data = Object.fromEntries(placements.map((p) => [p, null]));
|
||||
for (const ad of advertisements) {
|
||||
const json = ad.toJSON();
|
||||
if (data[json.placement] !== null) continue; // already have the winner for this placement
|
||||
json.status = deriveStatus(json);
|
||||
if (json.image) await attachImageStreamToken(json.image, req);
|
||||
data[json.placement] = json;
|
||||
}
|
||||
|
||||
return R.success(res, "Active advertisements retrieved.", { data });
|
||||
} catch (err) {
|
||||
console.error("[CLIENT][ADVERTISEMENT][GET ACTIVE BATCH]", err);
|
||||
return R.error(res, "Could not retrieve advertisements.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── GET BY UUID ──────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Resolves a single live advertisement by uuid for its own landing page — the
|
||||
// destination CTA/banner clicks resolve to when the ad has no redirect_link
|
||||
// (see /ads/:uuid on the client).
|
||||
//
|
||||
// GET /api/client/advertisements/uuid/:uuid
|
||||
//
|
||||
exports.getAdvertisementByUuid = async (req, res) => {
|
||||
try {
|
||||
const { uuid } = req.params;
|
||||
if (!uuid) return R.error(res, "uuid is required.", 400);
|
||||
|
||||
const advertisement = await Advertisement.findOne({
|
||||
where: liveWhere({ uuid }),
|
||||
include: [AD_IMAGE_INCLUDE],
|
||||
attributes: { exclude: AD_CLIENT_EXCLUDE },
|
||||
});
|
||||
|
||||
if (!advertisement) return R.error(res, "Advertisement not found.", 404);
|
||||
|
||||
const json = advertisement.toJSON();
|
||||
json.status = deriveStatus(json);
|
||||
if (json.image) await attachImageStreamToken(json.image, req);
|
||||
|
||||
return R.success(res, "Advertisement retrieved.", { data: json });
|
||||
} catch (err) {
|
||||
console.error("[CLIENT][ADVERTISEMENT][GET BY UUID]", 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,113 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* 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 { generateCertificate } = require('../../services/certificate.service');
|
||||
const { ensureCertificateRecord, formatInstructors } = require('../../services/certificate-record.service');
|
||||
const { fmtDate } = require('../../utils/datetime.util');
|
||||
|
||||
const {
|
||||
Course,
|
||||
CourseAssessment,
|
||||
CourseInstructor,
|
||||
} = require('../../models/courses/courses.associations');
|
||||
|
||||
const notDeleted = { deletedAt: null };
|
||||
|
||||
// ─── 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. 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';
|
||||
|
||||
// ── 3. Resolve or create the certificate record ─────────────────────────────
|
||||
// Shared with the hourly issuance cron (cron/jobs/issue_certificates.cron.js)
|
||||
// so both write through the same cert_no/ref_no sequence.
|
||||
const cert = await ensureCertificateRecord({ userId: user_id, courseId: course.course_id });
|
||||
if (!cert) {
|
||||
return R.error(res, 'Certificate not available — course assessment not passed yet.', 403);
|
||||
}
|
||||
|
||||
// Always use live instructors from course_instructors table for the PDF,
|
||||
// in case they changed since the certificate row was created.
|
||||
const liveInstructors = formatInstructors(course.instructors ?? []);
|
||||
|
||||
// ── 4. Format issued date as MM/DD/YY HH:MM AM/PM ───────────────────────────
|
||||
const issuedDate = new Date(cert.issued_at);
|
||||
const dateStr = fmtDate(issuedDate);
|
||||
|
||||
// ── 5. 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 ?? '',
|
||||
});
|
||||
|
||||
// ── 6. 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`;
|
||||
const asciiName = filename.replace(/[^\x20-\x7E]/g, '_').replace(/"/g, '_');
|
||||
const encodedName = encodeURIComponent(filename);
|
||||
|
||||
res.set({
|
||||
'Content-Type': 'application/pdf',
|
||||
'Content-Disposition': `attachment; filename="${asciiName}"; filename*=UTF-8''${encodedName}`,
|
||||
'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,182 @@
|
||||
'use strict';
|
||||
const mdl_CoursePurchase = require('../../models/courses/course_purchases.mdl');
|
||||
const mdl_Product = require('../../models/courses/products.mdl');
|
||||
const paymentSvc = require('../../services/payment.service');
|
||||
const R = require('../../utils/response.util');
|
||||
const { resolvePurchasable, checkoutPath } = require('../../utils/purchasable.util');
|
||||
|
||||
// ─── CREATE ORDER ─────────────────────────────────────────────────────────────
|
||||
// Despite the "course" naming (historical — this predates Units/Lessons being
|
||||
// individually purchasable), this endpoint and table are generic: a Product's
|
||||
// purchasable_type/purchasable_id drives everything below.
|
||||
|
||||
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);
|
||||
|
||||
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 ${product.purchasable_type}.`, 409);
|
||||
}
|
||||
|
||||
const target = await resolvePurchasable(product.purchasable_type, product.purchasable_id);
|
||||
if (!target) return R.error(res, 'Purchasable content not found.', 404);
|
||||
|
||||
const path = checkoutPath(product.purchasable_type, target);
|
||||
|
||||
const ppOrder = await paymentSvc.createOrder('paypal', {
|
||||
amount: Number(product.price).toFixed(2),
|
||||
currency: product.currency,
|
||||
referenceId: `user_${req.user.user_id}_product_${product_id}`,
|
||||
returnUrl: `${process.env.FRONTEND_URL}${path}`,
|
||||
cancelUrl: `${process.env.FRONTEND_URL}${path}?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' },
|
||||
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 paymentSvc.captureOrder(purchase.provider, order_id);
|
||||
} catch (ppErr) {
|
||||
await purchase.update({
|
||||
status: 'failed',
|
||||
provider_payload: { ...purchase.provider_payload, error: ppErr?.response?.data ?? {} },
|
||||
});
|
||||
return R.error(res, 'Payment capture failed.', 402);
|
||||
}
|
||||
|
||||
const capture = captureData.purchase_units?.[0]?.payments?.captures?.[0];
|
||||
|
||||
// PayPal can return an HTTP 2xx from the capture endpoint even when the
|
||||
// charge itself was declined or held for review (e.g. capture.status
|
||||
// "DECLINED"/"PENDING") — axios only throws on non-2xx, so the actual
|
||||
// status field must be checked explicitly before granting any access.
|
||||
const captureStatus = capture?.status ?? captureData.status;
|
||||
if (captureStatus !== 'COMPLETED') {
|
||||
await purchase.update({
|
||||
status: 'failed',
|
||||
provider_payload: { ...purchase.provider_payload, capture: captureData, failed_reason: captureStatus ?? 'unknown' },
|
||||
});
|
||||
return R.error(res, `Payment was not completed by PayPal (status: ${captureStatus ?? 'unknown'}).`, 402);
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
||||
const target = await resolvePurchasable(purchase.product.purchasable_type, purchase.product.purchasable_id);
|
||||
|
||||
return R.success(res, 'Payment successful. Access granted.', {
|
||||
purchase_id: purchase.id,
|
||||
expires_at: purchase.expires_at,
|
||||
purchasable_type: purchase.product.purchasable_type,
|
||||
purchasable_id: purchase.product.purchasable_id,
|
||||
target_uuid: target?.uuid ?? null,
|
||||
});
|
||||
} 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' },
|
||||
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', 'purchasable_type', 'purchasable_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,755 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* 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/completed
|
||||
* → every completed lesson/unit/course for the current user, course-scoped and
|
||||
* standalone reads unioned together, most-recently-completed first
|
||||
*
|
||||
* GET /client/courses/:courseId/progress/summary
|
||||
* → compact snapshot: lesson counts + percentage + course status
|
||||
*
|
||||
* GET /client/courses/:courseId/progress
|
||||
* → all progress rows for this user + course (flat, frontend builds the map)
|
||||
*
|
||||
* GET /client/courses/:courseId/task-context
|
||||
* → all pending task requirements (read_*) for this course's UUIDs that the user is assigned to
|
||||
*
|
||||
* POST /client/courses/:courseId/units/:unitId/lessons/:lessonId/progress
|
||||
* → UPSERT lesson + derives + UPSERTs parent unit + course in one transaction
|
||||
* → side-effects: writes to lesson_reading_progress / unit_reading_progress,
|
||||
* syncs task_progress for matching task requirements,
|
||||
* returns completed_tasks for any task whose read requirements are now all done
|
||||
*
|
||||
* Author: Kenneth Obsequio (@lash0000)
|
||||
* Date Created: Jun. 21, 2026
|
||||
***********************************************************************************************************************************************************************/
|
||||
'use strict';
|
||||
|
||||
const { Op } = require('sequelize');
|
||||
const R = require('../../utils/response.util');
|
||||
const logActivity = require('../../utils/logActivity.util');
|
||||
const { recomputeCascade, recordWatchProgress, recordManualComplete } = require('../../services/completion_requirements.service');
|
||||
const { recordPlaybackPosition } = require('../../services/playback_position.service');
|
||||
const CourseReadingProgress = require('../../models/courses/course_reading_progress.mdl');
|
||||
const UnitReadingProgress = require('../../models/courses/unit_reading_progress.mdl');
|
||||
const LessonReadingProgress = require('../../models/courses/lesson_reading_progress.mdl');
|
||||
const Certificate = require('../../models/courses/certificate.mdl');
|
||||
const {
|
||||
Course, Unit, Lesson,
|
||||
CourseUnit, UnitLesson,
|
||||
UnitQuiz, CourseAssessment, QuizAttempt,
|
||||
} = require('../../models/courses/courses.associations');
|
||||
const { countCourseLessons, getCourseUnitIds, flattenUnits } = require('../../utils/courses/hierarchy.util');
|
||||
|
||||
const { Task, TaskRequirement, TaskListGroup } = require('../../models/task/task.mdl');
|
||||
const { TaskProgress } = require('../../models/task/task_progress.mdl');
|
||||
const { mdl_UserGroupMembers } = require('../../models/users/user_groups.mdl');
|
||||
|
||||
const notDeleted = { deletedAt: null };
|
||||
|
||||
// ─── Internal helper: get task list IDs accessible to a user ─────────────────
|
||||
async function getAccessibleTaskListIds(userId) {
|
||||
const memberships = await mdl_UserGroupMembers.findAll({
|
||||
where: { user_id: userId, deletedAt: null },
|
||||
attributes: ['group_id'],
|
||||
});
|
||||
const groupIds = memberships.map((m) => m.group_id);
|
||||
if (!groupIds.length) return { taskListIds: [], taskListToGroup: {} };
|
||||
|
||||
const taskListGroups = await TaskListGroup.findAll({
|
||||
where: { group_id: groupIds },
|
||||
attributes: ['task_list_id', 'group_id'],
|
||||
});
|
||||
const taskListToGroup = Object.fromEntries(taskListGroups.map((tlg) => [tlg.task_list_id, tlg.group_id]));
|
||||
return { taskListIds: Object.keys(taskListToGroup), taskListToGroup };
|
||||
}
|
||||
|
||||
// Task-progress auto-sync (read_lesson/read_unit/read_course requirements) now lives in
|
||||
// services/task_reading_progress_sync.service.js#syncCompletedEntitiesToTaskProgress, called
|
||||
// directly from completion_requirements.service.js's cascade/recompute functions — covers every
|
||||
// completion trigger (scroll, watch_percent, manual_complete, pass_quiz, assessment), not just
|
||||
// this endpoint. getAccessibleTaskListIds stays here (below) since getCourseTaskContext still
|
||||
// needs its richer { taskListIds, taskListToGroup } shape.
|
||||
|
||||
// =============================================================================
|
||||
// ── IN-PROGRESS COURSES — profile learning progress card ──────────────────────
|
||||
// =============================================================================
|
||||
|
||||
exports.getMyInProgressCourses = async (req, res) => {
|
||||
try {
|
||||
const userId = req.user.user_id;
|
||||
|
||||
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.', []);
|
||||
|
||||
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 [lessons_total, lessons_completed] = await Promise.all([
|
||||
countCourseLessons(courseId),
|
||||
CourseReadingProgress.count({
|
||||
where: { user_id: userId, course_id: courseId, type: 'lesson', status: 'completed' },
|
||||
}),
|
||||
]);
|
||||
|
||||
// "Reading done" is derived independently from lesson counts — row.status now also
|
||||
// requires the course assessment to be passed, so it can't be used as the reading gate.
|
||||
const readingDone = lessons_total > 0 && lessons_completed === lessons_total;
|
||||
|
||||
let pending_quizzes = [];
|
||||
let pending_assessment = null;
|
||||
let assessment_configured = true;
|
||||
|
||||
if (readingDone) {
|
||||
const courseUnitIds = await getCourseUnitIds(courseId);
|
||||
const unitQuizzes = courseUnitIds.length ? await UnitQuiz.findAll({
|
||||
attributes: ['quiz_id', 'title', 'is_required', 'passing_score'],
|
||||
include: [{
|
||||
model: Unit,
|
||||
as: 'unit',
|
||||
attributes: ['unit_id', 'title'],
|
||||
where: notDeleted,
|
||||
required: true,
|
||||
}],
|
||||
where: { unit_id: courseUnitIds, ...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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const assessment = await CourseAssessment.findOne({
|
||||
attributes: ['assessment_id', 'title', 'is_required', 'passing_score'],
|
||||
where: { course_id: courseId },
|
||||
});
|
||||
assessment_configured = !!assessment;
|
||||
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: readingDone ? 'completed' : 'in_progress',
|
||||
assessment_configured,
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// ── COMPLETED CONTENT — "live view" of every finished lesson/unit/course ──────
|
||||
// =============================================================================
|
||||
|
||||
// GET /client/courses/completed
|
||||
// Unions the two completion systems (see completion_requirements.service.js header):
|
||||
// - CourseReadingProgress — course-scoped lessons/units/courses (course_id NOT NULL)
|
||||
// - Unit/LessonReadingProgress, filtered to course_id IS NULL — genuinely standalone
|
||||
// reads. Course-scoped reads also get a best-effort mirror written into these same
|
||||
// tables (see recomputeCascade's mirrorLessonRead call) but that mirror always
|
||||
// carries a course_id, so the IS NULL filter here excludes it and avoids double-
|
||||
// counting the same completion from both systems.
|
||||
exports.getMyCompletedContent = async (req, res) => {
|
||||
try {
|
||||
const userId = req.user.user_id;
|
||||
|
||||
const courseScoped = await CourseReadingProgress.findAll({
|
||||
where: { user_id: userId, status: 'completed' },
|
||||
attributes: ['reference_id', 'type', 'completed_at'],
|
||||
include: [{
|
||||
model: Course,
|
||||
as: 'course',
|
||||
attributes: ['course_id', 'uuid', 'title'],
|
||||
where: notDeleted,
|
||||
required: true,
|
||||
}],
|
||||
});
|
||||
|
||||
const completedCourseRows = courseScoped.filter((r) => r.type === 'course');
|
||||
const completedUnitRows = courseScoped.filter((r) => r.type === 'unit');
|
||||
const completedLessonRows = courseScoped.filter((r) => r.type === 'lesson');
|
||||
|
||||
const unitUuids = completedUnitRows.map((r) => r.reference_id);
|
||||
const lessonUuids = completedLessonRows.map((r) => r.reference_id);
|
||||
|
||||
const [unitRows, lessonRows, standaloneUnits, standaloneLessons] = await Promise.all([
|
||||
unitUuids.length
|
||||
? Unit.findAll({ where: { uuid: unitUuids, ...notDeleted }, attributes: ['unit_id', 'uuid', 'title'] })
|
||||
: [],
|
||||
lessonUuids.length
|
||||
? Lesson.findAll({ where: { uuid: lessonUuids, ...notDeleted }, attributes: ['lesson_id', 'uuid', 'title'] })
|
||||
: [],
|
||||
UnitReadingProgress.findAll({
|
||||
where: { user_id: userId, status: 'completed', course_id: null },
|
||||
attributes: ['completed_at'],
|
||||
include: [{
|
||||
model: Unit, as: 'unit', attributes: ['unit_id', 'uuid', 'title'], where: notDeleted, required: true,
|
||||
}],
|
||||
}),
|
||||
LessonReadingProgress.findAll({
|
||||
where: { user_id: userId, status: 'completed', course_id: null },
|
||||
attributes: ['completed_at'],
|
||||
include: [{
|
||||
model: Lesson, as: 'lesson', attributes: ['lesson_id', 'uuid', 'title'], where: notDeleted, required: true,
|
||||
}],
|
||||
}),
|
||||
]);
|
||||
|
||||
const unitByUuid = Object.fromEntries(unitRows.map((u) => [u.uuid, u]));
|
||||
const lessonByUuid = Object.fromEntries(lessonRows.map((l) => [l.uuid, l]));
|
||||
|
||||
const courseIds = completedCourseRows.map((r) => r.course.course_id);
|
||||
const certificates = courseIds.length
|
||||
? await Certificate.findAll({
|
||||
where: { user_id: userId, course_id: courseIds },
|
||||
attributes: ['uuid', 'cert_no', 'issued_at', 'score', 'course_id'],
|
||||
})
|
||||
: [];
|
||||
const certByCourseId = Object.fromEntries(certificates.map((c) => [String(c.course_id), c]));
|
||||
|
||||
const courses = completedCourseRows.map((r) => {
|
||||
const cert = certByCourseId[String(r.course.course_id)] ?? null;
|
||||
return {
|
||||
course_id: r.course.course_id,
|
||||
uuid: r.course.uuid,
|
||||
title: r.course.title,
|
||||
completed_at: r.completed_at,
|
||||
certificate: cert ? { uuid: cert.uuid, cert_no: cert.cert_no, issued_at: cert.issued_at, score: cert.score } : null,
|
||||
};
|
||||
}).sort((a, b) => new Date(b.completed_at) - new Date(a.completed_at));
|
||||
|
||||
const units = [
|
||||
...completedUnitRows
|
||||
.filter((r) => unitByUuid[r.reference_id])
|
||||
.map((r) => ({
|
||||
unit_id: unitByUuid[r.reference_id].unit_id,
|
||||
uuid: r.reference_id,
|
||||
title: unitByUuid[r.reference_id].title,
|
||||
completed_at: r.completed_at,
|
||||
course: { course_id: r.course.course_id, title: r.course.title },
|
||||
})),
|
||||
...standaloneUnits.map((r) => ({
|
||||
unit_id: r.unit.unit_id,
|
||||
uuid: r.unit.uuid,
|
||||
title: r.unit.title,
|
||||
completed_at: r.completed_at,
|
||||
course: null,
|
||||
})),
|
||||
].sort((a, b) => new Date(b.completed_at) - new Date(a.completed_at));
|
||||
|
||||
const lessons = [
|
||||
...completedLessonRows
|
||||
.filter((r) => lessonByUuid[r.reference_id])
|
||||
.map((r) => ({
|
||||
lesson_id: lessonByUuid[r.reference_id].lesson_id,
|
||||
uuid: r.reference_id,
|
||||
title: lessonByUuid[r.reference_id].title,
|
||||
completed_at: r.completed_at,
|
||||
course: { course_id: r.course.course_id, title: r.course.title },
|
||||
})),
|
||||
...standaloneLessons.map((r) => ({
|
||||
lesson_id: r.lesson.lesson_id,
|
||||
uuid: r.lesson.uuid,
|
||||
title: r.lesson.title,
|
||||
completed_at: r.completed_at,
|
||||
course: null,
|
||||
})),
|
||||
].sort((a, b) => new Date(b.completed_at) - new Date(a.completed_at));
|
||||
|
||||
return R.success(res, 'Completed content retrieved.', {
|
||||
courses, units, lessons,
|
||||
counts: { courses: courses.length, units: units.length, lessons: lessons.length },
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[CLIENT][COURSE READING PROGRESS][COMPLETED CONTENT]', err);
|
||||
return R.error(res, 'Could not retrieve completed content.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// ── GET PROGRESS SUMMARY ──────────────────────────────────────────────────────
|
||||
// =============================================================================
|
||||
|
||||
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([
|
||||
countCourseLessons(courseId),
|
||||
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 ─────────────────────────────────────────────────────
|
||||
// =============================================================================
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// ── TASK CONTEXT FOR A COURSE ─────────────────────────────────────────────────
|
||||
// =============================================================================
|
||||
|
||||
// GET /client/courses/:courseId/task-context
|
||||
// Returns all pending task requirements (read_course / read_unit / read_lesson)
|
||||
// whose reference_id matches this course, any of its units, or any of its lessons,
|
||||
// filtered to tasks the current user is actually assigned to (via group membership).
|
||||
// UnitList calls this on mount when no task context is passed via navigation state.
|
||||
|
||||
exports.getCourseTaskContext = 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', 'uuid'],
|
||||
include: [{
|
||||
model: Unit,
|
||||
as: 'units',
|
||||
where: notDeleted,
|
||||
required: false,
|
||||
attributes: ['unit_id', 'uuid'],
|
||||
through: { attributes: [] },
|
||||
include: [{
|
||||
model: Lesson,
|
||||
as: 'lessons',
|
||||
where: notDeleted,
|
||||
required: false,
|
||||
attributes: ['lesson_id', 'uuid'],
|
||||
through: { attributes: [] },
|
||||
}],
|
||||
}],
|
||||
});
|
||||
if (!course) return R.error(res, 'Course not found.', 404);
|
||||
|
||||
const units = course.units ?? [];
|
||||
const allUuids = [
|
||||
course.uuid,
|
||||
...units.map((u) => u.uuid),
|
||||
...units.flatMap((u) => (u.lessons ?? []).map((l) => l.uuid)),
|
||||
];
|
||||
|
||||
const { taskListIds, taskListToGroup } = await getAccessibleTaskListIds(userId);
|
||||
if (!taskListIds.length) {
|
||||
return R.success(res, 'Task context retrieved.', { has_task: false, contexts: [] });
|
||||
}
|
||||
|
||||
const requirements = await TaskRequirement.findAll({
|
||||
where: {
|
||||
type: { [Op.in]: ['read_course', 'read_unit', 'read_lesson'] },
|
||||
reference_id: { [Op.in]: allUuids },
|
||||
deletedAt: null,
|
||||
},
|
||||
include: [{
|
||||
model: Task,
|
||||
as: 'task',
|
||||
where: { task_list_id: { [Op.in]: taskListIds }, deletedAt: null },
|
||||
required: true,
|
||||
attributes: ['task_id', 'name', 'task_list_id'],
|
||||
}],
|
||||
attributes: ['requirement_id', 'task_id', 'type', 'reference_id', 'reference_label'],
|
||||
});
|
||||
|
||||
if (!requirements.length) {
|
||||
return R.success(res, 'Task context retrieved.', { has_task: false, contexts: [] });
|
||||
}
|
||||
|
||||
// Mark which requirements are already completed
|
||||
const taskIds = [...new Set(requirements.map((r) => r.task_id))];
|
||||
const doneProgress = await TaskProgress.findAll({
|
||||
where: { task_id: { [Op.in]: taskIds }, user_id: userId, completed: true },
|
||||
attributes: ['requirement_id', 'reference_id'],
|
||||
});
|
||||
const doneSet = new Set(doneProgress.map((p) => `${p.requirement_id}:${p.reference_id}`));
|
||||
|
||||
const contexts = requirements.map((req) => ({
|
||||
task_id: req.task_id,
|
||||
task_name: req.task.name,
|
||||
task_list_id: req.task.task_list_id,
|
||||
group_id: taskListToGroup[req.task.task_list_id],
|
||||
requirement_id: req.requirement_id,
|
||||
type: req.type,
|
||||
reference_id: req.reference_id,
|
||||
reference_label: req.reference_label,
|
||||
already_completed: doneSet.has(`${req.requirement_id}:${req.reference_id}`),
|
||||
}));
|
||||
|
||||
return R.success(res, 'Task context retrieved.', { has_task: true, contexts });
|
||||
} catch (err) {
|
||||
console.error('[CLIENT][COURSE TASK CONTEXT]', err);
|
||||
return R.error(res, 'Could not retrieve task context.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// ── TASK CONTEXT FOR A STANDALONE LESSON / UNIT ───────────────────────────────
|
||||
// =============================================================================
|
||||
//
|
||||
// Same idea as getCourseTaskContext, but scoped to a single lesson/unit UUID
|
||||
// rather than a whole course tree — the fallback source for LessonDetails.jsx/
|
||||
// UnitReader.jsx (the standalone/library readers reached via /lessons/:uuid and
|
||||
// /units/:uuid/read) when the page is opened directly rather than navigated to
|
||||
// from a task's requirement card, so the "Task mode" banner still shows up.
|
||||
|
||||
// GET /client/courses/lesson/uuid/:uuid/task-context
|
||||
|
||||
exports.getLessonTaskContext = async (req, res) => {
|
||||
try {
|
||||
const { uuid } = req.params;
|
||||
const userId = req.user.user_id;
|
||||
|
||||
const lesson = await Lesson.findOne({ where: { uuid, ...notDeleted }, attributes: ['lesson_id', 'uuid'] });
|
||||
if (!lesson) return R.error(res, 'Lesson not found.', 404);
|
||||
|
||||
const { taskListIds, taskListToGroup } = await getAccessibleTaskListIds(userId);
|
||||
if (!taskListIds.length) return R.success(res, 'Task context retrieved.', { has_task: false, contexts: [] });
|
||||
|
||||
const requirements = await TaskRequirement.findAll({
|
||||
where: { type: 'read_lesson', reference_id: uuid, deletedAt: null },
|
||||
include: [{
|
||||
model: Task,
|
||||
as: 'task',
|
||||
where: { task_list_id: { [Op.in]: taskListIds }, deletedAt: null },
|
||||
required: true,
|
||||
attributes: ['task_id', 'name', 'task_list_id'],
|
||||
}],
|
||||
attributes: ['requirement_id', 'task_id', 'reference_id'],
|
||||
});
|
||||
|
||||
if (!requirements.length) return R.success(res, 'Task context retrieved.', { has_task: false, contexts: [] });
|
||||
|
||||
const taskIds = [...new Set(requirements.map((r) => r.task_id))];
|
||||
const doneProgress = await TaskProgress.findAll({
|
||||
where: { task_id: { [Op.in]: taskIds }, user_id: userId, completed: true },
|
||||
attributes: ['requirement_id', 'reference_id'],
|
||||
});
|
||||
const doneSet = new Set(doneProgress.map((p) => `${p.requirement_id}:${p.reference_id}`));
|
||||
|
||||
const contexts = requirements.map((req) => ({
|
||||
task_id: req.task_id,
|
||||
task_name: req.task.name,
|
||||
task_list_id: req.task.task_list_id,
|
||||
group_id: taskListToGroup[req.task.task_list_id],
|
||||
requirement_id: req.requirement_id,
|
||||
already_completed: doneSet.has(`${req.requirement_id}:${req.reference_id}`),
|
||||
}));
|
||||
|
||||
return R.success(res, 'Task context retrieved.', { has_task: true, contexts });
|
||||
} catch (err) {
|
||||
console.error('[CLIENT][LESSON TASK CONTEXT]', err);
|
||||
return R.error(res, 'Could not retrieve task context.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// GET /client/courses/unit/uuid/:uuid/task-context
|
||||
|
||||
exports.getUnitTaskContext = async (req, res) => {
|
||||
try {
|
||||
const { uuid } = req.params;
|
||||
const userId = req.user.user_id;
|
||||
|
||||
const unit = await Unit.findOne({ where: { uuid, ...notDeleted }, attributes: ['unit_id', 'uuid'] });
|
||||
if (!unit) return R.error(res, 'Unit not found.', 404);
|
||||
|
||||
const { taskListIds, taskListToGroup } = await getAccessibleTaskListIds(userId);
|
||||
if (!taskListIds.length) return R.success(res, 'Task context retrieved.', { has_task: false, contexts: [] });
|
||||
|
||||
const requirements = await TaskRequirement.findAll({
|
||||
where: { type: 'read_unit', reference_id: uuid, deletedAt: null },
|
||||
include: [{
|
||||
model: Task,
|
||||
as: 'task',
|
||||
where: { task_list_id: { [Op.in]: taskListIds }, deletedAt: null },
|
||||
required: true,
|
||||
attributes: ['task_id', 'name', 'task_list_id'],
|
||||
}],
|
||||
attributes: ['requirement_id', 'task_id', 'reference_id'],
|
||||
});
|
||||
|
||||
if (!requirements.length) return R.success(res, 'Task context retrieved.', { has_task: false, contexts: [] });
|
||||
|
||||
const taskIds = [...new Set(requirements.map((r) => r.task_id))];
|
||||
const doneProgress = await TaskProgress.findAll({
|
||||
where: { task_id: { [Op.in]: taskIds }, user_id: userId, completed: true },
|
||||
attributes: ['requirement_id', 'reference_id'],
|
||||
});
|
||||
const doneSet = new Set(doneProgress.map((p) => `${p.requirement_id}:${p.reference_id}`));
|
||||
|
||||
const contexts = requirements.map((req) => ({
|
||||
task_id: req.task_id,
|
||||
task_name: req.task.name,
|
||||
task_list_id: req.task.task_list_id,
|
||||
group_id: taskListToGroup[req.task.task_list_id],
|
||||
requirement_id: req.requirement_id,
|
||||
already_completed: doneSet.has(`${req.requirement_id}:${req.reference_id}`),
|
||||
}));
|
||||
|
||||
return R.success(res, 'Task context retrieved.', { has_task: true, contexts });
|
||||
} catch (err) {
|
||||
console.error('[CLIENT][UNIT TASK CONTEXT]', err);
|
||||
return R.error(res, 'Could not retrieve task context.', 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 recomputeCascade (completion_requirements service) — lesson + unit + course
|
||||
// evaluated against any configured CompletionRequirement rows (or the default implicit rule),
|
||||
// all in one transaction
|
||||
// 3. Side-effect: sync task_progress for matching task requirements
|
||||
// 4. Return result + completed_tasks (tasks whose all read requirements are now satisfied)
|
||||
|
||||
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, courseLink, lessonLink] = await Promise.all([
|
||||
Course.findOne({
|
||||
where: { course_id: courseId, ...notDeleted },
|
||||
attributes: ['course_id', 'uuid'],
|
||||
}),
|
||||
Unit.findOne({
|
||||
where: { unit_id: unitId, ...notDeleted },
|
||||
attributes: ['unit_id', 'uuid'],
|
||||
}),
|
||||
Lesson.findOne({
|
||||
where: { lesson_id: lessonId, ...notDeleted },
|
||||
attributes: ['lesson_id', 'uuid'],
|
||||
}),
|
||||
CourseUnit.findOne({ where: { course_id: courseId, unit_id: unitId } }),
|
||||
UnitLesson.findOne({ where: { unit_id: unitId, lesson_id: lessonId } }),
|
||||
]);
|
||||
|
||||
if (!course) return R.error(res, 'Course not found.', 404);
|
||||
if (!unit || !courseLink) return R.error(res, 'Unit not found.', 404);
|
||||
if (!lesson || !lessonLink) return R.error(res, 'Lesson not found.', 404);
|
||||
|
||||
// ── 1. Consolidated evaluation + persistence: lesson → unit → course ──
|
||||
const result = await recomputeCascade(userId, {
|
||||
courseId: course.course_id,
|
||||
courseUuid: course.uuid,
|
||||
unitId: unit.unit_id,
|
||||
unitUuid: unit.uuid,
|
||||
lessonId: lesson.lesson_id,
|
||||
lessonUuid: lesson.uuid,
|
||||
lessonStatus: status,
|
||||
});
|
||||
|
||||
// Task-progress sync (read_lesson/read_unit/read_course auto-complete) already ran
|
||||
// inside recomputeCascade — result.completed_tasks reflects it directly.
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// ── WATCH PROGRESS (watch_percent completion requirement) ────────────────────
|
||||
// =============================================================================
|
||||
|
||||
// POST /client/courses/:courseId/units/:unitId/lessons/:lessonId/watch-progress
|
||||
// Body: { percent, block_id?, block_type? } — running max % of video/audio watched, 0-100.
|
||||
// block_id/block_type identify which block on the lesson's page sent this update — needed
|
||||
// to drive watch_video/listen_audio (every block of that type must individually reach 100);
|
||||
// omit them and only the aggregate watch_percent requirement (if configured) is touched.
|
||||
// No-ops (still 200s) if the lesson has neither requirement type configured.
|
||||
exports.upsertWatchProgress = async (req, res) => {
|
||||
try {
|
||||
const { courseId, unitId, lessonId } = req.params;
|
||||
const userId = req.user.user_id;
|
||||
const percent = Number(req.body.percent);
|
||||
if (!Number.isFinite(percent)) return R.error(res, 'percent must be a number.', 400);
|
||||
const blockId = req.body.block_id ?? null;
|
||||
const blockType = req.body.block_type ?? null;
|
||||
|
||||
const [course, unit, lesson, courseLink, lessonLink] = await Promise.all([
|
||||
Course.findOne({ where: { course_id: courseId, ...notDeleted }, attributes: ['course_id', 'uuid'] }),
|
||||
Unit.findOne({ where: { unit_id: unitId, ...notDeleted }, attributes: ['unit_id', 'uuid'] }),
|
||||
Lesson.findOne({ where: { lesson_id: lessonId, ...notDeleted }, attributes: ['lesson_id', 'uuid'] }),
|
||||
CourseUnit.findOne({ where: { course_id: courseId, unit_id: unitId } }),
|
||||
UnitLesson.findOne({ where: { unit_id: unitId, lesson_id: lessonId } }),
|
||||
]);
|
||||
if (!course) return R.error(res, 'Course not found.', 404);
|
||||
if (!unit || !courseLink) return R.error(res, 'Unit not found.', 404);
|
||||
if (!lesson || !lessonLink) return R.error(res, 'Lesson not found.', 404);
|
||||
|
||||
// Resume-position tracking is unconditional — every block gets it regardless of
|
||||
// whether a completion requirement is configured. recordWatchProgress, below, is
|
||||
// the anti-cheat-validated path and stays a no-op when nothing's configured.
|
||||
await recordPlaybackPosition(userId, { lessonId: lesson.lesson_id, blockId, percent });
|
||||
|
||||
const result = await recordWatchProgress(userId, {
|
||||
lessonId: lesson.lesson_id, lessonUuid: lesson.uuid,
|
||||
unitId: unit.unit_id, unitUuid: unit.uuid,
|
||||
courseId: course.course_id, courseUuid: course.uuid,
|
||||
percent, blockId, blockType,
|
||||
});
|
||||
|
||||
return R.success(res, 'Watch progress updated.', result, 200);
|
||||
} catch (err) {
|
||||
console.error('[CLIENT][COURSE READING PROGRESS][WATCH PROGRESS]', err);
|
||||
return R.error(res, 'Could not update watch progress.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// ── MARK COMPLETE (manual_complete completion requirement) ───────────────────
|
||||
// =============================================================================
|
||||
|
||||
// POST /client/courses/:courseId/units/:unitId/lessons/:lessonId/mark-complete
|
||||
// No-ops (still 200s) if the lesson has no configured manual_complete requirement.
|
||||
exports.markLessonComplete = async (req, res) => {
|
||||
try {
|
||||
const { courseId, unitId, lessonId } = req.params;
|
||||
const userId = req.user.user_id;
|
||||
|
||||
const [course, unit, lesson, courseLink, lessonLink] = await Promise.all([
|
||||
Course.findOne({ where: { course_id: courseId, ...notDeleted }, attributes: ['course_id', 'uuid'] }),
|
||||
Unit.findOne({ where: { unit_id: unitId, ...notDeleted }, attributes: ['unit_id', 'uuid'] }),
|
||||
Lesson.findOne({ where: { lesson_id: lessonId, ...notDeleted }, attributes: ['lesson_id', 'uuid'] }),
|
||||
CourseUnit.findOne({ where: { course_id: courseId, unit_id: unitId } }),
|
||||
UnitLesson.findOne({ where: { unit_id: unitId, lesson_id: lessonId } }),
|
||||
]);
|
||||
if (!course) return R.error(res, 'Course not found.', 404);
|
||||
if (!unit || !courseLink) return R.error(res, 'Unit not found.', 404);
|
||||
if (!lesson || !lessonLink) return R.error(res, 'Lesson not found.', 404);
|
||||
|
||||
const result = await recordManualComplete(userId, {
|
||||
entityType: 'lesson', entityId: lesson.lesson_id,
|
||||
lessonUuid: lesson.uuid,
|
||||
unitId: unit.unit_id, unitUuid: unit.uuid,
|
||||
courseId: course.course_id, courseUuid: course.uuid,
|
||||
});
|
||||
|
||||
return R.success(res, 'Lesson marked complete.', result, 200);
|
||||
} catch (err) {
|
||||
console.error('[CLIENT][COURSE READING PROGRESS][MARK COMPLETE]', err);
|
||||
return R.error(res, 'Could not mark lesson complete.', 500);
|
||||
}
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,291 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* 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 ───────────────────────────────────────────────
|
||||
|
||||
// Collapses IPv4-mapped IPv6 ("::ffff:127.0.0.1") and IPv6 loopback ("::1")
|
||||
// down to a single canonical form. Without this, a token minted off one
|
||||
// "localhost" connection (IPv4) fails IP-pin verification on a sibling
|
||||
// request that happened to land on the other stack (IPv6) — browsers race
|
||||
// both when resolving "localhost", so mint and stream requests can land on
|
||||
// different stacks even from the same client.
|
||||
function normalizeIp(ip) {
|
||||
if (ip === "::1") return "127.0.0.1";
|
||||
if (ip.startsWith("::ffff:")) return ip.slice(7);
|
||||
return ip;
|
||||
}
|
||||
|
||||
function resolveIp(req) {
|
||||
// x-forwarded-for is set by reverse proxies (nginx, Caddy, Cloudflare)
|
||||
const forwarded = req.headers["x-forwarded-for"];
|
||||
const raw = forwarded ? forwarded.split(",")[0].trim() : (req.ip ?? req.socket?.remoteAddress ?? "unknown");
|
||||
return normalizeIp(raw);
|
||||
}
|
||||
|
||||
// ─── 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;
|
||||
|
||||
// Tracks whether the client dropped the connection first.
|
||||
// proxyReq.destroy() itself fires an "error" event — we silence it when
|
||||
// we were the ones who triggered the teardown (client-closed case).
|
||||
let clientClosed = false;
|
||||
|
||||
const proxyReq = transport.request(remoteUrl, { headers: proxyHeaders }, (proxyRes) => {
|
||||
const status = proxyRes.statusCode ?? 502;
|
||||
|
||||
// Upstream (Garage/S3) returned something other than a successful
|
||||
// content response — surface the real failure instead of piping its
|
||||
// (often tiny XML/JSON) error body through as if it were the file.
|
||||
if (status !== 200 && status !== 206) {
|
||||
proxyRes.resume(); // drain so the socket can close cleanly
|
||||
console.error(`[CLIENT][MEDIA][PROXY] Upstream returned ${status} for ${remoteUrl}`);
|
||||
if (!res.headersSent) res.status(502).json({ message: "Stream unavailable." });
|
||||
return;
|
||||
}
|
||||
|
||||
[
|
||||
"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) => {
|
||||
if (clientClosed) return; // browser navigated away / component unmounted — expected
|
||||
console.error("[CLIENT][MEDIA][PROXY] Stream error:", err.message);
|
||||
if (!res.headersSent) res.status(502).json({ message: "Stream unavailable." });
|
||||
});
|
||||
|
||||
req.on("close", () => {
|
||||
clientClosed = true;
|
||||
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 }
|
||||
//
|
||||
// TOKEN HITS: If a consumer (e.g. ClientNav badge) re-fetches unexpectedly,
|
||||
// the fix lives on the frontend — not here. Use a useRef cache key by
|
||||
// asset_id on the consumer side so this endpoint is called exactly once per
|
||||
// asset per session. The 4h token TTL makes ref-caching safe within a session.
|
||||
|
||||
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, "File not found.", 404);
|
||||
|
||||
if (!SUPPORTED_TYPES.includes(asset.file_type)) {
|
||||
return R.error(res, `File type "${asset.file_type}" is not supported.`, 400);
|
||||
}
|
||||
|
||||
if (asset.storage_provider !== "s3") {
|
||||
return R.error(res, "Token flow is for S3 files 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.getPublicUrl(asset.thumbnail_storage_key);
|
||||
} 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).
|
||||
// pipeRemoteStream() above forwards the real upstream status instead of
|
||||
// collapsing everything to 200 — see its non-200/206 branch. A similar
|
||||
// swallowed-status issue may still exist in s3.service.js (~line 168-171),
|
||||
// not addressed here.
|
||||
|
||||
exports.streamAsset = async (req, res) => {
|
||||
const { token } = req.params;
|
||||
|
||||
// ── CORS ──────────────────────────────────────────────────────────────────
|
||||
// Mirrors server.js's global cors() origin check (reflect against
|
||||
// ALLOWED_ORIGINS) instead of a single hardcoded FRONTEND_URL — a static
|
||||
// origin here silently overwrote the correct header the global middleware
|
||||
// already set, breaking any CORS-checked read (e.g. pdf.js's Range-header
|
||||
// fetch) whenever FRONTEND_URL drifted from the deployed frontend domain.
|
||||
// <img>/<video> tags were unaffected since opaque loads skip CORS checks.
|
||||
const allowedOrigins = (process.env.ALLOWED_ORIGINS || process.env.APP_URL || "*").split(",");
|
||||
const requestOrigin = req.headers.origin;
|
||||
if (requestOrigin && allowedOrigins.includes(requestOrigin)) {
|
||||
res.setHeader("Access-Control-Allow-Origin", requestOrigin);
|
||||
}
|
||||
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);
|
||||
// ── Just comment out for debug if S3_ENDPOINT is undefined ────────────────
|
||||
// console.log("Presigned URL:", presignedUrl);
|
||||
} 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,155 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* 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
|
||||
* DELETE /client/notifications/clear-all — delete all notifications
|
||||
*
|
||||
* Author: Kenneth Obsequio (@lash0000)
|
||||
* Date Created: Jun. 19, 2026
|
||||
***********************************************************************************************************************************************************************/
|
||||
const UserNotification = require('../../models/notifications/user_notification.mdl');
|
||||
const mdl_Assets = require('../../models/assets/assets.mdl');
|
||||
const mediaToken = require('../../services/mediaToken.service');
|
||||
const R = require('../../utils/response.util');
|
||||
const { notInFutureOrExpired } = require('../../utils/notificationVisibility.util');
|
||||
|
||||
const STICKY_LIMIT = 2;
|
||||
const IMAGE_INCLUDE = {
|
||||
model: mdl_Assets,
|
||||
as: 'image',
|
||||
attributes: ["asset_id", "display_name", "file_url", "thumbnail_url", "storage_provider", "storage_key", "file_type", "mime_type"],
|
||||
required: false,
|
||||
};
|
||||
|
||||
// Mirrors controllers/admin/advertisements.controller.js's attachImageStreamToken
|
||||
// — kept duplicated rather than shared across the admin/client boundary.
|
||||
async function attachImageStreamToken(image, req) {
|
||||
if (!image || image.storage_provider !== 's3' || !mediaToken.SUPPORTED_TYPES.includes(image.file_type)) {
|
||||
return image;
|
||||
}
|
||||
const ip = mediaToken.resolveIp(req);
|
||||
const { token } = await mediaToken.issueForAsset(image, req.user?.user_id, ip);
|
||||
image.stream_token = token;
|
||||
image.file_url = null;
|
||||
image.thumbnail_url = null;
|
||||
delete image.storage_key;
|
||||
return image;
|
||||
}
|
||||
|
||||
// ─── 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, show_in_notifications: true, ...notInFutureOrExpired() },
|
||||
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, show_in_notifications: true, ...notInFutureOrExpired() },
|
||||
});
|
||||
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.');
|
||||
}
|
||||
}
|
||||
|
||||
// ─── GET /client/notifications/sticky ─────────────────────────────────────
|
||||
async function stickyAnnouncement(req, res) {
|
||||
try {
|
||||
const rows = await UserNotification.findAll({
|
||||
where: {
|
||||
user_id: req.user.user_id,
|
||||
seen: false,
|
||||
show_in_sticky: true,
|
||||
type: "announcement",
|
||||
...notInFutureOrExpired(),
|
||||
},
|
||||
include: [IMAGE_INCLUDE],
|
||||
order: [["createdAt", "DESC"]],
|
||||
limit: STICKY_LIMIT,
|
||||
});
|
||||
|
||||
const notifications = await Promise.all(rows.map(async (row) => {
|
||||
const json = row.toJSON();
|
||||
if (json.image) await attachImageStreamToken(json.image, req);
|
||||
return json;
|
||||
}));
|
||||
|
||||
return R.success(res, "Sticky alerts fetched.", { announcements: notifications });
|
||||
} catch (err) {
|
||||
console.error("[CLIENT NOTIFICATION] stickyAnnouncement error:", err);
|
||||
return R.error(res, "Failed to fetch sticky announcement.");
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 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.');
|
||||
}
|
||||
}
|
||||
|
||||
// ─── DELETE /client/notifications/clear-all ──────────────────────────────────
|
||||
async function clearAll(req, res) {
|
||||
try {
|
||||
const count = await UserNotification.destroy({
|
||||
where: { user_id: req.user.user_id },
|
||||
});
|
||||
return R.success(res, `${count} notification(s) cleared.`, { count });
|
||||
} catch (err) {
|
||||
console.error('[CLIENT NOTIFICATION] clearAll error:', err);
|
||||
return R.error(res, 'Failed to clear notifications.');
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { list, unseenCount, stickyAnnouncement, markSeen, markAllSeen, clearAll };
|
||||
@@ -0,0 +1,194 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name: profile.controller.js (client)
|
||||
* Type of Program: Controller
|
||||
* 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/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 trustedDevice = require('../../services/trustedDevice.service');
|
||||
const logActivity = require('../../utils/logActivity.util');
|
||||
const R = require('../../utils/response.util');
|
||||
const { replaceUserAvatar, removeUserAvatar } = require('../../services/avatar.service');
|
||||
const { resolveUserAvatar } = require('../../utils/resolveAvatar.util');
|
||||
|
||||
// ─── 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.', await resolveUserAvatar(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;
|
||||
|
||||
// Deep-merge personal_info so partial updates don't wipe existing data
|
||||
const merged = {
|
||||
...(user.personal_info || {}),
|
||||
...(personal_info || {}),
|
||||
name: {
|
||||
...((user.personal_info?.name) || {}),
|
||||
...((personal_info?.name) || {}),
|
||||
},
|
||||
};
|
||||
|
||||
await user.update({ personal_info: merged, needs_intro: false });
|
||||
|
||||
const updated = await mdl_Users.findByPk(req.user.user_id, {
|
||||
attributes: { exclude: ['password', 'otp_code', 'otp_expires_at'] },
|
||||
});
|
||||
|
||||
logActivity(req.user.user_id, 'update_profile');
|
||||
|
||||
return R.success(res, 'Profile updated.', await resolveUserAvatar(updated));
|
||||
} catch (err) {
|
||||
console.error('[CLIENT] updateProfile error:', err);
|
||||
return R.error(res, 'Profile update failed.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── 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']],
|
||||
attributes: { exclude: ['refresh_token_hash'] },
|
||||
});
|
||||
return R.success(res, 'Sessions retrieved.', sessions);
|
||||
} catch (err) {
|
||||
return R.error(res, 'Could not retrieve sessions.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── DELETE revoke a session ───────────────────────────────────────────────────
|
||||
|
||||
exports.revokeSession = async (req, res) => {
|
||||
try {
|
||||
const session = await mdl_UserSessions.findOne({
|
||||
where: { session_id: req.params.id, user_id: req.user.user_id },
|
||||
});
|
||||
if (!session) return R.error(res, 'Session not found.', 404);
|
||||
|
||||
await session.update({
|
||||
is_active: false,
|
||||
logout_info: { date: new Date().toISOString(), ip_address: req.ip },
|
||||
});
|
||||
await trustedDevice.revokeBySessionId(session.session_id);
|
||||
|
||||
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);
|
||||
|
||||
const avatarMeta = await replaceUserAvatar(user, req.file);
|
||||
|
||||
const merged = { ...(user.personal_info || {}), avatar: avatarMeta };
|
||||
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.', await resolveUserAvatar(updated));
|
||||
} catch (err) {
|
||||
if (err.status === 400) return R.error(res, err.message, 400);
|
||||
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);
|
||||
|
||||
await removeUserAvatar(user);
|
||||
|
||||
const merged = { ...(user.personal_info || {}), avatar: null };
|
||||
await user.update({ personal_info: merged });
|
||||
|
||||
return R.success(res, 'Avatar removed.');
|
||||
} catch (err) {
|
||||
if (err.status === 404) return R.error(res, err.message, 404);
|
||||
console.error('[CLIENT] deleteAvatar error:', err);
|
||||
return R.error(res, 'Could not remove avatar.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── DELETE own account ────────────────────────────────────────────────────────
|
||||
|
||||
exports.deleteAccount = async (req, res) => {
|
||||
try {
|
||||
const user = await mdl_Users.findByPk(req.user.user_id);
|
||||
if (!user) return R.error(res, 'User not found.', 404);
|
||||
|
||||
// Revoke all active sessions first
|
||||
await mdl_UserSessions.update(
|
||||
{ is_active: false, logout_info: { date: new Date().toISOString(), ip_address: req.ip, reason: 'account_deleted' } },
|
||||
{ where: { user_id: req.user.user_id, is_active: true } },
|
||||
);
|
||||
await trustedDevice.revokeAllForUser(req.user.user_id);
|
||||
|
||||
// Anonymize email before soft-delete so the unique slot is freed for re-registration
|
||||
await user.update({ email: `deleted_${req.user.user_id}@deleted.invalid`, deletedBy: req.user.user_id });
|
||||
await user.destroy(); // paranoid soft-delete — sets deleted_at
|
||||
|
||||
logActivity(req.user.user_id, 'delete_account');
|
||||
|
||||
res.clearCookie('refreshToken');
|
||||
res.clearCookie('_csrf');
|
||||
|
||||
return R.success(res, 'Account deleted.');
|
||||
} catch (err) {
|
||||
console.error('[CLIENT] deleteAccount error:', err);
|
||||
return R.error(res, 'Could not delete account.', 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);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,851 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* 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, TaskPrerequisite } = 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');
|
||||
const { hydrateReadTaskProgress } = require('../../services/task_reading_progress_sync.service');
|
||||
const UserNotification = require('../../models/notifications/user_notification.mdl');
|
||||
const { NOTIFICATION_REGISTRY } = require('../../data/notifications.data');
|
||||
const { onTaskCompleted, onTaskListCompleted } = require('../../services/achievements.service');
|
||||
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
const isUUID = (v) => UUID_RE.test(v);
|
||||
|
||||
// =============================================================================
|
||||
// ── 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: [],
|
||||
where: { deletedAt: null },
|
||||
},
|
||||
},
|
||||
{
|
||||
model: TaskList,
|
||||
as: 'taskLists',
|
||||
attributes: [],
|
||||
through: { attributes: [] },
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
where: { is_active: true },
|
||||
attributes: [
|
||||
'group_id',
|
||||
'name',
|
||||
'group_code',
|
||||
'description',
|
||||
[sequelize.fn('COUNT', sequelize.fn('DISTINCT', sequelize.col('taskLists.task_list_id'))), 'task_list_count'],
|
||||
],
|
||||
group: [
|
||||
'UserGroup.group_id',
|
||||
'UserGroup.name',
|
||||
'UserGroup.group_code',
|
||||
'UserGroup.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;
|
||||
};
|
||||
|
||||
// ─── Helper: per-user completion signals for a batch of tasks ─────────────────
|
||||
// Shared by getGroupTaskList/getGroupTaskLists. upload_file/submit_text share
|
||||
// one TaskCompletion per task (resubmit-anytime — latest by submitted_at wins).
|
||||
const getTaskCompletionSignals = async (userId, taskIds) => {
|
||||
const [completions, linkVisits, progressRows] = await Promise.all([
|
||||
taskIds.length
|
||||
? TaskCompletion.findAll({
|
||||
where: { task_id: { [Op.in]: taskIds }, user_id: userId },
|
||||
attributes: ['task_id', 'status', 'submitted_at'],
|
||||
order: [['submitted_at', 'DESC']],
|
||||
})
|
||||
: [],
|
||||
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'],
|
||||
})
|
||||
: [],
|
||||
]);
|
||||
|
||||
// First row per task_id wins — completions are ordered submitted_at DESC.
|
||||
const latestCompletionByTask = new Map();
|
||||
for (const c of completions) {
|
||||
if (!latestCompletionByTask.has(c.task_id)) latestCompletionByTask.set(c.task_id, c);
|
||||
}
|
||||
|
||||
return {
|
||||
latestCompletionByTask,
|
||||
visitedRequirementIds: new Set(linkVisits.map((v) => v.requirement_id)),
|
||||
completedProgressKeys: new Set(progressRows.map((p) => `${p.requirement_id}:${p.reference_id}`)),
|
||||
};
|
||||
};
|
||||
|
||||
// ─── Helper: has this requirement been satisfied by the user? ─────────────────
|
||||
const isRequirementDone = (r, signals) => {
|
||||
switch (r.type) {
|
||||
case 'upload_file':
|
||||
case 'submit_text': {
|
||||
const completion = signals.latestCompletionByTask.get(r.task_id);
|
||||
if (!completion) return false;
|
||||
return r.requires_review ? completion.status === 'approved' : true;
|
||||
}
|
||||
case 'visit_link':
|
||||
return signals.visitedRequirementIds.has(r.requirement_id);
|
||||
case 'read_course':
|
||||
case 'read_unit':
|
||||
case 'read_lesson':
|
||||
return signals.completedProgressKeys.has(`${r.requirement_id}:${r.reference_id}`);
|
||||
default:
|
||||
return true; // unknown requirement types don't block completion
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Helper: resolve which sibling task(s) are blocking a locked task ───────
|
||||
// Prerequisites are always siblings within the same task list (enforced by
|
||||
// admin's syncTaskPrerequisites), so names can always be resolved from `arr`.
|
||||
// A task with no explicit task_prerequisites rows is never locked.
|
||||
const resolveLockedBy = (task, i, arr, prereqsByTask, completedById) => {
|
||||
const prereqIds = prereqsByTask.get(task.task_id);
|
||||
if (!prereqIds || !prereqIds.length) return [];
|
||||
|
||||
const blockers = arr.filter((t) => prereqIds.includes(t.task_id) && completedById.get(t.task_id) !== true);
|
||||
return blockers.map((t) => ({ task_id: t.task_id, name: t.name }));
|
||||
};
|
||||
|
||||
// ─── Helper: explicit prerequisite gate ─────────────────────────────────────
|
||||
// Returns true/false when `taskId` has explicit task_prerequisites rows —
|
||||
// ALL of them must be completed by this user. Returns true (unlocked) when
|
||||
// the task has no explicit prerequisites configured.
|
||||
const checkPrerequisitesUnlocked = async (userId, taskId) => {
|
||||
const prereqRows = await TaskPrerequisite.findAll({ where: { task_id: taskId } });
|
||||
if (!prereqRows.length) return true;
|
||||
const results = await Promise.all(prereqRows.map((r) => checkTaskCompletion(userId, r.prerequisite_task_id)));
|
||||
return results.every(Boolean);
|
||||
};
|
||||
|
||||
// ─── Helper: server-side sequencing gate ───────────────────────────────────
|
||||
// A task is locked only by its own explicit task_prerequisites rows — no
|
||||
// implicit locking based on list position. Shared by this file's submitTask
|
||||
// and task_progress.controller.js's visitLink/updateProgress.
|
||||
const assertTaskUnlocked = async (userId, taskId) => checkPrerequisitesUnlocked(userId, taskId);
|
||||
|
||||
// ─── Helper: is this one task fully done for this user, right now? ─────────
|
||||
const checkTaskCompletion = async (userId, taskId) => {
|
||||
const reqs = await TaskRequirement.findAll({ where: { task_id: taskId } });
|
||||
if (!reqs.length) return false;
|
||||
const plainReqs = reqs.map((r) => r.toJSON());
|
||||
const signals = await getTaskCompletionSignals(userId, [taskId]);
|
||||
return plainReqs.every((r) => isRequirementDone(r, signals));
|
||||
};
|
||||
|
||||
// ─── Helper: fire task_completed (+ task_list_finisher achievement) on the
|
||||
// 0→1 completion transition. Callers compute `wasComplete` themselves right
|
||||
// before their write, then call this after, so it only fires once per task.
|
||||
const fireTaskCompletedEvent = async (userId, taskId) => {
|
||||
try {
|
||||
const task = await Task.findByPk(taskId);
|
||||
if (!task) return;
|
||||
|
||||
try {
|
||||
const notify = NOTIFICATION_REGISTRY.task_completed.build({ taskName: task.name });
|
||||
await UserNotification.create({ user_id: userId, ...notify, seen: false });
|
||||
} catch (notifyErr) {
|
||||
console.error('[TASK][NOTIFY COMPLETED]', notifyErr);
|
||||
}
|
||||
|
||||
await onTaskCompleted(userId, taskId, task.name);
|
||||
|
||||
// ── Whole-list completion — check every sibling task too ───────────
|
||||
const siblingTasks = await Task.findAll({ where: { task_list_id: task.task_list_id } });
|
||||
const allDone = siblingTasks.length > 0 && (
|
||||
await Promise.all(siblingTasks.map((t) => checkTaskCompletion(userId, t.task_id)))
|
||||
).every(Boolean);
|
||||
|
||||
if (allDone) {
|
||||
const taskList = await TaskList.findByPk(task.task_list_id);
|
||||
if (taskList) await onTaskListCompleted(userId, taskList.task_list_id, taskList.name);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[TASK][FIRE COMPLETED EVENT]', err);
|
||||
}
|
||||
};
|
||||
|
||||
exports.getTaskCompletionSignals = getTaskCompletionSignals;
|
||||
exports.isRequirementDone = isRequirementDone;
|
||||
exports.assertTaskUnlocked = assertTaskUnlocked;
|
||||
exports.checkPrerequisitesUnlocked = checkPrerequisitesUnlocked;
|
||||
exports.checkTaskCompletion = checkTaskCompletion;
|
||||
exports.fireTaskCompletedEvent = fireTaskCompletedEvent;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// REPLACEMENT: getGroupTaskList in task.controller.js (client)
|
||||
//
|
||||
// GET /client/groups/:groupId/task-lists/:taskListId?status=ongoing|completed|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:
|
||||
// completed → 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 completed AND task.deadline < now
|
||||
// ongoing → otherwise
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
exports.getGroupTaskList = async (req, res) => {
|
||||
try {
|
||||
const { groupId, taskListId } = req.params;
|
||||
const { status } = req.query; // optional: 'ongoing' | 'completed' | '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: [['order_index', '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);
|
||||
const readRequirements = tasks.flatMap((task) =>
|
||||
(task.requirements ?? [])
|
||||
.filter((req) => ['read_course', 'read_unit', 'read_lesson'].includes(req.type))
|
||||
.map((req) => ({ ...req, task_id: task.task_id }))
|
||||
);
|
||||
|
||||
await hydrateReadTaskProgress(userId, readRequirements);
|
||||
|
||||
// ── Fetch user's completion signals for these tasks ────────────────────
|
||||
const signals = await getTaskCompletionSignals(userId, taskIds);
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
// ── has_completed per task (needed up-front — both the bucket AND the
|
||||
// locked computation below depend on sibling tasks' completion) ────────
|
||||
const completedById = new Map(tasks.map((task) => {
|
||||
const requirements = task.requirements ?? [];
|
||||
return [task.task_id, requirements.length > 0 && requirements.every((r) => isRequirementDone(r, signals))];
|
||||
}));
|
||||
|
||||
// ── Explicit prerequisite edges for these tasks ─────────────────────────
|
||||
const prereqEdges = taskIds.length
|
||||
? await TaskPrerequisite.findAll({ where: { task_id: { [Op.in]: taskIds } }, attributes: ['task_id', 'prerequisite_task_id'] })
|
||||
: [];
|
||||
const prereqsByTask = new Map();
|
||||
for (const { task_id, prerequisite_task_id } of prereqEdges) {
|
||||
if (!prereqsByTask.has(task_id)) prereqsByTask.set(task_id, []);
|
||||
prereqsByTask.get(task_id).push(prerequisite_task_id);
|
||||
}
|
||||
|
||||
// ── Bucket + lock each task — a task with explicit prerequisites is
|
||||
// locked until ALL of them are done; a task with none is never locked
|
||||
// (same rule assertTaskUnlocked enforces).
|
||||
const bucketedTasks = tasks.map((task, i, arr) => {
|
||||
const has_completed = completedById.get(task.task_id);
|
||||
|
||||
let bucket;
|
||||
if (has_completed) {
|
||||
bucket = 'completed';
|
||||
} else if (task.deadline && new Date(task.deadline).getTime() < now) {
|
||||
bucket = 'overdue';
|
||||
} else {
|
||||
bucket = 'ongoing';
|
||||
}
|
||||
|
||||
const lockedBy = resolveLockedBy(task, i, arr, prereqsByTask, completedById);
|
||||
const locked = lockedBy.length > 0;
|
||||
|
||||
return { ...task, has_completed, locked, lockedBy, _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|completed|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 → Completed
|
||||
// NOT all completed 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' | 'completed' | '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: [['order_index', '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);
|
||||
const readRequirements = allTasks.flatMap((task) =>
|
||||
(task.requirements ?? [])
|
||||
.filter((req) => ['read_course', 'read_unit', 'read_lesson'].includes(req.type))
|
||||
.map((req) => ({
|
||||
task_id: task.task_id,
|
||||
requirement_id: req.requirement_id,
|
||||
reference_id: req.reference_id,
|
||||
type: req.type,
|
||||
}))
|
||||
);
|
||||
|
||||
await hydrateReadTaskProgress(userId, readRequirements);
|
||||
|
||||
// ── Fetch user's completion signals for these tasks ────────────────────
|
||||
const signals = await getTaskCompletionSignals(userId, taskIds);
|
||||
|
||||
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) => isRequirementDone(r, signals));
|
||||
};
|
||||
|
||||
// ── Explicit prerequisite edges for these tasks ─────────────────────────
|
||||
const prereqEdges = taskIds.length
|
||||
? await TaskPrerequisite.findAll({ where: { task_id: { [Op.in]: taskIds } }, attributes: ['task_id', 'prerequisite_task_id'] })
|
||||
: [];
|
||||
const prereqsByTask = new Map();
|
||||
for (const { task_id, prerequisite_task_id } of prereqEdges) {
|
||||
if (!prereqsByTask.has(task_id)) prereqsByTask.set(task_id, []);
|
||||
prereqsByTask.get(task_id).push(prerequisite_task_id);
|
||||
}
|
||||
|
||||
// ── 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);
|
||||
});
|
||||
|
||||
// has_completed lookup scoped to THIS list's tasks (prerequisite
|
||||
// edges only ever point at siblings within the same list).
|
||||
const completedById = new Map(tasks.map((task) => [task.task_id, task.has_completed]));
|
||||
tasks.forEach((task, i, arr) => {
|
||||
task.lockedBy = resolveLockedBy(task, i, arr, prereqsByTask, completedById);
|
||||
task.locked = task.lockedBy.length > 0;
|
||||
});
|
||||
|
||||
let bucket;
|
||||
if (tasks.length === 0) {
|
||||
bucket = 'ongoing';
|
||||
} else {
|
||||
const allDone = tasks.every((t) => t.has_completed);
|
||||
if (allDone) {
|
||||
bucket = 'completed';
|
||||
} 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;
|
||||
|
||||
if (!isUUID(taskId) || !isUUID(taskListId)) return R.error(res, 'Task not found.', 404);
|
||||
|
||||
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;
|
||||
data.locked = !(await assertTaskUnlocked(req.user.user_id, taskId));
|
||||
|
||||
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 = [], response_text } = 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 (task.accepts_submissions === false) {
|
||||
await t.rollback();
|
||||
return R.error(res, 'This task no longer accepts submissions.', 409);
|
||||
}
|
||||
|
||||
if (!(await assertTaskUnlocked(req.user.user_id, taskId))) {
|
||||
await t.rollback();
|
||||
return R.error(res, 'Complete this task\'s prerequisite tasks first.', 409);
|
||||
}
|
||||
|
||||
const wasComplete = await checkTaskCompletion(req.user.user_id, taskId);
|
||||
|
||||
// ── Which submission-based requirement(s) does this task have? ─────────
|
||||
const submissionRequirements = await TaskRequirement.findAll({
|
||||
where: { task_id: taskId, type: { [Op.in]: ['upload_file', 'submit_text'] } },
|
||||
transaction: t,
|
||||
});
|
||||
const uploadRequirement = submissionRequirements.find((r) => r.type === 'upload_file');
|
||||
const textRequirement = submissionRequirements.find((r) => r.type === 'submit_text');
|
||||
|
||||
if (!uploadRequirement && !textRequirement) {
|
||||
await t.rollback();
|
||||
return R.error(res, 'This task has no requirement that accepts a submission.', 400);
|
||||
}
|
||||
if (uploadRequirement && !files.length) {
|
||||
await t.rollback();
|
||||
return R.error(res, 'At least one file is required to submit.', 400);
|
||||
}
|
||||
if (!uploadRequirement && textRequirement && !(response_text ?? '').trim()) {
|
||||
await t.rollback();
|
||||
return R.error(res, 'A response 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);
|
||||
}
|
||||
|
||||
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,
|
||||
response_text: response_text || null,
|
||||
submitted_at: new Date(),
|
||||
createdBy: req.user.user_id,
|
||||
updatedBy: req.user.user_id,
|
||||
}, { transaction: t });
|
||||
|
||||
if (files.length) {
|
||||
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),
|
||||
});
|
||||
|
||||
if (!wasComplete && await checkTaskCompletion(req.user.user_id, taskId)) {
|
||||
fireTaskCompletedEvent(req.user.user_id, taskId); // fire-and-forget
|
||||
}
|
||||
|
||||
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;
|
||||
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,482 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* 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)
|
||||
* DELETE /client/groups/:groupId/task-lists/:taskListId/tasks/:taskId/requirements/:requirementId/visit
|
||||
* → DELETE TaskLinkVisit (unsubmit)
|
||||
*
|
||||
* 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 { Op } = require('sequelize');
|
||||
const sequelize = require('../../config/db.config');
|
||||
|
||||
const { Task, TaskRequirement } = require('../../models/task/task.mdl');
|
||||
const { assertTaskUnlocked, checkTaskCompletion, fireTaskCompletedEvent } = require('./task.controller');
|
||||
const { TaskLinkVisit, TaskProgress } = require('../../models/task/task_progress.mdl');
|
||||
const { mdl_UserGroupMembers } = require('../../models/users/user_groups.mdl');
|
||||
const { Course } = require('../../models/courses/courses.mdl');
|
||||
const CourseAssessment = require('../../models/courses/course_assessment.mdl');
|
||||
const QuizAttempt = require('../../models/courses/quiz_attempt.mdl');
|
||||
const logActivity = require('../../utils/logActivity.util');
|
||||
const R = require('../../utils/response.util');
|
||||
const { hydrateReadTaskProgress } = require('../../services/task_reading_progress_sync.service');
|
||||
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
const isUUID = (v) => UUID_RE.test(v);
|
||||
|
||||
// ─── 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 AND, if the course has a built assessment,
|
||||
// the user has passed it. A course with no assessment yet can never be "complete" —
|
||||
// finishing the reading alone isn't course completion.
|
||||
const deriveCourseCompletion = async (userId, courseRequirementId, courseUuid, t) => {
|
||||
const rows = await TaskProgress.findAll({
|
||||
where: {
|
||||
requirement_id: courseRequirementId,
|
||||
user_id: userId,
|
||||
type: 'read_unit',
|
||||
},
|
||||
transaction: t,
|
||||
});
|
||||
if (!rows.length) return false;
|
||||
const allUnitsRead = rows.every((r) => r.completed);
|
||||
if (!allUnitsRead) return false;
|
||||
|
||||
const course = await Course.findOne({
|
||||
where: { uuid: courseUuid },
|
||||
attributes: ['course_id'],
|
||||
transaction: t,
|
||||
});
|
||||
if (!course) return false;
|
||||
|
||||
const assessment = await CourseAssessment.findOne({
|
||||
where: { course_id: course.course_id },
|
||||
attributes: ['assessment_id'],
|
||||
transaction: t,
|
||||
});
|
||||
if (!assessment) return false;
|
||||
|
||||
const passedAttempt = await QuizAttempt.findOne({
|
||||
where: { user_id: userId, assessment_id: assessment.assessment_id, passed: true },
|
||||
transaction: t,
|
||||
});
|
||||
return !!passedAttempt;
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// ── 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;
|
||||
|
||||
if (!isUUID(taskId) || !isUUID(taskListId)) return R.error(res, 'Task not found.', 404);
|
||||
|
||||
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 readRequirements = await TaskRequirement.findAll({
|
||||
where: {
|
||||
task_id: taskId,
|
||||
type: { [Op.in]: ['read_course', 'read_unit', 'read_lesson'] },
|
||||
},
|
||||
attributes: ['task_id', 'requirement_id', 'reference_id', 'type'],
|
||||
});
|
||||
|
||||
await hydrateReadTaskProgress(req.user.user_id, readRequirements);
|
||||
|
||||
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 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 (!(await assertTaskUnlocked(req.user.user_id, taskId))) {
|
||||
await t.rollback();
|
||||
return R.error(res, 'Complete this task\'s prerequisite tasks first.', 409);
|
||||
}
|
||||
|
||||
const wasComplete = await checkTaskCompletion(req.user.user_id, taskId);
|
||||
|
||||
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 },
|
||||
});
|
||||
}
|
||||
|
||||
if (!wasComplete && await checkTaskCompletion(req.user.user_id, taskId)) {
|
||||
fireTaskCompletedEvent(req.user.user_id, taskId); // fire-and-forget
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// ── UNVISIT LINK (DELETE TaskLinkVisit) ──────────────────────────────────────
|
||||
// =============================================================================
|
||||
|
||||
// DELETE /client/groups/:groupId/task-lists/:taskListId/tasks/:taskId/requirements/:requirementId/visit
|
||||
|
||||
exports.unvisitLink = 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);
|
||||
}
|
||||
|
||||
await TaskLinkVisit.destroy({
|
||||
where: { requirement_id: requirementId, user_id: req.user.user_id, task_id: taskId },
|
||||
transaction: t,
|
||||
});
|
||||
|
||||
await t.commit();
|
||||
return R.success(res, 'Link visit removed.', { requirement_id: requirementId });
|
||||
} catch (err) {
|
||||
await t.rollback();
|
||||
console.error('[CLIENT][UNVISIT LINK]', err);
|
||||
return R.error(res, 'Could not remove 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 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 (!(await assertTaskUnlocked(req.user.user_id, taskId))) {
|
||||
await t.rollback();
|
||||
return R.error(res, 'Complete this task\'s prerequisite tasks first.', 409);
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const userId = req.user.user_id;
|
||||
const wasComplete = await checkTaskCompletion(userId, taskId);
|
||||
|
||||
// ── 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();
|
||||
if (!wasComplete && await checkTaskCompletion(userId, taskId)) {
|
||||
fireTaskCompletedEvent(userId, taskId); // fire-and-forget
|
||||
}
|
||||
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, courseReq.reference_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();
|
||||
if (!wasComplete && await checkTaskCompletion(userId, taskId)) {
|
||||
fireTaskCompletedEvent(userId, taskId); // fire-and-forget
|
||||
}
|
||||
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://cdn.yourdomain.com/your-bucket/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);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,630 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* 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)
|
||||
* - Promo code validation (server-side)
|
||||
* - PayPal redirect checkout (create order → capture → cancel → refund)
|
||||
* - View own payment history
|
||||
* Author: rgrgogu
|
||||
* Date Created: Jun. 6, 2026
|
||||
* Modified: Jun. 29, 2026
|
||||
***********************************************************************************************************************************************************************/
|
||||
const mdl_TierCategories = require('../../models/tiers/tier_categories.mdl');
|
||||
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_SystemBadges = require('../../models/system_badges/system_badges.mdl');
|
||||
const { mdl_UserTierGrants } = require('../../models/tiers/tier.associations');
|
||||
const Asset = require('../../models/assets/assets.mdl');
|
||||
const UserNotification = require('../../models/notifications/user_notification.mdl');
|
||||
const { onTierActivated } = require('../../services/achievements.service');
|
||||
const { Course } = require('../../models/courses/courses.mdl');
|
||||
const Unit = require('../../models/courses/units.mdl');
|
||||
const Lesson = require('../../models/courses/lessons.mdl');
|
||||
const paymentSvc = require('../../services/payment.service');
|
||||
const { snapshotPlanGrants } = require('../../services/tierGrants.service');
|
||||
const R = require('../../utils/response.util');
|
||||
const { NOTIFICATION_REGISTRY } = require('../../data/notifications.data');
|
||||
const { sendEmail } = require('../../services/email.service');
|
||||
const { fmtDate } = require('../../utils/datetime.util');
|
||||
|
||||
require('../../models/tiers/tier.associations');
|
||||
|
||||
// ─── MY TIER ──────────────────────────────────────────────────────────────────
|
||||
|
||||
// A user can hold more than one active tier concurrently (e.g. premium + exclusive
|
||||
// bought separately). Returns the full active set plus the highest-rank one as
|
||||
// `top_tier`, for callers that just want "the best tier this user currently has".
|
||||
exports.getMyTier = async (req, res) => {
|
||||
try {
|
||||
const badgeInclude = { model: Asset, as: 'badgeAsset', attributes: ['asset_id', 'storage_provider', 'file_url', 'display_name'], required: false };
|
||||
|
||||
const tiers = await mdl_UserTiers.findAll({
|
||||
where: { user_id: req.user.user_id, status: 'active' },
|
||||
include: [{
|
||||
model: mdl_TierPlans,
|
||||
as: 'plan',
|
||||
required: false,
|
||||
include: [{ model: mdl_TierCategories, as: 'category', required: false, include: [badgeInclude] }],
|
||||
}],
|
||||
order: [['createdAt', 'DESC']],
|
||||
});
|
||||
|
||||
// ── Inline safety net: expire between cron ticks ──────────────────────────
|
||||
let just_expired = false;
|
||||
const stillActive = [];
|
||||
for (const tier of tiers) {
|
||||
if (tier.expires_at && new Date(tier.expires_at) <= new Date()) {
|
||||
await tier.update({ status: 'expired' });
|
||||
UserNotification.create({
|
||||
user_id: req.user.user_id,
|
||||
...NOTIFICATION_REGISTRY.tier_expired.build({
|
||||
tier: tier.tier,
|
||||
label: tier.plan?.label ?? null,
|
||||
planId: tier.plan?.plan_id ?? null,
|
||||
}),
|
||||
}).catch(() => {});
|
||||
just_expired = true;
|
||||
continue;
|
||||
}
|
||||
stillActive.push(tier);
|
||||
}
|
||||
|
||||
if (!stillActive.length) {
|
||||
const freeCategory = await mdl_TierCategories.findOne({ where: { slug: 'free' }, include: [badgeInclude] });
|
||||
const freeTier = { tier: 'free', status: 'active', category: freeCategory ?? null };
|
||||
// Spread freeTier at top level too — keeps `myTier.tier`/`myTier.status`/`myTier.category`
|
||||
// working for existing frontend code that predates the active_tiers/top_tier shape.
|
||||
return R.success(res, 'Active subscription retrieved.', {
|
||||
...freeTier,
|
||||
active_tiers: [freeTier],
|
||||
top_tier: 'free',
|
||||
just_expired,
|
||||
my_grants: { course_ids: [], unit_ids: [], lesson_ids: [] },
|
||||
});
|
||||
}
|
||||
|
||||
// Item-specific entitlement (Tier Plans v2) — every course/unit/lesson id
|
||||
// granted by ANY of this user's currently-active tiers, flattened, so the
|
||||
// client can compute per-plan overlap (see PlanList.jsx) without a
|
||||
// separate endpoint per plan.
|
||||
const myGrantRows = await mdl_UserTierGrants.findAll({
|
||||
where: { user_tier_id: stillActive.map((t) => t.tier_id) },
|
||||
attributes: ['item_type', 'item_id'],
|
||||
});
|
||||
const my_grants = { course_ids: [], unit_ids: [], lesson_ids: [] };
|
||||
for (const g of myGrantRows) {
|
||||
if (g.item_type === 'course') my_grants.course_ids.push(g.item_id);
|
||||
else if (g.item_type === 'unit') my_grants.unit_ids.push(g.item_id);
|
||||
else if (g.item_type === 'lesson') my_grants.lesson_ids.push(g.item_id);
|
||||
}
|
||||
|
||||
const categories = await mdl_TierCategories.findAll({ attributes: ['slug', 'rank'] });
|
||||
const rankMap = Object.fromEntries(categories.map((c) => [c.slug, c.rank]));
|
||||
|
||||
const active_tiers = [];
|
||||
for (const tier of stillActive) {
|
||||
if (!tier.plan?.category) {
|
||||
const category = await mdl_TierCategories.findOne({ where: { slug: tier.tier }, include: [badgeInclude] });
|
||||
const plain = tier.toJSON();
|
||||
plain.category = category?.toJSON() ?? null;
|
||||
active_tiers.push(plain);
|
||||
} else {
|
||||
active_tiers.push(tier.toJSON());
|
||||
}
|
||||
}
|
||||
|
||||
let top_tier = active_tiers[0].tier;
|
||||
for (const t of active_tiers) {
|
||||
if ((rankMap[t.tier] ?? 0) > (rankMap[top_tier] ?? 0)) top_tier = t.tier;
|
||||
}
|
||||
|
||||
const topTierObj = active_tiers.find((t) => t.tier === top_tier) ?? active_tiers[0];
|
||||
|
||||
// Spread topTierObj at top level too — keeps `myTier.tier`/`myTier.status`/
|
||||
// `myTier.category`/`myTier.expires_at` working for existing frontend code
|
||||
// that predates the active_tiers/top_tier shape (it'll just see the best tier).
|
||||
return R.success(res, 'Active subscription retrieved.', { ...topTierObj, active_tiers, top_tier, just_expired, my_grants });
|
||||
} catch (err) {
|
||||
console.error('[CLIENT][GET MY TIER]', err);
|
||||
return R.error(res, 'Could not retrieve subscription.', 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, 'Subscription history retrieved.', history);
|
||||
} catch (err) {
|
||||
console.error('[CLIENT][GET MY TIER HISTORY]', err);
|
||||
return R.error(res, 'Could not retrieve subscription history.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── PLANS ────────────────────────────────────────────────────────────────────
|
||||
|
||||
exports.getPlans = async (req, res) => {
|
||||
try {
|
||||
const plans = await mdl_TierPlans.findAll({
|
||||
where: { status: 'published' },
|
||||
order: [['tier', 'ASC'], ['duration_days', 'ASC']],
|
||||
attributes: ['plan_id', 'tier', 'label', 'description', 'features', 'duration_days', 'duration_unit', 'price', 'currency', 'is_active', 'is_recommended'],
|
||||
include: [
|
||||
{
|
||||
model: Course,
|
||||
as: 'courses',
|
||||
attributes: ['course_id', 'uuid', 'title', 'course_code', 'level', 'duration_seconds'],
|
||||
through: { attributes: [] },
|
||||
},
|
||||
{
|
||||
model: Unit,
|
||||
as: 'units',
|
||||
attributes: ['unit_id', 'uuid', 'title', 'duration_seconds'],
|
||||
through: { attributes: [] },
|
||||
},
|
||||
{
|
||||
model: Lesson,
|
||||
as: 'lessons',
|
||||
attributes: ['lesson_id', 'uuid', 'title', 'duration_seconds'],
|
||||
through: { attributes: [] },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Each plan holds exactly one bundle type (single-type bundles, Tier Plans
|
||||
// v2) — expose the exact item id sets so the client can compute
|
||||
// overlap-with-existing-access without extra round-trips (see PlanList.jsx).
|
||||
const result = plans.map((p) => {
|
||||
const plain = p.toJSON();
|
||||
plain.course_count = plain.courses?.length ?? 0;
|
||||
plain.unit_count = plain.units?.length ?? 0;
|
||||
plain.lesson_count = plain.lessons?.length ?? 0;
|
||||
plain.course_ids = (plain.courses ?? []).map((c) => c.course_id);
|
||||
plain.unit_ids = (plain.units ?? []).map((u) => u.unit_id);
|
||||
plain.lesson_ids = (plain.lessons ?? []).map((l) => l.lesson_id);
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── PROMO CODE VALIDATION ────────────────────────────────────────────────────
|
||||
|
||||
exports.validatePromo = async (req, res) => {
|
||||
try {
|
||||
const { plan_id, code } = req.body;
|
||||
if (!plan_id || !code) return R.error(res, 'plan_id and code are required.', 400);
|
||||
|
||||
const plan = await mdl_TierPlans.findOne({ where: { plan_id, is_active: true, status: 'published' } });
|
||||
if (!plan) return R.error(res, 'Plan not found or inactive.', 404);
|
||||
|
||||
const policy = await paymentSvc.getPolicyForPlan(plan_id);
|
||||
const result = await paymentSvc.evaluatePromo(policy, plan, code, null);
|
||||
|
||||
return R.success(res, result.valid ? 'Promo code is valid.' : result.reason, result);
|
||||
} catch (err) {
|
||||
console.error('[CLIENT][VALIDATE PROMO]', err);
|
||||
return R.error(res, 'Could not validate promo code.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── 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, status: 'published' } });
|
||||
if (!plan) return R.error(res, 'Plan not found or inactive.', 404);
|
||||
|
||||
// Repurchasing the SAME plan while it's already active is allowed — it
|
||||
// extends the existing grant's expires_at (see captureOrder) rather than
|
||||
// being blocked. A different plan at the same tier slug is NOT the same
|
||||
// purchase — it creates its own independent user_tiers row with its own
|
||||
// item-specific grants, so this check is keyed on plan_id, not tier.
|
||||
// Surfaced here only for checkout-page messaging.
|
||||
const existingActive = await mdl_UserTiers.findOne({
|
||||
where: { user_id: req.user.user_id, plan_id, status: 'active' },
|
||||
attributes: ['expires_at'],
|
||||
});
|
||||
|
||||
const effectivePrice = Number(plan.price);
|
||||
const effectiveCurrency = plan.currency;
|
||||
|
||||
const policy = await paymentSvc.getPolicyForPlan(plan_id);
|
||||
|
||||
let promoResult = { valid: false, code: null, discount: 0 };
|
||||
if (promo_code) {
|
||||
promoResult = await paymentSvc.evaluatePromo(policy, plan, promo_code, effectivePrice);
|
||||
if (!promoResult.valid)
|
||||
return R.error(res, promoResult.reason ?? 'Invalid promo code.', 400);
|
||||
}
|
||||
|
||||
const subtotal = effectivePrice;
|
||||
const discount = promoResult.discount ?? 0;
|
||||
const total = Math.max(subtotal - discount, 0).toFixed(2);
|
||||
|
||||
if (Number(total) <= 0)
|
||||
return R.error(res, 'PayPal checkout requires a payable amount.', 400);
|
||||
|
||||
const provider = (policy?.allowed_providers?.[0]) ?? 'paypal';
|
||||
const ppOrder = await paymentSvc.createOrder(provider, {
|
||||
amount: total,
|
||||
currency: effectiveCurrency,
|
||||
referenceId: `user_${req.user.user_id}_plan_${plan_id}`,
|
||||
});
|
||||
|
||||
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: total,
|
||||
currency: effectiveCurrency,
|
||||
promo_code: promoResult.code,
|
||||
discount: discount.toFixed(2),
|
||||
provider,
|
||||
provider_payload: {
|
||||
order_id: ppOrder.id,
|
||||
approval_url: approvalUrl,
|
||||
checkout: {
|
||||
subtotal: subtotal.toFixed(2),
|
||||
discount: discount.toFixed(2),
|
||||
promo_code: promoResult.code,
|
||||
base_price: Number(plan.price).toFixed(2),
|
||||
base_currency: plan.currency,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return R.success(res, 'Order created.', {
|
||||
payment_id: payment.payment_id,
|
||||
order_id: ppOrder.id,
|
||||
approval_url: approvalUrl,
|
||||
amount: total,
|
||||
currency: effectiveCurrency,
|
||||
promo_code: promoResult.code,
|
||||
discount: discount.toFixed(2),
|
||||
extends_existing: !!existingActive,
|
||||
current_expires_at: existingActive?.expires_at ?? null,
|
||||
}, 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', user_id: req.user.user_id },
|
||||
include: [{ model: mdl_TierPlans, as: 'plan' }],
|
||||
order: [['createdAt', 'DESC']],
|
||||
});
|
||||
|
||||
if (!payment || payment.provider_payload?.order_id !== order_id)
|
||||
return R.error(res, 'Pending payment not found.', 404);
|
||||
|
||||
// Guard: plan was deactivated while user was on PayPal's approval page
|
||||
if (!payment.plan?.is_active) {
|
||||
await payment.update({
|
||||
status: 'cancelled',
|
||||
provider_payload: {
|
||||
...payment.provider_payload,
|
||||
cancelled_at: new Date().toISOString(),
|
||||
cancelled_by: 'system',
|
||||
cancel_reason: 'plan_deactivated',
|
||||
},
|
||||
});
|
||||
return R.error(res, 'This plan is no longer available. No payment was taken.', 409);
|
||||
}
|
||||
|
||||
let captureData;
|
||||
try {
|
||||
captureData = await paymentSvc.captureOrder(payment.provider, order_id);
|
||||
} catch (ppErr) {
|
||||
await payment.update({
|
||||
status: 'failed',
|
||||
provider_payload: { ...payment.provider_payload, error: ppErr?.response?.data ?? {} },
|
||||
});
|
||||
return R.error(res, 'Payment capture failed.', 402);
|
||||
}
|
||||
|
||||
const capture = captureData.purchase_units?.[0]?.payments?.captures?.[0];
|
||||
|
||||
// PayPal can return an HTTP 2xx from the capture endpoint even when the
|
||||
// charge itself was declined or held for review (e.g. capture.status
|
||||
// "DECLINED"/"PENDING") — axios only throws on non-2xx, so the actual
|
||||
// status field must be checked explicitly before granting any access.
|
||||
const captureStatus = capture?.status ?? captureData.status;
|
||||
if (captureStatus !== 'COMPLETED') {
|
||||
await payment.update({
|
||||
status: 'failed',
|
||||
provider_payload: { ...payment.provider_payload, capture: captureData, failed_reason: captureStatus ?? 'unknown' },
|
||||
});
|
||||
return R.error(res, `Payment was not completed by PayPal (status: ${captureStatus ?? 'unknown'}).`, 402);
|
||||
}
|
||||
|
||||
// Repurchasing THIS SAME plan while already active extends its expires_at
|
||||
// by the new duration, rather than being blocked/refunded. A different
|
||||
// plan — even at the same tier slug — is a distinct purchase and gets its
|
||||
// own user_tiers row with its own item-specific grants (see
|
||||
// snapshotPlanGrants below); it must NOT be merged into an unrelated
|
||||
// plan's row just because the tier slug matches (Tier Plans v2 — a Unit
|
||||
// bundle and a Course bundle can both be "premium" and both need to stay
|
||||
// independently active/tracked).
|
||||
const existingActive = await mdl_UserTiers.findOne({
|
||||
where: { user_id: req.user.user_id, plan_id: payment.plan_id, status: 'active' },
|
||||
});
|
||||
|
||||
let resultTier;
|
||||
let successMessage;
|
||||
|
||||
if (existingActive) {
|
||||
const newExpiresAt = new Date(existingActive.expires_at.getTime() + payment.plan.duration_days * 86400000);
|
||||
await existingActive.update({ expires_at: newExpiresAt });
|
||||
resultTier = existingActive;
|
||||
successMessage = `Payment successful. Your ${payment.plan.tier} access has been extended to ${newExpiresAt.toLocaleDateString()}.`;
|
||||
} else {
|
||||
const startsAt = new Date();
|
||||
const expiresAt = new Date(startsAt.getTime() + payment.plan.duration_days * 86400000);
|
||||
|
||||
resultTier = 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,
|
||||
});
|
||||
successMessage = 'Payment successful. Subscription activated.';
|
||||
}
|
||||
|
||||
// Snapshot the plan's current bundle contents into user_tier_grants —
|
||||
// refreshed on every purchase/extension so an admin's bundle edits since
|
||||
// the last purchase are picked up, but past purchasers of OTHER plans are
|
||||
// never retroactively affected (Tier Plans v2 item-specific entitlement).
|
||||
await snapshotPlanGrants(resultTier, payment.plan_id);
|
||||
|
||||
await payment.update({
|
||||
status: 'completed',
|
||||
tier_id: resultTier.tier_id,
|
||||
paid_at: new Date(),
|
||||
provider_payload: {
|
||||
...payment.provider_payload,
|
||||
capture_id: capture?.id,
|
||||
payer_id: captureData.payer?.payer_id,
|
||||
capture: captureData,
|
||||
},
|
||||
});
|
||||
|
||||
// Idempotent (grantAchievement checks for an existing row first) — safe
|
||||
// to call again on an extension, won't grant a duplicate achievement.
|
||||
await onTierActivated(req.user.user_id, resultTier.tier);
|
||||
|
||||
return R.success(res, successMessage, {
|
||||
tier: resultTier.tier,
|
||||
expires_at: resultTier.expires_at,
|
||||
extended: !!existingActive,
|
||||
});
|
||||
} 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' },
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
exports.refundOrder = async (req, res) => {
|
||||
try {
|
||||
const user_id = req.user.user_id;
|
||||
// plan_id disambiguates which active subscription to refund now that a user
|
||||
// can hold more than one concurrently — optional only while a user has just one.
|
||||
const { plan_id } = req.body;
|
||||
|
||||
const activeTierWhere = { user_id, status: 'active' };
|
||||
if (plan_id) activeTierWhere.plan_id = plan_id;
|
||||
|
||||
const activeTierCandidates = await mdl_UserTiers.findAll({
|
||||
where: activeTierWhere,
|
||||
order: [['createdAt', 'DESC']],
|
||||
});
|
||||
if (!activeTierCandidates.length) return R.error(res, 'No active subscription to refund.', 404);
|
||||
if (activeTierCandidates.length > 1) {
|
||||
return R.error(res, 'You have more than one active subscription — specify plan_id to refund a specific one.', 400);
|
||||
}
|
||||
const activeTier = activeTierCandidates[0];
|
||||
|
||||
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 subscription.', 404);
|
||||
|
||||
// Load plan's payment policy to get the configured refund window
|
||||
const policy = await paymentSvc.getPolicyForPlan(payment.plan_id);
|
||||
|
||||
if (!paymentSvc.isRefundAllowed(policy))
|
||||
return R.error(res, 'Refunds are not available for this plan.', 403);
|
||||
|
||||
const windowMs = paymentSvc.getRefundWindowMs(policy);
|
||||
if (!payment.paid_at || Date.now() - new Date(payment.paid_at).getTime() > windowMs) {
|
||||
const rp = policy?.refund_policy ?? {};
|
||||
const label = `${rp.window_value ?? 5} ${rp.window_unit ?? 'minutes'}`;
|
||||
return R.error(res, `Refund window has expired. Refunds are only available within ${label} of payment.`, 403);
|
||||
}
|
||||
|
||||
const captureId = payment.provider_payload?.capture_id;
|
||||
if (!captureId) return R.error(res, 'Capture ID not found. Cannot process refund.', 400);
|
||||
|
||||
let refundData;
|
||||
try {
|
||||
refundData = await paymentSvc.refundCapture(payment.provider, captureId, payment.amount, payment.currency);
|
||||
} catch (ppErr) {
|
||||
console.error('[CLIENT][REFUND] provider error:', ppErr?.response?.data);
|
||||
return R.error(res, 'Refund failed. Please try again.', 402);
|
||||
}
|
||||
|
||||
await payment.update({
|
||||
status: 'refunded',
|
||||
provider_payload: {
|
||||
...payment.provider_payload,
|
||||
refund: refundData,
|
||||
refunded_at: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
|
||||
const now = new Date();
|
||||
await activeTier.update({ status: 'revoked', expires_at: now, revoked_at: now });
|
||||
|
||||
const plan = await mdl_TierPlans.findByPk(payment.plan_id, { attributes: ['plan_id', 'label'] });
|
||||
|
||||
try {
|
||||
const notify = NOTIFICATION_REGISTRY.payment_refunded.build({
|
||||
label: plan?.label ?? 'your plan',
|
||||
amount: payment.amount,
|
||||
currency: payment.currency,
|
||||
planId: plan?.plan_id ?? null,
|
||||
});
|
||||
await UserNotification.create({ user_id, ...notify, seen: false });
|
||||
} catch (notifyErr) {
|
||||
console.error('[CLIENT][REFUND][NOTIFY]', notifyErr);
|
||||
}
|
||||
|
||||
try {
|
||||
const name = req.user.personal_info?.name?.full_name ?? 'there';
|
||||
sendEmail({
|
||||
to: req.user.email,
|
||||
type: 'REFUND_PROCESSED',
|
||||
data: {
|
||||
name,
|
||||
label: plan?.label ?? 'your plan',
|
||||
amount: payment.amount,
|
||||
currency: payment.currency,
|
||||
date: fmtDate(now),
|
||||
refundId: refundData.id,
|
||||
},
|
||||
}).catch((emailErr) => console.error('[CLIENT][REFUND][EMAIL]', emailErr));
|
||||
} catch (emailErr) {
|
||||
console.error('[CLIENT][REFUND][EMAIL]', emailErr);
|
||||
}
|
||||
|
||||
// Only fall back to free if the user has no other concurrently active tier —
|
||||
// revoking one subscription shouldn't drop them below a tier they still hold.
|
||||
const remainingActive = await mdl_UserTiers.count({ where: { user_id, status: 'active' } });
|
||||
if (remainingActive > 0) {
|
||||
return R.success(res, 'Refund processed successfully. Your access to this plan has been revoked.', {
|
||||
refund_id: refundData.id,
|
||||
status: refundData.status,
|
||||
});
|
||||
}
|
||||
|
||||
await mdl_UserTiers.create({
|
||||
user_id,
|
||||
tier: 'free',
|
||||
status: 'active',
|
||||
starts_at: now,
|
||||
expires_at: null,
|
||||
granted_by: null,
|
||||
notes: 'Auto-downgrade after refund.',
|
||||
});
|
||||
|
||||
return R.success(res, 'Refund processed successfully. Your access has been revoked.', {
|
||||
refund_id: refundData.id,
|
||||
status: refundData.status,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[CLIENT][REFUND]', err);
|
||||
return R.error(res, 'Could not process refund.', 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);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── TIER CATEGORIES + SYSTEM BADGES ─────────────────────────────────────────
|
||||
|
||||
exports.getCategories = async (req, res) => {
|
||||
try {
|
||||
const categories = await mdl_TierCategories.findAll({
|
||||
where: { is_active: true },
|
||||
attributes: ['tier_category_id', 'slug', 'name', 'rank', 'color', 'badge_icon', 'badge_label', 'is_default'],
|
||||
include: [{ model: Asset, as: 'badgeAsset', attributes: ['asset_id', 'storage_provider', 'file_url', 'display_name'], required: false }],
|
||||
order: [['rank', 'ASC']],
|
||||
});
|
||||
return R.success(res, 'Subscription categories retrieved.', categories);
|
||||
} catch (err) {
|
||||
console.error('[CLIENT][GET TIER CATEGORIES]', err);
|
||||
return R.error(res, 'Could not retrieve subscription categories.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.getSystemBadges = async (req, res) => {
|
||||
try {
|
||||
const badges = await mdl_SystemBadges.findAll({
|
||||
attributes: ['key', 'label', 'description', 'information', 'active_from', 'active_until'],
|
||||
include: [{ model: Asset, as: 'asset', attributes: ['file_url', 'display_name'], required: false }],
|
||||
order: [['key', 'ASC']],
|
||||
});
|
||||
return R.success(res, 'System badges retrieved.', badges);
|
||||
} catch (err) {
|
||||
console.error('[CLIENT][GET SYSTEM BADGES]', err);
|
||||
return R.error(res, 'Could not retrieve system badges.', 500);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,491 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name: units.controller.js (client)
|
||||
* Type of Program: Controller
|
||||
* Description: Standalone Unit / Lesson consumption — the junction revamp lets
|
||||
* learners run Units and Lessons outside any Course:
|
||||
*
|
||||
* GET /client/units → INDEPENDENT units only (no course affiliation)
|
||||
* GET /client/lessons → INDEPENDENT lessons only (no unit is course-affiliated)
|
||||
* GET /client/units/:uuid → unit metadata (shared handler)
|
||||
* GET /client/units/:uuid/lessons → unit + ALL lesson data (shared handler)
|
||||
* GET /client/units/:uuid/quiz → the unit's quiz, no course context
|
||||
* POST /client/units/:uuid/quiz/:quizId/submit→ graded attempt with course_id NULL
|
||||
* GET /client/lessons/:uuid → single lesson, runs independently (shared handler)
|
||||
* POST /client/lessons/:uuid/progress → standalone reading progress (course NULL, unit optional)
|
||||
*
|
||||
* Access rule: a unit attached to no course is open; otherwise the user must
|
||||
* be able to access at least one attached course. Lessons resolve through
|
||||
* their parent units the same way.
|
||||
*
|
||||
* Discovery rule (getUnits/getLessons only): ALL non-deleted units/lessons
|
||||
* are listed, whether or not they're attached to a course — course_count/
|
||||
* courses[] (published courses only) and is_locked tell the learner whether
|
||||
* a given item is standalone or bound, and if bound, whether they already
|
||||
* have access. This does not affect the single-item endpoints above
|
||||
* (:uuid) — those still enforce access normally for direct links, and
|
||||
* course-scoped consumption runs through a separate controller entirely.
|
||||
*
|
||||
* Author: Kenneth Obsequio (@lash0000)
|
||||
* Date Created: Jul. 7, 2026
|
||||
***********************************************************************************************************************************************************************/
|
||||
"use strict";
|
||||
|
||||
const R = require("../../utils/response.util");
|
||||
const logActivity = require("../../utils/logActivity.util");
|
||||
const sequelize = require("../../config/db.config");
|
||||
|
||||
const {
|
||||
Unit, Lesson,
|
||||
CourseUnit, UnitLesson,
|
||||
UnitQuiz, QuizQuestion, QuizOption, QuizAttempt, QuizSession,
|
||||
} = require("../../models/courses/courses.associations");
|
||||
|
||||
const coursesCtrl = require("./courses.controller"); // canAccessUnit / canAccessLesson / shared uuid handlers
|
||||
const { gradeSubmission } = require("../../utils/courses/grading.util");
|
||||
const { shuffleOptions, shuffleQuestions, getAttemptStatus } = require("../../utils/courses/quiz_security.util");
|
||||
const { recomputeCascade, recordWatchProgress, recordManualComplete } = require("../../services/completion_requirements.service");
|
||||
const { recordPlaybackPosition } = require("../../services/playback_position.service");
|
||||
|
||||
const notDeleted = { deletedAt: null };
|
||||
|
||||
// Strip correct-answer data (same policy as course-scoped quiz endpoints)
|
||||
function sanitizeQuestions(questions = []) {
|
||||
return questions.map((q) => {
|
||||
const plain = q.toJSON ? q.toJSON() : { ...q };
|
||||
if (plain.type === "multi_select") {
|
||||
plain.correct_count = (plain.options ?? []).filter((o) => o.is_correct).length;
|
||||
}
|
||||
plain.options = (plain.options ?? []).map(({ is_correct: _drop, ...o }) => o);
|
||||
delete plain.explanation;
|
||||
return plain;
|
||||
});
|
||||
}
|
||||
|
||||
// ─── UNIT LIBRARY (learner view) ──────────────────────────────────────────────
|
||||
|
||||
// Client-side Units/Lessons browsing shows ALL content, bound to a course or
|
||||
// not — course_count/courses[] + is_locked below tell the learner which is
|
||||
// which. This does not affect course-scoped consumption (which runs through
|
||||
// ClientCoursesContext/getCourse, a separate path) or direct-link access to
|
||||
// UnitDetails/LessonDetails, which still enforce access normally.
|
||||
exports.getUnits = async (req, res) => {
|
||||
try {
|
||||
const rows = await sequelize.query(`
|
||||
SELECT
|
||||
u.unit_id, u.uuid, u.title, u.subscription, u.description, u.duration_seconds,
|
||||
(SELECT CAST(COUNT(*) AS INTEGER) FROM unit_lessons ul
|
||||
JOIN lessons l ON l.lesson_id = ul.lesson_id AND l."deletedAt" IS NULL
|
||||
WHERE ul.unit_id = u.unit_id) AS lesson_count,
|
||||
(SELECT CAST(COUNT(*) AS INTEGER) FROM course_units cu
|
||||
JOIN courses c ON c.course_id = cu.course_id AND c."deletedAt" IS NULL AND c.status = 'published'
|
||||
WHERE cu.unit_id = u.unit_id) AS course_count,
|
||||
(SELECT quiz_id FROM unit_quizzes q
|
||||
WHERE q.unit_id = u.unit_id AND q."deletedAt" IS NULL LIMIT 1) AS quiz_id
|
||||
FROM units u
|
||||
WHERE u."deletedAt" IS NULL
|
||||
ORDER BY u.title ASC
|
||||
`, { type: sequelize.QueryTypes.SELECT });
|
||||
|
||||
// Batch-fetch attached courses for every returned unit in one query, so the
|
||||
// learner-facing upsell modal can say which course(s)/tier(s) unlock a unit
|
||||
// (a unit may sit under several courses at different tiers — no single "Buy").
|
||||
const unitIds = rows.map((r) => r.unit_id);
|
||||
const courseLinkRows = unitIds.length ? await sequelize.query(`
|
||||
SELECT cu.unit_id, c.course_id, c.uuid, c.title, c.subscription
|
||||
FROM course_units cu
|
||||
JOIN courses c ON c.course_id = cu.course_id AND c."deletedAt" IS NULL AND c.status = 'published'
|
||||
WHERE cu.unit_id IN (:unitIds)
|
||||
`, { replacements: { unitIds }, type: sequelize.QueryTypes.SELECT }) : [];
|
||||
|
||||
const coursesByUnit = new Map();
|
||||
for (const row of courseLinkRows) {
|
||||
const list = coursesByUnit.get(row.unit_id) ?? [];
|
||||
list.push({ course_id: row.course_id, uuid: row.uuid, title: row.title, subscription: row.subscription });
|
||||
coursesByUnit.set(row.unit_id, list);
|
||||
}
|
||||
|
||||
// is_locked mirrors canAccessUnit: a unit with its own subscription or at
|
||||
// least one attached course needs an access check; a fully open standalone
|
||||
// unit (no subscription, no course links) is never locked.
|
||||
const result = [];
|
||||
for (const row of rows) {
|
||||
const is_locked = (row.subscription || Number(row.course_count) > 0)
|
||||
? !(await coursesCtrl.canAccessUnit(req.user.user_id, row.unit_id))
|
||||
: false;
|
||||
result.push({
|
||||
...row,
|
||||
courses: coursesByUnit.get(row.unit_id) ?? [],
|
||||
is_locked,
|
||||
});
|
||||
}
|
||||
|
||||
return R.success(res, "Units retrieved.", result);
|
||||
} catch (err) {
|
||||
console.error("[CLIENT][UNITS][GET ALL]", err);
|
||||
return R.error(res, "Could not retrieve units.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── LESSON LIBRARY (learner view) ────────────────────────────────────────────
|
||||
// Mirrors getUnits above — a Lesson may sit in several Units (each possibly in
|
||||
// different courses), so is_locked/courses are resolved across ALL attached
|
||||
// units rather than a single direct course link.
|
||||
|
||||
exports.getLessons = async (req, res) => {
|
||||
try {
|
||||
const rows = await sequelize.query(`
|
||||
SELECT
|
||||
l.lesson_id, l.uuid, l.title, l.subscription, l.description, l.duration_seconds,
|
||||
(SELECT CAST(COUNT(*) AS INTEGER) FROM unit_lessons ul
|
||||
JOIN units u ON u.unit_id = ul.unit_id AND u."deletedAt" IS NULL
|
||||
WHERE ul.lesson_id = l.lesson_id) AS unit_count
|
||||
FROM lessons l
|
||||
WHERE l."deletedAt" IS NULL
|
||||
ORDER BY l.title ASC
|
||||
`, { type: sequelize.QueryTypes.SELECT });
|
||||
|
||||
// Batch-fetch every course reachable through any attached unit, for every
|
||||
// returned lesson, in one query — same batching style as getUnits.
|
||||
const lessonIds = rows.map((r) => r.lesson_id);
|
||||
const courseLinkRows = lessonIds.length ? await sequelize.query(`
|
||||
SELECT DISTINCT ul.lesson_id, c.course_id, c.uuid, c.title, c.subscription
|
||||
FROM unit_lessons ul
|
||||
JOIN course_units cu ON cu.unit_id = ul.unit_id
|
||||
JOIN courses c ON c.course_id = cu.course_id AND c."deletedAt" IS NULL AND c.status = 'published'
|
||||
WHERE ul.lesson_id IN (:lessonIds)
|
||||
`, { replacements: { lessonIds }, type: sequelize.QueryTypes.SELECT }) : [];
|
||||
|
||||
const coursesByLesson = new Map();
|
||||
for (const row of courseLinkRows) {
|
||||
const list = coursesByLesson.get(row.lesson_id) ?? [];
|
||||
list.push({ course_id: row.course_id, uuid: row.uuid, title: row.title, subscription: row.subscription });
|
||||
coursesByLesson.set(row.lesson_id, list);
|
||||
}
|
||||
|
||||
// is_locked mirrors canAccessLesson: a lesson with its own subscription or
|
||||
// at least one attached unit needs an access check; a fully open
|
||||
// standalone lesson (no subscription, no unit links) is never locked.
|
||||
const result = [];
|
||||
for (const row of rows) {
|
||||
const is_locked = (row.subscription || Number(row.unit_count) > 0)
|
||||
? !(await coursesCtrl.canAccessLesson(req.user.user_id, row.lesson_id))
|
||||
: false;
|
||||
result.push({
|
||||
...row,
|
||||
courses: coursesByLesson.get(row.lesson_id) ?? [],
|
||||
is_locked,
|
||||
});
|
||||
}
|
||||
|
||||
return R.success(res, "Lessons retrieved.", result);
|
||||
} catch (err) {
|
||||
console.error("[CLIENT][LESSONS][GET ALL]", err);
|
||||
return R.error(res, "Could not retrieve lessons.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── STANDALONE UNIT QUIZ ─────────────────────────────────────────────────────
|
||||
|
||||
exports.getUnitQuiz = async (req, res) => {
|
||||
try {
|
||||
const { uuid } = req.params;
|
||||
|
||||
const unit = await Unit.findOne({ where: { uuid, ...notDeleted } });
|
||||
if (!unit) return R.error(res, "Unit not found.", 404);
|
||||
|
||||
if (!await coursesCtrl.canAccessUnit(req.user.user_id, unit.unit_id)) {
|
||||
return R.error(res, "You do not have access to this unit.", 403);
|
||||
}
|
||||
|
||||
const quiz = await UnitQuiz.findOne({
|
||||
where: { unit_id: unit.unit_id, ...notDeleted },
|
||||
attributes: [
|
||||
"quiz_id", "uuid", "title",
|
||||
"is_required", "passing_score", "max_questions", "shuffle_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", "is_correct"],
|
||||
}],
|
||||
}],
|
||||
order: [[{ model: QuizQuestion, as: "questions" }, "order_index", "ASC"]],
|
||||
});
|
||||
|
||||
if (!quiz) return R.error(res, "Quiz not found.", 404);
|
||||
|
||||
const plain = quiz.toJSON();
|
||||
let qs = sanitizeQuestions(plain.questions ?? []);
|
||||
if (plain.shuffle_questions) qs = shuffleQuestions(qs);
|
||||
plain.questions = shuffleOptions(qs);
|
||||
|
||||
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, "quiz");
|
||||
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;
|
||||
|
||||
const activeSession = await QuizSession.findOne({
|
||||
where: { quiz_id: quiz.quiz_id, user_id: req.user.user_id, status: "in_progress" },
|
||||
attributes: ["session_id", "draft_answers", "started_at", "last_saved_at"],
|
||||
});
|
||||
plain.active_session = activeSession ?? null;
|
||||
|
||||
return R.success(res, "Quiz retrieved.", plain);
|
||||
} catch (err) {
|
||||
console.error("[CLIENT][UNITS][QUIZ][GET]", err);
|
||||
return R.error(res, "Could not retrieve quiz.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
exports.submitUnitQuiz = async (req, res) => {
|
||||
try {
|
||||
const { uuid, quizId } = req.params;
|
||||
const { answers = {} } = req.body;
|
||||
const user_id = req.user.user_id;
|
||||
|
||||
const unit = await Unit.findOne({ where: { uuid, ...notDeleted } });
|
||||
if (!unit) return R.error(res, "Unit not found.", 404);
|
||||
|
||||
if (!await coursesCtrl.canAccessUnit(user_id, unit.unit_id)) {
|
||||
return R.error(res, "You do not have access to this unit.", 403);
|
||||
}
|
||||
|
||||
const quiz = await UnitQuiz.findOne({
|
||||
where: { quiz_id: quizId, unit_id: unit.unit_id, ...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 { 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: null, // standalone — no course context
|
||||
attempt_number: priorAttempts.length + 1,
|
||||
answers,
|
||||
total_points: totalPoints,
|
||||
earned_points: earnedPoints,
|
||||
score,
|
||||
passing_score: quiz.passing_score ?? 70,
|
||||
passed,
|
||||
});
|
||||
|
||||
await QuizSession.update(
|
||||
{ status: "submitted" },
|
||||
{ where: { quiz_id: quiz.quiz_id, user_id, status: "in_progress" } }
|
||||
);
|
||||
|
||||
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,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[CLIENT][UNITS][QUIZ][SUBMIT]", err);
|
||||
return R.error(res, "Could not submit quiz.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── STANDALONE UNIT QUIZ DRAFT ───────────────────────────────────────────────
|
||||
// PATCH /client/units/:uuid/quiz/:quizId/draft — mirrors the course-scoped
|
||||
// saveQuizDraft in courses.controller.js; only quiz_id + user_id are needed to
|
||||
// locate the session, course_id/unit_id are just extra nullable columns on it.
|
||||
|
||||
exports.saveUnitQuizDraft = async (req, res) => {
|
||||
try {
|
||||
const { uuid, quizId } = req.params;
|
||||
const { answers = {} } = req.body;
|
||||
const user_id = req.user.user_id;
|
||||
|
||||
const unit = await Unit.findOne({ where: { uuid, ...notDeleted } });
|
||||
if (!unit) return R.error(res, "Unit not found.", 404);
|
||||
|
||||
const [updatedCount] = await QuizSession.update(
|
||||
{ draft_answers: answers, last_saved_at: new Date() },
|
||||
{ where: { quiz_id: quizId, user_id, status: "in_progress" } }
|
||||
);
|
||||
|
||||
if (updatedCount === 0) {
|
||||
await QuizSession.create({
|
||||
quiz_id: quizId,
|
||||
user_id,
|
||||
course_id: null,
|
||||
unit_id: unit.unit_id,
|
||||
draft_answers: answers,
|
||||
started_at: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(204).end();
|
||||
} catch (err) {
|
||||
console.error("[CLIENT][UNITS][QUIZ][DRAFT]", err);
|
||||
return R.error(res, "Could not save quiz draft.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── STANDALONE LESSON PROGRESS ───────────────────────────────────────────────
|
||||
// POST /client/lessons/:uuid/progress Body: { status, unit_uuid? }
|
||||
// Evaluated + written via completion_requirements.service#recomputeCascade with
|
||||
// courseId null, which persists to lesson_reading_progress/unit_reading_progress
|
||||
// (the tables that tolerate a null course_id) instead of course_reading_progress.
|
||||
// When unit_uuid is given (unit context, still no course) the parent unit is
|
||||
// re-evaluated + upserted too, against any configured CompletionRequirement rows.
|
||||
|
||||
exports.upsertStandaloneLessonProgress = async (req, res) => {
|
||||
try {
|
||||
const { uuid } = req.params;
|
||||
const userId = req.user.user_id;
|
||||
const status = req.body.status === "completed" ? "completed" : "in_progress";
|
||||
const unitUuid = req.body.unit_uuid ?? null;
|
||||
|
||||
const lesson = await Lesson.findOne({ where: { uuid, ...notDeleted }, attributes: ["lesson_id", "uuid"] });
|
||||
if (!lesson) return R.error(res, "Lesson not found.", 404);
|
||||
|
||||
if (!await coursesCtrl.canAccessLesson(userId, lesson.lesson_id)) {
|
||||
return R.error(res, "You do not have access to this lesson.", 403);
|
||||
}
|
||||
|
||||
let unitId = null;
|
||||
if (unitUuid) {
|
||||
const unit = await Unit.findOne({ where: { uuid: unitUuid, ...notDeleted }, attributes: ["unit_id"] });
|
||||
if (!unit) return R.error(res, "Unit not found.", 404);
|
||||
const link = await UnitLesson.findOne({ where: { unit_id: unit.unit_id, lesson_id: lesson.lesson_id } });
|
||||
if (!link) return R.error(res, "Lesson is not attached to this unit.", 404);
|
||||
unitId = unit.unit_id;
|
||||
}
|
||||
|
||||
const result = await recomputeCascade(userId, {
|
||||
courseId: null,
|
||||
unitId,
|
||||
unitUuid,
|
||||
lessonId: lesson.lesson_id,
|
||||
lessonUuid: lesson.uuid,
|
||||
lessonStatus: status,
|
||||
});
|
||||
|
||||
logActivity(userId, "lesson_read", {
|
||||
entityType: "lesson",
|
||||
entityId: lesson.lesson_id,
|
||||
details: { lesson_uuid: lesson.uuid, status, standalone: true },
|
||||
});
|
||||
|
||||
return R.success(res, "Progress updated.", result, 200);
|
||||
} catch (err) {
|
||||
console.error("[CLIENT][LESSONS][STANDALONE PROGRESS]", err);
|
||||
return R.error(res, "Could not update progress.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── STANDALONE WATCH PROGRESS ─────────────────────────────────────────────────
|
||||
// POST /client/lessons/:uuid/watch-progress Body: { percent, unit_uuid?, block_id?, block_type? }
|
||||
// block_id/block_type identify which block on the lesson's page sent this update — needed
|
||||
// to drive watch_video/listen_audio; omit them and only watch_percent (if configured) is touched.
|
||||
exports.upsertStandaloneWatchProgress = async (req, res) => {
|
||||
try {
|
||||
const { uuid } = req.params;
|
||||
const userId = req.user.user_id;
|
||||
const percent = Number(req.body.percent);
|
||||
if (!Number.isFinite(percent)) return R.error(res, "percent must be a number.", 400);
|
||||
const unitUuid = req.body.unit_uuid ?? null;
|
||||
const blockId = req.body.block_id ?? null;
|
||||
const blockType = req.body.block_type ?? null;
|
||||
|
||||
const lesson = await Lesson.findOne({ where: { uuid, ...notDeleted }, attributes: ["lesson_id", "uuid"] });
|
||||
if (!lesson) return R.error(res, "Lesson not found.", 404);
|
||||
if (!await coursesCtrl.canAccessLesson(userId, lesson.lesson_id)) {
|
||||
return R.error(res, "You do not have access to this lesson.", 403);
|
||||
}
|
||||
|
||||
let unitId = null;
|
||||
if (unitUuid) {
|
||||
const unit = await Unit.findOne({ where: { uuid: unitUuid, ...notDeleted }, attributes: ["unit_id"] });
|
||||
if (!unit) return R.error(res, "Unit not found.", 404);
|
||||
unitId = unit.unit_id;
|
||||
}
|
||||
|
||||
// Resume-position tracking is unconditional — every block gets it regardless of
|
||||
// whether a completion requirement is configured. recordWatchProgress, below, is
|
||||
// the anti-cheat-validated path and stays a no-op when nothing's configured.
|
||||
await recordPlaybackPosition(userId, { lessonId: lesson.lesson_id, blockId, percent });
|
||||
|
||||
const result = await recordWatchProgress(userId, {
|
||||
lessonId: lesson.lesson_id, lessonUuid: lesson.uuid,
|
||||
unitId, unitUuid,
|
||||
courseId: null, courseUuid: null,
|
||||
percent, blockId, blockType,
|
||||
});
|
||||
|
||||
return R.success(res, "Watch progress updated.", result, 200);
|
||||
} catch (err) {
|
||||
console.error("[CLIENT][LESSONS][STANDALONE WATCH PROGRESS]", err);
|
||||
return R.error(res, "Could not update watch progress.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── STANDALONE MARK COMPLETE ──────────────────────────────────────────────────
|
||||
// POST /client/lessons/:uuid/mark-complete Body: { unit_uuid? }
|
||||
exports.markStandaloneLessonComplete = async (req, res) => {
|
||||
try {
|
||||
const { uuid } = req.params;
|
||||
const userId = req.user.user_id;
|
||||
const unitUuid = req.body.unit_uuid ?? null;
|
||||
|
||||
const lesson = await Lesson.findOne({ where: { uuid, ...notDeleted }, attributes: ["lesson_id", "uuid"] });
|
||||
if (!lesson) return R.error(res, "Lesson not found.", 404);
|
||||
if (!await coursesCtrl.canAccessLesson(userId, lesson.lesson_id)) {
|
||||
return R.error(res, "You do not have access to this lesson.", 403);
|
||||
}
|
||||
|
||||
let unitId = null;
|
||||
if (unitUuid) {
|
||||
const unit = await Unit.findOne({ where: { uuid: unitUuid, ...notDeleted }, attributes: ["unit_id"] });
|
||||
if (!unit) return R.error(res, "Unit not found.", 404);
|
||||
unitId = unit.unit_id;
|
||||
}
|
||||
|
||||
const result = await recordManualComplete(userId, {
|
||||
entityType: "lesson", entityId: lesson.lesson_id,
|
||||
lessonUuid: lesson.uuid,
|
||||
unitId, unitUuid,
|
||||
courseId: null, courseUuid: null,
|
||||
});
|
||||
|
||||
return R.success(res, "Lesson marked complete.", result, 200);
|
||||
} catch (err) {
|
||||
console.error("[CLIENT][LESSONS][STANDALONE MARK COMPLETE]", err);
|
||||
return R.error(res, "Could not mark lesson complete.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Shared UUID handlers re-exported for the standalone routes ───────────────
|
||||
|
||||
exports.getUnitByUuid = coursesCtrl.getUnitByUuid;
|
||||
exports.getLessonsByUnitUuid = coursesCtrl.getLessonsByUnitUuid;
|
||||
exports.getLessonByUuid = coursesCtrl.getLessonByUuid;
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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, "File 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, `File type "${asset.file_type}" is not supported.`, 400);
|
||||
}
|
||||
|
||||
if (asset.storage_provider !== "s3") {
|
||||
return R.error(res, "Token flow is for S3 files 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);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,267 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name: groups.ctrl.js (staff)
|
||||
* Type of Program: Controller
|
||||
* Description: Staff-scoped group endpoints.
|
||||
* Returns only the groups the logged-in staff member belongs to,
|
||||
* along with their members and associated task lists.
|
||||
***********************************************************************************************************************************************************************/
|
||||
const { Op } = require('sequelize');
|
||||
const mdl_Users = require('../../models/users/users.mdl');
|
||||
const { mdl_UserGroups, mdl_UserGroupMembers } = require('../../models/users/user_groups.mdl');
|
||||
const { TaskList, Task, TaskRequirement, TaskListGroup } = require('../../models/task/task.mdl');
|
||||
const { getFieldValues } = require("../../utils/fieldValues.util");
|
||||
const { paginate } = require('../../utils/paginate.util');
|
||||
const R = require('../../utils/response.util');
|
||||
const { excludeAttributes: usersExclude, jsonbSchemas: usersSchemas, computedAttributes: userComputed } = require('../../models/users/users.attributes');
|
||||
|
||||
/**
|
||||
* GET /api/staff/groups
|
||||
* Returns all groups the staff member belongs to,
|
||||
* with member count and associated task lists.
|
||||
*/
|
||||
const getMyGroups = async (req, res) => {
|
||||
try {
|
||||
const staffUserId = req.user.user_id;
|
||||
|
||||
const groups = await mdl_UserGroups.findAll({
|
||||
include: [
|
||||
{
|
||||
model: mdl_UserGroupMembers,
|
||||
required: true, // INNER JOIN — only groups the staff member is in
|
||||
where: { user_id: staffUserId },
|
||||
attributes: [],
|
||||
},
|
||||
{
|
||||
model: mdl_Users,
|
||||
as: 'members',
|
||||
attributes: ['user_id', 'email', 'personal_info', 'is_active'],
|
||||
through: { attributes: ['joined_at'] },
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
model: TaskList,
|
||||
as: 'taskLists',
|
||||
attributes: ['task_list_id', 'name', 'description', 'createdAt'],
|
||||
through: { attributes: ['assignedAt'] },
|
||||
required: false,
|
||||
include: [
|
||||
{
|
||||
model: Task,
|
||||
as: 'tasks',
|
||||
attributes: ['task_id', 'name', 'deadline', 'status'],
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
where: { is_active: true },
|
||||
order: [['name', 'ASC']],
|
||||
});
|
||||
|
||||
// Add member_count computed field
|
||||
const data = groups.map(g => ({
|
||||
...g.toJSON(),
|
||||
member_count: g.members?.length ?? 0,
|
||||
}));
|
||||
|
||||
return res.status(200).json({ success: true, data });
|
||||
} catch (err) {
|
||||
console.error('[staff/groups] getMyGroups error:', err);
|
||||
return res.status(500).json({ success: false, message: 'Internal server error.' });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* GET /api/staff/groups/:group_id
|
||||
* Returns a single group's full details — members + task lists.
|
||||
* Staff must be a member of this group.
|
||||
*/
|
||||
const getGroupById = async (req, res) => {
|
||||
try {
|
||||
const staffUserId = req.user.user_id;
|
||||
const groupId = parseInt(req.params.group_id);
|
||||
|
||||
// Verify staff membership
|
||||
const membership = await mdl_UserGroupMembers.findOne({
|
||||
where: { user_id: staffUserId, group_id: groupId },
|
||||
});
|
||||
if (!membership) {
|
||||
return res.status(403).json({ success: false, message: 'You are not a member of this group.' });
|
||||
}
|
||||
|
||||
const group = await mdl_UserGroups.findOne({
|
||||
where: { group_id: groupId, is_active: true },
|
||||
include: [
|
||||
{
|
||||
model: mdl_Users,
|
||||
as: 'members',
|
||||
attributes: ['user_id', 'email', 'personal_info', 'is_active', 'acc_type'],
|
||||
through: { attributes: ['joined_at'] },
|
||||
},
|
||||
{
|
||||
model: TaskList,
|
||||
as: 'taskLists',
|
||||
attributes: ['task_list_id', 'name', 'description', 'createdAt'],
|
||||
through: { attributes: ['assignedAt'] },
|
||||
include: [
|
||||
{
|
||||
model: Task,
|
||||
as: 'tasks',
|
||||
attributes: ['task_id', 'name', 'description', 'deadline', 'status'],
|
||||
include: [
|
||||
{
|
||||
model: TaskRequirement,
|
||||
as: 'requirements',
|
||||
attributes: ['requirement_id', 'type', 'link_url', 'link_label', 'reference_id', 'reference_label', 'order'],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (!group) return res.status(404).json({ success: false, message: 'Group not found.' });
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
data: { ...group.toJSON(), member_count: group.members?.length ?? 0 },
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[staff/groups] getGroupById error:', err);
|
||||
return res.status(500).json({ success: false, message: 'Internal server error.' });
|
||||
}
|
||||
};
|
||||
|
||||
// GET /staff/groups/:group_id/members
|
||||
// Paginated, searchable, filterable — mirrors getUsers pattern
|
||||
const getGroupMembers = async (req, res) => {
|
||||
try {
|
||||
const staffUserId = req.user.user_id;
|
||||
const groupId = parseInt(req.params.group_id);
|
||||
|
||||
// Verify staff is a member of this group
|
||||
const membership = await mdl_UserGroupMembers.findOne({
|
||||
where: { user_id: staffUserId, group_id: groupId },
|
||||
});
|
||||
if (!membership) {
|
||||
return res.status(403).json({ success: false, message: 'You are not a member of this group.' });
|
||||
}
|
||||
|
||||
// Confirm group exists
|
||||
const groupExists = await mdl_UserGroups.findOne({
|
||||
where: { group_id: groupId, is_active: true },
|
||||
attributes: ['group_id'],
|
||||
});
|
||||
if (!groupExists) {
|
||||
return res.status(404).json({ success: false, message: 'Group not found.' });
|
||||
}
|
||||
|
||||
const result = await paginate(mdl_Users, req, {
|
||||
excludeAttributes: usersExclude,
|
||||
jsonbSchemas: usersSchemas,
|
||||
jsonbColumn: 'personal_info',
|
||||
computedAttributes: userComputed,
|
||||
context: "list",
|
||||
findOptions: {
|
||||
include: [
|
||||
{
|
||||
model: mdl_UserGroups,
|
||||
as: 'groups',
|
||||
through: { attributes: [] },
|
||||
attributes: ['group_id', 'name', 'group_code'],
|
||||
required: true, // INNER JOIN — only users in this group
|
||||
where: { group_id: groupId },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
return R.success(res, 'Group members retrieved.', result);
|
||||
} catch (err) {
|
||||
console.error('[staff/groups] getGroupMembers error:', err);
|
||||
return R.error(res, 'Could not retrieve group members.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* GET /api/staff/groups/by-code/:group_code
|
||||
* Convenience endpoint — look up a group by its invite code.
|
||||
* Staff must still be a member.
|
||||
*/
|
||||
const getGroupByCode = async (req, res) => {
|
||||
try {
|
||||
const staffUserId = req.user.user_id;
|
||||
const groupCode = req.params.group_code.toUpperCase().trim();
|
||||
|
||||
const group = await mdl_UserGroups.findOne({
|
||||
where: { group_code: groupCode, is_active: true },
|
||||
});
|
||||
|
||||
if (!group) return res.status(404).json({ success: false, message: 'Group not found.' });
|
||||
|
||||
// Verify staff membership
|
||||
const membership = await mdl_UserGroupMembers.findOne({
|
||||
where: { user_id: staffUserId, group_id: group.group_id },
|
||||
});
|
||||
if (!membership) {
|
||||
return res.status(403).json({ success: false, message: 'You are not a member of this group.' });
|
||||
}
|
||||
|
||||
// Reuse getGroupById logic by patching req.params
|
||||
req.params.group_id = group.group_id;
|
||||
return getGroupById(req, res);
|
||||
} catch (err) {
|
||||
console.error('[staff/groups] getGroupByCode error:', err);
|
||||
return res.status(500).json({ success: false, message: 'Internal server error.' });
|
||||
}
|
||||
};
|
||||
|
||||
// ─── GET /staff/groups/:group_id/members/field-values ────────────────────────
|
||||
const getGroupMemberFieldValues = async (req, res) => {
|
||||
try {
|
||||
const staffUserId = req.user.user_id;
|
||||
const groupId = parseInt(req.params.group_id);
|
||||
|
||||
const membership = await mdl_UserGroupMembers.findOne({
|
||||
where: { user_id: staffUserId, group_id: groupId },
|
||||
});
|
||||
if (!membership) {
|
||||
return res.status(403).json({ success: false, message: "You are not a member of this group." });
|
||||
}
|
||||
|
||||
const groupExists = await mdl_UserGroups.findOne({
|
||||
where: { group_id: groupId, is_active: true },
|
||||
attributes: ["group_id"],
|
||||
});
|
||||
if (!groupExists) {
|
||||
return res.status(404).json({ success: false, message: "Group not found." });
|
||||
}
|
||||
|
||||
return getFieldValues(mdl_Users, "USER", {
|
||||
blockedFields: ["password", "otp_code", "otp_expires_at", "personal_info"],
|
||||
extraDateFields: ["modifiedAt"],
|
||||
allowJsonb: true,
|
||||
selfJoin: true,
|
||||
// Scope distinct values to this group only
|
||||
baseWhere: {
|
||||
"$groups.group_id$": groupId,
|
||||
},
|
||||
associations: {
|
||||
groups: {
|
||||
model: mdl_UserGroups,
|
||||
labelField: "name",
|
||||
valueField: "name",
|
||||
// INNER JOIN so values are scoped to group members only
|
||||
required: true,
|
||||
where: { group_id: groupId },
|
||||
},
|
||||
},
|
||||
})(req, res);
|
||||
} catch (err) {
|
||||
console.error("[staff/groups] getGroupMemberFieldValues error:", err);
|
||||
return R.error(res, "Could not retrieve field values.", 500);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { getMyGroups, getGroupById, getGroupByCode, getGroupMembers, getGroupMemberFieldValues };
|
||||
@@ -0,0 +1,263 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name: scores.ctrl.js (staff)
|
||||
* Type of Program: Controller
|
||||
* Description: Staff-scoped scores and progress endpoints.
|
||||
* Staff can view:
|
||||
* - Task completion per user per task list
|
||||
* - Quiz attempt scores (UnitQuiz)
|
||||
* - Assessment attempt scores (CourseAssessment)
|
||||
* - A combined progress summary per group/task list
|
||||
***********************************************************************************************************************************************************************/
|
||||
const { Op } = require('sequelize');
|
||||
const sequelize = require('../../config/db.config');
|
||||
const mdl_Users = require('../../models/users/users.mdl');
|
||||
const { mdl_UserGroupMembers } = require('../../models/users/user_groups.mdl');
|
||||
const { Task, TaskList, TaskListGroup } = require('../../models/task/task.mdl');
|
||||
const TaskCompletion = require('../../models/scores/task_completions.mdl');
|
||||
const QuizAttempt = require('../../models/scores/quiz_attempts.mdl');
|
||||
const AssessmentAttempt = require('../../models/scores/assessment_attempts.mdl');
|
||||
const UnitQuiz = require('../../models/courses/unit_quiz.mdl');
|
||||
const CourseAssessment = require('../../models/courses/course_assessment.mdl');
|
||||
const { Unit } = require('../../models/courses/units.mdl');
|
||||
const { Course } = require('../../models/courses/courses.mdl');
|
||||
|
||||
// ─── Helper ───────────────────────────────────────────────────────────────────
|
||||
async function getStaffGroupIds(staffUserId) {
|
||||
const memberships = await mdl_UserGroupMembers.findAll({
|
||||
where: { user_id: staffUserId },
|
||||
attributes: ['group_id'],
|
||||
raw: true,
|
||||
});
|
||||
return memberships.map(m => m.group_id);
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════════
|
||||
// TASK COMPLETION TRACKING
|
||||
// ════════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* GET /api/staff/progress/task-list/:task_list_id
|
||||
* Returns completion status for ALL members in the groups attached to this task list.
|
||||
* Response shape: { members: [{ user, tasks: [{ task, completion }] }] }
|
||||
*/
|
||||
const getTaskListProgress = async (req, res) => {
|
||||
try {
|
||||
const staffUserId = req.user.user_id;
|
||||
const { task_list_id } = req.params;
|
||||
|
||||
const scopedGroupIds = await getStaffGroupIds(staffUserId);
|
||||
|
||||
// Verify staff has access to this task list
|
||||
const access = await TaskListGroup.findOne({
|
||||
where: { task_list_id, group_id: { [Op.in]: scopedGroupIds } },
|
||||
});
|
||||
if (!access) return res.status(403).json({ success: false, message: 'No access to this task list.' });
|
||||
|
||||
// Get all groups linked to this task list (within staff's scope)
|
||||
const linkedGroups = await TaskListGroup.findAll({
|
||||
where: { task_list_id, group_id: { [Op.in]: scopedGroupIds } },
|
||||
attributes: ['group_id'],
|
||||
raw: true,
|
||||
});
|
||||
const linkedGroupIds = linkedGroups.map(g => g.group_id);
|
||||
|
||||
// Get all unique members in those groups
|
||||
const memberships = await mdl_UserGroupMembers.findAll({
|
||||
where: { group_id: { [Op.in]: linkedGroupIds } },
|
||||
attributes: ['user_id'],
|
||||
raw: true,
|
||||
});
|
||||
const memberIds = [...new Set(memberships.map(m => m.user_id))];
|
||||
|
||||
// Get all tasks in the task list
|
||||
const tasks = await Task.findAll({ where: { task_list_id }, attributes: ['task_id', 'name', 'deadline', 'status'] });
|
||||
|
||||
// Get all completions for these tasks + members
|
||||
const completions = await TaskCompletion.findAll({
|
||||
where: {
|
||||
task_id: { [Op.in]: tasks.map(t => t.task_id) },
|
||||
user_id: { [Op.in]: memberIds },
|
||||
},
|
||||
attributes: ['task_id', 'user_id', 'status', 'completed_at'],
|
||||
raw: true,
|
||||
});
|
||||
|
||||
// Index completions: { user_id: { task_id: completion } }
|
||||
const completionIndex = {};
|
||||
completions.forEach(c => {
|
||||
if (!completionIndex[c.user_id]) completionIndex[c.user_id] = {};
|
||||
completionIndex[c.user_id][c.task_id] = c;
|
||||
});
|
||||
|
||||
// Fetch user info
|
||||
const users = await mdl_Users.findAll({
|
||||
where: { user_id: { [Op.in]: memberIds } },
|
||||
attributes: ['user_id', 'email', 'personal_info'],
|
||||
});
|
||||
|
||||
// Build response
|
||||
const data = users.map(u => ({
|
||||
user: u,
|
||||
tasks: tasks.map(t => ({
|
||||
task: t,
|
||||
completion: completionIndex[u.user_id]?.[t.task_id] ?? { status: 'pending', completed_at: null },
|
||||
})),
|
||||
completed_count: tasks.filter(t => completionIndex[u.user_id]?.[t.task_id]?.status === 'completed').length,
|
||||
total_tasks: tasks.length,
|
||||
}));
|
||||
|
||||
return res.status(200).json({ success: true, task_list_id, data });
|
||||
} catch (err) {
|
||||
console.error('[staff/scores] getTaskListProgress error:', err);
|
||||
return res.status(500).json({ success: false, message: 'Internal server error.' });
|
||||
}
|
||||
};
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════════
|
||||
// QUIZ SCORES
|
||||
// ════════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* GET /api/staff/scores/quiz/:quiz_id
|
||||
* Returns all users' quiz attempts for a given UnitQuiz.
|
||||
* Shows latest attempt + best score per user.
|
||||
*/
|
||||
const getQuizScores = async (req, res) => {
|
||||
try {
|
||||
const staffUserId = req.user.user_id;
|
||||
const quizId = parseInt(req.params.quiz_id);
|
||||
|
||||
const scopedGroupIds = await getStaffGroupIds(staffUserId);
|
||||
|
||||
// Get all members in the staff's groups
|
||||
const memberships = await mdl_UserGroupMembers.findAll({
|
||||
where: { group_id: { [Op.in]: scopedGroupIds } },
|
||||
attributes: ['user_id'],
|
||||
raw: true,
|
||||
});
|
||||
const memberIds = [...new Set(memberships.map(m => m.user_id))];
|
||||
|
||||
const quiz = await UnitQuiz.findByPk(quizId, {
|
||||
include: [{ model: Unit, as: 'unit', attributes: ['unit_id', 'title'] }],
|
||||
});
|
||||
if (!quiz) return res.status(404).json({ success: false, message: 'Quiz not found.' });
|
||||
|
||||
// All attempts by members
|
||||
const attempts = await QuizAttempt.findAll({
|
||||
where: { quiz_id: quizId, user_id: { [Op.in]: memberIds } },
|
||||
include: [
|
||||
{ model: mdl_Users, as: 'user', attributes: ['user_id', 'email', 'personal_info'] },
|
||||
],
|
||||
order: [['user_id', 'ASC'], ['attempt_number', 'DESC']],
|
||||
});
|
||||
|
||||
// Group by user, extract best + latest
|
||||
const byUser = {};
|
||||
attempts.forEach(a => {
|
||||
const uid = a.user_id;
|
||||
if (!byUser[uid]) byUser[uid] = { user: a.user, attempts: [], best_score: null, latest: null };
|
||||
byUser[uid].attempts.push(a);
|
||||
if (byUser[uid].best_score === null || a.score > byUser[uid].best_score) {
|
||||
byUser[uid].best_score = a.score;
|
||||
}
|
||||
if (!byUser[uid].latest || a.attempt_number > byUser[uid].latest.attempt_number) {
|
||||
byUser[uid].latest = a;
|
||||
}
|
||||
});
|
||||
|
||||
// Include members who haven't attempted yet
|
||||
const attemptedIds = new Set(Object.keys(byUser).map(Number));
|
||||
const nonAttempted = memberIds.filter(id => !attemptedIds.has(id));
|
||||
const nonAttemptedUsers = await mdl_Users.findAll({
|
||||
where: { user_id: { [Op.in]: nonAttempted } },
|
||||
attributes: ['user_id', 'email', 'personal_info'],
|
||||
});
|
||||
nonAttemptedUsers.forEach(u => {
|
||||
byUser[u.user_id] = { user: u, attempts: [], best_score: null, latest: null };
|
||||
});
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
quiz: { quiz_id: quiz.quiz_id, title: quiz.title, passing_score: quiz.passing_score, unit: quiz.unit },
|
||||
data: Object.values(byUser),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[staff/scores] getQuizScores error:', err);
|
||||
return res.status(500).json({ success: false, message: 'Internal server error.' });
|
||||
}
|
||||
};
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════════
|
||||
// ASSESSMENT SCORES
|
||||
// ════════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* GET /api/staff/scores/assessment/:assessment_id
|
||||
* Returns all users' assessment attempts for a CourseAssessment.
|
||||
*/
|
||||
const getAssessmentScores = async (req, res) => {
|
||||
try {
|
||||
const staffUserId = req.user.user_id;
|
||||
const assessmentId = parseInt(req.params.assessment_id);
|
||||
|
||||
const scopedGroupIds = await getStaffGroupIds(staffUserId);
|
||||
const memberships = await mdl_UserGroupMembers.findAll({
|
||||
where: { group_id: { [Op.in]: scopedGroupIds } },
|
||||
attributes: ['user_id'],
|
||||
raw: true,
|
||||
});
|
||||
const memberIds = [...new Set(memberships.map(m => m.user_id))];
|
||||
|
||||
const assessment = await CourseAssessment.findByPk(assessmentId, {
|
||||
include: [{ model: Course, as: 'course', attributes: ['title', 'course_code'] }],
|
||||
});
|
||||
if (!assessment) return res.status(404).json({ success: false, message: 'Assessment not found.' });
|
||||
|
||||
const attempts = await AssessmentAttempt.findAll({
|
||||
where: { assessment_id: assessmentId, user_id: { [Op.in]: memberIds } },
|
||||
include: [
|
||||
{ model: mdl_Users, as: 'user', attributes: ['user_id', 'email', 'personal_info'] },
|
||||
],
|
||||
order: [['user_id', 'ASC'], ['attempt_number', 'DESC']],
|
||||
});
|
||||
|
||||
const byUser = {};
|
||||
attempts.forEach(a => {
|
||||
const uid = a.user_id;
|
||||
if (!byUser[uid]) byUser[uid] = { user: a.user, attempts: [], best_score: null, latest: null };
|
||||
byUser[uid].attempts.push(a);
|
||||
if (byUser[uid].best_score === null || a.score > byUser[uid].best_score) byUser[uid].best_score = a.score;
|
||||
if (!byUser[uid].latest || a.attempt_number > byUser[uid].latest.attempt_number) byUser[uid].latest = a;
|
||||
});
|
||||
|
||||
const attemptedIds = new Set(Object.keys(byUser).map(Number));
|
||||
const nonAttempted = memberIds.filter(id => !attemptedIds.has(id));
|
||||
const nonAttemptedUsers = await mdl_Users.findAll({
|
||||
where: { user_id: { [Op.in]: nonAttempted } },
|
||||
attributes: ['user_id', 'email', 'personal_info'],
|
||||
});
|
||||
nonAttemptedUsers.forEach(u => {
|
||||
byUser[u.user_id] = { user: u, attempts: [], best_score: null, latest: null };
|
||||
});
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
assessment: {
|
||||
assessment_id: assessment.assessment_id,
|
||||
title: assessment.title,
|
||||
passing_score: assessment.passing_score,
|
||||
course: assessment.course,
|
||||
},
|
||||
data: Object.values(byUser),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[staff/scores] getAssessmentScores error:', err);
|
||||
return res.status(500).json({ success: false, message: 'Internal server error.' });
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
getTaskListProgress,
|
||||
getQuizScores,
|
||||
getAssessmentScores,
|
||||
};
|
||||
@@ -0,0 +1,813 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name: tasks.ctrl.js (staff)
|
||||
* Type of Program: Controller
|
||||
* Description: Staff-level task management — Task Lists and Tasks scoped to the
|
||||
* staff member's groups. Aligned with admin task.controller.js:
|
||||
* uses transactions, archiveOne/archiveMany, restoreOne/restoreMany,
|
||||
* paginate, and consistent R response helpers throughout.
|
||||
*
|
||||
* Author: Kenneth Obsequio (@lash0000)
|
||||
* Date Created: May 17, 2026
|
||||
***********************************************************************************************************************************************************************/
|
||||
const { Op } = require('sequelize');
|
||||
const sequelize = require('../../config/db.config');
|
||||
|
||||
const { Task, TaskList, TaskRequirement, TaskListGroup } = require('../../models/task/task.mdl');
|
||||
const { mdl_UserGroups, mdl_UserGroupMembers } = require('../../models/users/user_groups.mdl');
|
||||
const mdl_Users = require('../../models/users/users.mdl');
|
||||
const { staffExclude, jsonbSchemas, computedAttributes } = require('../../models/task/task.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 { getFieldValues } = require('../../utils/fieldValues.util');
|
||||
|
||||
// ─── Allowed filter/sort fields ───────────────────────────────────────────────
|
||||
const TASK_LIST_FIELDS = ['name', 'description', 'createdAt', 'updatedAt', 'deletedAt'];
|
||||
const TASK_FIELDS = ['name', 'description', 'deadline', 'status', 'createdAt', 'updatedAt', 'deletedAt'];
|
||||
|
||||
// ─── Reusable group include ───────────────────────────────────────────────────
|
||||
const GROUP_INCLUDE = {
|
||||
model: mdl_UserGroups,
|
||||
as: 'groups',
|
||||
attributes: ['group_id', 'name', 'group_code'],
|
||||
through: {
|
||||
model: TaskListGroup,
|
||||
as: 'assignment',
|
||||
attributes: ['assignedAt'],
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Helper: get group IDs scoped to this staff member ───────────────────────
|
||||
async function getStaffGroupIds(staffUserId) {
|
||||
const memberships = await mdl_UserGroupMembers.findAll({
|
||||
where: { user_id: staffUserId },
|
||||
attributes: ['group_id'],
|
||||
raw: true,
|
||||
});
|
||||
return memberships.map((m) => m.group_id);
|
||||
}
|
||||
|
||||
// ─── Helper: assert staff has access to at least one group of a task list ────
|
||||
async function assertTaskListAccess(staffUserId, taskListId, t) {
|
||||
const scopedGroupIds = await getStaffGroupIds(staffUserId);
|
||||
if (!scopedGroupIds.length) return false;
|
||||
const access = await TaskListGroup.findOne({
|
||||
where: { task_list_id: taskListId, group_id: { [Op.in]: scopedGroupIds } },
|
||||
transaction: t,
|
||||
});
|
||||
return !!access;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ── TASK LISTS ────────────────────────────────────────────────────────────────
|
||||
// =============================================================================
|
||||
|
||||
// ─── GET ALL ──────────────────────────────────────────────────────────────────
|
||||
|
||||
exports.getTaskLists = async (req, res) => {
|
||||
try {
|
||||
const staffUserId = req.user.user_id;
|
||||
const { group_id } = req.query;
|
||||
|
||||
let scopedGroupIds = await getStaffGroupIds(staffUserId);
|
||||
if (!scopedGroupIds.length)
|
||||
return R.success(res, 'Task lists retrieved.', { rows: [], count: 0 });
|
||||
|
||||
if (group_id) {
|
||||
const gid = Number(group_id);
|
||||
if (!scopedGroupIds.map(Number).includes(gid))
|
||||
return R.error(res, 'No access to this group.', 403);
|
||||
scopedGroupIds = [gid];
|
||||
}
|
||||
|
||||
// Find task list IDs accessible to this staff member
|
||||
const assignments = await TaskListGroup.findAll({
|
||||
where: { group_id: { [Op.in]: scopedGroupIds } },
|
||||
attributes: ['task_list_id'],
|
||||
raw: true,
|
||||
});
|
||||
const accessibleIds = [...new Set(assignments.map((a) => a.task_list_id))];
|
||||
|
||||
const result = await paginate(TaskList, req, {
|
||||
excludeAttributes: staffExclude,
|
||||
jsonbSchemas,
|
||||
computedAttributes,
|
||||
auditOptions: { mdl_Users, parentAlias: 'TaskList' },
|
||||
allowedFields: TASK_LIST_FIELDS,
|
||||
findOptions: {
|
||||
distinct: true, // ← count parent rows, not JOIN rows
|
||||
col: 'task_list_id', // ← count on PK, not inflated join tuples
|
||||
where: { task_list_id: { [Op.in]: accessibleIds } },
|
||||
include: [GROUP_INCLUDE],
|
||||
},
|
||||
});
|
||||
|
||||
return R.success(res, 'Task lists retrieved.', result);
|
||||
} catch (err) {
|
||||
console.error('[STAFF][GET ALL TASK LISTS]', err);
|
||||
return R.error(res, 'Could not retrieve task lists.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── GET ONE ──────────────────────────────────────────────────────────────────
|
||||
|
||||
exports.getTaskList = async (req, res) => {
|
||||
try {
|
||||
const { taskListId } = req.params;
|
||||
const staffUserId = req.user.user_id;
|
||||
|
||||
if (!(await assertTaskListAccess(staffUserId, taskListId)))
|
||||
return R.error(res, 'No access to this task list.', 403);
|
||||
|
||||
const taskList = await TaskList.findByPk(taskListId, {
|
||||
attributes: { exclude: staffExclude },
|
||||
paranoid: false,
|
||||
include: [
|
||||
{
|
||||
model: Task, as: 'tasks', paranoid: false,
|
||||
include: [{
|
||||
model: TaskRequirement,
|
||||
as: 'requirements',
|
||||
paranoid: false,
|
||||
attributes: { exclude: staffExclude },
|
||||
order: [['order', 'ASC']],
|
||||
}],
|
||||
},
|
||||
GROUP_INCLUDE,
|
||||
],
|
||||
});
|
||||
|
||||
if (!taskList) return R.error(res, 'Task list not found.', 404);
|
||||
|
||||
const data = taskList.toJSON();
|
||||
data.group_count = data.groups?.length ?? 0;
|
||||
|
||||
return R.success(res, 'Task list retrieved.', data);
|
||||
} catch (err) {
|
||||
console.error('[STAFF][GET TASK LIST]', err);
|
||||
return R.error(res, 'Could not retrieve task list.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── CREATE ───────────────────────────────────────────────────────────────────
|
||||
|
||||
exports.createTaskList = async (req, res) => {
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const staffUserId = req.user.user_id;
|
||||
const { name, description, group_ids } = req.body;
|
||||
|
||||
if (!name) { await t.rollback(); return R.error(res, 'Name is required.', 400); }
|
||||
if (!Array.isArray(group_ids) || !group_ids.length) {
|
||||
await t.rollback();
|
||||
return R.error(res, 'group_ids is required.', 400);
|
||||
}
|
||||
|
||||
// Verify staff has access to all requested groups
|
||||
const scopedGroupIds = (await getStaffGroupIds(staffUserId)).map(String);
|
||||
const normalizedIds = group_ids.map(String);
|
||||
const unauthorized = normalizedIds.filter((id) => !scopedGroupIds.includes(id));
|
||||
if (unauthorized.length) {
|
||||
await t.rollback();
|
||||
return R.error(res, `No access to group(s): ${unauthorized.join(', ')}`, 403);
|
||||
}
|
||||
|
||||
const taskList = await TaskList.create(
|
||||
{ name, description, createdBy: staffUserId, updatedBy: staffUserId },
|
||||
{ transaction: t }
|
||||
);
|
||||
|
||||
await TaskListGroup.bulkCreate(
|
||||
normalizedIds.map((gid) => ({
|
||||
task_list_id: taskList.task_list_id,
|
||||
group_id: gid,
|
||||
assignedAt: new Date(),
|
||||
assignedBy: staffUserId,
|
||||
})),
|
||||
{ transaction: t }
|
||||
);
|
||||
|
||||
await t.commit();
|
||||
|
||||
const full = await TaskList.findByPk(taskList.task_list_id, {
|
||||
attributes: { exclude: staffExclude },
|
||||
include: [GROUP_INCLUDE],
|
||||
});
|
||||
|
||||
return R.success(res, 'Task list created successfully.', full, 201);
|
||||
} catch (err) {
|
||||
await t.rollback();
|
||||
console.error('[STAFF][CREATE TASK LIST]', err);
|
||||
return R.error(res, 'Could not create task list.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── UPDATE ───────────────────────────────────────────────────────────────────
|
||||
|
||||
exports.updateTaskList = async (req, res) => {
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const { taskListId } = req.params;
|
||||
const staffUserId = req.user.user_id;
|
||||
|
||||
if (!(await assertTaskListAccess(staffUserId, taskListId, t))) {
|
||||
await t.rollback();
|
||||
return R.error(res, 'No access to this task list.', 403);
|
||||
}
|
||||
|
||||
const taskList = await TaskList.findByPk(taskListId, { transaction: t });
|
||||
if (!taskList) { await t.rollback(); return R.error(res, 'Task list not found.', 404); }
|
||||
|
||||
const { name, description } = req.body;
|
||||
await taskList.update({ name, description, updatedBy: staffUserId }, { transaction: t });
|
||||
|
||||
await t.commit();
|
||||
return R.success(res, 'Task list updated successfully.', taskList);
|
||||
} catch (err) {
|
||||
await t.rollback();
|
||||
console.error('[STAFF][UPDATE TASK LIST]', err);
|
||||
return R.error(res, 'Could not update task list.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── ARCHIVE (soft-delete) ────────────────────────────────────────────────────
|
||||
|
||||
exports.archiveTaskList = async (req, res) => {
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const { taskListId } = req.params;
|
||||
const staffUserId = req.user.user_id;
|
||||
|
||||
if (!(await assertTaskListAccess(staffUserId, taskListId, t))) {
|
||||
await t.rollback();
|
||||
return R.error(res, 'No access to this task list.', 403);
|
||||
}
|
||||
|
||||
const record = await archiveOne(TaskList, { task_list_id: taskListId }, staffUserId, t);
|
||||
if (!record) { await t.rollback(); return R.error(res, 'Task list not found.', 404); }
|
||||
|
||||
await t.commit();
|
||||
return R.success(res, 'Task list archived successfully.');
|
||||
} catch (err) {
|
||||
await t.rollback();
|
||||
console.error('[STAFF][ARCHIVE TASK LIST]', err);
|
||||
return R.error(res, 'Could not archive task list.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── RESTORE ──────────────────────────────────────────────────────────────────
|
||||
|
||||
exports.restoreTaskList = async (req, res) => {
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const { taskListId } = req.params;
|
||||
const staffUserId = req.user.user_id;
|
||||
|
||||
if (!(await assertTaskListAccess(staffUserId, taskListId, t))) {
|
||||
await t.rollback();
|
||||
return R.error(res, 'No access to this task list.', 403);
|
||||
}
|
||||
|
||||
const record = await restoreOne(
|
||||
TaskList,
|
||||
{ task_list_id: taskListId, deletedAt: { [Op.not]: null } },
|
||||
staffUserId,
|
||||
t
|
||||
);
|
||||
if (!record) { await t.rollback(); return R.error(res, 'Task list not found or not archived.', 404); }
|
||||
|
||||
await t.commit();
|
||||
return R.success(res, 'Task list restored successfully.', record);
|
||||
} catch (err) {
|
||||
await t.rollback();
|
||||
console.error('[STAFF][RESTORE TASK LIST]', err);
|
||||
return R.error(res, 'Could not restore task list.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── BULK ARCHIVE ─────────────────────────────────────────────────────────────
|
||||
|
||||
exports.bulkArchiveTaskLists = async (req, res) => {
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const staffUserId = req.user.user_id;
|
||||
const { ids } = req.body;
|
||||
|
||||
if (!Array.isArray(ids) || !ids.length)
|
||||
return R.error(res, 'No task list IDs provided.', 400);
|
||||
|
||||
// Scope: only IDs the staff can access
|
||||
const scopedGroupIds = await getStaffGroupIds(staffUserId);
|
||||
const assignments = await TaskListGroup.findAll({
|
||||
where: { task_list_id: { [Op.in]: ids }, group_id: { [Op.in]: scopedGroupIds } },
|
||||
attributes: ['task_list_id'],
|
||||
raw: true,
|
||||
});
|
||||
const accessibleIds = [...new Set(assignments.map((a) => String(a.task_list_id)))];
|
||||
const requestedIds = ids.map(String);
|
||||
const unauthorizedIds = requestedIds.filter((id) => !accessibleIds.includes(id));
|
||||
|
||||
if (!accessibleIds.length)
|
||||
return R.error(res, 'No accessible task lists found.', 404);
|
||||
|
||||
const taskLists = await TaskList.findAll({ where: { task_list_id: accessibleIds } });
|
||||
const activeIds = taskLists.filter((tl) => !tl.deletedAt).map((tl) => String(tl.task_list_id));
|
||||
|
||||
if (!activeIds.length) {
|
||||
await t.rollback();
|
||||
return R.error(res, 'All selected task lists are already archived.', 400);
|
||||
}
|
||||
|
||||
const count = await archiveMany(TaskList, 'task_list_id', activeIds, staffUserId, t);
|
||||
await t.commit();
|
||||
return R.success(res, `${count} task list(s) archived successfully.`, {
|
||||
archived_ids: activeIds,
|
||||
skipped_ids: requestedIds.filter((id) => !activeIds.includes(id)),
|
||||
unauthorized_ids: unauthorizedIds,
|
||||
});
|
||||
} catch (err) {
|
||||
await t.rollback();
|
||||
console.error('[STAFF][BULK ARCHIVE TASK LISTS]', err);
|
||||
return R.error(res, 'Could not archive task lists.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── BULK RESTORE ─────────────────────────────────────────────────────────────
|
||||
|
||||
exports.bulkRestoreTaskLists = async (req, res) => {
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const staffUserId = req.user.user_id;
|
||||
const { ids } = req.body;
|
||||
|
||||
if (!Array.isArray(ids) || !ids.length)
|
||||
return R.error(res, 'No task list IDs provided.', 400);
|
||||
|
||||
// Scope: only IDs the staff can access
|
||||
const scopedGroupIds = await getStaffGroupIds(staffUserId);
|
||||
const assignments = await TaskListGroup.findAll({
|
||||
where: { task_list_id: { [Op.in]: ids }, group_id: { [Op.in]: scopedGroupIds } },
|
||||
attributes: ['task_list_id'],
|
||||
raw: true,
|
||||
});
|
||||
const accessibleIds = [...new Set(assignments.map((a) => String(a.task_list_id)))];
|
||||
const requestedIds = ids.map(String);
|
||||
const unauthorizedIds = requestedIds.filter((id) => !accessibleIds.includes(id));
|
||||
|
||||
if (!accessibleIds.length)
|
||||
return R.error(res, 'No accessible task lists found.', 404);
|
||||
|
||||
const taskLists = await TaskList.findAll({
|
||||
where: { task_list_id: accessibleIds },
|
||||
paranoid: false,
|
||||
});
|
||||
const deletedIds = taskLists.filter((tl) => tl.deletedAt).map((tl) => String(tl.task_list_id));
|
||||
|
||||
if (!deletedIds.length) {
|
||||
await t.rollback();
|
||||
return R.error(res, 'All selected task lists are already active.', 400);
|
||||
}
|
||||
|
||||
const count = await restoreMany(TaskList, 'task_list_id', deletedIds, staffUserId, t);
|
||||
await t.commit();
|
||||
return R.success(res, `${count} task list(s) restored successfully.`, {
|
||||
restored_ids: deletedIds,
|
||||
skipped_ids: requestedIds.filter((id) => !deletedIds.includes(id)),
|
||||
unauthorized_ids: unauthorizedIds,
|
||||
});
|
||||
} catch (err) {
|
||||
await t.rollback();
|
||||
console.error('[STAFF][BULK RESTORE TASK LISTS]', err);
|
||||
return R.error(res, 'Could not restore task lists.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── GET ARCHIVED TASK LISTS ──────────────────────────────────────────────────
|
||||
|
||||
exports.getArchivedTaskLists = async (req, res) => {
|
||||
try {
|
||||
const staffUserId = req.user.user_id;
|
||||
const scopedGroupIds = await getStaffGroupIds(staffUserId);
|
||||
|
||||
if (!scopedGroupIds.length)
|
||||
return R.success(res, 'Archived task lists retrieved.', { rows: [], count: 0 });
|
||||
|
||||
const assignments = await TaskListGroup.findAll({
|
||||
where: { group_id: { [Op.in]: scopedGroupIds } },
|
||||
attributes: ['task_list_id'],
|
||||
raw: true,
|
||||
});
|
||||
const accessibleIds = [...new Set(assignments.map((a) => a.task_list_id))];
|
||||
|
||||
const result = await paginate(TaskList, req, {
|
||||
excludeAttributes: staffExclude,
|
||||
jsonbSchemas,
|
||||
computedAttributes,
|
||||
auditOptions: { mdl_Users, parentAlias: 'TaskList' },
|
||||
allowedFields: TASK_LIST_FIELDS,
|
||||
findOptions: {
|
||||
paranoid: false,
|
||||
where: {
|
||||
task_list_id: { [Op.in]: accessibleIds },
|
||||
deletedAt: { [Op.not]: null },
|
||||
},
|
||||
include: [GROUP_INCLUDE],
|
||||
},
|
||||
});
|
||||
|
||||
return R.success(res, 'Archived task lists retrieved.', result);
|
||||
} catch (err) {
|
||||
console.error('[STAFF][GET ARCHIVED TASK LISTS]', err);
|
||||
return R.error(res, 'Could not retrieve archived task lists.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// ── TASKS ─────────────────────────────────────────────────────────────────────
|
||||
// =============================================================================
|
||||
|
||||
// ─── GET ALL ──────────────────────────────────────────────────────────────────
|
||||
|
||||
exports.getTasks = async (req, res) => {
|
||||
try {
|
||||
const { taskListId } = req.params;
|
||||
const staffUserId = req.user.user_id;
|
||||
|
||||
if (!(await assertTaskListAccess(staffUserId, taskListId)))
|
||||
return R.error(res, 'No access to this task list.', 403);
|
||||
|
||||
const result = await paginate(Task, req, {
|
||||
excludeAttributes: staffExclude,
|
||||
jsonbSchemas,
|
||||
computedAttributes,
|
||||
auditOptions: { mdl_Users, parentAlias: 'Task' },
|
||||
allowedFields: TASK_FIELDS,
|
||||
findOptions: {
|
||||
where: { task_list_id: taskListId },
|
||||
},
|
||||
});
|
||||
|
||||
return R.success(res, 'Tasks retrieved.', result);
|
||||
} catch (err) {
|
||||
console.error('[STAFF][GET ALL TASKS]', err);
|
||||
return R.error(res, 'Could not retrieve tasks.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── GET ONE ──────────────────────────────────────────────────────────────────
|
||||
|
||||
exports.getTask = async (req, res) => {
|
||||
try {
|
||||
const { taskListId, taskId } = req.params;
|
||||
const staffUserId = req.user.user_id;
|
||||
|
||||
if (!(await assertTaskListAccess(staffUserId, taskListId)))
|
||||
return R.error(res, 'No access to this task list.', 403);
|
||||
|
||||
const task = await Task.findOne({
|
||||
where: { task_id: taskId, task_list_id: taskListId },
|
||||
attributes: { exclude: staffExclude },
|
||||
paranoid: false,
|
||||
include: [
|
||||
{
|
||||
model: TaskRequirement,
|
||||
as: 'requirements',
|
||||
paranoid: false,
|
||||
attributes: { exclude: staffExclude },
|
||||
order: [['order', 'ASC']],
|
||||
},
|
||||
{
|
||||
model: TaskList,
|
||||
as: 'taskList',
|
||||
paranoid: false,
|
||||
attributes: { exclude: staffExclude },
|
||||
include: [GROUP_INCLUDE],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (!task) return R.error(res, 'Task not found.', 404);
|
||||
|
||||
return R.success(res, 'Task retrieved.', task);
|
||||
} catch (err) {
|
||||
console.error('[STAFF][GET TASK]', err);
|
||||
return R.error(res, 'Could not retrieve task.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── CREATE ───────────────────────────────────────────────────────────────────
|
||||
|
||||
exports.createTask = async (req, res) => {
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const { taskListId } = req.params;
|
||||
const staffUserId = req.user.user_id;
|
||||
const { name, description, deadline, requirements = [] } = req.body;
|
||||
|
||||
if (!name) { await t.rollback(); return R.error(res, 'Task name is required.', 400); }
|
||||
|
||||
if (!(await assertTaskListAccess(staffUserId, taskListId, t))) {
|
||||
await t.rollback();
|
||||
return R.error(res, 'No access to this task list.', 403);
|
||||
}
|
||||
|
||||
const taskList = await TaskList.findByPk(taskListId, { transaction: t });
|
||||
if (!taskList) { await t.rollback(); return R.error(res, 'Task list not found.', 404); }
|
||||
|
||||
const task = await Task.create(
|
||||
{
|
||||
task_list_id: taskListId,
|
||||
name,
|
||||
description,
|
||||
deadline: deadline || null,
|
||||
status: 'pending',
|
||||
createdBy: staffUserId,
|
||||
updatedBy: staffUserId,
|
||||
},
|
||||
{ transaction: t }
|
||||
);
|
||||
|
||||
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,
|
||||
reference_label: r.reference_label || null,
|
||||
link_url: r.link_url || null,
|
||||
link_label: r.link_label || null,
|
||||
createdBy: staffUserId,
|
||||
updatedBy: staffUserId,
|
||||
}));
|
||||
await TaskRequirement.bulkCreate(reqRows, { transaction: t });
|
||||
}
|
||||
|
||||
await t.commit();
|
||||
|
||||
const full = await Task.findByPk(task.task_id, {
|
||||
attributes: { exclude: staffExclude },
|
||||
include: [{
|
||||
model: TaskRequirement,
|
||||
as: 'requirements',
|
||||
attributes: { exclude: staffExclude },
|
||||
order: [['order', 'ASC']],
|
||||
}],
|
||||
});
|
||||
|
||||
return R.success(res, 'Task created successfully.', full, 201);
|
||||
} catch (err) {
|
||||
await t.rollback();
|
||||
console.error('[STAFF][CREATE TASK]', err);
|
||||
return R.error(res, 'Could not create task.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── UPDATE ───────────────────────────────────────────────────────────────────
|
||||
|
||||
exports.updateTask = async (req, res) => {
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const { taskListId, taskId } = req.params;
|
||||
const staffUserId = req.user.user_id;
|
||||
|
||||
if (!(await assertTaskListAccess(staffUserId, taskListId, t))) {
|
||||
await t.rollback();
|
||||
return R.error(res, 'No access to this task list.', 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); }
|
||||
|
||||
const { name, description, deadline, status, requirements } = req.body;
|
||||
|
||||
await task.update(
|
||||
{ name, description, deadline: deadline || null, status, updatedBy: staffUserId },
|
||||
{ transaction: t }
|
||||
);
|
||||
|
||||
if (Array.isArray(requirements)) {
|
||||
await TaskRequirement.destroy({
|
||||
where: { task_id: taskId },
|
||||
force: false,
|
||||
transaction: t,
|
||||
});
|
||||
|
||||
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,
|
||||
reference_label: r.reference_label || null,
|
||||
link_url: r.link_url || null,
|
||||
link_label: r.link_label || null,
|
||||
createdBy: staffUserId,
|
||||
updatedBy: staffUserId,
|
||||
}));
|
||||
await TaskRequirement.bulkCreate(reqRows, { transaction: t });
|
||||
}
|
||||
}
|
||||
|
||||
await t.commit();
|
||||
|
||||
const full = await Task.findByPk(taskId, {
|
||||
attributes: { exclude: staffExclude },
|
||||
include: [{
|
||||
model: TaskRequirement,
|
||||
as: 'requirements',
|
||||
attributes: { exclude: staffExclude },
|
||||
order: [['order', 'ASC']],
|
||||
}],
|
||||
});
|
||||
|
||||
return R.success(res, 'Task updated successfully.', full);
|
||||
} catch (err) {
|
||||
await t.rollback();
|
||||
console.error('[STAFF][UPDATE TASK]', err);
|
||||
return R.error(res, 'Could not update task.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── ARCHIVE (soft-delete) ────────────────────────────────────────────────────
|
||||
|
||||
exports.archiveTask = async (req, res) => {
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const { taskListId, taskId } = req.params;
|
||||
const staffUserId = req.user.user_id;
|
||||
|
||||
if (!(await assertTaskListAccess(staffUserId, taskListId, t))) {
|
||||
await t.rollback();
|
||||
return R.error(res, 'No access to this task list.', 403);
|
||||
}
|
||||
|
||||
const record = await archiveOne(
|
||||
Task,
|
||||
{ task_id: taskId, task_list_id: taskListId },
|
||||
staffUserId,
|
||||
t
|
||||
);
|
||||
if (!record) { await t.rollback(); return R.error(res, 'Task not found.', 404); }
|
||||
|
||||
await t.commit();
|
||||
return R.success(res, 'Task archived successfully.');
|
||||
} catch (err) {
|
||||
await t.rollback();
|
||||
console.error('[STAFF][ARCHIVE TASK]', err);
|
||||
return R.error(res, 'Could not archive task.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── RESTORE ──────────────────────────────────────────────────────────────────
|
||||
|
||||
exports.restoreTask = async (req, res) => {
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const { taskListId, taskId } = req.params;
|
||||
const staffUserId = req.user.user_id;
|
||||
|
||||
if (!(await assertTaskListAccess(staffUserId, taskListId, t))) {
|
||||
await t.rollback();
|
||||
return R.error(res, 'No access to this task list.', 403);
|
||||
}
|
||||
|
||||
const record = await restoreOne(
|
||||
Task,
|
||||
{ task_id: taskId, task_list_id: taskListId, deletedAt: { [Op.not]: null } },
|
||||
staffUserId,
|
||||
t
|
||||
);
|
||||
if (!record) { await t.rollback(); return R.error(res, 'Task not found or not archived.', 404); }
|
||||
|
||||
await t.commit();
|
||||
return R.success(res, 'Task restored successfully.', record);
|
||||
} catch (err) {
|
||||
await t.rollback();
|
||||
console.error('[STAFF][RESTORE TASK]', err);
|
||||
return R.error(res, 'Could not restore task.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── BULK ARCHIVE ─────────────────────────────────────────────────────────────
|
||||
|
||||
exports.bulkArchiveTasks = async (req, res) => {
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const { taskListId } = req.params;
|
||||
const staffUserId = req.user.user_id;
|
||||
const { ids } = req.body;
|
||||
|
||||
if (!Array.isArray(ids) || !ids.length)
|
||||
return R.error(res, 'No task IDs provided.', 400);
|
||||
|
||||
if (!(await assertTaskListAccess(staffUserId, taskListId, t))) {
|
||||
await t.rollback();
|
||||
return R.error(res, 'No access to this task list.', 403);
|
||||
}
|
||||
|
||||
const tasks = await Task.findAll({
|
||||
where: { task_id: ids, task_list_id: taskListId },
|
||||
});
|
||||
if (!tasks.length) return R.error(res, 'No tasks found.', 404);
|
||||
|
||||
const activeIds = tasks.filter((task) => !task.deletedAt).map((task) => task.task_id);
|
||||
if (!activeIds.length) {
|
||||
await t.rollback();
|
||||
return R.error(res, 'All selected tasks are already archived.', 400);
|
||||
}
|
||||
|
||||
const count = await archiveMany(Task, 'task_id', activeIds, staffUserId, t);
|
||||
await t.commit();
|
||||
return R.success(res, `${count} task(s) archived successfully.`, {
|
||||
archived_ids: activeIds,
|
||||
skipped_ids: ids.filter((id) => !activeIds.includes(id)),
|
||||
});
|
||||
} catch (err) {
|
||||
await t.rollback();
|
||||
console.error('[STAFF][BULK ARCHIVE TASKS]', err);
|
||||
return R.error(res, 'Could not archive tasks.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── BULK RESTORE ─────────────────────────────────────────────────────────────
|
||||
|
||||
exports.bulkRestoreTasks = async (req, res) => {
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const { taskListId } = req.params;
|
||||
const staffUserId = req.user.user_id;
|
||||
const { ids } = req.body;
|
||||
|
||||
if (!Array.isArray(ids) || !ids.length)
|
||||
return R.error(res, 'No task IDs provided.', 400);
|
||||
|
||||
if (!(await assertTaskListAccess(staffUserId, taskListId, t))) {
|
||||
await t.rollback();
|
||||
return R.error(res, 'No access to this task list.', 403);
|
||||
}
|
||||
|
||||
const tasks = await Task.findAll({
|
||||
where: { task_id: ids, task_list_id: taskListId },
|
||||
paranoid: false,
|
||||
});
|
||||
if (!tasks.length) return R.error(res, 'No tasks found.', 404);
|
||||
|
||||
const deletedIds = tasks.filter((task) => task.deletedAt).map((task) => task.task_id);
|
||||
if (!deletedIds.length) {
|
||||
await t.rollback();
|
||||
return R.error(res, 'All selected tasks are already active.', 400);
|
||||
}
|
||||
|
||||
const count = await restoreMany(Task, 'task_id', deletedIds, staffUserId, t);
|
||||
await t.commit();
|
||||
return R.success(res, `${count} task(s) restored successfully.`, {
|
||||
restored_ids: deletedIds,
|
||||
skipped_ids: ids.filter((id) => !deletedIds.includes(id)),
|
||||
});
|
||||
} catch (err) {
|
||||
await t.rollback();
|
||||
console.error('[STAFF][BULK RESTORE TASKS]', err);
|
||||
return R.error(res, 'Could not restore tasks.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── GET ARCHIVED TASKS ───────────────────────────────────────────────────────
|
||||
|
||||
exports.getArchivedTasks = async (req, res) => {
|
||||
try {
|
||||
const { taskListId } = req.params;
|
||||
const staffUserId = req.user.user_id;
|
||||
|
||||
if (!(await assertTaskListAccess(staffUserId, taskListId)))
|
||||
return R.error(res, 'No access to this task list.', 403);
|
||||
|
||||
const result = await paginate(Task, req, {
|
||||
excludeAttributes: staffExclude,
|
||||
jsonbSchemas,
|
||||
computedAttributes,
|
||||
auditOptions: { mdl_Users, parentAlias: 'Task' },
|
||||
allowedFields: TASK_FIELDS,
|
||||
findOptions: {
|
||||
paranoid: false,
|
||||
where: {
|
||||
task_list_id: taskListId,
|
||||
deletedAt: { [Op.not]: null },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return R.success(res, 'Archived tasks retrieved.', result);
|
||||
} catch (err) {
|
||||
console.error('[STAFF][GET ARCHIVED TASKS]', err);
|
||||
return R.error(res, 'Could not retrieve archived tasks.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Field value helpers (for filter dropdowns) ───────────────────────────────
|
||||
exports.getTaskFieldValues = getFieldValues(Task, 'TASK');
|
||||
exports.getTaskListFieldValues = getFieldValues(TaskList, 'TASKLIST');
|
||||
@@ -0,0 +1,113 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name: users.controller.js (staff)
|
||||
* Type of Program: Controller
|
||||
* Description: Staff-level user management.
|
||||
* Staff can VIEW any client/staff user and perform limited actions.
|
||||
* Staff CANNOT modify admin accounts or assign/revoke admin roles.
|
||||
*
|
||||
* Endpoints (require authenticate → requireStaff()):
|
||||
* GET /api/staff/users → paginated user list (non-admins)
|
||||
* GET /api/staff/users/:id → view any non-admin user
|
||||
* PUT /api/staff/users/:id/status → activate / deactivate user
|
||||
* GET /api/staff/users/:id/sessions → view user sessions
|
||||
*
|
||||
* Author: rgrgogu
|
||||
* Date Created: Oct. 6, 2025
|
||||
***********************************************************************************************************************************************************************/
|
||||
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'];
|
||||
|
||||
// ─── GET paginated list (no admins) ────────────────────────────────────────────
|
||||
exports.getUsers = 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 { count, rows } = await mdl_Users.findAndCountAll({
|
||||
where: { acc_type: { [Op.ne]: 'admin' } },
|
||||
attributes: { exclude: EXCLUDED },
|
||||
limit,
|
||||
offset,
|
||||
order: [['createdAt', 'DESC']],
|
||||
});
|
||||
|
||||
return R.success(res, 'Users retrieved.', {
|
||||
total: count,
|
||||
page,
|
||||
totalPages: Math.ceil(count / limit),
|
||||
users: rows,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[STAFF] getUsers error:', err);
|
||||
return R.error(res, 'Could not retrieve users.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── GET single non-admin user ─────────────────────────────────────────────────
|
||||
exports.getUser = async (req, res) => {
|
||||
try {
|
||||
const user = await mdl_Users.findOne({
|
||||
where: { user_id: req.params.id, acc_type: { [Op.ne]: 'admin' } },
|
||||
attributes: { exclude: EXCLUDED },
|
||||
});
|
||||
if (!user) return R.error(res, 'User not found.', 404);
|
||||
return R.success(res, 'User retrieved.', user);
|
||||
} catch (err) {
|
||||
return R.error(res, 'Could not retrieve user.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── PUT activate / deactivate ─────────────────────────────────────────────────
|
||||
exports.setUserStatus = async (req, res) => {
|
||||
try {
|
||||
const { is_active } = req.body;
|
||||
if (typeof is_active !== 'boolean')
|
||||
return R.error(res, 'is_active must be a boolean.', 400);
|
||||
|
||||
const user = await mdl_Users.findOne({
|
||||
where: { user_id: req.params.id, acc_type: { [Op.ne]: 'admin' } },
|
||||
});
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── GET user sessions ─────────────────────────────────────────────────────────
|
||||
exports.getUserSessions = async (req, res) => {
|
||||
try {
|
||||
const user = await mdl_Users.findOne({
|
||||
where: { user_id: req.params.id, acc_type: { [Op.ne]: 'admin' } },
|
||||
});
|
||||
if (!user) return R.error(res, 'User not found.', 404);
|
||||
|
||||
const sessions = await mdl_UserSessions.findAll({
|
||||
where: { user_id: req.params.id },
|
||||
attributes: { exclude: ['refresh_token_hash'] },
|
||||
order: [['createdAt', 'DESC']],
|
||||
});
|
||||
return R.success(res, 'Sessions retrieved.', sessions);
|
||||
} catch (err) {
|
||||
return R.error(res, 'Could not retrieve sessions.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Staff profile (re-use client controller) ──────────────────────────────────
|
||||
// Staff also manage their own profile through the same client endpoints.
|
||||
// No additional staff-specific profile endpoints needed.
|
||||
@@ -0,0 +1,152 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name: users.ctrl.js (staff)
|
||||
* Type of Program: Controller
|
||||
* Description: Staff-scoped user endpoints.
|
||||
* A staff member can only see users who belong to at least one
|
||||
* of the same groups the staff member belongs to.
|
||||
***********************************************************************************************************************************************************************/
|
||||
const { Op } = require('sequelize');
|
||||
const mdl_Users = require('../../models/users/users.mdl');
|
||||
const { mdl_UserGroups, mdl_UserGroupMembers } = require('../../models/users/user_groups.mdl');
|
||||
|
||||
/**
|
||||
* GET /api/staff/users
|
||||
* Returns all users who share at least one group with the requesting staff member.
|
||||
* Supports optional query: ?group_id=27 or ?group_code=CG2027
|
||||
*/
|
||||
const getUsers = async (req, res) => {
|
||||
try {
|
||||
const staffUserId = req.user.user_id;
|
||||
const { group_id, group_code, page = 1, limit = 20 } = req.query;
|
||||
const offset = (parseInt(page) - 1) * parseInt(limit);
|
||||
|
||||
// 1. Find the groups the staff member belongs to
|
||||
const staffMemberships = await mdl_UserGroupMembers.findAll({
|
||||
where: { user_id: staffUserId },
|
||||
attributes: ['group_id'],
|
||||
raw: true,
|
||||
});
|
||||
|
||||
let scopedGroupIds = staffMemberships.map(m => m.group_id);
|
||||
|
||||
if (scopedGroupIds.length === 0) {
|
||||
return res.status(200).json({ success: true, data: [], total: 0, page, limit });
|
||||
}
|
||||
|
||||
// 2. Filter down to a specific group if requested
|
||||
if (group_id) {
|
||||
const gid = parseInt(group_id);
|
||||
if (!scopedGroupIds.includes(gid)) {
|
||||
return res.status(403).json({ success: false, message: 'You do not have access to this group.' });
|
||||
}
|
||||
scopedGroupIds = [gid];
|
||||
}
|
||||
|
||||
if (group_code) {
|
||||
const group = await mdl_UserGroups.findOne({
|
||||
where: { group_code: group_code.toUpperCase().trim(), deletedAt: null },
|
||||
attributes: ['group_id'],
|
||||
raw: true,
|
||||
});
|
||||
if (!group || !scopedGroupIds.includes(group.group_id)) {
|
||||
return res.status(403).json({ success: false, message: 'You do not have access to this group.' });
|
||||
}
|
||||
scopedGroupIds = [group.group_id];
|
||||
}
|
||||
|
||||
// 3. Find all user_ids in those groups (excluding the staff member themselves)
|
||||
const memberships = await mdl_UserGroupMembers.findAll({
|
||||
where: { group_id: { [Op.in]: scopedGroupIds } },
|
||||
attributes: ['user_id'],
|
||||
raw: true,
|
||||
});
|
||||
|
||||
const userIds = [...new Set(memberships.map(m => m.user_id))].filter(id => id !== staffUserId);
|
||||
|
||||
// 4. Fetch users
|
||||
const { count, rows } = await mdl_Users.findAndCountAll({
|
||||
where: { user_id: { [Op.in]: userIds } },
|
||||
attributes: [
|
||||
'user_id', 'email', 'is_active', 'is_verified',
|
||||
'acc_type', 'personal_info', 'createdAt',
|
||||
],
|
||||
include: [
|
||||
{
|
||||
model: mdl_UserGroups,
|
||||
as: 'groups',
|
||||
attributes: ['group_id', 'name', 'group_code'],
|
||||
through: { attributes: ['joined_at'] },
|
||||
where: { group_id: { [Op.in]: scopedGroupIds } },
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
limit: parseInt(limit),
|
||||
offset,
|
||||
order: [['createdAt', 'DESC']],
|
||||
});
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
data: rows,
|
||||
total: count,
|
||||
page: parseInt(page),
|
||||
limit: parseInt(limit),
|
||||
totalPages: Math.ceil(count / parseInt(limit)),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[staff/users] getUsers error:', err);
|
||||
return res.status(500).json({ success: false, message: 'Internal server error.' });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* GET /api/staff/users/:user_id
|
||||
* Get a single user's profile — only if they share a group with the staff member.
|
||||
*/
|
||||
const getUserById = async (req, res) => {
|
||||
try {
|
||||
const staffUserId = req.user.user_id;
|
||||
const targetUserId = parseInt(req.params.user_id);
|
||||
|
||||
// Get staff's groups
|
||||
const staffMemberships = await mdl_UserGroupMembers.findAll({
|
||||
where: { user_id: staffUserId },
|
||||
attributes: ['group_id'],
|
||||
raw: true,
|
||||
});
|
||||
const scopedGroupIds = staffMemberships.map(m => m.group_id);
|
||||
|
||||
// Check target user shares a group
|
||||
const sharedMembership = await mdl_UserGroupMembers.findOne({
|
||||
where: {
|
||||
user_id: targetUserId,
|
||||
group_id: { [Op.in]: scopedGroupIds },
|
||||
},
|
||||
});
|
||||
|
||||
if (!sharedMembership) {
|
||||
return res.status(403).json({ success: false, message: 'User not in your scope.' });
|
||||
}
|
||||
|
||||
const user = await mdl_Users.findByPk(targetUserId, {
|
||||
attributes: ['user_id', 'email', 'is_active', 'is_verified', 'acc_type', 'personal_info', 'createdAt'],
|
||||
include: [
|
||||
{
|
||||
model: mdl_UserGroups,
|
||||
as: 'groups',
|
||||
attributes: ['group_id', 'name', 'group_code'],
|
||||
through: { attributes: ['joined_at'] },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (!user) return res.status(404).json({ success: false, message: 'User not found.' });
|
||||
|
||||
return res.status(200).json({ success: true, data: user });
|
||||
} catch (err) {
|
||||
console.error('[staff/users] getUserById error:', err);
|
||||
return res.status(500).json({ success: false, message: 'Internal server error.' });
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { getUsers, getUserById };
|
||||
@@ -0,0 +1,64 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* 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.
|
||||
*
|
||||
* taskOverdue is settings-backed (see cronRegistry.util.js) —
|
||||
* its schedule/enabled state lives in cron_notification_settings
|
||||
* and is configurable from /admin/notifications/settings without
|
||||
* a restart. liftExpiredBans is not notification-related, so it
|
||||
* stays on a plain hardcoded schedule.
|
||||
*
|
||||
* Currently registered:
|
||||
* - taskOverdue (cron/jobs/task_overdue.cron.js) — settings-backed
|
||||
* - liftExpiredBans (cron/jobs/lift_expired_bans.cron.js)
|
||||
* - retryStuckTranscodes (cron/jobs/retry_stuck_transcodes.cron.js)
|
||||
*
|
||||
* Author: Kenneth Obsequio (@lash0000)
|
||||
* Date Created: Jun. 17, 2026
|
||||
***********************************************************************************************************************************************************************/
|
||||
const cron = require('node-cron');
|
||||
const taskOverdue = require('./jobs/task_overdue.cron');
|
||||
const liftExpiredBans = require('./jobs/lift_expired_bans.cron');
|
||||
const retryStuckTranscodes = require('./jobs/retry_stuck_transcodes.cron');
|
||||
const { startSettingsBackedJobs } = require('./cronRegistry.util');
|
||||
|
||||
// ─── Registry — add future admin-side cron jobs here ─────────────────────────
|
||||
const settingsBackedJobs = [
|
||||
taskOverdue,
|
||||
];
|
||||
|
||||
// Plain hardcoded-schedule jobs (not tied to any notification setting).
|
||||
const plainJobs = [
|
||||
liftExpiredBans,
|
||||
retryStuckTranscodes,
|
||||
];
|
||||
|
||||
// ─── Boot all registered admin-side jobs ──────────────────────────────────────
|
||||
async function startAdminCronJobs() {
|
||||
const registered = await startSettingsBackedJobs(settingsBackedJobs, 'ADMIN');
|
||||
|
||||
for (const job of plainJobs) {
|
||||
if (!cron.validate(job.schedule)) {
|
||||
console.error(`[CRON][ADMIN] Invalid schedule for "${job.name}": "${job.schedule}" — skipped.`);
|
||||
continue;
|
||||
}
|
||||
cron.schedule(job.schedule, job.run);
|
||||
registered.push({ name: job.name, scope: 'ADMIN', schedule: job.schedule });
|
||||
}
|
||||
|
||||
return registered;
|
||||
}
|
||||
|
||||
module.exports = { startAdminCronJobs };
|
||||
@@ -0,0 +1,65 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* 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.
|
||||
*
|
||||
* The settings-backed jobs emit notifications, so their
|
||||
* schedule/enabled state lives in cron_notification_settings
|
||||
* and is configurable from /admin/notifications/settings
|
||||
* without a restart. expireAdvertisements has no notification
|
||||
* tied to it, so it stays on a plain hardcoded schedule (same
|
||||
* reasoning as liftExpiredBans in admin.cron.js).
|
||||
*
|
||||
* Currently registered:
|
||||
* - userNotifications (cron/jobs/user_notifications.cron.js)
|
||||
* - issueCertificates (cron/jobs/issue_certificates.cron.js)
|
||||
* - expireUserTiers (cron/jobs/expire_user_tiers.cron.js)
|
||||
* - taskDueSoon (cron/jobs/task_due_soon.cron.js)
|
||||
* - expireAdvertisements (cron/jobs/expire_advertisements.cron.js) — plain
|
||||
* - failStalePayments (cron/jobs/fail_stale_payments.cron.js) — plain
|
||||
*
|
||||
* Author: Kenneth Obsequio (@lash0000)
|
||||
* Date Created: Jun. 17, 2026
|
||||
***********************************************************************************************************************************************************************/
|
||||
const cron = require('node-cron');
|
||||
const userNotifications = require('./jobs/user_notifications.cron');
|
||||
const issueCertificates = require('./jobs/issue_certificates.cron');
|
||||
const expireUserTiers = require('./jobs/expire_user_tiers.cron');
|
||||
const taskDueSoon = require('./jobs/task_due_soon.cron');
|
||||
const expireAdvertisements = require('./jobs/expire_advertisements.cron');
|
||||
const failStalePayments = require('./jobs/fail_stale_payments.cron');
|
||||
const { startSettingsBackedJobs } = require('./cronRegistry.util');
|
||||
|
||||
// ─── Registry — add future client-side cron jobs here ────────────────────────
|
||||
const settingsBackedJobs = [
|
||||
userNotifications,
|
||||
issueCertificates,
|
||||
expireUserTiers,
|
||||
taskDueSoon,
|
||||
];
|
||||
|
||||
// Plain hardcoded-schedule jobs (not tied to any notification setting).
|
||||
const plainJobs = [
|
||||
expireAdvertisements,
|
||||
failStalePayments,
|
||||
];
|
||||
|
||||
// ─── Boot all registered client-side jobs ─────────────────────────────────────
|
||||
async function startClientCronJobs() {
|
||||
const registered = await startSettingsBackedJobs(settingsBackedJobs, 'CLIENT');
|
||||
|
||||
for (const job of plainJobs) {
|
||||
if (!cron.validate(job.schedule)) {
|
||||
console.error(`[CRON][CLIENT] Invalid schedule for "${job.name}": "${job.schedule}" — skipped.`);
|
||||
continue;
|
||||
}
|
||||
cron.schedule(job.schedule, job.run);
|
||||
registered.push({ name: job.name, scope: 'CLIENT', schedule: job.schedule });
|
||||
}
|
||||
|
||||
return registered;
|
||||
}
|
||||
|
||||
module.exports = { startClientCronJobs };
|
||||
@@ -0,0 +1,82 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name : cronRegistry.util.js
|
||||
* Type : Utility
|
||||
* Description : Shared machinery for settings-backed cron jobs (the 4 jobs
|
||||
* that emit notifications and are configurable from
|
||||
* /admin/notifications/settings). Not every cron job in the
|
||||
* app goes through this — jobs with no notification tied to
|
||||
* them (e.g. lift_expired_bans) keep using node-cron directly.
|
||||
*
|
||||
* startSettingsBackedJobs() reads each job's schedule from
|
||||
* cron_notification_settings (falling back to — and seeding —
|
||||
* the job's own hardcoded default on first boot), then keeps
|
||||
* a live reference to the scheduled task so it can be swapped
|
||||
* out later via rescheduleJob() without a server restart.
|
||||
*
|
||||
* Author: Kenneth Obsequio (@lash0000)
|
||||
* Date Created: Jul. 2, 2026
|
||||
***********************************************************************************************************************************************************************/
|
||||
'use strict';
|
||||
|
||||
const cron = require('node-cron');
|
||||
const CronNotificationSetting = require('../models/notifications/cron_notification_setting.mdl');
|
||||
|
||||
// job_name -> { task: ScheduledTask, run: fn }
|
||||
const runningTasks = new Map();
|
||||
|
||||
// CockroachDB can't run Sequelize's findOrCreate() — it wraps the insert in a
|
||||
// pg_temp PL/pgSQL function to atomically catch unique_violation, which
|
||||
// CockroachDB rejects ("cannot create user-defined functions under a temporary
|
||||
// schema"). Plain findOne-then-create sidesteps it; the race window (two boots
|
||||
// racing to seed the same job_name) is a non-issue here — jobs are seeded once.
|
||||
async function getOrCreateSetting(jobName, defaultSchedule) {
|
||||
let row = await CronNotificationSetting.findOne({ where: { job_name: jobName } });
|
||||
if (row) return row;
|
||||
|
||||
try {
|
||||
row = await CronNotificationSetting.create({ job_name: jobName, enabled: true, schedule: defaultSchedule });
|
||||
} catch (err) {
|
||||
row = await CronNotificationSetting.findOne({ where: { job_name: jobName } });
|
||||
if (!row) throw err;
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
async function startSettingsBackedJobs(jobs, scopeLabel) {
|
||||
const registered = [];
|
||||
|
||||
for (const { name, schedule: defaultSchedule, run } of jobs) {
|
||||
let schedule = defaultSchedule;
|
||||
try {
|
||||
const settings = await getOrCreateSetting(name, defaultSchedule);
|
||||
schedule = settings.schedule || defaultSchedule;
|
||||
} catch (err) {
|
||||
console.error(`[CRON][${scopeLabel}] Failed to load settings for "${name}", using hardcoded default:`, err);
|
||||
}
|
||||
|
||||
if (!cron.validate(schedule)) {
|
||||
console.error(`[CRON][${scopeLabel}] Invalid schedule for "${name}": "${schedule}" — skipped.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const task = cron.schedule(schedule, run);
|
||||
runningTasks.set(name, { task, run });
|
||||
registered.push({ name, scope: scopeLabel, schedule });
|
||||
}
|
||||
|
||||
return registered;
|
||||
}
|
||||
|
||||
// Live-swap a running job's schedule — used by notificationSettings.controller.js
|
||||
// after an admin picks a new preset. No server restart required.
|
||||
function rescheduleJob(jobName, newSchedule) {
|
||||
const entry = runningTasks.get(jobName);
|
||||
if (!entry) throw new Error(`No running cron task found for "${jobName}".`);
|
||||
if (!cron.validate(newSchedule)) throw new Error(`Invalid cron schedule: "${newSchedule}".`);
|
||||
|
||||
entry.task.stop();
|
||||
const task = cron.schedule(newSchedule, entry.run);
|
||||
runningTasks.set(jobName, { task, run: entry.run });
|
||||
}
|
||||
|
||||
module.exports = { startSettingsBackedJobs, rescheduleJob, getOrCreateSetting };
|
||||
@@ -0,0 +1,57 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name : expire_advertisements.cron.js
|
||||
* Type : Cron Job
|
||||
* Description : Auto-archives (soft-deletes) advertisements once their
|
||||
* end_date has passed, so expired ads don't sit indefinitely
|
||||
* in the active Advertisements list — they fall through to
|
||||
* the Archived Advertisements table, same path as a manual
|
||||
* archive action.
|
||||
*
|
||||
* Only touches rows with end_date IS NOT NULL so ads with no
|
||||
* end date (run indefinitely) are never auto-archived.
|
||||
*
|
||||
* Schedule : Every minute ("* * * * *"). Registered by cron/client.cron.js.
|
||||
*
|
||||
* Author: Kenneth Obsequio (@lash0000)
|
||||
* Date Created: Jul. 11, 2026
|
||||
***********************************************************************************************************************************************************************/
|
||||
'use strict';
|
||||
|
||||
const { Op } = require('sequelize');
|
||||
const mdl_Advertisements = require('../../models/advertisements/advertisements.mdl');
|
||||
|
||||
async function run() {
|
||||
let expired;
|
||||
try {
|
||||
expired = await mdl_Advertisements.findAll({
|
||||
where: {
|
||||
deletedAt: null,
|
||||
end_date: { [Op.ne]: null, [Op.lt]: new Date() },
|
||||
},
|
||||
attributes: ['advertisement_id'],
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[CRON][EXPIRE ADVERTISEMENTS] Failed to query advertisements:', err);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!expired.length) return;
|
||||
|
||||
const ids = expired.map((a) => a.advertisement_id);
|
||||
|
||||
try {
|
||||
await mdl_Advertisements.update({ status: 'expired' }, { where: { advertisement_id: { [Op.in]: ids } } });
|
||||
await mdl_Advertisements.destroy({ where: { advertisement_id: { [Op.in]: ids } } });
|
||||
} catch (err) {
|
||||
console.error('[CRON][EXPIRE ADVERTISEMENTS] Archive failed:', err);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[CRON][EXPIRE ADVERTISEMENTS] Auto-archived ${ids.length} expired advertisement(s).`);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
name: 'expireAdvertisements',
|
||||
schedule: '* * * * *',
|
||||
run,
|
||||
};
|
||||
@@ -0,0 +1,99 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name : expire_user_tiers.cron.js
|
||||
* Type : Cron Job
|
||||
* Description : Marks active user_tiers rows as 'expired' when their expires_at
|
||||
* has passed. Runs every minute to support short-duration plans
|
||||
* (minute- and hour-level plans in addition to day/month/year).
|
||||
*
|
||||
* For each expired batch it:
|
||||
* 1. Bulk-updates matching rows to status = 'expired'.
|
||||
* 2. Sends an in-app UserNotification to each affected user.
|
||||
*
|
||||
* Safety:
|
||||
* - Only touches rows with expires_at IS NOT NULL so
|
||||
* manually-granted unlimited tiers (expires_at = NULL) are
|
||||
* never touched.
|
||||
* - Bulk update happens before notifications so a restart
|
||||
* mid-run never re-expires already-expired rows.
|
||||
*
|
||||
* Schedule : Every minute ("* * * * *"). Registered by cron/client.cron.js.
|
||||
*
|
||||
* Author: Kenneth Obsequio (@lash0000)
|
||||
* Date Created: Jun. 29, 2026
|
||||
***********************************************************************************************************************************************************************/
|
||||
'use strict';
|
||||
|
||||
const { Op } = require('sequelize');
|
||||
const mdl_UserTiers = require('../../models/tiers/user_tiers.mdl');
|
||||
const mdl_TierPlans = require('../../models/tiers/tier_plans.mdl');
|
||||
const UserNotification = require('../../models/notifications/user_notification.mdl');
|
||||
const CronNotificationSetting = require('../../models/notifications/cron_notification_setting.mdl');
|
||||
const { NOTIFICATION_REGISTRY } = require('../../data/notifications.data');
|
||||
|
||||
require('../../models/tiers/tier.associations');
|
||||
|
||||
async function run() {
|
||||
// ── 1. Find all active tiers whose expires_at has passed ──────────────────
|
||||
let expired;
|
||||
try {
|
||||
expired = await mdl_UserTiers.findAll({
|
||||
where: {
|
||||
status: 'active',
|
||||
expires_at: { [Op.ne]: null, [Op.lte]: new Date() },
|
||||
},
|
||||
include: [{
|
||||
model: mdl_TierPlans,
|
||||
as: 'plan',
|
||||
attributes: ['plan_id', 'label', 'tier'],
|
||||
required: false,
|
||||
}],
|
||||
attributes: ['tier_id', 'user_id', 'tier'],
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[CRON][EXPIRE TIERS] Failed to query user_tiers:', err);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!expired.length) return;
|
||||
|
||||
const tierIds = expired.map((t) => t.tier_id);
|
||||
|
||||
// ── 2. Bulk-update to expired ──────────────────────────────────────────────
|
||||
try {
|
||||
await mdl_UserTiers.update(
|
||||
{ status: 'expired' },
|
||||
{ where: { tier_id: tierIds } }
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('[CRON][EXPIRE TIERS] Bulk update failed:', err);
|
||||
return;
|
||||
}
|
||||
|
||||
// ── 3. Send in-app notifications (one per affected user) ──────────────────
|
||||
// Status flip above always happens — only this step is skippable via settings.
|
||||
const settings = await CronNotificationSetting.findOne({ where: { job_name: 'expireUserTiers' } });
|
||||
if (!settings || settings.enabled) {
|
||||
try {
|
||||
const notifications = expired.map((t) => ({
|
||||
user_id: t.user_id,
|
||||
...NOTIFICATION_REGISTRY.tier_expired.build({
|
||||
tier: t.tier,
|
||||
label: t.plan?.label ?? null,
|
||||
planId: t.plan?.plan_id ?? null,
|
||||
}),
|
||||
}));
|
||||
|
||||
await UserNotification.bulkCreate(notifications, { ignoreDuplicates: true });
|
||||
} catch (err) {
|
||||
console.error('[CRON][EXPIRE TIERS] Notification bulkCreate failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[CRON][EXPIRE TIERS] Expired ${expired.length} tier(s) for ${new Set(expired.map((t) => t.user_id)).size} user(s).`);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
name: 'expireUserTiers',
|
||||
schedule: '* * * * *',
|
||||
run,
|
||||
};
|
||||
@@ -0,0 +1,87 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name : fail_stale_payments.cron.js
|
||||
* Type : Cron Job
|
||||
* Description : Marks abandoned checkout attempts as 'failed' instead of
|
||||
* leaving them stuck on 'pending' forever.
|
||||
*
|
||||
* A payment/purchase row is created as 'pending' the moment
|
||||
* PayPal's create-order call succeeds (createOrder / createCourseOrder),
|
||||
* before the buyer ever reaches PayPal's approval page. If PayPal's
|
||||
* own hosted checkout then fails to load ("Things don't appear to
|
||||
* be working at the moment") or the buyer just abandons the tab,
|
||||
* the browser never gets redirected back to our return_url/cancel_url —
|
||||
* so captureOrder/cancelOrder is never called, and the row sits as
|
||||
* 'pending' indefinitely even though no money ever moved.
|
||||
*
|
||||
* This job sweeps 'pending' rows older than STALE_MINUTES and
|
||||
* marks them 'failed', so payment history correctly reflects
|
||||
* that nothing was charged, instead of silently doing nothing.
|
||||
*
|
||||
* Schedule : Every 10 minutes ("*\/10 * * * *"). Registered by cron/client.cron.js.
|
||||
*
|
||||
* Author: Kenneth Obsequio (@lash0000)
|
||||
* Date Created: Aug. 3, 2026
|
||||
***********************************************************************************************************************************************************************/
|
||||
'use strict';
|
||||
|
||||
const { Op } = require('sequelize');
|
||||
const mdl_Payments = require('../../models/tiers/payments.mdl');
|
||||
const mdl_CoursePurchase = require('../../models/courses/course_purchases.mdl');
|
||||
|
||||
const STALE_MINUTES = 30;
|
||||
|
||||
async function run() {
|
||||
const cutoff = new Date(Date.now() - STALE_MINUTES * 60_000);
|
||||
|
||||
try {
|
||||
const stalePayments = await mdl_Payments.findAll({
|
||||
where: { status: 'pending', createdAt: { [Op.lt]: cutoff } },
|
||||
});
|
||||
|
||||
for (const payment of stalePayments) {
|
||||
await payment.update({
|
||||
status: 'failed',
|
||||
provider_payload: {
|
||||
...payment.provider_payload,
|
||||
failed_reason: 'abandoned_checkout',
|
||||
marked_failed_at: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (stalePayments.length) {
|
||||
console.log(`[CRON][FAIL STALE PAYMENTS] Marked ${stalePayments.length} abandoned tier payment(s) as failed.`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[CRON][FAIL STALE PAYMENTS] Failed sweeping payments:', err);
|
||||
}
|
||||
|
||||
try {
|
||||
const stalePurchases = await mdl_CoursePurchase.findAll({
|
||||
where: { status: 'pending', createdAt: { [Op.lt]: cutoff } },
|
||||
});
|
||||
|
||||
for (const purchase of stalePurchases) {
|
||||
await purchase.update({
|
||||
status: 'failed',
|
||||
provider_payload: {
|
||||
...purchase.provider_payload,
|
||||
failed_reason: 'abandoned_checkout',
|
||||
marked_failed_at: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (stalePurchases.length) {
|
||||
console.log(`[CRON][FAIL STALE PAYMENTS] Marked ${stalePurchases.length} abandoned course purchase(s) as failed.`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[CRON][FAIL STALE PAYMENTS] Failed sweeping course purchases:', err);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
name: 'failStalePayments',
|
||||
schedule: '*/10 * * * *',
|
||||
run,
|
||||
};
|
||||
@@ -0,0 +1,123 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name : issue_certificates.cron.js
|
||||
* Type : Cron Job
|
||||
* Description : Issues certificates for users who passed a course assessment.
|
||||
* Runs every hour on the hour and processes any
|
||||
* pending_certificates row where issue_at <= NOW() and
|
||||
* processed_at IS NULL.
|
||||
*
|
||||
* For each ready row it:
|
||||
* 1. Grants the course_completed_<uuid> achievement (the key
|
||||
* MyCertificates / Profile use to display certificate cards).
|
||||
* 2. Persists the certificate record (cert_no/ref_no) via
|
||||
* services/certificate-record.service.js, so course.certificate
|
||||
* is populated immediately instead of only on first PDF download.
|
||||
* 3. Sends a 'certificate_issued' UserNotification.
|
||||
* 4. Marks the row processed_at = NOW() so it never fires again.
|
||||
*
|
||||
* Safety pattern: processed_at is set only after both step 1 and
|
||||
* step 2 succeed. If the process restarts mid-run the row will be
|
||||
* picked up again on the next tick — both DB writes are idempotent.
|
||||
*
|
||||
* Schedule : Every hour on the hour ("0 * * * *"). Registered by
|
||||
* cron/client.cron.js.
|
||||
*
|
||||
* Author: Kenneth Obsequio (@lash0000)
|
||||
* Date Created: Jun. 24, 2026
|
||||
***********************************************************************************************************************************************************************/
|
||||
'use strict';
|
||||
|
||||
const { Op } = require('sequelize');
|
||||
const PendingCertificate = require('../../models/courses/pending_certificate.mdl');
|
||||
const mdl_Achievements = require('../../models/users/achievements.mdl');
|
||||
const UserNotification = require('../../models/notifications/user_notification.mdl');
|
||||
const CronNotificationSetting = require('../../models/notifications/cron_notification_setting.mdl');
|
||||
const { NOTIFICATION_REGISTRY } = require('../../data/notifications.data');
|
||||
const { ensureCertificateRecord } = require('../../services/certificate-record.service');
|
||||
|
||||
async function run() {
|
||||
// Certificate/achievement issuance always happens — only the notification step is skippable.
|
||||
const settings = await CronNotificationSetting.findOne({ where: { job_name: 'issueCertificates' } });
|
||||
const notificationsEnabled = !settings || settings.enabled;
|
||||
|
||||
// ── 1. Fetch all rows ready to process ────────────────────────────────────
|
||||
let rows;
|
||||
try {
|
||||
rows = await PendingCertificate.findAll({
|
||||
where: {
|
||||
issue_at: { [Op.lte]: new Date() },
|
||||
processed_at: null,
|
||||
},
|
||||
raw: true,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[CRON][ISSUE CERTS] Failed to query pending_certificates:', err);
|
||||
return;
|
||||
}
|
||||
|
||||
if (rows.length === 0) return;
|
||||
|
||||
console.log(`[CRON][ISSUE CERTS] Processing ${rows.length} pending certificate(s).`);
|
||||
|
||||
for (const row of rows) {
|
||||
const { pending_id, user_id, course_uuid, course_title } = row;
|
||||
const achKey = `course_completed_${course_uuid}`;
|
||||
|
||||
try {
|
||||
// ── 2. Grant course_completed_<uuid> achievement (idempotent) ──────
|
||||
const existing = await mdl_Achievements.findOne({ where: { user_id, key: achKey } });
|
||||
if (!existing) {
|
||||
await mdl_Achievements.create({
|
||||
user_id,
|
||||
type: 'milestone',
|
||||
key: achKey,
|
||||
label: 'Certificate of Completion',
|
||||
description: course_title ?? '',
|
||||
granted_at: new Date(),
|
||||
metadata: { courseTitle: course_title, courseUuid: course_uuid },
|
||||
});
|
||||
}
|
||||
|
||||
// ── 3. Persist the actual certificate record (cert_no/ref_no) so
|
||||
// course.certificate is populated immediately, instead of only
|
||||
// lazily on first PDF download ────────────────────────────────
|
||||
await ensureCertificateRecord({ userId: user_id, courseId: row.course_id });
|
||||
|
||||
// ── 4. Send certificate_issued notification ─────────────────────────
|
||||
if (notificationsEnabled) {
|
||||
await UserNotification.create({
|
||||
user_id,
|
||||
...NOTIFICATION_REGISTRY.certificate_issued.build({
|
||||
courseTitle: course_title ?? '',
|
||||
courseUuid: course_uuid,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
// ── 5. Mark row processed ─────────────────────────────────────────
|
||||
await PendingCertificate.update(
|
||||
{ processed_at: new Date() },
|
||||
{ where: { pending_id } }
|
||||
);
|
||||
|
||||
console.log(`[CRON][ISSUE CERTS] Issued certificate for user ${user_id} / course ${course_uuid}.`);
|
||||
} catch (err) {
|
||||
// Log and continue — next tick will retry this row
|
||||
if (err?.parent?.code !== '23505') {
|
||||
console.error(`[CRON][ISSUE CERTS] Failed for pending_id ${pending_id}:`, err);
|
||||
} else {
|
||||
// Unique constraint: achievement already exists — still mark processed
|
||||
await PendingCertificate.update(
|
||||
{ processed_at: new Date() },
|
||||
{ where: { pending_id } }
|
||||
).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
name: 'issueCertificates',
|
||||
schedule: '0 * * * *',
|
||||
run,
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name : lift_expired_bans.cron.js
|
||||
* Type : Cron Job
|
||||
* Description : Automatically lifts temporary bans whose expires_at has passed.
|
||||
* Clears is_banned + ban_expires_at on the user record and marks
|
||||
* the ban row as lifted with a system lift_reason.
|
||||
*
|
||||
* Note: The auth middleware also auto-lifts expired bans inline on
|
||||
* the next login attempt, so this cron is a safety net — it keeps
|
||||
* the DB state clean even if a user never logs in again.
|
||||
*
|
||||
* Schedule : Every hour, on the hour ("0 * * * *"). Registered by
|
||||
* cron/admin.cron.js.
|
||||
*
|
||||
* Author: Kenneth Obsequio
|
||||
* Date Created: Jun. 27, 2026
|
||||
***********************************************************************************************************************************************************************/
|
||||
const { Op } = require('sequelize');
|
||||
const sequelize = require('../../config/db.config');
|
||||
const mdl_Users = require('../../models/users/users.mdl');
|
||||
const mdl_UserBans = require('../../models/users/user_bans.mdl');
|
||||
const { sendEmail } = require('../../services/email.service');
|
||||
const { fmtDate } = require('../../utils/datetime.util');
|
||||
|
||||
async function run() {
|
||||
try {
|
||||
const expiredBans = await mdl_UserBans.findAll({
|
||||
where: {
|
||||
ban_type: 'temporary',
|
||||
is_lifted: false,
|
||||
expires_at: { [Op.lte]: new Date() },
|
||||
},
|
||||
});
|
||||
|
||||
if (!expiredBans.length) return;
|
||||
|
||||
const banIds = expiredBans.map((b) => b.ban_id);
|
||||
const userIds = [...new Set(expiredBans.map((b) => Number(b.user_id)))];
|
||||
const usersMap = await mdl_Users.findAll({
|
||||
where: { user_id: userIds },
|
||||
attributes: ['user_id', 'email', 'personal_info'],
|
||||
}).then((rows) => Object.fromEntries(rows.map((u) => [u.user_id, u])));
|
||||
|
||||
await sequelize.transaction(async (t) => {
|
||||
await mdl_UserBans.update(
|
||||
{
|
||||
is_lifted: true,
|
||||
lifted_at: new Date(),
|
||||
lift_reason: 'Automatically lifted — ban period expired.',
|
||||
},
|
||||
{ where: { ban_id: banIds }, transaction: t }
|
||||
);
|
||||
|
||||
await mdl_Users.update(
|
||||
{ is_banned: false, ban_expires_at: null },
|
||||
{ where: { user_id: userIds }, transaction: t }
|
||||
);
|
||||
});
|
||||
|
||||
const dateStr = fmtDate(new Date());
|
||||
userIds.forEach((uid) => {
|
||||
const u = usersMap[uid];
|
||||
if (!u) return;
|
||||
sendEmail({
|
||||
to: u.email,
|
||||
type: 'BAN_LIFTED',
|
||||
data: {
|
||||
name: u.personal_info?.name?.full_name ?? 'User',
|
||||
email: u.email,
|
||||
date: dateStr,
|
||||
},
|
||||
}).catch((err) => console.error('[CRON][LIFT EXPIRED BANS] Email failed:', u.email, err));
|
||||
});
|
||||
|
||||
console.log(`[CRON][LIFT EXPIRED BANS] Lifted ${expiredBans.length} ban(s) for ${userIds.length} user(s).`);
|
||||
} catch (err) {
|
||||
console.error('[CRON][LIFT EXPIRED BANS] Failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
name: 'liftExpiredBans',
|
||||
schedule: '0 * * * *',
|
||||
run,
|
||||
};
|
||||
@@ -0,0 +1,65 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name : retry_stuck_transcodes.cron.js
|
||||
* Type : Cron Job
|
||||
* Description : Safety net for the .mov/.mkv -> faststart .mp4 background
|
||||
* remux (see services/assetTranscode.service.js). Picks up:
|
||||
* - "pending" — the fire-and-forget call in
|
||||
* assets.controller.js#finalizeAssetFromStorage
|
||||
* never actually started (e.g. this process
|
||||
* crashed between the DB commit and the call).
|
||||
* - "processing" for over 30 minutes — the job itself was
|
||||
* running when the process restarted/crashed
|
||||
* mid-remux and never got to flip the status.
|
||||
*
|
||||
* Processes at most 3 per run, sequentially — this runs on a
|
||||
* small droplet, and remuxing is disk/CPU-bound; no reason to
|
||||
* pile up concurrent ffmpeg processes for a background sweep.
|
||||
*
|
||||
* Schedule : Every 10 minutes ("*\/10 * * * *"). Registered by
|
||||
* cron/admin.cron.js.
|
||||
*
|
||||
* Author: Kenneth Obsequio
|
||||
* Date Created: Aug. 1, 2026
|
||||
***********************************************************************************************************************************************************************/
|
||||
const { Op } = require('sequelize');
|
||||
const mdl_Assets = require('../../models/assets/assets.mdl');
|
||||
const { transcodeAsset } = require('../../services/assetTranscode.service');
|
||||
|
||||
const MAX_PER_RUN = 3;
|
||||
const STUCK_PROCESSING_MINUTES = 30;
|
||||
|
||||
async function run() {
|
||||
try {
|
||||
const stuckSince = new Date(Date.now() - STUCK_PROCESSING_MINUTES * 60 * 1000);
|
||||
|
||||
// Demote stale "processing" rows back to "pending" so transcodeAsset()'s
|
||||
// own claim step (pending/failed -> processing) can pick them up again —
|
||||
// it never claims an in-progress "processing" row, by design (avoids
|
||||
// double-processing a job that's actually still running elsewhere).
|
||||
await mdl_Assets.update(
|
||||
{ transcode_status: 'pending' },
|
||||
{ where: { transcode_status: 'processing', updatedAt: { [Op.lt]: stuckSince } } },
|
||||
);
|
||||
|
||||
const candidates = await mdl_Assets.findAll({
|
||||
where: { deletedAt: null, transcode_status: 'pending' },
|
||||
limit: MAX_PER_RUN,
|
||||
});
|
||||
|
||||
if (!candidates.length) return;
|
||||
|
||||
for (const asset of candidates) {
|
||||
await transcodeAsset(asset);
|
||||
}
|
||||
|
||||
console.log(`[CRON][RETRY STUCK TRANSCODES] Processed ${candidates.length} asset(s).`);
|
||||
} catch (err) {
|
||||
console.error('[CRON][RETRY STUCK TRANSCODES] Failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
name: 'retryStuckTranscodes',
|
||||
schedule: '*/10 * * * *',
|
||||
run,
|
||||
};
|
||||
@@ -0,0 +1,114 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name : task_due_soon.cron.js
|
||||
* Type : Cron Job
|
||||
* Description : Emits a learner-facing "task_reminder" UserNotification for
|
||||
* each user who has NOT yet completed a task whose deadline
|
||||
* falls ~24h from now. Unlike task_overdue.cron.js (a single
|
||||
* admin-facing status flip), completion here is per-user, so
|
||||
* each candidate task's assigned-group members are checked
|
||||
* individually via checkTaskCompletion before notifying.
|
||||
*
|
||||
* "Falls ~24h from now" = deadline between (now + 23h) and
|
||||
* (now + 24h), a 1-hour sliding window — since this runs
|
||||
* hourly, each task's deadline crosses that window exactly
|
||||
* once, giving a single reminder ~24h before it's due
|
||||
* without needing a separate "already notified" table.
|
||||
*
|
||||
* Schedule : Every hour, 10 minutes past ("10 * * * *"). Registered by
|
||||
* cron/client.cron.js.
|
||||
*
|
||||
* Author: Kenneth Obsequio (@lash0000)
|
||||
* Date Created: Jul. 9, 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 CronNotificationSetting = require('../../models/notifications/cron_notification_setting.mdl');
|
||||
const { NOTIFICATION_REGISTRY } = require('../../data/notifications.data');
|
||||
const { checkTaskCompletion } = require('../../controllers/client/task.controller');
|
||||
|
||||
const WINDOW_START_MS = 23 * 60 * 60 * 1000;
|
||||
const WINDOW_END_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
async function run() {
|
||||
const settings = await CronNotificationSetting.findOne({ where: { job_name: 'taskDueSoon' } });
|
||||
if (settings && !settings.enabled) return;
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
let dueSoonTasks;
|
||||
try {
|
||||
dueSoonTasks = await Task.findAll({
|
||||
attributes: ['task_id', 'task_list_id', 'name', 'deadline'],
|
||||
where: {
|
||||
deadline: {
|
||||
[Op.gte]: new Date(now + WINDOW_START_MS),
|
||||
[Op.lt]: new Date(now + WINDOW_END_MS),
|
||||
},
|
||||
status: { [Op.notIn]: ['completed', 'overdue'] },
|
||||
},
|
||||
raw: true,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[CRON][TASK DUE SOON] Failed to query upcoming deadlines:', err);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!dueSoonTasks.length) return;
|
||||
|
||||
console.log(`[CRON][TASK DUE SOON] ${dueSoonTasks.length} task(s) due in ~24h — resolving affected users.`);
|
||||
|
||||
try {
|
||||
const taskListIds = [...new Set(dueSoonTasks.map((t) => t.task_list_id))];
|
||||
const memberRows = await sequelize.query(
|
||||
`SELECT DISTINCT tlg.task_list_id, 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 }
|
||||
);
|
||||
|
||||
const usersByTaskList = new Map();
|
||||
for (const row of memberRows) {
|
||||
const list = usersByTaskList.get(row.task_list_id) ?? [];
|
||||
list.push(row.user_id);
|
||||
usersByTaskList.set(row.task_list_id, list);
|
||||
}
|
||||
|
||||
const now2 = new Date();
|
||||
let notifiedCount = 0;
|
||||
|
||||
for (const task of dueSoonTasks) {
|
||||
const candidateUserIds = usersByTaskList.get(task.task_list_id) ?? [];
|
||||
if (!candidateUserIds.length) continue;
|
||||
|
||||
const incompleteUserIds = [];
|
||||
for (const userId of candidateUserIds) {
|
||||
const done = await checkTaskCompletion(userId, task.task_id);
|
||||
if (!done) incompleteUserIds.push(userId);
|
||||
}
|
||||
if (!incompleteUserIds.length) continue;
|
||||
|
||||
const notify = NOTIFICATION_REGISTRY.task_reminder.build({
|
||||
taskName: task.name, deadline: task.deadline,
|
||||
});
|
||||
await UserNotification.bulkCreate(
|
||||
incompleteUserIds.map((user_id) => ({ user_id, ...notify, seen: false, createdAt: now2, updatedAt: now2 })),
|
||||
{ validate: false }
|
||||
);
|
||||
notifiedCount += incompleteUserIds.length;
|
||||
}
|
||||
|
||||
console.log(`[CRON][TASK DUE SOON] Sent ${notifiedCount} reminder(s) across ${dueSoonTasks.length} task(s).`);
|
||||
} catch (err) {
|
||||
console.error('[CRON][TASK DUE SOON] Failed to emit reminders:', err);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
name: 'taskDueSoon',
|
||||
schedule: '10 * * * *',
|
||||
run,
|
||||
};
|
||||
@@ -0,0 +1,98 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name : task_overdue.cron.js
|
||||
* Type : Cron Job
|
||||
* Description : Flips Task.status to a configurable target status — 'overdue'
|
||||
* (default before Jul 2026) or 'completed' (current default) —
|
||||
* once its deadline has passed, provided it isn't already
|
||||
* 'completed' or 'overdue'. The target status is an admin-
|
||||
* configurable setting (cron_notification_settings.target_status
|
||||
* for job_name 'taskOverdue'; NULL is treated as 'completed').
|
||||
* 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.
|
||||
*
|
||||
* Every task this job touches also gets auto_marked_at set to
|
||||
* the current time — this is the ONLY writer of that column,
|
||||
* so downstream consumers (e.g. cron/jobs/user_notifications.cron.js)
|
||||
* can distinguish "the system just did this" from a user's own
|
||||
* legitimate completion. A task already sitting in 'overdue' or
|
||||
* 'completed' is never reclaimed by this sweep even if the
|
||||
* target status changes later — this job only ever moves tasks
|
||||
* OUT of 'pending'/'in_progress', never between the two terminal
|
||||
* states.
|
||||
*
|
||||
* 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 CronNotificationSetting = require('../../models/notifications/cron_notification_setting.mdl');
|
||||
const { NOTIFICATION_REGISTRY } = require('../../data/notifications.data');
|
||||
|
||||
// ─── The actual sweep ────────────────────────────────────────────────────────
|
||||
async function run() {
|
||||
// ── 0. Load configured target status (defaults to 'completed') ───────────
|
||||
let settings = null;
|
||||
let targetStatus = 'completed';
|
||||
try {
|
||||
settings = await CronNotificationSetting.findOne({ where: { job_name: 'taskOverdue' } });
|
||||
if (settings?.target_status === 'overdue' || settings?.target_status === 'completed') {
|
||||
targetStatus = settings.target_status;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[CRON][TASK OVERDUE] Failed to load target_status setting, defaulting to "completed":', err);
|
||||
}
|
||||
|
||||
// ── 1. Primary: flip task statuses ───────────────────────────────────────
|
||||
let affectedCount = 0;
|
||||
|
||||
try {
|
||||
[affectedCount] = await Task.update(
|
||||
{ status: targetStatus, auto_marked_at: new Date() },
|
||||
{
|
||||
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 ${targetStatus}.`);
|
||||
|
||||
// ── 2. Secondary: admin notification — isolated, never blocks step 1 ─────
|
||||
// Skippable via /admin/notifications/settings — the status flip above always happens either way.
|
||||
try {
|
||||
if (settings && !settings.enabled) return;
|
||||
|
||||
await AdminNotification.create(
|
||||
NOTIFICATION_REGISTRY.task_overdue.build({ count: affectedCount, targetStatus })
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('[CRON][TASK OVERDUE] Failed to insert admin notification:', err);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
name: 'taskOverdue',
|
||||
schedule: '0 * * * *',
|
||||
run,
|
||||
};
|
||||
@@ -0,0 +1,137 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* 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 the admin
|
||||
* taskOverdue cron JUST auto-flipped in the last hour — to
|
||||
* either 'overdue' or 'completed', depending on that job's
|
||||
* configured target_status.
|
||||
*
|
||||
* 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 auto-flipped" = auto_marked_at is within the last 65
|
||||
* minutes (1-hour window + 5-min drift buffer). auto_marked_at
|
||||
* is written ONLY by cron/jobs/task_overdue.cron.js, never by a
|
||||
* user's own completion flow, so this can't misfire on a task a
|
||||
* user legitimately just completed themselves.
|
||||
*
|
||||
* Rows are grouped by status: 'overdue' tasks get the existing
|
||||
* "Tasks Overdue" notification, 'completed' tasks get a
|
||||
* separate "Tasks Auto-Completed" notification. In practice a
|
||||
* single run is homogeneous (target_status is one job-wide
|
||||
* setting), but the grouping keeps this correct even if the
|
||||
* setting changed mid-window.
|
||||
*
|
||||
* When a status group contains exactly one task, its taskId/
|
||||
* taskListId are included in the notification data (plus each
|
||||
* recipient's own group_id) so the client can deep-link
|
||||
* straight to that task. A multi-task batch can't pick just
|
||||
* one task to link to, so it falls back to no link.
|
||||
*
|
||||
* 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 CronNotificationSetting = require('../../models/notifications/cron_notification_setting.mdl');
|
||||
const { NOTIFICATION_REGISTRY } = require('../../data/notifications.data');
|
||||
|
||||
const WINDOW_MS = 65 * 60 * 1000; // 65-minute lookback
|
||||
|
||||
async function run() {
|
||||
// Entire job exists to emit this notification — skippable via /admin/notifications/settings.
|
||||
const settings = await CronNotificationSetting.findOne({ where: { job_name: 'userNotifications' } });
|
||||
if (settings && !settings.enabled) return;
|
||||
|
||||
// ── 1. Find tasks the admin cron JUST auto-flipped in the last 65 minutes ─
|
||||
let recentlyAutoMarked;
|
||||
try {
|
||||
recentlyAutoMarked = await Task.findAll({
|
||||
attributes: ['task_id', 'task_list_id', 'name', 'status'],
|
||||
where: {
|
||||
status: { [Op.in]: ['overdue', 'completed'] },
|
||||
auto_marked_at: { [Op.gte]: new Date(Date.now() - WINDOW_MS) },
|
||||
},
|
||||
raw: true,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[CRON][USER NOTIFY] Failed to query recently auto-marked tasks:', err);
|
||||
return;
|
||||
}
|
||||
|
||||
if (recentlyAutoMarked.length === 0) return;
|
||||
|
||||
console.log(`[CRON][USER NOTIFY] ${recentlyAutoMarked.length} recently auto-marked task(s) — resolving affected users.`);
|
||||
|
||||
// ── 2. Resolve affected users via task_list_groups → user_group_members ───
|
||||
try {
|
||||
const byStatus = {
|
||||
overdue: recentlyAutoMarked.filter(t => t.status === 'overdue'),
|
||||
completed: recentlyAutoMarked.filter(t => t.status === 'completed'),
|
||||
};
|
||||
|
||||
const now = new Date();
|
||||
|
||||
for (const [status, tasks] of Object.entries(byStatus)) {
|
||||
if (tasks.length === 0) continue;
|
||||
|
||||
const taskListIds = [...new Set(tasks.map(t => t.task_list_id))];
|
||||
|
||||
// DISTINCT ON picks one group per user (deterministic — lowest group_id)
|
||||
// so each affected user gets a single notification even if they belong
|
||||
// to more than one group assigned to these task lists.
|
||||
const affectedUsers = await sequelize.query(
|
||||
`SELECT DISTINCT ON (ugm.user_id) ugm.user_id, ugm.group_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)
|
||||
ORDER BY ugm.user_id, ugm.group_id`,
|
||||
{ replacements: { taskListIds }, type: QueryTypes.SELECT }
|
||||
);
|
||||
|
||||
if (affectedUsers.length === 0) continue;
|
||||
|
||||
const count = tasks.length;
|
||||
const registryKey = status === 'completed' ? 'user_task_auto_completed' : 'user_task_overdue';
|
||||
// A single-task batch can deep-link straight to that task; a multi-task
|
||||
// batch can't pick just one, so it falls back to the task-list link.
|
||||
const single = count === 1 ? tasks[0] : null;
|
||||
const notify = NOTIFICATION_REGISTRY[registryKey].build({
|
||||
count,
|
||||
task_list_ids: taskListIds,
|
||||
taskId: single?.task_id ?? null,
|
||||
taskListId: single?.task_list_id ?? null,
|
||||
});
|
||||
|
||||
await UserNotification.bulkCreate(
|
||||
affectedUsers.map(({ user_id, group_id }) => ({
|
||||
user_id,
|
||||
...notify,
|
||||
data: { ...notify.data, groupId: single ? group_id : null },
|
||||
seen: false,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})),
|
||||
{ validate: false }
|
||||
);
|
||||
|
||||
console.log(`[CRON][USER NOTIFY] Notified ${affectedUsers.length} user(s) about ${count} ${status} task(s).`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[CRON][USER NOTIFY] Failed to emit user notifications:', err);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
name: 'userNotifications',
|
||||
schedule: '5 * * * *',
|
||||
run,
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name: achievements.data.js
|
||||
* Type of Program: Data
|
||||
* Description: Static config for the achievement system that isn't part of the
|
||||
* admin-managed catalog. The achievement catalog itself (keys, type,
|
||||
* label, description, icon) now lives in the achievement_definitions
|
||||
* table — see models/users/achievement_definitions.mdl.js and
|
||||
* controllers/admin/achievements.controller.js for CRUD.
|
||||
*
|
||||
* Author: Kenneth Obsequio (@lash0000)
|
||||
* Date Created: Jun. 19, 2026
|
||||
***********************************************************************************************************************************************************************/
|
||||
'use strict';
|
||||
|
||||
const EARLY_ACCESS_CUTOFF = new Date('2026-12-31T23:59:59Z');
|
||||
|
||||
module.exports = {
|
||||
EARLY_ACCESS_CUTOFF,
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name: cronPresets.data.js
|
||||
* Type of Program: Data
|
||||
* Description: Friendly schedule presets for admin-configurable notification
|
||||
* crons. The UI only ever offers these six options — no raw cron
|
||||
* expressions are accepted from the client, so `updateSetting`
|
||||
* in notificationSettings.controller.js validates against this map.
|
||||
*
|
||||
* Author: Kenneth Obsequio (@lash0000)
|
||||
* Date Created: Jul. 2, 2026
|
||||
***********************************************************************************************************************************************************************/
|
||||
'use strict';
|
||||
|
||||
const CRON_PRESETS = {
|
||||
every_minute: '* * * * *',
|
||||
every_5_min: '*/5 * * * *',
|
||||
every_15_min: '*/15 * * * *',
|
||||
hourly: '0 * * * *',
|
||||
every_6_hours: '0 */6 * * *',
|
||||
daily: '0 0 * * *',
|
||||
};
|
||||
|
||||
// Reverse lookup — cron string -> preset key (used to label a job's current schedule)
|
||||
const CRON_PRESET_BY_EXPRESSION = Object.fromEntries(
|
||||
Object.entries(CRON_PRESETS).map(([key, expr]) => [expr, key])
|
||||
);
|
||||
|
||||
module.exports = { CRON_PRESETS, CRON_PRESET_BY_EXPRESSION };
|
||||
@@ -0,0 +1,198 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name: email_body.data.js
|
||||
* Type of Program: Data
|
||||
* Description: Hardcoded subject/body per email type, keyed by the `type`
|
||||
* string passed to services/email.service.js's sendEmail(). To
|
||||
* add or edit an email, edit the template function here directly
|
||||
* and redeploy — there is no admin UI or DB table for this.
|
||||
* Author: rgrgogu, Kenneth Obsequio (@lash0000)
|
||||
* Date Created: Oct. 6, 2025
|
||||
***********************************************************************************************************************************************************************/
|
||||
const FONT = 'font-family: Arial, sans-serif; font-size: 14px; color: #000;';
|
||||
|
||||
const wrap = (body) => `
|
||||
<html>
|
||||
<body style="${FONT} line-height: 1.6;">
|
||||
${body.trim()}
|
||||
<br><br>
|
||||
<p style="margin: 0;">Regards,<br>Philproperties IT Team</p>
|
||||
<p style="margin: 0; font-size: 12px; color: #555;">This is an automated message from STARR System. Please do not reply.</p>
|
||||
</body>
|
||||
</html>`.trim();
|
||||
|
||||
const emailTemplates = {
|
||||
OTP: ({ otp, expiryMinutes = 10 }) => ({
|
||||
subject: "Email OTP Verification - STARR System",
|
||||
html: wrap(`
|
||||
<p>Dear User,</p>
|
||||
<p>Please use the One-Time Password (OTP) below to verify your email address. This code is valid for <strong>${expiryMinutes} minutes</strong>.</p>
|
||||
<p style="font-size: 28px; font-weight: bold; letter-spacing: 6px;">${otp}</p>
|
||||
<p>For security reasons, please do not share this code with anyone. If you did not request this, please contact the administrator.</p>
|
||||
`),
|
||||
}),
|
||||
|
||||
LOGIN_OTP: ({ otp, expiryMinutes = 10 }) => ({
|
||||
subject: "Sign-In Verification Code - STARR System",
|
||||
html: wrap(`
|
||||
<p>Dear User,</p>
|
||||
<p>Use the One-Time Password (OTP) below to confirm this sign-in. This code is valid for <strong>${expiryMinutes} minutes</strong>.</p>
|
||||
<p style="font-size: 28px; font-weight: bold; letter-spacing: 6px;">${otp}</p>
|
||||
<p>For security reasons, please do not share this code with anyone. If you did not attempt to log in, please contact the administrator.</p>
|
||||
`),
|
||||
}),
|
||||
|
||||
RESET_PASSWORD_OTP: ({ otp, expiryMinutes = 10 }) => ({
|
||||
subject: "Password Reset Code - STARR System",
|
||||
html: wrap(`
|
||||
<p>Dear User,</p>
|
||||
<p>Use the One-Time Password (OTP) below to reset your account password. This code is valid for <strong>${expiryMinutes} minutes</strong>.</p>
|
||||
<p style="font-size: 28px; font-weight: bold; letter-spacing: 6px;">${otp}</p>
|
||||
<p>For security reasons, please do not share this code with anyone. If you did not request a password reset, please ignore this email or contact the administrator.</p>
|
||||
`),
|
||||
}),
|
||||
|
||||
WELCOME: ({ name }) => ({
|
||||
subject: "Welcome to STARR System",
|
||||
html: wrap(`
|
||||
<p>Dear ${name},</p>
|
||||
<p>We are pleased to welcome you to the STARR System. Your account has been successfully created and is now ready for use.</p>
|
||||
<p>You may now access your dashboard and begin using the available services.</p>
|
||||
<p>We look forward to supporting you.</p>
|
||||
`),
|
||||
}),
|
||||
|
||||
PASSWORD_CHANGED: () => ({
|
||||
subject: "Password Update Confirmation - STARR System",
|
||||
html: wrap(`
|
||||
<p>Dear User,</p>
|
||||
<p>This is to confirm that your account password has been successfully changed.</p>
|
||||
<p>If you did not perform this action, please reset your password immediately or contact support.</p>
|
||||
<p>For your security, we recommend using a strong and unique password.</p>
|
||||
`),
|
||||
}),
|
||||
|
||||
ADDED_TO_GROUP: ({ groupName }) => ({
|
||||
subject: "Group Assignment Notification - STARR System",
|
||||
html: wrap(`
|
||||
<p>Dear User,</p>
|
||||
<p>You have been assigned to the group <strong>${groupName}</strong> in the STARR System.</p>
|
||||
<p>This assignment grants you access to shared resources and collaboration tools within the group.</p>
|
||||
<p>Please log in to your account to view group details.</p>
|
||||
`),
|
||||
}),
|
||||
|
||||
TASK_ASSIGNED: ({ taskTitle, dueDate }) => ({
|
||||
subject: "New Task Assignment - STARR System",
|
||||
html: wrap(`
|
||||
<p>Dear User,</p>
|
||||
<p>You have been assigned a new task in the STARR System.</p>
|
||||
<table style="${FONT}">
|
||||
<tr><td><strong>Task</strong></td><td>${taskTitle}</td></tr>
|
||||
<tr><td><strong>Due Date</strong></td><td>${dueDate}</td></tr>
|
||||
</table>
|
||||
<p>Kindly ensure completion within the specified timeframe.</p>
|
||||
`),
|
||||
}),
|
||||
|
||||
BAN_LIFTED: ({ name, email, date }) => ({
|
||||
subject: "Account Suspension Lifted - STARR System",
|
||||
html: wrap(`
|
||||
<p>Dear ${name},</p>
|
||||
<p>We are writing to inform you that the suspension on your account (<strong>${email}</strong>) has been lifted effective <strong>${date}</strong>.</p>
|
||||
<p>You may now log in and resume access to all services within the STARR System.</p>
|
||||
<p>If you have any concerns, please do not hesitate to contact your administrator.</p>
|
||||
`),
|
||||
}),
|
||||
|
||||
BANNED: ({ name, email, date, reason, ban_type }) => ({
|
||||
subject: "Account Suspension Notice - STARR System",
|
||||
html: wrap(`
|
||||
<p>Dear ${name},</p>
|
||||
<p>Your account (<strong>${email}</strong>) has been ${ban_type === 'permanent' ? 'permanently' : 'temporarily'} suspended from the STARR System effective <strong>${date}</strong>.</p>
|
||||
<table style="${FONT}">
|
||||
<tr><td><strong>Reason</strong></td><td>${reason}</td></tr>
|
||||
<tr><td><strong>Duration</strong></td><td>${ban_type === 'permanent' ? 'Permanent' : 'Temporary'}</td></tr>
|
||||
</table>
|
||||
<p>During this period, access to all system services has been revoked.${ban_type === 'permanent' ? '' : ' This suspension may be lifted upon review by the administrator.'}</p>
|
||||
<p>If you believe this was made in error, please contact your administrator.</p>
|
||||
`),
|
||||
}),
|
||||
|
||||
MADE_ADMIN: ({ name, email }) => ({
|
||||
subject: "You've Been Granted Administrator Access - STARR System",
|
||||
html: wrap(`
|
||||
<p>Dear ${name},</p>
|
||||
<p><strong>Congratulations!</strong></p>
|
||||
<p>We're pleased to inform you that your account has been granted Administrator access to the platform.</p>
|
||||
<p>As an Administrator, you now have access to management features that allow you to oversee users, courses, content, and other administrative functions.</p>
|
||||
<table style="${FONT}">
|
||||
<tr><td><strong>Email</strong></td><td>${email}</td></tr>
|
||||
<tr><td><strong>Role</strong></td><td>Administrator</td></tr>
|
||||
</table>
|
||||
<p>Please sign in using your existing credentials. If you have any questions or require assistance, don't hesitate to contact our support team.</p>
|
||||
`),
|
||||
}),
|
||||
|
||||
DEMOTED_ADMIN: ({ name, email }) => ({
|
||||
subject: "Your Account Role Has Been Updated - STARR System",
|
||||
html: wrap(`
|
||||
<p>Dear ${name},</p>
|
||||
<p>This email is to inform you that your account has been updated.</p>
|
||||
<p>Your Administrator privileges have been removed, and your account has been returned to a User role.</p>
|
||||
<table style="${FONT}">
|
||||
<tr><td><strong>Email</strong></td><td>${email}</td></tr>
|
||||
<tr><td><strong>Current Role</strong></td><td>User</td></tr>
|
||||
</table>
|
||||
<p>You can continue using the platform with the permissions available to standard users. If you believe this change was made in error or have any questions, please contact your system administrator.</p>
|
||||
`),
|
||||
}),
|
||||
|
||||
ADD_STAFF: ({ name, email, password, expiryHours = 24 }) => ({
|
||||
subject: "Your Staff Account Has Been Created - STARR System",
|
||||
html: wrap(`
|
||||
<p>Dear ${name},</p>
|
||||
<p>Your staff account has been successfully created in the STARR System. Below are your login credentials:</p>
|
||||
<table style="${FONT}">
|
||||
<tr><td><strong>Email</strong></td><td>${email}</td></tr>
|
||||
<tr><td><strong>Password</strong></td><td>${password}</td></tr>
|
||||
</table>
|
||||
<p>This temporary password is valid for <strong>${expiryHours} hours</strong>. You will be required to change it upon first login.</p>
|
||||
<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>
|
||||
<p>For security, please do not share your credentials with anyone. If you need a new password, contact your administrator.</p>
|
||||
`),
|
||||
}),
|
||||
|
||||
TIER_ACCESS_REVOKED: ({ name, label, date }) => ({
|
||||
subject: "Your Subscription Access Has Been Revoked - STARR System",
|
||||
html: wrap(`
|
||||
<p>Dear ${name},</p>
|
||||
<p>We're writing to inform you that your access under the following subscription plan has been revoked by our team, effective <strong>${date}</strong>:</p>
|
||||
<table style="${FONT}">
|
||||
<tr><td><strong>Plan</strong></td><td>${label}</td></tr>
|
||||
<tr><td><strong>Status</strong></td><td>Access Revoked</td></tr>
|
||||
<tr><td><strong>Refund</strong></td><td>Not applicable — this subscription is non-refundable</td></tr>
|
||||
</table>
|
||||
<p>Your account no longer has access to the courses, units, and lessons included in this plan. If you were not concurrently subscribed to another active plan, your account has reverted to the Free subscription.</p>
|
||||
<p>If you believe this was done in error or have questions about this change, please contact our support team.</p>
|
||||
`),
|
||||
}),
|
||||
|
||||
REFUND_PROCESSED: ({ name, label, amount, currency, date, refundId }) => ({
|
||||
subject: "Your Refund Has Been Processed - STARR System",
|
||||
html: wrap(`
|
||||
<p>Dear ${name},</p>
|
||||
<p>We're writing to confirm that your refund request has been processed as of <strong>${date}</strong>:</p>
|
||||
<table style="${FONT}">
|
||||
<tr><td><strong>Plan</strong></td><td>${label}</td></tr>
|
||||
<tr><td><strong>Amount Refunded</strong></td><td>${currency} ${amount}</td></tr>
|
||||
<tr><td><strong>Refund ID</strong></td><td>${refundId}</td></tr>
|
||||
<tr><td><strong>Status</strong></td><td>Refunded</td></tr>
|
||||
</table>
|
||||
<p>Your access to the courses, units, and lessons included in this plan has been revoked effective immediately. If you were not concurrently subscribed to another active plan, your account has reverted to the Free subscription.</p>
|
||||
<p>Please allow a few business days for the refunded amount to reflect on your original payment method, depending on your provider.</p>
|
||||
<p>If you have any questions about this refund, please contact our support team.</p>
|
||||
`),
|
||||
}),
|
||||
};
|
||||
|
||||
module.exports = { emailTemplates };
|
||||
@@ -0,0 +1,432 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* 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_registration, nogrp_user_registered
|
||||
* User : task_requirements_updated, task_submissions_closed, task_submissions_reopened, user_task_overdue, user_task_auto_completed, task_reminder, achievement,
|
||||
* course_unlocked, course_completed, certificate_issued, welcome,
|
||||
* nogrp_welcome, assessment_updated, announcement, tier_expired,
|
||||
* tier_plan_archived, tier_plan_access_revoked, payment_refunded,
|
||||
* task_submission_reviewed, task_assigned, task_completed
|
||||
* Both : broadcast (admin-composed, sent via notification_broadcasts CRUD)
|
||||
*
|
||||
* Author: Kenneth Obsequio (@lash0000)
|
||||
* Date Created: Jun. 19, 2026
|
||||
* Date Modified: Jul. 11, 2026 — reverted from notification_templates (DB-editable) back to hardcoded
|
||||
***********************************************************************************************************************************************************************/
|
||||
'use strict';
|
||||
|
||||
const { fmtDate } = require('../utils/datetime.util');
|
||||
|
||||
const NOTIFICATION_REGISTRY = {
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// ADMIN notifications (scope: 'admin')
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// ── Task ──────────────────────────────────────────────────────────────────
|
||||
task_overdue: {
|
||||
type: 'task_overdue',
|
||||
scope: 'admin',
|
||||
trigger: 'cron',
|
||||
build({ count, task_list_ids = [], targetStatus = 'overdue' }) {
|
||||
const label = targetStatus === 'completed' ? 'completed' : 'overdue';
|
||||
return {
|
||||
type: 'task_overdue',
|
||||
title: targetStatus === 'completed' ? 'Tasks Auto-Completed' : 'Tasks Overdue',
|
||||
message: `${count} task${count === 1 ? ' was' : 's were'} automatically marked as ${label}.`,
|
||||
data: { count, task_list_ids, target_status: targetStatus },
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
// ── 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 },
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
// ── Unaffiliated User Registration ───────────────────────────────────────
|
||||
nogrp_user_registered: {
|
||||
type: 'nogrp_user_registered',
|
||||
scope: 'admin',
|
||||
trigger: 'event',
|
||||
build({ userEmail, regType }) {
|
||||
return {
|
||||
type: 'nogrp_user_registered',
|
||||
title: 'New Unaffiliated User',
|
||||
message: `A new user (${userEmail}) registered via ${regType} without a group code and was placed in the default group.`,
|
||||
data: { userEmail, regType },
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// USER notifications (scope: 'user')
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// ── Task ──────────────────────────────────────────────────────────────────
|
||||
task_requirements_updated: {
|
||||
type: 'task',
|
||||
scope: 'user',
|
||||
trigger: 'event',
|
||||
build({ taskName, taskListId = null, groupId = null, taskId = null }) {
|
||||
return {
|
||||
type: 'task',
|
||||
title: 'Task Updated',
|
||||
message: `The requirements for "${taskName}" have been updated by your administrator.`,
|
||||
data: { taskName, taskListId, groupId, taskId },
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
task_submissions_closed: {
|
||||
type: 'task',
|
||||
scope: 'user',
|
||||
trigger: 'event',
|
||||
build({ taskName, taskListId = null, groupId = null, taskId = null }) {
|
||||
return {
|
||||
type: 'task',
|
||||
title: 'Submissions Closed',
|
||||
message: `"${taskName}" is no longer accepting submissions.`,
|
||||
data: { taskName, taskListId, groupId, taskId },
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
task_submissions_reopened: {
|
||||
type: 'task',
|
||||
scope: 'user',
|
||||
trigger: 'event',
|
||||
build({ taskName, taskListId = null, groupId = null, taskId = null }) {
|
||||
return {
|
||||
type: 'task',
|
||||
title: 'Submissions Reopened',
|
||||
message: `"${taskName}" is accepting submissions again.`,
|
||||
data: { taskName, taskListId, groupId, taskId },
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
user_task_overdue: {
|
||||
type: 'task',
|
||||
scope: 'user',
|
||||
trigger: 'cron',
|
||||
build({ count, task_list_ids = [], taskListId = null, taskId = null }) {
|
||||
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, taskListId, taskId },
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
user_task_auto_completed: {
|
||||
type: 'task',
|
||||
scope: 'user',
|
||||
trigger: 'cron',
|
||||
build({ count, task_list_ids = [], taskListId = null, taskId = null }) {
|
||||
const label = count === 1 ? '1 task has' : `${count} tasks have`;
|
||||
return {
|
||||
type: 'task',
|
||||
title: 'Tasks Auto-Completed',
|
||||
message: `${label} passed their deadline and been automatically marked as completed.`,
|
||||
data: { count, task_list_ids, taskListId, taskId },
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
task_reminder: {
|
||||
type: 'task',
|
||||
scope: 'user',
|
||||
trigger: 'cron',
|
||||
build({ taskName, deadline, taskListId = null, groupId = null }) {
|
||||
return {
|
||||
type: 'task',
|
||||
title: 'Task Deadline Approaching',
|
||||
message: `"${taskName}" is due on ${fmtDate(deadline)}.`,
|
||||
data: { taskName, deadline, taskListId, groupId },
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
// ── Task submission review (approve/reject) ─────────────────────────────
|
||||
task_submission_reviewed: {
|
||||
type: 'task',
|
||||
scope: 'user',
|
||||
trigger: 'event',
|
||||
build({ taskName, status, review_note = null }) {
|
||||
const statusLabel = status === 'approved' ? 'approved' : 'rejected';
|
||||
const reviewNoteSuffix = review_note ? ` Note: ${review_note}` : '';
|
||||
return {
|
||||
type: 'task',
|
||||
title: 'Submission Reviewed',
|
||||
message: `Your submission for "${taskName}" was ${statusLabel}.${reviewNoteSuffix}`,
|
||||
data: { taskName, status, review_note },
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
// ── Task list assignment (group added to a task list) ───────────────────
|
||||
task_assigned: {
|
||||
type: 'task',
|
||||
scope: 'user',
|
||||
trigger: 'event',
|
||||
build({ taskListName, taskCount }) {
|
||||
return {
|
||||
type: 'task',
|
||||
title: 'New Task Assigned',
|
||||
message: `You have been assigned "${taskListName}" — ${taskCount} task(s) to complete.`,
|
||||
data: { taskListName, taskCount },
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
// ── Per-task completion (0→1 transition) ─────────────────────────────────
|
||||
task_completed: {
|
||||
type: 'task',
|
||||
scope: 'user',
|
||||
trigger: 'event',
|
||||
build({ taskName }) {
|
||||
return {
|
||||
type: 'task',
|
||||
title: 'Task Completed',
|
||||
message: `You completed "${taskName}".`,
|
||||
data: { taskName },
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
// ── Achievement — title/message come from the achievement definition itself ─
|
||||
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, courseUuid = null }) {
|
||||
return {
|
||||
type: 'course',
|
||||
title: 'New Course Available',
|
||||
message: `"${courseTitle}" has been added to your learning library.`,
|
||||
data: { courseTitle, courseUuid },
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
course_completed: {
|
||||
type: 'course',
|
||||
scope: 'user',
|
||||
trigger: 'event',
|
||||
build({ courseTitle, courseUuid = null }) {
|
||||
return {
|
||||
type: 'course',
|
||||
title: 'Course Completed',
|
||||
message: `Great job! You've completed "${courseTitle}". Your certificate will be issued within the next hour.`,
|
||||
data: { courseTitle, courseUuid },
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
certificate_issued: {
|
||||
type: 'course',
|
||||
scope: 'user',
|
||||
trigger: 'cron',
|
||||
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, groupId = null }) {
|
||||
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, groupId },
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
// ── No-Group Welcome ──────────────────────────────────────────────────────
|
||||
nogrp_welcome: {
|
||||
type: 'announcement',
|
||||
scope: 'user',
|
||||
trigger: 'event',
|
||||
build() {
|
||||
return {
|
||||
type: 'announcement',
|
||||
title: "You're Not in a Group Yet",
|
||||
message: 'You are currently in the default group. Contact an administrator to be assigned to your team.',
|
||||
data: { groupCode: 'NOGRP' },
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
// ── Assessment ────────────────────────────────────────────────────────────
|
||||
assessment_updated: {
|
||||
type: 'assessment',
|
||||
scope: 'user',
|
||||
trigger: 'event',
|
||||
build({ assessmentTitle, courseTitle, courseUuid = null }) {
|
||||
return {
|
||||
type: 'assessment',
|
||||
title: 'Assessment Updated',
|
||||
message: `The administrator has updated the "${assessmentTitle || 'Course Assessment'}" in "${courseTitle || 'your course'}". Your current session is still valid — continue where you left off.`,
|
||||
data: { assessmentTitle, courseTitle, courseUuid },
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
// ── Platform ──────────────────────────────────────────────────────────────
|
||||
announcement: {
|
||||
type: 'announcement',
|
||||
scope: 'user',
|
||||
trigger: 'manual',
|
||||
build({ title, body }) {
|
||||
return {
|
||||
type: 'announcement',
|
||||
title,
|
||||
message: body,
|
||||
data: {},
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
// ── Broadcast (admin-composed, manual) — title/message typed per-send via
|
||||
// the notification_broadcasts CRUD, not a fixed template ─────────────────
|
||||
broadcast: {
|
||||
type: 'announcement',
|
||||
scope: 'both',
|
||||
trigger: 'manual',
|
||||
build({ title, message, targetType = null, targetId = null, groupId = null, linkUrl = null, linkLabel = null }) {
|
||||
return {
|
||||
type: 'announcement',
|
||||
title,
|
||||
message,
|
||||
data: { targetType, targetId, groupId, linkUrl, linkLabel },
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
// ── Tier ──────────────────────────────────────────────────────────────────
|
||||
tier_expired: {
|
||||
type: 'tier_expired',
|
||||
scope: 'user',
|
||||
trigger: 'cron',
|
||||
build({ tier, label, planId = null }) {
|
||||
return {
|
||||
type: 'tier_expired',
|
||||
title: 'Subscription Expired',
|
||||
message: `Your ${label ?? tier} plan has expired. Renew to keep access.`,
|
||||
data: { tier, label, planId },
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
tier_plan_archived: {
|
||||
type: 'tier_plan_archived',
|
||||
scope: 'user',
|
||||
trigger: 'event',
|
||||
build({ label, planId = null }) {
|
||||
return {
|
||||
type: 'tier_plan_archived',
|
||||
title: 'Plan Archived',
|
||||
message: `Your "${label}" plan has been archived and is no longer available for new subscriptions. Your current access is unaffected until it expires.`,
|
||||
data: { label, planId },
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
// Distinct from tier_plan_archived above — that one explicitly says access
|
||||
// is unaffected, which would be false here, so an admin force-revoke
|
||||
// always fires THIS instead of (never alongside) tier_plan_archived.
|
||||
tier_plan_access_revoked: {
|
||||
type: 'tier_plan_access_revoked',
|
||||
scope: 'user',
|
||||
trigger: 'event',
|
||||
build({ label, planId = null }) {
|
||||
return {
|
||||
type: 'tier_plan_access_revoked',
|
||||
title: 'Subscription Access Revoked',
|
||||
message: `Your access under the "${label}" plan has been revoked by an administrator, effective immediately. This subscription is non-refundable.`,
|
||||
data: { label, planId },
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
// Fired when a user self-serves a refund within their plan's refund window
|
||||
// (controllers/client/tiers.controller.js refundOrder) — distinct from
|
||||
// tier_plan_access_revoked above, which is an admin-initiated, non-refundable cutoff.
|
||||
payment_refunded: {
|
||||
type: 'payment_refunded',
|
||||
scope: 'user',
|
||||
trigger: 'event',
|
||||
build({ label, amount, currency, planId = null }) {
|
||||
return {
|
||||
type: 'payment_refunded',
|
||||
title: 'Refund Processed',
|
||||
message: `Your refund of ${currency} ${amount} for the "${label}" plan has been processed. Your access to this plan has been revoked.`,
|
||||
data: { label, amount, currency, planId },
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
};
|
||||
|
||||
module.exports = { NOTIFICATION_REGISTRY };
|
||||
@@ -0,0 +1,31 @@
|
||||
'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' },
|
||||
needs_intro: { type: Sequelize.BOOLEAN, allowNull: false, defaultValue: false },
|
||||
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,48 @@
|
||||
'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', 'audio'), 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) },
|
||||
thumbnail_storage_key: { type: Sequelize.STRING(512), allowNull: true },
|
||||
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,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,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,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,27 @@
|
||||
'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 },
|
||||
icon: { type: Sequelize.STRING(50), 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,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,43 @@
|
||||
'use strict';
|
||||
|
||||
// subscription/status are STRING + explicit CHECK constraints, not native
|
||||
// Sequelize.ENUM — matches the live schema after 20260101000058 collapsed
|
||||
// `subscription` from ENUM to a CockroachDB-compatible VARCHAR+CHECK column,
|
||||
// and 20260710000001 added `status` the same way from the start.
|
||||
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.STRING(20), allowNull: false, defaultValue: 'free' },
|
||||
status: { type: Sequelize.STRING(20), allowNull: false, defaultValue: 'draft' },
|
||||
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.sequelize.query(
|
||||
`ALTER TABLE courses ADD CONSTRAINT check_subscription CHECK (subscription IN ('free', 'premium', 'exclusive'))`
|
||||
);
|
||||
await queryInterface.sequelize.query(
|
||||
`ALTER TABLE courses ADD CONSTRAINT check_status CHECK (status IN ('draft', 'published', 'unpublished'))`
|
||||
);
|
||||
|
||||
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,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';
|
||||
|
||||
// units no longer holds a direct course_id / order_index — the junction
|
||||
// revamp (originally 20260707000001) moved course<->unit attachment (and its
|
||||
// ordering) onto course_units, so units can now be standalone.
|
||||
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 },
|
||||
title: { type: Sequelize.STRING(255), allowNull: false },
|
||||
subscription: { type: Sequelize.STRING(50), allowNull: true },
|
||||
description: { type: Sequelize.TEXT, allowNull: true },
|
||||
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', ['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');
|
||||
},
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user