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:
Generated
+2
-2
@@ -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}`);
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+2
-2
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user