course,tasklist,task and completed validation
Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
@@ -116,7 +116,9 @@ async function syncRequirements(entityType, entityId, req, res) {
|
||||
entity_type: entityType,
|
||||
entity_id: entityId,
|
||||
order: rest.order ?? i,
|
||||
min_percent: rest.type === 'watch_percent' ? (rest.min_percent ?? 100) : null,
|
||||
min_percent: rest.type === 'watch_percent'
|
||||
? Math.min(100, Math.max(1, Math.round(Number(rest.min_percent)) || 100))
|
||||
: null,
|
||||
button_label: rest.type === 'manual_complete' ? (rest.button_label || null) : null,
|
||||
is_required: rest.is_required ?? true,
|
||||
createdBy: req.user.user_id,
|
||||
|
||||
@@ -6,6 +6,7 @@ const R = require("../../utils/response.util");
|
||||
const { paginate } = require("../../utils/paginate.util");
|
||||
const { recomputeDurations, recomputeUnitDuration, recomputeCourseDuration, formatDuration } = require("../../utils/duration.util");
|
||||
const { syncObjectivesCreate, syncObjectivesUpdate } = require("../../utils/courses/objectives.util");
|
||||
const { resolvePrerequisiteTitles } = require("../../utils/courses/resolvePrerequisiteTitles.util");
|
||||
const { syncJunction } = require("../../utils/courses/junction.util");
|
||||
const { archiveOne, archiveMany } = require("../../utils/courses/archive.util");
|
||||
const { restoreOne, restoreMany } = require("../../utils/courses/restore.util");
|
||||
@@ -23,7 +24,7 @@ const {
|
||||
Unit, Lesson, LessonPage,
|
||||
CourseUnit, UnitLesson,
|
||||
CourseObjective, LessonObjective,
|
||||
CoursePrerequisite, CourseAssessment,
|
||||
CoursePrerequisite, CourseRole, CourseAssessment,
|
||||
UnitQuiz, QuizQuestion, QuizOption,
|
||||
QuizAttempt, AssessmentSession,
|
||||
CourseInstructor, CourseAchievement,
|
||||
@@ -102,10 +103,12 @@ exports.getCourse = async (req, res) => {
|
||||
},
|
||||
{ model: CourseObjective, as: "objectives", required: false },
|
||||
{ model: CoursePrerequisite, as: "prerequisites", required: false },
|
||||
{ model: CourseRole, as: "roles", required: false },
|
||||
{ model: CourseAssessment, as: "assessment", required: false },
|
||||
],
|
||||
order: [
|
||||
[{ model: CourseObjective, as: "objectives" }, "order_index", "ASC"],
|
||||
[{ model: CourseRole, as: "roles" }, "order_index", "ASC"],
|
||||
],
|
||||
});
|
||||
|
||||
@@ -113,6 +116,7 @@ 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 });
|
||||
return R.success(res, "Course retrieved.", { data: plain });
|
||||
} catch (err) {
|
||||
console.error("[COURSE][GET ONE]", err);
|
||||
@@ -127,6 +131,7 @@ exports.createCourse = async (req, res) => {
|
||||
title, description, order_index,
|
||||
course_code, level, subscription, status,
|
||||
objectives = [],
|
||||
roles = [],
|
||||
category_ids = [],
|
||||
achievement_keys = [],
|
||||
badge_color, badge_asset_id, badge_image_url,
|
||||
@@ -151,6 +156,7 @@ exports.createCourse = async (req, res) => {
|
||||
}, { transaction: t });
|
||||
|
||||
await syncObjectivesCreate(CourseObjective, "course_id", course.course_id, objectives, t);
|
||||
await syncObjectivesCreate(CourseRole, "course_id", course.course_id, roles, t);
|
||||
await syncJunction(CourseProductCat, course.course_id, category_ids, "category_id", t);
|
||||
|
||||
if (achievement_keys.length) {
|
||||
@@ -196,6 +202,8 @@ exports.createCourseFull = async (req, res) => {
|
||||
title, description, order_index,
|
||||
course_code, level, subscription, status,
|
||||
objectives = [],
|
||||
roles = [],
|
||||
prerequisites = [],
|
||||
category_ids = [],
|
||||
achievement_keys = [],
|
||||
badge_color, badge_asset_id, badge_image_url,
|
||||
@@ -218,6 +226,18 @@ exports.createCourseFull = async (req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
const validRefTypes = ["course", "unit", "lesson"];
|
||||
for (const p of prerequisites) {
|
||||
if (!validRefTypes.includes(p.ref_type)) {
|
||||
await t.rollback();
|
||||
return R.error(res, `Invalid ref_type: ${p.ref_type}`, 400);
|
||||
}
|
||||
if (p.ref_id === undefined || p.ref_id === null || p.ref_id === "") {
|
||||
await t.rollback();
|
||||
return R.error(res, "Each prerequisite needs an item selected.", 400);
|
||||
}
|
||||
}
|
||||
|
||||
const course = await Course.create({
|
||||
title,
|
||||
description: description ?? null,
|
||||
@@ -234,8 +254,21 @@ exports.createCourseFull = async (req, res) => {
|
||||
}, { transaction: t });
|
||||
|
||||
await syncObjectivesCreate(CourseObjective, "course_id", course.course_id, objectives, t);
|
||||
await syncObjectivesCreate(CourseRole, "course_id", course.course_id, roles, t);
|
||||
await syncJunction(CourseProductCat, course.course_id, category_ids, "category_id", t);
|
||||
|
||||
if (prerequisites.length) {
|
||||
await CoursePrerequisite.bulkCreate(
|
||||
prerequisites.map((p, i) => ({
|
||||
course_id: course.course_id,
|
||||
ref_type: p.ref_type,
|
||||
ref_id: p.ref_id,
|
||||
order_index: i,
|
||||
})),
|
||||
{ transaction: t },
|
||||
);
|
||||
}
|
||||
|
||||
if (achievement_keys.length) {
|
||||
await CourseAchievement.bulkCreate(
|
||||
achievement_keys.slice(0, 1).map((key, i) => ({ course_id: course.course_id, achievement_key: key, order_index: i })),
|
||||
@@ -344,7 +377,7 @@ exports.updateCourse = async (req, res) => {
|
||||
const {
|
||||
title, description, order_index,
|
||||
course_code, level, subscription, status,
|
||||
objectives, category_ids,
|
||||
objectives, roles, category_ids,
|
||||
badge_color, badge_asset_id, badge_image_url,
|
||||
updatedBy,
|
||||
} = req.body;
|
||||
@@ -363,6 +396,7 @@ exports.updateCourse = async (req, res) => {
|
||||
await course.save({ transaction: t });
|
||||
|
||||
if (objectives !== undefined) await syncObjectivesUpdate(CourseObjective, "course_id", courseId, objectives, t);
|
||||
if (roles !== undefined) await syncObjectivesUpdate(CourseRole, "course_id", courseId, roles, t, "role_id");
|
||||
if (category_ids !== undefined) await syncJunction(CourseProductCat, courseId, category_ids, "category_id", t);
|
||||
|
||||
await t.commit();
|
||||
@@ -562,7 +596,8 @@ exports.getPrerequisites = async (req, res) => {
|
||||
where: { course_id: courseId },
|
||||
order: [["order_index", "ASC"]],
|
||||
});
|
||||
return R.success(res, "Prerequisites retrieved.", { data: prereqs });
|
||||
const data = await resolvePrerequisiteTitles(prereqs.map((p) => p.toJSON()), { Course, Unit, Lesson });
|
||||
return R.success(res, "Prerequisites retrieved.", { data });
|
||||
} catch (err) {
|
||||
console.error("[PREREQ][GET ALL]", err);
|
||||
return R.error(res, "Could not retrieve prerequisites.", 500);
|
||||
@@ -587,6 +622,10 @@ exports.syncPrerequisites = async (req, res) => {
|
||||
await t.rollback();
|
||||
return R.error(res, `Invalid ref_type: ${p.ref_type}`, 400);
|
||||
}
|
||||
if (p.ref_id === undefined || p.ref_id === null || p.ref_id === "") {
|
||||
await t.rollback();
|
||||
return R.error(res, "Each prerequisite needs an item selected.", 400);
|
||||
}
|
||||
}
|
||||
await CoursePrerequisite.bulkCreate(
|
||||
prerequisites.map((p, i) => ({
|
||||
@@ -2265,7 +2304,7 @@ exports.getCoursesFlat = async (req, res) => {
|
||||
try {
|
||||
const data = await Course.findAll({
|
||||
where: notDeleted,
|
||||
attributes: ["uuid", "title", "subscription", "duration_seconds"],
|
||||
attributes: ["course_id", "uuid", "title", "subscription", "duration_seconds"],
|
||||
order: [["title", "ASC"]],
|
||||
});
|
||||
return R.success(res, "Courses retrieved.", data);
|
||||
@@ -2343,6 +2382,7 @@ exports.getUnitsFlat = async (req, res) => {
|
||||
}
|
||||
|
||||
const data = rows.map((r) => ({
|
||||
unit_id: r.unit_id,
|
||||
uuid: r.uuid,
|
||||
title: r.title,
|
||||
duration_seconds: Number(r.duration_seconds ?? 0),
|
||||
@@ -2389,6 +2429,7 @@ exports.getLessonsFlat = async (req, res) => {
|
||||
}
|
||||
|
||||
const data = rows.map((r) => ({
|
||||
lesson_id: r.lesson_id,
|
||||
uuid: r.uuid,
|
||||
title: r.title,
|
||||
duration_seconds: Number(r.duration_seconds ?? 0),
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
const { Op, Sequelize } = require('sequelize');
|
||||
const sequelize = require('../../config/db.config');
|
||||
|
||||
const { Task, TaskList, TaskRequirement, TaskListGroup, mdl_UserGroups } = require('../../models/task/task.mdl');
|
||||
const { Task, TaskList, TaskRequirement, TaskListGroup, TaskPrerequisite, mdl_UserGroups } = require('../../models/task/task.mdl');
|
||||
const { mdl_UserGroupMembers } = require('../../models/users/user_groups.mdl');
|
||||
const mdl_Users = require('../../models/users/users.mdl');
|
||||
const UserNotification = require('../../models/notifications/user_notification.mdl');
|
||||
@@ -27,6 +27,7 @@ const { permanentDeleteOne, permanentDeleteMany } = require("../../utils/courses
|
||||
const { TaskCompletion } = require('../../models/task/task_completion.mdl');
|
||||
const logActivity = require('../../utils/logActivity.util');
|
||||
const { nextOrderIndex, reorderJunction } = require('../../utils/courses/hierarchy.util');
|
||||
const { wouldCreateCycle } = require('../../utils/courses/taskPrerequisites.util');
|
||||
|
||||
// ─── URL normalizer — ensures protocol prefix so link_url is never a relative path ──
|
||||
const normalizeUrl = (url) => {
|
||||
@@ -95,10 +96,12 @@ exports.getTaskList = async (req, res) => {
|
||||
include: [{
|
||||
model: TaskRequirement,
|
||||
as: 'requirements',
|
||||
paranoid: false,
|
||||
// Requirements are replaced via soft-delete on task update (see
|
||||
// updateTask). paranoid: false here would resurrect the superseded
|
||||
// rows alongside the current set, double-counting "Requirements".
|
||||
attributes: { exclude: adminExclude },
|
||||
order: [['order', 'ASC']],
|
||||
}],
|
||||
}, PREREQUISITE_INCLUDE],
|
||||
},
|
||||
GROUP_INCLUDE,
|
||||
],
|
||||
@@ -519,10 +522,209 @@ exports.unassignGroups = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
// ─── SYNC GROUPS ──────────────────────────────────────────────────────────────
|
||||
// PUT /admin/task-lists/:taskListId/groups
|
||||
// Body: { group_ids: [1, 2, 3] }
|
||||
//
|
||||
// Replaces the full assigned-group set in one request/one transaction, so an
|
||||
// edit that both adds and removes groups only costs a single sensitiveOpsLimiter
|
||||
// hit instead of two (assign + unassign).
|
||||
|
||||
exports.syncGroups = async (req, res) => {
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const { taskListId } = req.params;
|
||||
const { group_ids } = req.body;
|
||||
|
||||
if (!Array.isArray(group_ids))
|
||||
return R.error(res, 'group_ids must be an array.', 400);
|
||||
|
||||
const taskList = await TaskList.findByPk(taskListId, { transaction: t });
|
||||
if (!taskList) {
|
||||
await t.rollback();
|
||||
return R.error(res, 'Task list not found.', 404);
|
||||
}
|
||||
|
||||
// Validate all provided group IDs actually exist
|
||||
const validGroups = await mdl_UserGroups.findAll({
|
||||
where: { group_id: { [Op.in]: group_ids } },
|
||||
attributes: ['group_id'],
|
||||
transaction: t,
|
||||
});
|
||||
const validIds = validGroups.map((g) => g.group_id);
|
||||
const invalidIds = group_ids.filter((id) => !validIds.includes(id));
|
||||
|
||||
const currentRows = await TaskListGroup.findAll({
|
||||
where: { task_list_id: taskListId },
|
||||
attributes: ['group_id'],
|
||||
transaction: t,
|
||||
});
|
||||
const currentIds = currentRows.map((r) => r.group_id);
|
||||
|
||||
const newIds = validIds.filter((id) => !currentIds.includes(id));
|
||||
const removedIds = currentIds.filter((id) => !validIds.includes(id));
|
||||
|
||||
if (newIds.length) {
|
||||
await TaskListGroup.bulkCreate(
|
||||
newIds.map((group_id) => ({
|
||||
task_list_id: taskListId,
|
||||
group_id,
|
||||
assignedAt: new Date(),
|
||||
assignedBy: req.user.user_id,
|
||||
})),
|
||||
{ transaction: t }
|
||||
);
|
||||
}
|
||||
|
||||
if (removedIds.length) {
|
||||
await TaskListGroup.destroy({
|
||||
where: { task_list_id: taskListId, group_id: { [Op.in]: removedIds } },
|
||||
transaction: t,
|
||||
});
|
||||
}
|
||||
|
||||
await t.commit();
|
||||
logActivity(req.user.user_id, 'sync_groups', {
|
||||
entityType: 'task_list',
|
||||
entityId: Number(taskListId),
|
||||
details: { assigned_ids: newIds, unassigned_ids: removedIds },
|
||||
});
|
||||
|
||||
// ── Notify every member of the newly-assigned group(s) ─────────────────
|
||||
if (newIds.length) {
|
||||
try {
|
||||
const taskCount = await Task.count({ where: { task_list_id: taskListId } });
|
||||
const memberRows = await mdl_UserGroupMembers.findAll({
|
||||
where: { group_id: newIds },
|
||||
attributes: ['user_id'],
|
||||
});
|
||||
const seenUsers = new Set();
|
||||
const userIds = memberRows.filter(({ user_id }) => {
|
||||
if (seenUsers.has(user_id)) return false;
|
||||
seenUsers.add(user_id);
|
||||
return true;
|
||||
}).map((m) => m.user_id);
|
||||
|
||||
if (userIds.length) {
|
||||
const now = new Date();
|
||||
const notify = NOTIFICATION_REGISTRY.task_assigned.build({
|
||||
taskListName: taskList.name,
|
||||
taskCount,
|
||||
});
|
||||
await UserNotification.bulkCreate(
|
||||
userIds.map((user_id) => ({ user_id, ...notify, seen: false, createdAt: now, updatedAt: now })),
|
||||
{ validate: false }
|
||||
);
|
||||
}
|
||||
} catch (notifyErr) {
|
||||
console.error('[ADMIN][SYNC GROUPS][NOTIFY]', notifyErr);
|
||||
}
|
||||
}
|
||||
|
||||
return R.success(res, 'Task list groups updated.', {
|
||||
assigned_ids: newIds,
|
||||
unassigned_ids: removedIds,
|
||||
invalid_ids: invalidIds,
|
||||
});
|
||||
} catch (err) {
|
||||
await t.rollback();
|
||||
console.error('[ADMIN][SYNC GROUPS]', err);
|
||||
return R.error(res, 'Could not update task list groups.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// ── TASKS ─────────────────────────────────────────────────────────────────────
|
||||
// =============================================================================
|
||||
|
||||
// ─── Prerequisite include — reused by getTask / getTaskList ───────────────────
|
||||
const PREREQUISITE_INCLUDE = {
|
||||
model: Task,
|
||||
as: 'prerequisites',
|
||||
attributes: ['task_id', 'name'],
|
||||
through: { attributes: [] },
|
||||
};
|
||||
|
||||
class TaskValidationError extends Error {}
|
||||
|
||||
// ─── Active-dependent lookup — guards archive/permanent-delete ────────────────
|
||||
// paranoid: false on the base query so this still works when checking an
|
||||
// already-archived task (permanent-delete path) — the nested 'dependents'
|
||||
// include is left at its default (paranoid: true, independent of the base
|
||||
// query's setting), so only non-archived dependents ever get counted.
|
||||
async function getActiveDependents(taskIds, transaction) {
|
||||
if (!taskIds.length) return new Map();
|
||||
const rows = await Task.findAll({
|
||||
where: { task_id: { [Op.in]: taskIds } },
|
||||
paranoid: false,
|
||||
include: [{
|
||||
model: Task,
|
||||
as: 'dependents',
|
||||
attributes: ['task_id', 'name'],
|
||||
through: { attributes: [] },
|
||||
required: false,
|
||||
}],
|
||||
transaction,
|
||||
});
|
||||
const map = new Map();
|
||||
for (const row of rows) {
|
||||
const names = (row.dependents ?? []).map((d) => d.name);
|
||||
if (names.length) map.set(row.task_id, names);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// ─── Validate + replace a task's prerequisite set ──────────────────────────────
|
||||
// Shared by createTask/updateTask. Throws TaskValidationError (→ 400) on:
|
||||
// - self-reference
|
||||
// - a prerequisite_task_id that isn't a sibling task in the same task list
|
||||
// - a prerequisite task with zero requirements (can never be "completed",
|
||||
// so it would permanently deadlock the dependent task)
|
||||
// - a proposed edge set that would introduce a cycle
|
||||
// On success, hard-deletes the task's existing TaskPrerequisite rows and
|
||||
// bulkCreates the new set (junction rows — not audit content, so unlike
|
||||
// TaskRequirement this is a real delete, not soft-delete).
|
||||
async function syncTaskPrerequisites(taskId, taskListId, prerequisiteIds, transaction) {
|
||||
const ids = [...new Set(prerequisiteIds)];
|
||||
|
||||
if (ids.includes(taskId)) {
|
||||
throw new TaskValidationError('A task cannot be its own prerequisite.');
|
||||
}
|
||||
|
||||
if (ids.length) {
|
||||
const siblingTasks = await Task.findAll({
|
||||
where: { task_id: { [Op.in]: ids }, task_list_id: taskListId },
|
||||
include: [{ model: TaskRequirement, as: 'requirements', attributes: ['requirement_id'] }],
|
||||
transaction,
|
||||
});
|
||||
|
||||
const foundIds = siblingTasks.map((t) => t.task_id);
|
||||
const invalidIds = ids.filter((id) => !foundIds.includes(id));
|
||||
if (invalidIds.length) {
|
||||
throw new TaskValidationError(`Some selected prerequisites do not belong to this task list: ${invalidIds.join(', ')}.`);
|
||||
}
|
||||
|
||||
const emptyTasks = siblingTasks.filter((t) => !(t.requirements ?? []).length);
|
||||
if (emptyTasks.length) {
|
||||
throw new TaskValidationError(
|
||||
`These tasks have no requirements yet and can never be marked complete, so they can't be used as a prerequisite: ${emptyTasks.map((t) => t.name).join(', ')}.`
|
||||
);
|
||||
}
|
||||
|
||||
if (await wouldCreateCycle(taskId, ids, taskListId, transaction)) {
|
||||
throw new TaskValidationError('That selection would create a circular dependency between tasks.');
|
||||
}
|
||||
}
|
||||
|
||||
await TaskPrerequisite.destroy({ where: { task_id: taskId }, transaction });
|
||||
if (ids.length) {
|
||||
await TaskPrerequisite.bulkCreate(
|
||||
ids.map((prerequisite_task_id) => ({ task_id: taskId, prerequisite_task_id })),
|
||||
{ transaction }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── GET ALL ──────────────────────────────────────────────────────────────────
|
||||
|
||||
exports.getTasks = async (req, res) => {
|
||||
@@ -564,6 +766,7 @@ exports.getTask = async (req, res) => {
|
||||
attributes: { exclude: adminExclude },
|
||||
order: [['order', 'ASC']],
|
||||
},
|
||||
PREREQUISITE_INCLUDE,
|
||||
{
|
||||
model: TaskList,
|
||||
as: 'taskList',
|
||||
@@ -589,7 +792,7 @@ exports.createTask = async (req, res) => {
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
const { taskListId } = req.params;
|
||||
const { name, description, deadline, is_required, requirements = [] } = req.body;
|
||||
const { name, description, deadline, is_required, requirements = [], prerequisite_task_ids = [] } = req.body;
|
||||
|
||||
if (!name) return R.error(res, 'Task name is required.', 400);
|
||||
|
||||
@@ -630,6 +833,10 @@ exports.createTask = async (req, res) => {
|
||||
await TaskRequirement.bulkCreate(reqRows, { transaction: t });
|
||||
}
|
||||
|
||||
if (prerequisite_task_ids.length) {
|
||||
await syncTaskPrerequisites(task.task_id, taskListId, prerequisite_task_ids, t);
|
||||
}
|
||||
|
||||
await t.commit();
|
||||
|
||||
const full = await Task.findByPk(task.task_id, {
|
||||
@@ -639,13 +846,14 @@ exports.createTask = async (req, res) => {
|
||||
as: 'requirements',
|
||||
attributes: { exclude: adminExclude },
|
||||
order: [['order', 'ASC']],
|
||||
}],
|
||||
}, PREREQUISITE_INCLUDE],
|
||||
});
|
||||
|
||||
logActivity(req.user.user_id, 'create_task', { entityType: 'task', entityId: task.task_id, details: { name: task.name } });
|
||||
return R.success(res, 'Task created successfully.', full, 201);
|
||||
} catch (err) {
|
||||
await t.rollback();
|
||||
if (err instanceof TaskValidationError) return R.error(res, err.message, 400);
|
||||
console.error('[ADMIN][CREATE TASK]', err);
|
||||
return R.error(res, 'Could not create task.', 500);
|
||||
}
|
||||
@@ -667,7 +875,7 @@ exports.updateTask = async (req, res) => {
|
||||
return R.error(res, 'Task not found.', 404);
|
||||
}
|
||||
|
||||
const { name, description, deadline, status, is_required, requirements } = req.body;
|
||||
const { name, description, deadline, status, is_required, requirements, prerequisite_task_ids } = req.body;
|
||||
|
||||
await task.update(
|
||||
{ name, description, deadline: deadline || null, status, is_required, updatedBy: req.user.user_id },
|
||||
@@ -722,6 +930,10 @@ exports.updateTask = async (req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(prerequisite_task_ids)) {
|
||||
await syncTaskPrerequisites(taskId, taskListId, prerequisite_task_ids, t);
|
||||
}
|
||||
|
||||
await t.commit();
|
||||
|
||||
const full = await Task.findByPk(taskId, {
|
||||
@@ -731,7 +943,7 @@ exports.updateTask = async (req, res) => {
|
||||
as: 'requirements',
|
||||
attributes: { exclude: adminExclude },
|
||||
order: [['order', 'ASC']],
|
||||
}],
|
||||
}, PREREQUISITE_INCLUDE],
|
||||
});
|
||||
|
||||
logActivity(req.user.user_id, 'update_task', { entityType: 'task', entityId: Number(taskId) });
|
||||
@@ -789,6 +1001,7 @@ exports.updateTask = async (req, res) => {
|
||||
return R.success(res, 'Task updated successfully.', full);
|
||||
} catch (err) {
|
||||
await t.rollback();
|
||||
if (err instanceof TaskValidationError) return R.error(res, err.message, 400);
|
||||
console.error('[ADMIN][UPDATE TASK]', err);
|
||||
return R.error(res, 'Could not update task.', 500);
|
||||
}
|
||||
@@ -874,6 +1087,12 @@ exports.archiveTask = async (req, res) => {
|
||||
try {
|
||||
const { taskListId, taskId } = req.params;
|
||||
|
||||
const dependents = await getActiveDependents([taskId], t);
|
||||
if (dependents.has(taskId)) {
|
||||
await t.rollback();
|
||||
return R.error(res, `${dependents.get(taskId).length} task(s) depend on this as a prerequisite: ${dependents.get(taskId).join(', ')}. Remove that dependency first.`, 400);
|
||||
}
|
||||
|
||||
const record = await archiveOne(
|
||||
Task,
|
||||
{ task_id: taskId, task_list_id: taskListId },
|
||||
@@ -934,12 +1153,24 @@ exports.bulkArchiveTasks = async (req, res) => {
|
||||
if (!activeIds.length)
|
||||
return R.error(res, 'All selected tasks are already archived.', 400);
|
||||
|
||||
const count = await archiveMany(Task, 'task_id', activeIds, req.user.user_id, t);
|
||||
// Exclude tasks that other (still-active) tasks depend on as a prerequisite —
|
||||
// archiving them would silently break those dependents' unlock logic.
|
||||
const dependents = await getActiveDependents(activeIds, t);
|
||||
const blockedIds = activeIds.filter((id) => dependents.has(id));
|
||||
const archivableIds = activeIds.filter((id) => !dependents.has(id));
|
||||
|
||||
if (!archivableIds.length) {
|
||||
await t.rollback();
|
||||
return R.error(res, `All selected tasks are depended on as a prerequisite by another task: ${[...dependents.values()].flat().join(', ')}. Remove those dependencies first.`, 400);
|
||||
}
|
||||
|
||||
const count = await archiveMany(Task, 'task_id', archivableIds, req.user.user_id, t);
|
||||
await t.commit();
|
||||
logActivity(req.user.user_id, 'bulk_archive_tasks', { entityType: 'task', details: { ids: activeIds, count } });
|
||||
logActivity(req.user.user_id, 'bulk_archive_tasks', { entityType: 'task', details: { ids: archivableIds, count } });
|
||||
return R.success(res, `${count} task(s) archived successfully.`, {
|
||||
archived_ids: activeIds,
|
||||
skipped_ids: ids.filter((id) => !activeIds.includes(id)),
|
||||
archived_ids: archivableIds,
|
||||
skipped_ids: ids.filter((id) => !archivableIds.includes(id)),
|
||||
blocked_ids: blockedIds,
|
||||
});
|
||||
} catch (err) {
|
||||
await t.rollback();
|
||||
@@ -989,6 +1220,12 @@ exports.permanentlyDeleteTask = async (req, res) => {
|
||||
try {
|
||||
const { taskListId, taskId } = req.params;
|
||||
|
||||
const dependents = await getActiveDependents([taskId], t);
|
||||
if (dependents.has(taskId)) {
|
||||
await t.rollback();
|
||||
return R.error(res, `${dependents.get(taskId).length} task(s) depend on this as a prerequisite: ${dependents.get(taskId).join(', ')}. Remove that dependency first.`, 400);
|
||||
}
|
||||
|
||||
const record = await permanentDeleteOne(Task, { task_id: taskId, task_list_id: taskListId }, t);
|
||||
if (record === null) { await t.rollback(); return R.error(res, 'Task not found.', 404); }
|
||||
if (record === false) { await t.rollback(); return R.error(res, 'Task must be archived before it can be permanently deleted.', 400); }
|
||||
@@ -1023,12 +1260,22 @@ exports.bulkPermanentlyDeleteTasks = async (req, res) => {
|
||||
if (!deletedIds.length)
|
||||
return R.error(res, 'All selected tasks must be archived before they can be permanently deleted.', 400);
|
||||
|
||||
const count = await permanentDeleteMany(Task, 'task_id', deletedIds, t);
|
||||
const dependents = await getActiveDependents(deletedIds, t);
|
||||
const blockedIds = deletedIds.filter((id) => dependents.has(id));
|
||||
const deletableIds = deletedIds.filter((id) => !dependents.has(id));
|
||||
|
||||
if (!deletableIds.length) {
|
||||
await t.rollback();
|
||||
return R.error(res, `All selected tasks are depended on as a prerequisite by another task: ${[...dependents.values()].flat().join(', ')}. Remove those dependencies first.`, 400);
|
||||
}
|
||||
|
||||
const count = await permanentDeleteMany(Task, 'task_id', deletableIds, t);
|
||||
await t.commit();
|
||||
logActivity(req.user.user_id, 'bulk_permanently_delete_tasks', { entityType: 'task', details: { ids: deletedIds, count } });
|
||||
logActivity(req.user.user_id, 'bulk_permanently_delete_tasks', { entityType: 'task', details: { ids: deletableIds, count } });
|
||||
return R.success(res, `${count} task(s) permanently deleted.`, {
|
||||
deleted_ids: deletedIds,
|
||||
skipped_ids: ids.filter((id) => !deletedIds.includes(id)),
|
||||
deleted_ids: deletableIds,
|
||||
skipped_ids: ids.filter((id) => !deletableIds.includes(id)),
|
||||
blocked_ids: blockedIds,
|
||||
});
|
||||
} catch (err) {
|
||||
await t.rollback();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ?? [],
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
'use strict';
|
||||
|
||||
module.exports = {
|
||||
async up(queryInterface, Sequelize) {
|
||||
await queryInterface.createTable('task_prerequisites', {
|
||||
id: { type: Sequelize.UUID, defaultValue: Sequelize.UUIDV4, primaryKey: true },
|
||||
task_id: { type: Sequelize.UUID, allowNull: false, references: { model: 'tasks', key: 'task_id' }, onDelete: 'CASCADE' },
|
||||
prerequisite_task_id: { type: Sequelize.UUID, allowNull: false, references: { model: 'tasks', key: 'task_id' }, onDelete: 'CASCADE' },
|
||||
createdAt: { type: Sequelize.DATE, allowNull: false },
|
||||
updatedAt: { type: Sequelize.DATE, allowNull: false },
|
||||
});
|
||||
|
||||
await queryInterface.addIndex('task_prerequisites', { fields: ['task_id', 'prerequisite_task_id'], unique: true, name: 'uq_task_prerequisite' });
|
||||
await queryInterface.addIndex('task_prerequisites', { fields: ['task_id'], name: 'idx_tp_task_id' });
|
||||
await queryInterface.addIndex('task_prerequisites', { fields: ['prerequisite_task_id'], name: 'idx_tp_prerequisite_task_id' });
|
||||
},
|
||||
|
||||
async down(queryInterface) {
|
||||
await queryInterface.dropTable('task_prerequisites');
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
'use strict';
|
||||
|
||||
// Reshapes completion_requirement_progress.block_progress from
|
||||
// { [blockId]: percentNumber } to { [blockId]: { percent, updatedAt } } so
|
||||
// recordWatchProgress can validate a reported percent against real elapsed
|
||||
// wall-clock time since that block's last sample (anti-skip hardening —
|
||||
// closes the "one API call reports 100%" gaming hole). Applies uniformly to
|
||||
// watch_percent rows too, which start using block_progress as of this change
|
||||
// (previously only watch_video/listen_audio populated it).
|
||||
//
|
||||
// Data-only migration — the column type itself (JSONB) doesn't change, so
|
||||
// this is a plain JS loop + parameterized UPDATE rather than raw jsonb SQL,
|
||||
// consistent with this table's CockroachDB-compatibility precedent (see
|
||||
// 20260715000002, which avoids native ALTER TYPE/enum SQL for the same reason).
|
||||
|
||||
module.exports = {
|
||||
async up(queryInterface) {
|
||||
const rows = await queryInterface.sequelize.query(
|
||||
`SELECT progress_id, block_progress, "updatedAt" FROM completion_requirement_progress WHERE block_progress IS NOT NULL`,
|
||||
{ type: queryInterface.sequelize.QueryTypes.SELECT }
|
||||
);
|
||||
|
||||
for (const row of rows) {
|
||||
const bp = row.block_progress ?? {};
|
||||
const alreadyShaped = Object.values(bp).every((v) => v && typeof v === 'object');
|
||||
if (alreadyShaped) continue;
|
||||
|
||||
const reshaped = Object.fromEntries(
|
||||
Object.entries(bp).map(([blockId, percent]) => [
|
||||
blockId, { percent: Number(percent) || 0, updatedAt: row.updatedAt ?? new Date().toISOString() },
|
||||
])
|
||||
);
|
||||
|
||||
await queryInterface.sequelize.query(
|
||||
`UPDATE completion_requirement_progress SET block_progress = :bp WHERE progress_id = :id`,
|
||||
{ replacements: { bp: JSON.stringify(reshaped), id: row.progress_id } }
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
async down(queryInterface) {
|
||||
const rows = await queryInterface.sequelize.query(
|
||||
`SELECT progress_id, block_progress FROM completion_requirement_progress WHERE block_progress IS NOT NULL`,
|
||||
{ type: queryInterface.sequelize.QueryTypes.SELECT }
|
||||
);
|
||||
|
||||
for (const row of rows) {
|
||||
const bp = row.block_progress ?? {};
|
||||
const flattened = Object.fromEntries(
|
||||
Object.entries(bp).map(([blockId, v]) => [blockId, v?.percent ?? v])
|
||||
);
|
||||
|
||||
await queryInterface.sequelize.query(
|
||||
`UPDATE completion_requirement_progress SET block_progress = :bp WHERE progress_id = :id`,
|
||||
{ replacements: { bp: JSON.stringify(flattened), id: row.progress_id } }
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
'use strict';
|
||||
|
||||
// Raw SQL for the same CockroachDB DO-block/CREATE TYPE reason as
|
||||
// 20260714000002-create-completion-requirement-progress.js.
|
||||
//
|
||||
// Decoupled from completion_requirements entirely — tracks last-known playback
|
||||
// position for ANY video/audio block, regardless of whether the lesson has a
|
||||
// watch-type completion requirement configured. Powers resume-on-reopen only.
|
||||
|
||||
module.exports = {
|
||||
async up(queryInterface) {
|
||||
await queryInterface.sequelize.query(`
|
||||
CREATE TABLE media_playback_positions (
|
||||
position_id UUID PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL REFERENCES users (user_id) ON DELETE CASCADE,
|
||||
lesson_id BIGINT NOT NULL REFERENCES lessons (lesson_id) ON DELETE CASCADE,
|
||||
block_id STRING NOT NULL,
|
||||
percent INTEGER NOT NULL,
|
||||
"createdAt" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT uq_mpp_user_lesson_block UNIQUE (user_id, lesson_id, block_id)
|
||||
);
|
||||
`);
|
||||
|
||||
await queryInterface.sequelize.query(
|
||||
`CREATE INDEX idx_mpp_user_lesson ON media_playback_positions (user_id, lesson_id);`
|
||||
);
|
||||
},
|
||||
|
||||
async down(queryInterface) {
|
||||
await queryInterface.sequelize.query(`DROP TABLE IF EXISTS media_playback_positions;`);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
'use strict';
|
||||
|
||||
module.exports = {
|
||||
async up(queryInterface, Sequelize) {
|
||||
await queryInterface.createTable('course_roles', {
|
||||
role_id: { type: Sequelize.BIGINT, primaryKey: true, autoIncrement: true },
|
||||
course_id: { type: Sequelize.BIGINT, allowNull: false, references: { model: 'courses', key: 'course_id' }, onDelete: 'CASCADE' },
|
||||
text: { type: Sequelize.STRING(255), allowNull: false },
|
||||
order_index: { type: Sequelize.INTEGER, defaultValue: 0 },
|
||||
createdAt: { type: Sequelize.DATE, allowNull: false },
|
||||
updatedAt: { type: Sequelize.DATE, allowNull: false },
|
||||
deletedAt: { type: Sequelize.DATE, allowNull: true },
|
||||
});
|
||||
|
||||
await queryInterface.addIndex('course_roles', ['course_id']);
|
||||
},
|
||||
|
||||
async down(queryInterface) {
|
||||
await queryInterface.dropTable('course_roles');
|
||||
},
|
||||
};
|
||||
@@ -33,7 +33,7 @@ const CompletionRequirement = sequelize.define('CompletionRequirement', {
|
||||
filterable: true,
|
||||
},
|
||||
type: {
|
||||
type: DataTypes.ENUM('read_all_content', 'pass_quiz', 'watch_percent', 'manual_complete'),
|
||||
type: DataTypes.ENUM('read_all_content', 'pass_quiz', 'watch_percent', 'manual_complete', 'watch_video', 'listen_audio'),
|
||||
allowNull: false,
|
||||
filterable: true,
|
||||
},
|
||||
|
||||
@@ -51,7 +51,7 @@ const CompletionRequirementProgress = sequelize.define('CompletionRequirementPro
|
||||
block_progress: {
|
||||
type: DataTypes.JSONB,
|
||||
allowNull: true,
|
||||
comment: 'watch_video/listen_audio only — { [block_id]: percent } running max per matching block; completed once every current block of that type is at 100.',
|
||||
comment: 'watch_percent/watch_video/listen_audio — { [block_id]: { percent, updatedAt } } running max + last-sample timestamp per block. The timestamp lets recordWatchProgress validate a reported percent against real elapsed wall-clock time (anti-skip). watch_video/listen_audio: completed once every current block of that type is at 100. watch_percent: also keeps progress_percent as its aggregate max across whichever block reports.',
|
||||
},
|
||||
completed: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
const { DataTypes } = require("sequelize");
|
||||
const sequelize = require("../../config/db.config");
|
||||
|
||||
const CourseRole = sequelize.define("CourseRole", {
|
||||
role_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true },
|
||||
course_id: { type: DataTypes.BIGINT, allowNull: false },
|
||||
text: { type: DataTypes.STRING(255), allowNull: false },
|
||||
order_index: { type: DataTypes.INTEGER, defaultValue: 0 },
|
||||
}, {
|
||||
tableName: "course_roles",
|
||||
timestamps: true,
|
||||
paranoid: true,
|
||||
});
|
||||
|
||||
module.exports = CourseRole
|
||||
@@ -9,6 +9,7 @@ const LessonPage = require("./lesson_page.mdl");
|
||||
const CourseObjective = require("./course_objective.mdl");
|
||||
const LessonObjective = require("./lesson_objective.mdl");
|
||||
const CoursePrerequisite = require("./course_prerequisite.mdl");
|
||||
const CourseRole = require("./course_role.mdl");
|
||||
const CourseAssessment = require("./course_assessment.mdl");
|
||||
const UnitQuiz = require("./unit_quiz.mdl");
|
||||
const QuizQuestion = require("./quiz_question.mdl");
|
||||
@@ -79,6 +80,7 @@ Course.belongsTo(mdl_Users, { as: "creator", foreignKey: "createdBy" });
|
||||
Course.belongsTo(mdl_Users, { as: "updater", foreignKey: "updatedBy" });
|
||||
Course.hasMany(CourseObjective, { as: "objectives", foreignKey: "course_id" });
|
||||
Course.hasMany(CoursePrerequisite, { as: "prerequisites", foreignKey: "course_id" });
|
||||
Course.hasMany(CourseRole, { as: "roles", foreignKey: "course_id" });
|
||||
Course.hasOne(CourseAssessment, { as: "assessment", foreignKey: "course_id" });
|
||||
Course.hasMany(Certificate, { as: "certificates", foreignKey: "course_id" });
|
||||
Course.hasMany(CourseInstructor, { as: "instructors", foreignKey: "course_id" });
|
||||
@@ -142,7 +144,7 @@ module.exports = {
|
||||
Unit, Lesson, LessonPage,
|
||||
CourseUnit, UnitLesson,
|
||||
CourseObjective, LessonObjective,
|
||||
CoursePrerequisite, CourseAssessment,
|
||||
CoursePrerequisite, CourseRole, CourseAssessment,
|
||||
UnitQuiz, QuizQuestion, QuizOption, QuizAttempt,
|
||||
AssessmentSession, QuizSession,
|
||||
mdl_Category, Certificate, CourseInstructor,
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name: media_playback_position.mdl.js
|
||||
* Type of Program: Model
|
||||
* Description: Per-user "where did I last leave off" position for a video/audio block, entirely
|
||||
* decoupled from CompletionRequirement — tracked for ANY video/audio block regardless
|
||||
* of whether the lesson has a watch-type completion requirement configured. Powers
|
||||
* resume-on-reopen only; carries no completion/anti-cheat semantics (that's
|
||||
* CompletionRequirementProgress's job, see completion_requirement_progress.mdl.js).
|
||||
*
|
||||
* MediaPlaybackPosition — UPSERT key: (user_id, lesson_id, block_id). Last-write-wins, not a
|
||||
* ratcheted max — a deliberate rewind-and-stop should resume there, not
|
||||
* snap back to a previously-reached high-water mark.
|
||||
*
|
||||
* Author: Kenneth Obsequio (@lash0000)
|
||||
* Date Created: Jul. 17, 2026
|
||||
***********************************************************************************************************************************************************************/
|
||||
const { DataTypes } = require('sequelize');
|
||||
const sequelize = require('../../config/db.config');
|
||||
|
||||
const MediaPlaybackPosition = sequelize.define('MediaPlaybackPosition', {
|
||||
position_id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true,
|
||||
},
|
||||
user_id: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: false,
|
||||
references: { model: 'users', key: 'user_id' },
|
||||
onDelete: 'CASCADE',
|
||||
},
|
||||
lesson_id: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: false,
|
||||
references: { model: 'lessons', key: 'lesson_id' },
|
||||
onDelete: 'CASCADE',
|
||||
},
|
||||
block_id: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
comment: 'The block\'s own id within LessonPage.blocks JSONB — no DB-level FK, blocks are not their own table.',
|
||||
},
|
||||
percent: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
comment: 'Last-known % position, 0-100. Last-write-wins — not a ratcheted max.',
|
||||
},
|
||||
}, {
|
||||
tableName: 'media_playback_positions',
|
||||
timestamps: true,
|
||||
paranoid: false, // position rows are never soft-deleted, matches CompletionRequirementProgress
|
||||
indexes: [
|
||||
{
|
||||
unique: true,
|
||||
fields: ['user_id', 'lesson_id', 'block_id'],
|
||||
name: 'uq_mpp_user_lesson_block',
|
||||
},
|
||||
{ fields: ['user_id', 'lesson_id'], name: 'idx_mpp_user_lesson' },
|
||||
],
|
||||
});
|
||||
|
||||
module.exports = MediaPlaybackPosition;
|
||||
+21
-1
@@ -90,6 +90,21 @@ const TaskRequirement = sequelize.define('TaskRequirement', {
|
||||
timestamps: true,
|
||||
});
|
||||
|
||||
// ─── Junction: Task ↔ Task (explicit prerequisite graph) ──────────────────────
|
||||
const TaskPrerequisite = sequelize.define('TaskPrerequisite', {
|
||||
id: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, primaryKey: true },
|
||||
task_id: { type: DataTypes.UUID, allowNull: false, references: { model: 'tasks', key: 'task_id' }, onDelete: 'CASCADE', },
|
||||
prerequisite_task_id: { type: DataTypes.UUID, allowNull: false, references: { model: 'tasks', key: 'task_id' }, onDelete: 'CASCADE', },
|
||||
}, {
|
||||
tableName: 'task_prerequisites',
|
||||
timestamps: true,
|
||||
indexes: [
|
||||
{ unique: true, fields: ['task_id', 'prerequisite_task_id'], name: 'uq_task_prerequisite' },
|
||||
{ fields: ['task_id'], name: 'idx_tp_task_id' },
|
||||
{ fields: ['prerequisite_task_id'], name: 'idx_tp_prerequisite_task_id' },
|
||||
],
|
||||
});
|
||||
|
||||
TaskList.hasMany(Task, { foreignKey: 'task_list_id', as: 'tasks' });
|
||||
Task.belongsTo(TaskList, { foreignKey: 'task_list_id', as: 'taskList' });
|
||||
Task.hasMany(TaskRequirement, { foreignKey: 'task_id', as: 'requirements' });
|
||||
@@ -99,4 +114,9 @@ TaskRequirement.belongsTo(Task, { foreignKey: 'task_id', as: 'task' });
|
||||
TaskList.belongsToMany(mdl_UserGroups, { through: TaskListGroup, foreignKey: 'task_list_id', otherKey: 'group_id', as: 'groups', });
|
||||
mdl_UserGroups.belongsToMany(TaskList, { through: TaskListGroup, foreignKey: 'group_id', otherKey: 'task_list_id', as: 'taskLists', });
|
||||
|
||||
module.exports = { Task, TaskList, TaskRequirement, TaskListGroup, mdl_UserGroups };
|
||||
// Task ↔ Task (self-referential many-to-many through TaskPrerequisite)
|
||||
// 'prerequisites' — tasks THIS task depends on. 'dependents' — tasks that depend on THIS task.
|
||||
Task.belongsToMany(Task, { through: TaskPrerequisite, foreignKey: 'task_id', otherKey: 'prerequisite_task_id', as: 'prerequisites' });
|
||||
Task.belongsToMany(Task, { through: TaskPrerequisite, foreignKey: 'prerequisite_task_id', otherKey: 'task_id', as: 'dependents' });
|
||||
|
||||
module.exports = { Task, TaskList, TaskRequirement, TaskListGroup, TaskPrerequisite, mdl_UserGroups };
|
||||
@@ -22,6 +22,7 @@ router.patch("/:taskListId/restore", sensitiveOpsLimiter, controller.restoreTask
|
||||
|
||||
// ─── Task List Groups ─────────────────────────────────────────────────────────
|
||||
router.get("/:taskListId/groups", controller.getTaskListGroups);
|
||||
router.put("/:taskListId/groups", sensitiveOpsLimiter, controller.syncGroups);
|
||||
router.post("/:taskListId/groups/assign", sensitiveOpsLimiter, controller.assignGroups);
|
||||
router.post("/:taskListId/groups/unassign", sensitiveOpsLimiter, controller.unassignGroups);
|
||||
|
||||
|
||||
@@ -12,7 +12,9 @@ router.get('/in-progress', progressCtrl.getMyInProgressCourses);
|
||||
// UUID lookups (task requirement blocks — must come before /:courseId)
|
||||
router.get('/uuid/:uuid', ctrl.getCourseByUuid);
|
||||
router.get('/unit/uuid/:uuid/lessons', ctrl.getLessonsByUnitUuid);
|
||||
router.get('/unit/uuid/:uuid/task-context', progressCtrl.getUnitTaskContext);
|
||||
router.get('/unit/uuid/:uuid', ctrl.getUnitByUuid);
|
||||
router.get('/lesson/uuid/:uuid/task-context', progressCtrl.getLessonTaskContext);
|
||||
router.get('/lesson/uuid/:uuid', ctrl.getLessonByUuid);
|
||||
router.get('/quiz/uuid/:uuid', ctrl.getQuizByUuid);
|
||||
|
||||
|
||||
@@ -144,9 +144,11 @@ async function recomputeCascade(userId, {
|
||||
// Task-progress sync (read_lesson/read_unit/read_course auto-complete) only runs once
|
||||
// this transaction is durable. When called with an externalTransaction (from
|
||||
// recordWatchProgress/recordManualComplete), that caller commits and syncs itself instead —
|
||||
// running it here would read pre-commit state.
|
||||
// running it here would read pre-commit state. Runs regardless of courseId — a
|
||||
// standalone (no parent course) lesson/unit read_lesson/read_unit task requirement
|
||||
// needs this too, not just course-scoped ones.
|
||||
let completedTasks = [];
|
||||
if (!externalTransaction && courseId) {
|
||||
if (!externalTransaction) {
|
||||
completedTasks = await syncCompletedEntitiesToTaskProgress(userId, { lessonUuid, unitUuid, courseUuid });
|
||||
}
|
||||
|
||||
@@ -224,6 +226,52 @@ async function recomputeCourseAfterAssessment(userId, courseId, t) {
|
||||
// blocks needs both kinds counted together when checking "every video block hit 100%".
|
||||
const VIDEO_LIKE_BLOCK_TYPES = ['video', 'text-video'];
|
||||
|
||||
// ─── Anti-gaming: wall-clock validation for reported watch progress ─────────────
|
||||
// A single client-reported percent (whether from a scrubbed-to-the-end seek or a
|
||||
// direct API call) is never trusted at face value. Each sample is checked against
|
||||
// how much real wall-clock time has elapsed since that block's last recorded
|
||||
// sample, allowing for playback up to MAX_PLAYBACK_SPEED — anything reported
|
||||
// faster than that is clamped down to what's actually plausible.
|
||||
const BASELINE_CEILING_PERCENT = 5; // a block's very first-ever sample is capped here
|
||||
// Small fixed jitter buffer for network/DB latency between consecutive samples — kept
|
||||
// low because it's an ABSOLUTE-seconds allowance added on top of real elapsed time, so a
|
||||
// large value would let two back-to-back calls claim a big percent jump on short clips
|
||||
// regardless of actual elapsed time. Legitimate throttled playback (~10s apart) is still
|
||||
// credited in full since real elapsed time carries most of the allowance there.
|
||||
const TOLERANCE_SECONDS = 2;
|
||||
const MAX_PLAYBACK_SPEED = 2; // matches the client UI's speed cap — do not diverge from it
|
||||
|
||||
function getBlockDuration(page, blockId) {
|
||||
const block = (page?.blocks ?? []).find((b) => String(b.id) === String(blockId));
|
||||
return Number(block?.content?.duration_seconds) || 0;
|
||||
}
|
||||
|
||||
// Returns the percent a sample should actually be credited with, after validating
|
||||
// it against real elapsed wall-clock time since the block's last recorded sample.
|
||||
function reconcileBlockSample({ prevEntry, reportedPercent, durationSeconds, now }) {
|
||||
const clampedReported = Math.min(100, Math.max(0, Math.round(reportedPercent)));
|
||||
|
||||
if (!prevEntry) {
|
||||
// Cold start: no prior sample to check elapsed time against. Accept as a
|
||||
// baseline only, hard-capped — a bare first-ever call (e.g. a direct API
|
||||
// replay bypassing the UI entirely) can never claim large/complete progress.
|
||||
return { percent: Math.min(clampedReported, BASELINE_CEILING_PERCENT), updatedAt: now, isFirstSample: true };
|
||||
}
|
||||
|
||||
const delta = clampedReported - prevEntry.percent;
|
||||
if (delta <= 0 || !durationSeconds) {
|
||||
// Not a forward increase (rewatch/no-op), or duration unknown — fail open
|
||||
// rather than blocking tracking for content whose duration hasn't backfilled.
|
||||
return { percent: Math.max(prevEntry.percent, clampedReported), updatedAt: now, isFirstSample: false };
|
||||
}
|
||||
|
||||
const elapsedSeconds = Math.max(0, (now.getTime() - new Date(prevEntry.updatedAt).getTime()) / 1000);
|
||||
const maxPlausibleDelta = ((elapsedSeconds + TOLERANCE_SECONDS) * MAX_PLAYBACK_SPEED / durationSeconds) * 100;
|
||||
const creditedDelta = Math.min(delta, maxPlausibleDelta);
|
||||
|
||||
return { percent: Math.min(100, Math.round(prevEntry.percent + creditedDelta)), updatedAt: now, isFirstSample: false };
|
||||
}
|
||||
|
||||
async function recordWatchProgress(userId, {
|
||||
lessonId, lessonUuid, unitId = null, unitUuid = null, courseId = null, courseUuid = null,
|
||||
percent, blockId = null, blockType = null,
|
||||
@@ -243,17 +291,42 @@ async function recordWatchProgress(userId, {
|
||||
const percentRequirement = requirements.find((r) => r.type === 'watch_percent');
|
||||
const blockRequirement = blockId ? requirements.find((r) => r.type === blockRequirementType) : null;
|
||||
|
||||
// Both branches need the block's canonical duration for wall-clock validation —
|
||||
// fetched once up front rather than lazily inside the blockRequirement branch.
|
||||
const page = (percentRequirement || blockRequirement)
|
||||
? await LessonPage.findOne({ where: { lesson_id: lessonId }, attributes: ['blocks'] })
|
||||
: null;
|
||||
const durationSeconds = blockId ? getBlockDuration(page, blockId) : 0;
|
||||
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
let anyCompleted = false;
|
||||
let aggregatePercent = null;
|
||||
const now = new Date();
|
||||
|
||||
if (percentRequirement) {
|
||||
const existing = await CompletionRequirementProgress.findOne({
|
||||
where: { requirement_id: percentRequirement.requirement_id, user_id: userId }, transaction: t,
|
||||
});
|
||||
const nextPercent = Math.max(existing?.progress_percent ?? 0, Math.min(100, Math.max(0, Math.round(percent))));
|
||||
const completed = nextPercent >= (percentRequirement.min_percent ?? 100);
|
||||
const prevBlockProgress = existing?.block_progress ?? {};
|
||||
|
||||
let creditedPercent = Math.min(100, Math.max(0, Math.round(percent)));
|
||||
let isFirstSample = false;
|
||||
let nextBlockProgress = prevBlockProgress;
|
||||
|
||||
if (blockId) {
|
||||
const result = reconcileBlockSample({ prevEntry: prevBlockProgress[blockId], reportedPercent: percent, durationSeconds, now });
|
||||
creditedPercent = result.percent;
|
||||
isFirstSample = result.isFirstSample;
|
||||
nextBlockProgress = { ...prevBlockProgress, [blockId]: { percent: creditedPercent, updatedAt: result.updatedAt } };
|
||||
}
|
||||
// No blockId supplied — fall back to the unvalidated legacy behavior
|
||||
// (shouldn't happen given current callers, but don't hard-fail the endpoint).
|
||||
|
||||
const nextPercent = Math.max(existing?.progress_percent ?? 0, creditedPercent);
|
||||
// A block's very first-ever sample can never itself complete the requirement —
|
||||
// guarantees at least one real elapsed-time check ran before crediting completion.
|
||||
const completed = !isFirstSample && nextPercent >= (percentRequirement.min_percent ?? 100);
|
||||
|
||||
await CompletionRequirementProgress.upsert({
|
||||
requirement_id: percentRequirement.requirement_id,
|
||||
@@ -261,6 +334,7 @@ async function recordWatchProgress(userId, {
|
||||
entity_type: 'lesson',
|
||||
entity_id: lessonId,
|
||||
progress_percent: nextPercent,
|
||||
block_progress: nextBlockProgress,
|
||||
completed,
|
||||
completed_at: completed ? (existing?.completed_at ?? new Date()) : null,
|
||||
updatedBy: userId,
|
||||
@@ -275,14 +349,15 @@ async function recordWatchProgress(userId, {
|
||||
where: { requirement_id: blockRequirement.requirement_id, user_id: userId }, transaction: t,
|
||||
});
|
||||
const prevBlockProgress = existing?.block_progress ?? {};
|
||||
const clampedPercent = Math.min(100, Math.max(0, Math.round(percent)));
|
||||
const nextBlockProgress = { ...prevBlockProgress, [blockId]: Math.max(prevBlockProgress[blockId] ?? 0, clampedPercent) };
|
||||
const result = reconcileBlockSample({ prevEntry: prevBlockProgress[blockId], reportedPercent: percent, durationSeconds, now });
|
||||
const nextBlockProgress = { ...prevBlockProgress, [blockId]: { percent: result.percent, updatedAt: result.updatedAt } };
|
||||
|
||||
const page = await LessonPage.findOne({ where: { lesson_id: lessonId }, attributes: ['blocks'], transaction: t });
|
||||
const matchingBlockIds = (page?.blocks ?? [])
|
||||
.filter((b) => isVideoLikeBlock ? VIDEO_LIKE_BLOCK_TYPES.includes(b.type) : b.type === blockType)
|
||||
.map((b) => b.id);
|
||||
const completed = matchingBlockIds.length > 0 && matchingBlockIds.every((id) => (nextBlockProgress[id] ?? 0) >= 100);
|
||||
const completed = matchingBlockIds.length > 0
|
||||
&& !result.isFirstSample
|
||||
&& matchingBlockIds.every((id) => (nextBlockProgress[id]?.percent ?? 0) >= 100);
|
||||
|
||||
await CompletionRequirementProgress.upsert({
|
||||
requirement_id: blockRequirement.requirement_id,
|
||||
@@ -310,10 +385,10 @@ async function recordWatchProgress(userId, {
|
||||
await t.commit();
|
||||
|
||||
// recomputeCascade skipped its own task-sync since it ran under our externalTransaction
|
||||
// (would've read pre-commit state) — run it now that everything is durable.
|
||||
const completedTasks = courseId
|
||||
? await syncCompletedEntitiesToTaskProgress(userId, { lessonUuid, unitUuid, courseUuid })
|
||||
: [];
|
||||
// (would've read pre-commit state) — run it now that everything is durable. Runs
|
||||
// regardless of courseId — a standalone (no parent course) lesson can satisfy a
|
||||
// read_lesson task requirement via watch_video/listen_audio/watch_percent too.
|
||||
const completedTasks = await syncCompletedEntitiesToTaskProgress(userId, { lessonUuid, unitUuid, courseUuid });
|
||||
|
||||
return { progress_percent: aggregatePercent, completed: anyCompleted, cascade: { ...cascade, completed_tasks: completedTasks } };
|
||||
} catch (err) {
|
||||
@@ -365,15 +440,15 @@ async function recordManualComplete(userId, { entityType, entityId, lessonId = n
|
||||
await t.commit();
|
||||
|
||||
// Same reasoning as recordWatchProgress — recomputeCascade (lesson branch) skipped its
|
||||
// own sync under our externalTransaction; the unit/course branches never called it at all.
|
||||
// own sync under our externalTransaction; the unit/course branches never called it at
|
||||
// all. Runs regardless of courseId — a standalone (no parent course) lesson/unit
|
||||
// manual_complete can satisfy a read_lesson/read_unit task requirement too.
|
||||
const syncUuids = entityType === 'lesson'
|
||||
? { lessonUuid, unitUuid, courseUuid }
|
||||
: entityType === 'unit'
|
||||
? { unitUuid, courseUuid }
|
||||
: { courseUuid };
|
||||
result.completed_tasks = courseId || entityType === 'course'
|
||||
? await syncCompletedEntitiesToTaskProgress(userId, syncUuids)
|
||||
: [];
|
||||
result.completed_tasks = await syncCompletedEntitiesToTaskProgress(userId, syncUuids);
|
||||
|
||||
return result;
|
||||
} catch (err) {
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name: playback_position.service.js
|
||||
* Type of Program: Service
|
||||
* Description: Last-known video/audio playback position per (user, lesson, block) — entirely
|
||||
* decoupled from CompletionRequirement, tracked for ANY video/audio block regardless
|
||||
* of whether the lesson has a watch-type completion requirement configured. Powers
|
||||
* "resume where I left off" only; no anti-cheat/wall-clock validation here since
|
||||
* there's nothing being gated — see completion_requirements.service.js for that.
|
||||
*
|
||||
* recordPlaybackPosition — last-write-wins upsert (not a ratcheted max — a deliberate rewind
|
||||
* should resume there, not snap back to a prior high-water mark).
|
||||
* getPlaybackPositions — { [block_id]: percent } for every block tracked on a lesson.
|
||||
*
|
||||
* Author: Kenneth Obsequio (@lash0000)
|
||||
* Date Created: Jul. 17, 2026
|
||||
***********************************************************************************************************************************************************************/
|
||||
'use strict';
|
||||
|
||||
const MediaPlaybackPosition = require('../models/courses/media_playback_position.mdl');
|
||||
|
||||
async function recordPlaybackPosition(userId, { lessonId, blockId, percent }) {
|
||||
if (!blockId) return;
|
||||
|
||||
await MediaPlaybackPosition.upsert({
|
||||
user_id: userId,
|
||||
lesson_id: lessonId,
|
||||
block_id: blockId,
|
||||
percent: Math.min(100, Math.max(0, Math.round(percent))),
|
||||
}, { conflictFields: ['user_id', 'lesson_id', 'block_id'] });
|
||||
}
|
||||
|
||||
async function getPlaybackPositions(userId, lessonId) {
|
||||
const rows = await MediaPlaybackPosition.findAll({
|
||||
where: { user_id: userId, lesson_id: lessonId },
|
||||
attributes: ['block_id', 'percent'],
|
||||
});
|
||||
return Object.fromEntries(rows.map((r) => [r.block_id, r.percent]));
|
||||
}
|
||||
|
||||
module.exports = { recordPlaybackPosition, getPlaybackPositions };
|
||||
@@ -1,8 +1,10 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* 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:
|
||||
* 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
|
||||
@@ -23,6 +25,10 @@
|
||||
|
||||
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');
|
||||
@@ -56,6 +62,60 @@ function normalizeRequirement(row) {
|
||||
};
|
||||
}
|
||||
|
||||
// ─── 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)
|
||||
@@ -85,8 +145,6 @@ async function hydrateReadTaskProgress(userId, requirements = [], options = {})
|
||||
transaction: options.transaction,
|
||||
});
|
||||
|
||||
if (!completedReadingRows.length) return [];
|
||||
|
||||
const completedReading = new Map(
|
||||
completedReadingRows.map((row) => [
|
||||
`${readAttr(row, 'type')}:${readAttr(row, 'reference_id')}`,
|
||||
@@ -94,6 +152,18 @@ async function hydrateReadTaskProgress(userId, requirements = [], options = {})
|
||||
])
|
||||
);
|
||||
|
||||
// ── 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}`)
|
||||
|
||||
@@ -18,26 +18,26 @@ async function syncObjectivesCreate(Model, parentField, parentId, objectives, tr
|
||||
}
|
||||
|
||||
/**
|
||||
* For UPDATE — upsert by objective_id, hard delete removed ones
|
||||
* For UPDATE — upsert by PK field (default "objective_id"), hard delete removed ones
|
||||
* objectives: [{ objective_id: "123", text: "text1" }, { text: "new" }]
|
||||
*/
|
||||
async function syncObjectivesUpdate(Model, parentField, parentId, objectives, transaction) {
|
||||
async function syncObjectivesUpdate(Model, parentField, parentId, objectives, transaction, pkField = "objective_id") {
|
||||
if (!objectives?.length) {
|
||||
await Model.destroy({ where: { [parentField]: parentId }, force: true, transaction });
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = await Model.findAll({ where: { [parentField]: parentId }, transaction });
|
||||
const existingMap = new Map(existing.map((o) => [String(o.objective_id), o]));
|
||||
const existingMap = new Map(existing.map((o) => [String(o[pkField]), o]));
|
||||
const incomingIds = new Set(
|
||||
objectives.filter((o) => o.objective_id).map((o) => String(o.objective_id))
|
||||
objectives.filter((o) => o[pkField]).map((o) => String(o[pkField]))
|
||||
);
|
||||
|
||||
// Hard delete removed
|
||||
const toDelete = existing.filter((o) => !incomingIds.has(String(o.objective_id)));
|
||||
const toDelete = existing.filter((o) => !incomingIds.has(String(o[pkField])));
|
||||
if (toDelete.length) {
|
||||
await Model.destroy({
|
||||
where: { objective_id: toDelete.map((o) => o.objective_id) },
|
||||
where: { [pkField]: toDelete.map((o) => o[pkField]) },
|
||||
force: true,
|
||||
transaction,
|
||||
});
|
||||
@@ -46,7 +46,7 @@ async function syncObjectivesUpdate(Model, parentField, parentId, objectives, tr
|
||||
// Update existing or create new
|
||||
for (let i = 0; i < objectives.length; i++) {
|
||||
const item = objectives[i];
|
||||
const record = item.objective_id ? existingMap.get(String(item.objective_id)) : null;
|
||||
const record = item[pkField] ? existingMap.get(String(item[pkField])) : null;
|
||||
|
||||
if (record) {
|
||||
await record.update({ text: item.text, order_index: i }, { transaction });
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
"use strict";
|
||||
|
||||
const REF_TYPE_CONFIG = {
|
||||
course: { pk: "course_id" },
|
||||
unit: { pk: "unit_id" },
|
||||
lesson: { pk: "lesson_id" },
|
||||
};
|
||||
|
||||
/**
|
||||
* Enriches CoursePrerequisite rows ({ref_type, ref_id, ...}) with the
|
||||
* referenced entity's title (and subscription tier slug, where the entity
|
||||
* has one) — one batched findAll per ref_type instead of a query per row.
|
||||
* Rows whose referenced entity no longer exists (deleted) get title: null
|
||||
* so callers can drop or flag them instead of crashing.
|
||||
*/
|
||||
async function resolvePrerequisiteTitles(prereqs, { Course, Unit, Lesson }) {
|
||||
if (!prereqs?.length) return prereqs ?? [];
|
||||
|
||||
const models = { course: Course, unit: Unit, lesson: Lesson };
|
||||
// Only Course/Unit carry a subscription tier gate — Lesson has none.
|
||||
const attrsByType = { course: ["title", "subscription"], unit: ["title", "subscription"], lesson: ["title"] };
|
||||
const idsByType = { course: new Set(), unit: new Set(), lesson: new Set() };
|
||||
for (const p of prereqs) {
|
||||
if (idsByType[p.ref_type]) idsByType[p.ref_type].add(p.ref_id);
|
||||
}
|
||||
|
||||
const infoMaps = {};
|
||||
for (const [type, { pk }] of Object.entries(REF_TYPE_CONFIG)) {
|
||||
const ids = [...idsByType[type]];
|
||||
infoMaps[type] = new Map();
|
||||
if (!ids.length) continue;
|
||||
const rows = await models[type].findAll({
|
||||
where: { [pk]: ids },
|
||||
attributes: [pk, ...attrsByType[type]],
|
||||
});
|
||||
for (const row of rows) infoMaps[type].set(String(row[pk]), row);
|
||||
}
|
||||
|
||||
return prereqs.map((p) => {
|
||||
const info = infoMaps[p.ref_type]?.get(String(p.ref_id));
|
||||
return {
|
||||
...p,
|
||||
title: info?.title ?? null,
|
||||
subscription: info?.subscription ?? null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Enriches prerequisite rows (already carrying `title`) with `completed` —
|
||||
* whether the given learner has finished the referenced course/unit/lesson.
|
||||
* "Completed" means: a Certificate exists for a course prereq, or the
|
||||
* matching UnitReadingProgress/LessonReadingProgress row has status
|
||||
* "completed" for a unit/lesson prereq.
|
||||
*/
|
||||
async function resolvePrerequisiteCompletion(prereqs, { Certificate, UnitReadingProgress, LessonReadingProgress }, userId) {
|
||||
if (!prereqs?.length) return prereqs ?? [];
|
||||
|
||||
const idsByType = { course: new Set(), unit: new Set(), lesson: new Set() };
|
||||
for (const p of prereqs) {
|
||||
if (idsByType[p.ref_type]) idsByType[p.ref_type].add(p.ref_id);
|
||||
}
|
||||
|
||||
const completedIds = { course: new Set(), unit: new Set(), lesson: new Set() };
|
||||
|
||||
const courseIds = [...idsByType.course];
|
||||
if (courseIds.length) {
|
||||
const certs = await Certificate.findAll({
|
||||
where: { user_id: userId, course_id: courseIds },
|
||||
attributes: ["course_id"],
|
||||
});
|
||||
certs.forEach((c) => completedIds.course.add(String(c.course_id)));
|
||||
}
|
||||
|
||||
const unitIds = [...idsByType.unit];
|
||||
if (unitIds.length) {
|
||||
const rows = await UnitReadingProgress.findAll({
|
||||
where: { user_id: userId, unit_id: unitIds, status: "completed" },
|
||||
attributes: ["unit_id"],
|
||||
});
|
||||
rows.forEach((r) => completedIds.unit.add(String(r.unit_id)));
|
||||
}
|
||||
|
||||
const lessonIds = [...idsByType.lesson];
|
||||
if (lessonIds.length) {
|
||||
const rows = await LessonReadingProgress.findAll({
|
||||
where: { user_id: userId, lesson_id: lessonIds, status: "completed" },
|
||||
attributes: ["lesson_id"],
|
||||
});
|
||||
rows.forEach((r) => completedIds.lesson.add(String(r.lesson_id)));
|
||||
}
|
||||
|
||||
return prereqs.map((p) => ({
|
||||
...p,
|
||||
completed: completedIds[p.ref_type]?.has(String(p.ref_id)) ?? false,
|
||||
}));
|
||||
}
|
||||
|
||||
module.exports = { resolvePrerequisiteTitles, resolvePrerequisiteCompletion };
|
||||
@@ -0,0 +1,63 @@
|
||||
"use strict";
|
||||
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name: taskPrerequisites.util.js
|
||||
* Type of Program: Utility
|
||||
* Description: Cycle detection for the explicit Task ↔ Task prerequisite graph
|
||||
* (task_prerequisites junction table). A task's prerequisite set is
|
||||
* only meaningful if the graph stays a DAG — this checks whether
|
||||
* persisting a proposed edge set would introduce a cycle.
|
||||
*
|
||||
* Author: Kenneth Obsequio (@lash0000)
|
||||
* Date Created: Jul. 17, 2026
|
||||
***********************************************************************************************************************************************************************/
|
||||
|
||||
const { TaskPrerequisite } = require("../../models/task/task.mdl");
|
||||
|
||||
/**
|
||||
* Would setting `taskId`'s prerequisites to `newPrereqIds` introduce a cycle?
|
||||
*
|
||||
* Loads every existing task_prerequisites edge for the task list, overlays the
|
||||
* proposed edges for `taskId` (replacing whatever it currently points to), then
|
||||
* DFS from `taskId` looking for a path back to itself.
|
||||
*/
|
||||
async function wouldCreateCycle(taskId, newPrereqIds, taskListId, transaction) {
|
||||
const { Task } = require("../../models/task/task.mdl");
|
||||
|
||||
const siblingTasks = await Task.findAll({
|
||||
where: { task_list_id: taskListId },
|
||||
attributes: ["task_id"],
|
||||
transaction,
|
||||
});
|
||||
const siblingIds = siblingTasks.map((t) => t.task_id);
|
||||
|
||||
const existingEdges = await TaskPrerequisite.findAll({
|
||||
where: { task_id: siblingIds },
|
||||
attributes: ["task_id", "prerequisite_task_id"],
|
||||
transaction,
|
||||
});
|
||||
|
||||
// adjacency: task_id -> Set(prerequisite_task_id) ("depends on")
|
||||
const adjacency = new Map();
|
||||
for (const { task_id, prerequisite_task_id } of existingEdges) {
|
||||
if (task_id === taskId) continue; // overlay taskId's edges with the proposed set below
|
||||
if (!adjacency.has(task_id)) adjacency.set(task_id, new Set());
|
||||
adjacency.get(task_id).add(prerequisite_task_id);
|
||||
}
|
||||
adjacency.set(taskId, new Set(newPrereqIds));
|
||||
|
||||
// DFS from taskId looking for a path back to taskId
|
||||
const visited = new Set();
|
||||
const stack = [...adjacency.get(taskId)];
|
||||
while (stack.length) {
|
||||
const current = stack.pop();
|
||||
if (current === taskId) return true;
|
||||
if (visited.has(current)) continue;
|
||||
visited.add(current);
|
||||
for (const next of adjacency.get(current) ?? []) stack.push(next);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
module.exports = { wouldCreateCycle };
|
||||
Reference in New Issue
Block a user