fix: use per-intake reminder setting as single source of truth (#384)

- Filter intakes by per-intake intakeRemindersEnabled instead of falling
  back to medication-level setting (fixes #383)
- Add SMTP delivery validation with accepted/rejected recipient checks
- Enhance email success logging with recipient, messageId, SMTP response
- Simplify MedDetailModal reminder icon logic to match backend behavior
- Sync lockfile versions to 1.18.2
This commit is contained in:
Daniel Volz
2026-03-06 19:50:45 +01:00
committed by GitHub
parent de1a508e52
commit 30c97e2f0d
4 changed files with 69 additions and 36 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "medassist-ng-backend", "name": "medassist-ng-backend",
"version": "1.18.1", "version": "1.18.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "medassist-ng-backend", "name": "medassist-ng-backend",
"version": "1.18.1", "version": "1.18.2",
"dependencies": { "dependencies": {
"@fastify/cookie": "^11.0.2", "@fastify/cookie": "^11.0.2",
"@fastify/cors": "^11.2.0", "@fastify/cors": "^11.2.0",
@@ -50,6 +50,36 @@ function saveIntakeReminderState(state: IntakeReminderState): void {
writeFileSync(intakeReminderStateFile, JSON.stringify(state, null, 2)); writeFileSync(intakeReminderStateFile, JSON.stringify(state, null, 2));
} }
type MailDeliveryInfo = {
accepted?: unknown;
rejected?: unknown;
response?: unknown;
};
function normalizeRecipients(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return value
.map((entry) => (typeof entry === "string" ? entry : String(entry ?? "")))
.map((entry) => entry.trim())
.filter(Boolean);
}
function getDeliveryError(info: MailDeliveryInfo): string | null {
const accepted = normalizeRecipients(info.accepted);
const rejected = normalizeRecipients(info.rejected);
if (accepted.length > 0) return null;
if (rejected.length > 0) {
return `SMTP rejected all recipients: ${rejected.join(", ")}`;
}
if (typeof info.response === "string" && info.response.trim()) {
return `SMTP did not confirm accepted recipients. Response: ${info.response}`;
}
return "SMTP did not confirm accepted recipients.";
}
function buildDoseIdForIntake(intake: UpcomingIntake & { medicationId: number; blisterIndex: number }): string { function buildDoseIdForIntake(intake: UpcomingIntake & { medicationId: number; blisterIndex: number }): string {
const intakeDate = intake.intakeTime; const intakeDate = intake.intakeTime;
const dateOnlyMs = new Date(intakeDate.getFullYear(), intakeDate.getMonth(), intakeDate.getDate()).getTime(); const dateOnlyMs = new Date(intakeDate.getFullYear(), intakeDate.getMonth(), intakeDate.getDate()).getTime();
@@ -166,7 +196,7 @@ async function sendIntakeReminderEmail(
repeatIntervalMinutes?: number, repeatIntervalMinutes?: number,
currentCount?: number, currentCount?: number,
maxCount?: number maxCount?: number
): Promise<{ success: boolean; error?: string }> { ): Promise<{ success: boolean; error?: string; messageId?: string; smtpResponse?: string }> {
const smtpHost = process.env.SMTP_HOST; const smtpHost = process.env.SMTP_HOST;
const smtpUser = process.env.SMTP_USER; const smtpUser = process.env.SMTP_USER;
const smtpPass = process.env.SMTP_TOKEN || process.env.SMTP_PASS; // Token takes precedence const smtpPass = process.env.SMTP_TOKEN || process.env.SMTP_PASS; // Token takes precedence
@@ -310,7 +340,7 @@ ${getFooterPlain(language)}`;
}, },
}); });
await transporter.sendMail({ const mailResult = await transporter.sendMail({
from: smtpFrom, from: smtpFrom,
to: email, to: email,
subject: `💊 ${subject}`, subject: `💊 ${subject}`,
@@ -318,7 +348,16 @@ ${getFooterPlain(language)}`;
html, html,
}); });
return { success: true }; const deliveryError = getDeliveryError(mailResult);
if (deliveryError) {
return { success: false, error: deliveryError };
}
return {
success: true,
messageId: mailResult.messageId,
smtpResponse: typeof mailResult.response === "string" ? mailResult.response : undefined,
};
} catch (error) { } catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown error"; const errorMessage = error instanceof Error ? error.message : "Unknown error";
return { success: false, error: errorMessage }; return { success: false, error: errorMessage };
@@ -380,17 +419,26 @@ async function checkAndSendIntakeRemindersForUser(
`[IntakeReminder] User ${settings.userId}: Notifications enabled (email:${emailEnabled}, shoutrrr:${shoutrrrEnabled})` `[IntakeReminder] User ${settings.userId}: Notifications enabled (email:${emailEnabled}, shoutrrr:${shoutrrrEnabled})`
); );
// Get all medications with intake reminders enabled for this user // Build medication entries that have at least one reminder-enabled intake.
const medsWithReminders = rows.filter((row) => row.intakeRemindersEnabled); // Intake-level reminders are the single source of truth.
const reminderEntries = rows
.map((med) => {
const intakes = parseIntakesJson(
med.intakesJson,
{ usageJson: med.usageJson, everyJson: med.everyJson, startJson: med.startJson },
false
);
const intakesWithReminders = intakes.filter((intake) => intake.intakeRemindersEnabled === true);
return { med, intakes, intakesWithReminders };
})
.filter((entry) => entry.intakesWithReminders.length > 0);
if (medsWithReminders.length === 0) { if (reminderEntries.length === 0) {
logger.debug(`[IntakeReminder] User ${settings.userId}: No medications have reminders enabled`); logger.debug(`[IntakeReminder] User ${settings.userId}: No medications have reminders enabled`);
return; // No medications have reminders enabled for this user return; // No medications have reminders enabled for this user
} }
logger.debug( logger.debug(`[IntakeReminder] User ${settings.userId}: Found ${reminderEntries.length} medications with reminders`);
`[IntakeReminder] User ${settings.userId}: Found ${medsWithReminders.length} medications with reminders`
);
const state = loadIntakeReminderState(); const state = loadIntakeReminderState();
const allUpcoming: (UpcomingIntake & { medicationId: number; blisterIndex: number })[] = []; const allUpcoming: (UpcomingIntake & { medicationId: number; blisterIndex: number })[] = [];
@@ -407,13 +455,7 @@ async function checkAndSendIntakeRemindersForUser(
); );
// Find intakes: upcoming ones in reminder window + past ones for repeat reminders // Find intakes: upcoming ones in reminder window + past ones for repeat reminders
for (const med of medsWithReminders) { for (const { med, intakes, intakesWithReminders } of reminderEntries) {
// Parse intakes using new format (with per-intake takenBy), falling back to legacy
const intakes = parseIntakesJson(
med.intakesJson,
{ usageJson: med.usageJson, everyJson: med.everyJson, startJson: med.startJson },
med.intakeRemindersEnabled ?? false
);
// Medication-level takenBy (for fallback/display purposes) // Medication-level takenBy (for fallback/display purposes)
const medicationTakenBy = parseTakenByJson(med.takenByJson); const medicationTakenBy = parseTakenByJson(med.takenByJson);
const medDisplayName = med.name || med.genericName || ""; const medDisplayName = med.name || med.genericName || "";
@@ -422,15 +464,6 @@ async function checkAndSendIntakeRemindersForUser(
`[IntakeReminder] User ${settings.userId}: Processing medication "${medDisplayName}" with ${intakes.length} intakes` `[IntakeReminder] User ${settings.userId}: Processing medication "${medDisplayName}" with ${intakes.length} intakes`
); );
// Filter intakes that have reminders enabled (per-intake setting or medication-level)
const intakesWithReminders = intakes.filter((intake, idx) => {
const hasReminder = intake.intakeRemindersEnabled || med.intakeRemindersEnabled;
if (!hasReminder) {
logger.debug(`[IntakeReminder] User ${settings.userId}: Intake ${idx} has reminders disabled, skipping`);
}
return hasReminder;
});
// Process each intake separately to track blisterIndex // Process each intake separately to track blisterIndex
intakesWithReminders.forEach((intake, _blisterIndex) => { intakesWithReminders.forEach((intake, _blisterIndex) => {
const actualIndex = intakes.indexOf(intake); // Get the actual index in original array const actualIndex = intakes.indexOf(intake); // Get the actual index in original array
@@ -670,7 +703,9 @@ async function checkAndSendIntakeRemindersForUser(
); );
emailSuccess = result.success; emailSuccess = result.success;
if (result.success) { if (result.success) {
logger.info(`[IntakeReminder] User ${settings.userId}: Email sent successfully`); logger.info(
`[IntakeReminder] User ${settings.userId}: Email sent successfully (to: ${settings.notificationEmail}, messageId: ${result.messageId}, smtp: ${result.smtpResponse})`
);
} else { } else {
logger.error(`[IntakeReminder] User ${settings.userId}: Failed to send email: ${result.error}`); logger.error(`[IntakeReminder] User ${settings.userId}: Failed to send email: ${result.error}`);
} }
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "medassist-ng-frontend", "name": "medassist-ng-frontend",
"version": "1.18.1", "version": "1.18.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "medassist-ng-frontend", "name": "medassist-ng-frontend",
"version": "1.18.1", "version": "1.18.2",
"dependencies": { "dependencies": {
"i18next": "^25.8.13", "i18next": "^25.8.13",
"i18next-browser-languagedetector": "^8.2.1", "i18next-browser-languagedetector": "^8.2.1",
+4 -6
View File
@@ -268,12 +268,10 @@ export function MedDetailModal({
every: blister.every, every: blister.every,
start: blister.start, start: blister.start,
takenBy: null, takenBy: null,
intakeRemindersEnabled: selectedMed.intakeRemindersEnabled ?? false, intakeRemindersEnabled: false,
intakeUnit: null, intakeUnit: null,
})); }));
const hasAnyIntakeReminder = scheduleIntakes.some( const hasAnyIntakeReminder = scheduleIntakes.some((intake) => intake.intakeRemindersEnabled === true);
(intake) => (intake.intakeRemindersEnabled ?? selectedMed.intakeRemindersEnabled ?? false) === true
);
const normalizeBlisterStock = (nextFull: number, nextPartial: number, nextLoose: number) => { const normalizeBlisterStock = (nextFull: number, nextPartial: number, nextLoose: number) => {
let normalizedFull = Math.max(0, nextFull); let normalizedFull = Math.max(0, nextFull);
let normalizedPartial = Math.max(0, nextPartial); let normalizedPartial = Math.max(0, nextPartial);
@@ -966,7 +964,7 @@ export function MedDetailModal({
<div className="med-detail-section"> <div className="med-detail-section">
<h3> <h3>
{t("modal.intakeSchedule")}{" "} {t("modal.intakeSchedule")}{" "}
{(selectedMed.intakeRemindersEnabled || hasAnyIntakeReminder) && ( {hasAnyIntakeReminder && (
<span className="reminder-icon info-tooltip" data-tooltip={t("tooltips.intakeReminders")}> <span className="reminder-icon info-tooltip" data-tooltip={t("tooltips.intakeReminders")}>
<Bell size={14} aria-hidden="true" /> <Bell size={14} aria-hidden="true" />
</span> </span>
@@ -977,7 +975,7 @@ export function MedDetailModal({
const hasPerIntakeTakenBy = !!intake.takenBy; const hasPerIntakeTakenBy = !!intake.takenBy;
const personCount = Math.max(1, selectedMed.takenBy?.length ?? 0); const personCount = Math.max(1, selectedMed.takenBy?.length ?? 0);
const totalUsage = hasPerIntakeTakenBy ? intake.usage : intake.usage * personCount; const totalUsage = hasPerIntakeTakenBy ? intake.usage : intake.usage * personCount;
const showIntakeBell = intake.intakeRemindersEnabled ?? selectedMed.intakeRemindersEnabled ?? false; const showIntakeBell = intake.intakeRemindersEnabled === true;
return ( return (
<div <div