diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 734d96df3..9b95a5e98 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -13103,6 +13103,7 @@ paths: - WEB - TELEGRAM - DAYLIO + - NOTIFICATION - in: query name: from schema: @@ -21632,6 +21633,56 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" + /api/notifications/action/{token}: + post: + tags: + - Notifications + summary: Log a quick answer tapped inside a notification + description: "Called by a notification app (ntfy `http` action), never by a signed-in client: the signed path token is + the only credential. The token is bound to one user and one answer, expires six hours after the reminder was + sent, and is single-use across all answers offered on that reminder. Rate-limited per client address before the + token is read. Writes a mood entry with source `NOTIFICATION`; refused with 403 when the mood module is off." + parameters: + - in: path + name: token + schema: + type: string + description: Signed action token from the notification's URL. + required: true + description: Signed action token from the notification's URL. + responses: + "200": + description: The answer was logged. + content: + application/json: + schema: + $ref: "#/components/schemas/NotificationActionResponse" + "401": + description: Invalid token (forged, altered, or unknown user). + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "403": + description: The mood module is turned off for this account. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "409": + description: This reminder was already answered. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "410": + description: The answer expired. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a4 + "429": *a5 /api/notifications/preferences: get: tags: @@ -27417,6 +27468,14 @@ components: properties: clientManaged: type: boolean + content: + type: object + properties: + detail: + type: string + enum: + - generic + - detailed baseUpdatedAt: description: "Optimistic-concurrency base token: the `updatedAt` the client last read for this resource. Omit it for the legacy unconditional write (older clients are unaffected). When present, the write is guarded on the stored @@ -28816,6 +28875,7 @@ components: - WEB - TELEGRAM - DAYLIO + - NOTIFICATION externalId: description: "iOS-side source-stable id for idempotent dedup on the `(userId, source, externalId)` key. Must be stable across app restarts — an object description or memory address (``, `0x12ab34cd`) is a new value @@ -29336,6 +29396,7 @@ components: - WEB - TELEGRAM - DAYLIO + - NOTIFICATION externalId: type: string minLength: 1 @@ -52677,6 +52738,19 @@ components: - nudgeRoutine - nudgeFrequency additionalProperties: false + content: + type: object + properties: + detail: + type: string + enum: + - generic + - detailed + description: What a notification may show. "generic" (default) names what it is about with no health figure; "detailed" + adds figures such as the morning score. Tap targets and quick answers are the same in both. + required: + - detail + additionalProperties: false updatedAt: description: "Optimistic-concurrency token: the stored row's `updatedAt` at read/write time. Echo it back as `baseUpdatedAt` on the next write. Opaque — only ever echo a server-returned value, never parse or @@ -52689,6 +52763,7 @@ components: - mood - cycle - coach + - content additionalProperties: false PatchNotificationPrefsResponse: type: object @@ -65116,6 +65191,7 @@ components: - WEB - TELEGRAM - DAYLIO + - NOTIFICATION externalId: anyOf: - type: string @@ -78271,6 +78347,42 @@ components: required: - ok additionalProperties: false + NotificationActionResponse: + type: object + properties: + data: + $ref: "#/components/schemas/NotificationActionResult" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + NotificationActionResult: + type: object + properties: + logged: + type: boolean + const: true + action: + type: string + enum: + - mood-good + - mood-okay + - mood-bad + description: "The answer that was logged: mood-good = 4 (GUT), mood-okay = 3 (OKAY), mood-bad = 2 (SCHLECHT) on the + five-point mood scale." + required: + - logged + - action + additionalProperties: false + description: A quick answer from a notification, logged as a mood entry. GetNotificationPreferences: type: object properties: diff --git a/docs/self-hosting/notifications.md b/docs/self-hosting/notifications.md index c631f5138..0aa0d0761 100644 --- a/docs/self-hosting/notifications.md +++ b/docs/self-hosting/notifications.md @@ -234,6 +234,8 @@ choice existed keeps sending: message are masked as they are on every other channel. - `priority` is a word: `urgent` for an event flagged urgent, `high` for medication reminders, `default` for everything else. +- `url`, when `APP_URL` is set, is the page the notification leads to (see + [Tap targets](#what-tapping-a-notification-opens)). Absent otherwise. **Gotify** sends the body Gotify's `POST /message` endpoint expects: @@ -450,6 +452,93 @@ Two gotchas worth knowing before you blame a stale token: > in the web UI. If you only run the PWA, ignore it entirely — your > medication reminders come from the server cron over the channels above. +## Tap targets, content and quick answers + +### What tapping a notification opens + +Every notification leads somewhere in the app. The server decides where, +once per event type, and every channel carries the same target: + +| Notification | Opens | +| -------------------------------------------- | ---------------------------------------------------------------------------------- | +| Mood reminder | `/mood`, with the mood entry sheet already open | +| Morning briefing | the dashboard | +| Medication reminder, low supply, shared dose | `/medications` (low supply opens the medication) | +| Check-up and measurement reminders | `/checkups` | +| Proactive Coach nudge | the Coach conversation it started | +| Cycle reminders | `/cycle` (nothing with discreet notifications on) | +| New sign-in | Settings, Security | +| Integration sync failing | Settings, Integrations (an admin alert about another account opens the admin page) | +| Illness red flag | the illness episode | +| Other system alerts | the dashboard, or the admin status page for operator alerts | + +How each channel carries it: + +- **ntfy**: the `Click` header, so tapping the notification opens the page. +- **Telegram**: an **Open in HealthLog** button under the message, below any + action buttons. Telegram only accepts `https` button links on a public + host, so a plain-HTTP instance gets a text link instead, and an instance + on `localhost` gets no link. +- **Web Push**: the service worker opens the page, or focuses an open + HealthLog tab and navigates it there. +- **APNs**: the path rides the payload as `url`. +- **Webhook**: the generic body gains a `url` field; the Gotify body gains + `extras["client::notification"].click.url`, which the Gotify app opens. +- **Email**: a link line under the message. + +ntfy, Telegram, the webhook and email need an absolute link, built from +`APP_URL` (or `NEXT_PUBLIC_APP_URL`). Without one those channels deliver as +before, just without a link. A notification about a profile you look after +as a Guardian carries no deep link, because your session opens on your own +record. + +### Health figures in notifications + +Settings, Notifications, **Health figures in notifications** decides what a +notification may say on a lock screen: + +- **Off** (the default): a notification says what it is about and shows no + health figure. The morning briefing reads "Your morning briefing". +- **On**: notifications add their figures. The morning briefing becomes + "Morning score 78" with the day's one-line summary underneath. + +Tap targets and quick answers work the same either way. The setting is +stored as `content.detail` (`generic` or `detailed`) in the notification +preferences (`PATCH /api/auth/me/notification-prefs`) and travels with the +account backup. + +### Quick answers on the mood reminder + +On **ntfy** the mood reminder carries three buttons, **Good**, **Okay** and +**Bad**. Tapping one logs a mood entry straight from the notification, +without opening the app, and clears the notification. They map onto the +five-point mood scale as Good = 4, Okay = 3, Bad = 2; the outer points +stay in the app. The entry is marked with the source **Notification**. + +Each button calls `POST /api/notifications/action/`. The +token is the only credential, so it is built to be harmless on its own: + +- it is signed with `API_TOKEN_HMAC_KEY` and names one account and one + answer, so it cannot be moved to another account or another answer; +- it expires six hours after the reminder was sent; +- the three answers of one reminder are one use between them: the first + tap logs, every later tap on that reminder is refused; +- the route is rate-limited before it reads the token, refuses when the + mood module has been turned off, and the token is scrubbed from logs. + +The buttons need `APP_URL`, because the ntfy app calls the URL from your +phone. Your phone has to reach the instance at that address. + +**Telegram** keeps its own keyboard on the mood reminder: the full 1 to 5 +scale, a note and a "later" button. + +**Web Push** offers no buttons. Most browsers show at most two actions, +which cannot carry a three-way answer, and Safari on iPhone shows none. +Tapping the notification opens the mood entry sheet instead. + +**APNs**: the iOS app draws its own buttons from the notification category +it registers for the mood reminder. + ## Which channel should I pick? | Your setup | Recommended channel | diff --git a/src/app/api/notifications/action/[token]/__tests__/route.test.ts b/src/app/api/notifications/action/[token]/__tests__/route.test.ts new file mode 100644 index 000000000..a77eda6cb --- /dev/null +++ b/src/app/api/notifications/action/[token]/__tests__/route.test.ts @@ -0,0 +1,154 @@ +/** + * POST /api/notifications/action/{token} — a quick answer from a + * notification. Pins the order of the gates and every refusal class: rate + * limit before the token is read, then signature, expiry, account, module, + * and the single-use write. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +const TEST_HMAC_KEY = "test-hmac-key-at-least-32-chars-long-xxxxx"; + +vi.mock("@/lib/db", () => ({ + prisma: { user: { findUnique: vi.fn() } }, +})); +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/rate-limit", () => ({ + checkAuthSurfaceRateLimit: vi.fn(), + rateLimitHeaders: vi.fn(() => ({ "Retry-After": "60" })), +})); +vi.mock("@/lib/modules/gate", () => ({ isModuleEnabled: vi.fn() })); +vi.mock("@/lib/mood/create-from-telegram", () => ({ + logTelegramMood: vi.fn(), +})); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +import { POST } from "../route"; +import { prisma } from "@/lib/db"; +import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit"; +import { isModuleEnabled } from "@/lib/modules/gate"; +import { logTelegramMood } from "@/lib/mood/create-from-telegram"; +import { + ACTION_TOKEN_TTL_MS, + mintActionToken, +} from "@/lib/notifications/action-token"; + +function call(token: string) { + const req = new NextRequest( + `http://localhost/api/notifications/action/${token}`, + { method: "POST" }, + ); + return POST(req, { params: Promise.resolve({ token }) }); +} + +function token(over: Partial[0]> = {}) { + return mintActionToken({ + userId: "user-1", + action: "mood-okay", + nonce: "nonce-1", + ...over, + }); +} + +beforeEach(() => { + vi.resetAllMocks(); + vi.stubEnv("API_TOKEN_HMAC_KEY", TEST_HMAC_KEY); + vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({ + allowed: true, + remaining: 19, + resetAt: new Date(), + ip: "203.0.113.5", + } as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + id: "user-1", + timezone: "Europe/Berlin", + } as never); + vi.mocked(isModuleEnabled).mockResolvedValue(true); + vi.mocked(logTelegramMood).mockResolvedValue({ + created: true, + moodEntryId: "m1", + score: 3, + }); +}); + +describe("POST /api/notifications/action/{token}", () => { + it("logs the answer under the reminder's nonce with the notification source", async () => { + const res = await call(token()); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ + data: { logged: true, action: "mood-okay" }, + error: null, + }); + expect(logTelegramMood).toHaveBeenCalledWith({ + userId: "user-1", + score: 3, + tz: "Europe/Berlin", + externalId: "notification-action:nonce-1", + source: "NOTIFICATION", + }); + }); + + it("is rate-limited before the token is read", async () => { + vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({ + allowed: false, + remaining: 0, + resetAt: new Date(), + ip: "203.0.113.5", + } as never); + const res = await call(token()); + expect(res.status).toBe(429); + expect(prisma.user.findUnique).not.toHaveBeenCalled(); + expect(logTelegramMood).not.toHaveBeenCalled(); + }); + + it("refuses a tampered token with 401 and writes nothing", async () => { + const forged = token().replace("user-1", "user-2"); + const res = await call(forged); + expect(res.status).toBe(401); + expect(prisma.user.findUnique).not.toHaveBeenCalled(); + expect(logTelegramMood).not.toHaveBeenCalled(); + }); + + it("refuses an expired token with 410", async () => { + const res = await call( + token({ now: Date.now() - ACTION_TOKEN_TTL_MS - 1 }), + ); + expect(res.status).toBe(410); + expect(logTelegramMood).not.toHaveBeenCalled(); + }); + + it("refuses a token for an account that no longer exists", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue(null); + const res = await call(token()); + expect(res.status).toBe(401); + expect(logTelegramMood).not.toHaveBeenCalled(); + }); + + it("writes only for the user the token names", async () => { + await call(token({ userId: "user-9" })); + expect(prisma.user.findUnique).toHaveBeenCalledWith( + expect.objectContaining({ where: { id: "user-9" } }), + ); + }); + + it("refuses with 403 when the mood module was switched off", async () => { + vi.mocked(isModuleEnabled).mockResolvedValue(false); + const res = await call(token()); + expect(res.status).toBe(403); + expect(isModuleEnabled).toHaveBeenCalledWith("user-1", "mood"); + expect(logTelegramMood).not.toHaveBeenCalled(); + }); + + it("refuses a reused reminder with 409", async () => { + vi.mocked(logTelegramMood).mockResolvedValue({ + created: false, + moodEntryId: "m1", + score: 3, + }); + const res = await call(token({ action: "mood-bad" })); + expect(res.status).toBe(409); + }); +}); diff --git a/src/lib/daily/__tests__/daily-briefing-push.test.ts b/src/lib/daily/__tests__/daily-briefing-push.test.ts index f1191cc27..1bffd2983 100644 --- a/src/lib/daily/__tests__/daily-briefing-push.test.ts +++ b/src/lib/daily/__tests__/daily-briefing-push.test.ts @@ -123,6 +123,69 @@ describe("buildDailyBriefingPush", () => { }); }); +describe("buildDailyBriefingPush — content detail", () => { + const { t } = getServerTranslator("en"); + + it("generic (the default) keeps the score off the lock screen", () => { + const { title, body } = buildDailyBriefingPush(makeDigest(), t); + expect(title).toBe("Your morning briefing"); + expect(`${title} ${body}`).not.toMatch(/\d/); + }); + + it("detailed puts the score in the title and keeps the line as the label", () => { + const { title, body } = buildDailyBriefingPush(makeDigest(), t, { + showFigures: true, + }); + expect(title).toBe("Morning score 82"); + expect(body).toBe("Sleep looked solid last night."); + }); + + it("detailed swaps the 'score is ready' floor for the band label", () => { + const digest = makeDigest({ + briefingLead: null, + score: { value: 64.6, band: "yellow", delta: null }, + line: t("daily.line.scoreReady"), + }); + const { title, body } = buildDailyBriefingPush(digest, t, { + showFigures: true, + }); + expect(title).toBe("Morning score 65"); + expect(body).toBe(t("daily.push.band.yellow")); + }); + + it("detailed without a score falls back to the plain title", () => { + const { title } = buildDailyBriefingPush(makeDigest({ score: null }), t, { + showFigures: true, + }); + expect(title).toBe("Your morning briefing"); + }); + + it("reads the choice from the user's notification preferences", async () => { + const detailed = makeDeps(); + await maybeDispatchDailyBriefing( + makePrisma({ + user: { + id: "u1", + timezone: "Europe/Berlin", + locale: "en", + morningDigestRefreshedOn: null, + notificationPrefs: { content: { detail: "detailed" } }, + }, + }), + "u1", + IN_WINDOW, + detailed, + ); + expect(detailed.dispatch.mock.calls[0][0].title).toBe("Morning score 82"); + + const generic = makeDeps(); + await maybeDispatchDailyBriefing(makePrisma({}), "u1", IN_WINDOW, generic); + expect(generic.dispatch.mock.calls[0][0].title).toBe( + "Your morning briefing", + ); + }); +}); + describe("digestHasSubstance", () => { it("is false for an empty account (score/lead/signal all null, no items)", () => { expect( diff --git a/src/lib/notifications/__tests__/action-token.test.ts b/src/lib/notifications/__tests__/action-token.test.ts new file mode 100644 index 000000000..1c0eb4774 --- /dev/null +++ b/src/lib/notifications/__tests__/action-token.test.ts @@ -0,0 +1,168 @@ +/** + * Notification quick-action tokens: what a URL holding one can and cannot do. + */ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { + ACTION_TOKEN_TTL_MS, + MOOD_QUICK_ACTIONS, + QUICK_ACTION_IDS, + buildMoodQuickActions, + mintActionToken, + quickActionExternalId, + verifyActionToken, +} from "@/lib/notifications/action-token"; + +const ORIGINAL_KEY = process.env.API_TOKEN_HMAC_KEY; +const NOW = Date.UTC(2026, 9, 11, 20, 0, 0); + +beforeEach(() => { + process.env.API_TOKEN_HMAC_KEY = "k".repeat(64); +}); + +afterEach(() => { + if (ORIGINAL_KEY === undefined) delete process.env.API_TOKEN_HMAC_KEY; + else process.env.API_TOKEN_HMAC_KEY = ORIGINAL_KEY; +}); + +function mint(over: Partial[0]> = {}) { + return mintActionToken({ + userId: "user_a", + action: "mood-good", + nonce: "nonce1", + now: NOW, + ...over, + }); +} + +/** Replace one dot-separated field, keeping the original signature. */ +function withField(token: string, index: number, value: string): string { + const parts = token.split("."); + parts[index] = value; + return parts.join("."); +} + +describe("mood quick actions", () => { + it("maps good / okay / bad onto the five-point scale (4 / 3 / 2)", () => { + expect(MOOD_QUICK_ACTIONS).toEqual({ + "mood-good": 4, + "mood-okay": 3, + "mood-bad": 2, + }); + expect(QUICK_ACTION_IDS).toHaveLength(3); + }); + + it("builds three path-safe URLs sharing one nonce", () => { + const actions = buildMoodQuickActions({ + userId: "user_a", + origin: "https://health.example", + labels: { "mood-good": "Good", "mood-okay": "Okay", "mood-bad": "Bad" }, + now: NOW, + }); + expect(actions.map((a) => a.id)).toEqual([ + "mood-good", + "mood-okay", + "mood-bad", + ]); + const nonces = new Set(); + for (const action of actions) { + expect(action.url).toMatch( + /^https:\/\/health\.example\/api\/notifications\/action\/[A-Za-z0-9._-]+$/, + ); + const token = action.url.split("/").at(-1) ?? ""; + const verdict = verifyActionToken(token, NOW); + expect(verdict.ok).toBe(true); + if (verdict.ok) { + expect(verdict.action).toBe(action.id); + nonces.add(verdict.nonce); + } + } + // One nonce: whichever answer lands first uses up the other two. + expect(nonces.size).toBe(1); + }); + + it("keys the single-use write on the nonce", () => { + expect(quickActionExternalId("abc")).toBe("notification-action:abc"); + }); +}); + +describe("verifyActionToken", () => { + it("accepts a fresh token and returns what it is bound to", () => { + const verdict = verifyActionToken(mint(), NOW + 1000); + expect(verdict).toEqual({ + ok: true, + userId: "user_a", + action: "mood-good", + nonce: "nonce1", + expiry: NOW + ACTION_TOKEN_TTL_MS, + }); + }); + + it("refuses a token at and after its expiry", () => { + const token = mint(); + expect(verifyActionToken(token, NOW + ACTION_TOKEN_TTL_MS - 1).ok).toBe( + true, + ); + expect(verifyActionToken(token, NOW + ACTION_TOKEN_TTL_MS)).toEqual({ + ok: false, + reason: "expired", + }); + }); + + it("refuses a token moved to another user", () => { + expect(verifyActionToken(withField(mint(), 1, "user_b"), NOW)).toEqual({ + ok: false, + reason: "bad_signature", + }); + }); + + it("refuses a token switched to another answer", () => { + expect(verifyActionToken(withField(mint(), 2, "mood-bad"), NOW)).toEqual({ + ok: false, + reason: "bad_signature", + }); + }); + + it("refuses an extended expiry as a forgery, not as fresh", () => { + const later = String(NOW + 10 * ACTION_TOKEN_TTL_MS); + expect( + verifyActionToken( + withField(mint(), 3, later), + NOW + 2 * ACTION_TOKEN_TTL_MS, + ), + ).toEqual({ ok: false, reason: "bad_signature" }); + }); + + it("refuses a tampered signature and a token signed under another key", () => { + const token = mint(); + const tag = token.split(".").at(-1) ?? ""; + const flipped = `${tag.slice(0, -1)}${tag.endsWith("A") ? "B" : "A"}`; + expect(verifyActionToken(withField(token, 5, flipped), NOW).ok).toBe(false); + + process.env.API_TOKEN_HMAC_KEY = "z".repeat(64); + expect(verifyActionToken(token, NOW)).toEqual({ + ok: false, + reason: "bad_signature", + }); + }); + + it.each([ + [""], + ["na1.user.mood-good.1.n"], + ["na2.user_a.mood-good.1.n.tag"], + ["na1.user_a.mood-great.1.n.tag"], + ["na1.user_a.mood-good.01.n.tag"], + ["na1..mood-good.1.n.tag"], + ["x".repeat(400)], + ])("refuses the malformed token %j", (token) => { + expect(verifyActionToken(token, NOW)).toEqual({ + ok: false, + reason: "malformed", + }); + }); + + it("refuses to mint without a signing key", () => { + delete process.env.API_TOKEN_HMAC_KEY; + expect(() => mint()).toThrow(/API_TOKEN_HMAC_KEY/); + }); +}); diff --git a/src/lib/notifications/__tests__/delivery-links.test.ts b/src/lib/notifications/__tests__/delivery-links.test.ts new file mode 100644 index 000000000..b609b26b0 --- /dev/null +++ b/src/lib/notifications/__tests__/delivery-links.test.ts @@ -0,0 +1,115 @@ +/** + * The dispatcher's one place for tap targets and quick actions. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { userFindUnique } = vi.hoisted(() => ({ userFindUnique: vi.fn() })); + +vi.mock("@/lib/db", () => ({ + prisma: { user: { findUnique: userFindUnique } }, +})); + +vi.mock("@/lib/logging/context", () => ({ + getEvent: () => ({ addWarning: vi.fn() }), +})); + +import { withDeliveryLinks } from "@/lib/notifications/delivery-links"; +import { verifyActionToken } from "@/lib/notifications/action-token"; +import type { NotificationPayload } from "@/lib/notifications/types"; + +const ORIGINAL = { + APP_URL: process.env.APP_URL, + NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL, + API_TOKEN_HMAC_KEY: process.env.API_TOKEN_HMAC_KEY, +}; + +beforeEach(() => { + process.env.APP_URL = "https://health.example"; + delete process.env.NEXT_PUBLIC_APP_URL; + process.env.API_TOKEN_HMAC_KEY = "k".repeat(64); + userFindUnique.mockResolvedValue({ locale: "de" }); +}); + +afterEach(() => { + vi.clearAllMocks(); + for (const [key, value] of Object.entries(ORIGINAL)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } +}); + +function payload(over: Partial = {}): NotificationPayload { + return { + eventType: "MOOD_REMINDER", + userId: "u1", + recordUserId: "u1", + recipientUserId: "u1", + title: "t", + message: "m", + ...over, + }; +} + +describe("withDeliveryLinks", () => { + it("attaches the tap target in the recipient's language", async () => { + const out = await withDeliveryLinks( + payload({ eventType: "DAILY_BRIEFING" }), + "u1", + ); + expect(out.tap).toEqual({ + path: "/", + url: "https://health.example/", + label: "In HealthLog öffnen", + }); + expect(out.quickActions).toBeUndefined(); + }); + + it("gives a self-delivered mood reminder three signed answers for its recipient", async () => { + const now = Date.UTC(2026, 9, 11, 20); + const out = await withDeliveryLinks(payload(), "u1", now); + expect(out.tap?.url).toBe("https://health.example/mood?add=1"); + expect(out.quickActions?.map((a) => [a.id, a.label])).toEqual([ + ["mood-good", "Gut"], + ["mood-okay", "Okay"], + ["mood-bad", "Schlecht"], + ]); + for (const action of out.quickActions ?? []) { + const verdict = verifyActionToken(action.url.split("/").at(-1), now); + expect(verdict.ok && verdict.userId).toBe("u1"); + } + }); + + it("offers no answers to a Guardian", async () => { + const out = await withDeliveryLinks( + payload({ recordUserId: "record-1", recipientUserId: "guardian-1" }), + "guardian-1", + ); + expect(out.quickActions).toBeUndefined(); + expect(out.tap).toBeUndefined(); + }); + + it("keeps the path but drops absolute URLs and answers without a public origin", async () => { + delete process.env.APP_URL; + const out = await withDeliveryLinks(payload(), "u1"); + expect(out.tap).toEqual({ + path: "/mood?add=1", + url: null, + label: "In HealthLog öffnen", + }); + expect(out.quickActions).toBeUndefined(); + }); + + it("still delivers, without answers, when no signing key is configured", async () => { + delete process.env.API_TOKEN_HMAC_KEY; + const out = await withDeliveryLinks(payload(), "u1"); + expect(out.quickActions).toBeUndefined(); + expect(out.tap?.path).toBe("/mood?add=1"); + }); + + it("returns the silent sync push untouched, without a locale read", async () => { + const input = payload({ eventType: "MEDICATION_INTAKE_SYNC" }); + const out = await withDeliveryLinks(input, "u1"); + expect(out).toBe(input); + expect(userFindUnique).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/notifications/__tests__/tap-target.test.ts b/src/lib/notifications/__tests__/tap-target.test.ts new file mode 100644 index 000000000..676e44e4b --- /dev/null +++ b/src/lib/notifications/__tests__/tap-target.test.ts @@ -0,0 +1,166 @@ +/** + * The event → tap-target map and its resolver. + * + * Walks `EVENT_TYPES` rather than the map's own keys, so an event type added + * to the union without a decision about where it leads fails here (the + * `Record` type already refuses to compile without one; this + * pins that every entry is also a usable path). + */ +import { afterEach, describe, expect, it } from "vitest"; + +import { EVENT_TYPES, type EventType } from "@/lib/notifications/types"; +import { + NOTIFICATION_TAP_PATHS, + absoluteAppUrl, + configuredAppOrigin, + isSafeAppPath, + resolveTapPath, + telegramLinkMode, +} from "@/lib/notifications/tap-target"; + +/** Events never shown to the user, so they carry no target. */ +const SILENT: ReadonlySet = new Set(["MEDICATION_INTAKE_SYNC"]); + +const ORIGINAL = { + APP_URL: process.env.APP_URL, + NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL, +}; + +afterEach(() => { + for (const [key, value] of Object.entries(ORIGINAL)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } +}); + +describe("NOTIFICATION_TAP_PATHS", () => { + it("names a target for every event type", () => { + expect(EVENT_TYPES.length).toBeGreaterThan(0); + for (const eventType of EVENT_TYPES) { + expect( + Object.prototype.hasOwnProperty.call(NOTIFICATION_TAP_PATHS, eventType), + `${eventType} has no tap target`, + ).toBe(true); + const path = NOTIFICATION_TAP_PATHS[eventType]; + if (SILENT.has(eventType)) { + expect(path, `${eventType} is silent`).toBeNull(); + } else { + expect(isSafeAppPath(path), `${eventType} → ${path}`).toBe(true); + expect(resolveTapPath({ eventType }), eventType).toBe(path); + } + } + }); + + it("keeps the targets the issue names", () => { + expect(NOTIFICATION_TAP_PATHS.MOOD_REMINDER).toBe("/mood?add=1"); + expect(NOTIFICATION_TAP_PATHS.DAILY_BRIEFING).toBe("/"); + expect(NOTIFICATION_TAP_PATHS.WITHINGS_SYNC_FAILED).toBe( + "/settings/integrations", + ); + expect(NOTIFICATION_TAP_PATHS.MEDICATION_REMINDER).toBe("/medications"); + }); + + it("has no entry for an event type outside the union", () => { + expect(Object.keys(NOTIFICATION_TAP_PATHS).sort()).toEqual( + [...EVENT_TYPES].sort(), + ); + }); +}); + +describe("resolveTapPath", () => { + it("takes a producer's same-origin path over the default", () => { + expect( + resolveTapPath({ + eventType: "MEDICATION_LOW_STOCK", + metadata: { url: "/medications/m1?tab=bestand" }, + }), + ).toBe("/medications/m1?tab=bestand"); + }); + + it.each([ + "https://evil.example/", + "//evil.example/x", + "/\\evil.example", + "javascript:alert(1)", + "/a b", + "", + 42, + ])("ignores an unsafe override %j", (url) => { + expect( + resolveTapPath({ eventType: "COACH_NUDGE", metadata: { url } }), + ).toBe("/coach"); + }); + + it("gives a managed Guardian delivery no path", () => { + expect( + resolveTapPath({ + eventType: "MEDICATION_REMINDER", + recordUserId: "record-1", + recipientUserId: "guardian-1", + }), + ).toBeNull(); + }); + + it("keeps a self delivery's path when both ids are the same", () => { + expect( + resolveTapPath({ + eventType: "MEDICATION_REMINDER", + recordUserId: "u1", + recipientUserId: "u1", + }), + ).toBe("/medications"); + }); + + it("gives a discreet delivery no path, so nothing names the cycle surface", () => { + expect( + resolveTapPath({ eventType: "CYCLE_PERIOD_SOON", discreet: true }), + ).toBeNull(); + }); + + it("gives the silent sync push no path even with an override", () => { + expect( + resolveTapPath({ + eventType: "MEDICATION_INTAKE_SYNC", + metadata: { url: "/medications" }, + }), + ).toBeNull(); + }); +}); + +describe("configuredAppOrigin / absoluteAppUrl", () => { + it("prefers APP_URL, then NEXT_PUBLIC_APP_URL, and keeps only the origin", () => { + process.env.APP_URL = "https://health.example/some/path"; + process.env.NEXT_PUBLIC_APP_URL = "https://other.example"; + expect(configuredAppOrigin()).toBe("https://health.example"); + delete process.env.APP_URL; + expect(configuredAppOrigin()).toBe("https://other.example"); + }); + + it("returns null without a usable origin, and so no absolute URL", () => { + delete process.env.APP_URL; + process.env.NEXT_PUBLIC_APP_URL = "not a url"; + expect(configuredAppOrigin()).toBeNull(); + expect(absoluteAppUrl("/mood?add=1")).toBeNull(); + }); + + it("joins origin and path", () => { + expect(absoluteAppUrl("/mood?add=1", "https://h.example")).toBe( + "https://h.example/mood?add=1", + ); + expect(absoluteAppUrl(null, "https://h.example")).toBeNull(); + }); +}); + +describe("telegramLinkMode", () => { + it.each([ + ["https://health.example/", "button"], + ["http://nas.lan:3000/", "text"], + ["https://localhost:3000/", "none"], + ["http://127.0.0.1/", "none"], + ["https://app.localhost/", "none"], + ["ftp://health.example/", "none"], + ["nonsense", "none"], + ])("%s → %s", (url, mode) => { + expect(telegramLinkMode(url)).toBe(mode); + }); +}); diff --git a/src/lib/notifications/__tests__/web-push-dose-clear.test.ts b/src/lib/notifications/__tests__/web-push-dose-clear.test.ts index 6b9eb9d66..cbdb11ffe 100644 --- a/src/lib/notifications/__tests__/web-push-dose-clear.test.ts +++ b/src/lib/notifications/__tests__/web-push-dose-clear.test.ts @@ -190,6 +190,39 @@ describe("web-push reminder push — stable tag + badge", () => { }); }); +describe("web-push tap target", () => { + async function sentBody(payload: Parameters[1]) { + findManyMock.mockResolvedValue([SUB]); + vi.spyOn(global, "fetch").mockResolvedValue( + new Response(null, { status: 201 }), + ); + await sendViaWebPush("user-1", payload); + return JSON.parse(generateRequestDetailsMock.mock.calls[0][1] as string); + } + + it("carries the dispatcher's resolved tap path as data url", async () => { + const body = await sentBody({ + eventType: "MOOD_REMINDER", + userId: "user-1", + title: "Log your mood", + message: "How are you feeling today?", + tap: { path: "/mood?add=1", url: null, label: "Open" }, + }); + expect(body.url).toBe("/mood?add=1"); + }); + + it("refuses a protocol-relative metadata url when no tap was resolved", async () => { + const body = await sentBody({ + eventType: "SYSTEM_ALERT", + userId: "user-1", + title: "t", + message: "m", + metadata: { url: "//evil.example/x" }, + }); + expect(body.url).toBe("/"); + }); +}); + describe("clear-on-taken push", () => { it("emits type:clear with the SAME slot tag as the reminder + badge", async () => { findManyMock.mockResolvedValue([SUB]); diff --git a/src/lib/notifications/senders/__tests__/apns.test.ts b/src/lib/notifications/senders/__tests__/apns.test.ts index c7c68b849..f60b60d25 100644 --- a/src/lib/notifications/senders/__tests__/apns.test.ts +++ b/src/lib/notifications/senders/__tests__/apns.test.ts @@ -698,6 +698,52 @@ describe("sendViaApns — dispatcher fan-out", () => { expect(note.payload).toEqual({ eventType: "MEDICATION_REMINDER" }); }); + it("carries the resolved tap path as the `url` payload key", async () => { + vi.mocked(prisma.device.findMany).mockResolvedValueOnce([ + { id: "d1", apnsToken: "tok-a", apnsEnvironment: "sandbox" }, + ] as never); + sendMock.mockResolvedValueOnce({ sent: [{ device: "tok-a" }], failed: [] }); + + await sendViaApns("u-1", { + title: "t", + message: "m", + eventType: "MOOD_REMINDER", + metadata: { localDate: "2026-10-11" }, + tap: { + path: "/mood?add=1", + url: "https://health.example/mood?add=1", + label: "Open", + }, + }); + + const note = sendMock.mock.calls[0][0]; + expect(note.payload).toEqual({ + eventType: "MOOD_REMINDER", + localDate: "2026-10-11", + url: "/mood?add=1", + }); + // Unchanged keys: the category still names the event. + expect(note.category).toBe("MOOD_REMINDER"); + }); + + it("never gives a managed Guardian a tap path, even when one is set", async () => { + vi.mocked(prisma.device.findMany).mockResolvedValueOnce([ + { id: "d1", apnsToken: "tok-a", apnsEnvironment: "sandbox" }, + ] as never); + sendMock.mockResolvedValueOnce({ sent: [{ device: "tok-a" }], failed: [] }); + + await sendViaApns("guardian-1", { + title: "t", + message: "m", + eventType: "MEDICATION_REMINDER", + recordUserId: "record-1", + recipientUserId: "guardian-1", + tap: { path: "/medications", url: null, label: "Open" }, + }); + + expect(sendMock.mock.calls[0][0].payload.url).toBeUndefined(); + }); + it("forwards reminderId on MEASUREMENT_REMINDER and strips unlisted keys", async () => { // The iOS "Erledigt" action POSTs the reminder completion directly // from a foreground / handled notification, so the reminder's id must diff --git a/src/lib/notifications/senders/__tests__/email.test.ts b/src/lib/notifications/senders/__tests__/email.test.ts index b785b1130..1f1452176 100644 --- a/src/lib/notifications/senders/__tests__/email.test.ts +++ b/src/lib/notifications/senders/__tests__/email.test.ts @@ -277,3 +277,25 @@ describe("sendViaEmail — transport and envelope faults are named too", () => { expect(result.failureCode).toBe(failureCode); }); }); + +describe("sendViaEmail — tap target", () => { + it("appends the tap link as a plain-text line", async () => { + loadEmailConfigMock.mockReturnValue(transport); + sendMailMock.mockResolvedValue({ messageId: "1" }); + + await sendViaEmail( + { recipient: "you@example.com" }, + payload({ + tap: { + path: "/settings/security", + url: "https://health.example/settings/security", + label: "Open in HealthLog", + }, + }), + ); + + expect(sendMailMock.mock.calls[0][0].text).toBe( + "Body\n\nOpen in HealthLog: https://health.example/settings/security", + ); + }); +}); diff --git a/src/lib/notifications/senders/__tests__/ntfy.test.ts b/src/lib/notifications/senders/__tests__/ntfy.test.ts index e38efa619..8a28200fb 100644 --- a/src/lib/notifications/senders/__tests__/ntfy.test.ts +++ b/src/lib/notifications/senders/__tests__/ntfy.test.ts @@ -268,3 +268,88 @@ describe("sendViaNtfy — what the server said (#947)", () => { expect((await sendViaNtfy(config, payload())).failureCode).toBe("timeout"); }); }); + +describe("sendViaNtfy — tap target and quick actions", () => { + function headersOfFirstCall(): Record { + const [, init] = safeFetchMock.mock.calls[0] as [ + string, + { headers: Record }, + ]; + return init.headers; + } + + it("sets Click to the resolved absolute tap URL", async () => { + safeFetchMock.mockResolvedValue({ ok: true, status: 200 }); + await sendViaNtfy( + config, + payload({ + eventType: "DAILY_BRIEFING", + tap: { + path: "/", + url: "https://health.example/", + label: "Open in HealthLog", + }, + }), + ); + expect(headersOfFirstCall().Click).toBe("https://health.example/"); + expect(headersOfFirstCall().Actions).toBeUndefined(); + }); + + it("omits Click when no public origin produced a URL", async () => { + safeFetchMock.mockResolvedValue({ ok: true, status: 200 }); + await sendViaNtfy( + config, + payload({ tap: { path: "/", url: null, label: "Open" } }), + ); + expect(headersOfFirstCall().Click).toBeUndefined(); + }); + + it("sends the quick answers as three POST http actions that clear the notification", async () => { + safeFetchMock.mockResolvedValue({ ok: true, status: 200 }); + await sendViaNtfy( + config, + payload({ + eventType: "MOOD_REMINDER", + quickActions: [ + { id: "mood-good", label: "Good", url: "https://h.example/a/1" }, + { id: "mood-okay", label: "Okay", url: "https://h.example/a/2" }, + { id: "mood-bad", label: "Bad", url: "https://h.example/a/3" }, + ], + }), + ); + expect(headersOfFirstCall().Actions).toBe( + "http, Good, https://h.example/a/1, method=POST, clear=true; " + + "http, Okay, https://h.example/a/2, method=POST, clear=true; " + + "http, Bad, https://h.example/a/3, method=POST, clear=true", + ); + }); +}); + +describe("ntfy header helpers", () => { + it("caps the actions at three and strips separators from labels", async () => { + const { buildNtfyActionsHeader } = await import("../ntfy"); + const header = buildNtfyActionsHeader({ + quickActions: [ + { id: "mood-good", label: 'Go;od, "yes"', url: "https://h/1" }, + { id: "mood-okay", label: "B", url: "https://h/2" }, + { id: "mood-bad", label: "C", url: "https://h/3" }, + { id: "mood-bad", label: "D", url: "https://h/4" }, + ], + }); + expect(header?.split("; ")).toHaveLength(3); + expect(header).toContain("http, Go od yes, https://h/1"); + expect(header).not.toContain("https://h/4"); + expect(buildNtfyActionsHeader({ quickActions: [] })).toBeNull(); + }); + + it("RFC 2047-encodes a non-ASCII header value and leaves ASCII alone", async () => { + const { ntfyHeaderValue } = await import("../ntfy"); + expect(ntfyHeaderValue("Morning score 78")).toBe("Morning score 78"); + const encoded = ntfyHeaderValue("아침 점수 78"); + expect(encoded).toMatch(/^=\?UTF-8\?B\?[A-Za-z0-9+/=]+\?=$/); + const inner = encoded.slice("=?UTF-8?B?".length, -2); + expect(Buffer.from(inner, "base64").toString("utf8")).toBe("아침 점수 78"); + // `ü` is Latin-1, which fetch would send as one invalid UTF-8 byte. + expect(ntfyHeaderValue("Morgenüberblick")).toMatch(/^=\?UTF-8\?B\?/); + }); +}); diff --git a/src/lib/notifications/senders/__tests__/telegram.test.ts b/src/lib/notifications/senders/__tests__/telegram.test.ts index 14455e171..0299caacf 100644 --- a/src/lib/notifications/senders/__tests__/telegram.test.ts +++ b/src/lib/notifications/senders/__tests__/telegram.test.ts @@ -347,3 +347,117 @@ describe("sendViaTelegram — interactive mood + measurement (v1.19.0)", () => { expect(scheduleTelegramAutoDeleteMock).not.toHaveBeenCalled(); }); }); + +describe("sendViaTelegram — tap target", () => { + beforeEach(() => { + sendTelegramMessageMock.mockResolvedValue({ ok: true, messageId: 5 }); + findUniqueMock.mockResolvedValue(null); + upsertMock.mockResolvedValue(undefined); + userFindUniqueMock.mockResolvedValue({ locale: "en" }); + scheduleTelegramAutoDeleteMock.mockResolvedValue(undefined); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + function sent(): { + text: string; + replyMarkup?: { inline_keyboard: Record[][] }; + } { + const call = sendTelegramMessageMock.mock.calls[0] as [ + string, + string, + string, + { replyMarkup?: { inline_keyboard: Record[][] } }, + ]; + return { text: call[2], replyMarkup: call[3].replyMarkup }; + } + + it("adds an https URL button as its own row under the action buttons", async () => { + await sendViaTelegram(config, { + ...reminderPayload(), + tap: { + path: "/medications", + url: "https://health.example/medications", + label: "Open in HealthLog", + }, + }); + const rows = sent().replyMarkup?.inline_keyboard ?? []; + // The medication keyboard keeps its two callback rows first. + expect(rows[0]?.[0]?.callback_data).toBe("taken:med-1"); + expect(rows.at(-1)).toEqual([ + { + text: "Open in HealthLog", + url: "https://health.example/medications", + }, + ]); + expect(sent().text).toBe("m"); + }); + + it("creates a keyboard with only the URL button for an event without actions", async () => { + await sendViaTelegram(config, { + eventType: "DAILY_BRIEFING", + userId: "user-1", + title: "t", + message: "line", + tap: { path: "/", url: "https://health.example/", label: "Open" }, + }); + expect(sent().replyMarkup).toEqual({ + inline_keyboard: [[{ text: "Open", url: "https://health.example/" }]], + }); + }); + + it("falls back to an escaped text link on a plain-HTTP origin Telegram refuses as a button", async () => { + await sendViaTelegram(config, { + eventType: "DAILY_BRIEFING", + userId: "user-1", + title: "t", + message: "line", + tap: { + path: "/?a=1&b=2", + url: "http://nas.lan/?a=1&b=2", + label: "Open ", + }, + }); + expect(sent().replyMarkup).toBeUndefined(); + expect(sent().text).toBe( + 'line\n\nOpen <app>', + ); + }); + + it("carries no link at all for a loopback origin", async () => { + await sendViaTelegram(config, { + eventType: "DAILY_BRIEFING", + userId: "user-1", + title: "t", + message: "line", + tap: { path: "/", url: "https://localhost:3000/", label: "Open" }, + }); + expect(sent().replyMarkup).toBeUndefined(); + expect(sent().text).toBe("line"); + }); + + it("keeps the 1–5 mood keyboard and adds the link row to the mood reminder", async () => { + await sendViaTelegram(config, { + eventType: "MOOD_REMINDER", + userId: "user-1", + title: "t", + message: "How are you?", + tap: { + path: "/mood?add=1", + url: "https://health.example/mood?add=1", + label: "Open", + }, + }); + const rows = sent().replyMarkup?.inline_keyboard ?? []; + expect(rows[0]?.map((b) => b.callback_data)).toEqual([ + "mood:1", + "mood:2", + "mood:3", + "mood:4", + "mood:5", + ]); + expect(rows.at(-1)?.[0]?.url).toBe("https://health.example/mood?add=1"); + }); +}); diff --git a/src/lib/notifications/senders/__tests__/webhook.test.ts b/src/lib/notifications/senders/__tests__/webhook.test.ts index 5850fa067..d07aa55c3 100644 --- a/src/lib/notifications/senders/__tests__/webhook.test.ts +++ b/src/lib/notifications/senders/__tests__/webhook.test.ts @@ -566,3 +566,32 @@ describe("sendViaWebhook — Gotify format (#947)", () => { expect(JSON.parse(sentBody()).priority).toBe("default"); }); }); + +describe("buildWebhookBody — tap target", () => { + const tap = { + path: "/checkups", + url: "https://health.example/checkups", + label: "Open in HealthLog", + }; + + it("adds the absolute tap URL to the generic body", () => { + const body = JSON.parse(buildWebhookBody({}, payload({ tap }))); + expect(body.url).toBe("https://health.example/checkups"); + }); + + it("leaves the generic body without `url` when no public origin is set", () => { + const body = JSON.parse( + buildWebhookBody({}, payload({ tap: { ...tap, url: null } })), + ); + expect(body).not.toHaveProperty("url"); + }); + + it("gives Gotify a click URL in its notification extras", () => { + const body = JSON.parse( + buildWebhookBody({ format: "gotify" }, payload({ tap })), + ); + expect(body.extras["client::notification"]).toEqual({ + click: { url: "https://health.example/checkups" }, + }); + }); +}); diff --git a/src/lib/notifications/tap-target.ts b/src/lib/notifications/tap-target.ts index cd3d97208..10bb30de4 100644 --- a/src/lib/notifications/tap-target.ts +++ b/src/lib/notifications/tap-target.ts @@ -63,7 +63,6 @@ export function isSafeAppPath(value: unknown): value is string { if (!value.startsWith("/")) return false; if (value.startsWith("//")) return false; if (value.includes("\\")) return false; - // eslint-disable-next-line no-control-regex if (/[\s\u0000-\u001f\u007f]/.test(value)) return false; return true; } diff --git a/src/lib/openapi/routes/notifications.ts b/src/lib/openapi/routes/notifications.ts index cf3868411..da0d64aad 100644 --- a/src/lib/openapi/routes/notifications.ts +++ b/src/lib/openapi/routes/notifications.ts @@ -188,7 +188,7 @@ export const notificationChannelPaths: NonNullable = "application/json": { schema: dataEnvelope( notificationActionResult, - "NotificationActionResult", + "NotificationActionResponse", ), }, }, diff --git a/tests/integration/notification-action-mood.test.ts b/tests/integration/notification-action-mood.test.ts new file mode 100644 index 000000000..ad7ba364f --- /dev/null +++ b/tests/integration/notification-action-mood.test.ts @@ -0,0 +1,103 @@ +/** + * v1.42.2 — a mood quick answer from a notification writes exactly once. + * + * The three answers on one reminder share a nonce, and the write is keyed on + * it through `MoodEntry`'s unique `(userId, source, externalId)`. Against + * real Postgres and the real route: the first answer logs, a repeat of the + * same answer, a different answer on the same reminder, and two answers + * racing each other all leave exactly one row behind. + */ +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { getPrismaClient, truncateAllTables } from "./setup"; + +const USER = "user-notification-action"; + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +function call(token: string) { + return import("@/app/api/notifications/action/[token]/route").then( + ({ POST }) => + POST( + new NextRequest(`http://localhost/api/notifications/action/${token}`, { + method: "POST", + }), + { params: Promise.resolve({ token }) }, + ), + ); +} + +async function tokens(nonce: string) { + const { mintActionToken } = await import("@/lib/notifications/action-token"); + return { + good: mintActionToken({ userId: USER, action: "mood-good", nonce }), + bad: mintActionToken({ userId: USER, action: "mood-bad", nonce }), + }; +} + +beforeEach(async () => { + vi.stubEnv( + "API_TOKEN_HMAC_KEY", + "integration-hmac-key-at-least-32-characters", + ); + await truncateAllTables(getPrismaClient()); + await getPrismaClient().user.create({ + data: { + id: USER, + username: "notification-action", + email: "notification-action@example.test", + timezone: "Europe/Berlin", + }, + }); +}); + +describe("POST /api/notifications/action/{token}", () => { + it("logs the first answer once and refuses every later answer on the reminder", async () => { + const { good, bad } = await tokens("reminder-1"); + + const first = await call(good); + expect(first.status).toBe(200); + const repeat = await call(good); + expect(repeat.status).toBe(409); + const other = await call(bad); + expect(other.status).toBe(409); + + const rows = await getPrismaClient().moodEntry.findMany({ + where: { userId: USER }, + select: { source: true, externalId: true, score: true, mood: true }, + }); + expect(rows).toEqual([ + { + source: "NOTIFICATION", + externalId: "notification-action:reminder-1", + score: 4, + mood: "GUT", + }, + ]); + }); + + it("lets only one of two racing answers write", async () => { + const { good, bad } = await tokens("reminder-2"); + + const statuses = (await Promise.all([call(good), call(bad)])) + .map((res) => res.status) + .sort(); + expect(statuses).toEqual([200, 409]); + expect( + await getPrismaClient().moodEntry.count({ where: { userId: USER } }), + ).toBe(1); + }); + + it("answers a separate reminder with its own entry", async () => { + const one = await tokens("reminder-3"); + const two = await tokens("reminder-4"); + expect((await call(one.good)).status).toBe(200); + expect((await call(two.bad)).status).toBe(200); + expect( + await getPrismaClient().moodEntry.count({ where: { userId: USER } }), + ).toBe(2); + }); +});