diff --git a/controllers/admin/notificationSettings.controller.js b/controllers/admin/notificationSettings.controller.js index 3a14c10..6021735 100644 --- a/controllers/admin/notificationSettings.controller.js +++ b/controllers/admin/notificationSettings.controller.js @@ -8,12 +8,16 @@ const { rescheduleJob, getOrCreateSetting } = require('../../cron/cronRegistry.u // ─── Job registry — which cron scope owns each job (for defaults + labels) ──── const JOBS = { - taskOverdue: { schedule: '0 * * * *', label: 'Task Overdue Alerts (Admin)', description: 'Notifies admins when tasks flip to overdue.' }, - userNotifications: { schedule: '5 * * * *', label: 'Task Overdue Alerts (Users)', description: 'Notifies affected users when their tasks are marked overdue.' }, + taskOverdue: { schedule: '0 * * * *', label: 'Task Alerts (Admin)', description: 'Automatically marks expired tasks as overdue or completed, and notifies admins.' }, + userNotifications: { schedule: '5 * * * *', label: 'Task Alerts (Users)', description: 'Notifies affected users when their tasks are automatically marked overdue or completed.' }, issueCertificates: { schedule: '0 * * * *', label: 'Certificate Issued', description: 'Notifies users when a course certificate is ready.' }, expireUserTiers: { schedule: '* * * * *', label: 'Tier Expired', description: 'Notifies users when their subscription tier expires.' }, }; +// Jobs whose behavior can be tuned via target_status, and the values each accepts. +const TARGET_STATUS_OPTIONS = ['overdue', 'completed']; +const TARGET_STATUS_JOBS = ['taskOverdue']; + // ─── GET ────────────────────────────────────────────────────────────────────── exports.getSettings = async (req, res) => { @@ -26,6 +30,9 @@ exports.getSettings = async (req, res) => { enabled: row.enabled, schedule: row.schedule, preset: CRON_PRESET_BY_EXPRESSION[row.schedule] ?? null, + target_status: TARGET_STATUS_JOBS.includes(job_name) + ? (row.target_status ?? 'completed') + : null, label: meta.label, description: meta.description, updatedAt: row.updatedAt, @@ -44,7 +51,7 @@ exports.getSettings = async (req, res) => { exports.updateSetting = async (req, res) => { try { const { jobName } = req.params; - const { enabled, preset, updatedBy } = req.body; + const { enabled, preset, target_status, updatedBy } = req.body; if (!JOBS[jobName]) return R.error(res, `Unknown job "${jobName}".`, 404); @@ -53,6 +60,16 @@ exports.updateSetting = async (req, res) => { if (enabled !== undefined) row.enabled = enabled === true || enabled === 'true'; + if (target_status !== undefined) { + if (!TARGET_STATUS_JOBS.includes(jobName)) { + return R.error(res, `"target_status" is not configurable for job "${jobName}".`, 400); + } + if (!TARGET_STATUS_OPTIONS.includes(target_status)) { + return R.error(res, `Invalid target_status. Must be one of: ${TARGET_STATUS_OPTIONS.join(', ')}`, 400); + } + row.target_status = target_status; + } + if (preset !== undefined) { const schedule = CRON_PRESETS[preset]; if (!schedule) return R.error(res, `Invalid preset. Must be one of: ${Object.keys(CRON_PRESETS).join(', ')}`, 400); @@ -69,7 +86,7 @@ exports.updateSetting = async (req, res) => { row.updatedBy = updatedBy ?? null; await row.save(); - logActivity(req.user?.user_id, 'update_notification_setting', { entityType: 'cron_notification_setting', entityId: jobName, details: { enabled: row.enabled, schedule: row.schedule } }); + logActivity(req.user?.user_id, 'update_notification_setting', { entityType: 'cron_notification_setting', entityId: jobName, details: { enabled: row.enabled, schedule: row.schedule, target_status: row.target_status } }); return R.success(res, 'Notification setting updated.', { data: row }); } catch (err) { console.error('[NOTIFICATION SETTINGS][UPDATE]', err); diff --git a/controllers/admin/task.controller.js b/controllers/admin/task.controller.js index 0758cc6..c93c876 100644 --- a/controllers/admin/task.controller.js +++ b/controllers/admin/task.controller.js @@ -36,6 +36,30 @@ const normalizeUrl = (url) => { return `https://${url}`; }; +// Deduped { user_id, group_id } rows for every user in a group assigned to this +// task list — shared by the requirements-changed and submissions-toggled notify +// blocks in updateTask. First group membership wins if a user is in more than one. +async function getTaskListMembers(taskListId) { + const groupRows = await TaskListGroup.findAll({ + where: { task_list_id: taskListId }, + attributes: ['group_id'], + }); + const groupIds = groupRows.map((r) => r.group_id); + if (!groupIds.length) return []; + + const memberRows = await mdl_UserGroupMembers.findAll({ + where: { group_id: groupIds }, + attributes: ['user_id', 'group_id'], + }); + + const seenUsers = new Set(); + return memberRows.filter(({ user_id }) => { + if (seenUsers.has(user_id)) return false; + seenUsers.add(user_id); + return true; + }); +} + // ─── Allowed filter/sort fields ─────────────────────────────────────────────── const TASK_LIST_FIELDS = ['name', 'description', 'createdAt', 'updatedAt', 'deletedAt']; const TASK_FIELDS = ['name', 'description', 'deadline', 'order_index', 'is_required', 'status', 'createdAt', 'updatedAt', 'deletedAt']; @@ -875,12 +899,19 @@ exports.updateTask = async (req, res) => { return R.error(res, 'Task not found.', 404); } - const { name, description, deadline, status, is_required, requirements, prerequisite_task_ids } = req.body; + const { name, description, deadline, status, is_required, accepts_submissions, requirements, prerequisite_task_ids } = req.body; - await task.update( - { name, description, deadline: deadline || null, status, is_required, updatedBy: req.user.user_id }, - { transaction: t } - ); + const wasAccepting = task.accepts_submissions; + + const updates = { updatedBy: req.user.user_id }; + if (name !== undefined) updates.name = name; + if (description !== undefined) updates.description = description; + if (deadline !== undefined) updates.deadline = deadline || null; + if (status !== undefined) updates.status = status; + if (is_required !== undefined) updates.is_required = is_required; + if (accepts_submissions !== undefined) updates.accepts_submissions = accepts_submissions; + + await task.update(updates, { transaction: t }); // ───────────────────────────────────────────────────────────────────────────── // PATCH: exports.updateTask in task.controller.js (admin) @@ -951,53 +982,66 @@ exports.updateTask = async (req, res) => { // ── Notify assigned users when requirements changed ──────────────────── if (Array.isArray(requirements)) { try { - const groupRows = await TaskListGroup.findAll({ - where: { task_list_id: task.task_list_id }, - attributes: ['group_id'], - }); - const groupIds = groupRows.map((r) => r.group_id); + const members = await getTaskListMembers(task.task_list_id); - if (groupIds.length) { - const memberRows = await mdl_UserGroupMembers.findAll({ - where: { group_id: groupIds }, - attributes: ['user_id', 'group_id'], + if (members.length) { + const now = new Date(); + // Title/message are identical for every member — render once, + // then vary only the per-member groupId in the data payload. + const notify = NOTIFICATION_REGISTRY.task_requirements_updated.build({ + taskName: full.name, + taskListId: task.task_list_id, + taskId: task.task_id, + groupId: null, }); - - // One notification per user — first group membership wins if they're in more than one. - const seenUsers = new Set(); - const members = memberRows.filter(({ user_id }) => { - if (seenUsers.has(user_id)) return false; - seenUsers.add(user_id); - return true; - }); - - if (members.length) { - const now = new Date(); - // Title/message are identical for every member — render once, - // then vary only the per-member groupId in the data payload. - const notify = NOTIFICATION_REGISTRY.task_requirements_updated.build({ - taskName: full.name, - taskListId: task.task_list_id, - groupId: null, - }); - await UserNotification.bulkCreate( - members.map(({ user_id, group_id }) => ({ - user_id, - ...notify, - data: { ...notify.data, groupId: group_id }, - seen: false, - createdAt: now, - updatedAt: now, - })), - { validate: false } - ); - } + await UserNotification.bulkCreate( + members.map(({ user_id, group_id }) => ({ + user_id, + ...notify, + data: { ...notify.data, groupId: group_id }, + seen: false, + createdAt: now, + updatedAt: now, + })), + { validate: false } + ); } } catch (notifyErr) { console.error('[ADMIN][UPDATE TASK][NOTIFY]', notifyErr); } } + // ── Notify assigned users when accepting-submissions toggled ─────────── + if (accepts_submissions !== undefined && accepts_submissions !== wasAccepting) { + try { + const members = await getTaskListMembers(task.task_list_id); + + if (members.length) { + const now = new Date(); + const registryKey = accepts_submissions ? 'task_submissions_reopened' : 'task_submissions_closed'; + const notify = NOTIFICATION_REGISTRY[registryKey].build({ + taskName: full.name, + taskListId: task.task_list_id, + taskId: task.task_id, + groupId: null, + }); + await UserNotification.bulkCreate( + members.map(({ user_id, group_id }) => ({ + user_id, + ...notify, + data: { ...notify.data, groupId: group_id }, + seen: false, + createdAt: now, + updatedAt: now, + })), + { validate: false } + ); + } + } catch (notifyErr) { + console.error('[ADMIN][UPDATE TASK][NOTIFY SUBMISSIONS]', notifyErr); + } + } + return R.success(res, 'Task updated successfully.', full); } catch (err) { await t.rollback(); diff --git a/controllers/client/task.controller.js b/controllers/client/task.controller.js index 6178cea..0678766 100644 --- a/controllers/client/task.controller.js +++ b/controllers/client/task.controller.js @@ -723,6 +723,11 @@ 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 (task.accepts_submissions === false) { + await t.rollback(); + return R.error(res, 'This task no longer accepts submissions.', 409); + } + 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/cron/jobs/task_overdue.cron.js b/cron/jobs/task_overdue.cron.js index 7db14a3..34c78d8 100644 --- a/cron/jobs/task_overdue.cron.js +++ b/cron/jobs/task_overdue.cron.js @@ -1,13 +1,27 @@ /*********************************************************************************************************************************************************************** * File Name : task_overdue.cron.js * Type : Cron Job - * Description : Flips Task.status to 'overdue' once its deadline has passed, - * provided it isn't already 'completed' or 'overdue'. Purely - * an admin-facing lifecycle label — does NOT touch + * Description : Flips Task.status to a configurable target status — 'overdue' + * (default before Jul 2026) or 'completed' (current default) — + * once its deadline has passed, provided it isn't already + * 'completed' or 'overdue'. The target status is an admin- + * configurable setting (cron_notification_settings.target_status + * for job_name 'taskOverdue'; NULL is treated as 'completed'). + * Purely an admin-facing lifecycle label — does NOT touch * TaskCompletion/TaskLinkVisit/TaskProgress, does NOT affect * per-user completion signals or client-side Ongoing/Done/ * Overdue bucketing, and does NOT block late submissions. * + * Every task this job touches also gets auto_marked_at set to + * the current time — this is the ONLY writer of that column, + * so downstream consumers (e.g. cron/jobs/user_notifications.cron.js) + * can distinguish "the system just did this" from a user's own + * legitimate completion. A task already sitting in 'overdue' or + * 'completed' is never reclaimed by this sweep even if the + * target status changes later — this job only ever moves tasks + * OUT of 'pending'/'in_progress', never between the two terminal + * states. + * * Safety pattern: * - Task.update is the primary operation and must always succeed. * - AdminNotification.create is secondary — wrapped in its own @@ -30,12 +44,24 @@ const { NOTIFICATION_REGISTRY } = require('../../data/notifications.data'); // ─── The actual sweep ──────────────────────────────────────────────────────── async function run() { + // ── 0. Load configured target status (defaults to 'completed') ─────────── + let settings = null; + let targetStatus = 'completed'; + try { + settings = await CronNotificationSetting.findOne({ where: { job_name: 'taskOverdue' } }); + if (settings?.target_status === 'overdue' || settings?.target_status === 'completed') { + targetStatus = settings.target_status; + } + } catch (err) { + console.error('[CRON][TASK OVERDUE] Failed to load target_status setting, defaulting to "completed":', err); + } + // ── 1. Primary: flip task statuses ─────────────────────────────────────── let affectedCount = 0; try { [affectedCount] = await Task.update( - { status: 'overdue' }, + { status: targetStatus, auto_marked_at: new Date() }, { where: { deadline: { [Op.lt]: new Date() }, @@ -50,16 +76,15 @@ async function run() { if (affectedCount === 0) return; - console.log(`[CRON][TASK OVERDUE] Marked ${affectedCount} task(s) as overdue.`); + console.log(`[CRON][TASK OVERDUE] Marked ${affectedCount} task(s) as ${targetStatus}.`); // ── 2. Secondary: admin notification — isolated, never blocks step 1 ───── // Skippable via /admin/notifications/settings — the status flip above always happens either way. try { - const settings = await CronNotificationSetting.findOne({ where: { job_name: 'taskOverdue' } }); if (settings && !settings.enabled) return; await AdminNotification.create( - NOTIFICATION_REGISTRY.task_overdue.build({ count: affectedCount }) + NOTIFICATION_REGISTRY.task_overdue.build({ count: affectedCount, targetStatus }) ); } catch (err) { console.error('[CRON][TASK OVERDUE] Failed to insert admin notification:', err); diff --git a/cron/jobs/user_notifications.cron.js b/cron/jobs/user_notifications.cron.js index 0de856f..ea2c2b7 100644 --- a/cron/jobs/user_notifications.cron.js +++ b/cron/jobs/user_notifications.cron.js @@ -2,17 +2,33 @@ * File Name : user_task_overdue_notify.cron.js * Type : Cron Job * Description : Emits a UserNotification for every user who belongs to a group - * assigned to a task list that contains a task that JUST flipped - * to 'overdue' in the last hour. + * assigned to a task list that contains a task the admin + * taskOverdue cron JUST auto-flipped in the last hour — to + * either 'overdue' or 'completed', depending on that job's + * configured target_status. * * Runs 5 minutes after the admin taskOverdue cron (which fires at * the top of each hour) so the status flips are already committed * before this job queries them. * - * "Just flipped" = status is 'overdue' AND updatedAt is within - * the last 65 minutes (1-hour window + 5-min drift buffer). - * This prevents re-notifying users for tasks that were already - * overdue before this run. + * "Just auto-flipped" = auto_marked_at is within the last 65 + * minutes (1-hour window + 5-min drift buffer). auto_marked_at + * is written ONLY by cron/jobs/task_overdue.cron.js, never by a + * user's own completion flow, so this can't misfire on a task a + * user legitimately just completed themselves. + * + * Rows are grouped by status: 'overdue' tasks get the existing + * "Tasks Overdue" notification, 'completed' tasks get a + * separate "Tasks Auto-Completed" notification. In practice a + * single run is homogeneous (target_status is one job-wide + * setting), but the grouping keeps this correct even if the + * setting changed mid-window. + * + * When a status group contains exactly one task, its taskId/ + * taskListId are included in the notification data (plus each + * recipient's own group_id) so the client can deep-link + * straight to that task. A multi-task batch can't pick just + * one task to link to, so it falls back to no link. * * Schedule : 5 minutes past every hour ("5 * * * *"). Registered by * cron/client.cron.js. @@ -34,57 +50,81 @@ async function run() { const settings = await CronNotificationSetting.findOne({ where: { job_name: 'userNotifications' } }); if (settings && !settings.enabled) return; - // ── 1. Find tasks that flipped to overdue in the last 65 minutes ────────── - let recentlyOverdue; + // ── 1. Find tasks the admin cron JUST auto-flipped in the last 65 minutes ─ + let recentlyAutoMarked; try { - recentlyOverdue = await Task.findAll({ - attributes: ['task_id', 'task_list_id', 'name'], + recentlyAutoMarked = await Task.findAll({ + attributes: ['task_id', 'task_list_id', 'name', 'status'], where: { - status: 'overdue', - updatedAt: { [Op.gte]: new Date(Date.now() - WINDOW_MS) }, + status: { [Op.in]: ['overdue', 'completed'] }, + auto_marked_at: { [Op.gte]: new Date(Date.now() - WINDOW_MS) }, }, raw: true, }); } catch (err) { - console.error('[CRON][USER NOTIFY] Failed to query recently overdue tasks:', err); + console.error('[CRON][USER NOTIFY] Failed to query recently auto-marked tasks:', err); return; } - if (recentlyOverdue.length === 0) return; + if (recentlyAutoMarked.length === 0) return; - console.log(`[CRON][USER NOTIFY] ${recentlyOverdue.length} recently overdue task(s) — resolving affected users.`); + console.log(`[CRON][USER NOTIFY] ${recentlyAutoMarked.length} recently auto-marked task(s) — resolving affected users.`); // ── 2. Resolve affected users via task_list_groups → user_group_members ─── try { - const taskListIds = [...new Set(recentlyOverdue.map(t => t.task_list_id))]; + const byStatus = { + overdue: recentlyAutoMarked.filter(t => t.status === 'overdue'), + completed: recentlyAutoMarked.filter(t => t.status === 'completed'), + }; - const affectedUsers = await sequelize.query( - `SELECT DISTINCT ugm.user_id - FROM task_list_groups tlg - JOIN user_group_members ugm ON ugm.group_id = tlg.group_id - AND ugm."deletedAt" IS NULL - WHERE tlg.task_list_id IN (:taskListIds)`, - { replacements: { taskListIds }, type: QueryTypes.SELECT } - ); + const now = new Date(); - if (affectedUsers.length === 0) return; + for (const [status, tasks] of Object.entries(byStatus)) { + if (tasks.length === 0) continue; - const count = recentlyOverdue.length; - const now = new Date(); - const notify = NOTIFICATION_REGISTRY.user_task_overdue.build({ count, task_list_ids: taskListIds }); + const taskListIds = [...new Set(tasks.map(t => t.task_list_id))]; - await UserNotification.bulkCreate( - affectedUsers.map(({ user_id }) => ({ - user_id, - ...notify, - seen: false, - createdAt: now, - updatedAt: now, - })), - { validate: false } - ); + // DISTINCT ON picks one group per user (deterministic — lowest group_id) + // so each affected user gets a single notification even if they belong + // to more than one group assigned to these task lists. + const affectedUsers = await sequelize.query( + `SELECT DISTINCT ON (ugm.user_id) ugm.user_id, ugm.group_id + FROM task_list_groups tlg + JOIN user_group_members ugm ON ugm.group_id = tlg.group_id + AND ugm."deletedAt" IS NULL + WHERE tlg.task_list_id IN (:taskListIds) + ORDER BY ugm.user_id, ugm.group_id`, + { replacements: { taskListIds }, type: QueryTypes.SELECT } + ); - console.log(`[CRON][USER NOTIFY] Notified ${affectedUsers.length} user(s) about ${count} overdue task(s).`); + if (affectedUsers.length === 0) continue; + + const count = tasks.length; + const registryKey = status === 'completed' ? 'user_task_auto_completed' : 'user_task_overdue'; + // A single-task batch can deep-link straight to that task; a multi-task + // batch can't pick just one, so it falls back to the task-list link. + const single = count === 1 ? tasks[0] : null; + const notify = NOTIFICATION_REGISTRY[registryKey].build({ + count, + task_list_ids: taskListIds, + taskId: single?.task_id ?? null, + taskListId: single?.task_list_id ?? null, + }); + + await UserNotification.bulkCreate( + affectedUsers.map(({ user_id, group_id }) => ({ + user_id, + ...notify, + data: { ...notify.data, groupId: single ? group_id : null }, + seen: false, + createdAt: now, + updatedAt: now, + })), + { validate: false } + ); + + console.log(`[CRON][USER NOTIFY] Notified ${affectedUsers.length} user(s) about ${count} ${status} task(s).`); + } } catch (err) { console.error('[CRON][USER NOTIFY] Failed to emit user notifications:', err); } diff --git a/data/notifications.data.js b/data/notifications.data.js index f49ad79..9bf4a43 100644 --- a/data/notifications.data.js +++ b/data/notifications.data.js @@ -20,7 +20,7 @@ * * Current types: * Admin : task_overdue, user_registration, nogrp_user_registered - * User : task_requirements_updated, user_task_overdue, task_reminder, achievement, + * User : task_requirements_updated, task_submissions_closed, task_submissions_reopened, user_task_overdue, user_task_auto_completed, task_reminder, achievement, * course_unlocked, course_completed, certificate_issued, welcome, * nogrp_welcome, assessment_updated, announcement, tier_expired, * task_submission_reviewed, task_assigned, task_completed @@ -45,12 +45,13 @@ const NOTIFICATION_REGISTRY = { type: 'task_overdue', scope: 'admin', trigger: 'cron', - build({ count, task_list_ids = [] }) { + build({ count, task_list_ids = [], targetStatus = 'overdue' }) { + const label = targetStatus === 'completed' ? 'completed' : 'overdue'; return { type: 'task_overdue', - title: 'Tasks Overdue', - message: `${count} task${count === 1 ? ' was' : 's were'} automatically marked as overdue.`, - data: { count, task_list_ids }, + title: targetStatus === 'completed' ? 'Tasks Auto-Completed' : 'Tasks Overdue', + message: `${count} task${count === 1 ? ' was' : 's were'} automatically marked as ${label}.`, + data: { count, task_list_ids, target_status: targetStatus }, }; }, }, @@ -94,12 +95,40 @@ const NOTIFICATION_REGISTRY = { type: 'task', scope: 'user', trigger: 'event', - build({ taskName, taskListId = null, groupId = null }) { + build({ taskName, taskListId = null, groupId = null, taskId = null }) { return { type: 'task', title: 'Task Updated', message: `The requirements for "${taskName}" have been updated by your administrator.`, - data: { taskName, taskListId, groupId }, + data: { taskName, taskListId, groupId, taskId }, + }; + }, + }, + + task_submissions_closed: { + type: 'task', + scope: 'user', + trigger: 'event', + build({ taskName, taskListId = null, groupId = null, taskId = null }) { + return { + type: 'task', + title: 'Submissions Closed', + message: `"${taskName}" is no longer accepting submissions.`, + data: { taskName, taskListId, groupId, taskId }, + }; + }, + }, + + task_submissions_reopened: { + type: 'task', + scope: 'user', + trigger: 'event', + build({ taskName, taskListId = null, groupId = null, taskId = null }) { + return { + type: 'task', + title: 'Submissions Reopened', + message: `"${taskName}" is accepting submissions again.`, + data: { taskName, taskListId, groupId, taskId }, }; }, }, @@ -108,13 +137,28 @@ const NOTIFICATION_REGISTRY = { type: 'task', scope: 'user', trigger: 'cron', - build({ count, task_list_ids = [] }) { + build({ count, task_list_ids = [], taskListId = null, taskId = null }) { const label = count === 1 ? '1 task has' : `${count} tasks have`; return { type: 'task', title: 'Tasks Overdue', message: `${label} passed their deadline and been marked as overdue.`, - data: { count, task_list_ids }, + data: { count, task_list_ids, taskListId, taskId }, + }; + }, + }, + + user_task_auto_completed: { + type: 'task', + scope: 'user', + trigger: 'cron', + build({ count, task_list_ids = [], taskListId = null, taskId = null }) { + const label = count === 1 ? '1 task has' : `${count} tasks have`; + return { + type: 'task', + title: 'Tasks Auto-Completed', + message: `${label} passed their deadline and been automatically marked as completed.`, + data: { count, task_list_ids, taskListId, taskId }, }; }, }, diff --git a/database/migrations/20260717000005-add-auto-marked-at-to-tasks.js b/database/migrations/20260717000005-add-auto-marked-at-to-tasks.js new file mode 100644 index 0000000..e006e4c --- /dev/null +++ b/database/migrations/20260717000005-add-auto-marked-at-to-tasks.js @@ -0,0 +1,15 @@ +'use strict'; + +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.addColumn('tasks', 'auto_marked_at', { + type: Sequelize.DATE, + allowNull: true, + defaultValue: null, + }); + }, + + async down(queryInterface) { + await queryInterface.removeColumn('tasks', 'auto_marked_at'); + }, +}; diff --git a/database/migrations/20260717000006-add-target-status-to-cron-notification-settings.js b/database/migrations/20260717000006-add-target-status-to-cron-notification-settings.js new file mode 100644 index 0000000..c8fbcc3 --- /dev/null +++ b/database/migrations/20260717000006-add-target-status-to-cron-notification-settings.js @@ -0,0 +1,15 @@ +'use strict'; + +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.addColumn('cron_notification_settings', 'target_status', { + type: Sequelize.STRING(20), + allowNull: true, + defaultValue: null, + }); + }, + + async down(queryInterface) { + await queryInterface.removeColumn('cron_notification_settings', 'target_status'); + }, +}; diff --git a/database/migrations/20260717000007-add-accepts-submissions-to-tasks.js b/database/migrations/20260717000007-add-accepts-submissions-to-tasks.js new file mode 100644 index 0000000..989b7d7 --- /dev/null +++ b/database/migrations/20260717000007-add-accepts-submissions-to-tasks.js @@ -0,0 +1,16 @@ +'use strict'; + +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.addColumn('tasks', 'accepts_submissions', { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: true, + after: 'is_required', + }); + }, + + async down(queryInterface) { + await queryInterface.removeColumn('tasks', 'accepts_submissions'); + }, +}; diff --git a/models/notifications/cron_notification_setting.mdl.js b/models/notifications/cron_notification_setting.mdl.js index 31e3ddc..978790a 100644 --- a/models/notifications/cron_notification_setting.mdl.js +++ b/models/notifications/cron_notification_setting.mdl.js @@ -6,6 +6,7 @@ const CronNotificationSetting = sequelize.define("CronNotificationSetting", { job_name: { type: DataTypes.STRING(64), primaryKey: true, label: "Job" }, enabled: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: true, label: "Enabled" }, schedule: { type: DataTypes.STRING(20), allowNull: false, label: "Schedule" }, + target_status: { type: DataTypes.STRING(20), allowNull: true, label: "Target Status" }, updatedBy: { type: DataTypes.BIGINT, allowNull: true, label: "Modified By" }, }, { tableName: "cron_notification_settings", diff --git a/models/task/task.mdl.js b/models/task/task.mdl.js index bcdd756..77ea7e0 100644 --- a/models/task/task.mdl.js +++ b/models/task/task.mdl.js @@ -43,7 +43,9 @@ const Task = sequelize.define('Task', { deadline: { type: DataTypes.DATE, allowNull: true, order: 2, filterable: true }, order_index: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0, order: 2.1, filterable: true, comment: 'Position within the task list — drives sequencing lock.' }, is_required: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: true, order: 2.2, filterable: true, comment: 'Optional tasks do not block later tasks in the sequencing lock.' }, + accepts_submissions: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: true, order: 2.25, filterable: true, comment: 'When false, no new TaskCompletion submissions are accepted for this task (existing completions are unaffected).' }, status: { type: DataTypes.ENUM('pending', 'in_progress', 'completed', 'overdue'), defaultValue: 'pending', allowNull: false, filterable: true }, + auto_marked_at: { type: DataTypes.DATE, allowNull: true, filterable: true, comment: 'Set only by the taskOverdue cron sweep when it auto-flips status; never touched by user-driven completion.' }, // ── Audit trails ──────────────────────────────────────────────────────── createdBy: { type: DataTypes.INTEGER, allowNull: true, filterable: true },