mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
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,
|
||||
|
||||
Reference in New Issue
Block a user