feat: wire interactive intake reminder actions
This commit is contained in:
@@ -12,6 +12,8 @@ import {
|
|||||||
type Language,
|
type Language,
|
||||||
t,
|
t,
|
||||||
} from "../i18n/translations.js";
|
} from "../i18n/translations.js";
|
||||||
|
|
||||||
|
import { env } from "../plugins/env.js";
|
||||||
import { getAllUserSettings, type UserSettings } from "../routes/settings.js";
|
import { getAllUserSettings, type UserSettings } from "../routes/settings.js";
|
||||||
import type { ServiceLogger } from "../utils/logger.js";
|
import type { ServiceLogger } from "../utils/logger.js";
|
||||||
// Import shared utilities
|
// Import shared utilities
|
||||||
@@ -29,6 +31,10 @@ import {
|
|||||||
type UpcomingIntake,
|
type UpcomingIntake,
|
||||||
} from "../utils/scheduler-utils.js";
|
} from "../utils/scheduler-utils.js";
|
||||||
import { computeMedicationCurrentStock } from "./current-stock.js";
|
import { computeMedicationCurrentStock } from "./current-stock.js";
|
||||||
|
import {
|
||||||
|
createNotificationActionContext,
|
||||||
|
storeNotificationActionGroupNtfyMessageId,
|
||||||
|
} from "./notification-actions-service.js";
|
||||||
import { getSmtpConfig, sendEmailNotification, sendPushNotification } from "./notifications/delivery.js";
|
import { getSmtpConfig, sendEmailNotification, sendPushNotification } from "./notifications/delivery.js";
|
||||||
import { updateReminderSentTime, updateUserReminderSentTime } from "./notifications/state.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}`;
|
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(
|
async function autoMarkDueIntakesAsTaken(
|
||||||
settings: UserSettings & { userId: number },
|
settings: UserSettings & { userId: number },
|
||||||
rows: (typeof medications.$inferSelect)[],
|
rows: (typeof medications.$inferSelect)[],
|
||||||
@@ -483,11 +514,42 @@ export async function checkAndSendIntakeRemindersForUser(
|
|||||||
return; // No medications have reminders enabled for this user
|
return; // No medications have reminders enabled for this user
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
const state = loadIntakeReminderState(logger);
|
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 })[] = [];
|
const allUpcoming: (UpcomingIntake & { medicationId: number; blisterIndex: number })[] = [];
|
||||||
let scheduledIntakesTodayCount = 0;
|
let scheduledIntakesTodayCount = 0;
|
||||||
// Get start and end of today in user's timezone (for filtering today's doses only)
|
// 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 }));
|
const todayStart = new Date(now.toLocaleString("en-US", { timeZone: tz }));
|
||||||
todayStart.setHours(0, 0, 0, 0);
|
todayStart.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
@@ -495,7 +557,7 @@ export async function checkAndSendIntakeRemindersForUser(
|
|||||||
todayEnd.setHours(23, 59, 59, 999);
|
todayEnd.setHours(23, 59, 59, 999);
|
||||||
|
|
||||||
// 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, intakes, intakesWithReminders } of reminderEntries) {
|
for (const { med, intakes, intakesWithReminders } of reminderEntriesEligible) {
|
||||||
// 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 = getMedicationDisplayName({ id: med.id, name: med.name, genericName: med.genericName });
|
const medDisplayName = getMedicationDisplayName({ id: med.id, name: med.name, genericName: med.genericName });
|
||||||
@@ -801,16 +863,96 @@ export async function checkAndSendIntakeRemindersForUser(
|
|||||||
.join("\n") +
|
.join("\n") +
|
||||||
repeatNote +
|
repeatNote +
|
||||||
`\n\n---\n${getFooterPlain(language)}`;
|
`\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;
|
shoutrrrSuccess = result.success;
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
logger.error(
|
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 {
|
} 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(
|
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