course,tasklist,task and completed validation

Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
2026-07-17 13:04:27 +08:00
parent 612805acaf
commit d49e3be4d2
26 changed files with 1174 additions and 69 deletions
@@ -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,
+45 -4
View File
@@ -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),
+262 -15
View File
@@ -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();