mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
@@ -35,8 +35,10 @@ const CourseReadingProgress = require('../../models/courses/course_readi
|
||||
const Certificate = require('../../models/courses/certificate.mdl');
|
||||
const {
|
||||
Course, Unit, Lesson,
|
||||
CourseUnit, UnitLesson,
|
||||
UnitQuiz, CourseAssessment, QuizAttempt,
|
||||
} = require('../../models/courses/courses.associations');
|
||||
const { countCourseLessons, getCourseUnitIds, flattenUnits } = require('../../utils/courses/hierarchy.util');
|
||||
|
||||
const { Task, TaskRequirement, TaskListGroup } = require('../../models/task/task.mdl');
|
||||
const { TaskProgress } = require('../../models/task/task_progress.mdl');
|
||||
@@ -192,15 +194,7 @@ exports.getMyInProgressCourses = async (req, res) => {
|
||||
const courseId = row.course_id;
|
||||
|
||||
const [lessons_total, lessons_completed] = await Promise.all([
|
||||
Lesson.count({
|
||||
include: [{
|
||||
model: Unit,
|
||||
as: 'unit',
|
||||
where: { course_id: courseId, ...notDeleted },
|
||||
required: true,
|
||||
}],
|
||||
where: notDeleted,
|
||||
}),
|
||||
countCourseLessons(courseId),
|
||||
CourseReadingProgress.count({
|
||||
where: { user_id: userId, course_id: courseId, type: 'lesson', status: 'completed' },
|
||||
}),
|
||||
@@ -215,17 +209,18 @@ exports.getMyInProgressCourses = async (req, res) => {
|
||||
let assessment_configured = true;
|
||||
|
||||
if (readingDone) {
|
||||
const unitQuizzes = await UnitQuiz.findAll({
|
||||
const courseUnitIds = await getCourseUnitIds(courseId);
|
||||
const unitQuizzes = courseUnitIds.length ? await UnitQuiz.findAll({
|
||||
attributes: ['quiz_id', 'title', 'is_required', 'passing_score'],
|
||||
include: [{
|
||||
model: Unit,
|
||||
as: 'unit',
|
||||
attributes: ['unit_id', 'title'],
|
||||
where: { course_id: courseId, ...notDeleted },
|
||||
where: notDeleted,
|
||||
required: true,
|
||||
}],
|
||||
where: notDeleted,
|
||||
});
|
||||
where: { unit_id: courseUnitIds, ...notDeleted },
|
||||
}) : [];
|
||||
|
||||
for (const quiz of unitQuizzes) {
|
||||
const [hasPassed, attemptCount] = await Promise.all([
|
||||
@@ -302,15 +297,7 @@ exports.getCourseProgressSummary = async (req, res) => {
|
||||
if (!course) return R.error(res, 'Course not found.', 404);
|
||||
|
||||
const [lessons_total, lessons_completed, courseRow] = await Promise.all([
|
||||
Lesson.count({
|
||||
include: [{
|
||||
model: Unit,
|
||||
as: 'unit',
|
||||
where: { course_id: courseId, ...notDeleted },
|
||||
required: true,
|
||||
}],
|
||||
where: notDeleted,
|
||||
}),
|
||||
countCourseLessons(courseId),
|
||||
CourseReadingProgress.count({
|
||||
where: { user_id: userId, course_id: courseId, type: 'lesson', status: 'completed' },
|
||||
}),
|
||||
@@ -385,12 +372,14 @@ exports.getCourseTaskContext = async (req, res) => {
|
||||
where: notDeleted,
|
||||
required: false,
|
||||
attributes: ['unit_id', 'uuid'],
|
||||
through: { attributes: [] },
|
||||
include: [{
|
||||
model: Lesson,
|
||||
as: 'lessons',
|
||||
where: notDeleted,
|
||||
required: false,
|
||||
attributes: ['lesson_id', 'uuid'],
|
||||
through: { attributes: [] },
|
||||
}],
|
||||
}],
|
||||
});
|
||||
@@ -475,24 +464,26 @@ exports.upsertLessonProgress = async (req, res) => {
|
||||
const userId = req.user.user_id;
|
||||
const status = req.body.status === 'completed' ? 'completed' : 'in_progress';
|
||||
|
||||
const [course, unit, lesson] = await Promise.all([
|
||||
const [course, unit, lesson, courseLink, lessonLink] = await Promise.all([
|
||||
Course.findOne({
|
||||
where: { course_id: courseId, ...notDeleted },
|
||||
attributes: ['course_id', 'uuid'],
|
||||
}),
|
||||
Unit.findOne({
|
||||
where: { unit_id: unitId, course_id: courseId, ...notDeleted },
|
||||
where: { unit_id: unitId, ...notDeleted },
|
||||
attributes: ['unit_id', 'uuid'],
|
||||
}),
|
||||
Lesson.findOne({
|
||||
where: { lesson_id: lessonId, unit_id: unitId, ...notDeleted },
|
||||
where: { lesson_id: lessonId, ...notDeleted },
|
||||
attributes: ['lesson_id', 'uuid'],
|
||||
}),
|
||||
CourseUnit.findOne({ where: { course_id: courseId, unit_id: unitId } }),
|
||||
UnitLesson.findOne({ where: { unit_id: unitId, lesson_id: lessonId } }),
|
||||
]);
|
||||
|
||||
if (!course) return R.error(res, 'Course not found.', 404);
|
||||
if (!unit) return R.error(res, 'Unit not found.', 404);
|
||||
if (!lesson) return R.error(res, 'Lesson not found.', 404);
|
||||
if (!course) return R.error(res, 'Course not found.', 404);
|
||||
if (!unit || !courseLink) return R.error(res, 'Unit not found.', 404);
|
||||
if (!lesson || !lessonLink) return R.error(res, 'Lesson not found.', 404);
|
||||
|
||||
// ── 1. Primary write: course_reading_progress ─────────────────────────
|
||||
const result = await upsertLessonRead(userId, {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* 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 → all units w/ lesson counts + is_locked
|
||||
* 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.
|
||||
*
|
||||
* 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 { upsertLessonRead } = require("../../services/reading_progress.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) ──────────────────────────────────────────────
|
||||
|
||||
exports.getUnits = async (req, res) => {
|
||||
try {
|
||||
const rows = await sequelize.query(`
|
||||
SELECT
|
||||
u.unit_id, u.uuid, u.title, 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
|
||||
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
|
||||
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: standalone units are open, attached units
|
||||
// need at least one accessible course.
|
||||
const result = [];
|
||||
for (const row of rows) {
|
||||
const is_locked = 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);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── 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? }
|
||||
// Writes lesson_reading_progress with course_id NULL. When unit_uuid is given
|
||||
// (unit context, still no course) the parent unit row is derived + upserted too.
|
||||
|
||||
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 upsertLessonRead(userId, {
|
||||
courseId: null,
|
||||
unitId,
|
||||
lessonId: lesson.lesson_id,
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Shared UUID handlers re-exported for the standalone routes ───────────────
|
||||
|
||||
exports.getUnitByUuid = coursesCtrl.getUnitByUuid;
|
||||
exports.getLessonsByUnitUuid = coursesCtrl.getLessonsByUnitUuid;
|
||||
exports.getLessonByUuid = coursesCtrl.getLessonByUuid;
|
||||
Reference in New Issue
Block a user