pushy

Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
2026-06-28 11:29:07 +08:00
parent 463a8d3978
commit 89acdfc239
67 changed files with 2736 additions and 306 deletions
+78
View File
@@ -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 };
+36 -19
View File
@@ -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 };
+53
View File
@@ -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 };
+1
View File
@@ -25,6 +25,7 @@ function estimateBlockDuration(block) {
switch (block.type) {
case "text":
case "markdown":
return readingSecs(block.content?.body);
case "image":