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
+106 -13
View File
@@ -19,6 +19,9 @@ const mdl_UserTiers = require("../../models/tiers/user_tiers.mdl");
const mdl_CoursePurchase = require("../../models/courses/course_purchases.mdl");
const mdl_Product = require("../../models/courses/products.mdl");
const mdl_Category = require("../../models/courses/categories.mdl");
const mdl_PlanPolicy = require("../../models/tiers/plan_policies.mdl");
const { mdl_UserGroupMembers } = require("../../models/users/user_groups.mdl");
const { evaluateCourseAccess } = require("../../utils/accessPolicy.util");
const {
Course,
@@ -79,7 +82,9 @@ async function expireSession(session, passingScore) {
return expiredAttempt;
}
// Builds minimal user context: active tier slug + live tier rank map
// Builds user context for evaluateCourseAccess: active tier slug, live tier
// rank map, the active plan's access_rules (if any), and group memberships
// (needed for the group_restriction rule type).
async function buildUserContext(user_id) {
const activeTier = await getActiveTier(user_id);
const tier = activeTier?.tier ?? 'free';
@@ -88,7 +93,19 @@ async function buildUserContext(user_id) {
const tierRankMap = {};
for (const c of categories) tierRankMap[c.slug] = c.rank;
return { tier, tierRankMap };
let access_rules = [];
if (activeTier?.plan_id) {
const policy = await mdl_PlanPolicy.findOne({ where: { plan_id: activeTier.plan_id } });
access_rules = policy?.access_rules ?? [];
}
const memberships = await mdl_UserGroupMembers.findAll({
where: { user_id, deletedAt: null },
attributes: ['group_id'],
});
const group_ids = memberships.map((m) => m.group_id);
return { tier, tierRankMap, access_rules, group_ids };
}
// ─── Shared tier + purchase access check ─────────────────────────────────────
@@ -96,16 +113,15 @@ async function buildUserContext(user_id) {
// Returns false → user's tier is too low AND no valid individual purchase.
async function canAccessCourse(user_id, course_id) {
const course = await Course.findOne({ where: { course_id, ...notDeleted }, attributes: ['subscription'] });
const requiredTier = course?.subscription ?? 'free';
if (!course) return false;
const userCtx = await buildUserContext(user_id);
// Rank-0 slugs (default/free tier) are always accessible — resolved dynamically
const courseRank = userCtx.tierRankMap[requiredTier] ?? Infinity;
if (courseRank === 0) return true;
const userRank = userCtx.tierRankMap[userCtx.tier] ?? 0;
if (userRank >= courseRank) return true;
// evaluateCourseAccess already falls back to plain rank comparison when the
// active plan has no access_rules configured — same behavior as before for
// every course/plan combination that hasn't opted into the richer engine.
const { allowed } = evaluateCourseAccess(userCtx, course, userCtx.tierRankMap);
if (allowed) return true;
// Individual purchase as fallback
const product = await mdl_Product.findOne({ where: { course_id } });
@@ -129,8 +145,19 @@ async function canAccessCourse(user_id, course_id) {
// content locked while letting genuinely standalone content run independently.
async function canAccessUnit(user_id, unit_id) {
// A unit's own subscription (standalone tier-gating) is an additional,
// OR'd access path alongside any attached course's access — most
// standalone units have zero course links anyway, but a unit that somehow
// has both should be unlockable via either.
const unit = await Unit.findOne({ where: { unit_id, ...notDeleted }, attributes: ['subscription'] });
if (unit?.subscription) {
const userCtx = await buildUserContext(user_id);
const { allowed } = evaluateCourseAccess(userCtx, { subscription: unit.subscription }, userCtx.tierRankMap);
if (allowed) return true;
}
const links = await CourseUnit.findAll({ where: { unit_id }, attributes: ['course_id'] });
if (!links.length) return true;
if (!links.length) return !unit?.subscription;
for (const link of links) {
if (await canAccessCourse(user_id, link.course_id)) return true;
}
@@ -1083,6 +1110,52 @@ exports.getUnitByUuid = async (req, res) => {
}
};
// "Quiz (self-enrich for pass_quiz task requirement blocks)" — mirrors
// getUnitByUuid/getLessonByUuid's uuid-lookup pattern. A quiz is always
// unit-scoped (unit_quizzes.unit_id unique) so access resolves through its
// one parent unit, same rule canAccessUnit already implements.
exports.getQuizByUuid = async (req, res) => {
try {
const { uuid } = req.params;
const quiz = await UnitQuiz.findOne({
where: { uuid, ...notDeleted },
attributes: ["quiz_id", "uuid", "title", "is_required", "passing_score"],
include: [{
model: Unit, as: "unit",
attributes: ["unit_id", "uuid", "title"],
include: [{
model: Course, as: "courses",
where: notDeleted, required: false,
attributes: ["course_id", "title", "subscription"],
through: { attributes: [] },
}],
}],
});
if (!quiz) return R.error(res, "Quiz not found.", 404);
if (!await canAccessUnit(req.user.user_id, quiz.unit.unit_id)) {
const first = quiz.unit.courses?.[0] ?? null;
return res.status(403).json({
status: "error",
message: "You do not have access to this quiz.",
course: first ? { title: first.title, subscription: first.subscription } : null,
});
}
const passedAttempt = await QuizAttempt.findOne({
where: { quiz_id: quiz.quiz_id, user_id: req.user.user_id, passed: true },
});
const plain = quiz.toJSON();
plain.unit.course = plain.unit.courses?.[0] ?? null; // back-compat singular field
plain.has_passed = !!passedAttempt;
return R.success(res, "Quiz retrieved.", plain);
} catch (err) {
console.error("[CLIENT][QUIZ][BY UUID]", err);
return R.error(res, "Could not retrieve quiz.", 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) => {
@@ -1105,7 +1178,10 @@ exports.getLessonsByUnitUuid = async (req, res) => {
required: false,
attributes: ["lesson_id", "uuid", "title", "description", "duration_seconds"],
through: { attributes: ["order_index"] },
include: [{ model: LessonPage, as: "page", attributes: ["blocks"], required: false }],
include: [
{ model: LessonPage, as: "page", attributes: ["blocks"], required: false },
{ model: LessonObjective, as: "objectives", required: false, attributes: ["objective_id", "text", "order_index"] },
],
},
{
model: UnitQuiz, as: "quiz",
@@ -1147,6 +1223,7 @@ exports.getLessonsByUnitUuid = async (req, res) => {
order_index: l.order_index ?? 0,
duration_seconds: l.duration_seconds ?? 0,
blocks: l.page?.blocks ?? [],
objectives: (l.objectives ?? []).slice().sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0)),
status: progressMap.get(String(l.lesson_id))?.status ?? "not_started",
completed_at: progressMap.get(String(l.lesson_id))?.completed_at ?? null,
}));
@@ -1187,7 +1264,7 @@ exports.getLessonByUuid = async (req, res) => {
const { uuid } = req.params;
const lesson = await Lesson.findOne({
where: { uuid, ...notDeleted },
attributes: ["lesson_id", "uuid", "title", "description"],
attributes: ["lesson_id", "uuid", "title", "description", "duration_seconds"],
include: [
{
model: LessonPage,
@@ -1195,6 +1272,12 @@ exports.getLessonByUuid = async (req, res) => {
attributes: ["blocks"],
required: false,
},
{
model: LessonObjective,
as: "objectives",
required: false,
attributes: ["objective_id", "text", "order_index"],
},
{
model: Unit,
as: "units",
@@ -1209,6 +1292,7 @@ exports.getLessonByUuid = async (req, res) => {
}],
},
],
order: [[{ model: LessonObjective, as: "objectives" }, "order_index", "ASC"]],
});
if (!lesson) return R.error(res, "Lesson not found.", 404);
@@ -1221,6 +1305,11 @@ exports.getLessonByUuid = async (req, res) => {
});
}
const progress = await LessonReadingProgress.findOne({
where: { user_id: req.user.user_id, lesson_id: lesson.lesson_id },
attributes: ["status", "completed_at"],
});
const plain = lesson.toJSON();
const firstUnit = plain.units?.[0] ?? null;
const data = {
@@ -1228,8 +1317,12 @@ exports.getLessonByUuid = async (req, res) => {
uuid: plain.uuid,
title: plain.title,
description: plain.description,
duration_seconds: plain.duration_seconds ?? 0,
blocks: plain.page?.blocks ?? [],
unit: firstUnit ? { unit_id: firstUnit.unit_id, title: firstUnit.title, course: firstUnit.courses?.[0] ?? null } : null, // back-compat singular field
objectives: plain.objectives ?? [],
status: progress?.status ?? "not_started",
completed_at: progress?.completed_at ?? null,
unit: firstUnit ? { unit_id: firstUnit.unit_id, uuid: firstUnit.uuid, title: firstUnit.title, course: firstUnit.courses?.[0] ?? null } : null, // back-compat singular field
units: plain.units ?? [],
};
return R.success(res, "Lesson retrieved.", data);