tier plans improving

Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
2026-08-01 23:02:31 +08:00
parent cae958b5d5
commit c5052fda4c
15 changed files with 371 additions and 41 deletions
@@ -44,7 +44,7 @@ exports.getCategory = async (req, res) => {
exports.createCategory = async (req, res) => { exports.createCategory = async (req, res) => {
try { try {
const { slug, name, description, rank, color, badge_asset_id, badge_icon, badge_label } = req.body; const { slug, name, description, rank, color, badge_asset_id, badge_icon, badge_label, is_special } = req.body;
if (!slug || !name) return R.error(res, 'slug and name are required.', 400); if (!slug || !name) return R.error(res, 'slug and name are required.', 400);
const parsedRank = Number(rank ?? 1); const parsedRank = Number(rank ?? 1);
@@ -63,6 +63,7 @@ exports.createCategory = async (req, res) => {
badge_label: badge_label ?? null, badge_label: badge_label ?? null,
is_default: false, is_default: false,
is_active: true, is_active: true,
is_special: !!is_special,
}); });
logActivity(req.user?.user_id, 'create_tier_category', { entityType: 'tier_category', details: { slug, name } }); logActivity(req.user?.user_id, 'create_tier_category', { entityType: 'tier_category', details: { slug, name } });
@@ -82,7 +83,7 @@ exports.updateCategory = async (req, res) => {
const cat = await mdl_TierCategories.findByPk(req.params.id); const cat = await mdl_TierCategories.findByPk(req.params.id);
if (!cat) return R.error(res, 'Tier category not found.', 404); if (!cat) return R.error(res, 'Tier category not found.', 404);
const { name, description, rank, color, badge_asset_id, badge_icon, badge_label, is_active } = req.body; const { name, description, rank, color, badge_asset_id, badge_icon, badge_label, is_active, is_special } = req.body;
if (!cat.is_default && rank !== undefined) { if (!cat.is_default && rank !== undefined) {
const parsedRank = Number(rank); const parsedRank = Number(rank);
@@ -99,6 +100,7 @@ exports.updateCategory = async (req, res) => {
badge_label: badge_label !== undefined ? (badge_label || null) : cat.badge_label, badge_label: badge_label !== undefined ? (badge_label || null) : cat.badge_label,
// Default category (free) cannot be deactivated // Default category (free) cannot be deactivated
is_active: (!cat.is_default && is_active !== undefined) ? is_active : cat.is_active, is_active: (!cat.is_default && is_active !== undefined) ? is_active : cat.is_active,
is_special: is_special !== undefined ? !!is_special : cat.is_special,
}); });
logActivity(req.user?.user_id, 'update_tier_category', { entityType: 'tier_category', details: { id: cat.tier_category_id, slug: cat.slug } }); logActivity(req.user?.user_id, 'update_tier_category', { entityType: 'tier_category', details: { id: cat.tier_category_id, slug: cat.slug } });
@@ -15,8 +15,11 @@ const VALID_RULE_TYPES = new Set([
'course_subscription_access', 'course_subscription_access',
'required_active_tier', 'required_active_tier',
'group_restriction', 'group_restriction',
'item_allowlist',
]); ]);
const VALID_ITEM_TYPES = new Set(['course', 'unit', 'lesson']);
async function validateRules(rules) { async function validateRules(rules) {
if (!Array.isArray(rules)) return 'access_rules must be an array.'; if (!Array.isArray(rules)) return 'access_rules must be an array.';
@@ -41,6 +44,15 @@ async function validateRules(rules) {
if (!Array.isArray(rule.group_ids)) if (!Array.isArray(rule.group_ids))
return 'group_restriction.group_ids must be an array.'; return 'group_restriction.group_ids must be an array.';
} }
if (rule.type === 'item_allowlist') {
if (!VALID_ITEM_TYPES.has(rule.item_type))
return `item_allowlist.item_type must be one of: ${[...VALID_ITEM_TYPES].join(', ')}.`;
if (!Array.isArray(rule.item_ids) || !rule.item_ids.length)
return 'item_allowlist.item_ids must be a non-empty array.';
if (rule.item_ids.length > 3)
return 'item_allowlist.item_ids must contain at most 3 items.';
}
} }
return null; return null;
} }
+46 -3
View File
@@ -28,6 +28,9 @@ const R = require('../../utils/response.util');
const { paginate } = require('../../utils/paginate.util'); const { paginate } = require('../../utils/paginate.util');
const { getFieldValues } = require('../../utils/fieldValues.util'); const { getFieldValues } = require('../../utils/fieldValues.util');
const logActivity = require('../../utils/logActivity.util'); const logActivity = require('../../utils/logActivity.util');
const UserNotification = require('../../models/notifications/user_notification.mdl');
const { NOTIFICATION_REGISTRY } = require('../../data/notifications.data');
const { resolveTierPlanUserIds } = require('../../utils/audienceResolver.util');
const { const {
excludeAttributes: plansExclude, excludeAttributes: plansExclude,
@@ -75,6 +78,7 @@ exports.getPlans = async (req, res) => {
jsonbSchemas: plansSchemas, jsonbSchemas: plansSchemas,
computedAttributes: plansComputed, computedAttributes: plansComputed,
context: archived ? 'archived' : 'list', context: archived ? 'archived' : 'list',
auditOptions: { mdl_Users, parentAlias: 'TierPlan' },
findOptions: archived ? { findOptions: archived ? {
paranoid: false, paranoid: false,
where: { deletedAt: { [Op.ne]: null } }, where: { deletedAt: { [Op.ne]: null } },
@@ -108,7 +112,7 @@ function computeDurationDays(value, unit) {
exports.createPlan = async (req, res) => { exports.createPlan = async (req, res) => {
try { try {
const { tier_category_id, label, description, features, duration_value, duration_unit = 'day', price, currency } = req.body; const { tier_category_id, label, description, features, duration_value, duration_unit = 'day', price, currency, createdBy } = req.body;
if (!tier_category_id || !label || !duration_value || !price) if (!tier_category_id || !label || !duration_value || !price)
return R.error(res, 'tier_category_id, label, duration_value, and price are required.', 400); return R.error(res, 'tier_category_id, label, duration_value, and price are required.', 400);
@@ -125,6 +129,7 @@ exports.createPlan = async (req, res) => {
tier_category_id: category.tier_category_id, tier_category_id: category.tier_category_id,
tier: category.slug, tier: category.slug,
label, description, features, duration_days, duration_unit, price, currency, label, description, features, duration_days, duration_unit, price, currency,
createdBy: createdBy ?? req.user?.user_id ?? null,
}); });
const plain = plan.get({ plain: true }); const plain = plan.get({ plain: true });
logActivity(req.user?.user_id, 'create_tier_plan', { entityType: 'tier_plan', details: { label: plain.label, tier: plain.tier } }); logActivity(req.user?.user_id, 'create_tier_plan', { entityType: 'tier_plan', details: { label: plain.label, tier: plain.tier } });
@@ -145,6 +150,7 @@ exports.updatePlan = async (req, res) => {
for (const k of allowed) { for (const k of allowed) {
if (req.body[k] !== undefined) updates[k] = req.body[k]; if (req.body[k] !== undefined) updates[k] = req.body[k];
} }
updates.updatedBy = req.body.updatedBy ?? req.user?.user_id ?? null;
// Recompute duration_days when value or unit changes // Recompute duration_days when value or unit changes
const { duration_value, duration_unit } = req.body; const { duration_value, duration_unit } = req.body;
@@ -197,8 +203,23 @@ exports.archivePlan = async (req, res) => {
if (!plan) return R.error(res, 'Plan not found.', 404); if (!plan) return R.error(res, 'Plan not found.', 404);
if (plan.deletedAt) return R.error(res, 'Plan is already archived.', 400); if (plan.deletedAt) return R.error(res, 'Plan is already archived.', 400);
await plan.update({ is_active: false }); await plan.update({ is_active: false, deletedBy: req.user?.user_id ?? null });
await plan.destroy(); await plan.destroy();
try {
const userIds = await resolveTierPlanUserIds(plan.plan_id);
if (userIds.length) {
const now = new Date();
const notify = NOTIFICATION_REGISTRY.tier_plan_archived.build({ label: plan.label, planId: plan.plan_id });
await UserNotification.bulkCreate(
userIds.map((user_id) => ({ user_id, ...notify, seen: false, createdAt: now, updatedAt: now })),
{ validate: false }
);
}
} catch (notifyErr) {
console.error('[ADMIN][ARCHIVE PLAN][NOTIFY]', notifyErr);
}
logActivity(req.user?.user_id, 'archive_tier_plan', { entityType: 'tier_plan', details: { label: plan.label } }); logActivity(req.user?.user_id, 'archive_tier_plan', { entityType: 'tier_plan', details: { label: plan.label } });
return R.success(res, 'Plan archived successfully.'); return R.success(res, 'Plan archived successfully.');
} catch (err) { } catch (err) {
@@ -222,9 +243,31 @@ exports.bulkArchivePlans = async (req, res) => {
const activeIds = activePlans.map((p) => p.plan_id); const activeIds = activePlans.map((p) => p.plan_id);
await mdl_TierPlans.update({ is_active: false }, { where: { plan_id: activeIds } }); await mdl_TierPlans.update({ is_active: false, deletedBy: req.user?.user_id ?? null }, { where: { plan_id: activeIds } });
await mdl_TierPlans.destroy({ where: { plan_id: activeIds } }); await mdl_TierPlans.destroy({ where: { plan_id: activeIds } });
try {
const holders = await mdl_UserTiers.findAll({
attributes: ['user_id', 'plan_id'],
where: { plan_id: activeIds, status: 'active' },
raw: true,
});
if (holders.length) {
const now = new Date();
const labelByPlanId = new Map(activePlans.map((p) => [String(p.plan_id), p.label]));
const notifications = holders.map(({ user_id, plan_id }) => ({
user_id,
...NOTIFICATION_REGISTRY.tier_plan_archived.build({ label: labelByPlanId.get(String(plan_id)), planId: plan_id }),
seen: false,
createdAt: now,
updatedAt: now,
}));
await UserNotification.bulkCreate(notifications, { validate: false });
}
} catch (notifyErr) {
console.error('[ADMIN][BULK ARCHIVE PLANS][NOTIFY]', notifyErr);
}
logActivity(req.user?.user_id, 'bulk_archive_tier_plans', { entityType: 'tier_plan', details: { ids: activeIds, count: activeIds.length } }); logActivity(req.user?.user_id, 'bulk_archive_tier_plans', { entityType: 'tier_plan', details: { ids: activeIds, count: activeIds.length } });
return R.success(res, `${activeIds.length} plan(s) archived successfully.`, { return R.success(res, `${activeIds.length} plan(s) archived successfully.`, {
archived_ids: activeIds, archived_ids: activeIds,
@@ -4,6 +4,7 @@ const mdl_Product = require('../../models/courses/products.mdl');
const paymentSvc = require('../../services/payment.service'); const paymentSvc = require('../../services/payment.service');
const R = require('../../utils/response.util'); const R = require('../../utils/response.util');
const { resolvePurchasable, checkoutPath } = require('../../utils/purchasable.util'); const { resolvePurchasable, checkoutPath } = require('../../utils/purchasable.util');
const { isPurchaseEligible } = require('./courses.controller');
// ─── CREATE ORDER ───────────────────────────────────────────────────────────── // ─── CREATE ORDER ─────────────────────────────────────────────────────────────
// Despite the "course" naming (historical — this predates Units/Lessons being // Despite the "course" naming (historical — this predates Units/Lessons being
@@ -28,6 +29,12 @@ exports.createCourseOrder = async (req, res) => {
const target = await resolvePurchasable(product.purchasable_type, product.purchasable_id); const target = await resolvePurchasable(product.purchasable_type, product.purchasable_id);
if (!target) return R.error(res, 'Purchasable content not found.', 404); if (!target) return R.error(res, 'Purchasable content not found.', 404);
const eligible = await isPurchaseEligible(req.user.user_id, target.subscription, product.purchasable_type, product.purchasable_id);
if (!eligible) {
return R.error(res, "Complete your plan's starter content before purchasing more.", 403);
}
const path = checkoutPath(product.purchasable_type, target); const path = checkoutPath(product.purchasable_type, target);
const ppOrder = await paymentSvc.createOrder('paypal', { const ppOrder = await paymentSvc.createOrder('paypal', {
+94 -21
View File
@@ -160,7 +160,7 @@ async function canAccessCourse(user_id, course_id) {
// rule engine. // rule engine.
const { allowed: rankAllowed } = evaluateCourseAccess( const { allowed: rankAllowed } = evaluateCourseAccess(
{ tier: userCtx.tier, access_rules: [], group_ids: userCtx.group_ids }, { 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; if (rankAllowed) return true;
@@ -169,7 +169,7 @@ async function canAccessCourse(user_id, course_id) {
for (const ruleset of userCtx.rulesets) { for (const ruleset of userCtx.rulesets) {
const { allowed } = evaluateCourseAccess( const { allowed } = evaluateCourseAccess(
{ tier: ruleset.tier, access_rules: ruleset.access_rules, group_ids: userCtx.group_ids }, { 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; if (allowed) return true;
} }
@@ -194,14 +194,23 @@ async function canAccessUnit(user_id, unit_id) {
const unit = await Unit.findOne({ where: { unit_id, ...notDeleted }, attributes: ['subscription'] }); const unit = await Unit.findOne({ where: { unit_id, ...notDeleted }, attributes: ['subscription'] });
if (unit?.subscription) { if (unit?.subscription) {
const userCtx = await buildUserContext(user_id); const userCtx = await buildUserContext(user_id);
// Unit-level subscription gating is a plain rank check against the user's // Plain rank check first, same baseline as canAccessCourse.
// best active tier, not a rule-based one. const { allowed: rankAllowed } = evaluateCourseAccess(
const { allowed } = evaluateCourseAccess(
{ tier: userCtx.tier, access_rules: [], group_ids: userCtx.group_ids }, { 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 (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 (allowed) return true;
} }
}
if (await hasActivePurchase(user_id, 'unit', unit_id)) return true; if (await hasActivePurchase(user_id, 'unit', unit_id)) return true;
@@ -227,12 +236,20 @@ async function canAccessLesson(user_id, lesson_id) {
const lesson = await Lesson.findOne({ where: { lesson_id, ...notDeleted }, attributes: ['subscription'] }); const lesson = await Lesson.findOne({ where: { lesson_id, ...notDeleted }, attributes: ['subscription'] });
if (lesson?.subscription) { if (lesson?.subscription) {
const userCtx = await buildUserContext(user_id); const userCtx = await buildUserContext(user_id);
const { allowed } = evaluateCourseAccess( const { allowed: rankAllowed } = evaluateCourseAccess(
{ tier: userCtx.tier, access_rules: [], group_ids: userCtx.group_ids }, { 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 (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 (allowed) return true;
} }
}
if (await hasActivePurchase(user_id, 'lesson', lesson_id)) 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; 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.canAccessCourse = canAccessCourse;
exports.canAccessUnit = canAccessUnit; exports.canAccessUnit = canAccessUnit;
exports.canAccessLesson = canAccessLesson; exports.canAccessLesson = canAccessLesson;
exports.isPurchaseEligible = isPurchaseEligible;
const COURSE_LIST_ATTRS = [ const COURSE_LIST_ATTRS = [
"course_id", "uuid", "title", "description", "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() } }], [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 // Certificate status for the course details card
const [pendingCert, certificate] = await Promise.all([ const [pendingCert, certificate] = await Promise.all([
@@ -559,6 +628,7 @@ exports.getCourse = async (req, res) => {
plan_tier, plan_tier,
product: product ?? null, product: product ?? null,
has_purchased: !!hasPurchase, has_purchased: !!hasPurchase,
purchase_eligible: purchaseEligible,
pending_certificate: pendingCert ?? null, pending_certificate: pendingCert ?? null,
certificate: certificate ?? null, certificate: certificate ?? null,
}); });
@@ -1379,12 +1449,12 @@ exports.getLessonsByUnitUuid = async (req, res) => {
if (!await canAccessUnit(req.user.user_id, unit.unit_id)) { if (!await canAccessUnit(req.user.user_id, unit.unit_id)) {
const first = unit.courses?.[0] ?? null; 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({ return res.status(403).json({
status: "error", status: "error",
message: "You do not have access to this unit.", message: "You do not have access to this unit.",
course: first ? { title: first.title, subscription: first.subscription } : null, 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)) { if (!await canAccessLesson(req.user.user_id, lesson.lesson_id)) {
const firstCourse = lesson.units?.[0]?.courses?.[0] ?? null; 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({ return res.status(403).json({
status: "error", status: "error",
message: "You do not have access to this lesson.", message: "You do not have access to this lesson.",
course: firstCourse ? { title: firstCourse.title, subscription: firstCourse.subscription } : null, 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() } }], [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) => { exports.getCourseCheckoutInfo = async (req, res) => {
@@ -1590,8 +1663,8 @@ exports.getCourseCheckoutInfo = async (req, res) => {
attributes: ["course_id", "uuid", "title", "description", "level", "subscription"], attributes: ["course_id", "uuid", "title", "description", "level", "subscription"],
}); });
if (!course) return R.error(res, "Course not found.", 404); if (!course) return R.error(res, "Course not found.", 404);
const { product, has_purchased } = await buildCheckoutInfo(req.user.user_id, "course", course); 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 }); return R.success(res, "Checkout info retrieved.", { ...course.toJSON(), product, has_purchased, purchase_eligible });
} catch (err) { } catch (err) {
console.error("[CLIENT][COURSES][CHECKOUT INFO]", err); console.error("[CLIENT][COURSES][CHECKOUT INFO]", err);
return R.error(res, "Could not retrieve checkout info.", 500); return R.error(res, "Could not retrieve checkout info.", 500);
@@ -1601,10 +1674,10 @@ exports.getCourseCheckoutInfo = async (req, res) => {
exports.getUnitCheckoutInfo = async (req, res) => { exports.getUnitCheckoutInfo = async (req, res) => {
try { try {
const { uuid } = req.params; 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); if (!unit) return R.error(res, "Unit not found.", 404);
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 R.success(res, "Checkout info retrieved.", { ...unit.toJSON(), product, has_purchased }); return R.success(res, "Checkout info retrieved.", { ...unit.toJSON(), product, has_purchased, purchase_eligible });
} catch (err) { } catch (err) {
console.error("[CLIENT][UNITS][CHECKOUT INFO]", err); console.error("[CLIENT][UNITS][CHECKOUT INFO]", err);
return R.error(res, "Could not retrieve checkout info.", 500); return R.error(res, "Could not retrieve checkout info.", 500);
@@ -1614,10 +1687,10 @@ exports.getUnitCheckoutInfo = async (req, res) => {
exports.getLessonCheckoutInfo = async (req, res) => { exports.getLessonCheckoutInfo = async (req, res) => {
try { try {
const { uuid } = req.params; 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); if (!lesson) return R.error(res, "Lesson not found.", 404);
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 R.success(res, "Checkout info retrieved.", { ...lesson.toJSON(), product, has_purchased }); return R.success(res, "Checkout info retrieved.", { ...lesson.toJSON(), product, has_purchased, purchase_eligible });
} catch (err) { } catch (err) {
console.error("[CLIENT][LESSONS][CHECKOUT INFO]", err); console.error("[CLIENT][LESSONS][CHECKOUT INFO]", err);
return R.error(res, "Could not retrieve checkout info.", 500); return R.error(res, "Could not retrieve checkout info.", 500);
+16 -4
View File
@@ -146,12 +146,18 @@ exports.getUnits = async (req, res) => {
const is_locked = (row.subscription || Number(row.course_count) > 0) const is_locked = (row.subscription || Number(row.course_count) > 0)
? !(await coursesCtrl.canAccessUnit(req.user.user_id, row.unit_id)) ? !(await coursesCtrl.canAccessUnit(req.user.user_id, row.unit_id))
: false; : false;
const product = productById.get(String(row.unit_id)) ?? null;
const has_purchased = purchasedIds.has(String(row.unit_id));
const purchase_eligible = (is_locked && product && !has_purchased)
? await coursesCtrl.isPurchaseEligible(req.user.user_id, row.subscription, "unit", row.unit_id)
: true;
result.push({ result.push({
...row, ...row,
courses: coursesByUnit.get(row.unit_id) ?? [], courses: coursesByUnit.get(row.unit_id) ?? [],
is_locked, is_locked,
product: productById.get(String(row.unit_id)) ?? null, product,
has_purchased: purchasedIds.has(String(row.unit_id)), has_purchased,
purchase_eligible,
}); });
} }
@@ -208,12 +214,18 @@ exports.getLessons = async (req, res) => {
const is_locked = (row.subscription || 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)) ? !(await coursesCtrl.canAccessLesson(req.user.user_id, row.lesson_id))
: false; : false;
const product = productById.get(String(row.lesson_id)) ?? null;
const has_purchased = purchasedIds.has(String(row.lesson_id));
const purchase_eligible = (is_locked && product && !has_purchased)
? await coursesCtrl.isPurchaseEligible(req.user.user_id, row.subscription, "lesson", row.lesson_id)
: true;
result.push({ result.push({
...row, ...row,
courses: coursesByLesson.get(row.lesson_id) ?? [], courses: coursesByLesson.get(row.lesson_id) ?? [],
is_locked, is_locked,
product: productById.get(String(row.lesson_id)) ?? null, product,
has_purchased: purchasedIds.has(String(row.lesson_id)), has_purchased,
purchase_eligible,
}); });
} }
+15
View File
@@ -23,6 +23,7 @@
* User : task_requirements_updated, task_submissions_closed, task_submissions_reopened, user_task_overdue, user_task_auto_completed, task_reminder, achievement, * User : task_requirements_updated, task_submissions_closed, task_submissions_reopened, user_task_overdue, user_task_auto_completed, task_reminder, achievement,
* course_unlocked, course_completed, certificate_issued, welcome, * course_unlocked, course_completed, certificate_issued, welcome,
* nogrp_welcome, assessment_updated, announcement, tier_expired, * nogrp_welcome, assessment_updated, announcement, tier_expired,
* tier_plan_archived,
* task_submission_reviewed, task_assigned, task_completed * task_submission_reviewed, task_assigned, task_completed
* Both : broadcast (admin-composed, sent via notification_broadcasts CRUD) * Both : broadcast (admin-composed, sent via notification_broadcasts CRUD)
* *
@@ -378,6 +379,20 @@ const NOTIFICATION_REGISTRY = {
}, },
}, },
tier_plan_archived: {
type: 'tier_plan_archived',
scope: 'user',
trigger: 'event',
build({ label, planId = null }) {
return {
type: 'tier_plan_archived',
title: 'Plan Archived',
message: `Your "${label}" plan has been archived and is no longer available for new subscriptions. Your current access is unaffected until it expires.`,
data: { label, planId },
};
},
},
}; };
module.exports = { NOTIFICATION_REGISTRY }; module.exports = { NOTIFICATION_REGISTRY };
@@ -0,0 +1,15 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.addColumn('tier_plans', 'createdBy', { type: Sequelize.BIGINT, allowNull: true });
await queryInterface.addColumn('tier_plans', 'updatedBy', { type: Sequelize.BIGINT, allowNull: true });
await queryInterface.addColumn('tier_plans', 'deletedBy', { type: Sequelize.BIGINT, allowNull: true });
},
async down(queryInterface) {
await queryInterface.removeColumn('tier_plans', 'createdBy');
await queryInterface.removeColumn('tier_plans', 'updatedBy');
await queryInterface.removeColumn('tier_plans', 'deletedBy');
},
};
@@ -0,0 +1,15 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.addColumn('tier_categories', 'is_special', {
type: Sequelize.BOOLEAN,
allowNull: false,
defaultValue: false,
});
},
async down(queryInterface) {
await queryInterface.removeColumn('tier_categories', 'is_special');
},
};
+1 -1
View File
@@ -13,7 +13,7 @@ const Asset = sequelize.define("Asset", {
original_name: { type: DataTypes.STRING(255), allowNull: false, label: "Original Name", order: 0, hidden: true }, original_name: { type: DataTypes.STRING(255), allowNull: false, label: "Original Name", order: 0, hidden: true },
display_name: { type: DataTypes.STRING(255), allowNull: false, label: "Name", order: 0, filterable: true }, display_name: { type: DataTypes.STRING(255), allowNull: false, label: "Name", order: 0, filterable: true },
file_url: { type: DataTypes.STRING(512), allowNull: false, label: "File URL", order: 0, hidden: true }, file_url: { type: DataTypes.STRING(512), allowNull: false, label: "File URL", order: 0, hidden: true },
file_size: { type: DataTypes.BIGINT, allowNull: false, label: "File Size", order: 0, hidden: true }, file_size: { type: DataTypes.BIGINT, allowNull: false, label: "File Size", order: 0 },
mime_type: { type: DataTypes.STRING(100), allowNull: false, label: "MIME Type", order: 0, hidden: true }, mime_type: { type: DataTypes.STRING(100), allowNull: false, label: "MIME Type", order: 0, hidden: true },
extension: { type: DataTypes.STRING(20), label: "File Type", order: 0, filterable: true }, extension: { type: DataTypes.STRING(20), label: "File Type", order: 0, filterable: true },
checksum: { type: DataTypes.STRING(64), label: "Checksum", order: 0, hidden: true }, checksum: { type: DataTypes.STRING(64), label: "Checksum", order: 0, hidden: true },
+1
View File
@@ -13,6 +13,7 @@ const mdl_TierCategories = sequelize.define('TierCategory', {
color: { type: DataTypes.STRING(30), allowNull: false, defaultValue: 'purple', label: 'Color' }, color: { type: DataTypes.STRING(30), allowNull: false, defaultValue: 'purple', label: 'Color' },
is_default: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: false, label: 'Default' }, is_default: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: false, label: 'Default' },
is_active: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: true, label: 'Active' }, is_active: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: true, label: 'Active' },
is_special: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: false, label: 'Special' },
}, { }, {
tableName: 'tier_categories', tableName: 'tier_categories',
timestamps: true, timestamps: true,
+54 -1
View File
@@ -13,6 +13,59 @@ const excludeAttributes = [
const jsonbSchemas = {}; // no JSONB columns on this model const jsonbSchemas = {}; // no JSONB columns on this model
const computedAttributes = []; // no computed fields needed const computedAttributes = [
{
key: "courseCount", label: "Courses", type: "number", order: 100, filterable: false,
literal: `(
SELECT CAST(COUNT(*) AS INTEGER)
FROM "plan_courses"
INNER JOIN "courses" ON "courses"."course_id" = "plan_courses"."course_id"
WHERE "plan_courses"."plan_id" = "TierPlan"."plan_id"
AND "courses"."deletedAt" IS NULL
)`,
},
{
key: "unitCount", label: "Units", type: "number", order: 101, filterable: false,
literal: `(
SELECT CAST(COUNT(*) AS INTEGER)
FROM "plan_units"
INNER JOIN "units" ON "units"."unit_id" = "plan_units"."unit_id"
WHERE "plan_units"."plan_id" = "TierPlan"."plan_id"
AND "units"."deletedAt" IS NULL
)`,
},
{
key: "lessonCount", label: "Lessons", type: "number", order: 102, filterable: false,
literal: `(
SELECT CAST(COUNT(*) AS INTEGER)
FROM "plan_lessons"
INNER JOIN "lessons" ON "lessons"."lesson_id" = "plan_lessons"."lesson_id"
WHERE "plan_lessons"."plan_id" = "TierPlan"."plan_id"
AND "lessons"."deletedAt" IS NULL
)`,
},
{
key: "bundleCount", label: "Bundles", type: "number", order: 103, filterable: false,
literal: `(
(SELECT CAST(COUNT(*) AS INTEGER)
FROM "plan_courses"
INNER JOIN "courses" ON "courses"."course_id" = "plan_courses"."course_id"
WHERE "plan_courses"."plan_id" = "TierPlan"."plan_id"
AND "courses"."deletedAt" IS NULL)
+
(SELECT CAST(COUNT(*) AS INTEGER)
FROM "plan_units"
INNER JOIN "units" ON "units"."unit_id" = "plan_units"."unit_id"
WHERE "plan_units"."plan_id" = "TierPlan"."plan_id"
AND "units"."deletedAt" IS NULL)
+
(SELECT CAST(COUNT(*) AS INTEGER)
FROM "plan_lessons"
INNER JOIN "lessons" ON "lessons"."lesson_id" = "plan_lessons"."lesson_id"
WHERE "plan_lessons"."plan_id" = "TierPlan"."plan_id"
AND "lessons"."deletedAt" IS NULL)
)`,
},
];
module.exports = { excludeAttributes, jsonbSchemas, computedAttributes }; module.exports = { excludeAttributes, jsonbSchemas, computedAttributes };
+3
View File
@@ -21,6 +21,9 @@ const mdl_TierPlans = sequelize.define('TierPlan', {
price: { type: DataTypes.DECIMAL(10, 2), allowNull: false, label: 'Price' }, price: { type: DataTypes.DECIMAL(10, 2), allowNull: false, label: 'Price' },
currency: { type: DataTypes.CHAR(3), allowNull: false, defaultValue: 'USD', label: 'Currency' }, currency: { type: DataTypes.CHAR(3), allowNull: false, defaultValue: 'USD', label: 'Currency' },
is_active: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: true, label: 'Active' }, is_active: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: true, label: 'Active' },
createdBy: { type: DataTypes.BIGINT, allowNull: true },
updatedBy: { type: DataTypes.BIGINT, allowNull: true },
deletedBy: { type: DataTypes.BIGINT, allowNull: true },
}, { }, {
tableName: 'tier_plans', tableName: 'tier_plans',
timestamps: true, timestamps: true,
+49
View File
@@ -122,6 +122,55 @@ describe('rule: group_restriction', () => {
}); });
}); });
// ── Rule: item_allowlist ───────────────────────────────────────────────────────
describe('rule: item_allowlist', () => {
const rules = [
{ type: 'course_subscription_access', levels: ['premium'] },
{ type: 'item_allowlist', item_type: 'course', item_ids: [42, 99] },
];
test('allowlisted exclusive course is granted even though the level-lock rule would block it', () => {
const result = evaluateCourseAccess(
ctx('premium', rules), course('exclusive'), TIER_RANK, { type: 'course', id: 42 }
);
expect(result).toEqual({ allowed: true, reason: null });
});
test('matches item_ids as strings or numbers interchangeably', () => {
const result = evaluateCourseAccess(
ctx('premium', rules), course('exclusive'), TIER_RANK, { type: 'course', id: '99' }
);
expect(result.allowed).toBe(true);
});
test('non-allowlisted exclusive course still falls through to the level-lock rule and is denied', () => {
const result = evaluateCourseAccess(
ctx('premium', rules), course('exclusive'), TIER_RANK, { type: 'course', id: 7 }
);
expect(result).toEqual({ allowed: false, reason: 'subscription_access' });
});
test('item_type must match — a unit id matching a course-scoped allowlist is not granted', () => {
const result = evaluateCourseAccess(
ctx('premium', rules), course('exclusive'), TIER_RANK, { type: 'unit', id: 42 }
);
expect(result).toEqual({ allowed: false, reason: 'subscription_access' });
});
test('no itemMeta (or null id) — item_allowlist is skipped entirely, existing callers unaffected', () => {
const result = evaluateCourseAccess(ctx('premium', rules), course('exclusive'), TIER_RANK);
expect(result).toEqual({ allowed: false, reason: 'subscription_access' });
});
test('allowlisted item still requires nothing when premium content is requested normally', () => {
const result = evaluateCourseAccess(
ctx('premium', rules), course('premium'), TIER_RANK, { type: 'course', id: 1 }
);
expect(result.allowed).toBe(true);
});
});
// ── Multiple rules evaluated together ──────────────────────────────────────── // ── Multiple rules evaluated together ────────────────────────────────────────
describe('multiple rules', () => { describe('multiple rules', () => {
+33 -3
View File
@@ -10,6 +10,16 @@
* → The user's active tier must be at least this rank (exclusive satisfies premium). * → The user's active tier must be at least this rank (exclusive satisfies premium).
* group_restriction — { type, group_ids: [number, ...] } * group_restriction — { type, group_ids: [number, ...] }
* → The user must belong to at least one of these groups. * → The user must belong to at least one of these groups.
* item_allowlist — { type, item_type: 'course'|'unit'|'lesson', item_ids: [id, ...] }
* → Grants access to these EXACT items regardless of level/tier/group — a
* curated "preview" override. Checked BEFORE the other rule types below;
* a match short-circuits straight to allowed, since it's meant to win
* even when a level-lock rule on the same plan would otherwise block it
* (e.g. Premium plan mostly locked to 'premium', but 2 specific
* Exclusive courses hand-picked as a preview).
*
* The 3 non-preview rule types combine as AND (any one can deny). item_allowlist
* is the one exception — it's an OR-style grant, not another AND constraint.
* *
* Fallback (no access_rules): uses simple tier rank comparison. * Fallback (no access_rules): uses simple tier rank comparison.
***********************************************************************************************************************************************************************/ ***********************************************************************************************************************************************************************/
@@ -20,19 +30,25 @@
const TIER_RANK = { free: 0, premium: 1, exclusive: 2 }; const TIER_RANK = { free: 0, premium: 1, exclusive: 2 };
/** /**
* Evaluates whether a user can access a course. * Evaluates whether a user can access a course (or, via itemMeta, a
* standalone unit/lesson — see courses.controller.js's canAccessUnit/
* canAccessLesson, which call this the same way canAccessCourse does).
* *
* @param {object} ctx * @param {object} ctx
* @param {string} ctx.tier — user's active tier slug * @param {string} ctx.tier — user's active tier slug
* @param {Array} ctx.access_rules — plan_policies.access_rules (may be empty) * @param {Array} ctx.access_rules — plan_policies.access_rules (may be empty)
* @param {number[]} ctx.group_ids — group IDs the user belongs to * @param {number[]} ctx.group_ids — group IDs the user belongs to
* @param {object} course * @param {object} course
* @param {string} course.subscription — course subscription level (slug) * @param {string} course.subscription — course/unit/lesson subscription level (slug)
* @param {Object} tierRankMap — { [slug]: rank } loaded from tier_categories; falls back to TIER_RANK * @param {Object} tierRankMap — { [slug]: rank } loaded from tier_categories; falls back to TIER_RANK
* @param {Object} [itemMeta] — { type: 'course'|'unit'|'lesson', id } — identifies the
* specific item being checked, so item_allowlist rules can match it. Omit
* (or leave id null) to skip item_allowlist matching entirely.
* @returns {{ allowed: boolean, reason: string|null }} * @returns {{ allowed: boolean, reason: string|null }}
*/ */
function evaluateCourseAccess(ctx, course, tierRankMap = TIER_RANK) { function evaluateCourseAccess(ctx, course, tierRankMap = TIER_RANK, itemMeta = {}) {
const { tier = 'free', access_rules = [], group_ids = [] } = ctx; const { tier = 'free', access_rules = [], group_ids = [] } = ctx;
const { type: itemType = null, id: itemId = null } = itemMeta;
const courseSubscription = course.subscription ?? 'free'; const courseSubscription = course.subscription ?? 'free';
const userRank = tierRankMap[tier] ?? 0; const userRank = tierRankMap[tier] ?? 0;
// Unknown required slug → Infinity so access is always denied (safe default) // Unknown required slug → Infinity so access is always denied (safe default)
@@ -41,6 +57,19 @@ function evaluateCourseAccess(ctx, course, tierRankMap = TIER_RANK) {
// Rank-0 courses (default/free tier) are always accessible // Rank-0 courses (default/free tier) are always accessible
if (courseRank === 0) return { allowed: true, reason: null }; if (courseRank === 0) return { allowed: true, reason: null };
// item_allowlist short-circuit — a curated preview item wins outright,
// bypassing level/tier/group checks below. Checked against every rule on
// the plan, not just when access_rules is otherwise empty.
if (itemId != null && access_rules && access_rules.length) {
for (const rule of access_rules) {
if (rule.type === 'item_allowlist' && rule.item_type === itemType) {
if ((rule.item_ids ?? []).map(String).includes(String(itemId))) {
return { allowed: true, reason: null };
}
}
}
}
// No plan policy — fallback: compare user rank vs course subscription rank // No plan policy — fallback: compare user rank vs course subscription rank
if (!access_rules || access_rules.length === 0) { if (!access_rules || access_rules.length === 0) {
return userRank >= courseRank return userRank >= courseRank
@@ -49,6 +78,7 @@ function evaluateCourseAccess(ctx, course, tierRankMap = TIER_RANK) {
} }
for (const rule of access_rules) { for (const rule of access_rules) {
if (rule.type === 'item_allowlist') continue; // handled above
if (rule.type === 'course_subscription_access') { if (rule.type === 'course_subscription_access') {
if (!(rule.levels ?? []).includes(courseSubscription)) { if (!(rule.levels ?? []).includes(courseSubscription)) {
return { allowed: false, reason: 'subscription_access' }; return { allowed: false, reason: 'subscription_access' };