mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
237 lines
13 KiB
JavaScript
237 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;
|
|
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,
|
|
};
|