/*********************************************************************************************************************************************************************** * 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 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 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. * * Author: Kenneth Obsequio (@lash0000) * Date Created: Jun. 19, 2026 ***********************************************************************************************************************************************************************/ const { Op, QueryTypes } = require('sequelize'); const sequelize = require('../../config/db.config'); const { Task } = require('../../models/task/task.mdl'); const UserNotification = require('../../models/notifications/user_notification.mdl'); const CronNotificationSetting = require('../../models/notifications/cron_notification_setting.mdl'); const { NOTIFICATION_REGISTRY } = require('../../data/notifications.data'); const WINDOW_MS = 65 * 60 * 1000; // 65-minute lookback async function run() { // Entire job exists to emit this notification — skippable via /admin/notifications/settings. const settings = await CronNotificationSetting.findOne({ where: { job_name: 'userNotifications' } }); if (settings && !settings.enabled) return; // ── 1. Find tasks the admin cron JUST auto-flipped in the last 65 minutes ─ let recentlyAutoMarked; try { recentlyAutoMarked = await Task.findAll({ attributes: ['task_id', 'task_list_id', 'name', 'status'], where: { 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 auto-marked tasks:', err); return; } if (recentlyAutoMarked.length === 0) return; 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 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( `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 } ); 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); } } module.exports = { name: 'userNotifications', schedule: '5 * * * *', run, };