mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
client and some admin new
Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
@@ -4,9 +4,8 @@
|
||||
* Description: User-facing course endpoints (read-only).
|
||||
* Access rules:
|
||||
* - All courses are returned in the list (for upsell visibility)
|
||||
* - Each course has is_locked: boolean based on the user's active tier
|
||||
* - free / no active tier → unassigned courses are open; plan courses are locked
|
||||
* - premium (active tier) → unassigned + courses under their plan are open
|
||||
* - Each course has is_locked: boolean based on item-specific entitlement
|
||||
* (user_tier_grants — see hasItemGrant) or an individual purchase
|
||||
* - getCourse still enforces hard 403 on locked access
|
||||
* Author: Kenneth Obsequio (@lash0000)
|
||||
* Date Created: Jun. 7, 2026
|
||||
@@ -16,12 +15,10 @@
|
||||
const { Op } = require("sequelize");
|
||||
const R = require("../../utils/response.util");
|
||||
const mdl_UserTiers = require("../../models/tiers/user_tiers.mdl");
|
||||
const { mdl_UserTierGrants } = require("../../models/tiers/tier.associations");
|
||||
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,
|
||||
@@ -34,7 +31,6 @@ const {
|
||||
} = require("../../models/courses/courses.associations");
|
||||
const { flattenUnits, flattenLessons } = require("../../utils/courses/hierarchy.util");
|
||||
const { resolvePrerequisiteTitles, resolvePrerequisiteCompletion } = require("../../utils/courses/resolvePrerequisiteTitles.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");
|
||||
const { onCourseCompleted } = require('../../services/achievements.service');
|
||||
@@ -90,43 +86,6 @@ async function expireSession(session, passingScore) {
|
||||
return expiredAttempt;
|
||||
}
|
||||
|
||||
// Builds user context for evaluateCourseAccess: the user's best (highest-rank)
|
||||
// active tier slug, live tier rank map, one ruleset per concurrently-active plan
|
||||
// that has access_rules configured, and group memberships (needed for the
|
||||
// group_restriction rule type). A user can hold more than one active tier at
|
||||
// once (e.g. premium + exclusive), so "tier" here is the effective best one for
|
||||
// plain rank checks, while "rulesets" preserves each active plan's own rules
|
||||
// for the OR-across-active-plans check in canAccessCourse.
|
||||
async function buildUserContext(user_id) {
|
||||
const activeTiers = await getActiveTiers(user_id);
|
||||
|
||||
const categories = await mdl_TierCategories.findAll({ attributes: ['slug', 'rank'] });
|
||||
const tierRankMap = {};
|
||||
for (const c of categories) tierRankMap[c.slug] = c.rank;
|
||||
|
||||
let tier = 'free';
|
||||
let bestRank = -Infinity;
|
||||
for (const t of activeTiers) {
|
||||
const rank = tierRankMap[t.tier] ?? 0;
|
||||
if (rank > bestRank) { bestRank = rank; tier = t.tier; }
|
||||
}
|
||||
|
||||
const rulesets = [];
|
||||
for (const t of activeTiers) {
|
||||
if (!t.plan_id) continue;
|
||||
const policy = await mdl_PlanPolicy.findOne({ where: { plan_id: t.plan_id } });
|
||||
if (policy?.access_rules?.length) rulesets.push({ tier: t.tier, 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, rulesets, group_ids, activeTiers };
|
||||
}
|
||||
|
||||
// Individual-purchase check shared by all three content types — a Product is
|
||||
// keyed by (purchasable_type, purchasable_id), see utils/purchasable.util.js.
|
||||
async function hasActivePurchase(user_id, purchasable_type, purchasable_id) {
|
||||
@@ -144,35 +103,39 @@ async function hasActivePurchase(user_id, purchasable_type, purchasable_id) {
|
||||
return !!hasPurchase;
|
||||
}
|
||||
|
||||
// ─── Item-specific entitlement check (Tier Plans v2) ─────────────────────────
|
||||
// A Tier Plan purchase snapshots the exact items its bundle granted into
|
||||
// user_tier_grants at purchase time (see captureOrder in
|
||||
// controllers/client/tiers.controller.js) — this replaces the old tier-rank
|
||||
// comparison, which unlocked ALL same-level content off any active purchase.
|
||||
async function hasItemGrant(user_id, item_type, item_id) {
|
||||
const grant = await mdl_UserTierGrants.findOne({
|
||||
where: { user_id, item_type, item_id },
|
||||
include: [{
|
||||
model: mdl_UserTiers,
|
||||
as: 'userTier',
|
||||
attributes: [],
|
||||
where: { status: 'active' },
|
||||
required: true,
|
||||
}],
|
||||
});
|
||||
return !!grant;
|
||||
}
|
||||
|
||||
// ─── Shared tier + purchase access check ─────────────────────────────────────
|
||||
// Returns true → user may access the course.
|
||||
// Returns false → user's tier is too low AND no valid individual purchase.
|
||||
// Returns false → user has no grant for this exact course AND no valid individual purchase.
|
||||
async function canAccessCourse(user_id, course_id) {
|
||||
const course = await Course.findOne({ where: { course_id, ...notDeleted }, attributes: ['subscription', 'status'] });
|
||||
if (!course) return false;
|
||||
if (course.status !== 'published') return false;
|
||||
|
||||
const userCtx = await buildUserContext(user_id);
|
||||
// Free/ungated content is always accessible.
|
||||
if (!course.subscription || course.subscription === 'free') return true;
|
||||
|
||||
// Plain rank check against the user's best active tier — evaluateCourseAccess
|
||||
// falls back to rank comparison when access_rules is empty. Same behavior as
|
||||
// before for every course/plan combination that hasn't opted into the richer
|
||||
// rule engine.
|
||||
const { allowed: rankAllowed } = evaluateCourseAccess(
|
||||
{ tier: userCtx.tier, access_rules: [], group_ids: userCtx.group_ids },
|
||||
course, userCtx.tierRankMap, { type: 'course', id: course_id }
|
||||
);
|
||||
if (rankAllowed) return true;
|
||||
|
||||
// Rule-based: a user can hold more than one active plan concurrently, and each
|
||||
// one's access_rules is independent — access is granted if ANY of them allow it.
|
||||
for (const ruleset of userCtx.rulesets) {
|
||||
const { allowed } = evaluateCourseAccess(
|
||||
{ tier: ruleset.tier, access_rules: ruleset.access_rules, group_ids: userCtx.group_ids },
|
||||
course, userCtx.tierRankMap, { type: 'course', id: course_id }
|
||||
);
|
||||
if (allowed) return true;
|
||||
}
|
||||
// Item-specific entitlement: did any purchased Tier Plan bundle grant THIS
|
||||
// exact course?
|
||||
if (await hasItemGrant(user_id, 'course', course_id)) return true;
|
||||
|
||||
// Individual purchase as fallback
|
||||
return hasActivePurchase(user_id, 'course', course_id);
|
||||
@@ -187,30 +150,13 @@ async function canAccessCourse(user_id, course_id) {
|
||||
// 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.
|
||||
// A unit's own grant (item-specific — a Unit bundle purchase grants only
|
||||
// this unit, not its parent course) 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);
|
||||
// Plain rank check first, same baseline as canAccessCourse.
|
||||
const { allowed: rankAllowed } = evaluateCourseAccess(
|
||||
{ tier: userCtx.tier, access_rules: [], group_ids: userCtx.group_ids },
|
||||
{ subscription: unit.subscription }, userCtx.tierRankMap, { type: 'unit', id: unit_id }
|
||||
);
|
||||
if (rankAllowed) return true;
|
||||
|
||||
// Rule-based fallback (item_allowlist previews, etc.) — mirrors
|
||||
// canAccessCourse's OR-across-active-plans check, previously missing here.
|
||||
for (const ruleset of userCtx.rulesets) {
|
||||
const { allowed } = evaluateCourseAccess(
|
||||
{ tier: ruleset.tier, access_rules: ruleset.access_rules, group_ids: userCtx.group_ids },
|
||||
{ subscription: unit.subscription }, userCtx.tierRankMap, { type: 'unit', id: unit_id }
|
||||
);
|
||||
if (allowed) return true;
|
||||
}
|
||||
}
|
||||
if (unit?.subscription && await hasItemGrant(user_id, 'unit', unit_id)) return true;
|
||||
|
||||
if (await hasActivePurchase(user_id, 'unit', unit_id)) return true;
|
||||
|
||||
@@ -231,25 +177,10 @@ async function canAccessUnit(user_id, unit_id) {
|
||||
}
|
||||
|
||||
async function canAccessLesson(user_id, lesson_id) {
|
||||
// Mirrors canAccessUnit's shape: own subscription, then own purchase, then
|
||||
// fall through to attached units (OR'd — a lesson can sit in more than one).
|
||||
// Mirrors canAccessUnit's shape: own grant, then own purchase, then fall
|
||||
// through to attached units (OR'd — a lesson can sit in more than one).
|
||||
const lesson = await Lesson.findOne({ where: { lesson_id, ...notDeleted }, attributes: ['subscription'] });
|
||||
if (lesson?.subscription) {
|
||||
const userCtx = await buildUserContext(user_id);
|
||||
const { allowed: rankAllowed } = evaluateCourseAccess(
|
||||
{ tier: userCtx.tier, access_rules: [], group_ids: userCtx.group_ids },
|
||||
{ subscription: lesson.subscription }, userCtx.tierRankMap, { type: 'lesson', id: lesson_id }
|
||||
);
|
||||
if (rankAllowed) return true;
|
||||
|
||||
for (const ruleset of userCtx.rulesets) {
|
||||
const { allowed } = evaluateCourseAccess(
|
||||
{ tier: ruleset.tier, access_rules: ruleset.access_rules, group_ids: userCtx.group_ids },
|
||||
{ subscription: lesson.subscription }, userCtx.tierRankMap, { type: 'lesson', id: lesson_id }
|
||||
);
|
||||
if (allowed) return true;
|
||||
}
|
||||
}
|
||||
if (lesson?.subscription && await hasItemGrant(user_id, 'lesson', lesson_id)) return true;
|
||||
|
||||
if (await hasActivePurchase(user_id, 'lesson', lesson_id)) return true;
|
||||
|
||||
@@ -261,58 +192,9 @@ async function canAccessLesson(user_id, lesson_id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// ─── Starter-set completion → paid-unlock eligibility ─────────────────────────
|
||||
// An `item_allowlist` access rule on a plan whose OWN tier matches the item
|
||||
// being evaluated doubles as a "starter set": those items are the plan's free
|
||||
// included content, and the subscriber can only purchase ADDITIONAL individual
|
||||
// items at that same level once every starter item is complete. A plan whose
|
||||
// tier doesn't match (e.g. a Premium plan previewing a couple of Exclusive
|
||||
// items) is a preview grant only — it never gates purchase eligibility.
|
||||
|
||||
async function areAllItemsComplete(user_id, item_type, item_ids) {
|
||||
if (!Array.isArray(item_ids) || !item_ids.length) return false;
|
||||
for (const id of item_ids) {
|
||||
const result = await evaluateEntity({
|
||||
entityType: item_type,
|
||||
entityId: id,
|
||||
userId: user_id,
|
||||
courseId: item_type === 'course' ? id : null,
|
||||
});
|
||||
if (result.status !== 'completed') return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function isPurchaseEligible(user_id, itemSubscription, itemType, itemId) {
|
||||
if (!itemSubscription || itemSubscription === 'free') return true;
|
||||
|
||||
const activeTiers = await getActiveTiers(user_id);
|
||||
const relevantPlanIds = activeTiers
|
||||
.filter((t) => t.tier === itemSubscription && t.plan_id)
|
||||
.map((t) => t.plan_id);
|
||||
if (!relevantPlanIds.length) return true; // not subscribed at this level — unrestricted, unchanged behavior
|
||||
|
||||
for (const planId of relevantPlanIds) {
|
||||
const policy = await mdl_PlanPolicy.findOne({ where: { plan_id: planId } });
|
||||
const allowlistRules = (policy?.access_rules ?? []).filter((r) => r.type === 'item_allowlist');
|
||||
if (!allowlistRules.length) return true; // this active plan imposes no starter-set gate
|
||||
|
||||
for (const rule of allowlistRules) {
|
||||
if (rule.item_type === itemType && (rule.item_ids ?? []).map(String).includes(String(itemId))) {
|
||||
return true; // item itself is in the free starter set — no purchase needed
|
||||
}
|
||||
if (await areAllItemsComplete(user_id, rule.item_type, rule.item_ids)) {
|
||||
return true; // starter set complete — this plan unlocks paid access to more
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
exports.canAccessCourse = canAccessCourse;
|
||||
exports.canAccessUnit = canAccessUnit;
|
||||
exports.canAccessLesson = canAccessLesson;
|
||||
exports.isPurchaseEligible = isPurchaseEligible;
|
||||
|
||||
const COURSE_LIST_ATTRS = [
|
||||
"course_id", "uuid", "title", "description",
|
||||
@@ -336,15 +218,6 @@ function sanitizeQuestions(questions = []) {
|
||||
});
|
||||
}
|
||||
|
||||
// Resolve ALL of the caller's concurrently-active tiers (empty array if free/expired) —
|
||||
// a user can hold more than one active tier at once (e.g. premium + exclusive).
|
||||
async function getActiveTiers(user_id) {
|
||||
return mdl_UserTiers.findAll({
|
||||
where: { user_id, status: "active" },
|
||||
order: [["createdAt", "DESC"]],
|
||||
});
|
||||
}
|
||||
|
||||
// ─── COURSE CATEGORIES (public list for filter chips) ─────────────────────────
|
||||
|
||||
exports.getCategories = async (req, res) => {
|
||||
@@ -367,9 +240,6 @@ exports.getCourses = async (req, res) => {
|
||||
try {
|
||||
const { category } = req.query; // optional slug filter
|
||||
|
||||
const userCtx = await buildUserContext(req.user.user_id);
|
||||
const userTier = userCtx.tier;
|
||||
|
||||
// Fetch all completed purchases for this user (for has_purchased check) —
|
||||
// course_purchases now spans all three content types, so filter down to
|
||||
// course-targeted products here.
|
||||
@@ -385,6 +255,16 @@ exports.getCourses = async (req, res) => {
|
||||
.map((p) => String(p.product.purchasable_id))
|
||||
);
|
||||
|
||||
// Item-specific entitlement (Tier Plans v2) — batch-fetch every course this
|
||||
// user was granted by an active Tier Plan purchase, same source canAccessCourse
|
||||
// checks per-item via hasItemGrant.
|
||||
const myGrants = await mdl_UserTierGrants.findAll({
|
||||
where: { user_id: req.user.user_id, item_type: 'course' },
|
||||
include: [{ model: mdl_UserTiers, as: 'userTier', attributes: [], where: { status: 'active' }, required: true }],
|
||||
attributes: ['item_id'],
|
||||
});
|
||||
const grantedCourseIds = new Set(myGrants.map((g) => String(g.item_id)));
|
||||
|
||||
// Build category filter
|
||||
const categoryInclude = {
|
||||
model: mdl_Category,
|
||||
@@ -411,15 +291,13 @@ exports.getCourses = async (req, res) => {
|
||||
order: [['order_index', 'ASC'], ['title', 'ASC']],
|
||||
});
|
||||
|
||||
const userRank = userCtx.tierRankMap[userCtx.tier] ?? 0;
|
||||
|
||||
const result = courses.map((c) => {
|
||||
const plain = c.toJSON();
|
||||
const has_purchased = purchasedCourseIds.has(String(plain.course_id));
|
||||
const has_grant = grantedCourseIds.has(String(plain.course_id));
|
||||
const subscription = plain.subscription ?? 'free';
|
||||
const courseRank = userCtx.tierRankMap[subscription] ?? Infinity;
|
||||
|
||||
const is_locked = courseRank > 0 && !has_purchased && userRank < courseRank;
|
||||
const is_locked = subscription !== 'free' && !has_purchased && !has_grant;
|
||||
|
||||
return { ...plain, is_locked, has_purchased };
|
||||
});
|
||||
@@ -573,6 +451,38 @@ exports.getCourse = async (req, res) => {
|
||||
}));
|
||||
}
|
||||
|
||||
// Same idea, one level up — each unit's own completion trigger (read_all_content
|
||||
// [default] / pass_quiz / manual_complete) plus the course's, so UnitList.jsx can
|
||||
// show a "how to complete this unit / this course" explainer without another
|
||||
// round trip (mirrors getLessonsByUnitUuid's standalone-reader equivalent).
|
||||
const allUnitIds = plain.units?.map((u) => u.unit_id) ?? [];
|
||||
if (allUnitIds.length) {
|
||||
const unitRequirements = await CompletionRequirement.findAll({
|
||||
where: { entity_type: 'unit', entity_id: allUnitIds },
|
||||
attributes: ['entity_id', 'type', 'min_percent', 'button_label'],
|
||||
});
|
||||
const byUnitId = new Map();
|
||||
unitRequirements.forEach((r) => { if (!byUnitId.has(String(r.entity_id))) byUnitId.set(String(r.entity_id), r); });
|
||||
|
||||
plain.units = plain.units.map((u) => {
|
||||
const row = byUnitId.get(String(u.unit_id));
|
||||
return {
|
||||
...u,
|
||||
completion: row
|
||||
? { type: row.type, min_percent: row.min_percent, button_label: row.button_label }
|
||||
: { type: 'read_all_content', min_percent: null, button_label: null },
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const courseRequirement = await CompletionRequirement.findOne({
|
||||
where: { entity_type: 'course', entity_id: course.course_id },
|
||||
attributes: ['type', 'min_percent', 'button_label'],
|
||||
});
|
||||
plain.completion = courseRequirement
|
||||
? { type: courseRequirement.type, min_percent: courseRequirement.min_percent, button_label: courseRequirement.button_label }
|
||||
: { type: 'read_all_content', min_percent: null, button_label: null };
|
||||
|
||||
// has_passed reflects the assessment attempt alone; is_completed is the consolidated
|
||||
// evaluator's result (default rule: all units read AND assessment passed, if one exists —
|
||||
// was previously hardcoded to assessment-pass alone here too, same bug fixed in
|
||||
@@ -607,9 +517,7 @@ exports.getCourse = async (req, res) => {
|
||||
[Op.or]: [{ expires_at: null }, { expires_at: { [Op.gt]: new Date() } }],
|
||||
},
|
||||
});
|
||||
const purchaseEligible = (!product || hasPurchase)
|
||||
? true
|
||||
: await isPurchaseEligible(req.user.user_id, plain.subscription, 'course', courseId);
|
||||
const purchaseEligible = true;
|
||||
|
||||
// Certificate status for the course details card
|
||||
const [pendingCert, certificate] = await Promise.all([
|
||||
@@ -1649,10 +1557,7 @@ async function buildCheckoutInfo(user_id, purchasable_type, record) {
|
||||
[Op.or]: [{ expires_at: null }, { expires_at: { [Op.gt]: new Date() } }],
|
||||
},
|
||||
});
|
||||
const purchaseEligible = (!product || hasPurchase)
|
||||
? true
|
||||
: await isPurchaseEligible(user_id, record.subscription, purchasable_type, record[CHECKOUT_PK[purchasable_type]]);
|
||||
return { product: product ?? null, has_purchased: !!hasPurchase, purchase_eligible: purchaseEligible };
|
||||
return { product: product ?? null, has_purchased: !!hasPurchase, purchase_eligible: true };
|
||||
}
|
||||
|
||||
exports.getCourseCheckoutInfo = async (req, res) => {
|
||||
|
||||
Reference in New Issue
Block a user