mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
484 lines
21 KiB
JavaScript
484 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.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: standalone/unattached lessons are
|
|
// open, attached lessons need at least one accessible course through
|
|
// any attached unit.
|
|
const result = [];
|
|
for (const row of rows) {
|
|
const is_locked = 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;
|