'use strict'; /*********************************************************************************************************************************************************************** * File Name: completion_requirements.registry.js * Type of Program: Utility (type → handler registry) * Description: Declarative dispatch table for the completion-requirement types * (read_all_content, pass_quiz, watch_percent, watch_video, listen_audio, * manual_complete), plus the `evaluateEntity` resolver that walks a * course/unit/lesson's configured * CompletionRequirement rows (or falls back to today's implicit rule when * none are configured) and ANDs the results. * * `evaluateEntity` lives here rather than in services/courses/completion.service.js * because unit/course evaluation is recursive (a unit's `read_all_content` rule * means "every child lesson evaluates to completed", which itself may be governed * by that lesson's own configured rule) — keeping the resolver next to the handler * map it recurses through avoids a circular require with the service layer, which * instead imports `evaluateEntity` from here and layers persistence/cascading on top. * * Read dispatch for "is this lesson/unit's own flag completed" branches on whether * a course context is present: * - courseId present → CourseReadingProgress (UUID-keyed, global per user+lesson) * - courseId null → LessonReadingProgress / UnitReadingProgress (BIGINT-keyed, * the only tables that support a null course_id — required * for standalone/library unit-and-lesson consumption, since * CourseReadingProgress.course_id is NOT NULL in the DB). * * Author: Kenneth Obsequio (@lash0000) * Date Created: Jul. 14, 2026 ***********************************************************************************************************************************************************************/ const CompletionRequirement = require('../../models/courses/completion_requirement.mdl'); const CompletionRequirementProgress = require('../../models/courses/completion_requirement_progress.mdl'); const CourseReadingProgress = require('../../models/courses/course_reading_progress.mdl'); const LessonReadingProgress = require('../../models/courses/lesson_reading_progress.mdl'); const Lesson = require('../../models/courses/lessons.mdl'); const UnitQuiz = require('../../models/courses/unit_quiz.mdl'); const CourseAssessment = require('../../models/courses/course_assessment.mdl'); const QuizAttempt = require('../../models/courses/quiz_attempt.mdl'); const { getCourseUnitIds, getUnitLessonIds } = require('./hierarchy.util'); // ─── Leaf read helpers ───────────────────────────────────────────────────────── // Shared by the type handlers below AND by services/courses/completion.service.js, // which uses these same reads immediately after writing progress within one transaction. // Shared by manual_complete / watch_video / listen_audio — all three are computed // entirely at write time (recordManualComplete / recordWatchProgress) and just read // back the persisted `completed` flag here. async function isProgressCompleted({ requirement, userId, t }) { const progress = await CompletionRequirementProgress.findOne({ where: { requirement_id: requirement.requirement_id, user_id: userId }, transaction: t, }); return !!progress?.completed; } async function isLessonComplete({ lessonId, userId, courseId, t }) { if (courseId) { const lesson = await Lesson.findOne({ where: { lesson_id: lessonId }, attributes: ['uuid'], transaction: t }); if (!lesson) return false; const row = await CourseReadingProgress.findOne({ where: { user_id: userId, type: 'lesson', reference_id: lesson.uuid, status: 'completed' }, transaction: t, }); return !!row; } const row = await LessonReadingProgress.findOne({ where: { user_id: userId, lesson_id: lessonId, status: 'completed' }, transaction: t, }); return !!row; } async function isUnitQuizPassed({ unitId, userId, t }) { const quiz = await UnitQuiz.findOne({ where: { unit_id: unitId }, attributes: ['quiz_id'], transaction: t }); if (!quiz) return false; const attempt = await QuizAttempt.findOne({ where: { quiz_id: quiz.quiz_id, user_id: userId, passed: true }, transaction: t, }); return !!attempt; } async function isCourseAssessmentPassed({ courseId, userId, t }) { const assessment = await CourseAssessment.findOne({ where: { course_id: courseId }, attributes: ['assessment_id'], transaction: t }); if (!assessment) return false; const attempt = await QuizAttempt.findOne({ where: { assessment_id: assessment.assessment_id, user_id: userId, passed: true }, transaction: t, }); return !!attempt; } // Every attached (non-archived) lesson under this unit must itself evaluate to 'completed'. async function areAllLessonsComplete({ unitId, userId, courseId, t }) { const lessonIds = await getUnitLessonIds(unitId); if (!lessonIds.length) return false; // no lessons attached — matches old deriveUnitStatus's "!links.length → in_progress" for (const lessonId of lessonIds) { const result = await evaluateEntity({ entityType: 'lesson', entityId: lessonId, userId, courseId, transaction: t }); if (result.status !== 'completed') return false; } return true; } // Every attached (non-archived) unit under this course must itself evaluate to 'completed'. async function areAllUnitsComplete({ courseId, userId, t }) { const unitIds = await getCourseUnitIds(courseId); if (!unitIds.length) return false; // matches old deriveCourseStatus's "!links.length → in_progress" for (const unitId of unitIds) { const result = await evaluateEntity({ entityType: 'unit', entityId: unitId, userId, courseId, transaction: t }); if (result.status !== 'completed') return false; } return true; } // ─── Type handlers ────────────────────────────────────────────────────────────── const TYPE_HANDLERS = { read_all_content: { validEntityTypes: ['course', 'unit', 'lesson'], isSatisfied: async ({ entityType, entityId, userId, courseId, t }) => { if (entityType === 'lesson') return isLessonComplete({ lessonId: entityId, userId, courseId, t }); if (entityType === 'unit') return areAllLessonsComplete({ unitId: entityId, userId, courseId, t }); if (entityType === 'course') return areAllUnitsComplete({ courseId: entityId, userId, t }); return false; }, }, pass_quiz: { validEntityTypes: ['unit', 'course'], isSatisfied: async ({ entityType, entityId, userId, t }) => { if (entityType === 'unit') return isUnitQuizPassed({ unitId: entityId, userId, t }); if (entityType === 'course') return isCourseAssessmentPassed({ courseId: entityId, userId, t }); return false; }, }, watch_percent: { validEntityTypes: ['lesson'], isSatisfied: async ({ userId, requirement, t }) => { const progress = await CompletionRequirementProgress.findOne({ where: { requirement_id: requirement.requirement_id, user_id: userId }, transaction: t, }); if (!progress) return false; if (progress.completed) return true; const minPercent = requirement.min_percent ?? 100; return (progress.progress_percent ?? 0) >= minPercent; }, }, // watch_video / listen_audio: unlike watch_percent (one aggregate figure across // whichever block is playing), these require EVERY block of the matching type on // the lesson to individually reach 100% — recordWatchProgress computes that against // the lesson's current block set and persists the result as `completed`, so reading // it back here is identical to manual_complete's check. watch_video: { validEntityTypes: ['lesson'], isSatisfied: async ({ userId, requirement, t }) => isProgressCompleted({ requirement, userId, t }), }, listen_audio: { validEntityTypes: ['lesson'], isSatisfied: async ({ userId, requirement, t }) => isProgressCompleted({ requirement, userId, t }), }, manual_complete: { validEntityTypes: ['course', 'unit', 'lesson'], isSatisfied: async ({ userId, requirement, t }) => isProgressCompleted({ requirement, userId, t }), }, }; // ─── Zero-requirements-configured fallback ────────────────────────────────────── // Reproduces today's exact implicit behavior so existing content doesn't change // behavior until an admin explicitly opts into configured requirements. const DEFAULT_HANDLERS = { lesson: ({ entityId, userId, courseId, t }) => isLessonComplete({ lessonId: entityId, userId, courseId, t }), unit: ({ entityId, userId, courseId, t }) => areAllLessonsComplete({ unitId: entityId, userId, courseId, t }), // A course with no assessment ever built can never reach 'completed' via this default — // matches the exact (if strict) behavior of the old deriveCourseStatus/hasPassedCourseAssessment. course: async ({ entityId, userId, t }) => { const allUnitsRead = await areAllUnitsComplete({ courseId: entityId, userId, t }); if (!allUnitsRead) return false; return isCourseAssessmentPassed({ courseId: entityId, userId, t }); }, }; // ─── Resolver ──────────────────────────────────────────────────────────────────── /** * Evaluate whether `entityType`/`entityId` is 'completed' for `userId`, either against * its configured CompletionRequirement rows (AND'd, `is_required` rows only gate status) * or — when none are configured — the default implicit rule for that entity_type. * * @returns {{ status: 'completed'|'in_progress', evaluated_via: 'configured'|'default', satisfied: string[] }} */ async function evaluateEntity({ entityType, entityId, userId, courseId = null, transaction = null }) { const t = transaction; const rows = await CompletionRequirement.findAll({ where: { entity_type: entityType, entity_id: entityId }, order: [['order', 'ASC']], transaction: t, }); if (!rows.length) { const completed = await DEFAULT_HANDLERS[entityType]({ entityId, userId, courseId, t }); return { status: completed ? 'completed' : 'in_progress', evaluated_via: 'default', satisfied: [] }; } const satisfied = []; for (const row of rows) { const handler = TYPE_HANDLERS[row.type]; if (!handler) continue; // unknown type — ignore rather than hard-fail evaluation const ok = await handler.isSatisfied({ entityType, entityId, userId, courseId, requirement: row, t }); if (ok) satisfied.push(row.requirement_id); } const requiredRows = rows.filter(r => r.is_required); const allRequiredSatisfied = requiredRows.every(r => satisfied.includes(r.requirement_id)); return { status: allRequiredSatisfied ? 'completed' : 'in_progress', evaluated_via: 'configured', satisfied }; } // entity_type → allowed requirement types, for admin-side validation (mirrors on the frontend). const VALID_ENTITY_TYPES = Object.fromEntries( Object.entries(TYPE_HANDLERS).map(([type, def]) => [type, def.validEntityTypes]) ); module.exports = { evaluateEntity, TYPE_HANDLERS, VALID_ENTITY_TYPES, // exported for reuse by services/courses/completion.service.js isLessonComplete, isUnitQuizPassed, isCourseAssessmentPassed, };