diff --git a/client/components/admin/EmailNotifCard.tsx b/client/components/admin/EmailNotifCard.tsx index cf61a7f..522dda2 100644 --- a/client/components/admin/EmailNotifCard.tsx +++ b/client/components/admin/EmailNotifCard.tsx @@ -23,6 +23,7 @@ interface EmailConfig { smtp_password: string; allow_user_config: boolean; global_recipient: string; + reminder_hour: string; } const DEFAULTS: EmailConfig = { @@ -33,8 +34,15 @@ const DEFAULTS: EmailConfig = { smtp_username: '', smtp_password: '', allow_user_config: false, global_recipient: '', + reminder_hour: '6', }; +// "6:00 AM" … "11:00 PM" for the hour select. +const HOUR_OPTIONS = Array.from({ length: 24 }, (_, h) => ({ + value: String(h), + label: `${(h % 12) || 12}:00 ${h < 12 ? 'AM' : 'PM'}`, +})); + export default function EmailNotifCard() { const [cfg, setCfg] = useState(DEFAULTS); const [loading, setLoading] = useState(true); @@ -95,6 +103,22 @@ export default function EmailNotifCard() { set('enabled', v)} label="Enable email notifications" /> + +
+ + Daily reminder emails — applies account-wide. +
+
+
diff --git a/routes/notifications.js b/routes/notifications.js index 4d8c2fe..2f6464d 100644 --- a/routes/notifications.js +++ b/routes/notifications.js @@ -15,10 +15,11 @@ router.get('/admin', requireAuth, requireAdmin, (req, res) => { 'notify_smtp_enabled', 'notify_sender_name', 'notify_sender_address', 'notify_smtp_host', 'notify_smtp_port', 'notify_smtp_encryption', 'notify_smtp_self_signed', 'notify_smtp_username', 'notify_smtp_password', - 'notify_allow_user_config', 'notify_global_recipient', + 'notify_allow_user_config', 'notify_global_recipient', 'reminder_hour', ]; const settings = {}; for (const k of keys) settings[k] = getSetting(k) || ''; + if (!settings.reminder_hour) settings.reminder_hour = '6'; // Mask password in response if (settings.notify_smtp_password) settings.notify_smtp_password = '••••••••'; res.json(settings); @@ -39,6 +40,17 @@ router.put('/admin', requireAuth, requireAdmin, (req, res) => { if (req.body.notify_smtp_password && !req.body.notify_smtp_password.startsWith('•')) { setSetting('notify_smtp_password', encryptSecret(req.body.notify_smtp_password)); } + // Reminder send-hour (0–23, global): persist clamped + live-reschedule the cron. + if (req.body.reminder_hour !== undefined) { + const h = parseInt(req.body.reminder_hour, 10); + const hour = Number.isInteger(h) && h >= 0 && h <= 23 ? h : 6; + setSetting('reminder_hour', String(hour)); + try { + require('../workers/dailyWorker').rescheduleDailyWorker(); + } catch (err) { + console.error('[notifications] Failed to reschedule daily worker:', err.message); + } + } res.json({ success: true }); }); diff --git a/workers/dailyWorker.js b/workers/dailyWorker.js index b205f5f..23435f3 100644 --- a/workers/dailyWorker.js +++ b/workers/dailyWorker.js @@ -1,7 +1,7 @@ 'use strict'; const cron = require('node-cron'); -const { getDb } = require('../db/database'); +const { getDb, getSetting } = require('../db/database'); const { buildTrackerRow, getCycleRange } = require('../services/statusService'); const { pruneExpiredSessions } = require('../services/authService'); const { pruneExpiredChallenges: pruneWebAuthnChallenges } = require('../services/webauthnService'); @@ -14,11 +14,17 @@ const { } = require('../services/statusRuntime'); const { localDateString, localDateStringDaysAgo } = require('../utils/dates.mts'); -const DAILY_CRON_HOUR = 6; +// The hour (0–23) the single daily job runs — configurable via the global +// 'reminder_hour' setting, defaulting to 6 AM. One source of truth for both the +// cron expression and the next-run display. +function resolveReminderHour() { + const parsed = parseInt(getSetting('reminder_hour') ?? '6', 10); + return Number.isInteger(parsed) && parsed >= 0 && parsed <= 23 ? parsed : 6; +} function nextDailyRunIso(from = new Date()) { const next = new Date(from); - next.setHours(DAILY_CRON_HOUR, 0, 0, 0); + next.setHours(resolveReminderHour(), 0, 0, 0); if (next <= from) next.setDate(next.getDate() + 1); return next.toISOString(); } @@ -113,6 +119,35 @@ async function runDailyTasks() { console.log(`[worker] Daily tasks ran at ${todayStr}`); } +let scheduledTask = null; + +// (Re)create the daily cron at the configured hour. Stops any prior task first. +function scheduleDaily() { + if (scheduledTask) { + scheduledTask.stop(); + scheduledTask = null; + } + const hour = resolveReminderHour(); + scheduledTask = cron.schedule(`0 ${hour} * * *`, () => { + runDailyTasks().catch(err => { + markWorkerError(err, nextDailyRunIso()); + console.error('[worker] Daily task error:', err); + }); + }); +} + +// Called when the 'reminder_hour' setting changes so it applies without a +// restart. Defensive: a failure here must never take the worker down. +function rescheduleDailyWorker() { + try { + scheduleDaily(); + markWorkerStarted(nextDailyRunIso()); + console.log(`[worker] Rescheduled daily tasks to ${resolveReminderHour()}:00`); + } catch (err) { + console.error('[worker] Failed to reschedule daily worker:', err.message); + } +} + function start() { markWorkerStarted(nextDailyRunIso()); @@ -122,13 +157,8 @@ function start() { console.error('[worker] Startup task error:', err); }); - // Run every day at 6:00 AM - cron.schedule('0 6 * * *', () => { - runDailyTasks().catch(err => { - markWorkerError(err, nextDailyRunIso()); - console.error('[worker] Daily task error:', err); - }); - }); + // Run every day at the configured hour (default 6 AM) + scheduleDaily(); } -module.exports = { start, runDailyTasks, nextDailyRunIso }; +module.exports = { start, runDailyTasks, nextDailyRunIso, rescheduleDailyWorker };