Compare commits

..

5 Commits

Author SHA1 Message Date
Daniel Volz f48a20ad55 feat: add ntfy scheduler interactive delivery 2026-05-10 19:01:37 +02:00
Daniel Volz 09ca3927bc feat: add ntfy interactive settings test delivery 2026-05-10 19:01:15 +02:00
Daniel Volz ae5aba29ad feat: add ntfy notification action context service 2026-05-10 19:01:13 +02:00
Daniel Volz de31ac7eb7 feat: add ntfy notification action renderer 2026-05-10 19:01:11 +02:00
Daniel Volz e2ed25059a feat: add ntfy notification action foundation 2026-05-10 19:00:57 +02:00
17 changed files with 1312 additions and 168 deletions
+1 -1
View File
@@ -155,6 +155,6 @@ EXPIRY_WARNING_DAYS=30 # Days before expiry to show yellow warning
# UI defaults
# DEFAULT_LANGUAGE=en # en or de
# DEFAULT_STOCK_CALCULATION_MODE=automatic # automatic or manual
# DEFAULT_SHARE_STOCK_STATUS=true # Show stock status on shared schedule links
# DEFAULT_SHARE_MEDICATION_OVERVIEW=false # Show medication overview section on shared schedule links
# DEFAULT_UPCOMING_TODAY_ONLY=false
# DEFAULT_SHARE_SCHEDULE_TODAY_ONLY=false
+1 -7
View File
@@ -1,17 +1,11 @@
# MedAssist-ng - Copilot Entry Point
## VERY IMPORTANT - Prioritized Constraints
## VERY IMPORTANT
**First: Update Memory and Reports**
- Always keep agent work memory updated in `doku/memory_notes.md` so progress and decisions remain recoverable across context loss.
- If `doku/memory_notes.md` is missing, create it immediately.
- Always keep a user-facing work report updated in `doku/report.md` so completed work is easy to review.
- If `doku/report.md` is missing, create it immediately.
- This memory/report rule replaces the previous `doku/APP_BEHAVIOR.md` persistence requirement.
**Second: Follow Governance Rules**
- Consult `AGENTS.md` for all governance, workflow, and skill rules.
Use `AGENTS.md` as the single source of truth for all governance, workflow, and skill rules.
## Required Startup Steps
+1 -15
View File
@@ -107,18 +107,4 @@ docs/SPEC_KIT.md
.github/skills/nodejs-backend-patterns/
.github/skills/nodejs-best-practices/
.github/skills/seo/
.playwright-mcp
# Local GSD/copilot generated workspace artifacts (not for upstream)
.github/agents/copilot-instructions.md
.github/agents/gsd-*.agent.md
.github/agents/medassist-feature-orchestrator.agent.md
.github/agents/speckit.*.agent.md
.github/get-shit-done/
.github/gsd-file-manifest.json
.github/prompts/speckit.*.prompt.md
.github/skills/gsd-*/
.planning/
doku/memory_notes.md
doku/report.md
ops/medtest/
.playwright-mcp
-8
View File
@@ -378,14 +378,6 @@ docker compose -p medassist-dev -f docker-compose.dev.yml up
- API docs UI: `http://localhost:3000/docs` (when docs are enabled)
- OpenAPI JSON: `http://localhost:3000/docs/json` (when docs are enabled)
If you run the frontend dev server behind a reverse proxy or on a remote host, you can optionally set these frontend-only environment variables before starting Vite:
- `VITE_ALLOWED_HOSTS`: comma-separated hostnames allowed to connect to the dev server; defaults to `localhost,127.0.0.1`
- `VITE_HMR_HOST`: public hostname used for HMR websocket connections
- `VITE_HMR_PROTOCOL`: optional websocket protocol override (`ws` or `wss`)
- `VITE_HMR_CLIENT_PORT`: optional public websocket port exposed to the browser
- `VITE_HMR_PORT`: optional server-side websocket port for the Vite process
Useful local commands:
```bash
@@ -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 })})`
);
}
}
@@ -117,7 +117,8 @@ export function buildNtfyActions(options: PushNotificationOptions): NtfyActionPa
label: action.label,
url: action.url,
method: "POST",
clear: false,
// Clear the original actionable ntfy notification locally after a successful mutation.
clear: true,
};
});
}
@@ -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")
);
});
});
@@ -0,0 +1,186 @@
import { describe, expect, it } from "vitest";
import {
getNotificationActionLabels,
isNtfyNotificationUrl,
type PushNotificationAction,
renderNotificationActionPayload,
} from "../services/notifications/action-renderer.js";
function decodeRfc2047Base64(value: string): string {
const match = /^=\?UTF-8\?B\?(.+)\?=$/.exec(value);
if (!match) {
return value;
}
return Buffer.from(match[1], "base64").toString("utf-8");
}
const actions: PushNotificationAction[] = [
{
kind: "taken",
label: "Take",
url: "https://app.example.com/api/notification-actions/taken-token",
method: "POST",
},
{
kind: "skip",
label: "Skip",
url: "https://app.example.com/api/notification-actions/skip-token",
method: "POST",
},
{ kind: "view", label: "View", url: "https://app.example.com/?date=2026-01-05", method: "GET" },
];
describe("notification action renderer", () => {
it("builds ntfy native actions without duplicate click headers", () => {
const result = renderNotificationActionPayload("ntfy://ntfy.sh/medassist", "Body", {
actions,
clickUrl: "https://app.example.com/api/notification-actions/respond-token",
respondUrl: "https://app.example.com/api/notification-actions/respond-token",
viewUrl: "https://app.example.com/?date=2026-01-05",
tags: ["pill"],
priority: 4,
sequenceId: "medassist-sequence",
});
expect(result.message).toBe("Body");
expect(result.headers).toMatchObject({
Tags: "pill",
Priority: "4",
"X-Sequence-ID": "medassist-sequence",
});
expect(result.headers.Click).toBeUndefined();
const parsedActions = JSON.parse(result.headers.Actions ?? "[]");
expect(parsedActions).toEqual([
{
action: "http",
label: "Take",
url: "https://app.example.com/api/notification-actions/taken-token",
method: "POST",
clear: true,
},
{
action: "http",
label: "Skip",
url: "https://app.example.com/api/notification-actions/skip-token",
method: "POST",
clear: true,
},
{
action: "view",
label: "View",
url: "https://app.example.com/?date=2026-01-05",
clear: false,
},
]);
});
it("keeps the ntfy click header when there are no native actions", () => {
const result = renderNotificationActionPayload("ntfy://ntfy.sh/medassist", "Body", {
clickUrl: "https://app.example.com/api/notification-actions/respond-token",
});
expect(result.headers.Click).toBe("https://app.example.com/api/notification-actions/respond-token");
});
it("treats direct https ntfy URLs as ntfy targets with native actions", () => {
const result = renderNotificationActionPayload("https://ntfy.danielvolz.org/medis_test", "Body", {
actions,
clickUrl: "https://app.example.com/api/notification-actions/respond-token",
respondUrl: "https://app.example.com/api/notification-actions/respond-token",
viewUrl: "https://app.example.com/?date=2026-01-05",
});
expect(isNtfyNotificationUrl("https://ntfy.danielvolz.org/medis_test")).toBe(true);
expect(result.message).toBe("Body");
expect(result.headers.Actions).toBeTruthy();
expect(result.message).not.toContain("Respond:");
});
it("keeps insecure http mutation targets as direct ntfy http actions without the dev fallback", () => {
const result = renderNotificationActionPayload("https://ntfy.danielvolz.org/medis_test", "Body", {
actions: [
{
kind: "taken",
label: "Take",
url: "http://192.168.0.113:5173/api/notification-actions/taken-token",
method: "POST",
},
],
});
expect(JSON.parse(result.headers.Actions ?? "[]")).toEqual([
{
action: "http",
label: "Take",
url: "http://192.168.0.113:5173/api/notification-actions/taken-token",
method: "POST",
clear: true,
},
]);
});
it("encodes non-ascii ntfy action labels as RFC 2047 headers", () => {
const result = renderNotificationActionPayload("https://ntfy.danielvolz.org/medis_test", "Body", {
actions: [
{
kind: "skip",
label: "Überspringen",
url: "https://app.example.com/api/notification-actions/skip-token",
method: "POST",
},
{
kind: "view",
label: "Öffnen",
url: "https://app.example.com/?date=2026-01-05",
method: "GET",
},
],
});
expect(result.headers.Actions).toMatch(/^=\?UTF-8\?B\?/);
expect(JSON.parse(decodeRfc2047Base64(result.headers.Actions ?? "[]"))).toEqual([
{
action: "http",
label: "Überspringen",
url: "https://app.example.com/api/notification-actions/skip-token",
method: "POST",
clear: true,
},
{
action: "view",
label: "Öffnen",
url: "https://app.example.com/?date=2026-01-05",
clear: false,
},
]);
});
it("uses consistent action-form labels for English and German", () => {
expect(getNotificationActionLabels("en")).toEqual({
taken: "Take",
skip: "Skip",
respond: "Respond",
view: "View",
});
expect(getNotificationActionLabels("de")).toEqual({
taken: "Einnehmen",
skip: "Überspringen",
respond: "Antworten",
view: "Öffnen",
});
});
it("appends respond and view fallback links for non-ntfy providers", () => {
const result = renderNotificationActionPayload("https://hooks.slack.com/services/a/b/c", "Body", {
respondUrl: "https://app.example.com/api/notification-actions/respond-token",
viewUrl: "https://app.example.com/?date=2026-01-05",
});
expect(result.headers).toEqual({});
expect(result.message).toBe(
"Body\n\nRespond:\nhttps://app.example.com/api/notification-actions/respond-token\n\nView:\nhttps://app.example.com/?date=2026-01-05"
);
});
});
@@ -0,0 +1,225 @@
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { migrate } from "drizzle-orm/libsql/migrator";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { runAlterMigrations } from "../db/db-utils.js";
const { testClient, testDb, mockedEnv } = vi.hoisted(() => {
const { createClient } = require("@libsql/client");
const { drizzle } = require("drizzle-orm/libsql");
const client = createClient({ url: ":memory:" });
const db = drizzle(client);
return {
testClient: client,
testDb: db,
mockedEnv: {
PUBLIC_APP_URL: "https://app.example.com",
CORS_ORIGINS: "http://localhost:5173,http://localhost:4173",
},
};
});
vi.mock("../db/client.js", () => ({
db: testDb,
migrationsReady: Promise.resolve(),
}));
vi.mock("../plugins/env.js", () => ({ env: mockedEnv }));
const { createNotificationActionContext, getNotificationActionTokenRecord, hashActionToken } = await import(
"../services/notification-actions-service.js"
);
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const migrationsFolder = resolve(__dirname, "../../drizzle");
function extractToken(url: string): string {
return url.split("/").at(-1) ?? "";
}
async function clearTables() {
await testClient.execute("DELETE FROM notification_action_tokens");
await testClient.execute("DELETE FROM notification_action_groups");
await testClient.execute("DELETE FROM users");
}
async function createUser(username: string) {
const result = await testClient.execute({
sql: "INSERT INTO users (username, auth_provider, is_active) VALUES (?, 'local', 1) RETURNING id",
args: [username],
});
return Number(result.rows[0].id);
}
describe("notification-actions-service", () => {
beforeAll(async () => {
await migrate(testDb, { migrationsFolder });
await runAlterMigrations(testClient);
});
afterAll(() => {
testClient.close();
});
beforeEach(async () => {
await clearTables();
mockedEnv.PUBLIC_APP_URL = "https://app.example.com";
mockedEnv.CORS_ORIGINS = "http://localhost:5173,http://localhost:4173";
});
it("creates a notification action group with hashed tokens and app/view links", async () => {
const userId = await createUser("notify-actions-user");
const scheduledFor = new Date("2026-01-05T08:00:00.000Z");
const context = await createNotificationActionContext({
userId,
title: "Reminder",
message: "Take your medication now",
doseIds: ["9-1-1736064000000", "9-0-1736064000000", "9-1-1736064000000"],
scheduledFor,
publicAppUrl: mockedEnv.PUBLIC_APP_URL,
language: "en",
});
expect(context).toMatchObject({
respondUrl: expect.stringContaining("/api/notification-actions/"),
viewUrl: "https://app.example.com/dashboard?day=2026-01-05&dose=9-0-1736064000000",
sequenceId: expect.stringMatching(/^medassist-/),
});
expect(context?.actions.map((action) => action.kind)).toEqual(["taken", "skip", "view"]);
const groups = await testClient.execute({
sql: "SELECT COUNT(*) AS count FROM notification_action_groups WHERE user_id = ?",
args: [userId],
});
expect(Number(groups.rows[0].count)).toBe(1);
const tokenRows = await testClient.execute({
sql: "SELECT kind, token_hash FROM notification_action_tokens ORDER BY kind ASC",
});
expect(tokenRows.rows).toHaveLength(3);
const respondToken = extractToken(context!.respondUrl!);
const respondRow = tokenRows.rows.find((row: { kind?: unknown }) => row.kind === "respond");
expect(respondRow).toEqual(expect.objectContaining({ token_hash: hashActionToken(respondToken), kind: "respond" }));
expect(respondRow?.token_hash).not.toBe(respondToken);
const record = await getNotificationActionTokenRecord(respondToken);
expect(record).toMatchObject({
doseIds: ["9-0-1736064000000", "9-1-1736064000000"],
viewUrl: "https://app.example.com/dashboard?day=2026-01-05&dose=9-0-1736064000000",
});
});
it("creates a view-only context without mutation tokens", async () => {
const userId = await createUser("notify-actions-view-only");
const scheduledFor = new Date("2026-01-05T08:00:00.000Z");
const context = await createNotificationActionContext({
userId,
title: "Grouped reminder",
message: "Open the dashboard for details",
doseIds: ["9-0-1736064000000", "10-0-1736064000000"],
scheduledFor,
publicAppUrl: mockedEnv.PUBLIC_APP_URL,
language: "en",
actionMode: "view-only",
});
expect(context).toEqual({
viewUrl: "https://app.example.com/dashboard?day=2026-01-05&dose=10-0-1736064000000",
actions: [
{
kind: "view",
label: "View",
url: "https://app.example.com/dashboard?day=2026-01-05&dose=10-0-1736064000000",
method: "GET",
},
],
});
const groups = await testClient.execute("SELECT COUNT(*) AS count FROM notification_action_groups");
expect(Number(groups.rows[0].count)).toBe(0);
const tokens = await testClient.execute("SELECT COUNT(*) AS count FROM notification_action_tokens");
expect(Number(tokens.rows[0].count)).toBe(0);
});
it("reuses an unresolved active group for the same dose set and schedule", async () => {
const userId = await createUser("notify-actions-reuse");
const scheduledFor = new Date("2026-01-05T08:00:00.000Z");
const first = await createNotificationActionContext({
userId,
title: "Reminder",
message: "Take your medication now",
doseIds: ["9-0-1736064000000"],
scheduledFor,
publicAppUrl: mockedEnv.PUBLIC_APP_URL,
language: "en",
});
const second = await createNotificationActionContext({
userId,
title: "Reminder",
message: "Take your medication now",
doseIds: ["9-0-1736064000000"],
scheduledFor,
publicAppUrl: mockedEnv.PUBLIC_APP_URL,
language: "en",
});
expect(second?.sequenceId).toBe(first?.sequenceId);
const groups = await testClient.execute("SELECT id, sequence_id FROM notification_action_groups");
expect(groups.rows).toHaveLength(1);
expect(groups.rows[0]).toEqual(expect.objectContaining({ sequence_id: first?.sequenceId }));
const tokens = await testClient.execute("SELECT COUNT(*) AS count FROM notification_action_tokens");
expect(Number(tokens.rows[0].count)).toBe(6);
});
it("prefers a non-local CORS origin when PUBLIC_APP_URL points to localhost", async () => {
const userId = await createUser("notify-actions-mobile");
const scheduledFor = new Date("2026-01-05T08:00:00.000Z");
mockedEnv.PUBLIC_APP_URL = "http://localhost:5173";
mockedEnv.CORS_ORIGINS = "http://localhost:5173,http://192.168.0.113:5173";
const context = await createNotificationActionContext({
userId,
title: "Reminder",
message: "Take your medication now",
doseIds: ["9-0-1736064000000"],
scheduledFor,
publicAppUrl: mockedEnv.PUBLIC_APP_URL,
language: "en",
});
expect(context).toMatchObject({
respondUrl: `http://192.168.0.113:5173/api/notification-actions/${extractToken(context!.respondUrl!)}`,
viewUrl: "http://192.168.0.113:5173/dashboard?day=2026-01-05&dose=9-0-1736064000000",
});
const record = await getNotificationActionTokenRecord(extractToken(context!.respondUrl!));
expect(record?.viewUrl).toBe("http://192.168.0.113:5173/dashboard?day=2026-01-05&dose=9-0-1736064000000");
});
it("falls back to the date view when dose ids do not contain a medication id", async () => {
const userId = await createUser("notify-actions-fallback");
const scheduledFor = new Date("2026-01-05T08:00:00.000Z");
const context = await createNotificationActionContext({
userId,
title: "Reminder",
message: "Take your medication now",
doseIds: ["invalid-dose-id"],
scheduledFor,
publicAppUrl: mockedEnv.PUBLIC_APP_URL,
language: "en",
});
expect(context?.viewUrl).toBe("https://app.example.com/dashboard?day=2026-01-05&dose=invalid-dose-id");
});
});
+2 -1
View File
@@ -6,6 +6,7 @@ Scope and behavior:
- These values are applied only when a user's settings are created for the first time.
- After that, values stored in the database are used and take precedence.
- Source of truth in code: [backend/src/routes/settings.ts](backend/src/routes/settings.ts).
## Email Defaults
@@ -46,6 +47,6 @@ Scope and behavior:
|----------|---------|-------------|
| `DEFAULT_LANGUAGE` | `en` | Default language (`en` or `de`). |
| `DEFAULT_STOCK_CALCULATION_MODE` | `automatic` | Default stock mode (`automatic` or `manual`). |
| `DEFAULT_SHARE_MEDICATION_OVERVIEW` | `false` | Show medication overview section on shared schedule links. |
| `DEFAULT_SHARE_STOCK_STATUS` | `true` | Show stock status on shared schedule links. |
| `DEFAULT_UPCOMING_TODAY_ONLY` | `false` | Show only today's upcoming doses by default. |
| `DEFAULT_SHARE_SCHEDULE_TODAY_ONLY` | `false` | Show only today's schedule in shared view by default. |
+1 -1
View File
@@ -506,7 +506,7 @@ function AppContent() {
<AboutModal isOpen={showAbout} onClose={closeAbout} />
<Routes>
<Route path="/" element={<Navigate to={{ pathname: "/dashboard", search: location.search }} replace />} />
<Route path="/" element={<Navigate to="/dashboard" replace />} />
<Route path="/dashboard" element={<DashboardPage />} />
<Route path="/medications" element={<MedicationsPage />} />
+6 -2
View File
@@ -235,6 +235,10 @@ export function SharedSchedule() {
}
async function markDoseTaken(doseId: string) {
if (dismissedDoses.has(doseId)) {
return;
}
const wasTaken = takenDoses.has(doseId);
const wasSkipped = dismissedDoses.has(doseId);
const wasAutomatic = automaticTakenDoses.has(doseId);
@@ -462,7 +466,7 @@ export function SharedSchedule() {
<button
className={`dose-btn take${options.isEmpty ? " out-of-stock" : ""}`}
onClick={() => markDoseTaken(options.doseId)}
disabled={options.isEmpty}
disabled={options.isEmpty || options.isSkipped}
title={options.isEmpty ? t("common.outOfStockTakeBlocked") : t("dose.markAsTaken")}
>
<span className="dose-btn-label">{t("dose.take")}</span>
@@ -472,7 +476,7 @@ export function SharedSchedule() {
const skipButton = options.isSkipped ? (
<button className="dose-btn undo skip" onClick={() => undoDoseSkipped(options.doseId)} title={t("common.undo")}>
<span className="dose-btn-label">{t("dose.undoSkip")}</span>
<span className="dose-btn-label">{t("common.undo")}</span>
<span aria-hidden="true"></span>
</button>
) : (
+23 -52
View File
@@ -257,10 +257,8 @@ export function MedicationsPage() {
useUnsavedChangesWarning(formChanged);
// View mode: grid (default) or form (edit/new)
// If navigating in with a medication deep-link, suppress rendering until the target form is ready
const [pendingEditTransition, setPendingEditTransition] = useState(
() => searchParams.has("editMedId") || searchParams.has("viewMedId")
);
// If navigating in with editMedId, suppress rendering until the edit form is ready
const [pendingEditTransition, setPendingEditTransition] = useState(() => searchParams.has("editMedId"));
const [viewMode, setViewMode] = useState<"grid" | "form">(pendingEditTransition ? "form" : "grid");
const [lightboxImage, setLightboxImage] = useState<{ src: string; alt: string } | null>(null);
const [activeTab, setActiveTab] = useState<"general" | "stock" | "prescription" | "schedule">("general");
@@ -271,23 +269,9 @@ export function MedicationsPage() {
useEffect(() => {
showEditModalRef.current = showEditModal;
}, [showEditModal]);
const processedMedicationLinkRef = useRef<string | null>(null);
const processedEditMedIdRef = useRef<string | null>(null);
const hasDesktopFormHistoryState = useRef(false);
const getMedicationLinkState = useCallback((params: URLSearchParams) => {
const viewMedId = params.get("viewMedId");
if (viewMedId) {
return { mode: "view" as const, linkedMedId: viewMedId };
}
const editMedId = params.get("editMedId");
if (editMedId) {
return { mode: "edit" as const, linkedMedId: editMedId };
}
return { mode: null, linkedMedId: null };
}, []);
// Sync formChanged state to the global context for navigation blocking
const { setHasUnsavedChanges } = useUnsavedChanges();
useEffect(() => {
@@ -835,13 +819,12 @@ export function MedicationsPage() {
[t]
);
const clearMedicationLinkParams = useCallback(() => {
const clearEditMedIdParam = useCallback(() => {
setSearchParams(
(prevParams) => {
if (!prevParams.has("editMedId") && !prevParams.has("viewMedId")) return prevParams;
if (!prevParams.has("editMedId")) return prevParams;
const nextParams = new URLSearchParams(prevParams);
nextParams.delete("editMedId");
nextParams.delete("viewMedId");
return nextParams;
},
{ replace: true }
@@ -865,7 +848,7 @@ export function MedicationsPage() {
setShowUnsavedConfirm(true);
return;
}
clearMedicationLinkParams();
clearEditMedIdParam();
// Mark as confirmed to avoid double confirmation in popstate handler
closeConfirmedRef.current = true;
window.history.back();
@@ -1176,7 +1159,7 @@ export function MedicationsPage() {
if (shouldCloseMobileModal) {
// Treat post-save close as confirmed so popstate does not trigger unsaved guards.
closeConfirmedRef.current = true;
clearMedicationLinkParams();
clearEditMedIdParam();
setShowEditModal(false);
setReadOnlyView(false);
setActiveTab("general");
@@ -1205,8 +1188,7 @@ export function MedicationsPage() {
// Handle browser back button for modals and unsaved changes
useEffect(() => {
const handlePopState = () => {
const currentParams = new URLSearchParams(window.location.search);
const { mode: currentLinkMode, linkedMedId: currentMedicationLinkId } = getMedicationLinkState(currentParams);
const currentEditMedId = new URLSearchParams(window.location.search).get("editMedId");
// Obsolete confirmation is open — dismiss it and stay where we are
if (showObsoleteConfirm) {
@@ -1225,10 +1207,10 @@ export function MedicationsPage() {
// If close was already confirmed programmatically, allow navigation
if (closeConfirmedRef.current) {
closeConfirmedRef.current = false;
if (currentMedicationLinkId && currentLinkMode) {
if (currentEditMedId) {
// Prevent URL popstate from immediately reopening mobile edit for the same id.
processedMedicationLinkRef.current = `${currentLinkMode}:${currentMedicationLinkId}`;
clearMedicationLinkParams();
processedEditMedIdRef.current = currentEditMedId;
clearEditMedIdParam();
}
if (showEditModal) {
setShowEditModal(false);
@@ -1249,11 +1231,11 @@ export function MedicationsPage() {
setShowUnsavedConfirm(true);
return;
}
if (currentMedicationLinkId && currentLinkMode) {
if (currentEditMedId) {
// Mark as handled before URL cleanup to avoid same-tick re-open races.
processedMedicationLinkRef.current = `${currentLinkMode}:${currentMedicationLinkId}`;
processedEditMedIdRef.current = currentEditMedId;
}
clearMedicationLinkParams();
clearEditMedIdParam();
setShowEditModal(false);
resetForm();
resetMedicationEnrichment();
@@ -1289,16 +1271,7 @@ export function MedicationsPage() {
};
window.addEventListener("popstate", handlePopState);
return () => window.removeEventListener("popstate", handlePopState);
}, [
showObsoleteConfirm,
showDeleteConfirm,
showEditModal,
viewMode,
formChanged,
resetForm,
clearMedicationLinkParams,
getMedicationLinkState,
]);
}, [showObsoleteConfirm, showDeleteConfirm, showEditModal, viewMode, formChanged, resetForm, clearEditMedIdParam]);
// Close modal on Escape key
useEffect(() => {
@@ -1416,23 +1389,22 @@ export function MedicationsPage() {
}, [activeMeds, editingId]);
useEffect(() => {
const { mode: linkMode, linkedMedId } = getMedicationLinkState(searchParams);
if (!linkedMedId || !linkMode) {
processedMedicationLinkRef.current = null;
const editMedId = searchParams.get("editMedId");
if (!editMedId) {
processedEditMedIdRef.current = null;
return;
}
const linkKey = `${linkMode}:${linkedMedId}`;
if (processedMedicationLinkRef.current === linkKey) return;
const parsedMedId = Number.parseInt(linkedMedId, 10);
if (processedEditMedIdRef.current === editMedId) return;
const parsedMedId = Number.parseInt(editMedId, 10);
if (Number.isNaN(parsedMedId)) return;
const medicationToEdit =
meds.find((med) => med.id === parsedMedId) ?? allMeds.find((med) => med.id === parsedMedId);
if (!medicationToEdit) return;
processedMedicationLinkRef.current = linkKey;
processedEditMedIdRef.current = editMedId;
setShowNameValidation(false);
setReadOnlyView(linkMode === "view");
setReadOnlyView(false);
setActiveTab("general");
resetMedicationEnrichment(medicationToEdit.name || medicationToEdit.genericName || "");
startEdit(medicationToEdit, openEditModal);
@@ -1443,9 +1415,8 @@ export function MedicationsPage() {
const nextParams = new URLSearchParams(searchParams);
nextParams.delete("editMedId");
nextParams.delete("viewMedId");
setSearchParams(nextParams, { replace: true });
}, [allMeds, getMedicationLinkState, meds, openEditModal, searchParams, setSearchParams, startEdit]);
}, [allMeds, meds, openEditModal, searchParams, setSearchParams, startEdit]);
const selectedMedication = useMemo(() => {
if (!editingId) return null;
+2 -23
View File
@@ -1,5 +1,5 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { MemoryRouter, useLocation } from "react-router-dom";
import { MemoryRouter } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import App from "../App";
@@ -59,15 +59,7 @@ vi.mock("../context", async () => {
});
vi.mock("../pages", () => ({
DashboardPage: () => {
const location = useLocation();
return (
<div>
<span>dashboard-page</span>
<span data-testid="dashboard-location-search">{location.search}</span>
</div>
);
},
DashboardPage: () => <div>dashboard-page</div>,
MedicationsPage: () => <div>medications-page</div>,
PlannerPage: () => <div>planner-page</div>,
SchedulePage: () => <div>schedule-page</div>,
@@ -273,19 +265,6 @@ describe("App", () => {
expect(screen.getByText("dashboard-page")).toBeInTheDocument();
});
it("preserves notification query params when redirecting root to dashboard", () => {
const search = "?date=2026-05-06&medId=4332&doseId=4332-0-1778104500000";
render(
<MemoryRouter initialEntries={[`/${search}`]}>
<App />
</MemoryRouter>
);
expect(screen.getByText("dashboard-page")).toBeInTheDocument();
expect(screen.getByTestId("dashboard-location-search")).toHaveTextContent(search);
});
it("renders initializing state when auth state is missing", () => {
authMock = {
user: null,
@@ -175,10 +175,6 @@ describe("LoginForm", () => {
oidcProviderName: "",
};
afterEach(() => {
window.history.replaceState({}, "", "/");
});
beforeEach(() => {
vi.clearAllMocks();
(global.fetch as ReturnType<typeof vi.fn>)
@@ -475,21 +475,6 @@ describe("MedicationsPage with items", () => {
});
});
it("opens read-only view from viewMedId query parameter", async () => {
const startEdit = vi.fn();
mockFormHookValue = createMockFormHook({ startEdit });
fetchMock.mockResolvedValue({ ok: true, json: async () => mockMeds });
renderPage("/medications?viewMedId=1");
await waitFor(() => {
expect(startEdit).toHaveBeenCalledTimes(1);
});
expect(screen.getByText("common.close")).toBeInTheDocument();
expect(screen.queryByText("common.save")).not.toBeInTheDocument();
});
it("opens unsaved confirm and continues edit after confirmation", async () => {
const startEdit = vi.fn();
const resetForm = vi.fn();
-33
View File
@@ -2,24 +2,6 @@ import { existsSync, readFileSync } from "fs";
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
function parseCsvEnv(value: string | undefined, fallback: string[]) {
const entries = value
?.split(",")
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
return entries && entries.length > 0 ? entries : fallback;
}
function parseOptionalPort(value: string | undefined) {
if (!value) {
return undefined;
}
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) ? parsed : undefined;
}
// Read version from package.json at build time
const packageJson = JSON.parse(readFileSync("./package.json", "utf-8"));
@@ -27,19 +9,6 @@ const packageJson = JSON.parse(readFileSync("./package.json", "utf-8"));
// In Docker, prefer backend-dev to avoid localhost proxy failures.
const defaultBackendTarget = existsSync("/.dockerenv") ? "http://backend-dev:3000" : "http://localhost:3000";
const backendTarget = process.env.BACKEND_URL || defaultBackendTarget;
const allowedHosts = parseCsvEnv(process.env.VITE_ALLOWED_HOSTS, ["localhost", "127.0.0.1"]);
const hmrHost = process.env.VITE_HMR_HOST?.trim();
const hmrProtocol = process.env.VITE_HMR_PROTOCOL === "ws" ? "ws" : process.env.VITE_HMR_PROTOCOL === "wss" ? "wss" : undefined;
const hmrClientPort = parseOptionalPort(process.env.VITE_HMR_CLIENT_PORT);
const hmrPort = parseOptionalPort(process.env.VITE_HMR_PORT);
const hmr = hmrHost
? {
host: hmrHost,
protocol: hmrProtocol ?? "wss",
clientPort: hmrClientPort ?? (hmrProtocol === "ws" ? 80 : 443),
port: hmrPort ?? 5173,
}
: undefined;
export default defineConfig({
plugins: [react()],
@@ -50,8 +19,6 @@ export default defineConfig({
server: {
port: 5173,
strictPort: true,
allowedHosts,
hmr,
proxy: {
"/api": {
target: backendTarget,