Files

492 lines
21 KiB
JavaScript

/***********************************************************************************************************************************************************************
* File Name: units.controller.js (client)
* Type of Program: Controller
* Description: Standalone Unit / Lesson consumption — the junction revamp lets
* learners run Units and Lessons outside any Course:
*
* GET /client/units → INDEPENDENT units only (no course affiliation)
* GET /client/lessons → INDEPENDENT lessons only (no unit is course-affiliated)
* GET /client/units/:uuid → unit metadata (shared handler)
* GET /client/units/:uuid/lessons → unit + ALL lesson data (shared handler)
* GET /client/units/:uuid/quiz → the unit's quiz, no course context
* POST /client/units/:uuid/quiz/:quizId/submit→ graded attempt with course_id NULL
* GET /client/lessons/:uuid → single lesson, runs independently (shared handler)
* POST /client/lessons/:uuid/progress → standalone reading progress (course NULL, unit optional)
*
* Access rule: a unit attached to no course is open; otherwise the user must
* be able to access at least one attached course. Lessons resolve through
* their parent units the same way.
*
* Discovery rule (getUnits/getLessons only): ALL non-deleted units/lessons
* are listed, whether or not they're attached to a course — course_count/
* courses[] (published courses only) and is_locked tell the learner whether
* a given item is standalone or bound, and if bound, whether they already
* have access. This does not affect the single-item endpoints above
* (:uuid) — those still enforce access normally for direct links, and
* course-scoped consumption runs through a separate controller entirely.
*
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jul. 7, 2026
***********************************************************************************************************************************************************************/
"use strict";
const R = require("../../utils/response.util");
const logActivity = require("../../utils/logActivity.util");
const sequelize = require("../../config/db.config");
const {
Unit, Lesson,
CourseUnit, UnitLesson,
UnitQuiz, QuizQuestion, QuizOption, QuizAttempt, QuizSession,
} = require("../../models/courses/courses.associations");
const coursesCtrl = require("./courses.controller"); // canAccessUnit / canAccessLesson / shared uuid handlers
const { gradeSubmission } = require("../../utils/courses/grading.util");
const { shuffleOptions, shuffleQuestions, getAttemptStatus } = require("../../utils/courses/quiz_security.util");
const { recomputeCascade, recordWatchProgress, recordManualComplete } = require("../../services/completion_requirements.service");
const { recordPlaybackPosition } = require("../../services/playback_position.service");
const notDeleted = { deletedAt: null };
// Strip correct-answer data (same policy as course-scoped quiz endpoints)
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;
});
}
// ─── UNIT LIBRARY (learner view) ──────────────────────────────────────────────
// Client-side Units/Lessons browsing shows ALL content, bound to a course or
// not — course_count/courses[] + is_locked below tell the learner which is
// which. This does not affect course-scoped consumption (which runs through
// ClientCoursesContext/getCourse, a separate path) or direct-link access to
// UnitDetails/LessonDetails, which still enforce access normally.
exports.getUnits = async (req, res) => {
try {
const rows = await sequelize.query(`
SELECT
u.unit_id, u.uuid, u.title, u.subscription, u.description, u.duration_seconds,
(SELECT CAST(COUNT(*) AS INTEGER) FROM unit_lessons ul
JOIN lessons l ON l.lesson_id = ul.lesson_id AND l."deletedAt" IS NULL
WHERE ul.unit_id = u.unit_id) AS lesson_count,
(SELECT CAST(COUNT(*) AS INTEGER) FROM course_units cu
JOIN courses c ON c.course_id = cu.course_id AND c."deletedAt" IS NULL AND c.status = 'published'
WHERE cu.unit_id = u.unit_id) AS course_count,
(SELECT quiz_id FROM unit_quizzes q
WHERE q.unit_id = u.unit_id AND q."deletedAt" IS NULL LIMIT 1) AS quiz_id
FROM units u
WHERE u."deletedAt" IS NULL
ORDER BY u.title ASC
`, { type: sequelize.QueryTypes.SELECT });
// Batch-fetch attached courses for every returned unit in one query, so the
// learner-facing upsell modal can say which course(s)/tier(s) unlock a unit
// (a unit may sit under several courses at different tiers — no single "Buy").
const unitIds = rows.map((r) => r.unit_id);
const courseLinkRows = unitIds.length ? await sequelize.query(`
SELECT cu.unit_id, c.course_id, c.uuid, c.title, c.subscription
FROM course_units cu
JOIN courses c ON c.course_id = cu.course_id AND c."deletedAt" IS NULL AND c.status = 'published'
WHERE cu.unit_id IN (:unitIds)
`, { replacements: { unitIds }, type: sequelize.QueryTypes.SELECT }) : [];
const coursesByUnit = new Map();
for (const row of courseLinkRows) {
const list = coursesByUnit.get(row.unit_id) ?? [];
list.push({ course_id: row.course_id, uuid: row.uuid, title: row.title, subscription: row.subscription });
coursesByUnit.set(row.unit_id, list);
}
// is_locked mirrors canAccessUnit: a unit with its own subscription or at
// least one attached course needs an access check; a fully open standalone
// unit (no subscription, no course links) is never locked.
const result = [];
for (const row of rows) {
const is_locked = (row.subscription || Number(row.course_count) > 0)
? !(await coursesCtrl.canAccessUnit(req.user.user_id, row.unit_id))
: false;
result.push({
...row,
courses: coursesByUnit.get(row.unit_id) ?? [],
is_locked,
});
}
return R.success(res, "Units retrieved.", result);
} catch (err) {
console.error("[CLIENT][UNITS][GET ALL]", err);
return R.error(res, "Could not retrieve units.", 500);
}
};
// ─── LESSON LIBRARY (learner view) ────────────────────────────────────────────
// Mirrors getUnits above — a Lesson may sit in several Units (each possibly in
// different courses), so is_locked/courses are resolved across ALL attached
// units rather than a single direct course link.
exports.getLessons = async (req, res) => {
try {
const rows = await sequelize.query(`
SELECT
l.lesson_id, l.uuid, l.title, l.subscription, l.description, l.duration_seconds,
(SELECT CAST(COUNT(*) AS INTEGER) FROM unit_lessons ul
JOIN units u ON u.unit_id = ul.unit_id AND u."deletedAt" IS NULL
WHERE ul.lesson_id = l.lesson_id) AS unit_count
FROM lessons l
WHERE l."deletedAt" IS NULL
ORDER BY l.title ASC
`, { type: sequelize.QueryTypes.SELECT });
// Batch-fetch every course reachable through any attached unit, for every
// returned lesson, in one query — same batching style as getUnits.
const lessonIds = rows.map((r) => r.lesson_id);
const courseLinkRows = lessonIds.length ? await sequelize.query(`
SELECT DISTINCT ul.lesson_id, c.course_id, c.uuid, c.title, c.subscription
FROM unit_lessons ul
JOIN course_units cu ON cu.unit_id = ul.unit_id
JOIN courses c ON c.course_id = cu.course_id AND c."deletedAt" IS NULL AND c.status = 'published'
WHERE ul.lesson_id IN (:lessonIds)
`, { replacements: { lessonIds }, type: sequelize.QueryTypes.SELECT }) : [];
const coursesByLesson = new Map();
for (const row of courseLinkRows) {
const list = coursesByLesson.get(row.lesson_id) ?? [];
list.push({ course_id: row.course_id, uuid: row.uuid, title: row.title, subscription: row.subscription });
coursesByLesson.set(row.lesson_id, list);
}
// is_locked mirrors canAccessLesson: a lesson with its own subscription or
// at least one attached unit needs an access check; a fully open
// standalone lesson (no subscription, no unit links) is never locked.
const result = [];
for (const row of rows) {
const is_locked = (row.subscription || Number(row.unit_count) > 0)
? !(await coursesCtrl.canAccessLesson(req.user.user_id, row.lesson_id))
: false;
result.push({
...row,
courses: coursesByLesson.get(row.lesson_id) ?? [],
is_locked,
});
}
return R.success(res, "Lessons retrieved.", result);
} catch (err) {
console.error("[CLIENT][LESSONS][GET ALL]", err);
return R.error(res, "Could not retrieve lessons.", 500);
}
};
// ─── STANDALONE UNIT QUIZ ─────────────────────────────────────────────────────
exports.getUnitQuiz = async (req, res) => {
try {
const { uuid } = req.params;
const unit = await Unit.findOne({ where: { uuid, ...notDeleted } });
if (!unit) return R.error(res, "Unit not found.", 404);
if (!await coursesCtrl.canAccessUnit(req.user.user_id, unit.unit_id)) {
return R.error(res, "You do not have access to this unit.", 403);
}
const quiz = await UnitQuiz.findOne({
where: { unit_id: unit.unit_id, ...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][UNITS][QUIZ][GET]", err);
return R.error(res, "Could not retrieve quiz.", 500);
}
};
exports.submitUnitQuiz = async (req, res) => {
try {
const { uuid, quizId } = req.params;
const { answers = {} } = req.body;
const user_id = req.user.user_id;
const unit = await Unit.findOne({ where: { uuid, ...notDeleted } });
if (!unit) return R.error(res, "Unit not found.", 404);
if (!await coursesCtrl.canAccessUnit(user_id, unit.unit_id)) {
return R.error(res, "You do not have access to this unit.", 403);
}
const quiz = await UnitQuiz.findOne({
where: { quiz_id: quizId, unit_id: unit.unit_id, ...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: null, // standalone — no course context
attempt_number: priorAttempts.length + 1,
answers,
total_points: totalPoints,
earned_points: earnedPoints,
score,
passing_score: quiz.passing_score ?? 70,
passed,
});
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,
score,
passed,
passing_score: attempt.passing_score,
total_points: totalPoints,
earned_points: earnedPoints,
});
} catch (err) {
console.error("[CLIENT][UNITS][QUIZ][SUBMIT]", err);
return R.error(res, "Could not submit quiz.", 500);
}
};
// ─── STANDALONE UNIT QUIZ DRAFT ───────────────────────────────────────────────
// PATCH /client/units/:uuid/quiz/:quizId/draft — mirrors the course-scoped
// saveQuizDraft in courses.controller.js; only quiz_id + user_id are needed to
// locate the session, course_id/unit_id are just extra nullable columns on it.
exports.saveUnitQuizDraft = async (req, res) => {
try {
const { uuid, quizId } = req.params;
const { answers = {} } = req.body;
const user_id = req.user.user_id;
const unit = await Unit.findOne({ where: { uuid, ...notDeleted } });
if (!unit) return R.error(res, "Unit not found.", 404);
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: null,
unit_id: unit.unit_id,
draft_answers: answers,
started_at: new Date(),
});
}
return res.status(204).end();
} catch (err) {
console.error("[CLIENT][UNITS][QUIZ][DRAFT]", err);
return R.error(res, "Could not save quiz draft.", 500);
}
};
// ─── STANDALONE LESSON PROGRESS ───────────────────────────────────────────────
// POST /client/lessons/:uuid/progress Body: { status, unit_uuid? }
// Evaluated + written via completion_requirements.service#recomputeCascade with
// courseId null, which persists to lesson_reading_progress/unit_reading_progress
// (the tables that tolerate a null course_id) instead of course_reading_progress.
// When unit_uuid is given (unit context, still no course) the parent unit is
// re-evaluated + upserted too, against any configured CompletionRequirement rows.
exports.upsertStandaloneLessonProgress = async (req, res) => {
try {
const { uuid } = req.params;
const userId = req.user.user_id;
const status = req.body.status === "completed" ? "completed" : "in_progress";
const unitUuid = req.body.unit_uuid ?? null;
const lesson = await Lesson.findOne({ where: { uuid, ...notDeleted }, attributes: ["lesson_id", "uuid"] });
if (!lesson) return R.error(res, "Lesson not found.", 404);
if (!await coursesCtrl.canAccessLesson(userId, lesson.lesson_id)) {
return R.error(res, "You do not have access to this lesson.", 403);
}
let unitId = null;
if (unitUuid) {
const unit = await Unit.findOne({ where: { uuid: unitUuid, ...notDeleted }, attributes: ["unit_id"] });
if (!unit) return R.error(res, "Unit not found.", 404);
const link = await UnitLesson.findOne({ where: { unit_id: unit.unit_id, lesson_id: lesson.lesson_id } });
if (!link) return R.error(res, "Lesson is not attached to this unit.", 404);
unitId = unit.unit_id;
}
const result = await recomputeCascade(userId, {
courseId: null,
unitId,
unitUuid,
lessonId: lesson.lesson_id,
lessonUuid: lesson.uuid,
lessonStatus: status,
});
logActivity(userId, "lesson_read", {
entityType: "lesson",
entityId: lesson.lesson_id,
details: { lesson_uuid: lesson.uuid, status, standalone: true },
});
return R.success(res, "Progress updated.", result, 200);
} catch (err) {
console.error("[CLIENT][LESSONS][STANDALONE PROGRESS]", err);
return R.error(res, "Could not update progress.", 500);
}
};
// ─── STANDALONE WATCH PROGRESS ─────────────────────────────────────────────────
// POST /client/lessons/:uuid/watch-progress Body: { percent, unit_uuid?, block_id?, block_type? }
// block_id/block_type identify which block on the lesson's page sent this update — needed
// to drive watch_video/listen_audio; omit them and only watch_percent (if configured) is touched.
exports.upsertStandaloneWatchProgress = async (req, res) => {
try {
const { uuid } = req.params;
const userId = req.user.user_id;
const percent = Number(req.body.percent);
if (!Number.isFinite(percent)) return R.error(res, "percent must be a number.", 400);
const unitUuid = req.body.unit_uuid ?? null;
const blockId = req.body.block_id ?? null;
const blockType = req.body.block_type ?? null;
const lesson = await Lesson.findOne({ where: { uuid, ...notDeleted }, attributes: ["lesson_id", "uuid"] });
if (!lesson) return R.error(res, "Lesson not found.", 404);
if (!await coursesCtrl.canAccessLesson(userId, lesson.lesson_id)) {
return R.error(res, "You do not have access to this lesson.", 403);
}
let unitId = null;
if (unitUuid) {
const unit = await Unit.findOne({ where: { uuid: unitUuid, ...notDeleted }, attributes: ["unit_id"] });
if (!unit) return R.error(res, "Unit not found.", 404);
unitId = unit.unit_id;
}
// Resume-position tracking is unconditional — every block gets it regardless of
// whether a completion requirement is configured. recordWatchProgress, below, is
// the anti-cheat-validated path and stays a no-op when nothing's configured.
await recordPlaybackPosition(userId, { lessonId: lesson.lesson_id, blockId, percent });
const result = await recordWatchProgress(userId, {
lessonId: lesson.lesson_id, lessonUuid: lesson.uuid,
unitId, unitUuid,
courseId: null, courseUuid: null,
percent, blockId, blockType,
});
return R.success(res, "Watch progress updated.", result, 200);
} catch (err) {
console.error("[CLIENT][LESSONS][STANDALONE WATCH PROGRESS]", err);
return R.error(res, "Could not update watch progress.", 500);
}
};
// ─── STANDALONE MARK COMPLETE ──────────────────────────────────────────────────
// POST /client/lessons/:uuid/mark-complete Body: { unit_uuid? }
exports.markStandaloneLessonComplete = async (req, res) => {
try {
const { uuid } = req.params;
const userId = req.user.user_id;
const unitUuid = req.body.unit_uuid ?? null;
const lesson = await Lesson.findOne({ where: { uuid, ...notDeleted }, attributes: ["lesson_id", "uuid"] });
if (!lesson) return R.error(res, "Lesson not found.", 404);
if (!await coursesCtrl.canAccessLesson(userId, lesson.lesson_id)) {
return R.error(res, "You do not have access to this lesson.", 403);
}
let unitId = null;
if (unitUuid) {
const unit = await Unit.findOne({ where: { uuid: unitUuid, ...notDeleted }, attributes: ["unit_id"] });
if (!unit) return R.error(res, "Unit not found.", 404);
unitId = unit.unit_id;
}
const result = await recordManualComplete(userId, {
entityType: "lesson", entityId: lesson.lesson_id,
lessonUuid: lesson.uuid,
unitId, unitUuid,
courseId: null, courseUuid: null,
});
return R.success(res, "Lesson marked complete.", result, 200);
} catch (err) {
console.error("[CLIENT][LESSONS][STANDALONE MARK COMPLETE]", err);
return R.error(res, "Could not mark lesson complete.", 500);
}
};
// ─── Shared UUID handlers re-exported for the standalone routes ───────────────
exports.getUnitByUuid = coursesCtrl.getUnitByUuid;
exports.getLessonsByUnitUuid = coursesCtrl.getLessonsByUnitUuid;
exports.getLessonByUuid = coursesCtrl.getLessonByUuid;