diff --git a/controllers/admin/task.controller.js b/controllers/admin/task.controller.js index 920b9c5..f77a2bd 100644 --- a/controllers/admin/task.controller.js +++ b/controllers/admin/task.controller.js @@ -28,6 +28,13 @@ 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'); +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 QuizAttempt = require('../../models/courses/quiz_attempt.mdl'); +const UnitQuiz = require('../../models/courses/unit_quiz.mdl'); // ─── URL normalizer — ensures protocol prefix so link_url is never a relative path ── const normalizeUrl = (url) => { @@ -671,6 +678,142 @@ const PREREQUISITE_INCLUDE = { class TaskValidationError extends Error {} +// ─── Pre-completed assignees check ──────────────────────────────────────────── +// A read_course/read_unit/read_lesson/pass_quiz requirement can reference content +// that a task list's assignees already finished BEFORE this requirement existed. +// That's not an error — task_reading_progress_sync.service.js's hydrateReadTaskProgress +// (client-side, on task list load) already auto-marks it done for them — but the +// admin creating/editing the task has no visibility into it otherwise. This is a +// heads-up, not a validator: it never blocks create/update, only informs. +const READ_TYPE_TO_PROGRESS_TYPE = { read_course: 'course', read_unit: 'unit', read_lesson: 'lesson' }; +const progressKeyType = (reqType) => (reqType === 'pass_quiz' ? 'pass_quiz' : READ_TYPE_TO_PROGRESS_TYPE[reqType]); + +async function getPreCompletedAssignees(taskListId, requirements, transaction) { + const contentReqs = (requirements ?? []).filter( + (r) => r.reference_id && progressKeyType(r.type) + ); + if (!contentReqs.length) return []; + + const members = await getTaskListMembers(taskListId); + if (!members.length) return []; + const userIds = members.map((m) => m.user_id); + + const completedByKey = new Map(); // `${progressType}:${reference_id}` -> Set(user_id) + const markCompleted = (progressType, referenceId, userId) => { + const key = `${progressType}:${referenceId}`; + if (!completedByKey.has(key)) completedByKey.set(key, new Set()); + completedByKey.get(key).add(userId); + }; + + // ── read_course / read_unit / read_lesson ─────────────────────────────── + const readReqs = contentReqs.filter((r) => READ_TYPE_TO_PROGRESS_TYPE[r.type]); + if (readReqs.length) { + const refsByProgressType = readReqs.reduce((acc, r) => { + const progressType = READ_TYPE_TO_PROGRESS_TYPE[r.type]; + if (!acc[progressType]) acc[progressType] = new Set(); + acc[progressType].add(r.reference_id); + return acc; + }, {}); + + const courseRows = await CourseReadingProgress.findAll({ + where: { + user_id: { [Op.in]: userIds }, + status: 'completed', + [Op.or]: Object.entries(refsByProgressType).map(([type, refs]) => ({ + type, reference_id: { [Op.in]: [...refs] }, + })), + }, + attributes: ['user_id', 'type', 'reference_id'], + transaction, + }); + for (const row of courseRows) markCompleted(row.type, row.reference_id, row.user_id); + + // Standalone lesson/unit (no parent course) — resolve uuid -> numeric PK first. + const lessonUuids = [...(refsByProgressType.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) => [l.lesson_id, l.uuid])); + const rows = await LessonReadingProgress.findAll({ + where: { user_id: { [Op.in]: userIds }, lesson_id: { [Op.in]: [...uuidByLessonId.keys()] }, status: 'completed' }, + attributes: ['user_id', 'lesson_id'], + transaction, + }); + for (const row of rows) { + const uuid = uuidByLessonId.get(row.lesson_id); + if (uuid) markCompleted('lesson', uuid, row.user_id); + } + } + } + + const unitUuids = [...(refsByProgressType.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) => [u.unit_id, u.uuid])); + const rows = await UnitReadingProgress.findAll({ + where: { user_id: { [Op.in]: userIds }, unit_id: { [Op.in]: [...uuidByUnitId.keys()] }, status: 'completed' }, + attributes: ['user_id', 'unit_id'], + transaction, + }); + for (const row of rows) { + const uuid = uuidByUnitId.get(row.unit_id); + if (uuid) markCompleted('unit', uuid, row.user_id); + } + } + } + } + + // ── pass_quiz ──────────────────────────────────────────────────────────── + const quizReqs = contentReqs.filter((r) => r.type === 'pass_quiz'); + if (quizReqs.length) { + const quizUuids = [...new Set(quizReqs.map((r) => r.reference_id))]; + const quizzes = await UnitQuiz.findAll({ + where: { uuid: { [Op.in]: quizUuids } }, + attributes: ['quiz_id', 'uuid'], + transaction, + }); + const uuidByQuizId = new Map(quizzes.map((q) => [q.quiz_id, q.uuid])); + const quizIds = quizzes.map((q) => q.quiz_id); + if (quizIds.length) { + const rows = await QuizAttempt.findAll({ + where: { quiz_id: { [Op.in]: quizIds }, user_id: { [Op.in]: userIds }, passed: true }, + attributes: ['user_id', 'quiz_id'], + transaction, + }); + for (const row of rows) { + const uuid = uuidByQuizId.get(row.quiz_id); + if (uuid) markCompleted('pass_quiz', uuid, row.user_id); + } + } + } + + // ── One entry per requirement that has at least one already-completed assignee ── + const results = []; + for (const r of contentReqs) { + const key = `${progressKeyType(r.type)}:${r.reference_id}`; + const completedCount = completedByKey.get(key)?.size ?? 0; + if (completedCount > 0) { + results.push({ + type: r.type, + reference_id: r.reference_id, + reference_label: r.reference_label ?? null, + completedCount, + totalAssignees: userIds.length, + }); + } + } + return results; +} + // ─── 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' @@ -773,6 +916,31 @@ exports.getTasks = async (req, res) => { } }; +// ─── REQUIREMENT COMPLETION CHECK ──────────────────────────────────────────── +// GET /:taskListId/tasks/requirement-completion-check?type=&reference_id=&reference_label= +// +// Live check used by RequirementBuilder.jsx the moment a content item is picked — +// reports whether any of this task list's assignees already completed it. Purely +// informational (see getPreCompletedAssignees above); returns null when nobody has. +exports.checkRequirementCompletion = async (req, res) => { + try { + const { taskListId } = req.params; + const { type, reference_id, reference_label } = req.query; + + if (!type || !reference_id) return R.error(res, 'type and reference_id are required.', 400); + + const [result] = await getPreCompletedAssignees( + taskListId, + [{ type, reference_id, reference_label }], + ); + + return R.success(res, 'Requirement completion check complete.', result ?? null); + } catch (err) { + console.error('[ADMIN][CHECK REQUIREMENT COMPLETION]', err); + return R.error(res, 'Could not check requirement completion.', 500); + } +}; + // ─── GET ONE ────────────────────────────────────────────────────────────────── exports.getTask = async (req, res) => { @@ -872,8 +1040,14 @@ exports.createTask = async (req, res) => { }, PREREQUISITE_INCLUDE], }); + // Heads-up only — never blocks creation. See getPreCompletedAssignees. + const warnings = await getPreCompletedAssignees( + taskListId, + (full.requirements ?? []).map((r) => ({ type: r.type, reference_id: r.reference_id, reference_label: r.reference_label })), + ); + 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); + return R.success(res, 'Task created successfully.', { ...full.toJSON(), warnings }, 201); } catch (err) { await t.rollback(); if (err instanceof TaskValidationError) return R.error(res, err.message, 400); @@ -977,6 +1151,14 @@ exports.updateTask = async (req, res) => { logActivity(req.user.user_id, 'update_task', { entityType: 'task', entityId: Number(taskId) }); + // Heads-up only — never blocks the update. See getPreCompletedAssignees. + const warnings = Array.isArray(requirements) + ? await getPreCompletedAssignees( + taskListId, + (full.requirements ?? []).map((r) => ({ type: r.type, reference_id: r.reference_id, reference_label: r.reference_label })), + ) + : []; + // ── Notify assigned users when requirements changed ──────────────────── if (Array.isArray(requirements)) { try { @@ -1040,7 +1222,7 @@ exports.updateTask = async (req, res) => { } } - return R.success(res, 'Task updated successfully.', full); + return R.success(res, 'Task updated successfully.', { ...full.toJSON(), warnings }); } catch (err) { await t.rollback(); if (err instanceof TaskValidationError) return R.error(res, err.message, 400); diff --git a/routes/admin/task.routes.js b/routes/admin/task.routes.js index 927d918..3345e10 100644 --- a/routes/admin/task.routes.js +++ b/routes/admin/task.routes.js @@ -30,6 +30,7 @@ router.post("/:taskListId/groups/unassign", sensitiveOpsLimiter, controller.unas router.get("/:taskListId/tasks", controller.getTasks); router.get("/:taskListId/tasks/archived", controller.getArchivedTasks); router.get("/:taskListId/tasks/field-values", controller.getTaskFieldValues); +router.get("/:taskListId/tasks/requirement-completion-check", controller.checkRequirementCompletion); router.post("/:taskListId/tasks", sensitiveOpsLimiter, controller.createTask); router.post("/:taskListId/tasks/bulk-archive", sensitiveOpsLimiter, controller.bulkArchiveTasks); router.post("/:taskListId/tasks/bulk-restore", sensitiveOpsLimiter, controller.bulkRestoreTasks); diff --git a/utils/buildQuery.util.js b/utils/buildQuery.util.js index f806b5b..90c59d6 100644 --- a/utils/buildQuery.util.js +++ b/utils/buildQuery.util.js @@ -24,9 +24,11 @@ const AUDIT_ID_FIELDS = new Set(["createdBy", "updatedBy", "deletedBy"]); * "column reference is ambiguous" as soon as a query joins another table * that happens to share a column name (e.g. Users + UserGroups both have * is_active/createdAt/updatedAt/deletedAt). + * @param {Set} [dateFields] - fields whose picklist values are + * calendar days (see below) — matched by range, not substring. * @returns {Object} Sequelize where clause */ -function buildWhere(filters = [], allowedFields = new Set(), parentAlias = null) { +function buildWhere(filters = [], allowedFields = new Set(), parentAlias = null, dateFields = new Set()) { const where = []; for (const { id, value } of filters) { @@ -42,6 +44,31 @@ function buildWhere(filters = [], allowedFields = new Set(), parentAlias = null) const qualifiedCol = parentAlias ? `${parentAlias}.${id}` : id; + // Date/timestamp columns (createdAt, updatedAt, ...) — the filter sheet's + // picklist values are whole calendar days (e.g. "2026-07-16"), but the + // column itself is a full timestamp. Casting the timestamp to TEXT and + // doing a substring iLike match against just the date portion is a loose + // match: it also picks up every OTHER row whose time-of-day component + // happens to render into digits that appear elsewhere in the cast string, + // so a single selected day can silently pull in unrelated rows. Match by + // an explicit [dayStart, nextDayStart) range on the real column instead — + // exact, and immune to how the DB happens to stringify the timestamp. + if (dateFields.has(id) && !id.startsWith("personal_info.")) { + const conditions = values.map((v) => { + const datePart = String(v).slice(0, 10); + const dayStart = new Date(`${datePart}T00:00:00.000Z`); + const dayEnd = new Date(dayStart.getTime() + 24 * 60 * 60 * 1000); + + return Sequelize.where(Sequelize.col(qualifiedCol), { + [Op.gte]: dayStart, + [Op.lt]: dayEnd, + }); + }); + + where.push({ [Op.or]: conditions }); + continue; + } + const conditions = values.map((v) => id.startsWith("personal_info.") ? Sequelize.where( @@ -114,17 +141,19 @@ function buildOrder(sort = [], allowedFields = new Set(), computedFields = new S * * @param {Array} filters * @param {Array} sort + * @param {Array} [dateFields] - fields to match by day-range instead of substring * @returns {{ where: Object, order: Array }} */ -function buildQuery(filters = [], sort = [], allowedFields = [], computedFields = [], parentAlias = null) { +function buildQuery(filters = [], sort = [], allowedFields = [], computedFields = [], parentAlias = null, dateFields = []) { const fieldSet = new Set(allowedFields); const computedSet = new Set(computedFields); const orderFieldSet = new Set([...allowedFields, ...computedFields]); + const dateFieldSet = new Set(dateFields); return { // Computed (subquery/literal) columns aren't real table columns — filtering // via Sequelize.col() would error, so only allow them in ORDER BY, not WHERE. - where: buildWhere(filters, fieldSet, parentAlias), + where: buildWhere(filters, fieldSet, parentAlias, dateFieldSet), order: buildOrder(sort, orderFieldSet, computedSet, parentAlias), }; } diff --git a/utils/paginate.util.js b/utils/paginate.util.js index 1445888..9d3be55 100644 --- a/utils/paginate.util.js +++ b/utils/paginate.util.js @@ -110,11 +110,12 @@ async function paginate(model, req, { const attributes = modelToAttributes(model, { exclude: excludeAttributes, jsonbSchemas, context }); const ALLOWED_FIELDS = attributes.map((a) => a.field); const computedFieldKeys = computedAttributes.map((c) => c.key); + const dateFieldKeys = attributes.filter((a) => a.type === 'date').map((a) => a.field); // Sequelize aliases the main model's table with the model's name by default // (e.g. `FROM "users" AS "User"`) — needed to qualify Sequelize.col() // references so they don't collide with same-named columns on joined tables. - const { where, order } = buildQuery(filters, sort, ALLOWED_FIELDS, computedFieldKeys, model.name); + const { where, order } = buildQuery(filters, sort, ALLOWED_FIELDS, computedFieldKeys, model.name, dateFieldKeys); // Build attribute includes: jsonb + audit subqueries + any extra from findOptions const baseIncludes = jsonbAttr ? [jsonbAttr] : [];