course,tasklist,task and completed validation

Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
2026-07-17 13:04:27 +08:00
parent 612805acaf
commit d49e3be4d2
26 changed files with 1174 additions and 69 deletions
@@ -30,6 +30,7 @@ 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 Certificate = require('../../models/courses/certificate.mdl');
const {
@@ -355,6 +356,116 @@ exports.getCourseTaskContext = async (req, res) => {
}
};
// =============================================================================
// ── 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 ────────────────────────────────────────────────────
// =============================================================================
@@ -453,6 +564,11 @@ exports.upsertWatchProgress = 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);
// 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,
+38 -3
View File
@@ -28,11 +28,12 @@ const {
Unit, Lesson, LessonPage,
CourseUnit, UnitLesson,
CourseObjective, LessonObjective,
CoursePrerequisite, CourseAssessment,
CoursePrerequisite, CourseRole, CourseAssessment,
UnitQuiz, QuizQuestion, QuizOption, QuizAttempt,
AssessmentSession, QuizSession, LessonReadingProgress,
AssessmentSession, QuizSession, LessonReadingProgress, UnitReadingProgress,
} = require("../../models/courses/courses.associations");
const { flattenUnits, flattenLessons } = require("../../utils/courses/hierarchy.util");
const { resolvePrerequisiteTitles, resolvePrerequisiteCompletion } = require("../../utils/courses/resolvePrerequisiteTitles.util");
const mdl_TierCategories = require("../../models/tiers/tier_categories.mdl");
const { gradeSubmission } = require("../../utils/courses/grading.util");
const { shuffleOptions, shuffleQuestions, getAttemptStatus } = require("../../utils/courses/quiz_security.util");
@@ -42,6 +43,7 @@ const {
recomputeUnitAfterQuiz,
recomputeCourseAfterAssessment,
} = require('../../services/completion_requirements.service');
const { getPlaybackPositions } = require('../../services/playback_position.service');
const CompletionRequirement = require('../../models/courses/completion_requirement.mdl');
const PendingCertificate = require('../../models/courses/pending_certificate.mdl');
const Certificate = require('../../models/courses/certificate.mdl');
@@ -359,6 +361,11 @@ exports.getCourse = async (req, res) => {
required: false,
attributes: ["prereq_id", "ref_type", "ref_id", "order_index"],
},
{
model: CourseRole, as: "roles",
required: false,
attributes: ["role_id", "text", "order_index"],
},
{
model: CourseAssessment, as: "assessment",
required: false,
@@ -384,6 +391,7 @@ exports.getCourse = async (req, res) => {
order: [
[{ model: CourseObjective, as: "objectives" }, "order_index", "ASC"],
[{ model: CoursePrerequisite, as: "prerequisites" }, "order_index", "ASC"],
[{ model: CourseRole, as: "roles" }, "order_index", "ASC"],
],
});
@@ -391,6 +399,12 @@ exports.getCourse = async (req, res) => {
const plain = course.toJSON();
plain.units = flattenUnits(plain.units); // junction order_index → flat field, sorted
plain.prerequisites = await resolvePrerequisiteTitles(plain.prerequisites, { Course, Unit, Lesson });
plain.prerequisites = await resolvePrerequisiteCompletion(
plain.prerequisites,
{ Certificate, UnitReadingProgress, LessonReadingProgress },
req.user.user_id,
);
// Attach has_passed to each unit's quiz in one query
const quizIds = plain.units
@@ -587,7 +601,23 @@ exports.getLesson = async (req, res) => {
});
if (!lesson) return R.error(res, "Lesson not found.", 404);
return R.success(res, "Lesson retrieved.", { ...lesson.toJSON(), unit_id: Number(unitId), order_index: lessonLink.order_index });
// completion tells the client whether this lesson's video/audio blocks are gated by a
// watch-type requirement (anti-skip seek-cap should only apply then) — resume_positions
// is unconditional, tracked for every video/audio block regardless of completion type.
const [requirement, resumePositions] = await Promise.all([
CompletionRequirement.findOne({
where: { entity_type: "lesson", entity_id: lessonId },
attributes: ["type", "min_percent", "button_label"],
}),
getPlaybackPositions(req.user.user_id, lessonId),
]);
return R.success(res, "Lesson retrieved.", {
...lesson.toJSON(), unit_id: Number(unitId), order_index: lessonLink.order_index,
completion: requirement ? { type: requirement.type, min_percent: requirement.min_percent, button_label: requirement.button_label } : null,
resume_positions: resumePositions,
});
} catch (err) {
console.error("[CLIENT][LESSON][GET ONE]", err);
return R.error(res, "Could not retrieve lesson.", 500);
@@ -1440,6 +1470,10 @@ exports.getLessonByUuid = async (req, res) => {
attributes: ["type", "min_percent", "button_label"],
});
// Unconditional — tracked for every video/audio block regardless of whether `requirement`
// above is watch-type or configured at all (resume is a UX convenience, not a gate).
const resumePositions = await getPlaybackPositions(req.user.user_id, lesson.lesson_id);
const plain = lesson.toJSON();
const firstUnit = plain.units?.[0] ?? null;
const data = {
@@ -1453,6 +1487,7 @@ exports.getLessonByUuid = async (req, res) => {
status: progress?.status ?? "not_started",
completed_at: progress?.completed_at ?? null,
completion: requirement ? { type: requirement.type, min_percent: requirement.min_percent, button_label: requirement.button_label } : null,
resume_positions: resumePositions,
unit: firstUnit ? { unit_id: firstUnit.unit_id, uuid: firstUnit.uuid, title: firstUnit.title, duration_seconds: firstUnit.duration_seconds, course: firstUnit.courses?.[0] ?? null } : null, // back-compat singular field
units: plain.units ?? [],
};
+82 -14
View File
@@ -12,7 +12,7 @@
const { Op } = require('sequelize');
const sequelize = require('../../config/db.config');
const { Task, TaskList, TaskRequirement, TaskListGroup } = require('../../models/task/task.mdl');
const { Task, TaskList, TaskRequirement, TaskListGroup, TaskPrerequisite } = require('../../models/task/task.mdl');
const { TaskCompletion, TaskCompletionFile } = require('../../models/task/task_completion.mdl');
const { TaskLinkVisit, TaskProgress } = require('../../models/task/task_progress.mdl');
const { mdl_UserGroups, mdl_UserGroupMembers } = require('../../models/users/user_groups.mdl');
@@ -207,13 +207,44 @@ const isRequirementDone = (r, signals) => {
}
};
// ─── Helper: resolve which sibling task(s) are blocking a locked task ───────
// Prerequisites are always siblings within the same task list (enforced by
// admin's syncTaskPrerequisites), so names can always be resolved from `arr`.
// Mirrors the locked boolean rule above: explicit prereqIds win when present,
// otherwise falls back to earlier is_required tasks in order_index order.
const resolveLockedBy = (task, i, arr, prereqsByTask, completedById) => {
const prereqIds = prereqsByTask.get(task.task_id);
const blockers = (prereqIds && prereqIds.length)
? arr.filter((t) => prereqIds.includes(t.task_id) && completedById.get(t.task_id) !== true)
: arr.slice(0, i).filter((prev) => prev.is_required && !completedById.get(prev.task_id));
return blockers.map((t) => ({ task_id: t.task_id, name: t.name }));
};
// ─── Helper: explicit prerequisite gate ─────────────────────────────────────
// Returns true/false when `taskId` has explicit task_prerequisites rows —
// ALL of them must be completed by this user. Returns null when the task has
// no explicit prerequisites configured, signaling the caller to fall back to
// the default order_index/is_required linear sequencing below.
const checkPrerequisitesUnlocked = async (userId, taskId) => {
const prereqRows = await TaskPrerequisite.findAll({ where: { task_id: taskId } });
if (!prereqRows.length) return null;
const results = await Promise.all(prereqRows.map((r) => checkTaskCompletion(userId, r.prerequisite_task_id)));
return results.every(Boolean);
};
// ─── Helper: server-side sequencing gate ───────────────────────────────────
// Rejects a completion write if any earlier *required* task in the same list
// isn't complete yet — the enforcement piece the unit-quiz sequencing
// precedent (UnitList.jsx) does NOT have (that one is client-lock-only).
// Shared by this file's submitTask and task_progress.controller.js's
// visitLink/updateProgress.
const assertTaskUnlocked = async (userId, taskListId, orderIndex) => {
// Prefers a task's explicit prerequisites (see checkPrerequisitesUnlocked)
// when configured; otherwise falls back to the original rule — rejects a
// completion write if any earlier *required* task in the same list isn't
// complete yet. Shared by this file's submitTask and
// task_progress.controller.js's visitLink/updateProgress.
const assertTaskUnlocked = async (userId, taskListId, orderIndex, taskId) => {
if (taskId) {
const prereqResult = await checkPrerequisitesUnlocked(userId, taskId);
if (prereqResult !== null) return prereqResult;
}
const earlierRequired = await Task.findAll({
where: { task_list_id: taskListId, order_index: { [Op.lt]: orderIndex }, is_required: true },
include: [{ model: TaskRequirement, as: 'requirements' }],
@@ -274,6 +305,7 @@ const fireTaskCompletedEvent = async (userId, taskId) => {
exports.getTaskCompletionSignals = getTaskCompletionSignals;
exports.isRequirementDone = isRequirementDone;
exports.assertTaskUnlocked = assertTaskUnlocked;
exports.checkPrerequisitesUnlocked = checkPrerequisitesUnlocked;
exports.checkTaskCompletion = checkTaskCompletion;
exports.fireTaskCompletedEvent = fireTaskCompletedEvent;
@@ -357,14 +389,28 @@ exports.getGroupTaskList = async (req, res) => {
const now = Date.now();
// ── Bucket each task by per-requirement completion ──────────────────────
const bucketedTasks = tasks.map((task) => {
// ── has_completed per task (needed up-front — both the bucket AND the
// locked computation below depend on sibling tasks' completion) ────────
const completedById = new Map(tasks.map((task) => {
const requirements = task.requirements ?? [];
return [task.task_id, requirements.length > 0 && requirements.every((r) => isRequirementDone(r, signals))];
}));
const allRequirementsDone = requirements.length > 0 &&
requirements.every((r) => isRequirementDone(r, signals));
// ── Explicit prerequisite edges for these tasks ─────────────────────────
const prereqEdges = taskIds.length
? await TaskPrerequisite.findAll({ where: { task_id: { [Op.in]: taskIds } }, attributes: ['task_id', 'prerequisite_task_id'] })
: [];
const prereqsByTask = new Map();
for (const { task_id, prerequisite_task_id } of prereqEdges) {
if (!prereqsByTask.has(task_id)) prereqsByTask.set(task_id, []);
prereqsByTask.get(task_id).push(prerequisite_task_id);
}
const has_completed = allRequirementsDone;
// ── Bucket + lock each task — a task with explicit prerequisites is
// locked until ALL of them are done; otherwise fall back to the linear
// order_index/is_required rule (same rule assertTaskUnlocked enforces).
const bucketedTasks = tasks.map((task, i, arr) => {
const has_completed = completedById.get(task.task_id);
let bucket;
if (has_completed) {
@@ -375,7 +421,10 @@ exports.getGroupTaskList = async (req, res) => {
bucket = 'ongoing';
}
return { ...task, has_completed, _bucket: bucket };
const lockedBy = resolveLockedBy(task, i, arr, prereqsByTask, completedById);
const locked = lockedBy.length > 0;
return { ...task, has_completed, locked, lockedBy, _bucket: bucket };
});
// ── Filter by requested status, strip internal _bucket field ──────────
@@ -479,6 +528,16 @@ exports.getGroupTaskLists = async (req, res) => {
return requirements.every((r) => isRequirementDone(r, signals));
};
// ── Explicit prerequisite edges for these tasks ─────────────────────────
const prereqEdges = taskIds.length
? await TaskPrerequisite.findAll({ where: { task_id: { [Op.in]: taskIds } }, attributes: ['task_id', 'prerequisite_task_id'] })
: [];
const prereqsByTask = new Map();
for (const { task_id, prerequisite_task_id } of prereqEdges) {
if (!prereqsByTask.has(task_id)) prereqsByTask.set(task_id, []);
prereqsByTask.get(task_id).push(prerequisite_task_id);
}
// ── Bucket each task list based on per-task has_completed ───────────────
const bucketed = taskLists.map((tl) => {
const json = tl.toJSON();
@@ -488,6 +547,14 @@ exports.getGroupTaskLists = async (req, res) => {
task.has_completed = computeHasCompleted(task);
});
// has_completed lookup scoped to THIS list's tasks (order_index
// fallback only ever looks at siblings within the same list).
const completedById = new Map(tasks.map((task) => [task.task_id, task.has_completed]));
tasks.forEach((task, i, arr) => {
task.lockedBy = resolveLockedBy(task, i, arr, prereqsByTask, completedById);
task.locked = task.lockedBy.length > 0;
});
let bucket;
if (tasks.length === 0) {
bucket = 'ongoing';
@@ -588,6 +655,7 @@ exports.getTask = async (req, res) => {
const data = task.toJSON();
data.latest_completion = data.completions?.[0] ?? null;
delete data.completions;
data.locked = !(await assertTaskUnlocked(req.user.user_id, taskListId, task.order_index, taskId));
return R.success(res, 'Task retrieved.', data);
} catch (err) {
@@ -655,7 +723,7 @@ exports.submitTask = async (req, res) => {
const task = await Task.findOne({ where: { task_id: taskId, task_list_id: taskListId }, transaction: t });
if (!task) { await t.rollback(); return R.error(res, 'Task not found.', 404); }
if (!(await assertTaskUnlocked(req.user.user_id, taskListId, task.order_index))) {
if (!(await assertTaskUnlocked(req.user.user_id, taskListId, task.order_index, taskId))) {
await t.rollback();
return R.error(res, 'Complete the earlier required tasks in this list first.', 409);
}
@@ -218,7 +218,7 @@ exports.visitLink = async (req, res) => {
const task = await Task.findOne({ where: { task_id: taskId, task_list_id: taskListId }, transaction: t });
if (!task) { await t.rollback(); return R.error(res, 'Task not found.', 404); }
if (!(await assertTaskUnlocked(req.user.user_id, taskListId, task.order_index))) {
if (!(await assertTaskUnlocked(req.user.user_id, taskListId, task.order_index, taskId))) {
await t.rollback();
return R.error(res, 'Complete the earlier required tasks in this list first.', 409);
}
@@ -347,6 +347,13 @@ exports.updateProgress = async (req, res) => {
return R.error(res, `Cannot update progress for requirement type: ${requirement.type}.`, 400);
}
const task = await Task.findOne({ where: { task_id: taskId, task_list_id: taskListId }, transaction: t });
if (!task) { await t.rollback(); return R.error(res, 'Task not found.', 404); }
if (!(await assertTaskUnlocked(req.user.user_id, taskListId, task.order_index, taskId))) {
await t.rollback();
return R.error(res, 'Complete the earlier required tasks in this list first.', 409);
}
const now = new Date();
const userId = req.user.user_id;
const wasComplete = await checkTaskCompletion(userId, taskId);
+6
View File
@@ -44,6 +44,7 @@ const coursesCtrl = require("./courses.controller"); // canAccessUnit / canAcces
const { gradeSubmission } = require("../../utils/courses/grading.util");
const { shuffleOptions, shuffleQuestions, getAttemptStatus } = require("../../utils/courses/quiz_security.util");
const { recomputeCascade, recordWatchProgress, recordManualComplete } = require("../../services/completion_requirements.service");
const { recordPlaybackPosition } = require("../../services/playback_position.service");
const notDeleted = { deletedAt: null };
@@ -421,6 +422,11 @@ exports.upsertStandaloneWatchProgress = async (req, res) => {
unitId = unit.unit_id;
}
// 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, unitUuid,