/*********************************************************************************************************************************************************************** * 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 };