mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
tier plans improving
Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
@@ -160,7 +160,7 @@ async function canAccessCourse(user_id, course_id) {
|
||||
// rule engine.
|
||||
const { allowed: rankAllowed } = evaluateCourseAccess(
|
||||
{ tier: userCtx.tier, access_rules: [], group_ids: userCtx.group_ids },
|
||||
course, userCtx.tierRankMap
|
||||
course, userCtx.tierRankMap, { type: 'course', id: course_id }
|
||||
);
|
||||
if (rankAllowed) return true;
|
||||
|
||||
@@ -169,7 +169,7 @@ async function canAccessCourse(user_id, course_id) {
|
||||
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
|
||||
course, userCtx.tierRankMap, { type: 'course', id: course_id }
|
||||
);
|
||||
if (allowed) return true;
|
||||
}
|
||||
@@ -194,13 +194,22 @@ async function canAccessUnit(user_id, unit_id) {
|
||||
const unit = await Unit.findOne({ where: { unit_id, ...notDeleted }, attributes: ['subscription'] });
|
||||
if (unit?.subscription) {
|
||||
const userCtx = await buildUserContext(user_id);
|
||||
// Unit-level subscription gating is a plain rank check against the user's
|
||||
// best active tier, not a rule-based one.
|
||||
const { allowed } = evaluateCourseAccess(
|
||||
// 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
|
||||
{ subscription: unit.subscription }, userCtx.tierRankMap, { type: 'unit', id: unit_id }
|
||||
);
|
||||
if (allowed) return true;
|
||||
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 (await hasActivePurchase(user_id, 'unit', unit_id)) return true;
|
||||
@@ -227,11 +236,19 @@ async function canAccessLesson(user_id, lesson_id) {
|
||||
const lesson = await Lesson.findOne({ where: { lesson_id, ...notDeleted }, attributes: ['subscription'] });
|
||||
if (lesson?.subscription) {
|
||||
const userCtx = await buildUserContext(user_id);
|
||||
const { allowed } = evaluateCourseAccess(
|
||||
const { allowed: rankAllowed } = evaluateCourseAccess(
|
||||
{ tier: userCtx.tier, access_rules: [], group_ids: userCtx.group_ids },
|
||||
{ subscription: lesson.subscription }, userCtx.tierRankMap
|
||||
{ subscription: lesson.subscription }, userCtx.tierRankMap, { type: 'lesson', id: lesson_id }
|
||||
);
|
||||
if (allowed) return true;
|
||||
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 (await hasActivePurchase(user_id, 'lesson', lesson_id)) return true;
|
||||
@@ -244,9 +261,58 @@ async function canAccessLesson(user_id, lesson_id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
exports.canAccessCourse = canAccessCourse;
|
||||
exports.canAccessUnit = canAccessUnit;
|
||||
exports.canAccessLesson = canAccessLesson;
|
||||
// ─── 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",
|
||||
@@ -541,6 +607,9 @@ 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);
|
||||
|
||||
// Certificate status for the course details card
|
||||
const [pendingCert, certificate] = await Promise.all([
|
||||
@@ -559,6 +628,7 @@ exports.getCourse = async (req, res) => {
|
||||
plan_tier,
|
||||
product: product ?? null,
|
||||
has_purchased: !!hasPurchase,
|
||||
purchase_eligible: purchaseEligible,
|
||||
pending_certificate: pendingCert ?? null,
|
||||
certificate: certificate ?? null,
|
||||
});
|
||||
@@ -1379,12 +1449,12 @@ exports.getLessonsByUnitUuid = async (req, res) => {
|
||||
|
||||
if (!await canAccessUnit(req.user.user_id, unit.unit_id)) {
|
||||
const first = unit.courses?.[0] ?? null;
|
||||
const { product, has_purchased } = await buildCheckoutInfo(req.user.user_id, "unit", unit);
|
||||
const { product, has_purchased, purchase_eligible } = await buildCheckoutInfo(req.user.user_id, "unit", unit);
|
||||
return res.status(403).json({
|
||||
status: "error",
|
||||
message: "You do not have access to this unit.",
|
||||
course: first ? { title: first.title, subscription: first.subscription } : null,
|
||||
item: { uuid: unit.uuid, subscription: unit.subscription, product, has_purchased },
|
||||
item: { uuid: unit.uuid, subscription: unit.subscription, product, has_purchased, purchase_eligible },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1508,12 +1578,12 @@ exports.getLessonByUuid = async (req, res) => {
|
||||
|
||||
if (!await canAccessLesson(req.user.user_id, lesson.lesson_id)) {
|
||||
const firstCourse = lesson.units?.[0]?.courses?.[0] ?? null;
|
||||
const { product, has_purchased } = await buildCheckoutInfo(req.user.user_id, "lesson", lesson);
|
||||
const { product, has_purchased, purchase_eligible } = await buildCheckoutInfo(req.user.user_id, "lesson", lesson);
|
||||
return res.status(403).json({
|
||||
status: "error",
|
||||
message: "You do not have access to this lesson.",
|
||||
course: firstCourse ? { title: firstCourse.title, subscription: firstCourse.subscription } : null,
|
||||
item: { uuid: lesson.uuid, subscription: lesson.subscription, product, has_purchased },
|
||||
item: { uuid: lesson.uuid, subscription: lesson.subscription, product, has_purchased, purchase_eligible },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1579,7 +1649,10 @@ async function buildCheckoutInfo(user_id, purchasable_type, record) {
|
||||
[Op.or]: [{ expires_at: null }, { expires_at: { [Op.gt]: new Date() } }],
|
||||
},
|
||||
});
|
||||
return { product: product ?? null, has_purchased: !!hasPurchase };
|
||||
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 };
|
||||
}
|
||||
|
||||
exports.getCourseCheckoutInfo = async (req, res) => {
|
||||
@@ -1590,8 +1663,8 @@ exports.getCourseCheckoutInfo = async (req, res) => {
|
||||
attributes: ["course_id", "uuid", "title", "description", "level", "subscription"],
|
||||
});
|
||||
if (!course) return R.error(res, "Course not found.", 404);
|
||||
const { product, has_purchased } = await buildCheckoutInfo(req.user.user_id, "course", course);
|
||||
return R.success(res, "Checkout info retrieved.", { ...course.toJSON(), product, has_purchased });
|
||||
const { product, has_purchased, purchase_eligible } = await buildCheckoutInfo(req.user.user_id, "course", course);
|
||||
return R.success(res, "Checkout info retrieved.", { ...course.toJSON(), product, has_purchased, purchase_eligible });
|
||||
} catch (err) {
|
||||
console.error("[CLIENT][COURSES][CHECKOUT INFO]", err);
|
||||
return R.error(res, "Could not retrieve checkout info.", 500);
|
||||
@@ -1601,10 +1674,10 @@ exports.getCourseCheckoutInfo = async (req, res) => {
|
||||
exports.getUnitCheckoutInfo = async (req, res) => {
|
||||
try {
|
||||
const { uuid } = req.params;
|
||||
const unit = await Unit.findOne({ where: { uuid, ...notDeleted }, attributes: ["unit_id", "uuid", "title", "description"] });
|
||||
const unit = await Unit.findOne({ where: { uuid, ...notDeleted }, attributes: ["unit_id", "uuid", "title", "description", "subscription"] });
|
||||
if (!unit) return R.error(res, "Unit not found.", 404);
|
||||
const { product, has_purchased } = await buildCheckoutInfo(req.user.user_id, "unit", unit);
|
||||
return R.success(res, "Checkout info retrieved.", { ...unit.toJSON(), product, has_purchased });
|
||||
const { product, has_purchased, purchase_eligible } = await buildCheckoutInfo(req.user.user_id, "unit", unit);
|
||||
return R.success(res, "Checkout info retrieved.", { ...unit.toJSON(), product, has_purchased, purchase_eligible });
|
||||
} catch (err) {
|
||||
console.error("[CLIENT][UNITS][CHECKOUT INFO]", err);
|
||||
return R.error(res, "Could not retrieve checkout info.", 500);
|
||||
@@ -1614,10 +1687,10 @@ exports.getUnitCheckoutInfo = async (req, res) => {
|
||||
exports.getLessonCheckoutInfo = async (req, res) => {
|
||||
try {
|
||||
const { uuid } = req.params;
|
||||
const lesson = await Lesson.findOne({ where: { uuid, ...notDeleted }, attributes: ["lesson_id", "uuid", "title", "description"] });
|
||||
const lesson = await Lesson.findOne({ where: { uuid, ...notDeleted }, attributes: ["lesson_id", "uuid", "title", "description", "subscription"] });
|
||||
if (!lesson) return R.error(res, "Lesson not found.", 404);
|
||||
const { product, has_purchased } = await buildCheckoutInfo(req.user.user_id, "lesson", lesson);
|
||||
return R.success(res, "Checkout info retrieved.", { ...lesson.toJSON(), product, has_purchased });
|
||||
const { product, has_purchased, purchase_eligible } = await buildCheckoutInfo(req.user.user_id, "lesson", lesson);
|
||||
return R.success(res, "Checkout info retrieved.", { ...lesson.toJSON(), product, has_purchased, purchase_eligible });
|
||||
} catch (err) {
|
||||
console.error("[CLIENT][LESSONS][CHECKOUT INFO]", err);
|
||||
return R.error(res, "Could not retrieve checkout info.", 500);
|
||||
|
||||
Reference in New Issue
Block a user