mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
@@ -8,12 +8,16 @@ const { rescheduleJob, getOrCreateSetting } = require('../../cron/cronRegistry.u
|
|||||||
|
|
||||||
// ─── Job registry — which cron scope owns each job (for defaults + labels) ────
|
// ─── Job registry — which cron scope owns each job (for defaults + labels) ────
|
||||||
const JOBS = {
|
const JOBS = {
|
||||||
taskOverdue: { schedule: '0 * * * *', label: 'Task Overdue Alerts (Admin)', description: 'Notifies admins when tasks flip to 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 Overdue Alerts (Users)', description: 'Notifies affected users when their tasks are marked overdue.' },
|
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.' },
|
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.' },
|
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 ──────────────────────────────────────────────────────────────────────
|
// ─── GET ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
exports.getSettings = async (req, res) => {
|
exports.getSettings = async (req, res) => {
|
||||||
@@ -26,6 +30,9 @@ exports.getSettings = async (req, res) => {
|
|||||||
enabled: row.enabled,
|
enabled: row.enabled,
|
||||||
schedule: row.schedule,
|
schedule: row.schedule,
|
||||||
preset: CRON_PRESET_BY_EXPRESSION[row.schedule] ?? null,
|
preset: CRON_PRESET_BY_EXPRESSION[row.schedule] ?? null,
|
||||||
|
target_status: TARGET_STATUS_JOBS.includes(job_name)
|
||||||
|
? (row.target_status ?? 'completed')
|
||||||
|
: null,
|
||||||
label: meta.label,
|
label: meta.label,
|
||||||
description: meta.description,
|
description: meta.description,
|
||||||
updatedAt: row.updatedAt,
|
updatedAt: row.updatedAt,
|
||||||
@@ -44,7 +51,7 @@ exports.getSettings = async (req, res) => {
|
|||||||
exports.updateSetting = async (req, res) => {
|
exports.updateSetting = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { jobName } = req.params;
|
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);
|
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 (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) {
|
if (preset !== undefined) {
|
||||||
const schedule = CRON_PRESETS[preset];
|
const schedule = CRON_PRESETS[preset];
|
||||||
if (!schedule) return R.error(res, `Invalid preset. Must be one of: ${Object.keys(CRON_PRESETS).join(', ')}`, 400);
|
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;
|
row.updatedBy = updatedBy ?? null;
|
||||||
await row.save();
|
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 });
|
return R.success(res, 'Notification setting updated.', { data: row });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[NOTIFICATION SETTINGS][UPDATE]', err);
|
console.error('[NOTIFICATION SETTINGS][UPDATE]', err);
|
||||||
|
|||||||
@@ -36,6 +36,30 @@ const normalizeUrl = (url) => {
|
|||||||
return `https://${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 ───────────────────────────────────────────────
|
// ─── Allowed filter/sort fields ───────────────────────────────────────────────
|
||||||
const TASK_LIST_FIELDS = ['name', 'description', 'createdAt', 'updatedAt', 'deletedAt'];
|
const TASK_LIST_FIELDS = ['name', 'description', 'createdAt', 'updatedAt', 'deletedAt'];
|
||||||
const TASK_FIELDS = ['name', 'description', 'deadline', 'order_index', 'is_required', 'status', '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);
|
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(
|
const wasAccepting = task.accepts_submissions;
|
||||||
{ name, description, deadline: deadline || null, status, is_required, updatedBy: req.user.user_id },
|
|
||||||
{ transaction: t }
|
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)
|
// PATCH: exports.updateTask in task.controller.js (admin)
|
||||||
@@ -951,25 +982,7 @@ exports.updateTask = async (req, res) => {
|
|||||||
// ── Notify assigned users when requirements changed ────────────────────
|
// ── Notify assigned users when requirements changed ────────────────────
|
||||||
if (Array.isArray(requirements)) {
|
if (Array.isArray(requirements)) {
|
||||||
try {
|
try {
|
||||||
const groupRows = await TaskListGroup.findAll({
|
const members = await getTaskListMembers(task.task_list_id);
|
||||||
where: { task_list_id: task.task_list_id },
|
|
||||||
attributes: ['group_id'],
|
|
||||||
});
|
|
||||||
const groupIds = groupRows.map((r) => r.group_id);
|
|
||||||
|
|
||||||
if (groupIds.length) {
|
|
||||||
const memberRows = await mdl_UserGroupMembers.findAll({
|
|
||||||
where: { group_id: groupIds },
|
|
||||||
attributes: ['user_id', 'group_id'],
|
|
||||||
});
|
|
||||||
|
|
||||||
// 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) {
|
if (members.length) {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -978,6 +991,7 @@ exports.updateTask = async (req, res) => {
|
|||||||
const notify = NOTIFICATION_REGISTRY.task_requirements_updated.build({
|
const notify = NOTIFICATION_REGISTRY.task_requirements_updated.build({
|
||||||
taskName: full.name,
|
taskName: full.name,
|
||||||
taskListId: task.task_list_id,
|
taskListId: task.task_list_id,
|
||||||
|
taskId: task.task_id,
|
||||||
groupId: null,
|
groupId: null,
|
||||||
});
|
});
|
||||||
await UserNotification.bulkCreate(
|
await UserNotification.bulkCreate(
|
||||||
@@ -992,12 +1006,42 @@ exports.updateTask = async (req, res) => {
|
|||||||
{ validate: false }
|
{ validate: false }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
} catch (notifyErr) {
|
} catch (notifyErr) {
|
||||||
console.error('[ADMIN][UPDATE TASK][NOTIFY]', 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);
|
return R.success(res, 'Task updated successfully.', full);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
await t.rollback();
|
await t.rollback();
|
||||||
|
|||||||
@@ -723,6 +723,11 @@ exports.submitTask = async (req, res) => {
|
|||||||
const task = await Task.findOne({ where: { task_id: taskId, task_list_id: taskListId }, transaction: t });
|
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) { 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))) {
|
if (!(await assertTaskUnlocked(req.user.user_id, taskListId, task.order_index, taskId))) {
|
||||||
await t.rollback();
|
await t.rollback();
|
||||||
return R.error(res, 'Complete the earlier required tasks in this list first.', 409);
|
return R.error(res, 'Complete the earlier required tasks in this list first.', 409);
|
||||||
|
|||||||
@@ -1,13 +1,27 @@
|
|||||||
/***********************************************************************************************************************************************************************
|
/***********************************************************************************************************************************************************************
|
||||||
* File Name : task_overdue.cron.js
|
* File Name : task_overdue.cron.js
|
||||||
* Type : Cron Job
|
* Type : Cron Job
|
||||||
* Description : Flips Task.status to 'overdue' once its deadline has passed,
|
* Description : Flips Task.status to a configurable target status — 'overdue'
|
||||||
* provided it isn't already 'completed' or 'overdue'. Purely
|
* (default before Jul 2026) or 'completed' (current default) —
|
||||||
* an admin-facing lifecycle label — does NOT touch
|
* 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
|
* TaskCompletion/TaskLinkVisit/TaskProgress, does NOT affect
|
||||||
* per-user completion signals or client-side Ongoing/Done/
|
* per-user completion signals or client-side Ongoing/Done/
|
||||||
* Overdue bucketing, and does NOT block late submissions.
|
* 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:
|
* Safety pattern:
|
||||||
* - Task.update is the primary operation and must always succeed.
|
* - Task.update is the primary operation and must always succeed.
|
||||||
* - AdminNotification.create is secondary — wrapped in its own
|
* - AdminNotification.create is secondary — wrapped in its own
|
||||||
@@ -30,12 +44,24 @@ const { NOTIFICATION_REGISTRY } = require('../../data/notifications.data');
|
|||||||
|
|
||||||
// ─── The actual sweep ────────────────────────────────────────────────────────
|
// ─── The actual sweep ────────────────────────────────────────────────────────
|
||||||
async function run() {
|
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 ───────────────────────────────────────
|
// ── 1. Primary: flip task statuses ───────────────────────────────────────
|
||||||
let affectedCount = 0;
|
let affectedCount = 0;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
[affectedCount] = await Task.update(
|
[affectedCount] = await Task.update(
|
||||||
{ status: 'overdue' },
|
{ status: targetStatus, auto_marked_at: new Date() },
|
||||||
{
|
{
|
||||||
where: {
|
where: {
|
||||||
deadline: { [Op.lt]: new Date() },
|
deadline: { [Op.lt]: new Date() },
|
||||||
@@ -50,16 +76,15 @@ async function run() {
|
|||||||
|
|
||||||
if (affectedCount === 0) return;
|
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 ─────
|
// ── 2. Secondary: admin notification — isolated, never blocks step 1 ─────
|
||||||
// Skippable via /admin/notifications/settings — the status flip above always happens either way.
|
// Skippable via /admin/notifications/settings — the status flip above always happens either way.
|
||||||
try {
|
try {
|
||||||
const settings = await CronNotificationSetting.findOne({ where: { job_name: 'taskOverdue' } });
|
|
||||||
if (settings && !settings.enabled) return;
|
if (settings && !settings.enabled) return;
|
||||||
|
|
||||||
await AdminNotification.create(
|
await AdminNotification.create(
|
||||||
NOTIFICATION_REGISTRY.task_overdue.build({ count: affectedCount })
|
NOTIFICATION_REGISTRY.task_overdue.build({ count: affectedCount, targetStatus })
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[CRON][TASK OVERDUE] Failed to insert admin notification:', err);
|
console.error('[CRON][TASK OVERDUE] Failed to insert admin notification:', err);
|
||||||
|
|||||||
@@ -2,17 +2,33 @@
|
|||||||
* File Name : user_task_overdue_notify.cron.js
|
* File Name : user_task_overdue_notify.cron.js
|
||||||
* Type : Cron Job
|
* Type : Cron Job
|
||||||
* Description : Emits a UserNotification for every user who belongs to a group
|
* Description : Emits a UserNotification for every user who belongs to a group
|
||||||
* assigned to a task list that contains a task that JUST flipped
|
* assigned to a task list that contains a task the admin
|
||||||
* to 'overdue' in the last hour.
|
* 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
|
* Runs 5 minutes after the admin taskOverdue cron (which fires at
|
||||||
* the top of each hour) so the status flips are already committed
|
* the top of each hour) so the status flips are already committed
|
||||||
* before this job queries them.
|
* before this job queries them.
|
||||||
*
|
*
|
||||||
* "Just flipped" = status is 'overdue' AND updatedAt is within
|
* "Just auto-flipped" = auto_marked_at is within the last 65
|
||||||
* the last 65 minutes (1-hour window + 5-min drift buffer).
|
* minutes (1-hour window + 5-min drift buffer). auto_marked_at
|
||||||
* This prevents re-notifying users for tasks that were already
|
* is written ONLY by cron/jobs/task_overdue.cron.js, never by a
|
||||||
* overdue before this run.
|
* 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
|
* Schedule : 5 minutes past every hour ("5 * * * *"). Registered by
|
||||||
* cron/client.cron.js.
|
* cron/client.cron.js.
|
||||||
@@ -34,49 +50,72 @@ async function run() {
|
|||||||
const settings = await CronNotificationSetting.findOne({ where: { job_name: 'userNotifications' } });
|
const settings = await CronNotificationSetting.findOne({ where: { job_name: 'userNotifications' } });
|
||||||
if (settings && !settings.enabled) return;
|
if (settings && !settings.enabled) return;
|
||||||
|
|
||||||
// ── 1. Find tasks that flipped to overdue in the last 65 minutes ──────────
|
// ── 1. Find tasks the admin cron JUST auto-flipped in the last 65 minutes ─
|
||||||
let recentlyOverdue;
|
let recentlyAutoMarked;
|
||||||
try {
|
try {
|
||||||
recentlyOverdue = await Task.findAll({
|
recentlyAutoMarked = await Task.findAll({
|
||||||
attributes: ['task_id', 'task_list_id', 'name'],
|
attributes: ['task_id', 'task_list_id', 'name', 'status'],
|
||||||
where: {
|
where: {
|
||||||
status: 'overdue',
|
status: { [Op.in]: ['overdue', 'completed'] },
|
||||||
updatedAt: { [Op.gte]: new Date(Date.now() - WINDOW_MS) },
|
auto_marked_at: { [Op.gte]: new Date(Date.now() - WINDOW_MS) },
|
||||||
},
|
},
|
||||||
raw: true,
|
raw: true,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} 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;
|
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 ───
|
// ── 2. Resolve affected users via task_list_groups → user_group_members ───
|
||||||
try {
|
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 now = new Date();
|
||||||
|
|
||||||
|
for (const [status, tasks] of Object.entries(byStatus)) {
|
||||||
|
if (tasks.length === 0) continue;
|
||||||
|
|
||||||
|
const taskListIds = [...new Set(tasks.map(t => t.task_list_id))];
|
||||||
|
|
||||||
|
// 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(
|
const affectedUsers = await sequelize.query(
|
||||||
`SELECT DISTINCT ugm.user_id
|
`SELECT DISTINCT ON (ugm.user_id) ugm.user_id, ugm.group_id
|
||||||
FROM task_list_groups tlg
|
FROM task_list_groups tlg
|
||||||
JOIN user_group_members ugm ON ugm.group_id = tlg.group_id
|
JOIN user_group_members ugm ON ugm.group_id = tlg.group_id
|
||||||
AND ugm."deletedAt" IS NULL
|
AND ugm."deletedAt" IS NULL
|
||||||
WHERE tlg.task_list_id IN (:taskListIds)`,
|
WHERE tlg.task_list_id IN (:taskListIds)
|
||||||
|
ORDER BY ugm.user_id, ugm.group_id`,
|
||||||
{ replacements: { taskListIds }, type: QueryTypes.SELECT }
|
{ replacements: { taskListIds }, type: QueryTypes.SELECT }
|
||||||
);
|
);
|
||||||
|
|
||||||
if (affectedUsers.length === 0) return;
|
if (affectedUsers.length === 0) continue;
|
||||||
|
|
||||||
const count = recentlyOverdue.length;
|
const count = tasks.length;
|
||||||
const now = new Date();
|
const registryKey = status === 'completed' ? 'user_task_auto_completed' : 'user_task_overdue';
|
||||||
const notify = NOTIFICATION_REGISTRY.user_task_overdue.build({ count, task_list_ids: taskListIds });
|
// 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(
|
await UserNotification.bulkCreate(
|
||||||
affectedUsers.map(({ user_id }) => ({
|
affectedUsers.map(({ user_id, group_id }) => ({
|
||||||
user_id,
|
user_id,
|
||||||
...notify,
|
...notify,
|
||||||
|
data: { ...notify.data, groupId: single ? group_id : null },
|
||||||
seen: false,
|
seen: false,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
@@ -84,7 +123,8 @@ async function run() {
|
|||||||
{ validate: false }
|
{ validate: false }
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log(`[CRON][USER NOTIFY] Notified ${affectedUsers.length} user(s) about ${count} overdue task(s).`);
|
console.log(`[CRON][USER NOTIFY] Notified ${affectedUsers.length} user(s) about ${count} ${status} task(s).`);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[CRON][USER NOTIFY] Failed to emit user notifications:', err);
|
console.error('[CRON][USER NOTIFY] Failed to emit user notifications:', err);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
*
|
*
|
||||||
* Current types:
|
* Current types:
|
||||||
* Admin : task_overdue, user_registration, nogrp_user_registered
|
* 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,
|
* course_unlocked, course_completed, certificate_issued, welcome,
|
||||||
* nogrp_welcome, assessment_updated, announcement, tier_expired,
|
* nogrp_welcome, assessment_updated, announcement, tier_expired,
|
||||||
* task_submission_reviewed, task_assigned, task_completed
|
* task_submission_reviewed, task_assigned, task_completed
|
||||||
@@ -45,12 +45,13 @@ const NOTIFICATION_REGISTRY = {
|
|||||||
type: 'task_overdue',
|
type: 'task_overdue',
|
||||||
scope: 'admin',
|
scope: 'admin',
|
||||||
trigger: 'cron',
|
trigger: 'cron',
|
||||||
build({ count, task_list_ids = [] }) {
|
build({ count, task_list_ids = [], targetStatus = 'overdue' }) {
|
||||||
|
const label = targetStatus === 'completed' ? 'completed' : 'overdue';
|
||||||
return {
|
return {
|
||||||
type: 'task_overdue',
|
type: 'task_overdue',
|
||||||
title: 'Tasks Overdue',
|
title: targetStatus === 'completed' ? 'Tasks Auto-Completed' : 'Tasks Overdue',
|
||||||
message: `${count} task${count === 1 ? ' was' : 's were'} automatically marked as overdue.`,
|
message: `${count} task${count === 1 ? ' was' : 's were'} automatically marked as ${label}.`,
|
||||||
data: { count, task_list_ids },
|
data: { count, task_list_ids, target_status: targetStatus },
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -94,12 +95,40 @@ const NOTIFICATION_REGISTRY = {
|
|||||||
type: 'task',
|
type: 'task',
|
||||||
scope: 'user',
|
scope: 'user',
|
||||||
trigger: 'event',
|
trigger: 'event',
|
||||||
build({ taskName, taskListId = null, groupId = null }) {
|
build({ taskName, taskListId = null, groupId = null, taskId = null }) {
|
||||||
return {
|
return {
|
||||||
type: 'task',
|
type: 'task',
|
||||||
title: 'Task Updated',
|
title: 'Task Updated',
|
||||||
message: `The requirements for "${taskName}" have been updated by your administrator.`,
|
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',
|
type: 'task',
|
||||||
scope: 'user',
|
scope: 'user',
|
||||||
trigger: 'cron',
|
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`;
|
const label = count === 1 ? '1 task has' : `${count} tasks have`;
|
||||||
return {
|
return {
|
||||||
type: 'task',
|
type: 'task',
|
||||||
title: 'Tasks Overdue',
|
title: 'Tasks Overdue',
|
||||||
message: `${label} passed their deadline and been marked as 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 },
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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');
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -6,6 +6,7 @@ const CronNotificationSetting = sequelize.define("CronNotificationSetting", {
|
|||||||
job_name: { type: DataTypes.STRING(64), primaryKey: true, label: "Job" },
|
job_name: { type: DataTypes.STRING(64), primaryKey: true, label: "Job" },
|
||||||
enabled: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: true, label: "Enabled" },
|
enabled: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: true, label: "Enabled" },
|
||||||
schedule: { type: DataTypes.STRING(20), allowNull: false, label: "Schedule" },
|
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" },
|
updatedBy: { type: DataTypes.BIGINT, allowNull: true, label: "Modified By" },
|
||||||
}, {
|
}, {
|
||||||
tableName: "cron_notification_settings",
|
tableName: "cron_notification_settings",
|
||||||
|
|||||||
@@ -43,7 +43,9 @@ const Task = sequelize.define('Task', {
|
|||||||
deadline: { type: DataTypes.DATE, allowNull: true, order: 2, filterable: true },
|
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.' },
|
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.' },
|
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 },
|
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 ────────────────────────────────────────────────────────
|
// ── Audit trails ────────────────────────────────────────────────────────
|
||||||
createdBy: { type: DataTypes.INTEGER, allowNull: true, filterable: true },
|
createdBy: { type: DataTypes.INTEGER, allowNull: true, filterable: true },
|
||||||
|
|||||||
Reference in New Issue
Block a user