From 328f7320663ef80c8e5bb8cf0bd9852151a22f8a Mon Sep 17 00:00:00 2001 From: Daniel Volz Date: Mon, 11 May 2026 09:24:29 +0200 Subject: [PATCH] fix: replace ntfy reminder with action confirmation * fix: replace ntfy reminder with action confirmation * fix: correct notification actions branch payload * fix: format notification actions follow-up --- backend/src/i18n/translations.ts | 6 ++ backend/src/routes/notification-actions.ts | 99 +++++++++++++++++-- .../test/notification-actions-route.test.ts | 46 ++++++--- 3 files changed, 131 insertions(+), 20 deletions(-) diff --git a/backend/src/i18n/translations.ts b/backend/src/i18n/translations.ts index 7dfcb43..8f15afb 100644 --- a/backend/src/i18n/translations.ts +++ b/backend/src/i18n/translations.ts @@ -109,6 +109,8 @@ type TranslationKeys = { stockTitle: string; stockTitleMultiple: string; intakeTitle: string; + intakeTakenConfirmation: string; + intakeSkippedConfirmation: string; pillsLeft: string; daysLeft: string; pillsAt: string; @@ -234,6 +236,8 @@ const translations: Record = { stockTitle: "MedAssist-ng: 1 Medication Running Critically Low", stockTitleMultiple: "MedAssist-ng: {count} Medications Running Critically Low", intakeTitle: "💊 Reminder: Medication intake in {minutes} min", + intakeTakenConfirmation: "✅ This dose was marked as taken.", + intakeSkippedConfirmation: "⏭️ This intake was marked as skipped.", pillsLeft: "{count} pills", daysLeft: "{count} days left", pillsAt: "{count} pills at {time}", @@ -355,6 +359,8 @@ const translations: Record = { stockTitle: "MedAssist-ng: 1 Medikament kritisch niedrig", stockTitleMultiple: "MedAssist-ng: {count} Medikamente kritisch niedrig", intakeTitle: "💊 Erinnerung: Medikamenteneinnahme in {minutes} Min.", + intakeTakenConfirmation: "✅ Diese Einnahme wurde als genommen markiert.", + intakeSkippedConfirmation: "⏭️ Diese Einnahme wurde als übersprungen markiert.", pillsLeft: "{count} Tabletten", daysLeft: "{count} Tage übrig", pillsAt: "{count} Tabletten um {time}", diff --git a/backend/src/routes/notification-actions.ts b/backend/src/routes/notification-actions.ts index 2967daf..6712ba6 100644 --- a/backend/src/routes/notification-actions.ts +++ b/backend/src/routes/notification-actions.ts @@ -4,13 +4,14 @@ import type { FastifyInstance, FastifyRequest } from "fastify"; import { z } from "zod"; import { db } from "../db/client.js"; import { notificationActionGroups, notificationActionTokens, userSettings } from "../db/schema.js"; -import type { Language } from "../i18n/translations.js"; +import { getTranslations, type Language } from "../i18n/translations.js"; import { markDoseTakenForUser, skipDosesForUser } from "../services/dose-tracking-service.js"; import { getNotificationActionTokenRecord, isNotificationActionExpired, } from "../services/notification-actions-service.js"; import { getNotificationActionLabels } from "../services/notifications/action-renderer.js"; +import { sendPushNotification } from "../services/notifications/delivery.js"; import { sanitizeNotificationUrl } from "../services/settings-service.js"; import { applyOpenApiRouteStandards, genericErrorSchema } from "../utils/openapi-route-standards.js"; @@ -33,6 +34,7 @@ function normalizeNotificationAction(action: string | null | undefined): Notific } const publicNotificationActionMethods = "GET,HEAD,POST,OPTIONS"; +const reminderFooterSeparator = "\n\n---\n"; function escapeHtml(value: string): string { return value @@ -110,6 +112,24 @@ function getActionRecordedText(language: Language, action: NotificationMutationA }; } +function buildReplacementReminderMessage( + language: Language, + action: NotificationMutationAction, + originalMessage: string +): string { + const tr = getTranslations(language); + const confirmationLine = action === "taken" ? tr.push.intakeTakenConfirmation : tr.push.intakeSkippedConfirmation; + const separatorIndex = originalMessage.indexOf(reminderFooterSeparator); + + if (separatorIndex >= 0) { + const beforeFooter = originalMessage.slice(0, separatorIndex).trimEnd(); + const footer = originalMessage.slice(separatorIndex); + return `${beforeFooter}\n\n${confirmationLine}${footer}`; + } + + return `${originalMessage.trimEnd()}\n\n${confirmationLine}`; +} + async function clearNtfyNotificationSequence(userId: number, sequenceId: string): Promise { const [settings] = await db .select({ shoutrrrEnabled: userSettings.shoutrrrEnabled, shoutrrrUrl: userSettings.shoutrrrUrl }) @@ -185,6 +205,51 @@ async function deleteNtfyNotificationSequence(userId: number, sequenceId: string } } +async function replaceNtfyNotificationSequence(options: { + userId: number; + sequenceId: string; + language: Language; + title: string; + originalMessage: string; + action: NotificationMutationAction; + viewUrl: string | null; +}): Promise { + const normalizedSequenceId = options.sequenceId.trim(); + if (normalizedSequenceId.length === 0) { + return false; + } + + const [settings] = await db + .select({ shoutrrrEnabled: userSettings.shoutrrrEnabled, shoutrrrUrl: userSettings.shoutrrrUrl }) + .from(userSettings) + .where(eq(userSettings.userId, options.userId)); + + if (!settings?.shoutrrrEnabled || !settings.shoutrrrUrl) { + return false; + } + + const sanitized = sanitizeNotificationUrl(settings.shoutrrrUrl); + if ("error" in sanitized || !sanitized.isNtfy) { + return false; + } + + const labels = getNotificationActionLabels(options.language); + const replacementMessage = buildReplacementReminderMessage(options.language, options.action, options.originalMessage); + const result = await sendPushNotification(settings.shoutrrrUrl, options.title, replacementMessage, { + actions: options.viewUrl ? [{ kind: "view", label: labels.view, url: options.viewUrl, method: "GET" }] : undefined, + viewUrl: options.viewUrl ?? undefined, + clickUrl: options.viewUrl ?? undefined, + sequenceId: normalizedSequenceId, + tags: ["pill"], + }); + + if (!result.success) { + throw new Error(result.error ?? "Failed to replace ntfy notification"); + } + + return true; +} + function renderPage(options: { language: Language; title: string; @@ -519,21 +584,41 @@ export async function notificationActionRoutes(app: FastifyInstance) { ); const recordedText = getActionRecordedText(language, action); + let replacedNtfyNotification = false; try { - await deleteNtfyNotificationSequence(record.group.userId, record.group.sequenceId); + replacedNtfyNotification = await replaceNtfyNotificationSequence({ + userId: record.group.userId, + sequenceId: record.group.sequenceId, + language, + title: record.group.title, + originalMessage: record.group.message, + action, + viewUrl: record.viewUrl, + }); } catch (error) { request.log.warn( buildNotificationActionLogContext(record, { requestedAction: action, error }), - "[NotificationActions] Failed to delete ntfy notification after resolved action" + "[NotificationActions] Failed to replace ntfy notification after resolved action" ); + } + + if (!replacedNtfyNotification) { try { - await clearNtfyNotificationSequence(record.group.userId, record.group.sequenceId); - } catch (clearError) { + await deleteNtfyNotificationSequence(record.group.userId, record.group.sequenceId); + } catch (error) { request.log.warn( - buildNotificationActionLogContext(record, { requestedAction: action, error: clearError }), - "[NotificationActions] Failed to clear ntfy notification after delete fallback" + buildNotificationActionLogContext(record, { requestedAction: action, error }), + "[NotificationActions] Failed to delete ntfy notification after resolved action" ); + try { + await clearNtfyNotificationSequence(record.group.userId, record.group.sequenceId); + } catch (clearError) { + request.log.warn( + buildNotificationActionLogContext(record, { requestedAction: action, error: clearError }), + "[NotificationActions] Failed to clear ntfy notification after delete fallback" + ); + } } } diff --git a/backend/src/test/notification-actions-route.test.ts b/backend/src/test/notification-actions-route.test.ts index f5dcb76..10020a4 100644 --- a/backend/src/test/notification-actions-route.test.ts +++ b/backend/src/test/notification-actions-route.test.ts @@ -342,7 +342,7 @@ describe("notification action routes", () => { }); }); - it("deletes the original ntfy notification after a successful action", async () => { + it("replaces the original ntfy notification after a successful action with a view-only confirmation", async () => { const userId = await createUser("notification-route-ntfy-delete"); await insertMedication({ id: 5, userId, packCount: 1, looseTablets: 0 }); await insertUserSettings(userId, "automatic", { @@ -350,7 +350,7 @@ describe("notification action routes", () => { shoutrrrUrl: "ntfy://user:pass@ntfy.example.com/medassist", }); const { takenToken, context } = await seedContext({ userId, doseId: "5-0-1736064000000" }); - fetchMock.mockResolvedValueOnce({ ok: true, text: () => Promise.resolve("") }); + fetchMock.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ id: "ntfy-msg-2" }) }); const response = await app.inject({ method: "POST", @@ -361,19 +361,31 @@ describe("notification action routes", () => { expect(fetchMock).toHaveBeenCalledTimes(1); const [targetUrl, requestInit] = fetchMock.mock.calls[0] ?? []; - expect(targetUrl).toBe(`https://ntfy.example.com/medassist/${context.sequenceId}`); + expect(targetUrl).toBe("https://ntfy.example.com/medassist"); expect(requestInit).toEqual( expect.objectContaining({ - method: "DELETE", + method: "POST", + body: "Take your medication now\n\n✅ This dose was marked as taken.", redirect: "error", headers: expect.objectContaining({ Authorization: expect.stringMatching(/^Basic /), + "X-Sequence-ID": context.sequenceId, }), }) ); + + const actionHeader = String((requestInit as { headers?: Record }).headers?.Actions ?? "[]"); + expect(JSON.parse(actionHeader)).toEqual([ + { + action: "view", + label: "View", + url: context.viewUrl, + clear: false, + }, + ]); }); - it("deletes the original ntfy notification after a skip action", async () => { + it("replaces the original ntfy notification after a skip action with a view-only confirmation", async () => { const userId = await createUser("notification-route-ntfy-skip-delete"); await insertMedication({ id: 5, userId, packCount: 1, looseTablets: 0 }); await insertUserSettings(userId, "automatic", { @@ -381,7 +393,7 @@ describe("notification action routes", () => { shoutrrrUrl: "ntfy://user:pass@ntfy.example.com/medassist", }); const { skipToken, context } = await seedContext({ userId, doseId: "5-0-1736064000000" }); - fetchMock.mockResolvedValueOnce({ ok: true, text: () => Promise.resolve("") }); + fetchMock.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ id: "ntfy-msg-3" }) }); const response = await app.inject({ method: "POST", @@ -392,19 +404,21 @@ describe("notification action routes", () => { expect(fetchMock).toHaveBeenCalledTimes(1); const [targetUrl, requestInit] = fetchMock.mock.calls[0] ?? []; - expect(targetUrl).toBe(`https://ntfy.example.com/medassist/${context.sequenceId}`); + expect(targetUrl).toBe("https://ntfy.example.com/medassist"); expect(requestInit).toEqual( expect.objectContaining({ - method: "DELETE", + method: "POST", + body: "Take your medication now\n\n⏭️ This intake was marked as skipped.", redirect: "error", headers: expect.objectContaining({ Authorization: expect.stringMatching(/^Basic /), + "X-Sequence-ID": context.sequenceId, }), }) ); }); - it("warns when ntfy delete and fallback clear both fail", async () => { + it("warns when ntfy replacement, delete, and fallback clear all fail", async () => { const userId = await createUser("notification-route-ntfy-delete-warn"); await insertMedication({ id: 5, userId, packCount: 1, looseTablets: 0 }); await insertUserSettings(userId, "automatic", { @@ -412,6 +426,7 @@ describe("notification action routes", () => { shoutrrrUrl: "ntfy://user:pass@ntfy.example.com/medassist", }); const { takenToken } = await seedContext({ userId, doseId: "5-0-1736064000000" }); + fetchMock.mockResolvedValueOnce({ ok: false, status: 500, text: () => Promise.resolve("publish failed") }); fetchMock.mockResolvedValueOnce({ ok: false, status: 500, text: () => Promise.resolve("upstream down") }); fetchMock.mockResolvedValueOnce({ ok: false, status: 404, text: () => Promise.resolve("not found") }); @@ -421,7 +436,11 @@ describe("notification action routes", () => { }); expect(response.statusCode).toBe(200); - expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock).toHaveBeenCalledTimes(3); + expect(app.log.warn).toHaveBeenCalledWith( + expect.objectContaining({ requestedAction: "taken" }), + expect.stringContaining("Failed to replace ntfy notification after resolved action") + ); expect(app.log.warn).toHaveBeenCalledWith( expect.objectContaining({ requestedAction: "taken" }), expect.stringContaining("Failed to delete ntfy notification after resolved action") @@ -432,7 +451,7 @@ describe("notification action routes", () => { ); }); - it("falls back to clear when deleting the ntfy notification fails", async () => { + it("falls back to clear when ntfy replacement and delete both fail", async () => { const userId = await createUser("notification-route-ntfy-delete-clear-fallback"); await insertMedication({ id: 5, userId, packCount: 1, looseTablets: 0 }); await insertUserSettings(userId, "automatic", { @@ -440,6 +459,7 @@ describe("notification action routes", () => { shoutrrrUrl: "ntfy://user:pass@ntfy.example.com/medassist", }); const { takenToken, context } = await seedContext({ userId, doseId: "5-0-1736064000000" }); + fetchMock.mockResolvedValueOnce({ ok: false, status: 500, text: () => Promise.resolve("publish failed") }); fetchMock.mockResolvedValueOnce({ ok: false, status: 404, text: () => Promise.resolve("missing") }); fetchMock.mockResolvedValueOnce({ ok: true, text: () => Promise.resolve("") }); @@ -449,9 +469,9 @@ describe("notification action routes", () => { }); expect(response.statusCode).toBe(200); - expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock).toHaveBeenCalledTimes(3); - const [clearUrl, clearInit] = fetchMock.mock.calls[1] ?? []; + const [clearUrl, clearInit] = fetchMock.mock.calls[2] ?? []; expect(clearUrl).toBe(`https://ntfy.example.com/medassist/${context.sequenceId}/clear`); expect(clearInit).toEqual( expect.objectContaining({