/*********************************************************************************************************************************************************************** * File Name: completion_requirements.service.js * Type of Program: Service * Description: Consolidated completion evaluation + persistence. Replaces the duplicated * derivation bodies previously spread across reading_progress.service.js * (deriveUnitStatus), course_reading_progress.service.js (deriveUnitStatus / * deriveCourseStatus), and an inline re-derivation in * controllers/client/courses.controller.js#getLessonsByUnitUuid — all three now * delegate to evaluateEntity() from utils/courses/completion_requirements.registry.js. * * evaluateEntity — re-exported from the registry (read-only, no persistence). * recomputeAndPersist — evaluate one entity and upsert the result into the entity's * system-of-record progress table (course-scoped → CourseReadingProgress; * standalone/library → Unit/LessonReadingProgress, the only tables that * tolerate a null course_id). * recomputeCascade — the lesson-progress entry point: writes the raw client-asserted * lesson fact, then re-evaluates+persists lesson → unit → course in one * transaction. Replaces upsertLessonRead in both legacy services. * recomputeUnitAfterQuiz / recomputeCourseAfterAssessment * — thin wrappers called after a quiz/assessment submit, so passing a * quiz/assessment immediately re-triggers parent evaluation instead of * requiring a subsequent lesson read to notice (closes the gap where * submitUnitQuiz/submitCourseAssessment never touched reading progress). * recordWatchProgress / recordManualComplete * — entry points backing the watch_percent / watch_video / listen_audio / * manual_complete requirement types, writing CompletionRequirementProgress * then cascading. * * Author: Kenneth Obsequio (@lash0000) * Date Created: Jul. 14, 2026 ***********************************************************************************************************************************************************************/ 'use strict'; const sequelize = require('../config/db.config'); const { evaluateEntity } = require('../utils/courses/completion_requirements.registry'); const CompletionRequirement = require('../models/courses/completion_requirement.mdl'); const CompletionRequirementProgress = require('../models/courses/completion_requirement_progress.mdl'); const Course = require('../models/courses/courses.mdl').Course; const Unit = require('../models/courses/units.mdl'); const Lesson = require('../models/courses/lessons.mdl'); const LessonPage = require('../models/courses/lesson_page.mdl'); const { upsertProgress } = require('./course_reading_progress.service'); const { upsertLessonProgress, upsertUnitProgress, upsertLessonRead: mirrorLessonRead, } = require('./reading_progress.service'); const { syncCompletedEntitiesToTaskProgress } = require('./task_reading_progress_sync.service'); // ─── Persist one entity's evaluated status into its system-of-record table ────── async function persistStatus({ entityType, entityId, userId, courseId, referenceId, status }, t) { if (courseId) { // Course-scoped: CourseReadingProgress, UUID-keyed, requires a non-null course_id. await upsertProgress({ userId, courseId, type: entityType, referenceId, status }, t); return; } // Standalone/library: Unit/LessonReadingProgress, the tables that tolerate null course_id. if (entityType === 'lesson') { await upsertLessonProgress({ userId, courseId: null, unitId: null, lessonId: entityId, status }, t); } else if (entityType === 'unit') { await upsertUnitProgress({ userId, courseId: null, unitId: entityId, status }, t); } // No standalone system-of-record for 'course' — courses always have a courseId by definition. } /** * Evaluate one entity and persist the result. Read-then-write — callers inside a cascade * pass the same transaction so the write is visible to the next level's evaluation. */ async function recomputeAndPersist({ entityType, entityId, userId, courseId = null, referenceId }, t) { const result = await evaluateEntity({ entityType, entityId, userId, courseId, transaction: t }); await persistStatus({ entityType, entityId, userId, courseId, referenceId, status: result.status }, t); return result; } // ─── Main entry point: lesson read → unit → course cascade ────────────────────── /** * Called when a user reads (or finishes reading) a lesson via the legacy scroll-trigger * endpoint. Writes the raw asserted lesson fact, then re-evaluates+persists lesson → unit → * course in one transaction (a lesson configured with watch_percent/manual_complete/pass_quiz * will simply ignore the raw fact during its own re-evaluation — see the registry's per-type * dispatch — so this stays safe to call regardless of what's configured on the lesson). * * @param {number} userId * @param {Object} payload * @param {number|null} payload.courseId — course BIGINT PK, or null for standalone reads * @param {string|null} payload.courseUuid * @param {number|null} payload.unitId — unit BIGINT PK, or null for standalone lesson-only reads * @param {string|null} payload.unitUuid * @param {number} payload.lessonId * @param {string} payload.lessonUuid * @param {string} payload.lessonStatus — 'in_progress' | 'completed', the client's raw assertion * @param {import('sequelize').Transaction} [externalTransaction] — reuse an already-open * transaction (e.g. from recordWatchProgress/recordManualComplete) instead of opening * and committing a new one, so the requirement-progress write and the cascade stay atomic. * @returns {{ lesson, unit, course }} */ async function recomputeCascade(userId, { courseId = null, courseUuid = null, unitId = null, unitUuid = null, lessonId, lessonUuid, lessonStatus = 'in_progress', }, externalTransaction = null) { const t = externalTransaction ?? await sequelize.transaction(); try { // 1. Raw asserted fact — the data source for read_all_content / default lesson evaluation. await persistStatus({ entityType: 'lesson', entityId: lessonId, userId, courseId, referenceId: lessonUuid, status: lessonStatus }, t); // 2. Re-evaluate the lesson (respects whatever type is actually configured on it). const lessonResult = await recomputeAndPersist( { entityType: 'lesson', entityId: lessonId, userId, courseId, referenceId: lessonUuid }, t ); // 3. Unit — derived from all sibling lessons (skipped for lesson-only standalone reads). let unitResult = null; if (unitId) { unitResult = await recomputeAndPersist( { entityType: 'unit', entityId: unitId, userId, courseId, referenceId: unitUuid }, t ); } // 4. Course — derived from all units (skipped for standalone reads, which have no courseId). let courseResult = null; if (courseId) { courseResult = await recomputeAndPersist( { entityType: 'course', entityId: courseId, userId, courseId, referenceId: courseUuid }, t ); } if (!externalTransaction) await t.commit(); // Best-effort mirror into lesson_reading_progress/unit_reading_progress for course-scoped // reads — standalone reads already write these tables directly as their system of record // (see persistStatus above). Several admin dashboards (controllers/admin/units.controller.js, // controllers/admin/courses.controller.js) still read these tables for completion stats; // this keeps them populated without making them a decision source for the evaluator itself. // Fired only after our own transaction is durable, and never awaited/allowed to fail the request. if (courseId && !externalTransaction) { mirrorLessonRead(userId, { courseId, unitId, lessonId, lessonStatus }) .catch((e) => console.error('[COMPLETION REQUIREMENTS] reading-progress mirror write failed:', e)); } // Task-progress sync (read_lesson/read_unit/read_course auto-complete) only runs once // this transaction is durable. When called with an externalTransaction (from // recordWatchProgress/recordManualComplete), that caller commits and syncs itself instead — // running it here would read pre-commit state. let completedTasks = []; if (!externalTransaction && courseId) { completedTasks = await syncCompletedEntitiesToTaskProgress(userId, { lessonUuid, unitUuid, courseUuid }); } return { lesson: { lesson_id: lessonId, reference_id: lessonUuid, status: lessonResult.status }, unit: unitId ? { unit_id: unitId, reference_id: unitUuid, status: unitResult.status } : null, course: courseId ? { course_id: courseId, reference_id: courseUuid, status: courseResult.status } : null, completed_tasks: completedTasks, }; } catch (err) { if (!externalTransaction) await t.rollback(); throw err; } } // ─── Quiz / assessment submit hooks ────────────────────────────────────────────── // submitUnitQuiz / submitCourseAssessment never touched reading progress before this feature — // these close that gap so a pass_quiz-configured unit/course reflects completion immediately. async function recomputeUnitAfterQuiz(userId, { unitId, courseId }, t) { const unit = await Unit.findOne({ where: { unit_id: unitId }, attributes: ['unit_id', 'uuid'], transaction: t }); if (!unit) return null; const unitResult = await recomputeAndPersist( { entityType: 'unit', entityId: unitId, userId, courseId: courseId ?? null, referenceId: unit.uuid }, t ); let courseResult = null; let courseUuid = null; if (courseId) { const course = await Course.findOne({ where: { course_id: courseId }, attributes: ['course_id', 'uuid'], transaction: t }); if (course) { courseUuid = course.uuid; courseResult = await recomputeAndPersist( { entityType: 'course', entityId: courseId, userId, courseId, referenceId: course.uuid }, t ); } } // Passing a quiz can complete a read_unit/read_course task requirement even though no // lesson was ever read — the gap this whole function exists to close, so the task-sync // needs to run here too, not just from the lesson-progress cascade. const completedTasks = await syncCompletedEntitiesToTaskProgress(userId, { unitUuid: unit.uuid, courseUuid }); return { unit: unitResult, course: courseResult, completed_tasks: completedTasks }; } async function recomputeCourseAfterAssessment(userId, courseId, t) { const course = await Course.findOne({ where: { course_id: courseId }, attributes: ['course_id', 'uuid'], transaction: t }); if (!course) return null; const result = await recomputeAndPersist( { entityType: 'course', entityId: courseId, userId, courseId, referenceId: course.uuid }, t ); const completedTasks = await syncCompletedEntitiesToTaskProgress(userId, { courseUuid: course.uuid }); return { ...result, completed_tasks: completedTasks }; } // ─── watch_percent / manual_complete entry points ──────────────────────────────── /** * Upsert a lesson's watch progress, then cascade lesson → unit → course. Handles two * independent requirement types in one write since both can be configured on the same * lesson simultaneously: * - watch_percent — one aggregate percent across whichever block is playing, monotonic, * satisfied once it crosses the requirement's configured min_percent. * - watch_video / listen_audio — only touched when the caller identifies which block sent * the update (`blockId`/`blockType`); tracks each matching block's own * running max in `block_progress`, satisfied once EVERY block of that * type currently on the lesson's page is at 100 (re-checked against the * live block set on every write, not a snapshot taken when configured). * No-ops (returns null) if the lesson has neither type configured. */ async function recordWatchProgress(userId, { lessonId, lessonUuid, unitId = null, unitUuid = null, courseId = null, courseUuid = null, percent, blockId = null, blockType = null, }) { const blockRequirementType = blockType === 'video' ? 'watch_video' : blockType === 'audio' ? 'listen_audio' : null; const requirements = await CompletionRequirement.findAll({ where: { entity_type: 'lesson', entity_id: lessonId, type: blockRequirementType ? ['watch_percent', blockRequirementType] : 'watch_percent', }, }); if (!requirements.length) return null; const percentRequirement = requirements.find((r) => r.type === 'watch_percent'); const blockRequirement = blockId ? requirements.find((r) => r.type === blockRequirementType) : null; const t = await sequelize.transaction(); try { let anyCompleted = false; let aggregatePercent = null; if (percentRequirement) { const existing = await CompletionRequirementProgress.findOne({ where: { requirement_id: percentRequirement.requirement_id, user_id: userId }, transaction: t, }); const nextPercent = Math.max(existing?.progress_percent ?? 0, Math.min(100, Math.max(0, Math.round(percent)))); const completed = nextPercent >= (percentRequirement.min_percent ?? 100); await CompletionRequirementProgress.upsert({ requirement_id: percentRequirement.requirement_id, user_id: userId, entity_type: 'lesson', entity_id: lessonId, progress_percent: nextPercent, completed, completed_at: completed ? (existing?.completed_at ?? new Date()) : null, updatedBy: userId, }, { conflictFields: ['requirement_id', 'user_id'], transaction: t }); aggregatePercent = nextPercent; if (completed) anyCompleted = true; } if (blockRequirement) { const existing = await CompletionRequirementProgress.findOne({ where: { requirement_id: blockRequirement.requirement_id, user_id: userId }, transaction: t, }); const prevBlockProgress = existing?.block_progress ?? {}; const clampedPercent = Math.min(100, Math.max(0, Math.round(percent))); const nextBlockProgress = { ...prevBlockProgress, [blockId]: Math.max(prevBlockProgress[blockId] ?? 0, clampedPercent) }; const page = await LessonPage.findOne({ where: { lesson_id: lessonId }, attributes: ['blocks'], transaction: t }); const matchingBlockIds = (page?.blocks ?? []).filter((b) => b.type === blockType).map((b) => b.id); const completed = matchingBlockIds.length > 0 && matchingBlockIds.every((id) => (nextBlockProgress[id] ?? 0) >= 100); await CompletionRequirementProgress.upsert({ requirement_id: blockRequirement.requirement_id, user_id: userId, entity_type: 'lesson', entity_id: lessonId, block_progress: nextBlockProgress, completed, completed_at: completed ? (existing?.completed_at ?? new Date()) : null, updatedBy: userId, }, { conflictFields: ['requirement_id', 'user_id'], transaction: t }); if (completed) anyCompleted = true; } const cascade = await recomputeCascade(userId, { courseId, courseUuid, unitId, unitUuid, lessonId, lessonUuid, // The lesson's own raw fact is irrelevant for a configured lesson (its evaluateEntity // call ignores it and ANDs the actual per-type checks instead), but recomputeCascade // still writes it for consistency with any other consumer reading the raw flag directly. lessonStatus: anyCompleted ? 'completed' : 'in_progress', }, t); await t.commit(); // recomputeCascade skipped its own task-sync since it ran under our externalTransaction // (would've read pre-commit state) — run it now that everything is durable. const completedTasks = courseId ? await syncCompletedEntitiesToTaskProgress(userId, { lessonUuid, unitUuid, courseUuid }) : []; return { progress_percent: aggregatePercent, completed: anyCompleted, cascade: { ...cascade, completed_tasks: completedTasks } }; } catch (err) { await t.rollback(); throw err; } } /** * Flip a manual_complete requirement's progress row to completed, then cascade upward from * whichever entity it's configured on. No-ops (returns null) if no manual_complete requirement * is configured for that entity. */ async function recordManualComplete(userId, { entityType, entityId, lessonId = null, lessonUuid = null, unitId = null, unitUuid = null, courseId = null, courseUuid = null }) { const requirement = await CompletionRequirement.findOne({ where: { entity_type: entityType, entity_id: entityId, type: 'manual_complete' }, }); if (!requirement) return null; const t = await sequelize.transaction(); try { await CompletionRequirementProgress.upsert({ requirement_id: requirement.requirement_id, user_id: userId, entity_type: entityType, entity_id: entityId, completed: true, completed_at: new Date(), updatedBy: userId, }, { conflictFields: ['requirement_id', 'user_id'], transaction: t }); let result; if (entityType === 'lesson') { result = await recomputeCascade(userId, { courseId, courseUuid, unitId, unitUuid, lessonId: entityId, lessonUuid, lessonStatus: 'completed', }, t); } else if (entityType === 'unit') { result = { unit: await recomputeAndPersist({ entityType: 'unit', entityId, userId, courseId, referenceId: unitUuid }, t) }; if (courseId) { const course = await Course.findOne({ where: { course_id: courseId }, attributes: ['uuid'], transaction: t }); if (course) result.course = await recomputeAndPersist({ entityType: 'course', entityId: courseId, userId, courseId, referenceId: course.uuid }, t); } } else { result = { course: await recomputeAndPersist({ entityType: 'course', entityId, userId, courseId: entityId, referenceId: courseUuid }, t) }; } await t.commit(); // Same reasoning as recordWatchProgress — recomputeCascade (lesson branch) skipped its // own sync under our externalTransaction; the unit/course branches never called it at all. const syncUuids = entityType === 'lesson' ? { lessonUuid, unitUuid, courseUuid } : entityType === 'unit' ? { unitUuid, courseUuid } : { courseUuid }; result.completed_tasks = courseId || entityType === 'course' ? await syncCompletedEntitiesToTaskProgress(userId, syncUuids) : []; return result; } catch (err) { await t.rollback(); throw err; } } module.exports = { evaluateEntity, recomputeAndPersist, recomputeCascade, recomputeUnitAfterQuiz, recomputeCourseAfterAssessment, recordWatchProgress, recordManualComplete, };