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
@@ -3,8 +3,12 @@ const mdl_CoursePurchase = require('../../models/courses/course_purchases.mdl');
const mdl_Product = require('../../models/courses/products.mdl');
const paymentSvc = require('../../services/payment.service');
const R = require('../../utils/response.util');
const { resolvePurchasable, checkoutPath } = require('../../utils/purchasable.util');
// ─── CREATE ORDER ─────────────────────────────────────────────────────────────
// Despite the "course" naming (historical — this predates Units/Lessons being
// individually purchasable), this endpoint and table are generic: a Product's
// purchasable_type/purchasable_id drives everything below.
exports.createCourseOrder = async (req, res) => {
try {
@@ -19,15 +23,19 @@ exports.createCourseOrder = async (req, res) => {
});
if (existing) {
const stillActive = !existing.expires_at || new Date(existing.expires_at) > new Date();
if (stillActive) return R.error(res, 'You already have active access to this course.', 409);
if (stillActive) return R.error(res, `You already have active access to this ${product.purchasable_type}.`, 409);
}
const target = await resolvePurchasable(product.purchasable_type, product.purchasable_id);
if (!target) return R.error(res, 'Purchasable content not found.', 404);
const path = checkoutPath(product.purchasable_type, target);
const ppOrder = await paymentSvc.createOrder('paypal', {
amount: Number(product.price).toFixed(2),
currency: product.currency,
referenceId: `user_${req.user.user_id}_product_${product_id}`,
returnUrl: `${process.env.FRONTEND_URL}/course/${product.course_id}/checkout`,
cancelUrl: `${process.env.FRONTEND_URL}/course/${product.course_id}/checkout?cancelled=true`,
returnUrl: `${process.env.FRONTEND_URL}${path}`,
cancelUrl: `${process.env.FRONTEND_URL}${path}?cancelled=true`,
});
const approvalUrl = ppOrder.links?.find((l) => l.rel === 'approve')?.href ?? null;
@@ -100,10 +108,14 @@ exports.captureCourseOrder = async (req, res) => {
},
});
return R.success(res, 'Payment successful. Course access granted.', {
purchase_id: purchase.id,
expires_at: purchase.expires_at,
course_id: purchase.product.course_id,
const target = await resolvePurchasable(purchase.product.purchasable_type, purchase.product.purchasable_id);
return R.success(res, 'Payment successful. Access granted.', {
purchase_id: purchase.id,
expires_at: purchase.expires_at,
purchasable_type: purchase.product.purchasable_type,
purchasable_id: purchase.product.purchasable_id,
target_uuid: target?.uuid ?? null,
});
} catch (err) {
console.error('[CLIENT][COURSE PURCHASE][CAPTURE]', err);
@@ -144,7 +156,7 @@ exports.getMyPurchases = async (req, res) => {
try {
const purchases = await mdl_CoursePurchase.findAll({
where: { user_id: req.user.user_id },
include: [{ model: mdl_Product, as: 'product', attributes: ['id', 'name', 'course_id', 'access_days'] }],
include: [{ model: mdl_Product, as: 'product', attributes: ['id', 'name', 'purchasable_type', 'purchasable_id', 'access_days'] }],
attributes: { exclude: ['provider_payload'] },
order: [['createdAt', 'DESC']],
});
+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);
}
};
+4 -3
View File
@@ -21,6 +21,7 @@ const trustedDevice = require('../../services/trustedDevice.service');
const logActivity = require('../../utils/logActivity.util');
const R = require('../../utils/response.util');
const { uploadFile, deleteFile } = require('../../services/s3.service');
const { resolveUserAvatar } = require('../../utils/resolveAvatar.util');
// ─── GET own profile ───────────────────────────────────────────────────────────
@@ -29,7 +30,7 @@ exports.getProfile = async (req, res) => {
const user = await mdl_Users.findByPk(req.user.user_id, {
attributes: { exclude: ['password', 'otp_code', 'otp_expires_at'] },
});
return R.success(res, 'Profile retrieved.', user);
return R.success(res, 'Profile retrieved.', await resolveUserAvatar(user));
} catch (err) {
return R.error(res, 'Could not retrieve profile.', 500);
}
@@ -60,7 +61,7 @@ exports.updateProfile = async (req, res) => {
logActivity(req.user.user_id, 'update_profile');
return R.success(res, 'Profile updated.', updated);
return R.success(res, 'Profile updated.', await resolveUserAvatar(updated));
} catch (err) {
console.error('[CLIENT] updateProfile error:', err);
return R.error(res, 'Profile update failed.', 500);
@@ -140,7 +141,7 @@ exports.uploadAvatar = async (req, res) => {
const updated = await mdl_Users.findByPk(req.user.user_id, {
attributes: { exclude: ['password', 'otp_code', 'otp_expires_at'] },
});
return R.success(res, 'Avatar updated.', updated);
return R.success(res, 'Avatar updated.', await resolveUserAvatar(updated));
} catch (err) {
console.error('[CLIENT] uploadAvatar error:', err);
return R.error(res, 'Avatar upload failed.', 500);
+50 -47
View File
@@ -184,12 +184,13 @@ exports.createOrder = async (req, res) => {
const plan = await mdl_TierPlans.findOne({ where: { plan_id, is_active: true } });
if (!plan) return R.error(res, 'Plan not found or inactive.', 404);
// Repurchasing a plan under a tier already held active is allowed — it
// extends the existing grant's expires_at (see captureOrder) rather than
// being blocked. Surfaced here only for checkout-page messaging.
const existingActive = await mdl_UserTiers.findOne({
where: { user_id: req.user.user_id, tier: plan.tier, status: 'active' },
attributes: ['expires_at'],
});
if (existingActive) {
return R.error(res, `You already have an active ${plan.tier} subscription until ${existingActive.expires_at}. You can repurchase once it expires.`, 409);
}
const effectivePrice = Number(plan.price);
const effectiveCurrency = plan.currency;
@@ -242,13 +243,15 @@ exports.createOrder = async (req, res) => {
});
return R.success(res, 'Order created.', {
payment_id: payment.payment_id,
order_id: ppOrder.id,
approval_url: approvalUrl,
amount: total,
currency: effectiveCurrency,
promo_code: promoResult.code,
discount: discount.toFixed(2),
payment_id: payment.payment_id,
order_id: ppOrder.id,
approval_url: approvalUrl,
amount: total,
currency: effectiveCurrency,
promo_code: promoResult.code,
discount: discount.toFixed(2),
extends_existing: !!existingActive,
current_expires_at: existingActive?.expires_at ?? null,
}, 201);
} catch (err) {
console.error('[CLIENT][CREATE ORDER]', err);
@@ -297,47 +300,44 @@ exports.captureOrder = async (req, res) => {
const capture = captureData.purchase_units?.[0]?.payments?.captures?.[0];
// Scoped, defensive re-check: createOrder already blocked this, but time may
// have passed (or two checkout tabs raced) between order creation and capture.
// Money has already moved via PayPal at this point, so auto-refund rather than
// leaving the user charged with nothing to show for it.
// Repurchasing a plan under a tier already held active extends the
// existing grant's expires_at by the new plan's duration, rather than
// being blocked/refunded — the original plan_id is kept (whichever plan
// first granted this tier keeps governing its bundle/access_rules; a
// sibling-plan repurchase only adds time). This also keeps the
// one-active-row-per-(user,tier) DB invariant intact, since no second
// row is ever created.
const existingActive = await mdl_UserTiers.findOne({
where: { user_id: req.user.user_id, tier: payment.plan.tier, status: 'active' },
});
let resultTier;
let successMessage;
if (existingActive) {
try {
await paymentSvc.refundCapture(payment.provider, capture?.id, payment.amount, payment.currency);
await payment.update({
status: 'refunded',
provider_payload: { ...payment.provider_payload, capture_id: capture?.id, capture: captureData, error: 'duplicate_active_tier_auto_refunded' },
});
return R.error(res, `You already have an active ${payment.plan.tier} subscription. Your payment has been automatically refunded.`, 409);
} catch (refundErr) {
console.error('[CLIENT][CAPTURE ORDER] auto-refund failed for duplicate active tier:', refundErr?.response?.data ?? refundErr);
await payment.update({
status: 'failed',
provider_payload: { ...payment.provider_payload, capture_id: capture?.id, capture: captureData, error: 'duplicate_active_tier_refund_failed' },
});
return R.error(res, `You already have an active ${payment.plan.tier} subscription. Refund could not be processed automatically — please contact support.`, 409);
}
const newExpiresAt = new Date(existingActive.expires_at.getTime() + payment.plan.duration_days * 86400000);
await existingActive.update({ expires_at: newExpiresAt });
resultTier = existingActive;
successMessage = `Payment successful. Your ${payment.plan.tier} access has been extended to ${newExpiresAt.toLocaleDateString()}.`;
} else {
const startsAt = new Date();
const expiresAt = new Date(startsAt.getTime() + payment.plan.duration_days * 86400000);
resultTier = await mdl_UserTiers.create({
user_id: req.user.user_id,
tier: payment.plan.tier,
plan_id: payment.plan_id,
status: 'active',
starts_at: startsAt,
expires_at: expiresAt,
granted_by: null,
});
successMessage = 'Payment successful. Tier activated.';
}
const startsAt = new Date();
const expiresAt = new Date(startsAt.getTime() + payment.plan.duration_days * 86400000);
const newTier = await mdl_UserTiers.create({
user_id: req.user.user_id,
tier: payment.plan.tier,
plan_id: payment.plan_id,
status: 'active',
starts_at: startsAt,
expires_at: expiresAt,
granted_by: null,
});
await payment.update({
status: 'completed',
tier_id: newTier.tier_id,
tier_id: resultTier.tier_id,
paid_at: new Date(),
provider_payload: {
...payment.provider_payload,
@@ -347,11 +347,14 @@ exports.captureOrder = async (req, res) => {
},
});
await onTierActivated(req.user.user_id, newTier.tier);
// Idempotent (grantAchievement checks for an existing row first) — safe
// to call again on an extension, won't grant a duplicate achievement.
await onTierActivated(req.user.user_id, resultTier.tier);
return R.success(res, 'Payment successful. Tier activated.', {
tier: newTier.tier,
expires_at: newTier.expires_at,
return R.success(res, successMessage, {
tier: resultTier.tier,
expires_at: resultTier.expires_at,
extended: !!existingActive,
});
} catch (err) {
console.error('[CLIENT][CAPTURE ORDER]', err);
+57 -7
View File
@@ -30,9 +30,12 @@
***********************************************************************************************************************************************************************/
"use strict";
const { Op } = require("sequelize");
const R = require("../../utils/response.util");
const logActivity = require("../../utils/logActivity.util");
const sequelize = require("../../config/db.config");
const mdl_Product = require("../../models/courses/products.mdl");
const mdl_CoursePurchase = require("../../models/courses/course_purchases.mdl");
const {
Unit, Lesson,
@@ -61,6 +64,35 @@ function sanitizeQuestions(questions = []) {
});
}
// Batch-fetch active product listings + this user's completed/unexpired
// purchases for a set of standalone targets (unit or lesson), same shape as
// the courses.controller.js equivalent — used by getUnits/getLessons below so
// the browse-list Buy button has price data without an N+1 query per row.
async function attachProducts(user_id, purchasable_type, ids) {
if (!ids.length) return { productById: new Map(), purchasedIds: new Set() };
const products = await mdl_Product.findAll({
where: { purchasable_type, purchasable_id: { [Op.in]: ids }, is_active: true },
attributes: ["id", "name", "price", "currency", "access_days", "is_active", "purchasable_id"],
});
const productById = new Map(products.map((p) => [String(p.purchasable_id), p]));
const productIds = products.map((p) => p.id);
const purchases = productIds.length ? await mdl_CoursePurchase.findAll({
where: {
user_id, product_id: { [Op.in]: productIds }, status: "completed",
[Op.or]: [{ expires_at: null }, { expires_at: { [Op.gt]: new Date() } }],
},
attributes: ["product_id"],
}) : [];
const purchasedProductIds = new Set(purchases.map((p) => String(p.product_id)));
const purchasedIds = new Set(
products.filter((p) => purchasedProductIds.has(String(p.id))).map((p) => String(p.purchasable_id))
);
return { productById, purchasedIds };
}
// ─── UNIT LIBRARY (learner view) ──────────────────────────────────────────────
// Client-side Units/Lessons browsing shows ALL content, bound to a course or
@@ -104,6 +136,8 @@ exports.getUnits = async (req, res) => {
coursesByUnit.set(row.unit_id, list);
}
const { productById, purchasedIds } = await attachProducts(req.user.user_id, "unit", unitIds);
// is_locked mirrors canAccessUnit: a unit with its own subscription or at
// least one attached course needs an access check; a fully open standalone
// unit (no subscription, no course links) is never locked.
@@ -112,7 +146,13 @@ exports.getUnits = async (req, res) => {
const is_locked = (row.subscription || Number(row.course_count) > 0)
? !(await coursesCtrl.canAccessUnit(req.user.user_id, row.unit_id))
: false;
result.push({ ...row, courses: coursesByUnit.get(row.unit_id) ?? [], is_locked });
result.push({
...row,
courses: coursesByUnit.get(row.unit_id) ?? [],
is_locked,
product: productById.get(String(row.unit_id)) ?? null,
has_purchased: purchasedIds.has(String(row.unit_id)),
});
}
return R.success(res, "Units retrieved.", result);
@@ -131,7 +171,7 @@ exports.getLessons = async (req, res) => {
try {
const rows = await sequelize.query(`
SELECT
l.lesson_id, l.uuid, l.title, l.description, l.duration_seconds,
l.lesson_id, l.uuid, l.title, l.subscription, l.description, l.duration_seconds,
(SELECT CAST(COUNT(*) AS INTEGER) FROM unit_lessons ul
JOIN units u ON u.unit_id = ul.unit_id AND u."deletedAt" IS NULL
WHERE ul.lesson_id = l.lesson_id) AS unit_count
@@ -158,15 +198,23 @@ exports.getLessons = async (req, res) => {
coursesByLesson.set(row.lesson_id, list);
}
// is_locked mirrors canAccessLesson: standalone/unattached lessons are
// open, attached lessons need at least one accessible course through
// any attached unit.
const { productById, purchasedIds } = await attachProducts(req.user.user_id, "lesson", lessonIds);
// is_locked mirrors canAccessLesson: a lesson with its own subscription or
// at least one attached unit needs an access check; a fully open
// standalone lesson (no subscription, no unit links) is never locked.
const result = [];
for (const row of rows) {
const is_locked = Number(row.unit_count) > 0
const is_locked = (row.subscription || Number(row.unit_count) > 0)
? !(await coursesCtrl.canAccessLesson(req.user.user_id, row.lesson_id))
: false;
result.push({ ...row, courses: coursesByLesson.get(row.lesson_id) ?? [], is_locked });
result.push({
...row,
courses: coursesByLesson.get(row.lesson_id) ?? [],
is_locked,
product: productById.get(String(row.lesson_id)) ?? null,
has_purchased: purchasedIds.has(String(row.lesson_id)),
});
}
return R.success(res, "Lessons retrieved.", result);
@@ -481,3 +529,5 @@ exports.markStandaloneLessonComplete = async (req, res) => {
exports.getUnitByUuid = coursesCtrl.getUnitByUuid;
exports.getLessonsByUnitUuid = coursesCtrl.getLessonsByUnitUuid;
exports.getLessonByUuid = coursesCtrl.getLessonByUuid;
exports.getUnitCheckoutInfo = coursesCtrl.getUnitCheckoutInfo;
exports.getLessonCheckoutInfo = coursesCtrl.getLessonCheckoutInfo;