= {};
+ if (sanitized.auth) {
+ headers.Authorization = `Basic ${Buffer.from(`${sanitized.auth.user}:${sanitized.auth.pass}`).toString("base64")}`;
+ }
+
+ const response = await fetch(deleteUrl.toString(), {
+ method: "DELETE",
+ headers,
+ redirect: "error",
+ });
+
+ if (!response.ok) {
+ const errorText = await response.text();
+ throw new Error(`HTTP ${response.status}: ${errorText}`);
+ }
+}
+
+function renderPage(options: {
+ language: Language;
+ title: string;
+ message: string;
+ bodyTitle: string;
+ bodyText: string;
+ viewUrl: string | null;
+ actionButtons: Array<{ label: string; formAction?: string }>;
+}): string {
+ const labels = getNotificationActionLabels(options.language);
+ const forms =
+ options.actionButtons.length > 0
+ ? `${options.actionButtons
+ .map((button) => {
+ const formAction = button.formAction ? ` action="${escapeHtml(button.formAction)}"` : "";
+ return ``;
+ })
+ .join("")}
`
+ : "";
+ const viewLink = options.viewUrl
+ ? `${escapeHtml(labels.view)}
`
+ : "";
+
+ return `
+
+
+
+
+ ${escapeHtml(options.bodyTitle)}
+
+
+
+
+ ${escapeHtml(options.bodyTitle)}
+ ${escapeHtml(options.bodyText)}
+
+
${escapeHtml(options.title)}
+
${toHtmlText(options.message)}
+
+ ${forms}
+ ${viewLink}
+
+
+`;
+}
+
+function parseRequestedAction(request: FastifyRequest, tokenKind: string): NotificationMutationAction | null {
+ const normalizedTokenAction = normalizeNotificationAction(tokenKind);
+ if (normalizedTokenAction) {
+ return normalizedTokenAction;
+ }
+
+ const parsedQuery = querySchema.safeParse(request.query);
+ if (parsedQuery.success && parsedQuery.data.action) {
+ return normalizeNotificationAction(parsedQuery.data.action);
+ }
+
+ const body = request.body;
+ if (body && typeof body === "object" && "action" in body) {
+ const actionValue = (body as { action?: unknown }).action;
+ if (typeof actionValue === "string") {
+ return normalizeNotificationAction(actionValue);
+ }
+ }
+
+ return null;
+}
+
+function buildNotificationActionLogContext(
+ record: Awaited> extends infer T ? Exclude : never,
+ extra: Record = {}
+) {
+ return {
+ groupId: record.group.id,
+ userId: record.group.userId,
+ tokenKind: record.token.kind,
+ doseCount: record.doseIds.length,
+ hasViewUrl: record.viewUrl !== null,
+ ...extra,
+ };
+}
+
+function buildNotificationRequestLogContext(request: FastifyRequest, extra: Record = {}) {
+ return {
+ method: request.method,
+ hasOrigin: typeof request.headers.origin === "string",
+ expectsHtml: wantsHtml(request),
+ ...extra,
+ };
+}
+
+export async function notificationActionRoutes(app: FastifyInstance) {
+ await app.register(formbody);
+
+ applyOpenApiRouteStandards(app, { tag: "notification-actions", protectedByDefault: false });
+
+ app.options<{ Params: { token: string } }>("/notification-actions/:token", async (request, reply) => {
+ applyPublicNotificationCorsHeaders(request, reply);
+ return reply.status(204).send();
+ });
+
+ app.get<{ Params: { token: string } }>(
+ "/notification-actions/:token",
+ {
+ config: {
+ rateLimit: { max: 30, timeWindow: "1 minute" },
+ },
+ schema: {
+ tags: ["notification-actions"],
+ params: {
+ type: "object",
+ required: ["token"],
+ properties: { token: { type: "string", minLength: 1 } },
+ },
+ response: {
+ 404: genericErrorSchema,
+ 405: genericErrorSchema,
+ 410: genericErrorSchema,
+ },
+ },
+ },
+ async (request, reply) => {
+ applyPublicNotificationCorsHeaders(request, reply);
+
+ const record = await getNotificationActionTokenRecord(request.params.token);
+ if (!record) {
+ request.log.warn(
+ buildNotificationRequestLogContext(request),
+ "[NotificationActions] Unknown notification action token requested"
+ );
+ return reply.status(404).send({ error: "Notification action not found" });
+ }
+
+ if (isNotificationActionExpired(record)) {
+ request.log.warn(
+ buildNotificationActionLogContext(record),
+ "[NotificationActions] Rejected expired notification action GET request"
+ );
+ return reply.status(410).send({ error: "Notification action has expired" });
+ }
+
+ if (record.token.kind !== "respond" && record.group.resolvedAction === null) {
+ request.log.warn(
+ buildNotificationActionLogContext(record),
+ "[NotificationActions] Rejected direct GET for unresolved non-respond token"
+ );
+ return reply.status(405).send({ error: "Direct GET is only available for respond actions" });
+ }
+
+ const language = getLanguage(record.group.language ?? null);
+ const labels = getNotificationActionLabels(language);
+ const resolvedAction = normalizeNotificationAction(record.group.resolvedAction);
+ let bodyTitle: string;
+ let bodyText: string;
+ let actionButtons: Array<{ label: string; formAction?: string }> = [];
+
+ if (resolvedAction) {
+ ({ bodyTitle, bodyText } = getAlreadyProcessedText(language, resolvedAction));
+ } else {
+ if (record.token.kind === "taken") {
+ bodyTitle = language === "de" ? "Einnahme bestätigen" : "Confirm dose";
+ bodyText =
+ language === "de"
+ ? "Bestätigen Sie, dass diese Einnahme als genommen markiert werden soll."
+ : "Confirm that this dose should be marked as taken.";
+ actionButtons = [{ label: labels.taken }];
+ } else if (record.token.kind === "skip" || record.token.kind === "dismiss") {
+ bodyTitle = language === "de" ? "Einnahme überspringen" : "Skip intake";
+ bodyText =
+ language === "de"
+ ? "Bestätigen Sie, dass diese Einnahme als übersprungen markiert werden soll."
+ : "Confirm that this intake should be marked as skipped.";
+ actionButtons = [{ label: labels.skip }];
+ } else {
+ bodyTitle = language === "de" ? "Erinnerung beantworten" : "Respond to reminder";
+ bodyText =
+ language === "de"
+ ? "Wählen Sie eine Aktion für diese Medikamentenerinnerung."
+ : "Choose an action for this medication reminder.";
+ actionButtons = [
+ { label: labels.taken, formAction: "?action=taken" },
+ { label: labels.skip, formAction: "?action=skip" },
+ ];
+ }
+ }
+
+ return reply.type("text/html; charset=utf-8").send(
+ renderPage({
+ language,
+ title: record.group.title,
+ message: record.group.message,
+ bodyTitle,
+ bodyText,
+ viewUrl: record.viewUrl,
+ actionButtons: resolvedAction ? [] : actionButtons,
+ })
+ );
+ }
+ );
+
+ app.post<{ Params: { token: string } }>(
+ "/notification-actions/:token",
+ {
+ config: {
+ rateLimit: { max: 30, timeWindow: "1 minute" },
+ },
+ schema: {
+ tags: ["notification-actions"],
+ params: {
+ type: "object",
+ required: ["token"],
+ properties: { token: { type: "string", minLength: 1 } },
+ },
+ response: {
+ 400: genericErrorSchema,
+ 404: genericErrorSchema,
+ 409: genericErrorSchema,
+ 410: genericErrorSchema,
+ },
+ },
+ },
+ async (request, reply) => {
+ applyPublicNotificationCorsHeaders(request, reply);
+
+ const record = await getNotificationActionTokenRecord(request.params.token);
+ if (!record) {
+ request.log.warn(
+ buildNotificationRequestLogContext(request),
+ "[NotificationActions] Unknown notification action token requested"
+ );
+ return reply.status(404).send({ error: "Notification action not found" });
+ }
+
+ if (isNotificationActionExpired(record)) {
+ request.log.warn(
+ buildNotificationActionLogContext(record),
+ "[NotificationActions] Rejected expired notification action POST request"
+ );
+ return reply.status(410).send({ error: "Notification action has expired" });
+ }
+
+ const action = parseRequestedAction(request, record.token.kind);
+ if (!action) {
+ request.log.warn(
+ buildNotificationActionLogContext(record),
+ "[NotificationActions] Missing or invalid action for notification action POST request"
+ );
+ return reply.status(400).send({ error: "Notification action is required" });
+ }
+
+ const language = getLanguage(record.group.language ?? null);
+ const resolvedAction = normalizeNotificationAction(record.group.resolvedAction);
+ if (resolvedAction) {
+ request.log.info(
+ buildNotificationActionLogContext(record, { requestedAction: action, resolvedAction }),
+ "[NotificationActions] Ignored notification action because it was already resolved"
+ );
+ const alreadyProcessedText = getAlreadyProcessedText(language, resolvedAction);
+
+ if (wantsHtml(request)) {
+ return reply.type("text/html; charset=utf-8").send(
+ renderPage({
+ language,
+ title: record.group.title,
+ message: record.group.message,
+ bodyTitle: alreadyProcessedText.bodyTitle,
+ bodyText: alreadyProcessedText.bodyText,
+ viewUrl: record.viewUrl,
+ actionButtons: [],
+ })
+ );
+ }
+
+ return reply.send({
+ success: true,
+ action: resolvedAction,
+ alreadyProcessed: true,
+ message: alreadyProcessedText.jsonMessage,
+ });
+ }
+
+ if (action === "taken") {
+ for (const [doseIndex, doseId] of record.doseIds.entries()) {
+ const result = await markDoseTakenForUser({
+ userId: record.group.userId,
+ doseId,
+ source: "notification",
+ markedBy: null,
+ });
+
+ if (!result.success) {
+ request.log.warn(
+ buildNotificationActionLogContext(record, {
+ requestedAction: action,
+ failedDoseIndex: doseIndex,
+ code: result.code,
+ }),
+ "[NotificationActions] Failed to record taken notification action"
+ );
+ const statusCode = result.code === "INVALID_DOSE" ? 400 : 409;
+ return reply.status(statusCode).send({ error: result.message, code: result.code });
+ }
+ }
+ } else {
+ await skipDosesForUser({ userId: record.group.userId, doseIds: record.doseIds });
+ }
+
+ await db
+ .update(notificationActionGroups)
+ .set({ resolvedAction: action, resolvedAt: new Date(), updatedAt: new Date() })
+ .where(eq(notificationActionGroups.id, record.group.id));
+ await db
+ .update(notificationActionTokens)
+ .set({ usedAt: new Date() })
+ .where(eq(notificationActionTokens.id, record.token.id));
+
+ request.log.info(
+ buildNotificationActionLogContext(record, { requestedAction: action }),
+ "[NotificationActions] Recorded notification action"
+ );
+
+ const recordedText = getActionRecordedText(language, action);
+
+ try {
+ await deleteNtfyNotificationSequence(record.group.userId, record.group.sequenceId);
+ } catch (error) {
+ request.log.warn(
+ 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"
+ );
+ }
+ }
+
+ if (wantsHtml(request)) {
+ return reply.type("text/html; charset=utf-8").send(
+ renderPage({
+ language,
+ title: record.group.title,
+ message: record.group.message,
+ bodyTitle: recordedText.bodyTitle,
+ bodyText: recordedText.bodyText,
+ viewUrl: record.viewUrl,
+ actionButtons: [],
+ })
+ );
+ }
+
+ return reply.send({ success: true, action });
+ }
+ );
+}
diff --git a/backend/src/test/notification-actions-route.test.ts b/backend/src/test/notification-actions-route.test.ts
new file mode 100644
index 0000000..f5dcb76
--- /dev/null
+++ b/backend/src/test/notification-actions-route.test.ts
@@ -0,0 +1,567 @@
+import { dirname, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+import { migrate } from "drizzle-orm/libsql/migrator";
+import Fastify from "fastify";
+import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
+import { runAlterMigrations } from "../db/db-utils.js";
+import { documentationSchemaAjv } from "../utils/documentation-schema-keywords.js";
+
+const { testClient, testDb, mockedEnv, fetchMock, mockLogger } = vi.hoisted(() => {
+ const { createClient } = require("@libsql/client");
+ const { drizzle } = require("drizzle-orm/libsql");
+ const client = createClient({ url: ":memory:" });
+ const db = drizzle(client);
+ const logger = {
+ info: vi.fn(),
+ warn: vi.fn(),
+ error: vi.fn(),
+ debug: vi.fn(),
+ trace: vi.fn(),
+ fatal: vi.fn(),
+ silent: vi.fn(),
+ level: "info",
+ child: vi.fn(),
+ };
+ logger.child.mockImplementation(() => logger);
+
+ return {
+ testClient: client,
+ testDb: db,
+ fetchMock: vi.fn(),
+ mockLogger: logger,
+ mockedEnv: {
+ AUTH_ENABLED: false,
+ OIDC_ENABLED: false,
+ OIDC_PROVIDER_NAME: "SSO",
+ NODE_ENV: "test",
+ LOG_LEVEL: "silent",
+ PORT: 3000,
+ CORS_ORIGINS: "*",
+ PUBLIC_APP_URL: "https://app.example.com",
+ OPENAPI_DOCS_ENABLED: false,
+ },
+ };
+});
+
+global.fetch = fetchMock;
+
+vi.mock("../db/client.js", () => ({
+ db: testDb,
+ migrationsReady: Promise.resolve(),
+}));
+
+vi.mock("../plugins/env.js", () => ({ env: mockedEnv }));
+
+const { notificationActionRoutes } = await import("../routes/notification-actions.js");
+const { createNotificationActionContext } = 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 dose_tracking");
+ await testClient.execute("DELETE FROM notification_action_tokens");
+ await testClient.execute("DELETE FROM notification_action_groups");
+ await testClient.execute("DELETE FROM medications");
+ await testClient.execute("DELETE FROM user_settings");
+ 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);
+}
+
+async function insertMedication(options: { id: number; userId: number; packCount?: number; looseTablets?: number }) {
+ const start = "2026-01-05T08:00:00.000Z";
+ await testClient.execute({
+ sql: `INSERT INTO medications (
+ id, user_id, name, taken_by_json, medication_form, package_type,
+ pack_count, blisters_per_pack, pills_per_blister, loose_tablets, stock_adjustment,
+ usage_json, every_json, start_json, intakes_json, intake_reminders_enabled
+ ) VALUES (?, ?, 'Route Medication', '[]', 'tablet', 'blister', ?, 1, 10, ?, 0, ?, ?, ?, ?, 1)`,
+ args: [
+ options.id,
+ options.userId,
+ options.packCount ?? 1,
+ options.looseTablets ?? 0,
+ JSON.stringify([1]),
+ JSON.stringify([1]),
+ JSON.stringify([start]),
+ JSON.stringify([{ usage: 1, every: 1, start, takenBy: null, intakeRemindersEnabled: true }]),
+ ],
+ });
+}
+
+async function insertUserSettings(
+ userId: number,
+ stockCalculationMode: "automatic" | "manual" = "automatic",
+ overrides: { shoutrrrEnabled?: boolean; shoutrrrUrl?: string | null } = {}
+) {
+ await testClient.execute({
+ sql: "INSERT INTO user_settings (user_id, stock_calculation_mode, shoutrrr_enabled, shoutrrr_url) VALUES (?, ?, ?, ?)",
+ args: [userId, stockCalculationMode, overrides.shoutrrrEnabled ? 1 : 0, overrides.shoutrrrUrl ?? null],
+ });
+}
+
+async function seedContext(options: { userId: number; doseId: string }) {
+ const scheduledFor = new Date("2026-01-05T08:00:00.000Z");
+ const context = await createNotificationActionContext({
+ userId: options.userId,
+ title: "Reminder",
+ message: "Take your medication now",
+ doseIds: [options.doseId],
+ scheduledFor,
+ publicAppUrl: mockedEnv.PUBLIC_APP_URL,
+ language: "en",
+ });
+
+ return {
+ respondToken: extractToken(context!.respondUrl!),
+ takenToken: extractToken(context!.actions.find((action) => action.kind === "taken")!.url),
+ skipToken: extractToken(context!.actions.find((action) => action.kind === "skip")!.url),
+ context: context!,
+ };
+}
+
+describe("notification action routes", () => {
+ let app: Awaited>;
+
+ beforeAll(async () => {
+ await migrate(testDb, { migrationsFolder });
+ await runAlterMigrations(testClient);
+ app = Fastify({ loggerInstance: mockLogger, disableRequestLogging: true, ajv: documentationSchemaAjv });
+ await app.register(notificationActionRoutes);
+ await app.ready();
+ });
+
+ afterAll(async () => {
+ await app.close();
+ testClient.close();
+ });
+
+ beforeEach(async () => {
+ await clearTables();
+ mockedEnv.PUBLIC_APP_URL = "https://app.example.com";
+ mockedEnv.NODE_ENV = "test";
+ fetchMock.mockReset();
+ fetchMock.mockResolvedValue({ ok: true });
+ mockLogger.info.mockClear();
+ mockLogger.warn.mockClear();
+ mockLogger.error.mockClear();
+ mockLogger.debug.mockClear();
+ mockLogger.trace.mockClear();
+ mockLogger.fatal.mockClear();
+ mockLogger.child.mockClear();
+ });
+
+ it("renders HTML for respond tokens without mutating state", async () => {
+ const userId = await createUser("notification-route-get");
+ const { respondToken } = await seedContext({ userId, doseId: "5-0-1736064000000" });
+
+ const response = await app.inject({
+ method: "GET",
+ url: `/notification-actions/${respondToken}`,
+ headers: { accept: "text/html" },
+ });
+
+ expect(response.statusCode).toBe(200);
+ expect(response.headers["content-type"]).toContain("text/html");
+ expect(response.body).toContain("Respond to reminder");
+ expect(response.body).toContain("Take your medication now");
+
+ const rows = await testClient.execute({
+ sql: `SELECT g.resolved_action, t.used_at
+ FROM notification_action_groups g
+ INNER JOIN notification_action_tokens t ON t.group_id = g.id
+ WHERE t.kind = 'respond'`,
+ });
+ expect(rows.rows).toEqual([expect.objectContaining({ resolved_action: null, used_at: null })]);
+ });
+
+ it("returns the expected GET behavior for missing, non-respond, and expired tokens", async () => {
+ const missing = await app.inject({ method: "GET", url: "/notification-actions/missing-token" });
+ expect(missing.statusCode).toBe(404);
+
+ const userId = await createUser("notification-route-errors");
+ const { respondToken, takenToken } = await seedContext({ userId, doseId: "5-0-1736064000000" });
+
+ const nonRespond = await app.inject({
+ method: "GET",
+ url: `/notification-actions/${takenToken}`,
+ headers: { accept: "text/html" },
+ });
+ expect(nonRespond.statusCode).toBe(405);
+ expect(nonRespond.json()).toEqual({ error: "Direct GET is only available for respond actions" });
+
+ await testClient.execute({
+ sql: "UPDATE notification_action_groups SET expires_at = ?",
+ args: [new Date(0)],
+ });
+
+ const expired = await app.inject({ method: "GET", url: `/notification-actions/${respondToken}` });
+ expect(expired.statusCode).toBe(410);
+ expect(expired.json()).toEqual({ error: "Notification action has expired" });
+ });
+
+ it("shows an already-processed HTML state for resolved respond tokens", async () => {
+ const userId = await createUser("notification-route-resolved");
+ const { respondToken } = await seedContext({ userId, doseId: "5-0-1736064000000" });
+ await testClient.execute({
+ sql: "UPDATE notification_action_groups SET resolved_action = 'skip', resolved_at = ?",
+ args: [new Date()],
+ });
+
+ const response = await app.inject({
+ method: "GET",
+ url: `/notification-actions/${respondToken}`,
+ headers: { accept: "text/html" },
+ });
+
+ expect(response.statusCode).toBe(200);
+ expect(response.body).toContain("Already processed");
+ expect(response.body).toContain(
+ "This intake is already marked as skipped. If you want to mark it as taken instead, open MedAssist and do that there."
+ );
+ });
+
+ it("skips doses through a respond token and returns friendly success for already-resolved follow-up actions", async () => {
+ const userId = await createUser("notification-route-skip");
+ const { respondToken, takenToken } = await seedContext({ userId, doseId: "5-0-1736064000000" });
+
+ const response = await app.inject({
+ method: "POST",
+ url: `/notification-actions/${respondToken}`,
+ payload: { action: "skip" },
+ });
+
+ expect(response.statusCode).toBe(200);
+ expect(response.json()).toEqual({ success: true, action: "skip" });
+ expect(mockLogger.info).toHaveBeenCalledWith(
+ expect.objectContaining({
+ userId,
+ groupId: expect.any(Number),
+ tokenKind: "respond",
+ doseCount: 1,
+ hasViewUrl: true,
+ requestedAction: "skip",
+ }),
+ "[NotificationActions] Recorded notification action"
+ );
+
+ const dismissedRow = await testClient.execute({
+ sql: "SELECT dismissed, taken_at FROM dose_tracking WHERE user_id = ? AND dose_id = ?",
+ args: [userId, "5-0-1736064000000"],
+ });
+ expect(dismissedRow.rows).toEqual([expect.objectContaining({ dismissed: 1, taken_at: 0 })]);
+
+ const groupRow = await testClient.execute({
+ sql: "SELECT resolved_action FROM notification_action_groups",
+ });
+ expect(groupRow.rows).toEqual([expect.objectContaining({ resolved_action: "skip" })]);
+
+ const conflict = await app.inject({
+ method: "POST",
+ url: `/notification-actions/${takenToken}`,
+ });
+
+ expect(conflict.statusCode).toBe(200);
+ expect(conflict.json()).toEqual({
+ success: true,
+ action: "skip",
+ alreadyProcessed: true,
+ message: "This intake is already marked as skipped. Changes can only be made in MedAssist.",
+ });
+ });
+
+ it("keeps legacy dismiss respond actions working as a skip alias", async () => {
+ const userId = await createUser("notification-route-dismiss-alias");
+ const { respondToken } = await seedContext({ userId, doseId: "5-0-1736064000000" });
+
+ const response = await app.inject({
+ method: "POST",
+ url: `/notification-actions/${respondToken}`,
+ payload: { action: "dismiss" },
+ });
+
+ expect(response.statusCode).toBe(200);
+ expect(response.json()).toEqual({ success: true, action: "skip" });
+
+ const groupRow = await testClient.execute({
+ sql: "SELECT resolved_action FROM notification_action_groups",
+ });
+ expect(groupRow.rows).toEqual([expect.objectContaining({ resolved_action: "skip" })]);
+ });
+
+ it("returns an undo hint when a reminder was already taken before a follow-up skip action", async () => {
+ const userId = await createUser("notification-route-taken-followup");
+ await insertMedication({ id: 5, userId, packCount: 1, looseTablets: 0 });
+ await insertUserSettings(userId, "automatic");
+ const { takenToken, respondToken } = await seedContext({ userId, doseId: "5-0-1736064000000" });
+
+ const firstResponse = await app.inject({
+ method: "POST",
+ url: `/notification-actions/${takenToken}`,
+ });
+
+ expect(firstResponse.statusCode).toBe(200);
+ expect(firstResponse.json()).toEqual({ success: true, action: "taken" });
+
+ const followUpHtml = await app.inject({
+ method: "GET",
+ url: `/notification-actions/${respondToken}`,
+ headers: { accept: "text/html" },
+ });
+
+ expect(followUpHtml.statusCode).toBe(200);
+ expect(followUpHtml.body).toContain(
+ "This dose is already marked as taken. If you need to change it, open MedAssist and undo it there."
+ );
+
+ const followUpJson = await app.inject({
+ method: "POST",
+ url: `/notification-actions/${respondToken}`,
+ payload: { action: "skip" },
+ });
+
+ expect(followUpJson.statusCode).toBe(200);
+ expect(followUpJson.json()).toEqual({
+ success: true,
+ action: "taken",
+ alreadyProcessed: true,
+ message: "This dose is already marked as taken. Changes can only be made in MedAssist.",
+ });
+ });
+
+ it("deletes the original ntfy notification after a successful action", async () => {
+ const userId = await createUser("notification-route-ntfy-delete");
+ await insertMedication({ id: 5, userId, packCount: 1, looseTablets: 0 });
+ await insertUserSettings(userId, "automatic", {
+ shoutrrrEnabled: true,
+ 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("") });
+
+ const response = await app.inject({
+ method: "POST",
+ url: `/notification-actions/${takenToken}`,
+ });
+
+ expect(response.statusCode).toBe(200);
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+
+ const [targetUrl, requestInit] = fetchMock.mock.calls[0] ?? [];
+ expect(targetUrl).toBe(`https://ntfy.example.com/medassist/${context.sequenceId}`);
+ expect(requestInit).toEqual(
+ expect.objectContaining({
+ method: "DELETE",
+ redirect: "error",
+ headers: expect.objectContaining({
+ Authorization: expect.stringMatching(/^Basic /),
+ }),
+ })
+ );
+ });
+
+ it("deletes the original ntfy notification after a skip action", async () => {
+ const userId = await createUser("notification-route-ntfy-skip-delete");
+ await insertMedication({ id: 5, userId, packCount: 1, looseTablets: 0 });
+ await insertUserSettings(userId, "automatic", {
+ shoutrrrEnabled: true,
+ 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("") });
+
+ const response = await app.inject({
+ method: "POST",
+ url: `/notification-actions/${skipToken}`,
+ });
+
+ expect(response.statusCode).toBe(200);
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+
+ const [targetUrl, requestInit] = fetchMock.mock.calls[0] ?? [];
+ expect(targetUrl).toBe(`https://ntfy.example.com/medassist/${context.sequenceId}`);
+ expect(requestInit).toEqual(
+ expect.objectContaining({
+ method: "DELETE",
+ redirect: "error",
+ headers: expect.objectContaining({
+ Authorization: expect.stringMatching(/^Basic /),
+ }),
+ })
+ );
+ });
+
+ it("warns when ntfy delete and fallback clear both fail", async () => {
+ const userId = await createUser("notification-route-ntfy-delete-warn");
+ await insertMedication({ id: 5, userId, packCount: 1, looseTablets: 0 });
+ await insertUserSettings(userId, "automatic", {
+ shoutrrrEnabled: true,
+ 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("upstream down") });
+ fetchMock.mockResolvedValueOnce({ ok: false, status: 404, text: () => Promise.resolve("not found") });
+
+ const response = await app.inject({
+ method: "POST",
+ url: `/notification-actions/${takenToken}`,
+ });
+
+ expect(response.statusCode).toBe(200);
+ expect(fetchMock).toHaveBeenCalledTimes(2);
+ expect(app.log.warn).toHaveBeenCalledWith(
+ expect.objectContaining({ requestedAction: "taken" }),
+ expect.stringContaining("Failed to delete ntfy notification after resolved action")
+ );
+ expect(app.log.warn).toHaveBeenCalledWith(
+ expect.objectContaining({ requestedAction: "taken" }),
+ expect.stringContaining("Failed to clear ntfy notification after delete fallback")
+ );
+ });
+
+ it("falls back to clear when deleting the ntfy notification fails", async () => {
+ const userId = await createUser("notification-route-ntfy-delete-clear-fallback");
+ await insertMedication({ id: 5, userId, packCount: 1, looseTablets: 0 });
+ await insertUserSettings(userId, "automatic", {
+ shoutrrrEnabled: true,
+ shoutrrrUrl: "ntfy://user:pass@ntfy.example.com/medassist",
+ });
+ const { takenToken, context } = await seedContext({ userId, doseId: "5-0-1736064000000" });
+ fetchMock.mockResolvedValueOnce({ ok: false, status: 404, text: () => Promise.resolve("missing") });
+ fetchMock.mockResolvedValueOnce({ ok: true, text: () => Promise.resolve("") });
+
+ const response = await app.inject({
+ method: "POST",
+ url: `/notification-actions/${takenToken}`,
+ });
+
+ expect(response.statusCode).toBe(200);
+ expect(fetchMock).toHaveBeenCalledTimes(2);
+
+ const [clearUrl, clearInit] = fetchMock.mock.calls[1] ?? [];
+ expect(clearUrl).toBe(`https://ntfy.example.com/medassist/${context.sequenceId}/clear`);
+ expect(clearInit).toEqual(
+ expect.objectContaining({
+ method: "PUT",
+ headers: expect.objectContaining({ Authorization: expect.stringMatching(/^Basic /) }),
+ })
+ );
+ });
+
+ it("allows browser-origin CORS requests for public notification action tokens", async () => {
+ const userId = await createUser("notification-route-cors");
+ const { respondToken } = await seedContext({ userId, doseId: "5-0-1736064000000" });
+
+ const preflight = await app.inject({
+ method: "OPTIONS",
+ url: `/notification-actions/${respondToken}?action=taken`,
+ headers: {
+ origin: "https://ntfy.danielvolz.org",
+ "access-control-request-method": "POST",
+ "access-control-request-headers": "content-type",
+ },
+ });
+
+ expect(preflight.statusCode).toBe(204);
+ expect(preflight.headers["access-control-allow-origin"]).toBe("https://ntfy.danielvolz.org");
+ expect(preflight.headers["access-control-allow-methods"]).toContain("POST");
+ expect(preflight.headers["access-control-allow-headers"]).toContain("content-type");
+
+ const response = await app.inject({
+ method: "POST",
+ url: `/notification-actions/${respondToken}`,
+ headers: {
+ origin: "https://ntfy.danielvolz.org",
+ },
+ payload: { action: "skip" },
+ });
+
+ expect(response.statusCode).toBe(200);
+ expect(response.headers["access-control-allow-origin"]).toBe("https://ntfy.danielvolz.org");
+ });
+
+ it("accepts standard HTML form posts on respond pages", async () => {
+ const userId = await createUser("notification-route-form-post");
+ await insertMedication({ id: 5, userId, packCount: 1, looseTablets: 0 });
+ await insertUserSettings(userId, "automatic");
+ const { respondToken } = await seedContext({ userId, doseId: "5-0-1736064000000" });
+
+ const response = await app.inject({
+ method: "POST",
+ url: `/notification-actions/${respondToken}?action=taken`,
+ headers: {
+ accept: "text/html",
+ "content-type": "application/x-www-form-urlencoded",
+ },
+ payload: "",
+ });
+
+ expect(response.statusCode).toBe(200);
+ expect(response.headers["content-type"]).toContain("text/html");
+ expect(response.body).toContain("Action recorded");
+ expect(response.body).toContain("The dose was marked as taken.");
+ });
+
+ it("returns non-2xx for invalid, expired, and out-of-stock POST actions", async () => {
+ const missing = await app.inject({ method: "POST", url: "/notification-actions/missing-token" });
+ expect(missing.statusCode).toBe(404);
+
+ const expiredUserId = await createUser("notification-route-expired");
+ const { respondToken } = await seedContext({ userId: expiredUserId, doseId: "5-0-1736064000000" });
+ await testClient.execute({
+ sql: "UPDATE notification_action_groups SET expires_at = ? WHERE user_id = ?",
+ args: [new Date(0), expiredUserId],
+ });
+
+ const expired = await app.inject({
+ method: "POST",
+ url: `/notification-actions/${respondToken}`,
+ payload: { action: "skip" },
+ });
+ expect(expired.statusCode).toBe(410);
+
+ const userId = await createUser("notification-route-stock");
+ await insertMedication({ id: 5, userId, packCount: 0, looseTablets: 0 });
+ await insertUserSettings(userId, "automatic");
+ const { takenToken } = await seedContext({ userId, doseId: "5-0-1736064000000" });
+
+ const outOfStock = await app.inject({
+ method: "POST",
+ url: `/notification-actions/${takenToken}`,
+ });
+ expect(outOfStock.statusCode).toBe(409);
+ expect(outOfStock.json()).toEqual({ error: "Medication is out of stock", code: "OUT_OF_STOCK" });
+ expect(mockLogger.warn).toHaveBeenCalledWith(
+ expect.objectContaining({
+ userId,
+ groupId: expect.any(Number),
+ tokenKind: "taken",
+ doseCount: 1,
+ hasViewUrl: true,
+ requestedAction: "taken",
+ failedDoseIndex: 0,
+ code: "OUT_OF_STOCK",
+ }),
+ "[NotificationActions] Failed to record taken notification action"
+ );
+
+ const state = await testClient.execute({
+ sql: "SELECT resolved_action FROM notification_action_groups WHERE user_id = ?",
+ args: [userId],
+ });
+ expect(state.rows).toEqual([expect.objectContaining({ resolved_action: null })]);
+ });
+});
diff --git a/backend/src/test/server.test.ts b/backend/src/test/server.test.ts
index 3a6e899..e2f27d6 100644
--- a/backend/src/test/server.test.ts
+++ b/backend/src/test/server.test.ts
@@ -244,6 +244,46 @@ describe("Server Bootstrap", () => {
await app.close();
});
+ it("should allow browser preflight requests on public notification action routes", async () => {
+ const origins = ["https://medtest.danielvolz.org"];
+
+ const app = Fastify({ logger: false, ajv: documentationSchemaAjv });
+ await app.register(cors, {
+ delegator: (request, callback) => {
+ if (request.raw.url?.startsWith("/notification-actions/")) {
+ callback(null, {
+ origin: true,
+ credentials: false,
+ methods: ["GET", "HEAD", "POST", "OPTIONS"],
+ });
+ return;
+ }
+
+ callback(null, { origin: origins, credentials: true });
+ },
+ });
+
+ app.post("/notification-actions/:token", async () => ({ ok: true }));
+ await app.ready();
+
+ const response = await app.inject({
+ method: "OPTIONS",
+ url: "/notification-actions/demo-token",
+ headers: {
+ origin: "https://ntfy.danielvolz.org",
+ "access-control-request-method": "POST",
+ "access-control-request-headers": "content-type",
+ },
+ });
+
+ expect(response.statusCode).toBe(204);
+ expect(response.headers["access-control-allow-origin"]).toBe("https://ntfy.danielvolz.org");
+ expect(response.headers["access-control-allow-credentials"]).toBeUndefined();
+ expect(response.headers["access-control-allow-methods"]).toContain("OPTIONS");
+
+ await app.close();
+ });
+
it("should register cookie plugin", async () => {
const app = Fastify({ logger: false, ajv: documentationSchemaAjv });
await app.register(cookie, { secret: "test-cookie-secret" });