Merge branch 'v1422/notify' into release/v1.42.2

This commit is contained in:
Marc-André Bombeck committed 2026-10-11 12:47:15 +02:00
commit 11c55cc6f8
60 files changed
+2813 -83

No files matched your search

+142 -29
View File
@@ -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 (`<Class: 0x…>`, `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:
+89
View File
@@ -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 <APP_URL>/api/notifications/action/<token>`. 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 |
+30 -3
View File
@@ -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": {
+30 -3
View File
@@ -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": {
+30 -3
View File
@@ -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": {
+30 -3
View File
@@ -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": {
+30 -3
View File
@@ -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": {
+30 -3
View File
@@ -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": {
+30 -3
View File
@@ -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": {
@@ -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",
@@ -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<Response>)(
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<Response>)(
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);
@@ -195,6 +195,7 @@ async function notifyAdminsOfFailure(event: NormalizedEvent): Promise<void> {
},
metadata: {
source: "deploy-webhook",
url: "/admin/system-status",
applicationName: event.applicationName,
applicationUuid: event.applicationUuid,
deploymentUuid: event.deploymentUuid,
@@ -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<Parameters<typeof mintActionToken>[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);
});
});
@@ -0,0 +1,152 @@
/**
* `POST /api/notifications/action/<token>` — 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 });
},
);
+25 -3
View File
@@ -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() {
/>
<ResponsiveSheet
open={dialogOpen}
open={dialogOpen && canAddMood}
onOpenChange={setDialogOpen}
title={t("dashboard.quickAddMood")}
footer={<div ref={setFooterEl} className="flex w-full" />}
+2
View File
@@ -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;
}
@@ -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<boolean | null>(null);
const [saving, setSaving] = useState(false);
const [msg, setMsg] = useState<string | null>(null);
const [msgType, setMsgType] = useState<"success" | "error" | null>(null);
const clearTimerRef = useRef<ReturnType<typeof setTimeout> | 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<NotificationPrefsShape>("/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 (
<SettingsCard
as="section"
aria-labelledby="settings-notification-content-title"
>
<SettingsCardHeader
icon={LockKeyhole}
title={t("notifications.content.title")}
titleId="settings-notification-content-title"
description={t("notifications.content.description")}
status={
<label className="flex min-h-11 items-center gap-3">
<Switch
checked={detailed}
onCheckedChange={handleToggle}
disabled={!isAuthenticated || saving}
aria-label={t("notifications.content.toggleAria")}
/>
<span className="text-muted-foreground text-xs">
{detailed
? t("notifications.content.statusDetailed")
: t("notifications.content.statusGeneric")}
</span>
</label>
}
/>
<p className="text-sm">{t("notifications.content.detail")}</p>
{msg && (
<p
role="status"
aria-live="polite"
className={
msgType === "error"
? "text-destructive text-sm"
: "text-muted-foreground text-sm"
}
>
{msg}
</p>
)}
</SettingsCard>
);
}
@@ -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}
</section>
) : null}
{/* Group 2 — How notifications look on the phone. Not module-gated:
it applies to every notification the account receives. */}
<section className="space-y-4">
<SectionHeading
icon={Smartphone}
title={t("settings.sections.notifications.displayHeading")}
/>
<div id="notification-content" className="scroll-mt-28">
<NotificationContentCard isAuthenticated={isAuthenticated} />
</div>
</section>
{/* 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. */}
@@ -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(
+33 -5
View File
@@ -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
@@ -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,
+4 -1
View File
@@ -427,7 +427,10 @@ async function evaluateOfflineGeoNotice(): Promise<void> {
host: resolveGeoProviderHost(),
docsUrl: GEOLITE2_DOCS_URL,
},
metadata: { source: "geo-offline-detection" },
metadata: {
source: "geo-offline-detection",
url: "/admin/system-status",
},
});
}
} catch (err) {
+2
View File
@@ -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);
+2
View File
@@ -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);
@@ -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.
+9 -1
View File
@@ -1277,7 +1277,15 @@ async function maybeAlertAdmins(input: AlertInput): Promise<DispatchOutcome> {
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;
+1
View File
@@ -95,6 +95,7 @@ async function alertAdminsOfPinChange(
},
metadata: {
source: "tls-pin-monitor",
url: "/admin/system-status",
host,
servedPin: probe.pin,
knownPins: knownPins.join(","),
+17
View File
@@ -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/",
);
});
});
+4
View File
@@ -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/<token>`.
{ prefix: "/api/notifications/action/" },
];
function redactPathSegments(input: string): string {
+2 -1
View File
@@ -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
+11 -4
View File
@@ -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/<token>`) 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<TelegramMoodResult> {
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,
},
@@ -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<Parameters<typeof mintActionToken>[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<string>();
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/);
});
});
@@ -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> = {}): 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();
});
});
@@ -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<EventType, …>` 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<EventType> = 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);
});
});
@@ -190,6 +190,39 @@ describe("web-push reminder push — stable tag + badge", () => {
});
});
describe("web-push tap target", () => {
async function sentBody(payload: Parameters<typeof sendViaWebPush>[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]);
+197
View File
@@ -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.<userId>.<action>.<expiryMs>.<nonce>.<tag>`. 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<QuickActionId, string>;
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,
})}`,
}));
}
+105
View File
@@ -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<QuickActionId, string> = {
"mood-good": "notifications.quickActions.moodGood",
"mood-okay": "notifications.quickActions.moodOkay",
"mood-bad": "notifications.quickActions.moodBad",
};
async function recipientLocale(userId: string): Promise<Locale> {
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<NotificationPayload> {
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;
}
+5 -2
View File
@@ -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({
@@ -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
@@ -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",
);
});
});
@@ -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<string, string> {
const [, init] = safeFetchMock.mock.calls[0] as [
string,
{ headers: Record<string, string> },
];
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\?/);
});
});
@@ -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<string, string>[][] };
} {
const call = sendTelegramMessageMock.mock.calls[0] as [
string,
string,
string,
{ replyMarkup?: { inline_keyboard: Record<string, string>[][] } },
];
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 <app>",
},
});
expect(sent().replyMarkup).toBeUndefined();
expect(sent().text).toBe(
'line\n\n<a href="http://nas.lan/?a=1&amp;b=2">Open &lt;app&gt;</a>',
);
});
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");
});
});
@@ -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" },
});
});
});
+9
View File
@@ -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<SendOutcome> {
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
+5 -1
View File
@@ -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({
+59 -1
View File
@@ -58,7 +58,7 @@ export async function sendViaNtfy(
? "reminder"
: payload.eventType.toLowerCase().replace(/_/g, "-");
const headers: Record<string, string> = {
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, <label>, <url>, method=POST, clear=true; …
*
* `clear=true` dismisses the notification once the request succeeded, so a
* reminder answered from the shade does not linger. The token in the URL is
* the request's only credential (see `action-token.ts`).
*/
export function buildNtfyActionsHeader(
payload: Pick<NotificationPayload, "quickActions">,
): string | null {
const actions = (payload.quickActions ?? []).slice(0, NTFY_MAX_ACTIONS);
if (actions.length === 0) return null;
return actions
.map(
(action) =>
`http, ${ntfyActionLabel(action.label)}, ${action.url}, method=POST, clear=true`,
)
.join("; ");
}
/**
* A header value fetch can send and ntfy reads as UTF-8.
*
* `fetch` refuses any header character above U+00FF outright (a Korean
* title or action label would fail the whole send), and a Latin-1 byte such
* as `ü` reaches ntfy as invalid UTF-8. ntfy decodes RFC 2047 encoded words
* in every header, so a value with anything beyond printable ASCII travels as
* one `=?UTF-8?B?…?=` word; plain ASCII stays readable on the wire.
*/
export function ntfyHeaderValue(value: string): string {
if (/^[\x20-\x7e]*$/.test(value)) return value;
return `=?UTF-8?B?${Buffer.from(value, "utf8").toString("base64")}?=`;
}
+34 -2
View File
@@ -15,6 +15,18 @@ import {
buildMeasurementKeyboard,
} from "@/lib/notifications/senders/telegram-keyboards";
import { defaultLocale, locales, type Locale } from "@/lib/i18n/config";
import { telegramLinkMode } from "@/lib/notifications/tap-target";
function escapeHtmlText(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
function escapeHtmlAttribute(value: string): string {
return escapeHtmlText(value).replace(/"/g, "&quot;");
}
/**
* v1.19.0 — resolve the bot locale for an interactive reminder. The
@@ -197,13 +209,33 @@ export async function sendViaTelegram(
}
: undefined);
// Tap target. Telegram has no "tap opens a URL" for a message, so the
// target rides an inline URL button under it (below any action row), or a
// text link where Telegram would refuse the button (see `telegramLinkMode`).
const linkMode = payload.tap?.url
? telegramLinkMode(payload.tap.url)
: "none";
const linkedKeyboard =
linkMode === "button" && payload.tap?.url
? {
inline_keyboard: [
...(keyboard?.inline_keyboard ?? []),
[{ text: payload.tap.label, url: payload.tap.url }],
],
}
: keyboard;
const text =
linkMode === "text" && payload.tap?.url
? `${payload.message}\n\n<a href="${escapeHtmlAttribute(payload.tap.url)}">${escapeHtmlText(payload.tap.label)}</a>`
: payload.message;
const result = await sendTelegramMessage(
config.botToken,
config.chatId,
payload.message,
text,
{
parseMode: "HTML",
replyMarkup: keyboard,
replyMarkup: linkedKeyboard,
},
);
+7 -6
View File
@@ -11,6 +11,7 @@ import { safeFetch } from "@/lib/safe-fetch";
import { plainPushText } from "@/lib/notifications/strip-emoji";
import { stripHtml } from "@/lib/notifications/strip-html";
import { isManagedGuardianDelivery } from "@/lib/notifications/managed-delivery";
import { isSafeAppPath } from "@/lib/notifications/tap-target";
/**
* Send Web Push notification to all subscribed devices of a user.
@@ -115,12 +116,12 @@ export async function sendViaWebPush(
// `/coach`); everything else keeps the legacy "/" click
// target. Only relative paths are honoured so a poisoned metadata
// value can never point the SW at a foreign origin.
url:
!managedDelivery &&
typeof payload.metadata?.url === "string" &&
payload.metadata.url.startsWith("/")
? payload.metadata.url
: "/",
// The dispatcher's resolved tap target (`tap-target.ts`) wins; a payload
// that did not pass through the dispatcher keeps the metadata path.
url: managedDelivery
? "/"
: (payload.tap?.path ??
(isSafeAppPath(payload.metadata?.url) ? payload.metadata.url : "/")),
// v1.18.4 — urgent events ask the service worker to keep the
// notification on screen until the user acts on it (where the
// browser honours `requireInteraction`).
+9
View File
@@ -69,6 +69,9 @@ export function buildWebhookBody(
const message = plainPushText(stripHtml(payload.message), payload.eventType);
const eventType = payload.discreet ? "reminder" : payload.eventType;
const band = priorityBand(payload);
// The tap target as an absolute URL, when the operator configured a public
// origin (`tap-target.ts`).
const url = payload.tap?.url ?? null;
if (config.format === "gotify") {
return JSON.stringify({
@@ -80,6 +83,9 @@ export function buildWebhookBody(
// body is never markdown (hard rule).
"client::display": { contentType: "text/plain" },
"healthlog::event": { type: eventType },
// Gotify's Android client opens this URL when the notification is
// tapped.
...(url ? { "client::notification": { click: { url } } } : {}),
},
});
}
@@ -102,6 +108,9 @@ export function buildWebhookBody(
content: truncateCodePoints(text, DISCORD_CONTENT_LIMIT),
text,
allowed_mentions: { parse: [] },
// Additive: where the notification leads in the app, for a relay rule
// that wants to attach a link. Absent without a public origin.
...(url ? { url } : {}),
});
}
+153
View File
@@ -0,0 +1,153 @@
/**
* Where a notification takes the user when they tap it.
*
* One map, one resolver. Every event type names the in-app path it opens;
* the dispatcher resolves the path once per delivery and every channel
* carries the same target in its own vocabulary:
*
* - ntfy → `Click` header (absolute URL)
* - Telegram → an inline URL button (absolute `https` URL only, see
* `telegramLinkMode`)
* - Web Push → `url` in the service-worker payload (same-origin path)
* - APNs → `url` in the custom payload (same-origin path)
* - Webhook → `url` in the body (absolute URL)
* - Email → a link line under the body (absolute URL)
*
* The map is typed `Record<EventType, …>`, so a new event type without a
* target does not compile, and `tap-target.test.ts` walks `EVENT_TYPES` so a
* target that stops resolving fails there too.
*
* A producer may name a more precise path in `metadata.url` (the low-stock
* alert opens the medication's stock tab, the Coach nudge opens its own
* conversation). That override is accepted only as a same-origin path, so a
* value that names another host can never become a click target.
*/
import type { EventType, NotificationPayload } from "@/lib/notifications/types";
import { isManagedGuardianDelivery } from "@/lib/notifications/managed-delivery";
/**
* The default in-app path per event type. `null` means the event is never
* shown to the user (a silent background push), so it carries no target.
*/
export const NOTIFICATION_TAP_PATHS: Record<EventType, string | null> = {
MEDICATION_REMINDER: "/medications",
MEASUREMENT_ANOMALY: "/measurements",
COMPLIANCE_LOW: "/insights/medications",
WITHINGS_SYNC_FAILED: "/settings/integrations",
SYSTEM_ALERT: "/",
PERSONAL_RECORD: "/achievements",
// `?add=1` opens the mood entry sheet on the first paint.
MOOD_REMINDER: "/mood?add=1",
CYCLE_PERIOD_SOON: "/cycle",
CYCLE_PERIOD_CONFIRM: "/cycle",
CYCLE_FERTILE_SOON: "/cycle",
COACH_NUDGE: "/coach",
MEDICATION_LOW_STOCK: "/medications",
MEASUREMENT_REMINDER: "/checkups",
MEDICATION_INTAKE_SYNC: null,
SECURITY_ALERT: "/settings/security",
DAILY_BRIEFING: "/",
SHARED_RECORD_INTAKE: "/medications",
};
const MAX_PATH_LENGTH = 512;
/**
* A same-origin, absolute-path reference: one leading slash, no scheme, no
* protocol-relative `//host`, no backslash (browsers normalise `/\host` to
* `//host`), no whitespace or control characters.
*/
export function isSafeAppPath(value: unknown): value is string {
if (typeof value !== "string") return false;
if (value.length === 0 || value.length > MAX_PATH_LENGTH) return false;
if (!value.startsWith("/")) return false;
if (value.startsWith("//")) return false;
if (value.includes("\\")) return false;
if (/[\s\u0000-\u001f\u007f]/.test(value)) return false;
return true;
}
/**
* The path a delivery opens, or `null` when it carries none.
*
* - A Guardian receiving a managed-record notification gets no deep link:
* their session opens on their own record, and a path into the managed
* record's surfaces would land them on the wrong person. The app root is
* where the record switcher lives.
* - Discreet mode (cycle privacy) carries no path either, because the path
* names the surface (`/cycle`) and discreet mode promises that nothing on
* the wire names the event.
*/
export function resolveTapPath(
payload: Pick<
NotificationPayload,
"eventType" | "metadata" | "discreet" | "recordUserId" | "recipientUserId"
>,
): string | null {
const fallback = NOTIFICATION_TAP_PATHS[payload.eventType];
if (fallback === null || fallback === undefined) return null;
if (isManagedGuardianDelivery(payload)) return null;
if (payload.discreet) return null;
const override = payload.metadata?.url;
return isSafeAppPath(override) ? override : fallback;
}
/**
* The operator-configured public origin (`APP_URL`, then
* `NEXT_PUBLIC_APP_URL`), the same precedence the invite and handover links
* use. Env only: a notification is sent from a worker with no request, and
* the `Host` header is never trusted for a link that leaves the server.
*/
export function configuredAppOrigin(): string | null {
for (const value of [process.env.APP_URL, process.env.NEXT_PUBLIC_APP_URL]) {
const trimmed = value?.trim();
if (!trimmed) continue;
try {
const url = new URL(trimmed);
if (url.protocol !== "https:" && url.protocol !== "http:") continue;
return url.origin;
} catch {
// try the next candidate
}
}
return null;
}
/** Absolute URL for a path, or `null` when no public origin is configured. */
export function absoluteAppUrl(
path: string | null,
origin: string | null = configuredAppOrigin(),
): string | null {
if (!path || !origin) return null;
return `${origin}${path}`;
}
/**
* How Telegram may carry a link.
*
* Telegram refuses an inline URL button whose URL is not `https` or whose
* host is `localhost` (the whole `sendMessage` fails with "BUTTON_URL_INVALID"),
* so the button is offered only for a public `https` origin. A plain-HTTP
* self-host still gets a text link under the message; a loopback origin gets
* nothing, because no phone can open it.
*/
export function telegramLinkMode(url: string): "button" | "text" | "none" {
let parsed: URL;
try {
parsed = new URL(url);
} catch {
return "none";
}
const host = parsed.hostname.toLowerCase();
const loopback =
host === "localhost" ||
host.endsWith(".localhost") ||
host === "[::1]" ||
host === "::1" ||
/^127\./.test(host) ||
host === "0.0.0.0";
if (loopback) return "none";
if (parsed.protocol === "https:") return "button";
if (parsed.protocol === "http:") return "text";
return "none";
}
+24
View File
@@ -1,4 +1,5 @@
import type { Locale } from "@/lib/i18n/config";
import type { NotificationQuickAction } from "@/lib/notifications/action-token";
/**
* Notification system type definitions.
@@ -355,6 +356,29 @@ export interface NotificationPayload {
* removes is the OFFER to act on it.
*/
suppressActions?: boolean;
/**
* Where tapping the notification leads, resolved once per delivery by the
* dispatcher from `NOTIFICATION_TAP_PATHS` (`tap-target.ts`). Producers
* never set it; a producer that knows a more precise path says so in
* `metadata.url`. `path` is a same-origin path (Web Push, APNs), `url` the
* same target as an absolute URL on the operator's public origin (ntfy,
* Telegram, webhook, email), `null` when no public origin is configured.
* `label` is the localised text of a link button.
*/
tap?: NotificationTap;
/**
* One-tap answers the channel can offer inside the notification (ntfy
* `http` actions). Minted by the dispatcher for a self-delivered mood
* reminder; each URL carries a signed single-use token
* (`action-token.ts`).
*/
quickActions?: NotificationQuickAction[];
}
export interface NotificationTap {
path: string | null;
url: string | null;
label: string;
}
/**
+6
View File
@@ -296,6 +296,12 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
"mood.update.invalid",
],
"notification-prefs": ["notification-prefs.body.invalid_json"],
notification: [
"notification.action.already_used",
"notification.action.expired",
"notification.action.invalid",
"notification.action.module_disabled",
],
nutrient: [
"nutrient.batch.invalid",
"nutrient.batch.too_large",
+63
View File
@@ -16,6 +16,10 @@ import { z } from "zod/v4";
import type { ZodOpenApiObject } from "zod-openapi";
import { CHANNEL_TYPES, EVENT_TYPES } from "@/lib/notifications/types";
import {
QUICK_ACTION_IDS,
type QuickActionId,
} from "@/lib/notifications/action-token";
import {
notificationPreferenceSchema,
reEnableChannelSchema,
@@ -148,8 +152,67 @@ const reEnableChannelRequest = reEnableChannelSchema.meta({
"Clear the auto-disable on one of the caller's own channels. A channel id the caller does not own answers 404.",
});
const notificationActionResult = z
.object({
logged: z.literal(true),
action: z
.enum(QUICK_ACTION_IDS as [QuickActionId, ...QuickActionId[]])
.describe(
"The answer that was logged: mood-good = 4 (GUT), mood-okay = 3 (OKAY), mood-bad = 2 (SCHLECHT) on the five-point mood scale.",
),
})
.meta({
id: "NotificationActionResult",
description: "A quick answer from a notification, logged as a mood entry.",
});
export const notificationChannelPaths: NonNullable<ZodOpenApiObject["paths"]> =
{
"/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.",
requestParams: {
path: z.object({
token: z
.string()
.describe("Signed action token from the notification's URL."),
}),
},
responses: {
"200": {
description: "The answer was logged.",
content: {
"application/json": {
schema: dataEnvelope(
notificationActionResult,
"NotificationActionResponse",
),
},
},
},
...stdResponses,
"401": {
description: "Invalid token (forged, altered, or unknown user).",
content: { "application/json": { schema: errorEnvelope } },
},
"403": {
description: "The mood module is turned off for this account.",
content: { "application/json": { schema: errorEnvelope } },
},
"409": {
description: "This reminder was already answered.",
content: { "application/json": { schema: errorEnvelope } },
},
"410": {
description: "The answer expired.",
content: { "application/json": { schema: errorEnvelope } },
},
},
},
},
"/api/notifications/preferences": {
get: {
tags: ["Notifications"],
+7
View File
@@ -335,6 +335,13 @@ const notificationPrefsResolved = z
.enum(["weekly", "biweekly"])
.describe("Nudge frequency cap: one per 7 or per 14 days."),
}),
content: z.object({
detail: z
.enum(["generic", "detailed"])
.describe(
'What a notification may show. "generic" (default) names what it is about with no health figure; "detailed" adds figures such as the morning score. Tap targets and quick answers are the same in both.',
),
}),
})
.meta({
id: "NotificationPrefs",
+3 -4
View File
@@ -11,10 +11,9 @@ interface TelegramResponse {
description?: string;
}
interface TelegramInlineKeyboardButton {
text: string;
callback_data: string;
}
/** A callback button, or a button that opens a URL (`https` only). */
type TelegramInlineKeyboardButton =
{ text: string; callback_data: string } | { text: string; url: string };
interface TelegramReplyMarkup {
inline_keyboard: TelegramInlineKeyboardButton[][];
@@ -15,6 +15,7 @@ import {
resolveLowStockRunwayDays,
resolveMoodReminderHour,
resolveNotificationPrefs,
notificationsShowFigures,
resolveReorderLeadDays,
} from "../notification-prefs";
@@ -114,6 +115,7 @@ describe("parseNotificationPrefs", () => {
measurementReminder: {
clientManaged: false,
},
content: { detail: "generic" },
});
});
@@ -138,6 +140,7 @@ describe("parseNotificationPrefs", () => {
measurementReminder: {
clientManaged: false,
},
content: { detail: "generic" },
});
});
@@ -164,6 +167,7 @@ describe("parseNotificationPrefs", () => {
measurementReminder: {
clientManaged: false,
},
content: { detail: "generic" },
});
});
@@ -206,6 +210,7 @@ describe("resolveNotificationPrefs (deep-merge)", () => {
measurementReminder: {
clientManaged: false,
},
content: { detail: "generic" },
});
});
@@ -236,6 +241,7 @@ describe("resolveNotificationPrefs (deep-merge)", () => {
measurementReminder: {
clientManaged: false,
},
content: { detail: "generic" },
});
});
@@ -263,6 +269,7 @@ describe("resolveNotificationPrefs (deep-merge)", () => {
measurementReminder: {
clientManaged: false,
},
content: { detail: "generic" },
});
});
@@ -290,6 +297,7 @@ describe("resolveNotificationPrefs (deep-merge)", () => {
measurementReminder: {
clientManaged: false,
},
content: { detail: "generic" },
});
});
@@ -318,6 +326,7 @@ describe("resolveNotificationPrefs (deep-merge)", () => {
measurementReminder: {
clientManaged: false,
},
content: { detail: "generic" },
});
});
@@ -645,3 +654,30 @@ describe("lowStockRunwayDays — v1.16.11 low-stock threshold", () => {
});
});
});
describe("notificationsShowFigures (content.detail)", () => {
it("defaults to generic: no figures for a null, empty or drifted row", () => {
expect(notificationsShowFigures(null)).toBe(false);
expect(notificationsShowFigures({})).toBe(false);
expect(notificationsShowFigures({ content: { detail: "all" } })).toBe(
false,
);
});
it("shows figures only when the user chose detailed", () => {
expect(notificationsShowFigures({ content: { detail: "detailed" } })).toBe(
true,
);
expect(notificationsShowFigures({ content: { detail: "generic" } })).toBe(
false,
);
});
it("survives a merge that touches another category", () => {
const merged = resolveNotificationPrefs(
{ content: { detail: "detailed" } },
{ mood: { reminderHour: 8 } },
);
expect(merged.content).toEqual({ detail: "detailed" });
});
});
+2
View File
@@ -46,6 +46,8 @@ export const moodSourceEnum = z.enum([
"WEB",
"TELEGRAM",
"DAYLIO",
// A quick answer tapped inside a mood-reminder notification (ntfy action).
"NOTIFICATION",
]);
const MOOD_SCORE_MAP: Record<string, number> = {
+45 -1
View File
@@ -11,7 +11,8 @@
* {
* "medication": { "clientManaged": boolean },
* "mood": { "reminderHour": 0..23 },
* "cycle": { "clientManaged": boolean }
* "cycle": { "clientManaged": boolean },
* "content": { "detail": "generic" | "detailed" }
* }
*
* The schema is intentionally additive — every new category grows the
@@ -139,6 +140,23 @@ const coachPrefsSchema = z
})
.partial();
/**
* v1.42.2 — what a notification may show. `generic` (the default) says what
* the notification is about and carries no health figure, so a lock screen
* reads "Your morning briefing" rather than a score; `detailed` adds the
* figures (the morning score). Tap targets and quick answers are the same in
* both. Read by the producers that have a figure to show.
*/
export const NOTIFICATION_CONTENT_DETAILS = ["generic", "detailed"] as const;
export type NotificationContentDetail =
(typeof NOTIFICATION_CONTENT_DETAILS)[number];
const contentPrefsSchema = z
.object({
detail: z.enum(NOTIFICATION_CONTENT_DETAILS),
})
.partial();
/**
* v1.7.0 — per-device delivery override schema for the device PATCH.
* NULL clears the override (the device inherits the user-level roaming
@@ -159,6 +177,7 @@ export const notificationPrefsSchema = z
cycle: cyclePrefsSchema,
coach: coachPrefsSchema,
measurementReminder: measurementReminderPrefsSchema,
content: contentPrefsSchema,
})
.partial();
@@ -225,6 +244,10 @@ export interface NotificationPrefs {
*/
clientManaged: boolean;
};
content: {
/** v1.42.2 — whether notifications may carry health figures. */
detail: NotificationContentDetail;
};
}
/**
@@ -277,6 +300,9 @@ export const DEFAULT_NOTIFICATION_PREFS: NotificationPrefs = {
measurementReminder: {
clientManaged: false,
},
content: {
detail: "generic",
},
};
/**
@@ -325,6 +351,10 @@ export function resolveNotificationPrefs(
...base.measurementReminder,
...(incoming.measurementReminder ?? {}),
},
content: {
...base.content,
...(incoming.content ?? {}),
},
});
}
@@ -354,6 +384,7 @@ function applyDeliveryDefaultMapping(
cycle: { ...prefs.cycle },
coach: { ...prefs.coach },
measurementReminder: { ...prefs.measurementReminder },
content: { ...prefs.content },
};
}
@@ -492,6 +523,7 @@ function cloneDefaults(): NotificationPrefs {
measurementReminder: {
...DEFAULT_NOTIFICATION_PREFS.measurementReminder,
},
content: { ...DEFAULT_NOTIFICATION_PREFS.content },
};
}
@@ -517,5 +549,17 @@ function mergeOverDefaults(input: NotificationPrefsInput): NotificationPrefs {
...DEFAULT_NOTIFICATION_PREFS.measurementReminder,
...(input.measurementReminder ?? {}),
},
content: {
...DEFAULT_NOTIFICATION_PREFS.content,
...(input.content ?? {}),
},
});
}
/**
* v1.42.2 — producer-side helper. Whether this user's notifications may carry
* health figures. A null / drifted row resolves to the default (`generic`).
*/
export function notificationsShowFigures(raw: unknown): boolean {
return parseNotificationPrefs(raw).content.detail === "detailed";
}
+5
View File
@@ -41,6 +41,11 @@ export const PUBLIC_PATHS = [
"/api/health",
"/api/version",
"/api/notifications/vapid",
// v1.42.2 — notification quick answers. A notification app (ntfy's `http`
// action) calls this with no session; the signed, single-use, short-lived
// token in the path is the whole credential, verified by the route after
// its rate limit. Trailing slash: admits only `/action/<token>`.
"/api/notifications/action/",
"/api/monitoring/",
"/api/withings/webhook",
// v1.11.0 — WHOOP webhook (`recovery.updated` / `sleep.updated` /
@@ -0,0 +1,103 @@
/**
* v1.42.2 — a mood quick answer from a notification writes exactly once.
*
* The three answers on one reminder share a nonce, and the write is keyed on
* it through `MoodEntry`'s unique `(userId, source, externalId)`. Against
* real Postgres and the real route: the first answer logs, a repeat of the
* same answer, a different answer on the same reminder, and two answers
* racing each other all leave exactly one row behind.
*/
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { getPrismaClient, truncateAllTables } from "./setup";
const USER = "user-notification-action";
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
function call(token: string) {
return import("@/app/api/notifications/action/[token]/route").then(
({ POST }) =>
POST(
new NextRequest(`http://localhost/api/notifications/action/${token}`, {
method: "POST",
}),
{ params: Promise.resolve({ token }) },
),
);
}
async function tokens(nonce: string) {
const { mintActionToken } = await import("@/lib/notifications/action-token");
return {
good: mintActionToken({ userId: USER, action: "mood-good", nonce }),
bad: mintActionToken({ userId: USER, action: "mood-bad", nonce }),
};
}
beforeEach(async () => {
vi.stubEnv(
"API_TOKEN_HMAC_KEY",
"integration-hmac-key-at-least-32-characters",
);
await truncateAllTables(getPrismaClient());
await getPrismaClient().user.create({
data: {
id: USER,
username: "notification-action",
email: "notification-action@example.test",
timezone: "Europe/Berlin",
},
});
});
describe("POST /api/notifications/action/{token}", () => {
it("logs the first answer once and refuses every later answer on the reminder", async () => {
const { good, bad } = await tokens("reminder-1");
const first = await call(good);
expect(first.status).toBe(200);
const repeat = await call(good);
expect(repeat.status).toBe(409);
const other = await call(bad);
expect(other.status).toBe(409);
const rows = await getPrismaClient().moodEntry.findMany({
where: { userId: USER },
select: { source: true, externalId: true, score: true, mood: true },
});
expect(rows).toEqual([
{
source: "NOTIFICATION",
externalId: "notification-action:reminder-1",
score: 4,
mood: "GUT",
},
]);
});
it("lets only one of two racing answers write", async () => {
const { good, bad } = await tokens("reminder-2");
const statuses = (await Promise.all([call(good), call(bad)]))
.map((res) => res.status)
.sort();
expect(statuses).toEqual([200, 409]);
expect(
await getPrismaClient().moodEntry.count({ where: { userId: USER } }),
).toBe(1);
});
it("answers a separate reminder with its own entry", async () => {
const one = await tokens("reminder-3");
const two = await tokens("reminder-4");
expect((await call(one.good)).status).toBe(200);
expect((await call(two.bad)).status).toBe(200);
expect(
await getPrismaClient().moodEntry.count({ where: { userId: USER } }),
).toBe(2);
});
});