mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
assets and tier plans revamp
Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
@@ -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);
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user