added new requirements for lessons and units

Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
2026-07-15 16:26:09 +08:00
parent b7d62b3b18
commit 9c82b0de09
25 changed files with 1569 additions and 233 deletions
@@ -29,8 +29,7 @@
const { Op } = require('sequelize');
const R = require('../../utils/response.util');
const logActivity = require('../../utils/logActivity.util');
const { upsertLessonRead } = require('../../services/course_reading_progress.service');
const { upsertLessonRead: upsertReadingProgress } = require('../../services/reading_progress.service');
const { recomputeCascade, recordWatchProgress, recordManualComplete } = require('../../services/completion_requirements.service');
const CourseReadingProgress = require('../../models/courses/course_reading_progress.mdl');
const Certificate = require('../../models/courses/certificate.mdl');
const {
@@ -63,100 +62,12 @@ async function getAccessibleTaskListIds(userId) {
return { taskListIds: Object.keys(taskListToGroup), taskListToGroup };
}
// ─── Internal helper: sync task_progress after a lesson read ─────────────────
// Finds TaskRequirement rows whose reference_id matches the lesson/unit/course UUID
// (only for tasks the user is assigned to) and marks them completed in task_progress.
// Returns an array of { task_id, task_name } for tasks where ALL read-only requirements
// are now satisfied — these are eligible for display as "auto turned-in" on the frontend.
async function syncTaskProgress(userId, { lessonUuid, unitUuid, courseUuid, lessonStatus, unitStatus, courseStatus }) {
if (lessonStatus !== 'completed') return [];
const { taskListIds } = await getAccessibleTaskListIds(userId);
if (!taskListIds.length) return [];
// Collect UUIDs to match based on what became completed
const matchUuids = [lessonUuid];
if (unitStatus === 'completed') matchUuids.push(unitUuid);
if (courseStatus === 'completed') matchUuids.push(courseUuid);
const requirements = await TaskRequirement.findAll({
where: {
reference_id: { [Op.in]: matchUuids },
type: { [Op.in]: ['read_lesson', 'read_unit', 'read_course'] },
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 [];
// Filter to (type, reference_id) pairs that actually became completed this call
const toComplete = requirements.filter((req) => {
if (req.type === 'read_lesson' && req.reference_id === lessonUuid) return true;
if (req.type === 'read_unit' && req.reference_id === unitUuid && unitStatus === 'completed') return true;
if (req.type === 'read_course' && req.reference_id === courseUuid && courseStatus === 'completed') return true;
return false;
});
if (!toComplete.length) return [];
const now = new Date();
// Upsert TaskProgress as completed for each matching requirement
await Promise.all(toComplete.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: now,
createdBy: userId,
updatedBy: userId,
},
{ conflictFields: ['requirement_id', 'user_id', 'reference_id'] }
)
));
// Check if any impacted task now has ALL its read requirements done
// (only auto-turn-in pure read tasks — tasks with upload_file/visit_link need manual submission)
const taskIds = [...new Set(toComplete.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_course', 'read_unit', 'read_lesson'].includes(r.type));
if (hasNonReadReqs) continue; // let the user manually submit
const readReqs = allReqs; // all are read-type at this point
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 = readReqs.every((r) => doneSet.has(`${r.requirement_id}:${r.reference_id}`));
if (allDone) {
const taskName = toComplete.find((r) => r.task_id === taskId)?.task?.name ?? '';
completedTasks.push({ task_id: taskId, task_name: taskName });
}
}
return completedTasks;
}
// Task-progress auto-sync (read_lesson/read_unit/read_course requirements) now lives in
// services/task_reading_progress_sync.service.js#syncCompletedEntitiesToTaskProgress, called
// directly from completion_requirements.service.js's cascade/recompute functions — covers every
// completion trigger (scroll, watch_percent, manual_complete, pass_quiz, assessment), not just
// this endpoint. getAccessibleTaskListIds stays here (below) since getCourseTaskContext still
// needs its richer { taskListIds, taskListToGroup } shape.
// =============================================================================
// ── IN-PROGRESS COURSES — profile learning progress card ──────────────────────
@@ -453,10 +364,11 @@ exports.getCourseTaskContext = async (req, res) => {
//
// Flow:
// 1. Resolve course / unit / lesson to get their UUIDs
// 2. Delegate to upsertLessonRead (course_reading_progress service) — lesson + unit + course in one tx
// 3. Side-effect A: write to lesson_reading_progress + unit_reading_progress (new dedicated tables)
// 4. Side-effect B: sync task_progress for matching task requirements
// 5. Return result + completed_tasks (tasks whose all read requirements are now satisfied)
// 2. Delegate to recomputeCascade (completion_requirements service) — lesson + unit + course
// evaluated against any configured CompletionRequirement rows (or the default implicit rule),
// all in one transaction
// 3. Side-effect: sync task_progress for matching task requirements
// 4. Return result + completed_tasks (tasks whose all read requirements are now satisfied)
exports.upsertLessonProgress = async (req, res) => {
try {
@@ -485,43 +397,108 @@ exports.upsertLessonProgress = async (req, res) => {
if (!unit || !courseLink) return R.error(res, 'Unit not found.', 404);
if (!lesson || !lessonLink) return R.error(res, 'Lesson not found.', 404);
// ── 1. Primary write: course_reading_progress ─────────────────────────
const result = await upsertLessonRead(userId, {
// ── 1. Consolidated evaluation + persistence: lesson → unit → course ──
const result = await recomputeCascade(userId, {
courseId: course.course_id,
courseUuid: course.uuid,
unitId: unit.unit_id,
unitUuid: unit.uuid,
lessonId: lesson.lesson_id,
lessonUuid: lesson.uuid,
lessonStatus: status,
});
// ── 2. Side-effect A: write to new dedicated tables (fire-and-forget) ──
upsertReadingProgress(userId, {
courseId: course.course_id,
unitId: unit.unit_id,
lessonId: lesson.lesson_id,
lessonStatus: status,
}).catch((e) => console.error('[READING PROGRESS] piggyback write failed:', e));
// ── 3. Side-effect B: sync task_progress ──────────────────────────────
const completedTasks = await syncTaskProgress(userId, {
lessonUuid: lesson.uuid,
unitUuid: unit.uuid,
courseUuid: course.uuid,
lessonStatus: status,
unitStatus: result.unit.status,
courseStatus: result.course.status,
});
// Task-progress sync (read_lesson/read_unit/read_course auto-complete) already ran
// inside recomputeCascade — result.completed_tasks reflects it directly.
logActivity(userId, 'lesson_read', {
entityType: 'lesson',
entityId: lesson.lesson_id,
details: { lesson_uuid: lesson.uuid, status },
});
return R.success(res, 'Progress updated.', { ...result, completed_tasks: completedTasks }, 200);
return R.success(res, 'Progress updated.', result, 200);
} catch (err) {
console.error('[CLIENT][COURSE READING PROGRESS][UPSERT]', err);
return R.error(res, 'Could not update progress.', 500);
}
};
// =============================================================================
// ── WATCH PROGRESS (watch_percent completion requirement) ────────────────────
// =============================================================================
// POST /client/courses/:courseId/units/:unitId/lessons/:lessonId/watch-progress
// Body: { percent, block_id?, block_type? } — running max % of video/audio watched, 0-100.
// block_id/block_type identify which block on the lesson's page sent this update — needed
// to drive watch_video/listen_audio (every block of that type must individually reach 100);
// omit them and only the aggregate watch_percent requirement (if configured) is touched.
// No-ops (still 200s) if the lesson has neither requirement type configured.
exports.upsertWatchProgress = async (req, res) => {
try {
const { courseId, unitId, lessonId } = req.params;
const userId = req.user.user_id;
const percent = Number(req.body.percent);
if (!Number.isFinite(percent)) return R.error(res, 'percent must be a number.', 400);
const blockId = req.body.block_id ?? null;
const blockType = req.body.block_type ?? null;
const [course, unit, lesson, courseLink, lessonLink] = await Promise.all([
Course.findOne({ where: { course_id: courseId, ...notDeleted }, attributes: ['course_id', 'uuid'] }),
Unit.findOne({ where: { unit_id: unitId, ...notDeleted }, attributes: ['unit_id', 'uuid'] }),
Lesson.findOne({ where: { lesson_id: lessonId, ...notDeleted }, attributes: ['lesson_id', 'uuid'] }),
CourseUnit.findOne({ where: { course_id: courseId, unit_id: unitId } }),
UnitLesson.findOne({ where: { unit_id: unitId, lesson_id: lessonId } }),
]);
if (!course) return R.error(res, 'Course not found.', 404);
if (!unit || !courseLink) return R.error(res, 'Unit not found.', 404);
if (!lesson || !lessonLink) return R.error(res, 'Lesson not found.', 404);
const result = await recordWatchProgress(userId, {
lessonId: lesson.lesson_id, lessonUuid: lesson.uuid,
unitId: unit.unit_id, unitUuid: unit.uuid,
courseId: course.course_id, courseUuid: course.uuid,
percent, blockId, blockType,
});
return R.success(res, 'Watch progress updated.', result, 200);
} catch (err) {
console.error('[CLIENT][COURSE READING PROGRESS][WATCH PROGRESS]', err);
return R.error(res, 'Could not update watch progress.', 500);
}
};
// =============================================================================
// ── MARK COMPLETE (manual_complete completion requirement) ───────────────────
// =============================================================================
// POST /client/courses/:courseId/units/:unitId/lessons/:lessonId/mark-complete
// No-ops (still 200s) if the lesson has no configured manual_complete requirement.
exports.markLessonComplete = async (req, res) => {
try {
const { courseId, unitId, lessonId } = req.params;
const userId = req.user.user_id;
const [course, unit, lesson, courseLink, lessonLink] = await Promise.all([
Course.findOne({ where: { course_id: courseId, ...notDeleted }, attributes: ['course_id', 'uuid'] }),
Unit.findOne({ where: { unit_id: unitId, ...notDeleted }, attributes: ['unit_id', 'uuid'] }),
Lesson.findOne({ where: { lesson_id: lessonId, ...notDeleted }, attributes: ['lesson_id', 'uuid'] }),
CourseUnit.findOne({ where: { course_id: courseId, unit_id: unitId } }),
UnitLesson.findOne({ where: { unit_id: unitId, lesson_id: lessonId } }),
]);
if (!course) return R.error(res, 'Course not found.', 404);
if (!unit || !courseLink) return R.error(res, 'Unit not found.', 404);
if (!lesson || !lessonLink) return R.error(res, 'Lesson not found.', 404);
const result = await recordManualComplete(userId, {
entityType: 'lesson', entityId: lesson.lesson_id,
lessonUuid: lesson.uuid,
unitId: unit.unit_id, unitUuid: unit.uuid,
courseId: course.course_id, courseUuid: course.uuid,
});
return R.success(res, 'Lesson marked complete.', result, 200);
} catch (err) {
console.error('[CLIENT][COURSE READING PROGRESS][MARK COMPLETE]', err);
return R.error(res, 'Could not mark lesson complete.', 500);
}
};