diff --git a/controllers/admin/tier_categories.controller.js b/controllers/admin/tier_categories.controller.js index 283c7f9..f5bb0eb 100644 --- a/controllers/admin/tier_categories.controller.js +++ b/controllers/admin/tier_categories.controller.js @@ -44,7 +44,7 @@ exports.getCategory = async (req, res) => { exports.createCategory = async (req, res) => { 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); const parsedRank = Number(rank ?? 1); @@ -63,6 +63,7 @@ exports.createCategory = async (req, res) => { badge_label: badge_label ?? null, is_default: false, is_active: true, + is_special: !!is_special, }); 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); 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) { const parsedRank = Number(rank); @@ -98,7 +99,8 @@ exports.updateCategory = async (req, res) => { badge_icon: badge_icon !== undefined ? (badge_icon || null) : cat.badge_icon, badge_label: badge_label !== undefined ? (badge_label || null) : cat.badge_label, // 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 } }); diff --git a/controllers/admin/tier_policies.controller.js b/controllers/admin/tier_policies.controller.js index 2b33f60..342877b 100644 --- a/controllers/admin/tier_policies.controller.js +++ b/controllers/admin/tier_policies.controller.js @@ -15,8 +15,11 @@ const VALID_RULE_TYPES = new Set([ 'course_subscription_access', 'required_active_tier', 'group_restriction', + 'item_allowlist', ]); +const VALID_ITEM_TYPES = new Set(['course', 'unit', 'lesson']); + async function validateRules(rules) { 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)) 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; } diff --git a/controllers/admin/tiers.controller.js b/controllers/admin/tiers.controller.js index a28a060..ab23f50 100644 --- a/controllers/admin/tiers.controller.js +++ b/controllers/admin/tiers.controller.js @@ -28,6 +28,9 @@ const R = require('../../utils/response.util'); const { paginate } = require('../../utils/paginate.util'); const { getFieldValues } = require('../../utils/fieldValues.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 { excludeAttributes: plansExclude, @@ -75,6 +78,7 @@ exports.getPlans = async (req, res) => { jsonbSchemas: plansSchemas, computedAttributes: plansComputed, context: archived ? 'archived' : 'list', + auditOptions: { mdl_Users, parentAlias: 'TierPlan' }, findOptions: archived ? { paranoid: false, where: { deletedAt: { [Op.ne]: null } }, @@ -108,7 +112,7 @@ function computeDurationDays(value, unit) { exports.createPlan = async (req, res) => { 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) 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.slug, label, description, features, duration_days, duration_unit, price, currency, + createdBy: createdBy ?? req.user?.user_id ?? null, }); const plain = plan.get({ plain: true }); 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) { 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 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.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(); + + 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 } }); return R.success(res, 'Plan archived successfully.'); } catch (err) { @@ -222,9 +243,31 @@ exports.bulkArchivePlans = async (req, res) => { 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 } }); + 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 } }); return R.success(res, `${activeIds.length} plan(s) archived successfully.`, { archived_ids: activeIds, diff --git a/controllers/client/course_purchases.controller.js b/controllers/client/course_purchases.controller.js index 964885e..fd52fa9 100644 --- a/controllers/client/course_purchases.controller.js +++ b/controllers/client/course_purchases.controller.js @@ -4,6 +4,7 @@ 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'); +const { isPurchaseEligible } = require('./courses.controller'); // ─── CREATE ORDER ───────────────────────────────────────────────────────────── // 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); 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 ppOrder = await paymentSvc.createOrder('paypal', { diff --git a/controllers/client/courses.controller.js b/controllers/client/courses.controller.js index c48274f..99ee851 100644 --- a/controllers/client/courses.controller.js +++ b/controllers/client/courses.controller.js @@ -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); diff --git a/controllers/client/units.controller.js b/controllers/client/units.controller.js index 0126404..ed1883e 100644 --- a/controllers/client/units.controller.js +++ b/controllers/client/units.controller.js @@ -146,12 +146,18 @@ 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; + 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({ ...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)), + product, + has_purchased, + purchase_eligible, }); } @@ -208,12 +214,18 @@ exports.getLessons = async (req, res) => { const is_locked = (row.subscription || Number(row.unit_count) > 0) ? !(await coursesCtrl.canAccessLesson(req.user.user_id, row.lesson_id)) : 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({ ...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)), + product, + has_purchased, + purchase_eligible, }); } diff --git a/data/notifications.data.js b/data/notifications.data.js index 9bf4a43..3604f30 100644 --- a/data/notifications.data.js +++ b/data/notifications.data.js @@ -23,6 +23,7 @@ * 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, * nogrp_welcome, assessment_updated, announcement, tier_expired, + * tier_plan_archived, * task_submission_reviewed, task_assigned, task_completed * 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 }; diff --git a/database/migrations/20270101000081-add-tier-plans-audit-columns.js b/database/migrations/20270101000081-add-tier-plans-audit-columns.js new file mode 100644 index 0000000..a4da4c8 --- /dev/null +++ b/database/migrations/20270101000081-add-tier-plans-audit-columns.js @@ -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'); + }, +}; diff --git a/database/migrations/20270101000082-add-tier-categories-special.js b/database/migrations/20270101000082-add-tier-categories-special.js new file mode 100644 index 0000000..8230b68 --- /dev/null +++ b/database/migrations/20270101000082-add-tier-categories-special.js @@ -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'); + }, +}; diff --git a/models/assets/assets.mdl.js b/models/assets/assets.mdl.js index c28cf17..609ccc1 100644 --- a/models/assets/assets.mdl.js +++ b/models/assets/assets.mdl.js @@ -13,7 +13,7 @@ const Asset = sequelize.define("Asset", { 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 }, 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 }, extension: { type: DataTypes.STRING(20), label: "File Type", order: 0, filterable: true }, checksum: { type: DataTypes.STRING(64), label: "Checksum", order: 0, hidden: true }, diff --git a/models/tiers/tier_categories.mdl.js b/models/tiers/tier_categories.mdl.js index e0d176c..7bb70ad 100644 --- a/models/tiers/tier_categories.mdl.js +++ b/models/tiers/tier_categories.mdl.js @@ -13,6 +13,7 @@ const mdl_TierCategories = sequelize.define('TierCategory', { color: { type: DataTypes.STRING(30), allowNull: false, defaultValue: 'purple', label: 'Color' }, is_default: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: false, label: 'Default' }, 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', timestamps: true, diff --git a/models/tiers/tier_plans.attributes.js b/models/tiers/tier_plans.attributes.js index 05c0a0e..8a0dc33 100644 --- a/models/tiers/tier_plans.attributes.js +++ b/models/tiers/tier_plans.attributes.js @@ -13,6 +13,59 @@ const excludeAttributes = [ 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 }; \ No newline at end of file diff --git a/models/tiers/tier_plans.mdl.js b/models/tiers/tier_plans.mdl.js index d1b56d6..4af557f 100644 --- a/models/tiers/tier_plans.mdl.js +++ b/models/tiers/tier_plans.mdl.js @@ -21,6 +21,9 @@ const mdl_TierPlans = sequelize.define('TierPlan', { price: { type: DataTypes.DECIMAL(10, 2), allowNull: false, label: 'Price' }, currency: { type: DataTypes.CHAR(3), allowNull: false, defaultValue: 'USD', label: 'Currency' }, 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', timestamps: true, diff --git a/tests/utils/accessPolicy.test.js b/tests/utils/accessPolicy.test.js index 83f2430..67904b3 100644 --- a/tests/utils/accessPolicy.test.js +++ b/tests/utils/accessPolicy.test.js @@ -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 ──────────────────────────────────────── describe('multiple rules', () => { diff --git a/utils/accessPolicy.util.js b/utils/accessPolicy.util.js index e307eae..d15dac5 100644 --- a/utils/accessPolicy.util.js +++ b/utils/accessPolicy.util.js @@ -10,6 +10,16 @@ * → The user's active tier must be at least this rank (exclusive satisfies premium). * group_restriction — { type, group_ids: [number, ...] } * → 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. ***********************************************************************************************************************************************************************/ @@ -20,19 +30,25 @@ 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 {string} ctx.tier — user's active tier slug * @param {Array} ctx.access_rules — plan_policies.access_rules (may be empty) * @param {number[]} ctx.group_ids — group IDs the user belongs to * @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} [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 }} */ -function evaluateCourseAccess(ctx, course, tierRankMap = TIER_RANK) { +function evaluateCourseAccess(ctx, course, tierRankMap = TIER_RANK, itemMeta = {}) { const { tier = 'free', access_rules = [], group_ids = [] } = ctx; + const { type: itemType = null, id: itemId = null } = itemMeta; const courseSubscription = course.subscription ?? 'free'; const userRank = tierRankMap[tier] ?? 0; // 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 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 if (!access_rules || access_rules.length === 0) { return userRank >= courseRank @@ -49,6 +78,7 @@ function evaluateCourseAccess(ctx, course, tierRankMap = TIER_RANK) { } for (const rule of access_rules) { + if (rule.type === 'item_allowlist') continue; // handled above if (rule.type === 'course_subscription_access') { if (!(rule.levels ?? []).includes(courseSubscription)) { return { allowed: false, reason: 'subscription_access' };