mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
@@ -0,0 +1,78 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* 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.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* @param {object} ctx
|
||||
* @param {string} ctx.tier — user's active tier slug
|
||||
* @param {Array} ctx.access_rules — plan_policies.access_rules (may be empty)
|
||||
* @param {number[]} ctx.group_ids — group IDs the user belongs to
|
||||
* @param {object} course
|
||||
* @param {string} course.subscription — course subscription level (slug)
|
||||
* @param {Object} tierRankMap — { [slug]: rank } loaded from tier_categories; falls back to TIER_RANK
|
||||
* @returns {{ allowed: boolean, reason: string|null }}
|
||||
*/
|
||||
function evaluateCourseAccess(ctx, course, tierRankMap = TIER_RANK) {
|
||||
const { tier = 'free', access_rules = [], group_ids = [] } = ctx;
|
||||
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 };
|
||||
|
||||
// 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 === '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 };
|
||||
@@ -1,21 +1,25 @@
|
||||
// Assessment cooldown policy is now stored per-assessment in the DB (max_attempts / cooldown_hours).
|
||||
// These fallbacks are used only if values are missing (e.g. legacy rows before the migration).
|
||||
const ASSESSMENT_FAILS_BEFORE_COOLDOWN = 3;
|
||||
const ASSESSMENT_COOLDOWN_HOURS = 24;
|
||||
// Fisher-Yates in-place shuffle — shared by shuffleOptions and shuffleQuestions.
|
||||
function fisherYates(arr) {
|
||||
for (let i = arr.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[arr[i], arr[j]] = [arr[j], arr[i]];
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
|
||||
// Fisher-Yates shuffle of each question's options. Pure — returns new
|
||||
// Randomises the ORDER OF OPTIONS within each question. Pure — returns new
|
||||
// arrays/objects, never mutates input. Grading is unaffected since
|
||||
// submitUnitQuiz/submitCourseAssessment always re-fetch questions fresh
|
||||
// from the DB and never trust shuffled client-facing order.
|
||||
// from the DB and never trust the shuffled client-facing order.
|
||||
function shuffleOptions(questions) {
|
||||
return questions.map((q) => {
|
||||
const options = [...(q.options ?? [])];
|
||||
for (let i = options.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[options[i], options[j]] = [options[j], options[i]];
|
||||
}
|
||||
return { ...q, options };
|
||||
});
|
||||
return questions.map((q) => ({ ...q, options: fisherYates([...(q.options ?? [])]) }));
|
||||
}
|
||||
|
||||
// Randomises the ORDER OF QUESTIONS. Pure — returns a new array.
|
||||
// Safe: grading re-fetches questions from DB in stored order; client position
|
||||
// has no effect on correctness checks.
|
||||
function shuffleQuestions(questions) {
|
||||
return fisherYates([...questions]);
|
||||
}
|
||||
|
||||
// Single source of truth for both the GET-time info fields and the
|
||||
@@ -23,7 +27,7 @@ function shuffleOptions(questions) {
|
||||
//
|
||||
// type = 'quiz' → unit quizzes: no cooldown, no attempt cap, always open
|
||||
// type = 'assessment' → course assessments: maxFails failed attempts → cooldownHours cooldown (rolling cycles)
|
||||
// maxFails / cooldownHours come from the assessment row; fallback to the constants above.
|
||||
// maxFails / cooldownHours come from the assessment row; null = feature off (no limit/cooldown).
|
||||
function getAttemptStatus(attempts, type = 'quiz', { maxFails, cooldownHours } = {}) {
|
||||
const attempt_count = attempts.length;
|
||||
const has_passed = attempts.some((a) => a.passed);
|
||||
@@ -44,9 +48,22 @@ function getAttemptStatus(attempts, type = 'quiz', { maxFails, cooldownHours } =
|
||||
};
|
||||
}
|
||||
|
||||
// Assessment: simulate rolling cycles — N failed attempts → cooldown (from the assessment's own config)
|
||||
const failLimit = maxFails ?? ASSESSMENT_FAILS_BEFORE_COOLDOWN;
|
||||
const lockHours = cooldownHours ?? ASSESSMENT_COOLDOWN_HOURS;
|
||||
// Assessment: simulate rolling cycles — N failed attempts → cooldown.
|
||||
// null means the feature is off: no attempt cap / no cooldown.
|
||||
const failLimit = maxFails ?? null;
|
||||
const lockHours = cooldownHours ?? null;
|
||||
|
||||
if (failLimit === null || lockHours === null) {
|
||||
return {
|
||||
attempt_count,
|
||||
has_passed,
|
||||
best_attempt,
|
||||
attempts_remaining: null,
|
||||
cooldown_until: null,
|
||||
window_reset_at: null,
|
||||
can_attempt: true,
|
||||
};
|
||||
}
|
||||
|
||||
const sorted = [...attempts].sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt));
|
||||
|
||||
@@ -79,4 +96,4 @@ function getAttemptStatus(attempts, type = 'quiz', { maxFails, cooldownHours } =
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { ASSESSMENT_FAILS_BEFORE_COOLDOWN, ASSESSMENT_COOLDOWN_HOURS, shuffleOptions, getAttemptStatus };
|
||||
module.exports = { shuffleOptions, shuffleQuestions, getAttemptStatus };
|
||||
@@ -0,0 +1,53 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name: datetime.util.js
|
||||
* Type of Program: Utility
|
||||
* Description: Pure date/time formatting helpers for backend use (emails, crons, notifications).
|
||||
*
|
||||
* All functions accept an optional options object: { timezone, locale }
|
||||
* timezone — 'UTC' (default) | 'local' (server's local timezone)
|
||||
* locale — BCP 47 tag, defaults to 'en-US'
|
||||
*
|
||||
* UTC is the default because emails and cron output must be unambiguous regardless
|
||||
* of where the server runs. Pass { timezone: 'local' } only when displaying times
|
||||
* relative to the server's configured locale (e.g. admin dashboards, server logs).
|
||||
***********************************************************************************************************************************************************************/
|
||||
|
||||
function tzOpt(timezone) {
|
||||
return timezone === 'local' ? {} : { timeZone: 'UTC' };
|
||||
}
|
||||
|
||||
function loc(locale) {
|
||||
return locale ?? 'en-US';
|
||||
}
|
||||
|
||||
/** "June 27, 2026" */
|
||||
function fmtDate(value, { timezone = 'UTC', locale } = {}) {
|
||||
if (!value) return '—';
|
||||
return new Date(value).toLocaleDateString(loc(locale), {
|
||||
month: 'long', day: 'numeric', year: 'numeric',
|
||||
...tzOpt(timezone),
|
||||
});
|
||||
}
|
||||
|
||||
/** "June 27, 2026, 3:45 PM UTC" */
|
||||
function fmtDateTime(value, { timezone = 'UTC', locale } = {}) {
|
||||
if (!value) return '—';
|
||||
return new Date(value).toLocaleString(loc(locale), {
|
||||
month: 'long', day: 'numeric', year: 'numeric',
|
||||
hour: 'numeric', minute: '2-digit',
|
||||
timeZoneName: 'short',
|
||||
...tzOpt(timezone),
|
||||
});
|
||||
}
|
||||
|
||||
/** "3:45 PM UTC" */
|
||||
function fmtTime(value, { timezone = 'UTC', locale } = {}) {
|
||||
if (!value) return '—';
|
||||
return new Date(value).toLocaleTimeString(loc(locale), {
|
||||
hour: 'numeric', minute: '2-digit',
|
||||
timeZoneName: 'short',
|
||||
...tzOpt(timezone),
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { fmtDate, fmtDateTime, fmtTime };
|
||||
@@ -25,6 +25,7 @@ function estimateBlockDuration(block) {
|
||||
|
||||
switch (block.type) {
|
||||
case "text":
|
||||
case "markdown":
|
||||
return readingSecs(block.content?.body);
|
||||
|
||||
case "image":
|
||||
|
||||
Reference in New Issue
Block a user