From 72e04db36532f748a4eb156d0868d9fd7f6a2ae5 Mon Sep 17 00:00:00 2001 From: Daniel Volz Date: Sun, 10 May 2026 19:01:13 +0200 Subject: [PATCH] feat: add ntfy notification action context service --- .../services/notification-actions-service.ts | 350 ++++++++++++++++++ .../test/notification-actions-service.test.ts | 225 +++++++++++ 2 files changed, 575 insertions(+) create mode 100644 backend/src/services/notification-actions-service.ts create mode 100644 backend/src/test/notification-actions-service.test.ts diff --git a/backend/src/services/notification-actions-service.ts b/backend/src/services/notification-actions-service.ts new file mode 100644 index 0000000..9230aa5 --- /dev/null +++ b/backend/src/services/notification-actions-service.ts @@ -0,0 +1,350 @@ +import { createHash, randomBytes } from "node:crypto"; +import { and, eq, gt, isNull } from "drizzle-orm"; +import { db } from "../db/client.js"; +import { notificationActionGroups, notificationActionTokens } from "../db/schema.js"; +import type { Language } from "../i18n/translations.js"; +import { env } from "../plugins/env.js"; +import { getNotificationActionLabels, type PushNotificationAction } from "./notifications/action-renderer.js"; + +export type NotificationActionKind = "taken" | "skip" | "respond" | "view"; + +type TokenKind = Exclude; +type ActiveTokenKind = "taken" | "skip" | "respond"; + +export type NotificationActionContext = { + groupId?: number; + sequenceId?: string; + respondUrl?: string; + viewUrl: string; + actions: PushNotificationAction[]; +}; + +type NotificationActionMode = "full" | "view-only"; + +export type NotificationActionTokenRecord = { + token: typeof notificationActionTokens.$inferSelect; + group: typeof notificationActionGroups.$inferSelect; + doseIds: string[]; + viewUrl: string | null; +}; + +const NOTIFICATION_ACTION_TTL_MS = 24 * 60 * 60 * 1000; + +function normalizePublicAppUrl(publicAppUrl: string): string { + return publicAppUrl.replace(/\/+$/, ""); +} + +function parseConfiguredUrl(value: string | null | undefined): URL | null { + const trimmedValue = value?.trim(); + if (!trimmedValue) { + return null; + } + + try { + return new URL(trimmedValue); + } catch { + return null; + } +} + +function isLoopbackHostname(hostname: string): boolean { + const normalizedHostname = hostname.toLowerCase(); + return normalizedHostname === "localhost" || normalizedHostname === "127.0.0.1" || normalizedHostname === "::1"; +} + +function resolveNotificationPublicAppUrl(publicAppUrl: string | null | undefined): string | null { + const configuredUrl = parseConfiguredUrl(publicAppUrl ?? env.PUBLIC_APP_URL); + if (configuredUrl && !isLoopbackHostname(configuredUrl.hostname)) { + return normalizePublicAppUrl(configuredUrl.toString()); + } + + const corsOrigins = env.CORS_ORIGINS.split(",") + .map((origin) => parseConfiguredUrl(origin)) + .filter((origin): origin is URL => origin !== null); + const reachableCorsOrigin = + corsOrigins.find((origin) => !isLoopbackHostname(origin.hostname)) ?? corsOrigins[0] ?? null; + if (reachableCorsOrigin) { + return normalizePublicAppUrl(reachableCorsOrigin.toString()); + } + + return configuredUrl ? normalizePublicAppUrl(configuredUrl.toString()) : null; +} + +function getScheduledKey(scheduledFor: Date): string { + return String(Math.floor(scheduledFor.getTime() / 60000)); +} + +function formatDateParam(value: Date): string { + const year = value.getFullYear(); + const month = String(value.getMonth() + 1).padStart(2, "0"); + const day = String(value.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; +} + +function buildViewUrl(baseUrl: string, scheduledFor: Date | null, doseIds: string[]): string { + const params = new URLSearchParams(); + const primaryDoseId = doseIds[0]; + + if (scheduledFor) { + params.set("day", formatDateParam(scheduledFor)); + } + + if (primaryDoseId) { + params.set("dose", primaryDoseId); + } + + const queryString = params.toString(); + return queryString.length > 0 ? `${baseUrl}/dashboard?${queryString}` : `${baseUrl}/dashboard`; +} + +function parseDoseIdsJson(value: string): string[] { + try { + const parsed = JSON.parse(value) as unknown; + if (!Array.isArray(parsed)) { + return []; + } + + return parsed.filter((entry): entry is string => typeof entry === "string" && entry.length > 0); + } catch { + return []; + } +} + +function createSequenceId(groupKey: string): string { + return `medassist-${createHash("sha256").update(groupKey, "utf8").digest("hex").slice(0, 32)}`; +} + +export function createActionToken(): string { + return randomBytes(32).toString("hex"); +} + +export function hashActionToken(token: string): string { + return createHash("sha256").update(token, "utf8").digest("hex"); +} + +async function createTokenRow(groupId: number, kind: TokenKind): Promise<{ kind: TokenKind; token: string }> { + const token = createActionToken(); + await db.insert(notificationActionTokens).values({ + groupId, + tokenHash: hashActionToken(token), + kind, + }); + + return { kind, token }; +} + +async function createActionTokens(groupId: number): Promise> { + const createdTokens = await Promise.all([ + createTokenRow(groupId, "taken"), + createTokenRow(groupId, "skip"), + createTokenRow(groupId, "respond"), + ]); + + return createdTokens.reduce( + (accumulator, entry) => { + accumulator[entry.kind] = entry.token; + return accumulator; + }, + { taken: "", skip: "", respond: "" } as Record + ); +} + +export async function createNotificationActionContext(input: { + userId: number; + title: string; + message: string; + doseIds: string[]; + scheduledFor: Date; + publicAppUrl?: string | null; + language: Language; + actionMode?: NotificationActionMode; +}): Promise { + const publicAppUrl = resolveNotificationPublicAppUrl(input.publicAppUrl); + if (!publicAppUrl) { + return null; + } + + const uniqueDoseIds = [...new Set(input.doseIds.filter((doseId) => doseId.trim().length > 0))].sort(); + if (uniqueDoseIds.length === 0) { + return null; + } + + const baseUrl = publicAppUrl; + const actionMode = input.actionMode ?? "full"; + const labels = getNotificationActionLabels(input.language); + const viewUrl = buildViewUrl(baseUrl, input.scheduledFor, uniqueDoseIds); + + if (actionMode === "view-only") { + return { + viewUrl, + actions: [{ kind: "view", label: labels.view, url: viewUrl, method: "GET" }], + }; + } + + const groupKey = `intake:${input.userId}:${uniqueDoseIds.join(",")}:${getScheduledKey(input.scheduledFor)}`; + const sequenceId = createSequenceId(groupKey); + const now = new Date(); + const expiresAt = new Date(now.getTime() + NOTIFICATION_ACTION_TTL_MS); + + let [group] = await db + .select() + .from(notificationActionGroups) + .where( + and( + eq(notificationActionGroups.groupKey, groupKey), + isNull(notificationActionGroups.resolvedAction), + gt(notificationActionGroups.expiresAt, now) + ) + ); + + 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 tokens = await createActionTokens(group.id); + const groupLanguage = (group.language as Language | null) ?? input.language; + const groupLabels = getNotificationActionLabels(groupLanguage); + const respondUrl = `${baseUrl}/api/notification-actions/${tokens.respond}`; + const resolvedViewUrl = buildViewUrl(baseUrl, group.scheduledFor ?? input.scheduledFor, uniqueDoseIds); + + return { + groupId: group.id, + sequenceId: group.sequenceId, + respondUrl, + viewUrl: resolvedViewUrl, + actions: [ + { + kind: "taken", + label: groupLabels.taken, + url: `${baseUrl}/api/notification-actions/${tokens.taken}`, + method: "POST", + }, + { + kind: "skip", + label: groupLabels.skip, + url: `${baseUrl}/api/notification-actions/${tokens.skip}`, + method: "POST", + }, + { kind: "view", label: groupLabels.view, url: resolvedViewUrl, method: "GET" }, + ], + }; +} + +export async function createTestNotificationActionContext(input: { + userId: number; + title: string; + message: string; + publicAppUrl?: string | null; + language: Language; +}): Promise { + const publicAppUrl = resolveNotificationPublicAppUrl(input.publicAppUrl); + if (!publicAppUrl) { + return null; + } + + const baseUrl = publicAppUrl; + const now = new Date(); + const groupKey = `test:${input.userId}:${now.getTime()}:${randomBytes(8).toString("hex")}`; + const sequenceId = createSequenceId(groupKey); + const expiresAt = new Date(now.getTime() + NOTIFICATION_ACTION_TTL_MS); + const viewUrl = buildViewUrl(baseUrl, null, []); + + const [group] = await db + .insert(notificationActionGroups) + .values({ + userId: input.userId, + groupKey, + sequenceId, + doseIdsJson: "[]", + title: input.title, + message: input.message, + language: input.language, + scheduledFor: now, + expiresAt, + updatedAt: now, + }) + .returning(); + + const tokens = await createActionTokens(group.id); + const groupLanguage = (group.language as Language | null) ?? input.language; + const groupLabels = getNotificationActionLabels(groupLanguage); + const respondUrl = `${baseUrl}/api/notification-actions/${tokens.respond}`; + + return { + groupId: group.id, + sequenceId: group.sequenceId, + respondUrl, + viewUrl, + actions: [ + { + kind: "taken", + label: groupLabels.taken, + url: `${baseUrl}/api/notification-actions/${tokens.taken}`, + method: "POST", + }, + { + kind: "skip", + label: groupLabels.skip, + url: `${baseUrl}/api/notification-actions/${tokens.skip}`, + method: "POST", + }, + { kind: "view", label: groupLabels.view, url: viewUrl, method: "GET" }, + ], + }; +} + +export async function getNotificationActionTokenRecord( + rawToken: string +): Promise { + const tokenHash = hashActionToken(rawToken); + const rows = await db + .select({ token: notificationActionTokens, group: notificationActionGroups }) + .from(notificationActionTokens) + .innerJoin(notificationActionGroups, eq(notificationActionTokens.groupId, notificationActionGroups.id)) + .where(eq(notificationActionTokens.tokenHash, tokenHash)); + + const record = rows[0]; + if (!record) { + return null; + } + + const baseUrl = resolveNotificationPublicAppUrl(env.PUBLIC_APP_URL); + return { + token: record.token, + group: record.group, + doseIds: parseDoseIdsJson(record.group.doseIdsJson), + viewUrl: baseUrl + ? buildViewUrl(baseUrl, record.group.scheduledFor, parseDoseIdsJson(record.group.doseIdsJson)) + : null, + }; +} + +export function isNotificationActionExpired(record: NotificationActionTokenRecord): boolean { + return record.group.expiresAt.getTime() <= Date.now(); +} + +export async function storeNotificationActionGroupNtfyMessageId(groupId: number, ntfyMessageId: string): Promise { + const normalizedMessageId = ntfyMessageId.trim(); + if (normalizedMessageId.length === 0) { + return; + } + + await db + .update(notificationActionGroups) + .set({ ntfyOriginalMessageId: normalizedMessageId, updatedAt: new Date() }) + .where(eq(notificationActionGroups.id, groupId)); +} diff --git a/backend/src/test/notification-actions-service.test.ts b/backend/src/test/notification-actions-service.test.ts new file mode 100644 index 0000000..7402699 --- /dev/null +++ b/backend/src/test/notification-actions-service.test.ts @@ -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"); + }); +});