assets and tier plans revamp

Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
2026-08-01 17:44:25 +08:00
parent 39c3c4566b
commit cae958b5d5
41 changed files with 1271 additions and 165 deletions
+117 -22
View File
@@ -127,6 +127,23 @@ async function buildUserContext(user_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) {
const product = await mdl_Product.findOne({ where: { purchasable_type, purchasable_id } });
if (!product) return false;
const hasPurchase = await mdl_CoursePurchase.findOne({
where: {
user_id,
product_id: product.id,
status: 'completed',
[Op.or]: [{ expires_at: null }, { expires_at: { [Op.gt]: new Date() } }],
},
});
return !!hasPurchase;
}
// ─── 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.
@@ -158,25 +175,16 @@ async function canAccessCourse(user_id, course_id) {
}
// Individual purchase as fallback
const product = await mdl_Product.findOne({ where: { course_id } });
if (!product) return false;
const hasPurchase = await mdl_CoursePurchase.findOne({
where: {
user_id,
product_id: product.id,
status: 'completed',
[Op.or]: [{ expires_at: null }, { expires_at: { [Op.gt]: new Date() } }],
},
});
return !!hasPurchase;
return hasActivePurchase(user_id, 'course', course_id);
}
// ─── Standalone access checks (junction revamp) ──────────────────────────────
// A Unit attached to no course is open to every authenticated user; a Unit
// attached to one or more courses is open when the user can access ANY of them.
// Lessons resolve through their parent units the same way. This keeps paid
// content locked while letting genuinely standalone content run independently.
// Lessons resolve the same way through their parent units. Both Unit and
// Lesson also carry their own optional subscription/individual-purchase gate,
// full parity with Course — this keeps paid 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,
@@ -195,6 +203,8 @@ async function canAccessUnit(user_id, unit_id) {
if (allowed) return true;
}
if (await hasActivePurchase(user_id, 'unit', unit_id)) return true;
// Only links to PUBLISHED courses count as a real course dependency — a unit
// whose only link is to a draft/unpublished course behaves as if it had no
// course link at all (falls through to the free/standalone branch below),
@@ -212,8 +222,22 @@ 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).
const lesson = await Lesson.findOne({ where: { lesson_id, ...notDeleted }, attributes: ['subscription'] });
if (lesson?.subscription) {
const userCtx = await buildUserContext(user_id);
const { allowed } = evaluateCourseAccess(
{ tier: userCtx.tier, access_rules: [], group_ids: userCtx.group_ids },
{ subscription: lesson.subscription }, userCtx.tierRankMap
);
if (allowed) return true;
}
if (await hasActivePurchase(user_id, 'lesson', lesson_id)) return true;
const unitLinks = await UnitLesson.findAll({ where: { lesson_id }, attributes: ['unit_id'] });
if (!unitLinks.length) return true;
if (!unitLinks.length) return !lesson?.subscription;
for (const link of unitLinks) {
if (await canAccessUnit(user_id, link.unit_id)) return true;
}
@@ -280,17 +304,19 @@ exports.getCourses = async (req, res) => {
const userCtx = await buildUserContext(req.user.user_id);
const userTier = userCtx.tier;
// Fetch all completed purchases for this user (for has_purchased check)
// 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.
const myPurchases = await mdl_CoursePurchase.findAll({
where: { user_id: req.user.user_id, status: 'completed' },
include: [{ model: mdl_Product, as: 'product', attributes: ['course_id', 'access_days'] }],
include: [{ model: mdl_Product, as: 'product', attributes: ['purchasable_type', 'purchasable_id', 'access_days'] }],
attributes: ['id', 'expires_at', 'product_id'],
});
const purchasedCourseIds = new Set(
myPurchases
.filter((p) => !p.expires_at || new Date(p.expires_at) > new Date())
.map((p) => String(p.product?.course_id))
.filter((p) => p.product?.purchasable_type === 'course' && (!p.expires_at || new Date(p.expires_at) > new Date()))
.map((p) => String(p.product.purchasable_id))
);
// Build category filter
@@ -506,7 +532,7 @@ exports.getCourse = async (req, res) => {
// Attach product info and purchase status for the buy-course flow
const product = await mdl_Product.findOne({
where: { course_id: courseId, is_active: true },
where: { purchasable_type: 'course', purchasable_id: courseId, is_active: true },
attributes: ['id', 'name', 'price', 'currency', 'access_days'],
});
const hasPurchase = product && await mdl_CoursePurchase.findOne({
@@ -1322,7 +1348,7 @@ exports.getLessonsByUnitUuid = async (req, res) => {
const { uuid } = req.params;
const unit = await Unit.findOne({
where: { uuid, ...notDeleted },
attributes: ["unit_id", "uuid", "title", "description", "duration_seconds"],
attributes: ["unit_id", "uuid", "title", "subscription", "description", "duration_seconds"],
include: [
{
model: Course, as: "courses",
@@ -1353,10 +1379,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);
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 },
});
}
@@ -1446,7 +1474,7 @@ exports.getLessonByUuid = async (req, res) => {
const { uuid } = req.params;
const lesson = await Lesson.findOne({
where: { uuid, ...notDeleted },
attributes: ["lesson_id", "uuid", "title", "description", "duration_seconds"],
attributes: ["lesson_id", "uuid", "title", "subscription", "description", "duration_seconds"],
include: [
{
model: LessonPage,
@@ -1480,10 +1508,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);
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 },
});
}
@@ -1527,4 +1557,69 @@ exports.getLessonByUuid = async (req, res) => {
console.error("[CLIENT][LESSONS][BY UUID]", err);
return R.error(res, "Could not retrieve lesson.", 500);
}
};
// ─── CHECKOUT INFO (course/unit/lesson) ───────────────────────────────────────
// Deliberately does NOT hard-403 on locked content like getCourse/
// getUnitByUuid/getLessonByUuid do — a locked-and-unpurchased item is exactly
// who needs to land on this page and see title/description/product, so it
// can't gate on the same canAccess*() check those content-serving routes use.
// Auth-only; content stays fully protected behind the routes above.
const CHECKOUT_PK = { course: "course_id", unit: "unit_id", lesson: "lesson_id" };
async function buildCheckoutInfo(user_id, purchasable_type, record) {
const product = await mdl_Product.findOne({
where: { purchasable_type, purchasable_id: record[CHECKOUT_PK[purchasable_type]], is_active: true },
attributes: ["id", "name", "price", "currency", "access_days"],
});
const hasPurchase = product && await mdl_CoursePurchase.findOne({
where: {
user_id, product_id: product.id, status: "completed",
[Op.or]: [{ expires_at: null }, { expires_at: { [Op.gt]: new Date() } }],
},
});
return { product: product ?? null, has_purchased: !!hasPurchase };
}
exports.getCourseCheckoutInfo = async (req, res) => {
try {
const { courseId } = req.params;
const course = await Course.findOne({
where: { course_id: courseId, ...notDeleted, status: "published" },
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 });
} catch (err) {
console.error("[CLIENT][COURSES][CHECKOUT INFO]", err);
return R.error(res, "Could not retrieve checkout info.", 500);
}
};
exports.getUnitCheckoutInfo = async (req, res) => {
try {
const { uuid } = req.params;
const unit = await Unit.findOne({ where: { uuid, ...notDeleted }, attributes: ["unit_id", "uuid", "title", "description"] });
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 });
} catch (err) {
console.error("[CLIENT][UNITS][CHECKOUT INFO]", err);
return R.error(res, "Could not retrieve checkout info.", 500);
}
};
exports.getLessonCheckoutInfo = async (req, res) => {
try {
const { uuid } = req.params;
const lesson = await Lesson.findOne({ where: { uuid, ...notDeleted }, attributes: ["lesson_id", "uuid", "title", "description"] });
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 });
} catch (err) {
console.error("[CLIENT][LESSONS][CHECKOUT INFO]", err);
return R.error(res, "Could not retrieve checkout info.", 500);
}
};