From d49e3be4d2d3da577a9567d71726c70b1b41ea9f Mon Sep 17 00:00:00 2001 From: Kenneth Obsequio Date: Fri, 17 Jul 2026 13:04:27 +0800 Subject: [PATCH] course,tasklist,task and completed validation Signed-off-by: Kenneth Obsequio --- .../completion_requirements.controller.js | 4 +- controllers/admin/courses.controller.js | 49 +++- controllers/admin/task.controller.js | 277 +++++++++++++++++- .../course_reading_progress.controller.js | 116 ++++++++ controllers/client/courses.controller.js | 41 ++- controllers/client/task.controller.js | 96 +++++- .../client/task_progress.controller.js | 9 +- controllers/client/units.controller.js | 6 + ...0260717000001-create-task-prerequisites.js | 21 ++ ...00002-reshape-block-progress-timestamps.js | 59 ++++ ...7000003-create-media-playback-positions.js | 33 +++ .../20260717000004-create-course-roles.js | 21 ++ models/courses/completion_requirement.mdl.js | 2 +- .../completion_requirement_progress.mdl.js | 2 +- models/courses/course_role.mdl.js | 15 + models/courses/courses.associations.js | 4 +- models/courses/media_playback_position.mdl.js | 62 ++++ models/task/task.mdl.js | 22 +- routes/admin/task.routes.js | 1 + routes/client/courses.routes.js | 2 + services/completion_requirements.service.js | 107 ++++++- services/playback_position.service.js | 40 +++ .../task_reading_progress_sync.service.js | 78 ++++- utils/courses/objectives.util.js | 14 +- .../courses/resolvePrerequisiteTitles.util.js | 99 +++++++ utils/courses/taskPrerequisites.util.js | 63 ++++ 26 files changed, 1174 insertions(+), 69 deletions(-) create mode 100644 database/migrations/20260717000001-create-task-prerequisites.js create mode 100644 database/migrations/20260717000002-reshape-block-progress-timestamps.js create mode 100644 database/migrations/20260717000003-create-media-playback-positions.js create mode 100644 database/migrations/20260717000004-create-course-roles.js create mode 100644 models/courses/course_role.mdl.js create mode 100644 models/courses/media_playback_position.mdl.js create mode 100644 services/playback_position.service.js create mode 100644 utils/courses/resolvePrerequisiteTitles.util.js create mode 100644 utils/courses/taskPrerequisites.util.js diff --git a/controllers/admin/completion_requirements.controller.js b/controllers/admin/completion_requirements.controller.js index a3479e4..6227417 100644 --- a/controllers/admin/completion_requirements.controller.js +++ b/controllers/admin/completion_requirements.controller.js @@ -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, diff --git a/controllers/admin/courses.controller.js b/controllers/admin/courses.controller.js index ae572d5..0a91309 100644 --- a/controllers/admin/courses.controller.js +++ b/controllers/admin/courses.controller.js @@ -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), diff --git a/controllers/admin/task.controller.js b/controllers/admin/task.controller.js index 1c8057a..0758cc6 100644 --- a/controllers/admin/task.controller.js +++ b/controllers/admin/task.controller.js @@ -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(); diff --git a/controllers/client/course_reading_progress.controller.js b/controllers/client/course_reading_progress.controller.js index 6f133b1..e91bf51 100644 --- a/controllers/client/course_reading_progress.controller.js +++ b/controllers/client/course_reading_progress.controller.js @@ -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, diff --git a/controllers/client/courses.controller.js b/controllers/client/courses.controller.js index 64ea8d8..f5dd0a0 100644 --- a/controllers/client/courses.controller.js +++ b/controllers/client/courses.controller.js @@ -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 ?? [], }; diff --git a/controllers/client/task.controller.js b/controllers/client/task.controller.js index dc7a3f4..6178cea 100644 --- a/controllers/client/task.controller.js +++ b/controllers/client/task.controller.js @@ -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); } diff --git a/controllers/client/task_progress.controller.js b/controllers/client/task_progress.controller.js index 9f5d025..392463a 100644 --- a/controllers/client/task_progress.controller.js +++ b/controllers/client/task_progress.controller.js @@ -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); diff --git a/controllers/client/units.controller.js b/controllers/client/units.controller.js index e3fa13f..f76df6d 100644 --- a/controllers/client/units.controller.js +++ b/controllers/client/units.controller.js @@ -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, diff --git a/database/migrations/20260717000001-create-task-prerequisites.js b/database/migrations/20260717000001-create-task-prerequisites.js new file mode 100644 index 0000000..748ef37 --- /dev/null +++ b/database/migrations/20260717000001-create-task-prerequisites.js @@ -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'); + }, +}; diff --git a/database/migrations/20260717000002-reshape-block-progress-timestamps.js b/database/migrations/20260717000002-reshape-block-progress-timestamps.js new file mode 100644 index 0000000..6436d13 --- /dev/null +++ b/database/migrations/20260717000002-reshape-block-progress-timestamps.js @@ -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 } } + ); + } + }, +}; diff --git a/database/migrations/20260717000003-create-media-playback-positions.js b/database/migrations/20260717000003-create-media-playback-positions.js new file mode 100644 index 0000000..42b777d --- /dev/null +++ b/database/migrations/20260717000003-create-media-playback-positions.js @@ -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;`); + }, +}; diff --git a/database/migrations/20260717000004-create-course-roles.js b/database/migrations/20260717000004-create-course-roles.js new file mode 100644 index 0000000..3266dca --- /dev/null +++ b/database/migrations/20260717000004-create-course-roles.js @@ -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'); + }, +}; diff --git a/models/courses/completion_requirement.mdl.js b/models/courses/completion_requirement.mdl.js index d753fb0..0f75644 100644 --- a/models/courses/completion_requirement.mdl.js +++ b/models/courses/completion_requirement.mdl.js @@ -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, }, diff --git a/models/courses/completion_requirement_progress.mdl.js b/models/courses/completion_requirement_progress.mdl.js index 721681f..75aff23 100644 --- a/models/courses/completion_requirement_progress.mdl.js +++ b/models/courses/completion_requirement_progress.mdl.js @@ -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, diff --git a/models/courses/course_role.mdl.js b/models/courses/course_role.mdl.js new file mode 100644 index 0000000..18bd4c6 --- /dev/null +++ b/models/courses/course_role.mdl.js @@ -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 diff --git a/models/courses/courses.associations.js b/models/courses/courses.associations.js index fee975a..aa7fd83 100644 --- a/models/courses/courses.associations.js +++ b/models/courses/courses.associations.js @@ -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, diff --git a/models/courses/media_playback_position.mdl.js b/models/courses/media_playback_position.mdl.js new file mode 100644 index 0000000..ec3c19a --- /dev/null +++ b/models/courses/media_playback_position.mdl.js @@ -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; diff --git a/models/task/task.mdl.js b/models/task/task.mdl.js index e59108e..bcdd756 100644 --- a/models/task/task.mdl.js +++ b/models/task/task.mdl.js @@ -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 }; \ No newline at end of file +// 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 }; \ No newline at end of file diff --git a/routes/admin/task.routes.js b/routes/admin/task.routes.js index d9f3587..927d918 100644 --- a/routes/admin/task.routes.js +++ b/routes/admin/task.routes.js @@ -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); diff --git a/routes/client/courses.routes.js b/routes/client/courses.routes.js index 4b1a8eb..d4cdbb3 100644 --- a/routes/client/courses.routes.js +++ b/routes/client/courses.routes.js @@ -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); diff --git a/services/completion_requirements.service.js b/services/completion_requirements.service.js index dd84543..3192085 100644 --- a/services/completion_requirements.service.js +++ b/services/completion_requirements.service.js @@ -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) { diff --git a/services/playback_position.service.js b/services/playback_position.service.js new file mode 100644 index 0000000..1b52fd1 --- /dev/null +++ b/services/playback_position.service.js @@ -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 }; diff --git a/services/task_reading_progress_sync.service.js b/services/task_reading_progress_sync.service.js index e8d0894..1273c54 100644 --- a/services/task_reading_progress_sync.service.js +++ b/services/task_reading_progress_sync.service.js @@ -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}`) diff --git a/utils/courses/objectives.util.js b/utils/courses/objectives.util.js index e5016ee..8cfeb5c 100644 --- a/utils/courses/objectives.util.js +++ b/utils/courses/objectives.util.js @@ -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 }); diff --git a/utils/courses/resolvePrerequisiteTitles.util.js b/utils/courses/resolvePrerequisiteTitles.util.js new file mode 100644 index 0000000..f581036 --- /dev/null +++ b/utils/courses/resolvePrerequisiteTitles.util.js @@ -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 }; diff --git a/utils/courses/taskPrerequisites.util.js b/utils/courses/taskPrerequisites.util.js new file mode 100644 index 0000000..f6c0e39 --- /dev/null +++ b/utils/courses/taskPrerequisites.util.js @@ -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 };