Due-date reminders: honour hour 0 (midnight) and notify card assignees (#3192).

Two source bugs kept due-date reminder mail from reaching people. (1) The reminder hour was parsed as parseInt(NOTIFY_DUE_AT_HOUR_OF_DAY, 10) || 8, so a configured hour of 0 (midnight) silently became 8 (0 is falsy) and out-of-range hours were accepted; parseNotifyDueHour now keeps 0 and rejects anything outside 0..23. (2) A due-card activity's notification participants were only the card CREATOR and its MEMBERS (server/models/activities.js) -- a user ASSIGNED a card but not a member was never notified about it, including its due date; card.assignees are now included too (still gated downstream by the board tracking level, so nobody who is not tracking is newly emailed). The NOTIFY_DUE_* parsing is extracted to a pure, unit-tested models/lib/dueNotificationConfig.js. Note: the built-in reminder still requires NOTIFY_DUE_DAYS_BEFORE_AND_AFTER to be set, and members/assignees are emailed per their board watch/tracking level.

Tests: tests/dueNotificationConfig.test.cjs (12 checks) covers the midnight-hour case, valid/invalid/out-of-range hours, the day-offset window parsing and negatives, plus source guards that cards.js uses parseNotifyDueHour and that assignees are in the notification participants.

Thanks to SirConfigMgr and xet7 !

Fixes #3192,
This commit is contained in:
Lauri Ojansivu committed 2026-07-22 11:19:39 +03:00
1 parent 8f2189a66f
commit c0bbacaf94
4 files changed
+136 -15

No files matched your search

+8 -14
View File
@@ -3468,24 +3468,18 @@ const findDueCards = async days => {
};
const addCronJob = debounce(
function findDueCardsDebounced() {
const { parseNotifyDueDays, parseNotifyDueHour } = require('/models/lib/dueNotificationConfig');
const envValue = process.env.NOTIFY_DUE_DAYS_BEFORE_AND_AFTER;
if (!envValue) {
return;
}
const notifydays = envValue
.split(',')
.map(value => {
const iValue = parseInt(value, 10);
// Allow -14..14: positive = days before due, 0 = due today, negative = days past due
if (isNaN(iValue) || iValue < -14 || iValue > 14) {
return false;
}
return iValue;
})
.filter(v => v !== false);
const notifyitvl = process.env.NOTIFY_DUE_AT_HOUR_OF_DAY; //passed in the itvl has to be a number standing for the hour of current time
const defaultitvl = 8; // default every morning at 8am, if the passed env variable has parsing error use default
const itvl = parseInt(notifyitvl, 10) || defaultitvl;
// -14..14: positive = days before due, 0 = due today, negative = days past due.
const notifydays = parseNotifyDueDays(envValue);
const defaultitvl = 8; // default every morning at 8am if the env var is missing/invalid
// #3192: parseNotifyDueHour keeps a configured hour of 0 (midnight) instead of
// the old `parseInt(..) || 8` that turned the falsy 0 into 8, and rejects
// out-of-range hours.
const itvl = parseNotifyDueHour(process.env.NOTIFY_DUE_AT_HOUR_OF_DAY, defaultitvl);
const scheduler = (job => () => {
const now = new Date();
const hour = 3600 * 1e3;
+38
View File
@@ -0,0 +1,38 @@
'use strict';
// Pure parsers for the due-date reminder configuration (NOTIFY_DUE_* env vars),
// extracted from models/cards.js so they are unit-testable without Meteor.
// Related to #3192 (due-date reminder mail).
// Parse NOTIFY_DUE_DAYS_BEFORE_AND_AFTER: a comma-separated list of day offsets
// (positive = days before due, 0 = due today, negative = days past due). Each is
// validated to the -14..14 window; invalid/out-of-range entries are dropped.
// Returns an array of integers (possibly empty).
function parseNotifyDueDays(envValue) {
if (!envValue || typeof envValue !== 'string') return [];
return envValue
.split(',')
.map(value => {
const iValue = parseInt(value, 10);
if (Number.isNaN(iValue) || iValue < -14 || iValue > 14) return false;
return iValue;
})
.filter(v => v !== false);
}
// Parse NOTIFY_DUE_AT_HOUR_OF_DAY: the hour of day (0..23) at which to send the
// reminders. Returns the parsed hour, or `defaultHour` when the value is missing,
// non-numeric, or out of range.
//
// #3192: the previous `parseInt(env, 10) || defaultHour` silently turned a valid
// hour of 0 (midnight) into the default (8), because 0 is falsy — so midnight
// reminders were impossible. It also accepted out-of-range hours (e.g. 25, -1).
// Number.isNaN + a 0..23 range check fixes both while keeping the "parse error ->
// default" behaviour.
function parseNotifyDueHour(envValue, defaultHour = 8) {
const hour = parseInt(envValue, 10);
if (Number.isNaN(hour) || hour < 0 || hour > 23) return defaultHour;
return hour;
}
module.exports = { parseNotifyDueDays, parseNotifyDueHour };
+10 -1
View File
@@ -139,7 +139,16 @@ Activities.after.insert(async (userId, doc) => {
if (activity.cardId) {
const card = (await activity.card()) || (await Cards.findOneAsync(activity.cardId));
if (card) {
participants = [...new Set([...participants, card.userId, ...(card.members || [])])];
// #3192: include the card's ASSIGNEES as participants too, not just its
// creator and members — a user assigned a card (e.g. one with a due date)
// must be notified about it. Participants are still gated downstream by the
// board tracking level, so this does not notify anyone who is not tracking.
participants = [...new Set([
...participants,
card.userId,
...(card.members || []),
...(card.assignees || []),
])];
watchers = [...new Set([...watchers, ...(card.watchers || [])])];
params.card = normalizeActivityText(card.title);
title = 'act-withCardTitle';
+80
View File
@@ -0,0 +1,80 @@
'use strict';
// Unit + negative tests for the due-date reminder config parsers (#3192).
// Run: node tests/dueNotificationConfig.test.cjs
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const {
parseNotifyDueDays,
parseNotifyDueHour,
} = require('../models/lib/dueNotificationConfig');
let passed = 0;
function check(name, fn) { fn(); passed += 1; console.log(' ok -', name); }
// ── parseNotifyDueHour — the #3192 midnight bug ─────────────────────────────
check('#3192: hour "0" (midnight) is kept, NOT silently turned into the default', () => {
// The old `parseInt(env,10) || 8` returned 8 for "0" because 0 is falsy.
assert.strictEqual(parseNotifyDueHour('0', 8), 0);
});
check('a valid hour is parsed as itself', () => {
assert.strictEqual(parseNotifyDueHour('8', 8), 8);
assert.strictEqual(parseNotifyDueHour('23', 8), 23);
assert.strictEqual(parseNotifyDueHour('13', 8), 13);
});
check('missing / non-numeric hour falls back to the default', () => {
assert.strictEqual(parseNotifyDueHour(undefined, 8), 8);
assert.strictEqual(parseNotifyDueHour('', 8), 8);
assert.strictEqual(parseNotifyDueHour('abc', 8), 8);
});
check('out-of-range hour falls back to the default (negative, >23)', () => {
assert.strictEqual(parseNotifyDueHour('-1', 8), 8);
assert.strictEqual(parseNotifyDueHour('24', 8), 8);
assert.strictEqual(parseNotifyDueHour('25', 8), 8);
});
check('the default is configurable', () => {
assert.strictEqual(parseNotifyDueHour('nope', 6), 6);
});
// ── parseNotifyDueDays ──────────────────────────────────────────────────────
check('parses a comma list of day offsets, keeping 0 and negatives', () => {
assert.deepStrictEqual(parseNotifyDueDays('-2,0,3'), [-2, 0, 3]);
});
check('drops entries outside the -14..14 window', () => {
assert.deepStrictEqual(parseNotifyDueDays('-15,-14,14,15,100'), [-14, 14]);
});
check('drops non-numeric entries but keeps the valid ones', () => {
assert.deepStrictEqual(parseNotifyDueDays('0,foo,2'), [0, 2]);
});
check('empty / undefined / non-string yields an empty array (negative)', () => {
assert.deepStrictEqual(parseNotifyDueDays(''), []);
assert.deepStrictEqual(parseNotifyDueDays(undefined), []);
assert.deepStrictEqual(parseNotifyDueDays(null), []);
assert.deepStrictEqual(parseNotifyDueDays(5), []);
});
check('a single valid value works', () => {
assert.deepStrictEqual(parseNotifyDueDays('0'), [0]);
});
// ── source guards ───────────────────────────────────────────────────────────
const read = rel => fs.readFileSync(path.join(__dirname, '..', rel), 'utf8');
check('cards.js uses parseNotifyDueHour (no falsy `|| defaultitvl` hour parse)', () => {
const src = read('models/cards.js');
assert.ok(/parseNotifyDueHour\(process\.env\.NOTIFY_DUE_AT_HOUR_OF_DAY/.test(src),
'the scheduler must parse the hour via parseNotifyDueHour');
assert.ok(!/parseInt\(notifyitvl, 10\) \|\| defaultitvl/.test(src),
'the old falsy-zero hour parse must be gone');
});
check('#3192: due-card notifications include card ASSIGNEES as participants', () => {
const src = read('server/models/activities.js');
// The card-activity participants block must add card.assignees alongside members.
const m = src.match(/participants = \[\.\.\.new Set\(\[[\s\S]{0,200}?card\.userId[\s\S]{0,200}?\]\)\]/);
assert.ok(m, 'the card participants assignment must exist');
assert.ok(/card\.assignees/.test(m[0]),
'participants must include card.assignees so assignees are notified');
});
console.log(`\ndueNotificationConfig: ${passed} checks passed`);