mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
1464 lines
58 KiB
JavaScript
1464 lines
58 KiB
JavaScript
/***********************************************************************************************************************************************************************
|
|
* File Name: courses.controller.js (client)
|
|
* Type of Program: Controller
|
|
* Description: User-facing course endpoints (read-only).
|
|
* Access rules:
|
|
* - All courses are returned in the list (for upsell visibility)
|
|
* - Each course has is_locked: boolean based on the user's active tier
|
|
* - free / no active tier → unassigned courses are open; plan courses are locked
|
|
* - premium (active tier) → unassigned + courses under their plan are open
|
|
* - getCourse still enforces hard 403 on locked access
|
|
* Author: Kenneth Obsequio (@lash0000)
|
|
* Date Created: Jun. 7, 2026
|
|
***********************************************************************************************************************************************************************/
|
|
"use strict";
|
|
|
|
const { Op } = require("sequelize");
|
|
const R = require("../../utils/response.util");
|
|
const mdl_UserTiers = require("../../models/tiers/user_tiers.mdl");
|
|
const mdl_CoursePurchase = require("../../models/courses/course_purchases.mdl");
|
|
const mdl_Product = require("../../models/courses/products.mdl");
|
|
const mdl_Category = require("../../models/courses/categories.mdl");
|
|
const mdl_PlanPolicy = require("../../models/tiers/plan_policies.mdl");
|
|
const { mdl_UserGroupMembers } = require("../../models/users/user_groups.mdl");
|
|
const { evaluateCourseAccess } = require("../../utils/accessPolicy.util");
|
|
|
|
const {
|
|
Course,
|
|
Unit, Lesson, LessonPage,
|
|
CourseUnit, UnitLesson,
|
|
CourseObjective, LessonObjective,
|
|
CoursePrerequisite, CourseAssessment,
|
|
UnitQuiz, QuizQuestion, QuizOption, QuizAttempt,
|
|
AssessmentSession, QuizSession, LessonReadingProgress,
|
|
} = require("../../models/courses/courses.associations");
|
|
const { flattenUnits, flattenLessons } = require("../../utils/courses/hierarchy.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 {
|
|
evaluateEntity,
|
|
recomputeUnitAfterQuiz,
|
|
recomputeCourseAfterAssessment,
|
|
} = require('../../services/completion_requirements.service');
|
|
const CompletionRequirement = require('../../models/courses/completion_requirement.mdl');
|
|
const PendingCertificate = require('../../models/courses/pending_certificate.mdl');
|
|
const Certificate = require('../../models/courses/certificate.mdl');
|
|
const UserNotification = require('../../models/notifications/user_notification.mdl');
|
|
const { NOTIFICATION_REGISTRY } = require('../../data/notifications.data');
|
|
|
|
const notDeleted = { deletedAt: null };
|
|
|
|
// Returns expiry info for a timed assessment session.
|
|
// Pass storedExpiresAt when the session already has a DB-persisted expires_at
|
|
// (post-resume sessions get their expires_at extended to account for offline gaps).
|
|
function computeExpiryInfo(startedAt, timeLimitMinutes, storedExpiresAt = null) {
|
|
const expires_at = storedExpiresAt
|
|
? new Date(storedExpiresAt)
|
|
: (timeLimitMinutes && startedAt ? new Date(new Date(startedAt).getTime() + timeLimitMinutes * 60000) : null);
|
|
if (!expires_at) return { expires_at: null, expired: false, remaining_seconds: null };
|
|
const now = new Date();
|
|
const expired = now >= expires_at;
|
|
const remaining_seconds = expired ? 0 : Math.ceil((expires_at - now) / 1000);
|
|
return { expires_at, expired, remaining_seconds };
|
|
}
|
|
|
|
// Creates a zero-score quiz_attempt for an expired session and marks the session 'expired'.
|
|
async function expireSession(session, passingScore) {
|
|
const priorCount = await QuizAttempt.count({
|
|
where: { assessment_id: session.assessment_id, user_id: session.user_id },
|
|
});
|
|
const expiredAttempt = await QuizAttempt.create({
|
|
user_id: session.user_id,
|
|
assessment_id: session.assessment_id,
|
|
course_id: session.course_id,
|
|
attempt_number: priorCount + 1,
|
|
answers: {},
|
|
total_points: 0,
|
|
earned_points: 0,
|
|
score: 0,
|
|
passing_score: passingScore ?? 70,
|
|
passed: false,
|
|
});
|
|
await AssessmentSession.update(
|
|
{ status: 'expired', attempt_id: expiredAttempt.attempt_id },
|
|
{ where: { session_id: session.session_id } }
|
|
);
|
|
return expiredAttempt;
|
|
}
|
|
|
|
// Builds user context for evaluateCourseAccess: active tier slug, live tier
|
|
// rank map, the active plan's access_rules (if any), and group memberships
|
|
// (needed for the group_restriction rule type).
|
|
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;
|
|
|
|
let access_rules = [];
|
|
if (activeTier?.plan_id) {
|
|
const policy = await mdl_PlanPolicy.findOne({ where: { plan_id: activeTier.plan_id } });
|
|
access_rules = policy?.access_rules ?? [];
|
|
}
|
|
|
|
const memberships = await mdl_UserGroupMembers.findAll({
|
|
where: { user_id, deletedAt: null },
|
|
attributes: ['group_id'],
|
|
});
|
|
const group_ids = memberships.map((m) => m.group_id);
|
|
|
|
return { tier, tierRankMap, access_rules, group_ids };
|
|
}
|
|
|
|
// ─── 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) {
|
|
const course = await Course.findOne({ where: { course_id, ...notDeleted }, attributes: ['subscription', 'status'] });
|
|
if (!course) return false;
|
|
if (course.status !== 'published') return false;
|
|
|
|
const userCtx = await buildUserContext(user_id);
|
|
|
|
// evaluateCourseAccess already falls back to plain rank comparison when the
|
|
// active plan has no access_rules configured — same behavior as before for
|
|
// every course/plan combination that hasn't opted into the richer engine.
|
|
const { allowed } = evaluateCourseAccess(userCtx, course, userCtx.tierRankMap);
|
|
if (allowed) return true;
|
|
|
|
// Individual purchase as fallback
|
|
const product = await mdl_Product.findOne({ where: { course_id } });
|
|
if (!product) return false;
|
|
|
|
const hasPurchase = await mdl_CoursePurchase.findOne({
|
|
where: {
|
|
user_id,
|
|
product_id: product.id,
|
|
status: 'completed',
|
|
[Op.or]: [{ expires_at: null }, { expires_at: { [Op.gt]: new Date() } }],
|
|
},
|
|
});
|
|
return !!hasPurchase;
|
|
}
|
|
|
|
// ─── Standalone access checks (junction revamp) ──────────────────────────────
|
|
// A Unit attached to no course is open to every authenticated user; a Unit
|
|
// attached to one or more courses is open when the user can access ANY of them.
|
|
// Lessons resolve through their parent units the same way. This keeps paid
|
|
// content locked while letting genuinely standalone content run independently.
|
|
|
|
async function canAccessUnit(user_id, unit_id) {
|
|
// A unit's own subscription (standalone tier-gating) is an additional,
|
|
// OR'd access path alongside any attached course's access — most
|
|
// standalone units have zero course links anyway, but a unit that somehow
|
|
// has both should be unlockable via either.
|
|
const unit = await Unit.findOne({ where: { unit_id, ...notDeleted }, attributes: ['subscription'] });
|
|
if (unit?.subscription) {
|
|
const userCtx = await buildUserContext(user_id);
|
|
const { allowed } = evaluateCourseAccess(userCtx, { subscription: unit.subscription }, userCtx.tierRankMap);
|
|
if (allowed) return true;
|
|
}
|
|
|
|
// Only links to PUBLISHED courses count as a real course dependency — a unit
|
|
// whose only link is to a draft/unpublished course behaves as if it had no
|
|
// course link at all (falls through to the free/standalone branch below),
|
|
// matching the client discovery-list's course_count computation.
|
|
const links = await CourseUnit.findAll({
|
|
where: { unit_id },
|
|
attributes: ['course_id'],
|
|
include: [{ model: Course, as: 'course', attributes: [], where: { status: 'published', ...notDeleted }, required: true }],
|
|
});
|
|
if (!links.length) return !unit?.subscription;
|
|
for (const link of links) {
|
|
if (await canAccessCourse(user_id, link.course_id)) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
async function canAccessLesson(user_id, lesson_id) {
|
|
const unitLinks = await UnitLesson.findAll({ where: { lesson_id }, attributes: ['unit_id'] });
|
|
if (!unitLinks.length) return true;
|
|
for (const link of unitLinks) {
|
|
if (await canAccessUnit(user_id, link.unit_id)) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
exports.canAccessCourse = canAccessCourse;
|
|
exports.canAccessUnit = canAccessUnit;
|
|
exports.canAccessLesson = canAccessLesson;
|
|
|
|
const COURSE_LIST_ATTRS = [
|
|
"course_id", "uuid", "title", "description",
|
|
"course_code", "level", "subscription",
|
|
"duration_seconds", "order_index",
|
|
"badge_color", "badge_asset_id", "badge_image_url",
|
|
];
|
|
|
|
// Strip correct-answer data before sending quiz questions to the client.
|
|
// For multi_select, preserve correct_count so the client can show "Select X answers"
|
|
// without revealing which options are correct.
|
|
function sanitizeQuestions(questions = []) {
|
|
return questions.map((q) => {
|
|
const plain = q.toJSON ? q.toJSON() : { ...q };
|
|
if (plain.type === 'multi_select') {
|
|
plain.correct_count = (plain.options ?? []).filter((o) => o.is_correct).length;
|
|
}
|
|
plain.options = (plain.options ?? []).map(({ is_correct: _drop, ...o }) => o);
|
|
delete plain.explanation;
|
|
return plain;
|
|
});
|
|
}
|
|
|
|
// Resolve the caller's active tier (returns null if free/expired)
|
|
async function getActiveTier(user_id) {
|
|
return mdl_UserTiers.findOne({
|
|
where: { user_id, status: "active" },
|
|
order: [["createdAt", "DESC"]],
|
|
});
|
|
}
|
|
|
|
// ─── COURSE CATEGORIES (public list for filter chips) ─────────────────────────
|
|
|
|
exports.getCategories = async (req, res) => {
|
|
try {
|
|
const rows = await mdl_Category.findAll({
|
|
where: { is_active: true },
|
|
attributes: ['id', 'name', 'slug'],
|
|
order: [['name', 'ASC']],
|
|
});
|
|
return R.success(res, 'Categories retrieved.', rows);
|
|
} catch (err) {
|
|
console.error('[CLIENT][COURSES][CATEGORIES]', err);
|
|
return R.error(res, 'Could not retrieve categories.', 500);
|
|
}
|
|
};
|
|
|
|
// ─── COURSES (all visible, is_locked per user tier) ───────────────────────────
|
|
|
|
exports.getCourses = async (req, res) => {
|
|
try {
|
|
const { category } = req.query; // optional slug filter
|
|
|
|
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({
|
|
where: { user_id: req.user.user_id, status: 'completed' },
|
|
include: [{ model: mdl_Product, as: 'product', attributes: ['course_id', 'access_days'] }],
|
|
attributes: ['id', 'expires_at', 'product_id'],
|
|
});
|
|
|
|
const purchasedCourseIds = new Set(
|
|
myPurchases
|
|
.filter((p) => !p.expires_at || new Date(p.expires_at) > new Date())
|
|
.map((p) => String(p.product?.course_id))
|
|
);
|
|
|
|
// Build category filter
|
|
const categoryInclude = {
|
|
model: mdl_Category,
|
|
as: 'categories',
|
|
through: { attributes: [] },
|
|
attributes: ['id', 'name', 'slug'],
|
|
required: !!category,
|
|
...(category ? { where: { slug: category } } : {}),
|
|
};
|
|
|
|
const courses = await Course.findAll({
|
|
where: { ...notDeleted, status: 'published' },
|
|
attributes: COURSE_LIST_ATTRS,
|
|
include: [
|
|
{
|
|
model: mdl_Product,
|
|
as: 'product',
|
|
required: false,
|
|
attributes: ['id', 'name', 'price', 'currency', 'access_days', 'is_active'],
|
|
paranoid: false,
|
|
},
|
|
categoryInclude,
|
|
],
|
|
order: [['order_index', 'ASC'], ['title', 'ASC']],
|
|
});
|
|
|
|
const userRank = userCtx.tierRankMap[userCtx.tier] ?? 0;
|
|
|
|
const result = courses.map((c) => {
|
|
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 is_locked = courseRank > 0 && !has_purchased && userRank < courseRank;
|
|
|
|
return { ...plain, is_locked, has_purchased };
|
|
});
|
|
|
|
return R.success(res, "Courses retrieved.", result);
|
|
} catch (err) {
|
|
console.error("[CLIENT][COURSES][GET ALL]", err);
|
|
return R.error(res, "Could not retrieve courses.", 500);
|
|
}
|
|
};
|
|
|
|
// ─── COURSE DETAIL (hard access check) ───────────────────────────────────────
|
|
|
|
exports.getCourse = async (req, res) => {
|
|
try {
|
|
const { courseId } = req.params;
|
|
|
|
// Access check — plan association → subscription field → individual purchase
|
|
if (!await canAccessCourse(req.user.user_id, courseId)) {
|
|
return R.error(res, "You do not have access to this course.", 403);
|
|
}
|
|
|
|
const course = await Course.findOne({
|
|
where: { course_id: courseId, ...notDeleted },
|
|
attributes: COURSE_LIST_ATTRS,
|
|
include: [
|
|
{
|
|
model: Unit, as: "units",
|
|
where: notDeleted, required: false,
|
|
attributes: [
|
|
"unit_id", "uuid", "title", "description",
|
|
"duration_seconds",
|
|
],
|
|
through: { attributes: ["order_index"] },
|
|
include: [
|
|
{
|
|
model: Lesson, as: "lessons",
|
|
where: notDeleted, required: false,
|
|
attributes: [
|
|
"lesson_id", "uuid", "title", "description",
|
|
"duration_seconds",
|
|
],
|
|
through: { attributes: ["order_index"] },
|
|
},
|
|
{
|
|
model: UnitQuiz, as: "quiz",
|
|
required: false,
|
|
attributes: [
|
|
"quiz_id", "uuid", "title",
|
|
"is_required", "passing_score", "max_questions",
|
|
],
|
|
},
|
|
],
|
|
},
|
|
{
|
|
model: CourseObjective, as: "objectives",
|
|
required: false,
|
|
attributes: ["objective_id", "text", "order_index"],
|
|
},
|
|
{
|
|
model: CoursePrerequisite, as: "prerequisites",
|
|
required: false,
|
|
attributes: ["prereq_id", "ref_type", "ref_id", "order_index"],
|
|
},
|
|
{
|
|
model: CourseAssessment, as: "assessment",
|
|
required: false,
|
|
attributes: [
|
|
"assessment_id", "uuid", "title",
|
|
"is_required", "passing_score",
|
|
"time_limit_minutes", "max_questions",
|
|
],
|
|
include: [{
|
|
model: QuizQuestion, as: "questions",
|
|
attributes: ["question_id"],
|
|
required: false,
|
|
}],
|
|
},
|
|
{
|
|
model: mdl_Category,
|
|
as: "categories",
|
|
through: { attributes: [] },
|
|
required: false,
|
|
attributes: ["id", "name", "slug"],
|
|
},
|
|
],
|
|
order: [
|
|
[{ model: CourseObjective, as: "objectives" }, "order_index", "ASC"],
|
|
[{ model: CoursePrerequisite, as: "prerequisites" }, "order_index", "ASC"],
|
|
],
|
|
});
|
|
|
|
if (!course) return R.error(res, "Course not found.", 404);
|
|
|
|
const plain = course.toJSON();
|
|
plain.units = flattenUnits(plain.units); // junction order_index → flat field, sorted
|
|
|
|
// Attach has_passed to each unit's quiz in one query
|
|
const quizIds = plain.units
|
|
?.map((u) => u.quiz?.quiz_id)
|
|
.filter(Boolean) ?? [];
|
|
|
|
if (quizIds.length) {
|
|
const passedQuizAttempts = await QuizAttempt.findAll({
|
|
where: { quiz_id: quizIds, user_id: req.user.user_id, passed: true },
|
|
attributes: ["quiz_id"],
|
|
});
|
|
const passedSet = new Set(passedQuizAttempts.map((a) => String(a.quiz_id)));
|
|
plain.units = plain.units.map((u) => ({
|
|
...u,
|
|
quiz: u.quiz ? { ...u.quiz, has_passed: passedSet.has(String(u.quiz.quiz_id)) } : null,
|
|
}));
|
|
}
|
|
|
|
// Attach each lesson's resolved completion trigger (read_all_content [default] /
|
|
// watch_percent / manual_complete) so the reader can dispatch the right UI without a
|
|
// second round trip. A lesson may have zero configured rows (→ default scroll trigger)
|
|
// or one row of one of these types (pass_quiz isn't valid on a lesson).
|
|
const allLessonIds = plain.units?.flatMap((u) => (u.lessons ?? []).map((l) => l.lesson_id)) ?? [];
|
|
if (allLessonIds.length) {
|
|
const lessonRequirements = await CompletionRequirement.findAll({
|
|
where: { entity_type: 'lesson', entity_id: allLessonIds },
|
|
attributes: ['entity_id', 'type', 'min_percent', 'button_label'],
|
|
});
|
|
const byLessonId = new Map();
|
|
lessonRequirements.forEach((r) => { if (!byLessonId.has(String(r.entity_id))) byLessonId.set(String(r.entity_id), r); });
|
|
|
|
plain.units = plain.units.map((u) => ({
|
|
...u,
|
|
lessons: (u.lessons ?? []).map((l) => {
|
|
const row = byLessonId.get(String(l.lesson_id));
|
|
return {
|
|
...l,
|
|
completion: row
|
|
? { type: row.type, min_percent: row.min_percent, button_label: row.button_label }
|
|
: { type: 'read_all_content', min_percent: null, button_label: null },
|
|
};
|
|
}),
|
|
}));
|
|
}
|
|
|
|
// has_passed reflects the assessment attempt alone; is_completed is the consolidated
|
|
// evaluator's result (default rule: all units read AND assessment passed, if one exists —
|
|
// was previously hardcoded to assessment-pass alone here too, same bug fixed in
|
|
// submitCourseAssessment/getLessonsByUnitUuid — this is the 5th call site of that bug).
|
|
if (plain.assessment) {
|
|
const passedAttempt = await QuizAttempt.findOne({
|
|
where: { assessment_id: plain.assessment.assessment_id, user_id: req.user.user_id, passed: true },
|
|
});
|
|
const questionCount = plain.assessment.questions?.length ?? 0;
|
|
plain.assessment = {
|
|
...plain.assessment,
|
|
has_passed: !!passedAttempt,
|
|
question_count: questionCount,
|
|
questions: undefined,
|
|
};
|
|
}
|
|
const courseEvaluation = await evaluateEntity({
|
|
entityType: 'course', entityId: course.course_id, userId: req.user.user_id, courseId: course.course_id,
|
|
});
|
|
plain.is_completed = courseEvaluation.status === 'completed';
|
|
|
|
const plan_tier = plain.subscription ?? null;
|
|
|
|
// Attach product info and purchase status for the buy-course flow
|
|
const product = await mdl_Product.findOne({
|
|
where: { course_id: courseId, is_active: true },
|
|
attributes: ['id', 'name', 'price', 'currency', 'access_days'],
|
|
});
|
|
const hasPurchase = product && await mdl_CoursePurchase.findOne({
|
|
where: {
|
|
user_id: req.user.user_id, product_id: product.id, status: 'completed',
|
|
[Op.or]: [{ expires_at: null }, { expires_at: { [Op.gt]: new Date() } }],
|
|
},
|
|
});
|
|
|
|
// Certificate status for the course details card
|
|
const [pendingCert, certificate] = await Promise.all([
|
|
PendingCertificate.findOne({
|
|
where: { user_id: req.user.user_id, course_id: courseId, processed_at: null },
|
|
attributes: ['pending_id', 'passed_at', 'issue_at'],
|
|
}),
|
|
Certificate.findOne({
|
|
where: { user_id: req.user.user_id, course_id: courseId },
|
|
attributes: ['uuid', 'cert_no', 'issued_at'],
|
|
}),
|
|
]);
|
|
|
|
return R.success(res, "Course retrieved.", {
|
|
...plain,
|
|
plan_tier,
|
|
product: product ?? null,
|
|
has_purchased: !!hasPurchase,
|
|
pending_certificate: pendingCert ?? null,
|
|
certificate: certificate ?? null,
|
|
});
|
|
} catch (err) {
|
|
console.error("[CLIENT][COURSES][GET ONE]", err);
|
|
return R.error(res, "Could not retrieve course.", 500);
|
|
}
|
|
};
|
|
|
|
// ─── UNIT ─────────────────────────────────────────────────────────────────────
|
|
|
|
exports.getUnit = async (req, res) => {
|
|
try {
|
|
const { courseId, unitId } = req.params;
|
|
|
|
const link = await CourseUnit.findOne({ where: { course_id: courseId, unit_id: unitId } });
|
|
if (!link) return R.error(res, "Unit not found.", 404);
|
|
|
|
if (!await canAccessUnit(req.user.user_id, unitId)) {
|
|
return R.error(res, "You do not have access to this unit.", 403);
|
|
}
|
|
|
|
const unit = await Unit.findOne({
|
|
where: { unit_id: unitId, ...notDeleted },
|
|
attributes: [
|
|
"unit_id", "uuid", "title", "description",
|
|
"duration_seconds",
|
|
],
|
|
include: [
|
|
{
|
|
model: Lesson, as: "lessons",
|
|
where: notDeleted, required: false,
|
|
attributes: [
|
|
"lesson_id", "uuid", "title", "description",
|
|
"duration_seconds",
|
|
],
|
|
through: { attributes: ["order_index"] },
|
|
},
|
|
{
|
|
model: UnitQuiz, as: "quiz",
|
|
required: false,
|
|
attributes: [
|
|
"quiz_id", "uuid", "title",
|
|
"is_required", "passing_score", "max_questions",
|
|
],
|
|
},
|
|
],
|
|
});
|
|
|
|
if (!unit) return R.error(res, "Unit not found.", 404);
|
|
|
|
const plain = unit.toJSON();
|
|
plain.order_index = link.order_index;
|
|
plain.lessons = flattenLessons(plain.lessons);
|
|
return R.success(res, "Unit retrieved.", plain);
|
|
} catch (err) {
|
|
console.error("[CLIENT][UNIT][GET ONE]", err);
|
|
return R.error(res, "Could not retrieve unit.", 500);
|
|
}
|
|
};
|
|
|
|
// ─── LESSON ───────────────────────────────────────────────────────────────────
|
|
|
|
exports.getLesson = async (req, res) => {
|
|
try {
|
|
const { courseId, unitId, lessonId } = req.params;
|
|
|
|
const [courseLink, lessonLink] = await Promise.all([
|
|
CourseUnit.findOne({ where: { course_id: courseId, unit_id: unitId } }),
|
|
UnitLesson.findOne({ where: { unit_id: unitId, lesson_id: lessonId } }),
|
|
]);
|
|
if (!courseLink || !lessonLink) return R.error(res, "Lesson not found.", 404);
|
|
|
|
if (!await canAccessLesson(req.user.user_id, lessonId)) {
|
|
return R.error(res, "You do not have access to this lesson.", 403);
|
|
}
|
|
|
|
const lesson = await Lesson.findOne({
|
|
where: { lesson_id: lessonId, ...notDeleted },
|
|
attributes: [
|
|
"lesson_id", "uuid", "title",
|
|
"description", "duration_seconds",
|
|
],
|
|
include: [
|
|
{
|
|
model: LessonPage, as: "page",
|
|
required: false,
|
|
attributes: ["page_id", "blocks"],
|
|
},
|
|
{
|
|
model: LessonObjective, as: "objectives",
|
|
required: false,
|
|
attributes: ["objective_id", "text", "order_index"],
|
|
},
|
|
],
|
|
order: [[{ model: LessonObjective, as: "objectives" }, "order_index", "ASC"]],
|
|
});
|
|
|
|
if (!lesson) return R.error(res, "Lesson not found.", 404);
|
|
return R.success(res, "Lesson retrieved.", { ...lesson.toJSON(), unit_id: Number(unitId), order_index: lessonLink.order_index });
|
|
} catch (err) {
|
|
console.error("[CLIENT][LESSON][GET ONE]", err);
|
|
return R.error(res, "Could not retrieve lesson.", 500);
|
|
}
|
|
};
|
|
|
|
// ─── QUIZ (no answers) ────────────────────────────────────────────────────────
|
|
|
|
exports.getUnitQuiz = async (req, res) => {
|
|
try {
|
|
const { courseId, unitId } = req.params;
|
|
|
|
const link = await CourseUnit.findOne({ where: { course_id: courseId, unit_id: unitId } });
|
|
if (!link) return R.error(res, "Unit not found.", 404);
|
|
|
|
if (!await canAccessUnit(req.user.user_id, unitId)) {
|
|
return R.error(res, "You do not have access to this unit.", 403);
|
|
}
|
|
|
|
const quiz = await UnitQuiz.findOne({
|
|
where: { unit_id: unitId, ...notDeleted },
|
|
attributes: [
|
|
"quiz_id", "uuid", "title",
|
|
"is_required", "passing_score", "max_questions", "shuffle_questions",
|
|
],
|
|
include: [{
|
|
model: QuizQuestion, as: "questions",
|
|
where: notDeleted, required: false,
|
|
attributes: ["question_id", "uuid", "type", "question", "order_index", "points"],
|
|
include: [{
|
|
model: QuizOption, as: "options",
|
|
attributes: ["option_id", "text", "order_index", "is_correct"],
|
|
}],
|
|
}],
|
|
order: [[{ model: QuizQuestion, as: "questions" }, "order_index", "ASC"]],
|
|
});
|
|
|
|
if (!quiz) return R.error(res, "Quiz not found.", 404);
|
|
|
|
const plain = quiz.toJSON();
|
|
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 },
|
|
attributes: ["attempt_id", "attempt_number", "score", "passing_score", "passed", "createdAt"],
|
|
});
|
|
|
|
const status = getAttemptStatus(attempts, 'quiz');
|
|
plain.attempt_count = status.attempt_count;
|
|
plain.has_passed = status.has_passed;
|
|
plain.best_attempt = status.best_attempt;
|
|
plain.attempts_remaining = status.attempts_remaining;
|
|
plain.cooldown_until = status.cooldown_until;
|
|
plain.window_reset_at = status.window_reset_at;
|
|
plain.can_attempt = status.can_attempt;
|
|
|
|
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);
|
|
return R.error(res, "Could not retrieve quiz.", 500);
|
|
}
|
|
};
|
|
|
|
// ─── ASSESSMENT (no answers) ──────────────────────────────────────────────────
|
|
|
|
exports.getCourseAssessment = async (req, res) => {
|
|
try {
|
|
const { courseId } = req.params;
|
|
|
|
if (!await canAccessCourse(req.user.user_id, courseId)) {
|
|
return R.error(res, "You do not have access to this course.", 403);
|
|
}
|
|
|
|
const assessment = await CourseAssessment.findOne({
|
|
where: { course_id: courseId, ...notDeleted },
|
|
attributes: [
|
|
"assessment_id", "uuid", "title",
|
|
"is_required", "passing_score",
|
|
"time_limit_minutes", "max_questions",
|
|
"max_attempts", "cooldown_hours", "shuffle_questions",
|
|
],
|
|
include: [{
|
|
model: QuizQuestion, as: "questions",
|
|
where: notDeleted, required: false,
|
|
attributes: ["question_id", "uuid", "type", "question", "order_index", "points"],
|
|
include: [{
|
|
model: QuizOption, as: "options",
|
|
attributes: ["option_id", "text", "order_index", "is_correct"],
|
|
}],
|
|
}],
|
|
order: [[{ model: QuizQuestion, as: "questions" }, "order_index", "ASC"]],
|
|
});
|
|
|
|
if (!assessment) return R.error(res, "Assessment not found.", 404);
|
|
|
|
const plain = assessment.toJSON();
|
|
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({
|
|
where: { assessment_id: assessment.assessment_id, user_id: req.user.user_id },
|
|
attributes: ["attempt_id", "attempt_number", "score", "passing_score", "passed", "createdAt"],
|
|
});
|
|
|
|
// Find active session from the dedicated sessions table
|
|
const activeSession = await AssessmentSession.findOne({
|
|
where: { assessment_id: assessment.assessment_id, user_id: req.user.user_id, status: 'in_progress' },
|
|
attributes: ["session_id", "started_at", "expires_at", "status", "assessment_id", "user_id", "course_id", "draft_answers", "last_heartbeat_at"],
|
|
});
|
|
|
|
if (activeSession) {
|
|
const { expired, expires_at, remaining_seconds } = computeExpiryInfo(activeSession.started_at, assessment.time_limit_minutes, activeSession.expires_at);
|
|
if (expired) {
|
|
await expireSession(activeSession, assessment.passing_score);
|
|
plain.active_session = null;
|
|
} else {
|
|
plain.active_session = {
|
|
session_id: activeSession.session_id,
|
|
started_at: activeSession.started_at,
|
|
expires_at: expires_at?.toISOString() ?? null,
|
|
remaining_seconds,
|
|
draft_answers: activeSession.draft_answers ?? {},
|
|
};
|
|
}
|
|
} else {
|
|
plain.active_session = null;
|
|
}
|
|
|
|
const status = getAttemptStatus(attempts, 'assessment', {
|
|
maxFails: plain.max_attempts,
|
|
cooldownHours: plain.cooldown_hours,
|
|
});
|
|
plain.attempt_count = status.attempt_count;
|
|
plain.has_passed = status.has_passed;
|
|
plain.best_attempt = status.best_attempt;
|
|
plain.attempts_remaining = status.attempts_remaining;
|
|
plain.cooldown_until = status.cooldown_until;
|
|
plain.window_reset_at = status.window_reset_at;
|
|
plain.can_attempt = status.can_attempt;
|
|
|
|
return R.success(res, "Assessment retrieved.", plain);
|
|
} catch (err) {
|
|
console.error("[CLIENT][ASSESSMENT][GET]", err);
|
|
return R.error(res, "Could not retrieve assessment.", 500);
|
|
}
|
|
};
|
|
|
|
// ─── ASSESSMENT START (timed sessions) ───────────────────────────────────────
|
|
|
|
exports.startCourseAssessment = async (req, res) => {
|
|
try {
|
|
const { courseId, assessmentId } = req.params;
|
|
const user_id = req.user.user_id;
|
|
|
|
if (!await canAccessCourse(user_id, courseId)) {
|
|
return R.error(res, "You do not have access to this course.", 403);
|
|
}
|
|
|
|
const assessment = await CourseAssessment.findOne({
|
|
where: { assessment_id: assessmentId, course_id: courseId, ...notDeleted },
|
|
attributes: ["assessment_id", "time_limit_minutes", "passing_score", "max_attempts", "cooldown_hours"],
|
|
});
|
|
if (!assessment) return R.error(res, "Assessment not found.", 404);
|
|
|
|
// Return or expire any existing in_progress session
|
|
const existing = await AssessmentSession.findOne({
|
|
where: { assessment_id: assessmentId, user_id, status: 'in_progress' },
|
|
attributes: ["session_id", "started_at", "expires_at", "assessment_id", "user_id", "course_id", "draft_answers", "last_heartbeat_at"],
|
|
});
|
|
|
|
if (existing) {
|
|
const now = new Date();
|
|
|
|
// Extend expires_at by the offline gap so the clock was effectively frozen
|
|
// while the browser was closed.
|
|
// Reference point: last_heartbeat_at if set (draft saved at least once),
|
|
// otherwise fall back to updatedAt (session row last touched — typically creation).
|
|
if (existing.expires_at) {
|
|
const ref = existing.last_heartbeat_at ?? existing.updatedAt;
|
|
const offlineMs = ref ? now - new Date(ref) : 0;
|
|
const GRACE_MS = 30_000; // ignore gaps under 30s (normal between drafts)
|
|
if (offlineMs > GRACE_MS) {
|
|
const extended = new Date(new Date(existing.expires_at).getTime() + offlineMs);
|
|
await existing.update({ expires_at: extended, last_heartbeat_at: now });
|
|
existing.expires_at = extended;
|
|
}
|
|
}
|
|
|
|
const { expired, expires_at, remaining_seconds } = computeExpiryInfo(existing.started_at, assessment.time_limit_minutes, existing.expires_at);
|
|
if (!expired) {
|
|
return R.success(res, "Session resumed.", {
|
|
session_id: existing.session_id,
|
|
started_at: existing.started_at,
|
|
expires_at: expires_at?.toISOString() ?? null,
|
|
remaining_seconds,
|
|
draft_answers: existing.draft_answers ?? {},
|
|
});
|
|
}
|
|
await expireSession(existing, assessment.passing_score);
|
|
}
|
|
|
|
// Cooldown check against all graded attempts
|
|
const priorAttempts = await QuizAttempt.findAll({
|
|
where: { assessment_id: assessmentId, user_id },
|
|
attributes: ["attempt_id", "score", "passed", "createdAt"],
|
|
});
|
|
|
|
const cooldownStatus = getAttemptStatus(priorAttempts, 'assessment', {
|
|
maxFails: assessment.max_attempts,
|
|
cooldownHours: assessment.cooldown_hours,
|
|
});
|
|
if (!cooldownStatus.can_attempt) {
|
|
return R.error(res, `You're on a ${assessment.cooldown_hours}-hour cooldown. Try again after the cooldown expires.`, 429);
|
|
}
|
|
|
|
const now = new Date();
|
|
const { expires_at, remaining_seconds } = computeExpiryInfo(now, assessment.time_limit_minutes);
|
|
|
|
const newSession = await AssessmentSession.create({
|
|
user_id,
|
|
assessment_id: assessmentId,
|
|
course_id: courseId,
|
|
started_at: now,
|
|
expires_at: expires_at ?? null,
|
|
status: 'in_progress',
|
|
});
|
|
|
|
return R.success(res, "Assessment started.", {
|
|
session_id: newSession.session_id,
|
|
started_at: now,
|
|
expires_at: expires_at?.toISOString() ?? null,
|
|
remaining_seconds,
|
|
});
|
|
} catch (err) {
|
|
console.error("[CLIENT][ASSESSMENT][START]", err);
|
|
return R.error(res, "Could not start assessment.", 500);
|
|
}
|
|
};
|
|
|
|
// ─── ASSESSMENT DRAFT UPSERT ─────────────────────────────────────────────────
|
|
// Called every ~25s from the client with current answers.
|
|
// Saves draft_answers + last_heartbeat_at so a crash-resume can restore answers
|
|
// and extend expires_at by the offline gap.
|
|
|
|
exports.getAssessmentSession = async (req, res) => {
|
|
try {
|
|
const { courseId, assessmentId } = req.params;
|
|
const user_id = req.user.user_id;
|
|
|
|
const [session, assessment] = await Promise.all([
|
|
AssessmentSession.findOne({
|
|
where: { assessment_id: assessmentId, user_id, course_id: courseId, status: 'in_progress' },
|
|
attributes: ['session_id', 'started_at', 'expires_at'],
|
|
}),
|
|
CourseAssessment.findOne({
|
|
where: { assessment_id: assessmentId, course_id: courseId },
|
|
attributes: ['time_limit_minutes'],
|
|
}),
|
|
]);
|
|
|
|
if (!session) return R.error(res, "No active session.", 404);
|
|
|
|
const { expired, expires_at, remaining_seconds } = computeExpiryInfo(
|
|
session.started_at,
|
|
assessment?.time_limit_minutes ?? 0,
|
|
session.expires_at
|
|
);
|
|
|
|
if (expired) return R.error(res, "Session expired.", 410);
|
|
|
|
return R.success(res, "Session retrieved.", {
|
|
session_id: session.session_id,
|
|
expires_at: expires_at?.toISOString() ?? null,
|
|
remaining_seconds,
|
|
});
|
|
} catch (err) {
|
|
console.error("[CLIENT][ASSESSMENT][SESSION]", err);
|
|
return R.error(res, "Could not get session.", 500);
|
|
}
|
|
};
|
|
|
|
exports.saveDraft = async (req, res) => {
|
|
try {
|
|
const { assessmentId } = req.params;
|
|
const { answers = {} } = req.body;
|
|
const user_id = req.user.user_id;
|
|
|
|
const session = await AssessmentSession.findOne({
|
|
where: { assessment_id: assessmentId, user_id, status: 'in_progress' },
|
|
attributes: ["session_id", "expires_at"],
|
|
});
|
|
|
|
if (!session) return R.error(res, "No active session.", 404);
|
|
|
|
await session.update({
|
|
draft_answers: answers,
|
|
last_heartbeat_at: new Date(),
|
|
});
|
|
|
|
return R.success(res, "Draft saved.");
|
|
} catch (err) {
|
|
console.error("[CLIENT][ASSESSMENT][DRAFT]", err);
|
|
return R.error(res, "Could not save draft.", 500);
|
|
}
|
|
};
|
|
|
|
// ─── QUIZ SUBMIT ──────────────────────────────────────────────────────────────
|
|
|
|
exports.submitUnitQuiz = async (req, res) => {
|
|
try {
|
|
const { courseId, unitId, quizId } = req.params;
|
|
const { answers = {} } = req.body;
|
|
const user_id = req.user.user_id;
|
|
|
|
const link = await CourseUnit.findOne({ where: { course_id: courseId, unit_id: unitId } });
|
|
if (!link) return R.error(res, "Unit not found.", 404);
|
|
|
|
if (!await canAccessUnit(user_id, unitId)) {
|
|
return R.error(res, "You do not have access to this unit.", 403);
|
|
}
|
|
|
|
const quiz = await UnitQuiz.findOne({
|
|
where: { quiz_id: quizId, unit_id: unitId, ...notDeleted },
|
|
include: [{
|
|
model: QuizQuestion, as: "questions",
|
|
where: notDeleted, required: false,
|
|
include: [{ model: QuizOption, as: "options" }],
|
|
}],
|
|
});
|
|
|
|
|
|
if (!quiz) return R.error(res, "Quiz not found.", 404);
|
|
|
|
const priorAttempts = await QuizAttempt.findAll({
|
|
where: { quiz_id: quiz.quiz_id, user_id },
|
|
attributes: ["attempt_id", "score", "passed", "createdAt"],
|
|
});
|
|
|
|
const { totalPoints, earnedPoints, score } = gradeSubmission(quiz.questions ?? [], answers);
|
|
const passed = score >= (quiz.passing_score ?? 70);
|
|
|
|
const attempt = await QuizAttempt.create({
|
|
user_id,
|
|
quiz_id: quiz.quiz_id,
|
|
course_id: courseId,
|
|
attempt_number: priorAttempts.length + 1,
|
|
answers,
|
|
total_points: totalPoints,
|
|
earned_points: earnedPoints,
|
|
score,
|
|
passing_score: quiz.passing_score ?? 70,
|
|
passed,
|
|
});
|
|
|
|
// Close any open draft session for this quiz
|
|
await QuizSession.update(
|
|
{ status: 'submitted' },
|
|
{ where: { quiz_id: quiz.quiz_id, user_id, status: 'in_progress' } }
|
|
);
|
|
|
|
// Passing a unit quiz can satisfy a pass_quiz completion requirement on the unit (and
|
|
// cascade to the course) — previously this endpoint never touched reading progress at all.
|
|
// Also syncs any read_unit/read_course task requirements the unit/course completion now
|
|
// satisfies, even though no lesson was read (the gap task-sync used to miss).
|
|
let completedTasks = [];
|
|
if (passed) {
|
|
const evaluation = await recomputeUnitAfterQuiz(user_id, { unitId, courseId });
|
|
completedTasks = evaluation?.completed_tasks ?? [];
|
|
}
|
|
|
|
return R.success(res, "Quiz submitted.", {
|
|
attempt_id: attempt.attempt_id,
|
|
attempt_number: attempt.attempt_number,
|
|
score,
|
|
passed,
|
|
passing_score: attempt.passing_score,
|
|
total_points: totalPoints,
|
|
earned_points: earnedPoints,
|
|
completed_tasks: completedTasks,
|
|
});
|
|
} catch (err) {
|
|
console.error("[CLIENT][QUIZ][SUBMIT]", err);
|
|
return R.error(res, "Could not submit quiz.", 500);
|
|
}
|
|
};
|
|
|
|
// ─── 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;
|
|
const { answers = {}, session_id } = req.body;
|
|
const user_id = req.user.user_id;
|
|
|
|
if (!await canAccessCourse(user_id, courseId)) {
|
|
return R.error(res, "You do not have access to this course.", 403);
|
|
}
|
|
|
|
const assessment = await CourseAssessment.findOne({
|
|
where: { assessment_id: assessmentId, course_id: courseId, ...notDeleted },
|
|
include: [{
|
|
model: QuizQuestion, as: "questions",
|
|
where: notDeleted, required: false,
|
|
include: [{ model: QuizOption, as: "options" }],
|
|
}],
|
|
});
|
|
if (!assessment) return R.error(res, "Assessment not found.", 404);
|
|
|
|
let activeSession = null;
|
|
|
|
if (session_id) {
|
|
activeSession = await AssessmentSession.findOne({
|
|
where: { session_id, user_id, assessment_id: assessmentId, status: 'in_progress' },
|
|
attributes: ["session_id", "started_at", "assessment_id", "user_id", "course_id"],
|
|
});
|
|
if (!activeSession) return R.error(res, "Session not found or already submitted.", 409);
|
|
|
|
const { expired } = computeExpiryInfo(activeSession.started_at, assessment.time_limit_minutes);
|
|
if (expired) {
|
|
await expireSession(activeSession, assessment.passing_score);
|
|
return R.error(res, "Time limit exceeded — your session has expired.", 410);
|
|
}
|
|
}
|
|
|
|
// All graded attempts for cooldown guard + attempt_number
|
|
const priorAttempts = await QuizAttempt.findAll({
|
|
where: { assessment_id: assessmentId, user_id },
|
|
attributes: ["attempt_id", "score", "passed", "createdAt"],
|
|
});
|
|
const cooldownStatus = getAttemptStatus(priorAttempts, 'assessment', {
|
|
maxFails: assessment.max_attempts,
|
|
cooldownHours: assessment.cooldown_hours,
|
|
});
|
|
if (!cooldownStatus.can_attempt) {
|
|
return R.error(res, `You've failed ${assessment.max_attempts} times — you're on a ${assessment.cooldown_hours}-hour cooldown. Check the assessment screen for when you can try again.`, 429);
|
|
}
|
|
|
|
const { totalPoints, earnedPoints, score } = gradeSubmission(assessment.questions ?? [], answers);
|
|
const passed = score >= (assessment.passing_score ?? 70);
|
|
|
|
const finalAttempt = await QuizAttempt.create({
|
|
user_id,
|
|
assessment_id: assessment.assessment_id,
|
|
course_id: courseId,
|
|
attempt_number: priorAttempts.length + 1,
|
|
answers,
|
|
total_points: totalPoints,
|
|
earned_points: earnedPoints,
|
|
score,
|
|
passing_score: assessment.passing_score ?? 70,
|
|
passed,
|
|
});
|
|
|
|
if (activeSession) {
|
|
await AssessmentSession.update(
|
|
{ status: 'completed', attempt_id: finalAttempt.attempt_id },
|
|
{ where: { session_id: activeSession.session_id } }
|
|
);
|
|
}
|
|
|
|
// BUG FIX: this used to be `course_completed = passed` — certification/achievements fired
|
|
// on assessment-pass alone, never checking whether the learner had actually read the course.
|
|
// Now gated on the consolidated evaluator (default rule: all units read AND assessment passed;
|
|
// or whatever the admin has explicitly configured via CompletionRequirement rows).
|
|
let course_completed = false;
|
|
let completedTasks = [];
|
|
if (passed) {
|
|
const evaluation = await recomputeCourseAfterAssessment(user_id, courseId);
|
|
course_completed = evaluation?.status === 'completed';
|
|
completedTasks = evaluation?.completed_tasks ?? [];
|
|
}
|
|
|
|
if (course_completed) {
|
|
const course = await Course.findOne({ where: { course_id: courseId }, attributes: ['course_id', 'uuid', 'title'] });
|
|
const totalCompleted = await QuizAttempt.count({
|
|
where: { user_id, passed: true, assessment_id: { [Op.ne]: null } },
|
|
distinct: true,
|
|
col: 'assessment_id',
|
|
});
|
|
|
|
// Milestone achievements fire immediately (first_course_completed, etc.)
|
|
await onCourseCompleted(user_id, courseId, totalCompleted, course?.title ?? null);
|
|
|
|
// Queue the certificate for issuance 45 minutes from now
|
|
const existing = await PendingCertificate.findOne({ where: { user_id, course_id: courseId } });
|
|
if (!existing) {
|
|
await PendingCertificate.create({
|
|
user_id,
|
|
course_id: courseId,
|
|
course_uuid: course?.uuid ?? '',
|
|
course_title: course?.title ?? '',
|
|
passed_at: new Date(),
|
|
issue_at: new Date(Date.now() + 5 * 60 * 1000),
|
|
}).catch(err => console.error('[ASSESSMENT] Failed to queue pending certificate:', err));
|
|
}
|
|
|
|
// Immediate notification: course completed, certificate incoming
|
|
UserNotification.create({
|
|
user_id,
|
|
...NOTIFICATION_REGISTRY.course_completed.build({ courseTitle: course?.title ?? '', courseUuid: course?.uuid ?? null }),
|
|
}).catch(err => console.error('[ASSESSMENT] Failed to emit course_completed notification:', err));
|
|
}
|
|
|
|
return R.success(res, "Assessment submitted.", {
|
|
attempt_id: finalAttempt.attempt_id,
|
|
attempt_number: finalAttempt.attempt_number,
|
|
score,
|
|
passed,
|
|
passing_score: finalAttempt.passing_score,
|
|
total_points: totalPoints,
|
|
earned_points: earnedPoints,
|
|
course_completed,
|
|
completed_tasks: completedTasks,
|
|
});
|
|
} catch (err) {
|
|
console.error("[CLIENT][ASSESSMENT][SUBMIT]", err);
|
|
return R.error(res, "Could not submit assessment.", 500);
|
|
}
|
|
};
|
|
|
|
// ─── UUID LOOKUPS (task requirement detail blocks) ────────────────────────────
|
|
|
|
exports.getCourseByUuid = async (req, res) => {
|
|
try {
|
|
const { uuid } = req.params;
|
|
const course = await Course.findOne({
|
|
where: { uuid, ...notDeleted },
|
|
attributes: ["course_id", "uuid", "title", "description", "level", "subscription", "badge_color", "badge_asset_id", "badge_image_url"],
|
|
});
|
|
if (!course) return R.error(res, "Course not found.", 404);
|
|
|
|
if (!await canAccessCourse(req.user.user_id, course.course_id)) {
|
|
return res.status(403).json({
|
|
status: "error",
|
|
message: "You do not have access to this course.",
|
|
course: { title: course.title, subscription: course.subscription },
|
|
});
|
|
}
|
|
|
|
return R.success(res, "Course retrieved.", course);
|
|
} catch (err) {
|
|
console.error("[CLIENT][COURSES][BY UUID]", err);
|
|
return R.error(res, "Could not retrieve course.", 500);
|
|
}
|
|
};
|
|
|
|
exports.getUnitByUuid = async (req, res) => {
|
|
try {
|
|
const { uuid } = req.params;
|
|
const unit = await Unit.findOne({
|
|
where: { uuid, ...notDeleted },
|
|
attributes: ["unit_id", "uuid", "title", "description"],
|
|
include: [{
|
|
model: Course, as: "courses",
|
|
where: notDeleted, required: false,
|
|
attributes: ["course_id", "title", "subscription"],
|
|
through: { attributes: [] },
|
|
}],
|
|
});
|
|
if (!unit) return R.error(res, "Unit not found.", 404);
|
|
|
|
// Standalone units (no attached course) are open; otherwise any accessible course grants entry
|
|
if (!await canAccessUnit(req.user.user_id, unit.unit_id)) {
|
|
const first = unit.courses?.[0] ?? null;
|
|
return res.status(403).json({
|
|
status: "error",
|
|
message: "You do not have access to this unit.",
|
|
course: first ? { title: first.title, subscription: first.subscription } : null,
|
|
});
|
|
}
|
|
|
|
const plain = unit.toJSON();
|
|
plain.course = plain.courses?.[0] ?? null; // back-compat singular field
|
|
return R.success(res, "Unit retrieved.", plain);
|
|
} catch (err) {
|
|
console.error("[CLIENT][UNITS][BY UUID]", err);
|
|
return R.error(res, "Could not retrieve unit.", 500);
|
|
}
|
|
};
|
|
|
|
// "Quiz (self-enrich for pass_quiz task requirement blocks)" — mirrors
|
|
// getUnitByUuid/getLessonByUuid's uuid-lookup pattern. A quiz is always
|
|
// unit-scoped (unit_quizzes.unit_id unique) so access resolves through its
|
|
// one parent unit, same rule canAccessUnit already implements.
|
|
exports.getQuizByUuid = async (req, res) => {
|
|
try {
|
|
const { uuid } = req.params;
|
|
const quiz = await UnitQuiz.findOne({
|
|
where: { uuid, ...notDeleted },
|
|
attributes: ["quiz_id", "uuid", "title", "is_required", "passing_score"],
|
|
include: [{
|
|
model: Unit, as: "unit",
|
|
attributes: ["unit_id", "uuid", "title"],
|
|
include: [{
|
|
model: Course, as: "courses",
|
|
where: notDeleted, required: false,
|
|
attributes: ["course_id", "title", "subscription"],
|
|
through: { attributes: [] },
|
|
}],
|
|
}],
|
|
});
|
|
if (!quiz) return R.error(res, "Quiz not found.", 404);
|
|
|
|
if (!await canAccessUnit(req.user.user_id, quiz.unit.unit_id)) {
|
|
const first = quiz.unit.courses?.[0] ?? null;
|
|
return res.status(403).json({
|
|
status: "error",
|
|
message: "You do not have access to this quiz.",
|
|
course: first ? { title: first.title, subscription: first.subscription } : null,
|
|
});
|
|
}
|
|
|
|
const passedAttempt = await QuizAttempt.findOne({
|
|
where: { quiz_id: quiz.quiz_id, user_id: req.user.user_id, passed: true },
|
|
});
|
|
|
|
const plain = quiz.toJSON();
|
|
plain.unit.course = plain.unit.courses?.[0] ?? null; // back-compat singular field
|
|
plain.has_passed = !!passedAttempt;
|
|
return R.success(res, "Quiz retrieved.", plain);
|
|
} catch (err) {
|
|
console.error("[CLIENT][QUIZ][BY UUID]", err);
|
|
return R.error(res, "Could not retrieve quiz.", 500);
|
|
}
|
|
};
|
|
|
|
// "Units → Lessons (returns all data)" — a Unit resolves all of its lesson
|
|
// content in one call, with or without a parent course.
|
|
exports.getLessonsByUnitUuid = async (req, res) => {
|
|
try {
|
|
const { uuid } = req.params;
|
|
const unit = await Unit.findOne({
|
|
where: { uuid, ...notDeleted },
|
|
attributes: ["unit_id", "uuid", "title", "description", "duration_seconds"],
|
|
include: [
|
|
{
|
|
model: Course, as: "courses",
|
|
where: notDeleted, required: false,
|
|
attributes: ["course_id", "title", "subscription"],
|
|
through: { attributes: [] },
|
|
},
|
|
{
|
|
model: Lesson,
|
|
as: "lessons",
|
|
where: notDeleted,
|
|
required: false,
|
|
attributes: ["lesson_id", "uuid", "title", "description", "duration_seconds"],
|
|
through: { attributes: ["order_index"] },
|
|
include: [
|
|
{ model: LessonPage, as: "page", attributes: ["blocks"], required: false },
|
|
{ model: LessonObjective, as: "objectives", required: false, attributes: ["objective_id", "text", "order_index"] },
|
|
],
|
|
},
|
|
{
|
|
model: UnitQuiz, as: "quiz",
|
|
required: false,
|
|
attributes: ["quiz_id", "uuid", "title", "is_required", "passing_score"],
|
|
},
|
|
],
|
|
});
|
|
if (!unit) return R.error(res, "Unit not found.", 404);
|
|
|
|
if (!await canAccessUnit(req.user.user_id, unit.unit_id)) {
|
|
const first = unit.courses?.[0] ?? null;
|
|
return res.status(403).json({
|
|
status: "error",
|
|
message: "You do not have access to this unit.",
|
|
course: first ? { title: first.title, subscription: first.subscription } : null,
|
|
});
|
|
}
|
|
|
|
const plain = unit.toJSON();
|
|
const userId = req.user.user_id;
|
|
|
|
// Per-lesson completion for the requesting user, evaluated against each lesson's own
|
|
// configured CompletionRequirement rows (or the default implicit rule when none are
|
|
// configured) via the consolidated evaluator. courseId is null here — this route is
|
|
// reached both course-scoped and standalone with no course context, and a lesson's
|
|
// completion is a property of the lesson itself, not scoped to whichever unit/course
|
|
// it was read under (see completion_requirements.registry.js's courseId-null dispatch,
|
|
// which reads lesson_reading_progress rather than course_reading_progress).
|
|
const flatLessons = flattenLessons(plain.lessons);
|
|
const lessonCompletedAtRows = flatLessons.length
|
|
? await LessonReadingProgress.findAll({
|
|
where: { user_id: userId, lesson_id: flatLessons.map((l) => l.lesson_id) },
|
|
attributes: ["lesson_id", "completed_at"],
|
|
})
|
|
: [];
|
|
const completedAtMap = new Map(lessonCompletedAtRows.map((p) => [String(p.lesson_id), p.completed_at]));
|
|
|
|
const lessonEvaluations = await Promise.all(
|
|
flatLessons.map((l) => evaluateEntity({ entityType: "lesson", entityId: l.lesson_id, userId, courseId: null }))
|
|
);
|
|
|
|
const lessons = flatLessons.map((l, i) => ({
|
|
lesson_id: l.lesson_id,
|
|
uuid: l.uuid,
|
|
title: l.title,
|
|
description: l.description,
|
|
order_index: l.order_index ?? 0,
|
|
duration_seconds: l.duration_seconds ?? 0,
|
|
blocks: l.page?.blocks ?? [],
|
|
objectives: (l.objectives ?? []).slice().sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0)),
|
|
status: lessonEvaluations[i].status === "completed" ? "completed" : (completedAtMap.has(String(l.lesson_id)) ? "in_progress" : "not_started"),
|
|
completed_at: lessonEvaluations[i].status === "completed" ? (completedAtMap.get(String(l.lesson_id)) ?? null) : null,
|
|
}));
|
|
|
|
// Attach has_passed to the quiz stub — same pattern as getCourse's unit list.
|
|
let quiz = null;
|
|
if (plain.quiz) {
|
|
const passedAttempt = await QuizAttempt.findOne({
|
|
where: { quiz_id: plain.quiz.quiz_id, user_id: userId, passed: true },
|
|
});
|
|
quiz = { ...plain.quiz, has_passed: !!passedAttempt };
|
|
}
|
|
|
|
// Consolidated evaluator — replaces the old inline `lessons.every(status === "completed")`
|
|
// re-derivation, which drifted from the POST-progress path's own unit derivation. Now both
|
|
// read and write paths go through the same evaluateEntity() call.
|
|
const unitEvaluation = await evaluateEntity({ entityType: "unit", entityId: unit.unit_id, userId, courseId: null });
|
|
const is_completed = unitEvaluation.status === "completed";
|
|
|
|
// Admin-configured completion requirement for the unit itself (pass_quiz /
|
|
// manual_complete / read_all_content) — null when nothing's configured.
|
|
const unitRequirement = await CompletionRequirement.findOne({
|
|
where: { entity_type: "unit", entity_id: unit.unit_id },
|
|
attributes: ["type", "min_percent", "button_label"],
|
|
});
|
|
|
|
return R.success(res, "Unit lessons retrieved.", {
|
|
unit_id: unit.unit_id,
|
|
uuid: unit.uuid,
|
|
title: unit.title,
|
|
description: unit.description,
|
|
duration_seconds: plain.duration_seconds ?? 0,
|
|
course: plain.courses?.[0] ?? null, // back-compat singular field
|
|
courses: plain.courses ?? [],
|
|
quiz,
|
|
is_completed,
|
|
completion: unitRequirement
|
|
? { type: unitRequirement.type, min_percent: unitRequirement.min_percent, button_label: unitRequirement.button_label }
|
|
: null,
|
|
lessons,
|
|
});
|
|
} catch (err) {
|
|
console.error("[CLIENT][UNITS][LESSONS BY UUID]", err);
|
|
return R.error(res, "Could not retrieve unit lessons.", 500);
|
|
}
|
|
};
|
|
|
|
// "Lessons (per data runs independently)" — a Lesson resolves on its own,
|
|
// with or without parent units/courses.
|
|
exports.getLessonByUuid = async (req, res) => {
|
|
try {
|
|
const { uuid } = req.params;
|
|
const lesson = await Lesson.findOne({
|
|
where: { uuid, ...notDeleted },
|
|
attributes: ["lesson_id", "uuid", "title", "description", "duration_seconds"],
|
|
include: [
|
|
{
|
|
model: LessonPage,
|
|
as: "page",
|
|
attributes: ["blocks"],
|
|
required: false,
|
|
},
|
|
{
|
|
model: LessonObjective,
|
|
as: "objectives",
|
|
required: false,
|
|
attributes: ["objective_id", "text", "order_index"],
|
|
},
|
|
{
|
|
model: Unit,
|
|
as: "units",
|
|
where: notDeleted, required: false,
|
|
attributes: ["unit_id", "uuid", "title", "duration_seconds"],
|
|
through: { attributes: ["order_index"] },
|
|
include: [{
|
|
model: Course, as: "courses",
|
|
where: notDeleted, required: false,
|
|
attributes: ["course_id", "title", "subscription", "duration_seconds"],
|
|
through: { attributes: [] },
|
|
}],
|
|
},
|
|
],
|
|
order: [[{ model: LessonObjective, as: "objectives" }, "order_index", "ASC"]],
|
|
});
|
|
if (!lesson) return R.error(res, "Lesson not found.", 404);
|
|
|
|
if (!await canAccessLesson(req.user.user_id, lesson.lesson_id)) {
|
|
const firstCourse = lesson.units?.[0]?.courses?.[0] ?? null;
|
|
return res.status(403).json({
|
|
status: "error",
|
|
message: "You do not have access to this lesson.",
|
|
course: firstCourse ? { title: firstCourse.title, subscription: firstCourse.subscription } : null,
|
|
});
|
|
}
|
|
|
|
const progress = await LessonReadingProgress.findOne({
|
|
where: { user_id: req.user.user_id, lesson_id: lesson.lesson_id },
|
|
attributes: ["status", "completed_at"],
|
|
});
|
|
|
|
// Admin-configured completion requirement (if any) — lets the standalone reader
|
|
// show what a learner must do to complete this lesson, same info the course-scoped
|
|
// reader gets via getCourse's per-lesson `completion` field. null when nothing's
|
|
// configured (default read_all_content behavior — nothing to display).
|
|
const requirement = await CompletionRequirement.findOne({
|
|
where: { entity_type: "lesson", entity_id: lesson.lesson_id },
|
|
attributes: ["type", "min_percent", "button_label"],
|
|
});
|
|
|
|
const plain = lesson.toJSON();
|
|
const firstUnit = plain.units?.[0] ?? null;
|
|
const data = {
|
|
lesson_id: plain.lesson_id,
|
|
uuid: plain.uuid,
|
|
title: plain.title,
|
|
description: plain.description,
|
|
duration_seconds: plain.duration_seconds ?? 0,
|
|
blocks: plain.page?.blocks ?? [],
|
|
objectives: plain.objectives ?? [],
|
|
status: progress?.status ?? "not_started",
|
|
completed_at: progress?.completed_at ?? null,
|
|
completion: requirement ? { type: requirement.type, min_percent: requirement.min_percent, button_label: requirement.button_label } : null,
|
|
unit: firstUnit ? { unit_id: firstUnit.unit_id, uuid: firstUnit.uuid, title: firstUnit.title, duration_seconds: firstUnit.duration_seconds, course: firstUnit.courses?.[0] ?? null } : null, // back-compat singular field
|
|
units: plain.units ?? [],
|
|
};
|
|
return R.success(res, "Lesson retrieved.", data);
|
|
} catch (err) {
|
|
console.error("[CLIENT][LESSONS][BY UUID]", err);
|
|
return R.error(res, "Could not retrieve lesson.", 500);
|
|
}
|
|
}; |