diff --git a/controllers/admin/advertisements.controller.js b/controllers/admin/advertisements.controller.js index 6829ea1..4f270e8 100644 --- a/controllers/admin/advertisements.controller.js +++ b/controllers/admin/advertisements.controller.js @@ -102,14 +102,12 @@ async function applyAdvertisementFields(advertisement, body) { // status is intentionally NOT settable here — it's derived via deriveStatus() // right before save, based on is_active + start_date/end_date. - if (body.content_mode !== undefined) { - if (!["image", "content"].includes(body.content_mode)) { - const err = new Error(`Invalid content_mode. Must be one of: image, content`); - err.status = 400; - throw err; - } - advertisement.content_mode = body.content_mode; - } + // content_mode is no longer an admin choice (the Image Only / Text with + // Image toggle was removed — every ad now carries the same mandatory + // badge/headline/description/image/link shape) — like type/format, it's + // fixed server-side rather than trusted from the request body. Legacy + // "image" mode rows keep that value until next edited. + advertisement.content_mode = "content"; if (body.badge_labels !== undefined) advertisement.badge_labels = normalizeBadgeLabels(body.badge_labels); if (body.headline !== undefined) advertisement.headline = body.headline; @@ -149,6 +147,38 @@ async function applyAdvertisementFields(advertisement, body) { advertisement.size = body.size || null; } + // Every ad now carries the same mandatory shape — badge label(s), headline, + // description, image, and a single link — enforced here as defense-in-depth + // alongside the frontend's Zod schema. The Admin Add/Edit Advertisement + // forms always submit the full shape, so this only ever fires on malformed + // requests — it does not retroactively touch existing incomplete rows, + // it just blocks saving one until it's brought up to the new shape. + if (!advertisement.badge_labels?.length) { + const err = new Error("At least one badge label is required."); + err.status = 400; + throw err; + } + if (!advertisement.headline?.trim()) { + const err = new Error("Headline is required."); + err.status = 400; + throw err; + } + if (!advertisement.description?.trim()) { + const err = new Error("Description is required."); + err.status = 400; + throw err; + } + if (!advertisement.image_asset_id) { + const err = new Error("Image is required."); + err.status = 400; + throw err; + } + if (!advertisement.redirect_link?.trim()) { + const err = new Error("Link is required."); + err.status = 400; + throw err; + } + // Recompute status now that is_active/start_date/end_date are all up to date advertisement.status = deriveStatus(advertisement); } diff --git a/controllers/admin/courses.controller.js b/controllers/admin/courses.controller.js index c469751..ef431f7 100644 --- a/controllers/admin/courses.controller.js +++ b/controllers/admin/courses.controller.js @@ -36,8 +36,6 @@ const { const mdl_Users = require("../../models/users/users.mdl"); -const { mdl_PlanCourses, mdl_TierPlans } = require("../../models/tiers/tier.associations"); - const { excludeAttributes: courseExclude, computedAttributes: courseComputed, @@ -2403,28 +2401,12 @@ exports.getCoursesBySubscription = async (req, res) => { const rows = await Course.findAll({ where: { ...notDeleted, subscription: slug }, attributes: ['course_id', 'title', 'description', 'subscription'], - include: [{ - model: mdl_PlanCourses, - as: 'planCourse', - required: false, - attributes: ['plan_id'], - include: [{ - model: mdl_TierPlans, - as: 'plan', - attributes: ['plan_id', 'label'], - }], - }], order: [['title', 'ASC']], }); - // Flatten so the frontend can just check `assigned_plan` — a course belongs - // to at most one plan (UNIQUE constraint on plan_courses.course_id). - const data = rows.map((c) => { - const plain = c.toJSON(); - const assigned_plan = plain.planCourse?.plan ?? null; - delete plain.planCourse; - return { ...plain, assigned_plan }; - }); + // A course may belong to any number of other plans (Tier Plans v2, silent + // duplication across bundles is intentional) — no conflict to report here. + const data = rows.map((c) => c.toJSON()); return R.success(res, 'Courses retrieved.', data); } catch (err) { diff --git a/controllers/admin/lessons.controller.js b/controllers/admin/lessons.controller.js index ad4dc41..94779bc 100644 --- a/controllers/admin/lessons.controller.js +++ b/controllers/admin/lessons.controller.js @@ -37,7 +37,6 @@ const { } = require("../../models/courses/courses.associations"); const mdl_Users = require("../../models/users/users.mdl"); -const { mdl_PlanLessons, mdl_TierPlans } = require("../../models/tiers/tier.associations"); const notDeleted = { deletedAt: null }; const onlyDeleted = { deletedAt: { [Op.not]: null } }; @@ -189,28 +188,12 @@ exports.getLessonsBySubscription = async (req, res) => { const rows = await Lesson.findAll({ where: { ...notDeleted, subscription: slug }, attributes: ['lesson_id', 'title', 'description', 'subscription'], - include: [{ - model: mdl_PlanLessons, - as: 'planLesson', - required: false, - attributes: ['plan_id'], - include: [{ - model: mdl_TierPlans, - as: 'plan', - attributes: ['plan_id', 'label'], - }], - }], order: [['title', 'ASC']], }); - // Flatten so the frontend can just check `assigned_plan` — a lesson - // belongs to at most one plan (UNIQUE constraint on plan_lessons.lesson_id). - const data = rows.map((l) => { - const plain = l.toJSON(); - const assigned_plan = plain.planLesson?.plan ?? null; - delete plain.planLesson; - return { ...plain, assigned_plan }; - }); + // A lesson may belong to any number of other plans (Tier Plans v2, silent + // duplication across bundles is intentional) — no conflict to report here. + const data = rows.map((l) => l.toJSON()); return R.success(res, 'Lessons retrieved.', data); } catch (err) { diff --git a/controllers/admin/tier_policies.controller.js b/controllers/admin/tier_policies.controller.js index 342877b..685f64e 100644 --- a/controllers/admin/tier_policies.controller.js +++ b/controllers/admin/tier_policies.controller.js @@ -1,8 +1,6 @@ 'use strict'; -const mdl_TierCategories = require('../../models/tiers/tier_categories.mdl'); const mdl_TierPlans = require('../../models/tiers/tier_plans.mdl'); -const mdl_PlanPolicies = require('../../models/tiers/plan_policies.mdl'); const mdl_PaymentPolicies = require('../../models/tiers/payment_policies.mdl'); const mdl_SystemBadges = require('../../models/system_badges/system_badges.mdl'); const Asset = require('../../models/assets/assets.mdl'); @@ -11,112 +9,8 @@ const logActivity = require('../../utils/logActivity.util'); require('../../models/tiers/tier.associations'); -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.'; - - // Load valid tier slugs dynamically from DB - const categories = await mdl_TierCategories.findAll({ attributes: ['slug'] }); - const validSlugs = new Set(categories.map((c) => c.slug)); - - for (const rule of rules) { - if (!VALID_RULE_TYPES.has(rule.type)) return `Unknown rule type: ${rule.type}`; - - if (rule.type === 'course_subscription_access') { - if (!Array.isArray(rule.levels) || !rule.levels.length) - return 'course_subscription_access.levels must be a non-empty array of tier slugs.'; - } - - if (rule.type === 'required_active_tier') { - if (!rule.tier || !validSlugs.has(rule.tier)) - return `required_active_tier.tier must be a valid tier category slug.`; - } - - if (rule.type === 'group_restriction') { - 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; -} - const ASSET_ATTRS = ['asset_id', 'file_url', 'display_name', 'mime_type']; -// ─── PLAN POLICIES ──────────────────────────────────────────────────────────── - -exports.getPlanPolicy = async (req, res) => { - try { - const plan = await mdl_TierPlans.findByPk(req.params.planId); - if (!plan) return R.error(res, 'Plan not found.', 404); - - const policy = await mdl_PlanPolicies.findOne({ where: { plan_id: req.params.planId } }); - return R.success(res, 'Policy retrieved.', policy ?? null); - } catch (err) { - console.error('[ADMIN][GET PLAN POLICY]', err); - return R.error(res, 'Could not retrieve policy.', 500); - } -}; - -exports.upsertPlanPolicy = async (req, res) => { - try { - const { planId } = req.params; - - const plan = await mdl_TierPlans.findByPk(planId); - if (!plan) return R.error(res, 'Plan not found.', 404); - - const { access_rules } = req.body; - - let parsedRules = []; - if (access_rules !== undefined) { - try { - parsedRules = typeof access_rules === 'string' ? JSON.parse(access_rules) : access_rules; - } catch { - return R.error(res, 'access_rules is not valid JSON.', 400); - } - const err = await validateRules(parsedRules); - if (err) return R.error(res, err, 400); - } - - let existing = await mdl_PlanPolicies.findOne({ where: { plan_id: planId } }); - - const payload = { - plan_id: planId, - access_rules: access_rules !== undefined ? parsedRules : (existing?.access_rules ?? []), - }; - - if (!existing) { - existing = await mdl_PlanPolicies.create(payload); - logActivity(req.user?.user_id, 'create_plan_policy', { entityType: 'plan_policy', details: { plan_id: planId } }); - } else { - await existing.update(payload); - logActivity(req.user?.user_id, 'update_plan_policy', { entityType: 'plan_policy', details: { plan_id: planId } }); - } - - const result = await mdl_PlanPolicies.findOne({ where: { plan_id: planId } }); - return R.success(res, 'Policy saved.', result); - } catch (err) { - console.error('[ADMIN][UPSERT PLAN POLICY]', err); - return R.error(res, 'Could not save policy.', 500); - } -}; - // ─── PAYMENT POLICIES ───────────────────────────────────────────────────────── const VALID_PROMO_TYPES = new Set(['flat', 'percent']); diff --git a/controllers/admin/tiers.controller.js b/controllers/admin/tiers.controller.js index ab23f50..67b5f2d 100644 --- a/controllers/admin/tiers.controller.js +++ b/controllers/admin/tiers.controller.js @@ -28,9 +28,8 @@ 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 { snapshotPlanGrants } = require('../../services/tierGrants.service'); +const { revokePlanSubscriberAccess } = require('../../services/planAccess.service'); const { excludeAttributes: plansExclude, @@ -112,7 +111,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, createdBy } = req.body; + const { tier_category_id, label, description, features, duration_value, duration_unit = 'day', price, currency, createdBy, status } = 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); @@ -129,6 +128,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, + status: status ?? 'draft', createdBy: createdBy ?? req.user?.user_id ?? null, }); const plain = plan.get({ plain: true }); @@ -145,7 +145,7 @@ exports.updatePlan = async (req, res) => { const plan = await mdl_TierPlans.findByPk(req.params.id); if (!plan) return R.error(res, 'Plan not found.', 404); - const allowed = ['label', 'description', 'features', 'price', 'currency', 'is_active', 'tier_category_id']; + const allowed = ['label', 'description', 'features', 'price', 'currency', 'is_active', 'status', 'tier_category_id']; const updates = {}; for (const k of allowed) { if (req.body[k] !== undefined) updates[k] = req.body[k]; @@ -206,22 +206,19 @@ exports.archivePlan = async (req, res) => { await plan.update({ is_active: false, deletedBy: req.user?.user_id ?? null }); await plan.destroy(); + // Archiving always force-revokes current subscribers' access (no refund) — + // fires tier_plan_access_revoked (see revokePlanSubscriberAccess), not the + // old "access unaffected" tier_plan_archived notice, since that's no + // longer true. + let revoked_user_count = 0; 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); + ({ revoked_user_count } = await revokePlanSubscriberAccess(plan, req.user?.user_id ?? null)); + } catch (revokeErr) { + console.error('[ADMIN][ARCHIVE PLAN][REVOKE ACCESS]', revokeErr); } - logActivity(req.user?.user_id, 'archive_tier_plan', { entityType: 'tier_plan', details: { label: plan.label } }); - return R.success(res, 'Plan archived successfully.'); + logActivity(req.user?.user_id, 'archive_tier_plan', { entityType: 'tier_plan', details: { label: plan.label, revoked_user_count } }); + return R.success(res, 'Plan archived successfully.', { revoked_user_count }); } catch (err) { console.error('[ADMIN][ARCHIVE PLAN]', err); return R.error(res, 'Could not archive plan.', 500); @@ -246,32 +243,24 @@ exports.bulkArchivePlans = async (req, res) => { 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 }); + // Archiving always force-revokes current subscribers' access (no refund) — + // each plan fires its own tier_plan_access_revoked (needs each plan's own + // label), not the old batched "access unaffected" tier_plan_archived notice. + let revoked_user_count = 0; + for (const p of activePlans) { + try { + const { revoked_user_count: c } = await revokePlanSubscriberAccess(p, req.user?.user_id ?? null); + revoked_user_count += c; + } catch (revokeErr) { + console.error('[ADMIN][BULK ARCHIVE PLANS][REVOKE ACCESS]', revokeErr); } - } 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, revoked_user_count } }); return R.success(res, `${activeIds.length} plan(s) archived successfully.`, { archived_ids: activeIds, skipped_ids: ids.filter((id) => !activeIds.includes(id)), + revoked_user_count, }); } catch (err) { console.error('[ADMIN][BULK ARCHIVE PLANS]', err); @@ -351,12 +340,24 @@ exports.permanentlyDeletePlan = async (req, res) => { if (!plan) return R.error(res, 'Plan not found.', 404); if (!plan.deletedAt) return R.error(res, 'Plan must be archived before it can be permanently deleted.', 400); + // A plan may still have active subscribers if it was archived before the + // auto-revoke-on-archive behavior existed, or if revoking failed the + // first time — permanently deleting it must not leave them with orphaned + // access (user_tiers.plan_id would just go NULL on delete, not revoke). + const { revoked_user_count } = await revokePlanSubscriberAccess(plan, req.user?.user_id ?? null); + + // payments.plan_id is RESTRICT at the DB level (payments are paranoid/soft-deleted + // by default) — a plan can't be force-destroyed while payment rows still + // reference it, so those rows are force-destroyed first. This permanently + // erases that plan's payment/billing history; there is no undo. + const deleted_payment_count = await mdl_Payments.destroy({ where: { plan_id: plan.plan_id }, force: true }); + await plan.destroy({ force: true }); - logActivity(req.user?.user_id, 'permanently_delete_tier_plan', { entityType: 'tier_plan', details: { label: plan.label } }); - return R.success(res, 'Plan permanently deleted.'); + logActivity(req.user?.user_id, 'permanently_delete_tier_plan', { entityType: 'tier_plan', details: { label: plan.label, deleted_payment_count, revoked_user_count } }); + return R.success(res, 'Plan permanently deleted.', { deleted_payment_count, revoked_user_count }); } catch (err) { if (err instanceof ForeignKeyConstraintError) { - return R.error(res, 'Cannot delete: this plan still has payment records on file. Payment history is preserved and cannot be removed.', 400); + return R.error(res, 'Cannot delete: this plan still has records on file referencing it.', 400); } console.error('[ADMIN][PERMANENT DELETE PLAN]', err); return R.error(res, 'Could not permanently delete plan.', 500); @@ -378,16 +379,33 @@ exports.bulkPermanentlyDeletePlans = async (req, res) => { const archivedIds = archivedPlans.map((p) => p.plan_id); + // Same reasoning as the single-delete path above: revoke any remaining + // active subscribers per plan (each needs its own label for the + // notification/email) before the records are gone for good. + let revoked_user_count = 0; + for (const p of archivedPlans) { + const { revoked_user_count: c } = await revokePlanSubscriberAccess(p, req.user?.user_id ?? null); + revoked_user_count += c; + } + + // payments.plan_id is RESTRICT at the DB level (payments are paranoid/soft-deleted + // by default) — plans can't be force-destroyed while payment rows still + // reference them, so those rows are force-destroyed first. This permanently + // erases these plans' payment/billing history; there is no undo. + const deleted_payment_count = await mdl_Payments.destroy({ where: { plan_id: archivedIds }, force: true }); + await mdl_TierPlans.destroy({ where: { plan_id: archivedIds }, force: true }); - logActivity(req.user?.user_id, 'bulk_permanently_delete_tier_plans', { entityType: 'tier_plan', details: { ids: archivedIds, count: archivedIds.length } }); + logActivity(req.user?.user_id, 'bulk_permanently_delete_tier_plans', { entityType: 'tier_plan', details: { ids: archivedIds, count: archivedIds.length, deleted_payment_count, revoked_user_count } }); return R.success(res, `${archivedIds.length} plan(s) permanently deleted.`, { deleted_ids: archivedIds, skipped_ids: ids.filter((id) => !archivedIds.includes(id)), + deleted_payment_count, + revoked_user_count, }); } catch (err) { if (err instanceof ForeignKeyConstraintError) { - return R.error(res, 'Cannot delete: one or more selected plans still have payment records on file. Payment history is preserved and cannot be removed.', 400); + return R.error(res, 'Cannot delete: one or more selected plans still have records on file referencing them.', 400); } console.error('[ADMIN][BULK PERMANENT DELETE PLANS]', err); return R.error(res, 'Could not permanently delete plans.', 500); @@ -430,11 +448,14 @@ exports.grantTier = async (req, res) => { if (!plan || !plan.is_active) return R.error(res, 'Plan not found or inactive.', 404); const tier = plan.tier; + // Keyed on plan_id, not tier — a user may already hold a different plan + // at this same tier slug (Tier Plans v2 allows multiple concurrently + // active plans per tier); only re-granting the exact same plan is blocked. const existingActive = await mdl_UserTiers.findOne({ - where: { user_id, tier, status: 'active' }, + where: { user_id, plan_id, status: 'active' }, }); if (existingActive) { - return R.error(res, `User already has an active ${tier} subscription until ${existingActive.expires_at}.`, 409); + return R.error(res, `User already has this plan active until ${existingActive.expires_at}.`, 409); } const startsAt = new Date(); @@ -448,6 +469,8 @@ exports.grantTier = async (req, res) => { notes, }); + await snapshotPlanGrants(newTier, plan_id); + logActivity(req.user.user_id, 'grant_tier', { entityType: 'tier', details: { user_id, tier, plan_id } }); return R.success(res, 'Tier granted.', newTier, 201); } catch (err) { @@ -578,9 +601,8 @@ exports.syncPlanCourses = async (req, res) => { await mdl_PlanCourses.destroy({ where: { plan_id: id } }); if (course_ids.length) { - // course_id has a UNIQUE constraint — clear any orphaned/other-plan assignments - // for these courses before inserting, so the insert isn't silently skipped - await mdl_PlanCourses.destroy({ where: { course_id: course_ids } }); + // A course may already belong to other plans — that's allowed (Tier Plans v2, + // silent duplication across bundles), so we only ever touch this plan's own rows. await mdl_PlanCourses.bulkCreate( course_ids.map(course_id => ({ plan_id: id, course_id })), ); @@ -630,9 +652,8 @@ exports.syncPlanUnits = async (req, res) => { await mdl_PlanUnits.destroy({ where: { plan_id: id } }); if (unit_ids.length) { - // unit_id has a UNIQUE constraint — clear any orphaned/other-plan assignments - // for these units before inserting, so the insert isn't silently skipped - await mdl_PlanUnits.destroy({ where: { unit_id: unit_ids } }); + // A unit may already belong to other plans — that's allowed (Tier Plans v2, + // silent duplication across bundles), so we only ever touch this plan's own rows. await mdl_PlanUnits.bulkCreate( unit_ids.map(unit_id => ({ plan_id: id, unit_id })), ); @@ -682,9 +703,8 @@ exports.syncPlanLessons = async (req, res) => { await mdl_PlanLessons.destroy({ where: { plan_id: id } }); if (lesson_ids.length) { - // lesson_id has a UNIQUE constraint — clear any orphaned/other-plan assignments - // for these lessons before inserting, so the insert isn't silently skipped - await mdl_PlanLessons.destroy({ where: { lesson_id: lesson_ids } }); + // A lesson may already belong to other plans — that's allowed (Tier Plans v2, + // silent duplication across bundles), so we only ever touch this plan's own rows. await mdl_PlanLessons.bulkCreate( lesson_ids.map(lesson_id => ({ plan_id: id, lesson_id })), ); diff --git a/controllers/admin/units.controller.js b/controllers/admin/units.controller.js index a026b68..a5cf96c 100644 --- a/controllers/admin/units.controller.js +++ b/controllers/admin/units.controller.js @@ -41,7 +41,6 @@ const CompletionRequirement = require("../../models/courses/completion_requireme const { VALID_ENTITY_TYPES } = require("../../utils/courses/completion_requirements.registry"); const mdl_Users = require("../../models/users/users.mdl"); -const { mdl_PlanUnits, mdl_TierPlans } = require("../../models/tiers/tier.associations"); const notDeleted = { deletedAt: null }; const onlyDeleted = { deletedAt: { [Op.not]: null } }; @@ -192,28 +191,12 @@ exports.getUnitsBySubscription = async (req, res) => { const rows = await Unit.findAll({ where: { ...notDeleted, subscription: slug }, attributes: ['unit_id', 'title', 'description', 'subscription'], - include: [{ - model: mdl_PlanUnits, - as: 'planUnit', - required: false, - attributes: ['plan_id'], - include: [{ - model: mdl_TierPlans, - as: 'plan', - attributes: ['plan_id', 'label'], - }], - }], order: [['title', 'ASC']], }); - // Flatten so the frontend can just check `assigned_plan` — a unit belongs - // to at most one plan (UNIQUE constraint on plan_units.unit_id). - const data = rows.map((u) => { - const plain = u.toJSON(); - const assigned_plan = plain.planUnit?.plan ?? null; - delete plain.planUnit; - return { ...plain, assigned_plan }; - }); + // A unit may belong to any number of other plans (Tier Plans v2, silent + // duplication across bundles is intentional) — no conflict to report here. + const data = rows.map((u) => u.toJSON()); return R.success(res, 'Units retrieved.', data); } catch (err) { diff --git a/controllers/client/course_purchases.controller.js b/controllers/client/course_purchases.controller.js index 0e220d0..5b1306b 100644 --- a/controllers/client/course_purchases.controller.js +++ b/controllers/client/course_purchases.controller.js @@ -4,7 +4,6 @@ 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 @@ -30,11 +29,6 @@ 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 99ee851..7ec696d 100644 --- a/controllers/client/courses.controller.js +++ b/controllers/client/courses.controller.js @@ -4,9 +4,8 @@ * Description: User-facing course endpoints (read-only). * Access rules: * - All courses are returned in the list (for upsell visibility) - * - Each course has is_locked: boolean based on the user's active tier - * - free / no active tier → unassigned courses are open; plan courses are locked - * - premium (active tier) → unassigned + courses under their plan are open + * - Each course has is_locked: boolean based on item-specific entitlement + * (user_tier_grants — see hasItemGrant) or an individual purchase * - getCourse still enforces hard 403 on locked access * Author: Kenneth Obsequio (@lash0000) * Date Created: Jun. 7, 2026 @@ -16,12 +15,10 @@ const { Op } = require("sequelize"); const R = require("../../utils/response.util"); const mdl_UserTiers = require("../../models/tiers/user_tiers.mdl"); +const { mdl_UserTierGrants } = require("../../models/tiers/tier.associations"); const mdl_CoursePurchase = require("../../models/courses/course_purchases.mdl"); const mdl_Product = require("../../models/courses/products.mdl"); const mdl_Category = require("../../models/courses/categories.mdl"); -const mdl_PlanPolicy = require("../../models/tiers/plan_policies.mdl"); -const { mdl_UserGroupMembers } = require("../../models/users/user_groups.mdl"); -const { evaluateCourseAccess } = require("../../utils/accessPolicy.util"); const { Course, @@ -34,7 +31,6 @@ const { } = require("../../models/courses/courses.associations"); const { flattenUnits, flattenLessons } = require("../../utils/courses/hierarchy.util"); const { resolvePrerequisiteTitles, resolvePrerequisiteCompletion } = require("../../utils/courses/resolvePrerequisiteTitles.util"); -const mdl_TierCategories = require("../../models/tiers/tier_categories.mdl"); const { gradeSubmission } = require("../../utils/courses/grading.util"); const { shuffleOptions, shuffleQuestions, getAttemptStatus } = require("../../utils/courses/quiz_security.util"); const { onCourseCompleted } = require('../../services/achievements.service'); @@ -90,43 +86,6 @@ async function expireSession(session, passingScore) { return expiredAttempt; } -// Builds user context for evaluateCourseAccess: the user's best (highest-rank) -// active tier slug, live tier rank map, one ruleset per concurrently-active plan -// that has access_rules configured, and group memberships (needed for the -// group_restriction rule type). A user can hold more than one active tier at -// once (e.g. premium + exclusive), so "tier" here is the effective best one for -// plain rank checks, while "rulesets" preserves each active plan's own rules -// for the OR-across-active-plans check in canAccessCourse. -async function buildUserContext(user_id) { - const activeTiers = await getActiveTiers(user_id); - - const categories = await mdl_TierCategories.findAll({ attributes: ['slug', 'rank'] }); - const tierRankMap = {}; - for (const c of categories) tierRankMap[c.slug] = c.rank; - - let tier = 'free'; - let bestRank = -Infinity; - for (const t of activeTiers) { - const rank = tierRankMap[t.tier] ?? 0; - if (rank > bestRank) { bestRank = rank; tier = t.tier; } - } - - const rulesets = []; - for (const t of activeTiers) { - if (!t.plan_id) continue; - const policy = await mdl_PlanPolicy.findOne({ where: { plan_id: t.plan_id } }); - if (policy?.access_rules?.length) rulesets.push({ tier: t.tier, access_rules: policy.access_rules }); - } - - const memberships = await mdl_UserGroupMembers.findAll({ - where: { user_id, deletedAt: null }, - attributes: ['group_id'], - }); - const group_ids = memberships.map((m) => m.group_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) { @@ -144,35 +103,39 @@ async function hasActivePurchase(user_id, purchasable_type, purchasable_id) { return !!hasPurchase; } +// ─── Item-specific entitlement check (Tier Plans v2) ───────────────────────── +// A Tier Plan purchase snapshots the exact items its bundle granted into +// user_tier_grants at purchase time (see captureOrder in +// controllers/client/tiers.controller.js) — this replaces the old tier-rank +// comparison, which unlocked ALL same-level content off any active purchase. +async function hasItemGrant(user_id, item_type, item_id) { + const grant = await mdl_UserTierGrants.findOne({ + where: { user_id, item_type, item_id }, + include: [{ + model: mdl_UserTiers, + as: 'userTier', + attributes: [], + where: { status: 'active' }, + required: true, + }], + }); + return !!grant; +} + // ─── 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. +// Returns false → user has no grant for this exact course AND no valid individual purchase. async function canAccessCourse(user_id, course_id) { const course = await Course.findOne({ where: { course_id, ...notDeleted }, attributes: ['subscription', 'status'] }); if (!course) return false; if (course.status !== 'published') return false; - const userCtx = await buildUserContext(user_id); + // Free/ungated content is always accessible. + if (!course.subscription || course.subscription === 'free') return true; - // Plain rank check against the user's best active tier — evaluateCourseAccess - // falls back to rank comparison when access_rules is empty. Same behavior as - // before for every course/plan combination that hasn't opted into the richer - // rule engine. - const { allowed: rankAllowed } = evaluateCourseAccess( - { tier: userCtx.tier, access_rules: [], group_ids: userCtx.group_ids }, - course, userCtx.tierRankMap, { type: 'course', id: course_id } - ); - if (rankAllowed) return true; - - // Rule-based: a user can hold more than one active plan concurrently, and each - // one's access_rules is independent — access is granted if ANY of them allow it. - 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, { type: 'course', id: course_id } - ); - if (allowed) return true; - } + // Item-specific entitlement: did any purchased Tier Plan bundle grant THIS + // exact course? + if (await hasItemGrant(user_id, 'course', course_id)) return true; // Individual purchase as fallback return hasActivePurchase(user_id, 'course', course_id); @@ -187,30 +150,13 @@ async function canAccessCourse(user_id, course_id) { // genuinely standalone content run independently. async function canAccessUnit(user_id, unit_id) { - // A unit's own subscription (standalone tier-gating) is an additional, - // OR'd access path alongside any attached course's access — most - // standalone units have zero course links anyway, but a unit that somehow - // has both should be unlockable via either. + // A unit's own grant (item-specific — a Unit bundle purchase grants only + // this unit, not its parent course) is an additional, OR'd access path + // alongside any attached course's access — most standalone units have zero + // course links anyway, but a unit that somehow has both should be + // unlockable via either. const unit = await Unit.findOne({ where: { unit_id, ...notDeleted }, attributes: ['subscription'] }); - if (unit?.subscription) { - const userCtx = await buildUserContext(user_id); - // 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, { 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 (unit?.subscription && await hasItemGrant(user_id, 'unit', unit_id)) return true; if (await hasActivePurchase(user_id, 'unit', unit_id)) return true; @@ -231,25 +177,10 @@ 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). + // Mirrors canAccessUnit's shape: own grant, 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: rankAllowed } = evaluateCourseAccess( - { tier: userCtx.tier, access_rules: [], group_ids: userCtx.group_ids }, - { 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 (lesson?.subscription && await hasItemGrant(user_id, 'lesson', lesson_id)) return true; if (await hasActivePurchase(user_id, 'lesson', lesson_id)) return true; @@ -261,58 +192,9 @@ async function canAccessLesson(user_id, lesson_id) { 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.canAccessUnit = canAccessUnit; exports.canAccessLesson = canAccessLesson; -exports.isPurchaseEligible = isPurchaseEligible; const COURSE_LIST_ATTRS = [ "course_id", "uuid", "title", "description", @@ -336,15 +218,6 @@ function sanitizeQuestions(questions = []) { }); } -// Resolve ALL of the caller's concurrently-active tiers (empty array if free/expired) — -// a user can hold more than one active tier at once (e.g. premium + exclusive). -async function getActiveTiers(user_id) { - return mdl_UserTiers.findAll({ - where: { user_id, status: "active" }, - order: [["createdAt", "DESC"]], - }); -} - // ─── COURSE CATEGORIES (public list for filter chips) ───────────────────────── exports.getCategories = async (req, res) => { @@ -367,9 +240,6 @@ exports.getCourses = async (req, res) => { try { const { category } = req.query; // optional slug filter - const userCtx = await buildUserContext(req.user.user_id); - const userTier = userCtx.tier; - // 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. @@ -385,6 +255,16 @@ exports.getCourses = async (req, res) => { .map((p) => String(p.product.purchasable_id)) ); + // Item-specific entitlement (Tier Plans v2) — batch-fetch every course this + // user was granted by an active Tier Plan purchase, same source canAccessCourse + // checks per-item via hasItemGrant. + const myGrants = await mdl_UserTierGrants.findAll({ + where: { user_id: req.user.user_id, item_type: 'course' }, + include: [{ model: mdl_UserTiers, as: 'userTier', attributes: [], where: { status: 'active' }, required: true }], + attributes: ['item_id'], + }); + const grantedCourseIds = new Set(myGrants.map((g) => String(g.item_id))); + // Build category filter const categoryInclude = { model: mdl_Category, @@ -411,15 +291,13 @@ exports.getCourses = async (req, res) => { order: [['order_index', 'ASC'], ['title', 'ASC']], }); - const userRank = userCtx.tierRankMap[userCtx.tier] ?? 0; - const result = courses.map((c) => { const plain = c.toJSON(); const has_purchased = purchasedCourseIds.has(String(plain.course_id)); + const has_grant = grantedCourseIds.has(String(plain.course_id)); const subscription = plain.subscription ?? 'free'; - const courseRank = userCtx.tierRankMap[subscription] ?? Infinity; - const is_locked = courseRank > 0 && !has_purchased && userRank < courseRank; + const is_locked = subscription !== 'free' && !has_purchased && !has_grant; return { ...plain, is_locked, has_purchased }; }); @@ -573,6 +451,38 @@ exports.getCourse = async (req, res) => { })); } + // Same idea, one level up — each unit's own completion trigger (read_all_content + // [default] / pass_quiz / manual_complete) plus the course's, so UnitList.jsx can + // show a "how to complete this unit / this course" explainer without another + // round trip (mirrors getLessonsByUnitUuid's standalone-reader equivalent). + const allUnitIds = plain.units?.map((u) => u.unit_id) ?? []; + if (allUnitIds.length) { + const unitRequirements = await CompletionRequirement.findAll({ + where: { entity_type: 'unit', entity_id: allUnitIds }, + attributes: ['entity_id', 'type', 'min_percent', 'button_label'], + }); + const byUnitId = new Map(); + unitRequirements.forEach((r) => { if (!byUnitId.has(String(r.entity_id))) byUnitId.set(String(r.entity_id), r); }); + + plain.units = plain.units.map((u) => { + const row = byUnitId.get(String(u.unit_id)); + return { + ...u, + completion: row + ? { type: row.type, min_percent: row.min_percent, button_label: row.button_label } + : { type: 'read_all_content', min_percent: null, button_label: null }, + }; + }); + } + + const courseRequirement = await CompletionRequirement.findOne({ + where: { entity_type: 'course', entity_id: course.course_id }, + attributes: ['type', 'min_percent', 'button_label'], + }); + plain.completion = courseRequirement + ? { type: courseRequirement.type, min_percent: courseRequirement.min_percent, button_label: courseRequirement.button_label } + : { type: 'read_all_content', min_percent: null, button_label: null }; + // has_passed reflects the assessment attempt alone; is_completed is the consolidated // evaluator's result (default rule: all units read AND assessment passed, if one exists — // was previously hardcoded to assessment-pass alone here too, same bug fixed in @@ -607,9 +517,7 @@ 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); + const purchaseEligible = true; // Certificate status for the course details card const [pendingCert, certificate] = await Promise.all([ @@ -1649,10 +1557,7 @@ async function buildCheckoutInfo(user_id, purchasable_type, record) { [Op.or]: [{ expires_at: null }, { expires_at: { [Op.gt]: new Date() } }], }, }); - 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 }; + return { product: product ?? null, has_purchased: !!hasPurchase, purchase_eligible: true }; } exports.getCourseCheckoutInfo = async (req, res) => { diff --git a/controllers/client/tiers.controller.js b/controllers/client/tiers.controller.js index 5d39bf7..7984bb5 100644 --- a/controllers/client/tiers.controller.js +++ b/controllers/client/tiers.controller.js @@ -16,13 +16,19 @@ const mdl_TierPlans = require('../../models/tiers/tier_plans.mdl'); const mdl_UserTiers = require('../../models/tiers/user_tiers.mdl'); const mdl_Payments = require('../../models/tiers/payments.mdl'); const mdl_SystemBadges = require('../../models/system_badges/system_badges.mdl'); +const { mdl_UserTierGrants } = require('../../models/tiers/tier.associations'); const Asset = require('../../models/assets/assets.mdl'); const UserNotification = require('../../models/notifications/user_notification.mdl'); const { onTierActivated } = require('../../services/achievements.service'); const { Course } = require('../../models/courses/courses.mdl'); +const Unit = require('../../models/courses/units.mdl'); +const Lesson = require('../../models/courses/lessons.mdl'); const paymentSvc = require('../../services/payment.service'); +const { snapshotPlanGrants } = require('../../services/tierGrants.service'); const R = require('../../utils/response.util'); const { NOTIFICATION_REGISTRY } = require('../../data/notifications.data'); +const { sendEmail } = require('../../services/email.service'); +const { fmtDate } = require('../../utils/datetime.util'); require('../../models/tiers/tier.associations'); @@ -76,9 +82,25 @@ exports.getMyTier = async (req, res) => { active_tiers: [freeTier], top_tier: 'free', just_expired, + my_grants: { course_ids: [], unit_ids: [], lesson_ids: [] }, }); } + // Item-specific entitlement (Tier Plans v2) — every course/unit/lesson id + // granted by ANY of this user's currently-active tiers, flattened, so the + // client can compute per-plan overlap (see PlanList.jsx) without a + // separate endpoint per plan. + const myGrantRows = await mdl_UserTierGrants.findAll({ + where: { user_tier_id: stillActive.map((t) => t.tier_id) }, + attributes: ['item_type', 'item_id'], + }); + const my_grants = { course_ids: [], unit_ids: [], lesson_ids: [] }; + for (const g of myGrantRows) { + if (g.item_type === 'course') my_grants.course_ids.push(g.item_id); + else if (g.item_type === 'unit') my_grants.unit_ids.push(g.item_id); + else if (g.item_type === 'lesson') my_grants.lesson_ids.push(g.item_id); + } + const categories = await mdl_TierCategories.findAll({ attributes: ['slug', 'rank'] }); const rankMap = Object.fromEntries(categories.map((c) => [c.slug, c.rank])); @@ -104,7 +126,7 @@ exports.getMyTier = async (req, res) => { // Spread topTierObj at top level too — keeps `myTier.tier`/`myTier.status`/ // `myTier.category`/`myTier.expires_at` working for existing frontend code // that predates the active_tiers/top_tier shape (it'll just see the best tier). - return R.success(res, 'Active tier retrieved.', { ...topTierObj, active_tiers, top_tier, just_expired }); + return R.success(res, 'Active tier retrieved.', { ...topTierObj, active_tiers, top_tier, just_expired, my_grants }); } catch (err) { console.error('[CLIENT][GET MY TIER]', err); return R.error(res, 'Could not retrieve tier.', 500); @@ -129,6 +151,7 @@ exports.getMyTierHistory = async (req, res) => { exports.getPlans = async (req, res) => { try { const plans = await mdl_TierPlans.findAll({ + where: { status: 'published' }, order: [['tier', 'ASC'], ['duration_days', 'ASC']], attributes: ['plan_id', 'tier', 'label', 'description', 'features', 'duration_days', 'duration_unit', 'price', 'currency', 'is_active'], include: [ @@ -138,12 +161,32 @@ exports.getPlans = async (req, res) => { attributes: ['course_id', 'uuid', 'title', 'course_code', 'level', 'duration_seconds'], through: { attributes: [] }, }, + { + model: Unit, + as: 'units', + attributes: ['unit_id', 'uuid', 'title', 'duration_seconds'], + through: { attributes: [] }, + }, + { + model: Lesson, + as: 'lessons', + attributes: ['lesson_id', 'uuid', 'title', 'duration_seconds'], + through: { attributes: [] }, + }, ], }); + // Each plan holds exactly one bundle type (single-type bundles, Tier Plans + // v2) — expose the exact item id sets so the client can compute + // overlap-with-existing-access without extra round-trips (see PlanList.jsx). const result = plans.map((p) => { const plain = p.toJSON(); plain.course_count = plain.courses?.length ?? 0; + plain.unit_count = plain.units?.length ?? 0; + plain.lesson_count = plain.lessons?.length ?? 0; + plain.course_ids = (plain.courses ?? []).map((c) => c.course_id); + plain.unit_ids = (plain.units ?? []).map((u) => u.unit_id); + plain.lesson_ids = (plain.lessons ?? []).map((l) => l.lesson_id); return plain; }); @@ -161,7 +204,7 @@ exports.validatePromo = async (req, res) => { const { plan_id, code } = req.body; if (!plan_id || !code) return R.error(res, 'plan_id and code are required.', 400); - const plan = await mdl_TierPlans.findOne({ where: { plan_id, is_active: true } }); + const plan = await mdl_TierPlans.findOne({ where: { plan_id, is_active: true, status: 'published' } }); if (!plan) return R.error(res, 'Plan not found or inactive.', 404); const policy = await paymentSvc.getPolicyForPlan(plan_id); @@ -181,14 +224,17 @@ exports.createOrder = async (req, res) => { const { plan_id, promo_code } = req.body; if (!plan_id) return R.error(res, 'plan_id is required.', 400); - const plan = await mdl_TierPlans.findOne({ where: { plan_id, is_active: true } }); + const plan = await mdl_TierPlans.findOne({ where: { plan_id, is_active: true, status: 'published' } }); if (!plan) return R.error(res, 'Plan not found or inactive.', 404); - // Repurchasing a plan under a tier already held active is allowed — it + // Repurchasing the SAME plan while it's already active is allowed — it // extends the existing grant's expires_at (see captureOrder) rather than - // being blocked. Surfaced here only for checkout-page messaging. + // being blocked. A different plan at the same tier slug is NOT the same + // purchase — it creates its own independent user_tiers row with its own + // item-specific grants, so this check is keyed on plan_id, not tier. + // 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' }, + where: { user_id: req.user.user_id, plan_id, status: 'active' }, attributes: ['expires_at'], }); @@ -313,15 +359,16 @@ exports.captureOrder = async (req, res) => { return R.error(res, `Payment was not completed by PayPal (status: ${captureStatus ?? 'unknown'}).`, 402); } - // 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. + // Repurchasing THIS SAME plan while already active extends its expires_at + // by the new duration, rather than being blocked/refunded. A different + // plan — even at the same tier slug — is a distinct purchase and gets its + // own user_tiers row with its own item-specific grants (see + // snapshotPlanGrants below); it must NOT be merged into an unrelated + // plan's row just because the tier slug matches (Tier Plans v2 — a Unit + // bundle and a Course bundle can both be "premium" and both need to stay + // independently active/tracked). const existingActive = await mdl_UserTiers.findOne({ - where: { user_id: req.user.user_id, tier: payment.plan.tier, status: 'active' }, + where: { user_id: req.user.user_id, plan_id: payment.plan_id, status: 'active' }, }); let resultTier; @@ -348,6 +395,12 @@ exports.captureOrder = async (req, res) => { successMessage = 'Payment successful. Tier activated.'; } + // Snapshot the plan's current bundle contents into user_tier_grants — + // refreshed on every purchase/extension so an admin's bundle edits since + // the last purchase are picked up, but past purchasers of OTHER plans are + // never retroactively affected (Tier Plans v2 item-specific entitlement). + await snapshotPlanGrants(resultTier, payment.plan_id); + await payment.update({ status: 'completed', tier_id: resultTier.tier_id, @@ -466,6 +519,38 @@ exports.refundOrder = async (req, res) => { const now = new Date(); await activeTier.update({ status: 'revoked', expires_at: now, revoked_at: now }); + const plan = await mdl_TierPlans.findByPk(payment.plan_id, { attributes: ['plan_id', 'label'] }); + + try { + const notify = NOTIFICATION_REGISTRY.payment_refunded.build({ + label: plan?.label ?? 'your plan', + amount: payment.amount, + currency: payment.currency, + planId: plan?.plan_id ?? null, + }); + await UserNotification.create({ user_id, ...notify, seen: false }); + } catch (notifyErr) { + console.error('[CLIENT][REFUND][NOTIFY]', notifyErr); + } + + try { + const name = req.user.personal_info?.name?.full_name ?? 'there'; + sendEmail({ + to: req.user.email, + type: 'REFUND_PROCESSED', + data: { + name, + label: plan?.label ?? 'your plan', + amount: payment.amount, + currency: payment.currency, + date: fmtDate(now), + refundId: refundData.id, + }, + }).catch((emailErr) => console.error('[CLIENT][REFUND][EMAIL]', emailErr)); + } catch (emailErr) { + console.error('[CLIENT][REFUND][EMAIL]', emailErr); + } + // Only fall back to free if the user has no other concurrently active tier — // revoking one subscription shouldn't drop them below a tier they still hold. const remainingActive = await mdl_UserTiers.count({ where: { user_id, status: 'active' } }); diff --git a/controllers/client/units.controller.js b/controllers/client/units.controller.js index ed1883e..308adda 100644 --- a/controllers/client/units.controller.js +++ b/controllers/client/units.controller.js @@ -148,9 +148,7 @@ exports.getUnits = async (req, res) => { : 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; + const purchase_eligible = true; result.push({ ...row, courses: coursesByUnit.get(row.unit_id) ?? [], @@ -216,9 +214,7 @@ exports.getLessons = async (req, res) => { : 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; + const purchase_eligible = true; result.push({ ...row, courses: coursesByLesson.get(row.lesson_id) ?? [], diff --git a/data/email_body.data.js b/data/email_body.data.js index bd37f36..6bd267e 100644 --- a/data/email_body.data.js +++ b/data/email_body.data.js @@ -161,6 +161,38 @@ const emailTemplates = {

For security, please do not share your credentials with anyone. If you need a new password, contact your administrator.

`), }), + + TIER_ACCESS_REVOKED: ({ name, label, date }) => ({ + subject: "Your Subscription Access Has Been Revoked - STARR System", + html: wrap(` +

Dear ${name},

+

We're writing to inform you that your access under the following subscription plan has been revoked by our team, effective ${date}:

+ + + + +
Plan${label}
StatusAccess Revoked
RefundNot applicable — this subscription is non-refundable
+

Your account no longer has access to the courses, units, and lessons included in this plan. If you were not concurrently subscribed to another active plan, your account has reverted to the Free tier.

+

If you believe this was done in error or have questions about this change, please contact our support team.

+ `), + }), + + REFUND_PROCESSED: ({ name, label, amount, currency, date, refundId }) => ({ + subject: "Your Refund Has Been Processed - STARR System", + html: wrap(` +

Dear ${name},

+

We're writing to confirm that your refund request has been processed as of ${date}:

+ + + + + +
Plan${label}
Amount Refunded${currency} ${amount}
Refund ID${refundId}
StatusRefunded
+

Your access to the courses, units, and lessons included in this plan has been revoked effective immediately. If you were not concurrently subscribed to another active plan, your account has reverted to the Free tier.

+

Please allow a few business days for the refunded amount to reflect on your original payment method, depending on your provider.

+

If you have any questions about this refund, please contact our support team.

+ `), + }), }; module.exports = { emailTemplates }; diff --git a/data/notifications.data.js b/data/notifications.data.js index 3604f30..d548aa6 100644 --- a/data/notifications.data.js +++ b/data/notifications.data.js @@ -23,7 +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, + * tier_plan_archived, tier_plan_access_revoked, payment_refunded, * task_submission_reviewed, task_assigned, task_completed * Both : broadcast (admin-composed, sent via notification_broadcasts CRUD) * @@ -393,6 +393,40 @@ const NOTIFICATION_REGISTRY = { }, }, + // Distinct from tier_plan_archived above — that one explicitly says access + // is unaffected, which would be false here, so an admin force-revoke + // always fires THIS instead of (never alongside) tier_plan_archived. + tier_plan_access_revoked: { + type: 'tier_plan_access_revoked', + scope: 'user', + trigger: 'event', + build({ label, planId = null }) { + return { + type: 'tier_plan_access_revoked', + title: 'Subscription Access Revoked', + message: `Your access under the "${label}" plan has been revoked by an administrator, effective immediately. This subscription is non-refundable.`, + data: { label, planId }, + }; + }, + }, + + // Fired when a user self-serves a refund within their plan's refund window + // (controllers/client/tiers.controller.js refundOrder) — distinct from + // tier_plan_access_revoked above, which is an admin-initiated, non-refundable cutoff. + payment_refunded: { + type: 'payment_refunded', + scope: 'user', + trigger: 'event', + build({ label, amount, currency, planId = null }) { + return { + type: 'payment_refunded', + title: 'Refund Processed', + message: `Your refund of ${currency} ${amount} for the "${label}" plan has been processed. Your access to this plan has been revoked.`, + data: { label, amount, currency, planId }, + }; + }, + }, + }; module.exports = { NOTIFICATION_REGISTRY }; diff --git a/database/migrations/20270101000088-relax-plan-bundle-item-uniqueness.js b/database/migrations/20270101000088-relax-plan-bundle-item-uniqueness.js new file mode 100644 index 0000000..be8b4c2 --- /dev/null +++ b/database/migrations/20270101000088-relax-plan-bundle-item-uniqueness.js @@ -0,0 +1,42 @@ +'use strict'; + +// Tier Plans v2: bundle items (courses/units/lessons) may now belong to more +// than one plan simultaneously — the old single-column unique constraints +// enforced "one item = at most one plan ever", which blocks that. Replace +// each with a composite unique on (plan_id, item_id), which still prevents +// the same item being added twice to the *same* plan. +module.exports = { + async up(queryInterface) { + await queryInterface.removeConstraint('plan_courses', 'plan_courses_course_id_key').catch(() => {}); + await queryInterface.removeIndex('plan_courses', 'plan_courses_course_id_key').catch(() => {}); + await queryInterface.addIndex('plan_courses', ['plan_id', 'course_id'], { + unique: true, + name: 'plan_courses_plan_id_course_id_unique', + }); + + await queryInterface.removeConstraint('plan_units', 'plan_units_unit_id_key').catch(() => {}); + await queryInterface.removeIndex('plan_units', 'plan_units_unit_id_key').catch(() => {}); + await queryInterface.addIndex('plan_units', ['plan_id', 'unit_id'], { + unique: true, + name: 'plan_units_plan_id_unit_id_unique', + }); + + await queryInterface.removeConstraint('plan_lessons', 'plan_lessons_lesson_id_key').catch(() => {}); + await queryInterface.removeIndex('plan_lessons', 'plan_lessons_lesson_id_key').catch(() => {}); + await queryInterface.addIndex('plan_lessons', ['plan_id', 'lesson_id'], { + unique: true, + name: 'plan_lessons_plan_id_lesson_id_unique', + }); + }, + + async down(queryInterface, Sequelize) { + await queryInterface.removeIndex('plan_courses', 'plan_courses_plan_id_course_id_unique'); + await queryInterface.changeColumn('plan_courses', 'course_id', { type: Sequelize.BIGINT, allowNull: false, unique: true }); + + await queryInterface.removeIndex('plan_units', 'plan_units_plan_id_unit_id_unique'); + await queryInterface.changeColumn('plan_units', 'unit_id', { type: Sequelize.BIGINT, allowNull: false, unique: true }); + + await queryInterface.removeIndex('plan_lessons', 'plan_lessons_plan_id_lesson_id_unique'); + await queryInterface.changeColumn('plan_lessons', 'lesson_id', { type: Sequelize.BIGINT, allowNull: false, unique: true }); + }, +}; diff --git a/database/migrations/20270101000089-create-user-tier-grants.js b/database/migrations/20270101000089-create-user-tier-grants.js new file mode 100644 index 0000000..9702821 --- /dev/null +++ b/database/migrations/20270101000089-create-user-tier-grants.js @@ -0,0 +1,48 @@ +'use strict'; + +// Tier Plans v2: item-specific entitlement. Until now a Tier Plan purchase +// only recorded `user_tiers.plan_id` + tier rank; the actual courses/units/ +// lessons granted were re-resolved LIVE off plan_courses/plan_units/ +// plan_lessons on every access check, so editing a plan's bundle later +// silently changed what past purchasers could access, and a Unit-only bundle +// couldn't be distinguished from its parent Course's bundle. +// +// This table is the purchase-time snapshot: one row per exact item granted +// by a given user_tiers purchase. Access checks now look up rows here +// instead of comparing tier rank against item subscription (see hasItemGrant() +// in controllers/client/courses.controller.js). +// +// item_type is STRING + CHECK, not a real ENUM, mirroring +// products.purchasable_type (20270101000078) — CockroachDB can't create a +// new enum type via addColumn the way createTable can. +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.createTable('user_tier_grants', { + id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true }, + user_tier_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'user_tiers', key: 'tier_id' }, onDelete: 'CASCADE' }, + user_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'users', key: 'user_id' }, onDelete: 'CASCADE' }, + plan_id: { type: Sequelize.BIGINT, allowNull: true, references: { model: 'tier_plans', key: 'plan_id' }, onUpdate: 'CASCADE', onDelete: 'SET NULL' }, + item_type: { type: Sequelize.STRING(10), allowNull: false }, + item_id: { type: Sequelize.BIGINT, allowNull: false }, + granted_at: { type: Sequelize.DATE, allowNull: false, defaultValue: Sequelize.NOW }, + createdAt: { type: Sequelize.DATE, allowNull: false }, + updatedAt: { type: Sequelize.DATE, allowNull: false }, + }); + + await queryInterface.addConstraint('user_tier_grants', { + fields: ['item_type'], + type: 'check', + name: 'check_user_tier_grants_item_type', + where: { item_type: { [Sequelize.Op.in]: ['course', 'unit', 'lesson'] } }, + }); + + await queryInterface.addIndex('user_tier_grants', ['user_id', 'item_type', 'item_id'], { + name: 'user_tier_grants_user_item_idx', + }); + await queryInterface.addIndex('user_tier_grants', ['user_tier_id']); + }, + + async down(queryInterface) { + await queryInterface.dropTable('user_tier_grants'); + }, +}; diff --git a/database/migrations/20270101000090-relax-user-tiers-active-per-plan.js b/database/migrations/20270101000090-relax-user-tiers-active-per-plan.js new file mode 100644 index 0000000..d14128d --- /dev/null +++ b/database/migrations/20270101000090-relax-user-tiers-active-per-plan.js @@ -0,0 +1,38 @@ +'use strict'; + +// Tier Plans v2: item-specific entitlement means two DIFFERENT plans at the +// SAME tier slug (e.g. two separate Premium bundles) must be able to stay +// concurrently active for one user, each governing its own distinct +// user_tier_grants snapshot — buying a second Premium bundle must not just +// extend the first one's expiry (see captureOrder in +// controllers/client/tiers.controller.js). +// +// The old partial unique index (20270101000075) enforced "one active row per +// (user_id, tier)", which blocks that. Replace it with "one active row per +// (user_id, plan_id)" — still prevents accidentally double-purchasing/ +// double-granting the exact same plan concurrently, but allows multiple +// plans at the same tier to coexist. NULL plan_id rows (admin-granted +// tiers with no backing plan) are never considered equal to one another by +// a unique index, so they're unaffected. +// +// NOT auto-run — do not execute against the shared dev/prod database without +// explicit confirmation. +module.exports = { + async up(queryInterface) { + await queryInterface.removeIndex('user_tiers', 'user_tiers_one_active_per_user_tier'); + await queryInterface.addIndex('user_tiers', ['user_id', 'plan_id'], { + unique: true, + where: { status: 'active' }, + name: 'user_tiers_one_active_per_user_plan', + }); + }, + + async down(queryInterface) { + await queryInterface.removeIndex('user_tiers', 'user_tiers_one_active_per_user_plan'); + await queryInterface.addIndex('user_tiers', ['user_id', 'tier'], { + unique: true, + where: { status: 'active' }, + name: 'user_tiers_one_active_per_user_tier', + }); + }, +}; diff --git a/database/migrations/20270101000091-drop-plan-policies.js b/database/migrations/20270101000091-drop-plan-policies.js new file mode 100644 index 0000000..e6da7a2 --- /dev/null +++ b/database/migrations/20270101000091-drop-plan-policies.js @@ -0,0 +1,31 @@ +'use strict'; + +// The "Access Rules" feature (rule-based access on top of/instead of item- +// specific entitlement — course_subscription_access/required_active_tier/ +// group_restriction/item_allowlist rule types, plus the "starter-set purchase +// eligibility" gate derived from item_allowlist) has been removed entirely, +// admin UI and runtime enforcement both — item-specific `user_tier_grants` is +// the only entitlement mechanism now (see hasItemGrant() in +// controllers/client/courses.controller.js). No other table has an FK into +// plan_policies (only plan_policies.plan_id -> tier_plans.plan_id), so this +// is a clean, isolated drop. +// +// NOT auto-run — do not execute against the shared dev/prod database without +// explicit confirmation. +module.exports = { + async up(queryInterface) { + await queryInterface.dropTable('plan_policies'); + }, + + async down(queryInterface, Sequelize) { + await queryInterface.createTable('plan_policies', { + policy_id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true }, + plan_id: { type: Sequelize.BIGINT, allowNull: false, unique: true, + references: { model: 'tier_plans', key: 'plan_id' }, + onUpdate: 'CASCADE', onDelete: 'CASCADE' }, + access_rules: { type: Sequelize.JSONB, allowNull: false, defaultValue: [] }, + createdAt: { type: Sequelize.DATE, allowNull: false }, + updatedAt: { type: Sequelize.DATE, allowNull: false }, + }); + }, +}; diff --git a/database/migrations/20270101000092-add-tier-plans-status.js b/database/migrations/20270101000092-add-tier-plans-status.js new file mode 100644 index 0000000..b35b8ed --- /dev/null +++ b/database/migrations/20270101000092-add-tier-plans-status.js @@ -0,0 +1,21 @@ +'use strict'; + +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.addColumn('tier_plans', 'status', { + type: Sequelize.ENUM('draft', 'published'), + allowNull: false, + defaultValue: 'draft', + }); + + // Preserve current live behavior — every plan that already exists is + // visible on the client today (only is_active governs purchasability), + // so backfill all of them to 'published'. The 'draft' default only + // takes effect for plans created after this migration. + await queryInterface.sequelize.query(`UPDATE "tier_plans" SET "status" = 'published'`); + }, + + async down(queryInterface) { + await queryInterface.removeColumn('tier_plans', 'status'); + }, +}; diff --git a/models/advertisements/advertisements.mdl.js b/models/advertisements/advertisements.mdl.js index f8995ee..6e0cbdc 100644 --- a/models/advertisements/advertisements.mdl.js +++ b/models/advertisements/advertisements.mdl.js @@ -33,9 +33,11 @@ const Advertisement = sequelize.define("Advertisement", { }, // ─── Content ────────────────────────────────────────────────────────────── - // "image" = image only, "content" = badge/headline/description/CTAs alongside - // the image — an explicit admin choice made in the creation wizard, decoupled - // from placement/format. + // Every ad is now created/edited as "content" (badge/headline/description/ + // image/link all mandatory) — applyAdvertisementFields on the backend fixes + // this server-side rather than accepting it from the client. "image" is + // legacy-only, left on rows created before the Image Only / Text with Image + // toggle was removed, until they're next edited. content_mode: { type: DataTypes.ENUM("image", "content"), allowNull: false, defaultValue: "image", label: "Content Mode", order: 3.5 }, // Up to 2 outline-badge chips shown alongside the headline — see MAX_BADGE_LABELS. badge_labels: { type: DataTypes.JSONB, allowNull: false, defaultValue: [], label: "Badge Labels", order: 4 }, diff --git a/models/tiers/plan_courses.mdl.js b/models/tiers/plan_courses.mdl.js index a6e3b8e..9f41bc6 100644 --- a/models/tiers/plan_courses.mdl.js +++ b/models/tiers/plan_courses.mdl.js @@ -2,7 +2,13 @@ * File Name: plan_courses.mdl.js * Type of Program: Model * Description: Junction table — links courses to a specific tier plan. - * UNIQUE on course_id enforces one course belongs to one plan only. + * Composite UNIQUE on (plan_id, course_id) only prevents adding + * the same course twice to the same plan — a course may belong + * to many plans simultaneously (Tier Plans v2, silent duplication + * across bundles is intentional; see admin/tiers.controller.js). + * Bundling/display only, not an access-control mechanism — see + * controllers/client/courses.controller.js (hasItemGrant) / + * user_tier_grants for entitlement. * Author: Kenneth Obsequio (@lash0000) * Date Created: Jun. 6, 2026 ***********************************************************************************************************************************************************************/ diff --git a/models/tiers/plan_lessons.mdl.js b/models/tiers/plan_lessons.mdl.js index e07a1b8..d678ff9 100644 --- a/models/tiers/plan_lessons.mdl.js +++ b/models/tiers/plan_lessons.mdl.js @@ -2,9 +2,12 @@ * File Name: plan_lessons.mdl.js * Type of Program: Model * Description: Junction table — links standalone Lessons to a specific tier plan. - * UNIQUE on lesson_id enforces one lesson belongs to one plan only. + * Composite UNIQUE on (plan_id, lesson_id) only prevents adding + * the same lesson twice to the same plan — a lesson may belong + * to many plans simultaneously (Tier Plans v2, silent duplication + * across bundles is intentional; see admin/tiers.controller.js). * Mirrors plan_courses.mdl.js — bundling/display only, not an - * access-control mechanism (see canAccessLesson). + * access-control mechanism (see canAccessLesson / user_tier_grants). * Author: Kenneth Obsequio (@lash0000) * Date Created: Aug. 1, 2026 ***********************************************************************************************************************************************************************/ diff --git a/models/tiers/plan_policies.mdl.js b/models/tiers/plan_policies.mdl.js deleted file mode 100644 index 3d703f0..0000000 --- a/models/tiers/plan_policies.mdl.js +++ /dev/null @@ -1,14 +0,0 @@ -const { DataTypes } = require('sequelize'); -const sequelize = require('../../config/db.config'); - -const mdl_PlanPolicies = sequelize.define('PlanPolicy', { - policy_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true }, - plan_id: { type: DataTypes.BIGINT, allowNull: false, unique: true }, - access_rules: { type: DataTypes.JSONB, allowNull: false, defaultValue: [], label: 'Access Rules' }, -}, { - tableName: 'plan_policies', - timestamps: true, - paranoid: false, -}); - -module.exports = mdl_PlanPolicies; diff --git a/models/tiers/plan_units.mdl.js b/models/tiers/plan_units.mdl.js index 29c4eec..df05eee 100644 --- a/models/tiers/plan_units.mdl.js +++ b/models/tiers/plan_units.mdl.js @@ -2,9 +2,12 @@ * File Name: plan_units.mdl.js * Type of Program: Model * Description: Junction table — links standalone Units to a specific tier plan. - * UNIQUE on unit_id enforces one unit belongs to one plan only. + * Composite UNIQUE on (plan_id, unit_id) only prevents adding the + * same unit twice to the same plan — a unit may belong to many + * plans simultaneously (Tier Plans v2, silent duplication across + * bundles is intentional; see admin/tiers.controller.js). * Mirrors plan_courses.mdl.js — bundling/display only, not an - * access-control mechanism (see canAccessUnit). + * access-control mechanism (see canAccessUnit / user_tier_grants). * Author: Kenneth Obsequio (@lash0000) * Date Created: Aug. 1, 2026 ***********************************************************************************************************************************************************************/ diff --git a/models/tiers/tier.associations.js b/models/tiers/tier.associations.js index d4b42a7..94129bd 100644 --- a/models/tiers/tier.associations.js +++ b/models/tiers/tier.associations.js @@ -6,7 +6,7 @@ const mdl_Payments = require('./payments.mdl'); const mdl_PlanCourses = require('./plan_courses.mdl'); const mdl_PlanUnits = require('./plan_units.mdl'); const mdl_PlanLessons = require('./plan_lessons.mdl'); -const mdl_PlanPolicies = require('./plan_policies.mdl'); +const mdl_UserTierGrants = require('./user_tier_grants.mdl'); const mdl_SystemBadges = require('../system_badges/system_badges.mdl'); const Asset = require('../assets/assets.mdl'); const { Course } = require('../courses/courses.mdl'); @@ -46,7 +46,7 @@ Course.belongsToMany(mdl_TierPlans, { }); mdl_PlanCourses.belongsTo(mdl_TierPlans, { foreignKey: 'plan_id', as: 'plan' }); mdl_PlanCourses.belongsTo(Course, { foreignKey: 'course_id', as: 'course' }); -Course.hasOne(mdl_PlanCourses, { as: 'planCourse', foreignKey: 'course_id' }); +Course.hasMany(mdl_PlanCourses, { as: 'planCourses', foreignKey: 'course_id' }); mdl_TierPlans.hasMany(mdl_PlanCourses, { as: 'planCourses', foreignKey: 'plan_id' }); // ─── Plan ↔ Units ───────────────────────────────────────────────────────────── @@ -64,7 +64,7 @@ Unit.belongsToMany(mdl_TierPlans, { }); mdl_PlanUnits.belongsTo(mdl_TierPlans, { foreignKey: 'plan_id', as: 'plan' }); mdl_PlanUnits.belongsTo(Unit, { foreignKey: 'unit_id', as: 'unit' }); -Unit.hasOne(mdl_PlanUnits, { as: 'planUnit', foreignKey: 'unit_id' }); +Unit.hasMany(mdl_PlanUnits, { as: 'planUnits', foreignKey: 'unit_id' }); mdl_TierPlans.hasMany(mdl_PlanUnits, { as: 'planUnits', foreignKey: 'plan_id' }); // ─── Plan ↔ Lessons ─────────────────────────────────────────────────────────── @@ -82,16 +82,18 @@ Lesson.belongsToMany(mdl_TierPlans, { }); mdl_PlanLessons.belongsTo(mdl_TierPlans, { foreignKey: 'plan_id', as: 'plan' }); mdl_PlanLessons.belongsTo(Lesson, { foreignKey: 'lesson_id', as: 'lesson' }); -Lesson.hasOne(mdl_PlanLessons, { as: 'planLesson', foreignKey: 'lesson_id' }); +Lesson.hasMany(mdl_PlanLessons, { as: 'planLessons', foreignKey: 'lesson_id' }); mdl_TierPlans.hasMany(mdl_PlanLessons, { as: 'planLessons', foreignKey: 'plan_id' }); // ─── UserTier → Plan ────────────────────────────────────────────────────────── mdl_UserTiers.belongsTo(mdl_TierPlans, { foreignKey: 'plan_id', as: 'plan' }); mdl_TierPlans.hasMany(mdl_UserTiers, { foreignKey: 'plan_id', as: 'userTiers' }); -// ─── Plan ↔ Policy ──────────────────────────────────────────────────────────── -mdl_TierPlans.hasOne(mdl_PlanPolicies, { foreignKey: 'plan_id', as: 'policy' }); -mdl_PlanPolicies.belongsTo(mdl_TierPlans, { foreignKey: 'plan_id', as: 'plan' }); +// ─── UserTier → UserTierGrants (item-specific entitlement snapshot) ────────── +mdl_UserTiers.hasMany(mdl_UserTierGrants, { foreignKey: 'user_tier_id', as: 'grants' }); +mdl_UserTierGrants.belongsTo(mdl_UserTiers, { foreignKey: 'user_tier_id', as: 'userTier' }); +mdl_UserTierGrants.belongsTo(mdl_Users, { foreignKey: 'user_id', as: 'user' }); +mdl_UserTierGrants.belongsTo(mdl_TierPlans, { foreignKey: 'plan_id', as: 'plan' }); // ─── SystemBadge → Asset ───────────────────────────────────────────────────── mdl_SystemBadges.belongsTo(Asset, { foreignKey: 'asset_id', as: 'asset' }); @@ -104,6 +106,6 @@ module.exports = { mdl_PlanCourses, mdl_PlanUnits, mdl_PlanLessons, - mdl_PlanPolicies, mdl_SystemBadges, + mdl_UserTierGrants, }; diff --git a/models/tiers/tier_plans.mdl.js b/models/tiers/tier_plans.mdl.js index 4af557f..7c59543 100644 --- a/models/tiers/tier_plans.mdl.js +++ b/models/tiers/tier_plans.mdl.js @@ -21,6 +21,7 @@ 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' }, + status: { type: DataTypes.ENUM('draft', 'published'), allowNull: false, defaultValue: 'draft', label: 'Status', hidden: false, filterable: true }, createdBy: { type: DataTypes.BIGINT, allowNull: true }, updatedBy: { type: DataTypes.BIGINT, allowNull: true }, deletedBy: { type: DataTypes.BIGINT, allowNull: true }, diff --git a/models/tiers/user_tier_grants.mdl.js b/models/tiers/user_tier_grants.mdl.js new file mode 100644 index 0000000..6df8ad4 --- /dev/null +++ b/models/tiers/user_tier_grants.mdl.js @@ -0,0 +1,30 @@ +/*********************************************************************************************************************************************************************** + * File Name: user_tier_grants.mdl.js + * Type of Program: Model + * Description: Purchase-time snapshot of the exact courses/units/lessons a + * Tier Plan purchase granted. Replaces live re-resolution off + * plan_courses/plan_units/plan_lessons at access-check time, so + * editing a plan's bundle later no longer retroactively changes + * what past purchasers can access. See hasItemGrant() in + * controllers/client/courses.controller.js. + * Author: Kenneth Obsequio (@lash0000) + * Date Created: Aug. 4, 2026 + ***********************************************************************************************************************************************************************/ +const { DataTypes } = require('sequelize'); +const sequelize = require('../../config/db.config'); + +const mdl_UserTierGrants = sequelize.define('UserTierGrant', { + id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true }, + user_tier_id: { type: DataTypes.BIGINT, allowNull: false, label: 'User Tier ID' }, + user_id: { type: DataTypes.BIGINT, allowNull: false, label: 'User ID' }, + plan_id: { type: DataTypes.BIGINT, allowNull: true, label: 'Plan ID' }, + item_type: { type: DataTypes.STRING(10), allowNull: false, label: 'Item Type' }, + item_id: { type: DataTypes.BIGINT, allowNull: false, label: 'Item ID' }, + granted_at: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW, label: 'Granted At' }, +}, { + tableName: 'user_tier_grants', + timestamps: true, + paranoid: false, +}); + +module.exports = mdl_UserTierGrants; diff --git a/routes/admin/tier_policies.routes.js b/routes/admin/tier_policies.routes.js index 8df2f5e..8a9977c 100644 --- a/routes/admin/tier_policies.routes.js +++ b/routes/admin/tier_policies.routes.js @@ -2,12 +2,6 @@ const express = require('express'); const router = express.Router(); const ctrl = require('../../controllers/admin/tier_policies.controller'); -// ─── Plan Policies (access rules) ──────────────────────────────────────────── -// GET /admin/tiers/plans/:planId/policy -// PUT /admin/tiers/plans/:planId/policy — JSON: { access_rules } -router.get('/plans/:planId/policy', ctrl.getPlanPolicy); -router.put('/plans/:planId/policy', ctrl.upsertPlanPolicy); - // ─── Payment Policies (promo codes + refund window) ─────────────────────────── // GET /admin/tiers/plans/:planId/payment-policy // PUT /admin/tiers/plans/:planId/payment-policy — JSON: { promo_rules, refund_policy, allowed_providers } diff --git a/services/planAccess.service.js b/services/planAccess.service.js new file mode 100644 index 0000000..99d937c --- /dev/null +++ b/services/planAccess.service.js @@ -0,0 +1,89 @@ +/*********************************************************************************************************************************************************************** + * File Name: planAccess.service.js + * Type of Program: Service + * Description: Force-revokes access for every currently active subscriber of + * a Tier Plan (no refund). Always fired automatically as part of + * archivePlan/bulkArchivePlans (archiving a plan revokes its + * subscribers' access, full stop — no opt-in) and again as part + * of permanentlyDeletePlan/bulkPermanentlyDeletePlans, in case a + * plan was archived before this existed and still has active + * subscribers when it's finally deleted for good. + * Author: Kenneth Obsequio (@lash0000) + * Date Created: Aug. 4, 2026 + ***********************************************************************************************************************************************************************/ +'use strict'; + +const mdl_UserTiers = require('../models/tiers/user_tiers.mdl'); +const mdl_Users = require('../models/users/users.mdl'); +const UserNotification = require('../models/notifications/user_notification.mdl'); +const { NOTIFICATION_REGISTRY } = require('../data/notifications.data'); +const { sendEmail } = require('./email.service'); +const { fmtDate } = require('../utils/datetime.util'); + +// Revokes every active user_tiers row tied to `plan`, replicating revokeTier's +// per-user "auto-downgrade to Free if no other active tier remains" rule +// (controllers/admin/tiers.controller.js) — a user can hold more than one +// concurrently-active plan, so this can't be a blanket status update. +async function revokePlanSubscriberAccess(plan, revokedByUserId) { + const activeRows = await mdl_UserTiers.findAll({ + where: { plan_id: plan.plan_id, status: 'active' }, + attributes: ['tier_id', 'user_id'], + }); + if (!activeRows.length) return { revoked_user_count: 0 }; + + const tierIds = activeRows.map((r) => r.tier_id); + const userIds = [...new Set(activeRows.map((r) => String(r.user_id)))]; + const now = new Date(); + + await mdl_UserTiers.update( + { status: 'revoked', revoked_by: revokedByUserId, revoked_at: now }, + { where: { tier_id: tierIds } }, + ); + + for (const user_id of userIds) { + const remainingActive = await mdl_UserTiers.count({ where: { user_id, status: 'active' } }); + if (remainingActive === 0) { + await mdl_UserTiers.create({ + user_id, + tier: 'free', + status: 'active', + starts_at: now, + expires_at: null, + granted_by: revokedByUserId, + notes: 'Auto-downgrade after plan access was force-revoked.', + }); + } + } + + try { + const notify = NOTIFICATION_REGISTRY.tier_plan_access_revoked.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('[PLAN ACCESS REVOKE][NOTIFY]', notifyErr); + } + + try { + const users = await mdl_Users.findAll({ + where: { user_id: userIds }, + attributes: ['user_id', 'email', 'personal_info'], + }); + const dateStr = fmtDate(now); + for (const u of users) { + const name = u.personal_info?.name?.full_name ?? 'there'; + sendEmail({ + to: u.email, + type: 'TIER_ACCESS_REVOKED', + data: { name, label: plan.label, date: dateStr }, + }).catch((emailErr) => console.error('[PLAN ACCESS REVOKE][EMAIL]', emailErr)); + } + } catch (emailBatchErr) { + console.error('[PLAN ACCESS REVOKE][EMAIL BATCH]', emailBatchErr); + } + + return { revoked_user_count: userIds.length }; +} + +module.exports = { revokePlanSubscriberAccess }; diff --git a/services/tierGrants.service.js b/services/tierGrants.service.js new file mode 100644 index 0000000..1a985ca --- /dev/null +++ b/services/tierGrants.service.js @@ -0,0 +1,56 @@ +/*********************************************************************************************************************************************************************** + * File Name: tierGrants.service.js + * Type of Program: Service + * Description: Snapshots a Tier Plan's current bundle contents (plan_courses/ + * plan_units/plan_lessons — exactly one of the three per plan, + * per the single-type bundle rule) into user_tier_grants for a + * given user_tiers row. Shared by the client purchase-capture + * flow (controllers/client/tiers.controller.js#captureOrder) and + * the admin manual-grant flow (controllers/admin/tiers.controller.js#grantTier) + * so both paths produce the same item-specific entitlement. + * Author: Kenneth Obsequio (@lash0000) + * Date Created: Aug. 4, 2026 + ***********************************************************************************************************************************************************************/ +'use strict'; + +const { + mdl_PlanCourses, + mdl_PlanUnits, + mdl_PlanLessons, + mdl_UserTierGrants, +} = require('../models/tiers/tier.associations'); + +// Replaces every grant row tied to this user_tiers purchase with a fresh +// snapshot of the plan's current bundle — safe to call again on repeat +// purchase/extension of the same plan (refreshes to whatever the plan +// currently contains, in case an admin edited it since the first purchase). +async function snapshotPlanGrants(userTierRow, planId) { + await mdl_UserTierGrants.destroy({ where: { user_tier_id: userTierRow.tier_id } }); + if (!planId) return; + + const [courseRows, unitRows, lessonRows] = await Promise.all([ + mdl_PlanCourses.findAll({ where: { plan_id: planId }, attributes: ['course_id'] }), + mdl_PlanUnits.findAll({ where: { plan_id: planId }, attributes: ['unit_id'] }), + mdl_PlanLessons.findAll({ where: { plan_id: planId }, attributes: ['lesson_id'] }), + ]); + + const grants = [ + ...courseRows.map((r) => ({ item_type: 'course', item_id: r.course_id })), + ...unitRows.map((r) => ({ item_type: 'unit', item_id: r.unit_id })), + ...lessonRows.map((r) => ({ item_type: 'lesson', item_id: r.lesson_id })), + ]; + if (!grants.length) return; + + await mdl_UserTierGrants.bulkCreate( + grants.map((g) => ({ + user_tier_id: userTierRow.tier_id, + user_id: userTierRow.user_id, + plan_id: planId, + item_type: g.item_type, + item_id: g.item_id, + granted_at: new Date(), + })), + ); +} + +module.exports = { snapshotPlanGrants }; diff --git a/tests/controllers/courses.controller.access.test.js b/tests/controllers/courses.controller.access.test.js new file mode 100644 index 0000000..efbda5a --- /dev/null +++ b/tests/controllers/courses.controller.access.test.js @@ -0,0 +1,82 @@ +'use strict'; + +// ── Regression tests for item-specific entitlement (Tier Plans v2) ────────── +// Before this change, canAccessCourse/canAccessUnit/canAccessLesson unlocked +// content purely off tier RANK (any active Premium purchase unlocked every +// premium-tagged course). Now a Tier Plan purchase only grants the EXACT +// items its bundle contained at purchase time (recorded in +// user_tier_grants) — plain tier rank alone must no longer be sufficient. +// These tests exercise the real controller/model objects (Sequelize doesn't +// connect until a query actually runs) and stub only the specific static +// methods each scenario touches, via jest.spyOn. + +const { Course } = require('../../models/courses/courses.associations'); +const { mdl_UserTierGrants } = require('../../models/tiers/tier.associations'); +const mdl_UserTiers = require('../../models/tiers/user_tiers.mdl'); +const mdl_TierCategories = require('../../models/tiers/tier_categories.mdl'); +const { mdl_UserGroupMembers } = require('../../models/users/user_groups.mdl'); +const mdl_Product = require('../../models/courses/products.mdl'); +const mdl_CoursePurchase = require('../../models/courses/course_purchases.mdl'); + +const { canAccessCourse } = require('../../controllers/client/courses.controller'); + +function mockActiveTierUnused() { + jest.spyOn(mdl_UserTiers, 'findAll').mockResolvedValue([]); + jest.spyOn(mdl_TierCategories, 'findAll').mockResolvedValue([]); + jest.spyOn(mdl_UserGroupMembers, 'findAll').mockResolvedValue([]); +} + +afterEach(() => jest.restoreAllMocks()); + +describe('canAccessCourse() — item-specific entitlement', () => { + test('free/ungated course is always accessible, no grant lookup needed', async () => { + jest.spyOn(Course, 'findOne').mockResolvedValue({ subscription: 'free', status: 'published' }); + const grantSpy = jest.spyOn(mdl_UserTierGrants, 'findOne'); + + const result = await canAccessCourse(1, 100); + + expect(result).toBe(true); + expect(grantSpy).not.toHaveBeenCalled(); + }); + + test('unpublished course is never accessible regardless of entitlement', async () => { + jest.spyOn(Course, 'findOne').mockResolvedValue({ subscription: 'premium', status: 'draft' }); + + const result = await canAccessCourse(1, 100); + + expect(result).toBe(false); + }); + + test('premium course with no grant, no purchase, no active tier → denied (tier rank alone no longer grants access)', async () => { + jest.spyOn(Course, 'findOne').mockResolvedValue({ subscription: 'premium', status: 'published' }); + jest.spyOn(mdl_UserTierGrants, 'findOne').mockResolvedValue(null); + jest.spyOn(mdl_Product, 'findOne').mockResolvedValue(null); + mockActiveTierUnused(); + + const result = await canAccessCourse(1, 100); + + expect(result).toBe(false); + }); + + test('premium course unlocked by an item-specific grant for THIS exact course', async () => { + jest.spyOn(Course, 'findOne').mockResolvedValue({ subscription: 'premium', status: 'published' }); + jest.spyOn(mdl_UserTierGrants, 'findOne').mockResolvedValue({ item_id: 100 }); // active grant found + mockActiveTierUnused(); + + const result = await canAccessCourse(1, 100); + + expect(result).toBe(true); + }); + + test('premium course still unlocked via a valid individual (a la carte) purchase, unrelated to any tier grant', async () => { + jest.spyOn(Course, 'findOne').mockResolvedValue({ subscription: 'premium', status: 'published' }); + jest.spyOn(mdl_UserTierGrants, 'findOne').mockResolvedValue(null); + jest.spyOn(mdl_Product, 'findOne').mockResolvedValue({ id: 55 }); + jest.spyOn(mdl_CoursePurchase, 'findOne').mockResolvedValue({ id: 1 }); + mockActiveTierUnused(); + + const result = await canAccessCourse(1, 100); + + expect(result).toBe(true); + }); +}); diff --git a/tests/controllers/tiers.controller.capture.test.js b/tests/controllers/tiers.controller.capture.test.js index a8accf3..c00452a 100644 --- a/tests/controllers/tiers.controller.capture.test.js +++ b/tests/controllers/tiers.controller.capture.test.js @@ -22,6 +22,10 @@ jest.mock('../../models/courses/courses.mdl', () => ({ Course: {} })); jest.mock('../../services/payment.service', () => ({ captureOrder: jest.fn() })); jest.mock('../../data/notifications.data', () => ({ NOTIFICATION_REGISTRY: {} })); jest.mock('../../models/tiers/tier.associations', () => ({})); +// captureOrder now snapshots the plan's bundle into user_tier_grants on every +// successful capture (Tier Plans v2 item-specific entitlement) — mocked out +// here since these tests only pin the payment-status gating behavior. +jest.mock('../../services/tierGrants.service', () => ({ snapshotPlanGrants: jest.fn() })); const mdl_UserTiers = require('../../models/tiers/user_tiers.mdl'); const mdl_Payments = require('../../models/tiers/payments.mdl'); diff --git a/tests/services/planAccess.service.test.js b/tests/services/planAccess.service.test.js new file mode 100644 index 0000000..cf15cfe --- /dev/null +++ b/tests/services/planAccess.service.test.js @@ -0,0 +1,91 @@ +'use strict'; + +// ── Regression tests for the "revoke plan access, no refund" feature ──────── +// revokePlanSubscriberAccess() must replicate revokeTier's single-record +// "auto-downgrade to Free only if the user holds no other active tier" rule +// (controllers/admin/tiers.controller.js) instead of blanket-downgrading +// everyone, and must fire exactly one in-app notification batch plus one +// email per affected user. + +jest.mock('../../models/tiers/user_tiers.mdl', () => ({ + findAll: jest.fn(), + update: jest.fn(), + count: jest.fn(), + create: jest.fn(), +})); +jest.mock('../../models/users/users.mdl', () => ({ findAll: jest.fn() })); +jest.mock('../../models/notifications/user_notification.mdl', () => ({ bulkCreate: jest.fn() })); +jest.mock('../../services/email.service', () => ({ sendEmail: jest.fn().mockResolvedValue(true) })); + +const mdl_UserTiers = require('../../models/tiers/user_tiers.mdl'); +const mdl_Users = require('../../models/users/users.mdl'); +const UserNotification = require('../../models/notifications/user_notification.mdl'); +const { sendEmail } = require('../../services/email.service'); + +const { revokePlanSubscriberAccess } = require('../../services/planAccess.service'); + +function makePlan(overrides = {}) { + return { plan_id: 10, label: 'Premium – 1 Month', ...overrides }; +} + +beforeEach(() => jest.clearAllMocks()); + +describe('revokePlanSubscriberAccess()', () => { + test('no active subscribers → no-op, returns zero count', async () => { + mdl_UserTiers.findAll.mockResolvedValue([]); + + const result = await revokePlanSubscriberAccess(makePlan(), 99); + + expect(result).toEqual({ revoked_user_count: 0 }); + expect(mdl_UserTiers.update).not.toHaveBeenCalled(); + expect(UserNotification.bulkCreate).not.toHaveBeenCalled(); + expect(sendEmail).not.toHaveBeenCalled(); + }); + + test('a user with ONLY this plan active gets auto-downgraded to Free', async () => { + mdl_UserTiers.findAll.mockResolvedValue([{ tier_id: 1, user_id: 5 }]); + mdl_UserTiers.count.mockResolvedValue(0); // no other active tier remains + mdl_Users.findAll.mockResolvedValue([{ user_id: 5, email: 'a@b.com', personal_info: { name: { full_name: 'Ana' } } }]); + + const result = await revokePlanSubscriberAccess(makePlan(), 99); + + expect(mdl_UserTiers.update).toHaveBeenCalledWith( + expect.objectContaining({ status: 'revoked', revoked_by: 99 }), + { where: { tier_id: [1] } } + ); + expect(mdl_UserTiers.create).toHaveBeenCalledWith( + expect.objectContaining({ user_id: '5', tier: 'free', status: 'active' }) + ); + expect(result).toEqual({ revoked_user_count: 1 }); + }); + + test('a user with ANOTHER concurrently-active plan does NOT get downgraded', async () => { + mdl_UserTiers.findAll.mockResolvedValue([{ tier_id: 2, user_id: 6 }]); + mdl_UserTiers.count.mockResolvedValue(1); // still holds a different active plan + mdl_Users.findAll.mockResolvedValue([{ user_id: 6, email: 'c@d.com', personal_info: {} }]); + + await revokePlanSubscriberAccess(makePlan(), 99); + + expect(mdl_UserTiers.create).not.toHaveBeenCalled(); + }); + + test('fires exactly one notification batch and one email per affected user', async () => { + mdl_UserTiers.findAll.mockResolvedValue([ + { tier_id: 1, user_id: 5 }, + { tier_id: 2, user_id: 6 }, + ]); + mdl_UserTiers.count.mockResolvedValue(1); + mdl_Users.findAll.mockResolvedValue([ + { user_id: 5, email: 'a@b.com', personal_info: {} }, + { user_id: 6, email: 'c@d.com', personal_info: {} }, + ]); + + const result = await revokePlanSubscriberAccess(makePlan(), 99); + + expect(UserNotification.bulkCreate).toHaveBeenCalledTimes(1); + expect(UserNotification.bulkCreate.mock.calls[0][0]).toHaveLength(2); + expect(sendEmail).toHaveBeenCalledTimes(2); + expect(sendEmail).toHaveBeenCalledWith(expect.objectContaining({ to: 'a@b.com', type: 'TIER_ACCESS_REVOKED' })); + expect(result).toEqual({ revoked_user_count: 2 }); + }); +}); diff --git a/tests/utils/accessPolicy.test.js b/tests/utils/accessPolicy.test.js deleted file mode 100644 index 67904b3..0000000 --- a/tests/utils/accessPolicy.test.js +++ /dev/null @@ -1,192 +0,0 @@ -'use strict'; - -const { evaluateCourseAccess, TIER_RANK } = require('../../utils/accessPolicy.util'); - -// ── Helpers ─────────────────────────────────────────────────────────────────── - -function ctx(tier = 'free', access_rules = [], group_ids = []) { - return { tier, access_rules, group_ids }; -} - -function course(subscription = 'free') { - return { subscription }; -} - -// ── Free course is always accessible ───────────────────────────────────────── - -describe('free course (rank 0)', () => { - test('free user can access free course', () => { - expect(evaluateCourseAccess(ctx('free'), course('free'))).toEqual({ allowed: true, reason: null }); - }); - - test('premium user can access free course', () => { - expect(evaluateCourseAccess(ctx('premium'), course('free'))).toEqual({ allowed: true, reason: null }); - }); - - test('free user with no rules can still access free course', () => { - expect(evaluateCourseAccess(ctx('free', []), course('free'))).toEqual({ allowed: true, reason: null }); - }); -}); - -// ── No access_rules — fallback tier rank comparison ────────────────────────── - -describe('no access_rules (fallback mode)', () => { - test('premium user can access premium course', () => { - const result = evaluateCourseAccess(ctx('premium', []), course('premium'), TIER_RANK); - expect(result).toEqual({ allowed: true, reason: null }); - }); - - test('exclusive user can access exclusive course', () => { - const result = evaluateCourseAccess(ctx('exclusive', []), course('exclusive'), TIER_RANK); - expect(result).toEqual({ allowed: true, reason: null }); - }); - - test('free user cannot access premium course', () => { - const result = evaluateCourseAccess(ctx('free', []), course('premium'), TIER_RANK); - expect(result).toEqual({ allowed: false, reason: 'tier_rank' }); - }); - - test('premium user cannot access exclusive course', () => { - const result = evaluateCourseAccess(ctx('premium', []), course('exclusive'), TIER_RANK); - expect(result).toEqual({ allowed: false, reason: 'tier_rank' }); - }); - - test('unknown course subscription slug → denied (safe default)', () => { - const result = evaluateCourseAccess(ctx('exclusive', []), course('unknown-tier'), TIER_RANK); - expect(result.allowed).toBe(false); - }); -}); - -// ── Rule: course_subscription_access ───────────────────────────────────────── - -describe('rule: course_subscription_access', () => { - const rules = [{ type: 'course_subscription_access', levels: ['free', 'premium'] }]; - - test('plan covers premium → allowed', () => { - const result = evaluateCourseAccess(ctx('premium', rules), course('premium'), TIER_RANK); - expect(result.allowed).toBe(true); - }); - - test('plan does not cover exclusive → denied', () => { - const result = evaluateCourseAccess(ctx('exclusive', rules), course('exclusive'), TIER_RANK); - expect(result).toEqual({ allowed: false, reason: 'subscription_access' }); - }); -}); - -// ── Rule: required_active_tier ──────────────────────────────────────────────── - -describe('rule: required_active_tier', () => { - const rules = [{ type: 'required_active_tier', tier: 'premium' }]; - - test('premium user meets premium requirement → allowed', () => { - const result = evaluateCourseAccess(ctx('premium', rules), course('premium'), TIER_RANK); - expect(result.allowed).toBe(true); - }); - - test('exclusive user exceeds premium requirement → allowed', () => { - const result = evaluateCourseAccess(ctx('exclusive', rules), course('premium'), TIER_RANK); - expect(result.allowed).toBe(true); - }); - - test('free user fails premium requirement → denied', () => { - const result = evaluateCourseAccess(ctx('free', rules), course('premium'), TIER_RANK); - expect(result).toEqual({ allowed: false, reason: 'required_tier' }); - }); - - test('unknown required tier slug → always denied', () => { - const badRules = [{ type: 'required_active_tier', tier: 'ghost-tier' }]; - const result = evaluateCourseAccess(ctx('exclusive', badRules), course('premium'), TIER_RANK); - expect(result.allowed).toBe(false); - }); -}); - -// ── Rule: group_restriction ─────────────────────────────────────────────────── - -describe('rule: group_restriction', () => { - const rules = [{ type: 'group_restriction', group_ids: [5, 10] }]; - - test('user in allowed group → allowed', () => { - const result = evaluateCourseAccess(ctx('premium', rules, [10, 20]), course('premium'), TIER_RANK); - expect(result.allowed).toBe(true); - }); - - test('user not in any allowed group → denied', () => { - const result = evaluateCourseAccess(ctx('premium', rules, [99]), course('premium'), TIER_RANK); - expect(result).toEqual({ allowed: false, reason: 'group_restriction' }); - }); - - test('empty group_ids on rule → no restriction (passes)', () => { - const openRules = [{ type: 'group_restriction', group_ids: [] }]; - const result = evaluateCourseAccess(ctx('premium', openRules, []), course('premium'), TIER_RANK); - expect(result.allowed).toBe(true); - }); -}); - -// ── 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', () => { - test('fails if any one rule blocks', () => { - const rules = [ - { type: 'course_subscription_access', levels: ['premium', 'exclusive'] }, - { type: 'required_active_tier', tier: 'premium' }, - { type: 'group_restriction', group_ids: [7] }, - ]; - // All pass - expect(evaluateCourseAccess(ctx('premium', rules, [7]), course('premium'), TIER_RANK).allowed).toBe(true); - - // Fails group_restriction - expect(evaluateCourseAccess(ctx('premium', rules, [99]), course('premium'), TIER_RANK).allowed).toBe(false); - - // Fails required_active_tier - expect(evaluateCourseAccess(ctx('free', rules, [7]), course('premium'), TIER_RANK).allowed).toBe(false); - }); -}); diff --git a/utils/accessPolicy.util.js b/utils/accessPolicy.util.js deleted file mode 100644 index d15dac5..0000000 --- a/utils/accessPolicy.util.js +++ /dev/null @@ -1,108 +0,0 @@ -/*********************************************************************************************************************************************************************** - * File Name: accessPolicy.util.js - * Type of Program: Utility - * Description: Evaluates a user's access to a course based on their active plan's access_rules JSONB. - * - * Rule types: - * course_subscription_access — { type, levels: ['free','premium','exclusive'] } - * → The plan grants access to these subscription levels only. - * required_active_tier — { type, tier: 'premium' | 'exclusive' } - * → 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. - ***********************************************************************************************************************************************************************/ -'use strict'; - -// Default rank map used as fallback when a live DB map is not available. -// Overridden at call time with ranks loaded from tier_categories. -const TIER_RANK = { free: 0, premium: 1, exclusive: 2 }; - -/** - * 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/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, 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) - const courseRank = tierRankMap[courseSubscription] ?? Infinity; - - // 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 - ? { allowed: true, reason: null } - : { allowed: false, reason: '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' }; - } - } - - if (rule.type === 'required_active_tier') { - // Unknown rule tier slug → Infinity, so the rule always blocks - const reqRank = tierRankMap[rule.tier] ?? Infinity; - if (userRank < reqRank) { - return { allowed: false, reason: 'required_tier' }; - } - } - - if (rule.type === 'group_restriction') { - const required = (rule.group_ids ?? []).map(Number); - if (required.length > 0) { - const inGroup = required.some((gid) => group_ids.includes(gid)); - if (!inGroup) return { allowed: false, reason: 'group_restriction' }; - } - } - } - - return { allowed: true, reason: null }; -} - -module.exports = { evaluateCourseAccess, TIER_RANK };