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:
@@ -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}",
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
Reference in New Issue
Block a user