add: ver()

Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
2026-07-08 11:31:40 +08:00
parent 0e4cd86119
commit bb7e8fde08
29 changed files with 2234 additions and 780 deletions
+169 -66
View File
@@ -23,11 +23,13 @@ const mdl_Category = require("../../models/courses/categories.mdl");
const {
Course,
Unit, Lesson, LessonPage,
CourseUnit, UnitLesson,
CourseObjective, LessonObjective,
CoursePrerequisite, CourseAssessment,
UnitQuiz, QuizQuestion, QuizOption, QuizAttempt,
AssessmentSession, QuizSession,
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");
@@ -120,6 +122,34 @@ async function canAccessCourse(user_id, course_id) {
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) {
const links = await CourseUnit.findAll({ where: { unit_id }, attributes: ['course_id'] });
if (!links.length) return true;
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",
@@ -254,16 +284,18 @@ exports.getCourse = async (req, res) => {
where: notDeleted, required: false,
attributes: [
"unit_id", "uuid", "title", "description",
"order_index", "duration_seconds",
"duration_seconds",
],
through: { attributes: ["order_index"] },
include: [
{
model: Lesson, as: "lessons",
where: notDeleted, required: false,
attributes: [
"lesson_id", "uuid", "title", "description",
"order_index", "duration_seconds",
"duration_seconds",
],
through: { attributes: ["order_index"] },
},
{
model: UnitQuiz, as: "quiz",
@@ -308,8 +340,6 @@ exports.getCourse = async (req, res) => {
},
],
order: [
[{ model: Unit, as: "units" }, "order_index", "ASC"],
[{ model: Unit, as: "units" }, { model: Lesson, as: "lessons" }, "order_index", "ASC"],
[{ model: CourseObjective, as: "objectives" }, "order_index", "ASC"],
[{ model: CoursePrerequisite, as: "prerequisites" }, "order_index", "ASC"],
],
@@ -318,6 +348,7 @@ exports.getCourse = async (req, res) => {
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
@@ -398,11 +429,14 @@ 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);
const unit = await Unit.findOne({
where: { unit_id: unitId, course_id: courseId, ...notDeleted },
where: { unit_id: unitId, ...notDeleted },
attributes: [
"unit_id", "uuid", "title", "description",
"order_index", "duration_seconds",
"duration_seconds",
],
include: [
{
@@ -410,8 +444,9 @@ exports.getUnit = async (req, res) => {
where: notDeleted, required: false,
attributes: [
"lesson_id", "uuid", "title", "description",
"order_index", "duration_seconds",
"duration_seconds",
],
through: { attributes: ["order_index"] },
},
{
model: UnitQuiz, as: "quiz",
@@ -422,11 +457,14 @@ exports.getUnit = async (req, res) => {
],
},
],
order: [[{ model: Lesson, as: "lessons" }, "order_index", "ASC"]],
});
if (!unit) return R.error(res, "Unit not found.", 404);
return R.success(res, "Unit retrieved.", unit);
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);
@@ -439,18 +477,19 @@ 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);
const lesson = await Lesson.findOne({
where: { lesson_id: lessonId, unit_id: unitId, ...notDeleted },
where: { lesson_id: lessonId, ...notDeleted },
attributes: [
"lesson_id", "uuid", "unit_id", "title",
"description", "order_index", "duration_seconds",
"lesson_id", "uuid", "title",
"description", "duration_seconds",
],
include: [
{
model: Unit, as: "unit",
where: { course_id: courseId, ...notDeleted },
attributes: [],
},
{
model: LessonPage, as: "page",
required: false,
@@ -466,7 +505,7 @@ exports.getLesson = async (req, res) => {
});
if (!lesson) return R.error(res, "Lesson not found.", 404);
return R.success(res, "Lesson retrieved.", lesson);
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);
@@ -479,8 +518,8 @@ exports.getUnitQuiz = async (req, res) => {
try {
const { courseId, unitId } = req.params;
const unit = await Unit.findOne({ where: { unit_id: unitId, course_id: courseId, ...notDeleted } });
if (!unit) return R.error(res, "Unit not found.", 404);
const link = await CourseUnit.findOne({ where: { course_id: courseId, unit_id: unitId } });
if (!link) return R.error(res, "Unit not found.", 404);
const quiz = await UnitQuiz.findOne({
where: { unit_id: unitId, ...notDeleted },
@@ -779,8 +818,8 @@ exports.submitUnitQuiz = async (req, res) => {
const { answers = {} } = req.body;
const user_id = req.user.user_id;
const unit = await Unit.findOne({ where: { unit_id: unitId, course_id: courseId, ...notDeleted } });
if (!unit) return R.error(res, "Unit not found.", 404);
const link = await CourseUnit.findOne({ where: { course_id: courseId, unit_id: unitId } });
if (!link) return R.error(res, "Unit not found.", 404);
const quiz = await UnitQuiz.findOne({
where: { quiz_id: quizId, unit_id: unitId, ...notDeleted },
@@ -1016,71 +1055,123 @@ exports.getUnitByUuid = async (req, res) => {
const unit = await Unit.findOne({
where: { uuid, ...notDeleted },
attributes: ["unit_id", "uuid", "title", "description"],
include: [{ model: Course, as: "course", attributes: ["course_id", "title", "subscription"] }],
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);
if (!unit.course) return R.error(res, "Unit has no associated course.", 404);
if (!await canAccessCourse(req.user.user_id, unit.course.course_id)) {
// 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 course.",
course: { title: unit.course.title, subscription: unit.course.subscription },
message: "You do not have access to this unit.",
course: first ? { title: first.title, subscription: first.subscription } : null,
});
}
return R.success(res, "Unit retrieved.", unit);
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);
}
};
// "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", "order_index"],
attributes: ["unit_id", "uuid", "title", "description", "duration_seconds"],
include: [
{ model: Course, as: "course", attributes: ["course_id", "title", "subscription"] },
{
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", "order_index"],
attributes: ["lesson_id", "uuid", "title", "description", "duration_seconds"],
through: { attributes: ["order_index"] },
include: [{ model: LessonPage, as: "page", attributes: ["blocks"], required: false }],
order: [["order_index", "ASC"]],
},
{
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 (!unit.course) return R.error(res, "Unit has no associated course.", 404);
if (!await canAccessCourse(req.user.user_id, unit.course.course_id)) {
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 course.",
course: { title: unit.course.title, subscription: unit.course.subscription },
message: "You do not have access to this unit.",
course: first ? { title: first.title, subscription: first.subscription } : null,
});
}
const lessons = (unit.lessons ?? [])
.sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0))
.map((l) => ({
lesson_id: l.lesson_id,
uuid: l.uuid,
title: l.title,
description: l.description,
order_index: l.order_index ?? 0,
blocks: l.page?.blocks ?? [],
}));
const plain = unit.toJSON();
// Per-lesson completion for the requesting user. NOTE: lesson_reading_progress
// upserts on (user_id, lesson_id) only — a lesson's completion is a property
// of the lesson itself, not scoped to whichever unit it was read under.
const flatLessons = flattenLessons(plain.lessons);
const progressRows = flatLessons.length
? await LessonReadingProgress.findAll({
where: { user_id: req.user.user_id, lesson_id: flatLessons.map((l) => l.lesson_id) },
attributes: ["lesson_id", "status", "completed_at"],
})
: [];
const progressMap = new Map(progressRows.map((p) => [String(p.lesson_id), p]));
const lessons = flatLessons.map((l) => ({
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 ?? [],
status: progressMap.get(String(l.lesson_id))?.status ?? "not_started",
completed_at: progressMap.get(String(l.lesson_id))?.completed_at ?? 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: req.user.user_id, passed: true },
});
quiz = { ...plain.quiz, has_passed: !!passedAttempt };
}
const is_completed = lessons.length > 0 && lessons.every((l) => l.status === "completed");
return R.success(res, "Unit lessons retrieved.", {
unit_id: unit.unit_id,
uuid: unit.uuid,
title: unit.title,
description: unit.description,
course: unit.course ?? null,
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,
lessons,
});
} catch (err) {
@@ -1089,6 +1180,8 @@ exports.getLessonsByUnitUuid = async (req, res) => {
}
};
// "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;
@@ -1104,30 +1197,40 @@ exports.getLessonByUuid = async (req, res) => {
},
{
model: Unit,
as: "unit",
attributes: ["unit_id", "title", "order_index"],
include: [{ model: Course, as: "course", attributes: ["course_id", "title", "subscription"] }],
as: "units",
where: notDeleted, required: false,
attributes: ["unit_id", "uuid", "title"],
through: { attributes: ["order_index"] },
include: [{
model: Course, as: "courses",
where: notDeleted, required: false,
attributes: ["course_id", "title", "subscription"],
through: { attributes: [] },
}],
},
],
});
if (!lesson) return R.error(res, "Lesson not found.", 404);
if (!lesson.unit) return R.error(res, "Lesson has no associated unit.", 404);
if (!lesson.unit.course) return R.error(res, "Unit has no associated course.", 404);
if (!await canAccessCourse(req.user.user_id, lesson.unit.course.course_id)) {
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 course.",
course: { title: lesson.unit.course.title, subscription: lesson.unit.course.subscription },
message: "You do not have access to this lesson.",
course: firstCourse ? { title: firstCourse.title, subscription: firstCourse.subscription } : null,
});
}
const plain = lesson.toJSON();
const firstUnit = plain.units?.[0] ?? null;
const data = {
lesson_id: lesson.lesson_id,
uuid: lesson.uuid,
title: lesson.title,
description: lesson.description,
blocks: lesson.page?.blocks ?? [],
unit: lesson.unit ?? null,
lesson_id: plain.lesson_id,
uuid: plain.uuid,
title: plain.title,
description: plain.description,
blocks: plain.page?.blocks ?? [],
unit: firstUnit ? { unit_id: firstUnit.unit_id, title: firstUnit.title, course: firstUnit.courses?.[0] ?? null } : null, // back-compat singular field
units: plain.units ?? [],
};
return R.success(res, "Lesson retrieved.", data);
} catch (err) {