Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f48a20ad55 |
@@ -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<ReturnType<typeof createNotificationActionContext>> | 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<ReturnType<typeof createNotificationActionContext>> | 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 })})`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<typeof import("../utils/scheduler-utils.js")>("../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<T>(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")
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user