/*********************************************************************************************************************************************************************** * File Name: task_reading_progress_sync.service.js * Type of Program: Service * Description: Bridges course/unit/lesson completion (course_reading_progress) 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 { 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, }; } 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, }); if (!completedReadingRows.length) return []; const completedReading = new Map( completedReadingRows.map((row) => [ `${readAttr(row, 'type')}:${readAttr(row, 'reference_id')}`, readAttr(row, 'completed_at'), ]) ); 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/pass_quiz) // 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, };