pushy

Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
2026-06-28 11:29:07 +08:00
parent 463a8d3978
commit 89acdfc239
67 changed files with 2736 additions and 306 deletions
+61 -11
View File
@@ -875,7 +875,7 @@ exports.getQuiz = async (req, res) => {
exports.createQuiz = async (req, res) => { exports.createQuiz = async (req, res) => {
try { try {
const { courseId, unitId } = req.params; 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 } }); const unit = await Unit.findOne({ where: { unit_id: unitId, course_id: courseId, ...notDeleted } });
if (!unit) return R.error(res, "Unit not found.", 404); if (!unit) return R.error(res, "Unit not found.", 404);
@@ -889,6 +889,7 @@ exports.createQuiz = async (req, res) => {
is_required: is_required ?? false, is_required: is_required ?? false,
passing_score: passing_score ?? 70, passing_score: passing_score ?? 70,
max_questions: max_questions ?? null, max_questions: max_questions ?? null,
shuffle_questions: shuffle_questions ?? false,
createdBy: createdBy ?? null, createdBy: createdBy ?? null,
}); });
@@ -903,7 +904,7 @@ exports.createQuiz = async (req, res) => {
exports.updateQuiz = async (req, res) => { exports.updateQuiz = async (req, res) => {
try { try {
const { courseId, unitId, quizId } = req.params; 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 } }); const quiz = await UnitQuiz.findOne({ where: { quiz_id: quizId, unit_id: unitId, ...notDeleted } });
if (!quiz) return R.error(res, "Quiz not found.", 404); if (!quiz) return R.error(res, "Quiz not found.", 404);
@@ -912,6 +913,7 @@ exports.updateQuiz = async (req, res) => {
if (is_required !== undefined) quiz.is_required = is_required; if (is_required !== undefined) quiz.is_required = is_required;
if (passing_score !== undefined) quiz.passing_score = passing_score; if (passing_score !== undefined) quiz.passing_score = passing_score;
if (max_questions !== undefined) quiz.max_questions = max_questions; if (max_questions !== undefined) quiz.max_questions = max_questions;
if (shuffle_questions !== undefined) quiz.shuffle_questions = shuffle_questions;
quiz.updatedBy = updatedBy ?? null; quiz.updatedBy = updatedBy ?? null;
@@ -1244,7 +1246,7 @@ exports.getAssessment = async (req, res) => {
exports.createAssessment = async (req, res) => { exports.createAssessment = async (req, res) => {
try { try {
const { courseId } = req.params; 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 } }); const course = await Course.findOne({ where: { course_id: courseId, ...notDeleted } });
if (!course) return R.error(res, "Course not found.", 404); 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_questions: max_questions ?? null,
max_attempts: max_attempts ?? 3, max_attempts: max_attempts ?? 3,
cooldown_hours: cooldown_hours ?? 24, cooldown_hours: cooldown_hours ?? 24,
shuffle_questions: shuffle_questions ?? false,
createdBy: createdBy ?? null, createdBy: createdBy ?? null,
}); });
@@ -1275,7 +1278,7 @@ exports.createAssessment = async (req, res) => {
exports.updateAssessment = async (req, res) => { exports.updateAssessment = async (req, res) => {
try { try {
const { courseId, assessmentId } = req.params; 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({ const assessment = await CourseAssessment.findOne({
where: { assessment_id: assessmentId, course_id: courseId, ...notDeleted }, where: { assessment_id: assessmentId, course_id: courseId, ...notDeleted },
@@ -1289,6 +1292,7 @@ exports.updateAssessment = async (req, res) => {
if (max_questions !== undefined) assessment.max_questions = max_questions; if (max_questions !== undefined) assessment.max_questions = max_questions;
if (max_attempts !== undefined) assessment.max_attempts = max_attempts; if (max_attempts !== undefined) assessment.max_attempts = max_attempts;
if (cooldown_hours !== undefined) assessment.cooldown_hours = cooldown_hours; if (cooldown_hours !== undefined) assessment.cooldown_hours = cooldown_hours;
if (shuffle_questions !== undefined) assessment.shuffle_questions = shuffle_questions;
assessment.updatedBy = updatedBy ?? null; 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) => { exports.getUnitsFlat = async (req, res) => {
try { try {
const rows = await Unit.findAll({ const rows = await Unit.findAll({
@@ -1565,11 +1586,12 @@ exports.syncInstructors = async (req, res) => {
// ─── COMPLETIONS HELPERS ────────────────────────────────────────────────────── // ─── COMPLETIONS HELPERS ──────────────────────────────────────────────────────
function extractUserInfo(user) { 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 { return {
full_name: user.personal_info?.name?.full_name ?? null, full_name: user.personal_info?.name?.full_name ?? null,
email: user.email ?? null, email: user.email ?? null,
avatar_url: user.personal_info?.avatar?.url ?? null, avatar_url: user.personal_info?.avatar?.url ?? null,
deleted: !!user.deletedAt,
}; };
} }
@@ -1578,12 +1600,13 @@ function groupByUser(attempts) {
for (const a of attempts) { for (const a of attempts) {
const uid = String(a.user_id); const uid = String(a.user_id);
if (!map.has(uid)) { 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, { map.set(uid, {
user_id: a.user_id, user_id: a.user_id,
full_name, full_name,
email, email,
avatar_url, avatar_url,
deleted,
attempt_count: 0, attempt_count: 0,
best_score: 0, best_score: 0,
passed: false, passed: false,
@@ -1605,7 +1628,33 @@ function buildSummary(attempts) {
const takers = new Set(attempts.map((a) => String(a.user_id))).size; const takers = new Set(attempts.map((a) => String(a.user_id))).size;
const passed = attempts.filter((a) => a.passed).length; const passed = attempts.filter((a) => a.passed).length;
const failed = attempts.length - passed; const failed = attempts.length - passed;
const avg = attempts.length ? Math.round(attempts.reduce((s, a) => s + a.score, 0) / attempts.length) : 0;
/*
* ┌─────────────────────────────────────────────────────────────────┐
* │ 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 { return {
total_takers: takers, total_takers: takers,
passed_count: passed, passed_count: passed,
@@ -1625,7 +1674,7 @@ exports.getQuizCompletions = async (req, res) => {
const attempts = await QuizAttempt.findAll({ const attempts = await QuizAttempt.findAll({
where: { quiz_id: quizId }, where: { quiz_id: quizId },
attributes: ["attempt_id", "user_id", "attempt_number", "score", "earned_points", "total_points", "passed", "createdAt"], 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"]], order: [["createdAt", "DESC"]],
}); });
@@ -1649,7 +1698,7 @@ exports.getAssessmentCompletions = async (req, res) => {
const attempts = await QuizAttempt.findAll({ const attempts = await QuizAttempt.findAll({
where: { assessment_id: assessmentId }, where: { assessment_id: assessmentId },
attributes: ["attempt_id", "user_id", "attempt_number", "score", "earned_points", "total_points", "passed", "createdAt"], 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"]], order: [["createdAt", "DESC"]],
}); });
@@ -1673,13 +1722,13 @@ exports.getAssessmentSessions = async (req, res) => {
const sessions = await AssessmentSession.findAll({ const sessions = await AssessmentSession.findAll({
where: { assessment_id: assessmentId }, where: { assessment_id: assessmentId },
attributes: ["session_id", "user_id", "status", "started_at", "expires_at", "attempt_id", "createdAt", "updatedAt"], 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"]], order: [["createdAt", "DESC"]],
}); });
const rows = sessions.map((s) => { const rows = sessions.map((s) => {
const j = s.toJSON(); 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 const time_spent_seconds = j.status !== 'in_progress' && j.started_at
? Math.round((new Date(j.updatedAt) - new Date(j.started_at)) / 1000) ? Math.round((new Date(j.updatedAt) - new Date(j.started_at)) / 1000)
: null; : null;
@@ -1689,6 +1738,7 @@ exports.getAssessmentSessions = async (req, res) => {
full_name, full_name,
email, email,
avatar_url, avatar_url,
deleted,
status: j.status, status: j.status,
started_at: j.started_at, started_at: j.started_at,
expires_at: j.expires_at, expires_at: j.expires_at,
+53 -13
View File
@@ -12,6 +12,7 @@
***********************************************************************************************************************************************************************/ ***********************************************************************************************************************************************************************/
"use strict"; "use strict";
const { Op } = require("sequelize");
const jwt = require("jsonwebtoken"); const jwt = require("jsonwebtoken");
const R = require("../../utils/response.util"); const R = require("../../utils/response.util");
@@ -29,6 +30,21 @@ function resolveIp(req) {
return req.ip ?? req.socket?.remoteAddress ?? "unknown"; 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 ────────────────────────────────────────────────── // ─── POST /admin/media/token ──────────────────────────────────────────────────
exports.issueToken = async (req, res) => { exports.issueToken = async (req, res) => {
@@ -52,19 +68,7 @@ exports.issueToken = async (req, res) => {
} }
const ip = resolveIp(req); const ip = resolveIp(req);
const token = signToken(asset, req.user.user_id, ip);
const token = jwt.sign(
{
asset_id,
user_id: req.user.user_id,
storage_key: asset.storage_key,
file_type: asset.file_type,
mime_type: asset.mime_type,
ip,
},
MEDIA_SECRET,
{ expiresIn: TOKEN_TTL_SEC }
);
// ── Presign thumbnail URL so the browser can load it directly ───────────── // ── Presign thumbnail URL so the browser can load it directly ─────────────
let thumbnail_url = null; let thumbnail_url = null;
@@ -88,3 +92,39 @@ exports.issueToken = async (req, res) => {
return R.error(res, "Could not issue media token.", 500); 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);
}
};
@@ -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);
}
};
@@ -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);
}
};
+35 -12
View File
@@ -10,6 +10,7 @@
***********************************************************************************************************************************************************************/ ***********************************************************************************************************************************************************************/
const { Op } = require('sequelize'); const { Op } = require('sequelize');
const mdl_TierCategories = require('../../models/tiers/tier_categories.mdl');
const mdl_TierPlans = require('../../models/tiers/tier_plans.mdl'); const mdl_TierPlans = require('../../models/tiers/tier_plans.mdl');
const mdl_UserTiers = require('../../models/tiers/user_tiers.mdl'); const mdl_UserTiers = require('../../models/tiers/user_tiers.mdl');
const mdl_Payments = require('../../models/tiers/payments.mdl'); const mdl_Payments = require('../../models/tiers/payments.mdl');
@@ -84,14 +85,24 @@ exports.getPlan = async (req, res) => {
exports.createPlan = async (req, res) => { exports.createPlan = async (req, res) => {
try { try {
const { tier, label, duration_days, price, currency } = req.body; const { tier_category_id, label, duration_days, price, currency } = req.body;
if (!tier || !label || !duration_days || !price) if (!tier_category_id || !label || !duration_days || !price)
return R.error(res, 'tier, label, duration_days, and price are required.', 400); 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 }); const plain = plan.get({ plain: true });
logActivity(req.user?.user_id, 'create_tier_plan', { entityType: 'tier_plan', details: { label: plain.label, tier: plain.tier } }); 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); return R.success(res, 'Plan created.', { ...plain, plan_id: String(plain.plan_id) }, 201);
} catch (err) { } catch (err) {
console.error('[ADMIN][CREATE PLAN]', 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); const plan = await mdl_TierPlans.findByPk(req.params.id);
if (!plan) return R.error(res, 'Plan not found.', 404); 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 = {}; 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); await plan.update(updates);
logActivity(req.user?.user_id, 'update_tier_plan', { entityType: 'tier_plan', details: { label: plan.label } }); 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) => { exports.grantTier = async (req, res) => {
try { try {
const { user_id, tier, plan_id, notes } = req.body; const { user_id, plan_id, notes } = req.body;
if (!user_id || !tier || !plan_id) if (!user_id || !plan_id)
return R.error(res, 'user_id, tier, and plan_id are required.', 400); return R.error(res, 'user_id and plan_id are required.', 400);
const user = await mdl_Users.findByPk(user_id); const user = await mdl_Users.findByPk(user_id);
if (!user) return R.error(res, 'User not found.', 404); if (!user) return R.error(res, 'User not found.', 404);
const plan = await mdl_TierPlans.findByPk(plan_id); 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 || !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( await mdl_UserTiers.update(
{ status: 'expired' }, { status: 'expired' },
@@ -254,7 +277,7 @@ exports.grantTier = async (req, res) => {
const expiresAt = new Date(startsAt.getTime() + plan.duration_days * 86400000); const expiresAt = new Date(startsAt.getTime() + plan.duration_days * 86400000);
const newTier = await mdl_UserTiers.create({ const newTier = await mdl_UserTiers.create({
user_id, tier, status: 'active', user_id, tier, plan_id, status: 'active',
starts_at: startsAt, starts_at: startsAt,
expires_at: expiresAt, expires_at: expiresAt,
granted_by: req.user.user_id, granted_by: req.user.user_id,
@@ -286,6 +286,7 @@ exports.getUsersNotInGroup = async (req, res) => {
try { try {
const { gid: group_id } = req.params; 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 members = await mdl_UserGroupMembers.findAll({ where: { group_id }, attributes: ['user_id'] });
const memberIds = members.map((m) => m.user_id); const memberIds = members.map((m) => m.user_id);
@@ -294,6 +295,19 @@ exports.getUsersNotInGroup = async (req, res) => {
attributes: [ attributes: [
'user_id', 'user_id',
[Sequelize.literal(`("User"."personal_info"->'name'->>'full_name')`), 'full_name'], [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',
],
], ],
}); });
+286
View File
@@ -14,9 +14,11 @@ const crypto = require('crypto');
const mdl_Users = require('../../models/users/users.mdl'); const mdl_Users = require('../../models/users/users.mdl');
const mdl_UserSessions = require('../../models/users/user_sessions.mdl'); const mdl_UserSessions = require('../../models/users/user_sessions.mdl');
const mdl_Achievements = require('../../models/users/achievements.mdl'); const mdl_Achievements = require('../../models/users/achievements.mdl');
const mdl_UserBans = require('../../models/users/user_bans.mdl');
const { mdl_UserGroups, mdl_UserGroupMembers } = require('../../models/users/user_groups.mdl'); const { mdl_UserGroups, mdl_UserGroupMembers } = require('../../models/users/user_groups.mdl');
const { sendEmail } = require('../../services/email.service'); const { sendEmail } = require('../../services/email.service');
const { fmtDate } = require('../../utils/datetime.util');
const logActivity = require('../../utils/logActivity.util'); const logActivity = require('../../utils/logActivity.util');
const R = require('../../utils/response.util'); const R = require('../../utils/response.util');
const { paginate } = require('../../utils/paginate.util'); const { paginate } = require('../../utils/paginate.util');
@@ -384,3 +386,287 @@ exports.getUserAchievements = async (req, res) => {
return R.error(res, 'Could not retrieve achievements.', 500); 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);
}
};
+101 -8
View File
@@ -28,6 +28,7 @@ const bcrypt = require('bcryptjs');
const sequelize = require('../config/db.config') const sequelize = require('../config/db.config')
const mdl_Users = require('../models/users/users.mdl'); const mdl_Users = require('../models/users/users.mdl');
const mdl_UserSessions = require('../models/users/user_sessions.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 { mdl_UserGroups, mdl_UserGroupMembers } = require('../models/users/user_groups.mdl')
const { generateTokens, verifyRefreshToken, hashToken, shouldRotateRefreshToken } = require('../utils/token.util'); const { generateTokens, verifyRefreshToken, hashToken, shouldRotateRefreshToken } = require('../utils/token.util');
const { generateState, generateNonce, generatePKCE, buildAuthUrl, exchangeCode, verifyIdToken } = require('../utils/google_oidc.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(); 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) { if (group) {
AdminNotification.create({ AdminNotification.create({
...NOTIFICATION_REGISTRY.user_registration.build({ ...NOTIFICATION_REGISTRY.user_registration.build({
@@ -115,6 +116,13 @@ exports.register = async (req, res) => {
userEmail: email, userEmail: email,
}), }),
}).catch(err => console.error('[AUTH] Failed to emit user_registration notification:', err)); }).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.', { 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) return R.error(res, 'User not found.', 404);
if (user.is_verified) return R.error(res, 'Account already verified.', 400); 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); 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 }); 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({ mdl_UserGroupMembers.findOne({
where: { user_id: user.user_id }, where: { user_id: user.user_id },
include: [{ model: mdl_UserGroups, attributes: ['name', 'group_code'] }], include: [{ model: mdl_UserGroups, attributes: ['name', 'group_code'] }],
}).then(membership => { }).then(async membership => {
const grp = membership?.UserGroup; const grp = membership?.UserGroup;
return UserNotification.create({ const now = new Date();
const notifications = [
{
user_id: user.user_id, user_id: user.user_id,
...NOTIFICATION_REGISTRY.welcome.build({ ...NOTIFICATION_REGISTRY.welcome.build({
groupName: grp?.name ?? null, groupName: grp?.name ?? null,
groupCode: grp?.group_code ?? null, groupCode: grp?.group_code ?? null,
accType: user.acc_type, 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)); }).catch(err => console.error('[AUTH] Failed to emit welcome notification:', err));
res.cookie('refreshToken', refreshToken, { 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.', { return R.success(res, 'Email verified successfully. You are now logged in.', {
accessToken, accessToken,
refreshToken,
session_id: session.session_id, session_id: session.session_id,
user: safeUser(user), 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_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_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); const match = await bcrypt.compare(password, user.password);
if (!match) return R.error(res, 'Invalid credentials.', 401); 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); const payload = await verifyIdToken(tokens.id_token, nonce);
// Find or auto-create the user. // 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) { if (!user) {
const t = await sequelize.transaction(); const t = await sequelize.transaction();
try { try {
@@ -353,9 +399,33 @@ exports.googleCallback = async (req, res) => {
await t.commit(); 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) onUserRegistered(user.user_id)
.catch(err => console.error('[AUTH] googleCallback: Failed to grant achievements:', err)); .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) { } catch (err) {
await t.rollback(); await t.rollback();
throw err; throw err;
@@ -366,6 +436,23 @@ exports.googleCallback = async (req, res) => {
return res.redirect(`${CALLBACK_PAGE}?error=account_deactivated`); 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 { accessToken, refreshToken } = generateTokens(user);
const googleSession = await mdl_UserSessions.create({ const googleSession = await mdl_UserSessions.create({
user_id: user.user_id, user_id: user.user_id,
@@ -409,6 +496,12 @@ exports.refreshToken = async (req, res) => {
const user = await mdl_Users.findByPk(decoded.user_id); 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 || !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 // Check if refresh token is expired
if (!shouldRotateRefreshToken(decoded)) { if (!shouldRotateRefreshToken(decoded)) {
const { accessToken } = generateTokens(user); const { accessToken } = generateTokens(user);
@@ -426,7 +519,7 @@ exports.refreshToken = async (req, res) => {
maxAge: 7 * 24 * 60 * 60 * 1000, 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) { } catch (err) {
console.error('[AUTH] refresh token error:', err); console.error('[AUTH] refresh token error:', err);
return R.error(res, 'Invalid or expired refresh token.', 401); return R.error(res, 'Invalid or expired refresh token.', 401);
+5 -6
View File
@@ -15,6 +15,7 @@ const R = require('../../utils/response.util');
const mdl_Users = require('../../models/users/users.mdl'); const mdl_Users = require('../../models/users/users.mdl');
const { generateCertificate } = require('../../services/certificate.service'); const { generateCertificate } = require('../../services/certificate.service');
const { formatDuration } = require('../../utils/duration.util'); const { formatDuration } = require('../../utils/duration.util');
const { fmtDate } = require('../../utils/datetime.util');
const { const {
Course, Course,
@@ -134,11 +135,7 @@ exports.getCertificate = async (req, res) => {
// ── 5. Format issued date as MM/DD/YY HH:MM AM/PM ─────────────────────────── // ── 5. Format issued date as MM/DD/YY HH:MM AM/PM ───────────────────────────
const issuedDate = new Date(cert.issued_at); const issuedDate = new Date(cert.issued_at);
const dateStr = new Intl.DateTimeFormat('en-US', { const dateStr = fmtDate(issuedDate);
month: 'long',
day: 'numeric',
year: 'numeric',
}).format(issuedDate);
// ── 6. Generate PDF ──────────────────────────────────────────────────────── // ── 6. Generate PDF ────────────────────────────────────────────────────────
const pdf = await generateCertificate({ const pdf = await generateCertificate({
@@ -157,10 +154,12 @@ exports.getCertificate = async (req, res) => {
const firstName = nameParts.length > 1 ? nameParts.slice(0, -1).join(' ') : ''; const firstName = nameParts.length > 1 ? nameParts.slice(0, -1).join(' ') : '';
const safeTitle = course.title.replace(/[/\\:*?"<>|]/g, '').trim(); const safeTitle = course.title.replace(/[/\\:*?"<>|]/g, '').trim();
const filename = `${lastName},${firstName}_${safeTitle}_${cert.cert_no}.pdf`; const filename = `${lastName},${firstName}_${safeTitle}_${cert.cert_no}.pdf`;
const asciiName = filename.replace(/[^\x20-\x7E]/g, '_').replace(/"/g, '_');
const encodedName = encodeURIComponent(filename);
res.set({ res.set({
'Content-Type': 'application/pdf', 'Content-Type': 'application/pdf',
'Content-Disposition': `attachment; filename="${filename}"`, 'Content-Disposition': `attachment; filename="${asciiName}"; filename*=UTF-8''${encodedName}`,
'Content-Length': pdf.length, 'Content-Length': pdf.length,
}); });
@@ -6,24 +6,31 @@
* GET /client/courses/in-progress * GET /client/courses/in-progress
* → courses where the current user has status = 'in_progress', with lesson counts * → 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 * 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 * POST /client/courses/:courseId/units/:unitId/lessons/:lessonId/progress
* → UPSERT lesson + derives + UPSERTs parent unit + course in one transaction * → UPSERT lesson + derives + UPSERTs parent unit + course in one transaction
* * → side-effects: writes to lesson_reading_progress / unit_reading_progress,
* Body (POST): * syncs task_progress for matching task requirements,
* { status: 'in_progress' | 'completed' } * returns completed_tasks for any task whose read requirements are now all done
* Defaults to 'in_progress' if omitted (on first visit).
* *
* Author: Kenneth Obsequio (@lash0000) * Author: Kenneth Obsequio (@lash0000)
* Date Created: Jun. 21, 2026 * Date Created: Jun. 21, 2026
***********************************************************************************************************************************************************************/ ***********************************************************************************************************************************************************************/
'use strict'; 'use strict';
const { Op } = require('sequelize');
const R = require('../../utils/response.util'); const R = require('../../utils/response.util');
const logActivity = require('../../utils/logActivity.util'); const logActivity = require('../../utils/logActivity.util');
const { upsertLessonRead } = require('../../services/course_reading_progress.service'); 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 CourseReadingProgress = require('../../models/courses/course_reading_progress.mdl');
const Certificate = require('../../models/courses/certificate.mdl'); const Certificate = require('../../models/courses/certificate.mdl');
const { const {
@@ -31,24 +38,132 @@ const {
UnitQuiz, CourseAssessment, QuizAttempt, UnitQuiz, CourseAssessment, QuizAttempt,
} = require('../../models/courses/courses.associations'); } = 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 }; 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 ────────────────────── // ── 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) => { exports.getMyInProgressCourses = async (req, res) => {
try { try {
const userId = req.user.user_id; const userId = req.user.user_id;
// All course-level progress rows for this user (any reading status)
const courseRows = await CourseReadingProgress.findAll({ const courseRows = await CourseReadingProgress.findAll({
where: { user_id: userId, type: 'course' }, where: { user_id: userId, type: 'course' },
attributes: ['course_id', 'status', 'last_accessed_at'], 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.', []); 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({ const certificates = await Certificate.findAll({
where: { user_id: userId }, where: { user_id: userId },
attributes: ['course_id'], attributes: ['course_id'],
@@ -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_quizzes = [];
let pending_assessment = null; let pending_assessment = null;
if (readingDone) { if (readingDone) {
// All unit quizzes in this course
const unitQuizzes = await UnitQuiz.findAll({ const unitQuizzes = await UnitQuiz.findAll({
attributes: ['quiz_id', 'title', 'is_required', 'passing_score'], attributes: ['quiz_id', 'title', 'is_required', 'passing_score'],
include: [{ include: [{
@@ -128,7 +240,6 @@ exports.getMyInProgressCourses = async (req, res) => {
} }
} }
// Course assessment
const assessment = await CourseAssessment.findOne({ const assessment = await CourseAssessment.findOne({
attributes: ['assessment_id', 'title', 'is_required', 'passing_score'], attributes: ['assessment_id', 'title', 'is_required', 'passing_score'],
where: { course_id: courseId }, where: { course_id: courseId },
@@ -173,11 +284,6 @@ exports.getMyInProgressCourses = async (req, res) => {
// ── GET PROGRESS SUMMARY ────────────────────────────────────────────────────── // ── 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) => { exports.getCourseProgressSummary = async (req, res) => {
try { try {
const { courseId } = req.params; const { courseId } = req.params;
@@ -226,10 +332,6 @@ exports.getCourseProgressSummary = async (req, res) => {
// ── GET PROGRESS SNAPSHOT ───────────────────────────────────────────────────── // ── 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) => { exports.getCourseProgress = async (req, res) => {
try { try {
const { courseId } = req.params; 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 ──────────────────────────────────────────────────── // ── UPSERT LESSON PROGRESS ────────────────────────────────────────────────────
// ============================================================================= // =============================================================================
@@ -262,7 +458,10 @@ exports.getCourseProgress = async (req, res) => {
// //
// Flow: // Flow:
// 1. Resolve course / unit / lesson to get their UUIDs // 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) => { exports.upsertLessonProgress = async (req, res) => {
try { try {
@@ -289,6 +488,7 @@ exports.upsertLessonProgress = async (req, res) => {
if (!unit) return R.error(res, 'Unit not found.', 404); if (!unit) return R.error(res, 'Unit not found.', 404);
if (!lesson) return R.error(res, 'Lesson not found.', 404); if (!lesson) return R.error(res, 'Lesson not found.', 404);
// ── 1. Primary write: course_reading_progress ─────────────────────────
const result = await upsertLessonRead(userId, { const result = await upsertLessonRead(userId, {
courseId: course.course_id, courseId: course.course_id,
courseUuid: course.uuid, courseUuid: course.uuid,
@@ -298,13 +498,31 @@ exports.upsertLessonProgress = async (req, res) => {
lessonStatus: status, 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', { logActivity(userId, 'lesson_read', {
entityType: 'lesson', entityType: 'lesson',
entityId: lesson.lesson_id, entityId: lesson.lesson_id,
details: { lesson_uuid: lesson.uuid, status }, 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) { } catch (err) {
console.error('[CLIENT][COURSE READING PROGRESS][UPSERT]', err); console.error('[CLIENT][COURSE READING PROGRESS][UPSERT]', err);
return R.error(res, 'Could not update progress.', 500); return R.error(res, 'Could not update progress.', 500);
+84 -57
View File
@@ -16,8 +16,6 @@
const { Op } = require("sequelize"); const { Op } = require("sequelize");
const R = require("../../utils/response.util"); const R = require("../../utils/response.util");
const mdl_UserTiers = require("../../models/tiers/user_tiers.mdl"); 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_CoursePurchase = require("../../models/courses/course_purchases.mdl");
const mdl_Product = require("../../models/courses/products.mdl"); const mdl_Product = require("../../models/courses/products.mdl");
const mdl_Category = require("../../models/courses/categories.mdl"); const mdl_Category = require("../../models/courses/categories.mdl");
@@ -28,10 +26,11 @@ const {
CourseObjective, LessonObjective, CourseObjective, LessonObjective,
CoursePrerequisite, CourseAssessment, CoursePrerequisite, CourseAssessment,
UnitQuiz, QuizQuestion, QuizOption, QuizAttempt, UnitQuiz, QuizQuestion, QuizOption, QuizAttempt,
AssessmentSession, AssessmentSession, QuizSession,
} = require("../../models/courses/courses.associations"); } = require("../../models/courses/courses.associations");
const mdl_TierCategories = require("../../models/tiers/tier_categories.mdl");
const { gradeSubmission } = require("../../utils/courses/grading.util"); const { gradeSubmission } = require("../../utils/courses/grading.util");
const { shuffleOptions, getAttemptStatus, ASSESSMENT_FAILS_BEFORE_COOLDOWN, ASSESSMENT_COOLDOWN_HOURS } = require("../../utils/courses/quiz_security.util"); const { shuffleOptions, shuffleQuestions, getAttemptStatus } = require("../../utils/courses/quiz_security.util");
const { onCourseCompleted } = require('../../services/achievements.service'); const { onCourseCompleted } = require('../../services/achievements.service');
const PendingCertificate = require('../../models/courses/pending_certificate.mdl'); const PendingCertificate = require('../../models/courses/pending_certificate.mdl');
const Certificate = require('../../models/courses/certificate.mdl'); const Certificate = require('../../models/courses/certificate.mdl');
@@ -78,37 +77,33 @@ async function expireSession(session, passingScore) {
return expiredAttempt; 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 ───────────────────────────────────── // ─── Shared tier + purchase access check ─────────────────────────────────────
// Returns true → user may access the course. // Returns true → user may access the course.
// Returns false → user's tier is too low AND no valid individual purchase. // Returns false → user's tier is too low AND no valid individual purchase.
async function canAccessCourse(user_id, course_id) { async function canAccessCourse(user_id, course_id) {
let requiredTier = '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'] }); const course = await Course.findOne({ where: { course_id, ...notDeleted }, attributes: ['subscription'] });
requiredTier = course?.subscription ?? 'free'; const 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';
}
if (requiredTier === 'free') return true; const userCtx = await buildUserContext(user_id);
const tierRank = { free: 0, premium: 1, exclusive: 2 }; // Rank-0 slugs (default/free tier) are always accessible — resolved dynamically
const activeTier = await getActiveTier(user_id); const courseRank = userCtx.tierRankMap[requiredTier] ?? Infinity;
const userRank = tierRank[activeTier?.tier ?? 'free'] ?? 0; if (courseRank === 0) return true;
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 // Individual purchase as fallback
const product = await mdl_Product.findOne({ where: { course_id } }); const product = await mdl_Product.findOne({ where: { course_id } });
@@ -160,10 +155,8 @@ exports.getCourses = async (req, res) => {
try { try {
const { category } = req.query; // optional slug filter const { category } = req.query; // optional slug filter
const activeTier = await getActiveTier(req.user.user_id); const userCtx = await buildUserContext(req.user.user_id);
const userTier = activeTier?.tier ?? 'free'; const userTier = userCtx.tier;
const tierRank = { free: 0, premium: 1, exclusive: 2 };
const userRank = tierRank[userTier] ?? 0;
// Fetch all completed purchases for this user (for has_purchased check) // Fetch all completed purchases for this user (for has_purchased check)
const myPurchases = await mdl_CoursePurchase.findAll({ const myPurchases = await mdl_CoursePurchase.findAll({
@@ -192,13 +185,6 @@ exports.getCourses = async (req, res) => {
where: { ...notDeleted }, where: { ...notDeleted },
attributes: COURSE_LIST_ATTRS, attributes: COURSE_LIST_ATTRS,
include: [ include: [
{
model: mdl_PlanCourses,
as: 'planCourse',
required: false,
attributes: ['id', 'plan_id'],
include: [{ model: mdl_TierPlans, as: 'plan', attributes: ['tier'] }],
},
{ {
model: mdl_Product, model: mdl_Product,
as: 'product', as: 'product',
@@ -211,21 +197,17 @@ exports.getCourses = async (req, res) => {
order: [['order_index', 'ASC'], ['title', 'ASC']], order: [['order_index', 'ASC'], ['title', 'ASC']],
}); });
const userRank = userCtx.tierRankMap[userCtx.tier] ?? 0;
const result = courses.map((c) => { const result = courses.map((c) => {
const plain = c.toJSON(); const plain = c.toJSON();
const planCourse = plain.planCourse;
const plan_tier = planCourse?.plan?.tier ?? null;
const has_purchased = purchasedCourseIds.has(String(plain.course_id)); 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'; const is_locked = courseRank > 0 && !has_purchased && userRank < courseRank;
let is_locked = false;
if (effectiveTier && effectiveTier !== 'free') {
const reqRank = tierRank[effectiveTier] ?? 0;
if (userRank < reqRank && !has_purchased) is_locked = true;
}
delete plain.planCourse; return { ...plain, is_locked, has_purchased };
return { ...plain, is_locked, plan_tier: effectiveTier, has_purchased };
}); });
return R.success(res, "Courses retrieved.", result); 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 }, where: { assessment_id: plain.assessment.assessment_id, user_id: req.user.user_id, passed: true },
}); });
is_completed = !!passedAttempt; is_completed = !!passedAttempt;
plain.assessment = { ...plain.assessment, has_passed: is_completed };
} }
plain.is_completed = is_completed; plain.is_completed = is_completed;
const planCourse = await mdl_PlanCourses.findOne({ const plan_tier = plain.subscription ?? null;
where: { course_id: courseId },
include: [{ model: mdl_TierPlans, as: 'plan', attributes: ['tier'] }],
});
const plan_tier = planCourse?.plan?.tier ?? plain.subscription ?? null;
// Attach product info and purchase status for the buy-course flow // Attach product info and purchase status for the buy-course flow
const product = await mdl_Product.findOne({ const product = await mdl_Product.findOne({
@@ -472,7 +451,7 @@ exports.getUnitQuiz = async (req, res) => {
where: { unit_id: unitId, ...notDeleted }, where: { unit_id: unitId, ...notDeleted },
attributes: [ attributes: [
"quiz_id", "uuid", "title", "quiz_id", "uuid", "title",
"is_required", "passing_score", "max_questions", "is_required", "passing_score", "max_questions", "shuffle_questions",
], ],
include: [{ include: [{
model: QuizQuestion, as: "questions", model: QuizQuestion, as: "questions",
@@ -489,7 +468,9 @@ exports.getUnitQuiz = async (req, res) => {
if (!quiz) return R.error(res, "Quiz not found.", 404); if (!quiz) return R.error(res, "Quiz not found.", 404);
const plain = quiz.toJSON(); 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({ const attempts = await QuizAttempt.findAll({
where: { quiz_id: quiz.quiz_id, user_id: req.user.user_id }, 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.window_reset_at = status.window_reset_at;
plain.can_attempt = status.can_attempt; 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); return R.success(res, "Quiz retrieved.", plain);
} catch (err) { } catch (err) {
console.error("[CLIENT][QUIZ][GET]", err); console.error("[CLIENT][QUIZ][GET]", err);
@@ -524,7 +511,7 @@ exports.getCourseAssessment = async (req, res) => {
"assessment_id", "uuid", "title", "assessment_id", "uuid", "title",
"is_required", "passing_score", "is_required", "passing_score",
"time_limit_minutes", "max_questions", "time_limit_minutes", "max_questions",
"max_attempts", "cooldown_hours", "max_attempts", "cooldown_hours", "shuffle_questions",
], ],
include: [{ include: [{
model: QuizQuestion, as: "questions", model: QuizQuestion, as: "questions",
@@ -541,7 +528,9 @@ exports.getCourseAssessment = async (req, res) => {
if (!assessment) return R.error(res, "Assessment not found.", 404); if (!assessment) return R.error(res, "Assessment not found.", 404);
const plain = assessment.toJSON(); 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 // All graded attempts for cooldown/status calc
const attempts = await QuizAttempt.findAll({ const attempts = await QuizAttempt.findAll({
@@ -791,6 +780,12 @@ exports.submitUnitQuiz = async (req, res) => {
passed, 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.", { return R.success(res, "Quiz submitted.", {
attempt_id: attempt.attempt_id, attempt_id: attempt.attempt_id,
attempt_number: attempt.attempt_number, 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) => { exports.submitCourseAssessment = async (req, res) => {
try { try {
const { courseId, assessmentId } = req.params; const { courseId, assessmentId } = req.params;
+10 -1
View File
@@ -79,6 +79,11 @@ function pipeRemoteStream(remoteUrl, req, res) {
const proxyHeaders = { "User-Agent": "StarrMediaProxy/1.0" }; const proxyHeaders = { "User-Agent": "StarrMediaProxy/1.0" };
if (req.headers.range) proxyHeaders["Range"] = req.headers.range; 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 proxyReq = transport.request(remoteUrl, { headers: proxyHeaders }, (proxyRes) => {
const status = proxyRes.statusCode === 206 ? 206 : 200; const status = proxyRes.statusCode === 206 ? 206 : 200;
@@ -101,11 +106,15 @@ function pipeRemoteStream(remoteUrl, req, res) {
}); });
proxyReq.on("error", (err) => { proxyReq.on("error", (err) => {
if (clientClosed) return; // browser navigated away / component unmounted — expected
console.error("[CLIENT][MEDIA][PROXY] Stream error:", err.message); console.error("[CLIENT][MEDIA][PROXY] Stream error:", err.message);
if (!res.headersSent) res.status(502).json({ message: "Stream unavailable." }); if (!res.headersSent) res.status(502).json({ message: "Stream unavailable." });
}); });
req.on("close", () => proxyReq.destroy()); req.on("close", () => {
clientClosed = true;
proxyReq.destroy();
});
proxyReq.end(); proxyReq.end();
} }
+29
View File
@@ -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 ────────────────────────────────────────────────────── // ─── GET own achievements ──────────────────────────────────────────────────────
exports.getAchievements = async (req, res) => { exports.getAchievements = async (req, res) => {
@@ -8,6 +8,8 @@
* *
* POST /client/groups/:groupId/task-lists/:taskListId/tasks/:taskId/requirements/:requirementId/visit * POST /client/groups/:groupId/task-lists/:taskListId/tasks/:taskId/requirements/:requirementId/visit
* → UPSERT TaskLinkVisit (visit_link) * → 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 * POST /client/groups/:groupId/task-lists/:taskListId/tasks/:taskId/requirements/:requirementId/progress
* → UPSERT TaskProgress (read_lesson) + derives read_unit + read_course * → 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) ────────────────── // ── UPDATE LESSON PROGRESS (UPSERT — derives unit + course) ──────────────────
// ============================================================================= // =============================================================================
+93 -6
View File
@@ -10,10 +10,12 @@
* Date Created: Jun. 6, 2026 * Date Created: Jun. 6, 2026
* Modified: Jun. 9, 2026 * Modified: Jun. 9, 2026
***********************************************************************************************************************************************************************/ ***********************************************************************************************************************************************************************/
const mdl_TierCategories = require('../../models/tiers/tier_categories.mdl');
const mdl_TierPlans = require('../../models/tiers/tier_plans.mdl'); const mdl_TierPlans = require('../../models/tiers/tier_plans.mdl');
const mdl_UserTiers = require('../../models/tiers/user_tiers.mdl'); const mdl_UserTiers = require('../../models/tiers/user_tiers.mdl');
const mdl_Payments = require('../../models/tiers/payments.mdl'); const mdl_Payments = require('../../models/tiers/payments.mdl');
const mdl_PlanCourses = require('../../models/tiers/plan_courses.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 { onTierActivated } = require('../../services/achievements.service');
const { Course } = require('../../models/courses/courses.mdl'); const { Course } = require('../../models/courses/courses.mdl');
const paypal = require('../../services/paypal.service'); const paypal = require('../../services/paypal.service');
@@ -49,9 +51,42 @@ exports.getMyTier = async (req, res) => {
try { try {
const tier = await mdl_UserTiers.findOne({ const tier = await mdl_UserTiers.findOne({
where: { user_id: req.user.user_id, status: 'active' }, 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']], 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) { } catch (err) {
console.error('[CLIENT][GET MY TIER]', err); console.error('[CLIENT][GET MY TIER]', err);
return R.error(res, 'Could not retrieve tier.', 500); return R.error(res, 'Could not retrieve tier.', 500);
@@ -282,8 +317,41 @@ 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 ──────────────── // — add refundOrder export ────────────────
const REFUND_WINDOW_MS = 5 * 60 * 1000; // 5 minutes from paid_at
exports.refundOrder = async (req, res) => { exports.refundOrder = async (req, res) => {
try { try {
const user_id = req.user.user_id; const user_id = req.user.user_id;
@@ -302,6 +370,10 @@ exports.refundOrder = async (req, res) => {
}); });
if (!payment) return R.error(res, 'No completed payment found for this tier.', 404); 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 // Get capture_id from provider_payload
const captureId = payment.provider_payload?.capture_id; const captureId = payment.provider_payload?.capture_id;
if (!captureId) return R.error(res, 'Capture ID not found. Cannot process refund.', 400); if (!captureId) return R.error(res, 'Capture ID not found. Cannot process refund.', 400);
@@ -325,13 +397,28 @@ exports.refundOrder = async (req, res) => {
}, },
}); });
// Cancel tier at end of period — keep access until expires_at // Immediately terminate access — cut expires_at to now and revoke
await activeTier.update({ status: 'revoked' }); const now = new Date();
await activeTier.update({
status: 'revoked',
expires_at: now,
revoked_at: now,
});
return R.success(res, 'Refund processed successfully. Your access will remain until the end of the billing period.', { // 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, refund_id: refundData.id,
status: refundData.status, status: refundData.status,
expires_at: activeTier.expires_at,
}); });
} catch (err) { } catch (err) {
console.error('[CLIENT][REFUND]', err); console.error('[CLIENT][REFUND]', err);
+2
View File
@@ -22,10 +22,12 @@
***********************************************************************************************************************************************************************/ ***********************************************************************************************************************************************************************/
const cron = require('node-cron'); 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 ───────────────────────── // ─── Registry — add future admin-side cron jobs here ─────────────────────────
const jobs = [ const jobs = [
taskOverdue, taskOverdue,
liftExpiredBans,
]; ];
// ─── Boot all registered admin-side jobs ────────────────────────────────────── // ─── Boot all registered admin-side jobs ──────────────────────────────────────
+4 -6
View File
@@ -1,8 +1,8 @@
/*********************************************************************************************************************************************************************** /***********************************************************************************************************************************************************************
* File Name : issue_certificates.cron.js * File Name : issue_certificates.cron.js
* Type : Cron Job * Type : Cron Job
* Description : Issues certificates for users who passed a course assessment * Description : Issues certificates for users who passed a course assessment.
* 45 minutes ago. Runs every 5 minutes and processes any * Runs every hour on the hour and processes any
* pending_certificates row where issue_at <= NOW() and * pending_certificates row where issue_at <= NOW() and
* processed_at IS NULL. * processed_at IS NULL.
* *
@@ -16,7 +16,7 @@
* step 2 succeed. If the process restarts mid-run the row will be * 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. * 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. * cron/client.cron.js.
* *
* Author: Kenneth Obsequio (@lash0000) * 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 UserNotification = require('../../models/notifications/user_notification.mdl');
const { NOTIFICATION_REGISTRY } = require('../../data/notifications.data'); const { NOTIFICATION_REGISTRY } = require('../../data/notifications.data');
const DELAY_MS = 5 * 60 * 1000; // 5 minutes
async function run() { async function run() {
// ── 1. Fetch all rows ready to process ──────────────────────────────────── // ── 1. Fetch all rows ready to process ────────────────────────────────────
let rows; let rows;
@@ -104,6 +102,6 @@ async function run() {
module.exports = { module.exports = {
name: 'issueCertificates', name: 'issueCertificates',
schedule: '5 * * * *', schedule: '0 * * * *',
run, run,
}; };
+85
View File
@@ -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,
};
+70 -54
View File
@@ -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) => `
<html>
<body style="${FONT} line-height: 1.6;">
${body.trim()}
<br><br>
<p style="margin: 0;">Regards,<br>Philproperties IT Team</p>
<p style="margin: 0; font-size: 12px; color: #555;">This is an automated message from STARR System. Please do not reply.</p>
</body>
</html>`.trim();
export const emailTemplates = { export const emailTemplates = {
OTP: ({ otp, expiryMinutes = 10 }) => ({ OTP: ({ otp, expiryMinutes = 10 }) => ({
subject: "Email OTP Verification - STARR System", subject: "Email OTP Verification - STARR System",
text: build(` html: wrap(`
Dear User, <p>Dear User,</p>
<p>Please use the One-Time Password (OTP) below to verify your email address. This code is valid for <strong>${expiryMinutes} minutes</strong>.</p>
Please use the One-Time Password (OTP) below to verify your email address. This code is valid for ${expiryMinutes} minutes. <p style="font-size: 28px; font-weight: bold; letter-spacing: 6px;">${otp}</p>
<p>For security reasons, please do not share this code with anyone. If you did not request this, please contact the administrator.</p>
${otp}
For security reasons, please do not share this code with anyone. If you did not request this, please contact the administrator.
`), `),
}), }),
WELCOME: ({ name }) => ({ WELCOME: ({ name }) => ({
subject: "Welcome to STARR System", subject: "Welcome to STARR System",
text: build(` html: wrap(`
Dear ${name}, <p>Dear ${name},</p>
<p>We are pleased to welcome you to the STARR System. Your account has been successfully created and is now ready for use.</p>
We are pleased to welcome you to the STARR System. Your account has been successfully created and is now ready for use. <p>You may now access your dashboard and begin using the available services.</p>
<p>We look forward to supporting you.</p>
You may now access your dashboard and begin using the available services.
We look forward to supporting you.
`), `),
}), }),
PASSWORD_CHANGED: () => ({ PASSWORD_CHANGED: () => ({
subject: "Password Update Confirmation - STARR System", subject: "Password Update Confirmation - STARR System",
text: build(` html: wrap(`
Dear User, <p>Dear User,</p>
<p>This is to confirm that your account password has been successfully changed.</p>
This is to confirm that your account password has been successfully changed. <p>If you did not perform this action, please reset your password immediately or contact support.</p>
<p>For your security, we recommend using a strong and unique password.</p>
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 }) => ({ ADDED_TO_GROUP: ({ groupName }) => ({
subject: "Group Assignment Notification - STARR System", subject: "Group Assignment Notification - STARR System",
text: build(` html: wrap(`
Dear User, <p>Dear User,</p>
<p>You have been assigned to the group <strong>${groupName}</strong> in the STARR System.</p>
You have been assigned to the group "${groupName}" in the STARR System. <p>This assignment grants you access to shared resources and collaboration tools within the group.</p>
<p>Please log in to your account to view group details.</p>
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 }) => ({ TASK_ASSIGNED: ({ taskTitle, dueDate }) => ({
subject: "New Task Assignment - STARR System", subject: "New Task Assignment - STARR System",
text: build(` html: wrap(`
Dear User, <p>Dear User,</p>
<p>You have been assigned a new task in the STARR System.</p>
<table style="${FONT}">
<tr><td><strong>Task</strong></td><td>${taskTitle}</td></tr>
<tr><td><strong>Due Date</strong></td><td>${dueDate}</td></tr>
</table>
<p>Kindly ensure completion within the specified timeframe.</p>
`),
}),
You have been assigned a new task in the STARR System. BAN_LIFTED: ({ name, email, date }) => ({
subject: "Account Suspension Lifted - STARR System",
html: wrap(`
<p>Dear ${name},</p>
<p>We are writing to inform you that the suspension on your account (<strong>${email}</strong>) has been lifted effective <strong>${date}</strong>.</p>
<p>You may now log in and resume access to all services within the STARR System.</p>
<p>If you have any concerns, please do not hesitate to contact your administrator.</p>
`),
}),
Task: ${taskTitle} BANNED: ({ name, email, date, reason, ban_type }) => ({
Due Date: ${dueDate} subject: "Account Suspension Notice - STARR System",
html: wrap(`
Kindly ensure completion within the specified timeframe. <p>Dear ${name},</p>
<p>Your account (<strong>${email}</strong>) has been ${ban_type === 'permanent' ? 'permanently' : 'temporarily'} suspended from the STARR System effective <strong>${date}</strong>.</p>
<table style="${FONT}">
<tr><td><strong>Reason</strong></td><td>${reason}</td></tr>
<tr><td><strong>Duration</strong></td><td>${ban_type === 'permanent' ? 'Permanent' : 'Temporary'}</td></tr>
</table>
<p>During this period, access to all system services has been revoked.${ban_type === 'permanent' ? '' : ' This suspension may be lifted upon review by the administrator.'}</p>
<p>If you believe this was made in error, please contact your administrator.</p>
`), `),
}), }),
ADD_STAFF: ({ name, email, password, expiryHours = 24 }) => ({ ADD_STAFF: ({ name, email, password, expiryHours = 24 }) => ({
subject: "Your Staff Account Has Been Created - STARR System", subject: "Your Staff Account Has Been Created - STARR System",
text: build(` html: wrap(`
Dear ${name}, <p>Dear ${name},</p>
<p>Your staff account has been successfully created in the STARR System. Below are your login credentials:</p>
Your staff account has been successfully created in the STARR System. Below are your login credentials: <table style="${FONT}">
<tr><td><strong>Email</strong></td><td>${email}</td></tr>
Email: ${email} <tr><td><strong>Password</strong></td><td>${password}</td></tr>
Password: ${password} </table>
<p>This temporary password is valid for <strong>${expiryHours} hours</strong>. You will be required to change it upon first login.</p>
This temporary password is valid for ${expiryHours} hours. You will be required to change it upon first login. <p>If you did not request this account or believe this was created in error, please contact your administrator immediately to have it deactivated.</p>
<p>For security, please do not share your credentials with anyone. If you need a new password, contact your administrator.</p>
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.
`), `),
}), }),
}; };
+37 -4
View File
@@ -19,15 +19,18 @@
* No other changes needed. * No other changes needed.
* *
* Current types: * Current types:
* Admin : task_overdue * Admin : task_overdue, user_registration, nogrp_user_registered
* User : user_task_overdue, achievement, course_unlocked, * 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) * Author: Kenneth Obsequio (@lash0000)
* Date Created: Jun. 19, 2026 * Date Created: Jun. 19, 2026
***********************************************************************************************************************************************************************/ ***********************************************************************************************************************************************************************/
'use strict'; 'use strict';
const { fmtDate } = require('../utils/datetime.util');
const NOTIFICATION_REGISTRY = { 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') // USER notifications (scope: 'user')
// ───────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────
@@ -92,7 +110,7 @@ const NOTIFICATION_REGISTRY = {
return { return {
type: 'task', type: 'task',
title: 'Task Deadline Approaching', 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 }, data: { taskName, deadline },
}; };
}, },
@@ -136,7 +154,7 @@ const NOTIFICATION_REGISTRY = {
return { return {
type: 'course', type: 'course',
title: 'Course Completed', 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 }, 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 ────────────────────────────────────────────────────────────
assessment_updated: { assessment_updated: {
type: 'assessment', type: 'assessment',
@@ -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');
},
};
@@ -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');
},
};
@@ -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'))`
);
},
};
@@ -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');
},
};
@@ -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');
},
};
@@ -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');
},
};
@@ -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');
},
};
@@ -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'))`
);
},
};
@@ -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 });
},
};
@@ -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'))`
);
},
};
+27 -1
View File
@@ -36,6 +36,18 @@ const authenticate = async (req, res, next) => {
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_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; req.user = user;
next(); next();
} catch (err) { } catch (err) {
@@ -74,7 +86,21 @@ const softAuthenticate = async (req, res, next) => {
attributes: { exclude: ['password', 'otp_code', 'otp_expires_at'] }, 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(); next();
} catch (err) { } catch (err) {
if (err.name === 'TokenExpiredError') if (err.name === 'TokenExpiredError')
+32
View File
@@ -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 };
+1
View File
@@ -12,6 +12,7 @@ const CourseAssessment = sequelize.define("CourseAssessment", {
max_questions: { type: DataTypes.INTEGER, allowNull: true }, // null = show all max_questions: { type: DataTypes.INTEGER, allowNull: true }, // null = show all
max_attempts: { type: DataTypes.INTEGER, defaultValue: 3 }, // failed attempts before cooldown max_attempts: { type: DataTypes.INTEGER, defaultValue: 3 }, // failed attempts before cooldown
cooldown_hours: { type: DataTypes.INTEGER, defaultValue: 24 }, // hours locked after hitting max_attempts 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 }, createdBy: { type: DataTypes.BIGINT, allowNull: true },
updatedBy: { type: DataTypes.BIGINT, allowNull: true }, updatedBy: { type: DataTypes.BIGINT, allowNull: true },
deletedBy: { type: DataTypes.BIGINT, allowNull: true }, deletedBy: { type: DataTypes.BIGINT, allowNull: true },
+25 -2
View File
@@ -14,10 +14,13 @@ const QuizOption = require("./quiz_option.mdl");
const mdl_Users = require("../users/users.mdl"); const mdl_Users = require("../users/users.mdl");
const QuizAttempt = require("./quiz_attempt.mdl"); const QuizAttempt = require("./quiz_attempt.mdl");
const AssessmentSession = require("./assessment_session.mdl"); const AssessmentSession = require("./assessment_session.mdl");
const QuizSession = require("./quiz_session.mdl");
const mdl_Category = require("./categories.mdl"); const mdl_Category = require("./categories.mdl");
const Certificate = require("./certificate.mdl"); const Certificate = require("./certificate.mdl");
const CourseInstructor = require("./course_instructor.mdl"); const CourseInstructor = require("./course_instructor.mdl");
const CourseReadingProgress = require("./course_reading_progress.mdl"); const CourseReadingProgress = require("./course_reading_progress.mdl");
const UnitReadingProgress = require("./unit_reading_progress.mdl");
const LessonReadingProgress = require("./lesson_reading_progress.mdl");
// ── CourseReadingProgress ───────────────────────────────────────────────────── // ── CourseReadingProgress ─────────────────────────────────────────────────────
CourseReadingProgress.belongsTo(mdl_Users, { foreignKey: 'user_id', as: 'user' }); 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' }); mdl_Users.hasMany(CourseReadingProgress, { foreignKey: 'user_id', as: 'courseReadingProgress' });
Course.hasMany(CourseReadingProgress, { foreignKey: 'course_id', as: 'readingProgress' }); 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 ────────────────────────────────────────────────────────────────────
Course.belongsTo(mdl_Users, { as: "creator", foreignKey: "createdBy" }); Course.belongsTo(mdl_Users, { as: "creator", foreignKey: "createdBy" });
Course.belongsTo(mdl_Users, { as: "updater", foreignKey: "updatedBy" }); 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" }); AssessmentSession.belongsTo(mdl_Users, { as: "user", foreignKey: "user_id" });
CourseAssessment.hasMany(AssessmentSession, { as: "sessions", foreignKey: "assessment_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 = { module.exports = {
Course, CourseProductCategory, Course, CourseProductCategory,
Unit, Lesson, LessonPage, Unit, Lesson, LessonPage,
CourseObjective, LessonObjective, CourseObjective, LessonObjective,
CoursePrerequisite, CourseAssessment, CoursePrerequisite, CourseAssessment,
UnitQuiz, QuizQuestion, QuizOption, QuizAttempt, UnitQuiz, QuizQuestion, QuizOption, QuizAttempt,
AssessmentSession, AssessmentSession, QuizSession,
mdl_Category, Certificate, CourseInstructor, mdl_Category, Certificate, CourseInstructor,
CourseReadingProgress, CourseReadingProgress, UnitReadingProgress, LessonReadingProgress,
}; };
+1 -1
View File
@@ -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 }, 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 }, 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 }, 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 }, duration_seconds: { type: DataTypes.INTEGER, allowNull: true, defaultValue: 0, hidden: false, order: 8, filterable: false },
createdBy: { type: DataTypes.BIGINT, allowNull: true }, createdBy: { type: DataTypes.BIGINT, allowNull: true },
updatedBy: { type: DataTypes.BIGINT, allowNull: true }, updatedBy: { type: DataTypes.BIGINT, allowNull: true },
@@ -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;
+20
View File
@@ -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;
+1
View File
@@ -9,6 +9,7 @@ const UnitQuiz = sequelize.define("UnitQuiz", {
is_required: { type: DataTypes.BOOLEAN, defaultValue: false }, is_required: { type: DataTypes.BOOLEAN, defaultValue: false },
passing_score: { type: DataTypes.INTEGER, defaultValue: 70 }, // percentage passing_score: { type: DataTypes.INTEGER, defaultValue: 70 }, // percentage
max_questions: { type: DataTypes.INTEGER, allowNull: true }, // null = show all max_questions: { type: DataTypes.INTEGER, allowNull: true }, // null = show all
shuffle_questions: { type: DataTypes.BOOLEAN, defaultValue: false },
createdBy: { type: DataTypes.BIGINT, allowNull: true }, createdBy: { type: DataTypes.BIGINT, allowNull: true },
updatedBy: { type: DataTypes.BIGINT, allowNull: true }, updatedBy: { type: DataTypes.BIGINT, allowNull: true },
deletedBy: { type: DataTypes.BIGINT, allowNull: true }, deletedBy: { type: DataTypes.BIGINT, allowNull: true },
@@ -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;
+19
View File
@@ -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;
+14
View File
@@ -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;
+32 -4
View File
@@ -1,9 +1,20 @@
const mdl_Users = require('../users/users.mdl'); const mdl_Users = require('../users/users.mdl');
const mdl_TierCategories = require('./tier_categories.mdl');
const mdl_TierPlans = require('./tier_plans.mdl'); const mdl_TierPlans = require('./tier_plans.mdl');
const mdl_UserTiers = require('./user_tiers.mdl'); const mdl_UserTiers = require('./user_tiers.mdl');
const mdl_Payments = require('./payments.mdl'); const mdl_Payments = require('./payments.mdl');
const mdl_PlanCourses = require('./plan_courses.mdl'); const mdl_PlanCourses = require('./plan_courses.mdl');
const { Course } = require('../courses/courses.mdl'); // ← named export 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 ──────────────────────────────────────────────────────────────── // ─── UserTiers ────────────────────────────────────────────────────────────────
mdl_UserTiers.belongsTo(mdl_Users, { foreignKey: 'user_id', as: 'user' }); mdl_UserTiers.belongsTo(mdl_Users, { foreignKey: 'user_id', as: 'user' });
@@ -23,17 +34,34 @@ mdl_TierPlans.belongsToMany(Course, {
otherKey: 'course_id', otherKey: 'course_id',
as: 'courses', as: 'courses',
}); });
Course.belongsToMany(mdl_TierPlans, { Course.belongsToMany(mdl_TierPlans, {
through: mdl_PlanCourses, through: mdl_PlanCourses,
foreignKey: 'course_id', foreignKey: 'course_id',
otherKey: 'plan_id', otherKey: 'plan_id',
as: 'plans', as: 'plans',
}); });
mdl_PlanCourses.belongsTo(mdl_TierPlans, { foreignKey: 'plan_id', as: 'plan' }); mdl_PlanCourses.belongsTo(mdl_TierPlans, { foreignKey: 'plan_id', as: 'plan' });
mdl_PlanCourses.belongsTo(Course, { foreignKey: 'course_id', as: 'course' }); mdl_PlanCourses.belongsTo(Course, { foreignKey: 'course_id', as: 'course' });
Course.hasOne(mdl_PlanCourses, { as: 'planCourse', foreignKey: 'course_id' }); Course.hasOne(mdl_PlanCourses, { as: 'planCourse', foreignKey: 'course_id' });
mdl_TierPlans.hasMany(mdl_PlanCourses, { as: 'planCourses', foreignKey: 'plan_id' }); mdl_TierPlans.hasMany(mdl_PlanCourses, { as: 'planCourses', foreignKey: 'plan_id' });
module.exports = { mdl_TierPlans, mdl_UserTiers, mdl_Payments, mdl_PlanCourses }; // ─── 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,
};
+22
View File
@@ -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;
+2 -1
View File
@@ -11,7 +11,8 @@ const sequelize = require('../../config/db.config');
const mdl_TierPlans = sequelize.define('TierPlan', { const mdl_TierPlans = sequelize.define('TierPlan', {
plan_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true, label: 'Plan ID' }, plan_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true, label: 'Plan ID' },
tier: { type: DataTypes.ENUM('premium', 'exclusive'), allowNull: false, label: 'Tier' }, 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' }, label: { type: DataTypes.STRING(100), allowNull: false, label: 'Plan Label' },
duration_days: { type: DataTypes.INTEGER, allowNull: false, label: 'Duration (Days)' }, duration_days: { type: DataTypes.INTEGER, allowNull: false, label: 'Duration (Days)' },
price: { type: DataTypes.DECIMAL(10, 2), allowNull: false, label: 'Price' }, price: { type: DataTypes.DECIMAL(10, 2), allowNull: false, label: 'Price' },
+2 -1
View File
@@ -12,11 +12,12 @@ const sequelize = require('../../config/db.config');
const mdl_UserTiers = sequelize.define('UserTier', { const mdl_UserTiers = sequelize.define('UserTier', {
tier_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true, label: 'Tier ID' }, tier_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true, label: 'Tier ID' },
user_id: { type: DataTypes.BIGINT, allowNull: false, label: 'User 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' }, 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' }, starts_at: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW, label: 'Starts At' },
expires_at: { type: DataTypes.DATE, allowNull: true, label: 'Expires 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' }, granted_by: { type: DataTypes.BIGINT, allowNull: true, label: 'Granted By' },
revoked_by: { type: DataTypes.BIGINT, allowNull: true, label: 'Revoked By' }, revoked_by: { type: DataTypes.BIGINT, allowNull: true, label: 'Revoked By' },
revoked_at: { type: DataTypes.DATE, allowNull: true, label: 'Revoked At' }, revoked_at: { type: DataTypes.DATE, allowNull: true, label: 'Revoked At' },
+41
View File
@@ -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;
+1 -1
View File
@@ -1,5 +1,5 @@
const excludeAttributes = [ 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.given_name",
"personal_info.name.middle_name", "personal_info.name.middle_name",
"personal_info.name.last_name", "personal_info.name.last_name",
+4
View File
@@ -43,6 +43,10 @@ const mdl_Users = sequelize.define('User', {
*/ */
personal_info: { type: DataTypes.JSONB, defaultValue: null, label: "Personal Info", order: 2 }, 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 ───────────────────────────────────────────────────────── // ── Password policy ─────────────────────────────────────────────────────────
must_change_password: { type: DataTypes.BOOLEAN, defaultValue: false, allowNull: false, label: "Must Change Password" }, 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" }, password_expires_at: { type: DataTypes.DATE, defaultValue: null, allowNull: true, label: "Password Expires At" },
+2
View File
@@ -31,6 +31,7 @@ const assetsRoutes = require('./assets.routes');
const coursesRoutes = require('./courses.routes'); const coursesRoutes = require('./courses.routes');
const taskRoutes = require('./task.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 categoriesRoutes = require('./categories.routes');
const productsRoutes = require('./products.routes'); const productsRoutes = require('./products.routes');
const advertisementRoutes = require('./advertisements.routes'); const advertisementRoutes = require('./advertisements.routes');
@@ -49,6 +50,7 @@ router.use('/groups', groupsRoutes);
router.use('/assets', assetsRoutes); router.use('/assets', assetsRoutes);
router.use('/courses', coursesRoutes); router.use('/courses', coursesRoutes);
router.use('/task-lists', taskRoutes); router.use('/task-lists', taskRoutes);
router.use('/tiers/categories', tierCategoriesRoutes);
router.use('/tiers', tiersRoutes); router.use('/tiers', tiersRoutes);
router.use('/categories', categoriesRoutes); router.use('/categories', categoriesRoutes);
router.use('/products', productsRoutes); router.use('/products', productsRoutes);
+1
View File
@@ -21,6 +21,7 @@ router.delete("/bulk", ctrl.bulkArchiveCourses);
router.get("/archives", ctrl.getArchivedCourses); router.get("/archives", ctrl.getArchivedCourses);
router.patch("/restore/bulk", ctrl.bulkRestoreCourses); router.patch("/restore/bulk", ctrl.bulkRestoreCourses);
router.get("/flat", ctrl.getCoursesFlat); router.get("/flat", ctrl.getCoursesFlat);
router.get("/by-subscription", ctrl.getCoursesBySubscription);
router.get("/units-flat", ctrl.getUnitsFlat); router.get("/units-flat", ctrl.getUnitsFlat);
router.get("/lessons-flat", ctrl.getLessonsFlat); router.get("/lessons-flat", ctrl.getLessonsFlat);
+1
View File
@@ -11,5 +11,6 @@ const ctrl = require("../../controllers/admin/media.controller");
// Auth + requireAdmin are already applied by admin.routes.js before reaching here. // 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; module.exports = router;
+14
View File
@@ -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;
+19
View File
@@ -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;
+7
View File
@@ -16,6 +16,13 @@ router.put('/:id', sensitiveOpsLimiter, usersCtrl.updateUser);
router.delete('/:id', sensitiveOpsLimiter, usersCtrl.deactivateUser); router.delete('/:id', sensitiveOpsLimiter, usersCtrl.deactivateUser);
router.post('/:id/restore', sensitiveOpsLimiter, usersCtrl.restoreUser); 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 ────────────────────────────────────────────────────────────────── // ── Sessions ──────────────────────────────────────────────────────────────────
router.get('/:id/sessions', usersCtrl.getUserSessions); router.get('/:id/sessions', usersCtrl.getUserSessions);
router.delete('/:id/sessions/:sid', sensitiveOpsLimiter, usersCtrl.terminateSession); router.delete('/:id/sessions/:sid', sensitiveOpsLimiter, usersCtrl.terminateSession);
+1 -1
View File
@@ -40,7 +40,7 @@ router.post('/register', ...registerValidator, validate, authCtrl.register);
router.post('/verify-otp', otpLimiter, ...verifyOTPValidator, validate, authCtrl.verifyOTP); router.post('/verify-otp', otpLimiter, ...verifyOTPValidator, validate, authCtrl.verifyOTP);
router.post('/resend-otp', otpLimiter, ...resendOTPValidator, validate, authCtrl.resendOTP); router.post('/resend-otp', otpLimiter, ...resendOTPValidator, validate, authCtrl.resendOTP);
router.post('/login', ...loginValidator, validate, authCtrl.login); 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('/logout', authenticate, authLimiter, authCtrl.logout);
router.post('/change-password', authenticate, sensitiveOpsLimiter, ...changePassValidator, validate, authCtrl.changePassword); router.post('/change-password', authenticate, sensitiveOpsLimiter, ...changePassValidator, validate, authCtrl.changePassword);
+2
View File
@@ -6,6 +6,7 @@
* Route Map: * Route Map:
* GET /api/client/profile → view own profile * GET /api/client/profile → view own profile
* PUT /api/client/profile → update 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 * GET /api/client/sessions → view own active sessions
* DELETE /api/client/sessions/:id → revoke own session * DELETE /api/client/sessions/:id → revoke own session
* *
@@ -48,6 +49,7 @@ router.use(authenticate, requireClient());
router.get('/profile', profileCtrl.getProfile); router.get('/profile', profileCtrl.getProfile);
router.put('/profile', ...updateProfileValidator, validate, profileCtrl.updateProfile); router.put('/profile', ...updateProfileValidator, validate, profileCtrl.updateProfile);
router.delete('/profile', profileCtrl.deleteAccount);
router.post('/profile/avatar', handleAvatarUpload, profileCtrl.uploadAvatar); router.post('/profile/avatar', handleAvatarUpload, profileCtrl.uploadAvatar);
router.delete('/profile/avatar', profileCtrl.deleteAvatar); router.delete('/profile/avatar', profileCtrl.deleteAvatar);
router.get('/sessions', profileCtrl.getSessions); router.get('/sessions', profileCtrl.getSessions);
+2
View File
@@ -31,12 +31,14 @@ router.post('/:courseId/assessment/:assessmentId/start', ctrl.startCour
router.get( '/:courseId/assessment/:assessmentId/session', ctrl.getAssessmentSession); router.get( '/:courseId/assessment/:assessmentId/session', ctrl.getAssessmentSession);
router.patch('/:courseId/assessment/:assessmentId/draft', ctrl.saveDraft); 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/units/:unitId/quiz/:quizId/submit', ctrl.submitUnitQuiz);
router.post('/:courseId/assessment/:assessmentId/submit', ctrl.submitCourseAssessment); router.post('/:courseId/assessment/:assessmentId/submit', ctrl.submitCourseAssessment);
// Reading progress // Reading progress
router.get( '/:courseId/progress/summary', progressCtrl.getCourseProgressSummary); router.get( '/:courseId/progress/summary', progressCtrl.getCourseProgressSummary);
router.get( '/:courseId/progress', progressCtrl.getCourseProgress); router.get( '/:courseId/progress', progressCtrl.getCourseProgress);
router.get( '/:courseId/task-context', progressCtrl.getCourseTaskContext);
router.post('/:courseId/units/:unitId/lessons/:lessonId/progress', progressCtrl.upsertLessonProgress); router.post('/:courseId/units/:unitId/lessons/:lessonId/progress', progressCtrl.upsertLessonProgress);
module.exports = router; module.exports = router;
+5
View File
@@ -88,6 +88,11 @@ router.post(
sensitiveOpsLimiter, sensitiveOpsLimiter,
progressController.visitLink progressController.visitLink
); );
router.delete(
'/:groupId/task-lists/:taskListId/tasks/:taskId/requirements/:requirementId/visit',
sensitiveOpsLimiter,
progressController.unvisitLink
);
router.post( router.post(
'/:groupId/task-lists/:taskListId/tasks/:taskId/requirements/:requirementId/progress', '/:groupId/task-lists/:taskListId/tasks/:taskId/requirements/:requirementId/progress',
sensitiveOpsLimiter, sensitiveOpsLimiter,
+6
View File
@@ -20,4 +20,10 @@ router.post('/checkout/refund', ctrl.refundOrder);
// My payments // My payments
router.get ('/me/payments', ctrl.getMyPayments); 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; module.exports = router;
+42
View File
@@ -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();
})();
+2 -2
View File
@@ -32,7 +32,7 @@ const sendEmail = async ({ to, type, data = {} }) => {
throw new Error(`Email template "${type}" not found`); throw new Error(`Email template "${type}" not found`);
} }
const { subject, text } = templateFn(data); const { subject, html } = templateFn(data);
return await new Promise((resolve, reject) => { return await new Promise((resolve, reject) => {
transporter.sendMail( transporter.sendMail(
@@ -43,7 +43,7 @@ const sendEmail = async ({ to, type, data = {} }) => {
}, },
to, to,
subject, subject,
text, html,
}, },
(err, info) => { (err, info) => {
if (err) return reject(err); if (err) return reject(err);
+153
View File
@@ -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,
};
+1
View File
@@ -60,6 +60,7 @@ const PREFIX_MAP = {
document: "documents", document: "documents",
avatar: "avatars", avatar: "avatars",
thumbnail: "thumbnails", thumbnail: "thumbnails",
badge: "badges",
}; };
function resolvePrefix(ownerType = "image") { function resolvePrefix(ownerType = "image") {
+78
View File
@@ -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 };
+36 -19
View File
@@ -1,21 +1,25 @@
// Assessment cooldown policy is now stored per-assessment in the DB (max_attempts / cooldown_hours). // Fisher-Yates in-place shuffle — shared by shuffleOptions and shuffleQuestions.
// These fallbacks are used only if values are missing (e.g. legacy rows before the migration). function fisherYates(arr) {
const ASSESSMENT_FAILS_BEFORE_COOLDOWN = 3; for (let i = arr.length - 1; i > 0; i--) {
const ASSESSMENT_COOLDOWN_HOURS = 24; 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 // arrays/objects, never mutates input. Grading is unaffected since
// submitUnitQuiz/submitCourseAssessment always re-fetch questions fresh // 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) { function shuffleOptions(questions) {
return questions.map((q) => { return questions.map((q) => ({ ...q, options: fisherYates([...(q.options ?? [])]) }));
const options = [...(q.options ?? [])]; }
for (let i = options.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1)); // Randomises the ORDER OF QUESTIONS. Pure — returns a new array.
[options[i], options[j]] = [options[j], options[i]]; // Safe: grading re-fetches questions from DB in stored order; client position
} // has no effect on correctness checks.
return { ...q, options }; function shuffleQuestions(questions) {
}); return fisherYates([...questions]);
} }
// Single source of truth for both the GET-time info fields and the // 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 = 'quiz' → unit quizzes: no cooldown, no attempt cap, always open
// type = 'assessment' → course assessments: maxFails failed attempts → cooldownHours cooldown (rolling cycles) // 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 } = {}) { function getAttemptStatus(attempts, type = 'quiz', { maxFails, cooldownHours } = {}) {
const attempt_count = attempts.length; const attempt_count = attempts.length;
const has_passed = attempts.some((a) => a.passed); 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) // Assessment: simulate rolling cycles — N failed attempts → cooldown.
const failLimit = maxFails ?? ASSESSMENT_FAILS_BEFORE_COOLDOWN; // null means the feature is off: no attempt cap / no cooldown.
const lockHours = cooldownHours ?? ASSESSMENT_COOLDOWN_HOURS; 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)); 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 }; module.exports = { shuffleOptions, shuffleQuestions, getAttemptStatus };
+53
View File
@@ -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 };
+1
View File
@@ -25,6 +25,7 @@ function estimateBlockDuration(block) {
switch (block.type) { switch (block.type) {
case "text": case "text":
case "markdown":
return readingSecs(block.content?.body); return readingSecs(block.content?.body);
case "image": case "image":