mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
@@ -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,15 +904,16 @@ 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);
|
||||||
|
|
||||||
if (title !== undefined) quiz.title = title;
|
if (title !== undefined) quiz.title = title;
|
||||||
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,20 +1278,21 @@ 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 },
|
||||||
});
|
});
|
||||||
if (!assessment) return R.error(res, "Assessment not found.", 404);
|
if (!assessment) return R.error(res, "Assessment not found.", 404);
|
||||||
|
|
||||||
if (title !== undefined) assessment.title = title;
|
if (title !== undefined) assessment.title = title;
|
||||||
if (is_required !== undefined) assessment.is_required = is_required;
|
if (is_required !== undefined) assessment.is_required = is_required;
|
||||||
if (passing_score !== undefined) assessment.passing_score = passing_score;
|
if (passing_score !== undefined) assessment.passing_score = passing_score;
|
||||||
if (time_limit_minutes !== undefined) assessment.time_limit_minutes = time_limit_minutes;
|
if (time_limit_minutes !== undefined) assessment.time_limit_minutes = time_limit_minutes;
|
||||||
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,
|
||||||
@@ -1602,10 +1625,36 @@ function groupByUser(attempts) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildSummary(attempts) {
|
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,
|
||||||
|
|||||||
@@ -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) => {
|
||||||
@@ -51,20 +67,8 @@ exports.issueToken = async (req, res) => {
|
|||||||
return R.error(res, "Token flow is for S3 assets only. Use the raw file_url for other providers.", 400);
|
return R.error(res, "Token flow is for S3 assets only. Use the raw file_url for other providers.", 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const ip = resolveIp(req);
|
const 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);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -10,10 +10,11 @@
|
|||||||
***********************************************************************************************************************************************************************/
|
***********************************************************************************************************************************************************************/
|
||||||
const { Op } = require('sequelize');
|
const { Op } = require('sequelize');
|
||||||
|
|
||||||
const mdl_TierPlans = require('../../models/tiers/tier_plans.mdl');
|
const mdl_TierCategories = require('../../models/tiers/tier_categories.mdl');
|
||||||
const mdl_UserTiers = require('../../models/tiers/user_tiers.mdl');
|
const mdl_TierPlans = require('../../models/tiers/tier_plans.mdl');
|
||||||
const mdl_Payments = require('../../models/tiers/payments.mdl');
|
const mdl_UserTiers = require('../../models/tiers/user_tiers.mdl');
|
||||||
const mdl_Users = require('../../models/users/users.mdl');
|
const mdl_Payments = require('../../models/tiers/payments.mdl');
|
||||||
|
const mdl_Users = require('../../models/users/users.mdl');
|
||||||
const mdl_PlanCourses = require('../../models/tiers/plan_courses.mdl');
|
const mdl_PlanCourses = require('../../models/tiers/plan_courses.mdl');
|
||||||
const { Course } = require('../../models/courses/courses.mdl');
|
const { Course } = require('../../models/courses/courses.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,14 +286,28 @@ 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);
|
||||||
|
|
||||||
const users = await mdl_Users.findAll({
|
const users = await mdl_Users.findAll({
|
||||||
where: { user_id: { [Op.notIn]: memberIds.length ? memberIds : [0] } },
|
where: { user_id: { [Op.notIn]: memberIds.length ? memberIds : [0] } },
|
||||||
attributes: [
|
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',
|
||||||
|
],
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
};
|
||||||
+108
-15
@@ -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();
|
||||||
user_id: user.user_id,
|
const notifications = [
|
||||||
...NOTIFICATION_REGISTRY.welcome.build({
|
{
|
||||||
groupName: grp?.name ?? null,
|
user_id: user.user_id,
|
||||||
groupCode: grp?.group_code ?? null,
|
...NOTIFICATION_REGISTRY.welcome.build({
|
||||||
accType: user.acc_type,
|
groupName: grp?.name ?? null,
|
||||||
}),
|
groupCode: grp?.group_code ?? null,
|
||||||
});
|
accType: user.acc_type,
|
||||||
|
}),
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
if (grp?.group_code === 'NOGRP') {
|
||||||
|
notifications.push({
|
||||||
|
user_id: user.user_id,
|
||||||
|
...NOTIFICATION_REGISTRY.nogrp_welcome.build(),
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return UserNotification.bulkCreate(notifications, { validate: false });
|
||||||
}).catch(err => console.error('[AUTH] Failed to emit welcome notification:', err));
|
}).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);
|
||||||
|
|||||||
@@ -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,49 +6,164 @@
|
|||||||
* 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 R = require('../../utils/response.util');
|
const { Op } = require('sequelize');
|
||||||
const logActivity = require('../../utils/logActivity.util');
|
const R = require('../../utils/response.util');
|
||||||
const { upsertLessonRead } = require('../../services/course_reading_progress.service');
|
const logActivity = require('../../utils/logActivity.util');
|
||||||
const CourseReadingProgress = require('../../models/courses/course_reading_progress.mdl');
|
const { upsertLessonRead } = require('../../services/course_reading_progress.service');
|
||||||
const Certificate = require('../../models/courses/certificate.mdl');
|
const { upsertLessonRead: upsertReadingProgress } = require('../../services/reading_progress.service');
|
||||||
|
const CourseReadingProgress = require('../../models/courses/course_reading_progress.mdl');
|
||||||
|
const Certificate = require('../../models/courses/certificate.mdl');
|
||||||
const {
|
const {
|
||||||
Course, Unit, Lesson,
|
Course, Unit, Lesson,
|
||||||
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'],
|
||||||
@@ -75,8 +189,8 @@ exports.getMyInProgressCourses = async (req, res) => {
|
|||||||
if (!pending.length) return R.success(res, 'No courses in progress.', []);
|
if (!pending.length) return R.success(res, 'No courses in progress.', []);
|
||||||
|
|
||||||
const result = await Promise.all(pending.map(async (row) => {
|
const result = await Promise.all(pending.map(async (row) => {
|
||||||
const courseId = row.course_id;
|
const courseId = row.course_id;
|
||||||
const readingDone = row.status === 'completed';
|
const readingDone = row.status === 'completed';
|
||||||
|
|
||||||
const [lessons_total, lessons_completed] = await Promise.all([
|
const [lessons_total, lessons_completed] = await Promise.all([
|
||||||
Lesson.count({
|
Lesson.count({
|
||||||
@@ -93,12 +207,10 @@ exports.getMyInProgressCourses = async (req, res) => {
|
|||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Only compute pending quiz/assessment detail when all lessons are read
|
|
||||||
let pending_quizzes = [];
|
let pending_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);
|
||||||
|
|||||||
@@ -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 { gradeSubmission } = require("../../utils/courses/grading.util");
|
const mdl_TierCategories = require("../../models/tiers/tier_categories.mdl");
|
||||||
const { shuffleOptions, getAttemptStatus, ASSESSMENT_FAILS_BEFORE_COOLDOWN, ASSESSMENT_COOLDOWN_HOURS } = require("../../utils/courses/quiz_security.util");
|
const { gradeSubmission } = require("../../utils/courses/grading.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';
|
const course = await Course.findOne({ where: { course_id, ...notDeleted }, attributes: ['subscription'] });
|
||||||
|
const requiredTier = course?.subscription ?? 'free';
|
||||||
|
|
||||||
// Primary: explicit plan association
|
const userCtx = await buildUserContext(user_id);
|
||||||
const planCourse = await mdl_PlanCourses.findOne({ where: { course_id } });
|
|
||||||
if (planCourse) {
|
|
||||||
const plan = await mdl_TierPlans.findByPk(planCourse.plan_id, { attributes: ['tier'] });
|
|
||||||
if (plan?.tier) {
|
|
||||||
requiredTier = plan.tier;
|
|
||||||
} else {
|
|
||||||
// Plan was soft-deleted or missing — fall back to course.subscription
|
|
||||||
const course = await Course.findOne({ where: { course_id, ...notDeleted }, attributes: ['subscription'] });
|
|
||||||
requiredTier = course?.subscription ?? 'free';
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Fallback: use the course's own subscription field (premium / exclusive / free)
|
|
||||||
const course = await Course.findOne({ where: { course_id, ...notDeleted }, attributes: ['subscription'] });
|
|
||||||
requiredTier = course?.subscription ?? 'free';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (requiredTier === 'free') return true;
|
// Rank-0 slugs (default/free tier) are always accessible — resolved dynamically
|
||||||
|
const courseRank = userCtx.tierRankMap[requiredTier] ?? Infinity;
|
||||||
|
if (courseRank === 0) return true;
|
||||||
|
|
||||||
const tierRank = { free: 0, premium: 1, exclusive: 2 };
|
const userRank = userCtx.tierRankMap[userCtx.tier] ?? 0;
|
||||||
const activeTier = await getActiveTier(user_id);
|
if (userRank >= courseRank) return true;
|
||||||
const userRank = tierRank[activeTier?.tier ?? 'free'] ?? 0;
|
|
||||||
const reqRank = tierRank[requiredTier] ?? 0;
|
|
||||||
|
|
||||||
if (userRank >= reqRank) 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;
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -6,8 +6,10 @@
|
|||||||
* GET /client/groups/:groupId/task-lists/:taskListId/tasks/:taskId/progress
|
* GET /client/groups/:groupId/task-lists/:taskListId/tasks/:taskId/progress
|
||||||
* → returns full progress snapshot: { link_visits, progress }
|
* → returns full progress snapshot: { link_visits, progress }
|
||||||
*
|
*
|
||||||
* 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) ──────────────────
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|||||||
@@ -10,10 +10,12 @@
|
|||||||
* Date Created: Jun. 6, 2026
|
* Date Created: Jun. 6, 2026
|
||||||
* Modified: Jun. 9, 2026
|
* Modified: Jun. 9, 2026
|
||||||
***********************************************************************************************************************************************************************/
|
***********************************************************************************************************************************************************************/
|
||||||
const mdl_TierPlans = require('../../models/tiers/tier_plans.mdl');
|
const mdl_TierCategories = require('../../models/tiers/tier_categories.mdl');
|
||||||
const mdl_UserTiers = require('../../models/tiers/user_tiers.mdl');
|
const mdl_TierPlans = require('../../models/tiers/tier_plans.mdl');
|
||||||
const mdl_Payments = require('../../models/tiers/payments.mdl');
|
const mdl_UserTiers = require('../../models/tiers/user_tiers.mdl');
|
||||||
const mdl_PlanCourses = require('../../models/tiers/plan_courses.mdl');
|
const mdl_Payments = require('../../models/tiers/payments.mdl');
|
||||||
|
const mdl_SystemBadges = require('../../models/system_badges/system_badges.mdl');
|
||||||
|
const Asset = require('../../models/assets/assets.mdl');
|
||||||
const { onTierActivated } = require('../../services/achievements.service');
|
const { 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
|
||||||
refund_id: refundData.id,
|
await mdl_UserTiers.create({
|
||||||
status: refundData.status,
|
user_id,
|
||||||
expires_at: activeTier.expires_at,
|
tier: 'free',
|
||||||
|
status: 'active',
|
||||||
|
starts_at: now,
|
||||||
|
expires_at: null,
|
||||||
|
granted_by: null,
|
||||||
|
notes: 'Auto-downgrade after refund.',
|
||||||
|
});
|
||||||
|
|
||||||
|
return R.success(res, 'Refund processed successfully. Your access has been revoked.', {
|
||||||
|
refund_id: refundData.id,
|
||||||
|
status: refundData.status,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[CLIENT][REFUND]', err);
|
console.error('[CLIENT][REFUND]', err);
|
||||||
|
|||||||
+3
-1
@@ -21,11 +21,13 @@
|
|||||||
* Date Created: Jun. 17, 2026
|
* Date Created: Jun. 17, 2026
|
||||||
***********************************************************************************************************************************************************************/
|
***********************************************************************************************************************************************************************/
|
||||||
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 ──────────────────────────────────────
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
@@ -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.
|
|
||||||
`),
|
`),
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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'))`
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -33,9 +33,21 @@ const authenticate = async (req, res, next) => {
|
|||||||
attributes: { exclude: ['password', 'otp_code', 'otp_expires_at'] },
|
attributes: { exclude: ['password', 'otp_code', 'otp_expires_at'] },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!user) return R.error(res, 'User not found.', 401);
|
if (!user) return R.error(res, 'User not found.', 401);
|
||||||
if (!user.is_active) return R.error(res, 'Account is deactivated.', 403);
|
if (!user.is_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')
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -10,8 +10,9 @@ const CourseAssessment = sequelize.define("CourseAssessment", {
|
|||||||
passing_score: { type: DataTypes.INTEGER, defaultValue: 70 },
|
passing_score: { type: DataTypes.INTEGER, defaultValue: 70 },
|
||||||
time_limit_minutes: { type: DataTypes.INTEGER, allowNull: true }, // null = no limit
|
time_limit_minutes: { type: DataTypes.INTEGER, allowNull: true }, // null = no limit
|
||||||
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 },
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -6,9 +6,10 @@ const UnitQuiz = sequelize.define("UnitQuiz", {
|
|||||||
uuid: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, unique: true },
|
uuid: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, unique: true },
|
||||||
unit_id: { type: DataTypes.BIGINT, allowNull: false, unique: true }, // one quiz per unit
|
unit_id: { type: DataTypes.BIGINT, allowNull: false, unique: true }, // one quiz per unit
|
||||||
title: { type: DataTypes.STRING(255), allowNull: true },
|
title: { type: DataTypes.STRING(255), allowNull: true },
|
||||||
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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -1,15 +1,26 @@
|
|||||||
const mdl_Users = require('../users/users.mdl');
|
const mdl_Users = require('../users/users.mdl');
|
||||||
const mdl_TierPlans = require('./tier_plans.mdl');
|
const mdl_TierCategories = require('./tier_categories.mdl');
|
||||||
const mdl_UserTiers = require('./user_tiers.mdl');
|
const mdl_TierPlans = require('./tier_plans.mdl');
|
||||||
const mdl_Payments = require('./payments.mdl');
|
const mdl_UserTiers = require('./user_tiers.mdl');
|
||||||
const mdl_PlanCourses = require('./plan_courses.mdl');
|
const mdl_Payments = require('./payments.mdl');
|
||||||
const { Course } = require('../courses/courses.mdl'); // ← named export
|
const mdl_PlanCourses = require('./plan_courses.mdl');
|
||||||
|
const mdl_PlanPolicies = require('./plan_policies.mdl');
|
||||||
|
const mdl_SystemBadges = require('../system_badges/system_badges.mdl');
|
||||||
|
const Asset = require('../assets/assets.mdl');
|
||||||
|
const { Course } = require('../courses/courses.mdl');
|
||||||
|
|
||||||
|
// ─── TierCategory ─────────────────────────────────────────────────────────────
|
||||||
|
mdl_TierCategories.belongsTo(Asset, { foreignKey: 'badge_asset_id', as: 'badgeAsset' });
|
||||||
|
mdl_TierCategories.hasMany(mdl_TierPlans, { foreignKey: 'tier_category_id', as: 'plans' });
|
||||||
|
|
||||||
|
// ─── TierPlans → TierCategory ─────────────────────────────────────────────────
|
||||||
|
mdl_TierPlans.belongsTo(mdl_TierCategories, { foreignKey: 'tier_category_id', as: 'category' });
|
||||||
|
|
||||||
// ─── UserTiers ────────────────────────────────────────────────────────────────
|
// ─── UserTiers ────────────────────────────────────────────────────────────────
|
||||||
mdl_UserTiers.belongsTo(mdl_Users, { foreignKey: 'user_id', as: 'user' });
|
mdl_UserTiers.belongsTo(mdl_Users, { foreignKey: 'user_id', as: 'user' });
|
||||||
mdl_UserTiers.belongsTo(mdl_Users, { foreignKey: 'granted_by', as: 'grantedByUser' });
|
mdl_UserTiers.belongsTo(mdl_Users, { foreignKey: 'granted_by', as: 'grantedByUser' });
|
||||||
mdl_UserTiers.belongsTo(mdl_Users, { foreignKey: 'revoked_by', as: 'revokedByUser' });
|
mdl_UserTiers.belongsTo(mdl_Users, { foreignKey: 'revoked_by', as: 'revokedByUser' });
|
||||||
mdl_Users.hasMany(mdl_UserTiers, { foreignKey: 'user_id', as: 'tiers' });
|
mdl_Users.hasMany(mdl_UserTiers, { foreignKey: 'user_id', as: 'tiers' });
|
||||||
|
|
||||||
// ─── Payments ─────────────────────────────────────────────────────────────────
|
// ─── Payments ─────────────────────────────────────────────────────────────────
|
||||||
mdl_Payments.belongsTo(mdl_Users, { foreignKey: 'user_id', as: 'user' });
|
mdl_Payments.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,
|
||||||
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -10,8 +10,9 @@ const { DataTypes } = require('sequelize');
|
|||||||
const sequelize = require('../../config/db.config');
|
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' },
|
||||||
|
|||||||
@@ -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' },
|
||||||
|
|||||||
@@ -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,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",
|
||||||
|
|||||||
@@ -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" },
|
||||||
|
|||||||
@@ -30,7 +30,8 @@ const groupsRoutes = require('./groups.routes');
|
|||||||
const assetsRoutes = require('./assets.routes');
|
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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ const router = require("express").Router();
|
|||||||
const ctrl = require("../../controllers/admin/media.controller");
|
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;
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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();
|
||||||
|
})();
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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") {
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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":
|
||||||
|
|||||||
Reference in New Issue
Block a user