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
This commit is contained in:
Daniel Volz
2026-05-11 09:24:29 +02:00
committed by GitHub
parent ec478f7601
commit 328f732066
3 changed files with 131 additions and 20 deletions
+6
View File
@@ -109,6 +109,8 @@ type TranslationKeys = {
stockTitle: string; stockTitle: string;
stockTitleMultiple: string; stockTitleMultiple: string;
intakeTitle: string; intakeTitle: string;
intakeTakenConfirmation: string;
intakeSkippedConfirmation: string;
pillsLeft: string; pillsLeft: string;
daysLeft: string; daysLeft: string;
pillsAt: string; pillsAt: string;
@@ -234,6 +236,8 @@ const translations: Record<Language, TranslationKeys> = {
stockTitle: "MedAssist-ng: 1 Medication Running Critically Low", stockTitle: "MedAssist-ng: 1 Medication Running Critically Low",
stockTitleMultiple: "MedAssist-ng: {count} Medications Running Critically Low", stockTitleMultiple: "MedAssist-ng: {count} Medications Running Critically Low",
intakeTitle: "💊 Reminder: Medication intake in {minutes} min", intakeTitle: "💊 Reminder: Medication intake in {minutes} min",
intakeTakenConfirmation: "✅ This dose was marked as taken.",
intakeSkippedConfirmation: "⏭️ This intake was marked as skipped.",
pillsLeft: "{count} pills", pillsLeft: "{count} pills",
daysLeft: "{count} days left", daysLeft: "{count} days left",
pillsAt: "{count} pills at {time}", pillsAt: "{count} pills at {time}",
@@ -355,6 +359,8 @@ const translations: Record<Language, TranslationKeys> = {
stockTitle: "MedAssist-ng: 1 Medikament kritisch niedrig", stockTitle: "MedAssist-ng: 1 Medikament kritisch niedrig",
stockTitleMultiple: "MedAssist-ng: {count} Medikamente kritisch niedrig", stockTitleMultiple: "MedAssist-ng: {count} Medikamente kritisch niedrig",
intakeTitle: "💊 Erinnerung: Medikamenteneinnahme in {minutes} Min.", intakeTitle: "💊 Erinnerung: Medikamenteneinnahme in {minutes} Min.",
intakeTakenConfirmation: "✅ Diese Einnahme wurde als genommen markiert.",
intakeSkippedConfirmation: "⏭️ Diese Einnahme wurde als übersprungen markiert.",
pillsLeft: "{count} Tabletten", pillsLeft: "{count} Tabletten",
daysLeft: "{count} Tage übrig", daysLeft: "{count} Tage übrig",
pillsAt: "{count} Tabletten um {time}", pillsAt: "{count} Tabletten um {time}",
+92 -7
View File
@@ -4,13 +4,14 @@ import type { FastifyInstance, FastifyRequest } from "fastify";
import { z } from "zod"; import { z } from "zod";
import { db } from "../db/client.js"; import { db } from "../db/client.js";
import { notificationActionGroups, notificationActionTokens, userSettings } from "../db/schema.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 { markDoseTakenForUser, skipDosesForUser } from "../services/dose-tracking-service.js";
import { import {
getNotificationActionTokenRecord, getNotificationActionTokenRecord,
isNotificationActionExpired, isNotificationActionExpired,
} from "../services/notification-actions-service.js"; } from "../services/notification-actions-service.js";
import { getNotificationActionLabels } from "../services/notifications/action-renderer.js"; import { getNotificationActionLabels } from "../services/notifications/action-renderer.js";
import { sendPushNotification } from "../services/notifications/delivery.js";
import { sanitizeNotificationUrl } from "../services/settings-service.js"; import { sanitizeNotificationUrl } from "../services/settings-service.js";
import { applyOpenApiRouteStandards, genericErrorSchema } from "../utils/openapi-route-standards.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 publicNotificationActionMethods = "GET,HEAD,POST,OPTIONS";
const reminderFooterSeparator = "\n\n---\n";
function escapeHtml(value: string): string { function escapeHtml(value: string): string {
return value 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<void> { async function clearNtfyNotificationSequence(userId: number, sequenceId: string): Promise<void> {
const [settings] = await db const [settings] = await db
.select({ shoutrrrEnabled: userSettings.shoutrrrEnabled, shoutrrrUrl: userSettings.shoutrrrUrl }) .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<boolean> {
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: { function renderPage(options: {
language: Language; language: Language;
title: string; title: string;
@@ -519,21 +584,41 @@ export async function notificationActionRoutes(app: FastifyInstance) {
); );
const recordedText = getActionRecordedText(language, action); const recordedText = getActionRecordedText(language, action);
let replacedNtfyNotification = false;
try { 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) { } catch (error) {
request.log.warn( request.log.warn(
buildNotificationActionLogContext(record, { requestedAction: action, error }), 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 { try {
await clearNtfyNotificationSequence(record.group.userId, record.group.sequenceId); await deleteNtfyNotificationSequence(record.group.userId, record.group.sequenceId);
} catch (clearError) { } catch (error) {
request.log.warn( request.log.warn(
buildNotificationActionLogContext(record, { requestedAction: action, error: clearError }), buildNotificationActionLogContext(record, { requestedAction: action, error }),
"[NotificationActions] Failed to clear ntfy notification after delete fallback" "[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"
);
}
} }
} }
@@ -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"); const userId = await createUser("notification-route-ntfy-delete");
await insertMedication({ id: 5, userId, packCount: 1, looseTablets: 0 }); await insertMedication({ id: 5, userId, packCount: 1, looseTablets: 0 });
await insertUserSettings(userId, "automatic", { await insertUserSettings(userId, "automatic", {
@@ -350,7 +350,7 @@ describe("notification action routes", () => {
shoutrrrUrl: "ntfy://user:pass@ntfy.example.com/medassist", shoutrrrUrl: "ntfy://user:pass@ntfy.example.com/medassist",
}); });
const { takenToken, context } = await seedContext({ userId, doseId: "5-0-1736064000000" }); 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({ const response = await app.inject({
method: "POST", method: "POST",
@@ -361,19 +361,31 @@ describe("notification action routes", () => {
expect(fetchMock).toHaveBeenCalledTimes(1); expect(fetchMock).toHaveBeenCalledTimes(1);
const [targetUrl, requestInit] = fetchMock.mock.calls[0] ?? []; 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(requestInit).toEqual(
expect.objectContaining({ expect.objectContaining({
method: "DELETE", method: "POST",
body: "Take your medication now\n\n✅ This dose was marked as taken.",
redirect: "error", redirect: "error",
headers: expect.objectContaining({ headers: expect.objectContaining({
Authorization: expect.stringMatching(/^Basic /), Authorization: expect.stringMatching(/^Basic /),
"X-Sequence-ID": context.sequenceId,
}), }),
}) })
); );
const actionHeader = String((requestInit as { headers?: Record<string, string> }).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"); const userId = await createUser("notification-route-ntfy-skip-delete");
await insertMedication({ id: 5, userId, packCount: 1, looseTablets: 0 }); await insertMedication({ id: 5, userId, packCount: 1, looseTablets: 0 });
await insertUserSettings(userId, "automatic", { await insertUserSettings(userId, "automatic", {
@@ -381,7 +393,7 @@ describe("notification action routes", () => {
shoutrrrUrl: "ntfy://user:pass@ntfy.example.com/medassist", shoutrrrUrl: "ntfy://user:pass@ntfy.example.com/medassist",
}); });
const { skipToken, context } = await seedContext({ userId, doseId: "5-0-1736064000000" }); 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({ const response = await app.inject({
method: "POST", method: "POST",
@@ -392,19 +404,21 @@ describe("notification action routes", () => {
expect(fetchMock).toHaveBeenCalledTimes(1); expect(fetchMock).toHaveBeenCalledTimes(1);
const [targetUrl, requestInit] = fetchMock.mock.calls[0] ?? []; 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(requestInit).toEqual(
expect.objectContaining({ expect.objectContaining({
method: "DELETE", method: "POST",
body: "Take your medication now\n\n⏭️ This intake was marked as skipped.",
redirect: "error", redirect: "error",
headers: expect.objectContaining({ headers: expect.objectContaining({
Authorization: expect.stringMatching(/^Basic /), 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"); const userId = await createUser("notification-route-ntfy-delete-warn");
await insertMedication({ id: 5, userId, packCount: 1, looseTablets: 0 }); await insertMedication({ id: 5, userId, packCount: 1, looseTablets: 0 });
await insertUserSettings(userId, "automatic", { await insertUserSettings(userId, "automatic", {
@@ -412,6 +426,7 @@ describe("notification action routes", () => {
shoutrrrUrl: "ntfy://user:pass@ntfy.example.com/medassist", shoutrrrUrl: "ntfy://user:pass@ntfy.example.com/medassist",
}); });
const { takenToken } = await seedContext({ userId, doseId: "5-0-1736064000000" }); 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: 500, text: () => Promise.resolve("upstream down") });
fetchMock.mockResolvedValueOnce({ ok: false, status: 404, text: () => Promise.resolve("not found") }); 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(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(app.log.warn).toHaveBeenCalledWith(
expect.objectContaining({ requestedAction: "taken" }), expect.objectContaining({ requestedAction: "taken" }),
expect.stringContaining("Failed to delete ntfy notification after resolved action") 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"); const userId = await createUser("notification-route-ntfy-delete-clear-fallback");
await insertMedication({ id: 5, userId, packCount: 1, looseTablets: 0 }); await insertMedication({ id: 5, userId, packCount: 1, looseTablets: 0 });
await insertUserSettings(userId, "automatic", { await insertUserSettings(userId, "automatic", {
@@ -440,6 +459,7 @@ describe("notification action routes", () => {
shoutrrrUrl: "ntfy://user:pass@ntfy.example.com/medassist", shoutrrrUrl: "ntfy://user:pass@ntfy.example.com/medassist",
}); });
const { takenToken, context } = await seedContext({ userId, doseId: "5-0-1736064000000" }); 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: false, status: 404, text: () => Promise.resolve("missing") });
fetchMock.mockResolvedValueOnce({ ok: true, text: () => Promise.resolve("") }); fetchMock.mockResolvedValueOnce({ ok: true, text: () => Promise.resolve("") });
@@ -449,9 +469,9 @@ describe("notification action routes", () => {
}); });
expect(response.statusCode).toBe(200); 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(clearUrl).toBe(`https://ntfy.example.com/medassist/${context.sequenceId}/clear`);
expect(clearInit).toEqual( expect(clearInit).toEqual(
expect.objectContaining({ expect.objectContaining({