mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user