/*********************************************************************************************************************************************************************** * 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. * * 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. * * 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 that flipped to overdue in the last 65 minutes ────────── let recentlyOverdue; try { recentlyOverdue = await Task.findAll({ attributes: ['task_id', 'task_list_id', 'name'], where: { status: 'overdue', updatedAt: { [Op.gte]: new Date(Date.now() - WINDOW_MS) }, }, raw: true, }); } catch (err) { console.error('[CRON][USER NOTIFY] Failed to query recently overdue tasks:', err); return; } if (recentlyOverdue.length === 0) return; console.log(`[CRON][USER NOTIFY] ${recentlyOverdue.length} recently overdue 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 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 } ); if (affectedUsers.length === 0) return; const count = recentlyOverdue.length; const now = new Date(); const notify = NOTIFICATION_REGISTRY.user_task_overdue.build({ count, task_list_ids: taskListIds }); await UserNotification.bulkCreate( affectedUsers.map(({ user_id }) => ({ user_id, ...notify, seen: false, createdAt: now, updatedAt: now, })), { validate: false } ); console.log(`[CRON][USER NOTIFY] Notified ${affectedUsers.length} user(s) about ${count} overdue task(s).`); } catch (err) { console.error('[CRON][USER NOTIFY] Failed to emit user notifications:', err); } } module.exports = { name: 'userNotifications', schedule: '5 * * * *', run, };