Files
starr-philproperties/apps/api/utils/courses/completion_requirements.registry.js
T

246 lines
13 KiB
JavaScript

'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;
// CockroachDB returns INTEGER columns as strings over the wire (same class of
// issue as the BigInt precision fix) — without Number() coercion this becomes a
// lexicographic string comparison, where e.g. "7" >= "100" is true.
const minPercent = Number(requirement.min_percent ?? 100);
return Number(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;
// Units always complete by passing their own quiz — overrides whatever
// completion_requirement type (if any) is configured on the unit. A unit
// with no quiz attached never completes (isUnitQuizPassed returns false).
if (entityType === 'unit') {
const completed = await isUnitQuizPassed({ unitId: entityId, userId, t });
return { status: completed ? 'completed' : 'in_progress', evaluated_via: 'quiz_required', satisfied: [] };
}
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,
};