mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
chore: relocate backend into apps/api ahead of monorepo merge
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,245 @@
|
||||
'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,
|
||||
};
|
||||
Reference in New Issue
Block a user