feat(settings): configurable reminder send-time (plan Tier 5)
The daily reminder job ran at a hardcoded 6 AM. Make the hour configurable: - dailyWorker: resolveReminderHour() reads the global 'reminder_hour' setting (clamp 0–23, default 6) as the single source for both the cron expression and the next-run display; the task is stored so rescheduleDailyWorker() can restart it live (defensively wrapped so a bad value can't take the worker down). - notifications admin GET/PUT expose reminder_hour; PUT clamps, persists, and live-reschedules the worker. - Admin Email Notifications card: a "Reminder send time" select (account-wide). It's a global setting by design — there's one shared daily job, so a per-user hour would require an hourly-worker refactor (out of scope) and would otherwise be a dead setting. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
0e6f04c7bc
commit
1f57b22ff8
|
|
@ -23,6 +23,7 @@ interface EmailConfig {
|
||||||
smtp_password: string;
|
smtp_password: string;
|
||||||
allow_user_config: boolean;
|
allow_user_config: boolean;
|
||||||
global_recipient: string;
|
global_recipient: string;
|
||||||
|
reminder_hour: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULTS: EmailConfig = {
|
const DEFAULTS: EmailConfig = {
|
||||||
|
|
@ -33,8 +34,15 @@ const DEFAULTS: EmailConfig = {
|
||||||
smtp_username: '', smtp_password: '',
|
smtp_username: '', smtp_password: '',
|
||||||
allow_user_config: false,
|
allow_user_config: false,
|
||||||
global_recipient: '',
|
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() {
|
export default function EmailNotifCard() {
|
||||||
const [cfg, setCfg] = useState<EmailConfig>(DEFAULTS);
|
const [cfg, setCfg] = useState<EmailConfig>(DEFAULTS);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
@ -95,6 +103,22 @@ export default function EmailNotifCard() {
|
||||||
<Toggle checked={cfg.enabled} onChange={v => set('enabled', v)} label="Enable email notifications" />
|
<Toggle checked={cfg.enabled} onChange={v => set('enabled', v)} label="Enable email notifications" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<FieldRow label="Reminder send time">
|
||||||
|
<div className="flex flex-col items-end gap-1">
|
||||||
|
<Select value={cfg.reminder_hour} onValueChange={v => set('reminder_hour', v)}>
|
||||||
|
<SelectTrigger className="w-40">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{HOUR_OPTIONS.map(o => (
|
||||||
|
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<span className="text-[11px] text-muted-foreground">Daily reminder emails — applies account-wide.</span>
|
||||||
|
</div>
|
||||||
|
</FieldRow>
|
||||||
|
|
||||||
<div className="border-t border-border" />
|
<div className="border-t border-border" />
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
|
|
||||||
|
|
@ -15,10 +15,11 @@ router.get('/admin', requireAuth, requireAdmin, (req, res) => {
|
||||||
'notify_smtp_enabled', 'notify_sender_name', 'notify_sender_address',
|
'notify_smtp_enabled', 'notify_sender_name', 'notify_sender_address',
|
||||||
'notify_smtp_host', 'notify_smtp_port', 'notify_smtp_encryption',
|
'notify_smtp_host', 'notify_smtp_port', 'notify_smtp_encryption',
|
||||||
'notify_smtp_self_signed', 'notify_smtp_username', 'notify_smtp_password',
|
'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 = {};
|
const settings = {};
|
||||||
for (const k of keys) settings[k] = getSetting(k) || '';
|
for (const k of keys) settings[k] = getSetting(k) || '';
|
||||||
|
if (!settings.reminder_hour) settings.reminder_hour = '6';
|
||||||
// Mask password in response
|
// Mask password in response
|
||||||
if (settings.notify_smtp_password) settings.notify_smtp_password = '••••••••';
|
if (settings.notify_smtp_password) settings.notify_smtp_password = '••••••••';
|
||||||
res.json(settings);
|
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('•')) {
|
if (req.body.notify_smtp_password && !req.body.notify_smtp_password.startsWith('•')) {
|
||||||
setSetting('notify_smtp_password', encryptSecret(req.body.notify_smtp_password));
|
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 });
|
res.json({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const cron = require('node-cron');
|
const cron = require('node-cron');
|
||||||
const { getDb } = require('../db/database');
|
const { getDb, getSetting } = require('../db/database');
|
||||||
const { buildTrackerRow, getCycleRange } = require('../services/statusService');
|
const { buildTrackerRow, getCycleRange } = require('../services/statusService');
|
||||||
const { pruneExpiredSessions } = require('../services/authService');
|
const { pruneExpiredSessions } = require('../services/authService');
|
||||||
const { pruneExpiredChallenges: pruneWebAuthnChallenges } = require('../services/webauthnService');
|
const { pruneExpiredChallenges: pruneWebAuthnChallenges } = require('../services/webauthnService');
|
||||||
|
|
@ -14,11 +14,17 @@ const {
|
||||||
} = require('../services/statusRuntime');
|
} = require('../services/statusRuntime');
|
||||||
const { localDateString, localDateStringDaysAgo } = require('../utils/dates.mts');
|
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()) {
|
function nextDailyRunIso(from = new Date()) {
|
||||||
const next = new Date(from);
|
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);
|
if (next <= from) next.setDate(next.getDate() + 1);
|
||||||
return next.toISOString();
|
return next.toISOString();
|
||||||
}
|
}
|
||||||
|
|
@ -113,6 +119,35 @@ async function runDailyTasks() {
|
||||||
console.log(`[worker] Daily tasks ran at ${todayStr}`);
|
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() {
|
function start() {
|
||||||
markWorkerStarted(nextDailyRunIso());
|
markWorkerStarted(nextDailyRunIso());
|
||||||
|
|
||||||
|
|
@ -122,13 +157,8 @@ function start() {
|
||||||
console.error('[worker] Startup task error:', err);
|
console.error('[worker] Startup task error:', err);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Run every day at 6:00 AM
|
// Run every day at the configured hour (default 6 AM)
|
||||||
cron.schedule('0 6 * * *', () => {
|
scheduleDaily();
|
||||||
runDailyTasks().catch(err => {
|
|
||||||
markWorkerError(err, nextDailyRunIso());
|
|
||||||
console.error('[worker] Daily task error:', err);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { start, runDailyTasks, nextDailyRunIso };
|
module.exports = { start, runDailyTasks, nextDailyRunIso, rescheduleDailyWorker };
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue