mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
314 lines
13 KiB
JavaScript
314 lines
13 KiB
JavaScript
/***********************************************************************************************************************************************************************
|
|
* 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,
|
|
};
|