diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..09e0451 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +node_modules +.env +*.log +package-lock.json +docker-compose.yml +.dockerignore diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..3fd83f3 --- /dev/null +++ b/.env.example @@ -0,0 +1,119 @@ +# ═══════════════════════════════════════════════════════════════════════════════ +# $APP_NAME — Environment Variables Template +# Copy to .env and fill in all CHANGE_ME values before deploying. +# +# Self-hosted setup (recommended): +# 1. cp .env.example .env +# 2. Fill in every CHANGE_ME value below +# 3. npm install +# 4. npm start (or: pm2 start server.js --name $APP_NAME) +# 5. Point Nginx to PORT (see nginx.conf.example) +# +# Generate random secrets with: +# node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" +# ═══════════════════════════════════════════════════════════════════════════════ + +# ── App ─────────────────────────────────────────────────────────────────────── +APP_NAME=starr +NODE_ENV=production +ORIGIN_GUARD_DISABLED=false # set to true in dev to allow Postman/curl (ignored in production) +PORT=3024 +APP_URL=https://api.yourdomain.com +APP_LOGO_URL=https://your-cdn.com/logo.png +FRONTEND_URL=https://yourdomain.com + +# ── Database (PostgreSQL / CockroachDB) ────────────────────────────────────── +# For self-hosted PostgreSQL: DB_HOST=127.0.0.1 DB_PORT=5432 +# For CockroachDB serverless: DB_HOST=.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://:@:6380 +REDIS_URL=redis://127.0.0.1:6379 + +# ── JWT ─────────────────────────────────────────────────────────────────────── +# Generate each secret independently — never reuse across fields. +JWT_SECRET=CHANGE_ME_32_BYTE_HEX +MEDIA_JWT_SECRET=CHANGE_ME_32_BYTE_HEX +JWT_EXPIRES_IN=15m +JWT_REFRESH_SECRET=CHANGE_ME_32_BYTE_HEX +JWT_REFRESH_EXPIRES_IN=7d + +# ── CSRF & Cookies ──────────────────────────────────────────────────────────── +SESSION_SECRET=CHANGE_ME_32_BYTE_HEX + +# ── Google OAuth ────────────────────────────────────────────────────────────── +# console.cloud.google.com → Credentials → OAuth 2.0 Client ID +# Add https://api.yourdomain.com/api/auth/google/callback to Authorized redirect URIs +GOOGLE_CLIENT_ID=CHANGE_ME +GOOGLE_CLIENT_SECRET=CHANGE_ME +GOOGLE_CALLBACK_URL=https://api.yourdomain.com/api/auth/google/callback + +# ── PayPal ──────────────────────────────────────────────────────────────────── +# Switch PAYPAL_ENV from "sandbox" to "live" when ready for real payments. +# Live credentials come from developer.paypal.com → My Apps & Credentials → Live tab. +PAYPAL_CLIENT_ID=CHANGE_ME +PAYPAL_CLIENT_SECRET=CHANGE_ME +PAYPAL_ENV=live + +# ── Email (SMTP) ────────────────────────────────────────────────────────────── +# Gmail: enable 2FA → generate an App Password at myaccount.google.com/apppasswords +# For higher volume use a transactional provider (Mailgun, Resend, SendGrid, etc.) +SMTP_HOST=smtp.gmail.com +SMTP_PORT=587 +SMTP_USER=CHANGE_ME@gmail.com +SMTP_PASS=CHANGE_ME_APP_PASSWORD +EMAIL_FROM=CHANGE_ME@gmail.com + +OTP_EXPIRY_MINUTES=10 + +# ── S3-compatible Storage (Garage self-hosted) ──────────────────────────────── +# S3_ENDPOINT: internal address of your Garage node (e.g. http://127.0.0.1:3900 +# if Garage runs on the same server, or http://:3900) +# S3_PUBLIC_URL: the public-facing base URL served by your reverse proxy +# (e.g. https://cdn.yourdomain.com) +S3_ENDPOINT=http://127.0.0.1:3900 +S3_REGION=garage +S3_ACCESS_KEY=CHANGE_ME +S3_SECRET_KEY=CHANGE_ME +S3_BUCKET=CHANGE_ME +S3_PUBLIC_URL=https://cdn.yourdomain.com + +# ── Chibisafe (optional — used alongside S3 for some asset types) ───────────── +# Replace the zrok tunnel URL with a stable domain pointing to your Chibisafe instance. +CHIBISAFE_BASE_URL=https://files.yourdomain.com +CHIBISAFE_API_KEY=CHANGE_ME +CHIBISAFE_ALBUM_AVATARS=CHANGE_ME_UUID +CHIBISAFE_ALBUM_IMAGES=CHANGE_ME_UUID +CHIBISAFE_ALBUM_VIDEOS=CHANGE_ME_UUID +CHIBISAFE_ALBUM_THUMBNAILS=CHANGE_ME_UUID +CHIBISAFE_ALBUM_DOCUMENTS=CHANGE_ME_UUID +CHIBISAFE_ALBUM_ARCHIVED=CHANGE_ME_UUID + +# ── CORS ────────────────────────────────────────────────────────────────────── +# Comma-separated list of allowed origins. Must match FRONTEND_URL exactly. +ALLOWED_ORIGINS=https://yourdomain.com diff --git a/.gitignore b/.gitignore index d945ee1..2346c26 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,44 @@ +# ── Dependencies ────────────────────────────────────────────────────────────── node_modules/ -.env* + +# ── Environment / Secrets ───────────────────────────────────────────────────── +.env +.env.local +.env.*.local +# Keep .env.example — it is safe and documents required variables + +# ── SSL / TLS / Keys ────────────────────────────────────────────────────────── +*.pem +*.key +*.cert +*.crt +*.p12 +*.pfx + +# ── Logs ────────────────────────────────────────────────────────────────────── *.log +logs/ +npm-debug.log* +pnpm-debug.log* +yarn-debug.log* + +# ── Build / Output ──────────────────────────────────────────────────────────── dist/ coverage/ + +# ── Uploads / Temp ──────────────────────────────────────────────────────────── +uploads/ +tmp/ +temp/ + +# ── OS ──────────────────────────────────────────────────────────────────────── +.DS_Store +Thumbs.db +desktop.ini + +# ── Editors ─────────────────────────────────────────────────────────────────── +.vscode/ +.idea/ +*.swp +*.swo +*.suo diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0010ca4 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,21 @@ +FROM node:22-alpine + +# Enable corepack and activate pnpm +RUN corepack enable && corepack prepare pnpm@11.3.0 --activate + +# pm2 manages the process; sequelize-cli runs migrations on startup +RUN npm install -g pm2 sequelize-cli + +WORKDIR /app + +# Install production dependencies only +COPY package.json pnpm-lock.yaml ./ +RUN pnpm install --frozen-lockfile --prod + +COPY . . + +RUN chmod +x docker-entrypoint.sh + +EXPOSE 3024 + +ENTRYPOINT ["./docker-entrypoint.sh"] diff --git a/README.md b/README.md index 9bf56cc..146d79c 100644 --- a/README.md +++ b/README.md @@ -163,7 +163,9 @@ npm start | Variable | Description | Example | |---|---|---| +| `APP_NAME` | Application name | `starr` | | `NODE_ENV` | `development` or `production` | `development` | +| `ORIGIN_GUARD_DISABLED` | Set `true` to bypass originGuard (dev/Postman only; ignored in production) | `false` | | `PORT` | HTTP port | `3000` | | `APP_URL` | Frontend/app URL (used for CORS) | `http://localhost:3000` | | `DB_HOST` | PostgreSQL host | `localhost` | @@ -264,7 +266,30 @@ CSRF is provided via **csurf** (Double Submit Cookie pattern). - Current password required before change - All sessions invalidated after password change -### 6.5 RBAC Hierarchy +### 6.5 Origin Guard + +All API routes (except `/api/health`) are protected by `originGuard.middleware.js`, a two-layer non-browser request filter: + +| Layer | Header checked | Applies to | Blocks | +|---|---|---|---| +| 1 | `Sec-Fetch-Site` (must be present) | All methods | curl, Postman, Nikto, sqlmap, scanners | +| 2 | `Origin` (must be in `ALLOWED_ORIGINS`) | POST · PUT · PATCH · DELETE | Cross-origin mutation from unlisted domains | + +**Development bypass** — set `ORIGIN_GUARD_DISABLED=true` in `.env` to allow Postman and other tools through without restarting with a different config. The bypass is **hard-locked off** when `NODE_ENV=production`, even if the flag is set. + +``` +# .env — enable for Postman testing +ORIGIN_GUARD_DISABLED=true + +# .env — re-enable when done +ORIGIN_GUARD_DISABLED=false +``` + +> Note: BurpSuite running as a MITM proxy through a real browser is not blocked — the browser supplies all correct headers. Rate limiting and valid credentials are the only defences there. + +--- + +### 6.6 RBAC Hierarchy ``` admin ──► can do everything @@ -745,6 +770,7 @@ app.use(session({ store: new RedisStore({ client: redisClient }), ... })); ### Production Checklist - [ ] Set `NODE_ENV=production` +- [ ] Set `ORIGIN_GUARD_DISABLED=false` (or remove the key entirely) - [ ] Use strong `JWT_SECRET` (≥ 64 random characters) - [ ] Enable HTTPS / TLS (set `cookie.secure: true`) - [ ] Point `APP_URL` to your actual domain for CORS diff --git a/ROUTES.md b/ROUTES.md new file mode 100644 index 0000000..2819ede --- /dev/null +++ b/ROUTES.md @@ -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** | diff --git a/TODO b/TODO new file mode 100644 index 0000000..a743013 --- /dev/null +++ b/TODO @@ -0,0 +1,24 @@ + The good news — no Google verification required for the scopes we use (openid email profile). Verification is only mandatory for sensitive scopes + like Gmail or Drive access. Basic OIDC login is exempt. + + What you DO need for production: + + 1. Publish the app (remove testing restriction) + - Google Auth Platform → Audience → change from Testing to Production + - Right now only manually added test users can log in — publishing removes that limit + + 2. Add production URIs to the OAuth client + - Authorized JavaScript origins: add https://yourdomain.com and https://api.yourdomain.com + - Authorized redirect URIs: add https://api.yourdomain.com/api/auth/google/callback + + 3. Update production .env + GOOGLE_CALLBACK_URL=https://api.yourdomain.com/api/auth/google/callback + FRONTEND_URL=https://yourdomain.com + + 4. HTTPS is required — Google won't allow http:// redirect URIs in production (localhost is the only exception). Your production server needs SSL, + which you likely already have via Nginx. + + 5. Add Privacy Policy URL to the branding page — Google requires it when the app is published (even without verification). A simple /privacy page on + your frontend is enough. + + That's the full checklist. The localhost dev setup stays untouched alongside the production URIs — Google allows multiple redirect URIs per client. \ No newline at end of file diff --git a/config/database.cjs b/config/database.cjs new file mode 100644 index 0000000..f633378 --- /dev/null +++ b/config/database.cjs @@ -0,0 +1,26 @@ +'use strict'; +require('dotenv').config(); + +const useSSL = process.env.DB_SSL !== 'false'; + +const base = { + username: process.env.DB_USER, + password: process.env.DB_PASSWORD, + database: process.env.DB_NAME, + host: process.env.DB_HOST, + port: parseInt(process.env.DB_PORT, 10) || 26257, + dialect: 'postgres', + dialectOptions: { + ...(useSSL && { + ssl: { + require: true, + rejectUnauthorized: false, + }, + }), + }, +}; + +module.exports = { + development: base, + production: base, +}; diff --git a/config/db.config.js b/config/db.config.js index b7ebdba..e8bd6ec 100644 --- a/config/db.config.js +++ b/config/db.config.js @@ -14,6 +14,8 @@ require('dotenv').config(); const { Sequelize } = require('sequelize'); +const useSSL = process.env.DB_SSL !== 'false'; + const sequelize = new Sequelize( process.env.DB_NAME, process.env.DB_USER, @@ -23,10 +25,12 @@ const sequelize = new Sequelize( port: process.env.DB_PORT || 5432, dialect: 'postgres', dialectOptions: { - ssl: { - require: true, - rejectUnauthorized: false, // or provide CA cert if strict - }, + ...(useSSL && { + ssl: { + require: true, + rejectUnauthorized: false, // or provide CA cert if strict + }, + }), }, // logging: process.env.NODE_ENV === 'development' ? console.log : false, logging: false, diff --git a/config/passport.config.js b/config/passport.config.js deleted file mode 100644 index a7d0bef..0000000 --- a/config/passport.config.js +++ /dev/null @@ -1,70 +0,0 @@ -/*********************************************************************************************************************************************************************** - * File Name: passport.config.js - * Type of Program: Configuration - * Description: Passport.js strategy configuration. - * Registers the Google OAuth 2.0 strategy. On first login via Google - * a new user record is auto-created with reg_type="google". - * Returns a signed JWT payload after successful authentication. - * Author: rgrgogu - * Date Created: Oct. 6, 2025 - *********************************************************************************************************************************************************************** - * HOW TO USE: - * In server.js: - * require('./config/passport.config'); - * app.use(passport.initialize()); - * In routes: - * router.get('/google', passport.authenticate('google', { scope: ['profile', 'email'] })); - * router.get('/google/callback', passport.authenticate('google', { session: false }), handler); - ***********************************************************************************************************************************************************************/ -const passport = require('passport'); -const { Strategy: GoogleStrategy } = require('passport-google-oauth20'); -const mdl_Users = require('../models/users/users.mdl'); - -passport.use( - new GoogleStrategy( - { - clientID: process.env.GOOGLE_CLIENT_ID, - clientSecret: process.env.GOOGLE_CLIENT_SECRET, - callbackURL: process.env.GOOGLE_CALLBACK_URL, - }, - async (accessToken, refreshToken, profile, done) => { - try { - const email = profile.emails?.[0]?.value; - if (!email) return done(new Error('No email returned from Google'), null); - - let user = await mdl_Users.findOne({ where: { email } }); - - if (!user) { - // Auto-register Google users as 'user' acc_type - user = await mdl_Users.create({ - email, - reg_type: 'google', - acc_type: 'user', - is_active: true, - is_verified: true, // Google accounts are pre-verified - personal_info: { - name: { - given_name: profile.name?.givenName || '', - last_name: profile.name?.familyName || '', - full_name: profile.displayName || '', - }, - avatar: { - url: profile.photos?.[0]?.value || null, - }, - }, - }); - } - - if (!user.is_active) { - return done(null, false, { message: 'Account is deactivated.' }); - } - - return done(null, user); - } catch (err) { - return done(err, null); - } - } - ) -); - -module.exports = passport; \ No newline at end of file diff --git a/config/redis.config.js b/config/redis.config.js new file mode 100644 index 0000000..7433196 --- /dev/null +++ b/config/redis.config.js @@ -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; +} diff --git a/controllers/admin/advertisements.controller.js b/controllers/admin/advertisements.controller.js new file mode 100644 index 0000000..b2d8794 --- /dev/null +++ b/controllers/admin/advertisements.controller.js @@ -0,0 +1,357 @@ +// controllers/admin/advertisements.controller.js + +const sequelize = require("../../config/db.config"); +const Advertisement = require("../../models/advertisements/advertisements.mdl"); +const mdl_Assets = require("../../models/assets/assets.mdl"); +const mdl_Users = require('../../models/users/users.mdl'); +const R = require('../../utils/response.util'); +const { paginate } = require("../../utils/paginate.util"); +const { adminExclude, jsonbSchemas, computedAttributes } = require("../../models/advertisements/advertisements.attributes"); +const { getFieldValues } = require("../../utils/fieldValues.util"); +const logActivity = require('../../utils/logActivity.util'); + +const { Op } = require('sequelize'); + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +const notDeleted = { deletedAt: null }; + +const ALLOWED_TYPES = ["hero", "banner", "popup", "sidebar"]; +const ALLOWED_STATUSES = ["draft", "active", "scheduled", "expired", "archived"]; + +// ─── Status derivation ───────────────────────────────────────────────────── +// status is never trusted as manually-set truth — it's derived from is_active +// + start_date/end_date every time an advertisement is read or written. +// "archived" is the only status that bypasses derivation (set by archive/restore). +function deriveStatus(advertisement) { + if (advertisement.deletedAt) return "archived"; + if (!advertisement.is_active) return "draft"; + + const now = new Date(); + const start = advertisement.start_date ? new Date(advertisement.start_date) : null; + const end = advertisement.end_date ? new Date(advertisement.end_date) : null; + + if (end && end < now) return "expired"; + if (start && start > now) return "scheduled"; + return "active"; +} + +const ALLOWED_CTA_VARIANTS = ["default", "outline"]; + +function normalizeCtas(ctas) { + if (!Array.isArray(ctas)) return []; + return ctas + .filter((c) => c && typeof c.label === "string" && typeof c.link === "string") + .slice(0, 2) // hard cap: max 2 CTAs per advertisement + .map((c, i) => ({ + label: c.label.trim(), + link: c.link.trim(), + // First CTA defaults to "default" (primary), second to "outline" — but + // an explicit, valid variant from the client always wins. + variant: ALLOWED_CTA_VARIANTS.includes(c.variant) ? c.variant : (i === 0 ? "default" : "outline"), + })); +} + +async function applyAdvertisementFields(advertisement, body) { + if (body.type !== undefined) { + if (!ALLOWED_TYPES.includes(body.type)) { + const err = new Error(`Invalid type. Must be one of: ${ALLOWED_TYPES.join(", ")}`); + err.status = 400; + throw err; + } + advertisement.type = body.type; + } + + // status is intentionally NOT settable here — it's derived via deriveStatus() + // right before save, based on is_active + start_date/end_date. + + if (body.badge_label !== undefined) advertisement.badge_label = body.badge_label; + if (body.headline !== undefined) advertisement.headline = body.headline; + if (body.description !== undefined) advertisement.description = body.description; + if (body.image_url !== undefined) advertisement.image_url = body.image_url; + + if (body.image_asset_id !== undefined) { + if (body.image_asset_id === null) { + advertisement.image_asset_id = null; + } else { + const asset = await mdl_Assets.findOne({ where: { asset_id: body.image_asset_id, deletedAt: null } }); + if (!asset) { + const err = new Error("Selected image asset was not found."); + err.status = 400; + throw err; + } + advertisement.image_asset_id = asset.asset_id; + } + } + + if (body.ctas !== undefined) advertisement.ctas = normalizeCtas(body.ctas); + + if (body.start_date !== undefined) advertisement.start_date = body.start_date || null; + if (body.end_date !== undefined) advertisement.end_date = body.end_date || null; + + if (body.order !== undefined) advertisement.order = parseInt(body.order) || 0; + if (body.is_active !== undefined) advertisement.is_active = body.is_active === true || body.is_active === "true"; + + if (body.size !== undefined) { + if (body.size !== null && !["sm", "md", "lg"].includes(body.size)) { + const err = new Error(`Invalid size. Must be one of: sm, md, lg`); + err.status = 400; + throw err; + } + advertisement.size = body.size || null; + } + + // Recompute status now that is_active/start_date/end_date are all up to date + advertisement.status = deriveStatus(advertisement); +} + +// ─── GET ALL ────────────────────────────────────────────────────────────────── + +exports.getAdvertisements = async (req, res) => { + try { + const result = await paginate(Advertisement, req, { + excludeAttributes: adminExclude, + jsonbSchemas, + computedAttributes, + context: "list", + auditOptions: { mdl_Users, parentAlias: 'Advertisement' }, + findOptions: { + where: { ...notDeleted }, + include: [{ + model: mdl_Assets, + as: "image", + attributes: ["asset_id", "display_name", "file_url", "thumbnail_url"], + required: false, + }], + }, + }); + + // Resync status on the way out — never trust what's stored, since + // start_date/end_date may have lapsed since the row was last saved. + if (Array.isArray(result?.data)) { + result.data = result.data.map((row) => ({ ...row, status: deriveStatus(row) })); + } + + return R.success(res, "Advertisements retrieved.", result); + } catch (err) { + console.error("[ADVERTISEMENT][GET ALL]", err); + return R.error(res, "Could not retrieve advertisements.", 500); + } +}; + +// ─── GET ONE ────────────────────────────────────────────────────────────────── + +exports.getAdvertisement = async (req, res) => { + try { + const { advertisementId } = req.params; + if (!advertisementId || advertisementId === "undefined") return R.error(res, "Invalid advertisement ID.", 400); + + const advertisement = await Advertisement.findOne({ + where: { advertisement_id: advertisementId, ...notDeleted }, + include: [ + { model: mdl_Assets, as: "image", attributes: ["asset_id", "display_name", "file_url", "thumbnail_url"], required: false }, + { model: mdl_Users, as: "creator", attributes: ["user_id", "personal_info"], foreignKey: "createdBy" }, + { model: mdl_Users, as: "updater", attributes: ["user_id", "personal_info"], foreignKey: "updatedBy" }, + ], + }); + + if (!advertisement) return R.error(res, "Advertisement not found.", 404); + + const json = advertisement.toJSON(); + json.status = deriveStatus(json); + + if (json.creator) { + json.creator = { + user_id: json.creator.user_id, + full_name: json.creator.personal_info?.name?.full_name ?? null, + }; + } + if (json.updater) { + json.updater = { + user_id: json.updater.user_id, + full_name: json.updater.personal_info?.name?.full_name ?? null, + }; + } + + return R.success(res, "Advertisement retrieved.", { data: json }); + } catch (err) { + console.error("[ADVERTISEMENT][GET ONE]", err); + return R.error(res, "Internal server error.", 500); + } +}; + +// ─── CREATE ─────────────────────────────────────────────────────────────────── + +exports.createAdvertisement = async (req, res) => { + try { + const { type, createdBy } = req.body; + + if (!type) return R.error(res, "type is required.", 400); + if (!ALLOWED_TYPES.includes(type)) return R.error(res, `Invalid type. Must be one of: ${ALLOWED_TYPES.join(", ")}`, 400); + if (!createdBy) return R.error(res, "createdBy is required.", 400); + + const t = await sequelize.transaction(); + try { + const advertisement = await Advertisement.build({ type, createdBy }); + await applyAdvertisementFields(advertisement, req.body); + await advertisement.save({ transaction: t }); + await t.commit(); + + logActivity(req.user?.user_id, 'create_advertisement', { entityType: 'advertisement', entityId: advertisement.advertisement_id, details: { type: advertisement.type } }); + return R.success(res, "Advertisement created.", { data: advertisement }, 201); + } catch (dbErr) { + try { await t.rollback(); } catch { /* connection gone */ } + throw dbErr; + } + } catch (err) { + console.error("[ADVERTISEMENT][CREATE]", err); + if (err.status) return R.error(res, err.message, err.status); + return R.error(res, "Internal server error.", 500); + } +}; + +// ─── UPDATE ─────────────────────────────────────────────────────────────────── + +exports.updateAdvertisement = async (req, res) => { + try { + const { advertisementId } = req.params; + if (!advertisementId || advertisementId === "undefined") return R.error(res, "Invalid advertisement ID.", 400); + + const advertisement = await Advertisement.findOne({ where: { advertisement_id: advertisementId, ...notDeleted } }); + if (!advertisement) return R.error(res, "Advertisement not found.", 404); + + const t = await sequelize.transaction(); + try { + await applyAdvertisementFields(advertisement, req.body); + advertisement.updatedBy = req.body.updatedBy ?? null; + await advertisement.save({ transaction: t }); + await t.commit(); + + logActivity(req.user?.user_id, 'update_advertisement', { entityType: 'advertisement', entityId: Number(advertisementId) }); + return R.success(res, "Advertisement updated.", { data: advertisement }); + } catch (dbErr) { + try { await t.rollback(); } catch { /* gone */ } + throw dbErr; + } + } catch (err) { + console.error("[ADVERTISEMENT][UPDATE]", err); + if (err.status) return R.error(res, err.message, err.status); + return R.error(res, "Internal server error.", 500); + } +}; + +// ─── ARCHIVE (single) ───────────────────────────────────────────────────────── + +exports.archiveAdvertisement = async (req, res) => { + try { + const { advertisementId } = req.params; + if (!advertisementId || advertisementId === "undefined") return R.error(res, "Invalid advertisement ID.", 400); + + const advertisement = await Advertisement.findOne({ where: { advertisement_id: advertisementId, ...notDeleted } }); + if (!advertisement) return R.error(res, "Advertisement not found.", 404); + + await advertisement.update({ deletedBy: req.body.deletedBy ?? null }); + await advertisement.destroy(); + logActivity(req.user?.user_id, 'archive_advertisement', { entityType: 'advertisement', entityId: Number(advertisementId) }); + return R.success(res, "Advertisement archived."); + } catch (err) { + console.error("[ADVERTISEMENT][ARCHIVE]", err); + return R.error(res, "Internal server error.", 500); + } +}; + +// ─── ARCHIVE (bulk) ─────────────────────────────────────────────────────────── + +exports.archiveAdvertisements = async (req, res) => { + try { + const { ids, deletedBy } = req.body; + if (!Array.isArray(ids) || !ids.length) return R.error(res, "ids must be a non-empty array.", 400); + + const advertisements = await Advertisement.findAll({ where: { advertisement_id: { [Op.in]: ids }, ...notDeleted } }); + if (!advertisements.length) return R.error(res, "No advertisements found.", 404); + + const activeIds = advertisements.map((a) => a.advertisement_id); + + await Advertisement.update({ deletedBy: deletedBy ?? null }, { where: { advertisement_id: { [Op.in]: activeIds } } }); + await Advertisement.destroy({ where: { advertisement_id: { [Op.in]: activeIds } } }); + + logActivity(req.user?.user_id, 'bulk_archive_advertisements', { entityType: 'advertisement', details: { ids: activeIds, count: activeIds.length } }); + return R.success(res, `${activeIds.length} advertisement(s) archived.`, { + archived_ids: activeIds, + skipped_ids: ids.filter((id) => !activeIds.includes(id)), + }); + } catch (err) { + console.error("[ADVERTISEMENT][BULK ARCHIVE]", err); + return R.error(res, "Internal server error.", 500); + } +}; + +// ─── RESTORE (single) ───────────────────────────────────────────────────────── + +exports.restoreAdvertisement = async (req, res) => { + try { + const { advertisementId } = req.params; + + const advertisement = await Advertisement.findOne({ where: { advertisement_id: advertisementId }, paranoid: false }); + if (!advertisement) return R.error(res, "Advertisement not found.", 404); + if (!advertisement.deletedAt) return R.error(res, "Advertisement is not archived.", 400); + + await advertisement.restore(); + await advertisement.update({ deletedBy: null }); + logActivity(req.user?.user_id, 'restore_advertisement', { entityType: 'advertisement', entityId: Number(advertisementId) }); + return R.success(res, "Advertisement restored.", { data: advertisement }); + } catch (err) { + console.error("[ADVERTISEMENT][RESTORE]", err); + return R.error(res, "Internal server error.", 500); + } +}; + +// ─── RESTORE (bulk) ─────────────────────────────────────────────────────────── + +exports.restoreAdvertisements = async (req, res) => { + try { + const { ids } = req.body; + if (!Array.isArray(ids) || !ids.length) return R.error(res, "ids must be a non-empty array.", 400); + + const advertisements = await Advertisement.findAll({ where: { advertisement_id: { [Op.in]: ids } }, paranoid: false }); + if (!advertisements.length) return R.error(res, "No advertisements found.", 404); + + const archived = advertisements.filter((a) => a.deletedAt); + if (!archived.length) return R.error(res, "All selected advertisements are already active.", 400); + + const archivedIds = archived.map((a) => a.advertisement_id); + + await Advertisement.restore({ where: { advertisement_id: { [Op.in]: archivedIds } } }); + await Advertisement.update({ deletedBy: null }, { where: { advertisement_id: { [Op.in]: archivedIds } }, paranoid: false }); + + logActivity(req.user?.user_id, 'bulk_restore_advertisements', { entityType: 'advertisement', details: { ids: archivedIds, count: archivedIds.length } }); + return R.success(res, `${archivedIds.length} advertisement(s) restored.`, { + restored_ids: archivedIds, + skipped_ids: ids.filter((id) => !archivedIds.includes(id)), + }); + } catch (err) { + console.error("[ADVERTISEMENT][BULK RESTORE]", err); + return R.error(res, "Internal server error.", 500); + } +}; + +// ─── ARCHIVED LIST ──────────────────────────────────────────────────────────── + +exports.getArchivedAdvertisements = async (req, res) => { + try { + const result = await paginate(Advertisement, req, { + excludeAttributes: adminExclude, + jsonbSchemas, + computedAttributes, + context: "list", + auditOptions: { mdl_Users, parentAlias: 'Advertisement' }, + findOptions: { paranoid: false, where: { deletedAt: { [Op.ne]: null } } }, + }); + return R.success(res, "Archived advertisements retrieved.", result); + } catch (err) { + console.error("[ADVERTISEMENT][GET ARCHIVED]", err); + return R.error(res, "Could not retrieve archived advertisements.", 500); + } +}; + +exports.getAdvertisementFieldValues = getFieldValues(Advertisement, "ADVERTISEMENT"); \ No newline at end of file diff --git a/controllers/admin/assets.controller.js b/controllers/admin/assets.controller.js index fa8be2d..5ebe057 100644 --- a/controllers/admin/assets.controller.js +++ b/controllers/admin/assets.controller.js @@ -5,15 +5,16 @@ const crypto = require("crypto"); const sequelize = require("../../config/db.config"); const Asset = require("../../models/assets/assets.mdl"); const chibi = require("../../services/chibisafe.service"); +const s3 = require("../../services/s3.service"); const { extractVideoMeta } = require("../../services/ffprobe.service"); const R = require('../../utils/response.util'); const { paginate } = require("../../utils/paginate.util"); -const { adminExclude, jsonbSchemas, computedAttributes, } = require("../../models/assets/assets.attributes"); +const { adminExclude, jsonbSchemas, computedAttributes } = require("../../models/assets/assets.attributes"); const mdl_Users = require('../../models/users/users.mdl'); const { getFieldValues } = require("../../utils/fieldValues.util"); +const logActivity = require('../../utils/logActivity.util'); -const { Op, Sequelize } = require('sequelize'); -const auditByFields = ['createdBy', 'updatedBy', 'deletedBy']; +const { Op } = require('sequelize'); // ─── Helpers ────────────────────────────────────────────────────────────────── @@ -22,9 +23,8 @@ const notDeleted = { deletedAt: null }; function resolveFileType(mimeType = "") { if (mimeType.startsWith("image/")) return "image"; if (mimeType.startsWith("video/")) return "video"; - if (mimeType.startsWith("application/") || mimeType.startsWith("text/")) - return "document"; - return "image"; + if (mimeType.startsWith("audio/")) return "audio"; + if (mimeType.startsWith("application/") || mimeType.startsWith("text/")) return "document"; } function resolveExtension(originalName = "") { @@ -41,31 +41,64 @@ function resolveResolution(width, height) { if (h >= 2160) return "4K"; if (h >= 1440) return "1440p"; if (h >= 1080) return "1080p"; - if (h >= 720) return "720p"; - if (h >= 480) return "480p"; - if (h >= 360) return "360p"; - if (h >= 240) return "240p"; + if (h >= 720) return "720p"; + if (h >= 480) return "480p"; + if (h >= 360) return "360p"; + if (h >= 240) return "240p"; return `${width}x${height}`; } -// ─── Helpers ────────────────────────────────────────────────────────────────── +// ─── Provider resolver ──────────────────────────────────────────────────────── +// +// Returns the correct service module based on storage_provider. +// Both chibi and s3 expose the same interface: uploadFile / deleteFile. +// +function getProvider(storageProvider) { + if (storageProvider === "s3") return s3; + if (storageProvider === "chibisafe") return chibi; + return null; // local / other — no remote provider needed +} -async function uploadToAlbum(file, ownerType) { +// ─── rollbackUploads ────────────────────────────────────────────────────────── +// +// Best-effort cleanup after a failed DB transaction. +// uploads: [{ key, provider }] +// +async function rollbackUploads(uploads = []) { + for (const { key, provider } of uploads) { + if (!key || !provider) continue; + const svc = getProvider(provider); + if (!svc) continue; + try { + await svc.deleteFile(key); + } catch (err) { + console.error(`[ASSET][ROLLBACK] Failed to delete "${key}" from "${provider}":`, err.message); + } + } +} + +// ─── uploadToProvider ───────────────────────────────────────────────────────── +// +// Uploads a single file to the resolved provider. +// Returns { file_url, storage_key, mime_type, extension, checksum, file_type } +// +async function uploadToProvider(file, ownerType, storageProvider) { const mime_type = file.mimetype; const extension = resolveExtension(file.originalname); - const checksum = resolveChecksum(file.buffer); + const checksum = resolveChecksum(file.buffer); const file_type = resolveFileType(mime_type); - const chibiResult = await chibi.uploadFile({ - buffer: file.buffer, + const svc = getProvider(storageProvider); + const result = await svc.uploadFile({ + buffer: file.buffer, originalname: file.originalname, - mimetype: mime_type, + mimetype: mime_type, ownerType, }); return { - file_url: chibiResult.url, - chibi_uuid: chibiResult.uuid, + file_url: result.url, + storage_key: result.uuid, // chibisafe UUID or S3 key — both stored as storage_key in DB mime_type, extension, checksum, @@ -73,62 +106,64 @@ async function uploadToAlbum(file, ownerType) { }; } +// ─── applyAssetUpdate ───────────────────────────────────────────────────────── + async function applyAssetUpdate(asset, file, body) { const isThumbnailOnly = asset.file_type === "video" && !!file; if (body.display_name !== undefined) asset.display_name = body.display_name; - if (body.description !== undefined) asset.description = body.description; - if (body.is_public !== undefined) asset.is_public = body.is_public === "true" || body.is_public === true; + if (body.description !== undefined) asset.description = body.description; + if (body.is_public !== undefined) asset.is_public = body.is_public === "true" || body.is_public === true; asset.updatedBy = body.updatedBy ?? null; if (file) { if (isThumbnailOnly) { - asset.thumbnail_url = file.file_url; - asset.thumbnail_storage_key = file.chibi_uuid; + asset.thumbnail_url = file.file_url; + asset.thumbnail_storage_key = file.storage_key; } else { asset.original_name = file.originalname; - asset.file_url = file.file_url; - asset.file_size = file.size; - asset.mime_type = file.mime_type; - asset.extension = file.extension; - asset.checksum = file.checksum; - asset.file_type = file.file_type; - asset.storage_key = file.chibi_uuid; + asset.file_url = file.file_url; + asset.file_size = file.size; + asset.mime_type = file.mime_type; + asset.extension = file.extension; + asset.checksum = file.checksum; + asset.file_type = file.file_type; + asset.storage_key = file.storage_key; - const parsedWidth = body.width ? parseInt(body.width) : null; + const parsedWidth = body.width ? parseInt(body.width) : null; const parsedHeight = body.height ? parseInt(body.height) : null; if (parsedWidth || parsedHeight) { - asset.width = parsedWidth; - asset.height = parsedHeight; + asset.width = parsedWidth; + asset.height = parsedHeight; asset.resolution = resolveResolution(parsedWidth, parsedHeight); } } } } -async function deleteOldFile(asset, oldStorageKey, newUuid) { - if (asset.storage_provider !== "chibisafe") return; - if (!oldStorageKey || oldStorageKey === newUuid) return; +// ─── deleteOldFile ──────────────────────────────────────────────────────────── + +async function deleteOldFile(storageProvider, oldStorageKey, newKey) { + if (!oldStorageKey || oldStorageKey === newKey) return; + const svc = getProvider(storageProvider); + if (!svc) return; try { - await chibi.deleteFile(oldStorageKey); + await svc.deleteFile(oldStorageKey); } catch (err) { - console.warn("[ASSET][REPLACE FILE] Old file cleanup failed:", err.message); + console.warn(`[ASSET][CLEANUP] Old file cleanup failed for "${oldStorageKey}":`, err.message); } } -/** - * Best-effort cleanup of Chibisafe files after a failed transaction. - * Never throws — the original error is what matters. - */ -async function rollbackChibiUploads(uuids = []) { - for (const uuid of uuids) { - if (!uuid) continue; - try { - await chibi.deleteFile(uuid); - } catch (cleanupErr) { - console.error(`[ASSET][ROLLBACK] Failed to delete Chibisafe file ${uuid}:`, cleanupErr.message); - } - } +// ─── Helper: hide S3 file_url from responses ────────────────────────────────── +// +// The raw S3 presigned/public URL is never sent to any browser. +// Admin viewers request a short-lived stream token instead +// (POST /api/admin/media/token → GET /api/client/media/stream/:token). +// Chibisafe assets keep their file_url (CDN public URL, no proxy needed). +// +function redactS3Url(asset) { + if (asset?.storage_provider === "s3") asset.file_url = null; + return asset; } // ─── GET ALL ────────────────────────────────────────────────────────────────── @@ -141,11 +176,9 @@ exports.getAssets = async (req, res) => { computedAttributes, context: "list", auditOptions: { mdl_Users, parentAlias: 'Asset' }, - findOptions: { - where: { deletedAt: null }, - }, + findOptions: { where: { deletedAt: null } }, }); - + result.data = result.data.map(redactS3Url); return R.success(res, "Assets retrieved.", result); } catch (err) { console.error("[ASSET][GET ALL]", err); @@ -158,48 +191,35 @@ exports.getAssets = async (req, res) => { exports.getAsset = async (req, res) => { try { const { assetId } = req.params; - if (!assetId || assetId === "undefined") { - return R.error(res, "Invalid asset ID.", 400); - } + if (!assetId || assetId === "undefined") return R.error(res, "Invalid asset ID.", 400); const asset = await Asset.findOne({ where: { asset_id: assetId, ...notDeleted }, attributes: { exclude: ["storage_key", "storage_bucket"] }, include: [ - { - model: mdl_Users, - as: "creator", - attributes: ["user_id", "personal_info"], - foreignKey: "createdBy", - }, - { - model: mdl_Users, - as: "updater", - attributes: ["user_id", "personal_info"], - foreignKey: "updatedBy", - }, + { model: mdl_Users, as: "creator", attributes: ["user_id", "personal_info"], foreignKey: "createdBy" }, + { model: mdl_Users, as: "updater", attributes: ["user_id", "personal_info"], foreignKey: "updatedBy" }, ], }); if (!asset) return R.error(res, "Asset not found.", 404); - // ── Flatten creator / updater name from personal_info JSONB ────────────── const json = asset.toJSON(); if (json.creator) { json.creator = { - user_id: json.creator.user_id, + user_id: json.creator.user_id, full_name: json.creator.personal_info?.name?.full_name ?? null, }; } - if (json.updater) { json.updater = { - user_id: json.updater.user_id, + user_id: json.updater.user_id, full_name: json.updater.personal_info?.name?.full_name ?? null, }; } + redactS3Url(json); return R.success(res, "Asset retrieved.", { data: json }); } catch (err) { console.error("[ASSET][GET ONE]", err); @@ -212,31 +232,27 @@ exports.getAsset = async (req, res) => { // ┌─────────────────────────────────────────────────────────────────────────┐ // │ TRANSACTION STRATEGY │ // │ │ -// │ Phase 1 — SLOW WORK (no transaction, no DB connection held): │ +// │ Phase 1 — SLOW WORK (outside transaction): │ // │ • Input validation │ // │ • ffprobe metadata extraction │ -// │ • Chibisafe file upload → track UUID for rollback │ -// │ • Chibisafe thumb upload → track UUID for rollback │ +// │ • Provider upload (chibi or s3) → track for rollback │ +// │ • Thumbnail upload → track for rollback │ // │ │ -// │ Phase 2 — FAST WORK (transaction open for milliseconds only): │ -// │ • BEGIN transaction │ -// │ • Asset.create() │ -// │ • COMMIT │ +// │ Phase 2 — FAST WORK (transaction open milliseconds only): │ +// │ • BEGIN → Asset.create() → COMMIT │ // │ │ -// │ On any Phase 2 error: │ +// │ On Phase 2 error: │ // │ • ROLLBACK transaction │ -// │ • deleteFile() each tracked Chibisafe UUID (cleanup orphans) │ +// │ • rollbackUploads([{ key, provider }]) to clean orphans │ // └─────────────────────────────────────────────────────────────────────────┘ -// -// Expects multer.fields([{ name: "file", maxCount: 1 }, { name: "thumbnail", maxCount: 1 }]) exports.uploadAsset = async (req, res) => { - const uploadedChibiUuids = []; + const uploadedFiles = []; // [{ key, provider }] try { - // ── Phase 1a: Validate inputs ───────────────────────────────────────────── + // ── Phase 1a: Validate ──────────────────────────────────────────────────── - const file = req.files?.file?.[0]; + const file = req.files?.file?.[0]; const thumbFile = req.files?.thumbnail?.[0]; if (!file) return R.error(res, "No file uploaded.", 400); @@ -244,111 +260,116 @@ exports.uploadAsset = async (req, res) => { const { display_name, description, - is_public = false, - storage_provider = "local", + is_public = false, + storage_provider = "chibisafe", storage_bucket, storage_key, createdBy, } = req.body; - if (!createdBy) { - return R.error(res, "createdBy is required.", 400); - } + if (!createdBy) return R.error(res, "createdBy is required.", 400); - const mime_type = file.mimetype; - const file_type = resolveFileType(mime_type); - const extension = resolveExtension(file.originalname); - const checksum = file.buffer ? resolveChecksum(file.buffer) : null; + const mime_type = file.mimetype; + const file_type = resolveFileType(mime_type); + const extension = resolveExtension(file.originalname); + const checksum = file.buffer ? resolveChecksum(file.buffer) : null; + const usesProvider = ["chibisafe", "s3"].includes(storage_provider); if (file_type === "video" && !thumbFile) { - return R.error(res, "A thumbnail image is required for video uploads. Include it as the 'thumbnail' field.", 400); + return R.error(res, "A thumbnail image is required for video uploads.", 400); } - if (storage_provider === "chibisafe" && !file.buffer) { - return R.error(res, "File buffer is required for Chibisafe uploads. Ensure multer uses memoryStorage.", 400); + if (usesProvider && !file.buffer) { + return R.error(res, "File buffer is required. Ensure multer uses memoryStorage.", 400); } - // ── Phase 1b: Upload main file to Chibisafe ─────────────────────────────── + // ── Phase 1b: Upload main file ──────────────────────────────────────────── - let file_url = null; - let chibi_uuid = null; + let file_url = null; + let storage_key_resolved = null; - if (storage_provider === "chibisafe") { - const chibiResult = await chibi.uploadFile({ - buffer: file.buffer, + if (usesProvider) { + const svc = getProvider(storage_provider); + const result = await svc.uploadFile({ + buffer: file.buffer, originalname: file.originalname, - mimetype: mime_type, + mimetype: mime_type, + ownerType: file_type, }); - - file_url = chibiResult.url; - chibi_uuid = chibiResult.uuid; - uploadedChibiUuids.push(chibi_uuid); + file_url = result.url; + storage_key_resolved = result.uuid; + uploadedFiles.push({ key: storage_key_resolved, provider: storage_provider }); } else { file_url = storage_provider === "local" ? `/uploads/${file.filename}` : req.body.file_url; - if (!file_url) { - return R.error(res, "file_url is required for non-local storage.", 400); - } + if (!file_url) return R.error(res, "file_url is required for non-local storage.", 400); } - // ── Phase 1c: ffprobe + thumbnail upload ────────────────────────────────── + // ── Phase 1c: ffprobe + thumbnail ───────────────────────────────────────── - let width = null; - let height = null; - let resolution = null; - let duration = null; - let frame_rate = null; - let bitrate = null; - let video_codec = null; - let audio_codec = null; + let width = null, height = null, resolution = null; + let duration = null, frame_rate = null, bitrate = null; + let video_codec = null, audio_codec = null; let thumbnail_url = null; if (file_type === "video") { - const meta = await extractVideoMeta({ - buffer: file.buffer, - extension: extension || "mp4", - }); - - width = meta.width; - height = meta.height; - resolution = meta.resolution; - duration = meta.duration; - frame_rate = meta.frame_rate; - bitrate = meta.bitrate; + const meta = await extractVideoMeta({ buffer: file.buffer, extension: extension || "mp4" }); + width = meta.width; + height = meta.height; + resolution = meta.resolution; + duration = meta.duration; + frame_rate = meta.frame_rate; + bitrate = meta.bitrate; video_codec = meta.video_codec; audio_codec = meta.audio_codec; - if (storage_provider === "chibisafe") { - if (!thumbFile.buffer) { - await rollbackChibiUploads(uploadedChibiUuids); - return R.error(res, "Thumbnail buffer is required. Ensure multer uses memoryStorage.", 400); + if (usesProvider) { + if (!thumbFile?.buffer) { + await rollbackUploads(uploadedFiles); + return R.error(res, "Thumbnail buffer is required.", 400); } - - const baseName = file.originalname.replace(/\.[^.]+$/, ""); - const thumbResult = await chibi.uploadFile({ - buffer: thumbFile.buffer, + const baseName = file.originalname.replace(/\.[^.]+$/, ""); + const svc = getProvider(storage_provider); + const thumbResult = await svc.uploadFile({ + buffer: thumbFile.buffer, originalname: `thumb_${baseName}.${resolveExtension(thumbFile.originalname) || "jpg"}`, - mimetype: thumbFile.mimetype, - ownerType: "thumbnail", + mimetype: thumbFile.mimetype, + ownerType: "thumbnail", }); - thumbnail_url = thumbResult.url; - uploadedChibiUuids.push(thumbResult.uuid); - + uploadedFiles.push({ key: thumbResult.uuid, provider: storage_provider }); } else { - thumbnail_url = thumbFile.filename - ? `/uploads/${thumbFile.filename}` - : null; + thumbnail_url = thumbFile?.filename ? `/uploads/${thumbFile.filename}` : null; + } + + } else if (file_type === "audio" && thumbFile) { + if (usesProvider) { + if (!thumbFile.buffer) { + await rollbackUploads(uploadedFiles); + return R.error(res, "Thumbnail buffer is required.", 400); + } + const baseName = file.originalname.replace(/\.[^.]+$/, ""); + const svc = getProvider(storage_provider); + const thumbResult = await svc.uploadFile({ + buffer: thumbFile.buffer, + originalname: `thumb_${baseName}.${resolveExtension(thumbFile.originalname) || "jpg"}`, + mimetype: thumbFile.mimetype, + ownerType: "thumbnail", + }); + thumbnail_url = thumbResult.url; + uploadedFiles.push({ key: thumbResult.uuid, provider: storage_provider }); + } else { + thumbnail_url = thumbFile?.filename ? `/uploads/${thumbFile.filename}` : null; } } else { - const parsedWidth = req.body.width ? parseInt(req.body.width) : null; + const parsedWidth = req.body.width ? parseInt(req.body.width) : null; const parsedHeight = req.body.height ? parseInt(req.body.height) : null; - width = parsedWidth; - height = parsedHeight; + width = parsedWidth; + height = parsedHeight; resolution = resolveResolution(parsedWidth, parsedHeight); } @@ -357,10 +378,10 @@ exports.uploadAsset = async (req, res) => { const t = await sequelize.transaction(); try { const asset = await Asset.create({ - original_name: file.originalname, - display_name: display_name || file.originalname, + original_name: file.originalname, + display_name: display_name || file.originalname, file_url, - file_size: file.size, + file_size: file.size, mime_type, extension, checksum, @@ -376,29 +397,87 @@ exports.uploadAsset = async (req, res) => { thumbnail_url, description, storage_provider, - storage_bucket: storage_bucket || null, - storage_key: chibi_uuid || storage_key || file.filename, + storage_bucket: storage_bucket || (storage_provider === "s3" ? process.env.S3_BUCKET : null) || null, + storage_key: storage_key_resolved || storage_key || file.filename || null, is_public, createdBy, }, { transaction: t }); await t.commit(); - + logActivity(req.user?.user_id, 'upload_asset', { entityType: 'asset', entityId: asset.asset_id, details: { display_name: asset.display_name, file_type: asset.file_type } }); return R.success(res, "Asset uploaded.", { data: asset }, 201); } catch (dbErr) { - try { await t.rollback(); } catch { /* connection already gone */ } - await rollbackChibiUploads(uploadedChibiUuids); + try { await t.rollback(); } catch { /* connection gone */ } + await rollbackUploads(uploadedFiles); throw dbErr; } } catch (err) { console.error("[ASSET][UPLOAD]", err); + if (err.status) return R.error(res, err.message, err.status, { detail: err.chibiBody }); + return R.error(res, "Internal server error.", 500); + } +}; - if (err.status) { - return R.error(res, err.message, err.status, { detail: err.chibiBody }); +// ─── UPDATE ─────────────────────────────────────────────────────────────────── + +exports.updateAsset = async (req, res) => { + let newUpload = null; // { key, provider } + + try { + const { assetId } = req.params; + if (!assetId || assetId === "undefined") return R.error(res, "Invalid asset ID.", 400); + + const asset = await Asset.findOne({ where: { asset_id: assetId, ...notDeleted } }); + if (!asset) return R.error(res, "Asset not found.", 404); + + const file = req.files?.file?.[0] ?? req.file ?? null; + const isVideo = asset.file_type === "video"; + const isDocument = asset.file_type === "document"; + + if (isDocument && file) return R.error(res, "Document files cannot be replaced.", 400); + if (isVideo && file && !req.body.is_thumbnail) return R.error(res, "Video files cannot be replaced. Upload a new asset instead.", 400); + if (file && !file.buffer) return R.error(res, "File buffer is required.", 400); + + const storageProvider = asset.storage_provider; + const usesProvider = ["chibisafe", "s3"].includes(storageProvider); + const oldStorageKey = isVideo ? asset.thumbnail_storage_key : asset.storage_key; + + // ── Phase 1: Upload ─────────────────────────────────────────────────────── + + let uploaded = null; + + if (file && usesProvider) { + const ownerType = isVideo ? "thumbnail" : resolveFileType(file.mimetype); + uploaded = await uploadToProvider(file, ownerType, storageProvider); + newUpload = { key: uploaded.storage_key, provider: storageProvider }; } + // ── Phase 2: DB update ──────────────────────────────────────────────────── + + const t = await sequelize.transaction(); + try { + await applyAssetUpdate(asset, uploaded ? { ...file, ...uploaded } : null, req.body); + await asset.save({ transaction: t }); + await t.commit(); + } catch (dbErr) { + try { await t.rollback(); } catch { /* gone */ } + if (newUpload) await rollbackUploads([newUpload]); + throw dbErr; + } + + // ── Phase 3: Cleanup old file ───────────────────────────────────────────── + + if (uploaded) await deleteOldFile(storageProvider, oldStorageKey, uploaded.storage_key); + + logActivity(req.user?.user_id, 'update_asset', { entityType: 'asset', entityId: Number(assetId) }); + return R.success(res, "Asset updated.", { data: asset }); + + } catch (err) { + if (newUpload) await rollbackUploads([newUpload]); + console.error("[ASSET][UPDATE]", err); + if (err.status) return R.error(res, err.message, err.status, { detail: err.chibiBody }); return R.error(res, "Internal server error.", 500); } }; @@ -415,7 +494,7 @@ exports.archiveAsset = async (req, res) => { await asset.update({ deletedBy: req.body.deletedBy ?? null }); await asset.destroy(); - + logActivity(req.user?.user_id, 'archive_asset', { entityType: 'asset', entityId: Number(assetId) }); return R.success(res, "Asset archived."); } catch (err) { console.error("[ASSET][ARCHIVE]", err); @@ -435,12 +514,10 @@ exports.archiveAssets = async (req, res) => { const activeIds = assets.map((a) => a.asset_id); - await Asset.update( - { deletedBy: deletedBy ?? null }, - { where: { asset_id: { [Op.in]: activeIds } } } - ); + await Asset.update({ deletedBy: deletedBy ?? null }, { where: { asset_id: { [Op.in]: activeIds } } }); await Asset.destroy({ where: { asset_id: { [Op.in]: activeIds } } }); + logActivity(req.user?.user_id, 'bulk_archive_assets', { entityType: 'asset', details: { ids: activeIds, count: activeIds.length } }); return R.success(res, `${activeIds.length} asset(s) archived.`, { archived_ids: activeIds, skipped_ids: ids.filter((id) => !activeIds.includes(id)), @@ -457,16 +534,13 @@ exports.restoreAsset = async (req, res) => { try { const { assetId } = req.params; - const asset = await Asset.findOne({ - where: { asset_id: assetId }, - paranoid: false, - }); - if (!asset) return R.error(res, "Asset not found.", 404); + const asset = await Asset.findOne({ where: { asset_id: assetId }, paranoid: false }); + if (!asset) return R.error(res, "Asset not found.", 404); if (!asset.deletedAt) return R.error(res, "Asset is not archived.", 400); await asset.restore(); await asset.update({ deletedBy: null }); - + logActivity(req.user?.user_id, 'restore_asset', { entityType: 'asset', entityId: Number(assetId) }); return R.success(res, "Asset restored.", { data: asset }); } catch (err) { console.error("[ASSET][RESTORE]", err); @@ -481,10 +555,7 @@ exports.restoreAssets = async (req, res) => { const { ids } = req.body; if (!Array.isArray(ids) || !ids.length) return R.error(res, "ids must be a non-empty array.", 400); - const assets = await Asset.findAll({ - where: { asset_id: { [Op.in]: ids } }, - paranoid: false, - }); + const assets = await Asset.findAll({ where: { asset_id: { [Op.in]: ids } }, paranoid: false }); if (!assets.length) return R.error(res, "No assets found.", 404); const archivedAssets = assets.filter((a) => a.deletedAt); @@ -493,11 +564,9 @@ exports.restoreAssets = async (req, res) => { const archivedIds = archivedAssets.map((a) => a.asset_id); await Asset.restore({ where: { asset_id: { [Op.in]: archivedIds } } }); - await Asset.update( - { deletedBy: null }, - { where: { asset_id: { [Op.in]: archivedIds } }, paranoid: false } - ); + await Asset.update({ deletedBy: null }, { where: { asset_id: { [Op.in]: archivedIds } }, paranoid: false }); + logActivity(req.user?.user_id, 'bulk_restore_assets', { entityType: 'asset', details: { ids: archivedIds, count: archivedIds.length } }); return R.success(res, `${archivedIds.length} asset(s) restored.`, { restored_ids: archivedIds, skipped_ids: ids.filter((id) => !archivedIds.includes(id)), @@ -508,58 +577,7 @@ exports.restoreAssets = async (req, res) => { } }; -exports.updateAsset = async (req, res) => { - let newChibiUuid = null; - - try { - const { assetId } = req.params; - if (!assetId || assetId === "undefined") return R.error(res, "Invalid asset ID.", 400); - - const asset = await Asset.findOne({ where: { asset_id: assetId, ...notDeleted } }); - if (!asset) return R.error(res, "Asset not found.", 404); - - const file = req.files?.file?.[0] ?? req.file ?? null; - const isVideo = asset.file_type === "video"; - const isDocument = asset.file_type === "document"; - - if (isDocument && file) return R.error(res, "Document files cannot be replaced.", 400); - if (isVideo && file && !req.body.is_thumbnail) return R.error(res, "Video files cannot be replaced. Upload a new asset instead.", 400); - if (file && !file.buffer) return R.error(res, "File buffer is required.", 400); - - // ── Phase 1: Upload ─────────────────────────────────────────────────────── - let uploaded = null; - let oldStorageKey = isVideo ? asset.thumbnail_storage_key : asset.storage_key; - - if (file && asset.storage_provider === "chibisafe") { - const ownerType = isVideo ? "thumbnail" : resolveFileType(file.mimetype); - uploaded = await uploadToAlbum(file, ownerType); - newChibiUuid = uploaded.chibi_uuid; - } - - // ── Phase 2: DB update ──────────────────────────────────────────────────── - const t = await sequelize.transaction(); - try { - await applyAssetUpdate(asset, uploaded ? { ...file, ...uploaded } : null, req.body); - await asset.save({ transaction: t }); - await t.commit(); - } catch (dbErr) { - try { await t.rollback(); } catch { /* gone */ } - if (newChibiUuid) await rollbackChibiUploads([newChibiUuid]); - throw dbErr; - } - - // ── Phase 3: Cleanup ────────────────────────────────────────────────────── - if (uploaded) await deleteOldFile(asset, oldStorageKey, newChibiUuid); - - return R.success(res, "Asset updated.", { data: asset }); - - } catch (err) { - if (newChibiUuid) await rollbackChibiUploads([newChibiUuid]); - console.error("[ASSET][REPLACE FILE]", err); - if (err.status) return R.error(res, err.message, err.status, { detail: err.chibiBody }); - return R.error(res, "Internal server error.", 500); - } -}; +// ─── ARCHIVED LIST ──────────────────────────────────────────────────────────── exports.getArchivedAssets = async (req, res) => { try { @@ -569,12 +587,9 @@ exports.getArchivedAssets = async (req, res) => { computedAttributes, context: "list", auditOptions: { mdl_Users, parentAlias: 'Asset' }, - findOptions: { - paranoid: false, - where: { deletedAt: { [Op.ne]: null } }, - }, + findOptions: { paranoid: false, where: { deletedAt: { [Op.ne]: null } } }, }); - + result.data = result.data.map(redactS3Url); return R.success(res, "Archived assets retrieved.", result); } catch (err) { console.error("[ASSET][GET ARCHIVED]", err); diff --git a/controllers/admin/categories.controller.js b/controllers/admin/categories.controller.js new file mode 100644 index 0000000..8321780 --- /dev/null +++ b/controllers/admin/categories.controller.js @@ -0,0 +1,89 @@ +'use strict'; +const mdl_Category = require('../../models/courses/categories.mdl'); +const R = require('../../utils/response.util'); +const logActivity = require('../../utils/logActivity.util'); + +const slugify = (str) => + str.toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, ''); + +exports.getCategories = async (req, res) => { + try { + const rows = await mdl_Category.findAll({ order: [['name', 'ASC']], paranoid: false }); + return R.success(res, 'Categories retrieved.', rows); + } catch (err) { + console.error('[ADMIN][CATEGORIES][GET ALL]', err); + return R.error(res, 'Could not retrieve categories.', 500); + } +}; + +exports.getCategory = async (req, res) => { + try { + const row = await mdl_Category.findByPk(req.params.id, { paranoid: false }); + if (!row) return R.error(res, 'Category not found.', 404); + return R.success(res, 'Category retrieved.', row); + } catch (err) { + console.error('[ADMIN][CATEGORIES][GET ONE]', err); + return R.error(res, 'Could not retrieve category.', 500); + } +}; + +exports.createCategory = async (req, res) => { + try { + const { name, description, is_active } = req.body; + if (!name) return R.error(res, 'name is required.', 400); + + const slug = slugify(name); + const row = await mdl_Category.create({ name, slug, description: description ?? null, is_active: is_active ?? true }); + logActivity(req.user?.user_id, 'create_category', { entityType: 'category', entityId: row.category_id, details: { name: row.name } }); + return R.success(res, 'Category created.', row, 201); + } catch (err) { + if (err.name === 'SequelizeUniqueConstraintError') + return R.error(res, 'A category with that name already exists.', 409); + console.error('[ADMIN][CATEGORIES][CREATE]', err); + return R.error(res, 'Could not create category.', 500); + } +}; + +exports.updateCategory = async (req, res) => { + try { + const row = await mdl_Category.findByPk(req.params.id); + if (!row) return R.error(res, 'Category not found.', 404); + + const { name, description, is_active } = req.body; + const slug = name ? slugify(name) : row.slug; + await row.update({ name: name ?? row.name, slug, description: description ?? row.description, is_active: is_active ?? row.is_active }); + logActivity(req.user?.user_id, 'update_category', { entityType: 'category', entityId: row.category_id }); + return R.success(res, 'Category updated.', row); + } catch (err) { + if (err.name === 'SequelizeUniqueConstraintError') + return R.error(res, 'A category with that name already exists.', 409); + console.error('[ADMIN][CATEGORIES][UPDATE]', err); + return R.error(res, 'Could not update category.', 500); + } +}; + +exports.archiveCategory = async (req, res) => { + try { + const row = await mdl_Category.findByPk(req.params.id); + if (!row) return R.error(res, 'Category not found.', 404); + await row.destroy(); + logActivity(req.user?.user_id, 'archive_category', { entityType: 'category', entityId: row.category_id }); + return R.success(res, 'Category archived.'); + } catch (err) { + console.error('[ADMIN][CATEGORIES][ARCHIVE]', err); + return R.error(res, 'Could not archive category.', 500); + } +}; + +exports.restoreCategory = async (req, res) => { + try { + const row = await mdl_Category.findByPk(req.params.id, { paranoid: false }); + if (!row) return R.error(res, 'Category not found.', 404); + await row.restore(); + logActivity(req.user?.user_id, 'restore_category', { entityType: 'category', entityId: row.category_id }); + return R.success(res, 'Category restored.', row); + } catch (err) { + console.error('[ADMIN][CATEGORIES][RESTORE]', err); + return R.error(res, 'Could not restore category.', 500); + } +}; diff --git a/controllers/admin/course_reading_progress.controller.js b/controllers/admin/course_reading_progress.controller.js new file mode 100644 index 0000000..95a5c5a --- /dev/null +++ b/controllers/admin/course_reading_progress.controller.js @@ -0,0 +1,196 @@ +/*********************************************************************************************************************************************************************** + * File Name: course_reading_progress.controller.js (admin) + * Type of Program: Controller + * Description: Admin-facing endpoints for viewing course reading progress. + * + * GET /:courseId/reading-progress + * → one entry per user who has touched the course, with lesson/unit counts aggregated + * + * GET /:courseId/reading-progress/users/:userId + * → full lesson + unit breakdown for a single user (loaded on row expand) + * + * Author: Kenneth Obsequio (@lash0000) + * Date Created: Jun. 21, 2026 + ***********************************************************************************************************************************************************************/ +'use strict'; + +const { Op } = require('sequelize'); +const R = require('../../utils/response.util'); +const CourseReadingProgress = require('../../models/courses/course_reading_progress.mdl'); +const { Course, Unit, Lesson } = require('../../models/courses/courses.associations'); +const mdl_Users = require('../../models/users/users.mdl'); + +const notDeleted = { deletedAt: null }; + +// ============================================================================= +// ── LIST — all users who touched this course ────────────────────────────────── +// ============================================================================= + +// GET /admin/courses/:courseId/reading-progress +// Returns one summary row per user. Lesson + unit counts are derived by querying +// the course structure server-side so the totals are always accurate. + +exports.getCourseReadingProgress = async (req, res) => { + try { + const { courseId } = req.params; + + const course = await Course.findOne({ + where: { course_id: courseId, ...notDeleted }, + attributes: ['course_id'], + }); + if (!course) return R.error(res, 'Course not found.', 404); + + // Count total lessons and units in the course (structure totals) + const [units, allLessons] = await Promise.all([ + Unit.findAll({ + where: { course_id: courseId, ...notDeleted }, + attributes: ['unit_id', 'uuid'], + }), + Lesson.findAll({ + include: [{ + model: Unit, + as: 'unit', + where: { course_id: courseId, ...notDeleted }, + attributes: [], + required: true, + }], + where: { ...notDeleted }, + attributes: ['lesson_id', 'uuid'], + }), + ]); + + const units_total = units.length; + const lessons_total = allLessons.length; + + // All progress rows for this course, grouped per user + const rows = await CourseReadingProgress.findAll({ + where: { course_id: courseId }, + attributes: ['user_id', 'reference_id', 'type', 'status', 'last_accessed_at'], + order: [['last_accessed_at', 'DESC']], + }); + + if (!rows.length) return R.success(res, 'No reading progress for this course yet.', []); + + // Aggregate per user + const userIds = [...new Set(rows.map((r) => r.user_id))]; + + const users = await mdl_Users.findAll({ + where: { user_id: userIds }, + attributes: ['user_id', 'email', 'personal_info'], + }); + const userMap = Object.fromEntries(users.map((u) => [u.user_id, u])); + + // Build per-user summary + const summaryMap = {}; + for (const row of rows) { + if (!summaryMap[row.user_id]) { + summaryMap[row.user_id] = { + user_id: row.user_id, + course_status: null, + last_accessed_at: null, + lessons_completed: 0, + units_completed: 0, + }; + } + const entry = summaryMap[row.user_id]; + + // Track latest access across all rows for this user + if (!entry.last_accessed_at || new Date(row.last_accessed_at) > new Date(entry.last_accessed_at)) { + entry.last_accessed_at = row.last_accessed_at; + } + + if (row.type === 'course') entry.course_status = row.status; + if (row.type === 'unit' && row.status === 'completed') entry.units_completed++; + if (row.type === 'lesson' && row.status === 'completed') entry.lessons_completed++; + } + + const result = Object.values(summaryMap).map((entry) => { + const u = userMap[entry.user_id]; + return { + ...entry, + user: { + email: u?.email ?? null, + full_name: u?.personal_info?.name?.full_name ?? null, + avatar_url: u?.personal_info?.avatar?.url ?? null, + }, + units_total, + lessons_total, + // Fall back to in_progress if the course row hasn't been written yet + course_status: entry.course_status ?? 'in_progress', + }; + }); + + // Sort: completed last, most recent first within each group + result.sort((a, b) => { + if (a.course_status !== b.course_status) { + return a.course_status === 'completed' ? 1 : -1; + } + return new Date(b.last_accessed_at) - new Date(a.last_accessed_at); + }); + + return R.success(res, 'Course reading progress retrieved.', result); + } catch (err) { + console.error('[ADMIN][COURSE READING PROGRESS][LIST]', err); + return R.error(res, 'Could not retrieve reading progress.', 500); + } +}; + +// ============================================================================= +// ── DETAIL — single user's full lesson/unit breakdown ───────────────────────── +// ============================================================================= + +// GET /admin/courses/:courseId/reading-progress/users/:userId +// Loaded lazily when the admin expands a user row. +// Returns units with their lessons and the progress status per item. + +exports.getUserReadingProgress = async (req, res) => { + try { + const { courseId, userId } = req.params; + + const [units, progressRows] = await Promise.all([ + Unit.findAll({ + where: { course_id: courseId, ...notDeleted }, + attributes: ['unit_id', 'uuid', 'title', 'order_index'], + include: [{ + model: Lesson, + as: 'lessons', + where: notDeleted, + required: false, + attributes: ['lesson_id', 'uuid', 'title', 'order_index'], + }], + order: [ + ['order_index', 'ASC'], + [{ model: Lesson, as: 'lessons' }, 'order_index', 'ASC'], + ], + }), + CourseReadingProgress.findAll({ + where: { course_id: courseId, user_id: userId }, + attributes: ['reference_id', 'type', 'status', 'completed_at', 'last_accessed_at'], + }), + ]); + + // Build a quick lookup: { [reference_id (uuid)]: status } + const progressMap = Object.fromEntries( + progressRows.map((r) => [r.reference_id, { status: r.status, completed_at: r.completed_at }]) + ); + + const breakdown = units.map((unit) => ({ + unit_id: unit.unit_id, + uuid: unit.uuid, + title: unit.title, + status: progressMap[unit.uuid]?.status ?? null, + lessons: (unit.lessons ?? []).map((lesson) => ({ + lesson_id: lesson.lesson_id, + uuid: lesson.uuid, + title: lesson.title, + status: progressMap[lesson.uuid]?.status ?? null, + completed_at: progressMap[lesson.uuid]?.completed_at ?? null, + })), + })); + + return R.success(res, 'User reading progress retrieved.', breakdown); + } catch (err) { + console.error('[ADMIN][COURSE READING PROGRESS][USER DETAIL]', err); + return R.error(res, 'Could not retrieve user reading progress.', 500); + } +}; diff --git a/controllers/admin/courses.controller.js b/controllers/admin/courses.controller.js index 3694d7e..4296d91 100644 --- a/controllers/admin/courses.controller.js +++ b/controllers/admin/courses.controller.js @@ -10,15 +10,17 @@ const { syncJunction } = require("../../utils/courses/junction.util"); const { archiveOne, archiveMany } = require("../../utils/courses/archive.util"); const { restoreOne, restoreMany } = require("../../utils/courses/restore.util"); const { getFieldValues } = require("../../utils/fieldValues.util"); +const logActivity = require('../../utils/logActivity.util'); // ── Models ──────────────────────────────────────────────────────────────────── const { - Course, CourseProduct, CourseRole, CourseProductCategory: CourseProductCat, + Course, CourseProductCategory: CourseProductCat, Unit, Lesson, LessonPage, CourseObjective, LessonObjective, CoursePrerequisite, CourseAssessment, UnitQuiz, QuizQuestion, QuizOption, + CourseInstructor, } = require("../../models/courses/courses.associations"); const mdl_Users = require("../../models/users/users.mdl"); @@ -94,7 +96,7 @@ exports.createCourse = async (req, res) => { title, description, order_index, course_code, level, subscription, objectives = [], - product_ids = [], role_ids = [], category_ids = [], + category_ids = [], createdBy, } = req.body; @@ -112,11 +114,10 @@ exports.createCourse = async (req, res) => { }, { transaction: t }); await syncObjectivesCreate(CourseObjective, "course_id", course.course_id, objectives, t); - await syncJunction(CourseProduct, course.course_id, product_ids, "product_id", t); - await syncJunction(CourseRole, course.course_id, role_ids, "role_id", t); await syncJunction(CourseProductCat, course.course_id, category_ids, "category_id", t); await t.commit(); + logActivity(req.user?.user_id, 'create_course', { entityType: 'course', entityId: course.course_id, details: { title: course.title } }); return R.success(res, "Course created.", { data: course }, 201); } catch (err) { await t.rollback(); @@ -135,7 +136,7 @@ exports.updateCourse = async (req, res) => { const { title, description, order_index, course_code, level, subscription, - objectives, product_ids, role_ids, category_ids, + objectives, category_ids, updatedBy, } = req.body; @@ -149,11 +150,10 @@ exports.updateCourse = async (req, res) => { await course.save({ transaction: t }); if (objectives !== undefined) await syncObjectivesUpdate(CourseObjective, "course_id", courseId, objectives, t); - if (product_ids !== undefined) await syncJunction(CourseProduct, courseId, product_ids, "product_id", t); - if (role_ids !== undefined) await syncJunction(CourseRole, courseId, role_ids, "role_id", t); if (category_ids !== undefined) await syncJunction(CourseProductCat, courseId, category_ids, "category_id", t); await t.commit(); + logActivity(req.user?.user_id, 'update_course', { entityType: 'course', entityId: Number(courseId), details: { title: course.title } }); return R.success(res, "Course updated.", { data: course }); } catch (err) { await t.rollback(); @@ -169,6 +169,7 @@ exports.archiveCourse = async (req, res) => { const record = await archiveOne(Course, { course_id: courseId, ...notDeleted }, req.user.user_id, t); if (!record) return R.error(res, "Course not found.", 404); await t.commit(); + logActivity(req.user.user_id, 'archive_course', { entityType: 'course', entityId: Number(courseId) }); return R.success(res, "Course archived."); } catch (err) { await t.rollback(); @@ -185,6 +186,7 @@ exports.bulkArchiveCourses = async (req, res) => { const count = await archiveMany(Course, "course_id", ids, req.user.user_id, t); await t.commit(); + logActivity(req.user.user_id, 'bulk_archive_courses', { entityType: 'course', details: { ids, count } }); return R.success(res, `${count} course${count !== 1 ? "s" : ""} archived.`); } catch (err) { await t.rollback(); @@ -231,6 +233,7 @@ exports.restoreCourse = async (req, res) => { const record = await restoreOne(Course, { course_id: courseId }, req.user.user_id, t); if (!record) return R.error(res, "Archived course not found.", 404); await t.commit(); + logActivity(req.user.user_id, 'restore_course', { entityType: 'course', entityId: Number(courseId) }); return R.success(res, "Course restored.", { data: record }); } catch (err) { await t.rollback(); @@ -247,6 +250,7 @@ exports.bulkRestoreCourses = async (req, res) => { const count = await restoreMany(Course, "course_id", ids, req.user.user_id, t); await t.commit(); + logActivity(req.user.user_id, 'bulk_restore_courses', { entityType: 'course', details: { ids, count } }); return R.success(res, `${count} course${count !== 1 ? "s" : ""} restored.`); } catch (err) { await t.rollback(); @@ -304,6 +308,7 @@ exports.syncPrerequisites = async (req, res) => { } await t.commit(); + logActivity(req.user?.user_id, 'sync_prerequisites', { entityType: 'course', entityId: Number(courseId), details: { count: prerequisites.length } }); return R.success(res, "Prerequisites updated."); } catch (err) { await t.rollback(); @@ -380,6 +385,7 @@ exports.createUnit = async (req, res) => { createdBy: createdBy ?? null, }); + logActivity(req.user?.user_id, 'create_unit', { entityType: 'unit', entityId: unit.unit_id, details: { title: unit.title } }); return R.success(res, "Unit created.", { data: unit }, 201); } catch (err) { console.error("[UNIT][CREATE]", err); @@ -402,6 +408,7 @@ exports.updateUnit = async (req, res) => { unit.updatedBy = updatedBy ?? null; await unit.save(); + logActivity(req.user?.user_id, 'update_unit', { entityType: 'unit', entityId: Number(unitId) }); return R.success(res, "Unit updated.", { data: unit }); } catch (err) { console.error("[UNIT][UPDATE]", err); @@ -416,6 +423,7 @@ exports.archiveUnit = async (req, res) => { const record = await archiveOne(Unit, { unit_id: unitId, course_id: courseId, ...notDeleted }, req.user.user_id, t); if (!record) return R.error(res, "Unit not found.", 404); await t.commit(); + logActivity(req.user.user_id, 'archive_unit', { entityType: 'unit', entityId: Number(unitId) }); return R.success(res, "Unit archived."); } catch (err) { await t.rollback(); @@ -436,6 +444,7 @@ exports.bulkArchiveUnits = async (req, res) => { const count = await archiveMany(Unit, "unit_id", validIds, req.user.user_id, t); await t.commit(); + logActivity(req.user.user_id, 'bulk_archive_units', { entityType: 'unit', details: { ids: validIds, count } }); return R.success(res, `${count} unit${count !== 1 ? "s" : ""} archived.`); } catch (err) { await t.rollback(); @@ -491,6 +500,7 @@ exports.restoreUnit = async (req, res) => { const record = await restoreOne(Unit, { unit_id: unitId, course_id: courseId, ...onlyDeleted }, req.user.user_id, t); if (!record) return R.error(res, "Archived unit not found.", 404); await t.commit(); + logActivity(req.user.user_id, 'restore_unit', { entityType: 'unit', entityId: Number(unitId) }); return R.success(res, "Unit restored.", { data: record }); } catch (err) { await t.rollback(); @@ -511,6 +521,7 @@ exports.bulkRestoreUnits = async (req, res) => { const count = await restoreMany(Unit, "unit_id", validIds, req.user.user_id, t); await t.commit(); + logActivity(req.user.user_id, 'bulk_restore_units', { entityType: 'unit', details: { ids: validIds, count } }); return R.success(res, `${count} unit${count !== 1 ? "s" : ""} restored.`); } catch (err) { await t.rollback(); @@ -597,6 +608,7 @@ exports.createLesson = async (req, res) => { await syncObjectivesCreate(LessonObjective, "lesson_id", lesson.lesson_id, objectives, t); await t.commit(); + logActivity(req.user?.user_id, 'create_lesson', { entityType: 'lesson', entityId: lesson.lesson_id, details: { title: lesson.title } }); return R.success(res, "Lesson created.", { data: lesson }, 201); } catch (err) { await t.rollback(); @@ -635,6 +647,7 @@ exports.updateLesson = async (req, res) => { include: [{ model: LessonObjective, as: "objectives", order: [["order_index", "ASC"]] }], }); + logActivity(req.user?.user_id, 'update_lesson', { entityType: 'lesson', entityId: Number(lessonId) }); return R.success(res, "Lesson updated.", { data: updated }); } catch (err) { await t.rollback(); @@ -655,6 +668,7 @@ exports.archiveLesson = async (req, res) => { if (!record) return R.error(res, "Lesson not found.", 404); await t.commit(); + logActivity(req.user.user_id, 'archive_lesson', { entityType: 'lesson', entityId: Number(lessonId) }); return R.success(res, "Lesson archived."); } catch (err) { await t.rollback(); @@ -678,6 +692,7 @@ exports.bulkArchiveLessons = async (req, res) => { const count = await archiveMany(Lesson, "lesson_id", validIds, req.user.user_id, t); await t.commit(); + logActivity(req.user.user_id, 'bulk_archive_lessons', { entityType: 'lesson', details: { ids: validIds, count } }); return R.success(res, `${count} lesson${count !== 1 ? "s" : ""} archived.`); } catch (err) { await t.rollback(); @@ -741,6 +756,7 @@ exports.restoreLesson = async (req, res) => { const record = await restoreOne(Lesson, { lesson_id: lessonId, unit_id: unitId, ...onlyDeleted }, req.user.user_id, t); if (!record) return R.error(res, "Archived lesson not found.", 404); await t.commit(); + logActivity(req.user.user_id, 'restore_lesson', { entityType: 'lesson', entityId: Number(lessonId) }); return R.success(res, "Lesson restored.", { data: record }); } catch (err) { await t.rollback(); @@ -764,6 +780,7 @@ exports.bulkRestoreLessons = async (req, res) => { const count = await restoreMany(Lesson, "lesson_id", validIds, req.user.user_id, t); await t.commit(); + logActivity(req.user.user_id, 'bulk_restore_lessons', { entityType: 'lesson', details: { ids: validIds, count } }); return R.success(res, `${count} lesson${count !== 1 ? "s" : ""} restored.`); } catch (err) { await t.rollback(); @@ -811,6 +828,7 @@ exports.upsertLessonPage = async (req, res) => { console.error("[LESSON PAGE][DURATION]", durErr); } + logActivity(req.user?.user_id, 'upsert_lesson_page', { entityType: 'lesson', entityId: Number(lessonId) }); return R.success( res, created ? "Lesson page created." : "Lesson page updated.", @@ -871,6 +889,7 @@ exports.createQuiz = async (req, res) => { createdBy: createdBy ?? null, }); + logActivity(req.user?.user_id, 'create_quiz', { entityType: 'quiz', entityId: quiz.quiz_id }); return R.success(res, "Quiz created.", { data: quiz }, 201); } catch (err) { console.error("[QUIZ][CREATE]", err); @@ -894,6 +913,7 @@ exports.updateQuiz = async (req, res) => { quiz.updatedBy = updatedBy ?? null; await quiz.save(); + logActivity(req.user?.user_id, 'update_quiz', { entityType: 'quiz', entityId: Number(quizId) }); return R.success(res, "Quiz updated.", { data: quiz }); } catch (err) { console.error("[QUIZ][UPDATE]", err); @@ -908,6 +928,7 @@ exports.deleteQuiz = async (req, res) => { const record = await archiveOne(UnitQuiz, { quiz_id: quizId, unit_id: unitId, ...notDeleted }, req.body.deletedBy, t); if (!record) return R.error(res, "Quiz not found.", 404); await t.commit(); + logActivity(req.user?.user_id, 'archive_quiz', { entityType: 'quiz', entityId: Number(quizId) }); return R.success(res, "Quiz archived."); } catch (err) { await t.rollback(); @@ -942,6 +963,7 @@ exports.restoreQuiz = async (req, res) => { const record = await restoreOne(UnitQuiz, { quiz_id: quizId, unit_id: unitId, ...onlyDeleted }, req.body.restoredBy, t); if (!record) return R.error(res, "Archived quiz not found.", 404); await t.commit(); + logActivity(req.user?.user_id, 'restore_quiz', { entityType: 'quiz', entityId: Number(quizId) }); return R.success(res, "Quiz restored.", { data: record }); } catch (err) { await t.rollback(); @@ -1034,6 +1056,7 @@ exports.createQuestion = async (req, res) => { include: [{ model: QuizOption, as: "options", order: [["order_index", "ASC"]] }], }); + logActivity(req.user?.user_id, 'create_question', { entityType: 'question', entityId: created.question_id }); return R.success(res, "Question created.", { data: created }, 201); } catch (err) { await t.rollback(); @@ -1081,6 +1104,7 @@ exports.updateQuestion = async (req, res) => { include: [{ model: QuizOption, as: "options", order: [["order_index", "ASC"]] }], }); + logActivity(req.user?.user_id, 'update_question', { entityType: 'question', entityId: Number(questionId) }); return R.success(res, "Question updated.", { data: updated }); } catch (err) { await t.rollback(); @@ -1096,6 +1120,7 @@ exports.deleteQuestion = async (req, res) => { const record = await archiveOne(QuizQuestion, { question_id: questionId, ...notDeleted }, req.body.deletedBy, t); if (!record) return R.error(res, "Question not found.", 404); await t.commit(); + logActivity(req.user?.user_id, 'archive_question', { entityType: 'question', entityId: Number(questionId) }); return R.success(res, "Question archived."); } catch (err) { await t.rollback(); @@ -1127,6 +1152,7 @@ exports.restoreQuestion = async (req, res) => { const record = await restoreOne(QuizQuestion, { question_id: questionId, ...onlyDeleted }, req.body.restoredBy, t); if (!record) return R.error(res, "Archived question not found.", 404); await t.commit(); + logActivity(req.user?.user_id, 'restore_question', { entityType: 'question', entityId: Number(questionId) }); return R.success(res, "Question restored.", { data: record }); } catch (err) { await t.rollback(); @@ -1152,6 +1178,7 @@ exports.bulkArchiveQuestions = async (req, res) => { const count = await archiveMany(QuizQuestion, "question_id", validIds, deletedBy, t); await t.commit(); + logActivity(req.user?.user_id, 'bulk_archive_questions', { entityType: 'question', details: { ids: validIds, count } }); return R.success(res, `${count} question${count !== 1 ? "s" : ""} archived.`); } catch (err) { await t.rollback(); @@ -1177,6 +1204,7 @@ exports.bulkRestoreQuestions = async (req, res) => { const count = await restoreMany(QuizQuestion, "question_id", validIds, restoredBy, t); await t.commit(); + logActivity(req.user?.user_id, 'bulk_restore_questions', { entityType: 'question', details: { ids: validIds, count } }); return R.success(res, `${count} question${count !== 1 ? "s" : ""} restored.`); } catch (err) { await t.rollback(); @@ -1227,10 +1255,11 @@ exports.createAssessment = async (req, res) => { is_required: is_required ?? false, passing_score: passing_score ?? 70, time_limit_minutes: time_limit_minutes ?? null, - max_questions: max_questions ?? null, // ← add + max_questions: max_questions ?? null, createdBy: createdBy ?? null, }); + logActivity(req.user?.user_id, 'create_assessment', { entityType: 'assessment', entityId: assessment.assessment_id }); return R.success(res, "Assessment created.", { data: assessment }, 201); } catch (err) { console.error("[ASSESSMENT][CREATE]", err); @@ -1257,6 +1286,7 @@ exports.updateAssessment = async (req, res) => { assessment.updatedBy = updatedBy ?? null; await assessment.save(); + logActivity(req.user?.user_id, 'update_assessment', { entityType: 'assessment', entityId: Number(assessmentId) }); return R.success(res, "Assessment updated.", { data: assessment }); } catch (err) { console.error("[ASSESSMENT][UPDATE]", err); @@ -1271,6 +1301,7 @@ exports.deleteAssessment = async (req, res) => { const record = await archiveOne(CourseAssessment, { assessment_id: assessmentId, course_id: courseId, ...notDeleted }, req.body.deletedBy, t); if (!record) return R.error(res, "Assessment not found.", 404); await t.commit(); + logActivity(req.user?.user_id, 'archive_assessment', { entityType: 'assessment', entityId: Number(assessmentId) }); return R.success(res, "Assessment archived."); } catch (err) { await t.rollback(); @@ -1305,6 +1336,7 @@ exports.restoreAssessment = async (req, res) => { const record = await restoreOne(CourseAssessment, { assessment_id: assessmentId, course_id: courseId, ...onlyDeleted }, req.body.restoredBy, t); if (!record) return R.error(res, "Archived assessment not found.", 404); await t.commit(); + logActivity(req.user?.user_id, 'restore_assessment', { entityType: 'assessment', entityId: Number(assessmentId) }); return R.success(res, "Assessment restored.", { data: record }); } catch (err) { await t.rollback(); @@ -1315,4 +1347,160 @@ exports.restoreAssessment = async (req, res) => { exports.getCourseFieldValues = getFieldValues(Course, "COURSE"); exports.getUnitFieldValues = getFieldValues(Unit, "UNIT"); -exports.getLessonFieldValues = getFieldValues(Lesson, "LESSON"); \ No newline at end of file +exports.getLessonFieldValues = getFieldValues(Lesson, "LESSON"); + +// ── Flat lists for requirement builder dropdowns ─────────────────────────────── +// Returns lightweight id+title arrays (no pagination) used when building task requirements. + +exports.getCoursesFlat = async (req, res) => { + try { + const data = await Course.findAll({ + where: notDeleted, + attributes: ["uuid", "title"], + order: [["title", "ASC"]], + }); + return R.success(res, "Courses retrieved.", data); + } catch (err) { + console.error("[COURSE][GET FLAT]", err); + return R.error(res, "Could not retrieve courses.", 500); + } +}; + +exports.getUnitsFlat = async (req, res) => { + try { + const rows = await Unit.findAll({ + where: notDeleted, + attributes: ["uuid", "title", "order_index"], + include: [{ + model: Course, + as: "course", + attributes: ["title"], + }], + order: [ + [{ model: Course, as: "course" }, "title", "ASC"], + ["order_index", "ASC"], + ], + }); + const data = rows.map((u) => ({ + uuid: u.uuid, + title: u.title, + order_index: u.order_index ?? 0, + course_title: u.course?.title ?? "", + })); + return R.success(res, "Units retrieved.", data); + } catch (err) { + console.error("[UNIT][GET FLAT]", err); + return R.error(res, "Could not retrieve units.", 500); + } +}; + +exports.getLessonsFlat = async (req, res) => { + try { + const rows = await Lesson.findAll({ + where: notDeleted, + attributes: ["uuid", "title", "order_index"], + include: [{ + model: Unit, + as: "unit", + attributes: ["title", "order_index"], + include: [{ + model: Course, + as: "course", + attributes: ["title"], + }], + }], + order: [ + [{ model: Unit, as: "unit" }, { model: Course, as: "course" }, "title", "ASC"], + [{ model: Unit, as: "unit" }, "order_index", "ASC"], + ["order_index", "ASC"], + ], + }); + const data = rows.map((l) => ({ + uuid: l.uuid, + title: l.title, + order_index: l.order_index ?? 0, + unit_title: l.unit?.title ?? "", + unit_order: l.unit?.order_index ?? 0, + course_title: l.unit?.course?.title ?? "", + })); + return R.success(res, "Lessons retrieved.", data); + } catch (err) { + console.error("[LESSON][GET FLAT]", err); + return R.error(res, "Could not retrieve lessons.", 500); + } +}; +// ══════════════════════════════════════════════════════════════════════════════ +// COURSE INSTRUCTORS +// ══════════════════════════════════════════════════════════════════════════════ + +exports.getInstructors = async (req, res) => { + try { + const { courseId } = req.params; + + const instructors = await CourseInstructor.findAll({ + where: { course_id: courseId }, + order: [["order_index", "ASC"]], + include: [{ + model: mdl_Users, + as: "user", + attributes: ["user_id", "email", "acc_type", "personal_info"], + required: false, + }], + }); + + return R.success(res, "Instructors retrieved.", { data: instructors }); + } catch (err) { + console.error("[INSTRUCTOR][GET]", err); + return R.error(res, "Could not retrieve instructors.", 500); + } +}; + +exports.syncInstructors = async (req, res) => { + const t = await sequelize.transaction(); + try { + const { courseId } = req.params; + const { instructors = [] } = req.body; + const actor_id = req.user?.user_id ?? null; + + const course = await Course.findOne({ where: { course_id: courseId, ...notDeleted } }); + if (!course) return R.error(res, "Course not found.", 404); + + // Validate any linked user_ids are staff or admin + const linkedIds = instructors.map(i => i.user_id).filter(Boolean); + if (linkedIds.length) { + const validUsers = await mdl_Users.findAll({ + where: { user_id: linkedIds, acc_type: ["staff", "admin"], deletedAt: null }, + attributes: ["user_id"], + }); + const validSet = new Set(validUsers.map(u => String(u.user_id))); + const invalid = linkedIds.find(id => !validSet.has(String(id))); + if (invalid) { + await t.rollback(); + return R.error(res, `User ${invalid} is not a staff or admin account.`, 422); + } + } + + await CourseInstructor.destroy({ where: { course_id: courseId }, transaction: t }); + + if (instructors.length) { + await CourseInstructor.bulkCreate( + instructors.map((inst, i) => ({ + course_id: courseId, + user_id: inst.user_id ?? null, + display_name: inst.display_name, + order_index: inst.order_index ?? i, + created_by: actor_id, + })), + { transaction: t } + ); + } + + await t.commit(); + logActivity(actor_id, 'sync_instructors', { entityType: 'course', entityId: Number(courseId), details: { count: instructors.length } }); + return R.success(res, "Instructors updated."); + } catch (err) { + await t.rollback(); + console.error("[INSTRUCTOR][SYNC]", err); + return R.error(res, "Could not update instructors.", 500); + } +}; diff --git a/controllers/admin/documentation/advertisements.md b/controllers/admin/documentation/advertisements.md new file mode 100644 index 0000000..dde36f8 --- /dev/null +++ b/controllers/admin/documentation/advertisements.md @@ -0,0 +1,284 @@ +# Advertisements Controller Documentation + +**File:** `controllers/admin/advertisements.controller.js` +**Base URL:** `/api/admin/advertisements` +**Guards:** `authenticate → requireAdmin() → adminLimiter` + +--- + +## Table of Contents +- [Get All Advertisements](#get-all-advertisements) +- [Get Single Advertisement](#get-single-advertisement) +- [Create Advertisement](#create-advertisement) +- [Update Advertisement](#update-advertisement) +- [Archive Advertisement](#archive-advertisement) +- [Bulk Archive Advertisements](#bulk-archive-advertisements) +- [Restore Advertisement](#restore-advertisement) +- [Bulk Restore Advertisements](#bulk-restore-advertisements) +- [Get Archived Advertisements](#get-archived-advertisements) +- [Get Field Values](#get-field-values) + +--- + +## Status Derivation + +Status is **never** trusted as stored — it is recomputed on every read and write: + +| Condition | Derived Status | +|-----------|----------------| +| `deletedAt` is set | `archived` | +| `is_active = false` | `draft` | +| `end_date` < now | `expired` | +| `start_date` > now | `scheduled` | +| Otherwise | `active` | + +`archived` is the only status that bypasses derivation (set explicitly by archive/restore). + +--- + +## CTAs + +Each advertisement supports up to **2 CTAs**: + +```json +[ + { "label": "Learn More", "link": "/courses", "variant": "default" }, + { "label": "Sign Up", "link": "/register", "variant": "outline" } +] +``` + +- First CTA defaults to `"default"` variant; second defaults to `"outline"`. +- An explicit valid variant from the client (`"default"` or `"outline"`) always wins. +- Items beyond 2 are silently discarded. + +--- + +## Get All Advertisements + +**`GET /api/admin/advertisements`** + +Returns a paginated list of active (non-deleted) advertisements. Status is resynced on the way out. + +### Query Parameters +| Parameter | Type | Required | Description | +|-----------|--------|----------|-------------| +| page | number | No | Default: `1` | +| limit | number | No | Default: `10`, max: `1000` | +| filters | array | No | JSON array of filter objects | +| sort | array | No | JSON array of sort objects | + +### Response `200` +```json +{ + "status": "success", + "message": "Advertisements retrieved.", + "data": [...], + "pagination": { "page": 1, "limit": 10, "total": 5, "totalPages": 1 } +} +``` + +--- + +## Get Single Advertisement + +**`GET /api/admin/advertisements/:advertisementId`** + +Returns one advertisement with its `image` asset and audit user info. + +### Response `200` +```json +{ + "status": "success", + "message": "Advertisement retrieved.", + "data": { + "advertisement_id": 1, + "uuid": "...", + "type": "hero", + "status": "active", + "badge_label": "New", + "headline": "Welcome", + "description": "...", + "image_url": null, + "image_asset_id": 12, + "image": { "asset_id": 12, "display_name": "hero.jpg", "file_url": "...", "thumbnail_url": "..." }, + "ctas": [{ "label": "Start", "link": "/start", "variant": "default" }], + "start_date": "2026-01-01T00:00:00.000Z", + "end_date": null, + "order": 0, + "is_active": true, + "size": null, + "click_count": 0, + "creator": { "user_id": 1, "full_name": "Admin User" }, + "updater": null, + "createdAt": "2026-01-01T00:00:00.000Z", + "updatedAt": "2026-01-01T00:00:00.000Z" + } +} +``` + +### Error Responses +| Status | Message | +|--------|---------| +| `400` | `Invalid advertisement ID.` | +| `404` | `Advertisement not found.` | + +--- + +## Create Advertisement + +**`POST /api/admin/advertisements`** + +### Request Body +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `type` | string | **Yes** | `hero`, `banner`, `popup`, `sidebar` | +| `createdBy` | number | **Yes** | User ID of creator | +| `badge_label` | string | No | Small label shown on the ad | +| `headline` | string | No | Main heading | +| `description` | string | No | Body text | +| `image_url` | string | No | Direct image URL | +| `image_asset_id` | number | No | FK to `assets` table | +| `ctas` | array | No | Up to 2 CTA objects `[{ label, link, variant }]` | +| `start_date` | date | No | ISO date string | +| `end_date` | date | No | ISO date string | +| `order` | number | No | Display order. Default: `0` | +| `is_active` | boolean | No | Default: `true` | +| `size` | string | No | `sm`, `md`, `lg` — banner only | + +### Response `201` +```json +{ + "status": "success", + "message": "Advertisement created.", + "data": { ... } +} +``` + +### Error Responses +| Status | Message | +|--------|---------| +| `400` | `type is required.` | +| `400` | `Invalid type. Must be one of: hero, banner, popup, sidebar` | +| `400` | `createdBy is required.` | +| `400` | `Invalid size. Must be one of: sm, md, lg` | + +--- + +## Update Advertisement + +**`PATCH /api/admin/advertisements/:advertisementId`** + +Partial update. Only fields present in the body are changed. Status is recomputed after all fields are applied. + +### Request Body +Same optional fields as Create. Does not accept `type` once set. Accepts `updatedBy`. + +### Response `200` +```json +{ "status": "success", "message": "Advertisement updated.", "data": { ... } } +``` + +--- + +## Archive Advertisement + +**`DELETE /api/admin/advertisements/:advertisementId`** + +Soft-deletes the advertisement (`deletedAt` set, `status` → `archived`). + +### Response `200` +```json +{ "status": "success", "message": "Advertisement archived." } +``` + +--- + +## Bulk Archive Advertisements + +**`DELETE /api/admin/advertisements/bulk`** + +Rate-limited. Soft-deletes multiple advertisements. + +### Request Body +```json +{ "ids": [1, 2, 3], "deletedBy": 1 } +``` + +### Response `200` +```json +{ + "status": "success", + "message": "3 advertisement(s) archived.", + "archived_ids": [1, 2, 3], + "skipped_ids": [] +} +``` + +--- + +## Restore Advertisement + +**`PATCH /api/admin/advertisements/:advertisementId/restore`** + +Restores a soft-deleted advertisement. + +### Response `200` +```json +{ "status": "success", "message": "Advertisement restored.", "data": { ... } } +``` + +--- + +## Bulk Restore Advertisements + +**`PATCH /api/admin/advertisements/bulk-restore`** + +### Request Body +```json +{ "ids": [1, 2] } +``` + +### Response `200` +```json +{ + "status": "success", + "message": "2 advertisement(s) restored.", + "restored_ids": [1, 2], + "skipped_ids": [] +} +``` + +--- + +## Get Archived Advertisements + +**`GET /api/admin/advertisements/archived`** + +Returns paginated list of soft-deleted advertisements. + +### Response `200` +```json +{ + "status": "success", + "message": "Archived advertisements retrieved.", + "data": [...], + "pagination": { ... } +} +``` + +--- + +## Get Field Values + +**`GET /api/admin/advertisements/field-values`** + +Returns distinct values for filterable advertisement fields. Used by DataTable filter dropdowns. + +### Response `200` +```json +{ + "status": "success", + "message": "Field values retrieved.", + "data": { "type": ["hero", "banner"], "status": ["active", "draft"] } +} +``` diff --git a/controllers/admin/documentation/categories.md b/controllers/admin/documentation/categories.md new file mode 100644 index 0000000..5c287c0 --- /dev/null +++ b/controllers/admin/documentation/categories.md @@ -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.` | diff --git a/controllers/admin/documentation/courses.md b/controllers/admin/documentation/courses.md new file mode 100644 index 0000000..aec396d --- /dev/null +++ b/controllers/admin/documentation/courses.md @@ -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 + } + ] + } + ] +} +``` diff --git a/controllers/admin/documentation/dashboard.md b/controllers/admin/documentation/dashboard.md new file mode 100644 index 0000000..0bd0fc4 --- /dev/null +++ b/controllers/admin/documentation/dashboard.md @@ -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 } + ] + } + ] + } +} +``` diff --git a/controllers/admin/documentation/landing_pages.md b/controllers/admin/documentation/landing_pages.md new file mode 100644 index 0000000..63ba68f --- /dev/null +++ b/controllers/admin/documentation/landing_pages.md @@ -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` | diff --git a/controllers/admin/documentation/notifications.md b/controllers/admin/documentation/notifications.md new file mode 100644 index 0000000..d089eba --- /dev/null +++ b/controllers/admin/documentation/notifications.md @@ -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 } +} +``` diff --git a/controllers/admin/documentation/products.md b/controllers/admin/documentation/products.md new file mode 100644 index 0000000..684eac7 --- /dev/null +++ b/controllers/admin/documentation/products.md @@ -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." } +``` diff --git a/controllers/admin/documentation/profile.md b/controllers/admin/documentation/profile.md new file mode 100644 index 0000000..0c97837 --- /dev/null +++ b/controllers/admin/documentation/profile.md @@ -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.` | diff --git a/controllers/admin/documentation/tasks.md b/controllers/admin/documentation/tasks.md new file mode 100644 index 0000000..8a5af57 --- /dev/null +++ b/controllers/admin/documentation/tasks.md @@ -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. diff --git a/controllers/admin/documentation/tiers.md b/controllers/admin/documentation/tiers.md new file mode 100644 index 0000000..3a1dae5 --- /dev/null +++ b/controllers/admin/documentation/tiers.md @@ -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. diff --git a/controllers/admin/documentation/user_activity.md b/controllers/admin/documentation/user_activity.md new file mode 100644 index 0000000..4821bfb --- /dev/null +++ b/controllers/admin/documentation/user_activity.md @@ -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.` | diff --git a/controllers/admin/media.controller.js b/controllers/admin/media.controller.js new file mode 100644 index 0000000..f7ebea7 --- /dev/null +++ b/controllers/admin/media.controller.js @@ -0,0 +1,90 @@ +/*********************************************************************************************************************************************************************** + * File Name: media.controller.js (admin) + * Type of Program: Controller + * Description: Issues short-lived JWT stream tokens for admin asset preview. + * Works identically to the client media token flow but is scoped to + * admin-authenticated requests and allows any asset regardless of + * is_public. The stream endpoint (/api/client/media/stream/:token) + * is shared — the JWT payload shape is identical. + * + * Author: Kenneth Obsequio (@lash0000) + * Date Created: Jun. 22, 2026 + ***********************************************************************************************************************************************************************/ +"use strict"; + +const jwt = require("jsonwebtoken"); + +const R = require("../../utils/response.util"); +const mdl_Assets = require("../../models/assets/assets.mdl"); +const s3 = require("../../services/s3.service"); + +const MEDIA_SECRET = process.env.MEDIA_JWT_SECRET ?? process.env.JWT_SECRET; +const TOKEN_TTL_SEC = 30 * 60; // 30 min — admin preview session + +const SUPPORTED_TYPES = ["video", "audio", "document", "image"]; + +function resolveIp(req) { + const forwarded = req.headers["x-forwarded-for"]; + if (forwarded) return forwarded.split(",")[0].trim(); + return req.ip ?? req.socket?.remoteAddress ?? "unknown"; +} + +// ─── POST /admin/media/token ────────────────────────────────────────────────── + +exports.issueToken = async (req, res) => { + try { + const { asset_id } = req.body; + if (!asset_id) return R.error(res, "asset_id is required.", 400); + + const asset = await mdl_Assets.findOne({ + where: { asset_id, deletedAt: null }, + attributes: ["asset_id", "file_type", "storage_provider", "storage_key", "mime_type", "thumbnail_storage_key"], + }); + + if (!asset) return R.error(res, "Asset not found.", 404); + + if (!SUPPORTED_TYPES.includes(asset.file_type)) { + return R.error(res, `Asset type "${asset.file_type}" is not supported.`, 400); + } + + if (asset.storage_provider !== "s3") { + return R.error(res, "Token flow is for S3 assets only. Use the raw file_url for other providers.", 400); + } + + const ip = resolveIp(req); + + const token = jwt.sign( + { + asset_id, + user_id: req.user.user_id, + storage_key: asset.storage_key, + file_type: asset.file_type, + mime_type: asset.mime_type, + ip, + }, + MEDIA_SECRET, + { expiresIn: TOKEN_TTL_SEC } + ); + + // ── Presign thumbnail URL so the browser can load it directly ───────────── + let thumbnail_url = null; + if (asset.thumbnail_storage_key) { + try { + thumbnail_url = await s3.getSignedDownloadUrl(asset.thumbnail_storage_key, TOKEN_TTL_SEC); + } catch { + // Non-fatal — thumbnail is cosmetic + } + } + + return R.success(res, "Token issued.", { + token, + provider: "s3", + file_type: asset.file_type, + thumbnail_url, + }); + + } catch (err) { + console.error("[ADMIN][MEDIA][TOKEN]", err); + return R.error(res, "Could not issue media token.", 500); + } +}; diff --git a/controllers/admin/notification.controller.js b/controllers/admin/notification.controller.js new file mode 100644 index 0000000..feadc09 --- /dev/null +++ b/controllers/admin/notification.controller.js @@ -0,0 +1,79 @@ +/*********************************************************************************************************************************************************************** + * File Name : notification.controller.js + * Type : Controller (Admin) + * Description : Admin notification management. + * GET /admin/notifications — paginated list, newest first + * GET /admin/notifications/unseen — unseen count only + * PATCH /admin/notifications/:id/seen — mark one as seen + * PATCH /admin/notifications/seen-all — mark all as seen + * + * Author: Kenneth Obsequio (@lash0000) + * Date Created: Jun. 19, 2026 + ***********************************************************************************************************************************************************************/ +const AdminNotification = require('../../models/notifications/admin_notification.mdl'); +const R = require('../../utils/response.util'); + +// ─── GET /admin/notifications ───────────────────────────────────────────────── +async function list(req, res) { + try { + const page = Math.max(1, parseInt(req.query.page) || 1); + const limit = Math.min(50, parseInt(req.query.limit) || 20); + const offset = (page - 1) * limit; + + const { count, rows } = await AdminNotification.findAndCountAll({ + order: [['createdAt', 'DESC']], + limit, + offset, + }); + + return R.success(res, 'Notifications fetched.', { + notifications: rows, + pagination: { page, limit, total: count, pages: Math.ceil(count / limit) }, + }); + } catch (err) { + console.error('[NOTIFICATION] list error:', err); + return R.error(res, 'Failed to fetch notifications.'); + } +} + +// ─── GET /admin/notifications/unseen ───────────────────────────────────────── +async function unseenCount(req, res) { + try { + const count = await AdminNotification.count({ where: { seen: false } }); + return R.success(res, 'Unseen count fetched.', { count }); + } catch (err) { + console.error('[NOTIFICATION] unseenCount error:', err); + return R.error(res, 'Failed to fetch unseen count.'); + } +} + +// ─── PATCH /admin/notifications/:id/seen ───────────────────────────────────── +async function markSeen(req, res) { + try { + const notification = await AdminNotification.findByPk(req.params.id); + if (!notification) return R.error(res, 'Notification not found.', 404); + + await notification.update({ seen: true, seen_at: new Date() }); + return R.success(res, 'Notification marked as seen.', notification); + } catch (err) { + console.error('[NOTIFICATION] markSeen error:', err); + return R.error(res, 'Failed to mark notification as seen.'); + } +} + +// ─── PATCH /admin/notifications/seen-all ───────────────────────────────────── +async function markAllSeen(req, res) { + try { + const now = new Date(); + const [count] = await AdminNotification.update( + { seen: true, seen_at: now }, + { where: { seen: false } } + ); + return R.success(res, `${count} notification(s) marked as seen.`, { count }); + } catch (err) { + console.error('[NOTIFICATION] markAllSeen error:', err); + return R.error(res, 'Failed to mark all notifications as seen.'); + } +} + +module.exports = { list, unseenCount, markSeen, markAllSeen }; diff --git a/controllers/admin/products.controller.js b/controllers/admin/products.controller.js new file mode 100644 index 0000000..41d6698 --- /dev/null +++ b/controllers/admin/products.controller.js @@ -0,0 +1,91 @@ +'use strict'; +const mdl_Product = require('../../models/courses/products.mdl'); +const mdl_Category = require('../../models/courses/categories.mdl'); +const { Course, CourseProductCategory: mdl_CourseProductCategory } = require('../../models/courses/courses.mdl'); +const R = require('../../utils/response.util'); +const logActivity = require('../../utils/logActivity.util'); + +// ─── PRODUCT (per course) ───────────────────────────────────────────────────── + +exports.getCourseProduct = async (req, res) => { + try { + const product = await mdl_Product.findOne({ where: { course_id: req.params.courseId }, paranoid: false }); + return R.success(res, 'Product retrieved.', product ?? null); + } catch (err) { + console.error('[ADMIN][PRODUCTS][GET]', err); + return R.error(res, 'Could not retrieve product.', 500); + } +}; + +exports.upsertCourseProduct = async (req, res) => { + try { + const { courseId } = req.params; + const { name, description, price, currency, access_days, is_active } = req.body; + if (!name || price == null) return R.error(res, 'name and price are required.', 400); + + const existing = await mdl_Product.findOne({ where: { course_id: courseId }, paranoid: false }); + + if (existing) { + if (existing.deletedAt) await existing.restore(); + await existing.update({ name, description: description ?? null, price, currency: currency ?? 'USD', access_days: access_days ?? null, is_active: is_active ?? true }); + logActivity(req.user?.user_id, 'upsert_course_product', { entityType: 'product', details: { course_id: courseId, name } }); + return R.success(res, 'Product updated.', existing); + } + + const product = await mdl_Product.create({ course_id: courseId, name, description: description ?? null, price, currency: currency ?? 'USD', access_days: access_days ?? null, is_active: is_active ?? true }); + logActivity(req.user?.user_id, 'upsert_course_product', { entityType: 'product', entityId: product.product_id, details: { course_id: courseId, name } }); + return R.success(res, 'Product created.', product, 201); + } catch (err) { + console.error('[ADMIN][PRODUCTS][UPSERT]', err); + return R.error(res, 'Could not save product.', 500); + } +}; + +exports.removeCourseProduct = async (req, res) => { + try { + const product = await mdl_Product.findOne({ where: { course_id: req.params.courseId } }); + if (!product) return R.error(res, 'Product not found.', 404); + await product.destroy(); + logActivity(req.user?.user_id, 'remove_course_product', { entityType: 'product', details: { course_id: req.params.courseId } }); + return R.success(res, 'Product removed.'); + } catch (err) { + console.error('[ADMIN][PRODUCTS][REMOVE]', err); + return R.error(res, 'Could not remove product.', 500); + } +}; + +// ─── CATEGORIES (per course) ────────────────────────────────────────────────── + +exports.getCourseCategories = async (req, res) => { + try { + const course = await Course.findByPk(req.params.courseId, { + include: [{ model: mdl_Category, as: 'categories', through: { attributes: [] } }], + }); + if (!course) return R.error(res, 'Course not found.', 404); + return R.success(res, 'Course categories retrieved.', course.categories ?? []); + } catch (err) { + console.error('[ADMIN][PRODUCTS][GET CATEGORIES]', err); + return R.error(res, 'Could not retrieve course categories.', 500); + } +}; + +exports.syncCourseCategories = async (req, res) => { + try { + const { courseId } = req.params; + const { category_ids = [] } = req.body; + + await mdl_CourseProductCategory.destroy({ where: { course_id: courseId } }); + + if (category_ids.length > 0) { + await mdl_CourseProductCategory.bulkCreate( + category_ids.map((id) => ({ course_id: courseId, category_id: id })) + ); + } + + logActivity(req.user?.user_id, 'sync_course_categories', { entityType: 'product', details: { course_id: courseId, category_ids, count: category_ids.length } }); + return R.success(res, 'Course categories updated.'); + } catch (err) { + console.error('[ADMIN][PRODUCTS][SYNC CATEGORIES]', err); + return R.error(res, 'Could not sync course categories.', 500); + } +}; diff --git a/controllers/admin/profile.controller.js b/controllers/admin/profile.controller.js new file mode 100644 index 0000000..855a022 --- /dev/null +++ b/controllers/admin/profile.controller.js @@ -0,0 +1,123 @@ +/*********************************************************************************************************************************************************************** + * File Name: profile.controller.js (admin) + * Type of Program: Controller + * Description: Self-service profile management for admin users. + * All routes require: authenticate → requireAdmin() + * + * Endpoints: + * GET /api/admin/profile → view own profile + * PUT /api/admin/profile → update personal_info + * POST /api/admin/profile/avatar → upload / replace avatar + * DELETE /api/admin/profile/avatar → remove avatar + * + * Author: Kenneth Obsequio (@lash0000) + * Date Created: Jun. 18, 2026 + ***********************************************************************************************************************************************************************/ +'use strict'; + +const mdl_Users = require('../../models/users/users.mdl'); +const R = require('../../utils/response.util'); +const { uploadFile, deleteFile } = require('../../services/s3.service'); + +// ─── GET own profile ─────────────────────────────────────────────────────────── + +exports.getProfile = async (req, res) => { + try { + const user = await mdl_Users.findByPk(req.user.user_id, { + attributes: { exclude: ['password', 'otp_code', 'otp_expires_at'] }, + }); + return R.success(res, 'Profile retrieved.', user); + } catch (err) { + return R.error(res, 'Could not retrieve profile.', 500); + } +}; + +// ─── PUT update own profile ──────────────────────────────────────────────────── + +exports.updateProfile = async (req, res) => { + try { + const user = await mdl_Users.findByPk(req.user.user_id); + const { personal_info } = req.body; + + const merged = { + ...(user.personal_info || {}), + ...(personal_info || {}), + name: { + ...((user.personal_info?.name) || {}), + ...((personal_info?.name) || {}), + }, + }; + + await user.update({ personal_info: merged }); + + const updated = await mdl_Users.findByPk(req.user.user_id, { + attributes: { exclude: ['password', 'otp_code', 'otp_expires_at'] }, + }); + return R.success(res, 'Profile updated.', updated); + } catch (err) { + console.error('[ADMIN] updateProfile error:', err); + return R.error(res, 'Profile update failed.', 500); + } +}; + +// ─── POST upload own avatar ──────────────────────────────────────────────────── + +exports.uploadAvatar = async (req, res) => { + try { + if (!req.file) return R.error(res, 'No file provided.', 400); + + const user = await mdl_Users.findByPk(req.user.user_id); + + // Remove old avatar from S3 before replacing + const oldKey = user.personal_info?.avatar?.uuid; + if (oldKey) await deleteFile(oldKey).catch(() => {}); + + const { url, uuid } = await uploadFile({ + buffer: req.file.buffer, + originalname: req.file.originalname, + mimetype: req.file.mimetype, + ownerType: 'avatar', + }); + + const merged = { + ...(user.personal_info || {}), + avatar: { + url, + uuid, + name: req.file.originalname, + mime_type: req.file.mimetype, + size: req.file.size, + }, + }; + + await user.update({ personal_info: merged }); + + const updated = await mdl_Users.findByPk(req.user.user_id, { + attributes: { exclude: ['password', 'otp_code', 'otp_expires_at'] }, + }); + return R.success(res, 'Avatar updated.', updated); + } catch (err) { + console.error('[ADMIN] uploadAvatar error:', err); + return R.error(res, 'Avatar upload failed.', 500); + } +}; + +// ─── DELETE remove own avatar ────────────────────────────────────────────────── + +exports.deleteAvatar = async (req, res) => { + try { + const user = await mdl_Users.findByPk(req.user.user_id); + const key = user.personal_info?.avatar?.uuid; + if (!key) return R.error(res, 'No avatar to remove.', 404); + + await deleteFile(key).catch(() => {}); + + const merged = { ...(user.personal_info || {}), avatar: null }; + await user.update({ personal_info: merged }); + + return R.success(res, 'Avatar removed.'); + } catch (err) { + console.error('[ADMIN] deleteAvatar error:', err); + return R.error(res, 'Could not remove avatar.', 500); + } +}; diff --git a/controllers/admin/task.controller.js b/controllers/admin/task.controller.js index 16c2608..b9bbdc2 100644 --- a/controllers/admin/task.controller.js +++ b/controllers/admin/task.controller.js @@ -20,6 +20,7 @@ const { filterableFields } = require('../../models/task/task.attributes'); const { getFieldValues } = require('../../utils/fieldValues.util'); const { archiveOne, archiveMany } = require("../../utils/courses/archive.util"); const { restoreOne, restoreMany } = require("../../utils/courses/restore.util"); +const logActivity = require('../../utils/logActivity.util'); // ─── Allowed filter/sort fields ─────────────────────────────────────────────── const TASK_LIST_FIELDS = ['name', 'description', 'createdAt', 'updatedAt', 'deletedAt']; @@ -118,6 +119,7 @@ exports.createTaskList = async (req, res) => { updatedBy: req.user.user_id, }); + logActivity(req.user.user_id, 'create_task_list', { entityType: 'task_list', entityId: taskList.task_list_id, details: { name: taskList.name } }); return R.success(res, 'Task list created successfully.', taskList, 201); } catch (err) { console.error('[ADMIN][CREATE TASK LIST]', err); @@ -138,6 +140,7 @@ exports.updateTaskList = async (req, res) => { await taskList.update({ name, description, updatedBy: req.user.user_id }); + logActivity(req.user.user_id, 'update_task_list', { entityType: 'task_list', entityId: taskList.task_list_id }); return R.success(res, 'Task list updated successfully.', taskList); } catch (err) { console.error('[ADMIN][UPDATE TASK LIST]', err); @@ -152,12 +155,13 @@ exports.archiveTaskList = async (req, res) => { try { const { taskListId } = req.params; - const record = await archiveOne(TaskList, { task_list_id: taskListId }, + const record = await archiveOne(TaskList, { task_list_id: taskListId }, req.user.user_id, t ); if (!record) { await t.rollback(); return R.error(res, 'Task list not found.', 404); } await t.commit(); + logActivity(req.user.user_id, 'archive_task_list', { entityType: 'task_list', entityId: Number(taskListId) }); return R.success(res, 'Task list archived successfully.'); } catch (err) { await t.rollback(); @@ -177,6 +181,7 @@ exports.restoreTaskList = async (req, res) => { if (!record) { await t.rollback(); return R.error(res, 'Task list not found or not archived.', 404); } await t.commit(); + logActivity(req.user.user_id, 'restore_task_list', { entityType: 'task_list', entityId: Number(taskListId) }); return R.success(res, 'Task list restored successfully.', record); } catch (err) { await t.rollback(); @@ -203,6 +208,7 @@ exports.bulkArchiveTaskLists = async (req, res) => { const count = await archiveMany(TaskList, 'task_list_id', activeIds, req.user.user_id, t); await t.commit(); + logActivity(req.user.user_id, 'bulk_archive_task_lists', { entityType: 'task_list', details: { ids: activeIds, count } }); return R.success(res, `${count} task list(s) archived successfully.`, { archived_ids: activeIds, skipped_ids: ids.filter((id) => !activeIds.includes(id)), @@ -232,6 +238,7 @@ exports.bulkRestoreTaskLists = async (req, res) => { const count = await restoreMany(TaskList, 'task_list_id', deletedIds, req.user.user_id, t); await t.commit(); + logActivity(req.user.user_id, 'bulk_restore_task_lists', { entityType: 'task_list', details: { ids: deletedIds, count } }); return R.success(res, `${count} task list(s) restored successfully.`, { restored_ids: deletedIds, skipped_ids: ids.filter((id) => !deletedIds.includes(id)), @@ -330,6 +337,7 @@ exports.assignGroups = async (req, res) => { } await t.commit(); + logActivity(req.user.user_id, 'assign_groups', { entityType: 'task_list', entityId: Number(taskListId), details: { group_ids: newIds } }); return R.success(res, `${newIds.length} group(s) assigned.`, { assigned_ids: newIds, already_assigned_ids: existingIds, @@ -384,6 +392,7 @@ exports.unassignGroups = async (req, res) => { }); await t.commit(); + logActivity(req.user.user_id, 'unassign_groups', { entityType: 'task_list', entityId: Number(taskListId), details: { group_ids: existingIds } }); return R.success(res, `${existingIds.length} group(s) unassigned.`, { unassigned_ids: existingIds, skipped_ids: skippedIds, @@ -437,7 +446,6 @@ exports.getTask = async (req, res) => { { model: TaskRequirement, as: 'requirements', - paranoid: false, attributes: { exclude: adminExclude }, order: [['order', 'ASC']], }, @@ -515,6 +523,7 @@ exports.createTask = async (req, res) => { }], }); + logActivity(req.user.user_id, 'create_task', { entityType: 'task', entityId: task.task_id, details: { name: task.name } }); return R.success(res, 'Task created successfully.', full, 201); } catch (err) { await t.rollback(); @@ -546,6 +555,23 @@ exports.updateTask = async (req, res) => { { transaction: t } ); + // ───────────────────────────────────────────────────────────────────────────── + // PATCH: exports.updateTask in task.controller.js (admin) + // + // BUG: TaskRequirement.destroy({ where: { task_id }, force: false }) is a + // SOFT delete (paranoid: true) — the row stays in the table with deletedAt + // set, still occupying its requirement_id primary key slot. The subsequent + // bulkCreate spread `...r`, which still carried the OLD requirement_id from + // the requirement object the frontend sent back (since RequirementBuilder.jsx + // initializes from the previously-fetched requirements, including their IDs). + // Inserting a new row with that same requirement_id collides with the + // soft-deleted row still sitting on that PK → SequelizeUniqueConstraintError. + // + // FIX: strip requirement_id (and any timestamp fields) from each incoming + // requirement before building reqRows, so bulkCreate always lets the model's + // defaultValue: DataTypes.UUIDV4 generate a fresh ID for the replacement set. + // ───────────────────────────────────────────────────────────────────────────── + if (Array.isArray(requirements)) { // soft-delete existing requirements then insert fresh ones await TaskRequirement.destroy({ @@ -555,17 +581,24 @@ exports.updateTask = async (req, res) => { }); if (requirements.length) { - const reqRows = requirements.map((r, i) => ({ - ...r, - task_id: task.task_id, - order: r.order ?? i, - reference_id: r.reference_id || null, // '' → null (UUID column) - reference_label: r.reference_label || null, // '' → null - link_url: r.link_url || null, - link_label: r.link_label || null, - createdBy: req.user.user_id, - updatedBy: req.user.user_id, - })); + const reqRows = requirements.map((r, i) => { + // Strip requirement_id and timestamps — these are server-owned. + // Reusing requirement_id here would collide with the soft-deleted + // row still occupying that primary key. + const { requirement_id, createdAt, updatedAt, deletedAt, ...rest } = r; + + return { + ...rest, + task_id: task.task_id, + order: rest.order ?? i, + reference_id: rest.reference_id || null, // '' → null (UUID column) + reference_label: rest.reference_label || null, // '' → null + link_url: rest.link_url || null, + link_label: rest.link_label || null, + createdBy: req.user.user_id, + updatedBy: req.user.user_id, + }; + }); await TaskRequirement.bulkCreate(reqRows, { transaction: t }); } } @@ -582,6 +615,7 @@ exports.updateTask = async (req, res) => { }], }); + logActivity(req.user.user_id, 'update_task', { entityType: 'task', entityId: Number(taskId) }); return R.success(res, 'Task updated successfully.', full); } catch (err) { await t.rollback(); @@ -654,6 +688,7 @@ exports.archiveTask = async (req, res) => { if (!record) { await t.rollback(); return R.error(res, 'Task not found.', 404); } await t.commit(); + logActivity(req.user.user_id, 'archive_task', { entityType: 'task', entityId: Number(taskId) }); return R.success(res, 'Task archived successfully.'); } catch (err) { await t.rollback(); @@ -678,6 +713,7 @@ exports.restoreTask = async (req, res) => { if (!record) { await t.rollback(); return R.error(res, 'Task not found or not archived.', 404); } await t.commit(); + logActivity(req.user.user_id, 'restore_task', { entityType: 'task', entityId: Number(taskId) }); return R.success(res, 'Task restored successfully.', record); } catch (err) { await t.rollback(); @@ -705,6 +741,7 @@ exports.bulkArchiveTasks = async (req, res) => { const count = await archiveMany(Task, 'task_id', activeIds, req.user.user_id, t); await t.commit(); + logActivity(req.user.user_id, 'bulk_archive_tasks', { entityType: 'task', details: { ids: activeIds, count } }); return R.success(res, `${count} task(s) archived successfully.`, { archived_ids: activeIds, skipped_ids: ids.filter((id) => !activeIds.includes(id)), @@ -738,6 +775,7 @@ exports.bulkRestoreTasks = async (req, res) => { const count = await restoreMany(Task, 'task_id', deletedIds, req.user.user_id, t); await t.commit(); + logActivity(req.user.user_id, 'bulk_restore_tasks', { entityType: 'task', details: { ids: deletedIds, count } }); return R.success(res, `${count} task(s) restored successfully.`, { restored_ids: deletedIds, skipped_ids: ids.filter((id) => !deletedIds.includes(id)), diff --git a/controllers/admin/task_completion.controller.js b/controllers/admin/task_completion.controller.js new file mode 100644 index 0000000..5860b34 --- /dev/null +++ b/controllers/admin/task_completion.controller.js @@ -0,0 +1,281 @@ +/*********************************************************************************************************************************************************************** + * File Name: task_completion.controller.js (admin) + * Type of Program: Controller + * Description: Admin-level task completion management. + * Admins can view all completions per task, view a single completion, + * and archive/restore completions. Completions are created by clients only. + * + * Author: Kenneth Obsequio (@lash0000) + * Date Created: Jun. 13, 2026 + ***********************************************************************************************************************************************************************/ +const { Op, Sequelize } = require('sequelize'); +const sequelize = require('../../config/db.config'); + +const { TaskCompletion, TaskCompletionFile } = require('../../models/task/task_completion.mdl'); +const { Task } = require('../../models/task/task.mdl'); +const mdl_Users = require('../../models/users/users.mdl'); + +const { adminExclude } = require('../../models/task/task_completion.attributes'); +const R = require('../../utils/response.util'); +const { paginate } = require('../../utils/paginate.util'); +const { archiveOne, archiveMany } = require('../../utils/courses/archive.util'); +const { restoreOne, restoreMany } = require('../../utils/courses/restore.util'); +const logActivity = require('../../utils/logActivity.util'); + +// ─── Allowed filter/sort fields ─────────────────────────────────────────────── +const COMPLETION_FIELDS = ['submitted_at', 'createdAt', 'updatedAt', 'deletedAt']; + +// ─── Reusable include: completion files ─────────────────────────────────────── +// separate: true → Sequelize fetches files in a second SELECT ... WHERE completion_id IN (...) +// instead of a JOIN, which avoids the subquery alias conflict that occurs when +// paginate applies LIMIT/OFFSET alongside a hasMany include. +const FILES_INCLUDE = { + model: TaskCompletionFile, + as: 'files', + attributes: { exclude: adminExclude }, + paranoid: false, + separate: true, + order: [['createdAt', 'ASC']], +}; + +// ─── Reusable include: submitting user ──────────────────────────────────────── +const USER_INCLUDE = { + model: mdl_Users, + as: 'user', + attributes: [ + 'user_id', + 'email', // ← direct column, fine as-is + [ + Sequelize.literal(`("user"."personal_info"->'name'->>'full_name')`), + 'name', + ], + ], +}; + +// ============================================================================= +// ── COMPLETIONS (nested under task-list → task) ─────────────────────────────── +// ============================================================================= + +// ─── GET ALL ────────────────────────────────────────────────────────────────── +// GET /admin/task-lists/:taskListId/tasks/:taskId/completions + +exports.getCompletions = async (req, res) => { + try { + const { taskListId, taskId } = req.params; + + const task = await Task.findOne({ where: { task_id: taskId, task_list_id: taskListId } }); + if (!task) return R.error(res, 'Task not found.', 404); + + const result = await paginate(TaskCompletion, req, { + excludeAttributes: adminExclude, + jsonbSchemas: {}, + computedAttributes: [], + auditOptions: { mdl_Users, parentAlias: 'TaskCompletion' }, + allowedFields: COMPLETION_FIELDS, + findOptions: { + where: { task_id: taskId }, + include: [USER_INCLUDE, FILES_INCLUDE], + }, + }); + + return R.success(res, 'Completions retrieved.', result); + } catch (err) { + console.error('[ADMIN][GET ALL COMPLETIONS]', err); + return R.error(res, 'Could not retrieve completions.', 500); + } +}; + +// ─── GET ONE ────────────────────────────────────────────────────────────────── +// GET /admin/task-lists/:taskListId/tasks/:taskId/completions/:completionId + +exports.getCompletion = async (req, res) => { + try { + const { taskListId, taskId, completionId } = req.params; + + const task = await Task.findOne({ where: { task_id: taskId, task_list_id: taskListId } }); + if (!task) return R.error(res, 'Task not found.', 404); + + const completion = await TaskCompletion.findOne({ + where: { completion_id: completionId, task_id: taskId }, + attributes: { exclude: adminExclude }, + paranoid: false, + include: [USER_INCLUDE, FILES_INCLUDE], + }); + + if (!completion) return R.error(res, 'Completion not found.', 404); + + return R.success(res, 'Completion retrieved.', completion); + } catch (err) { + console.error('[ADMIN][GET COMPLETION]', err); + return R.error(res, 'Could not retrieve completion.', 500); + } +}; + +// ─── GET ALL BY USER ────────────────────────────────────────────────────────── +// GET /admin/task-lists/:taskListId/tasks/:taskId/completions/user/:userId + +exports.getCompletionsByUser = async (req, res) => { + try { + const { taskListId, taskId, userId } = req.params; + + const task = await Task.findOne({ where: { task_id: taskId, task_list_id: taskListId } }); + if (!task) return R.error(res, 'Task not found.', 404); + + const result = await paginate(TaskCompletion, req, { + excludeAttributes: adminExclude, + jsonbSchemas: {}, + computedAttributes: [], + auditOptions: { mdl_Users, parentAlias: 'TaskCompletion' }, + allowedFields: COMPLETION_FIELDS, + findOptions: { + where: { task_id: taskId, user_id: userId }, + include: [FILES_INCLUDE], + }, + }); + + return R.success(res, 'User completions retrieved.', result); + } catch (err) { + console.error('[ADMIN][GET COMPLETIONS BY USER]', err); + return R.error(res, 'Could not retrieve user completions.', 500); + } +}; + +// ─── ARCHIVE ────────────────────────────────────────────────────────────────── +// DELETE /admin/task-lists/:taskListId/tasks/:taskId/completions/:completionId + +exports.archiveCompletion = async (req, res) => { + const t = await sequelize.transaction(); + try { + const { taskListId, taskId, completionId } = req.params; + + const task = await Task.findOne({ where: { task_id: taskId, task_list_id: taskListId }, transaction: t }); + if (!task) { await t.rollback(); return R.error(res, 'Task not found.', 404); } + + const record = await archiveOne( + TaskCompletion, + { completion_id: completionId, task_id: taskId }, + req.user.user_id, + t + ); + if (!record) { await t.rollback(); return R.error(res, 'Completion not found.', 404); } + + await t.commit(); + logActivity(req.user.user_id, 'archive_completion', { entityType: 'task_completion', entityId: Number(completionId), details: { task_id: taskId } }); + return R.success(res, 'Completion archived successfully.'); + } catch (err) { + await t.rollback(); + console.error('[ADMIN][ARCHIVE COMPLETION]', err); + return R.error(res, 'Could not archive completion.', 500); + } +}; + +// ─── RESTORE ────────────────────────────────────────────────────────────────── +// PATCH /admin/task-lists/:taskListId/tasks/:taskId/completions/:completionId/restore + +exports.restoreCompletion = async (req, res) => { + const t = await sequelize.transaction(); + try { + const { taskListId, taskId, completionId } = req.params; + + const record = await restoreOne( + TaskCompletion, + { completion_id: completionId, task_id: taskId, deletedAt: { [Op.not]: null } }, + req.user.user_id, + t + ); + if (!record) { await t.rollback(); return R.error(res, 'Completion not found or not archived.', 404); } + + await t.commit(); + logActivity(req.user.user_id, 'restore_completion', { entityType: 'task_completion', entityId: Number(completionId), details: { task_id: taskId } }); + return R.success(res, 'Completion restored successfully.', record); + } catch (err) { + await t.rollback(); + console.error('[ADMIN][RESTORE COMPLETION]', err); + return R.error(res, 'Could not restore completion.', 500); + } +}; + +// ─── BULK ARCHIVE ───────────────────────────────────────────────────────────── +// POST /admin/task-lists/:taskListId/tasks/:taskId/completions/bulk-archive + +exports.bulkArchiveCompletions = async (req, res) => { + const t = await sequelize.transaction(); + try { + const { taskListId, taskId } = req.params; + const { ids } = req.body; + + if (!Array.isArray(ids) || !ids.length) + return R.error(res, 'No completion IDs provided.', 400); + + const task = await Task.findOne({ where: { task_id: taskId, task_list_id: taskListId } }); + if (!task) { await t.rollback(); return R.error(res, 'Task not found.', 404); } + + const completions = await TaskCompletion.findAll({ + where: { completion_id: ids, task_id: taskId }, + }); + if (!completions.length) return R.error(res, 'No completions found.', 404); + + const activeIds = completions + .filter((c) => !c.deletedAt) + .map((c) => c.completion_id); + + if (!activeIds.length) + return R.error(res, 'All selected completions are already archived.', 400); + + const count = await archiveMany(TaskCompletion, 'completion_id', activeIds, req.user.user_id, t); + await t.commit(); + + logActivity(req.user.user_id, 'bulk_archive_completions', { entityType: 'task_completion', details: { ids: activeIds, count, task_id: taskId } }); + return R.success(res, `${count} completion(s) archived successfully.`, { + archived_ids: activeIds, + skipped_ids: ids.filter((id) => !activeIds.includes(id)), + }); + } catch (err) { + await t.rollback(); + console.error('[ADMIN][BULK ARCHIVE COMPLETIONS]', err); + return R.error(res, 'Could not archive completions.', 500); + } +}; + +// ─── BULK RESTORE ───────────────────────────────────────────────────────────── +// POST /admin/task-lists/:taskListId/tasks/:taskId/completions/bulk-restore + +exports.bulkRestoreCompletions = async (req, res) => { + const t = await sequelize.transaction(); + try { + const { taskListId, taskId } = req.params; + const { ids } = req.body; + + if (!Array.isArray(ids) || !ids.length) + return R.error(res, 'No completion IDs provided.', 400); + + const task = await Task.findOne({ where: { task_id: taskId, task_list_id: taskListId } }); + if (!task) { await t.rollback(); return R.error(res, 'Task not found.', 404); } + + const completions = await TaskCompletion.findAll({ + where: { completion_id: ids, task_id: taskId }, + paranoid: false, + }); + if (!completions.length) return R.error(res, 'No completions found.', 404); + + const deletedIds = completions + .filter((c) => c.deletedAt) + .map((c) => c.completion_id); + + if (!deletedIds.length) + return R.error(res, 'All selected completions are already active.', 400); + + const count = await restoreMany(TaskCompletion, 'completion_id', deletedIds, req.user.user_id, t); + await t.commit(); + + logActivity(req.user.user_id, 'bulk_restore_completions', { entityType: 'task_completion', details: { ids: deletedIds, count, task_id: taskId } }); + return R.success(res, `${count} completion(s) restored successfully.`, { + restored_ids: deletedIds, + skipped_ids: ids.filter((id) => !deletedIds.includes(id)), + }); + } catch (err) { + await t.rollback(); + console.error('[ADMIN][BULK RESTORE COMPLETIONS]', err); + return R.error(res, 'Could not restore completions.', 500); + } +}; \ No newline at end of file diff --git a/controllers/admin/tiers.controller.js b/controllers/admin/tiers.controller.js new file mode 100644 index 0000000..be97422 --- /dev/null +++ b/controllers/admin/tiers.controller.js @@ -0,0 +1,400 @@ +/*********************************************************************************************************************************************************************** + * File Name: tiers.controller.js (admin) + * Type of Program: Controller + * Description: Admin-level tier and plan management. + * - CRUD + archive/restore for tier_plans + * - View/grant/revoke user tiers + * - Paginated payments list + * Author: rgrgogu + * Date Created: Jun. 6, 2026 + ***********************************************************************************************************************************************************************/ +const { Op } = require('sequelize'); + +const mdl_TierPlans = require('../../models/tiers/tier_plans.mdl'); +const mdl_UserTiers = require('../../models/tiers/user_tiers.mdl'); +const mdl_Payments = require('../../models/tiers/payments.mdl'); +const mdl_Users = require('../../models/users/users.mdl'); +const mdl_PlanCourses = require('../../models/tiers/plan_courses.mdl'); +const { Course } = require('../../models/courses/courses.mdl'); + +require('../../models/tiers/tier.associations'); + +const R = require('../../utils/response.util'); +const { paginate } = require('../../utils/paginate.util'); +const { getFieldValues } = require('../../utils/fieldValues.util'); +const logActivity = require('../../utils/logActivity.util'); + +const { + excludeAttributes: plansExclude, + jsonbSchemas: plansSchemas, + computedAttributes: plansComputed, +} = require('../../models/tiers/tier_plans.attributes'); + +const { + excludeAttributes: paymentsExclude, + jsonbSchemas: paymentsSchemas, + computedAttributes: paymentsComputed, +} = require('../../models/tiers/payments.attributes'); + +const PENDING_PAYMENT_EXPIRY_MINUTES = 60; + +const expireStalePendingPayments = async () => { + const expiresBefore = new Date(Date.now() - PENDING_PAYMENT_EXPIRY_MINUTES * 60 * 1000); + await mdl_Payments.update( + { status: 'expired' }, + { where: { status: 'pending', createdAt: { [Op.lt]: expiresBefore } } } + ); +}; + + +// ─── PLANS ──────────────────────────────────────────────────────────────────── + +exports.getPlans = async (req, res) => { + try { + const archived = req.query.archived === 'true'; + + const result = await paginate(mdl_TierPlans, req, { + excludeAttributes: plansExclude, + jsonbSchemas: plansSchemas, + computedAttributes: plansComputed, + context: archived ? 'archived' : 'list', + findOptions: archived ? { + paranoid: false, + where: { deletedAt: { [Op.ne]: null } }, + } : {}, + }); + + return R.success(res, 'Plans retrieved.', result); + } catch (err) { + console.error('[ADMIN][GET PLANS]', err); + return R.error(res, 'Could not retrieve plans.', 500); + } +}; + +exports.getPlan = async (req, res) => { + try { + const plan = await mdl_TierPlans.findByPk(req.params.id); + if (!plan) return R.error(res, 'Plan not found.', 404); + return R.success(res, 'Plan retrieved.', plan); + } catch (err) { + console.error('[ADMIN][GET PLAN]', err); + return R.error(res, 'Could not retrieve plan.', 500); + } +}; + +exports.createPlan = async (req, res) => { + try { + const { tier, label, duration_days, price, currency } = req.body; + if (!tier || !label || !duration_days || !price) + return R.error(res, 'tier, label, duration_days, and price are required.', 400); + + const plan = await mdl_TierPlans.create({ tier, label, duration_days, price, currency }); + const plain = plan.get({ plain: true }); + logActivity(req.user?.user_id, 'create_tier_plan', { entityType: 'tier_plan', details: { label: plain.label, tier: plain.tier } }); + // Serialize plan_id as string — CockroachDB BigInts exceed JS Number.MAX_SAFE_INTEGER + return R.success(res, 'Plan created.', { ...plain, plan_id: String(plain.plan_id) }, 201); + } catch (err) { + console.error('[ADMIN][CREATE PLAN]', err); + return R.error(res, 'Could not create plan.', 500); + } +}; + +exports.updatePlan = async (req, res) => { + try { + const plan = await mdl_TierPlans.findByPk(req.params.id); + if (!plan) return R.error(res, 'Plan not found.', 404); + + const allowed = ['label', 'duration_days', 'price', 'currency', 'is_active']; + const updates = {}; + allowed.forEach((k) => { if (req.body[k] !== undefined) updates[k] = req.body[k]; }); + + await plan.update(updates); + logActivity(req.user?.user_id, 'update_tier_plan', { entityType: 'tier_plan', details: { label: plan.label } }); + return R.success(res, 'Plan updated.', plan); + } catch (err) { + console.error('[ADMIN][UPDATE PLAN]', err); + return R.error(res, 'Could not update plan.', 500); + } +}; + +exports.archivePlan = async (req, res) => { + try { + const plan = await mdl_TierPlans.findByPk(req.params.id); + if (!plan) return R.error(res, 'Plan not found.', 404); + if (plan.deletedAt) return R.error(res, 'Plan is already archived.', 400); + + await plan.update({ is_active: false }); + await plan.destroy(); + logActivity(req.user?.user_id, 'archive_tier_plan', { entityType: 'tier_plan', details: { label: plan.label } }); + return R.success(res, 'Plan archived successfully.'); + } catch (err) { + console.error('[ADMIN][ARCHIVE PLAN]', err); + return R.error(res, 'Could not archive plan.', 500); + } +}; + +exports.bulkArchivePlans = async (req, res) => { + try { + const { ids } = req.body; + if (!Array.isArray(ids) || !ids.length) + return R.error(res, 'No plan IDs provided.', 400); + + const plans = await mdl_TierPlans.findAll({ where: { plan_id: ids } }); + if (!plans.length) return R.error(res, 'No plans found.', 404); + + const activePlans = plans.filter((p) => !p.deletedAt); + if (!activePlans.length) + return R.error(res, 'All selected plans are already archived.', 400); + + const activeIds = activePlans.map((p) => p.plan_id); + + await mdl_TierPlans.update({ is_active: false }, { where: { plan_id: activeIds } }); + await mdl_TierPlans.destroy({ where: { plan_id: activeIds } }); + + logActivity(req.user?.user_id, 'bulk_archive_tier_plans', { entityType: 'tier_plan', details: { ids: activeIds, count: activeIds.length } }); + return R.success(res, `${activeIds.length} plan(s) archived successfully.`, { + archived_ids: activeIds, + skipped_ids: ids.filter((id) => !activeIds.includes(id)), + }); + } catch (err) { + console.error('[ADMIN][BULK ARCHIVE PLANS]', err); + return R.error(res, 'Could not archive plans.', 500); + } +}; + +exports.restorePlan = async (req, res) => { + try { + const plan = await mdl_TierPlans.findOne({ + where: { plan_id: req.params.id }, paranoid: false, + }); + if (!plan) return R.error(res, 'Plan not found.', 404); + if (!plan.deletedAt) return R.error(res, 'Plan is not archived.', 400); + + await plan.restore(); + await plan.update({ is_active: true }); + logActivity(req.user?.user_id, 'restore_tier_plan', { entityType: 'tier_plan', details: { label: plan.label } }); + return R.success(res, 'Plan restored successfully.'); + } catch (err) { + console.error('[ADMIN][RESTORE PLAN]', err); + return R.error(res, 'Could not restore plan.', 500); + } +}; + +exports.bulkRestorePlans = async (req, res) => { + try { + const { ids } = req.body; + if (!Array.isArray(ids) || !ids.length) + return R.error(res, 'No plan IDs provided.', 400); + + const plans = await mdl_TierPlans.findAll({ where: { plan_id: ids }, paranoid: false }); + if (!plans.length) return R.error(res, 'No plans found.', 404); + + const archivedPlans = plans.filter((p) => p.deletedAt); + if (!archivedPlans.length) + return R.error(res, 'All selected plans are already active.', 400); + + const archivedIds = archivedPlans.map((p) => p.plan_id); + + await mdl_TierPlans.restore({ where: { plan_id: archivedIds } }); + await mdl_TierPlans.update({ is_active: true }, { where: { plan_id: archivedIds }, paranoid: false }); + + logActivity(req.user?.user_id, 'bulk_restore_tier_plans', { entityType: 'tier_plan', details: { ids: archivedIds, count: archivedIds.length } }); + return R.success(res, `${archivedIds.length} plan(s) restored successfully.`, { + restored_ids: archivedIds, + skipped_ids: ids.filter((id) => !archivedIds.includes(id)), + }); + } catch (err) { + console.error('[ADMIN][BULK RESTORE PLANS]', err); + return R.error(res, 'Could not restore plans.', 500); + } +}; + +exports.getPlanFieldValues = getFieldValues(mdl_TierPlans, 'TIER_PLAN', { + blockedFields: [], +}); + +// ─── USER TIERS ─────────────────────────────────────────────────────────────── + +exports.getUserTiers = async (req, res) => { + try { + const tiers = await mdl_UserTiers.findAll({ + where: { user_id: req.params.id }, + include: [ + { model: mdl_Users, as: 'grantedByUser', attributes: ['user_id', 'email'] }, + { model: mdl_Users, as: 'revokedByUser', attributes: ['user_id', 'email'] }, + ], + order: [['createdAt', 'DESC']], + }); + return R.success(res, 'User tiers retrieved.', tiers); + } catch (err) { + console.error('[ADMIN][GET USER TIERS]', err); + return R.error(res, 'Could not retrieve user tiers.', 500); + } +}; + +exports.grantTier = async (req, res) => { + try { + const { user_id, tier, plan_id, notes } = req.body; + if (!user_id || !tier || !plan_id) + return R.error(res, 'user_id, tier, and plan_id are required.', 400); + + const user = await mdl_Users.findByPk(user_id); + if (!user) return R.error(res, 'User not found.', 404); + + const plan = await mdl_TierPlans.findByPk(plan_id); + if (!plan || !plan.is_active) return R.error(res, 'Plan not found or inactive.', 404); + if (plan.tier !== tier) return R.error(res, 'Plan tier mismatch.', 400); + + await mdl_UserTiers.update( + { status: 'expired' }, + { where: { user_id, status: 'active' } } + ); + + const startsAt = new Date(); + const expiresAt = new Date(startsAt.getTime() + plan.duration_days * 86400000); + + const newTier = await mdl_UserTiers.create({ + user_id, tier, status: 'active', + starts_at: startsAt, + expires_at: expiresAt, + granted_by: req.user.user_id, + notes, + }); + + logActivity(req.user.user_id, 'grant_tier', { entityType: 'tier', details: { user_id, tier, plan_id } }); + return R.success(res, 'Tier granted.', newTier, 201); + } catch (err) { + console.error('[ADMIN][GRANT TIER]', err); + return R.error(res, 'Could not grant tier.', 500); + } +}; + +exports.revokeTier = async (req, res) => { + try { + const tierRecord = await mdl_UserTiers.findByPk(req.params.tid); + if (!tierRecord) return R.error(res, 'Tier record not found.', 404); + if (tierRecord.status !== 'active') return R.error(res, 'Tier is not active.', 400); + + await tierRecord.update({ + status: 'revoked', + revoked_by: req.user.user_id, + revoked_at: new Date(), + }); + + await mdl_UserTiers.create({ + user_id: tierRecord.user_id, + tier: 'free', + status: 'active', + starts_at: new Date(), + expires_at: null, + granted_by: req.user.user_id, + notes: 'Auto-downgrade after revoke.', + }); + + logActivity(req.user.user_id, 'revoke_tier', { entityType: 'tier', details: { user_id: tierRecord.user_id, tier: tierRecord.tier } }); + return R.success(res, 'Tier revoked. User downgraded to free.'); + } catch (err) { + console.error('[ADMIN][REVOKE TIER]', err); + return R.error(res, 'Could not revoke tier.', 500); + } +}; + +// ─── PAYMENTS ───────────────────────────────────────────────────────────────── + +exports.getPayments = async (req, res) => { + try { + await expireStalePendingPayments(); + + const result = await paginate(mdl_Payments, req, { + excludeAttributes: paymentsExclude, + jsonbSchemas: paymentsSchemas, + computedAttributes: paymentsComputed, + context: 'list', + findOptions: { + include: [ + { model: mdl_Users, as: 'user', attributes: ['user_id', 'email'] }, + { model: mdl_TierPlans, as: 'plan', attributes: ['plan_id', 'label', 'tier', 'duration_days'] }, + ], + }, + }); + return R.success(res, 'Payments retrieved.', result); + } catch (err) { + console.error('[ADMIN][GET PAYMENTS]', err); + return R.error(res, 'Could not retrieve payments.', 500); + } +}; + +exports.getPayment = async (req, res) => { + try { + await expireStalePendingPayments(); + + const payment = await mdl_Payments.findByPk(req.params.id, { + include: [ + { model: mdl_Users, as: 'user', attributes: ['user_id', 'email'] }, + { model: mdl_TierPlans, as: 'plan' }, + { model: mdl_UserTiers, as: 'tier' }, + ], + }); + if (!payment) return R.error(res, 'Payment not found.', 404); + return R.success(res, 'Payment retrieved.', payment); + } catch (err) { + console.error('[ADMIN][GET PAYMENT]', err); + return R.error(res, 'Could not retrieve payment.', 500); + } +}; + +exports.getPaymentFieldValues = getFieldValues(mdl_Payments, 'PAYMENT', { + blockedFields: ['provider_payload'], +}); + + +// ─── PLAN COURSES ───────────────────────────────────────────────────────────── + +exports.getPlanCourses = async (req, res) => { + try { + const entries = await mdl_PlanCourses.findAll({ + where: { plan_id: req.params.id }, + include: [{ + model: Course, + as: 'course', + attributes: ['course_id', 'title', 'course_code', 'subscription', 'level'], + }], + order: [['createdAt', 'ASC']], + }); + return R.success(res, 'Plan courses retrieved.', entries.map(e => e.course)); + } catch (err) { + console.error('[ADMIN][GET PLAN COURSES]', err); + return R.error(res, 'Could not retrieve plan courses.', 500); + } +}; + +exports.syncPlanCourses = async (req, res) => { + try { + const { id } = req.params; + const { course_ids = [] } = req.body; + + if (!Array.isArray(course_ids)) + return R.error(res, 'course_ids must be an array.', 400); + + const plan = await mdl_TierPlans.findByPk(id); + if (!plan) return R.error(res, 'Plan not found.', 404); + + // Remove all courses from this plan + await mdl_PlanCourses.destroy({ where: { plan_id: id } }); + + if (course_ids.length) { + // course_id has a UNIQUE constraint — clear any orphaned/other-plan assignments + // for these courses before inserting, so the insert isn't silently skipped + await mdl_PlanCourses.destroy({ where: { course_id: course_ids } }); + await mdl_PlanCourses.bulkCreate( + course_ids.map(course_id => ({ plan_id: id, course_id })), + ); + } + + logActivity(req.user?.user_id, 'sync_plan_courses', { entityType: 'tier_plan', details: { plan_id: id, course_ids, count: course_ids.length } }); + return R.success(res, 'Plan courses updated.'); + } catch (err) { + console.error('[ADMIN][SYNC PLAN COURSES]', err); + return R.error(res, 'Could not update plan courses.', 500); + } +}; diff --git a/controllers/admin/user_activity.controller.js b/controllers/admin/user_activity.controller.js new file mode 100644 index 0000000..7f7a94d --- /dev/null +++ b/controllers/admin/user_activity.controller.js @@ -0,0 +1,128 @@ +/*********************************************************************************************************************************************************************** + * File Name: user_activity.controller.js (admin) + * Type of Program: Controller + * Description: Admin-only view of the user_activity log. + * + * GET /api/admin/activity → global paginated activity feed (all users) + * GET /api/admin/users/:id/activity → paginated activity for a single user + * + * Author: Kenneth Obsequio (@lash0000) + * Date Created: Jun. 21, 2026 + ***********************************************************************************************************************************************************************/ +const { Op } = require('sequelize'); +const mdl_UserActivity = require('../../models/users/user_activity.mdl'); +const mdl_Users = require('../../models/users/users.mdl'); +const R = require('../../utils/response.util'); + +const USER_ATTRS = [ + 'user_id', 'email', 'acc_type', + // full_name from JSONB — resolved in the association below +]; + +// ─── Shared query builder ───────────────────────────────────────────────────── + +function buildWhere(query, extraWhere = {}) { + const where = { ...extraWhere }; + + if (query.action) + where.action = query.action; + + if (query.from || query.to) { + where.created_at = {}; + if (query.from) where.created_at[Op.gte] = new Date(query.from); + if (query.to) where.created_at[Op.lte] = new Date(query.to); + } + + return where; +} + +// ─── GET GLOBAL ACTIVITY FEED ───────────────────────────────────────────────── + +exports.getActivity = async (req, res) => { + try { + const page = Math.max(1, parseInt(req.query.page) || 1); + const limit = Math.min(100, parseInt(req.query.limit) || 20); + const offset = (page - 1) * limit; + + const where = buildWhere(req.query); + + const { count, rows } = await mdl_UserActivity.findAndCountAll({ + where, + include: [{ + model: mdl_Users, + as: 'user', + attributes: ['user_id', 'email', 'acc_type', 'personal_info'], + }], + order: [['created_at', 'DESC']], + limit, + offset, + }); + + const data = rows.map(formatRow); + + return R.success(res, 'Activity feed retrieved.', { + total: count, + page, + totalPages: Math.ceil(count / limit), + activities: data, + }); + } catch (err) { + console.error('[ADMIN][GET ACTIVITY FEED]', err); + return R.error(res, 'Could not retrieve activity feed.', 500); + } +}; + +// ─── GET PER-USER ACTIVITY ──────────────────────────────────────────────────── + +exports.getUserActivity = async (req, res) => { + try { + const { id } = req.params; + if (!id || id === 'undefined') return R.error(res, 'Invalid User ID.', 400); + + const user = await mdl_Users.findByPk(id, { attributes: ['user_id'] }); + if (!user) return R.error(res, 'User not found.', 404); + + const page = Math.max(1, parseInt(req.query.page) || 1); + const limit = Math.min(100, parseInt(req.query.limit) || 20); + const offset = (page - 1) * limit; + + const where = buildWhere(req.query, { user_id: id }); + + const { count, rows } = await mdl_UserActivity.findAndCountAll({ + where, + order: [['created_at', 'DESC']], + limit, + offset, + }); + + return R.success(res, 'User activity retrieved.', { + total: count, + page, + totalPages: Math.ceil(count / limit), + activities: rows, + }); + } catch (err) { + console.error('[ADMIN][GET USER ACTIVITY]', err); + return R.error(res, 'Could not retrieve user activity.', 500); + } +}; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function formatRow(row) { + const r = row.toJSON(); + const info = r.user?.personal_info; + return { + activity_id: r.activity_id, + user_id: r.user_id, + email: r.user?.email ?? null, + full_name: info?.name?.full_name ?? null, + avatar_url: info?.avatar?.url ?? null, + acc_type: r.user?.acc_type ?? null, + action: r.action, + entity_type: r.entity_type, + entity_id: r.entity_id, + details: r.details, + created_at: r.created_at, + }; +} diff --git a/controllers/admin/user_groups.controller.js b/controllers/admin/user_groups.controller.js index 9431d44..4123c9c 100644 --- a/controllers/admin/user_groups.controller.js +++ b/controllers/admin/user_groups.controller.js @@ -23,6 +23,7 @@ const { getFieldValues } = require('../../utils/fieldValues.util'); const { excludeAttributes: usersExclude, jsonbSchemas: usersSchemas } = require('../../models/users/users.attributes'); const { excludeAttributes: groupExclude, jsonbSchemas: groupSchemas, computedAttributes: groupComputed } = require('../../models/users/user_groups.attributes'); +const logActivity = require('../../utils/logActivity.util'); // ─── Helper — generate a unique group code ──────────────────────────────────── /** @@ -110,6 +111,7 @@ exports.createGroup = async (req, res) => { createdBy: req.user.user_id, }); + logActivity(req.user.user_id, 'create_group', { entityType: 'group', entityId: group.group_id, details: { name: group.name, group_code: group.group_code } }); return R.success(res, 'Group created.', group, 201); } catch (err) { console.error('[ADMIN][CREATE GROUP]', err); @@ -141,6 +143,7 @@ exports.updateGroup = async (req, res) => { group.updatedBy = req.user.user_id; await group.save(); + logActivity(req.user.user_id, 'update_group', { entityType: 'group', entityId: group.group_id }); return R.success(res, 'Group updated.', group); } catch (err) { console.error('[ADMIN][UPDATE GROUP]', err); @@ -158,6 +161,7 @@ exports.deactivateGroup = async (req, res) => { await group.update({ is_active: false, updatedBy: req.user.user_id, deletedBy: req.user.user_id }); await group.destroy(); + logActivity(req.user.user_id, 'deactivate_group', { entityType: 'group', entityId: group.group_id }); return R.success(res, 'Group deactivated.'); } catch (err) { console.error('[ADMIN][DEACTIVATE GROUP]', err); @@ -175,6 +179,7 @@ exports.restoreGroup = async (req, res) => { await group.restore(); await group.update({ is_active: true, updatedBy: req.user.user_id, deletedBy: null }); + logActivity(req.user.user_id, 'restore_group', { entityType: 'group', entityId: group.group_id }); return R.success(res, 'Group restored.'); } catch (err) { console.error('[ADMIN][RESTORE GROUP]', err); @@ -204,6 +209,7 @@ exports.bulkDeactivateGroups = async (req, res) => { ); await mdl_UserGroups.destroy({ where: { group_id: activeIds } }); + logActivity(req.user.user_id, 'bulk_deactivate_groups', { entityType: 'group', details: { ids: activeIds, count: activeIds.length } }); return R.success(res, `${activeIds.length} group(s) deactivated successfully.`, { deactivated_ids: activeIds, skipped_ids: ids.filter((id) => !activeIds.includes(id)), @@ -236,6 +242,7 @@ exports.bulkRestoreGroups = async (req, res) => { { where: { group_id: deletedIds }, paranoid: false } ); + logActivity(req.user.user_id, 'bulk_restore_groups', { entityType: 'group', details: { ids: deletedIds, count: deletedIds.length } }); return R.success(res, `${deletedIds.length} group(s) restored successfully.`, { restored_ids: deletedIds, skipped_ids: ids.filter((id) => !deletedIds.includes(id)), @@ -346,6 +353,7 @@ exports.addUserToGroup = async (req, res) => { { ignoreDuplicates: true } ); + logActivity(req.user.user_id, 'add_user_to_group', { entityType: 'group', entityId: Number(group_id), details: { user_ids } }); return R.success(res, 'Users added to group.'); } catch (err) { console.error('[ADMIN][ADD USERS TO GROUP]', err); @@ -376,6 +384,7 @@ exports.removeUserFromGroup = async (req, res) => { ); await mdl_UserGroupMembers.destroy({ where: { user_id: user_ids, group_id } }); + logActivity(req.user.user_id, 'remove_user_from_group', { entityType: 'group', entityId: Number(group_id), details: { user_ids } }); return R.success(res, 'Users removed from group.'); } catch (err) { console.error('[ADMIN][REMOVE USERS FROM GROUP]', err); diff --git a/controllers/admin/users.controller.js b/controllers/admin/users.controller.js index 599f1a9..9e21e24 100644 --- a/controllers/admin/users.controller.js +++ b/controllers/admin/users.controller.js @@ -13,9 +13,11 @@ const crypto = require('crypto'); const mdl_Users = require('../../models/users/users.mdl'); const mdl_UserSessions = require('../../models/users/user_sessions.mdl'); +const mdl_Achievements = require('../../models/users/achievements.mdl'); const { mdl_UserGroups, mdl_UserGroupMembers } = require('../../models/users/user_groups.mdl'); -const sendEmail = require('../../services/email.service'); +const { sendEmail } = require('../../services/email.service'); +const logActivity = require('../../utils/logActivity.util'); const R = require('../../utils/response.util'); const { paginate } = require('../../utils/paginate.util'); const { enrichPersonalInfo } = require('../../utils/personalInfo.util'); @@ -119,6 +121,8 @@ exports.addStaffUser = async (req, res) => { }, }); + logActivity(req.user.user_id, 'create_staff', { entityType: 'user', entityId: user.user_id, details: { email } }); + return R.success(res, 'Staff user created successfully.', { user_id: user.user_id, email: user.email, @@ -150,6 +154,9 @@ exports.updateUser = async (req, res) => { updates.updatedBy = req.user.user_id; await user.update(updates); + + logActivity(req.user.user_id, 'update_user', { entityType: 'user', entityId: Number(req.params.id) }); + const updated = await mdl_Users.findByPk(req.params.id, { attributes: { exclude: EXCLUDED } }); return R.success(res, 'User updated.', updated); } catch (err) { @@ -178,6 +185,8 @@ exports.deactivateUser = async (req, res) => { { where: { user_id: req.params.id } } ); + logActivity(req.user.user_id, 'deactivate_user', { entityType: 'user', entityId: Number(req.params.id) }); + return R.success(res, 'User deactivated successfully.'); } catch (err) { console.error('[ADMIN][DEACTIVATE USER]', err); @@ -238,6 +247,8 @@ exports.restoreUser = async (req, res) => { await user.restore(); await user.update({ is_active: true, updatedBy: req.user.user_id, deletedBy: null }); + logActivity(req.user.user_id, 'restore_user', { entityType: 'user', entityId: Number(req.params.id) }); + return R.success(res, 'User restored successfully.'); } catch (err) { console.error('[ADMIN][RESTORE USER]', err); @@ -304,6 +315,8 @@ exports.terminateSession = async (req, res) => { logout_info: { date: new Date().toISOString(), forced_by: req.user.user_id }, }); + logActivity(req.user.user_id, 'terminate_session', { entityType: 'session', entityId: session.session_id }); + return R.success(res, 'Session terminated.'); } catch (err) { console.error('[ADMIN][TERMINATE SESSION]', err); @@ -350,4 +363,24 @@ exports.getArchivedUsers = async (req, res) => { console.error('[ADMIN][GET ARCHIVED USERS]', err); return R.error(res, 'Could not retrieve archived users.', 500); } +}; + +// ─── GET USER ACHIEVEMENTS ──────────────────────────────────────────────────── + +exports.getUserAchievements = async (req, res) => { + try { + const { id } = req.params; + const user = await mdl_Users.findByPk(id, { attributes: ['user_id'] }); + if (!user) return R.error(res, 'User not found.', 404); + + const achievements = await mdl_Achievements.findAll({ + where: { user_id: id }, + order: [['granted_at', 'DESC']], + }); + + return R.success(res, 'Achievements retrieved.', achievements); + } catch (err) { + console.error('[ADMIN][GET USER ACHIEVEMENTS]', err); + return R.error(res, 'Could not retrieve achievements.', 500); + } }; \ No newline at end of file diff --git a/controllers/auth.controller.js b/controllers/auth.controller.js index 8617f3f..cd92ef2 100644 --- a/controllers/auth.controller.js +++ b/controllers/auth.controller.js @@ -30,19 +30,19 @@ const mdl_Users = require('../models/users/users.mdl'); const mdl_UserSessions = require('../models/users/user_sessions.mdl'); const { mdl_UserGroups, mdl_UserGroupMembers } = require('../models/users/user_groups.mdl') const { generateTokens, verifyRefreshToken, hashToken, shouldRotateRefreshToken } = require('../utils/token.util'); +const { generateState, generateNonce, generatePKCE, buildAuthUrl, exchangeCode, verifyIdToken } = require('../utils/google_oidc.util'); const { generateOTP, getOTPExpiry, isOTPExpired } = require('../utils/otp.util'); -const sendEmail = require('../services/email.service'); +const { onUserRegistered } = require('../services/achievements.service'); +const AdminNotification = require('../models/notifications/admin_notification.mdl'); +const UserNotification = require('../models/notifications/user_notification.mdl'); +const { NOTIFICATION_REGISTRY } = require('../data/notifications.data'); +const { sendEmail } = require('../services/email.service'); +const buildSessionInfo = require('../utils/session_info.util'); +const logActivity = require('../utils/logActivity.util'); const R = require('../utils/response.util'); const EXCLUDED = ['password', 'otp_code', 'otp_expires_at', 'must_change_password', 'password_expires_at', 'createdAt', 'updatedAt', 'deletedAt', 'createdBy', 'updatedBy', 'deletedBy']; -// ─── Helpers ─────────────────────────────────────────────────────────────────── -const buildLoginInfo = (req) => ({ - date: new Date().toISOString(), - ip_address: req.ip, - device_info: req.headers['user-agent'] || 'unknown', -}); - const safeUser = (user, extraExclude = []) => { const u = user.toJSON ? user.toJSON() : { ...user }; @@ -100,7 +100,18 @@ exports.register = async (req, res) => { await sendEmail({ to: email, type: 'OTP', data: { otp } }); await transaction.commit(); - + + // Fire-and-forget: notify admins about the new group registration + if (group) { + AdminNotification.create({ + ...NOTIFICATION_REGISTRY.user_registration.build({ + groupName: group.name, + groupCode: group.group_code, + userEmail: email, + }), + }).catch(err => console.error('[AUTH] Failed to emit user_registration notification:', err)); + } + return R.success(res, 'Registration successful. Please check your email for the OTP.', { email: user.email, }, 201); @@ -131,15 +142,38 @@ exports.verifyOTP = async (req, res) => { const { accessToken, refreshToken } = generateTokens(user); const session = await mdl_UserSessions.create({ user_id: user.user_id, - login_info: buildLoginInfo(req), + login_info: await buildSessionInfo(req), refresh_token_hash: hashToken(refreshToken), is_active: true, }, { transaction }); - await sendEmail({ to: email, type: "WELCOME", data: { name: email } }); - await transaction.commit(); + // Fire-and-forget: activity log + logActivity(user.user_id, 'register'); + + // Fire-and-forget: achievements, welcome email, notification (do not block the response) + onUserRegistered(user.user_id) + .catch(err => console.error('[AUTH] Failed to grant achievements:', err)); + + sendEmail({ to: email, type: "WELCOME", data: { name: email } }) + .catch(err => console.error('[AUTH] Failed to send welcome email:', err)); + + mdl_UserGroupMembers.findOne({ + where: { user_id: user.user_id }, + include: [{ model: mdl_UserGroups, attributes: ['name', 'group_code'] }], + }).then(membership => { + const grp = membership?.UserGroup; + return UserNotification.create({ + user_id: user.user_id, + ...NOTIFICATION_REGISTRY.welcome.build({ + groupName: grp?.name ?? null, + groupCode: grp?.group_code ?? null, + accType: user.acc_type, + }), + }); + }).catch(err => console.error('[AUTH] Failed to emit welcome notification:', err)); + res.cookie('refreshToken', refreshToken, { httpOnly: true, // ← JS cannot read this secure: process.env.NODE_ENV === 'production', @@ -202,11 +236,13 @@ exports.login = async (req, res) => { const { accessToken, refreshToken } = generateTokens(user); const session = await mdl_UserSessions.create({ user_id: user.user_id, - login_info: buildLoginInfo(req), + login_info: await buildSessionInfo(req), refresh_token_hash: hashToken(refreshToken), is_active: true, }); + logActivity(user.user_id, 'login', { entityType: 'session', entityId: Number(session.session_id) }); + res.cookie('refreshToken', refreshToken, { httpOnly: true, // ← JS cannot read this secure: process.env.NODE_ENV === 'production', @@ -225,28 +261,107 @@ exports.login = async (req, res) => { } }; -// ─── Google OAuth Callback ───────────────────────────────────────────────────── +// ─── Google OIDC — Redirect ──────────────────────────────────────────────────── +// Generates state, nonce, and PKCE verifier, stores them in a signed httpOnly +// cookie, then redirects the browser to Google's authorization endpoint. +exports.googleRedirect = (req, res) => { + const state = generateState(); + const nonce = generateNonce(); + const { codeVerifier, codeChallenge } = generatePKCE(); + + // SameSite=Lax is required: the cookie must survive the cross-site redirect + // back from Google (top-level GET navigations are allowed under Lax). + res.cookie('_oauth', JSON.stringify({ state, nonce, codeVerifier }), { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + maxAge: 10 * 60 * 1000, // 10 minutes — enough time to complete the flow + signed: true, + }); + + return res.redirect(buildAuthUrl(state, nonce, codeChallenge)); +}; + +// ─── Google OIDC — Callback ──────────────────────────────────────────────────── +// Verifies state (CSRF), exchanges the authorization code, verifies the ID token +// (signature + nonce), finds or creates the user, sets the refresh cookie, then +// redirects the browser to the frontend callback page. exports.googleCallback = async (req, res) => { + const FRONTEND_URL = process.env.FRONTEND_URL; + const CALLBACK_PAGE = `${FRONTEND_URL}/auth/callback/google`; + try { - const user = req.user; // set by passport + const { code, state, error } = req.query; + + if (error) { + return res.redirect(`${CALLBACK_PAGE}?error=${encodeURIComponent(error)}`); + } + + // Read and immediately clear the oauth state cookie. + const rawCookie = req.signedCookies['_oauth']; + res.clearCookie('_oauth'); + + if (!rawCookie) return res.redirect(`${CALLBACK_PAGE}?error=session_expired`); + + const { state: expectedState, nonce, codeVerifier } = JSON.parse(rawCookie); + + if (!state || state !== expectedState) { + return res.redirect(`${CALLBACK_PAGE}?error=state_mismatch`); + } + + // Exchange authorization code → { id_token, access_token, ... } + const tokens = await exchangeCode(code, codeVerifier); + + // Verify ID token signature, audience, expiry, and nonce. + const payload = await verifyIdToken(tokens.id_token, nonce); + + // Find or auto-create the user. + let user = await mdl_Users.findOne({ where: { email: payload.email } }); + if (!user) { + user = await mdl_Users.create({ + email: payload.email, + reg_type: 'google', + acc_type: 'user', + is_active: true, + is_verified: true, + personal_info: { + name: { + given_name: payload.given_name ?? '', + last_name: payload.family_name ?? '', + full_name: payload.name ?? '', + }, + avatar: { url: payload.picture ?? null }, + }, + }); + } + + if (!user.is_active) { + return res.redirect(`${CALLBACK_PAGE}?error=account_deactivated`); + } + const { accessToken, refreshToken } = generateTokens(user); - - await mdl_UserSessions.create({ - user_id: user.user_id, - login_info: buildLoginInfo(req), + const googleSession = await mdl_UserSessions.create({ + user_id: user.user_id, + login_info: await buildSessionInfo(req), refresh_token_hash: hashToken(refreshToken), - is_active: true, + is_active: true, }); - // In a real SPA: redirect with tokens in query or set httpOnly cookie - return R.success(res, 'Google login successful.', { - accessToken, - refreshToken, - user: safeUser(user), + logActivity(user.user_id, 'login', { entityType: 'session', entityId: Number(googleSession.session_id), details: { reg_type: 'google' } }); + + res.cookie('refreshToken', refreshToken, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: process.env.NODE_ENV === 'production' ? 'strict' : 'lax', + maxAge: 7 * 24 * 60 * 60 * 1000, }); + + // Redirect to the frontend; App.jsx's restoreSession() will pick up the + // refresh cookie and complete the login automatically. + return res.redirect(CALLBACK_PAGE); } catch (err) { - console.error('[AUTH] googleCallback error:', err); - return R.error(res, 'Google authentication failed.', 500); + console.error('[AUTH] googleCallback OIDC error:', err); + return res.redirect(`${process.env.FRONTEND_URL}/auth/callback/google?error=auth_failed`); } }; @@ -270,7 +385,7 @@ exports.refreshToken = async (req, res) => { // Check if refresh token is expired if (!shouldRotateRefreshToken(decoded)) { const { accessToken } = generateTokens(user); - return R.success(res, 'Token refreshed.', { accessToken, user: safeUser(user) }); + return R.success(res, 'Token refreshed.', { accessToken, session_id: session.session_id, user: safeUser(user) }); } // ─── Rotate refresh token ─────────────────────────────────────────────────── @@ -284,7 +399,7 @@ exports.refreshToken = async (req, res) => { maxAge: 7 * 24 * 60 * 60 * 1000, }); - return R.success(res, 'Token refreshed.', { ...tokens, user: safeUser(user) }); + return R.success(res, 'Token refreshed.', { ...tokens, session_id: session.session_id, user: safeUser(user) }); } catch (err) { console.error('[AUTH] refresh token error:', err); return R.error(res, 'Invalid or expired refresh token.', 401); @@ -297,11 +412,13 @@ exports.logout = async (req, res) => { const { session_id } = req.body; if (session_id) { await mdl_UserSessions.update( - { is_active: false, logout_info: buildLoginInfo(req) }, + { is_active: false, logout_info: await buildSessionInfo(req) }, { where: { session_id, user_id: req.user.user_id } } ); } + logActivity(req.user.user_id, 'logout', { entityType: 'session', entityId: session_id ? Number(session_id) : null }); + res.clearCookie('refreshToken') res.clearCookie('_csrf') @@ -330,6 +447,8 @@ exports.changePassword = async (req, res) => { // Invalidate all sessions to force re-login await mdl_UserSessions.update({ is_active: false }, { where: { user_id: user.user_id } }); + logActivity(user.user_id, 'password_change'); + return R.success(res, 'Password changed. All sessions have been invalidated. Please log in again.'); } catch (err) { console.error('[AUTH] changePassword error:', err); diff --git a/controllers/client/advertisements.controller.js b/controllers/client/advertisements.controller.js new file mode 100644 index 0000000..30b2578 --- /dev/null +++ b/controllers/client/advertisements.controller.js @@ -0,0 +1,100 @@ +// controllers/client/advertisements.controller.js + +const Advertisement = require("../../models/advertisements/advertisements.mdl"); +const mdl_Assets = require("../../models/assets/assets.mdl"); +const R = require('../../utils/response.util'); + +const { Op } = require('sequelize'); + +const ALLOWED_TYPES = ["hero", "banner", "popup", "sidebar"]; + +// ─── Status derivation ───────────────────────────────────────────────────── +// Mirrors admin controller's deriveStatus — single source of truth for what +// "live right now" means. Kept duplicated rather than shared to avoid a +// cross-boundary import between admin and client controllers. +function deriveStatus(advertisement) { + if (advertisement.deletedAt) return "archived"; + if (!advertisement.is_active) return "draft"; + + const now = new Date(); + const start = advertisement.start_date ? new Date(advertisement.start_date) : null; + const end = advertisement.end_date ? new Date(advertisement.end_date) : null; + + if (end && end < now) return "expired"; + if (start && start > now) return "scheduled"; + return "active"; +} + +// ─── GET ACTIVE ─────────────────────────────────────────────────────────────── +// +// Resolves the single highest-priority live advertisement for a given placement +// type. "Live" means is_active = true AND within start_date/end_date window — +// computed the same way as deriveStatus, but expressed as a SQL WHERE clause +// here since we want the DB to do the filtering/ordering, not JS. +// +// GET /api/client/advertisements/active?type=hero +// +exports.getActiveAdvertisement = async (req, res) => { + try { + const { type } = req.query; + + if (!type) return R.error(res, "type is required.", 400); + if (!ALLOWED_TYPES.includes(type)) return R.error(res, `Invalid type. Must be one of: ${ALLOWED_TYPES.join(", ")}`, 400); + + const now = new Date(); + + const advertisement = await Advertisement.findOne({ + where: { + type, + is_active: true, + deletedAt: null, + [Op.and]: [ + { [Op.or]: [{ start_date: null }, { start_date: { [Op.lte]: now } }] }, + { [Op.or]: [{ end_date: null }, { end_date: { [Op.gte]: now } }] }, + ], + }, + order: [["order", "ASC"], ["createdAt", "DESC"]], + include: [{ + model: mdl_Assets, + as: "image", + attributes: ["asset_id", "display_name", "file_url", "thumbnail_url"], + required: false, + }], + attributes: { exclude: ["createdBy", "updatedBy", "deletedBy", "deletedAt", "image_asset_id"] }, + }); + + if (!advertisement) return R.success(res, "No active advertisement for this placement.", { data: null }); + + const json = advertisement.toJSON(); + json.status = deriveStatus(json); // will always be "active" given the WHERE clause, but kept for shape consistency + + return R.success(res, "Active advertisement retrieved.", { data: json }); + } catch (err) { + console.error("[CLIENT][ADVERTISEMENT][GET ACTIVE]", err); + return R.error(res, "Could not retrieve advertisement.", 500); + } +}; + +// ─── TRACK CLICK ────────────────────────────────────────────────────────────── +// +// POST /api/client/advertisements/:advertisementId/click +// Fire-and-forget increment. Never blocks or surfaces errors to the user — +// a failed click tracking call should never disrupt navigation to the CTA link. +// +exports.trackClick = async (req, res) => { + try { + const { advertisementId } = req.params; + if (!advertisementId || advertisementId === "undefined") return R.error(res, "Invalid advertisement ID.", 400); + + const advertisement = await Advertisement.findOne({ where: { advertisement_id: advertisementId, deletedAt: null } }); + if (!advertisement) return R.success(res, "Advertisement not found, skipped.", { data: null }); + + await advertisement.increment("click_count"); + + return R.success(res, "Click tracked.", { data: { click_count: advertisement.click_count + 1 } }); + } catch (err) { + console.error("[CLIENT][ADVERTISEMENT][TRACK CLICK]", err); + // Still respond 200-ish/success shape — click tracking failures shouldn't surface to the user. + return R.success(res, "Click tracking failed silently.", { data: null }); + } +}; \ No newline at end of file diff --git a/controllers/client/certificate.controller.js b/controllers/client/certificate.controller.js new file mode 100644 index 0000000..ac9dbe4 --- /dev/null +++ b/controllers/client/certificate.controller.js @@ -0,0 +1,214 @@ +/*********************************************************************************************************************************************************************** + * File Name: certificate.controller.js (client) + * Type of Program: Controller + * Description: Issues a PDF certificate for a completed course. + * A certificate is available only when the user has passed the course assessment. + * Certificate records are persisted (findOrCreate) so the same cert_no/ref_no is + * returned on every subsequent download. + * + * Author: Kenneth Obsequio (@lash0000) + * Date Created: Jun. 18, 2026 + ***********************************************************************************************************************************************************************/ +'use strict'; + +const R = require('../../utils/response.util'); +const mdl_Users = require('../../models/users/users.mdl'); +const mdl_Achievements = require('../../models/users/achievements.mdl'); +const { generateCertificate } = require('../../services/certificate.service'); +const { formatDuration } = require('../../utils/duration.util'); +const UserNotification = require('../../models/notifications/user_notification.mdl'); +const { NOTIFICATION_REGISTRY } = require('../../data/notifications.data'); + +const { + Course, + CourseAssessment, + QuizAttempt, + Certificate, + CourseInstructor, +} = require('../../models/courses/courses.associations'); + +const notDeleted = { deletedAt: null }; + +// ─── Helpers ─────────────────────────────────────────────────────────────────── + +// cert_no format: YYYYMM-{userId:6}-{userCertSeq:5} +// userCertSeq = how many certs this user will have after this insert +async function buildCertNo(userId) { + const count = await Certificate.count({ where: { user_id: userId } }); + const seq = String(count + 1).padStart(5, '0'); + const uid = String(userId).padStart(6, '0'); + const now = new Date(); + const YYYYMM = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}`; + return `${YYYYMM}-${uid}-${seq}`; +} + +// ref_no format: PP-YYYYMM-{globalSeq:5} (unique across all certs) +async function buildRefNo() { + const count = await Certificate.count(); + const seq = String(count + 1).padStart(5, '0'); + const now = new Date(); + const YYYYMM = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}`; + return `PP-${YYYYMM}-${seq}`; +} + +function formatInstructors(rows) { + const names = rows.map(r => r.display_name); + if (names.length === 0) return ''; + if (names.length === 1) return names[0]; + if (names.length === 2) return `${names[0]} and ${names[1]}`; + return `${names[0]}, ${names[1]} and et. al`; +} + +// ─── GET /api/client/certificates/:courseUuid ────────────────────────────────── + +exports.getCertificate = async (req, res) => { + try { + const { courseUuid } = req.params; + const user_id = req.user.user_id; + + // ── 1. Resolve course ────────────────────────────────────────────────────── + const course = await Course.findOne({ + where: { uuid: courseUuid, ...notDeleted }, + attributes: ['course_id', 'title', 'course_code', 'duration_seconds'], + include: [ + { + model: CourseAssessment, + as: 'assessment', + attributes: ['assessment_id'], + required: false, + }, + { + model: CourseInstructor, + as: 'instructors', + attributes: ['display_name', 'order_index'], + required: false, + order: [['order_index', 'ASC']], + }, + ], + }); + + if (!course) return R.error(res, 'Course not found.', 404); + + if (!course.assessment) { + return R.error(res, 'This course does not have an assessment — no certificate available.', 404); + } + + // ── 2. Verify the user passed ────────────────────────────────────────────── + const passedAttempt = await QuizAttempt.findOne({ + where: { + user_id, + assessment_id: course.assessment.assessment_id, + passed: true, + }, + order: [['createdAt', 'DESC']], + attributes: ['score', 'createdAt'], + }); + + if (!passedAttempt) { + return R.error(res, 'Certificate not available — course assessment not passed yet.', 403); + } + + // ── 3. Get user's name ───────────────────────────────────────────────────── + const user = await mdl_Users.findByPk(user_id, { attributes: ['personal_info'] }); + const fullName = user?.personal_info?.name?.full_name?.trim() || 'Participant'; + + // ── 4. Resolve or create the certificate record ──────────────────────────── + const [cert, created] = await Certificate.findOrCreate({ + where: { user_id, course_id: course.course_id }, + defaults: { + cert_no: await buildCertNo(user_id), + ref_no: await buildRefNo(), + instructors: formatInstructors(course.instructors ?? []), + score: passedAttempt.score ?? null, + length_str: formatDuration(course.duration_seconds), + issued_at: passedAttempt.createdAt, + }, + }); + + // Always use live instructors from course_instructors table for the PDF. + // Keep the snapshot in sync so it reflects the current state. + const liveInstructors = formatInstructors(course.instructors ?? []); + if (liveInstructors !== (cert.instructors ?? '')) { + await cert.update({ instructors: liveInstructors }); + } + + // ── 5. On first issue: fire notification + achievements ──────────────────── + if (created) { + // Certificate issued notification + UserNotification.create({ + user_id, + ...NOTIFICATION_REGISTRY.certificate_issued.build({ + courseTitle: course.title, + courseUuid, + }), + }).catch(err => console.error('[CERTIFICATE] Failed to emit notification:', err)); + + // Per-course completion achievement + mdl_Achievements.findOrCreate({ + where: { user_id, key: `course_completed_${courseUuid}` }, + defaults: { + type: 'milestone', + label: 'Certificate of Completion', + description: course.title, + granted_at: passedAttempt.createdAt, + metadata: { courseTitle: course.title, courseUuid }, + }, + }).catch(err => console.error('[CERTIFICATE] Failed to grant course achievement:', err)); + + // First-course achievement (only if this is their very first certificate) + const totalCerts = await Certificate.count({ where: { user_id } }); + if (totalCerts === 1) { + mdl_Achievements.findOrCreate({ + where: { user_id, key: 'first_course_completed' }, + defaults: { + type: 'milestone', + label: 'First Course Completed', + description: 'Completed your very first course on Philproperties.', + granted_at: passedAttempt.createdAt, + metadata: { courseTitle: course.title, courseUuid }, + }, + }).catch(err => console.error('[CERTIFICATE] Failed to grant first-course achievement:', err)); + } + } + + // ── 6. Format issued date as MM/DD/YY HH:MM AM/PM ──────────────────────── + const issuedDate = new Date(cert.issued_at); + const dateStr = new Intl.DateTimeFormat('en-US', { + month: '2-digit', + day: '2-digit', + year: '2-digit', + hour: '2-digit', + minute: '2-digit', + hour12: true, + }).format(issuedDate); + + // ── 7. Generate PDF ──────────────────────────────────────────────────────── + const pdf = await generateCertificate({ + name: fullName, + course: course.title, + date: dateStr, + cert_no: cert.cert_no, + ref_no: cert.ref_no, + instructors: liveInstructors, + length: cert.length_str ?? '', + }); + + // ── 8. Stream response ───────────────────────────────────────────────────── + const nameParts = fullName.trim().split(/\s+/); + const lastName = nameParts.length > 1 ? nameParts[nameParts.length - 1] : nameParts[0]; + const firstName = nameParts.length > 1 ? nameParts.slice(0, -1).join(' ') : ''; + const safeTitle = course.title.replace(/[/\\:*?"<>|]/g, '').trim(); + const filename = `${lastName},${firstName}_${safeTitle}_${cert.cert_no}.pdf`; + + res.set({ + 'Content-Type': 'application/pdf', + 'Content-Disposition': `attachment; filename="${filename}"`, + 'Content-Length': pdf.length, + }); + + return res.send(pdf); + } catch (err) { + console.error('[CLIENT][CERTIFICATE]', err); + return R.error(res, 'Could not generate certificate.', 500); + } +}; diff --git a/controllers/client/course_purchases.controller.js b/controllers/client/course_purchases.controller.js new file mode 100644 index 0000000..68edf73 --- /dev/null +++ b/controllers/client/course_purchases.controller.js @@ -0,0 +1,157 @@ +'use strict'; +const mdl_CoursePurchase = require('../../models/courses/course_purchases.mdl'); +const mdl_Product = require('../../models/courses/products.mdl'); +const paypal = require('../../services/paypal.service'); +const R = require('../../utils/response.util'); + +// ─── CREATE ORDER ───────────────────────────────────────────────────────────── + +exports.createCourseOrder = async (req, res) => { + try { + const { product_id } = req.body; + if (!product_id) return R.error(res, 'product_id is required.', 400); + + const product = await mdl_Product.findOne({ where: { id: product_id, is_active: true } }); + if (!product) return R.error(res, 'Product not found or inactive.', 404); + + // Block if user already has an active completed purchase for this product + const existing = await mdl_CoursePurchase.findOne({ + where: { user_id: req.user.user_id, product_id, status: 'completed' }, + }); + if (existing) { + const stillActive = !existing.expires_at || new Date(existing.expires_at) > new Date(); + if (stillActive) return R.error(res, 'You already have active access to this course.', 409); + } + + const ppOrder = await paypal.createOrder({ + amount: Number(product.price).toFixed(2), + currency: product.currency, + referenceId: `user_${req.user.user_id}_product_${product_id}`, + returnUrl: `${process.env.FRONTEND_URL}/course/${product.course_id}/checkout`, + cancelUrl: `${process.env.FRONTEND_URL}/course/${product.course_id}/checkout?cancelled=true`, + }); + + const approvalUrl = ppOrder.links?.find((l) => l.rel === 'approve')?.href ?? null; + + const expiresAt = product.access_days + ? new Date(Date.now() + product.access_days * 86400000) + : null; + + const purchase = await mdl_CoursePurchase.create({ + user_id: req.user.user_id, + product_id, + amount: product.price, + currency: product.currency, + status: 'pending', + provider: 'paypal', + expires_at: expiresAt, + provider_payload: { order_id: ppOrder.id, approval_url: approvalUrl }, + }); + + return R.success(res, 'Order created.', { + purchase_id: purchase.id, + order_id: ppOrder.id, + approval_url: approvalUrl, + amount: product.price, + currency: product.currency, + }, 201); + } catch (err) { + console.error('[CLIENT][COURSE PURCHASE][CREATE ORDER]', err); + return R.error(res, 'Could not create order.', 500); + } +}; + +// ─── CAPTURE ORDER ──────────────────────────────────────────────────────────── + +exports.captureCourseOrder = async (req, res) => { + try { + const { order_id } = req.body; + if (!order_id) return R.error(res, 'order_id is required.', 400); + + const purchase = await mdl_CoursePurchase.findOne({ + where: { user_id: req.user.user_id, status: 'pending', provider: 'paypal' }, + include: [{ model: mdl_Product, as: 'product' }], + order: [['createdAt', 'DESC']], + }); + + if (!purchase || purchase.provider_payload?.order_id !== order_id) + return R.error(res, 'Pending purchase not found.', 404); + + let captureData; + try { + captureData = await paypal.captureOrder(order_id); + } catch (ppErr) { + await purchase.update({ + status: 'failed', + provider_payload: { ...purchase.provider_payload, error: ppErr?.response?.data ?? {} }, + }); + return R.error(res, 'PayPal capture failed.', 402); + } + + const capture = captureData.purchase_units?.[0]?.payments?.captures?.[0]; + + await purchase.update({ + status: 'completed', + paid_at: new Date(), + provider_payload: { + ...purchase.provider_payload, + capture_id: capture?.id, + payer_id: captureData.payer?.payer_id, + capture: captureData, + }, + }); + + return R.success(res, 'Payment successful. Course access granted.', { + purchase_id: purchase.id, + expires_at: purchase.expires_at, + course_id: purchase.product.course_id, + }); + } catch (err) { + console.error('[CLIENT][COURSE PURCHASE][CAPTURE]', err); + return R.error(res, 'Could not capture order.', 500); + } +}; + +// ─── CANCEL ORDER ───────────────────────────────────────────────────────────── + +exports.cancelCourseOrder = async (req, res) => { + try { + const { order_id } = req.body; + if (!order_id) return R.error(res, 'order_id is required.', 400); + + const purchase = await mdl_CoursePurchase.findOne({ + where: { user_id: req.user.user_id, status: 'pending', provider: 'paypal' }, + order: [['createdAt', 'DESC']], + }); + + if (!purchase || purchase.provider_payload?.order_id !== order_id) + return R.error(res, 'Pending purchase not found.', 404); + + await purchase.update({ + status: 'cancelled', + provider_payload: { ...purchase.provider_payload, cancelled_at: new Date().toISOString() }, + }); + + return R.success(res, 'Purchase cancelled.'); + } catch (err) { + console.error('[CLIENT][COURSE PURCHASE][CANCEL]', err); + return R.error(res, 'Could not cancel purchase.', 500); + } +}; + +// ─── MY PURCHASES ───────────────────────────────────────────────────────────── + +exports.getMyPurchases = async (req, res) => { + try { + const purchases = await mdl_CoursePurchase.findAll({ + where: { user_id: req.user.user_id }, + include: [{ model: mdl_Product, as: 'product', attributes: ['id', 'name', 'course_id', 'access_days'] }], + attributes: { exclude: ['provider_payload'] }, + order: [['createdAt', 'DESC']], + }); + return R.success(res, 'Purchases retrieved.', purchases); + } catch (err) { + console.error('[CLIENT][COURSE PURCHASE][GET MINE]', err); + return R.error(res, 'Could not retrieve purchases.', 500); + } +}; diff --git a/controllers/client/course_reading_progress.controller.js b/controllers/client/course_reading_progress.controller.js new file mode 100644 index 0000000..93305c1 --- /dev/null +++ b/controllers/client/course_reading_progress.controller.js @@ -0,0 +1,312 @@ +/*********************************************************************************************************************************************************************** + * File Name: course_reading_progress.controller.js (client) + * Type of Program: Controller + * Description: Tracks user reading progress through a course hierarchy (course → unit → lesson). + * + * GET /client/courses/in-progress + * → courses where the current user has status = 'in_progress', with lesson counts + * + * GET /client/courses/:courseId/progress + * → returns all progress rows for this user + course (flat, frontend builds the map) + * + * POST /client/courses/:courseId/units/:unitId/lessons/:lessonId/progress + * → UPSERT lesson + derives + UPSERTs parent unit + course in one transaction + * + * Body (POST): + * { status: 'in_progress' | 'completed' } + * Defaults to 'in_progress' if omitted (on first visit). + * + * Author: Kenneth Obsequio (@lash0000) + * Date Created: Jun. 21, 2026 + ***********************************************************************************************************************************************************************/ +'use strict'; + +const R = require('../../utils/response.util'); +const logActivity = require('../../utils/logActivity.util'); +const { upsertLessonRead } = require('../../services/course_reading_progress.service'); +const CourseReadingProgress = require('../../models/courses/course_reading_progress.mdl'); +const Certificate = require('../../models/courses/certificate.mdl'); +const { + Course, Unit, Lesson, + UnitQuiz, CourseAssessment, QuizAttempt, +} = require('../../models/courses/courses.associations'); + +const notDeleted = { deletedAt: null }; + +// ============================================================================= +// ── IN-PROGRESS COURSES — profile learning progress card ────────────────────── +// ============================================================================= + +// GET /client/courses/in-progress +// Returns courses the user has started but not yet completed (no certificate). +// Includes both: +// • reading in_progress → still working through lessons +// • reading completed → finished lessons but quiz / assessment still pending +// Excludes any course where the user already holds a certificate. + +exports.getMyInProgressCourses = async (req, res) => { + try { + const userId = req.user.user_id; + + // All course-level progress rows for this user (any reading status) + const courseRows = await CourseReadingProgress.findAll({ + where: { user_id: userId, type: 'course' }, + attributes: ['course_id', 'status', 'last_accessed_at'], + include: [{ + model: Course, + as: 'course', + attributes: ['course_id', 'title'], + where: notDeleted, + required: true, + }], + order: [['last_accessed_at', 'DESC']], + }); + + if (!courseRows.length) return R.success(res, 'No courses in progress.', []); + + // Courses the user has already earned a certificate for — exclude these + const certificates = await Certificate.findAll({ + where: { user_id: userId }, + attributes: ['course_id'], + }); + const certSet = new Set(certificates.map((c) => String(c.course_id))); + + const pending = courseRows.filter((r) => !certSet.has(String(r.course_id))); + if (!pending.length) return R.success(res, 'No courses in progress.', []); + + const result = await Promise.all(pending.map(async (row) => { + const courseId = row.course_id; + const readingDone = row.status === 'completed'; + + const [lessons_total, lessons_completed] = await Promise.all([ + Lesson.count({ + include: [{ + model: Unit, + as: 'unit', + where: { course_id: courseId, ...notDeleted }, + required: true, + }], + where: notDeleted, + }), + CourseReadingProgress.count({ + where: { user_id: userId, course_id: courseId, type: 'lesson', status: 'completed' }, + }), + ]); + + // Only compute pending quiz/assessment detail when all lessons are read + let pending_quizzes = []; + let pending_assessment = null; + + if (readingDone) { + // All unit quizzes in this course + const unitQuizzes = await UnitQuiz.findAll({ + attributes: ['quiz_id', 'title', 'is_required', 'passing_score'], + include: [{ + model: Unit, + as: 'unit', + attributes: ['unit_id', 'title'], + where: { course_id: courseId, ...notDeleted }, + required: true, + }], + where: notDeleted, + }); + + for (const quiz of unitQuizzes) { + const [hasPassed, attemptCount] = await Promise.all([ + QuizAttempt.findOne({ where: { user_id: userId, quiz_id: quiz.quiz_id, passed: true } }), + QuizAttempt.count({ where: { user_id: userId, quiz_id: quiz.quiz_id } }), + ]); + if (!hasPassed) { + pending_quizzes.push({ + quiz_id: quiz.quiz_id, + title: quiz.title, + unit_title: quiz.unit.title, + is_required: quiz.is_required, + passing_score: quiz.passing_score, + attempt_count: attemptCount, + }); + } + } + + // Course assessment + const assessment = await CourseAssessment.findOne({ + attributes: ['assessment_id', 'title', 'is_required', 'passing_score'], + where: { course_id: courseId }, + }); + if (assessment) { + const [hasPassed, attemptCount] = await Promise.all([ + QuizAttempt.findOne({ where: { user_id: userId, assessment_id: assessment.assessment_id, passed: true } }), + QuizAttempt.count({ where: { user_id: userId, assessment_id: assessment.assessment_id } }), + ]); + if (!hasPassed) { + pending_assessment = { + assessment_id: assessment.assessment_id, + title: assessment.title, + is_required: assessment.is_required, + passing_score: assessment.passing_score, + attempt_count: attemptCount, + }; + } + } + } + + return { + course_id: courseId, + title: row.course.title, + reading_status: row.status, + lessons_total, + lessons_completed, + last_accessed_at: row.last_accessed_at, + pending_quizzes, + pending_assessment, + }; + })); + + return R.success(res, 'In-progress courses retrieved.', result); + } catch (err) { + console.error('[CLIENT][COURSE READING PROGRESS][IN PROGRESS]', err); + return R.error(res, 'Could not retrieve in-progress courses.', 500); + } +}; + +// ============================================================================= +// ── GET PROGRESS SUMMARY ────────────────────────────────────────────────────── +// ============================================================================= + +// GET /client/courses/:courseId/progress/summary +// Returns a compact progress snapshot: lesson counts + percentage + course status. +// Used by the ReadCourse block to render the inline progress bar without needing +// the full flat row list. + +exports.getCourseProgressSummary = async (req, res) => { + try { + const { courseId } = req.params; + const userId = req.user.user_id; + + const course = await Course.findOne({ + where: { course_id: courseId, ...notDeleted }, + attributes: ['course_id'], + }); + if (!course) return R.error(res, 'Course not found.', 404); + + const [lessons_total, lessons_completed, courseRow] = await Promise.all([ + Lesson.count({ + include: [{ + model: Unit, + as: 'unit', + where: { course_id: courseId, ...notDeleted }, + required: true, + }], + where: notDeleted, + }), + CourseReadingProgress.count({ + where: { user_id: userId, course_id: courseId, type: 'lesson', status: 'completed' }, + }), + CourseReadingProgress.findOne({ + where: { user_id: userId, course_id: courseId, type: 'course' }, + attributes: ['status'], + }), + ]); + + const percent = lessons_total > 0 ? Math.round((lessons_completed / lessons_total) * 100) : 0; + + return R.success(res, 'Progress summary retrieved.', { + lessons_total, + lessons_completed, + percent, + status: courseRow?.status ?? null, + }); + } catch (err) { + console.error('[CLIENT][COURSE READING PROGRESS][SUMMARY]', err); + return R.error(res, 'Could not retrieve progress summary.', 500); + } +}; + +// ============================================================================= +// ── GET PROGRESS SNAPSHOT ───────────────────────────────────────────────────── +// ============================================================================= + +// GET /client/courses/:courseId/progress +// Returns all progress rows for this user+course. +// Frontend uses this to decorate the sidebar (completed checkmarks, locked states, etc.) + +exports.getCourseProgress = async (req, res) => { + try { + const { courseId } = req.params; + const userId = req.user.user_id; + + const course = await Course.findOne({ + where: { course_id: courseId, ...notDeleted }, + attributes: ['course_id'], + }); + if (!course) return R.error(res, 'Course not found.', 404); + + const rows = await CourseReadingProgress.findAll({ + where: { user_id: userId, course_id: courseId }, + attributes: ['progress_id', 'reference_id', 'type', 'status', 'completed_at', 'last_accessed_at'], + }); + + return R.success(res, 'Course progress retrieved.', rows); + } catch (err) { + console.error('[CLIENT][COURSE READING PROGRESS][GET]', err); + return R.error(res, 'Could not retrieve course progress.', 500); + } +}; + +// ============================================================================= +// ── UPSERT LESSON PROGRESS ──────────────────────────────────────────────────── +// ============================================================================= + +// POST /client/courses/:courseId/units/:unitId/lessons/:lessonId/progress +// Body: { status: 'in_progress' | 'completed' } +// +// Flow: +// 1. Resolve course / unit / lesson to get their UUIDs +// 2. Delegate to upsertLessonRead — handles lesson + unit + course in one tx + +exports.upsertLessonProgress = async (req, res) => { + try { + const { courseId, unitId, lessonId } = req.params; + const userId = req.user.user_id; + const status = req.body.status === 'completed' ? 'completed' : 'in_progress'; + + const [course, unit, lesson] = await Promise.all([ + Course.findOne({ + where: { course_id: courseId, ...notDeleted }, + attributes: ['course_id', 'uuid'], + }), + Unit.findOne({ + where: { unit_id: unitId, course_id: courseId, ...notDeleted }, + attributes: ['unit_id', 'uuid'], + }), + Lesson.findOne({ + where: { lesson_id: lessonId, unit_id: unitId, ...notDeleted }, + attributes: ['lesson_id', 'uuid'], + }), + ]); + + if (!course) return R.error(res, 'Course not found.', 404); + if (!unit) return R.error(res, 'Unit not found.', 404); + if (!lesson) return R.error(res, 'Lesson not found.', 404); + + const result = await upsertLessonRead(userId, { + courseId: course.course_id, + courseUuid: course.uuid, + unitId: unit.unit_id, + unitUuid: unit.uuid, + lessonUuid: lesson.uuid, + lessonStatus: status, + }); + + logActivity(userId, 'lesson_read', { + entityType: 'lesson', + entityId: lesson.lesson_id, + details: { lesson_uuid: lesson.uuid, status }, + }); + + return R.success(res, 'Progress updated.', result, 200); + } catch (err) { + console.error('[CLIENT][COURSE READING PROGRESS][UPSERT]', err); + return R.error(res, 'Could not update progress.', 500); + } +}; diff --git a/controllers/client/courses.controller.js b/controllers/client/courses.controller.js new file mode 100644 index 0000000..916f12d --- /dev/null +++ b/controllers/client/courses.controller.js @@ -0,0 +1,727 @@ +/*********************************************************************************************************************************************************************** + * File Name: courses.controller.js (client) + * Type of Program: Controller + * Description: User-facing course endpoints (read-only). + * Access rules: + * - All courses are returned in the list (for upsell visibility) + * - Each course has is_locked: boolean based on the user's active tier + * - free / no active tier → unassigned courses are open; plan courses are locked + * - premium (active tier) → unassigned + courses under their plan are open + * - getCourse still enforces hard 403 on locked access + * Author: Kenneth Obsequio (@lash0000) + * Date Created: Jun. 7, 2026 + ***********************************************************************************************************************************************************************/ +"use strict"; + +const { Op } = require("sequelize"); +const R = require("../../utils/response.util"); +const mdl_UserTiers = require("../../models/tiers/user_tiers.mdl"); +const mdl_PlanCourses = require("../../models/tiers/plan_courses.mdl"); +const mdl_TierPlans = require("../../models/tiers/tier_plans.mdl"); +const mdl_CoursePurchase = require("../../models/courses/course_purchases.mdl"); +const mdl_Product = require("../../models/courses/products.mdl"); +const mdl_Category = require("../../models/courses/categories.mdl"); + +const { + Course, + Unit, Lesson, LessonPage, + CourseObjective, LessonObjective, + CoursePrerequisite, CourseAssessment, + UnitQuiz, QuizQuestion, QuizOption, QuizAttempt +} = require("../../models/courses/courses.associations"); +const { gradeSubmission } = require("../../utils/courses/grading.util"); +const { shuffleOptions, getAttemptStatus, MAX_ATTEMPTS } = require("../../utils/courses/quiz_security.util"); +const { onCourseCompleted } = require('../../services/achievements.service') + +const notDeleted = { deletedAt: null }; + +const COURSE_LIST_ATTRS = [ + "course_id", "uuid", "title", "description", + "course_code", "level", "subscription", + "duration_seconds", "order_index", +]; + +// Strip correct-answer data before sending quiz questions to the client +function sanitizeQuestions(questions = []) { + return questions.map((q) => { + const plain = q.toJSON ? q.toJSON() : { ...q }; + plain.options = (plain.options ?? []).map(({ is_correct: _drop, ...o }) => o); + delete plain.explanation; + return plain; + }); +} + +// Resolve the caller's active tier (returns null if free/expired) +async function getActiveTier(user_id) { + return mdl_UserTiers.findOne({ + where: { user_id, status: "active" }, + order: [["createdAt", "DESC"]], + }); +} + +// ─── COURSES (all visible, is_locked per user tier) ─────────────────────────── + +exports.getCourses = async (req, res) => { + try { + const { category } = req.query; // optional slug filter + + const activeTier = await getActiveTier(req.user.user_id); + const userTier = activeTier?.tier ?? 'free'; + const tierRank = { free: 0, premium: 1, exclusive: 2 }; + const userRank = tierRank[userTier] ?? 0; + + // Fetch all completed purchases for this user (for has_purchased check) + const myPurchases = await mdl_CoursePurchase.findAll({ + where: { user_id: req.user.user_id, status: 'completed' }, + include: [{ model: mdl_Product, as: 'product', attributes: ['course_id', 'access_days'] }], + attributes: ['id', 'expires_at', 'product_id'], + }); + + const purchasedCourseIds = new Set( + myPurchases + .filter((p) => !p.expires_at || new Date(p.expires_at) > new Date()) + .map((p) => String(p.product?.course_id)) + ); + + // Build category filter + const categoryInclude = { + model: mdl_Category, + as: 'categories', + through: { attributes: [] }, + attributes: ['id', 'name', 'slug'], + required: !!category, + ...(category ? { where: { slug: category } } : {}), + }; + + const courses = await Course.findAll({ + where: { ...notDeleted }, + attributes: COURSE_LIST_ATTRS, + include: [ + { + model: mdl_PlanCourses, + as: 'planCourse', + required: false, + attributes: ['id', 'plan_id'], + include: [{ model: mdl_TierPlans, as: 'plan', attributes: ['tier'] }], + }, + { + model: mdl_Product, + as: 'product', + required: false, + attributes: ['id', 'name', 'price', 'currency', 'access_days', 'is_active'], + paranoid: false, + }, + categoryInclude, + ], + order: [['order_index', 'ASC'], ['title', 'ASC']], + }); + + const result = courses.map((c) => { + const plain = c.toJSON(); + const planCourse = plain.planCourse; + const plan_tier = planCourse?.plan?.tier ?? null; + const has_purchased = purchasedCourseIds.has(String(plain.course_id)); + + let is_locked = false; + if (plan_tier && plan_tier !== 'free') { + const reqRank = tierRank[plan_tier] ?? 0; + if (userRank < reqRank && !has_purchased) is_locked = true; + } + + delete plain.planCourse; + return { ...plain, is_locked, plan_tier, has_purchased }; + }); + + return R.success(res, "Courses retrieved.", result); + } catch (err) { + console.error("[CLIENT][COURSES][GET ALL]", err); + return R.error(res, "Could not retrieve courses.", 500); + } +}; + +// ─── COURSE DETAIL (hard access check) ─────────────────────────────────────── + +exports.getCourse = async (req, res) => { + try { + const { courseId } = req.params; + + // Access check — tier OR individual purchase + const activeTier = await getActiveTier(req.user.user_id); + const planCourse = await mdl_PlanCourses.findOne({ where: { course_id: courseId } }); + let plan = null; + + if (planCourse) { + plan = await mdl_TierPlans.findByPk(planCourse.plan_id, { attributes: ['tier'] }); + const requiredTier = plan?.tier ?? 'free'; + const tierRank = { free: 0, premium: 1, exclusive: 2 }; + const userRank = tierRank[activeTier?.tier ?? 'free'] ?? 0; + const reqRank = tierRank[requiredTier] ?? 0; + + if (userRank < reqRank) { + // Check individual purchase as fallback + const product = await mdl_Product.findOne({ where: { course_id: courseId } }); + const hasPurchase = product && await mdl_CoursePurchase.findOne({ + where: { + user_id: req.user.user_id, product_id: product.id, status: 'completed', + [Op.or]: [{ expires_at: null }, { expires_at: { [Op.gt]: new Date() } }], + }, + }); + if (!hasPurchase) return R.error(res, "You do not have access to this course.", 403); + } + } + + const course = await Course.findOne({ + where: { course_id: courseId, ...notDeleted }, + attributes: COURSE_LIST_ATTRS, + include: [ + { + model: Unit, as: "units", + where: notDeleted, required: false, + attributes: [ + "unit_id", "uuid", "title", "description", + "order_index", "duration_seconds", + ], + include: [ + { + model: Lesson, as: "lessons", + where: notDeleted, required: false, + attributes: [ + "lesson_id", "uuid", "title", "description", + "order_index", "duration_seconds", + ], + }, + { + model: UnitQuiz, as: "quiz", + required: false, + attributes: [ + "quiz_id", "uuid", "title", + "is_required", "passing_score", "max_questions", + ], + }, + ], + }, + { + model: CourseObjective, as: "objectives", + required: false, + attributes: ["objective_id", "text", "order_index"], + }, + { + model: CoursePrerequisite, as: "prerequisites", + required: false, + attributes: ["prereq_id", "ref_type", "ref_id", "order_index"], + }, + { + model: CourseAssessment, as: "assessment", + required: false, + attributes: [ + "assessment_id", "uuid", "title", + "is_required", "passing_score", + "time_limit_minutes", "max_questions", + ], + }, + ], + order: [ + [{ model: Unit, as: "units" }, "order_index", "ASC"], + [{ model: Unit, as: "units" }, { model: Lesson, as: "lessons" }, "order_index", "ASC"], + [{ model: CourseObjective, as: "objectives" }, "order_index", "ASC"], + [{ model: CoursePrerequisite, as: "prerequisites" }, "order_index", "ASC"], + ], + }); + + if (!course) return R.error(res, "Course not found.", 404); + + const plain = course.toJSON(); + + // Attach has_passed to each unit's quiz in one query + const quizIds = plain.units + ?.map((u) => u.quiz?.quiz_id) + .filter(Boolean) ?? []; + + if (quizIds.length) { + const passedQuizAttempts = await QuizAttempt.findAll({ + where: { quiz_id: quizIds, user_id: req.user.user_id, passed: true }, + attributes: ["quiz_id"], + }); + const passedSet = new Set(passedQuizAttempts.map((a) => String(a.quiz_id))); + plain.units = plain.units.map((u) => ({ + ...u, + quiz: u.quiz ? { ...u.quiz, has_passed: passedSet.has(String(u.quiz.quiz_id)) } : null, + })); + } + + let is_completed = false; + if (plain.assessment) { + const passedAttempt = await QuizAttempt.findOne({ + where: { assessment_id: plain.assessment.assessment_id, user_id: req.user.user_id, passed: true }, + }); + is_completed = !!passedAttempt; + } + plain.is_completed = is_completed; + + const plan_tier = plan?.tier ?? null; + + // Attach product info and purchase status for the buy-course flow + const product = await mdl_Product.findOne({ + where: { course_id: courseId, is_active: true }, + attributes: ['id', 'name', 'price', 'currency', 'access_days'], + }); + const hasPurchase = product && await mdl_CoursePurchase.findOne({ + where: { + user_id: req.user.user_id, product_id: product.id, status: 'completed', + [Op.or]: [{ expires_at: null }, { expires_at: { [Op.gt]: new Date() } }], + }, + }); + + return R.success(res, "Course retrieved.", { + ...plain, + plan_tier, + product: product ?? null, + has_purchased: !!hasPurchase, + }); + } catch (err) { + console.error("[CLIENT][COURSES][GET ONE]", err); + return R.error(res, "Could not retrieve course.", 500); + } +}; + +// ─── UNIT ───────────────────────────────────────────────────────────────────── + +exports.getUnit = async (req, res) => { + try { + const { courseId, unitId } = req.params; + + const unit = await Unit.findOne({ + where: { unit_id: unitId, course_id: courseId, ...notDeleted }, + attributes: [ + "unit_id", "uuid", "title", "description", + "order_index", "duration_seconds", + ], + include: [ + { + model: Lesson, as: "lessons", + where: notDeleted, required: false, + attributes: [ + "lesson_id", "uuid", "title", "description", + "order_index", "duration_seconds", + ], + }, + { + model: UnitQuiz, as: "quiz", + required: false, + attributes: [ + "quiz_id", "uuid", "title", + "is_required", "passing_score", "max_questions", + ], + }, + ], + order: [[{ model: Lesson, as: "lessons" }, "order_index", "ASC"]], + }); + + if (!unit) return R.error(res, "Unit not found.", 404); + return R.success(res, "Unit retrieved.", unit); + } catch (err) { + console.error("[CLIENT][UNIT][GET ONE]", err); + return R.error(res, "Could not retrieve unit.", 500); + } +}; + +// ─── LESSON ─────────────────────────────────────────────────────────────────── + +exports.getLesson = async (req, res) => { + try { + const { courseId, unitId, lessonId } = req.params; + + const lesson = await Lesson.findOne({ + where: { lesson_id: lessonId, unit_id: unitId, ...notDeleted }, + attributes: [ + "lesson_id", "uuid", "unit_id", "title", + "description", "order_index", "duration_seconds", + ], + include: [ + { + model: Unit, as: "unit", + where: { course_id: courseId, ...notDeleted }, + attributes: [], + }, + { + model: LessonPage, as: "page", + required: false, + attributes: ["page_id", "blocks"], + }, + { + model: LessonObjective, as: "objectives", + required: false, + attributes: ["objective_id", "text", "order_index"], + }, + ], + order: [[{ model: LessonObjective, as: "objectives" }, "order_index", "ASC"]], + }); + + if (!lesson) return R.error(res, "Lesson not found.", 404); + return R.success(res, "Lesson retrieved.", lesson); + } catch (err) { + console.error("[CLIENT][LESSON][GET ONE]", err); + return R.error(res, "Could not retrieve lesson.", 500); + } +}; + +// ─── QUIZ (no answers) ──────────────────────────────────────────────────────── + +exports.getUnitQuiz = async (req, res) => { + try { + const { courseId, unitId } = req.params; + + const unit = await Unit.findOne({ where: { unit_id: unitId, course_id: courseId, ...notDeleted } }); + if (!unit) return R.error(res, "Unit not found.", 404); + + const quiz = await UnitQuiz.findOne({ + where: { unit_id: unitId, ...notDeleted }, + attributes: [ + "quiz_id", "uuid", "title", + "is_required", "passing_score", "max_questions", + ], + include: [{ + model: QuizQuestion, as: "questions", + where: notDeleted, required: false, + attributes: ["question_id", "uuid", "type", "question", "order_index", "points"], + include: [{ + model: QuizOption, as: "options", + attributes: ["option_id", "text", "order_index"], + }], + }], + order: [[{ model: QuizQuestion, as: "questions" }, "order_index", "ASC"]], + }); + + if (!quiz) return R.error(res, "Quiz not found.", 404); + + const plain = quiz.toJSON(); + plain.questions = shuffleOptions(sanitizeQuestions(plain.questions ?? [])); + + const attempts = await QuizAttempt.findAll({ + where: { quiz_id: quiz.quiz_id, user_id: req.user.user_id }, + attributes: ["attempt_id", "attempt_number", "score", "passing_score", "passed", "createdAt"], + }); + + const status = getAttemptStatus(attempts); + plain.attempt_count = status.attempt_count; + plain.has_passed = status.has_passed; + plain.best_attempt = status.best_attempt; + plain.attempts_remaining = status.attempts_remaining; + plain.cooldown_until = status.cooldown_until; + plain.window_reset_at = status.window_reset_at; + plain.can_attempt = status.can_attempt; + + return R.success(res, "Quiz retrieved.", plain); + } catch (err) { + console.error("[CLIENT][QUIZ][GET]", err); + return R.error(res, "Could not retrieve quiz.", 500); + } +}; + +// ─── ASSESSMENT (no answers) ────────────────────────────────────────────────── + +exports.getCourseAssessment = async (req, res) => { + try { + const { courseId } = req.params; + + const assessment = await CourseAssessment.findOne({ + where: { course_id: courseId, ...notDeleted }, + attributes: [ + "assessment_id", "uuid", "title", + "is_required", "passing_score", + "time_limit_minutes", "max_questions", + ], + include: [{ + model: QuizQuestion, as: "questions", + where: notDeleted, required: false, + attributes: ["question_id", "uuid", "type", "question", "order_index", "points"], + include: [{ + model: QuizOption, as: "options", + attributes: ["option_id", "text", "order_index"], + }], + }], + order: [[{ model: QuizQuestion, as: "questions" }, "order_index", "ASC"]], + }); + + if (!assessment) return R.error(res, "Assessment not found.", 404); + + const plain = assessment.toJSON(); + plain.questions = shuffleOptions(sanitizeQuestions(plain.questions ?? [])); + + const attempts = await QuizAttempt.findAll({ + where: { assessment_id: assessment.assessment_id, user_id: req.user.user_id }, + attributes: ["attempt_id", "attempt_number", "score", "passing_score", "passed", "createdAt"], + }); + + const status = getAttemptStatus(attempts); + plain.attempt_count = status.attempt_count; + plain.has_passed = status.has_passed; + plain.best_attempt = status.best_attempt; + plain.attempts_remaining = status.attempts_remaining; + plain.cooldown_until = status.cooldown_until; + plain.window_reset_at = status.window_reset_at; + plain.can_attempt = status.can_attempt; + + return R.success(res, "Assessment retrieved.", plain); + } catch (err) { + console.error("[CLIENT][ASSESSMENT][GET]", err); + return R.error(res, "Could not retrieve assessment.", 500); + } +}; + +// ─── QUIZ SUBMIT ────────────────────────────────────────────────────────────── + +exports.submitUnitQuiz = async (req, res) => { + try { + const { courseId, unitId, quizId } = req.params; + const { answers = {} } = req.body; + const user_id = req.user.user_id; + + const unit = await Unit.findOne({ where: { unit_id: unitId, course_id: courseId, ...notDeleted } }); + if (!unit) return R.error(res, "Unit not found.", 404); + + const quiz = await UnitQuiz.findOne({ + where: { quiz_id: quizId, unit_id: unitId, ...notDeleted }, + include: [{ + model: QuizQuestion, as: "questions", + where: notDeleted, required: false, + include: [{ model: QuizOption, as: "options" }], + }], + }); + + + if (!quiz) return R.error(res, "Quiz not found.", 404); + + const priorAttempts = await QuizAttempt.findAll({ + where: { quiz_id: quiz.quiz_id, user_id }, + attributes: ["attempt_id", "score", "passed", "createdAt"], + }); + const status = getAttemptStatus(priorAttempts); + + if (!status.can_attempt) { + if (status.cooldown_until) { + return R.error(res, "Please wait a bit before retaking this quiz — check the quiz screen for when you can try again.", 429); + } + return R.error(res, `You've used all ${MAX_ATTEMPTS} attempts for this ${ATTEMPT_WINDOW_HOURS}-hour window — check the quiz screen for when it resets.`, 429); + } + + const { totalPoints, earnedPoints, score } = gradeSubmission(quiz.questions ?? [], answers); + const passed = score >= (quiz.passing_score ?? 70); + + const attempt = await QuizAttempt.create({ + user_id, + quiz_id: quiz.quiz_id, + course_id: courseId, + attempt_number: priorAttempts.length + 1, + answers, + total_points: totalPoints, + earned_points: earnedPoints, + score, + passing_score: quiz.passing_score ?? 70, + passed, + }); + + return R.success(res, "Quiz submitted.", { + attempt_id: attempt.attempt_id, + attempt_number: attempt.attempt_number, + score, + passed, + passing_score: attempt.passing_score, + total_points: totalPoints, + earned_points: earnedPoints, + attempts_remaining: Math.max(0, status.attempts_remaining - 1), + }); + } catch (err) { + console.error("[CLIENT][QUIZ][SUBMIT]", err); + return R.error(res, "Could not submit quiz.", 500); + } +}; + +exports.submitCourseAssessment = async (req, res) => { + try { + const { courseId, assessmentId } = req.params; + const { answers = {} } = req.body; + const user_id = req.user.user_id; + + const assessment = await CourseAssessment.findOne({ + where: { assessment_id: assessmentId, course_id: courseId, ...notDeleted }, + include: [{ + model: QuizQuestion, as: "questions", + where: notDeleted, required: false, + include: [{ model: QuizOption, as: "options" }], + }], + }); + + if (!assessment) return R.error(res, "Assessment not found.", 404); + + const priorAttempts = await QuizAttempt.findAll({ + where: { assessment_id: assessment.assessment_id, user_id }, + attributes: ["attempt_id", "score", "passed", "createdAt"], + }); + const status = getAttemptStatus(priorAttempts); + + if (!status.can_attempt) { + if (status.cooldown_until) { + return R.error(res, "Please wait a bit before retaking this quiz — check the quiz screen for when you can try again.", 429); + } + return R.error(res, `You've used all ${MAX_ATTEMPTS} attempts for this ${ATTEMPT_WINDOW_HOURS}-hour window — check the quiz screen for when it resets.`, 429); + } + + const { totalPoints, earnedPoints, score } = gradeSubmission(assessment.questions ?? [], answers); + const passed = score >= (assessment.passing_score ?? 70); + + const attempt = await QuizAttempt.create({ + user_id, + assessment_id: assessment.assessment_id, + course_id: courseId, + attempt_number: priorAttempts.length + 1, + answers, + total_points: totalPoints, + earned_points: earnedPoints, + score, + passing_score: assessment.passing_score ?? 70, + passed, + }); + + let course_completed = false; + if (passed) { + course_completed = true; + const course = await Course.findOne({ where: { course_id: courseId }, attributes: ["course_id", "title"] }); + const totalCompleted = await QuizAttempt.count({ + where: { user_id, passed: true, assessment_id: { [Op.ne]: null } }, + distinct: true, + col: "assessment_id", + }); + await onCourseCompleted(user_id, courseId, totalCompleted, course?.title ?? null); + } + + return R.success(res, "Assessment submitted.", { + attempt_id: attempt.attempt_id, + attempt_number: attempt.attempt_number, + score, + passed, + passing_score: attempt.passing_score, + total_points: totalPoints, + earned_points: earnedPoints, + attempts_remaining: Math.max(0, status.attempts_remaining - 1), + course_completed, + }); + } catch (err) { + console.error("[CLIENT][ASSESSMENT][SUBMIT]", err); + return R.error(res, "Could not submit assessment.", 500); + } +}; + +// ─── UUID LOOKUPS (task requirement detail blocks) ──────────────────────────── + +exports.getCourseByUuid = async (req, res) => { + try { + const { uuid } = req.params; + const course = await Course.findOne({ + where: { uuid, ...notDeleted }, + attributes: ["course_id", "uuid", "title", "description", "level", "subscription"], + }); + if (!course) return R.error(res, "Course not found.", 404); + return R.success(res, "Course retrieved.", course); + } catch (err) { + console.error("[CLIENT][COURSES][BY UUID]", err); + return R.error(res, "Could not retrieve course.", 500); + } +}; + +exports.getUnitByUuid = async (req, res) => { + try { + const { uuid } = req.params; + const unit = await Unit.findOne({ + where: { uuid, ...notDeleted }, + attributes: ["unit_id", "uuid", "title", "description"], + include: [{ model: Course, as: "course", attributes: ["course_id", "title"] }], + }); + if (!unit) return R.error(res, "Unit not found.", 404); + return R.success(res, "Unit retrieved.", unit); + } catch (err) { + console.error("[CLIENT][UNITS][BY UUID]", err); + return R.error(res, "Could not retrieve unit.", 500); + } +}; + +exports.getLessonsByUnitUuid = async (req, res) => { + try { + const { uuid } = req.params; + const unit = await Unit.findOne({ + where: { uuid, ...notDeleted }, + attributes: ["unit_id", "uuid", "title", "description", "order_index"], + include: [ + { model: Course, as: "course", attributes: ["course_id", "title"] }, + { + model: Lesson, + as: "lessons", + where: notDeleted, + required: false, + attributes: ["lesson_id", "uuid", "title", "description", "order_index"], + include: [{ model: LessonPage, as: "page", attributes: ["blocks"], required: false }], + order: [["order_index", "ASC"]], + }, + ], + }); + if (!unit) return R.error(res, "Unit not found.", 404); + const lessons = (unit.lessons ?? []) + .sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0)) + .map((l) => ({ + lesson_id: l.lesson_id, + uuid: l.uuid, + title: l.title, + description: l.description, + order_index: l.order_index ?? 0, + blocks: l.page?.blocks ?? [], + })); + return R.success(res, "Unit lessons retrieved.", { + unit_id: unit.unit_id, + uuid: unit.uuid, + title: unit.title, + description: unit.description, + course: unit.course ?? null, + lessons, + }); + } catch (err) { + console.error("[CLIENT][UNITS][LESSONS BY UUID]", err); + return R.error(res, "Could not retrieve unit lessons.", 500); + } +}; + +exports.getLessonByUuid = async (req, res) => { + try { + const { uuid } = req.params; + const lesson = await Lesson.findOne({ + where: { uuid, ...notDeleted }, + attributes: ["lesson_id", "uuid", "title", "description"], + include: [ + { + model: LessonPage, + as: "page", + attributes: ["blocks"], + required: false, + }, + { + model: Unit, + as: "unit", + attributes: ["unit_id", "title", "order_index"], + include: [{ model: Course, as: "course", attributes: ["course_id", "title"] }], + }, + ], + }); + if (!lesson) return R.error(res, "Lesson not found.", 404); + const data = { + lesson_id: lesson.lesson_id, + uuid: lesson.uuid, + title: lesson.title, + description: lesson.description, + blocks: lesson.page?.blocks ?? [], + unit: lesson.unit ?? null, + }; + return R.success(res, "Lesson retrieved.", data); + } catch (err) { + console.error("[CLIENT][LESSONS][BY UUID]", err); + return R.error(res, "Could not retrieve lesson.", 500); + } +}; \ No newline at end of file diff --git a/controllers/client/media.controller.js b/controllers/client/media.controller.js new file mode 100644 index 0000000..73e8acb --- /dev/null +++ b/controllers/client/media.controller.js @@ -0,0 +1,240 @@ +/*********************************************************************************************************************************************************************** + * File Name: media.controller.js (client) + * Type of Program: Controller + * Description: Secure media delivery for S3/Garage assets only. + * + * Chibisafe assets use their raw file_url directly — no token needed. + * The block content already has the URL saved at CMS time (handleSelect). + * + * S3 Flow: + * 1. POST /client/media/token { asset_id } + * → validates tier access + * → signs JWT with user_id + IP binding + * → returns { token, provider: "s3", file_type } + * + * 2. Browser sets