From 89acdfc239d39bc5b9ebb443cae339e1b52f1b21 Mon Sep 17 00:00:00 2001 From: Kenneth Obsequio Date: Sun, 28 Jun 2026 11:28:43 +0800 Subject: [PATCH] push pushy Signed-off-by: Kenneth Obsequio --- controllers/admin/courses.controller.js | 98 ++++-- controllers/admin/media.controller.js | 68 ++++- .../admin/tier_categories.controller.js | 136 +++++++++ controllers/admin/tier_policies.controller.js | 170 +++++++++++ controllers/admin/tiers.controller.js | 55 +++- controllers/admin/user_groups.controller.js | 16 +- controllers/admin/users.controller.js | 286 +++++++++++++++++ controllers/auth.controller.js | 123 +++++++- controllers/client/certificate.controller.js | 11 +- .../course_reading_progress.controller.js | 288 +++++++++++++++--- controllers/client/courses.controller.js | 147 +++++---- controllers/client/media.controller.js | 11 +- controllers/client/profile.controller.js | 29 ++ .../client/task_progress.controller.js | 41 ++- controllers/client/tiers.controller.js | 115 ++++++- cron/admin.cron.js | 4 +- cron/jobs/issue_certificates.cron.js | 10 +- cron/jobs/lift_expired_bans.cron.js | 85 ++++++ data/email_body.data.js | 124 ++++---- data/notifications.data.js | 41 ++- ...0101000056-create-unit-reading-progress.js | 34 +++ ...01000057-create-lesson-reading-progress.js | 36 +++ ...58-add-exclusive-to-course-subscription.js | 26 ++ ...0260101000059-add-plan-id-to-user-tiers.js | 17 ++ .../20260101000060-create-plan-policies.js | 25 ++ .../20260101000061-create-system-badges.js | 24 ++ .../20260101000062-create-tier-categories.js | 36 +++ ...1000063-add-tier-category-to-tier-plans.js | 39 +++ ...1000064-remove-badge-from-plan-policies.js | 20 ++ ...1000065-drop-tier-enum-check-user-tiers.js | 21 ++ middleware/auth.middleware.js | 30 +- middleware/badge_upload.middleware.js | 32 ++ models/courses/course_assessment.mdl.js | 5 +- models/courses/courses.associations.js | 27 +- models/courses/courses.mdl.js | 2 +- models/courses/lesson_reading_progress.mdl.js | 85 ++++++ models/courses/quiz_session.mdl.js | 20 ++ models/courses/unit_quiz.mdl.js | 7 +- models/courses/unit_reading_progress.mdl.js | 78 +++++ models/system_badges/system_badges.mdl.js | 19 ++ models/tiers/plan_policies.mdl.js | 14 + models/tiers/tier.associations.js | 58 +++- models/tiers/tier_categories.mdl.js | 22 ++ models/tiers/tier_plans.mdl.js | 5 +- models/tiers/user_tiers.mdl.js | 3 +- models/users/user_bans.mdl.js | 41 +++ models/users/users.attributes.js | 2 +- models/users/users.mdl.js | 4 + routes/admin/admin.routes.js | 4 +- routes/admin/courses.routes.js | 1 + routes/admin/media.routes.js | 3 +- routes/admin/tier_categories.routes.js | 14 + routes/admin/tier_policies.routes.js | 19 ++ routes/admin/users.routes.js | 7 + routes/auth.routes.js | 2 +- routes/client/client.routes.js | 2 + routes/client/courses.routes.js | 2 + routes/client/groups.routes.js | 5 + routes/client/tiers.routes.js | 6 + scripts/backfill-durations.js | 42 +++ services/email.service.js | 4 +- services/reading_progress.service.js | 153 ++++++++++ services/s3.service.js | 1 + utils/accessPolicy.util.js | 78 +++++ utils/courses/quiz_security.util.js | 55 ++-- utils/datetime.util.js | 53 ++++ utils/duration.util.js | 1 + 67 files changed, 2736 insertions(+), 306 deletions(-) create mode 100644 controllers/admin/tier_categories.controller.js create mode 100644 controllers/admin/tier_policies.controller.js create mode 100644 cron/jobs/lift_expired_bans.cron.js create mode 100644 database/migrations/20260101000056-create-unit-reading-progress.js create mode 100644 database/migrations/20260101000057-create-lesson-reading-progress.js create mode 100644 database/migrations/20260101000058-add-exclusive-to-course-subscription.js create mode 100644 database/migrations/20260101000059-add-plan-id-to-user-tiers.js create mode 100644 database/migrations/20260101000060-create-plan-policies.js create mode 100644 database/migrations/20260101000061-create-system-badges.js create mode 100644 database/migrations/20260101000062-create-tier-categories.js create mode 100644 database/migrations/20260101000063-add-tier-category-to-tier-plans.js create mode 100644 database/migrations/20260101000064-remove-badge-from-plan-policies.js create mode 100644 database/migrations/20260101000065-drop-tier-enum-check-user-tiers.js create mode 100644 middleware/badge_upload.middleware.js create mode 100644 models/courses/lesson_reading_progress.mdl.js create mode 100644 models/courses/quiz_session.mdl.js create mode 100644 models/courses/unit_reading_progress.mdl.js create mode 100644 models/system_badges/system_badges.mdl.js create mode 100644 models/tiers/plan_policies.mdl.js create mode 100644 models/tiers/tier_categories.mdl.js create mode 100644 models/users/user_bans.mdl.js create mode 100644 routes/admin/tier_categories.routes.js create mode 100644 routes/admin/tier_policies.routes.js create mode 100644 scripts/backfill-durations.js create mode 100644 services/reading_progress.service.js create mode 100644 utils/accessPolicy.util.js create mode 100644 utils/datetime.util.js diff --git a/controllers/admin/courses.controller.js b/controllers/admin/courses.controller.js index c401f8b..23fb66f 100644 --- a/controllers/admin/courses.controller.js +++ b/controllers/admin/courses.controller.js @@ -875,7 +875,7 @@ exports.getQuiz = async (req, res) => { exports.createQuiz = async (req, res) => { try { const { courseId, unitId } = req.params; - const { title, is_required, passing_score, max_questions, createdBy } = req.body; + const { title, is_required, passing_score, max_questions, shuffle_questions, createdBy } = req.body; const unit = await Unit.findOne({ where: { unit_id: unitId, course_id: courseId, ...notDeleted } }); if (!unit) return R.error(res, "Unit not found.", 404); @@ -889,6 +889,7 @@ exports.createQuiz = async (req, res) => { is_required: is_required ?? false, passing_score: passing_score ?? 70, max_questions: max_questions ?? null, + shuffle_questions: shuffle_questions ?? false, createdBy: createdBy ?? null, }); @@ -903,15 +904,16 @@ exports.createQuiz = async (req, res) => { exports.updateQuiz = async (req, res) => { try { const { courseId, unitId, quizId } = req.params; - const { title, is_required, passing_score, max_questions, updatedBy } = req.body; + const { title, is_required, passing_score, max_questions, shuffle_questions, updatedBy } = req.body; const quiz = await UnitQuiz.findOne({ where: { quiz_id: quizId, unit_id: unitId, ...notDeleted } }); if (!quiz) return R.error(res, "Quiz not found.", 404); - if (title !== undefined) quiz.title = title; - if (is_required !== undefined) quiz.is_required = is_required; - if (passing_score !== undefined) quiz.passing_score = passing_score; - if (max_questions !== undefined) quiz.max_questions = max_questions; + if (title !== undefined) quiz.title = title; + if (is_required !== undefined) quiz.is_required = is_required; + if (passing_score !== undefined) quiz.passing_score = passing_score; + if (max_questions !== undefined) quiz.max_questions = max_questions; + if (shuffle_questions !== undefined) quiz.shuffle_questions = shuffle_questions; quiz.updatedBy = updatedBy ?? null; @@ -1244,7 +1246,7 @@ exports.getAssessment = async (req, res) => { exports.createAssessment = async (req, res) => { try { const { courseId } = req.params; - const { title, is_required, passing_score, time_limit_minutes, max_questions, max_attempts, cooldown_hours, createdBy } = req.body; + const { title, is_required, passing_score, time_limit_minutes, max_questions, max_attempts, cooldown_hours, shuffle_questions, createdBy } = req.body; const course = await Course.findOne({ where: { course_id: courseId, ...notDeleted } }); if (!course) return R.error(res, "Course not found.", 404); @@ -1261,6 +1263,7 @@ exports.createAssessment = async (req, res) => { max_questions: max_questions ?? null, max_attempts: max_attempts ?? 3, cooldown_hours: cooldown_hours ?? 24, + shuffle_questions: shuffle_questions ?? false, createdBy: createdBy ?? null, }); @@ -1275,20 +1278,21 @@ exports.createAssessment = async (req, res) => { exports.updateAssessment = async (req, res) => { try { const { courseId, assessmentId } = req.params; - const { title, is_required, passing_score, time_limit_minutes, max_questions, max_attempts, cooldown_hours, updatedBy } = req.body; + const { title, is_required, passing_score, time_limit_minutes, max_questions, max_attempts, cooldown_hours, shuffle_questions, updatedBy } = req.body; const assessment = await CourseAssessment.findOne({ where: { assessment_id: assessmentId, course_id: courseId, ...notDeleted }, }); if (!assessment) return R.error(res, "Assessment not found.", 404); - if (title !== undefined) assessment.title = title; - if (is_required !== undefined) assessment.is_required = is_required; - if (passing_score !== undefined) assessment.passing_score = passing_score; + if (title !== undefined) assessment.title = title; + if (is_required !== undefined) assessment.is_required = is_required; + if (passing_score !== undefined) assessment.passing_score = passing_score; if (time_limit_minutes !== undefined) assessment.time_limit_minutes = time_limit_minutes; - if (max_questions !== undefined) assessment.max_questions = max_questions; - if (max_attempts !== undefined) assessment.max_attempts = max_attempts; - if (cooldown_hours !== undefined) assessment.cooldown_hours = cooldown_hours; + if (max_questions !== undefined) assessment.max_questions = max_questions; + if (max_attempts !== undefined) assessment.max_attempts = max_attempts; + if (cooldown_hours !== undefined) assessment.cooldown_hours = cooldown_hours; + if (shuffle_questions !== undefined) assessment.shuffle_questions = shuffle_questions; assessment.updatedBy = updatedBy ?? null; @@ -1423,6 +1427,23 @@ exports.getCoursesFlat = async (req, res) => { } }; +exports.getCoursesBySubscription = async (req, res) => { + try { + const { slug } = req.query; + if (!slug) return R.error(res, 'slug query param is required.', 400); + + const data = await Course.findAll({ + where: { ...notDeleted, subscription: slug }, + attributes: ['course_id', 'title', 'description', 'subscription'], + order: [['title', 'ASC']], + }); + return R.success(res, 'Courses retrieved.', data); + } catch (err) { + console.error('[COURSE][BY SUBSCRIPTION]', err); + return R.error(res, 'Could not retrieve courses.', 500); + } +}; + exports.getUnitsFlat = async (req, res) => { try { const rows = await Unit.findAll({ @@ -1565,11 +1586,12 @@ exports.syncInstructors = async (req, res) => { // ─── COMPLETIONS HELPERS ────────────────────────────────────────────────────── function extractUserInfo(user) { - if (!user) return { full_name: null, email: null, avatar_url: null }; + if (!user) return { full_name: null, email: null, avatar_url: null, deleted: false }; return { full_name: user.personal_info?.name?.full_name ?? null, email: user.email ?? null, avatar_url: user.personal_info?.avatar?.url ?? null, + deleted: !!user.deletedAt, }; } @@ -1578,12 +1600,13 @@ function groupByUser(attempts) { for (const a of attempts) { const uid = String(a.user_id); if (!map.has(uid)) { - const { full_name, email, avatar_url } = extractUserInfo(a.user); + const { full_name, email, avatar_url, deleted } = extractUserInfo(a.user); map.set(uid, { user_id: a.user_id, full_name, email, avatar_url, + deleted, attempt_count: 0, best_score: 0, passed: false, @@ -1602,10 +1625,36 @@ function groupByUser(attempts) { } function buildSummary(attempts) { - const takers = new Set(attempts.map((a) => String(a.user_id))).size; - const passed = attempts.filter((a) => a.passed).length; - const failed = attempts.length - passed; - const avg = attempts.length ? Math.round(attempts.reduce((s, a) => s + a.score, 0) / attempts.length) : 0; + const takers = new Set(attempts.map((a) => String(a.user_id))).size; + const passed = attempts.filter((a) => a.passed).length; + const failed = attempts.length - passed; + + /* + * ┌─────────────────────────────────────────────────────────────────┐ + * │ AVG SCORE formula │ + * │ │ + * │ avg = Σ( max(score) per user ) / unique_user_count │ + * │ │ + * │ Each student contributes exactly once — their personal best. │ + * │ A student who retries 3× before passing counts once at peak │ + * │ performance, not three times. Mirrors the "Best Score" column │ + * │ shown per student in the completions table. │ + * │ │ + * │ NOTE: CockroachDB returns INT8 columns as strings via the pg │ + * │ driver. Coerce with Number() before any arithmetic. │ + * └─────────────────────────────────────────────────────────────────┘ + */ + const bestByUser = new Map(); + for (const a of attempts) { + const uid = String(a.user_id); + const s = Number(a.score); + if (!bestByUser.has(uid) || s > bestByUser.get(uid)) bestByUser.set(uid, s); + } + const bestScores = [...bestByUser.values()]; + const avg = bestScores.length + ? Math.round(bestScores.reduce((sum, s) => sum + s, 0) / bestScores.length) + : 0; + return { total_takers: takers, passed_count: passed, @@ -1625,7 +1674,7 @@ exports.getQuizCompletions = async (req, res) => { const attempts = await QuizAttempt.findAll({ where: { quiz_id: quizId }, attributes: ["attempt_id", "user_id", "attempt_number", "score", "earned_points", "total_points", "passed", "createdAt"], - include: [{ model: mdl_Users, as: "user", attributes: ["user_id", "email", "personal_info"] }], + include: [{ model: mdl_Users, as: "user", attributes: ["user_id", "email", "personal_info", "deletedAt"], paranoid: false }], order: [["createdAt", "DESC"]], }); @@ -1649,7 +1698,7 @@ exports.getAssessmentCompletions = async (req, res) => { const attempts = await QuizAttempt.findAll({ where: { assessment_id: assessmentId }, attributes: ["attempt_id", "user_id", "attempt_number", "score", "earned_points", "total_points", "passed", "createdAt"], - include: [{ model: mdl_Users, as: "user", attributes: ["user_id", "email", "personal_info"] }], + include: [{ model: mdl_Users, as: "user", attributes: ["user_id", "email", "personal_info", "deletedAt"], paranoid: false }], order: [["createdAt", "DESC"]], }); @@ -1673,13 +1722,13 @@ exports.getAssessmentSessions = async (req, res) => { const sessions = await AssessmentSession.findAll({ where: { assessment_id: assessmentId }, attributes: ["session_id", "user_id", "status", "started_at", "expires_at", "attempt_id", "createdAt", "updatedAt"], - include: [{ model: mdl_Users, as: "user", attributes: ["user_id", "email", "personal_info"] }], + include: [{ model: mdl_Users, as: "user", attributes: ["user_id", "email", "personal_info", "deletedAt"], paranoid: false }], order: [["createdAt", "DESC"]], }); const rows = sessions.map((s) => { const j = s.toJSON(); - const { full_name, email, avatar_url } = extractUserInfo(j.user); + const { full_name, email, avatar_url, deleted } = extractUserInfo(j.user); const time_spent_seconds = j.status !== 'in_progress' && j.started_at ? Math.round((new Date(j.updatedAt) - new Date(j.started_at)) / 1000) : null; @@ -1689,6 +1738,7 @@ exports.getAssessmentSessions = async (req, res) => { full_name, email, avatar_url, + deleted, status: j.status, started_at: j.started_at, expires_at: j.expires_at, diff --git a/controllers/admin/media.controller.js b/controllers/admin/media.controller.js index f7ebea7..d9f688e 100644 --- a/controllers/admin/media.controller.js +++ b/controllers/admin/media.controller.js @@ -12,6 +12,7 @@ ***********************************************************************************************************************************************************************/ "use strict"; +const { Op } = require("sequelize"); const jwt = require("jsonwebtoken"); const R = require("../../utils/response.util"); @@ -29,6 +30,21 @@ function resolveIp(req) { return req.ip ?? req.socket?.remoteAddress ?? "unknown"; } +function signToken(asset, userId, ip) { + return jwt.sign( + { + asset_id: asset.asset_id, + user_id: userId, + storage_key: asset.storage_key, + file_type: asset.file_type, + mime_type: asset.mime_type, + ip, + }, + MEDIA_SECRET, + { expiresIn: TOKEN_TTL_SEC } + ); +} + // ─── POST /admin/media/token ────────────────────────────────────────────────── exports.issueToken = async (req, res) => { @@ -51,20 +67,8 @@ exports.issueToken = async (req, res) => { 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 } - ); + const ip = resolveIp(req); + const token = signToken(asset, req.user.user_id, ip); // ── Presign thumbnail URL so the browser can load it directly ───────────── let thumbnail_url = null; @@ -88,3 +92,39 @@ exports.issueToken = async (req, res) => { return R.error(res, "Could not issue media token.", 500); } }; + +// ─── POST /admin/media/tokens (batch) ─────────────────────────────────────── +// +// Accepts { asset_ids: [id, ...] } — S3 assets only, max 50. +// Returns { tokens: { [asset_id]: token } } +// One round-trip instead of N per-card requests. + +exports.issueTokensBatch = async (req, res) => { + try { + const { asset_ids } = req.body; + if (!Array.isArray(asset_ids) || !asset_ids.length) + return R.error(res, "asset_ids must be a non-empty array.", 400); + if (asset_ids.length > 50) + return R.error(res, "Maximum 50 asset_ids per batch.", 400); + + const assets = await mdl_Assets.findAll({ + where: { + asset_id: { [Op.in]: asset_ids }, + storage_provider: "s3", + deletedAt: null, + }, + attributes: ["asset_id", "file_type", "storage_key", "mime_type"], + }); + + const ip = resolveIp(req); + const tokens = {}; + for (const asset of assets) { + tokens[String(asset.asset_id)] = signToken(asset, req.user.user_id, ip); + } + + return R.success(res, "Tokens issued.", { tokens }); + } catch (err) { + console.error("[ADMIN][MEDIA][TOKENS BATCH]", err); + return R.error(res, "Could not issue media tokens.", 500); + } +}; diff --git a/controllers/admin/tier_categories.controller.js b/controllers/admin/tier_categories.controller.js new file mode 100644 index 0000000..283c7f9 --- /dev/null +++ b/controllers/admin/tier_categories.controller.js @@ -0,0 +1,136 @@ +'use strict'; + +const mdl_TierCategories = require('../../models/tiers/tier_categories.mdl'); +const mdl_TierPlans = require('../../models/tiers/tier_plans.mdl'); +const Asset = require('../../models/assets/assets.mdl'); +const R = require('../../utils/response.util'); +const logActivity = require('../../utils/logActivity.util'); + +require('../../models/tiers/tier.associations'); + +const ASSET_ATTRS = ['asset_id', 'file_url', 'display_name', 'mime_type']; + +const withBadge = [{ model: Asset, as: 'badgeAsset', attributes: ASSET_ATTRS, required: false }]; + +// ─── GET /admin/tiers/categories ───────────────────────────────────────────── + +exports.getCategories = async (req, res) => { + try { + const categories = await mdl_TierCategories.findAll({ + include: withBadge, + order: [['rank', 'ASC']], + }); + return R.success(res, 'Tier categories retrieved.', categories); + } catch (err) { + console.error('[ADMIN][GET TIER CATEGORIES]', err); + return R.error(res, 'Could not retrieve tier categories.', 500); + } +}; + +// ─── GET /admin/tiers/categories/:id ───────────────────────────────────────── + +exports.getCategory = async (req, res) => { + try { + const cat = await mdl_TierCategories.findByPk(req.params.id, { include: withBadge }); + if (!cat) return R.error(res, 'Tier category not found.', 404); + return R.success(res, 'Tier category retrieved.', cat); + } catch (err) { + console.error('[ADMIN][GET TIER CATEGORY]', err); + return R.error(res, 'Could not retrieve tier category.', 500); + } +}; + +// ─── POST /admin/tiers/categories ──────────────────────────────────────────── + +exports.createCategory = async (req, res) => { + try { + const { slug, name, description, rank, color, badge_asset_id, badge_icon, badge_label } = req.body; + if (!slug || !name) return R.error(res, 'slug and name are required.', 400); + + const parsedRank = Number(rank ?? 1); + if (parsedRank <= 0) return R.error(res, 'Non-default tier categories must have rank greater than 0.', 400); + + const exists = await mdl_TierCategories.findOne({ where: { slug } }); + if (exists) return R.error(res, `A tier category with slug "${slug}" already exists.`, 409); + + const cat = await mdl_TierCategories.create({ + slug, name, + description: description ?? null, + rank: parsedRank, + color: color || 'purple', + badge_asset_id: badge_asset_id || null, + badge_icon: badge_icon || null, + badge_label: badge_label ?? null, + is_default: false, + is_active: true, + }); + + logActivity(req.user?.user_id, 'create_tier_category', { entityType: 'tier_category', details: { slug, name } }); + + const result = await mdl_TierCategories.findByPk(cat.tier_category_id, { include: withBadge }); + return R.success(res, 'Tier category created.', result, 201); + } catch (err) { + console.error('[ADMIN][CREATE TIER CATEGORY]', err); + return R.error(res, 'Could not create tier category.', 500); + } +}; + +// ─── PUT /admin/tiers/categories/:id ───────────────────────────────────────── + +exports.updateCategory = async (req, res) => { + try { + const cat = await mdl_TierCategories.findByPk(req.params.id); + if (!cat) return R.error(res, 'Tier category not found.', 404); + + const { name, description, rank, color, badge_asset_id, badge_icon, badge_label, is_active } = req.body; + + if (!cat.is_default && rank !== undefined) { + const parsedRank = Number(rank); + if (parsedRank <= 0) return R.error(res, 'Non-default tier categories must have rank greater than 0.', 400); + } + + await cat.update({ + name: name ?? cat.name, + description: description !== undefined ? (description || null) : cat.description, + rank: rank !== undefined ? Number(rank) : cat.rank, + color: color !== undefined ? (color || cat.color) : cat.color, + badge_asset_id: badge_asset_id !== undefined ? (badge_asset_id || null) : cat.badge_asset_id, + badge_icon: badge_icon !== undefined ? (badge_icon || null) : cat.badge_icon, + badge_label: badge_label !== undefined ? (badge_label || null) : cat.badge_label, + // Default category (free) cannot be deactivated + is_active: (!cat.is_default && is_active !== undefined) ? is_active : cat.is_active, + }); + + logActivity(req.user?.user_id, 'update_tier_category', { entityType: 'tier_category', details: { id: cat.tier_category_id, slug: cat.slug } }); + + const result = await mdl_TierCategories.findByPk(cat.tier_category_id, { include: withBadge }); + return R.success(res, 'Tier category updated.', result); + } catch (err) { + console.error('[ADMIN][UPDATE TIER CATEGORY]', err); + return R.error(res, 'Could not update tier category.', 500); + } +}; + +// ─── DELETE /admin/tiers/categories/:id ────────────────────────────────────── + +exports.deleteCategory = async (req, res) => { + try { + const cat = await mdl_TierCategories.findByPk(req.params.id); + if (!cat) return R.error(res, 'Tier category not found.', 404); + if (cat.is_default) return R.error(res, 'The default (Free) tier category cannot be deleted.', 400); + + // Block deletion if active plans still reference this category + const activePlans = await mdl_TierPlans.count({ + where: { tier_category_id: cat.tier_category_id, is_active: true }, + }); + if (activePlans > 0) + return R.error(res, `Cannot delete — ${activePlans} active plan(s) belong to this category. Archive or reassign them first.`, 409); + + await cat.destroy(); + logActivity(req.user?.user_id, 'delete_tier_category', { entityType: 'tier_category', details: { slug: cat.slug } }); + return R.success(res, 'Tier category deleted.'); + } catch (err) { + console.error('[ADMIN][DELETE TIER CATEGORY]', err); + return R.error(res, 'Could not delete tier category.', 500); + } +}; diff --git a/controllers/admin/tier_policies.controller.js b/controllers/admin/tier_policies.controller.js new file mode 100644 index 0000000..9f049d1 --- /dev/null +++ b/controllers/admin/tier_policies.controller.js @@ -0,0 +1,170 @@ +'use strict'; + +const mdl_TierCategories = require('../../models/tiers/tier_categories.mdl'); +const mdl_TierPlans = require('../../models/tiers/tier_plans.mdl'); +const mdl_PlanPolicies = require('../../models/tiers/plan_policies.mdl'); +const mdl_SystemBadges = require('../../models/system_badges/system_badges.mdl'); +const Asset = require('../../models/assets/assets.mdl'); +const R = require('../../utils/response.util'); +const logActivity = require('../../utils/logActivity.util'); + +require('../../models/tiers/tier.associations'); + +const VALID_RULE_TYPES = new Set([ + 'course_subscription_access', + 'required_active_tier', + 'group_restriction', +]); + +async function validateRules(rules) { + if (!Array.isArray(rules)) return 'access_rules must be an array.'; + + // Load valid tier slugs dynamically from DB + const categories = await mdl_TierCategories.findAll({ attributes: ['slug'] }); + const validSlugs = new Set(categories.map((c) => c.slug)); + + for (const rule of rules) { + if (!VALID_RULE_TYPES.has(rule.type)) return `Unknown rule type: ${rule.type}`; + + if (rule.type === 'course_subscription_access') { + if (!Array.isArray(rule.levels) || !rule.levels.length) + return 'course_subscription_access.levels must be a non-empty array of tier slugs.'; + } + + if (rule.type === 'required_active_tier') { + if (!rule.tier || !validSlugs.has(rule.tier)) + return `required_active_tier.tier must be a valid tier category slug.`; + } + + if (rule.type === 'group_restriction') { + if (!Array.isArray(rule.group_ids)) + return 'group_restriction.group_ids must be an array.'; + } + } + return null; +} + +const ASSET_ATTRS = ['asset_id', 'file_url', 'display_name', 'mime_type']; + +// ─── PLAN POLICIES ──────────────────────────────────────────────────────────── + +exports.getPlanPolicy = async (req, res) => { + try { + const plan = await mdl_TierPlans.findByPk(req.params.planId); + if (!plan) return R.error(res, 'Plan not found.', 404); + + const policy = await mdl_PlanPolicies.findOne({ where: { plan_id: req.params.planId } }); + return R.success(res, 'Policy retrieved.', policy ?? null); + } catch (err) { + console.error('[ADMIN][GET PLAN POLICY]', err); + return R.error(res, 'Could not retrieve policy.', 500); + } +}; + +exports.upsertPlanPolicy = async (req, res) => { + try { + const { planId } = req.params; + + const plan = await mdl_TierPlans.findByPk(planId); + if (!plan) return R.error(res, 'Plan not found.', 404); + + const { access_rules } = req.body; + + let parsedRules = []; + if (access_rules !== undefined) { + try { + parsedRules = typeof access_rules === 'string' ? JSON.parse(access_rules) : access_rules; + } catch { + return R.error(res, 'access_rules is not valid JSON.', 400); + } + const err = await validateRules(parsedRules); + if (err) return R.error(res, err, 400); + } + + let existing = await mdl_PlanPolicies.findOne({ where: { plan_id: planId } }); + + const payload = { + plan_id: planId, + access_rules: access_rules !== undefined ? parsedRules : (existing?.access_rules ?? []), + }; + + if (!existing) { + existing = await mdl_PlanPolicies.create(payload); + logActivity(req.user?.user_id, 'create_plan_policy', { entityType: 'plan_policy', details: { plan_id: planId } }); + } else { + await existing.update(payload); + logActivity(req.user?.user_id, 'update_plan_policy', { entityType: 'plan_policy', details: { plan_id: planId } }); + } + + const result = await mdl_PlanPolicies.findOne({ where: { plan_id: planId } }); + return R.success(res, 'Policy saved.', result); + } catch (err) { + console.error('[ADMIN][UPSERT PLAN POLICY]', err); + return R.error(res, 'Could not save policy.', 500); + } +}; + +// ─── SYSTEM BADGES ──────────────────────────────────────────────────────────── + +exports.getSystemBadges = async (req, res) => { + try { + const badges = await mdl_SystemBadges.findAll({ + include: [{ model: Asset, as: 'asset', attributes: ASSET_ATTRS, required: false }], + order: [['key', 'ASC']], + }); + return R.success(res, 'System badges retrieved.', badges); + } catch (err) { + console.error('[ADMIN][GET SYSTEM BADGES]', err); + return R.error(res, 'Could not retrieve system badges.', 500); + } +}; + +exports.getSystemBadge = async (req, res) => { + try { + const badge = await mdl_SystemBadges.findOne({ + where: { key: req.params.key }, + include: [{ model: Asset, as: 'asset', attributes: ASSET_ATTRS, required: false }], + }); + if (!badge) return R.error(res, 'System badge not found.', 404); + return R.success(res, 'System badge retrieved.', badge); + } catch (err) { + console.error('[ADMIN][GET SYSTEM BADGE]', err); + return R.error(res, 'Could not retrieve system badge.', 500); + } +}; + +exports.upsertSystemBadge = async (req, res) => { + try { + const { key } = req.params; + const { asset_id, label, description, information, active_from, active_until } = req.body; + + let existing = await mdl_SystemBadges.findOne({ where: { key } }); + + const payload = { + key, + asset_id: asset_id !== undefined ? (asset_id || null) : existing?.asset_id ?? null, + label: label ?? existing?.label ?? key, + description: description ?? existing?.description ?? null, + information: information ?? existing?.information ?? null, + active_from: active_from !== undefined ? (active_from || null) : existing?.active_from ?? null, + active_until: active_until !== undefined ? (active_until || null) : existing?.active_until ?? null, + }; + + if (!existing) { + existing = await mdl_SystemBadges.create(payload); + logActivity(req.user?.user_id, 'create_system_badge', { entityType: 'system_badge', details: { key } }); + } else { + await existing.update(payload); + logActivity(req.user?.user_id, 'update_system_badge', { entityType: 'system_badge', details: { key } }); + } + + const result = await mdl_SystemBadges.findOne({ + where: { key }, + include: [{ model: Asset, as: 'asset', attributes: ASSET_ATTRS, required: false }], + }); + return R.success(res, 'System badge saved.', result); + } catch (err) { + console.error('[ADMIN][UPSERT SYSTEM BADGE]', err); + return R.error(res, 'Could not save system badge.', 500); + } +}; diff --git a/controllers/admin/tiers.controller.js b/controllers/admin/tiers.controller.js index be97422..3409e91 100644 --- a/controllers/admin/tiers.controller.js +++ b/controllers/admin/tiers.controller.js @@ -10,10 +10,11 @@ ***********************************************************************************************************************************************************************/ 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_TierCategories = require('../../models/tiers/tier_categories.mdl'); +const mdl_TierPlans = require('../../models/tiers/tier_plans.mdl'); +const mdl_UserTiers = require('../../models/tiers/user_tiers.mdl'); +const mdl_Payments = require('../../models/tiers/payments.mdl'); +const mdl_Users = require('../../models/users/users.mdl'); const mdl_PlanCourses = require('../../models/tiers/plan_courses.mdl'); const { Course } = require('../../models/courses/courses.mdl'); @@ -84,14 +85,24 @@ exports.getPlan = async (req, res) => { 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 { tier_category_id, label, duration_days, price, currency } = req.body; + if (!tier_category_id || !label || !duration_days || !price) + return R.error(res, 'tier_category_id, label, duration_days, and price are required.', 400); - const plan = await mdl_TierPlans.create({ tier, label, duration_days, price, currency }); + const category = await mdl_TierCategories.findByPk(tier_category_id); + if (!category || !category.is_active) + return R.error(res, 'Tier category not found or inactive.', 404); + + if (category.is_default) + return R.error(res, 'Plans cannot be created under the default (Free) tier. Free access is automatic.', 400); + + const plan = await mdl_TierPlans.create({ + tier_category_id: category.tier_category_id, + tier: category.slug, + 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); @@ -104,9 +115,21 @@ exports.updatePlan = async (req, res) => { 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 allowed = ['label', 'duration_days', 'price', 'currency', 'is_active', 'tier_category_id']; const updates = {}; - allowed.forEach((k) => { if (req.body[k] !== undefined) updates[k] = req.body[k]; }); + for (const k of allowed) { + if (req.body[k] !== undefined) updates[k] = req.body[k]; + } + + // If tier_category_id is being changed, sync the tier slug + if (updates.tier_category_id) { + const category = await mdl_TierCategories.findByPk(updates.tier_category_id); + if (!category || !category.is_active) + return R.error(res, 'Tier category not found or inactive.', 404); + if (category.is_default) + return R.error(res, 'Plans cannot be moved to the Free tier category.', 400); + updates.tier = category.slug; + } await plan.update(updates); logActivity(req.user?.user_id, 'update_tier_plan', { entityType: 'tier_plan', details: { label: plan.label } }); @@ -234,16 +257,16 @@ exports.getUserTiers = async (req, res) => { 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_id, plan_id, notes } = req.body; + if (!user_id || !plan_id) + return R.error(res, 'user_id and plan_id are required.', 400); const user = await mdl_Users.findByPk(user_id); if (!user) return R.error(res, 'User not found.', 404); const plan = await mdl_TierPlans.findByPk(plan_id); if (!plan || !plan.is_active) return R.error(res, 'Plan not found or inactive.', 404); - if (plan.tier !== tier) return R.error(res, 'Plan tier mismatch.', 400); + const tier = plan.tier; await mdl_UserTiers.update( { status: 'expired' }, @@ -254,7 +277,7 @@ exports.grantTier = async (req, res) => { const expiresAt = new Date(startsAt.getTime() + plan.duration_days * 86400000); const newTier = await mdl_UserTiers.create({ - user_id, tier, status: 'active', + user_id, tier, plan_id, status: 'active', starts_at: startsAt, expires_at: expiresAt, granted_by: req.user.user_id, diff --git a/controllers/admin/user_groups.controller.js b/controllers/admin/user_groups.controller.js index 4123c9c..6d06bed 100644 --- a/controllers/admin/user_groups.controller.js +++ b/controllers/admin/user_groups.controller.js @@ -286,14 +286,28 @@ exports.getUsersNotInGroup = async (req, res) => { try { const { gid: group_id } = req.params; + // Exclude users already in THIS group const members = await mdl_UserGroupMembers.findAll({ where: { group_id }, attributes: ['user_id'] }); const memberIds = members.map((m) => m.user_id); const users = await mdl_Users.findAll({ - where: { user_id: { [Op.notIn]: memberIds.length ? memberIds : [0] } }, + where: { user_id: { [Op.notIn]: memberIds.length ? memberIds : [0] } }, attributes: [ 'user_id', [Sequelize.literal(`("User"."personal_info"->'name'->>'full_name')`), 'full_name'], + // All active groups this user belongs to (excluding NOGRP), comma-separated + [ + Sequelize.literal(`( + SELECT STRING_AGG(ug.name || ' (' || ug.group_code || ')', ', ' ORDER BY ug.name) + FROM user_group_members ugm + JOIN user_groups ug ON ug.group_id = ugm.group_id + WHERE ugm.user_id = "User".user_id + AND ugm."deletedAt" IS NULL + AND ug."deletedAt" IS NULL + AND ug.group_code != 'NOGRP' + )`), + 'current_group', + ], ], }); diff --git a/controllers/admin/users.controller.js b/controllers/admin/users.controller.js index 9e21e24..c0bed21 100644 --- a/controllers/admin/users.controller.js +++ b/controllers/admin/users.controller.js @@ -14,9 +14,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_UserBans = require('../../models/users/user_bans.mdl'); const { mdl_UserGroups, mdl_UserGroupMembers } = require('../../models/users/user_groups.mdl'); const { sendEmail } = require('../../services/email.service'); +const { fmtDate } = require('../../utils/datetime.util'); const logActivity = require('../../utils/logActivity.util'); const R = require('../../utils/response.util'); const { paginate } = require('../../utils/paginate.util'); @@ -383,4 +385,288 @@ exports.getUserAchievements = async (req, res) => { console.error('[ADMIN][GET USER ACHIEVEMENTS]', err); return R.error(res, 'Could not retrieve achievements.', 500); } +}; + +// ─── BAN USER ───────────────────────────────────────────────────────────────── + +exports.banUser = async (req, res) => { + try { + const { id } = req.params; + + if (Number(id) === req.user.user_id) + return R.error(res, 'You cannot ban your own account.', 400); + + const user = await mdl_Users.findByPk(id); + if (!user) return R.error(res, 'User not found.', 404); + if (user.is_banned) return R.error(res, 'User is already banned.', 400); + + const { reason, ban_type, expires_at } = req.body; + + if (!reason?.trim()) return R.error(res, 'Ban reason is required.', 400); + if (!['temporary', 'permanent'].includes(ban_type)) + return R.error(res, 'Invalid ban type. Must be "temporary" or "permanent".', 400); + if (ban_type === 'temporary' && !expires_at) + return R.error(res, 'Expiry date is required for temporary bans.', 400); + if (ban_type === 'temporary' && new Date(expires_at) <= new Date()) + return R.error(res, 'Expiry date must be in the future.', 400); + + const banExpiresAt = ban_type === 'temporary' ? new Date(expires_at) : null; + + await sequelize.transaction(async (t) => { + await mdl_UserBans.create({ + user_id: id, + banned_by: req.user.user_id, + reason: reason.trim(), + ban_type, + banned_at: new Date(), + expires_at: banExpiresAt, + }, { transaction: t }); + + await user.update({ is_banned: true, ban_expires_at: banExpiresAt }, { transaction: t }); + + await mdl_UserSessions.update( + { is_active: false, logout_info: { date: new Date().toISOString(), forced_by: req.user.user_id } }, + { where: { user_id: id }, transaction: t } + ); + }); + + logActivity(req.user.user_id, 'ban_user', { + entityType: 'user', + entityId: Number(id), + details: { reason: reason.trim(), ban_type, expires_at: banExpiresAt }, + }); + + sendEmail({ + to: user.email, + type: 'BANNED', + data: { + name: user.personal_info?.name?.full_name ?? 'User', + email: user.email, + date: fmtDate(new Date()), + reason: reason.trim(), + ban_type, + }, + }).catch((err) => console.error('[ADMIN][BAN USER] Email failed:', err)); + + return R.success(res, 'User banned successfully.'); + } catch (err) { + console.error('[ADMIN][BAN USER]', err); + return R.error(res, 'Could not ban user.', 500); + } +}; + +// ─── UNBAN USER ─────────────────────────────────────────────────────────────── + +exports.unbanUser = async (req, res) => { + try { + const { id } = req.params; + + const user = await mdl_Users.findByPk(id); + if (!user) return R.error(res, 'User not found.', 404); + if (!user.is_banned) return R.error(res, 'User is not currently banned.', 400); + + const { lift_reason } = req.body; + + const activeBan = await mdl_UserBans.findOne({ + where: { user_id: id, is_lifted: false }, + order: [['banned_at', 'DESC']], + }); + + await sequelize.transaction(async (t) => { + if (activeBan) { + await activeBan.update({ + is_lifted: true, + lifted_at: new Date(), + lifted_by: req.user.user_id, + lift_reason: lift_reason?.trim() || null, + }, { transaction: t }); + } + + await user.update({ is_banned: false, ban_expires_at: null }, { transaction: t }); + }); + + logActivity(req.user.user_id, 'unban_user', { entityType: 'user', entityId: Number(id) }); + + sendEmail({ + to: user.email, + type: 'BAN_LIFTED', + data: { + name: user.personal_info?.name?.full_name ?? 'User', + email: user.email, + date: fmtDate(new Date()), + }, + }).catch((err) => console.error('[ADMIN][UNBAN USER] Email failed:', err)); + + return R.success(res, 'User unbanned successfully.'); + } catch (err) { + console.error('[ADMIN][UNBAN USER]', err); + return R.error(res, 'Could not unban user.', 500); + } +}; + +// ─── BULK BAN ───────────────────────────────────────────────────────────────── + +exports.bulkBanUsers = async (req, res) => { + try { + const { ids, reason, ban_type, expires_at } = req.body; + + if (!Array.isArray(ids) || !ids.length) + return R.error(res, 'No user IDs provided.', 400); + if (ids.includes(req.user.user_id)) + return R.error(res, 'You cannot ban your own account.', 400); + if (!reason?.trim()) return R.error(res, 'Ban reason is required.', 400); + if (!['temporary', 'permanent'].includes(ban_type)) + return R.error(res, 'Invalid ban type.', 400); + if (ban_type === 'temporary' && !expires_at) + return R.error(res, 'Expiry date is required for temporary bans.', 400); + if (ban_type === 'temporary' && new Date(expires_at) <= new Date()) + return R.error(res, 'Expiry date must be in the future.', 400); + + const users = await mdl_Users.findAll({ where: { user_id: ids } }); + if (!users.length) return R.error(res, 'No users found.', 404); + + const unbannedUsers = users.filter((u) => !u.is_banned); + if (!unbannedUsers.length) + return R.error(res, 'All selected users are already banned.', 400); + + const targetIds = unbannedUsers.map((u) => u.user_id); + const banExpiresAt = ban_type === 'temporary' ? new Date(expires_at) : null; + const now = new Date(); + + await sequelize.transaction(async (t) => { + await mdl_UserBans.bulkCreate( + targetIds.map((uid) => ({ + user_id: uid, + banned_by: req.user.user_id, + reason: reason.trim(), + ban_type, + banned_at: now, + expires_at: banExpiresAt, + })), + { transaction: t } + ); + + await mdl_Users.update( + { is_banned: true, ban_expires_at: banExpiresAt }, + { where: { user_id: targetIds }, transaction: t } + ); + + await mdl_UserSessions.update( + { is_active: false, logout_info: { date: now.toISOString(), forced_by: req.user.user_id } }, + { where: { user_id: targetIds }, transaction: t } + ); + }); + + const dateStr = fmtDate(new Date()); + unbannedUsers.forEach((u) => { + sendEmail({ + to: u.email, + type: 'BANNED', + data: { + name: u.personal_info?.name?.full_name ?? 'User', + email: u.email, + date: dateStr, + reason: reason.trim(), + ban_type, + }, + }).catch((err) => console.error('[ADMIN][BULK BAN] Email failed:', u.email, err)); + }); + + return R.success(res, `${targetIds.length} user(s) banned successfully.`, { + banned_ids: targetIds, + skipped_ids: ids.filter((id) => !targetIds.includes(Number(id))), + }); + } catch (err) { + console.error('[ADMIN][BULK BAN USERS]', err); + return R.error(res, 'Could not ban users.', 500); + } +}; + +// ─── GET USER BAN HISTORY ───────────────────────────────────────────────────── + +exports.getUserBans = async (req, res) => { + try { + const { id } = req.params; + + const user = await mdl_Users.findByPk(id, { attributes: ['user_id'] }); + if (!user) return R.error(res, 'User not found.', 404); + + const bans = await mdl_UserBans.findAll({ + where: { user_id: id }, + include: [ + { model: mdl_Users, as: 'banner', attributes: ['user_id', 'email', 'personal_info'] }, + { model: mdl_Users, as: 'lifter', attributes: ['user_id', 'email', 'personal_info'] }, + ], + order: [['banned_at', 'DESC']], + }); + + return R.success(res, 'Ban history retrieved.', bans); + } catch (err) { + console.error('[ADMIN][GET USER BANS]', err); + return R.error(res, 'Could not retrieve ban history.', 500); + } +}; + +// ─── BULK UNBAN ─────────────────────────────────────────────────────────────── + +exports.bulkUnbanUsers = async (req, res) => { + try { + const { ids, lift_reason } = req.body; + + if (!Array.isArray(ids) || !ids.length) + return R.error(res, 'No user IDs provided.', 400); + + const users = await mdl_Users.findAll({ where: { user_id: ids } }); + if (!users.length) return R.error(res, 'No users found.', 404); + + const bannedUsers = users.filter((u) => u.is_banned); + if (!bannedUsers.length) + return R.error(res, 'None of the selected users are currently banned.', 400); + + const targetIds = bannedUsers.map((u) => u.user_id); + const now = new Date(); + + await sequelize.transaction(async (t) => { + await mdl_UserBans.update( + { + is_lifted: true, + lifted_at: now, + lifted_by: req.user.user_id, + lift_reason: lift_reason?.trim() || null, + }, + { where: { user_id: targetIds, is_lifted: false }, transaction: t } + ); + + await mdl_Users.update( + { is_banned: false, ban_expires_at: null }, + { where: { user_id: targetIds }, transaction: t } + ); + }); + + logActivity(req.user.user_id, 'bulk_unban_users', { + entityType: 'user', + details: { unban_ids: targetIds }, + }); + + const dateStr = fmtDate(now); + bannedUsers.forEach((u) => { + sendEmail({ + to: u.email, + type: 'BAN_LIFTED', + data: { + name: u.personal_info?.name?.full_name ?? 'User', + email: u.email, + date: dateStr, + }, + }).catch((err) => console.error('[ADMIN][BULK UNBAN] Email failed:', u.email, err)); + }); + + return R.success(res, `${targetIds.length} user(s) unbanned successfully.`, { + unbanned_ids: targetIds, + skipped_ids: ids.filter((id) => !targetIds.includes(Number(id))), + }); + } catch (err) { + console.error('[ADMIN][BULK UNBAN USERS]', err); + return R.error(res, 'Could not unban users.', 500); + } }; \ No newline at end of file diff --git a/controllers/auth.controller.js b/controllers/auth.controller.js index 8c26bcc..cf04c33 100644 --- a/controllers/auth.controller.js +++ b/controllers/auth.controller.js @@ -28,6 +28,7 @@ const bcrypt = require('bcryptjs'); const sequelize = require('../config/db.config') const mdl_Users = require('../models/users/users.mdl'); const mdl_UserSessions = require('../models/users/user_sessions.mdl'); +const mdl_UserBans = require('../models/users/user_bans.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'); @@ -106,7 +107,7 @@ exports.register = async (req, res) => { await transaction.commit(); - // Fire-and-forget: notify admins only for explicit group code registrations + // Fire-and-forget: notify admins — explicit group or NOGRP fallback if (group) { AdminNotification.create({ ...NOTIFICATION_REGISTRY.user_registration.build({ @@ -115,6 +116,13 @@ exports.register = async (req, res) => { userEmail: email, }), }).catch(err => console.error('[AUTH] Failed to emit user_registration notification:', err)); + } else if (enrollGroup) { + AdminNotification.create({ + ...NOTIFICATION_REGISTRY.nogrp_user_registered.build({ + userEmail: email, + regType: 'system', + }), + }).catch(err => console.error('[AUTH] Failed to emit nogrp_user_registered notification:', err)); } return R.success(res, 'Registration successful. Please check your email for the OTP.', { @@ -138,7 +146,8 @@ exports.verifyOTP = async (req, res) => { if (!user) return R.error(res, 'User not found.', 404); if (user.is_verified) return R.error(res, 'Account already verified.', 400); - if (user.otp_code !== otp) return R.error(res, 'Invalid OTP.', 400); + if (!crypto.timingSafeEqual(Buffer.from(user.otp_code ?? ''), Buffer.from(otp))) + return R.error(res, 'Invalid OTP.', 400); if (isOTPExpired(user.otp_expires_at)) return R.error(res, 'OTP has expired. Please request a new one.', 400); await user.update({ is_verified: true, otp_code: null, otp_expires_at: null }, { transaction }); @@ -167,16 +176,30 @@ exports.verifyOTP = async (req, res) => { mdl_UserGroupMembers.findOne({ where: { user_id: user.user_id }, include: [{ model: mdl_UserGroups, attributes: ['name', 'group_code'] }], - }).then(membership => { + }).then(async 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, - }), - }); + const now = new Date(); + const notifications = [ + { + user_id: user.user_id, + ...NOTIFICATION_REGISTRY.welcome.build({ + groupName: grp?.name ?? null, + groupCode: grp?.group_code ?? null, + accType: user.acc_type, + }), + createdAt: now, + updatedAt: now, + }, + ]; + if (grp?.group_code === 'NOGRP') { + notifications.push({ + user_id: user.user_id, + ...NOTIFICATION_REGISTRY.nogrp_welcome.build(), + createdAt: now, + updatedAt: now, + }); + } + return UserNotification.bulkCreate(notifications, { validate: false }); }).catch(err => console.error('[AUTH] Failed to emit welcome notification:', err)); res.cookie('refreshToken', refreshToken, { @@ -188,7 +211,6 @@ exports.verifyOTP = async (req, res) => { return R.success(res, 'Email verified successfully. You are now logged in.', { accessToken, - refreshToken, session_id: session.session_id, user: safeUser(user), }); @@ -235,6 +257,25 @@ exports.login = async (req, res) => { if (!user.is_active) return R.error(res, 'Account is deactivated.', 403); if (!user.is_verified) return R.error(res, 'Please verify your email first.', 403); + if (user.is_banned) { + const stillBanned = !user.ban_expires_at || new Date() < new Date(user.ban_expires_at); + if (stillBanned) { + const activeBan = await mdl_UserBans.findOne({ + where: { user_id: user.user_id, is_lifted: false }, + order: [['banned_at', 'DESC']], + attributes: ['reason', 'ban_type', 'expires_at'], + }); + return R.error(res, 'Your account has been suspended.', 403, { + banned: true, + reason: activeBan?.reason ?? null, + ban_type: activeBan?.ban_type ?? null, + ban_expires_at: activeBan?.expires_at ?? null, + }); + } + // Expired temporary ban — auto-lift + await user.update({ is_banned: false, ban_expires_at: null }); + } + const match = await bcrypt.compare(password, user.password); if (!match) return R.error(res, 'Invalid credentials.', 401); @@ -321,7 +362,12 @@ exports.googleCallback = async (req, res) => { const payload = await verifyIdToken(tokens.id_token, nonce); // Find or auto-create the user. - let user = await mdl_Users.findOne({ where: { email: payload.email } }); + // Use paranoid:false so soft-deleted rows are visible — if one is blocking the email slot, free it first. + let user = await mdl_Users.findOne({ where: { email: payload.email }, paranoid: false }); + if (user?.deletedAt) { + await user.update({ email: `deleted_${user.user_id}@deleted.invalid` }); + user = null; + } if (!user) { const t = await sequelize.transaction(); try { @@ -353,9 +399,33 @@ exports.googleCallback = async (req, res) => { await t.commit(); - // Fire-and-forget: achievements + welcome notification for new Google user + // Fire-and-forget: achievements + welcome notifications for new Google user onUserRegistered(user.user_id) .catch(err => console.error('[AUTH] googleCallback: Failed to grant achievements:', err)); + + const _now = new Date(); + UserNotification.bulkCreate([ + { + user_id: user.user_id, + ...NOTIFICATION_REGISTRY.welcome.build({ groupName: null, groupCode: 'NOGRP', accType: 'user' }), + createdAt: _now, + updatedAt: _now, + }, + { + user_id: user.user_id, + ...NOTIFICATION_REGISTRY.nogrp_welcome.build(), + createdAt: _now, + updatedAt: _now, + }, + ], { validate: false }) + .catch(err => console.error('[AUTH] googleCallback: Failed to emit welcome notifications:', err)); + + AdminNotification.create({ + ...NOTIFICATION_REGISTRY.nogrp_user_registered.build({ + userEmail: payload.email, + regType: 'google', + }), + }).catch(err => console.error('[AUTH] googleCallback: Failed to emit nogrp_user_registered notification:', err)); } catch (err) { await t.rollback(); throw err; @@ -366,6 +436,23 @@ exports.googleCallback = async (req, res) => { return res.redirect(`${CALLBACK_PAGE}?error=account_deactivated`); } + if (user.is_banned) { + const stillBanned = !user.ban_expires_at || new Date() < new Date(user.ban_expires_at); + if (stillBanned) { + const activeBan = await mdl_UserBans.findOne({ + where: { user_id: user.user_id, is_lifted: false }, + order: [['banned_at', 'DESC']], + attributes: ['reason', 'ban_type', 'expires_at'], + }); + const params = new URLSearchParams({ error: 'account_banned' }); + if (activeBan?.reason) params.set('reason', activeBan.reason); + if (activeBan?.ban_type) params.set('ban_type', activeBan.ban_type); + if (activeBan?.expires_at) params.set('expires_at', new Date(activeBan.expires_at).toISOString()); + return res.redirect(`${CALLBACK_PAGE}?${params.toString()}`); + } + await user.update({ is_banned: false, ban_expires_at: null }); + } + const { accessToken, refreshToken } = generateTokens(user); const googleSession = await mdl_UserSessions.create({ user_id: user.user_id, @@ -409,6 +496,12 @@ exports.refreshToken = async (req, res) => { const user = await mdl_Users.findByPk(decoded.user_id); if (!user || !user.is_active) return R.error(res, 'User not found or deactivated.', 401); + if (user.is_banned) { + const stillBanned = !user.ban_expires_at || new Date() < new Date(user.ban_expires_at); + if (stillBanned) return R.error(res, 'Your account has been suspended.', 403, { banned: true }); + await user.update({ is_banned: false, ban_expires_at: null }); + } + // Check if refresh token is expired if (!shouldRotateRefreshToken(decoded)) { const { accessToken } = generateTokens(user); @@ -426,7 +519,7 @@ exports.refreshToken = async (req, res) => { maxAge: 7 * 24 * 60 * 60 * 1000, }); - return R.success(res, 'Token refreshed.', { ...tokens, session_id: session.session_id, user: safeUser(user) }); + return R.success(res, 'Token refreshed.', { accessToken: tokens.accessToken, 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); diff --git a/controllers/client/certificate.controller.js b/controllers/client/certificate.controller.js index a3e4327..8ed4502 100644 --- a/controllers/client/certificate.controller.js +++ b/controllers/client/certificate.controller.js @@ -15,6 +15,7 @@ const R = require('../../utils/response.util'); const mdl_Users = require('../../models/users/users.mdl'); const { generateCertificate } = require('../../services/certificate.service'); const { formatDuration } = require('../../utils/duration.util'); +const { fmtDate } = require('../../utils/datetime.util'); const { Course, @@ -134,11 +135,7 @@ exports.getCertificate = async (req, res) => { // ── 5. 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: 'long', - day: 'numeric', - year: 'numeric', - }).format(issuedDate); + const dateStr = fmtDate(issuedDate); // ── 6. Generate PDF ──────────────────────────────────────────────────────── const pdf = await generateCertificate({ @@ -157,10 +154,12 @@ exports.getCertificate = async (req, res) => { const firstName = nameParts.length > 1 ? nameParts.slice(0, -1).join(' ') : ''; const safeTitle = course.title.replace(/[/\\:*?"<>|]/g, '').trim(); const filename = `${lastName},${firstName}_${safeTitle}_${cert.cert_no}.pdf`; + const asciiName = filename.replace(/[^\x20-\x7E]/g, '_').replace(/"/g, '_'); + const encodedName = encodeURIComponent(filename); res.set({ 'Content-Type': 'application/pdf', - 'Content-Disposition': `attachment; filename="${filename}"`, + 'Content-Disposition': `attachment; filename="${asciiName}"; filename*=UTF-8''${encodedName}`, 'Content-Length': pdf.length, }); diff --git a/controllers/client/course_reading_progress.controller.js b/controllers/client/course_reading_progress.controller.js index 93305c1..b6b77d9 100644 --- a/controllers/client/course_reading_progress.controller.js +++ b/controllers/client/course_reading_progress.controller.js @@ -6,49 +6,164 @@ * GET /client/courses/in-progress * → courses where the current user has status = 'in_progress', with lesson counts * + * GET /client/courses/:courseId/progress/summary + * → compact snapshot: lesson counts + percentage + course status + * * GET /client/courses/:courseId/progress - * → returns all progress rows for this user + course (flat, frontend builds the map) + * → all progress rows for this user + course (flat, frontend builds the map) + * + * GET /client/courses/:courseId/task-context + * → all pending task requirements (read_*) for this course's UUIDs that the user is assigned to * * POST /client/courses/:courseId/units/:unitId/lessons/:lessonId/progress * → UPSERT lesson + derives + UPSERTs parent unit + course in one transaction - * - * Body (POST): - * { status: 'in_progress' | 'completed' } - * Defaults to 'in_progress' if omitted (on first visit). + * → side-effects: writes to lesson_reading_progress / unit_reading_progress, + * syncs task_progress for matching task requirements, + * returns completed_tasks for any task whose read requirements are now all done * * Author: Kenneth Obsequio (@lash0000) * Date Created: Jun. 21, 2026 ***********************************************************************************************************************************************************************/ 'use strict'; -const 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 { Op } = require('sequelize'); +const R = require('../../utils/response.util'); +const logActivity = require('../../utils/logActivity.util'); +const { upsertLessonRead } = require('../../services/course_reading_progress.service'); +const { upsertLessonRead: upsertReadingProgress } = require('../../services/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 { Task, TaskRequirement, TaskListGroup } = require('../../models/task/task.mdl'); +const { TaskProgress } = require('../../models/task/task_progress.mdl'); +const { mdl_UserGroupMembers } = require('../../models/users/user_groups.mdl'); + const notDeleted = { deletedAt: null }; +// ─── Internal helper: get task list IDs accessible to a user ───────────────── +async function getAccessibleTaskListIds(userId) { + const memberships = await mdl_UserGroupMembers.findAll({ + where: { user_id: userId, deletedAt: null }, + attributes: ['group_id'], + }); + const groupIds = memberships.map((m) => m.group_id); + if (!groupIds.length) return { taskListIds: [], taskListToGroup: {} }; + + const taskListGroups = await TaskListGroup.findAll({ + where: { group_id: groupIds }, + attributes: ['task_list_id', 'group_id'], + }); + const taskListToGroup = Object.fromEntries(taskListGroups.map((tlg) => [tlg.task_list_id, tlg.group_id])); + return { taskListIds: Object.keys(taskListToGroup), taskListToGroup }; +} + +// ─── Internal helper: sync task_progress after a lesson read ───────────────── +// Finds TaskRequirement rows whose reference_id matches the lesson/unit/course UUID +// (only for tasks the user is assigned to) and marks them completed in task_progress. +// Returns an array of { task_id, task_name } for tasks where ALL read-only requirements +// are now satisfied — these are eligible for display as "auto turned-in" on the frontend. +async function syncTaskProgress(userId, { lessonUuid, unitUuid, courseUuid, lessonStatus, unitStatus, courseStatus }) { + if (lessonStatus !== 'completed') return []; + + const { taskListIds } = await getAccessibleTaskListIds(userId); + if (!taskListIds.length) return []; + + // Collect UUIDs to match based on what became completed + const matchUuids = [lessonUuid]; + if (unitStatus === 'completed') matchUuids.push(unitUuid); + if (courseStatus === 'completed') matchUuids.push(courseUuid); + + const requirements = await TaskRequirement.findAll({ + where: { + reference_id: { [Op.in]: matchUuids }, + type: { [Op.in]: ['read_lesson', 'read_unit', 'read_course'] }, + deletedAt: null, + }, + include: [{ + model: Task, + as: 'task', + where: { task_list_id: { [Op.in]: taskListIds }, deletedAt: null }, + required: true, + attributes: ['task_id', 'name', 'task_list_id'], + }], + attributes: ['requirement_id', 'task_id', 'type', 'reference_id'], + }); + + if (!requirements.length) return []; + + // Filter to (type, reference_id) pairs that actually became completed this call + const toComplete = requirements.filter((req) => { + if (req.type === 'read_lesson' && req.reference_id === lessonUuid) return true; + if (req.type === 'read_unit' && req.reference_id === unitUuid && unitStatus === 'completed') return true; + if (req.type === 'read_course' && req.reference_id === courseUuid && courseStatus === 'completed') return true; + return false; + }); + if (!toComplete.length) return []; + + const now = new Date(); + + // Upsert TaskProgress as completed for each matching requirement + await Promise.all(toComplete.map((req) => + TaskProgress.upsert( + { + task_id: req.task_id, + requirement_id: req.requirement_id, + user_id: userId, + reference_id: req.reference_id, + type: req.type, + completed: true, + completed_at: now, + createdBy: userId, + updatedBy: userId, + }, + { conflictFields: ['requirement_id', 'user_id', 'reference_id'] } + ) + )); + + // Check if any impacted task now has ALL its read requirements done + // (only auto-turn-in pure read tasks — tasks with upload_file/visit_link need manual submission) + const taskIds = [...new Set(toComplete.map((r) => r.task_id))]; + const completedTasks = []; + + for (const taskId of taskIds) { + const allReqs = await TaskRequirement.findAll({ + where: { task_id: taskId, deletedAt: null }, + attributes: ['requirement_id', 'type', 'reference_id'], + }); + + const hasNonReadReqs = allReqs.some((r) => !['read_course', 'read_unit', 'read_lesson'].includes(r.type)); + if (hasNonReadReqs) continue; // let the user manually submit + + const readReqs = allReqs; // all are read-type at this point + + const doneProgress = await TaskProgress.findAll({ + where: { task_id: taskId, user_id: userId, completed: true }, + attributes: ['requirement_id', 'reference_id'], + }); + const doneSet = new Set(doneProgress.map((p) => `${p.requirement_id}:${p.reference_id}`)); + const allDone = readReqs.every((r) => doneSet.has(`${r.requirement_id}:${r.reference_id}`)); + + if (allDone) { + const taskName = toComplete.find((r) => r.task_id === taskId)?.task?.name ?? ''; + completedTasks.push({ task_id: taskId, task_name: taskName }); + } + } + + return completedTasks; +} + // ============================================================================= // ── 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'], @@ -64,7 +179,6 @@ exports.getMyInProgressCourses = async (req, res) => { 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'], @@ -75,8 +189,8 @@ exports.getMyInProgressCourses = async (req, res) => { 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 courseId = row.course_id; + const readingDone = row.status === 'completed'; const [lessons_total, lessons_completed] = await Promise.all([ Lesson.count({ @@ -93,12 +207,10 @@ exports.getMyInProgressCourses = async (req, res) => { }), ]); - // 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: [{ @@ -128,7 +240,6 @@ exports.getMyInProgressCourses = async (req, res) => { } } - // Course assessment const assessment = await CourseAssessment.findOne({ attributes: ['assessment_id', 'title', 'is_required', 'passing_score'], where: { course_id: courseId }, @@ -173,11 +284,6 @@ exports.getMyInProgressCourses = async (req, res) => { // ── 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; @@ -226,10 +332,6 @@ exports.getCourseProgressSummary = async (req, res) => { // ── 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; @@ -253,6 +355,100 @@ exports.getCourseProgress = async (req, res) => { } }; +// ============================================================================= +// ── TASK CONTEXT FOR A COURSE ───────────────────────────────────────────────── +// ============================================================================= + +// GET /client/courses/:courseId/task-context +// Returns all pending task requirements (read_course / read_unit / read_lesson) +// whose reference_id matches this course, any of its units, or any of its lessons, +// filtered to tasks the current user is actually assigned to (via group membership). +// UnitList calls this on mount when no task context is passed via navigation state. + +exports.getCourseTaskContext = async (req, res) => { + try { + const { courseId } = req.params; + const userId = req.user.user_id; + + const course = await Course.findOne({ + where: { course_id: courseId, ...notDeleted }, + attributes: ['course_id', 'uuid'], + include: [{ + model: Unit, + as: 'units', + where: notDeleted, + required: false, + attributes: ['unit_id', 'uuid'], + include: [{ + model: Lesson, + as: 'lessons', + where: notDeleted, + required: false, + attributes: ['lesson_id', 'uuid'], + }], + }], + }); + if (!course) return R.error(res, 'Course not found.', 404); + + const units = course.units ?? []; + const allUuids = [ + course.uuid, + ...units.map((u) => u.uuid), + ...units.flatMap((u) => (u.lessons ?? []).map((l) => l.uuid)), + ]; + + const { taskListIds, taskListToGroup } = await getAccessibleTaskListIds(userId); + if (!taskListIds.length) { + return R.success(res, 'Task context retrieved.', { has_task: false, contexts: [] }); + } + + const requirements = await TaskRequirement.findAll({ + where: { + type: { [Op.in]: ['read_course', 'read_unit', 'read_lesson'] }, + reference_id: { [Op.in]: allUuids }, + deletedAt: null, + }, + include: [{ + model: Task, + as: 'task', + where: { task_list_id: { [Op.in]: taskListIds }, deletedAt: null }, + required: true, + attributes: ['task_id', 'name', 'task_list_id'], + }], + attributes: ['requirement_id', 'task_id', 'type', 'reference_id', 'reference_label'], + }); + + if (!requirements.length) { + return R.success(res, 'Task context retrieved.', { has_task: false, contexts: [] }); + } + + // Mark which requirements are already completed + const taskIds = [...new Set(requirements.map((r) => r.task_id))]; + const doneProgress = await TaskProgress.findAll({ + where: { task_id: { [Op.in]: taskIds }, user_id: userId, completed: true }, + attributes: ['requirement_id', 'reference_id'], + }); + const doneSet = new Set(doneProgress.map((p) => `${p.requirement_id}:${p.reference_id}`)); + + const contexts = requirements.map((req) => ({ + task_id: req.task_id, + task_name: req.task.name, + task_list_id: req.task.task_list_id, + group_id: taskListToGroup[req.task.task_list_id], + requirement_id: req.requirement_id, + type: req.type, + reference_id: req.reference_id, + reference_label: req.reference_label, + already_completed: doneSet.has(`${req.requirement_id}:${req.reference_id}`), + })); + + return R.success(res, 'Task context retrieved.', { has_task: true, contexts }); + } catch (err) { + console.error('[CLIENT][COURSE TASK CONTEXT]', err); + return R.error(res, 'Could not retrieve task context.', 500); + } +}; + // ============================================================================= // ── UPSERT LESSON PROGRESS ──────────────────────────────────────────────────── // ============================================================================= @@ -262,7 +458,10 @@ exports.getCourseProgress = async (req, res) => { // // Flow: // 1. Resolve course / unit / lesson to get their UUIDs -// 2. Delegate to upsertLessonRead — handles lesson + unit + course in one tx +// 2. Delegate to upsertLessonRead (course_reading_progress service) — lesson + unit + course in one tx +// 3. Side-effect A: write to lesson_reading_progress + unit_reading_progress (new dedicated tables) +// 4. Side-effect B: sync task_progress for matching task requirements +// 5. Return result + completed_tasks (tasks whose all read requirements are now satisfied) exports.upsertLessonProgress = async (req, res) => { try { @@ -289,6 +488,7 @@ exports.upsertLessonProgress = async (req, res) => { if (!unit) return R.error(res, 'Unit not found.', 404); if (!lesson) return R.error(res, 'Lesson not found.', 404); + // ── 1. Primary write: course_reading_progress ───────────────────────── const result = await upsertLessonRead(userId, { courseId: course.course_id, courseUuid: course.uuid, @@ -298,13 +498,31 @@ exports.upsertLessonProgress = async (req, res) => { lessonStatus: status, }); + // ── 2. Side-effect A: write to new dedicated tables (fire-and-forget) ── + upsertReadingProgress(userId, { + courseId: course.course_id, + unitId: unit.unit_id, + lessonId: lesson.lesson_id, + lessonStatus: status, + }).catch((e) => console.error('[READING PROGRESS] piggyback write failed:', e)); + + // ── 3. Side-effect B: sync task_progress ────────────────────────────── + const completedTasks = await syncTaskProgress(userId, { + lessonUuid: lesson.uuid, + unitUuid: unit.uuid, + courseUuid: course.uuid, + lessonStatus: status, + unitStatus: result.unit.status, + courseStatus: result.course.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); + return R.success(res, 'Progress updated.', { ...result, completed_tasks: completedTasks }, 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 index 78da4d5..2804851 100644 --- a/controllers/client/courses.controller.js +++ b/controllers/client/courses.controller.js @@ -16,8 +16,6 @@ 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"); @@ -28,10 +26,11 @@ const { CourseObjective, LessonObjective, CoursePrerequisite, CourseAssessment, UnitQuiz, QuizQuestion, QuizOption, QuizAttempt, - AssessmentSession, + AssessmentSession, QuizSession, } = require("../../models/courses/courses.associations"); -const { gradeSubmission } = require("../../utils/courses/grading.util"); -const { shuffleOptions, getAttemptStatus, ASSESSMENT_FAILS_BEFORE_COOLDOWN, ASSESSMENT_COOLDOWN_HOURS } = require("../../utils/courses/quiz_security.util"); +const mdl_TierCategories = require("../../models/tiers/tier_categories.mdl"); +const { gradeSubmission } = require("../../utils/courses/grading.util"); +const { shuffleOptions, shuffleQuestions, getAttemptStatus } = require("../../utils/courses/quiz_security.util"); const { onCourseCompleted } = require('../../services/achievements.service'); const PendingCertificate = require('../../models/courses/pending_certificate.mdl'); const Certificate = require('../../models/courses/certificate.mdl'); @@ -78,37 +77,33 @@ async function expireSession(session, passingScore) { return expiredAttempt; } +// Builds minimal user context: active tier slug + live tier rank map +async function buildUserContext(user_id) { + const activeTier = await getActiveTier(user_id); + const tier = activeTier?.tier ?? 'free'; + + const categories = await mdl_TierCategories.findAll({ attributes: ['slug', 'rank'] }); + const tierRankMap = {}; + for (const c of categories) tierRankMap[c.slug] = c.rank; + + return { tier, tierRankMap }; +} + // ─── Shared tier + purchase access check ───────────────────────────────────── // Returns true → user may access the course. // Returns false → user's tier is too low AND no valid individual purchase. async function canAccessCourse(user_id, course_id) { - let requiredTier = 'free'; + const course = await Course.findOne({ where: { course_id, ...notDeleted }, attributes: ['subscription'] }); + const requiredTier = course?.subscription ?? 'free'; - // Primary: explicit plan association - const planCourse = await mdl_PlanCourses.findOne({ where: { course_id } }); - if (planCourse) { - const plan = await mdl_TierPlans.findByPk(planCourse.plan_id, { attributes: ['tier'] }); - if (plan?.tier) { - requiredTier = plan.tier; - } else { - // Plan was soft-deleted or missing — fall back to course.subscription - const course = await Course.findOne({ where: { course_id, ...notDeleted }, attributes: ['subscription'] }); - requiredTier = course?.subscription ?? 'free'; - } - } else { - // Fallback: use the course's own subscription field (premium / exclusive / free) - const course = await Course.findOne({ where: { course_id, ...notDeleted }, attributes: ['subscription'] }); - requiredTier = course?.subscription ?? 'free'; - } + const userCtx = await buildUserContext(user_id); - if (requiredTier === 'free') return true; + // Rank-0 slugs (default/free tier) are always accessible — resolved dynamically + const courseRank = userCtx.tierRankMap[requiredTier] ?? Infinity; + if (courseRank === 0) return true; - const tierRank = { free: 0, premium: 1, exclusive: 2 }; - const activeTier = await getActiveTier(user_id); - const userRank = tierRank[activeTier?.tier ?? 'free'] ?? 0; - const reqRank = tierRank[requiredTier] ?? 0; - - if (userRank >= reqRank) return true; + const userRank = userCtx.tierRankMap[userCtx.tier] ?? 0; + if (userRank >= courseRank) return true; // Individual purchase as fallback const product = await mdl_Product.findOne({ where: { course_id } }); @@ -160,10 +155,8 @@ 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; + const userCtx = await buildUserContext(req.user.user_id); + const userTier = userCtx.tier; // Fetch all completed purchases for this user (for has_purchased check) const myPurchases = await mdl_CoursePurchase.findAll({ @@ -192,13 +185,6 @@ exports.getCourses = async (req, res) => { 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', @@ -211,21 +197,17 @@ exports.getCourses = async (req, res) => { order: [['order_index', 'ASC'], ['title', 'ASC']], }); + const userRank = userCtx.tierRankMap[userCtx.tier] ?? 0; + const result = courses.map((c) => { - const plain = c.toJSON(); - const planCourse = plain.planCourse; - const plan_tier = planCourse?.plan?.tier ?? null; + const plain = c.toJSON(); const has_purchased = purchasedCourseIds.has(String(plain.course_id)); + const subscription = plain.subscription ?? 'free'; + const courseRank = userCtx.tierRankMap[subscription] ?? Infinity; - const effectiveTier = plan_tier || plain.subscription || 'free'; - let is_locked = false; - if (effectiveTier && effectiveTier !== 'free') { - const reqRank = tierRank[effectiveTier] ?? 0; - if (userRank < reqRank && !has_purchased) is_locked = true; - } + const is_locked = courseRank > 0 && !has_purchased && userRank < courseRank; - delete plain.planCourse; - return { ...plain, is_locked, plan_tier: effectiveTier, has_purchased }; + return { ...plain, is_locked, has_purchased }; }); return R.success(res, "Courses retrieved.", result); @@ -331,14 +313,11 @@ exports.getCourse = async (req, res) => { where: { assessment_id: plain.assessment.assessment_id, user_id: req.user.user_id, passed: true }, }); is_completed = !!passedAttempt; + plain.assessment = { ...plain.assessment, has_passed: is_completed }; } plain.is_completed = is_completed; - const planCourse = await mdl_PlanCourses.findOne({ - where: { course_id: courseId }, - include: [{ model: mdl_TierPlans, as: 'plan', attributes: ['tier'] }], - }); - const plan_tier = planCourse?.plan?.tier ?? plain.subscription ?? null; + const plan_tier = plain.subscription ?? null; // Attach product info and purchase status for the buy-course flow const product = await mdl_Product.findOne({ @@ -472,7 +451,7 @@ exports.getUnitQuiz = async (req, res) => { where: { unit_id: unitId, ...notDeleted }, attributes: [ "quiz_id", "uuid", "title", - "is_required", "passing_score", "max_questions", + "is_required", "passing_score", "max_questions", "shuffle_questions", ], include: [{ model: QuizQuestion, as: "questions", @@ -489,7 +468,9 @@ exports.getUnitQuiz = async (req, res) => { if (!quiz) return R.error(res, "Quiz not found.", 404); const plain = quiz.toJSON(); - plain.questions = shuffleOptions(sanitizeQuestions(plain.questions ?? [])); + let qs = sanitizeQuestions(plain.questions ?? []); + if (plain.shuffle_questions) qs = shuffleQuestions(qs); + plain.questions = shuffleOptions(qs); const attempts = await QuizAttempt.findAll({ where: { quiz_id: quiz.quiz_id, user_id: req.user.user_id }, @@ -505,6 +486,12 @@ exports.getUnitQuiz = async (req, res) => { plain.window_reset_at = status.window_reset_at; plain.can_attempt = status.can_attempt; + const activeSession = await QuizSession.findOne({ + where: { quiz_id: quiz.quiz_id, user_id: req.user.user_id, status: 'in_progress' }, + attributes: ["session_id", "draft_answers", "started_at", "last_saved_at"], + }); + plain.active_session = activeSession ?? null; + return R.success(res, "Quiz retrieved.", plain); } catch (err) { console.error("[CLIENT][QUIZ][GET]", err); @@ -524,7 +511,7 @@ exports.getCourseAssessment = async (req, res) => { "assessment_id", "uuid", "title", "is_required", "passing_score", "time_limit_minutes", "max_questions", - "max_attempts", "cooldown_hours", + "max_attempts", "cooldown_hours", "shuffle_questions", ], include: [{ model: QuizQuestion, as: "questions", @@ -541,7 +528,9 @@ exports.getCourseAssessment = async (req, res) => { if (!assessment) return R.error(res, "Assessment not found.", 404); const plain = assessment.toJSON(); - plain.questions = shuffleOptions(sanitizeQuestions(plain.questions ?? [])); + let qs = sanitizeQuestions(plain.questions ?? []); + if (plain.shuffle_questions) qs = shuffleQuestions(qs); + plain.questions = shuffleOptions(qs); // All graded attempts for cooldown/status calc const attempts = await QuizAttempt.findAll({ @@ -791,6 +780,12 @@ exports.submitUnitQuiz = async (req, res) => { passed, }); + // Close any open draft session for this quiz + await QuizSession.update( + { status: 'submitted' }, + { where: { quiz_id: quiz.quiz_id, user_id, status: 'in_progress' } } + ); + return R.success(res, "Quiz submitted.", { attempt_id: attempt.attempt_id, attempt_number: attempt.attempt_number, @@ -806,6 +801,38 @@ exports.submitUnitQuiz = async (req, res) => { } }; +// ─── QUIZ DRAFT UPSERT ──────────────────────────────────────────────────────── + +exports.saveQuizDraft = async (req, res) => { + try { + const { courseId, unitId, quizId } = req.params; + const { answers = {} } = req.body; + const user_id = req.user.user_id; + + // Update all in_progress sessions for this user+quiz (handles any duplicates gracefully) + const [updatedCount] = await QuizSession.update( + { draft_answers: answers, last_saved_at: new Date() }, + { where: { quiz_id: quizId, user_id, status: 'in_progress' } } + ); + + if (updatedCount === 0) { + await QuizSession.create({ + quiz_id: quizId, + user_id, + course_id: courseId, + unit_id: unitId, + draft_answers: answers, + started_at: new Date(), + }); + } + + return res.status(204).end(); + } catch (err) { + console.error("[CLIENT][QUIZ][DRAFT]", err); + return R.error(res, "Could not save quiz draft.", 500); + } +}; + exports.submitCourseAssessment = async (req, res) => { try { const { courseId, assessmentId } = req.params; diff --git a/controllers/client/media.controller.js b/controllers/client/media.controller.js index 73e8acb..1cd6cb5 100644 --- a/controllers/client/media.controller.js +++ b/controllers/client/media.controller.js @@ -79,6 +79,11 @@ function pipeRemoteStream(remoteUrl, req, res) { const proxyHeaders = { "User-Agent": "StarrMediaProxy/1.0" }; if (req.headers.range) proxyHeaders["Range"] = req.headers.range; + // Tracks whether the client dropped the connection first. + // proxyReq.destroy() itself fires an "error" event — we silence it when + // we were the ones who triggered the teardown (client-closed case). + let clientClosed = false; + const proxyReq = transport.request(remoteUrl, { headers: proxyHeaders }, (proxyRes) => { const status = proxyRes.statusCode === 206 ? 206 : 200; @@ -101,11 +106,15 @@ function pipeRemoteStream(remoteUrl, req, res) { }); proxyReq.on("error", (err) => { + if (clientClosed) return; // browser navigated away / component unmounted — expected console.error("[CLIENT][MEDIA][PROXY] Stream error:", err.message); if (!res.headersSent) res.status(502).json({ message: "Stream unavailable." }); }); - req.on("close", () => proxyReq.destroy()); + req.on("close", () => { + clientClosed = true; + proxyReq.destroy(); + }); proxyReq.end(); } diff --git a/controllers/client/profile.controller.js b/controllers/client/profile.controller.js index d8c4f7a..28c6772 100644 --- a/controllers/client/profile.controller.js +++ b/controllers/client/profile.controller.js @@ -165,6 +165,35 @@ exports.deleteAvatar = async (req, res) => { } }; +// ─── DELETE own account ──────────────────────────────────────────────────────── + +exports.deleteAccount = async (req, res) => { + try { + const user = await mdl_Users.findByPk(req.user.user_id); + if (!user) return R.error(res, 'User not found.', 404); + + // Revoke all active sessions first + await mdl_UserSessions.update( + { is_active: false, logout_info: { date: new Date().toISOString(), ip_address: req.ip, reason: 'account_deleted' } }, + { where: { user_id: req.user.user_id, is_active: true } }, + ); + + // Anonymize email before soft-delete so the unique slot is freed for re-registration + await user.update({ email: `deleted_${req.user.user_id}@deleted.invalid`, deletedBy: req.user.user_id }); + await user.destroy(); // paranoid soft-delete — sets deleted_at + + logActivity(req.user.user_id, 'delete_account'); + + res.clearCookie('refreshToken'); + res.clearCookie('_csrf'); + + return R.success(res, 'Account deleted.'); + } catch (err) { + console.error('[CLIENT] deleteAccount error:', err); + return R.error(res, 'Could not delete account.', 500); + } +}; + // ─── GET own achievements ────────────────────────────────────────────────────── exports.getAchievements = async (req, res) => { diff --git a/controllers/client/task_progress.controller.js b/controllers/client/task_progress.controller.js index 6db721d..357120c 100644 --- a/controllers/client/task_progress.controller.js +++ b/controllers/client/task_progress.controller.js @@ -6,8 +6,10 @@ * GET /client/groups/:groupId/task-lists/:taskListId/tasks/:taskId/progress * → returns full progress snapshot: { link_visits, progress } * - * POST /client/groups/:groupId/task-lists/:taskListId/tasks/:taskId/requirements/:requirementId/visit - * → UPSERT TaskLinkVisit (visit_link) + * POST /client/groups/:groupId/task-lists/:taskListId/tasks/:taskId/requirements/:requirementId/visit + * → UPSERT TaskLinkVisit (visit_link) + * DELETE /client/groups/:groupId/task-lists/:taskListId/tasks/:taskId/requirements/:requirementId/visit + * → DELETE TaskLinkVisit (unsubmit) * * POST /client/groups/:groupId/task-lists/:taskListId/tasks/:taskId/requirements/:requirementId/progress * → UPSERT TaskProgress (read_lesson) + derives read_unit + read_course @@ -183,6 +185,41 @@ exports.visitLink = async (req, res) => { } }; +// ============================================================================= +// ── UNVISIT LINK (DELETE TaskLinkVisit) ────────────────────────────────────── +// ============================================================================= + +// DELETE /client/groups/:groupId/task-lists/:taskListId/tasks/:taskId/requirements/:requirementId/visit + +exports.unvisitLink = async (req, res) => { + const t = await sequelize.transaction(); + try { + const { groupId, taskListId, taskId, requirementId } = req.params; + + const member = await isMember(req.user.user_id, groupId); + if (!member) { await t.rollback(); return R.error(res, 'Group not found or you are not a member.', 403); } + + const requirement = await getRequirement(requirementId, taskId); + if (!requirement) { await t.rollback(); return R.error(res, 'Requirement not found.', 404); } + if (requirement.type !== 'visit_link') { + await t.rollback(); + return R.error(res, 'Requirement is not a visit_link type.', 400); + } + + await TaskLinkVisit.destroy({ + where: { requirement_id: requirementId, user_id: req.user.user_id, task_id: taskId }, + transaction: t, + }); + + await t.commit(); + return R.success(res, 'Link visit removed.', { requirement_id: requirementId }); + } catch (err) { + await t.rollback(); + console.error('[CLIENT][UNVISIT LINK]', err); + return R.error(res, 'Could not remove link visit.', 500); + } +}; + // ============================================================================= // ── UPDATE LESSON PROGRESS (UPSERT — derives unit + course) ────────────────── // ============================================================================= diff --git a/controllers/client/tiers.controller.js b/controllers/client/tiers.controller.js index 1c4ede0..4df5d0a 100644 --- a/controllers/client/tiers.controller.js +++ b/controllers/client/tiers.controller.js @@ -10,10 +10,12 @@ * Date Created: Jun. 6, 2026 * Modified: Jun. 9, 2026 ***********************************************************************************************************************************************************************/ -const mdl_TierPlans = require('../../models/tiers/tier_plans.mdl'); -const mdl_UserTiers = require('../../models/tiers/user_tiers.mdl'); -const mdl_Payments = require('../../models/tiers/payments.mdl'); -const mdl_PlanCourses = require('../../models/tiers/plan_courses.mdl'); +const mdl_TierCategories = require('../../models/tiers/tier_categories.mdl'); +const mdl_TierPlans = require('../../models/tiers/tier_plans.mdl'); +const mdl_UserTiers = require('../../models/tiers/user_tiers.mdl'); +const mdl_Payments = require('../../models/tiers/payments.mdl'); +const mdl_SystemBadges = require('../../models/system_badges/system_badges.mdl'); +const Asset = require('../../models/assets/assets.mdl'); const { onTierActivated } = require('../../services/achievements.service'); const { Course } = require('../../models/courses/courses.mdl'); const paypal = require('../../services/paypal.service'); @@ -49,9 +51,42 @@ exports.getMyTier = async (req, res) => { try { const tier = await mdl_UserTiers.findOne({ where: { user_id: req.user.user_id, status: 'active' }, + include: [{ + model: mdl_TierPlans, + as: 'plan', + required: false, + include: [{ + model: mdl_TierCategories, + as: 'category', + required: false, + include: [{ model: Asset, as: 'badgeAsset', attributes: ['asset_id', 'file_url', 'display_name'], required: false }], + }], + }], order: [['createdAt', 'DESC']], }); - return R.success(res, 'Active tier retrieved.', tier ?? { tier: 'free', status: 'active' }); + + if (!tier) { + // Free users with no user_tier row: look up free category badge + const freeCategory = await mdl_TierCategories.findOne({ + where: { slug: 'free' }, + include: [{ model: Asset, as: 'badgeAsset', attributes: ['asset_id', 'file_url', 'display_name'], required: false }], + }); + return R.success(res, 'Active tier retrieved.', { tier: 'free', status: 'active', category: freeCategory ?? null }); + } + + // Supplement with the tier category badge even when the user's tier slug doesn't come via a plan + // (e.g., manually granted tiers that only store a slug, not a plan_id) + if (!tier.plan?.category) { + const category = await mdl_TierCategories.findOne({ + where: { slug: tier.tier }, + include: [{ model: Asset, as: 'badgeAsset', attributes: ['asset_id', 'file_url', 'display_name'], required: false }], + }); + const plain = tier.toJSON(); + plain.category = category?.toJSON() ?? null; + return R.success(res, 'Active tier retrieved.', plain); + } + + return R.success(res, 'Active tier retrieved.', tier); } catch (err) { console.error('[CLIENT][GET MY TIER]', err); return R.error(res, 'Could not retrieve tier.', 500); @@ -282,25 +317,62 @@ exports.getMyPayments = async (req, res) => { } }; +// ─── SYSTEM BADGES (read-only for client profile) ──────────────────────────── + +exports.getCategories = async (req, res) => { + try { + const categories = await mdl_TierCategories.findAll({ + where: { is_active: true }, + attributes: ['tier_category_id', 'slug', 'name', 'rank', 'color', 'badge_label', 'is_default'], + include: [{ model: Asset, as: 'badgeAsset', attributes: ['file_url', 'display_name'], required: false }], + order: [['rank', 'ASC']], + }); + return R.success(res, 'Tier categories retrieved.', categories); + } catch (err) { + console.error('[CLIENT][GET TIER CATEGORIES]', err); + return R.error(res, 'Could not retrieve tier categories.', 500); + } +}; + +exports.getSystemBadges = async (req, res) => { + try { + const badges = await mdl_SystemBadges.findAll({ + attributes: ['key', 'label', 'description', 'information', 'active_from', 'active_until'], + include: [{ model: Asset, as: 'asset', attributes: ['file_url', 'display_name'], required: false }], + order: [['key', 'ASC']], + }); + return R.success(res, 'System badges retrieved.', badges); + } catch (err) { + console.error('[CLIENT][GET SYSTEM BADGES]', err); + return R.error(res, 'Could not retrieve system badges.', 500); + } +}; + // — add refundOrder export ──────────────── +const REFUND_WINDOW_MS = 5 * 60 * 1000; // 5 minutes from paid_at + exports.refundOrder = async (req, res) => { try { const user_id = req.user.user_id; - + // Get the active tier const activeTier = await mdl_UserTiers.findOne({ where: { user_id, status: 'active' }, order: [['createdAt', 'DESC']], }); if (!activeTier) return R.error(res, 'No active tier to refund.', 404); - + // Get the completed payment for this tier const payment = await mdl_Payments.findOne({ where: { user_id, tier_id: activeTier.tier_id, status: 'completed' }, order: [['paid_at', 'DESC']], }); if (!payment) return R.error(res, 'No completed payment found for this tier.', 404); + + // Enforce 5-minute refund window + if (!payment.paid_at || Date.now() - new Date(payment.paid_at).getTime() > REFUND_WINDOW_MS) + return R.error(res, 'Refund window has expired. Refunds are only available within 5 minutes of payment.', 403); // Get capture_id from provider_payload const captureId = payment.provider_payload?.capture_id; @@ -325,13 +397,28 @@ exports.refundOrder = async (req, res) => { }, }); - // Cancel tier at end of period — keep access until expires_at - await activeTier.update({ status: 'revoked' }); - - return R.success(res, 'Refund processed successfully. Your access will remain until the end of the billing period.', { - refund_id: refundData.id, - status: refundData.status, - expires_at: activeTier.expires_at, + // Immediately terminate access — cut expires_at to now and revoke + const now = new Date(); + await activeTier.update({ + status: 'revoked', + expires_at: now, + revoked_at: now, + }); + + // Drop user back to free immediately + await mdl_UserTiers.create({ + user_id, + tier: 'free', + status: 'active', + starts_at: now, + expires_at: null, + granted_by: null, + notes: 'Auto-downgrade after refund.', + }); + + return R.success(res, 'Refund processed successfully. Your access has been revoked.', { + refund_id: refundData.id, + status: refundData.status, }); } catch (err) { console.error('[CLIENT][REFUND]', err); diff --git a/cron/admin.cron.js b/cron/admin.cron.js index 4286144..494f478 100644 --- a/cron/admin.cron.js +++ b/cron/admin.cron.js @@ -21,11 +21,13 @@ * Date Created: Jun. 17, 2026 ***********************************************************************************************************************************************************************/ const cron = require('node-cron'); -const taskOverdue = require('./jobs/task_overdue.cron'); +const taskOverdue = require('./jobs/task_overdue.cron'); +const liftExpiredBans = require('./jobs/lift_expired_bans.cron'); // ─── Registry — add future admin-side cron jobs here ───────────────────────── const jobs = [ taskOverdue, + liftExpiredBans, ]; // ─── Boot all registered admin-side jobs ────────────────────────────────────── diff --git a/cron/jobs/issue_certificates.cron.js b/cron/jobs/issue_certificates.cron.js index e1b249a..ebd9bda 100644 --- a/cron/jobs/issue_certificates.cron.js +++ b/cron/jobs/issue_certificates.cron.js @@ -1,8 +1,8 @@ /*********************************************************************************************************************************************************************** * File Name : issue_certificates.cron.js * Type : Cron Job - * Description : Issues certificates for users who passed a course assessment - * 45 minutes ago. Runs every 5 minutes and processes any + * Description : Issues certificates for users who passed a course assessment. + * Runs every hour on the hour and processes any * pending_certificates row where issue_at <= NOW() and * processed_at IS NULL. * @@ -16,7 +16,7 @@ * step 2 succeed. If the process restarts mid-run the row will be * picked up again on the next tick — both DB writes are idempotent. * - * Schedule : Every hour at minute 5 ("5 * * * *"). Registered by + * Schedule : Every hour on the hour ("0 * * * *"). Registered by * cron/client.cron.js. * * Author: Kenneth Obsequio (@lash0000) @@ -30,8 +30,6 @@ const mdl_Achievements = require('../../models/users/achievements.mdl'); const UserNotification = require('../../models/notifications/user_notification.mdl'); const { NOTIFICATION_REGISTRY } = require('../../data/notifications.data'); -const DELAY_MS = 5 * 60 * 1000; // 5 minutes - async function run() { // ── 1. Fetch all rows ready to process ──────────────────────────────────── let rows; @@ -104,6 +102,6 @@ async function run() { module.exports = { name: 'issueCertificates', - schedule: '5 * * * *', + schedule: '0 * * * *', run, }; diff --git a/cron/jobs/lift_expired_bans.cron.js b/cron/jobs/lift_expired_bans.cron.js new file mode 100644 index 0000000..65c9938 --- /dev/null +++ b/cron/jobs/lift_expired_bans.cron.js @@ -0,0 +1,85 @@ +/*********************************************************************************************************************************************************************** + * File Name : lift_expired_bans.cron.js + * Type : Cron Job + * Description : Automatically lifts temporary bans whose expires_at has passed. + * Clears is_banned + ban_expires_at on the user record and marks + * the ban row as lifted with a system lift_reason. + * + * Note: The auth middleware also auto-lifts expired bans inline on + * the next login attempt, so this cron is a safety net — it keeps + * the DB state clean even if a user never logs in again. + * + * Schedule : Every hour, on the hour ("0 * * * *"). Registered by + * cron/admin.cron.js. + * + * Author: Kenneth Obsequio + * Date Created: Jun. 27, 2026 + ***********************************************************************************************************************************************************************/ +const { Op } = require('sequelize'); +const sequelize = require('../../config/db.config'); +const mdl_Users = require('../../models/users/users.mdl'); +const mdl_UserBans = require('../../models/users/user_bans.mdl'); +const { sendEmail } = require('../../services/email.service'); +const { fmtDate } = require('../../utils/datetime.util'); + +async function run() { + try { + const expiredBans = await mdl_UserBans.findAll({ + where: { + ban_type: 'temporary', + is_lifted: false, + expires_at: { [Op.lte]: new Date() }, + }, + }); + + if (!expiredBans.length) return; + + const banIds = expiredBans.map((b) => b.ban_id); + const userIds = [...new Set(expiredBans.map((b) => Number(b.user_id)))]; + const usersMap = await mdl_Users.findAll({ + where: { user_id: userIds }, + attributes: ['user_id', 'email', 'personal_info'], + }).then((rows) => Object.fromEntries(rows.map((u) => [u.user_id, u]))); + + await sequelize.transaction(async (t) => { + await mdl_UserBans.update( + { + is_lifted: true, + lifted_at: new Date(), + lift_reason: 'Automatically lifted — ban period expired.', + }, + { where: { ban_id: banIds }, transaction: t } + ); + + await mdl_Users.update( + { is_banned: false, ban_expires_at: null }, + { where: { user_id: userIds }, transaction: t } + ); + }); + + const dateStr = fmtDate(new Date()); + userIds.forEach((uid) => { + const u = usersMap[uid]; + if (!u) return; + sendEmail({ + to: u.email, + type: 'BAN_LIFTED', + data: { + name: u.personal_info?.name?.full_name ?? 'User', + email: u.email, + date: dateStr, + }, + }).catch((err) => console.error('[CRON][LIFT EXPIRED BANS] Email failed:', u.email, err)); + }); + + console.log(`[CRON][LIFT EXPIRED BANS] Lifted ${expiredBans.length} ban(s) for ${userIds.length} user(s).`); + } catch (err) { + console.error('[CRON][LIFT EXPIRED BANS] Failed:', err); + } +} + +module.exports = { + name: 'liftExpiredBans', + schedule: '0 * * * *', + run, +}; diff --git a/data/email_body.data.js b/data/email_body.data.js index d3ff1b2..75849f1 100644 --- a/data/email_body.data.js +++ b/data/email_body.data.js @@ -1,89 +1,105 @@ -const CLOSING = `Regards,\nPhilproperties IT Team\n\nThis is an automated message from STARR System. Please do not reply.`; +const FONT = 'font-family: Arial, sans-serif; font-size: 14px; color: #000;'; -const build = (body) => `${body.trim()}\n\n${CLOSING}`; +const wrap = (body) => ` + + + ${body.trim()} +

+

Regards,
Philproperties IT Team

+

This is an automated message from STARR System. Please do not reply.

+ +`.trim(); export const emailTemplates = { OTP: ({ otp, expiryMinutes = 10 }) => ({ subject: "Email OTP Verification - STARR System", - text: build(` -Dear User, - -Please use the One-Time Password (OTP) below to verify your email address. This code is valid for ${expiryMinutes} minutes. - - ${otp} - -For security reasons, please do not share this code with anyone. If you did not request this, please contact the administrator. + html: wrap(` +

Dear User,

+

Please use the One-Time Password (OTP) below to verify your email address. This code is valid for ${expiryMinutes} minutes.

+

${otp}

+

For security reasons, please do not share this code with anyone. If you did not request this, please contact the administrator.

`), }), WELCOME: ({ name }) => ({ subject: "Welcome to STARR System", - text: build(` -Dear ${name}, - -We are pleased to welcome you to the STARR System. Your account has been successfully created and is now ready for use. - -You may now access your dashboard and begin using the available services. - -We look forward to supporting you. + html: wrap(` +

Dear ${name},

+

We are pleased to welcome you to the STARR System. Your account has been successfully created and is now ready for use.

+

You may now access your dashboard and begin using the available services.

+

We look forward to supporting you.

`), }), PASSWORD_CHANGED: () => ({ subject: "Password Update Confirmation - STARR System", - text: build(` -Dear User, - -This is to confirm that your account password has been successfully changed. - -If you did not perform this action, please reset your password immediately or contact support. - -For your security, we recommend using a strong and unique password. + html: wrap(` +

Dear User,

+

This is to confirm that your account password has been successfully changed.

+

If you did not perform this action, please reset your password immediately or contact support.

+

For your security, we recommend using a strong and unique password.

`), }), ADDED_TO_GROUP: ({ groupName }) => ({ subject: "Group Assignment Notification - STARR System", - text: build(` -Dear User, - -You have been assigned to the group "${groupName}" in the STARR System. - -This assignment grants you access to shared resources and collaboration tools within the group. - -Please log in to your account to view group details. + html: wrap(` +

Dear User,

+

You have been assigned to the group ${groupName} in the STARR System.

+

This assignment grants you access to shared resources and collaboration tools within the group.

+

Please log in to your account to view group details.

`), }), TASK_ASSIGNED: ({ taskTitle, dueDate }) => ({ subject: "New Task Assignment - STARR System", - text: build(` -Dear User, + html: wrap(` +

Dear User,

+

You have been assigned a new task in the STARR System.

+ + + +
Task${taskTitle}
Due Date${dueDate}
+

Kindly ensure completion within the specified timeframe.

+ `), + }), -You have been assigned a new task in the STARR System. + BAN_LIFTED: ({ name, email, date }) => ({ + subject: "Account Suspension Lifted - STARR System", + html: wrap(` +

Dear ${name},

+

We are writing to inform you that the suspension on your account (${email}) has been lifted effective ${date}.

+

You may now log in and resume access to all services within the STARR System.

+

If you have any concerns, please do not hesitate to contact your administrator.

+ `), + }), - Task: ${taskTitle} - Due Date: ${dueDate} - -Kindly ensure completion within the specified timeframe. + BANNED: ({ name, email, date, reason, ban_type }) => ({ + subject: "Account Suspension Notice - STARR System", + html: wrap(` +

Dear ${name},

+

Your account (${email}) has been ${ban_type === 'permanent' ? 'permanently' : 'temporarily'} suspended from the STARR System effective ${date}.

+ + + +
Reason${reason}
Duration${ban_type === 'permanent' ? 'Permanent' : 'Temporary'}
+

During this period, access to all system services has been revoked.${ban_type === 'permanent' ? '' : ' This suspension may be lifted upon review by the administrator.'}

+

If you believe this was made in error, please contact your administrator.

`), }), ADD_STAFF: ({ name, email, password, expiryHours = 24 }) => ({ subject: "Your Staff Account Has Been Created - STARR System", - text: build(` -Dear ${name}, - -Your staff account has been successfully created in the STARR System. Below are your login credentials: - - Email: ${email} - Password: ${password} - -This temporary password is valid for ${expiryHours} hours. You will be required to change it upon first login. - -If you did not request this account or believe this was created in error, please contact your administrator immediately to have it deactivated. - -For security, please do not share your credentials with anyone. If you need a new password, contact your administrator. + html: wrap(` +

Dear ${name},

+

Your staff account has been successfully created in the STARR System. Below are your login credentials:

+ + + +
Email${email}
Password${password}
+

This temporary password is valid for ${expiryHours} hours. You will be required to change it upon first login.

+

If you did not request this account or believe this was created in error, please contact your administrator immediately to have it deactivated.

+

For security, please do not share your credentials with anyone. If you need a new password, contact your administrator.

`), }), }; diff --git a/data/notifications.data.js b/data/notifications.data.js index 6e078d8..08023c4 100644 --- a/data/notifications.data.js +++ b/data/notifications.data.js @@ -19,15 +19,18 @@ * No other changes needed. * * Current types: - * Admin : task_overdue + * Admin : task_overdue, user_registration, nogrp_user_registered * User : user_task_overdue, achievement, course_unlocked, - * course_completed, certificate_issued, task_reminder, announcement + * course_completed, certificate_issued, task_reminder, announcement, + * nogrp_welcome * * Author: Kenneth Obsequio (@lash0000) * Date Created: Jun. 19, 2026 ***********************************************************************************************************************************************************************/ 'use strict'; +const { fmtDate } = require('../utils/datetime.util'); + const NOTIFICATION_REGISTRY = { // ───────────────────────────────────────────────────────────────────────── @@ -64,6 +67,21 @@ const NOTIFICATION_REGISTRY = { }, }, + // ── Unaffiliated User Registration ─────────────────────────────────────── + nogrp_user_registered: { + type: 'nogrp_user_registered', + scope: 'admin', + trigger: 'event', + build({ userEmail, regType }) { + return { + type: 'nogrp_user_registered', + title: 'New Unaffiliated User', + message: `A new user (${userEmail}) registered via ${regType} without a group code and was placed in the default group.`, + data: { userEmail, regType }, + }; + }, + }, + // ───────────────────────────────────────────────────────────────────────── // USER notifications (scope: 'user') // ───────────────────────────────────────────────────────────────────────── @@ -92,7 +110,7 @@ const NOTIFICATION_REGISTRY = { return { type: 'task', title: 'Task Deadline Approaching', - message: `"${taskName}" is due on ${new Date(deadline).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })}.`, + message: `"${taskName}" is due on ${fmtDate(deadline)}.`, data: { taskName, deadline }, }; }, @@ -136,7 +154,7 @@ const NOTIFICATION_REGISTRY = { return { type: 'course', title: 'Course Completed', - message: `Great job! You've completed "${courseTitle}". Your certificate is being prepared and will be ready in about 5 minutes.`, + message: `Great job! You've completed "${courseTitle}". Your certificate will be issued within the next hour.`, data: { courseTitle }, }; }, @@ -176,6 +194,21 @@ const NOTIFICATION_REGISTRY = { }, }, + // ── No-Group Welcome ────────────────────────────────────────────────────── + nogrp_welcome: { + type: 'announcement', + scope: 'user', + trigger: 'event', + build() { + return { + type: 'announcement', + title: "You're Not in a Group Yet", + message: 'You are currently in the default group. Contact an administrator to be assigned to your team.', + data: { groupCode: 'NOGRP' }, + }; + }, + }, + // ── Assessment ──────────────────────────────────────────────────────────── assessment_updated: { type: 'assessment', diff --git a/database/migrations/20260101000056-create-unit-reading-progress.js b/database/migrations/20260101000056-create-unit-reading-progress.js new file mode 100644 index 0000000..1556925 --- /dev/null +++ b/database/migrations/20260101000056-create-unit-reading-progress.js @@ -0,0 +1,34 @@ +'use strict'; + +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.createTable('unit_reading_progress', { + progress_id: { type: Sequelize.UUID, defaultValue: Sequelize.UUIDV4, primaryKey: true }, + user_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'users', key: 'user_id' }, onDelete: 'CASCADE' }, + course_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'courses', key: 'course_id' }, onDelete: 'CASCADE' }, + unit_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'units', key: 'unit_id' }, onDelete: 'CASCADE' }, + status: { type: Sequelize.ENUM('in_progress', 'completed'), allowNull: false, defaultValue: 'in_progress' }, + completed_at: { type: Sequelize.DATE, allowNull: true }, + last_accessed_at: { type: Sequelize.DATE, allowNull: false, defaultValue: Sequelize.literal('CURRENT_TIMESTAMP') }, + createdBy: { type: Sequelize.BIGINT, allowNull: true }, + updatedBy: { type: Sequelize.BIGINT, allowNull: true }, + createdAt: { type: Sequelize.DATE, allowNull: false }, + updatedAt: { type: Sequelize.DATE, allowNull: false }, + }); + + await queryInterface.addIndex('unit_reading_progress', { + fields: ['user_id', 'unit_id'], + unique: true, + name: 'uq_urp_user_unit', + }); + + await queryInterface.addIndex('unit_reading_progress', { fields: ['user_id'], name: 'idx_urp_user_id' }); + await queryInterface.addIndex('unit_reading_progress', { fields: ['course_id'], name: 'idx_urp_course_id' }); + await queryInterface.addIndex('unit_reading_progress', { fields: ['unit_id'], name: 'idx_urp_unit_id' }); + await queryInterface.addIndex('unit_reading_progress', { fields: ['status'], name: 'idx_urp_status' }); + }, + + async down(queryInterface) { + await queryInterface.dropTable('unit_reading_progress'); + }, +}; diff --git a/database/migrations/20260101000057-create-lesson-reading-progress.js b/database/migrations/20260101000057-create-lesson-reading-progress.js new file mode 100644 index 0000000..f04f6d8 --- /dev/null +++ b/database/migrations/20260101000057-create-lesson-reading-progress.js @@ -0,0 +1,36 @@ +'use strict'; + +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.createTable('lesson_reading_progress', { + progress_id: { type: Sequelize.UUID, defaultValue: Sequelize.UUIDV4, primaryKey: true }, + user_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'users', key: 'user_id' }, onDelete: 'CASCADE' }, + course_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'courses', key: 'course_id' }, onDelete: 'CASCADE' }, + unit_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'units', key: 'unit_id' }, onDelete: 'CASCADE' }, + lesson_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'lessons', key: 'lesson_id' }, onDelete: 'CASCADE' }, + status: { type: Sequelize.ENUM('in_progress', 'completed'), allowNull: false, defaultValue: 'in_progress' }, + completed_at: { type: Sequelize.DATE, allowNull: true }, + last_accessed_at: { type: Sequelize.DATE, allowNull: false, defaultValue: Sequelize.literal('CURRENT_TIMESTAMP') }, + createdBy: { type: Sequelize.BIGINT, allowNull: true }, + updatedBy: { type: Sequelize.BIGINT, allowNull: true }, + createdAt: { type: Sequelize.DATE, allowNull: false }, + updatedAt: { type: Sequelize.DATE, allowNull: false }, + }); + + await queryInterface.addIndex('lesson_reading_progress', { + fields: ['user_id', 'lesson_id'], + unique: true, + name: 'uq_lrp_user_lesson', + }); + + await queryInterface.addIndex('lesson_reading_progress', { fields: ['user_id'], name: 'idx_lrp_user_id' }); + await queryInterface.addIndex('lesson_reading_progress', { fields: ['course_id'], name: 'idx_lrp_course_id' }); + await queryInterface.addIndex('lesson_reading_progress', { fields: ['unit_id'], name: 'idx_lrp_unit_id' }); + await queryInterface.addIndex('lesson_reading_progress', { fields: ['lesson_id'], name: 'idx_lrp_lesson_id' }); + await queryInterface.addIndex('lesson_reading_progress', { fields: ['status'], name: 'idx_lrp_status' }); + }, + + async down(queryInterface) { + await queryInterface.dropTable('lesson_reading_progress'); + }, +}; diff --git a/database/migrations/20260101000058-add-exclusive-to-course-subscription.js b/database/migrations/20260101000058-add-exclusive-to-course-subscription.js new file mode 100644 index 0000000..65b792b --- /dev/null +++ b/database/migrations/20260101000058-add-exclusive-to-course-subscription.js @@ -0,0 +1,26 @@ +'use strict'; + +// CockroachDB stores ENUM columns as VARCHAR + check constraint. +// The original check_subscription constraint only allows 'free' | 'premium'. +// This migration drops and recreates it to include 'exclusive'. +module.exports = { + async up(queryInterface) { + await queryInterface.sequelize.query( + `ALTER TABLE courses DROP CONSTRAINT IF EXISTS check_subscription` + ); + await queryInterface.sequelize.query( + `ALTER TABLE courses ADD CONSTRAINT check_subscription + CHECK (subscription IN ('free', 'premium', 'exclusive'))` + ); + }, + + async down(queryInterface) { + await queryInterface.sequelize.query( + `ALTER TABLE courses DROP CONSTRAINT IF EXISTS check_subscription` + ); + await queryInterface.sequelize.query( + `ALTER TABLE courses ADD CONSTRAINT check_subscription + CHECK (subscription IN ('free', 'premium'))` + ); + }, +}; diff --git a/database/migrations/20260101000059-add-plan-id-to-user-tiers.js b/database/migrations/20260101000059-add-plan-id-to-user-tiers.js new file mode 100644 index 0000000..22843cb --- /dev/null +++ b/database/migrations/20260101000059-add-plan-id-to-user-tiers.js @@ -0,0 +1,17 @@ +'use strict'; + +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.addColumn('user_tiers', 'plan_id', { + type: Sequelize.BIGINT, + allowNull: true, + references: { model: 'tier_plans', key: 'plan_id' }, + onUpdate: 'CASCADE', + onDelete: 'SET NULL', + }); + }, + + async down(queryInterface) { + await queryInterface.removeColumn('user_tiers', 'plan_id'); + }, +}; diff --git a/database/migrations/20260101000060-create-plan-policies.js b/database/migrations/20260101000060-create-plan-policies.js new file mode 100644 index 0000000..cb48c2d --- /dev/null +++ b/database/migrations/20260101000060-create-plan-policies.js @@ -0,0 +1,25 @@ +'use strict'; + +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.createTable('plan_policies', { + policy_id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true }, + plan_id: { type: Sequelize.BIGINT, allowNull: false, unique: true, + references: { model: 'tier_plans', key: 'plan_id' }, + onUpdate: 'CASCADE', onDelete: 'CASCADE' }, + badge_asset_id: { type: Sequelize.BIGINT, allowNull: true, + references: { model: 'assets', key: 'asset_id' }, + onUpdate: 'CASCADE', onDelete: 'SET NULL' }, + badge_label: { type: Sequelize.STRING(100), allowNull: false, defaultValue: '' }, + badge_description: { type: Sequelize.TEXT, allowNull: true }, + badge_information: { type: Sequelize.TEXT, allowNull: true }, + access_rules: { type: Sequelize.JSONB, allowNull: false, defaultValue: [] }, + createdAt: { type: Sequelize.DATE, allowNull: false }, + updatedAt: { type: Sequelize.DATE, allowNull: false }, + }); + }, + + async down(queryInterface) { + await queryInterface.dropTable('plan_policies'); + }, +}; diff --git a/database/migrations/20260101000061-create-system-badges.js b/database/migrations/20260101000061-create-system-badges.js new file mode 100644 index 0000000..f3d5b9a --- /dev/null +++ b/database/migrations/20260101000061-create-system-badges.js @@ -0,0 +1,24 @@ +'use strict'; + +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.createTable('system_badges', { + badge_id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true }, + key: { type: Sequelize.STRING(50), allowNull: false, unique: true }, + asset_id: { type: Sequelize.BIGINT, allowNull: true, + references: { model: 'assets', key: 'asset_id' }, + onUpdate: 'CASCADE', onDelete: 'SET NULL' }, + label: { type: Sequelize.STRING(100), allowNull: false }, + description: { type: Sequelize.TEXT, allowNull: true }, + information: { type: Sequelize.TEXT, allowNull: true }, + active_from: { type: Sequelize.DATEONLY, allowNull: true }, + active_until: { type: Sequelize.DATEONLY, allowNull: true }, + createdAt: { type: Sequelize.DATE, allowNull: false }, + updatedAt: { type: Sequelize.DATE, allowNull: false }, + }); + }, + + async down(queryInterface) { + await queryInterface.dropTable('system_badges'); + }, +}; diff --git a/database/migrations/20260101000062-create-tier-categories.js b/database/migrations/20260101000062-create-tier-categories.js new file mode 100644 index 0000000..7804102 --- /dev/null +++ b/database/migrations/20260101000062-create-tier-categories.js @@ -0,0 +1,36 @@ +'use strict'; + +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.createTable('tier_categories', { + tier_category_id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true }, + slug: { type: Sequelize.STRING(50), allowNull: false, unique: true }, + name: { type: Sequelize.STRING(100), allowNull: false }, + description: { type: Sequelize.TEXT, allowNull: true }, + rank: { type: Sequelize.INTEGER, allowNull: false, defaultValue: 0 }, + badge_asset_id: { + type: Sequelize.BIGINT, allowNull: true, + references: { model: 'assets', key: 'asset_id' }, + onUpdate: 'CASCADE', onDelete: 'SET NULL', + }, + badge_label: { type: Sequelize.STRING(100), allowNull: true }, + is_default: { type: Sequelize.BOOLEAN, allowNull: false, defaultValue: false }, + is_active: { type: Sequelize.BOOLEAN, allowNull: false, defaultValue: true }, + createdAt: { type: Sequelize.DATE, allowNull: false }, + updatedAt: { type: Sequelize.DATE, allowNull: false }, + }); + + // Seed the three baseline tier categories + await queryInterface.sequelize.query(` + INSERT INTO tier_categories (slug, name, rank, is_default, is_active, "createdAt", "updatedAt") + VALUES + ('free', 'Free', 0, true, true, NOW(), NOW()), + ('premium', 'Premium', 1, false, true, NOW(), NOW()), + ('exclusive', 'Exclusive', 2, false, true, NOW(), NOW()) + `); + }, + + async down(queryInterface) { + await queryInterface.dropTable('tier_categories'); + }, +}; diff --git a/database/migrations/20260101000063-add-tier-category-to-tier-plans.js b/database/migrations/20260101000063-add-tier-category-to-tier-plans.js new file mode 100644 index 0000000..5bd26a1 --- /dev/null +++ b/database/migrations/20260101000063-add-tier-category-to-tier-plans.js @@ -0,0 +1,39 @@ +'use strict'; + +module.exports = { + async up(queryInterface, Sequelize) { + // Add the FK column + await queryInterface.addColumn('tier_plans', 'tier_category_id', { + type: Sequelize.BIGINT, + allowNull: true, + references: { model: 'tier_categories', key: 'tier_category_id' }, + onUpdate: 'CASCADE', + onDelete: 'SET NULL', + }); + + // Populate from the existing tier slug → tier_categories row + await queryInterface.sequelize.query(` + UPDATE tier_plans tp + SET tier_category_id = tc.tier_category_id + FROM tier_categories tc + WHERE tc.slug = tp.tier + `); + + // CockroachDB stores Sequelize ENUMs as VARCHAR + CHECK constraint. + // Drop the constraint so `tier` becomes a free-form slug that can hold + // any value matching a tier_categories.slug (including admin-created ones). + await queryInterface.sequelize.query( + `ALTER TABLE tier_plans DROP CONSTRAINT IF EXISTS check_tier` + ); + await queryInterface.sequelize.query( + `ALTER TABLE tier_plans DROP CONSTRAINT IF EXISTS "tier_plans_tier_check"` + ); + }, + + async down(queryInterface) { + await queryInterface.removeColumn('tier_plans', 'tier_category_id'); + await queryInterface.sequelize.query( + `ALTER TABLE tier_plans ADD CONSTRAINT check_tier CHECK (tier IN ('premium', 'exclusive'))` + ); + }, +}; diff --git a/database/migrations/20260101000064-remove-badge-from-plan-policies.js b/database/migrations/20260101000064-remove-badge-from-plan-policies.js new file mode 100644 index 0000000..c349c9a --- /dev/null +++ b/database/migrations/20260101000064-remove-badge-from-plan-policies.js @@ -0,0 +1,20 @@ +'use strict'; + +// Badge config moves to tier_categories — plan_policies now only holds access_rules. + +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.sequelize.query(`SET sql_safe_updates = false`); + await queryInterface.sequelize.query(`ALTER TABLE plan_policies DROP COLUMN IF EXISTS badge_asset_id`); + await queryInterface.sequelize.query(`ALTER TABLE plan_policies DROP COLUMN IF EXISTS badge_label`); + await queryInterface.sequelize.query(`ALTER TABLE plan_policies DROP COLUMN IF EXISTS badge_description`); + await queryInterface.sequelize.query(`ALTER TABLE plan_policies DROP COLUMN IF EXISTS badge_information`); + }, + + async down(queryInterface, Sequelize) { + await queryInterface.addColumn('plan_policies', 'badge_asset_id', { type: Sequelize.BIGINT, allowNull: true }); + await queryInterface.addColumn('plan_policies', 'badge_label', { type: Sequelize.STRING(100), allowNull: false, defaultValue: '' }); + await queryInterface.addColumn('plan_policies', 'badge_description', { type: Sequelize.TEXT, allowNull: true }); + await queryInterface.addColumn('plan_policies', 'badge_information', { type: Sequelize.TEXT, allowNull: true }); + }, +}; diff --git a/database/migrations/20260101000065-drop-tier-enum-check-user-tiers.js b/database/migrations/20260101000065-drop-tier-enum-check-user-tiers.js new file mode 100644 index 0000000..846b484 --- /dev/null +++ b/database/migrations/20260101000065-drop-tier-enum-check-user-tiers.js @@ -0,0 +1,21 @@ +'use strict'; + +// user_tiers.tier was ENUM('free','premium','exclusive'). +// Drop the check constraint so it can hold any tier_categories.slug value. + +module.exports = { + async up(queryInterface) { + await queryInterface.sequelize.query( + `ALTER TABLE user_tiers DROP CONSTRAINT IF EXISTS check_tier` + ); + await queryInterface.sequelize.query( + `ALTER TABLE user_tiers DROP CONSTRAINT IF EXISTS "user_tiers_tier_check"` + ); + }, + + async down(queryInterface) { + await queryInterface.sequelize.query( + `ALTER TABLE user_tiers ADD CONSTRAINT check_tier CHECK (tier IN ('free', 'premium', 'exclusive'))` + ); + }, +}; diff --git a/middleware/auth.middleware.js b/middleware/auth.middleware.js index 6bb7d43..e669f84 100644 --- a/middleware/auth.middleware.js +++ b/middleware/auth.middleware.js @@ -33,9 +33,21 @@ const authenticate = async (req, res, next) => { attributes: { exclude: ['password', 'otp_code', 'otp_expires_at'] }, }); - if (!user) return R.error(res, 'User not found.', 401); + if (!user) return R.error(res, 'User not found.', 401); if (!user.is_active) return R.error(res, 'Account is deactivated.', 403); + if (user.is_banned) { + const stillBanned = !user.ban_expires_at || new Date() < new Date(user.ban_expires_at); + if (stillBanned) { + return R.error(res, 'Your account has been suspended.', 403, { + banned: true, + ban_expires_at: user.ban_expires_at ?? null, + }); + } + // Expired temporary ban — auto-lift so the user can log in again + await user.update({ is_banned: false, ban_expires_at: null }); + } + req.user = user; next(); } catch (err) { @@ -74,7 +86,21 @@ const softAuthenticate = async (req, res, next) => { attributes: { exclude: ['password', 'otp_code', 'otp_expires_at'] }, }); - req.user = (user && user.is_active) ? user : null; + if (!user || !user.is_active) { + req.user = null; + return next(); + } + + if (user.is_banned) { + const stillBanned = !user.ban_expires_at || new Date() < new Date(user.ban_expires_at); + if (stillBanned) { + req.user = null; + return next(); + } + await user.update({ is_banned: false, ban_expires_at: null }); + } + + req.user = user; next(); } catch (err) { if (err.name === 'TokenExpiredError') diff --git a/middleware/badge_upload.middleware.js b/middleware/badge_upload.middleware.js new file mode 100644 index 0000000..2c96c4f --- /dev/null +++ b/middleware/badge_upload.middleware.js @@ -0,0 +1,32 @@ +'use strict'; + +const multer = require('multer'); + +const ALLOWED_MIME = new Set([ + 'image/jpeg', 'image/png', 'image/webp', 'image/gif', 'image/svg+xml', +]); + +const upload = multer({ + storage: multer.memoryStorage(), + limits: { fileSize: 5 * 1024 * 1024 }, // 5 MB + fileFilter(req, file, cb) { + if (ALLOWED_MIME.has(file.mimetype)) return cb(null, true); + cb(Object.assign(new Error('Only JPEG, PNG, WebP, GIF, or SVG images are allowed.'), { code: 'INVALID_TYPE' })); + }, +}); + +const badgeSingle = upload.single('badge'); + +const handleBadgeUpload = (req, res, next) => { + badgeSingle(req, res, (err) => { + if (!err) return next(); + if (err.code === 'LIMIT_FILE_SIZE') + return res.status(400).json({ status: 'error', message: 'Badge image must be under 5 MB.' }); + if (err.code === 'INVALID_TYPE') + return res.status(400).json({ status: 'error', message: err.message }); + console.error('[MULTER BADGE]', err); + return res.status(400).json({ status: 'error', message: err.message ?? 'Upload failed.' }); + }); +}; + +module.exports = { handleBadgeUpload }; diff --git a/models/courses/course_assessment.mdl.js b/models/courses/course_assessment.mdl.js index 5f73035..9391923 100644 --- a/models/courses/course_assessment.mdl.js +++ b/models/courses/course_assessment.mdl.js @@ -10,8 +10,9 @@ const CourseAssessment = sequelize.define("CourseAssessment", { passing_score: { type: DataTypes.INTEGER, defaultValue: 70 }, time_limit_minutes: { type: DataTypes.INTEGER, allowNull: true }, // null = no limit max_questions: { type: DataTypes.INTEGER, allowNull: true }, // null = show all - max_attempts: { type: DataTypes.INTEGER, defaultValue: 3 }, // failed attempts before cooldown - cooldown_hours: { type: DataTypes.INTEGER, defaultValue: 24 }, // hours locked after hitting max_attempts + max_attempts: { type: DataTypes.INTEGER, defaultValue: 3 }, // failed attempts before cooldown + cooldown_hours: { type: DataTypes.INTEGER, defaultValue: 24 }, // hours locked after hitting max_attempts + shuffle_questions: { type: DataTypes.BOOLEAN, defaultValue: false }, createdBy: { type: DataTypes.BIGINT, allowNull: true }, updatedBy: { type: DataTypes.BIGINT, allowNull: true }, deletedBy: { type: DataTypes.BIGINT, allowNull: true }, diff --git a/models/courses/courses.associations.js b/models/courses/courses.associations.js index bffc2b1..10d3d45 100644 --- a/models/courses/courses.associations.js +++ b/models/courses/courses.associations.js @@ -14,10 +14,13 @@ const QuizOption = require("./quiz_option.mdl"); const mdl_Users = require("../users/users.mdl"); const QuizAttempt = require("./quiz_attempt.mdl"); const AssessmentSession = require("./assessment_session.mdl"); +const QuizSession = require("./quiz_session.mdl"); const mdl_Category = require("./categories.mdl"); const Certificate = require("./certificate.mdl"); const CourseInstructor = require("./course_instructor.mdl"); const CourseReadingProgress = require("./course_reading_progress.mdl"); +const UnitReadingProgress = require("./unit_reading_progress.mdl"); +const LessonReadingProgress = require("./lesson_reading_progress.mdl"); // ── CourseReadingProgress ───────────────────────────────────────────────────── CourseReadingProgress.belongsTo(mdl_Users, { foreignKey: 'user_id', as: 'user' }); @@ -25,6 +28,21 @@ CourseReadingProgress.belongsTo(Course, { foreignKey: 'course_id', as: 'cours mdl_Users.hasMany(CourseReadingProgress, { foreignKey: 'user_id', as: 'courseReadingProgress' }); Course.hasMany(CourseReadingProgress, { foreignKey: 'course_id', as: 'readingProgress' }); +// ── UnitReadingProgress ─────────────────────────────────────────────────────── +UnitReadingProgress.belongsTo(mdl_Users, { foreignKey: 'user_id', as: 'user' }); +UnitReadingProgress.belongsTo(Course, { foreignKey: 'course_id', as: 'course' }); +UnitReadingProgress.belongsTo(Unit, { foreignKey: 'unit_id', as: 'unit' }); +mdl_Users.hasMany(UnitReadingProgress, { foreignKey: 'user_id', as: 'unitReadingProgress' }); +Unit.hasMany(UnitReadingProgress, { foreignKey: 'unit_id', as: 'readingProgress' }); + +// ── LessonReadingProgress ───────────────────────────────────────────────────── +LessonReadingProgress.belongsTo(mdl_Users, { foreignKey: 'user_id', as: 'user' }); +LessonReadingProgress.belongsTo(Course, { foreignKey: 'course_id', as: 'course' }); +LessonReadingProgress.belongsTo(Unit, { foreignKey: 'unit_id', as: 'unit' }); +LessonReadingProgress.belongsTo(Lesson, { foreignKey: 'lesson_id', as: 'lesson' }); +mdl_Users.hasMany(LessonReadingProgress, { foreignKey: 'user_id', as: 'lessonReadingProgress' }); +Lesson.hasMany(LessonReadingProgress, { foreignKey: 'lesson_id', as: 'readingProgress' }); + // ── Course ──────────────────────────────────────────────────────────────────── Course.belongsTo(mdl_Users, { as: "creator", foreignKey: "createdBy" }); Course.belongsTo(mdl_Users, { as: "updater", foreignKey: "updatedBy" }); @@ -85,13 +103,18 @@ AssessmentSession.belongsTo(CourseAssessment, { as: "assessment", foreignKey: "a AssessmentSession.belongsTo(mdl_Users, { as: "user", foreignKey: "user_id" }); CourseAssessment.hasMany(AssessmentSession, { as: "sessions", foreignKey: "assessment_id" }); +// ── QuizSession ─────────────────────────────────────────────────────────────── +QuizSession.belongsTo(UnitQuiz, { as: "quiz", foreignKey: "quiz_id" }); +QuizSession.belongsTo(mdl_Users, { as: "user", foreignKey: "user_id" }); +UnitQuiz.hasMany(QuizSession, { as: "sessions", foreignKey: "quiz_id" }); + module.exports = { Course, CourseProductCategory, Unit, Lesson, LessonPage, CourseObjective, LessonObjective, CoursePrerequisite, CourseAssessment, UnitQuiz, QuizQuestion, QuizOption, QuizAttempt, - AssessmentSession, + AssessmentSession, QuizSession, mdl_Category, Certificate, CourseInstructor, - CourseReadingProgress, + CourseReadingProgress, UnitReadingProgress, LessonReadingProgress, }; \ No newline at end of file diff --git a/models/courses/courses.mdl.js b/models/courses/courses.mdl.js index 43fa40d..6cc9579 100644 --- a/models/courses/courses.mdl.js +++ b/models/courses/courses.mdl.js @@ -9,7 +9,7 @@ const Course = sequelize.define("Course", { course_code: { type: DataTypes.STRING(50), allowNull: true, unique: true, hidden: false, order: 1, filterable: true }, order_index: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0, hidden: false, order: 7, filterable: false }, level: { type: DataTypes.ENUM("beginner", "intermediate", "advanced"), allowNull: true, hidden: false, order: 3, filterable: true }, - subscription: { type: DataTypes.ENUM("free", "premium"), allowNull: false, defaultValue: "free", hidden: false, order: 4, filterable: true }, + subscription: { type: DataTypes.STRING(50), allowNull: false, defaultValue: "free", hidden: false, order: 4, filterable: true }, duration_seconds: { type: DataTypes.INTEGER, allowNull: true, defaultValue: 0, hidden: false, order: 8, filterable: false }, createdBy: { type: DataTypes.BIGINT, allowNull: true }, updatedBy: { type: DataTypes.BIGINT, allowNull: true }, diff --git a/models/courses/lesson_reading_progress.mdl.js b/models/courses/lesson_reading_progress.mdl.js new file mode 100644 index 0000000..3174985 --- /dev/null +++ b/models/courses/lesson_reading_progress.mdl.js @@ -0,0 +1,85 @@ +/*********************************************************************************************************************************************************************** + * File Name: lesson_reading_progress.mdl.js + * Type of Program: Model + * Description: Tracks a user's reading progress at the lesson level. + * + * LessonReadingProgress — one row per (user, lesson). + * UPSERT key: (user_id, lesson_id) + * status is set directly by the caller ('in_progress' | 'completed'). + * last_accessed_at updated on every UPSERT. + * + * Author: Kenneth Obsequio (@lash0000) + * Date Created: Jun. 26, 2026 + ***********************************************************************************************************************************************************************/ +const { DataTypes } = require('sequelize'); +const sequelize = require('../../config/db.config'); + +const LessonReadingProgress = sequelize.define('LessonReadingProgress', { + progress_id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + user_id: { + type: DataTypes.BIGINT, + allowNull: false, + references: { model: 'users', key: 'user_id' }, + onDelete: 'CASCADE', + }, + course_id: { + type: DataTypes.BIGINT, + allowNull: false, + references: { model: 'courses', key: 'course_id' }, + onDelete: 'CASCADE', + }, + unit_id: { + type: DataTypes.BIGINT, + allowNull: false, + references: { model: 'units', key: 'unit_id' }, + onDelete: 'CASCADE', + }, + lesson_id: { + type: DataTypes.BIGINT, + allowNull: false, + references: { model: 'lessons', key: 'lesson_id' }, + onDelete: 'CASCADE', + }, + status: { + type: DataTypes.ENUM('in_progress', 'completed'), + allowNull: false, + defaultValue: 'in_progress', + }, + completed_at: { + type: DataTypes.DATE, + allowNull: true, + comment: 'Set when status flips to completed. Null while in_progress.', + }, + last_accessed_at: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW, + comment: 'Updated on every UPSERT.', + }, + + // ── Audit trails ──────────────────────────────────────────────────────── + createdBy: { type: DataTypes.BIGINT, allowNull: true }, + updatedBy: { type: DataTypes.BIGINT, allowNull: true }, +}, { + tableName: 'lesson_reading_progress', + timestamps: true, + paranoid: false, + indexes: [ + { + unique: true, + fields: ['user_id', 'lesson_id'], + name: 'uq_lrp_user_lesson', + }, + { fields: ['user_id'], name: 'idx_lrp_user_id' }, + { fields: ['course_id'], name: 'idx_lrp_course_id' }, + { fields: ['unit_id'], name: 'idx_lrp_unit_id' }, + { fields: ['lesson_id'], name: 'idx_lrp_lesson_id' }, + { fields: ['status'], name: 'idx_lrp_status' }, + ], +}); + +module.exports = LessonReadingProgress; diff --git a/models/courses/quiz_session.mdl.js b/models/courses/quiz_session.mdl.js new file mode 100644 index 0000000..54dfee6 --- /dev/null +++ b/models/courses/quiz_session.mdl.js @@ -0,0 +1,20 @@ +const { DataTypes } = require("sequelize"); +const sequelize = require("../../config/db.config"); + +const QuizSession = sequelize.define("QuizSession", { + session_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true }, + uuid: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, unique: true }, + user_id: { type: DataTypes.BIGINT, allowNull: false }, + quiz_id: { type: DataTypes.BIGINT, allowNull: false }, + course_id: { type: DataTypes.BIGINT, allowNull: true }, + unit_id: { type: DataTypes.BIGINT, allowNull: true }, + status: { type: DataTypes.STRING(20), allowNull: false, defaultValue: 'in_progress' }, // 'in_progress' | 'submitted' + draft_answers: { type: DataTypes.JSONB, allowNull: true, defaultValue: null }, + started_at: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW }, + last_saved_at: { type: DataTypes.DATE, allowNull: true, defaultValue: null }, +}, { + tableName: "quiz_sessions", + timestamps: true, +}); + +module.exports = QuizSession; diff --git a/models/courses/unit_quiz.mdl.js b/models/courses/unit_quiz.mdl.js index 315fb61..74d6e81 100644 --- a/models/courses/unit_quiz.mdl.js +++ b/models/courses/unit_quiz.mdl.js @@ -6,9 +6,10 @@ const UnitQuiz = sequelize.define("UnitQuiz", { uuid: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, unique: true }, unit_id: { type: DataTypes.BIGINT, allowNull: false, unique: true }, // one quiz per unit title: { type: DataTypes.STRING(255), allowNull: true }, - is_required: { type: DataTypes.BOOLEAN, defaultValue: false }, - passing_score: { type: DataTypes.INTEGER, defaultValue: 70 }, // percentage - max_questions: { type: DataTypes.INTEGER, allowNull: true }, // null = show all + is_required: { type: DataTypes.BOOLEAN, defaultValue: false }, + passing_score: { type: DataTypes.INTEGER, defaultValue: 70 }, // percentage + max_questions: { type: DataTypes.INTEGER, allowNull: true }, // null = show all + shuffle_questions: { type: DataTypes.BOOLEAN, defaultValue: false }, createdBy: { type: DataTypes.BIGINT, allowNull: true }, updatedBy: { type: DataTypes.BIGINT, allowNull: true }, deletedBy: { type: DataTypes.BIGINT, allowNull: true }, diff --git a/models/courses/unit_reading_progress.mdl.js b/models/courses/unit_reading_progress.mdl.js new file mode 100644 index 0000000..ac39e27 --- /dev/null +++ b/models/courses/unit_reading_progress.mdl.js @@ -0,0 +1,78 @@ +/*********************************************************************************************************************************************************************** + * File Name: unit_reading_progress.mdl.js + * Type of Program: Model + * Description: Tracks a user's reading progress at the unit level. + * + * UnitReadingProgress — one row per (user, unit). + * UPSERT key: (user_id, unit_id) + * status flips in_progress → completed when all sibling lessons are completed. + * last_accessed_at updated on every UPSERT. + * + * Author: Kenneth Obsequio (@lash0000) + * Date Created: Jun. 26, 2026 + ***********************************************************************************************************************************************************************/ +const { DataTypes } = require('sequelize'); +const sequelize = require('../../config/db.config'); + +const UnitReadingProgress = sequelize.define('UnitReadingProgress', { + progress_id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + user_id: { + type: DataTypes.BIGINT, + allowNull: false, + references: { model: 'users', key: 'user_id' }, + onDelete: 'CASCADE', + }, + course_id: { + type: DataTypes.BIGINT, + allowNull: false, + references: { model: 'courses', key: 'course_id' }, + onDelete: 'CASCADE', + }, + unit_id: { + type: DataTypes.BIGINT, + allowNull: false, + references: { model: 'units', key: 'unit_id' }, + onDelete: 'CASCADE', + }, + status: { + type: DataTypes.ENUM('in_progress', 'completed'), + allowNull: false, + defaultValue: 'in_progress', + }, + completed_at: { + type: DataTypes.DATE, + allowNull: true, + comment: 'Set when status flips to completed. Null while in_progress.', + }, + last_accessed_at: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW, + comment: 'Updated on every UPSERT.', + }, + + // ── Audit trails ──────────────────────────────────────────────────────── + createdBy: { type: DataTypes.BIGINT, allowNull: true }, + updatedBy: { type: DataTypes.BIGINT, allowNull: true }, +}, { + tableName: 'unit_reading_progress', + timestamps: true, + paranoid: false, + indexes: [ + { + unique: true, + fields: ['user_id', 'unit_id'], + name: 'uq_urp_user_unit', + }, + { fields: ['user_id'], name: 'idx_urp_user_id' }, + { fields: ['course_id'], name: 'idx_urp_course_id' }, + { fields: ['unit_id'], name: 'idx_urp_unit_id' }, + { fields: ['status'], name: 'idx_urp_status' }, + ], +}); + +module.exports = UnitReadingProgress; diff --git a/models/system_badges/system_badges.mdl.js b/models/system_badges/system_badges.mdl.js new file mode 100644 index 0000000..1497f62 --- /dev/null +++ b/models/system_badges/system_badges.mdl.js @@ -0,0 +1,19 @@ +const { DataTypes } = require('sequelize'); +const sequelize = require('../../config/db.config'); + +const mdl_SystemBadges = sequelize.define('SystemBadge', { + badge_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true }, + key: { type: DataTypes.STRING(50), allowNull: false, unique: true, label: 'Badge Key' }, + asset_id: { type: DataTypes.BIGINT, allowNull: true, label: 'Asset' }, + label: { type: DataTypes.STRING(100), allowNull: false, label: 'Label' }, + description: { type: DataTypes.TEXT, allowNull: true, label: 'Description' }, + information: { type: DataTypes.TEXT, allowNull: true, label: 'Information' }, + active_from: { type: DataTypes.DATEONLY, allowNull: true, label: 'Active From' }, + active_until: { type: DataTypes.DATEONLY, allowNull: true, label: 'Active Until' }, +}, { + tableName: 'system_badges', + timestamps: true, + paranoid: false, +}); + +module.exports = mdl_SystemBadges; diff --git a/models/tiers/plan_policies.mdl.js b/models/tiers/plan_policies.mdl.js new file mode 100644 index 0000000..3d703f0 --- /dev/null +++ b/models/tiers/plan_policies.mdl.js @@ -0,0 +1,14 @@ +const { DataTypes } = require('sequelize'); +const sequelize = require('../../config/db.config'); + +const mdl_PlanPolicies = sequelize.define('PlanPolicy', { + policy_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true }, + plan_id: { type: DataTypes.BIGINT, allowNull: false, unique: true }, + access_rules: { type: DataTypes.JSONB, allowNull: false, defaultValue: [], label: 'Access Rules' }, +}, { + tableName: 'plan_policies', + timestamps: true, + paranoid: false, +}); + +module.exports = mdl_PlanPolicies; diff --git a/models/tiers/tier.associations.js b/models/tiers/tier.associations.js index e74f0cb..d5262a5 100644 --- a/models/tiers/tier.associations.js +++ b/models/tiers/tier.associations.js @@ -1,15 +1,26 @@ -const mdl_Users = require('../users/users.mdl'); -const mdl_TierPlans = require('./tier_plans.mdl'); -const mdl_UserTiers = require('./user_tiers.mdl'); -const mdl_Payments = require('./payments.mdl'); -const mdl_PlanCourses = require('./plan_courses.mdl'); -const { Course } = require('../courses/courses.mdl'); // ← named export +const mdl_Users = require('../users/users.mdl'); +const mdl_TierCategories = require('./tier_categories.mdl'); +const mdl_TierPlans = require('./tier_plans.mdl'); +const mdl_UserTiers = require('./user_tiers.mdl'); +const mdl_Payments = require('./payments.mdl'); +const mdl_PlanCourses = require('./plan_courses.mdl'); +const mdl_PlanPolicies = require('./plan_policies.mdl'); +const mdl_SystemBadges = require('../system_badges/system_badges.mdl'); +const Asset = require('../assets/assets.mdl'); +const { Course } = require('../courses/courses.mdl'); + +// ─── TierCategory ───────────────────────────────────────────────────────────── +mdl_TierCategories.belongsTo(Asset, { foreignKey: 'badge_asset_id', as: 'badgeAsset' }); +mdl_TierCategories.hasMany(mdl_TierPlans, { foreignKey: 'tier_category_id', as: 'plans' }); + +// ─── TierPlans → TierCategory ───────────────────────────────────────────────── +mdl_TierPlans.belongsTo(mdl_TierCategories, { foreignKey: 'tier_category_id', as: 'category' }); // ─── UserTiers ──────────────────────────────────────────────────────────────── -mdl_UserTiers.belongsTo(mdl_Users, { foreignKey: 'user_id', as: 'user' }); -mdl_UserTiers.belongsTo(mdl_Users, { foreignKey: 'granted_by', as: 'grantedByUser' }); -mdl_UserTiers.belongsTo(mdl_Users, { foreignKey: 'revoked_by', as: 'revokedByUser' }); -mdl_Users.hasMany(mdl_UserTiers, { foreignKey: 'user_id', as: 'tiers' }); +mdl_UserTiers.belongsTo(mdl_Users, { foreignKey: 'user_id', as: 'user' }); +mdl_UserTiers.belongsTo(mdl_Users, { foreignKey: 'granted_by', as: 'grantedByUser' }); +mdl_UserTiers.belongsTo(mdl_Users, { foreignKey: 'revoked_by', as: 'revokedByUser' }); +mdl_Users.hasMany(mdl_UserTiers, { foreignKey: 'user_id', as: 'tiers' }); // ─── Payments ───────────────────────────────────────────────────────────────── mdl_Payments.belongsTo(mdl_Users, { foreignKey: 'user_id', as: 'user' }); @@ -23,17 +34,34 @@ mdl_TierPlans.belongsToMany(Course, { otherKey: 'course_id', as: 'courses', }); - Course.belongsToMany(mdl_TierPlans, { through: mdl_PlanCourses, foreignKey: 'course_id', otherKey: 'plan_id', as: 'plans', }); - mdl_PlanCourses.belongsTo(mdl_TierPlans, { foreignKey: 'plan_id', as: 'plan' }); mdl_PlanCourses.belongsTo(Course, { foreignKey: 'course_id', as: 'course' }); -Course.hasOne(mdl_PlanCourses, { as: 'planCourse', foreignKey: 'course_id' }); -mdl_TierPlans.hasMany(mdl_PlanCourses, { as: 'planCourses', foreignKey: 'plan_id' }); +Course.hasOne(mdl_PlanCourses, { as: 'planCourse', foreignKey: 'course_id' }); +mdl_TierPlans.hasMany(mdl_PlanCourses, { as: 'planCourses', foreignKey: 'plan_id' }); -module.exports = { mdl_TierPlans, mdl_UserTiers, mdl_Payments, mdl_PlanCourses }; \ No newline at end of file +// ─── UserTier → Plan ────────────────────────────────────────────────────────── +mdl_UserTiers.belongsTo(mdl_TierPlans, { foreignKey: 'plan_id', as: 'plan' }); +mdl_TierPlans.hasMany(mdl_UserTiers, { foreignKey: 'plan_id', as: 'userTiers' }); + +// ─── Plan ↔ Policy ──────────────────────────────────────────────────────────── +mdl_TierPlans.hasOne(mdl_PlanPolicies, { foreignKey: 'plan_id', as: 'policy' }); +mdl_PlanPolicies.belongsTo(mdl_TierPlans, { foreignKey: 'plan_id', as: 'plan' }); + +// ─── SystemBadge → Asset ───────────────────────────────────────────────────── +mdl_SystemBadges.belongsTo(Asset, { foreignKey: 'asset_id', as: 'asset' }); + +module.exports = { + mdl_TierCategories, + mdl_TierPlans, + mdl_UserTiers, + mdl_Payments, + mdl_PlanCourses, + mdl_PlanPolicies, + mdl_SystemBadges, +}; diff --git a/models/tiers/tier_categories.mdl.js b/models/tiers/tier_categories.mdl.js new file mode 100644 index 0000000..e0d176c --- /dev/null +++ b/models/tiers/tier_categories.mdl.js @@ -0,0 +1,22 @@ +const { DataTypes } = require('sequelize'); +const sequelize = require('../../config/db.config'); + +const mdl_TierCategories = sequelize.define('TierCategory', { + tier_category_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true }, + slug: { type: DataTypes.STRING(50), allowNull: false, unique: true, label: 'Slug' }, + name: { type: DataTypes.STRING(100), allowNull: false, label: 'Name' }, + description: { type: DataTypes.TEXT, allowNull: true, label: 'Description' }, + rank: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0, label: 'Rank' }, + badge_asset_id: { type: DataTypes.BIGINT, allowNull: true, label: 'Badge Asset' }, + badge_icon: { type: DataTypes.STRING(50), allowNull: true, label: 'Badge Icon' }, + badge_label: { type: DataTypes.STRING(100), allowNull: true, label: 'Badge Label' }, + color: { type: DataTypes.STRING(30), allowNull: false, defaultValue: 'purple', label: 'Color' }, + is_default: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: false, label: 'Default' }, + is_active: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: true, label: 'Active' }, +}, { + tableName: 'tier_categories', + timestamps: true, + paranoid: false, +}); + +module.exports = mdl_TierCategories; diff --git a/models/tiers/tier_plans.mdl.js b/models/tiers/tier_plans.mdl.js index fab68a7..935734d 100644 --- a/models/tiers/tier_plans.mdl.js +++ b/models/tiers/tier_plans.mdl.js @@ -10,8 +10,9 @@ const { DataTypes } = require('sequelize'); const sequelize = require('../../config/db.config'); const mdl_TierPlans = sequelize.define('TierPlan', { - plan_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true, label: 'Plan ID' }, - tier: { type: DataTypes.ENUM('premium', 'exclusive'), allowNull: false, label: 'Tier' }, + plan_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true, label: 'Plan ID' }, + tier_category_id: { type: DataTypes.BIGINT, allowNull: true, label: 'Tier Category' }, + tier: { type: DataTypes.STRING(50), allowNull: false, label: 'Tier' }, label: { type: DataTypes.STRING(100), allowNull: false, label: 'Plan Label' }, duration_days: { type: DataTypes.INTEGER, allowNull: false, label: 'Duration (Days)' }, price: { type: DataTypes.DECIMAL(10, 2), allowNull: false, label: 'Price' }, diff --git a/models/tiers/user_tiers.mdl.js b/models/tiers/user_tiers.mdl.js index 52dce2f..434e0d8 100644 --- a/models/tiers/user_tiers.mdl.js +++ b/models/tiers/user_tiers.mdl.js @@ -12,11 +12,12 @@ const sequelize = require('../../config/db.config'); const mdl_UserTiers = sequelize.define('UserTier', { tier_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true, label: 'Tier ID' }, user_id: { type: DataTypes.BIGINT, allowNull: false, label: 'User ID' }, - tier: { type: DataTypes.ENUM('free', 'premium', 'exclusive'), allowNull: false, defaultValue: 'free', label: 'Tier' }, + tier: { type: DataTypes.STRING(50), allowNull: false, defaultValue: 'free', label: 'Tier' }, status: { type: DataTypes.ENUM('active', 'expired', 'revoked'), allowNull: false, defaultValue: 'active', label: 'Status' }, starts_at: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW, label: 'Starts At' }, expires_at: { type: DataTypes.DATE, allowNull: true, label: 'Expires At' }, + plan_id: { type: DataTypes.BIGINT, allowNull: true, label: 'Plan ID' }, granted_by: { type: DataTypes.BIGINT, allowNull: true, label: 'Granted By' }, revoked_by: { type: DataTypes.BIGINT, allowNull: true, label: 'Revoked By' }, revoked_at: { type: DataTypes.DATE, allowNull: true, label: 'Revoked At' }, diff --git a/models/users/user_bans.mdl.js b/models/users/user_bans.mdl.js new file mode 100644 index 0000000..1953a22 --- /dev/null +++ b/models/users/user_bans.mdl.js @@ -0,0 +1,41 @@ +/*********************************************************************************************************************************************************************** + * File Name: user_bans.mdl.js + * Type of Program: Model + * Description: Sequelize model for the `user_bans` table. + * Stores ban/unban audit records for policy enforcement actions. + * Distinct from deactivation (account lifecycle) — bans track WHY, + * WHO banned, duration, and lift history. + * Author: Kenneth Obsequio + * Date Created: Jun. 27, 2026 + ***********************************************************************************************************************************************************************/ +const { DataTypes } = require('sequelize'); +const sequelize = require('../../config/db.config'); +const mdl_Users = require('./users.mdl'); + +const mdl_UserBans = sequelize.define('UserBan', { + ban_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true }, + user_id: { type: DataTypes.BIGINT, allowNull: false, references: { model: mdl_Users, key: 'user_id' } }, + banned_by: { type: DataTypes.BIGINT, allowNull: false, references: { model: mdl_Users, key: 'user_id' } }, + + reason: { type: DataTypes.TEXT, allowNull: false }, + ban_type: { type: DataTypes.ENUM('temporary', 'permanent'), allowNull: false }, + + banned_at: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW }, + expires_at: { type: DataTypes.DATE, allowNull: true }, + + is_lifted: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: false }, + lifted_at: { type: DataTypes.DATE, allowNull: true }, + lifted_by: { type: DataTypes.BIGINT, allowNull: true, references: { model: mdl_Users, key: 'user_id' } }, + lift_reason: { type: DataTypes.TEXT, allowNull: true }, +}, { + tableName: 'user_bans', + timestamps: true, +}); + +// ─── Associations ────────────────────────────────────────────────────────────── +mdl_UserBans.belongsTo(mdl_Users, { as: 'user', foreignKey: 'user_id' }); +mdl_UserBans.belongsTo(mdl_Users, { as: 'banner', foreignKey: 'banned_by' }); +mdl_UserBans.belongsTo(mdl_Users, { as: 'lifter', foreignKey: 'lifted_by' }); +mdl_Users.hasMany(mdl_UserBans, { as: 'bans', foreignKey: 'user_id' }); + +module.exports = mdl_UserBans; diff --git a/models/users/users.attributes.js b/models/users/users.attributes.js index ed93882..4c46111 100644 --- a/models/users/users.attributes.js +++ b/models/users/users.attributes.js @@ -1,5 +1,5 @@ const excludeAttributes = [ - "password", "otp_code", "otp_expires_at", 'must_change_password', 'password_expires_at', + "password", "otp_code", "otp_expires_at", 'must_change_password', 'password_expires_at', "needs_intro", "personal_info.name.given_name", "personal_info.name.middle_name", "personal_info.name.last_name", diff --git a/models/users/users.mdl.js b/models/users/users.mdl.js index 7998b3c..b048d8c 100644 --- a/models/users/users.mdl.js +++ b/models/users/users.mdl.js @@ -43,6 +43,10 @@ const mdl_Users = sequelize.define('User', { */ personal_info: { type: DataTypes.JSONB, defaultValue: null, label: "Personal Info", order: 2 }, + // ── Ban state ──────────────────────────────────────────────────────────────── + is_banned: { type: DataTypes.BOOLEAN, defaultValue: false, allowNull: false, label: "Banned" }, + ban_expires_at: { type: DataTypes.DATE, defaultValue: null, allowNull: true, label: "Ban Expires At" }, + // ── Password policy ───────────────────────────────────────────────────────── must_change_password: { type: DataTypes.BOOLEAN, defaultValue: false, allowNull: false, label: "Must Change Password" }, password_expires_at: { type: DataTypes.DATE, defaultValue: null, allowNull: true, label: "Password Expires At" }, diff --git a/routes/admin/admin.routes.js b/routes/admin/admin.routes.js index 724371b..e472640 100644 --- a/routes/admin/admin.routes.js +++ b/routes/admin/admin.routes.js @@ -30,7 +30,8 @@ const groupsRoutes = require('./groups.routes'); const assetsRoutes = require('./assets.routes'); const coursesRoutes = require('./courses.routes'); const taskRoutes = require('./task.routes'); -const tiersRoutes = require('./tiers.routes'); +const tiersRoutes = require('./tiers.routes'); +const tierCategoriesRoutes = require('./tier_categories.routes'); const categoriesRoutes = require('./categories.routes'); const productsRoutes = require('./products.routes'); const advertisementRoutes = require('./advertisements.routes'); @@ -49,6 +50,7 @@ router.use('/groups', groupsRoutes); router.use('/assets', assetsRoutes); router.use('/courses', coursesRoutes); router.use('/task-lists', taskRoutes); +router.use('/tiers/categories', tierCategoriesRoutes); router.use('/tiers', tiersRoutes); router.use('/categories', categoriesRoutes); router.use('/products', productsRoutes); diff --git a/routes/admin/courses.routes.js b/routes/admin/courses.routes.js index 8d0d5c1..7b45e8d 100644 --- a/routes/admin/courses.routes.js +++ b/routes/admin/courses.routes.js @@ -21,6 +21,7 @@ router.delete("/bulk", ctrl.bulkArchiveCourses); router.get("/archives", ctrl.getArchivedCourses); router.patch("/restore/bulk", ctrl.bulkRestoreCourses); router.get("/flat", ctrl.getCoursesFlat); +router.get("/by-subscription", ctrl.getCoursesBySubscription); router.get("/units-flat", ctrl.getUnitsFlat); router.get("/lessons-flat", ctrl.getLessonsFlat); diff --git a/routes/admin/media.routes.js b/routes/admin/media.routes.js index 05bd243..af90a27 100644 --- a/routes/admin/media.routes.js +++ b/routes/admin/media.routes.js @@ -10,6 +10,7 @@ const router = require("express").Router(); const ctrl = require("../../controllers/admin/media.controller"); // Auth + requireAdmin are already applied by admin.routes.js before reaching here. -router.post("/token", ctrl.issueToken); +router.post("/token", ctrl.issueToken); +router.post("/tokens", ctrl.issueTokensBatch); module.exports = router; diff --git a/routes/admin/tier_categories.routes.js b/routes/admin/tier_categories.routes.js new file mode 100644 index 0000000..c246b13 --- /dev/null +++ b/routes/admin/tier_categories.routes.js @@ -0,0 +1,14 @@ +'use strict'; + +const router = require('express').Router(); +const ctrl = require('../../controllers/admin/tier_categories.controller'); + +// Auth + requireAdmin applied by admin.routes.js + +router.get ('/', ctrl.getCategories); +router.post ('/', ctrl.createCategory); +router.get ('/:id', ctrl.getCategory); +router.put ('/:id', ctrl.updateCategory); +router.delete('/:id', ctrl.deleteCategory); + +module.exports = router; diff --git a/routes/admin/tier_policies.routes.js b/routes/admin/tier_policies.routes.js new file mode 100644 index 0000000..f3936ed --- /dev/null +++ b/routes/admin/tier_policies.routes.js @@ -0,0 +1,19 @@ +const express = require('express'); +const router = express.Router(); +const ctrl = require('../../controllers/admin/tier_policies.controller'); + +// ─── Plan Policies ──────────────────────────────────────────────────────────── +// GET /admin/tiers/plans/:planId/policy +// PUT /admin/tiers/plans/:planId/policy — JSON: { badge_asset_id, badge_label, access_rules, … } +router.get('/plans/:planId/policy', ctrl.getPlanPolicy); +router.put('/plans/:planId/policy', ctrl.upsertPlanPolicy); + +// ─── System Badges ──────────────────────────────────────────────────────────── +// GET /admin/tiers/system-badges +// GET /admin/tiers/system-badges/:key +// PUT /admin/tiers/system-badges/:key — JSON: { asset_id, label, active_from, active_until, … } +router.get('/system-badges', ctrl.getSystemBadges); +router.get('/system-badges/:key', ctrl.getSystemBadge); +router.put('/system-badges/:key', ctrl.upsertSystemBadge); + +module.exports = router; diff --git a/routes/admin/users.routes.js b/routes/admin/users.routes.js index 105dfe3..a013228 100644 --- a/routes/admin/users.routes.js +++ b/routes/admin/users.routes.js @@ -16,6 +16,13 @@ router.put('/:id', sensitiveOpsLimiter, usersCtrl.updateUser); router.delete('/:id', sensitiveOpsLimiter, usersCtrl.deactivateUser); router.post('/:id/restore', sensitiveOpsLimiter, usersCtrl.restoreUser); +// ── Bans ────────────────────────────────────────────────────────────────────── +router.post('/bulk/ban', sensitiveOpsLimiter, usersCtrl.bulkBanUsers); +router.post('/bulk/unban', sensitiveOpsLimiter, usersCtrl.bulkUnbanUsers); +router.get('/:id/bans', usersCtrl.getUserBans); +router.post('/:id/ban', sensitiveOpsLimiter, usersCtrl.banUser); +router.post('/:id/unban', sensitiveOpsLimiter, usersCtrl.unbanUser); + // ── Sessions ────────────────────────────────────────────────────────────────── router.get('/:id/sessions', usersCtrl.getUserSessions); router.delete('/:id/sessions/:sid', sensitiveOpsLimiter, usersCtrl.terminateSession); diff --git a/routes/auth.routes.js b/routes/auth.routes.js index 94d1528..9576133 100644 --- a/routes/auth.routes.js +++ b/routes/auth.routes.js @@ -40,7 +40,7 @@ router.post('/register', ...registerValidator, validate, authCtrl.register); router.post('/verify-otp', otpLimiter, ...verifyOTPValidator, validate, authCtrl.verifyOTP); router.post('/resend-otp', otpLimiter, ...resendOTPValidator, validate, authCtrl.resendOTP); router.post('/login', ...loginValidator, validate, authCtrl.login); -router.post('/refresh', authCtrl.refreshToken); +router.post('/refresh', authLimiter, authCtrl.refreshToken); router.post('/logout', authenticate, authLimiter, authCtrl.logout); router.post('/change-password', authenticate, sensitiveOpsLimiter, ...changePassValidator, validate, authCtrl.changePassword); diff --git a/routes/client/client.routes.js b/routes/client/client.routes.js index 2e38ac5..a6864f2 100644 --- a/routes/client/client.routes.js +++ b/routes/client/client.routes.js @@ -6,6 +6,7 @@ * Route Map: * GET /api/client/profile → view own profile * PUT /api/client/profile → update own profile + * DELETE /api/client/profile → delete own account (soft-delete) * GET /api/client/sessions → view own active sessions * DELETE /api/client/sessions/:id → revoke own session * @@ -48,6 +49,7 @@ router.use(authenticate, requireClient()); router.get('/profile', profileCtrl.getProfile); router.put('/profile', ...updateProfileValidator, validate, profileCtrl.updateProfile); +router.delete('/profile', profileCtrl.deleteAccount); router.post('/profile/avatar', handleAvatarUpload, profileCtrl.uploadAvatar); router.delete('/profile/avatar', profileCtrl.deleteAvatar); router.get('/sessions', profileCtrl.getSessions); diff --git a/routes/client/courses.routes.js b/routes/client/courses.routes.js index 7b22e41..9951f82 100644 --- a/routes/client/courses.routes.js +++ b/routes/client/courses.routes.js @@ -31,12 +31,14 @@ router.post('/:courseId/assessment/:assessmentId/start', ctrl.startCour router.get( '/:courseId/assessment/:assessmentId/session', ctrl.getAssessmentSession); router.patch('/:courseId/assessment/:assessmentId/draft', ctrl.saveDraft); +router.patch('/:courseId/units/:unitId/quiz/:quizId/draft', ctrl.saveQuizDraft); router.post('/:courseId/units/:unitId/quiz/:quizId/submit', ctrl.submitUnitQuiz); router.post('/:courseId/assessment/:assessmentId/submit', ctrl.submitCourseAssessment); // Reading progress router.get( '/:courseId/progress/summary', progressCtrl.getCourseProgressSummary); router.get( '/:courseId/progress', progressCtrl.getCourseProgress); +router.get( '/:courseId/task-context', progressCtrl.getCourseTaskContext); router.post('/:courseId/units/:unitId/lessons/:lessonId/progress', progressCtrl.upsertLessonProgress); module.exports = router; \ No newline at end of file diff --git a/routes/client/groups.routes.js b/routes/client/groups.routes.js index 193d1b4..51a2d05 100644 --- a/routes/client/groups.routes.js +++ b/routes/client/groups.routes.js @@ -88,6 +88,11 @@ router.post( sensitiveOpsLimiter, progressController.visitLink ); +router.delete( + '/:groupId/task-lists/:taskListId/tasks/:taskId/requirements/:requirementId/visit', + sensitiveOpsLimiter, + progressController.unvisitLink +); router.post( '/:groupId/task-lists/:taskListId/tasks/:taskId/requirements/:requirementId/progress', sensitiveOpsLimiter, diff --git a/routes/client/tiers.routes.js b/routes/client/tiers.routes.js index 996ae09..3945774 100644 --- a/routes/client/tiers.routes.js +++ b/routes/client/tiers.routes.js @@ -20,4 +20,10 @@ router.post('/checkout/refund', ctrl.refundOrder); // My payments router.get ('/me/payments', ctrl.getMyPayments); +// Tier categories (public — for course badge display) +router.get ('/categories', ctrl.getCategories); + +// System badges (public read for profile display) +router.get ('/system-badges', ctrl.getSystemBadges); + module.exports = router; \ No newline at end of file diff --git a/scripts/backfill-durations.js b/scripts/backfill-durations.js new file mode 100644 index 0000000..13f2fdb --- /dev/null +++ b/scripts/backfill-durations.js @@ -0,0 +1,42 @@ +/** + * One-time backfill: recompute duration_seconds for every lesson + * that has at least one saved page block. + * + * Run from the project root: + * node scripts/backfill-durations.js + */ + +const path = require('path'); +require('dotenv').config({ path: path.join(__dirname, '../.env') }); + +const sequelize = require('../config/db.config'); +const LessonPage = require('../models/courses/lesson_page.mdl'); +const { recomputeDurations } = require('../utils/duration.util'); + +(async () => { + await sequelize.authenticate(); + console.log('DB connected.\n'); + + const pages = await LessonPage.findAll({ + attributes: ['lesson_id', 'blocks'], + where: sequelize.literal(`jsonb_array_length(blocks::jsonb) > 0`), + }); + + console.log(`Found ${pages.length} lessons with blocks. Recomputing...`); + + let ok = 0, fail = 0; + for (const page of pages) { + try { + await recomputeDurations(page.lesson_id); + process.stdout.write('.'); + ok++; + } catch (err) { + process.stdout.write('✗'); + console.error(`\n lesson ${page.lesson_id}: ${err.message}`); + fail++; + } + } + + console.log(`\n\nDone — ${ok} recomputed, ${fail} failed.`); + await sequelize.close(); +})(); diff --git a/services/email.service.js b/services/email.service.js index bf7d27e..28c3b3e 100644 --- a/services/email.service.js +++ b/services/email.service.js @@ -32,7 +32,7 @@ const sendEmail = async ({ to, type, data = {} }) => { throw new Error(`Email template "${type}" not found`); } - const { subject, text } = templateFn(data); + const { subject, html } = templateFn(data); return await new Promise((resolve, reject) => { transporter.sendMail( @@ -43,7 +43,7 @@ const sendEmail = async ({ to, type, data = {} }) => { }, to, subject, - text, + html, }, (err, info) => { if (err) return reject(err); diff --git a/services/reading_progress.service.js b/services/reading_progress.service.js new file mode 100644 index 0000000..707e1ab --- /dev/null +++ b/services/reading_progress.service.js @@ -0,0 +1,153 @@ +/*********************************************************************************************************************************************************************** + * File Name: reading_progress.service.js + * Type of Program: Service + * Description: UPSERT-based progress tracking for unit and lesson reading activity. + * + * upsertLessonRead — main entry point, called when a user reads a lesson. + * UPSERTs the lesson row in lesson_reading_progress, then derives and + * UPSERTs the parent unit row in unit_reading_progress. + * Both writes run in a single transaction. + * + * UPSERT keys: + * lesson_reading_progress → (user_id, lesson_id) + * unit_reading_progress → (user_id, unit_id) + * + * Derivation rule: + * unit → completed when ALL non-deleted lessons under it have a completed row for this user + * + * Author: Kenneth Obsequio (@lash0000) + * Date Created: Jun. 26, 2026 + ***********************************************************************************************************************************************************************/ +'use strict'; + +const sequelize = require('../config/db.config'); +const LessonReadingProgress = require('../models/courses/lesson_reading_progress.mdl'); +const UnitReadingProgress = require('../models/courses/unit_reading_progress.mdl'); +const Lesson = require('../models/courses/lessons.mdl'); + +// ─── Core UPSERTs ──────────────────────────────────────────────────────────── + +async function upsertLessonProgress({ userId, courseId, unitId, lessonId, status }, t) { + const now = new Date(); + const [record] = await LessonReadingProgress.upsert( + { + user_id: userId, + course_id: courseId, + unit_id: unitId, + lesson_id: lessonId, + status, + completed_at: status === 'completed' ? now : null, + last_accessed_at: now, + createdBy: userId, + updatedBy: userId, + }, + { + conflictFields: ['user_id', 'lesson_id'], + returning: true, + transaction: t, + } + ); + return record; +} + +async function upsertUnitProgress({ userId, courseId, unitId, status }, t) { + const now = new Date(); + const [record] = await UnitReadingProgress.upsert( + { + user_id: userId, + course_id: courseId, + unit_id: unitId, + status, + completed_at: status === 'completed' ? now : null, + last_accessed_at: now, + createdBy: userId, + updatedBy: userId, + }, + { + conflictFields: ['user_id', 'unit_id'], + returning: true, + transaction: t, + } + ); + return record; +} + +// ─── Derivation helper ──────────────────────────────────────────────────────── + +// Unit is completed when every non-deleted lesson under it has a completed row for this user. +async function deriveUnitStatus(userId, unitId, t) { + const lessons = await Lesson.findAll({ + where: { unit_id: unitId }, + attributes: ['lesson_id'], + transaction: t, + }); + if (!lessons.length) return 'in_progress'; + + const lessonIds = lessons.map(l => l.lesson_id); + const completedCount = await LessonReadingProgress.count({ + where: { + user_id: userId, + lesson_id: lessonIds, + status: 'completed', + }, + transaction: t, + }); + + return completedCount === lessons.length ? 'completed' : 'in_progress'; +} + +// ─── Main entry point ───────────────────────────────────────────────────────── + +/** + * Called when a user reads (or finishes reading) a lesson. + * Writes two rows in one transaction: lesson → unit. + * + * @param {number} userId + * @param {Object} payload + * @param {number} payload.courseId — course BIGINT PK + * @param {number} payload.unitId — unit BIGINT PK + * @param {number} payload.lessonId — lesson BIGINT PK + * @param {string} payload.lessonStatus — 'in_progress' | 'completed' + * @returns {{ lesson, unit }} — status snapshot for each level + */ +async function upsertLessonRead(userId, { courseId, unitId, lessonId, lessonStatus = 'in_progress' }) { + const t = await sequelize.transaction(); + try { + // 1. Lesson + await upsertLessonProgress({ + userId, + courseId, + unitId, + lessonId, + status: lessonStatus, + }, t); + + // 2. Unit — derived from all sibling lessons + const unitStatus = await deriveUnitStatus(userId, unitId, t); + await upsertUnitProgress({ + userId, + courseId, + unitId, + status: unitStatus, + }, t); + + await t.commit(); + + return { + lesson: { lesson_id: lessonId, status: lessonStatus }, + unit: { unit_id: unitId, status: unitStatus }, + }; + } catch (err) { + await t.rollback(); + throw err; + } +} + +// ─── Exports ────────────────────────────────────────────────────────────────── + +module.exports = { + upsertLessonRead, + upsertLessonProgress, + upsertUnitProgress, + deriveUnitStatus, +}; diff --git a/services/s3.service.js b/services/s3.service.js index 4ba4192..2d18a5f 100644 --- a/services/s3.service.js +++ b/services/s3.service.js @@ -60,6 +60,7 @@ const PREFIX_MAP = { document: "documents", avatar: "avatars", thumbnail: "thumbnails", + badge: "badges", }; function resolvePrefix(ownerType = "image") { diff --git a/utils/accessPolicy.util.js b/utils/accessPolicy.util.js new file mode 100644 index 0000000..e307eae --- /dev/null +++ b/utils/accessPolicy.util.js @@ -0,0 +1,78 @@ +/*********************************************************************************************************************************************************************** + * File Name: accessPolicy.util.js + * Type of Program: Utility + * Description: Evaluates a user's access to a course based on their active plan's access_rules JSONB. + * + * Rule types: + * course_subscription_access — { type, levels: ['free','premium','exclusive'] } + * → The plan grants access to these subscription levels only. + * required_active_tier — { type, tier: 'premium' | 'exclusive' } + * → The user's active tier must be at least this rank (exclusive satisfies premium). + * group_restriction — { type, group_ids: [number, ...] } + * → The user must belong to at least one of these groups. + * + * Fallback (no access_rules): uses simple tier rank comparison. + ***********************************************************************************************************************************************************************/ +'use strict'; + +// Default rank map used as fallback when a live DB map is not available. +// Overridden at call time with ranks loaded from tier_categories. +const TIER_RANK = { free: 0, premium: 1, exclusive: 2 }; + +/** + * Evaluates whether a user can access a course. + * + * @param {object} ctx + * @param {string} ctx.tier — user's active tier slug + * @param {Array} ctx.access_rules — plan_policies.access_rules (may be empty) + * @param {number[]} ctx.group_ids — group IDs the user belongs to + * @param {object} course + * @param {string} course.subscription — course subscription level (slug) + * @param {Object} tierRankMap — { [slug]: rank } loaded from tier_categories; falls back to TIER_RANK + * @returns {{ allowed: boolean, reason: string|null }} + */ +function evaluateCourseAccess(ctx, course, tierRankMap = TIER_RANK) { + const { tier = 'free', access_rules = [], group_ids = [] } = ctx; + const courseSubscription = course.subscription ?? 'free'; + const userRank = tierRankMap[tier] ?? 0; + // Unknown required slug → Infinity so access is always denied (safe default) + const courseRank = tierRankMap[courseSubscription] ?? Infinity; + + // Rank-0 courses (default/free tier) are always accessible + if (courseRank === 0) return { allowed: true, reason: null }; + + // No plan policy — fallback: compare user rank vs course subscription rank + if (!access_rules || access_rules.length === 0) { + return userRank >= courseRank + ? { allowed: true, reason: null } + : { allowed: false, reason: 'tier_rank' }; + } + + for (const rule of access_rules) { + if (rule.type === 'course_subscription_access') { + if (!(rule.levels ?? []).includes(courseSubscription)) { + return { allowed: false, reason: 'subscription_access' }; + } + } + + if (rule.type === 'required_active_tier') { + // Unknown rule tier slug → Infinity, so the rule always blocks + const reqRank = tierRankMap[rule.tier] ?? Infinity; + if (userRank < reqRank) { + return { allowed: false, reason: 'required_tier' }; + } + } + + if (rule.type === 'group_restriction') { + const required = (rule.group_ids ?? []).map(Number); + if (required.length > 0) { + const inGroup = required.some((gid) => group_ids.includes(gid)); + if (!inGroup) return { allowed: false, reason: 'group_restriction' }; + } + } + } + + return { allowed: true, reason: null }; +} + +module.exports = { evaluateCourseAccess, TIER_RANK }; diff --git a/utils/courses/quiz_security.util.js b/utils/courses/quiz_security.util.js index 4cc9ad2..ea01258 100644 --- a/utils/courses/quiz_security.util.js +++ b/utils/courses/quiz_security.util.js @@ -1,21 +1,25 @@ -// Assessment cooldown policy is now stored per-assessment in the DB (max_attempts / cooldown_hours). -// These fallbacks are used only if values are missing (e.g. legacy rows before the migration). -const ASSESSMENT_FAILS_BEFORE_COOLDOWN = 3; -const ASSESSMENT_COOLDOWN_HOURS = 24; +// Fisher-Yates in-place shuffle — shared by shuffleOptions and shuffleQuestions. +function fisherYates(arr) { + for (let i = arr.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [arr[i], arr[j]] = [arr[j], arr[i]]; + } + return arr; +} -// Fisher-Yates shuffle of each question's options. Pure — returns new +// Randomises the ORDER OF OPTIONS within each question. Pure — returns new // arrays/objects, never mutates input. Grading is unaffected since // submitUnitQuiz/submitCourseAssessment always re-fetch questions fresh -// from the DB and never trust shuffled client-facing order. +// from the DB and never trust the shuffled client-facing order. function shuffleOptions(questions) { - return questions.map((q) => { - const options = [...(q.options ?? [])]; - for (let i = options.length - 1; i > 0; i--) { - const j = Math.floor(Math.random() * (i + 1)); - [options[i], options[j]] = [options[j], options[i]]; - } - return { ...q, options }; - }); + return questions.map((q) => ({ ...q, options: fisherYates([...(q.options ?? [])]) })); +} + +// Randomises the ORDER OF QUESTIONS. Pure — returns a new array. +// Safe: grading re-fetches questions from DB in stored order; client position +// has no effect on correctness checks. +function shuffleQuestions(questions) { + return fisherYates([...questions]); } // Single source of truth for both the GET-time info fields and the @@ -23,7 +27,7 @@ function shuffleOptions(questions) { // // type = 'quiz' → unit quizzes: no cooldown, no attempt cap, always open // type = 'assessment' → course assessments: maxFails failed attempts → cooldownHours cooldown (rolling cycles) -// maxFails / cooldownHours come from the assessment row; fallback to the constants above. +// maxFails / cooldownHours come from the assessment row; null = feature off (no limit/cooldown). function getAttemptStatus(attempts, type = 'quiz', { maxFails, cooldownHours } = {}) { const attempt_count = attempts.length; const has_passed = attempts.some((a) => a.passed); @@ -44,9 +48,22 @@ function getAttemptStatus(attempts, type = 'quiz', { maxFails, cooldownHours } = }; } - // Assessment: simulate rolling cycles — N failed attempts → cooldown (from the assessment's own config) - const failLimit = maxFails ?? ASSESSMENT_FAILS_BEFORE_COOLDOWN; - const lockHours = cooldownHours ?? ASSESSMENT_COOLDOWN_HOURS; + // Assessment: simulate rolling cycles — N failed attempts → cooldown. + // null means the feature is off: no attempt cap / no cooldown. + const failLimit = maxFails ?? null; + const lockHours = cooldownHours ?? null; + + if (failLimit === null || lockHours === null) { + return { + attempt_count, + has_passed, + best_attempt, + attempts_remaining: null, + cooldown_until: null, + window_reset_at: null, + can_attempt: true, + }; + } const sorted = [...attempts].sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt)); @@ -79,4 +96,4 @@ function getAttemptStatus(attempts, type = 'quiz', { maxFails, cooldownHours } = }; } -module.exports = { ASSESSMENT_FAILS_BEFORE_COOLDOWN, ASSESSMENT_COOLDOWN_HOURS, shuffleOptions, getAttemptStatus }; \ No newline at end of file +module.exports = { shuffleOptions, shuffleQuestions, getAttemptStatus }; \ No newline at end of file diff --git a/utils/datetime.util.js b/utils/datetime.util.js new file mode 100644 index 0000000..1f337d2 --- /dev/null +++ b/utils/datetime.util.js @@ -0,0 +1,53 @@ +/*********************************************************************************************************************************************************************** + * File Name: datetime.util.js + * Type of Program: Utility + * Description: Pure date/time formatting helpers for backend use (emails, crons, notifications). + * + * All functions accept an optional options object: { timezone, locale } + * timezone — 'UTC' (default) | 'local' (server's local timezone) + * locale — BCP 47 tag, defaults to 'en-US' + * + * UTC is the default because emails and cron output must be unambiguous regardless + * of where the server runs. Pass { timezone: 'local' } only when displaying times + * relative to the server's configured locale (e.g. admin dashboards, server logs). + ***********************************************************************************************************************************************************************/ + +function tzOpt(timezone) { + return timezone === 'local' ? {} : { timeZone: 'UTC' }; +} + +function loc(locale) { + return locale ?? 'en-US'; +} + +/** "June 27, 2026" */ +function fmtDate(value, { timezone = 'UTC', locale } = {}) { + if (!value) return '—'; + return new Date(value).toLocaleDateString(loc(locale), { + month: 'long', day: 'numeric', year: 'numeric', + ...tzOpt(timezone), + }); +} + +/** "June 27, 2026, 3:45 PM UTC" */ +function fmtDateTime(value, { timezone = 'UTC', locale } = {}) { + if (!value) return '—'; + return new Date(value).toLocaleString(loc(locale), { + month: 'long', day: 'numeric', year: 'numeric', + hour: 'numeric', minute: '2-digit', + timeZoneName: 'short', + ...tzOpt(timezone), + }); +} + +/** "3:45 PM UTC" */ +function fmtTime(value, { timezone = 'UTC', locale } = {}) { + if (!value) return '—'; + return new Date(value).toLocaleTimeString(loc(locale), { + hour: 'numeric', minute: '2-digit', + timeZoneName: 'short', + ...tzOpt(timezone), + }); +} + +module.exports = { fmtDate, fmtDateTime, fmtTime }; diff --git a/utils/duration.util.js b/utils/duration.util.js index 42a07be..00d1181 100644 --- a/utils/duration.util.js +++ b/utils/duration.util.js @@ -25,6 +25,7 @@ function estimateBlockDuration(block) { switch (block.type) { case "text": + case "markdown": return readingSecs(block.content?.body); case "image":