units,lesson as standalone

This commit is contained in:
2026-07-10 11:44:46 +08:00
parent 86fba50b95
commit e1ffdab190
46 changed files with 1463 additions and 197 deletions
+85 -5
View File
@@ -4,7 +4,8 @@
* 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 → 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
@@ -16,6 +17,13 @@
* be able to access at least one attached course. Lessons resolve through
* their parent units the same way.
*
* Discovery rule (getUnits/getLessons only): only course-free content is
* listed at all — a unit with any course affiliation, or a lesson with any
* unit that has a course affiliation, is excluded outright rather than
* listed-but-locked. 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
***********************************************************************************************************************************************************************/
@@ -53,11 +61,17 @@ function sanitizeQuestions(questions = []) {
// ─── UNIT LIBRARY (learner view) ──────────────────────────────────────────────
// Client-side Units/Lessons browsing only ever shows INDEPENDENT content —
// anything affiliated with a course (directly, or for a lesson, through any
// of its attached units) is excluded from these listings entirely, not just
// flagged locked. 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.description, u.duration_seconds,
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,
@@ -68,6 +82,11 @@ exports.getUnits = async (req, res) => {
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
AND NOT EXISTS (
SELECT 1 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
)
ORDER BY u.title ASC
`, { type: sequelize.QueryTypes.SELECT });
@@ -89,11 +108,12 @@ exports.getUnits = async (req, res) => {
coursesByUnit.set(row.unit_id, list);
}
// is_locked mirrors canAccessUnit: standalone units are open, attached units
// need at least one accessible course.
// 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 = Number(row.course_count) > 0
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 });
@@ -106,6 +126,66 @@ exports.getUnits = async (req, res) => {
}
};
// ─── 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
AND NOT EXISTS (
SELECT 1 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
WHERE ul.lesson_id = l.lesson_id
)
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
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) => {