/*********************************************************************************************************************************************************************** * File Name: task_reading_progress_sync.service.js * Type of Program: Service * Description: Bridges course/unit/lesson completion (course_reading_progress, plus the * standalone lesson_reading_progress/unit_reading_progress system-of-record * used when a lesson/unit has no parent course — see the junction revamp) to * Task requirements of type read_course/read_unit/read_lesson, in both directions: * * hydrateReadTaskProgress(userId, requirements) * — given a list of TaskRequirement rows (typically when a task/task list is newly * assigned), backfills task_progress for any that reference content the user has * already completed reading. * * syncCompletedEntitiesToTaskProgress(userId, { lessonUuid, unitUuid, courseUuid }) * — given the UUIDs of course/unit/lesson entities that just reached 'completed' * (from any trigger — scroll-to-bottom, watch_percent threshold, manual_complete, * pass_quiz, assessment pass), finds matching TaskRequirement rows the user is * assigned to and marks them done, then reports any task whose read-only * requirements are now ALL satisfied (eligible for "auto turned-in" display). * Called from every completion.service.js entry point — NOT just the legacy * lesson-progress endpoint — so a unit/course completing via pass_quiz/manual_complete * alone (no lesson ever read) still satisfies read_unit/read_course task requirements. ***********************************************************************************************************************************************************************/ 'use strict'; const { Op } = require('sequelize'); const CourseReadingProgress = require('../models/courses/course_reading_progress.mdl'); const LessonReadingProgress = require('../models/courses/lesson_reading_progress.mdl'); const UnitReadingProgress = require('../models/courses/unit_reading_progress.mdl'); const Lesson = require('../models/courses/lessons.mdl'); const Unit = require('../models/courses/units.mdl'); const { TaskProgress } = require('../models/task/task_progress.mdl'); const { Task, TaskRequirement, TaskListGroup } = require('../models/task/task.mdl'); const { mdl_UserGroupMembers } = require('../models/users/user_groups.mdl'); const READ_TYPE_TO_PROGRESS_TYPE = { read_course: 'course', read_unit: 'unit', read_lesson: 'lesson', }; const READ_REQUIREMENT_TYPES = Object.keys(READ_TYPE_TO_PROGRESS_TYPE); function readAttr(row, attr) { if (!row) return undefined; if (typeof row.get === 'function') return row.get(attr); return row[attr]; } function normalizeRequirement(row) { const type = readAttr(row, 'type'); if (!READ_REQUIREMENT_TYPES.includes(type)) return null; const referenceId = readAttr(row, 'reference_id'); if (!referenceId) return null; return { task_id: readAttr(row, 'task_id'), requirement_id: readAttr(row, 'requirement_id'), reference_id: referenceId, type, }; } // ─── Standalone (no parent course) lesson/unit completions ─────────────────── // lesson_reading_progress/unit_reading_progress are keyed by numeric PK, but // TaskRequirement.reference_id is the lesson/unit UUID — resolve UUID -> PK // first, then look up completion, then map back to the `type:uuid` key shape // hydrateReadTaskProgress's caller expects. read_course has no standalone // table (a course always has a courseId by definition), so it's skipped. async function getStandaloneCompletedReading(userId, referencesByProgressType, transaction) { const entries = []; const lessonUuids = [...(referencesByProgressType.lesson ?? [])]; if (lessonUuids.length) { const lessons = await Lesson.findAll({ where: { uuid: { [Op.in]: lessonUuids } }, attributes: ['lesson_id', 'uuid'], transaction, }); if (lessons.length) { const uuidByLessonId = new Map(lessons.map((l) => [readAttr(l, 'lesson_id'), readAttr(l, 'uuid')])); const rows = await LessonReadingProgress.findAll({ where: { user_id: userId, lesson_id: { [Op.in]: [...uuidByLessonId.keys()] }, status: 'completed' }, attributes: ['lesson_id', 'completed_at'], transaction, }); for (const row of rows) { const uuid = uuidByLessonId.get(readAttr(row, 'lesson_id')); if (uuid) entries.push([`lesson:${uuid}`, readAttr(row, 'completed_at')]); } } } const unitUuids = [...(referencesByProgressType.unit ?? [])]; if (unitUuids.length) { const units = await Unit.findAll({ where: { uuid: { [Op.in]: unitUuids } }, attributes: ['unit_id', 'uuid'], transaction, }); if (units.length) { const uuidByUnitId = new Map(units.map((u) => [readAttr(u, 'unit_id'), readAttr(u, 'uuid')])); const rows = await UnitReadingProgress.findAll({ where: { user_id: userId, unit_id: { [Op.in]: [...uuidByUnitId.keys()] }, status: 'completed' }, attributes: ['unit_id', 'completed_at'], transaction, }); for (const row of rows) { const uuid = uuidByUnitId.get(readAttr(row, 'unit_id')); if (uuid) entries.push([`unit:${uuid}`, readAttr(row, 'completed_at')]); } } } return entries; } async function hydrateReadTaskProgress(userId, requirements = [], options = {}) { const readRequirements = requirements .map(normalizeRequirement) .filter((req) => req && req.task_id && req.requirement_id); if (!readRequirements.length) return []; const referencesByProgressType = readRequirements.reduce((acc, req) => { const progressType = READ_TYPE_TO_PROGRESS_TYPE[req.type]; if (!acc[progressType]) acc[progressType] = new Set(); acc[progressType].add(req.reference_id); return acc; }, {}); const where = { user_id: userId, status: 'completed', [Op.or]: Object.entries(referencesByProgressType).map(([type, references]) => ({ type, reference_id: { [Op.in]: [...references] }, })), }; const completedReadingRows = await CourseReadingProgress.findAll({ where, attributes: ['type', 'reference_id', 'completed_at'], transaction: options.transaction, }); const completedReading = new Map( completedReadingRows.map((row) => [ `${readAttr(row, 'type')}:${readAttr(row, 'reference_id')}`, readAttr(row, 'completed_at'), ]) ); // ── Standalone lessons/units (no parent course) don't have a // CourseReadingProgress row at all — their system-of-record is // lesson_reading_progress/unit_reading_progress instead (see // completion_requirements.service.js#persistStatus). Merge those in too, // resolving UUID (the TaskRequirement's reference_id) -> numeric PK first. const standaloneEntries = await getStandaloneCompletedReading(userId, referencesByProgressType, options.transaction); for (const [key, completedAt] of standaloneEntries) { if (!completedReading.has(key)) completedReading.set(key, completedAt); } if (!completedReading.size) return []; const now = new Date(); const rowsToUpsert = readRequirements.filter((req) => completedReading.has(`${READ_TYPE_TO_PROGRESS_TYPE[req.type]}:${req.reference_id}`) ); if (!rowsToUpsert.length) return []; const existingProgressRows = await TaskProgress.findAll({ where: { user_id: userId, completed: true, requirement_id: { [Op.in]: rowsToUpsert.map((req) => req.requirement_id) }, reference_id: { [Op.in]: rowsToUpsert.map((req) => req.reference_id) }, }, attributes: ['requirement_id', 'reference_id'], transaction: options.transaction, }); const existingProgress = new Set( existingProgressRows.map((row) => `${readAttr(row, 'requirement_id')}:${readAttr(row, 'reference_id')}` ) ); const missingRows = rowsToUpsert.filter((req) => !existingProgress.has(`${req.requirement_id}:${req.reference_id}`) ); if (!missingRows.length) return []; await Promise.all(missingRows.map((req) => TaskProgress.upsert( { task_id: req.task_id, requirement_id: req.requirement_id, user_id: userId, reference_id: req.reference_id, type: req.type, completed: true, completed_at: completedReading.get(`${READ_TYPE_TO_PROGRESS_TYPE[req.type]}:${req.reference_id}`) ?? now, createdBy: userId, updatedBy: userId, }, { conflictFields: ['requirement_id', 'user_id', 'reference_id'], transaction: options.transaction, } ) )); return missingRows; } // ─── Accessible task lists (via group membership) ──────────────────────────── async function getAccessibleTaskListIds(userId) { const memberships = await mdl_UserGroupMembers.findAll({ where: { user_id: userId, deletedAt: null }, attributes: ['group_id'], }); const groupIds = memberships.map((m) => m.group_id); if (!groupIds.length) return []; const taskListGroups = await TaskListGroup.findAll({ where: { group_id: groupIds }, attributes: ['task_list_id'], }); return [...new Set(taskListGroups.map((tlg) => tlg.task_list_id))]; } // ─── Sync from a completion event (any trigger) → task_progress ───────────── /** * Call after ANY course/unit/lesson reaches 'completed' for a user, regardless of which * completion-requirement type triggered it. Finds TaskRequirement rows (read_course/ * read_unit/read_lesson) referencing the given UUIDs, among task lists the user's groups * can access, backfills task_progress via hydrateReadTaskProgress, and reports any task * whose read-only requirements are now ALL satisfied. * * @param {number} userId * @param {{ lessonUuid?: string, unitUuid?: string, courseUuid?: string }} uuids * @returns {Promise<{ task_id, task_name }[]>} */ async function syncCompletedEntitiesToTaskProgress(userId, { lessonUuid, unitUuid, courseUuid } = {}) { const referenceIds = [lessonUuid, unitUuid, courseUuid].filter(Boolean); if (!referenceIds.length) return []; const taskListIds = await getAccessibleTaskListIds(userId); if (!taskListIds.length) return []; const requirements = await TaskRequirement.findAll({ where: { reference_id: { [Op.in]: referenceIds }, type: { [Op.in]: READ_REQUIREMENT_TYPES }, deletedAt: null, }, include: [{ model: Task, as: 'task', where: { task_list_id: { [Op.in]: taskListIds }, deletedAt: null }, required: true, attributes: ['task_id', 'name', 'task_list_id'], }], attributes: ['requirement_id', 'task_id', 'type', 'reference_id'], }); if (!requirements.length) return []; const newlyCompleted = await hydrateReadTaskProgress(userId, requirements); if (!newlyCompleted.length) return []; // Check whether any impacted task now has ALL its read-only requirements satisfied — // tasks with any non-read requirement (upload_file/visit_link/submit_text) // still need manual submission, so they're excluded from auto-turn-in. const taskIds = [...new Set(newlyCompleted.map((r) => r.task_id))]; const completedTasks = []; for (const taskId of taskIds) { const allReqs = await TaskRequirement.findAll({ where: { task_id: taskId, deletedAt: null }, attributes: ['requirement_id', 'type', 'reference_id'], }); const hasNonReadReqs = allReqs.some((r) => !READ_REQUIREMENT_TYPES.includes(r.type)); if (hasNonReadReqs) continue; const doneProgress = await TaskProgress.findAll({ where: { task_id: taskId, user_id: userId, completed: true }, attributes: ['requirement_id', 'reference_id'], }); const doneSet = new Set(doneProgress.map((p) => `${p.requirement_id}:${p.reference_id}`)); const allDone = allReqs.every((r) => doneSet.has(`${r.requirement_id}:${r.reference_id}`)); if (allDone) { const task = requirements.find((r) => r.task_id === taskId)?.task; completedTasks.push({ task_id: taskId, task_name: task?.name ?? '' }); } } return completedTasks; } module.exports = { hydrateReadTaskProgress, syncCompletedEntitiesToTaskProgress, getAccessibleTaskListIds, READ_REQUIREMENT_TYPES, };