From f19dfc4572767925816288761e82f1d710e70088 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 11 Oct 2026 12:21:51 +0200 Subject: [PATCH 1/5] feat(notifications): resolve a tap target per event and offer mood quick answers --- src/__tests__/session-surface-guard.test.ts | 8 + .../api/notifications/action/[token]/route.ts | 152 ++++++++++++++ src/components/mood/mood-list.tsx | 2 + src/lib/logging/redact.ts | 4 + src/lib/logging/types.ts | 3 +- src/lib/mood/create-from-telegram.ts | 15 +- src/lib/notifications/action-token.ts | 197 ++++++++++++++++++ src/lib/notifications/delivery-links.ts | 105 ++++++++++ src/lib/notifications/dispatcher.ts | 7 +- src/lib/notifications/senders/apns.ts | 9 + src/lib/notifications/senders/email.ts | 6 +- src/lib/notifications/senders/ntfy.ts | 60 +++++- src/lib/notifications/senders/telegram.ts | 34 ++- src/lib/notifications/senders/web-push.ts | 13 +- src/lib/notifications/senders/webhook.ts | 9 + src/lib/notifications/tap-target.ts | 154 ++++++++++++++ src/lib/notifications/types.ts | 24 +++ src/lib/openapi/routes/notifications.ts | 63 ++++++ src/lib/telegram.ts | 8 +- src/lib/validations/mood.ts | 2 + src/proxy.ts | 5 + 21 files changed, 859 insertions(+), 21 deletions(-) create mode 100644 src/app/api/notifications/action/[token]/route.ts create mode 100644 src/lib/notifications/action-token.ts create mode 100644 src/lib/notifications/delivery-links.ts create mode 100644 src/lib/notifications/tap-target.ts diff --git a/src/__tests__/session-surface-guard.test.ts b/src/__tests__/session-surface-guard.test.ts index b06ac7b19..ebf46edd7 100644 --- a/src/__tests__/session-surface-guard.test.ts +++ b/src/__tests__/session-surface-guard.test.ts @@ -271,6 +271,13 @@ describe("S2 — the URL-token authentication set is frozen", () => { // renders nothing about the profile before the preview answers. "app/auth/claim/page.tsx": "carries the handover token from the query string into the claim requests' body", + // v1.42.2 — notification quick answers. A notification app (ntfy's + // `http` action) calls it with no session, so the signed, user- and + // action-bound, short-lived token in the path is the credential. Rate + // limited before verification, single-use through the mood entry's unique + // key, and scrubbed from logs by `PATH_SECRET_PATHS`. + "app/api/notifications/action/[token]/route.ts": + "signed single-use quick-answer token from a notification action", }; /** @@ -304,6 +311,7 @@ describe("S2 — the URL-token authentication set is frozen", () => { expect(bySegment.length).toBeGreaterThan(0); expect(bySegment).toEqual([ "app/api/c/[token]/unlock/route.ts", + "app/api/notifications/action/[token]/route.ts", "app/api/whoop/webhook/[token]/route.ts", "app/api/withings/webhook/[token]/route.ts", "app/c/[token]/d/[id]/route.ts", diff --git a/src/app/api/notifications/action/[token]/route.ts b/src/app/api/notifications/action/[token]/route.ts new file mode 100644 index 000000000..bd6e22f0f --- /dev/null +++ b/src/app/api/notifications/action/[token]/route.ts @@ -0,0 +1,152 @@ +/** + * `POST /api/notifications/action/` — a quick answer tapped inside a + * notification. + * + * The caller is a notification app (ntfy's `http` action), not a browser + * with a session, so the signed path segment is the whole credential. What + * the token proves and how it is kept single-use is in + * `src/lib/notifications/action-token.ts`; this route only applies it: + * + * 1. Rate-limit per client address BEFORE looking at the token, so the + * route is no oracle for guessing one (the anonymous-surface limiter + * collapses a broken proxy trust chain into one tight bucket). + * 2. Verify the signature (constant-time) and the expiry. + * 3. Re-check what may have changed since the reminder went out: the + * account still exists and its mood module is still on. + * 4. Write the mood entry under the reminder's nonce. The unique + * `(userId, source, externalId)` key makes the first answer the only + * one: a repeat tap, or a different answer on the same reminder, is + * refused with 409 and writes nothing. + * + * The token travels in the path, so it is scrubbed from every log line by + * the `PATH_SECRET_PATHS` entry for this prefix, and the route sits on the + * URL-credential inventory in `session-surface-guard.test.ts`. + */ +import { NextRequest } from "next/server"; + +import { Prisma } from "@/generated/prisma/client"; +import { apiHandler } from "@/lib/api-handler"; +import { apiError, apiSuccess } from "@/lib/api-response"; +import { prisma } from "@/lib/db"; +import { annotate, getEvent } from "@/lib/logging/context"; +import { isModuleEnabled } from "@/lib/modules/gate"; +import { logTelegramMood } from "@/lib/mood/create-from-telegram"; +import { + MOOD_QUICK_ACTIONS, + quickActionExternalId, + verifyActionToken, +} from "@/lib/notifications/action-token"; +import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; + +export const dynamic = "force-dynamic"; + +/** Generous for one person tapping, tight for anyone enumerating. */ +const RATE_LIMIT = 20; +const RATE_WINDOW_MS = 10 * 60 * 1000; + +interface RouteContext { + params: Promise<{ token: string }>; +} + +function isUniqueViolation(err: unknown): boolean { + return ( + err instanceof Prisma.PrismaClientKnownRequestError && err.code === "P2002" + ); +} + +export const POST = apiHandler( + async (request: NextRequest, context: RouteContext) => { + annotate({ action: { name: "notification.action.invoke" } }); + + const rl = await checkAuthSurfaceRateLimit( + request, + "notification-action", + RATE_LIMIT, + RATE_WINDOW_MS, + ); + if (!rl.allowed) { + const response = apiError("Too many requests", 429); + for (const [k, v] of Object.entries(rateLimitHeaders(rl))) { + response.headers.set(k, v); + } + return response; + } + + const { token } = await context.params; + const verdict = verifyActionToken(token); + if (!verdict.ok) { + annotate({ + action: { name: "notification.action.refused" }, + meta: { reason: verdict.reason }, + }); + return verdict.reason === "expired" + ? apiError("This answer has expired", 410, { + errorCode: "notification_action.expired", + }) + : apiError("Invalid action", 401, { + errorCode: "notification_action.invalid", + }); + } + + const user = await prisma.user.findUnique({ + where: { id: verdict.userId }, + select: { id: true, timezone: true }, + }); + if (!user) { + annotate({ + action: { name: "notification.action.refused" }, + meta: { reason: "unknown_user" }, + }); + return apiError("Invalid action", 401, { + errorCode: "notification_action.invalid", + }); + } + getEvent()?.setAuth({ + user_id: user.id, + auth_method: "notification_action_token", + }); + + if (!(await isModuleEnabled(user.id, "mood"))) { + annotate({ + action: { name: "notification.action.refused" }, + meta: { reason: "module_disabled" }, + }); + return apiError("Mood tracking is turned off", 403, { + errorCode: "notification_action.module_disabled", + }); + } + + const score = MOOD_QUICK_ACTIONS[verdict.action]; + let created: boolean; + try { + const result = await logTelegramMood({ + userId: user.id, + score, + tz: user.timezone, + externalId: quickActionExternalId(verdict.nonce), + source: "NOTIFICATION", + }); + created = result.created; + } catch (err) { + // Two taps raced past the existence check; the unique key let one in. + if (!isUniqueViolation(err)) throw err; + created = false; + } + + if (!created) { + annotate({ + action: { name: "notification.action.refused" }, + meta: { reason: "already_used" }, + }); + return apiError("This reminder was already answered", 409, { + errorCode: "notification_action.already_used", + }); + } + + annotate({ + action: { name: "notification.action.mood_logged" }, + meta: { action: verdict.action }, + }); + return apiSuccess({ logged: true, action: verdict.action }); + }, +); diff --git a/src/components/mood/mood-list.tsx b/src/components/mood/mood-list.tsx index d97fe4327..03602aad7 100644 --- a/src/components/mood/mood-list.tsx +++ b/src/components/mood/mood-list.tsx @@ -1395,6 +1395,8 @@ function formatMoodSource( return t("mood.sourceTelegram"); case "DAYLIO": return t("mood.sourceDaylio"); + case "NOTIFICATION": + return t("mood.sourceNotification"); default: return source; } diff --git a/src/lib/logging/redact.ts b/src/lib/logging/redact.ts index ef4bbf2ef..84f9ce9df 100644 --- a/src/lib/logging/redact.ts +++ b/src/lib/logging/redact.ts @@ -68,6 +68,10 @@ export const PATH_SECRET_PATHS: ReadonlyArray<{ // and its last segment is a route name. A full URL carrying the token is // still scrubbed, by the `hlp_` rule in `redactSecrets`. { prefix: "/claim/", pathStart: true }, + // v1.42.2 — notification quick answers (ntfy `http` actions). The signed + // single-use action token is the request's only credential and rides the + // trailing path segment of `/api/notifications/action/`. + { prefix: "/api/notifications/action/" }, ]; function redactPathSegments(input: string): string { diff --git a/src/lib/logging/types.ts b/src/lib/logging/types.ts index 8a5119cdb..086357230 100644 --- a/src/lib/logging/types.ts +++ b/src/lib/logging/types.ts @@ -59,7 +59,8 @@ export interface WideEvent { | "api_key" | "cron_secret" | "telegram_webhook" - | "webhook_secret"; + | "webhook_secret" + | "notification_action_token"; /** * v1.36.0 — the account whose record this request read or wrote, when it * is not the caller's own. `user_id` above always stays the ACTOR: the diff --git a/src/lib/mood/create-from-telegram.ts b/src/lib/mood/create-from-telegram.ts index e54e527eb..298ac79d4 100644 --- a/src/lib/mood/create-from-telegram.ts +++ b/src/lib/mood/create-from-telegram.ts @@ -7,8 +7,9 @@ * write so the two entry points agree on the canonical shape: the same * `getScoreForMood` / `moodDateKey` derivation, the same per-user `tz` * anchoring, the same cache invalidation + DAY-rollup recompute the route - * fires. `source` is pinned to "TELEGRAM" - * (already an accepted free-text source value, no enum migration needed). + * fires. `source` is "TELEGRAM" by default; a notification quick answer + * (`/api/notifications/action/`) writes the same shape under + * "NOTIFICATION". Both are free-text source values, no enum migration. * * The `userId` is NEVER taken from the Telegram payload — the caller * passes the id it resolved from the stored `telegramChatId` binding. @@ -25,6 +26,9 @@ import { invalidateUserMood } from "@/lib/cache/invalidate"; import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups"; import { getEvent } from "@/lib/logging/context"; +/** The quick-write sources that share this helper. */ +export type QuickMoodSource = "TELEGRAM" | "NOTIFICATION"; + export interface TelegramMoodResult { /** True when a new row was written; false when the idempotency key already had one. */ created: boolean; @@ -47,8 +51,11 @@ export async function logTelegramMood(input: { /** Stable per-tap id for Telegram redelivery dedup. */ externalId: string; note?: string | null; + /** Provenance of the quick write. Default "TELEGRAM". */ + source?: QuickMoodSource; client?: PrismaClient | Prisma.TransactionClient; }): Promise { + const source = input.source ?? "TELEGRAM"; const prisma = (input.client ?? defaultPrisma) as PrismaClient; const mood = MOOD_ENUM_BY_SCORE[input.score]; if (!mood) { @@ -63,7 +70,7 @@ export async function logTelegramMood(input: { where: { userId_source_externalId: { userId: input.userId, - source: "TELEGRAM", + source, externalId: input.externalId, }, }, @@ -88,7 +95,7 @@ export async function logTelegramMood(input: { // v1.23 — encrypt the note at rest; legacy plaintext column nulled. note: null, noteEncrypted: encryptNote(input.note ?? null), - source: "TELEGRAM", + source, externalId: input.externalId, moodLoggedAt, }, diff --git a/src/lib/notifications/action-token.ts b/src/lib/notifications/action-token.ts new file mode 100644 index 000000000..9870d248c --- /dev/null +++ b/src/lib/notifications/action-token.ts @@ -0,0 +1,197 @@ +/** + * Signed, short-lived, single-use tokens behind notification quick actions. + * + * A quick action is a button inside a notification ("Good", "Okay", "Bad" + * on the mood reminder) that the notification app calls directly, with no + * HealthLog session: ntfy's `http` action POSTs to a URL and nothing else. + * The URL therefore carries the whole credential, and this module is what + * makes that safe: + * + * - Bound. The token names the user and the one action it performs. A + * token minted for one user cannot act for another (the user id is inside + * the signed payload), and a token for "okay" cannot log "bad". + * - Signed. HMAC-SHA256 over the payload, under a subkey derived from + * `API_TOKEN_HMAC_KEY` with a fixed label, so the tag can never collide + * with an OAuth state or a Bearer-token hash made from the same root key. + * Compared with `timingSafeEqual`. + * - Short-lived. The expiry is inside the signed payload; a token is good + * for `ACTION_TOKEN_TTL_MS` after the reminder was sent. + * - Single-use. The three answers of one reminder share one nonce, and the + * write it authorises is keyed on that nonce (`MoodEntry`'s unique + * `(userId, source, externalId)`), so the first answer wins and every + * later tap on the same reminder, whichever button, is refused. The + * database constraint is the arbiter, so two concurrent taps cannot both + * write. + * + * Layout: `na1.....`. Every field is + * free of `.` by construction (a cuid, a fixed action id, digits, base64url), + * so the split is unambiguous, and every field is URL-path safe. + */ +import { createHmac, randomBytes, timingSafeEqual } from "node:crypto"; + +/** + * The mood quick answers and the five-point mood scale value each one logs + * (`MOOD_ENUM_BY_SCORE`: 4 = GUT, 3 = OKAY, 2 = SCHLECHT). Three answers, + * because ntfy shows at most three actions; the scale's outer points + * (1 and 5) stay in the app and in the Telegram keyboard, which shows all + * five. + */ +export const MOOD_QUICK_ACTIONS = { + "mood-good": 4, + "mood-okay": 3, + "mood-bad": 2, +} as const; + +export type QuickActionId = keyof typeof MOOD_QUICK_ACTIONS; + +export const QUICK_ACTION_IDS = Object.keys( + MOOD_QUICK_ACTIONS, +) as QuickActionId[]; + +/** + * Six hours: long enough to answer a reminder sent at the chosen evening + * hour before going to bed, short enough that a notification left on a + * phone overnight no longer writes anything the next day. + */ +export const ACTION_TOKEN_TTL_MS = 6 * 60 * 60 * 1000; + +const VERSION = "na1"; +const SEP = "."; +const MAX_TOKEN_LENGTH = 300; +const KEY_LABEL = "healthlog:notification-action:v1"; + +function signingKey(): Buffer { + const root = process.env.API_TOKEN_HMAC_KEY; + if (!root || root.length < 32) { + throw new Error( + "API_TOKEN_HMAC_KEY must be set (>=32 chars) to sign notification actions", + ); + } + return createHmac("sha256", root).update(KEY_LABEL).digest(); +} + +function sign(payload: string): string { + return createHmac("sha256", signingKey()).update(payload).digest("base64url"); +} + +function isQuickActionId(value: string): value is QuickActionId { + return Object.prototype.hasOwnProperty.call(MOOD_QUICK_ACTIONS, value); +} + +/** A fresh nonce shared by every action offered on one notification. */ +export function newActionNonce(): string { + return randomBytes(16).toString("base64url"); +} + +export function mintActionToken(input: { + userId: string; + action: QuickActionId; + nonce: string; + now?: number; +}): string { + const expiry = (input.now ?? Date.now()) + ACTION_TOKEN_TTL_MS; + const payload = [ + VERSION, + input.userId, + input.action, + String(expiry), + input.nonce, + ].join(SEP); + return `${payload}${SEP}${sign(payload)}`; +} + +export type ActionTokenVerdict = + | { + ok: true; + userId: string; + action: QuickActionId; + nonce: string; + expiry: number; + } + | { ok: false; reason: "malformed" | "bad_signature" | "expired" }; + +/** + * Verify a token. The signature is checked before the expiry, so an expired + * verdict is only ever given for a token this server actually minted; a + * forged or altered token reads as `bad_signature` whatever its expiry says. + * Never throws on bad input (only on a missing server key, which is an + * operator misconfiguration). + */ +export function verifyActionToken( + token: string | null | undefined, + now: number = Date.now(), +): ActionTokenVerdict { + if (!token || token.length > MAX_TOKEN_LENGTH) { + return { ok: false, reason: "malformed" }; + } + const parts = token.split(SEP); + if (parts.length !== 6) return { ok: false, reason: "malformed" }; + const [version, userId, action, expiryStr, nonce, tag] = parts as [ + string, + string, + string, + string, + string, + string, + ]; + if (version !== VERSION || !userId || !nonce || !tag) { + return { ok: false, reason: "malformed" }; + } + if (!isQuickActionId(action)) return { ok: false, reason: "malformed" }; + const expiry = Number.parseInt(expiryStr, 10); + if (!Number.isFinite(expiry) || String(expiry) !== expiryStr) { + return { ok: false, reason: "malformed" }; + } + + const expected = Buffer.from( + sign([version, userId, action, expiryStr, nonce].join(SEP)), + ); + const received = Buffer.from(tag); + if ( + received.length !== expected.length || + !timingSafeEqual(received, expected) + ) { + return { ok: false, reason: "bad_signature" }; + } + if (now >= expiry) return { ok: false, reason: "expired" }; + + return { ok: true, userId, action, nonce, expiry }; +} + +/** The `MoodEntry.externalId` that makes one reminder's answers single-use. */ +export function quickActionExternalId(nonce: string): string { + return `notification-action:${nonce}`; +} + +/** The public route a quick action calls. */ +export const QUICK_ACTION_ROUTE_PREFIX = "/api/notifications/action/"; + +export interface NotificationQuickAction { + id: QuickActionId; + label: string; + /** Absolute URL the notification app POSTs to. */ + url: string; +} + +/** + * The three mood answers for one reminder, sharing one nonce. Needs the + * public origin: an app on a phone can only call an absolute URL. + */ +export function buildMoodQuickActions(input: { + userId: string; + origin: string; + labels: Record; + now?: number; +}): NotificationQuickAction[] { + const nonce = newActionNonce(); + return QUICK_ACTION_IDS.map((id) => ({ + id, + label: input.labels[id], + url: `${input.origin}${QUICK_ACTION_ROUTE_PREFIX}${mintActionToken({ + userId: input.userId, + action: id, + nonce, + now: input.now, + })}`, + })); +} diff --git a/src/lib/notifications/delivery-links.ts b/src/lib/notifications/delivery-links.ts new file mode 100644 index 000000000..fb955d4c8 --- /dev/null +++ b/src/lib/notifications/delivery-links.ts @@ -0,0 +1,105 @@ +/** + * Attach the tap target and the quick actions to a delivery. + * + * Called by the dispatcher once per recipient, after the delivery identity + * is known and before the channel cascade, so every channel of one dispatch + * carries the same target and the same (single-use) action tokens. The + * senders only translate these fields into their own wire format; none of + * them decides where a notification leads. + */ +import { prisma } from "@/lib/db"; +import { defaultLocale, locales, type Locale } from "@/lib/i18n/config"; +import { getServerTranslator } from "@/lib/i18n/server-translator"; +import { getEvent } from "@/lib/logging/context"; +import { + buildMoodQuickActions, + type QuickActionId, +} from "@/lib/notifications/action-token"; +import { isManagedGuardianDelivery } from "@/lib/notifications/managed-delivery"; +import { + absoluteAppUrl, + configuredAppOrigin, + resolveTapPath, +} from "@/lib/notifications/tap-target"; +import type { NotificationPayload } from "@/lib/notifications/types"; + +const QUICK_ACTION_LABEL_KEYS: Record = { + "mood-good": "notifications.quickActions.moodGood", + "mood-okay": "notifications.quickActions.moodOkay", + "mood-bad": "notifications.quickActions.moodBad", +}; + +async function recipientLocale(userId: string): Promise { + try { + const row = await prisma.user.findUnique({ + where: { id: userId }, + select: { locale: true }, + }); + const value = row?.locale; + if (value && (locales as readonly string[]).includes(value)) { + return value as Locale; + } + } catch { + // A failed read only costs the label its language. + } + return defaultLocale; +} + +/** + * Whether this delivery may offer the mood quick answers: a mood reminder + * delivered to the account it is about. A Guardian never gets a button that + * writes into a record they are only notified about. + */ +function offersMoodActions(payload: NotificationPayload): boolean { + return ( + payload.eventType === "MOOD_REMINDER" && + !payload.suppressActions && + !payload.discreet && + !isManagedGuardianDelivery(payload) + ); +} + +export async function withDeliveryLinks( + payload: NotificationPayload, + recipientUserId: string, + now: number = Date.now(), +): Promise { + const path = resolveTapPath(payload); + const moodActions = offersMoodActions(payload); + if (!path && !moodActions) return payload; + + const origin = configuredAppOrigin(); + const { t } = getServerTranslator(await recipientLocale(recipientUserId)); + + const next: NotificationPayload = { ...payload }; + if (path) { + next.tap = { + path, + url: absoluteAppUrl(path, origin), + label: t("notifications.links.open"), + }; + } + + if (moodActions && origin) { + try { + next.quickActions = buildMoodQuickActions({ + userId: recipientUserId, + origin, + labels: { + "mood-good": t(QUICK_ACTION_LABEL_KEYS["mood-good"]), + "mood-okay": t(QUICK_ACTION_LABEL_KEYS["mood-okay"]), + "mood-bad": t(QUICK_ACTION_LABEL_KEYS["mood-bad"]), + }, + now, + }); + } catch (err) { + // No signing key: the reminder still goes out, only without answers. + getEvent()?.addWarning( + `notification quick actions unavailable: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + } + } + return next; +} diff --git a/src/lib/notifications/dispatcher.ts b/src/lib/notifications/dispatcher.ts index e8ccc3f25..9edf7ad71 100644 --- a/src/lib/notifications/dispatcher.ts +++ b/src/lib/notifications/dispatcher.ts @@ -36,6 +36,7 @@ import { hasClientManagedApnsGate, } from "@/lib/notifications/client-managed-apns"; import { recordPushAttempt } from "@/lib/notifications/senders/push-attempt-record"; +import { withDeliveryLinks } from "@/lib/notifications/delivery-links"; import { claimManagedGuardianEgressAuthorization, completeManagedGuardianEgressAuthorization, @@ -131,8 +132,10 @@ export async function dispatchNotification( recordUserId: delivery.recordUserId, recipientUserId: delivery.recipientUserId, }; - const recipientPayload = await renderForRecipient( - deliveryPayload, + // Tap target + quick actions, resolved once so every channel of this + // delivery carries the same target and the same single-use tokens. + const recipientPayload = await withDeliveryLinks( + await renderForRecipient(deliveryPayload, delivery.recipientUserId), delivery.recipientUserId, ); const channels = await prisma.notificationChannel.findMany({ diff --git a/src/lib/notifications/senders/apns.ts b/src/lib/notifications/senders/apns.ts index 118195217..8fb2e9ac3 100644 --- a/src/lib/notifications/senders/apns.ts +++ b/src/lib/notifications/senders/apns.ts @@ -33,6 +33,7 @@ import { recordPushAttemptForPayload } from "@/lib/notifications/senders/push-at import { plainPushText } from "@/lib/notifications/strip-emoji"; import { stripHtml } from "@/lib/notifications/strip-html"; import { isManagedGuardianDelivery } from "@/lib/notifications/managed-delivery"; +import type { NotificationTap } from "@/lib/notifications/types"; export interface ApnsPayload { /** APNs `aps.alert.title` + `aps.alert.body`. */ @@ -597,6 +598,7 @@ export async function sendViaApns( suppressActions?: boolean; recordUserId?: string; recipientUserId?: string; + tap?: NotificationTap; }, ): Promise { const config = loadApnsConfig(); @@ -751,6 +753,13 @@ export async function sendViaApns( ? stripCycleMetadata(payload.metadata) : payload.metadata, ), + // The resolved tap target (`tap-target.ts`): a same-origin path + // the app opens on tap. Overrides a producer's `metadata.url`, + // which the resolver already took into account. Never on a + // managed delivery or a discreet one (the resolver returns null). + ...(!managedDelivery && payload.tap?.path + ? { url: payload.tap.path } + : {}), }, threadId: routingEvent, // The iOS app registers a `UNNotificationCategory` per event-type diff --git a/src/lib/notifications/senders/email.ts b/src/lib/notifications/senders/email.ts index 22011e45e..703edfc4f 100644 --- a/src/lib/notifications/senders/email.ts +++ b/src/lib/notifications/senders/email.ts @@ -163,7 +163,11 @@ export async function sendViaEmail( // Plain text only — strip HTML + decorative emoji on routine reminders. const subject = plainPushText(payload.title, payload.eventType); - const text = plainPushText(stripHtml(payload.message), payload.eventType); + const body = plainPushText(stripHtml(payload.message), payload.eventType); + // The tap target as a plain link line (plain text body, no markup). + const text = payload.tap?.url + ? `${body}\n\n${payload.tap.label}: ${payload.tap.url}` + : body; try { await transport.transporter.sendMail({ diff --git a/src/lib/notifications/senders/ntfy.ts b/src/lib/notifications/senders/ntfy.ts index a2fe63401..13b1fab78 100644 --- a/src/lib/notifications/senders/ntfy.ts +++ b/src/lib/notifications/senders/ntfy.ts @@ -58,7 +58,7 @@ export async function sendViaNtfy( ? "reminder" : payload.eventType.toLowerCase().replace(/_/g, "-"); const headers: Record = { - Title: plainPushText(payload.title, payload.eventType), + Title: ntfyHeaderValue(plainPushText(payload.title, payload.eventType)), Priority: priority, // Discreet mode (cycle privacy): the X-Tags header is visible on the // lock screen, so collapse it to a generic tag instead of leaking the @@ -71,6 +71,16 @@ export async function sendViaNtfy( headers["Authorization"] = `Bearer ${config.authToken}`; } + // Tapping the notification opens this URL (ntfy `Click`). Absent when + // the operator has not configured a public origin. + if (payload.tap?.url) { + headers["Click"] = payload.tap.url; + } + const actions = buildNtfyActionsHeader(payload); + if (actions) { + headers["Actions"] = ntfyHeaderValue(actions); + } + // Strip HTML tags for ntfy (plain text only) + emoji on routine reminders const body = plainPushText(stripHtml(payload.message), payload.eventType); @@ -187,3 +197,51 @@ export async function sendViaNtfy( }; } } + +/** ntfy shows at most three actions per notification. */ +const NTFY_MAX_ACTIONS = 3; + +/** + * A label safe inside the `Actions` header's short format: `,` and `;` + * separate fields and actions there, and a quote would open a quoted value. + */ +function ntfyActionLabel(label: string): string { + return label.replace(/[,;"'\r\n]/g, " ").trim() || "?"; +} + +/** + * The `Actions` header for the payload's quick answers, in ntfy's short + * format (https://docs.ntfy.sh/publish/#action-buttons): + * + * http, + } + /> +

{t("notifications.content.detail")}

+ {msg && ( +

+ {msg} +

+ )} + + ); +} diff --git a/src/components/settings/notifications-section.tsx b/src/components/settings/notifications-section.tsx index 09989f9da..b52b26676 100644 --- a/src/components/settings/notifications-section.tsx +++ b/src/components/settings/notifications-section.tsx @@ -1,13 +1,14 @@ "use client"; import Link from "next/link"; -import { Bell } from "lucide-react"; +import { Bell, Smartphone } from "lucide-react"; import { useAuth } from "@/hooks/use-auth"; import { useTranslations } from "@/lib/i18n/context"; import { LowStockCard } from "@/components/settings/low-stock-card"; import { SectionHeading } from "@/components/ui/section-heading"; import { MoodReminderCard } from "@/components/settings/mood-reminder-card"; +import { NotificationContentCard } from "@/components/settings/notification-content-card"; import { Button } from "@/components/ui/button"; /** @@ -72,6 +73,18 @@ export function NotificationsSection() { ) : null} ) : null} + + {/* Group 2 — How notifications look on the phone. Not module-gated: + it applies to every notification the account receives. */} +
+ +
+ +
+
{/* Cross-links to the other two notification surfaces. At the END of the page: a screen that opens with two ways off it never says what it is for. */} diff --git a/src/lib/daily/daily-briefing-push.ts b/src/lib/daily/daily-briefing-push.ts index 003854f8d..5e9c2eea2 100644 --- a/src/lib/daily/daily-briefing-push.ts +++ b/src/lib/daily/daily-briefing-push.ts @@ -16,7 +16,8 @@ * `ok` ledger row; the frequency-cap check suppresses the second — one push per * user per local day, no "you haven't opened the app" second push, ever. * - * Privacy + calm posture (load-bearing): the body is `digest.line`, which + * Privacy + calm posture (load-bearing): the title carries the score only + * when the user chose detailed notifications; the body is `digest.line`, which * carries no number by construction (a briefing lead sentence or the top * signal's headline without a figure in it, "your score is ready", or the * honest all-clear — never a reading, a dose or the score itself, since the @@ -39,6 +40,8 @@ import { dispatchNotification } from "@/lib/notifications/dispatcher"; import { loadDailyDigest } from "@/lib/daily/load-digest"; import type { DailyDigest } from "@/lib/daily/digest"; import { PRIORITY_ITEM_KINDS } from "@/lib/daily/priority-item"; +import { SCORE_BANDS } from "@/lib/analytics/score/record"; +import { notificationsShowFigures } from "@/lib/validations/notification-prefs"; /** * The local-morning window this nudge may fire in, [earliest, latest). Both the @@ -115,14 +118,37 @@ export function digestHasSubstance(digest: DailyDigest): boolean { * still provisional (last night's sleep not yet in), the honest sleep-pending * wording is appended so the read is never silently presented as final. */ +function isScoreBand(band: string): band is (typeof SCORE_BANDS)[number] { + return (SCORE_BANDS as readonly string[]).includes(band); +} + export function buildDailyBriefingPush( digest: DailyDigest, t: ServerTranslator["t"], + options: { showFigures?: boolean } = {}, ): { title: string; body: string } { - const title = t("daily.push.title"); + // The score rides the title only when the user chose detailed + // notifications (`notificationPrefs.content.detail`); the default keeps the + // lock screen free of figures. The body stays `digest.line`, number-free by + // construction, so it reads as the one-line label under the score. + const title = + options.showFigures && digest.score + ? t("daily.push.titleScore", { + score: String(Math.round(digest.score.value)), + }) + : t("daily.push.title"); + // With the score in the title, the floor line "your score is ready" only + // repeats it; the band says something instead. + const line = + options.showFigures && + digest.score && + isScoreBand(digest.score.band) && + digest.line === t("daily.line.scoreReady") + ? t(`daily.push.band.${digest.score.band}`) + : digest.line; const body = digest.sleepPending - ? t("daily.push.bodyProvisional", { line: digest.line }) - : digest.line; + ? t("daily.push.bodyProvisional", { line }) + : line; return { title, body }; } @@ -217,7 +243,9 @@ export async function maybeDispatchDailyBriefing( } const { t } = getServerTranslator(locale); - const { title, body } = buildDailyBriefingPush(digest, t); + const { title, body } = buildDailyBriefingPush(digest, t, { + showFigures: notificationsShowFigures(user.notificationPrefs), + }); const outcome = await dispatch({ // NON-URGENT by construction: no `urgent` flag, and DAILY_BRIEFING is not diff --git a/src/lib/geo.ts b/src/lib/geo.ts index b8c647e14..fcae66f66 100644 --- a/src/lib/geo.ts +++ b/src/lib/geo.ts @@ -427,7 +427,10 @@ async function evaluateOfflineGeoNotice(): Promise { host: resolveGeoProviderHost(), docsUrl: GEOLITE2_DOCS_URL, }, - metadata: { source: "geo-offline-detection" }, + metadata: { + source: "geo-offline-detection", + url: "/admin/system-status", + }, }); } } catch (err) { diff --git a/src/lib/illness/red-flag-notify.ts b/src/lib/illness/red-flag-notify.ts index 19ff68069..4558826df 100644 --- a/src/lib/illness/red-flag-notify.ts +++ b/src/lib/illness/red-flag-notify.ts @@ -80,6 +80,8 @@ export async function notifyIllnessRedFlag(input: { messageKey, eventType: "SYSTEM_ALERT", urgent: true, + // Tap target: the illness episode the red flag belongs to. + metadata: { url: `/illness/${encodeURIComponent(episodeId)}` }, }); getEvent()?.addMeta("illness_red_flag_notified", episodeId); diff --git a/src/lib/illness/safety-floor-notify.ts b/src/lib/illness/safety-floor-notify.ts index 532d1bafd..61517210e 100644 --- a/src/lib/illness/safety-floor-notify.ts +++ b/src/lib/illness/safety-floor-notify.ts @@ -133,6 +133,8 @@ export async function notifySafetyFloor(input: { eventType: "SYSTEM_ALERT", managedFanoutEvent: "SAFETY_FLOOR_ALERT", urgent: true, + // Tap target: the readings the alert is about. + metadata: { url: "/measurements" }, }); getEvent()?.addMeta("safety_floor_notified", decision.reason); diff --git a/src/lib/integrations/__tests__/status.test.ts b/src/lib/integrations/__tests__/status.test.ts index dc672b92f..88eb2b38d 100644 --- a/src/lib/integrations/__tests__/status.test.ts +++ b/src/lib/integrations/__tests__/status.test.ts @@ -209,6 +209,8 @@ describe("recordSyncFailure — at threshold", () => { affectedUserId: "u1", consecutiveFailures: 3, errorCode: "503", + // The affected user is not the admin, so the tap opens the admin page. + url: "/admin/integrations", }); // alertedAt is stamped to gate re-alerting for 24h. diff --git a/src/lib/integrations/status.ts b/src/lib/integrations/status.ts index 96e730cb5..d00fbdad5 100644 --- a/src/lib/integrations/status.ts +++ b/src/lib/integrations/status.ts @@ -1277,7 +1277,15 @@ async function maybeAlertAdmins(input: AlertInput): Promise { userId: admin.id, title: payload.title, message: payload.message, - metadata: payload.metadata, + metadata: { + ...payload.metadata, + // Tap target: an admin whose own sync fails fixes it in their own + // integration settings; anyone else's shows on the admin page. + url: + admin.id === input.userId + ? "/settings/integrations" + : "/admin/integrations", + }, }); outcome.dispatched ||= adminOutcome.dispatched; outcome.channelsAttempted += adminOutcome.channelsAttempted; diff --git a/src/lib/jobs/tls-pin-monitor.ts b/src/lib/jobs/tls-pin-monitor.ts index 095fe8334..3c1c5e394 100644 --- a/src/lib/jobs/tls-pin-monitor.ts +++ b/src/lib/jobs/tls-pin-monitor.ts @@ -95,6 +95,7 @@ async function alertAdminsOfPinChange( }, metadata: { source: "tls-pin-monitor", + url: "/admin/system-status", host, servedPin: probe.pin, knownPins: knownPins.join(","), diff --git a/src/lib/openapi/routes/profile.ts b/src/lib/openapi/routes/profile.ts index 12d7da622..e249366d4 100644 --- a/src/lib/openapi/routes/profile.ts +++ b/src/lib/openapi/routes/profile.ts @@ -335,6 +335,13 @@ const notificationPrefsResolved = z .enum(["weekly", "biweekly"]) .describe("Nudge frequency cap: one per 7 or per 14 days."), }), + content: z.object({ + detail: z + .enum(["generic", "detailed"]) + .describe( + '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.', + ), + }), }) .meta({ id: "NotificationPrefs", diff --git a/src/lib/validations/notification-prefs.ts b/src/lib/validations/notification-prefs.ts index af346cd24..ef800422d 100644 --- a/src/lib/validations/notification-prefs.ts +++ b/src/lib/validations/notification-prefs.ts @@ -11,7 +11,8 @@ * { * "medication": { "clientManaged": boolean }, * "mood": { "reminderHour": 0..23 }, - * "cycle": { "clientManaged": boolean } + * "cycle": { "clientManaged": boolean }, + * "content": { "detail": "generic" | "detailed" } * } * * The schema is intentionally additive — every new category grows the @@ -139,6 +140,23 @@ const coachPrefsSchema = z }) .partial(); +/** + * v1.42.2 — what a notification may show. `generic` (the default) says what + * the notification is about and carries no health figure, so a lock screen + * reads "Your morning briefing" rather than a score; `detailed` adds the + * figures (the morning score). Tap targets and quick answers are the same in + * both. Read by the producers that have a figure to show. + */ +export const NOTIFICATION_CONTENT_DETAILS = ["generic", "detailed"] as const; +export type NotificationContentDetail = + (typeof NOTIFICATION_CONTENT_DETAILS)[number]; + +const contentPrefsSchema = z + .object({ + detail: z.enum(NOTIFICATION_CONTENT_DETAILS), + }) + .partial(); + /** * v1.7.0 — per-device delivery override schema for the device PATCH. * NULL clears the override (the device inherits the user-level roaming @@ -159,6 +177,7 @@ export const notificationPrefsSchema = z cycle: cyclePrefsSchema, coach: coachPrefsSchema, measurementReminder: measurementReminderPrefsSchema, + content: contentPrefsSchema, }) .partial(); @@ -225,6 +244,10 @@ export interface NotificationPrefs { */ clientManaged: boolean; }; + content: { + /** v1.42.2 — whether notifications may carry health figures. */ + detail: NotificationContentDetail; + }; } /** @@ -277,6 +300,9 @@ export const DEFAULT_NOTIFICATION_PREFS: NotificationPrefs = { measurementReminder: { clientManaged: false, }, + content: { + detail: "generic", + }, }; /** @@ -325,6 +351,10 @@ export function resolveNotificationPrefs( ...base.measurementReminder, ...(incoming.measurementReminder ?? {}), }, + content: { + ...base.content, + ...(incoming.content ?? {}), + }, }); } @@ -354,6 +384,7 @@ function applyDeliveryDefaultMapping( cycle: { ...prefs.cycle }, coach: { ...prefs.coach }, measurementReminder: { ...prefs.measurementReminder }, + content: { ...prefs.content }, }; } @@ -492,6 +523,7 @@ function cloneDefaults(): NotificationPrefs { measurementReminder: { ...DEFAULT_NOTIFICATION_PREFS.measurementReminder, }, + content: { ...DEFAULT_NOTIFICATION_PREFS.content }, }; } @@ -517,5 +549,17 @@ function mergeOverDefaults(input: NotificationPrefsInput): NotificationPrefs { ...DEFAULT_NOTIFICATION_PREFS.measurementReminder, ...(input.measurementReminder ?? {}), }, + content: { + ...DEFAULT_NOTIFICATION_PREFS.content, + ...(input.content ?? {}), + }, }); } + +/** + * v1.42.2 — producer-side helper. Whether this user's notifications may carry + * health figures. A null / drifted row resolves to the default (`generic`). + */ +export function notificationsShowFigures(raw: unknown): boolean { + return parseNotificationPrefs(raw).content.detail === "detailed"; +} From 543589027597f3334bf4566da5634e8ea66556c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 11 Oct 2026 12:33:02 +0200 Subject: [PATCH 3/5] test(notifications): cover tap targets, quick-answer tokens and the action route; document them --- docs/api/openapi.yaml | 112 ++++++++++++ docs/self-hosting/notifications.md | 89 ++++++++++ .../action/[token]/__tests__/route.test.ts | 154 ++++++++++++++++ .../__tests__/daily-briefing-push.test.ts | 63 +++++++ .../__tests__/action-token.test.ts | 168 ++++++++++++++++++ .../__tests__/delivery-links.test.ts | 115 ++++++++++++ .../__tests__/tap-target.test.ts | 166 +++++++++++++++++ .../__tests__/web-push-dose-clear.test.ts | 33 ++++ .../senders/__tests__/apns.test.ts | 46 +++++ .../senders/__tests__/email.test.ts | 22 +++ .../senders/__tests__/ntfy.test.ts | 85 +++++++++ .../senders/__tests__/telegram.test.ts | 114 ++++++++++++ .../senders/__tests__/webhook.test.ts | 29 +++ src/lib/notifications/tap-target.ts | 1 - src/lib/openapi/routes/notifications.ts | 2 +- .../notification-action-mood.test.ts | 103 +++++++++++ 16 files changed, 1300 insertions(+), 2 deletions(-) create mode 100644 src/app/api/notifications/action/[token]/__tests__/route.test.ts create mode 100644 src/lib/notifications/__tests__/action-token.test.ts create mode 100644 src/lib/notifications/__tests__/delivery-links.test.ts create mode 100644 src/lib/notifications/__tests__/tap-target.test.ts create mode 100644 tests/integration/notification-action-mood.test.ts 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); + }); +}); From 274cf4900e77117760c1af196e51f5d54a240584 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 11 Oct 2026 12:46:12 +0200 Subject: [PATCH 4/5] test(notifications): pin the content preference in backups and the action error codes --- docs/api/openapi.yaml | 59 ++++++++++--------- .../api/notifications/action/[token]/route.ts | 10 ++-- .../__tests__/account-settings-backup.test.ts | 19 ++++++ src/lib/notifications/senders/telegram.ts | 4 +- src/lib/openapi/error-codes.ts | 6 ++ src/lib/telegram.ts | 3 +- .../__tests__/notification-prefs.test.ts | 36 +++++++++++ 7 files changed, 100 insertions(+), 37 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 9b95a5e98..6ccc776a9 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -34434,35 +34434,36 @@ components: mood.create.external_id_not_delegable, mood.create.invalid, mood.duplicate_timestamp, mood.linkedContext.invalid, mood.list.invalid, mood.not_found, mood.ratedFactor.out_of_range, mood.restore.invalid, mood.update.invalid; notification-prefs — notification-prefs.body.invalid_json; - nutrient — nutrient.batch.invalid, nutrient.batch.too_large, nutrient.daily.invalid, - nutrient.read.invalid, nutrient.water.invalid, nutrient.water.invalid_day; onboarding — - onboarding.answers.invalid, onboarding.answers.rateLimited, onboarding.complete.invalid, - onboarding.complete.recordNotFound, onboarding.complete.recordTargetMismatch, - onboarding.disclaimer.invalid, onboarding.restart.invalid, onboarding.restart.rateLimited, - onboarding.tour.invalid; passkey — passkey.challenge.expired, passkey.response.invalid, passkey.unknown, - passkey.verification.failed; personal_records — personal_records.invalid_query; practitioner — - practitioner.invalid; profile — profile.update.emailInUse, profile.update.emailRateLimited, - profile.update.emailSecondFactorRequired, profile.update.invalidBody, profile.update.nothingSaved; - profile_claim — profile_claim.invalid, profile_claim.oidc_only_unsupported, profile_claim.taken; - record_write — record_write.rate_limited; report-selection — report-selection.body.invalid_json, - report-selection.body.invalid_shape, report-selection.leaves.unknown; route — route.retired; share-link - — share-link.document.decryptFailed, share-link.document.notFound, share-link.document.rateLimited, - share-link.documents.invalid, share-link.report.notFound, share-link.report.rateLimited, - share-link.selection.forbidden_leaf, share-link.selection.unknown_leaf; sharing — - sharing.accept.expired, sharing.accept.not_found, sharing.accept.not_pending, sharing.accept.revoked, - sharing.access.denied, sharing.invite.duplicate, sharing.invite.invalid, sharing.invite.invalid_scope, - sharing.invite.manage_browser_only, sharing.invite.refused, sharing.invite.self, sharing.not_permitted, - sharing.renounce.already_ended, sharing.renounce.not_found, sharing.revoke.already_ended, - sharing.revoke.not_found, sharing.session.changed, sharing.switch.invalid, - sharing.switch.wrong_transport; source-priority — source-priority.body.invalid_json, - source-priority.body.invalid_shape; symptoms — symptoms.definition.limitReached, - symptoms.definition.notFound, symptoms.episode.notOpen, symptoms.event.notFound, symptoms.invalid; - timeline — timeline.invalid; tokens — tokens.documents.ceiling_reached, - tokens.measurements.ceiling_reached, tokens.workouts.ceiling_reached; vaccination — - vaccination.booster-invalid, vaccination.booster-no-antigen, vaccination.booster-out-of-scope, - vaccination.custom-vaccine-not-found, vaccination.custom.invalid, vaccination.custom.name-taken, - vaccination.custom.not-found, vaccination.encounter-not-found, vaccination.identity-required, - vaccination.invalid, vaccination.link.invalid, vaccination.not-found, + notification — notification.action.already_used, notification.action.expired, + notification.action.invalid, notification.action.module_disabled; nutrient — nutrient.batch.invalid, + nutrient.batch.too_large, nutrient.daily.invalid, nutrient.read.invalid, nutrient.water.invalid, + nutrient.water.invalid_day; onboarding — onboarding.answers.invalid, onboarding.answers.rateLimited, + onboarding.complete.invalid, onboarding.complete.recordNotFound, + onboarding.complete.recordTargetMismatch, onboarding.disclaimer.invalid, onboarding.restart.invalid, + onboarding.restart.rateLimited, onboarding.tour.invalid; passkey — passkey.challenge.expired, + passkey.response.invalid, passkey.unknown, passkey.verification.failed; personal_records — + personal_records.invalid_query; practitioner — practitioner.invalid; profile — + profile.update.emailInUse, profile.update.emailRateLimited, profile.update.emailSecondFactorRequired, + profile.update.invalidBody, profile.update.nothingSaved; profile_claim — profile_claim.invalid, + profile_claim.oidc_only_unsupported, profile_claim.taken; record_write — record_write.rate_limited; + report-selection — report-selection.body.invalid_json, report-selection.body.invalid_shape, + report-selection.leaves.unknown; route — route.retired; share-link — share-link.document.decryptFailed, + share-link.document.notFound, share-link.document.rateLimited, share-link.documents.invalid, + share-link.report.notFound, share-link.report.rateLimited, share-link.selection.forbidden_leaf, + share-link.selection.unknown_leaf; sharing — sharing.accept.expired, sharing.accept.not_found, + sharing.accept.not_pending, sharing.accept.revoked, sharing.access.denied, sharing.invite.duplicate, + sharing.invite.invalid, sharing.invite.invalid_scope, sharing.invite.manage_browser_only, + sharing.invite.refused, sharing.invite.self, sharing.not_permitted, sharing.renounce.already_ended, + sharing.renounce.not_found, sharing.revoke.already_ended, sharing.revoke.not_found, + sharing.session.changed, sharing.switch.invalid, sharing.switch.wrong_transport; source-priority — + source-priority.body.invalid_json, source-priority.body.invalid_shape; symptoms — + symptoms.definition.limitReached, symptoms.definition.notFound, symptoms.episode.notOpen, + symptoms.event.notFound, symptoms.invalid; timeline — timeline.invalid; tokens — + tokens.documents.ceiling_reached, tokens.measurements.ceiling_reached, tokens.workouts.ceiling_reached; + vaccination — vaccination.booster-invalid, vaccination.booster-no-antigen, + vaccination.booster-out-of-scope, vaccination.custom-vaccine-not-found, vaccination.custom.invalid, + vaccination.custom.name-taken, vaccination.custom.not-found, vaccination.encounter-not-found, + vaccination.identity-required, vaccination.invalid, vaccination.link.invalid, vaccination.not-found, vaccination.practitioner-not-found; workout — workout.batch.invalid, workout.batch.payload_too_large, workout.batch.source_not_permitted, workout.batch.too_large, workout.delete.synced_source; (unprefixed) — about_me_conflict, ai_response_truncated, coach_prefs_conflict, dashboard_layout_conflict, empty_file, diff --git a/src/app/api/notifications/action/[token]/route.ts b/src/app/api/notifications/action/[token]/route.ts index bd6e22f0f..f10eb613a 100644 --- a/src/app/api/notifications/action/[token]/route.ts +++ b/src/app/api/notifications/action/[token]/route.ts @@ -81,10 +81,10 @@ export const POST = apiHandler( }); return verdict.reason === "expired" ? apiError("This answer has expired", 410, { - errorCode: "notification_action.expired", + errorCode: "notification.action.expired", }) : apiError("Invalid action", 401, { - errorCode: "notification_action.invalid", + errorCode: "notification.action.invalid", }); } @@ -98,7 +98,7 @@ export const POST = apiHandler( meta: { reason: "unknown_user" }, }); return apiError("Invalid action", 401, { - errorCode: "notification_action.invalid", + errorCode: "notification.action.invalid", }); } getEvent()?.setAuth({ @@ -112,7 +112,7 @@ export const POST = apiHandler( meta: { reason: "module_disabled" }, }); return apiError("Mood tracking is turned off", 403, { - errorCode: "notification_action.module_disabled", + errorCode: "notification.action.module_disabled", }); } @@ -139,7 +139,7 @@ export const POST = apiHandler( meta: { reason: "already_used" }, }); return apiError("This reminder was already answered", 409, { - errorCode: "notification_action.already_used", + errorCode: "notification.action.already_used", }); } diff --git a/src/lib/export/__tests__/account-settings-backup.test.ts b/src/lib/export/__tests__/account-settings-backup.test.ts index 16003f267..abb0594a7 100644 --- a/src/lib/export/__tests__/account-settings-backup.test.ts +++ b/src/lib/export/__tests__/account-settings-backup.test.ts @@ -22,6 +22,7 @@ import { import { UNREADABLE_EXPORT_MARKER } from "@/lib/export/unreadable-marker"; import { openLocation, sealLocation } from "@/lib/environment/location-cipher"; import { reasoningEffortFor } from "@/lib/ai/reasoning-effort"; +import { notificationsShowFigures } from "@/lib/validations/notification-prefs"; import { coachReasoningLevel, parseCoachPrefs, @@ -251,6 +252,24 @@ describe("admitAccountSettings", () => { expect(coachReasoningLevel(parseCoachPrefs(restored))).toBe("high"); }); + it("carries the notification content detail through a round trip", async () => { + const prefs = { + mood: { reminderHour: 21 }, + content: { detail: "detailed" }, + }; + const { accountSettings } = await buildAccountSettingsBackupSection( + prismaReturning(rowWith({ notificationPrefs: prefs })), + "u1", + { purpose: "portable-export" }, + ); + const restored = admitAccountSettings( + JSON.parse(JSON.stringify(accountSettings)), + ctx, + ).data.notificationPrefs; + expect(restored).toEqual(prefs); + expect(notificationsShowFigures(restored)).toBe(true); + }); + it("restores a hidden dashboard top card with the rest of the layout", () => { const layout = { version: 1, diff --git a/src/lib/notifications/senders/telegram.ts b/src/lib/notifications/senders/telegram.ts index a572e479e..a754bde89 100644 --- a/src/lib/notifications/senders/telegram.ts +++ b/src/lib/notifications/senders/telegram.ts @@ -212,7 +212,9 @@ export async function sendViaTelegram( // Tap target. Telegram has no "tap opens a URL" for a message, so the // target rides an inline URL button under it (below any action row), or a // text link where Telegram would refuse the button (see `telegramLinkMode`). - const linkMode = payload.tap?.url ? telegramLinkMode(payload.tap.url) : "none"; + const linkMode = payload.tap?.url + ? telegramLinkMode(payload.tap.url) + : "none"; const linkedKeyboard = linkMode === "button" && payload.tap?.url ? { diff --git a/src/lib/openapi/error-codes.ts b/src/lib/openapi/error-codes.ts index e0ae588ad..117d3247e 100644 --- a/src/lib/openapi/error-codes.ts +++ b/src/lib/openapi/error-codes.ts @@ -296,6 +296,12 @@ export const ERROR_CODE_CATALOGUE: Readonly> = "mood.update.invalid", ], "notification-prefs": ["notification-prefs.body.invalid_json"], + notification: [ + "notification.action.already_used", + "notification.action.expired", + "notification.action.invalid", + "notification.action.module_disabled", + ], nutrient: [ "nutrient.batch.invalid", "nutrient.batch.too_large", diff --git a/src/lib/telegram.ts b/src/lib/telegram.ts index 6e0694785..af1067740 100644 --- a/src/lib/telegram.ts +++ b/src/lib/telegram.ts @@ -13,8 +13,7 @@ interface TelegramResponse { /** A callback button, or a button that opens a URL (`https` only). */ type TelegramInlineKeyboardButton = - | { text: string; callback_data: string } - | { text: string; url: string }; + { text: string; callback_data: string } | { text: string; url: string }; interface TelegramReplyMarkup { inline_keyboard: TelegramInlineKeyboardButton[][]; diff --git a/src/lib/validations/__tests__/notification-prefs.test.ts b/src/lib/validations/__tests__/notification-prefs.test.ts index 357eb798b..db7dc8513 100644 --- a/src/lib/validations/__tests__/notification-prefs.test.ts +++ b/src/lib/validations/__tests__/notification-prefs.test.ts @@ -15,6 +15,7 @@ import { resolveLowStockRunwayDays, resolveMoodReminderHour, resolveNotificationPrefs, + notificationsShowFigures, resolveReorderLeadDays, } from "../notification-prefs"; @@ -114,6 +115,7 @@ describe("parseNotificationPrefs", () => { measurementReminder: { clientManaged: false, }, + content: { detail: "generic" }, }); }); @@ -138,6 +140,7 @@ describe("parseNotificationPrefs", () => { measurementReminder: { clientManaged: false, }, + content: { detail: "generic" }, }); }); @@ -164,6 +167,7 @@ describe("parseNotificationPrefs", () => { measurementReminder: { clientManaged: false, }, + content: { detail: "generic" }, }); }); @@ -206,6 +210,7 @@ describe("resolveNotificationPrefs (deep-merge)", () => { measurementReminder: { clientManaged: false, }, + content: { detail: "generic" }, }); }); @@ -236,6 +241,7 @@ describe("resolveNotificationPrefs (deep-merge)", () => { measurementReminder: { clientManaged: false, }, + content: { detail: "generic" }, }); }); @@ -263,6 +269,7 @@ describe("resolveNotificationPrefs (deep-merge)", () => { measurementReminder: { clientManaged: false, }, + content: { detail: "generic" }, }); }); @@ -290,6 +297,7 @@ describe("resolveNotificationPrefs (deep-merge)", () => { measurementReminder: { clientManaged: false, }, + content: { detail: "generic" }, }); }); @@ -318,6 +326,7 @@ describe("resolveNotificationPrefs (deep-merge)", () => { measurementReminder: { clientManaged: false, }, + content: { detail: "generic" }, }); }); @@ -645,3 +654,30 @@ describe("lowStockRunwayDays — v1.16.11 low-stock threshold", () => { }); }); }); + +describe("notificationsShowFigures (content.detail)", () => { + it("defaults to generic: no figures for a null, empty or drifted row", () => { + expect(notificationsShowFigures(null)).toBe(false); + expect(notificationsShowFigures({})).toBe(false); + expect(notificationsShowFigures({ content: { detail: "all" } })).toBe( + false, + ); + }); + + it("shows figures only when the user chose detailed", () => { + expect(notificationsShowFigures({ content: { detail: "detailed" } })).toBe( + true, + ); + expect(notificationsShowFigures({ content: { detail: "generic" } })).toBe( + false, + ); + }); + + it("survives a merge that touches another category", () => { + const merged = resolveNotificationPrefs( + { content: { detail: "detailed" } }, + { mood: { reminderHour: 8 } }, + ); + expect(merged.content).toEqual({ detail: "detailed" }); + }); +}); From 8f99b81f5fbc9971e46780ce0d709cb37ecf4495 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 11 Oct 2026 12:46:43 +0200 Subject: [PATCH 5/5] test(logging): scrub the notification action token from logged paths --- src/lib/logging/__tests__/redact.test.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/lib/logging/__tests__/redact.test.ts b/src/lib/logging/__tests__/redact.test.ts index bfe2d415e..911136e31 100644 --- a/src/lib/logging/__tests__/redact.test.ts +++ b/src/lib/logging/__tests__/redact.test.ts @@ -288,3 +288,20 @@ describe("redactOptional", () => { expect(redactOptional("Bearer x")).toBe("Bearer [REDACTED]"); }); }); + +describe("notification quick-answer token in the path", () => { + it("scrubs the signed action token from the path and a full URL", () => { + const token = "na1.user_1.mood-good.1760000000000.bm9uY2U.c2lnbmF0dXJl"; + expect(redactSecrets(`/api/notifications/action/${token}`)).toBe( + "/api/notifications/action/[REDACTED]", + ); + expect( + redactSecrets( + `POST https://health.example/api/notifications/action/${token}`, + ), + ).toBe("POST https://health.example/api/notifications/action/[REDACTED]"); + expect(PATH_SECRET_PATHS.map((p) => p.prefix)).toContain( + "/api/notifications/action/", + ); + }); +});