mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
units,lesson as standalone
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user