From f48a20ad55ec51eb4b73ef26f800c649793457e7 Mon Sep 17 00:00:00 2001 From: Daniel Volz Date: Sun, 10 May 2026 19:01:37 +0200 Subject: [PATCH] feat: add ntfy scheduler interactive delivery --- .../src/services/intake-reminder-scheduler.ts | 152 +++- .../intake-reminder-scheduler-actions.test.ts | 715 ++++++++++++++++++ 2 files changed, 862 insertions(+), 5 deletions(-) create mode 100644 backend/src/test/intake-reminder-scheduler-actions.test.ts diff --git a/backend/src/services/intake-reminder-scheduler.ts b/backend/src/services/intake-reminder-scheduler.ts index 05e0c46..e73e90a 100644 --- a/backend/src/services/intake-reminder-scheduler.ts +++ b/backend/src/services/intake-reminder-scheduler.ts @@ -12,6 +12,8 @@ import { type Language, t, } from "../i18n/translations.js"; + +import { env } from "../plugins/env.js"; import { getAllUserSettings, type UserSettings } from "../routes/settings.js"; import type { ServiceLogger } from "../utils/logger.js"; // Import shared utilities @@ -29,6 +31,10 @@ import { type UpcomingIntake, } from "../utils/scheduler-utils.js"; import { computeMedicationCurrentStock } from "./current-stock.js"; +import { + createNotificationActionContext, + storeNotificationActionGroupNtfyMessageId, +} from "./notification-actions-service.js"; import { getSmtpConfig, sendEmailNotification, sendPushNotification } from "./notifications/delivery.js"; import { updateReminderSentTime, updateUserReminderSentTime } from "./notifications/state.js"; @@ -93,6 +99,31 @@ function getMedicationDisplayName(med: { id: number; name: string | null; generi return `Medication #${med.id}`; } +function getPushProviderLabel(url: string): string { + const normalizedUrl = url.trim().toLowerCase(); + if (normalizedUrl.startsWith("ntfy://")) return "ntfy"; + if (normalizedUrl.startsWith("discord://")) return "discord"; + if (normalizedUrl.startsWith("pushover://")) return "pushover"; + if (normalizedUrl.startsWith("gotify://")) return "gotify"; + if (normalizedUrl.startsWith("telegram://")) return "telegram"; + + try { + const parsedUrl = new URL(url); + return parsedUrl.hostname || parsedUrl.protocol.replace(":", "") || "unknown"; + } catch { + return "unknown"; + } +} + +function formatActionContextLog(options: { + actionMode: "full" | "view-only"; + doseCount: number; + actionContext: Awaited> | null; +}): string { + const { actionMode, doseCount, actionContext } = options; + return `actionMode=${actionMode}, doses=${doseCount}, actions=${actionContext?.actions.length ?? 0}, hasRespondUrl=${actionContext?.respondUrl ? "yes" : "no"}, hasViewUrl=${actionContext?.viewUrl ? "yes" : "no"}, sequenceId=${actionContext?.sequenceId ?? "none"}, groupId=${actionContext?.groupId ?? "n/a"}`; +} + async function autoMarkDueIntakesAsTaken( settings: UserSettings & { userId: number }, rows: (typeof medications.$inferSelect)[], @@ -483,11 +514,42 @@ export async function checkAndSendIntakeRemindersForUser( return; // No medications have reminders enabled for this user } + const now = new Date(); const state = loadIntakeReminderState(logger); + const trackedDoses = await db + .select() + .from(doseTracking) + .where(and(eq(doseTracking.userId, settings.userId), eq(doseTracking.dismissed, false))); + + const reminderEntriesWithStock = reminderEntries.map((entry) => ({ + ...entry, + currentStock: computeMedicationCurrentStock({ + medication: entry.med, + doses: trackedDoses, + stockCalculationMode: settings.stockCalculationMode, + nowMs: now.getTime(), + }), + })); + const suppressedEmptyStockEntries = reminderEntriesWithStock.filter((entry) => entry.currentStock <= 0); + if (suppressedEmptyStockEntries.length > 0) { + logger.info( + `[IntakeReminder] Skipping reminder-enabled medications with empty stock for user=${username} (userId=${settings.userId}): count=${suppressedEmptyStockEntries.length}, meds=${suppressedEmptyStockEntries + .map((entry) => + getMedicationDisplayName({ id: entry.med.id, name: entry.med.name, genericName: entry.med.genericName }) + ) + .join(", ")}` + ); + } + const reminderEntriesEligible = reminderEntriesWithStock.filter((entry) => entry.currentStock > 0); + if (reminderEntriesEligible.length === 0) { + logger.info( + `[IntakeReminder] No reminder-eligible medications with stock remaining for user=${username} (userId=${settings.userId})` + ); + return; + } const allUpcoming: (UpcomingIntake & { medicationId: number; blisterIndex: number })[] = []; let scheduledIntakesTodayCount = 0; // Get start and end of today in user's timezone (for filtering today's doses only) - const now = new Date(); const todayStart = new Date(now.toLocaleString("en-US", { timeZone: tz })); todayStart.setHours(0, 0, 0, 0); @@ -495,7 +557,7 @@ export async function checkAndSendIntakeRemindersForUser( todayEnd.setHours(23, 59, 59, 999); // Find intakes: upcoming ones in reminder window + past ones for repeat reminders - for (const { med, intakes, intakesWithReminders } of reminderEntries) { + for (const { med, intakes, intakesWithReminders } of reminderEntriesEligible) { // Medication-level takenBy (for fallback/display purposes) const medicationTakenBy = parseTakenByJson(med.takenByJson); const medDisplayName = getMedicationDisplayName({ id: med.id, name: med.name, genericName: med.genericName }); @@ -801,16 +863,96 @@ export async function checkAndSendIntakeRemindersForUser( .join("\n") + repeatNote + `\n\n---\n${getFooterPlain(language)}`; + const actionMode = remindersToSend.length === 1 ? "full" : "view-only"; + const actionDoseIds = remindersToSend.map((intake) => + buildDoseIdForIntake({ + ...intake, + medicationId: intake.medicationId, + blisterIndex: intake.blisterIndex, + }) + ); + let actionContext: Awaited> | null = null; + let actionContextFailed = false; + try { + actionContext = await createNotificationActionContext({ + userId: settings.userId, + title, + message, + doseIds: actionDoseIds, + scheduledFor: remindersToSend[0]?.intakeTime ?? new Date(), + publicAppUrl: env.PUBLIC_APP_URL, + language, + actionMode, + }); + } catch (error) { + actionContextFailed = true; + const errorMessage = error instanceof Error ? error.message : String(error); + logger.error( + `[IntakeReminder] Notification action context failed for user=${username} (userId=${settings.userId}, provider=${getPushProviderLabel( + settings.shoutrrrUrl! + )}, ${formatActionContextLog({ actionMode, doseCount: actionDoseIds.length, actionContext: null })}): ${errorMessage}` + ); + } + if (!actionContext) { + if (actionContextFailed) { + logger.warn( + `[IntakeReminder] Sending intake reminders without actions after action context failure for user=${username} (userId=${settings.userId}, provider=${getPushProviderLabel( + settings.shoutrrrUrl! + )})` + ); + } else { + logger.warn( + `[IntakeReminder] No reachable public app URL configured; sending intake reminders without actions for user=${username} (userId=${settings.userId})` + ); + } + } else { + logger.info( + `[IntakeReminder] Notification action context ready for user=${username} (userId=${settings.userId}, provider=${getPushProviderLabel( + settings.shoutrrrUrl! + )}, ${formatActionContextLog({ actionMode, doseCount: actionDoseIds.length, actionContext })})` + ); + } - const result = await sendPushNotification(settings.shoutrrrUrl!, title, message); + const pushProvider = getPushProviderLabel(settings.shoutrrrUrl!); + logger.info( + `[IntakeReminder] Sending push reminder for user=${username} (userId=${settings.userId}, provider=${pushProvider}, reminders=${remindersToSend.length}, priority=${hasNaggingReminder ? 4 : 3}, ${formatActionContextLog({ actionMode, doseCount: actionDoseIds.length, actionContext })})` + ); + + const result = await sendPushNotification(settings.shoutrrrUrl!, title, message, { + actions: actionContext?.actions, + respondUrl: actionContext?.respondUrl, + viewUrl: actionContext?.viewUrl, + clickUrl: actionContext?.respondUrl ?? actionContext?.viewUrl, + sequenceId: actionContext?.sequenceId, + tags: ["pill"], + priority: hasNaggingReminder ? 4 : 3, + }); shoutrrrSuccess = result.success; if (!result.success) { logger.error( - `[IntakeReminder] Push delivery failed for user=${username} (userId=${settings.userId}): ${result.error}` + `[IntakeReminder] Push delivery failed for user=${username} (userId=${settings.userId}, provider=${pushProvider}, reminders=${remindersToSend.length}, ${formatActionContextLog({ actionMode, doseCount: actionDoseIds.length, actionContext })}): ${result.error}` ); } else { + if (actionContext?.groupId && result.providerMessageId) { + try { + await storeNotificationActionGroupNtfyMessageId(actionContext.groupId, result.providerMessageId); + logger.info( + `[IntakeReminder] Stored ntfy message id for action group for user=${username} (userId=${settings.userId}, groupId=${actionContext.groupId}, providerMessageId=${result.providerMessageId})` + ); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + logger.warn( + `[IntakeReminder] Failed to store ntfy message id for action group for user=${username} (userId=${settings.userId}, groupId=${actionContext.groupId}, providerMessageId=${result.providerMessageId}): ${errorMessage}` + ); + } + } else if (actionContext?.groupId && pushProvider === "ntfy") { + logger.warn( + `[IntakeReminder] Push delivered without ntfy message id for action group for user=${username} (userId=${settings.userId}, groupId=${actionContext.groupId})` + ); + } + logger.info( - `[IntakeReminder] Push delivered for user=${username} (userId=${settings.userId}, reminders=${remindersToSend.length})` + `[IntakeReminder] Push delivered for user=${username} (userId=${settings.userId}, provider=${pushProvider}, reminders=${remindersToSend.length}, providerMessageId=${result.providerMessageId ?? "n/a"}, ${formatActionContextLog({ actionMode, doseCount: actionDoseIds.length, actionContext })})` ); } } diff --git a/backend/src/test/intake-reminder-scheduler-actions.test.ts b/backend/src/test/intake-reminder-scheduler-actions.test.ts new file mode 100644 index 0000000..e15983e --- /dev/null +++ b/backend/src/test/intake-reminder-scheduler-actions.test.ts @@ -0,0 +1,715 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { + mockedEnv, + createNotificationActionContextMock, + storeNotificationActionGroupNtfyMessageIdMock, + sendPushNotificationMock, +} = vi.hoisted(() => ({ + mockedEnv: { + PUBLIC_APP_URL: undefined as string | undefined, + CORS_ORIGINS: "http://localhost:5173" as string, + }, + createNotificationActionContextMock: vi.fn(), + storeNotificationActionGroupNtfyMessageIdMock: vi.fn(), + sendPushNotificationMock: vi.fn(), +})); + +vi.mock("node:fs", () => ({ + existsSync: () => false, + readFileSync: vi.fn(), + writeFileSync: vi.fn(), +})); + +vi.mock("../db/path-utils.js", () => ({ + getDataDir: () => "/tmp", +})); + +vi.mock("../db/client.js", () => ({ + db: { + select: vi.fn(), + insert: vi.fn(), + }, + migrationsReady: Promise.resolve(), +})); + +vi.mock("../plugins/env.js", () => ({ env: mockedEnv })); + +vi.mock("../services/notification-actions-service.js", () => ({ + createNotificationActionContext: createNotificationActionContextMock, + storeNotificationActionGroupNtfyMessageId: storeNotificationActionGroupNtfyMessageIdMock, +})); + +vi.mock("../services/notifications/delivery.js", () => ({ + getSmtpConfig: vi.fn(() => null), + sendEmailNotification: vi.fn(), + sendPushNotification: sendPushNotificationMock, +})); + +vi.mock("../services/notifications/state.js", () => ({ + updateReminderSentTime: vi.fn(), + updateUserReminderSentTime: vi.fn(), +})); + +vi.mock("../utils/scheduler-utils.js", async () => { + const actual = await vi.importActual("../utils/scheduler-utils.js"); + const candidate = { + medName: "Calcium", + intakeTime: new Date("2026-01-05T11:15:00.000Z"), + intakeTimeStr: "11:15", + usage: 1, + takenBy: null, + pillWeightMg: null, + doseUnit: "mg", + }; + + return { + ...actual, + getEffectiveTimezone: () => Intl.DateTimeFormat().resolvedOptions().timeZone, + getDateLocale: () => "en-US", + parseTakenByJson: () => [], + parseIntakesJson: () => [ + { + usage: 1, + every: 1, + start: "2026-01-05T10:45:00.000Z", + takenBy: null, + intakeRemindersEnabled: true, + }, + ], + getTodaysIntakes: () => [candidate], + getUpcomingIntakes: () => [candidate], + }; +}); + +import { db } from "../db/client.js"; +import { checkAndSendIntakeRemindersForUser } from "../services/intake-reminder-scheduler.js"; + +function createLogger() { + return { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; +} + +function mockSelectWhere(result: T) { + return { + from: () => ({ + where: async () => result, + }), + } as never; +} + +describe("intake reminder scheduler action wiring", () => { + const mockedDb = vi.mocked(db); + let originalTz: string | undefined; + + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(2026, 0, 5, 10, 30, 0)); + originalTz = process.env.TZ; + process.env.TZ = Intl.DateTimeFormat().resolvedOptions().timeZone; + mockedEnv.PUBLIC_APP_URL = undefined; + mockedEnv.CORS_ORIGINS = "http://localhost:5173"; + createNotificationActionContextMock.mockReset(); + storeNotificationActionGroupNtfyMessageIdMock.mockReset(); + sendPushNotificationMock.mockReset(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + if (originalTz === undefined) { + delete process.env.TZ; + } else { + process.env.TZ = originalTz; + } + }); + + it("attaches action context to push notifications when PUBLIC_APP_URL is configured", async () => { + mockedEnv.PUBLIC_APP_URL = "https://app.example.com"; + + const selectMock = vi.mocked(mockedDb.select); + selectMock + .mockImplementationOnce(() => mockSelectWhere([{ username: "push-user" }])) + .mockImplementationOnce(() => + mockSelectWhere([ + { + id: 7, + userId: 11, + name: "Calcium", + genericName: null, + takenByJson: null, + packageType: "blister", + medicationForm: "tablet", + packCount: 1, + blistersPerPack: 1, + pillsPerBlister: 10, + looseTablets: 0, + stockAdjustment: 0, + pillWeightMg: null, + doseUnit: "mg", + isObsolete: false, + intakeRemindersEnabled: true, + intakesJson: "[]", + usageJson: "[]", + everyJson: "[]", + startJson: "[]", + }, + ]) + ) + .mockImplementationOnce(() => mockSelectWhere([])); + + createNotificationActionContextMock.mockResolvedValue({ + groupId: 41, + actions: [ + { + kind: "taken", + label: "Taken", + url: "https://app.example.com/api/notification-actions/taken", + method: "POST", + }, + ], + respondUrl: "https://app.example.com/api/notification-actions/respond", + viewUrl: "https://app.example.com/?date=2026-01-05", + sequenceId: "medassist-sequence", + }); + sendPushNotificationMock.mockResolvedValue({ success: true, providerMessageId: "ntfy-msg-1" }); + + const logger = createLogger(); + + await checkAndSendIntakeRemindersForUser( + { + userId: 11, + language: "en", + stockCalculationMode: "manual", + emailEnabled: false, + notificationEmail: null, + emailIntakeReminders: false, + shoutrrrEnabled: true, + shoutrrrUrl: "ntfy://ntfy.sh/medassist", + shoutrrrIntakeReminders: true, + repeatRemindersEnabled: false, + } as never, + logger as never + ); + + expect(createNotificationActionContextMock).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 11, + publicAppUrl: "https://app.example.com", + language: "en", + actionMode: "full", + doseIds: [expect.stringMatching(/^7-0-/)], + }) + ); + expect(sendPushNotificationMock).toHaveBeenCalledWith( + "ntfy://ntfy.sh/medassist", + expect.any(String), + expect.any(String), + expect.objectContaining({ + actions: [ + { + kind: "taken", + label: "Taken", + url: "https://app.example.com/api/notification-actions/taken", + method: "POST", + }, + ], + respondUrl: "https://app.example.com/api/notification-actions/respond", + viewUrl: "https://app.example.com/?date=2026-01-05", + clickUrl: "https://app.example.com/api/notification-actions/respond", + sequenceId: "medassist-sequence", + tags: ["pill"], + priority: 3, + }) + ); + expect(storeNotificationActionGroupNtfyMessageIdMock).toHaveBeenCalledWith(41, "ntfy-msg-1"); + expect(logger.info).toHaveBeenCalledWith(expect.stringContaining("Notification action context ready")); + expect(logger.info).toHaveBeenCalledWith(expect.stringContaining("Sending push reminder")); + expect(logger.info).toHaveBeenCalledWith(expect.stringContaining("Push delivered")); + }); + + it("uses view-only actions for grouped intake reminders", async () => { + mockedEnv.PUBLIC_APP_URL = "https://app.example.com"; + + const selectMock = vi.mocked(mockedDb.select); + selectMock + .mockImplementationOnce(() => mockSelectWhere([{ username: "grouped-user" }])) + .mockImplementationOnce(() => + mockSelectWhere([ + { + id: 7, + userId: 13, + name: "Calcium", + genericName: null, + takenByJson: null, + packageType: "blister", + medicationForm: "tablet", + packCount: 1, + blistersPerPack: 1, + pillsPerBlister: 10, + looseTablets: 0, + stockAdjustment: 0, + pillWeightMg: null, + doseUnit: "mg", + isObsolete: false, + intakeRemindersEnabled: true, + intakesJson: "[]", + usageJson: "[]", + everyJson: "[]", + startJson: "[]", + }, + { + id: 8, + userId: 13, + name: "Vitamin D", + genericName: null, + takenByJson: null, + packageType: "blister", + medicationForm: "tablet", + packCount: 1, + blistersPerPack: 1, + pillsPerBlister: 10, + looseTablets: 0, + stockAdjustment: 0, + pillWeightMg: null, + doseUnit: "mg", + isObsolete: false, + intakeRemindersEnabled: true, + intakesJson: "[]", + usageJson: "[]", + everyJson: "[]", + startJson: "[]", + }, + ]) + ) + .mockImplementationOnce(() => mockSelectWhere([])); + + createNotificationActionContextMock.mockResolvedValue({ + actions: [ + { + kind: "view", + label: "View", + url: "https://app.example.com/dashboard?day=2026-01-05&dose=7-0-1736075700000", + method: "GET", + }, + ], + viewUrl: "https://app.example.com/dashboard?day=2026-01-05&dose=7-0-1736075700000", + }); + sendPushNotificationMock.mockResolvedValue({ success: true }); + + const logger = createLogger(); + + await checkAndSendIntakeRemindersForUser( + { + userId: 13, + language: "en", + stockCalculationMode: "manual", + emailEnabled: false, + notificationEmail: null, + emailIntakeReminders: false, + shoutrrrEnabled: true, + shoutrrrUrl: "ntfy://ntfy.sh/medassist", + shoutrrrIntakeReminders: true, + repeatRemindersEnabled: false, + } as never, + logger as never + ); + + expect(createNotificationActionContextMock).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 13, + publicAppUrl: "https://app.example.com", + language: "en", + actionMode: "view-only", + doseIds: [expect.stringMatching(/^7-0-/), expect.stringMatching(/^8-0-/)], + }) + ); + expect(sendPushNotificationMock).toHaveBeenCalledWith( + "ntfy://ntfy.sh/medassist", + expect.any(String), + expect.any(String), + expect.objectContaining({ + actions: [ + { + kind: "view", + label: "View", + url: "https://app.example.com/dashboard?day=2026-01-05&dose=7-0-1736075700000", + method: "GET", + }, + ], + respondUrl: undefined, + viewUrl: "https://app.example.com/dashboard?day=2026-01-05&dose=7-0-1736075700000", + clickUrl: "https://app.example.com/dashboard?day=2026-01-05&dose=7-0-1736075700000", + sequenceId: undefined, + tags: ["pill"], + priority: 3, + }) + ); + }); + + it("sends push notifications without actions when PUBLIC_APP_URL is missing", async () => { + createNotificationActionContextMock.mockResolvedValue(null); + + const selectMock = vi.mocked(mockedDb.select); + selectMock + .mockImplementationOnce(() => mockSelectWhere([{ username: "pushless-user" }])) + .mockImplementationOnce(() => + mockSelectWhere([ + { + id: 7, + userId: 12, + name: "Calcium", + genericName: null, + takenByJson: null, + packageType: "blister", + medicationForm: "tablet", + packCount: 1, + blistersPerPack: 1, + pillsPerBlister: 10, + looseTablets: 0, + stockAdjustment: 0, + pillWeightMg: null, + doseUnit: "mg", + isObsolete: false, + intakeRemindersEnabled: true, + intakesJson: "[]", + usageJson: "[]", + everyJson: "[]", + startJson: "[]", + }, + ]) + ) + .mockImplementationOnce(() => mockSelectWhere([])); + + sendPushNotificationMock.mockResolvedValue({ success: true }); + + const logger = createLogger(); + + await checkAndSendIntakeRemindersForUser( + { + userId: 12, + language: "en", + stockCalculationMode: "manual", + emailEnabled: false, + notificationEmail: null, + emailIntakeReminders: false, + shoutrrrEnabled: true, + shoutrrrUrl: "ntfy://ntfy.sh/medassist", + shoutrrrIntakeReminders: true, + repeatRemindersEnabled: false, + } as never, + logger as never + ); + + expect(createNotificationActionContextMock).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 12, + publicAppUrl: undefined, + }) + ); + expect(sendPushNotificationMock).toHaveBeenCalledWith( + "ntfy://ntfy.sh/medassist", + expect.any(String), + expect.any(String), + expect.objectContaining({ + actions: undefined, + respondUrl: undefined, + viewUrl: undefined, + clickUrl: undefined, + tags: ["pill"], + priority: 3, + }) + ); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining("No reachable public app URL configured; sending intake reminders without actions") + ); + }); + + it("falls back to push delivery without actions when action context generation fails", async () => { + mockedEnv.PUBLIC_APP_URL = "https://app.example.com"; + + const selectMock = vi.mocked(mockedDb.select); + selectMock + .mockImplementationOnce(() => mockSelectWhere([{ username: "context-failure-user" }])) + .mockImplementationOnce(() => + mockSelectWhere([ + { + id: 7, + userId: 15, + name: "Calcium", + genericName: null, + takenByJson: null, + packageType: "blister", + medicationForm: "tablet", + packCount: 1, + blistersPerPack: 1, + pillsPerBlister: 10, + looseTablets: 0, + stockAdjustment: 0, + pillWeightMg: null, + doseUnit: "mg", + isObsolete: false, + intakeRemindersEnabled: true, + intakesJson: "[]", + usageJson: "[]", + everyJson: "[]", + startJson: "[]", + }, + ]) + ) + .mockImplementationOnce(() => mockSelectWhere([])); + + createNotificationActionContextMock.mockRejectedValue(new Error("action context write failed")); + sendPushNotificationMock.mockResolvedValue({ success: true }); + + const logger = createLogger(); + + await checkAndSendIntakeRemindersForUser( + { + userId: 15, + language: "en", + stockCalculationMode: "manual", + emailEnabled: false, + notificationEmail: null, + emailIntakeReminders: false, + shoutrrrEnabled: true, + shoutrrrUrl: "ntfy://ntfy.sh/medassist", + shoutrrrIntakeReminders: true, + repeatRemindersEnabled: false, + } as never, + logger as never + ); + + expect(sendPushNotificationMock).toHaveBeenCalledWith( + "ntfy://ntfy.sh/medassist", + expect.any(String), + expect.any(String), + expect.objectContaining({ + actions: undefined, + respondUrl: undefined, + viewUrl: undefined, + clickUrl: undefined, + }) + ); + expect(logger.error).toHaveBeenCalledWith(expect.stringContaining("Notification action context failed")); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining("Sending intake reminders without actions after action context failure") + ); + expect(logger.info).toHaveBeenCalledWith(expect.stringContaining("Sending push reminder")); + expect(logger.info).toHaveBeenCalledWith(expect.stringContaining("Push delivered")); + }); + + it("logs enriched push delivery failures with action context metadata", async () => { + mockedEnv.PUBLIC_APP_URL = "https://app.example.com"; + + const selectMock = vi.mocked(mockedDb.select); + selectMock + .mockImplementationOnce(() => mockSelectWhere([{ username: "push-failure-user" }])) + .mockImplementationOnce(() => + mockSelectWhere([ + { + id: 7, + userId: 16, + name: "Calcium", + genericName: null, + takenByJson: null, + packageType: "blister", + medicationForm: "tablet", + packCount: 1, + blistersPerPack: 1, + pillsPerBlister: 10, + looseTablets: 0, + stockAdjustment: 0, + pillWeightMg: null, + doseUnit: "mg", + isObsolete: false, + intakeRemindersEnabled: true, + intakesJson: "[]", + usageJson: "[]", + everyJson: "[]", + startJson: "[]", + }, + ]) + ) + .mockImplementationOnce(() => mockSelectWhere([])); + + createNotificationActionContextMock.mockResolvedValue({ + groupId: 52, + actions: [ + { + kind: "taken", + label: "Taken", + url: "https://app.example.com/api/notification-actions/taken", + method: "POST", + }, + ], + respondUrl: "https://app.example.com/api/notification-actions/respond", + viewUrl: "https://app.example.com/?date=2026-01-05", + sequenceId: "medassist-sequence", + }); + sendPushNotificationMock.mockResolvedValue({ success: false, error: "HTTP 500: upstream down" }); + + const logger = createLogger(); + + await checkAndSendIntakeRemindersForUser( + { + userId: 16, + language: "en", + stockCalculationMode: "manual", + emailEnabled: false, + notificationEmail: null, + emailIntakeReminders: false, + shoutrrrEnabled: true, + shoutrrrUrl: "ntfy://ntfy.sh/medassist", + shoutrrrIntakeReminders: true, + repeatRemindersEnabled: false, + } as never, + logger as never + ); + + expect(logger.info).toHaveBeenCalledWith(expect.stringContaining("Notification action context ready")); + expect(logger.info).toHaveBeenCalledWith(expect.stringContaining("Sending push reminder")); + expect(logger.error).toHaveBeenCalledWith(expect.stringContaining("Push delivery failed")); + expect(logger.error).toHaveBeenCalledWith(expect.stringContaining("provider=ntfy")); + expect(logger.error).toHaveBeenCalledWith(expect.stringContaining("actionMode=full")); + expect(storeNotificationActionGroupNtfyMessageIdMock).not.toHaveBeenCalled(); + }); + + it("warns but keeps reminder flow alive when ntfy message id persistence fails", async () => { + mockedEnv.PUBLIC_APP_URL = "https://app.example.com"; + + const selectMock = vi.mocked(mockedDb.select); + selectMock + .mockImplementationOnce(() => mockSelectWhere([{ username: "persist-warning-user" }])) + .mockImplementationOnce(() => + mockSelectWhere([ + { + id: 7, + userId: 17, + name: "Calcium", + genericName: null, + takenByJson: null, + packageType: "blister", + medicationForm: "tablet", + packCount: 1, + blistersPerPack: 1, + pillsPerBlister: 10, + looseTablets: 0, + stockAdjustment: 0, + pillWeightMg: null, + doseUnit: "mg", + isObsolete: false, + intakeRemindersEnabled: true, + intakesJson: "[]", + usageJson: "[]", + everyJson: "[]", + startJson: "[]", + }, + ]) + ) + .mockImplementationOnce(() => mockSelectWhere([])); + + createNotificationActionContextMock.mockResolvedValue({ + groupId: 77, + actions: [ + { + kind: "taken", + label: "Taken", + url: "https://app.example.com/api/notification-actions/taken", + method: "POST", + }, + ], + respondUrl: "https://app.example.com/api/notification-actions/respond", + viewUrl: "https://app.example.com/?date=2026-01-05", + sequenceId: "medassist-sequence", + }); + sendPushNotificationMock.mockResolvedValue({ success: true, providerMessageId: "ntfy-msg-77" }); + storeNotificationActionGroupNtfyMessageIdMock.mockRejectedValue(new Error("db write failed")); + + const logger = createLogger(); + + await checkAndSendIntakeRemindersForUser( + { + userId: 17, + language: "en", + stockCalculationMode: "manual", + emailEnabled: false, + notificationEmail: null, + emailIntakeReminders: false, + shoutrrrEnabled: true, + shoutrrrUrl: "ntfy://ntfy.sh/medassist", + shoutrrrIntakeReminders: true, + repeatRemindersEnabled: false, + } as never, + logger as never + ); + + expect(storeNotificationActionGroupNtfyMessageIdMock).toHaveBeenCalledWith(77, "ntfy-msg-77"); + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining("Failed to store ntfy message id")); + expect(logger.info).toHaveBeenCalledWith(expect.stringContaining("Push delivered")); + }); + + it("does not send intake reminders for reminder-enabled medications with empty stock", async () => { + const selectMock = vi.mocked(mockedDb.select); + selectMock + .mockImplementationOnce(() => mockSelectWhere([{ username: "empty-stock-user" }])) + .mockImplementationOnce(() => + mockSelectWhere([ + { + id: 7, + userId: 14, + name: "Calcium", + genericName: null, + takenByJson: null, + packageType: "blister", + medicationForm: "tablet", + packCount: 0, + blistersPerPack: 1, + pillsPerBlister: 10, + looseTablets: 0, + stockAdjustment: 0, + pillWeightMg: null, + doseUnit: "mg", + isObsolete: false, + intakeRemindersEnabled: true, + intakesJson: "[]", + usageJson: "[]", + everyJson: "[]", + startJson: "[]", + }, + ]) + ) + .mockImplementationOnce(() => mockSelectWhere([])); + + const logger = createLogger(); + + await checkAndSendIntakeRemindersForUser( + { + userId: 14, + language: "en", + stockCalculationMode: "manual", + emailEnabled: false, + notificationEmail: null, + emailIntakeReminders: false, + shoutrrrEnabled: true, + shoutrrrUrl: "ntfy://ntfy.sh/medassist", + shoutrrrIntakeReminders: true, + repeatRemindersEnabled: false, + } as never, + logger as never + ); + + expect(createNotificationActionContextMock).not.toHaveBeenCalled(); + expect(sendPushNotificationMock).not.toHaveBeenCalled(); + expect(logger.info).toHaveBeenCalledWith( + expect.stringContaining("Skipping reminder-enabled medications with empty stock") + ); + expect(logger.info).toHaveBeenCalledWith( + expect.stringContaining("No reminder-eligible medications with stock remaining") + ); + }); +});