mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
chore: relocate backend into apps/api ahead of monorepo merge
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,755 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name: course_reading_progress.controller.js (client)
|
||||
* Type of Program: Controller
|
||||
* Description: Tracks user reading progress through a course hierarchy (course → unit → lesson).
|
||||
*
|
||||
* GET /client/courses/in-progress
|
||||
* → courses where the current user has status = 'in_progress', with lesson counts
|
||||
*
|
||||
* GET /client/courses/completed
|
||||
* → every completed lesson/unit/course for the current user, course-scoped and
|
||||
* standalone reads unioned together, most-recently-completed first
|
||||
*
|
||||
* GET /client/courses/:courseId/progress/summary
|
||||
* → compact snapshot: lesson counts + percentage + course status
|
||||
*
|
||||
* GET /client/courses/:courseId/progress
|
||||
* → all progress rows for this user + course (flat, frontend builds the map)
|
||||
*
|
||||
* GET /client/courses/:courseId/task-context
|
||||
* → all pending task requirements (read_*) for this course's UUIDs that the user is assigned to
|
||||
*
|
||||
* POST /client/courses/:courseId/units/:unitId/lessons/:lessonId/progress
|
||||
* → UPSERT lesson + derives + UPSERTs parent unit + course in one transaction
|
||||
* → side-effects: writes to lesson_reading_progress / unit_reading_progress,
|
||||
* syncs task_progress for matching task requirements,
|
||||
* returns completed_tasks for any task whose read requirements are now all done
|
||||
*
|
||||
* Author: Kenneth Obsequio (@lash0000)
|
||||
* Date Created: Jun. 21, 2026
|
||||
***********************************************************************************************************************************************************************/
|
||||
'use strict';
|
||||
|
||||
const { Op } = require('sequelize');
|
||||
const R = require('../../utils/response.util');
|
||||
const logActivity = require('../../utils/logActivity.util');
|
||||
const { recomputeCascade, recordWatchProgress, recordManualComplete } = require('../../services/completion_requirements.service');
|
||||
const { recordPlaybackPosition } = require('../../services/playback_position.service');
|
||||
const CourseReadingProgress = require('../../models/courses/course_reading_progress.mdl');
|
||||
const UnitReadingProgress = require('../../models/courses/unit_reading_progress.mdl');
|
||||
const LessonReadingProgress = require('../../models/courses/lesson_reading_progress.mdl');
|
||||
const Certificate = require('../../models/courses/certificate.mdl');
|
||||
const {
|
||||
Course, Unit, Lesson,
|
||||
CourseUnit, UnitLesson,
|
||||
UnitQuiz, CourseAssessment, QuizAttempt,
|
||||
} = require('../../models/courses/courses.associations');
|
||||
const { countCourseLessons, getCourseUnitIds, flattenUnits } = require('../../utils/courses/hierarchy.util');
|
||||
|
||||
const { Task, TaskRequirement, TaskListGroup } = require('../../models/task/task.mdl');
|
||||
const { TaskProgress } = require('../../models/task/task_progress.mdl');
|
||||
const { mdl_UserGroupMembers } = require('../../models/users/user_groups.mdl');
|
||||
|
||||
const notDeleted = { deletedAt: null };
|
||||
|
||||
// ─── Internal helper: get task list IDs accessible to a user ─────────────────
|
||||
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 { taskListIds: [], taskListToGroup: {} };
|
||||
|
||||
const taskListGroups = await TaskListGroup.findAll({
|
||||
where: { group_id: groupIds },
|
||||
attributes: ['task_list_id', 'group_id'],
|
||||
});
|
||||
const taskListToGroup = Object.fromEntries(taskListGroups.map((tlg) => [tlg.task_list_id, tlg.group_id]));
|
||||
return { taskListIds: Object.keys(taskListToGroup), taskListToGroup };
|
||||
}
|
||||
|
||||
// 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 ──────────────────────
|
||||
// =============================================================================
|
||||
|
||||
exports.getMyInProgressCourses = async (req, res) => {
|
||||
try {
|
||||
const userId = req.user.user_id;
|
||||
|
||||
const courseRows = await CourseReadingProgress.findAll({
|
||||
where: { user_id: userId, type: 'course' },
|
||||
attributes: ['course_id', 'status', 'last_accessed_at'],
|
||||
include: [{
|
||||
model: Course,
|
||||
as: 'course',
|
||||
attributes: ['course_id', 'title'],
|
||||
where: notDeleted,
|
||||
required: true,
|
||||
}],
|
||||
order: [['last_accessed_at', 'DESC']],
|
||||
});
|
||||
|
||||
if (!courseRows.length) return R.success(res, 'No courses in progress.', []);
|
||||
|
||||
const certificates = await Certificate.findAll({
|
||||
where: { user_id: userId },
|
||||
attributes: ['course_id'],
|
||||
});
|
||||
const certSet = new Set(certificates.map((c) => String(c.course_id)));
|
||||
|
||||
const pending = courseRows.filter((r) => !certSet.has(String(r.course_id)));
|
||||
if (!pending.length) return R.success(res, 'No courses in progress.', []);
|
||||
|
||||
const result = await Promise.all(pending.map(async (row) => {
|
||||
const courseId = row.course_id;
|
||||
|
||||
const [lessons_total, lessons_completed] = await Promise.all([
|
||||
countCourseLessons(courseId),
|
||||
CourseReadingProgress.count({
|
||||
where: { user_id: userId, course_id: courseId, type: 'lesson', status: 'completed' },
|
||||
}),
|
||||
]);
|
||||
|
||||
// "Reading done" is derived independently from lesson counts — row.status now also
|
||||
// requires the course assessment to be passed, so it can't be used as the reading gate.
|
||||
const readingDone = lessons_total > 0 && lessons_completed === lessons_total;
|
||||
|
||||
let pending_quizzes = [];
|
||||
let pending_assessment = null;
|
||||
let assessment_configured = true;
|
||||
|
||||
if (readingDone) {
|
||||
const courseUnitIds = await getCourseUnitIds(courseId);
|
||||
const unitQuizzes = courseUnitIds.length ? await UnitQuiz.findAll({
|
||||
attributes: ['quiz_id', 'title', 'is_required', 'passing_score'],
|
||||
include: [{
|
||||
model: Unit,
|
||||
as: 'unit',
|
||||
attributes: ['unit_id', 'title'],
|
||||
where: notDeleted,
|
||||
required: true,
|
||||
}],
|
||||
where: { unit_id: courseUnitIds, ...notDeleted },
|
||||
}) : [];
|
||||
|
||||
for (const quiz of unitQuizzes) {
|
||||
const [hasPassed, attemptCount] = await Promise.all([
|
||||
QuizAttempt.findOne({ where: { user_id: userId, quiz_id: quiz.quiz_id, passed: true } }),
|
||||
QuizAttempt.count({ where: { user_id: userId, quiz_id: quiz.quiz_id } }),
|
||||
]);
|
||||
if (!hasPassed) {
|
||||
pending_quizzes.push({
|
||||
quiz_id: quiz.quiz_id,
|
||||
title: quiz.title,
|
||||
unit_title: quiz.unit.title,
|
||||
is_required: quiz.is_required,
|
||||
passing_score: quiz.passing_score,
|
||||
attempt_count: attemptCount,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const assessment = await CourseAssessment.findOne({
|
||||
attributes: ['assessment_id', 'title', 'is_required', 'passing_score'],
|
||||
where: { course_id: courseId },
|
||||
});
|
||||
assessment_configured = !!assessment;
|
||||
if (assessment) {
|
||||
const [hasPassed, attemptCount] = await Promise.all([
|
||||
QuizAttempt.findOne({ where: { user_id: userId, assessment_id: assessment.assessment_id, passed: true } }),
|
||||
QuizAttempt.count({ where: { user_id: userId, assessment_id: assessment.assessment_id } }),
|
||||
]);
|
||||
if (!hasPassed) {
|
||||
pending_assessment = {
|
||||
assessment_id: assessment.assessment_id,
|
||||
title: assessment.title,
|
||||
is_required: assessment.is_required,
|
||||
passing_score: assessment.passing_score,
|
||||
attempt_count: attemptCount,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
course_id: courseId,
|
||||
title: row.course.title,
|
||||
reading_status: readingDone ? 'completed' : 'in_progress',
|
||||
assessment_configured,
|
||||
lessons_total,
|
||||
lessons_completed,
|
||||
last_accessed_at: row.last_accessed_at,
|
||||
pending_quizzes,
|
||||
pending_assessment,
|
||||
};
|
||||
}));
|
||||
|
||||
return R.success(res, 'In-progress courses retrieved.', result);
|
||||
} catch (err) {
|
||||
console.error('[CLIENT][COURSE READING PROGRESS][IN PROGRESS]', err);
|
||||
return R.error(res, 'Could not retrieve in-progress courses.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// ── COMPLETED CONTENT — "live view" of every finished lesson/unit/course ──────
|
||||
// =============================================================================
|
||||
|
||||
// GET /client/courses/completed
|
||||
// Unions the two completion systems (see completion_requirements.service.js header):
|
||||
// - CourseReadingProgress — course-scoped lessons/units/courses (course_id NOT NULL)
|
||||
// - Unit/LessonReadingProgress, filtered to course_id IS NULL — genuinely standalone
|
||||
// reads. Course-scoped reads also get a best-effort mirror written into these same
|
||||
// tables (see recomputeCascade's mirrorLessonRead call) but that mirror always
|
||||
// carries a course_id, so the IS NULL filter here excludes it and avoids double-
|
||||
// counting the same completion from both systems.
|
||||
exports.getMyCompletedContent = async (req, res) => {
|
||||
try {
|
||||
const userId = req.user.user_id;
|
||||
|
||||
const courseScoped = await CourseReadingProgress.findAll({
|
||||
where: { user_id: userId, status: 'completed' },
|
||||
attributes: ['reference_id', 'type', 'completed_at'],
|
||||
include: [{
|
||||
model: Course,
|
||||
as: 'course',
|
||||
attributes: ['course_id', 'uuid', 'title'],
|
||||
where: notDeleted,
|
||||
required: true,
|
||||
}],
|
||||
});
|
||||
|
||||
const completedCourseRows = courseScoped.filter((r) => r.type === 'course');
|
||||
const completedUnitRows = courseScoped.filter((r) => r.type === 'unit');
|
||||
const completedLessonRows = courseScoped.filter((r) => r.type === 'lesson');
|
||||
|
||||
const unitUuids = completedUnitRows.map((r) => r.reference_id);
|
||||
const lessonUuids = completedLessonRows.map((r) => r.reference_id);
|
||||
|
||||
const [unitRows, lessonRows, standaloneUnits, standaloneLessons] = await Promise.all([
|
||||
unitUuids.length
|
||||
? Unit.findAll({ where: { uuid: unitUuids, ...notDeleted }, attributes: ['unit_id', 'uuid', 'title'] })
|
||||
: [],
|
||||
lessonUuids.length
|
||||
? Lesson.findAll({ where: { uuid: lessonUuids, ...notDeleted }, attributes: ['lesson_id', 'uuid', 'title'] })
|
||||
: [],
|
||||
UnitReadingProgress.findAll({
|
||||
where: { user_id: userId, status: 'completed', course_id: null },
|
||||
attributes: ['completed_at'],
|
||||
include: [{
|
||||
model: Unit, as: 'unit', attributes: ['unit_id', 'uuid', 'title'], where: notDeleted, required: true,
|
||||
}],
|
||||
}),
|
||||
LessonReadingProgress.findAll({
|
||||
where: { user_id: userId, status: 'completed', course_id: null },
|
||||
attributes: ['completed_at'],
|
||||
include: [{
|
||||
model: Lesson, as: 'lesson', attributes: ['lesson_id', 'uuid', 'title'], where: notDeleted, required: true,
|
||||
}],
|
||||
}),
|
||||
]);
|
||||
|
||||
const unitByUuid = Object.fromEntries(unitRows.map((u) => [u.uuid, u]));
|
||||
const lessonByUuid = Object.fromEntries(lessonRows.map((l) => [l.uuid, l]));
|
||||
|
||||
const courseIds = completedCourseRows.map((r) => r.course.course_id);
|
||||
const certificates = courseIds.length
|
||||
? await Certificate.findAll({
|
||||
where: { user_id: userId, course_id: courseIds },
|
||||
attributes: ['uuid', 'cert_no', 'issued_at', 'score', 'course_id'],
|
||||
})
|
||||
: [];
|
||||
const certByCourseId = Object.fromEntries(certificates.map((c) => [String(c.course_id), c]));
|
||||
|
||||
const courses = completedCourseRows.map((r) => {
|
||||
const cert = certByCourseId[String(r.course.course_id)] ?? null;
|
||||
return {
|
||||
course_id: r.course.course_id,
|
||||
uuid: r.course.uuid,
|
||||
title: r.course.title,
|
||||
completed_at: r.completed_at,
|
||||
certificate: cert ? { uuid: cert.uuid, cert_no: cert.cert_no, issued_at: cert.issued_at, score: cert.score } : null,
|
||||
};
|
||||
}).sort((a, b) => new Date(b.completed_at) - new Date(a.completed_at));
|
||||
|
||||
const units = [
|
||||
...completedUnitRows
|
||||
.filter((r) => unitByUuid[r.reference_id])
|
||||
.map((r) => ({
|
||||
unit_id: unitByUuid[r.reference_id].unit_id,
|
||||
uuid: r.reference_id,
|
||||
title: unitByUuid[r.reference_id].title,
|
||||
completed_at: r.completed_at,
|
||||
course: { course_id: r.course.course_id, title: r.course.title },
|
||||
})),
|
||||
...standaloneUnits.map((r) => ({
|
||||
unit_id: r.unit.unit_id,
|
||||
uuid: r.unit.uuid,
|
||||
title: r.unit.title,
|
||||
completed_at: r.completed_at,
|
||||
course: null,
|
||||
})),
|
||||
].sort((a, b) => new Date(b.completed_at) - new Date(a.completed_at));
|
||||
|
||||
const lessons = [
|
||||
...completedLessonRows
|
||||
.filter((r) => lessonByUuid[r.reference_id])
|
||||
.map((r) => ({
|
||||
lesson_id: lessonByUuid[r.reference_id].lesson_id,
|
||||
uuid: r.reference_id,
|
||||
title: lessonByUuid[r.reference_id].title,
|
||||
completed_at: r.completed_at,
|
||||
course: { course_id: r.course.course_id, title: r.course.title },
|
||||
})),
|
||||
...standaloneLessons.map((r) => ({
|
||||
lesson_id: r.lesson.lesson_id,
|
||||
uuid: r.lesson.uuid,
|
||||
title: r.lesson.title,
|
||||
completed_at: r.completed_at,
|
||||
course: null,
|
||||
})),
|
||||
].sort((a, b) => new Date(b.completed_at) - new Date(a.completed_at));
|
||||
|
||||
return R.success(res, 'Completed content retrieved.', {
|
||||
courses, units, lessons,
|
||||
counts: { courses: courses.length, units: units.length, lessons: lessons.length },
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[CLIENT][COURSE READING PROGRESS][COMPLETED CONTENT]', err);
|
||||
return R.error(res, 'Could not retrieve completed content.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// ── GET PROGRESS SUMMARY ──────────────────────────────────────────────────────
|
||||
// =============================================================================
|
||||
|
||||
exports.getCourseProgressSummary = async (req, res) => {
|
||||
try {
|
||||
const { courseId } = req.params;
|
||||
const userId = req.user.user_id;
|
||||
|
||||
const course = await Course.findOne({
|
||||
where: { course_id: courseId, ...notDeleted },
|
||||
attributes: ['course_id'],
|
||||
});
|
||||
if (!course) return R.error(res, 'Course not found.', 404);
|
||||
|
||||
const [lessons_total, lessons_completed, courseRow] = await Promise.all([
|
||||
countCourseLessons(courseId),
|
||||
CourseReadingProgress.count({
|
||||
where: { user_id: userId, course_id: courseId, type: 'lesson', status: 'completed' },
|
||||
}),
|
||||
CourseReadingProgress.findOne({
|
||||
where: { user_id: userId, course_id: courseId, type: 'course' },
|
||||
attributes: ['status'],
|
||||
}),
|
||||
]);
|
||||
|
||||
const percent = lessons_total > 0 ? Math.round((lessons_completed / lessons_total) * 100) : 0;
|
||||
|
||||
return R.success(res, 'Progress summary retrieved.', {
|
||||
lessons_total,
|
||||
lessons_completed,
|
||||
percent,
|
||||
status: courseRow?.status ?? null,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[CLIENT][COURSE READING PROGRESS][SUMMARY]', err);
|
||||
return R.error(res, 'Could not retrieve progress summary.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// ── GET PROGRESS SNAPSHOT ─────────────────────────────────────────────────────
|
||||
// =============================================================================
|
||||
|
||||
exports.getCourseProgress = async (req, res) => {
|
||||
try {
|
||||
const { courseId } = req.params;
|
||||
const userId = req.user.user_id;
|
||||
|
||||
const course = await Course.findOne({
|
||||
where: { course_id: courseId, ...notDeleted },
|
||||
attributes: ['course_id'],
|
||||
});
|
||||
if (!course) return R.error(res, 'Course not found.', 404);
|
||||
|
||||
const rows = await CourseReadingProgress.findAll({
|
||||
where: { user_id: userId, course_id: courseId },
|
||||
attributes: ['progress_id', 'reference_id', 'type', 'status', 'completed_at', 'last_accessed_at'],
|
||||
});
|
||||
|
||||
return R.success(res, 'Course progress retrieved.', rows);
|
||||
} catch (err) {
|
||||
console.error('[CLIENT][COURSE READING PROGRESS][GET]', err);
|
||||
return R.error(res, 'Could not retrieve course progress.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// ── TASK CONTEXT FOR A COURSE ─────────────────────────────────────────────────
|
||||
// =============================================================================
|
||||
|
||||
// GET /client/courses/:courseId/task-context
|
||||
// Returns all pending task requirements (read_course / read_unit / read_lesson)
|
||||
// whose reference_id matches this course, any of its units, or any of its lessons,
|
||||
// filtered to tasks the current user is actually assigned to (via group membership).
|
||||
// UnitList calls this on mount when no task context is passed via navigation state.
|
||||
|
||||
exports.getCourseTaskContext = async (req, res) => {
|
||||
try {
|
||||
const { courseId } = req.params;
|
||||
const userId = req.user.user_id;
|
||||
|
||||
const course = await Course.findOne({
|
||||
where: { course_id: courseId, ...notDeleted },
|
||||
attributes: ['course_id', 'uuid'],
|
||||
include: [{
|
||||
model: Unit,
|
||||
as: 'units',
|
||||
where: notDeleted,
|
||||
required: false,
|
||||
attributes: ['unit_id', 'uuid'],
|
||||
through: { attributes: [] },
|
||||
include: [{
|
||||
model: Lesson,
|
||||
as: 'lessons',
|
||||
where: notDeleted,
|
||||
required: false,
|
||||
attributes: ['lesson_id', 'uuid'],
|
||||
through: { attributes: [] },
|
||||
}],
|
||||
}],
|
||||
});
|
||||
if (!course) return R.error(res, 'Course not found.', 404);
|
||||
|
||||
const units = course.units ?? [];
|
||||
const allUuids = [
|
||||
course.uuid,
|
||||
...units.map((u) => u.uuid),
|
||||
...units.flatMap((u) => (u.lessons ?? []).map((l) => l.uuid)),
|
||||
];
|
||||
|
||||
const { taskListIds, taskListToGroup } = await getAccessibleTaskListIds(userId);
|
||||
if (!taskListIds.length) {
|
||||
return R.success(res, 'Task context retrieved.', { has_task: false, contexts: [] });
|
||||
}
|
||||
|
||||
const requirements = await TaskRequirement.findAll({
|
||||
where: {
|
||||
type: { [Op.in]: ['read_course', 'read_unit', 'read_lesson'] },
|
||||
reference_id: { [Op.in]: allUuids },
|
||||
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', 'reference_label'],
|
||||
});
|
||||
|
||||
if (!requirements.length) {
|
||||
return R.success(res, 'Task context retrieved.', { has_task: false, contexts: [] });
|
||||
}
|
||||
|
||||
// Mark which requirements are already completed
|
||||
const taskIds = [...new Set(requirements.map((r) => r.task_id))];
|
||||
const doneProgress = await TaskProgress.findAll({
|
||||
where: { task_id: { [Op.in]: taskIds }, user_id: userId, completed: true },
|
||||
attributes: ['requirement_id', 'reference_id'],
|
||||
});
|
||||
const doneSet = new Set(doneProgress.map((p) => `${p.requirement_id}:${p.reference_id}`));
|
||||
|
||||
const contexts = requirements.map((req) => ({
|
||||
task_id: req.task_id,
|
||||
task_name: req.task.name,
|
||||
task_list_id: req.task.task_list_id,
|
||||
group_id: taskListToGroup[req.task.task_list_id],
|
||||
requirement_id: req.requirement_id,
|
||||
type: req.type,
|
||||
reference_id: req.reference_id,
|
||||
reference_label: req.reference_label,
|
||||
already_completed: doneSet.has(`${req.requirement_id}:${req.reference_id}`),
|
||||
}));
|
||||
|
||||
return R.success(res, 'Task context retrieved.', { has_task: true, contexts });
|
||||
} catch (err) {
|
||||
console.error('[CLIENT][COURSE TASK CONTEXT]', err);
|
||||
return R.error(res, 'Could not retrieve task context.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// ── TASK CONTEXT FOR A STANDALONE LESSON / UNIT ───────────────────────────────
|
||||
// =============================================================================
|
||||
//
|
||||
// Same idea as getCourseTaskContext, but scoped to a single lesson/unit UUID
|
||||
// rather than a whole course tree — the fallback source for LessonDetails.jsx/
|
||||
// UnitReader.jsx (the standalone/library readers reached via /lessons/:uuid and
|
||||
// /units/:uuid/read) when the page is opened directly rather than navigated to
|
||||
// from a task's requirement card, so the "Task mode" banner still shows up.
|
||||
|
||||
// GET /client/courses/lesson/uuid/:uuid/task-context
|
||||
|
||||
exports.getLessonTaskContext = async (req, res) => {
|
||||
try {
|
||||
const { uuid } = req.params;
|
||||
const userId = req.user.user_id;
|
||||
|
||||
const lesson = await Lesson.findOne({ where: { uuid, ...notDeleted }, attributes: ['lesson_id', 'uuid'] });
|
||||
if (!lesson) return R.error(res, 'Lesson not found.', 404);
|
||||
|
||||
const { taskListIds, taskListToGroup } = await getAccessibleTaskListIds(userId);
|
||||
if (!taskListIds.length) return R.success(res, 'Task context retrieved.', { has_task: false, contexts: [] });
|
||||
|
||||
const requirements = await TaskRequirement.findAll({
|
||||
where: { type: 'read_lesson', reference_id: uuid, 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', 'reference_id'],
|
||||
});
|
||||
|
||||
if (!requirements.length) return R.success(res, 'Task context retrieved.', { has_task: false, contexts: [] });
|
||||
|
||||
const taskIds = [...new Set(requirements.map((r) => r.task_id))];
|
||||
const doneProgress = await TaskProgress.findAll({
|
||||
where: { task_id: { [Op.in]: taskIds }, user_id: userId, completed: true },
|
||||
attributes: ['requirement_id', 'reference_id'],
|
||||
});
|
||||
const doneSet = new Set(doneProgress.map((p) => `${p.requirement_id}:${p.reference_id}`));
|
||||
|
||||
const contexts = requirements.map((req) => ({
|
||||
task_id: req.task_id,
|
||||
task_name: req.task.name,
|
||||
task_list_id: req.task.task_list_id,
|
||||
group_id: taskListToGroup[req.task.task_list_id],
|
||||
requirement_id: req.requirement_id,
|
||||
already_completed: doneSet.has(`${req.requirement_id}:${req.reference_id}`),
|
||||
}));
|
||||
|
||||
return R.success(res, 'Task context retrieved.', { has_task: true, contexts });
|
||||
} catch (err) {
|
||||
console.error('[CLIENT][LESSON TASK CONTEXT]', err);
|
||||
return R.error(res, 'Could not retrieve task context.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// GET /client/courses/unit/uuid/:uuid/task-context
|
||||
|
||||
exports.getUnitTaskContext = async (req, res) => {
|
||||
try {
|
||||
const { uuid } = req.params;
|
||||
const userId = req.user.user_id;
|
||||
|
||||
const unit = await Unit.findOne({ where: { uuid, ...notDeleted }, attributes: ['unit_id', 'uuid'] });
|
||||
if (!unit) return R.error(res, 'Unit not found.', 404);
|
||||
|
||||
const { taskListIds, taskListToGroup } = await getAccessibleTaskListIds(userId);
|
||||
if (!taskListIds.length) return R.success(res, 'Task context retrieved.', { has_task: false, contexts: [] });
|
||||
|
||||
const requirements = await TaskRequirement.findAll({
|
||||
where: { type: 'read_unit', reference_id: uuid, 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', 'reference_id'],
|
||||
});
|
||||
|
||||
if (!requirements.length) return R.success(res, 'Task context retrieved.', { has_task: false, contexts: [] });
|
||||
|
||||
const taskIds = [...new Set(requirements.map((r) => r.task_id))];
|
||||
const doneProgress = await TaskProgress.findAll({
|
||||
where: { task_id: { [Op.in]: taskIds }, user_id: userId, completed: true },
|
||||
attributes: ['requirement_id', 'reference_id'],
|
||||
});
|
||||
const doneSet = new Set(doneProgress.map((p) => `${p.requirement_id}:${p.reference_id}`));
|
||||
|
||||
const contexts = requirements.map((req) => ({
|
||||
task_id: req.task_id,
|
||||
task_name: req.task.name,
|
||||
task_list_id: req.task.task_list_id,
|
||||
group_id: taskListToGroup[req.task.task_list_id],
|
||||
requirement_id: req.requirement_id,
|
||||
already_completed: doneSet.has(`${req.requirement_id}:${req.reference_id}`),
|
||||
}));
|
||||
|
||||
return R.success(res, 'Task context retrieved.', { has_task: true, contexts });
|
||||
} catch (err) {
|
||||
console.error('[CLIENT][UNIT TASK CONTEXT]', err);
|
||||
return R.error(res, 'Could not retrieve task context.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// ── UPSERT LESSON PROGRESS ────────────────────────────────────────────────────
|
||||
// =============================================================================
|
||||
|
||||
// POST /client/courses/:courseId/units/:unitId/lessons/:lessonId/progress
|
||||
// Body: { status: 'in_progress' | 'completed' }
|
||||
//
|
||||
// Flow:
|
||||
// 1. Resolve course / unit / lesson to get their UUIDs
|
||||
// 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 {
|
||||
const { courseId, unitId, lessonId } = req.params;
|
||||
const userId = req.user.user_id;
|
||||
const status = req.body.status === 'completed' ? 'completed' : 'in_progress';
|
||||
|
||||
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);
|
||||
|
||||
// ── 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,
|
||||
});
|
||||
|
||||
// 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, 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);
|
||||
|
||||
// Resume-position tracking is unconditional — every block gets it regardless of
|
||||
// whether a completion requirement is configured. recordWatchProgress, below, is
|
||||
// the anti-cheat-validated path and stays a no-op when nothing's configured.
|
||||
await recordPlaybackPosition(userId, { lessonId: lesson.lesson_id, blockId, percent });
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user