pushy

Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
2026-06-28 11:29:07 +08:00
parent 463a8d3978
commit 89acdfc239
67 changed files with 2736 additions and 306 deletions
+5 -6
View File
@@ -15,6 +15,7 @@ const R = require('../../utils/response.util');
const mdl_Users = require('../../models/users/users.mdl');
const { generateCertificate } = require('../../services/certificate.service');
const { formatDuration } = require('../../utils/duration.util');
const { fmtDate } = require('../../utils/datetime.util');
const {
Course,
@@ -134,11 +135,7 @@ exports.getCertificate = async (req, res) => {
// ── 5. Format issued date as MM/DD/YY HH:MM AM/PM ───────────────────────────
const issuedDate = new Date(cert.issued_at);
const dateStr = new Intl.DateTimeFormat('en-US', {
month: 'long',
day: 'numeric',
year: 'numeric',
}).format(issuedDate);
const dateStr = fmtDate(issuedDate);
// ── 6. Generate PDF ────────────────────────────────────────────────────────
const pdf = await generateCertificate({
@@ -157,10 +154,12 @@ exports.getCertificate = async (req, res) => {
const firstName = nameParts.length > 1 ? nameParts.slice(0, -1).join(' ') : '';
const safeTitle = course.title.replace(/[/\\:*?"<>|]/g, '').trim();
const filename = `${lastName},${firstName}_${safeTitle}_${cert.cert_no}.pdf`;
const asciiName = filename.replace(/[^\x20-\x7E]/g, '_').replace(/"/g, '_');
const encodedName = encodeURIComponent(filename);
res.set({
'Content-Type': 'application/pdf',
'Content-Disposition': `attachment; filename="${filename}"`,
'Content-Disposition': `attachment; filename="${asciiName}"; filename*=UTF-8''${encodedName}`,
'Content-Length': pdf.length,
});
@@ -6,49 +6,164 @@
* GET /client/courses/in-progress
* → courses where the current user has status = 'in_progress', with lesson counts
*
* GET /client/courses/:courseId/progress/summary
* → compact snapshot: lesson counts + percentage + course status
*
* GET /client/courses/:courseId/progress
* → returns all progress rows for this user + course (flat, frontend builds the map)
* → all progress rows for this user + course (flat, frontend builds the map)
*
* GET /client/courses/:courseId/task-context
* → all pending task requirements (read_*) for this course's UUIDs that the user is assigned to
*
* POST /client/courses/:courseId/units/:unitId/lessons/:lessonId/progress
* → UPSERT lesson + derives + UPSERTs parent unit + course in one transaction
*
* Body (POST):
* { status: 'in_progress' | 'completed' }
* Defaults to 'in_progress' if omitted (on first visit).
* → side-effects: writes to lesson_reading_progress / unit_reading_progress,
* syncs task_progress for matching task requirements,
* returns completed_tasks for any task whose read requirements are now all done
*
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jun. 21, 2026
***********************************************************************************************************************************************************************/
'use strict';
const R = require('../../utils/response.util');
const logActivity = require('../../utils/logActivity.util');
const { upsertLessonRead } = require('../../services/course_reading_progress.service');
const CourseReadingProgress = require('../../models/courses/course_reading_progress.mdl');
const Certificate = require('../../models/courses/certificate.mdl');
const { Op } = require('sequelize');
const R = require('../../utils/response.util');
const logActivity = require('../../utils/logActivity.util');
const { upsertLessonRead } = require('../../services/course_reading_progress.service');
const { upsertLessonRead: upsertReadingProgress } = require('../../services/reading_progress.service');
const CourseReadingProgress = require('../../models/courses/course_reading_progress.mdl');
const Certificate = require('../../models/courses/certificate.mdl');
const {
Course, Unit, Lesson,
UnitQuiz, CourseAssessment, QuizAttempt,
} = require('../../models/courses/courses.associations');
const { Task, TaskRequirement, TaskListGroup } = require('../../models/task/task.mdl');
const { TaskProgress } = require('../../models/task/task_progress.mdl');
const { mdl_UserGroupMembers } = require('../../models/users/user_groups.mdl');
const notDeleted = { deletedAt: null };
// ─── Internal helper: get task list IDs accessible to a user ─────────────────
async function getAccessibleTaskListIds(userId) {
const memberships = await mdl_UserGroupMembers.findAll({
where: { user_id: userId, deletedAt: null },
attributes: ['group_id'],
});
const groupIds = memberships.map((m) => m.group_id);
if (!groupIds.length) return { taskListIds: [], taskListToGroup: {} };
const taskListGroups = await TaskListGroup.findAll({
where: { group_id: groupIds },
attributes: ['task_list_id', 'group_id'],
});
const taskListToGroup = Object.fromEntries(taskListGroups.map((tlg) => [tlg.task_list_id, tlg.group_id]));
return { taskListIds: Object.keys(taskListToGroup), taskListToGroup };
}
// ─── Internal helper: sync task_progress after a lesson read ─────────────────
// Finds TaskRequirement rows whose reference_id matches the lesson/unit/course UUID
// (only for tasks the user is assigned to) and marks them completed in task_progress.
// Returns an array of { task_id, task_name } for tasks where ALL read-only requirements
// are now satisfied — these are eligible for display as "auto turned-in" on the frontend.
async function syncTaskProgress(userId, { lessonUuid, unitUuid, courseUuid, lessonStatus, unitStatus, courseStatus }) {
if (lessonStatus !== 'completed') return [];
const { taskListIds } = await getAccessibleTaskListIds(userId);
if (!taskListIds.length) return [];
// Collect UUIDs to match based on what became completed
const matchUuids = [lessonUuid];
if (unitStatus === 'completed') matchUuids.push(unitUuid);
if (courseStatus === 'completed') matchUuids.push(courseUuid);
const requirements = await TaskRequirement.findAll({
where: {
reference_id: { [Op.in]: matchUuids },
type: { [Op.in]: ['read_lesson', 'read_unit', 'read_course'] },
deletedAt: null,
},
include: [{
model: Task,
as: 'task',
where: { task_list_id: { [Op.in]: taskListIds }, deletedAt: null },
required: true,
attributes: ['task_id', 'name', 'task_list_id'],
}],
attributes: ['requirement_id', 'task_id', 'type', 'reference_id'],
});
if (!requirements.length) return [];
// Filter to (type, reference_id) pairs that actually became completed this call
const toComplete = requirements.filter((req) => {
if (req.type === 'read_lesson' && req.reference_id === lessonUuid) return true;
if (req.type === 'read_unit' && req.reference_id === unitUuid && unitStatus === 'completed') return true;
if (req.type === 'read_course' && req.reference_id === courseUuid && courseStatus === 'completed') return true;
return false;
});
if (!toComplete.length) return [];
const now = new Date();
// Upsert TaskProgress as completed for each matching requirement
await Promise.all(toComplete.map((req) =>
TaskProgress.upsert(
{
task_id: req.task_id,
requirement_id: req.requirement_id,
user_id: userId,
reference_id: req.reference_id,
type: req.type,
completed: true,
completed_at: now,
createdBy: userId,
updatedBy: userId,
},
{ conflictFields: ['requirement_id', 'user_id', 'reference_id'] }
)
));
// Check if any impacted task now has ALL its read requirements done
// (only auto-turn-in pure read tasks — tasks with upload_file/visit_link need manual submission)
const taskIds = [...new Set(toComplete.map((r) => r.task_id))];
const completedTasks = [];
for (const taskId of taskIds) {
const allReqs = await TaskRequirement.findAll({
where: { task_id: taskId, deletedAt: null },
attributes: ['requirement_id', 'type', 'reference_id'],
});
const hasNonReadReqs = allReqs.some((r) => !['read_course', 'read_unit', 'read_lesson'].includes(r.type));
if (hasNonReadReqs) continue; // let the user manually submit
const readReqs = allReqs; // all are read-type at this point
const doneProgress = await TaskProgress.findAll({
where: { task_id: taskId, user_id: userId, completed: true },
attributes: ['requirement_id', 'reference_id'],
});
const doneSet = new Set(doneProgress.map((p) => `${p.requirement_id}:${p.reference_id}`));
const allDone = readReqs.every((r) => doneSet.has(`${r.requirement_id}:${r.reference_id}`));
if (allDone) {
const taskName = toComplete.find((r) => r.task_id === taskId)?.task?.name ?? '';
completedTasks.push({ task_id: taskId, task_name: taskName });
}
}
return completedTasks;
}
// =============================================================================
// ── IN-PROGRESS COURSES — profile learning progress card ──────────────────────
// =============================================================================
// GET /client/courses/in-progress
// Returns courses the user has started but not yet completed (no certificate).
// Includes both:
// • reading in_progress → still working through lessons
// • reading completed → finished lessons but quiz / assessment still pending
// Excludes any course where the user already holds a certificate.
exports.getMyInProgressCourses = async (req, res) => {
try {
const userId = req.user.user_id;
// All course-level progress rows for this user (any reading status)
const courseRows = await CourseReadingProgress.findAll({
where: { user_id: userId, type: 'course' },
attributes: ['course_id', 'status', 'last_accessed_at'],
@@ -64,7 +179,6 @@ exports.getMyInProgressCourses = async (req, res) => {
if (!courseRows.length) return R.success(res, 'No courses in progress.', []);
// Courses the user has already earned a certificate for — exclude these
const certificates = await Certificate.findAll({
where: { user_id: userId },
attributes: ['course_id'],
@@ -75,8 +189,8 @@ exports.getMyInProgressCourses = async (req, res) => {
if (!pending.length) return R.success(res, 'No courses in progress.', []);
const result = await Promise.all(pending.map(async (row) => {
const courseId = row.course_id;
const readingDone = row.status === 'completed';
const courseId = row.course_id;
const readingDone = row.status === 'completed';
const [lessons_total, lessons_completed] = await Promise.all([
Lesson.count({
@@ -93,12 +207,10 @@ exports.getMyInProgressCourses = async (req, res) => {
}),
]);
// Only compute pending quiz/assessment detail when all lessons are read
let pending_quizzes = [];
let pending_assessment = null;
if (readingDone) {
// All unit quizzes in this course
const unitQuizzes = await UnitQuiz.findAll({
attributes: ['quiz_id', 'title', 'is_required', 'passing_score'],
include: [{
@@ -128,7 +240,6 @@ exports.getMyInProgressCourses = async (req, res) => {
}
}
// Course assessment
const assessment = await CourseAssessment.findOne({
attributes: ['assessment_id', 'title', 'is_required', 'passing_score'],
where: { course_id: courseId },
@@ -173,11 +284,6 @@ exports.getMyInProgressCourses = async (req, res) => {
// ── GET PROGRESS SUMMARY ──────────────────────────────────────────────────────
// =============================================================================
// GET /client/courses/:courseId/progress/summary
// Returns a compact progress snapshot: lesson counts + percentage + course status.
// Used by the ReadCourse block to render the inline progress bar without needing
// the full flat row list.
exports.getCourseProgressSummary = async (req, res) => {
try {
const { courseId } = req.params;
@@ -226,10 +332,6 @@ exports.getCourseProgressSummary = async (req, res) => {
// ── GET PROGRESS SNAPSHOT ─────────────────────────────────────────────────────
// =============================================================================
// GET /client/courses/:courseId/progress
// Returns all progress rows for this user+course.
// Frontend uses this to decorate the sidebar (completed checkmarks, locked states, etc.)
exports.getCourseProgress = async (req, res) => {
try {
const { courseId } = req.params;
@@ -253,6 +355,100 @@ exports.getCourseProgress = async (req, res) => {
}
};
// =============================================================================
// ── TASK CONTEXT FOR A COURSE ─────────────────────────────────────────────────
// =============================================================================
// GET /client/courses/:courseId/task-context
// Returns all pending task requirements (read_course / read_unit / read_lesson)
// whose reference_id matches this course, any of its units, or any of its lessons,
// filtered to tasks the current user is actually assigned to (via group membership).
// UnitList calls this on mount when no task context is passed via navigation state.
exports.getCourseTaskContext = async (req, res) => {
try {
const { courseId } = req.params;
const userId = req.user.user_id;
const course = await Course.findOne({
where: { course_id: courseId, ...notDeleted },
attributes: ['course_id', 'uuid'],
include: [{
model: Unit,
as: 'units',
where: notDeleted,
required: false,
attributes: ['unit_id', 'uuid'],
include: [{
model: Lesson,
as: 'lessons',
where: notDeleted,
required: false,
attributes: ['lesson_id', 'uuid'],
}],
}],
});
if (!course) return R.error(res, 'Course not found.', 404);
const units = course.units ?? [];
const allUuids = [
course.uuid,
...units.map((u) => u.uuid),
...units.flatMap((u) => (u.lessons ?? []).map((l) => l.uuid)),
];
const { taskListIds, taskListToGroup } = await getAccessibleTaskListIds(userId);
if (!taskListIds.length) {
return R.success(res, 'Task context retrieved.', { has_task: false, contexts: [] });
}
const requirements = await TaskRequirement.findAll({
where: {
type: { [Op.in]: ['read_course', 'read_unit', 'read_lesson'] },
reference_id: { [Op.in]: allUuids },
deletedAt: null,
},
include: [{
model: Task,
as: 'task',
where: { task_list_id: { [Op.in]: taskListIds }, deletedAt: null },
required: true,
attributes: ['task_id', 'name', 'task_list_id'],
}],
attributes: ['requirement_id', 'task_id', 'type', 'reference_id', 'reference_label'],
});
if (!requirements.length) {
return R.success(res, 'Task context retrieved.', { has_task: false, contexts: [] });
}
// Mark which requirements are already completed
const taskIds = [...new Set(requirements.map((r) => r.task_id))];
const doneProgress = await TaskProgress.findAll({
where: { task_id: { [Op.in]: taskIds }, user_id: userId, completed: true },
attributes: ['requirement_id', 'reference_id'],
});
const doneSet = new Set(doneProgress.map((p) => `${p.requirement_id}:${p.reference_id}`));
const contexts = requirements.map((req) => ({
task_id: req.task_id,
task_name: req.task.name,
task_list_id: req.task.task_list_id,
group_id: taskListToGroup[req.task.task_list_id],
requirement_id: req.requirement_id,
type: req.type,
reference_id: req.reference_id,
reference_label: req.reference_label,
already_completed: doneSet.has(`${req.requirement_id}:${req.reference_id}`),
}));
return R.success(res, 'Task context retrieved.', { has_task: true, contexts });
} catch (err) {
console.error('[CLIENT][COURSE TASK CONTEXT]', err);
return R.error(res, 'Could not retrieve task context.', 500);
}
};
// =============================================================================
// ── UPSERT LESSON PROGRESS ────────────────────────────────────────────────────
// =============================================================================
@@ -262,7 +458,10 @@ exports.getCourseProgress = async (req, res) => {
//
// Flow:
// 1. Resolve course / unit / lesson to get their UUIDs
// 2. Delegate to upsertLessonRead — handles lesson + unit + course in one tx
// 2. Delegate to upsertLessonRead (course_reading_progress service) — lesson + unit + course in one tx
// 3. Side-effect A: write to lesson_reading_progress + unit_reading_progress (new dedicated tables)
// 4. Side-effect B: sync task_progress for matching task requirements
// 5. Return result + completed_tasks (tasks whose all read requirements are now satisfied)
exports.upsertLessonProgress = async (req, res) => {
try {
@@ -289,6 +488,7 @@ exports.upsertLessonProgress = async (req, res) => {
if (!unit) return R.error(res, 'Unit not found.', 404);
if (!lesson) return R.error(res, 'Lesson not found.', 404);
// ── 1. Primary write: course_reading_progress ─────────────────────────
const result = await upsertLessonRead(userId, {
courseId: course.course_id,
courseUuid: course.uuid,
@@ -298,13 +498,31 @@ exports.upsertLessonProgress = async (req, res) => {
lessonStatus: status,
});
// ── 2. Side-effect A: write to new dedicated tables (fire-and-forget) ──
upsertReadingProgress(userId, {
courseId: course.course_id,
unitId: unit.unit_id,
lessonId: lesson.lesson_id,
lessonStatus: status,
}).catch((e) => console.error('[READING PROGRESS] piggyback write failed:', e));
// ── 3. Side-effect B: sync task_progress ──────────────────────────────
const completedTasks = await syncTaskProgress(userId, {
lessonUuid: lesson.uuid,
unitUuid: unit.uuid,
courseUuid: course.uuid,
lessonStatus: status,
unitStatus: result.unit.status,
courseStatus: result.course.status,
});
logActivity(userId, 'lesson_read', {
entityType: 'lesson',
entityId: lesson.lesson_id,
details: { lesson_uuid: lesson.uuid, status },
});
return R.success(res, 'Progress updated.', result, 200);
return R.success(res, 'Progress updated.', { ...result, completed_tasks: completedTasks }, 200);
} catch (err) {
console.error('[CLIENT][COURSE READING PROGRESS][UPSERT]', err);
return R.error(res, 'Could not update progress.', 500);
+87 -60
View File
@@ -16,8 +16,6 @@
const { Op } = require("sequelize");
const R = require("../../utils/response.util");
const mdl_UserTiers = require("../../models/tiers/user_tiers.mdl");
const mdl_PlanCourses = require("../../models/tiers/plan_courses.mdl");
const mdl_TierPlans = require("../../models/tiers/tier_plans.mdl");
const mdl_CoursePurchase = require("../../models/courses/course_purchases.mdl");
const mdl_Product = require("../../models/courses/products.mdl");
const mdl_Category = require("../../models/courses/categories.mdl");
@@ -28,10 +26,11 @@ const {
CourseObjective, LessonObjective,
CoursePrerequisite, CourseAssessment,
UnitQuiz, QuizQuestion, QuizOption, QuizAttempt,
AssessmentSession,
AssessmentSession, QuizSession,
} = require("../../models/courses/courses.associations");
const { gradeSubmission } = require("../../utils/courses/grading.util");
const { shuffleOptions, getAttemptStatus, ASSESSMENT_FAILS_BEFORE_COOLDOWN, ASSESSMENT_COOLDOWN_HOURS } = require("../../utils/courses/quiz_security.util");
const mdl_TierCategories = require("../../models/tiers/tier_categories.mdl");
const { gradeSubmission } = require("../../utils/courses/grading.util");
const { shuffleOptions, shuffleQuestions, getAttemptStatus } = require("../../utils/courses/quiz_security.util");
const { onCourseCompleted } = require('../../services/achievements.service');
const PendingCertificate = require('../../models/courses/pending_certificate.mdl');
const Certificate = require('../../models/courses/certificate.mdl');
@@ -78,37 +77,33 @@ async function expireSession(session, passingScore) {
return expiredAttempt;
}
// Builds minimal user context: active tier slug + live tier rank map
async function buildUserContext(user_id) {
const activeTier = await getActiveTier(user_id);
const tier = activeTier?.tier ?? 'free';
const categories = await mdl_TierCategories.findAll({ attributes: ['slug', 'rank'] });
const tierRankMap = {};
for (const c of categories) tierRankMap[c.slug] = c.rank;
return { tier, tierRankMap };
}
// ─── Shared tier + purchase access check ─────────────────────────────────────
// Returns true → user may access the course.
// Returns false → user's tier is too low AND no valid individual purchase.
async function canAccessCourse(user_id, course_id) {
let requiredTier = 'free';
const course = await Course.findOne({ where: { course_id, ...notDeleted }, attributes: ['subscription'] });
const requiredTier = course?.subscription ?? 'free';
// Primary: explicit plan association
const planCourse = await mdl_PlanCourses.findOne({ where: { course_id } });
if (planCourse) {
const plan = await mdl_TierPlans.findByPk(planCourse.plan_id, { attributes: ['tier'] });
if (plan?.tier) {
requiredTier = plan.tier;
} else {
// Plan was soft-deleted or missing — fall back to course.subscription
const course = await Course.findOne({ where: { course_id, ...notDeleted }, attributes: ['subscription'] });
requiredTier = course?.subscription ?? 'free';
}
} else {
// Fallback: use the course's own subscription field (premium / exclusive / free)
const course = await Course.findOne({ where: { course_id, ...notDeleted }, attributes: ['subscription'] });
requiredTier = course?.subscription ?? 'free';
}
const userCtx = await buildUserContext(user_id);
if (requiredTier === 'free') return true;
// Rank-0 slugs (default/free tier) are always accessible — resolved dynamically
const courseRank = userCtx.tierRankMap[requiredTier] ?? Infinity;
if (courseRank === 0) return true;
const tierRank = { free: 0, premium: 1, exclusive: 2 };
const activeTier = await getActiveTier(user_id);
const userRank = tierRank[activeTier?.tier ?? 'free'] ?? 0;
const reqRank = tierRank[requiredTier] ?? 0;
if (userRank >= reqRank) return true;
const userRank = userCtx.tierRankMap[userCtx.tier] ?? 0;
if (userRank >= courseRank) return true;
// Individual purchase as fallback
const product = await mdl_Product.findOne({ where: { course_id } });
@@ -160,10 +155,8 @@ exports.getCourses = async (req, res) => {
try {
const { category } = req.query; // optional slug filter
const activeTier = await getActiveTier(req.user.user_id);
const userTier = activeTier?.tier ?? 'free';
const tierRank = { free: 0, premium: 1, exclusive: 2 };
const userRank = tierRank[userTier] ?? 0;
const userCtx = await buildUserContext(req.user.user_id);
const userTier = userCtx.tier;
// Fetch all completed purchases for this user (for has_purchased check)
const myPurchases = await mdl_CoursePurchase.findAll({
@@ -192,13 +185,6 @@ exports.getCourses = async (req, res) => {
where: { ...notDeleted },
attributes: COURSE_LIST_ATTRS,
include: [
{
model: mdl_PlanCourses,
as: 'planCourse',
required: false,
attributes: ['id', 'plan_id'],
include: [{ model: mdl_TierPlans, as: 'plan', attributes: ['tier'] }],
},
{
model: mdl_Product,
as: 'product',
@@ -211,21 +197,17 @@ exports.getCourses = async (req, res) => {
order: [['order_index', 'ASC'], ['title', 'ASC']],
});
const userRank = userCtx.tierRankMap[userCtx.tier] ?? 0;
const result = courses.map((c) => {
const plain = c.toJSON();
const planCourse = plain.planCourse;
const plan_tier = planCourse?.plan?.tier ?? null;
const plain = c.toJSON();
const has_purchased = purchasedCourseIds.has(String(plain.course_id));
const subscription = plain.subscription ?? 'free';
const courseRank = userCtx.tierRankMap[subscription] ?? Infinity;
const effectiveTier = plan_tier || plain.subscription || 'free';
let is_locked = false;
if (effectiveTier && effectiveTier !== 'free') {
const reqRank = tierRank[effectiveTier] ?? 0;
if (userRank < reqRank && !has_purchased) is_locked = true;
}
const is_locked = courseRank > 0 && !has_purchased && userRank < courseRank;
delete plain.planCourse;
return { ...plain, is_locked, plan_tier: effectiveTier, has_purchased };
return { ...plain, is_locked, has_purchased };
});
return R.success(res, "Courses retrieved.", result);
@@ -331,14 +313,11 @@ exports.getCourse = async (req, res) => {
where: { assessment_id: plain.assessment.assessment_id, user_id: req.user.user_id, passed: true },
});
is_completed = !!passedAttempt;
plain.assessment = { ...plain.assessment, has_passed: is_completed };
}
plain.is_completed = is_completed;
const planCourse = await mdl_PlanCourses.findOne({
where: { course_id: courseId },
include: [{ model: mdl_TierPlans, as: 'plan', attributes: ['tier'] }],
});
const plan_tier = planCourse?.plan?.tier ?? plain.subscription ?? null;
const plan_tier = plain.subscription ?? null;
// Attach product info and purchase status for the buy-course flow
const product = await mdl_Product.findOne({
@@ -472,7 +451,7 @@ exports.getUnitQuiz = async (req, res) => {
where: { unit_id: unitId, ...notDeleted },
attributes: [
"quiz_id", "uuid", "title",
"is_required", "passing_score", "max_questions",
"is_required", "passing_score", "max_questions", "shuffle_questions",
],
include: [{
model: QuizQuestion, as: "questions",
@@ -489,7 +468,9 @@ exports.getUnitQuiz = async (req, res) => {
if (!quiz) return R.error(res, "Quiz not found.", 404);
const plain = quiz.toJSON();
plain.questions = shuffleOptions(sanitizeQuestions(plain.questions ?? []));
let qs = sanitizeQuestions(plain.questions ?? []);
if (plain.shuffle_questions) qs = shuffleQuestions(qs);
plain.questions = shuffleOptions(qs);
const attempts = await QuizAttempt.findAll({
where: { quiz_id: quiz.quiz_id, user_id: req.user.user_id },
@@ -505,6 +486,12 @@ exports.getUnitQuiz = async (req, res) => {
plain.window_reset_at = status.window_reset_at;
plain.can_attempt = status.can_attempt;
const activeSession = await QuizSession.findOne({
where: { quiz_id: quiz.quiz_id, user_id: req.user.user_id, status: 'in_progress' },
attributes: ["session_id", "draft_answers", "started_at", "last_saved_at"],
});
plain.active_session = activeSession ?? null;
return R.success(res, "Quiz retrieved.", plain);
} catch (err) {
console.error("[CLIENT][QUIZ][GET]", err);
@@ -524,7 +511,7 @@ exports.getCourseAssessment = async (req, res) => {
"assessment_id", "uuid", "title",
"is_required", "passing_score",
"time_limit_minutes", "max_questions",
"max_attempts", "cooldown_hours",
"max_attempts", "cooldown_hours", "shuffle_questions",
],
include: [{
model: QuizQuestion, as: "questions",
@@ -541,7 +528,9 @@ exports.getCourseAssessment = async (req, res) => {
if (!assessment) return R.error(res, "Assessment not found.", 404);
const plain = assessment.toJSON();
plain.questions = shuffleOptions(sanitizeQuestions(plain.questions ?? []));
let qs = sanitizeQuestions(plain.questions ?? []);
if (plain.shuffle_questions) qs = shuffleQuestions(qs);
plain.questions = shuffleOptions(qs);
// All graded attempts for cooldown/status calc
const attempts = await QuizAttempt.findAll({
@@ -791,6 +780,12 @@ exports.submitUnitQuiz = async (req, res) => {
passed,
});
// Close any open draft session for this quiz
await QuizSession.update(
{ status: 'submitted' },
{ where: { quiz_id: quiz.quiz_id, user_id, status: 'in_progress' } }
);
return R.success(res, "Quiz submitted.", {
attempt_id: attempt.attempt_id,
attempt_number: attempt.attempt_number,
@@ -806,6 +801,38 @@ exports.submitUnitQuiz = async (req, res) => {
}
};
// ─── QUIZ DRAFT UPSERT ────────────────────────────────────────────────────────
exports.saveQuizDraft = async (req, res) => {
try {
const { courseId, unitId, quizId } = req.params;
const { answers = {} } = req.body;
const user_id = req.user.user_id;
// Update all in_progress sessions for this user+quiz (handles any duplicates gracefully)
const [updatedCount] = await QuizSession.update(
{ draft_answers: answers, last_saved_at: new Date() },
{ where: { quiz_id: quizId, user_id, status: 'in_progress' } }
);
if (updatedCount === 0) {
await QuizSession.create({
quiz_id: quizId,
user_id,
course_id: courseId,
unit_id: unitId,
draft_answers: answers,
started_at: new Date(),
});
}
return res.status(204).end();
} catch (err) {
console.error("[CLIENT][QUIZ][DRAFT]", err);
return R.error(res, "Could not save quiz draft.", 500);
}
};
exports.submitCourseAssessment = async (req, res) => {
try {
const { courseId, assessmentId } = req.params;
+10 -1
View File
@@ -79,6 +79,11 @@ function pipeRemoteStream(remoteUrl, req, res) {
const proxyHeaders = { "User-Agent": "StarrMediaProxy/1.0" };
if (req.headers.range) proxyHeaders["Range"] = req.headers.range;
// Tracks whether the client dropped the connection first.
// proxyReq.destroy() itself fires an "error" event — we silence it when
// we were the ones who triggered the teardown (client-closed case).
let clientClosed = false;
const proxyReq = transport.request(remoteUrl, { headers: proxyHeaders }, (proxyRes) => {
const status = proxyRes.statusCode === 206 ? 206 : 200;
@@ -101,11 +106,15 @@ function pipeRemoteStream(remoteUrl, req, res) {
});
proxyReq.on("error", (err) => {
if (clientClosed) return; // browser navigated away / component unmounted — expected
console.error("[CLIENT][MEDIA][PROXY] Stream error:", err.message);
if (!res.headersSent) res.status(502).json({ message: "Stream unavailable." });
});
req.on("close", () => proxyReq.destroy());
req.on("close", () => {
clientClosed = true;
proxyReq.destroy();
});
proxyReq.end();
}
+29
View File
@@ -165,6 +165,35 @@ exports.deleteAvatar = async (req, res) => {
}
};
// ─── DELETE own account ────────────────────────────────────────────────────────
exports.deleteAccount = async (req, res) => {
try {
const user = await mdl_Users.findByPk(req.user.user_id);
if (!user) return R.error(res, 'User not found.', 404);
// Revoke all active sessions first
await mdl_UserSessions.update(
{ is_active: false, logout_info: { date: new Date().toISOString(), ip_address: req.ip, reason: 'account_deleted' } },
{ where: { user_id: req.user.user_id, is_active: true } },
);
// Anonymize email before soft-delete so the unique slot is freed for re-registration
await user.update({ email: `deleted_${req.user.user_id}@deleted.invalid`, deletedBy: req.user.user_id });
await user.destroy(); // paranoid soft-delete — sets deleted_at
logActivity(req.user.user_id, 'delete_account');
res.clearCookie('refreshToken');
res.clearCookie('_csrf');
return R.success(res, 'Account deleted.');
} catch (err) {
console.error('[CLIENT] deleteAccount error:', err);
return R.error(res, 'Could not delete account.', 500);
}
};
// ─── GET own achievements ──────────────────────────────────────────────────────
exports.getAchievements = async (req, res) => {
+39 -2
View File
@@ -6,8 +6,10 @@
* GET /client/groups/:groupId/task-lists/:taskListId/tasks/:taskId/progress
* → returns full progress snapshot: { link_visits, progress }
*
* POST /client/groups/:groupId/task-lists/:taskListId/tasks/:taskId/requirements/:requirementId/visit
* → UPSERT TaskLinkVisit (visit_link)
* POST /client/groups/:groupId/task-lists/:taskListId/tasks/:taskId/requirements/:requirementId/visit
* → UPSERT TaskLinkVisit (visit_link)
* DELETE /client/groups/:groupId/task-lists/:taskListId/tasks/:taskId/requirements/:requirementId/visit
* → DELETE TaskLinkVisit (unsubmit)
*
* POST /client/groups/:groupId/task-lists/:taskListId/tasks/:taskId/requirements/:requirementId/progress
* → UPSERT TaskProgress (read_lesson) + derives read_unit + read_course
@@ -183,6 +185,41 @@ exports.visitLink = async (req, res) => {
}
};
// =============================================================================
// ── UNVISIT LINK (DELETE TaskLinkVisit) ──────────────────────────────────────
// =============================================================================
// DELETE /client/groups/:groupId/task-lists/:taskListId/tasks/:taskId/requirements/:requirementId/visit
exports.unvisitLink = async (req, res) => {
const t = await sequelize.transaction();
try {
const { groupId, taskListId, taskId, requirementId } = req.params;
const member = await isMember(req.user.user_id, groupId);
if (!member) { await t.rollback(); return R.error(res, 'Group not found or you are not a member.', 403); }
const requirement = await getRequirement(requirementId, taskId);
if (!requirement) { await t.rollback(); return R.error(res, 'Requirement not found.', 404); }
if (requirement.type !== 'visit_link') {
await t.rollback();
return R.error(res, 'Requirement is not a visit_link type.', 400);
}
await TaskLinkVisit.destroy({
where: { requirement_id: requirementId, user_id: req.user.user_id, task_id: taskId },
transaction: t,
});
await t.commit();
return R.success(res, 'Link visit removed.', { requirement_id: requirementId });
} catch (err) {
await t.rollback();
console.error('[CLIENT][UNVISIT LINK]', err);
return R.error(res, 'Could not remove link visit.', 500);
}
};
// =============================================================================
// ── UPDATE LESSON PROGRESS (UPSERT — derives unit + course) ──────────────────
// =============================================================================
+101 -14
View File
@@ -10,10 +10,12 @@
* Date Created: Jun. 6, 2026
* Modified: Jun. 9, 2026
***********************************************************************************************************************************************************************/
const mdl_TierPlans = require('../../models/tiers/tier_plans.mdl');
const mdl_UserTiers = require('../../models/tiers/user_tiers.mdl');
const mdl_Payments = require('../../models/tiers/payments.mdl');
const mdl_PlanCourses = require('../../models/tiers/plan_courses.mdl');
const mdl_TierCategories = require('../../models/tiers/tier_categories.mdl');
const mdl_TierPlans = require('../../models/tiers/tier_plans.mdl');
const mdl_UserTiers = require('../../models/tiers/user_tiers.mdl');
const mdl_Payments = require('../../models/tiers/payments.mdl');
const mdl_SystemBadges = require('../../models/system_badges/system_badges.mdl');
const Asset = require('../../models/assets/assets.mdl');
const { onTierActivated } = require('../../services/achievements.service');
const { Course } = require('../../models/courses/courses.mdl');
const paypal = require('../../services/paypal.service');
@@ -49,9 +51,42 @@ exports.getMyTier = async (req, res) => {
try {
const tier = await mdl_UserTiers.findOne({
where: { user_id: req.user.user_id, status: 'active' },
include: [{
model: mdl_TierPlans,
as: 'plan',
required: false,
include: [{
model: mdl_TierCategories,
as: 'category',
required: false,
include: [{ model: Asset, as: 'badgeAsset', attributes: ['asset_id', 'file_url', 'display_name'], required: false }],
}],
}],
order: [['createdAt', 'DESC']],
});
return R.success(res, 'Active tier retrieved.', tier ?? { tier: 'free', status: 'active' });
if (!tier) {
// Free users with no user_tier row: look up free category badge
const freeCategory = await mdl_TierCategories.findOne({
where: { slug: 'free' },
include: [{ model: Asset, as: 'badgeAsset', attributes: ['asset_id', 'file_url', 'display_name'], required: false }],
});
return R.success(res, 'Active tier retrieved.', { tier: 'free', status: 'active', category: freeCategory ?? null });
}
// Supplement with the tier category badge even when the user's tier slug doesn't come via a plan
// (e.g., manually granted tiers that only store a slug, not a plan_id)
if (!tier.plan?.category) {
const category = await mdl_TierCategories.findOne({
where: { slug: tier.tier },
include: [{ model: Asset, as: 'badgeAsset', attributes: ['asset_id', 'file_url', 'display_name'], required: false }],
});
const plain = tier.toJSON();
plain.category = category?.toJSON() ?? null;
return R.success(res, 'Active tier retrieved.', plain);
}
return R.success(res, 'Active tier retrieved.', tier);
} catch (err) {
console.error('[CLIENT][GET MY TIER]', err);
return R.error(res, 'Could not retrieve tier.', 500);
@@ -282,25 +317,62 @@ exports.getMyPayments = async (req, res) => {
}
};
// ─── SYSTEM BADGES (read-only for client profile) ────────────────────────────
exports.getCategories = async (req, res) => {
try {
const categories = await mdl_TierCategories.findAll({
where: { is_active: true },
attributes: ['tier_category_id', 'slug', 'name', 'rank', 'color', 'badge_label', 'is_default'],
include: [{ model: Asset, as: 'badgeAsset', attributes: ['file_url', 'display_name'], required: false }],
order: [['rank', 'ASC']],
});
return R.success(res, 'Tier categories retrieved.', categories);
} catch (err) {
console.error('[CLIENT][GET TIER CATEGORIES]', err);
return R.error(res, 'Could not retrieve tier categories.', 500);
}
};
exports.getSystemBadges = async (req, res) => {
try {
const badges = await mdl_SystemBadges.findAll({
attributes: ['key', 'label', 'description', 'information', 'active_from', 'active_until'],
include: [{ model: Asset, as: 'asset', attributes: ['file_url', 'display_name'], required: false }],
order: [['key', 'ASC']],
});
return R.success(res, 'System badges retrieved.', badges);
} catch (err) {
console.error('[CLIENT][GET SYSTEM BADGES]', err);
return R.error(res, 'Could not retrieve system badges.', 500);
}
};
// — add refundOrder export ────────────────
const REFUND_WINDOW_MS = 5 * 60 * 1000; // 5 minutes from paid_at
exports.refundOrder = async (req, res) => {
try {
const user_id = req.user.user_id;
// Get the active tier
const activeTier = await mdl_UserTiers.findOne({
where: { user_id, status: 'active' },
order: [['createdAt', 'DESC']],
});
if (!activeTier) return R.error(res, 'No active tier to refund.', 404);
// Get the completed payment for this tier
const payment = await mdl_Payments.findOne({
where: { user_id, tier_id: activeTier.tier_id, status: 'completed' },
order: [['paid_at', 'DESC']],
});
if (!payment) return R.error(res, 'No completed payment found for this tier.', 404);
// Enforce 5-minute refund window
if (!payment.paid_at || Date.now() - new Date(payment.paid_at).getTime() > REFUND_WINDOW_MS)
return R.error(res, 'Refund window has expired. Refunds are only available within 5 minutes of payment.', 403);
// Get capture_id from provider_payload
const captureId = payment.provider_payload?.capture_id;
@@ -325,13 +397,28 @@ exports.refundOrder = async (req, res) => {
},
});
// Cancel tier at end of period — keep access until expires_at
await activeTier.update({ status: 'revoked' });
return R.success(res, 'Refund processed successfully. Your access will remain until the end of the billing period.', {
refund_id: refundData.id,
status: refundData.status,
expires_at: activeTier.expires_at,
// Immediately terminate access — cut expires_at to now and revoke
const now = new Date();
await activeTier.update({
status: 'revoked',
expires_at: now,
revoked_at: now,
});
// Drop user back to free immediately
await mdl_UserTiers.create({
user_id,
tier: 'free',
status: 'active',
starts_at: now,
expires_at: null,
granted_by: null,
notes: 'Auto-downgrade after refund.',
});
return R.success(res, 'Refund processed successfully. Your access has been revoked.', {
refund_id: refundData.id,
status: refundData.status,
});
} catch (err) {
console.error('[CLIENT][REFUND]', err);