diff --git a/backend/src/routes/notification-actions.ts b/backend/src/routes/notification-actions.ts index 6712ba6..996c0cc 100644 --- a/backend/src/routes/notification-actions.ts +++ b/backend/src/routes/notification-actions.ts @@ -213,10 +213,10 @@ async function replaceNtfyNotificationSequence(options: { originalMessage: string; action: NotificationMutationAction; viewUrl: string | null; -}): Promise { +}): Promise<{ replaced: boolean; providerMessageId?: string }> { const normalizedSequenceId = options.sequenceId.trim(); if (normalizedSequenceId.length === 0) { - return false; + return { replaced: false }; } const [settings] = await db @@ -225,12 +225,12 @@ async function replaceNtfyNotificationSequence(options: { .where(eq(userSettings.userId, options.userId)); if (!settings?.shoutrrrEnabled || !settings.shoutrrrUrl) { - return false; + return { replaced: false }; } const sanitized = sanitizeNotificationUrl(settings.shoutrrrUrl); if ("error" in sanitized || !sanitized.isNtfy) { - return false; + return { replaced: false }; } const labels = getNotificationActionLabels(options.language); @@ -247,7 +247,7 @@ async function replaceNtfyNotificationSequence(options: { throw new Error(result.error ?? "Failed to replace ntfy notification"); } - return true; + return { replaced: true, providerMessageId: result.providerMessageId }; } function renderPage(options: { @@ -585,9 +585,10 @@ export async function notificationActionRoutes(app: FastifyInstance) { const recordedText = getActionRecordedText(language, action); let replacedNtfyNotification = false; + const previousNtfyMessageId = record.group.ntfyOriginalMessageId.trim(); try { - replacedNtfyNotification = await replaceNtfyNotificationSequence({ + const replacementResult = await replaceNtfyNotificationSequence({ userId: record.group.userId, sequenceId: record.group.sequenceId, language, @@ -596,6 +597,33 @@ export async function notificationActionRoutes(app: FastifyInstance) { action, viewUrl: record.viewUrl, }); + replacedNtfyNotification = replacementResult.replaced; + + if (replacementResult.providerMessageId) { + await db + .update(notificationActionGroups) + .set({ ntfyOriginalMessageId: replacementResult.providerMessageId, updatedAt: new Date() }) + .where(eq(notificationActionGroups.id, record.group.id)); + } + + if ( + replacementResult.replaced && + previousNtfyMessageId.length > 0 && + previousNtfyMessageId !== replacementResult.providerMessageId + ) { + try { + await deleteNtfyNotificationSequence(record.group.userId, previousNtfyMessageId); + } catch (error) { + request.log.warn( + buildNotificationActionLogContext(record, { + requestedAction: action, + originalMessageId: previousNtfyMessageId, + error, + }), + "[NotificationActions] Failed to delete original ntfy notification after replacement" + ); + } + } } catch (error) { request.log.warn( buildNotificationActionLogContext(record, { requestedAction: action, error }), diff --git a/backend/src/services/notification-actions-service.ts b/backend/src/services/notification-actions-service.ts index 9230aa5..b8c2292 100644 --- a/backend/src/services/notification-actions-service.ts +++ b/backend/src/services/notification-actions-service.ts @@ -149,6 +149,10 @@ async function createActionTokens(groupId: number): Promise { + await db.delete(notificationActionTokens).where(eq(notificationActionTokens.groupId, groupId)); +} + export async function createNotificationActionContext(input: { userId: number; title: string; @@ -198,21 +202,47 @@ export async function createNotificationActionContext(input: { ); if (!group) { - [group] = await db - .insert(notificationActionGroups) - .values({ - userId: input.userId, - groupKey, - sequenceId, - doseIdsJson: JSON.stringify(uniqueDoseIds), - title: input.title, - message: input.message, - language: input.language, - scheduledFor: input.scheduledFor, - expiresAt, - updatedAt: now, - }) - .returning(); + const [existingGroup] = await db + .select() + .from(notificationActionGroups) + .where(eq(notificationActionGroups.groupKey, groupKey)); + + if (existingGroup) { + await resetActionTokens(existingGroup.id); + [group] = await db + .update(notificationActionGroups) + .set({ + sequenceId, + ntfyOriginalMessageId: "", + doseIdsJson: JSON.stringify(uniqueDoseIds), + title: input.title, + message: input.message, + language: input.language, + scheduledFor: input.scheduledFor, + expiresAt, + resolvedAction: null, + resolvedAt: null, + updatedAt: now, + }) + .where(eq(notificationActionGroups.id, existingGroup.id)) + .returning(); + } else { + [group] = await db + .insert(notificationActionGroups) + .values({ + userId: input.userId, + groupKey, + sequenceId, + doseIdsJson: JSON.stringify(uniqueDoseIds), + title: input.title, + message: input.message, + language: input.language, + scheduledFor: input.scheduledFor, + expiresAt, + updatedAt: now, + }) + .returning(); + } } const tokens = await createActionTokens(group.id); diff --git a/backend/src/test/notification-actions-route.test.ts b/backend/src/test/notification-actions-route.test.ts index 10020a4..2f980f5 100644 --- a/backend/src/test/notification-actions-route.test.ts +++ b/backend/src/test/notification-actions-route.test.ts @@ -350,7 +350,12 @@ describe("notification action routes", () => { shoutrrrUrl: "ntfy://user:pass@ntfy.example.com/medassist", }); const { takenToken, context } = await seedContext({ userId, doseId: "5-0-1736064000000" }); + await testClient.execute({ + sql: "UPDATE notification_action_groups SET ntfy_original_message_id = ? WHERE user_id = ?", + args: ["ntfy-msg-1", userId], + }); fetchMock.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ id: "ntfy-msg-2" }) }); + fetchMock.mockResolvedValueOnce({ ok: true, text: () => Promise.resolve("") }); const response = await app.inject({ method: "POST", @@ -358,7 +363,7 @@ describe("notification action routes", () => { }); expect(response.statusCode).toBe(200); - expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledTimes(2); const [targetUrl, requestInit] = fetchMock.mock.calls[0] ?? []; expect(targetUrl).toBe("https://ntfy.example.com/medassist"); @@ -383,6 +388,21 @@ describe("notification action routes", () => { clear: false, }, ]); + + const [deleteUrl, deleteInit] = fetchMock.mock.calls[1] ?? []; + expect(deleteUrl).toBe("https://ntfy.example.com/medassist/ntfy-msg-1"); + expect(deleteInit).toEqual( + expect.objectContaining({ + method: "DELETE", + headers: expect.objectContaining({ Authorization: expect.stringMatching(/^Basic /) }), + }) + ); + + const groupRow = await testClient.execute({ + sql: "SELECT ntfy_original_message_id FROM notification_action_groups WHERE user_id = ?", + args: [userId], + }); + expect(groupRow.rows).toEqual([expect.objectContaining({ ntfy_original_message_id: "ntfy-msg-2" })]); }); it("replaces the original ntfy notification after a skip action with a view-only confirmation", async () => { @@ -393,7 +413,12 @@ describe("notification action routes", () => { shoutrrrUrl: "ntfy://user:pass@ntfy.example.com/medassist", }); const { skipToken, context } = await seedContext({ userId, doseId: "5-0-1736064000000" }); + await testClient.execute({ + sql: "UPDATE notification_action_groups SET ntfy_original_message_id = ? WHERE user_id = ?", + args: ["ntfy-msg-7", userId], + }); fetchMock.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ id: "ntfy-msg-3" }) }); + fetchMock.mockResolvedValueOnce({ ok: true, text: () => Promise.resolve("") }); const response = await app.inject({ method: "POST", @@ -401,7 +426,7 @@ describe("notification action routes", () => { }); expect(response.statusCode).toBe(200); - expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledTimes(2); const [targetUrl, requestInit] = fetchMock.mock.calls[0] ?? []; expect(targetUrl).toBe("https://ntfy.example.com/medassist"); @@ -416,6 +441,21 @@ describe("notification action routes", () => { }), }) ); + + const [deleteUrl, deleteInit] = fetchMock.mock.calls[1] ?? []; + expect(deleteUrl).toBe("https://ntfy.example.com/medassist/ntfy-msg-7"); + expect(deleteInit).toEqual( + expect.objectContaining({ + method: "DELETE", + headers: expect.objectContaining({ Authorization: expect.stringMatching(/^Basic /) }), + }) + ); + + const groupRow = await testClient.execute({ + sql: "SELECT ntfy_original_message_id FROM notification_action_groups WHERE user_id = ?", + args: [userId], + }); + expect(groupRow.rows).toEqual([expect.objectContaining({ ntfy_original_message_id: "ntfy-msg-3" })]); }); it("warns when ntfy replacement, delete, and fallback clear all fail", async () => { diff --git a/backend/src/test/notification-actions-service.test.ts b/backend/src/test/notification-actions-service.test.ts index 7402699..0358455 100644 --- a/backend/src/test/notification-actions-service.test.ts +++ b/backend/src/test/notification-actions-service.test.ts @@ -39,6 +39,11 @@ function extractToken(url: string): string { return url.split("/").at(-1) ?? ""; } +type ActionTokenRow = { + kind: string | null; + token_hash: string | null; +}; + async function clearTables() { await testClient.execute("DELETE FROM notification_action_tokens"); await testClient.execute("DELETE FROM notification_action_groups"); @@ -181,6 +186,97 @@ describe("notification-actions-service", () => { expect(Number(tokens.rows[0].count)).toBe(6); }); + it("reactivates a resolved group with the same key instead of inserting a duplicate", async () => { + const userId = await createUser("notify-actions-reactivate"); + const scheduledFor = new Date("2026-01-05T08:00:00.000Z"); + const doseIds = ["9-1-1736064000000", "9-0-1736064000000"]; + + const first = await createNotificationActionContext({ + userId, + title: "Reminder", + message: "Take your medication now", + doseIds, + scheduledFor, + publicAppUrl: mockedEnv.PUBLIC_APP_URL, + language: "en", + }); + + expect(first).toMatchObject({ + groupId: expect.any(Number), + respondUrl: expect.stringContaining("/api/notification-actions/"), + sequenceId: expect.stringMatching(/^medassist-/), + viewUrl: "https://app.example.com/dashboard?day=2026-01-05&dose=9-0-1736064000000", + }); + + const firstGroupId = first!.groupId!; + const firstSequenceId = first!.sequenceId!; + const firstRespondToken = extractToken(first!.respondUrl!); + const firstTokenRows = await testClient.execute({ + sql: "SELECT kind, token_hash FROM notification_action_tokens WHERE group_id = ? ORDER BY kind ASC", + args: [firstGroupId], + }); + expect(firstTokenRows.rows).toHaveLength(3); + + await testClient.execute({ + sql: "UPDATE notification_action_groups SET resolved_action = 'taken', resolved_at = ?, ntfy_original_message_id = 'old-message-id' WHERE id = ?", + args: [new Date(), firstGroupId], + }); + + const second = await createNotificationActionContext({ + userId, + title: "Reminder", + message: "Take your medication now", + doseIds, + scheduledFor, + publicAppUrl: mockedEnv.PUBLIC_APP_URL, + language: "en", + }); + + expect(second).toMatchObject({ + groupId: firstGroupId, + respondUrl: expect.stringContaining("/api/notification-actions/"), + sequenceId: expect.stringMatching(/^medassist-/), + viewUrl: "https://app.example.com/dashboard?day=2026-01-05&dose=9-0-1736064000000", + }); + expect(second?.sequenceId).toBe(firstSequenceId); + + const groups = await testClient.execute( + "SELECT id, sequence_id, resolved_action, resolved_at, ntfy_original_message_id FROM notification_action_groups" + ); + expect(groups.rows).toHaveLength(1); + expect(groups.rows[0]).toEqual( + expect.objectContaining({ + id: firstGroupId, + sequence_id: second?.sequenceId, + resolved_action: null, + resolved_at: null, + ntfy_original_message_id: "", + }) + ); + + const secondTokenRows = await testClient.execute({ + sql: "SELECT kind, token_hash FROM notification_action_tokens WHERE group_id = ? ORDER BY kind ASC", + args: [firstGroupId], + }); + expect(secondTokenRows.rows).toHaveLength(3); + expect(secondTokenRows.rows.map((row: ActionTokenRow) => row.kind)).toEqual(["respond", "skip", "taken"]); + + const firstTokenHashes = new Set(firstTokenRows.rows.map((row: ActionTokenRow) => String(row.token_hash))); + const secondTokenHashes = new Set(secondTokenRows.rows.map((row: ActionTokenRow) => String(row.token_hash))); + expect(secondTokenHashes.size).toBe(3); + expect([...secondTokenHashes].every((tokenHash) => !firstTokenHashes.has(tokenHash))).toBe(true); + + expect(await getNotificationActionTokenRecord(firstRespondToken)).toBeNull(); + + const secondRespondToken = extractToken(second!.respondUrl!); + const secondRespondRecord = await getNotificationActionTokenRecord(secondRespondToken); + expect(secondRespondRecord).toMatchObject({ + doseIds: ["9-0-1736064000000", "9-1-1736064000000"], + viewUrl: "https://app.example.com/dashboard?day=2026-01-05&dose=9-0-1736064000000", + }); + expect(secondRespondRecord?.group.id).toBe(firstGroupId); + }); + 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"); diff --git a/docs/PUSH_NOTIFICATIONS.md b/docs/PUSH_NOTIFICATIONS.md index b9cddc9..1862eeb 100644 --- a/docs/PUSH_NOTIFICATIONS.md +++ b/docs/PUSH_NOTIFICATIONS.md @@ -6,7 +6,11 @@ MedAssist uses [Shoutrrr](https://containrrr.dev/shoutrrr/) for push notificatio Recommended provider: `ntfy`. -Use `ntfy` when you want the best-supported MedAssist notification flow, especially for intake reminders with direct actions such as `Take`, `Skip`, and `View`. +Use `ntfy` when you want the best-supported MedAssist notification flow, especially for intake reminders with actions such as `Take`, `Skip`, and `View`. + +For `ntfy`, MedAssist publishes native action buttons so `Take` and `Skip` are executed directly from the notification. The browser-based confirmation flow remains the fallback path for other Shoutrrr targets that do not support native action buttons. + +When an ntfy intake action succeeds, MedAssist publishes the confirmation as the updated notification state and removes the outdated actionable ntfy entry using the original ntfy message ID when available, so duplicate reminder entries do not accumulate unnecessarily. ## Supported URL Schemes