diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 734d96df3..6ccc776a9 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 @@ -34373,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, @@ -52677,6 +52739,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 +52764,7 @@ components: - mood - cycle - coach + - content additionalProperties: false PatchNotificationPrefsResponse: type: object @@ -65116,6 +65192,7 @@ components: - WEB - TELEGRAM - DAYLIO + - NOTIFICATION externalId: anyOf: - type: string @@ -78271,6 +78348,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/messages/de.json b/messages/de.json index 8395f39a5..1e0b7f437 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1229,7 +1229,8 @@ "bodyLogged": "Im Krankheitstagebuch erfasst: {count} Symptome", "bodyNotLogged": "Für diesen Tag ist im Krankheitstagebuch nichts erfasst", "openIllness": "Öffnen" - } + }, + "sourceNotification": "Benachrichtigung" }, "medications": { "title": "Medikamente", @@ -5325,7 +5326,8 @@ "subtitle": "Wähle, welche Erinnerungen du erhältst und wohin sie zugestellt werden.", "remindersHeading": "Erinnerungen", "matrixLink": "Benachrichtigungen je Kanal", - "channelsLink": "Zustellkanäle" + "channelsLink": "Zustellkanäle", + "displayHeading": "Auf dem Handy" }, "layout": { "title": "Layout", @@ -8459,6 +8461,25 @@ "changedRead": "Du kannst die Gesundheitsakte der Person, die du betreut hast, jetzt ansehen, aber nicht ändern.", "changedManage": "Du kannst die Gesundheitsakte der Person, die du betreut hast, verwalten." } + }, + "links": { + "open": "In HealthLog öffnen" + }, + "quickActions": { + "moodGood": "Gut", + "moodOkay": "Okay", + "moodBad": "Schlecht" + }, + "content": { + "title": "Gesundheitswerte in Benachrichtigungen", + "description": "Ob eine Benachrichtigung Werte wie deinen Morgenscore zeigen darf.", + "detail": "Aus: Eine Benachrichtigung sagt nur, worum es geht, der Sperrbildschirm zeigt keine Gesundheitswerte. An: Sie nennt auch die Werte, etwa den Morgenscore im Überblick. Ein Tipp auf die Benachrichtigung öffnet in beiden Fällen die passende Stelle in der App.", + "toggleAria": "Gesundheitswerte in Benachrichtigungen zeigen", + "statusDetailed": "Mit Werten", + "statusGeneric": "Ohne Werte", + "detailedToast": "Benachrichtigungen zeigen jetzt Gesundheitswerte.", + "genericToast": "Benachrichtigungen zeigen keine Gesundheitswerte mehr.", + "saveError": "Speichern fehlgeschlagen. Bitte später erneut versuchen." } }, "i18n": { @@ -11261,7 +11282,13 @@ }, "push": { "title": "Dein Morgenüberblick", - "bodyProvisional": "{line}\nDer Schlaf von letzter Nacht fehlt noch." + "bodyProvisional": "{line}\nDer Schlaf von letzter Nacht fehlt noch.", + "titleScore": "Morgenscore {score}", + "band": { + "green": "Heute sieht es stabil aus.", + "yellow": "Heute lohnt sich ein Blick auf ein paar Dinge.", + "red": "Heute brauchen einige Bereiche Aufmerksamkeit." + } }, "item": { "doseWindow": { diff --git a/messages/en.json b/messages/en.json index 164ca09ff..b4ade2b97 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1229,7 +1229,8 @@ "bodyLogged": "Logged in the illness journal: {count} symptoms", "bodyNotLogged": "Nothing is logged in the illness journal for this day", "openIllness": "Open" - } + }, + "sourceNotification": "Notification" }, "medications": { "title": "Medications", @@ -5325,7 +5326,8 @@ "subtitle": "Choose which reminders you receive and where they're delivered.", "remindersHeading": "Reminders", "matrixLink": "Per-channel notifications", - "channelsLink": "Delivery channels" + "channelsLink": "Delivery channels", + "displayHeading": "On your phone" }, "layout": { "title": "Layout", @@ -8459,6 +8461,25 @@ "changedRead": "You can now view the health record of the person you looked after, but not change it.", "changedManage": "You can manage the health record of the person you looked after." } + }, + "links": { + "open": "Open in HealthLog" + }, + "quickActions": { + "moodGood": "Good", + "moodOkay": "Okay", + "moodBad": "Bad" + }, + "content": { + "title": "Health figures in notifications", + "description": "Whether a notification may show values such as your morning score.", + "detail": "Off, a notification only says what it is about, so a lock screen shows no health figures. On, it adds the figures, for example the morning score in the briefing. Tapping a notification opens the right place in the app either way.", + "toggleAria": "Show health figures in notifications", + "statusDetailed": "Figures shown", + "statusGeneric": "Without figures", + "detailedToast": "Notifications now include health figures.", + "genericToast": "Notifications no longer include health figures.", + "saveError": "Could not save. Please try again later." } }, "i18n": { @@ -11261,7 +11282,13 @@ }, "push": { "title": "Your morning briefing", - "bodyProvisional": "{line}\nLast night's sleep isn't in yet." + "bodyProvisional": "{line}\nLast night's sleep isn't in yet.", + "titleScore": "Morning score {score}", + "band": { + "green": "Looking steady today.", + "yellow": "A few things are worth a look today.", + "red": "Some areas need attention today." + } }, "item": { "doseWindow": { diff --git a/messages/es.json b/messages/es.json index 85feaee82..020c42fdb 100644 --- a/messages/es.json +++ b/messages/es.json @@ -1229,7 +1229,8 @@ "bodyLogged": "Registrado en el diario de enfermedad: {count} síntomas", "bodyNotLogged": "No hay nada registrado en el diario de enfermedad para este día", "openIllness": "Abrir" - } + }, + "sourceNotification": "Notificación" }, "medications": { "title": "Medicamentos", @@ -5325,7 +5326,8 @@ "subtitle": "Elige qué recordatorios recibes y dónde se entregan.", "remindersHeading": "Recordatorios", "matrixLink": "Notificaciones por canal", - "channelsLink": "Canales de entrega" + "channelsLink": "Canales de entrega", + "displayHeading": "En tu teléfono" }, "layout": { "title": "Diseño", @@ -8459,6 +8461,25 @@ "changedRead": "Ahora puedes ver el historial de salud de la persona a la que cuidabas, pero no modificarlo.", "changedManage": "Puedes gestionar el historial de salud de la persona a la que cuidabas." } + }, + "links": { + "open": "Abrir en HealthLog" + }, + "quickActions": { + "moodGood": "Bien", + "moodOkay": "Normal", + "moodBad": "Mal" + }, + "content": { + "title": "Valores de salud en las notificaciones", + "description": "Si una notificación puede mostrar valores como tu puntuación de la mañana.", + "detail": "Desactivado, una notificación solo dice de qué trata, así que la pantalla de bloqueo no muestra valores de salud. Activado, añade los valores, por ejemplo la puntuación de la mañana en el resumen. Al tocar una notificación se abre el lugar adecuado de la app en ambos casos.", + "toggleAria": "Mostrar valores de salud en las notificaciones", + "statusDetailed": "Con valores", + "statusGeneric": "Sin valores", + "detailedToast": "Las notificaciones ahora incluyen valores de salud.", + "genericToast": "Las notificaciones ya no incluyen valores de salud.", + "saveError": "No se pudo guardar. Inténtalo de nuevo más tarde." } }, "i18n": { @@ -11261,7 +11282,13 @@ }, "push": { "title": "Tu resumen matinal", - "bodyProvisional": "{line}\nEl sueño de anoche aún no está disponible." + "bodyProvisional": "{line}\nEl sueño de anoche aún no está disponible.", + "titleScore": "Puntuación de la mañana {score}", + "band": { + "green": "Hoy todo parece estable.", + "yellow": "Hoy vale la pena revisar algunas cosas.", + "red": "Hoy algunas áreas necesitan atención." + } }, "item": { "doseWindow": { diff --git a/messages/fr.json b/messages/fr.json index e84f55fb3..655d07843 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -1229,7 +1229,8 @@ "bodyLogged": "Consigné dans le journal de maladie : {count} symptômes", "bodyNotLogged": "Rien n'est consigné dans le journal de maladie pour cette journée", "openIllness": "Ouvrir" - } + }, + "sourceNotification": "Notification" }, "medications": { "title": "Médicaments", @@ -5325,7 +5326,8 @@ "subtitle": "Choisissez les rappels que vous recevez et où ils sont envoyés.", "remindersHeading": "Rappels", "matrixLink": "Notifications par canal", - "channelsLink": "Canaux de diffusion" + "channelsLink": "Canaux de diffusion", + "displayHeading": "Sur ton téléphone" }, "layout": { "title": "Disposition", @@ -8459,6 +8461,25 @@ "changedRead": "Vous pouvez désormais consulter le dossier de santé de la personne dont vous vous occupiez, mais pas le modifier.", "changedManage": "Vous pouvez gérer le dossier de santé de la personne dont vous vous occupiez." } + }, + "links": { + "open": "Ouvrir dans HealthLog" + }, + "quickActions": { + "moodGood": "Bien", + "moodOkay": "Moyen", + "moodBad": "Mal" + }, + "content": { + "title": "Valeurs de santé dans les notifications", + "description": "Si une notification peut afficher des valeurs comme ton score du matin.", + "detail": "Désactivé, une notification dit seulement de quoi il s'agit, l'écran verrouillé n'affiche donc aucune valeur de santé. Activé, elle ajoute les valeurs, par exemple le score du matin dans le récapitulatif. Toucher une notification ouvre le bon endroit de l'app dans les deux cas.", + "toggleAria": "Afficher les valeurs de santé dans les notifications", + "statusDetailed": "Avec valeurs", + "statusGeneric": "Sans valeurs", + "detailedToast": "Les notifications incluent désormais des valeurs de santé.", + "genericToast": "Les notifications n'incluent plus de valeurs de santé.", + "saveError": "Enregistrement impossible. Réessaie plus tard." } }, "i18n": { @@ -11261,7 +11282,13 @@ }, "push": { "title": "Votre résumé du matin", - "bodyProvisional": "{line}\nLe sommeil de la nuit dernière n'est pas encore disponible." + "bodyProvisional": "{line}\nLe sommeil de la nuit dernière n'est pas encore disponible.", + "titleScore": "Score du matin {score}", + "band": { + "green": "Tout semble stable aujourd'hui.", + "yellow": "Quelques points méritent un coup d'œil aujourd'hui.", + "red": "Certains domaines demandent de l'attention aujourd'hui." + } }, "item": { "doseWindow": { diff --git a/messages/it.json b/messages/it.json index 6937f4746..5cff6c4c9 100644 --- a/messages/it.json +++ b/messages/it.json @@ -1229,7 +1229,8 @@ "bodyLogged": "Registrato nel diario di malattia: {count} sintomi", "bodyNotLogged": "Per questa giornata non c'è nulla nel diario di malattia", "openIllness": "Apri" - } + }, + "sourceNotification": "Notifica" }, "medications": { "title": "Farmaci", @@ -5325,7 +5326,8 @@ "subtitle": "Scegli quali promemoria ricevere e dove vengono recapitati.", "remindersHeading": "Promemoria", "matrixLink": "Notifiche per canale", - "channelsLink": "Canali di recapito" + "channelsLink": "Canali di recapito", + "displayHeading": "Sul telefono" }, "layout": { "title": "Layout", @@ -8459,6 +8461,25 @@ "changedRead": "Ora puoi consultare la cartella sanitaria della persona di cui ti occupavi, ma non modificarla.", "changedManage": "Puoi gestire la cartella sanitaria della persona di cui ti occupavi." } + }, + "links": { + "open": "Apri in HealthLog" + }, + "quickActions": { + "moodGood": "Bene", + "moodOkay": "Così così", + "moodBad": "Male" + }, + "content": { + "title": "Valori di salute nelle notifiche", + "description": "Se una notifica può mostrare valori come il tuo punteggio del mattino.", + "detail": "Disattivato, una notifica dice solo di cosa si tratta, quindi la schermata di blocco non mostra valori di salute. Attivato, aggiunge i valori, per esempio il punteggio del mattino nel riepilogo. Toccando una notifica si apre il punto giusto dell'app in entrambi i casi.", + "toggleAria": "Mostra i valori di salute nelle notifiche", + "statusDetailed": "Con valori", + "statusGeneric": "Senza valori", + "detailedToast": "Le notifiche ora includono valori di salute.", + "genericToast": "Le notifiche non includono più valori di salute.", + "saveError": "Impossibile salvare. Riprova più tardi." } }, "i18n": { @@ -11261,7 +11282,13 @@ }, "push": { "title": "Il tuo riepilogo mattutino", - "bodyProvisional": "{line}\nIl sonno della scorsa notte non è ancora disponibile." + "bodyProvisional": "{line}\nIl sonno della scorsa notte non è ancora disponibile.", + "titleScore": "Punteggio del mattino {score}", + "band": { + "green": "Oggi tutto sembra stabile.", + "yellow": "Oggi vale la pena dare un'occhiata ad alcune cose.", + "red": "Oggi alcune aree richiedono attenzione." + } }, "item": { "doseWindow": { diff --git a/messages/ko.json b/messages/ko.json index ec79c416d..cc0e71c90 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -1229,7 +1229,8 @@ "bodyLogged": "아픈 날 일지에 증상 {count}개를 기록했어요", "bodyNotLogged": "이 날은 아픈 날 일지에 기록된 게 없어요", "openIllness": "열기" - } + }, + "sourceNotification": "알림" }, "medications": { "title": "복약", @@ -5325,7 +5326,8 @@ "subtitle": "받을 알림과 전달될 곳을 골라요.", "remindersHeading": "알림", "matrixLink": "채널별 알림", - "channelsLink": "전송 채널" + "channelsLink": "전송 채널", + "displayHeading": "휴대폰에서" }, "layout": { "title": "화면 구성", @@ -8459,6 +8461,25 @@ "changedRead": "이제 돌보던 분의 건강 기록을 볼 수 있지만 변경할 수는 없어요.", "changedManage": "돌보던 분의 건강 기록을 관리할 수 있어요." } + }, + "links": { + "open": "HealthLog에서 열기" + }, + "quickActions": { + "moodGood": "좋음", + "moodOkay": "보통", + "moodBad": "나쁨" + }, + "content": { + "title": "알림의 건강 수치", + "description": "알림에 아침 점수 같은 수치를 표시할지 정합니다.", + "detail": "끄면 알림은 무엇에 관한 것인지만 알려 주므로 잠금 화면에 건강 수치가 나타나지 않아요. 켜면 수치도 함께 보여 줘요. 예를 들어 브리핑에 아침 점수가 표시됩니다. 어느 쪽이든 알림을 누르면 앱의 알맞은 곳이 열려요.", + "toggleAria": "알림에 건강 수치 표시", + "statusDetailed": "수치 표시", + "statusGeneric": "수치 없이", + "detailedToast": "이제 알림에 건강 수치가 포함됩니다.", + "genericToast": "이제 알림에 건강 수치가 포함되지 않습니다.", + "saveError": "저장하지 못했어요. 나중에 다시 시도해 주세요." } }, "i18n": { @@ -11261,7 +11282,13 @@ }, "push": { "title": "아침 브리핑", - "bodyProvisional": "{line}\n어젯밤 수면은 아직 들어오지 않았어요." + "bodyProvisional": "{line}\n어젯밤 수면은 아직 들어오지 않았어요.", + "titleScore": "아침 점수 {score}", + "band": { + "green": "오늘은 안정적으로 보여요.", + "yellow": "오늘은 몇 가지를 살펴볼 만해요.", + "red": "오늘은 몇몇 영역에 주의가 필요해요." + } }, "item": { "doseWindow": { diff --git a/messages/pl.json b/messages/pl.json index 19bdad46a..0df9ceb8a 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -1229,7 +1229,8 @@ "bodyLogged": "Zapisano w dzienniku choroby: {count} objawów", "bodyNotLogged": "Na ten dzień nic nie zapisano w dzienniku choroby", "openIllness": "Otwórz" - } + }, + "sourceNotification": "Powiadomienie" }, "medications": { "title": "Leki", @@ -5325,7 +5326,8 @@ "subtitle": "Wybierz, które przypomnienia otrzymujesz i gdzie są dostarczane.", "remindersHeading": "Przypomnienia", "matrixLink": "Powiadomienia wg kanału", - "channelsLink": "Kanały dostarczania" + "channelsLink": "Kanały dostarczania", + "displayHeading": "Na telefonie" }, "layout": { "title": "Układ", @@ -8459,6 +8461,25 @@ "changedRead": "Możesz teraz przeglądać dokumentację zdrowotną osoby pod Twoją opieką, ale nie możesz jej zmieniać.", "changedManage": "Możesz zarządzać dokumentacją zdrowotną osoby pod Twoją opieką." } + }, + "links": { + "open": "Otwórz w HealthLog" + }, + "quickActions": { + "moodGood": "Dobrze", + "moodOkay": "W porządku", + "moodBad": "Źle" + }, + "content": { + "title": "Wartości zdrowotne w powiadomieniach", + "description": "Czy powiadomienie może pokazywać wartości, takie jak poranny wynik.", + "detail": "Wyłączone: powiadomienie mówi tylko, czego dotyczy, więc ekran blokady nie pokazuje wartości zdrowotnych. Włączone: dodaje wartości, na przykład poranny wynik w podsumowaniu. Dotknięcie powiadomienia w obu przypadkach otwiera właściwe miejsce w aplikacji.", + "toggleAria": "Pokazuj wartości zdrowotne w powiadomieniach", + "statusDetailed": "Z wartościami", + "statusGeneric": "Bez wartości", + "detailedToast": "Powiadomienia zawierają teraz wartości zdrowotne.", + "genericToast": "Powiadomienia nie zawierają już wartości zdrowotnych.", + "saveError": "Nie udało się zapisać. Spróbuj ponownie później." } }, "i18n": { @@ -11261,7 +11282,13 @@ }, "push": { "title": "Twoje poranne podsumowanie", - "bodyProvisional": "{line}\nBrakuje jeszcze snu z zeszłej nocy." + "bodyProvisional": "{line}\nBrakuje jeszcze snu z zeszłej nocy.", + "titleScore": "Poranny wynik {score}", + "band": { + "green": "Dziś wszystko wygląda stabilnie.", + "yellow": "Dziś warto przyjrzeć się kilku rzeczom.", + "red": "Dziś niektóre obszary wymagają uwagi." + } }, "item": { "doseWindow": { 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/auth/me/notification-prefs/__tests__/route.test.ts b/src/app/api/auth/me/notification-prefs/__tests__/route.test.ts index a833a6b22..c00dd9536 100644 --- a/src/app/api/auth/me/notification-prefs/__tests__/route.test.ts +++ b/src/app/api/auth/me/notification-prefs/__tests__/route.test.ts @@ -109,6 +109,7 @@ describe("GET /api/auth/me/notification-prefs", () => { measurementReminder: { clientManaged: false, }, + content: { detail: "generic" }, }); }); @@ -145,6 +146,7 @@ describe("GET /api/auth/me/notification-prefs", () => { measurementReminder: { clientManaged: false, }, + content: { detail: "generic" }, }); }); @@ -183,6 +185,7 @@ describe("GET /api/auth/me/notification-prefs", () => { measurementReminder: { clientManaged: false, }, + content: { detail: "generic" }, }); }); }); @@ -232,6 +235,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => { measurementReminder: { clientManaged: false, }, + content: { detail: "generic" }, // v1.32.22 (M1) — the write echoes the fresh optimistic-concurrency token. updatedAt: expect.any(String), }); @@ -260,6 +264,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => { measurementReminder: { clientManaged: false, }, + content: { detail: "generic" }, }, }, }); @@ -289,6 +294,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => { measurementReminder: { clientManaged: false, }, + content: { detail: "generic" }, }, next: { medication: { @@ -310,6 +316,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => { measurementReminder: { clientManaged: false, }, + content: { detail: "generic" }, }, changed: ["medication"], }), @@ -393,6 +400,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => { measurementReminder: { clientManaged: false, }, + content: { detail: "generic" }, }, }, }); @@ -432,6 +440,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => { measurementReminder: { clientManaged: false, }, + content: { detail: "generic" }, // v1.32.22 (M1) — the write echoes the fresh optimistic-concurrency token. updatedAt: expect.any(String), }); @@ -460,6 +469,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => { measurementReminder: { clientManaged: false, }, + content: { detail: "generic" }, }, }, }); @@ -505,11 +515,46 @@ describe("PATCH /api/auth/me/notification-prefs", () => { measurementReminder: { clientManaged: false, }, + content: { detail: "generic" }, }, }, }); }); + it("persists content.detail and keeps the siblings", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + notificationPrefs: { mood: { reminderHour: 7 } }, + } as never); + vi.mocked(prisma.user.update).mockResolvedValue({} as never); + + const res = await (PATCH as (r: Request) => Promise)( + mkPatch({ content: { detail: "detailed" } }), + ); + expect(res.status).toBe(200); + const env = (await res.json()) as { + data: { content: { detail: string }; mood: { reminderHour: number } }; + }; + expect(env.data.content).toEqual({ detail: "detailed" }); + expect(env.data.mood).toEqual({ reminderHour: 7 }); + const written = vi.mocked(prisma.user.update).mock.calls[0]?.[0] as { + data: { notificationPrefs: { content: { detail: string } } }; + }; + expect(written.data.notificationPrefs.content).toEqual({ + detail: "detailed", + }); + }); + + it("rejects an unknown content.detail with 422", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + + const res = await (PATCH as (r: Request) => Promise)( + mkPatch({ content: { detail: "everything" } }), + ); + expect(res.status).toBe(422); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + it("v1.7.0 — rejects a mood.reminderHour outside 0..23 with 422", async () => { vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); diff --git a/src/app/api/internal/deploy-webhook/route.ts b/src/app/api/internal/deploy-webhook/route.ts index 3d1e59cf1..96bb99e0e 100644 --- a/src/app/api/internal/deploy-webhook/route.ts +++ b/src/app/api/internal/deploy-webhook/route.ts @@ -195,6 +195,7 @@ async function notifyAdminsOfFailure(event: NormalizedEvent): Promise { }, metadata: { source: "deploy-webhook", + url: "/admin/system-status", applicationName: event.applicationName, applicationUuid: event.applicationUuid, deploymentUuid: event.deploymentUuid, 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/app/api/notifications/action/[token]/route.ts b/src/app/api/notifications/action/[token]/route.ts new file mode 100644 index 000000000..f10eb613a --- /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/app/mood/page-client.tsx b/src/app/mood/page-client.tsx index 4bf441c6b..25464b0b7 100644 --- a/src/app/mood/page-client.tsx +++ b/src/app/mood/page-client.tsx @@ -15,7 +15,7 @@ import { PageAuthGate } from "@/components/ui/page-auth-gate"; import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator"; import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; import { ArrowRight, Plus, Wrench } from "lucide-react"; -import { useRouter } from "next/navigation"; +import { useRouter, useSearchParams } from "next/navigation"; import { useEffect } from "react"; import { useTranslations } from "@/lib/i18n/context"; @@ -27,7 +27,21 @@ export default function MoodPageClient() { const mounted = useMounted(); const router = useRouter(); const queryClient = useQueryClient(); - const [dialogOpen, setDialogOpen] = useState(false); + const searchParams = useSearchParams(); + // `?add=1` opens the entry sheet on the first paint: the mood reminder's + // tap target (`tap-target.ts`). Read in the lazy initialiser so SSR and the + // hydration render agree; the effect below strips the one-shot param so the + // back button does not reopen the sheet. A later client-side navigation + // back onto `?add=1` is handled render-side, the measurements-page pattern. + const addParam = searchParams.get("add") === "1"; + const [dialogOpen, setDialogOpen] = useState(() => addParam); + const [consumedAdd, setConsumedAdd] = useState(() => addParam); + if (!addParam && consumedAdd) { + setConsumedAdd(false); + } else if (addParam && !consumedAdd) { + setConsumedAdd(true); + setDialogOpen(true); + } // The list's empty state carries the add action while there is nothing // to list, so the header drops its own then. const [listEmpty, setListEmpty] = useState(false); @@ -57,6 +71,14 @@ export default function MoodPageClient() { disabled: dialogOpen, }); + useEffect(() => { + if (!searchParams.has("add")) return; + const next = new URLSearchParams(searchParams.toString()); + next.delete("add"); + const search = next.toString(); + router.replace(search ? `/mood?${search}` : "/mood"); + }, [searchParams, router]); + useEffect(() => { if (isLoading) return; if (!isAuthenticated) { @@ -127,7 +149,7 @@ export default function MoodPageClient() { /> } 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/components/settings/notification-content-card.tsx b/src/components/settings/notification-content-card.tsx new file mode 100644 index 000000000..91dfc2f2b --- /dev/null +++ b/src/components/settings/notification-content-card.tsx @@ -0,0 +1,147 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { LockKeyhole } from "lucide-react"; + +import { Switch } from "@/components/ui/switch"; +import { SettingsCard } from "@/components/settings/settings-card"; +import { SettingsCardHeader } from "@/components/settings/_card-header"; +import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; +import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch"; +import { readUpdatedAtToken, withBaseToken } from "@/lib/api/optimistic-token"; + +interface NotificationPrefsShape { + content?: { detail: "generic" | "detailed" }; + updatedAt?: string; +} + +/** + * Settings → Notifications: what a notification may show on a lock screen. + * + * Off (the default) keeps every notification to what it is about ("Your + * morning briefing"); on adds the health figures a notification has, today + * the morning score. Stored as `notificationPrefs.content.detail` through + * the same PATCH the iOS app uses, with the optimistic-concurrency token, so + * a stale web write cannot revert a sibling category. + */ +export function NotificationContentCard({ + isAuthenticated, +}: { + isAuthenticated: boolean; +}) { + const { t } = useTranslations(); + const queryClient = useQueryClient(); + const [optimistic, setOptimistic] = useState(null); + const [saving, setSaving] = useState(false); + const [msg, setMsg] = useState(null); + const [msgType, setMsgType] = useState<"success" | "error" | null>(null); + const clearTimerRef = useRef | null>(null); + + useEffect(() => { + return () => { + if (clearTimerRef.current !== null) clearTimeout(clearTimerRef.current); + }; + }, []); + + function scheduleClear() { + if (clearTimerRef.current !== null) clearTimeout(clearTimerRef.current); + clearTimerRef.current = setTimeout(() => { + clearTimerRef.current = null; + setMsg(null); + setMsgType(null); + }, 3000); + } + + const { data: prefs } = useQuery({ + queryKey: queryKeys.authNotificationPrefs(), + queryFn: async () => + apiGet("/api/auth/me/notification-prefs"), + enabled: isAuthenticated, + }); + + const detailed = optimistic ?? prefs?.content?.detail === "detailed"; + + async function handleToggle(next: boolean) { + setOptimistic(next); + setSaving(true); + setMsg(null); + setMsgType(null); + + const res = await apiFetchRaw("/api/auth/me/notification-prefs", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify( + withBaseToken( + { content: { detail: next ? "detailed" : "generic" } }, + readUpdatedAtToken(queryClient, queryKeys.authNotificationPrefs()), + ), + ), + }); + + await queryClient.invalidateQueries({ + queryKey: queryKeys.authNotificationPrefs(), + }); + setOptimistic(null); + if (res.ok) { + setMsg( + next + ? t("notifications.content.detailedToast") + : t("notifications.content.genericToast"), + ); + setMsgType("success"); + } else if (res.status === 409) { + setMsg(t("common.conflictReloaded")); + setMsgType("error"); + } else { + setMsg(t("notifications.content.saveError")); + setMsgType("error"); + } + scheduleClear(); + setSaving(false); + } + + return ( + + + + + {detailed + ? t("notifications.content.statusDetailed") + : t("notifications.content.statusGeneric")} + + + } + /> +

{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/__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/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/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/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/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/", + ); + }); +}); 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/__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/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/__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/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,