// This will do mandatory call const MAX_ATTEMPTS = 10; const ATTEMPT_WINDOW_HOURS = 24; const COOLDOWN_MINUTES = 60; // Fisher-Yates shuffle of each question's options. 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. 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 }; }); } // Single source of truth for both the GET-time info fields and the // submit-time enforcement check. Doesn't care about input order — // derives best/most-recent itself, so callers can just fetch attempts // with no ORDER BY. function getAttemptStatus(attempts) { const now = new Date(); const windowStart = new Date(now.getTime() - ATTEMPT_WINDOW_HOURS * 60 * 60 * 1000); const attemptsInWindow = attempts.filter((a) => new Date(a.createdAt) >= windowStart); const attempt_count = attempts.length; // lifetime — still used for has_passed/best_attempt const has_passed = attempts.some((a) => a.passed); const best_attempt = attempts.reduce( (best, a) => (!best || a.score > best.score ? a : best), null ); const most_recent = attempts.reduce( (latest, a) => (!latest || new Date(a.createdAt) > new Date(latest.createdAt) ? a : latest), null ); let cooldown_until = null; if (most_recent) { const unlockAt = new Date(new Date(most_recent.createdAt).getTime() + COOLDOWN_MINUTES * 60000); if (unlockAt > now) cooldown_until = unlockAt.toISOString(); } const attempts_remaining = Math.max(0, MAX_ATTEMPTS - attemptsInWindow.length); let window_reset_at = null; if (attempts_remaining === 0 && attemptsInWindow.length > 0) { const oldestInWindow = attemptsInWindow.reduce( (oldest, a) => (!oldest || new Date(a.createdAt) < new Date(oldest.createdAt) ? a : oldest), null ); window_reset_at = new Date( new Date(oldestInWindow.createdAt).getTime() + ATTEMPT_WINDOW_HOURS * 60 * 60 * 1000 ).toISOString(); } const can_attempt = attempts_remaining > 0 && !cooldown_until; return { attempt_count, has_passed, best_attempt, attempts_remaining, cooldown_until, window_reset_at, can_attempt }; } module.exports = { MAX_ATTEMPTS, ATTEMPT_WINDOW_HOURS, COOLDOWN_MINUTES, shuffleOptions, getAttemptStatus };