mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Merge pull request #339 from MBombeck/release/v1.18.4
v1.18.4 — urgent per-channel alerts + stronger free web-push path
This commit is contained in:
28 files changed
+1421
-64
No files matched your search
@@ -104,6 +104,7 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
|
||||
# APNS_KEY_B64="" # PEM body base64-encoded -- recommended (escapes newlines)
|
||||
# APNS_KEY="" # OR APNS_KEY (raw PEM) OR APNS_KEY_FILE -- one satisfies the manifest
|
||||
# APNS_KEY_FILE=""
|
||||
# APNS_CRITICAL_ENTITLEMENT="" # "true" only if your build has Apple's Critical Alerts entitlement; else urgent = time-sensitive
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
@@ -2,6 +2,27 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.18.4] — 2026-06-18 — urgent alerts that reach you on whatever you've set up, and a stronger free notification path
|
||||
|
||||
This release makes notifications work well for self-hosters who don't run Apple push. Genuinely urgent health signals — a sustained low-oxygen or fever pattern flagged by the condition journal — now go out at the strongest level each notification channel you've configured supports, and reach you even with no Apple Developer account: max-priority ntfy, high-urgency Web Push, a webhook, or a time-sensitive iOS alert if you do run push. The installed web app gains real reminder handling: a dose reminder clears itself once you mark it taken, and an app badge counts what's still due. No breaking changes; no migration.
|
||||
|
||||
### Added
|
||||
|
||||
- **Urgent alerts, on every channel you have.** A condition red-flag (sustained low SpO₂ or fever — "seek care", never reassurance) now sends an urgent alert that escalates per channel: time-sensitive on iOS (critical only if you've been granted and enabled Apple's entitlement), max priority on ntfy, high urgency on Web Push, flagged on the webhook. No Apple account required — an instance with only ntfy or Web Push still gets the urgent treatment. De-duplicated per condition.
|
||||
- **A real reminder experience in the installed web app.** Dose, preventive-care and low-stock reminders reach the home-screen web app over Web Push; marking a dose taken clears its reminder (no leftover notification), and an app badge shows how many doses are still due today — the closest the no-Apple-account path gets to the native lock-screen behaviour.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Notifications degrade gracefully without Apple push.** The channel cascade and every urgent path are now explicit that an instance without APNs still delivers through its other channels at their top tier.
|
||||
|
||||
### Docs
|
||||
|
||||
- A self-hosting notifications guide: what each free channel (Web Push, Telegram, ntfy, webhook, email) can and can't do, the recommended PWA + Web Push setup, and a step-by-step path for running your own signed iOS build with your own Apple Developer account and APNs key for native push and the lock-screen widget.
|
||||
|
||||
### Operator note
|
||||
|
||||
- UI/API-only; no migration. Optional new env var `APNS_CRITICAL_ENTITLEMENT` (default off) — set it only if your own iOS build carries Apple's Critical Alerts entitlement; otherwise urgent iOS alerts use the time-sensitive level, which needs no Apple approval.
|
||||
|
||||
## [1.18.3] — 2026-06-17 — the condition journal you can scroll back through, and a clearer view of what's tracked
|
||||
|
||||
A small follow-up that finishes a few rough edges from the previous releases. The condition journal's day-by-day timeline now scrolls through the whole illness, not just today; deleting a condition can be undone; the condition journal is on by default like every other area; and medication inventory that was never set now reads as "unknown" instead of a misleading zero. No breaking changes; no migration.
|
||||
|
||||
@@ -136,6 +136,9 @@ services:
|
||||
APNS_KEY_ID: "${APNS_KEY_ID:-}"
|
||||
APNS_TEAM_ID: "${APNS_TEAM_ID:-}"
|
||||
APNS_BUNDLE_ID: "${APNS_BUNDLE_ID:-}"
|
||||
# Set "true" only if your own iOS build carries Apple's Critical Alerts
|
||||
# entitlement; otherwise urgent alerts use the time-sensitive level.
|
||||
APNS_CRITICAL_ENTITLEMENT: "${APNS_CRITICAL_ENTITLEMENT:-}"
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: HealthLog API
|
||||
version: 1.18.3
|
||||
version: 1.18.4
|
||||
description: >-
|
||||
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
|
||||
|
||||
|
||||
@@ -147,7 +147,7 @@ NEXT_PUBLIC_DASHBOARD_SNAPSHOT=false pnpm build
|
||||
| Next step | File |
|
||||
| --------- | ---- |
|
||||
| TLS + reverse-proxy configuration | `docs/self-hosting/reverse-proxy.md` |
|
||||
| Push notifications — Web Push, Telegram, ntfy, APNs | `docs/self-hosting/notifications.md` |
|
||||
| Push notifications — Web Push, Telegram, ntfy, webhook, email, APNs | `docs/self-hosting/notifications.md` |
|
||||
| Web/worker process split for horizontal scale | `docs/self-hosting/scaling.md` |
|
||||
| Off-host encrypted backups to S3/R2/B2 | `docs/ops/backup-restore.md` |
|
||||
| Encryption-key rotation procedure | `docs/ops/encryption-key-rotation.md` |
|
||||
|
||||
@@ -3,17 +3,24 @@
|
||||
HealthLog delivers reminders (medication doses, mood check-ins, personal
|
||||
records) and a few operational alerts through a small cascade of push
|
||||
channels. The single message to take away: **you do not need an Apple
|
||||
Developer account or APNs to get push.** Three of the four channels are
|
||||
free, work in the browser or in a messaging app you already have, and
|
||||
cover every platform a self-hoster runs. APNs is only for the optional
|
||||
native iOS app.
|
||||
Developer account or APNs to get push.** Five of the six channels are
|
||||
free, work in the browser or in an app you already have, and cover every
|
||||
platform a self-hoster runs. APNs is only for the native iOS app, and
|
||||
even that you can build yourself (see the last section).
|
||||
|
||||
When an event fires, the dispatcher fans it out to every channel a user
|
||||
has enabled, in a fixed order — APNs first, then Telegram, then ntfy,
|
||||
then Web Push as the universal fallback
|
||||
(`src/lib/notifications/dispatcher.ts`). Each channel is best-effort and
|
||||
independent: a user can enable one, two, or all four, and a failure on
|
||||
one never blocks the others.
|
||||
then the generic webhook, then email, then Web Push as the universal
|
||||
fallback (`src/lib/notifications/dispatcher.ts`). Each channel is
|
||||
best-effort and independent: a user can enable one or several, and a
|
||||
failure on one never blocks the others.
|
||||
|
||||
An APNs-less instance degrades gracefully. Nothing about push depends on
|
||||
APNs being configured. Urgent events still surface everywhere it matters:
|
||||
ntfy escalates them to max priority (`5`), Web Push raises urgency and
|
||||
keeps the notification on screen until the user acts, and the webhook
|
||||
carries the urgent flag for your own routing. APNs only adds the
|
||||
Apple-platform exclusives the web cannot offer.
|
||||
|
||||
## TL;DR — which channel needs what
|
||||
|
||||
@@ -22,7 +29,9 @@ one never blocks the others.
|
||||
| **Web Push** | A VAPID keypair (one command, or paste into the admin panel) | Free | Any modern desktop/Android browser; the installed PWA on iPhone (iOS 16.4+, after *Add to Home Screen*) |
|
||||
| **Telegram** | A bot token from @BotFather + each user's chat ID | Free | Anywhere the Telegram app runs |
|
||||
| **ntfy** | A topic on `ntfy.sh` or your own ntfy server | Free | The ntfy app (iOS/Android) or any browser |
|
||||
| **APNs** | An Apple Developer account + a `.p8` push key | Apple Developer Program (paid) | The native iOS app only (TestFlight / App Store) |
|
||||
| **Webhook** | A URL you own (and optionally one custom header) | Free | Anywhere — you route the POST yourself |
|
||||
| **Email** | An SMTP transport you operate or rent | Free–cheap | Any mailbox |
|
||||
| **APNs** | An Apple Developer account + a `.p8` push key + your own signed iOS build | Apple Developer Program (paid) | A native iOS app you build and sign |
|
||||
|
||||
Web Push is the recommended default — it needs no third-party account,
|
||||
just a keypair you generate yourself once.
|
||||
@@ -73,9 +82,8 @@ That prints a **public key** and a **private key** (both Base64URL).
|
||||
|
||||
### 2. Store the keys via environment variables (alternative)
|
||||
|
||||
If you would rather keep
|
||||
secrets out of the database, set them in `.env` / the compose
|
||||
`environment:` block instead:
|
||||
If you would rather keep secrets out of the database, set them in `.env` /
|
||||
the compose `environment:` block instead:
|
||||
|
||||
```env
|
||||
VAPID_PUBLIC_KEY="BElx...(public key)"
|
||||
@@ -96,7 +104,28 @@ button, and a Test button to fire a one-off push once subscribed. The
|
||||
browser prompts for notification permission on first subscribe; once
|
||||
granted, that browser receives every enabled event.
|
||||
|
||||
### iPhone caveat — Add to Home Screen first
|
||||
## The PWA path — install to the Home Screen
|
||||
|
||||
Installing HealthLog as a PWA and enabling Web Push is the recommended
|
||||
default for phones. It needs nothing from Apple and gives you, in
|
||||
v1.18.4:
|
||||
|
||||
- **Reminders.** Medication doses, mood check-ins, and any other event
|
||||
the user has enabled, delivered to the installed app even when it is
|
||||
closed.
|
||||
- **Taken → clears the reminder.** When you log a dose, the server sends
|
||||
a silent `type:"clear"` push that closes the still-pending dose-due
|
||||
reminder for that slot (matched on its stable tag) without showing a
|
||||
new notification (`public/sw.js`). This is the PWA equivalent of ending
|
||||
an iOS Live Activity.
|
||||
- **An app badge for outstanding doses.** The icon on the Home Screen
|
||||
carries a server-authoritative count of doses still due; logging a dose
|
||||
decrements it, and a count of zero clears the badge. The count is
|
||||
computed on the server and reflected by the service worker via the
|
||||
Badging API (`navigator.setAppBadge`), feature-detected — engines
|
||||
without it (most desktop Firefox, older Safari) silently no-op.
|
||||
|
||||
### iPhone — Add to Home Screen first
|
||||
|
||||
Safari on iPhone supports Web Push only from **iOS 16.4 or newer**, and
|
||||
only for a site that has been **installed to the Home Screen as a PWA**.
|
||||
@@ -112,6 +141,14 @@ A regular Safari tab cannot subscribe. So on iPhone:
|
||||
Android and desktop browsers have no such step — Subscribe and grant the
|
||||
permission, and push works.
|
||||
|
||||
### The inherent limit
|
||||
|
||||
The installed PWA gets reminders, the taken-clears behaviour, and the app
|
||||
badge — but it **cannot** show a native iOS lock-screen Live Activity
|
||||
widget. That single feature rides Apple Push Notification service and is
|
||||
only available through a native iOS build with APNs. Reminders themselves
|
||||
arrive on both paths; only the Live Activity is APNs-exclusive.
|
||||
|
||||
## Telegram
|
||||
|
||||
Telegram delivers reminders as bot messages, with inline action buttons
|
||||
@@ -146,38 +183,112 @@ HealthLog publishes to that topic.
|
||||
|
||||
If your ntfy server requires auth, an access token can be attached; the
|
||||
server URL is validated as a public host and dialled through the egress
|
||||
guard (`src/lib/notifications/senders/ntfy.ts`).
|
||||
guard (`src/lib/notifications/senders/ntfy.ts`). Urgent events are sent at
|
||||
ntfy's top priority (`5` / max) so they bypass batching and surface on the
|
||||
lock screen.
|
||||
|
||||
## APNs — native iOS app only
|
||||
## Webhook
|
||||
|
||||
Apple Push Notification service is used by **only** the native SwiftUI
|
||||
iOS app (TestFlight / App Store build). PWA and browser users never touch
|
||||
it. Setting it up requires a paid **Apple Developer Program** membership.
|
||||
The generic webhook POSTs every enabled event as JSON to a URL you own —
|
||||
wire it into your own automation (Home Assistant, n8n, a Discord/Slack
|
||||
relay, whatever routes the payload). You supply a public URL and,
|
||||
optionally, one custom header (e.g. an auth token).
|
||||
|
||||
You do **not** need APNs to give iPhone users push: the installed PWA
|
||||
gets every reminder over Web Push (see the iPhone caveat above). APNs adds
|
||||
one Apple-platform exclusive the PWA cannot offer — the lock-screen Live
|
||||
Activity — plus the tighter native delivery path. Reminders themselves
|
||||
arrive on both.
|
||||
1. In HealthLog open `/settings/notifications`, fill the Webhook card's
|
||||
**URL** and, if your endpoint expects one, a single **header name** +
|
||||
**header value**.
|
||||
2. Save. A Test button fires a one-off POST so you can confirm routing.
|
||||
|
||||
If you do run the native app and want server-driven push, set the APNs
|
||||
manifest (`src/lib/notifications/senders/apns.ts`) — three identifiers
|
||||
plus exactly one key source:
|
||||
The URL is validated as a public host and dialled through
|
||||
`safeFetch({ requirePublicHost: true })` — the SSRF guard blocks private
|
||||
and loopback ranges because the target is user-supplied
|
||||
(`src/lib/notifications/senders/webhook.ts`). The webhook carries the
|
||||
event's urgency flag so you can route urgent events differently.
|
||||
|
||||
## Email (SMTP)
|
||||
|
||||
The email channel sends reminders to a mailbox over an SMTP transport you
|
||||
operate or rent. It is configured **instance-wide by the operator** (one
|
||||
transport serves every user); each user then enables the channel and sets
|
||||
their destination address from `/settings/notifications`.
|
||||
|
||||
Set the SMTP transport in `.env` / the compose `environment:` block:
|
||||
|
||||
```env
|
||||
SMTP_HOST="smtp.example.com"
|
||||
SMTP_PORT="587"
|
||||
SMTP_FROM="HealthLog <noreply@example.com>"
|
||||
SMTP_USER="" # optional — omit for an unauthenticated relay
|
||||
SMTP_PASS="" # optional
|
||||
SMTP_SECURE="false" # true = implicit TLS (port 465); false = STARTTLS
|
||||
```
|
||||
|
||||
`SMTP_HOST` + `SMTP_PORT` + `SMTP_FROM` together enable the channel;
|
||||
`SMTP_USER` / `SMTP_PASS` are optional (omit them for an unauthenticated
|
||||
relay). `SMTP_SECURE=true` uses implicit TLS on port 465; leave it `false`
|
||||
for STARTTLS on 587. As with every var in `docker-compose.yml`, these must
|
||||
be on the compose `environment:` whitelist to reach the container.
|
||||
|
||||
## APNs — native iOS, and why it needs your own Apple account
|
||||
|
||||
Apple Push Notification service is used by **only** a native SwiftUI iOS
|
||||
app. PWA, browser, Telegram, ntfy, webhook, and email users never touch
|
||||
it. It is the one channel a self-hoster cannot simply switch on, and the
|
||||
reason is worth stating plainly.
|
||||
|
||||
### Why your server can't push to the published app
|
||||
|
||||
APNs binds a push to a specific **Apple Developer team + app bundle ID +
|
||||
signing key**. The HealthLog iOS app published on TestFlight / the App
|
||||
Store is signed under **the maintainer's Apple team and bundle ID**. Your
|
||||
self-hosted server has neither that team's `.p8` key nor the authority to
|
||||
mint one, so it **cannot** send APNs pushes to the published app. There is
|
||||
no way around this — it is how Apple's trust model works, by design.
|
||||
|
||||
To get native iOS push **and** the lock-screen Live Activity widget on
|
||||
**your own** infrastructure, you have to publish your own copy of the app
|
||||
under your own Apple identity. Everything below is that path.
|
||||
|
||||
### Step by step — your own native iOS build
|
||||
|
||||
1. **Get an Apple Developer account** (~$99/yr). Required to sign an iOS
|
||||
app for a real device and to create a push key.
|
||||
2. **Build and sign your own copy of the iOS app.** The client is open in
|
||||
a separate repository (`healthlog-iOS`). Open it in Xcode and set your
|
||||
**own bundle identifier** and **your team** as the signing team, then
|
||||
build to your device (or distribute via your own TestFlight).
|
||||
3. **Create your own APNs `.p8` auth key** in the Apple Developer Portal,
|
||||
under **Keys → Create a Key → Apple Push Notifications service (APNs)**.
|
||||
Note the **Key ID**, and your **Team ID** (top-right of the portal).
|
||||
Scope the key **Sandbox & Production** (see the gotcha below).
|
||||
4. **Point your bundle ID at your server.** Build the iOS app against your
|
||||
instance so the device registers its APNs token with your server.
|
||||
5. **Set the APNs manifest on the server.** Add the env vars below to
|
||||
`.env` / the compose `environment:` block.
|
||||
|
||||
The APNs env vars (`src/lib/notifications/senders/apns.ts`):
|
||||
|
||||
| Variable | Purpose |
|
||||
| --- | --- |
|
||||
| `APNS_KEY_ID` | The `.p8` key's Key ID |
|
||||
| `APNS_TEAM_ID` | Your Apple Developer Team ID |
|
||||
| `APNS_BUNDLE_ID` | The app's bundle identifier |
|
||||
| `APNS_BUNDLE_ID` | Your app's bundle identifier |
|
||||
| `APNS_KEY_B64` | The `.p8` PEM, base64-encoded (recommended key source) |
|
||||
| `APNS_KEY` | OR the raw PEM body inline |
|
||||
| `APNS_KEY_FILE` | OR a path to the `.p8` file |
|
||||
| `APNS_CRITICAL_ENTITLEMENT` | Optional — set `true` only if Apple grants your app the Critical Alerts entitlement |
|
||||
|
||||
APNs is **all-or-none**: either set the three IDs plus one key source, or
|
||||
leave the whole block unset. A partial config is rejected so the channel
|
||||
never silently half-works (`scripts/check-env.ts`). When more than one key
|
||||
source is present, the precedence is `APNS_KEY_B64` > `APNS_KEY` >
|
||||
`APNS_KEY_FILE`.
|
||||
APNs is **all-or-none**: either set the three IDs plus exactly one key
|
||||
source, or leave the whole block unset. A partial config is rejected so
|
||||
the channel never silently half-works (`scripts/check-env.ts`). When more
|
||||
than one key source is present, the precedence is `APNS_KEY_B64` >
|
||||
`APNS_KEY` > `APNS_KEY_FILE`.
|
||||
|
||||
`APNS_CRITICAL_ENTITLEMENT=true` is honoured only if Apple has actually
|
||||
granted your app the Critical Alerts entitlement (a separate request to
|
||||
Apple). Without that entitlement, leave it unset — the server keeps urgent
|
||||
events at normal priority rather than minting a `critical` payload your
|
||||
app cannot send (`src/lib/notifications/types.ts`).
|
||||
|
||||
Two gotchas worth knowing before you blame a stale token:
|
||||
|
||||
@@ -190,9 +301,9 @@ Two gotchas worth knowing before you blame a stale token:
|
||||
`BadEnvironmentKeyInToken`. Confirm the key shows **Sandbox &
|
||||
Production**; a wrongly scoped key cannot be reconfigured — re-issue it.
|
||||
|
||||
> **`clientManaged` is iOS-app-only.** The native iOS app can opt to run
|
||||
> its own local medication reminders and ask the server to stop sending
|
||||
> the duplicate `MEDICATION_REMINDER` push (the `clientManaged` flag on
|
||||
> **`clientManaged` is iOS-app-only.** A native iOS app can opt to run its
|
||||
> own local medication reminders and ask the server to stop sending the
|
||||
> duplicate `MEDICATION_REMINDER` push (the `clientManaged` flag on
|
||||
> `PATCH /api/auth/me/notification-prefs`). This is a native-app contract:
|
||||
> a web/PWA-only self-hoster never needs it and there is no toggle for it
|
||||
> in the web UI. If you only run the PWA, ignore it entirely — your
|
||||
@@ -203,9 +314,11 @@ Two gotchas worth knowing before you blame a stale token:
|
||||
| Your setup | Recommended channel |
|
||||
| --- | --- |
|
||||
| PWA on Android or desktop | **Web Push** — generate a VAPID keypair, subscribe, done. |
|
||||
| PWA on an iPhone | **Web Push**, but install the PWA via *Add to Home Screen* first (iOS 16.4+). Reminders work; the lock-screen Live Activity needs the native app. |
|
||||
| You want the native iOS app's full experience | **APNs** (paid Apple Developer account) *plus* Web Push as a fallback for any non-iOS device. |
|
||||
| PWA on an iPhone | **Web Push**, but install the PWA via *Add to Home Screen* first (iOS 16.4+). Reminders, taken-clears, and the app badge all work; the lock-screen Live Activity needs a native build. |
|
||||
| You want the native iOS lock-screen Live Activity | Build and sign **your own** iOS app under your own Apple account and wire up **APNs** (paid), with Web Push as a fallback for any non-iOS device. |
|
||||
| Headless / no browser, or you live in chat | **Telegram** or **ntfy** — neither needs a browser, both are free. ntfy is the most self-host-native; Telegram adds inline reminder action buttons. |
|
||||
| You already run an automation hub | **Webhook** — POST every event to your own URL and route it however you like. |
|
||||
| You want reminders in your inbox | **Email** — set the SMTP transport once as the operator; users add their address. |
|
||||
|
||||
Mixing channels is fine and common — e.g. Web Push on the laptop and
|
||||
ntfy on the phone. Each user configures their own set from
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.18.3",
|
||||
"version": "1.18.4",
|
||||
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"homepage": "https://healthlog.dev",
|
||||
|
||||
+62
-8
@@ -36,7 +36,7 @@ try {
|
||||
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
|
||||
const CACHE_VERSION =
|
||||
(typeof self !== "undefined" && self.__APP_VERSION__) ||
|
||||
/* @sw-version-fallback */ "v1.18.2";
|
||||
/* @sw-version-fallback */ "v1.18.4";
|
||||
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
|
||||
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
|
||||
const MAX_STATIC_ENTRIES = 150;
|
||||
@@ -216,6 +216,27 @@ async function trimCache(cacheName, maxEntries) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── App badge helper (PWA Badging API) ───────────────────────────────────────
|
||||
// v1.18.4 — reflect the server-authoritative outstanding-dose count on the
|
||||
// installed PWA icon. Feature-detected + best-effort: unsupported engines
|
||||
// (most desktop Firefox, older Safari) silently no-op. A count of 0 clears the
|
||||
// badge; a positive count sets it. `undefined`/non-number leaves it untouched.
|
||||
function applyAppBadge(count) {
|
||||
if (typeof count !== "number" || !isFinite(count)) return;
|
||||
try {
|
||||
if (count > 0) {
|
||||
if (typeof self.navigator?.setAppBadge === "function") {
|
||||
self.navigator.setAppBadge(count);
|
||||
}
|
||||
} else if (typeof self.navigator?.clearAppBadge === "function") {
|
||||
self.navigator.clearAppBadge();
|
||||
}
|
||||
} catch {
|
||||
// Badging API can reject (permission / unsupported) — never let it
|
||||
// break the push handler.
|
||||
}
|
||||
}
|
||||
|
||||
// ── Push Notifications ───────────────────────────────────────────────────────
|
||||
self.addEventListener("push", (event) => {
|
||||
if (!event.data) return;
|
||||
@@ -227,21 +248,54 @@ self.addEventListener("push", (event) => {
|
||||
payload = { title: "HealthLog", body: event.data.text() };
|
||||
}
|
||||
|
||||
// v1.18.4 — `type:"clear"` is the PWA equivalent of ending an iOS Live
|
||||
// Activity: the server sends it when a dose is logged so the still-pending
|
||||
// dose-due reminder for that slot is closed here (matched on its stable
|
||||
// `tag`), and the app badge re-reflects the outstanding-dose count. No new
|
||||
// notification is shown.
|
||||
if (payload && payload.type === "clear") {
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
const tagToClear = payload.tag;
|
||||
if (tagToClear) {
|
||||
const matches = await self.registration.getNotifications({
|
||||
tag: tagToClear,
|
||||
});
|
||||
for (const n of matches) n.close();
|
||||
}
|
||||
applyAppBadge(payload.badge);
|
||||
})(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const {
|
||||
title = "HealthLog",
|
||||
body = "",
|
||||
tag = "default",
|
||||
url = "/",
|
||||
badge,
|
||||
requireInteraction = false,
|
||||
} = payload;
|
||||
|
||||
event.waitUntil(
|
||||
self.registration.showNotification(title, {
|
||||
body,
|
||||
tag,
|
||||
icon: "/logo-192.png",
|
||||
badge: "/logo-192.png",
|
||||
data: { url },
|
||||
}),
|
||||
Promise.all([
|
||||
self.registration.showNotification(title, {
|
||||
body,
|
||||
tag,
|
||||
// `renotify` lets a re-fired reminder for the same `tag` re-alert the
|
||||
// user instead of silently replacing the existing notification.
|
||||
renotify: true,
|
||||
// v1.18.4 — urgent events (the web-push sender sets this) keep the
|
||||
// notification on screen until the user acts, where the browser
|
||||
// honours it.
|
||||
requireInteraction: requireInteraction === true,
|
||||
icon: "/logo-192.png",
|
||||
badge: "/logo-192.png",
|
||||
data: { url },
|
||||
}),
|
||||
Promise.resolve(applyAppBadge(badge)),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -114,15 +114,47 @@ function bootServiceWorker(): SwHarness {
|
||||
navPreload.enabled = true;
|
||||
},
|
||||
};
|
||||
// v1.18.4 — push-handler fakes: a notification store the `getNotifications`
|
||||
// / `showNotification` calls read/write, and a Badging-API spy.
|
||||
const shown: Array<{ title: string; tag?: string; closed: boolean }> = [];
|
||||
const badge = { value: undefined as number | undefined, cleared: false };
|
||||
const selfObj = {
|
||||
addEventListener: (type: string, fn: (event: unknown) => void) => {
|
||||
listeners.set(type, fn);
|
||||
},
|
||||
skipWaiting: async () => {},
|
||||
clients: { claim: async () => {} },
|
||||
registration: { navigationPreload: navPreload },
|
||||
registration: {
|
||||
navigationPreload: navPreload,
|
||||
showNotification: async (title: string, opts: { tag?: string }) => {
|
||||
shown.push({ title, tag: opts?.tag, closed: false });
|
||||
},
|
||||
getNotifications: async ({ tag }: { tag?: string }) =>
|
||||
shown
|
||||
.filter((n) => !n.closed && (tag === undefined || n.tag === tag))
|
||||
.map((n) => ({
|
||||
close: () => {
|
||||
n.closed = true;
|
||||
},
|
||||
})),
|
||||
},
|
||||
navigator: {
|
||||
setAppBadge: (n: number) => {
|
||||
badge.value = n;
|
||||
},
|
||||
clearAppBadge: () => {
|
||||
badge.cleared = true;
|
||||
badge.value = undefined;
|
||||
},
|
||||
},
|
||||
location: { origin: ORIGIN },
|
||||
};
|
||||
(
|
||||
selfObj as unknown as { __shown: typeof shown; __badge: typeof badge }
|
||||
).__shown = shown;
|
||||
(
|
||||
selfObj as unknown as { __shown: typeof shown; __badge: typeof badge }
|
||||
).__badge = badge;
|
||||
const context: Record<string, unknown> = {
|
||||
self: selfObj,
|
||||
caches: cacheStorage,
|
||||
@@ -306,3 +338,77 @@ describe("sw.js — trimCache", () => {
|
||||
expect(store.map.has(`${ORIGIN}/page-7`)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── push handler (v1.18.4: clear-on-taken tag close + app badge) ─────────────
|
||||
async function dispatchPush(
|
||||
harness: SwHarness,
|
||||
data: unknown,
|
||||
): Promise<void> {
|
||||
let settled: Promise<unknown> = Promise.resolve();
|
||||
harness.listeners.get("push")!({
|
||||
data: { json: () => data, text: () => JSON.stringify(data) },
|
||||
waitUntil: (p: Promise<unknown>) => {
|
||||
settled = p;
|
||||
},
|
||||
});
|
||||
await settled;
|
||||
}
|
||||
|
||||
function swFakes(harness: SwHarness) {
|
||||
const selfObj = harness.context.self as unknown as {
|
||||
__shown: Array<{ title: string; tag?: string; closed: boolean }>;
|
||||
__badge: { value: number | undefined; cleared: boolean };
|
||||
};
|
||||
return { shown: selfObj.__shown, badge: selfObj.__badge };
|
||||
}
|
||||
|
||||
describe("sw.js — push handler", () => {
|
||||
it("shows a reminder with its stable tag and sets the app badge", async () => {
|
||||
const harness = bootServiceWorker();
|
||||
const { shown, badge } = swFakes(harness);
|
||||
const tag = "med:med-1:2026-06-18T07:00:00.000Z";
|
||||
|
||||
await dispatchPush(harness, {
|
||||
title: "Time for your dose",
|
||||
body: "Ramipril 5mg",
|
||||
tag,
|
||||
badge: 3,
|
||||
});
|
||||
|
||||
expect(shown).toHaveLength(1);
|
||||
expect(shown[0].tag).toBe(tag);
|
||||
expect(badge.value).toBe(3);
|
||||
});
|
||||
|
||||
it("a type:clear push closes the matching-tag notification (no new one) and updates the badge", async () => {
|
||||
const harness = bootServiceWorker();
|
||||
const { shown, badge } = swFakes(harness);
|
||||
const tag = "med:med-1:2026-06-18T07:00:00.000Z";
|
||||
|
||||
await dispatchPush(harness, { title: "dose", tag, badge: 1 });
|
||||
expect(shown.filter((n) => !n.closed)).toHaveLength(1);
|
||||
|
||||
await dispatchPush(harness, { type: "clear", tag, badge: 0 });
|
||||
|
||||
// The pending reminder for that slot is closed; no new notification shown.
|
||||
expect(shown).toHaveLength(1);
|
||||
expect(shown[0].closed).toBe(true);
|
||||
// badge: 0 clears the app badge.
|
||||
expect(badge.cleared).toBe(true);
|
||||
});
|
||||
|
||||
it("a type:clear push only closes its own slot's tag", async () => {
|
||||
const harness = bootServiceWorker();
|
||||
const { shown } = swFakes(harness);
|
||||
|
||||
await dispatchPush(harness, { title: "a", tag: "med:a:t1" });
|
||||
await dispatchPush(harness, { title: "b", tag: "med:b:t2" });
|
||||
|
||||
await dispatchPush(harness, { type: "clear", tag: "med:a:t1" });
|
||||
|
||||
const a = shown.find((n) => n.tag === "med:a:t1")!;
|
||||
const b = shown.find((n) => n.tag === "med:b:t2")!;
|
||||
expect(a.closed).toBe(true);
|
||||
expect(b.closed).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -21,6 +21,7 @@ import { annotate } from "@/lib/logging/context";
|
||||
import { apiSuccess, apiError } from "@/lib/api-response";
|
||||
import { requireIllnessEnabled } from "@/lib/illness/gate";
|
||||
import { computeEpisodeCorrelation } from "@/lib/illness/correlation-read";
|
||||
import { notifyIllnessRedFlag } from "@/lib/illness/red-flag-notify";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
@@ -74,6 +75,18 @@ export const GET = apiHandler(
|
||||
},
|
||||
});
|
||||
|
||||
// v1.18.4 — bridge the red-flag escalation to an urgent push. Owner-scoped
|
||||
// (the episode is already confirmed to belong to `user.id`) + module-gated
|
||||
// (the illness gate above). Awaited but internally de-duped + no-throw, so a
|
||||
// re-read never re-fires and a notification hiccup can't fail the read.
|
||||
if (derived.status === "ok" && derived.value.redFlags.length > 0) {
|
||||
await notifyIllnessRedFlag({
|
||||
userId: user.id,
|
||||
episodeId: id,
|
||||
redFlags: derived.value.redFlags,
|
||||
});
|
||||
}
|
||||
|
||||
return apiSuccess({
|
||||
episodeId: id,
|
||||
status: derived.status,
|
||||
|
||||
@@ -69,6 +69,8 @@ import {
|
||||
} from "@/lib/validations/medication";
|
||||
import type { InjectionSiteKey } from "@/lib/medications/injection-sites";
|
||||
import { dispatchMedicationIntakeSyncBulk } from "@/lib/notifications/medication-intake-sync";
|
||||
import { dispatchMedicationIntakeWebClearBulk } from "@/lib/notifications/web-push-clear";
|
||||
import { countOutstandingDosesToday } from "@/lib/medications/outstanding-doses";
|
||||
|
||||
const MAX_ENTRIES_PER_BATCH = 500;
|
||||
const BATCH_RATE_LIMIT_MAX = 60;
|
||||
@@ -711,6 +713,23 @@ async function postBulk(request: NextRequest): Promise<Response> {
|
||||
slots: Array.from(syncSlots.values()),
|
||||
originDeviceToken: request.headers.get("x-device-id"),
|
||||
});
|
||||
|
||||
// v1.18.4 — PWA-only counterpart: close the still-pending dose-due
|
||||
// reminders for every affected slot over Web Push and refresh the app
|
||||
// badge once with the post-batch outstanding-dose count. Best-effort,
|
||||
// fire-and-forget — a clear miss never affects the batch response.
|
||||
const affectedSlots = Array.from(syncSlots.values());
|
||||
void (async () => {
|
||||
const badgeCount = await countOutstandingDosesToday(
|
||||
user.id,
|
||||
user.timezone,
|
||||
);
|
||||
await dispatchMedicationIntakeWebClearBulk({
|
||||
userId: user.id,
|
||||
slots: affectedSlots,
|
||||
badgeCount,
|
||||
});
|
||||
})();
|
||||
}
|
||||
|
||||
return apiSuccess({
|
||||
|
||||
@@ -51,6 +51,8 @@ import {
|
||||
type InjectionSiteValue,
|
||||
} from "@/lib/validations/medication";
|
||||
import { dispatchMedicationIntakeSync } from "@/lib/notifications/medication-intake-sync";
|
||||
import { dispatchMedicationIntakeWebClear } from "@/lib/notifications/web-push-clear";
|
||||
import { countOutstandingDosesToday } from "@/lib/medications/outstanding-doses";
|
||||
|
||||
const querySchema = z.object({
|
||||
scope: z.enum(["today", "compliance"]),
|
||||
@@ -698,5 +700,23 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
originDeviceToken: request.headers.get("x-device-id"),
|
||||
});
|
||||
|
||||
// v1.18.4 — PWA-only equivalent of the Live Activity end: when a dose is
|
||||
// resolved (taken / skipped), push a `type:"clear"` to the user's Web Push
|
||||
// subscriptions so the still-pending dose-due reminder for this slot is
|
||||
// closed by the service worker (matched on the stable slot tag) and the app
|
||||
// badge re-reflects the outstanding-dose count. A snooze leaves the dose
|
||||
// outstanding, so it neither clears the reminder nor changes the badge.
|
||||
if (status === "taken" || status === "skipped") {
|
||||
void (async () => {
|
||||
const badgeCount = await countOutstandingDosesToday(user.id, userTzForHook);
|
||||
await dispatchMedicationIntakeWebClear({
|
||||
userId: user.id,
|
||||
medicationId: existing.medicationId,
|
||||
scheduledFor: (movedTo ?? existing.scheduledFor).toISOString(),
|
||||
badgeCount,
|
||||
});
|
||||
})();
|
||||
}
|
||||
|
||||
return apiSuccess(updated);
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* v1.18.4 — illness red-flag escalation push.
|
||||
*
|
||||
* Pins: empty red-flags is a no-op; a fresh red flag dispatches an URGENT
|
||||
* localised SYSTEM_ALERT; a recent ledger row de-dupes (no re-fire); the
|
||||
* fever reason is preferred for the body; failures never throw.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const findFirstMock = vi.fn();
|
||||
const createMock = vi.fn();
|
||||
const dispatchMock = vi.fn();
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
pushAttempt: {
|
||||
findFirst: (...a: unknown[]) => findFirstMock(...a),
|
||||
create: (...a: unknown[]) => createMock(...a),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
getEvent: () => ({ addMeta: vi.fn(), addWarning: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/notifications/dispatch-localised", () => ({
|
||||
dispatchLocalisedNotification: (...a: unknown[]) => dispatchMock(...a),
|
||||
}));
|
||||
|
||||
import { notifyIllnessRedFlag } from "../red-flag-notify";
|
||||
import type { IllnessRedFlag } from "../correlation";
|
||||
|
||||
const spo2Flag: IllnessRedFlag = {
|
||||
type: "SPO2" as never,
|
||||
reason: "sustained_low_spo2",
|
||||
worstValue: 89,
|
||||
days: 3,
|
||||
};
|
||||
const feverFlag: IllnessRedFlag = {
|
||||
type: "BODY_TEMPERATURE" as never,
|
||||
reason: "sustained_fever",
|
||||
worstValue: 39.1,
|
||||
days: 3,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
findFirstMock.mockReset().mockResolvedValue(null);
|
||||
createMock.mockReset().mockResolvedValue({});
|
||||
dispatchMock.mockReset().mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
describe("notifyIllnessRedFlag", () => {
|
||||
it("no-op when there are no red flags", async () => {
|
||||
await notifyIllnessRedFlag({ userId: "u1", episodeId: "e1", redFlags: [] });
|
||||
expect(dispatchMock).not.toHaveBeenCalled();
|
||||
expect(findFirstMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("dispatches an URGENT SYSTEM_ALERT for a fresh red flag", async () => {
|
||||
await notifyIllnessRedFlag({
|
||||
userId: "u1",
|
||||
episodeId: "e1",
|
||||
redFlags: [spo2Flag],
|
||||
});
|
||||
expect(dispatchMock).toHaveBeenCalledTimes(1);
|
||||
const opts = dispatchMock.mock.calls[0][0];
|
||||
expect(opts.urgent).toBe(true);
|
||||
expect(opts.eventType).toBe("SYSTEM_ALERT");
|
||||
expect(opts.userId).toBe("u1");
|
||||
expect(opts.titleKey).toBe("illness.correlation.redFlagTitle");
|
||||
expect(opts.messageKey).toBe("illness.correlation.redFlagSpo2");
|
||||
});
|
||||
|
||||
it("prefers the fever reason for the body when both flags fire", async () => {
|
||||
await notifyIllnessRedFlag({
|
||||
userId: "u1",
|
||||
episodeId: "e1",
|
||||
redFlags: [spo2Flag, feverFlag],
|
||||
});
|
||||
expect(dispatchMock.mock.calls[0][0].messageKey).toBe(
|
||||
"illness.correlation.redFlagFever",
|
||||
);
|
||||
});
|
||||
|
||||
it("de-dupes when a recent ledger row exists for the episode", async () => {
|
||||
findFirstMock.mockResolvedValueOnce({ id: "prior" });
|
||||
await notifyIllnessRedFlag({
|
||||
userId: "u1",
|
||||
episodeId: "e1",
|
||||
redFlags: [feverFlag],
|
||||
});
|
||||
expect(dispatchMock).not.toHaveBeenCalled();
|
||||
expect(createMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stamps the ledger anchor before dispatching", async () => {
|
||||
await notifyIllnessRedFlag({
|
||||
userId: "u1",
|
||||
episodeId: "e9",
|
||||
redFlags: [feverFlag],
|
||||
});
|
||||
expect(createMock).toHaveBeenCalledTimes(1);
|
||||
expect(createMock.mock.calls[0][0].data.reason).toContain("e9");
|
||||
});
|
||||
|
||||
it("never throws when dispatch fails", async () => {
|
||||
dispatchMock.mockRejectedValueOnce(new Error("boom"));
|
||||
await expect(
|
||||
notifyIllnessRedFlag({
|
||||
userId: "u1",
|
||||
episodeId: "e1",
|
||||
redFlags: [feverFlag],
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* v1.18.4 — illness red-flag escalation push.
|
||||
*
|
||||
* The illness correlation engine (`computeIllnessCorrelation`) surfaces a
|
||||
* retrospective "seek care" red flag when a vital held an absolute clinical
|
||||
* floor for a sustained run (sustained low SpO2, sustained fever). Until now
|
||||
* that escalation only rendered in-app on the correlation surface; it never
|
||||
* left a push. This helper bridges it to the notification dispatcher so the
|
||||
* escalation reaches the user wherever they configured a channel.
|
||||
*
|
||||
* It dispatches at the URGENT level: every configured channel delivers at its
|
||||
* highest urgency (APNs time-sensitive, ntfy max, Web Push `Urgency: high`,
|
||||
* webhook `urgent`). There is NO dependency on APNs — an instance with only
|
||||
* ntfy / Web Push / Telegram / webhook / email still gets the escalation at
|
||||
* the strongest tier those channels support.
|
||||
*
|
||||
* Owner-scoped (the authenticated episode owner) and module-gated by the
|
||||
* caller (`requireIllnessEnabled`). De-duplicated through the `pushAttempt`
|
||||
* ledger: at most one escalation per episode per rolling window, so a
|
||||
* correlation surface that is re-read repeatedly never re-fires the alarm.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getEvent } from "@/lib/logging/context";
|
||||
import { dispatchLocalisedNotification } from "@/lib/notifications/dispatch-localised";
|
||||
import type { IllnessRedFlag } from "@/lib/illness/correlation";
|
||||
|
||||
/** One escalation per episode per this window. */
|
||||
const DEDUPE_WINDOW_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/** Stable ledger reason prefix so the dedupe lookup is exact-matchable. */
|
||||
const LEDGER_REASON_PREFIX = "illness_red_flag:";
|
||||
|
||||
/**
|
||||
* Emit an urgent escalation push for an episode's red flags, at most once
|
||||
* per `DEDUPE_WINDOW_MS`. No-op when `redFlags` is empty. Never throws —
|
||||
* a notification failure must not break the correlation read.
|
||||
*/
|
||||
export async function notifyIllnessRedFlag(input: {
|
||||
userId: string;
|
||||
episodeId: string;
|
||||
redFlags: IllnessRedFlag[];
|
||||
}): Promise<void> {
|
||||
const { userId, episodeId, redFlags } = input;
|
||||
if (redFlags.length === 0) return;
|
||||
|
||||
try {
|
||||
// Dedupe: skip when an escalation for THIS episode already landed in the
|
||||
// ledger within the window. The episode id rides the `reason` column so
|
||||
// the lookup is exact without a schema change.
|
||||
const reason = `${LEDGER_REASON_PREFIX}${episodeId}`;
|
||||
const since = new Date(Date.now() - DEDUPE_WINDOW_MS);
|
||||
const prior = await prisma.pushAttempt.findFirst({
|
||||
where: { userId, reason, createdAt: { gte: since } },
|
||||
select: { id: true },
|
||||
});
|
||||
if (prior) return;
|
||||
|
||||
// Prefer the most clinically actionable reason for the body. Both copy
|
||||
// keys already exist in every locale bundle (i18n integrity guarantee).
|
||||
const fever = redFlags.find((f) => f.reason === "sustained_fever");
|
||||
const messageKey = fever
|
||||
? "illness.correlation.redFlagFever"
|
||||
: "illness.correlation.redFlagSpo2";
|
||||
|
||||
// Stamp the ledger BEFORE dispatching so a slow/duplicate concurrent read
|
||||
// can't double-fire. The senders write their own per-channel rows too;
|
||||
// this synthetic row is purely the dedupe anchor.
|
||||
await prisma.pushAttempt.create({
|
||||
data: {
|
||||
userId,
|
||||
channel: "WEB_PUSH",
|
||||
eventType: "SYSTEM_ALERT",
|
||||
result: "skipped",
|
||||
reason,
|
||||
},
|
||||
});
|
||||
|
||||
await dispatchLocalisedNotification({
|
||||
userId,
|
||||
titleKey: "illness.correlation.redFlagTitle",
|
||||
messageKey,
|
||||
eventType: "SYSTEM_ALERT",
|
||||
urgent: true,
|
||||
});
|
||||
|
||||
getEvent()?.addMeta("illness_red_flag_notified", episodeId);
|
||||
} catch (err) {
|
||||
getEvent()?.addWarning(
|
||||
`illness red-flag notify failed for episode ${episodeId}: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-
|
||||
import { decrypt } from "@/lib/crypto";
|
||||
import { withBackgroundEvent } from "@/lib/logging/background";
|
||||
import { dispatchNotification } from "@/lib/notifications/dispatcher";
|
||||
import { medicationDoseTag } from "@/lib/notifications/dose-tag";
|
||||
import {
|
||||
buildCanonicalSchedule,
|
||||
buildRecurrenceContext,
|
||||
@@ -494,6 +495,15 @@ export async function handleReminderCheck(jobs: Job<ReminderCheckPayload>[]) {
|
||||
timeOfDay: dedupTimeOfDay,
|
||||
scheduledAt: scheduledAtIso,
|
||||
replyMarkup: keyboard,
|
||||
// v1.18.4 — stable per-slot Web Push tag. The
|
||||
// clear-on-taken push reuses the SAME tag so the SW
|
||||
// replaces/closes this reminder when the dose is logged
|
||||
// (the PWA equivalent of ending a Live Activity), and a
|
||||
// re-fired reminder for the same slot coalesces instead
|
||||
// of stacking on the lock screen. Deep-link clicks land
|
||||
// on the dose's medication page.
|
||||
webPushTag: medicationDoseTag(med.id, scheduledAtIso),
|
||||
url: `/medications/${med.id}`,
|
||||
},
|
||||
});
|
||||
} catch (notifErr) {
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* v1.18.4 — server-authoritative count of a user's still-outstanding doses
|
||||
* for the current local day. Drives the PWA app badge (`navigator.setAppBadge`)
|
||||
* so the installed icon mirrors how many doses still need logging — the
|
||||
* free-tier equivalent of the iOS widget's pending count.
|
||||
*
|
||||
* Outstanding = a today-scheduled `MedicationIntakeEvent` that is neither
|
||||
* taken (`takenAt == null`) nor skipped (`skipped == false`) nor swept by the
|
||||
* miss cutoff (`autoMissed == false`). Pending rows are minted by
|
||||
* `projectTodayIntakesAndRecompute`; this is a read-only count over the same
|
||||
* window, so callers that have just projected today's rows get an accurate
|
||||
* total.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getUserTodayBounds } from "@/lib/tz/local-day";
|
||||
|
||||
/**
|
||||
* Count the user's outstanding (pending) doses for today in their timezone.
|
||||
* Best-effort: any failure returns 0 so a badge update never blocks a caller.
|
||||
*/
|
||||
export async function countOutstandingDosesToday(
|
||||
userId: string,
|
||||
tz: string,
|
||||
): Promise<number> {
|
||||
try {
|
||||
const { start, end } = getUserTodayBounds(new Date(), tz);
|
||||
return await prisma.medicationIntakeEvent.count({
|
||||
where: {
|
||||
userId,
|
||||
scheduledFor: { gte: start, lte: end },
|
||||
takenAt: null,
|
||||
skipped: false,
|
||||
autoMissed: false,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -501,3 +501,45 @@ describe("dispatchNotification — Telegram hard reject", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("dispatchNotification — reminders reach Web Push without APNs", () => {
|
||||
it("a PWA-only self-hoster (WEB_PUSH channel, no APNS) still gets MEDICATION_REMINDER", async () => {
|
||||
const channel = makeChannel({ type: "WEB_PUSH" });
|
||||
vi.mocked(prisma.notificationChannel.findMany).mockResolvedValueOnce([
|
||||
channel,
|
||||
] as never);
|
||||
sendViaWebPushMock.mockResolvedValueOnce({ ok: true });
|
||||
|
||||
const outcome = await dispatchNotification({
|
||||
eventType: "MEDICATION_REMINDER",
|
||||
userId: "u-1",
|
||||
title: "Time for your dose",
|
||||
message: "Ramipril 5mg",
|
||||
metadata: { webPushTag: "med:med-1:2026-06-18T07:00:00.000Z" },
|
||||
});
|
||||
|
||||
expect(sendViaWebPushMock).toHaveBeenCalledTimes(1);
|
||||
expect(outcome.dispatched).toBe(true);
|
||||
expect(outcome.channelsSucceeded).toBe(1);
|
||||
});
|
||||
|
||||
it("MEASUREMENT_REMINDER and MEDICATION_LOW_STOCK also reach Web Push", async () => {
|
||||
for (const eventType of [
|
||||
"MEASUREMENT_REMINDER",
|
||||
"MEDICATION_LOW_STOCK",
|
||||
] as const) {
|
||||
vi.mocked(prisma.notificationChannel.findMany).mockResolvedValueOnce([
|
||||
makeChannel({ type: "WEB_PUSH" }),
|
||||
] as never);
|
||||
sendViaWebPushMock.mockResolvedValueOnce({ ok: true });
|
||||
|
||||
const outcome = await dispatchNotification({
|
||||
eventType,
|
||||
userId: "u-1",
|
||||
title: "t",
|
||||
message: "m",
|
||||
});
|
||||
expect(outcome.dispatched).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* v1.18.4 — PWA-only dose lifecycle over Web Push.
|
||||
*
|
||||
* Verifies the three pieces a self-hoster without an Apple Developer account
|
||||
* relies on:
|
||||
* 1. The dose-due reminder push carries a STABLE per-slot tag + badge count.
|
||||
* 2. The clear-on-taken push reuses the SAME tag (so the SW closes the
|
||||
* pending reminder — the equivalent of ending a Live Activity) and
|
||||
* refreshes the badge.
|
||||
* 3. A Web Push channel still delivers when APNs is absent.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { sendNotificationMock, findManyMock, deleteManyMock } = vi.hoisted(
|
||||
() => ({
|
||||
sendNotificationMock: vi.fn(),
|
||||
findManyMock: vi.fn(),
|
||||
deleteManyMock: vi.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
getEvent: () => ({
|
||||
addWarning: vi.fn(),
|
||||
addExternalCall: vi.fn(),
|
||||
setError: vi.fn(),
|
||||
addMeta: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/notifications/senders/push-attempt-record", () => ({
|
||||
recordPushAttempt: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
pushSubscription: {
|
||||
findMany: findManyMock,
|
||||
deleteMany: deleteManyMock,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
decrypt: (v: string) => v,
|
||||
encrypt: (v: string) => v,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/notifications/vapid-config", () => ({
|
||||
getVapidConfig: () =>
|
||||
Promise.resolve({
|
||||
subject: "mailto:a@b.c",
|
||||
publicKey: "pub",
|
||||
privateKey: "priv",
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/validations/notifications", () => ({
|
||||
isPublicUrl: () => true,
|
||||
}));
|
||||
|
||||
vi.mock("web-push", () => ({
|
||||
setVapidDetails: vi.fn(),
|
||||
sendNotification: (...args: unknown[]) => sendNotificationMock(...args),
|
||||
}));
|
||||
|
||||
import { sendViaWebPush } from "../senders/web-push";
|
||||
import { dispatchMedicationIntakeWebClear } from "../web-push-clear";
|
||||
import { medicationDoseTag } from "../dose-tag";
|
||||
|
||||
const SUB = {
|
||||
id: "s1",
|
||||
endpoint: "https://push.example.com/x",
|
||||
p256dh: "a",
|
||||
auth: "b",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
sendNotificationMock.mockReset();
|
||||
findManyMock.mockReset();
|
||||
deleteManyMock.mockReset();
|
||||
deleteManyMock.mockResolvedValue({ count: 0 });
|
||||
});
|
||||
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
describe("medicationDoseTag", () => {
|
||||
it("is stable across ISO instants that differ only in milliseconds", () => {
|
||||
const a = medicationDoseTag("med-1", "2026-06-18T07:00:00.000Z");
|
||||
const b = medicationDoseTag("med-1", "2026-06-18T07:00:00Z");
|
||||
expect(a).toBe(b);
|
||||
expect(a).toBe("med:med-1:2026-06-18T07:00:00.000Z");
|
||||
});
|
||||
});
|
||||
|
||||
describe("web-push reminder push — stable tag + badge", () => {
|
||||
it("uses metadata.webPushTag as the notification tag and carries the badge", async () => {
|
||||
findManyMock.mockResolvedValue([SUB]);
|
||||
sendNotificationMock.mockResolvedValue(undefined);
|
||||
|
||||
const tag = medicationDoseTag("med-1", "2026-06-18T07:00:00.000Z");
|
||||
const res = await sendViaWebPush("user-1", {
|
||||
eventType: "MEDICATION_REMINDER",
|
||||
userId: "user-1",
|
||||
title: "Time for your dose",
|
||||
message: "Ramipril 5mg",
|
||||
metadata: { webPushTag: tag, badgeCount: 2, url: "/medications/med-1" },
|
||||
});
|
||||
|
||||
expect(res.ok).toBe(true);
|
||||
const body = JSON.parse(sendNotificationMock.mock.calls[0][1] as string);
|
||||
expect(body.tag).toBe(tag);
|
||||
expect(body.badge).toBe(2);
|
||||
expect(body.url).toBe("/medications/med-1");
|
||||
});
|
||||
|
||||
it("discreet mode still wins over a stable tag (no event name leak)", async () => {
|
||||
findManyMock.mockResolvedValue([SUB]);
|
||||
sendNotificationMock.mockResolvedValue(undefined);
|
||||
|
||||
await sendViaWebPush("user-1", {
|
||||
eventType: "CYCLE_PERIOD_SOON",
|
||||
userId: "user-1",
|
||||
title: "HealthLog reminder",
|
||||
message: "HealthLog reminder",
|
||||
discreet: true,
|
||||
metadata: { webPushTag: "med:should-not-leak" },
|
||||
});
|
||||
|
||||
const body = JSON.parse(sendNotificationMock.mock.calls[0][1] as string);
|
||||
expect(body.tag).toBe("REMINDER");
|
||||
});
|
||||
});
|
||||
|
||||
describe("clear-on-taken push", () => {
|
||||
it("emits type:clear with the SAME slot tag as the reminder + badge", async () => {
|
||||
findManyMock.mockResolvedValue([SUB]);
|
||||
sendNotificationMock.mockResolvedValue(undefined);
|
||||
|
||||
await dispatchMedicationIntakeWebClear({
|
||||
userId: "user-1",
|
||||
medicationId: "med-1",
|
||||
scheduledFor: "2026-06-18T07:00:00.000Z",
|
||||
badgeCount: 1,
|
||||
});
|
||||
|
||||
expect(sendNotificationMock).toHaveBeenCalledTimes(1);
|
||||
const body = JSON.parse(sendNotificationMock.mock.calls[0][1] as string);
|
||||
expect(body.type).toBe("clear");
|
||||
expect(body.tag).toBe(medicationDoseTag("med-1", "2026-06-18T07:00:00.000Z"));
|
||||
expect(body.badge).toBe(1);
|
||||
});
|
||||
|
||||
it("a count of 0 clears the badge (badge:0 on the wire)", async () => {
|
||||
findManyMock.mockResolvedValue([SUB]);
|
||||
sendNotificationMock.mockResolvedValue(undefined);
|
||||
|
||||
await dispatchMedicationIntakeWebClear({
|
||||
userId: "user-1",
|
||||
medicationId: "med-1",
|
||||
scheduledFor: "2026-06-18T07:00:00.000Z",
|
||||
badgeCount: 0,
|
||||
});
|
||||
|
||||
const body = JSON.parse(sendNotificationMock.mock.calls[0][1] as string);
|
||||
expect(body.badge).toBe(0);
|
||||
});
|
||||
|
||||
it("no subscriptions → no-op, no throw", async () => {
|
||||
findManyMock.mockResolvedValue([]);
|
||||
await expect(
|
||||
dispatchMedicationIntakeWebClear({
|
||||
userId: "user-1",
|
||||
medicationId: "med-1",
|
||||
scheduledFor: "2026-06-18T07:00:00.000Z",
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
expect(sendNotificationMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reaps expired (410) subscriptions", async () => {
|
||||
findManyMock.mockResolvedValue([SUB]);
|
||||
sendNotificationMock.mockRejectedValue({ statusCode: 410 });
|
||||
|
||||
await dispatchMedicationIntakeWebClear({
|
||||
userId: "user-1",
|
||||
medicationId: "med-1",
|
||||
scheduledFor: "2026-06-18T07:00:00.000Z",
|
||||
});
|
||||
|
||||
expect(deleteManyMock).toHaveBeenCalledWith({
|
||||
where: { id: { in: ["s1"] } },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -59,6 +59,13 @@ interface DispatchLocalisedOptions {
|
||||
eventType?: EventType;
|
||||
/** Channel-specific extras forwarded to the dispatcher metadata. */
|
||||
metadata?: Record<string, unknown>;
|
||||
/**
|
||||
* Escalate to every channel's highest urgency (v1.18.4). Forwarded to
|
||||
* the dispatcher so APNs goes time-sensitive, ntfy max, Web Push
|
||||
* `Urgency: high`, webhook `urgent` — APNs-less instances degrade
|
||||
* gracefully to whatever channels are configured.
|
||||
*/
|
||||
urgent?: boolean;
|
||||
}
|
||||
|
||||
function isLocale(value: string | null | undefined): value is Locale {
|
||||
@@ -163,5 +170,6 @@ export async function dispatchLocalisedNotification(
|
||||
title,
|
||||
message,
|
||||
metadata,
|
||||
urgent: opts.urgent,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* v1.18.4 — stable Web Push notification tag for a medication dose slot.
|
||||
*
|
||||
* A PWA self-hoster with no Apple Developer account has no Live Activity to
|
||||
* end when a dose is logged. Instead the server pushes a `type: "clear"`
|
||||
* web-push that the service worker turns into `getNotifications({ tag })` +
|
||||
* `notification.close()`. For that to work the REMINDER push and the CLEAR
|
||||
* push must agree on a single, stable tag per dose slot.
|
||||
*
|
||||
* The slot is uniquely identified by `(medicationId, scheduledFor-instant)`:
|
||||
* - the dose-due reminder fires with `scheduledAt` = the slot's ISO instant,
|
||||
* - the clear-on-taken fires with `scheduledFor` = the same ISO instant.
|
||||
*
|
||||
* `scheduledFor` is normalised through `Date` so a reminder ISO carrying
|
||||
* milliseconds and a clear ISO without them still collapse to one tag.
|
||||
*/
|
||||
export function medicationDoseTag(
|
||||
medicationId: string,
|
||||
scheduledForIso: string,
|
||||
): string {
|
||||
const t = Date.parse(scheduledForIso);
|
||||
const instant = Number.isNaN(t) ? scheduledForIso : new Date(t).toISOString();
|
||||
return `med:${medicationId}:${instant}`;
|
||||
}
|
||||
@@ -630,6 +630,53 @@ describe("sendViaApns — dispatcher fan-out", () => {
|
||||
expect(note.priority).toBe(10);
|
||||
});
|
||||
|
||||
it("urgent payload sets time-sensitive + priority=10 on a non-medication event", async () => {
|
||||
// v1.18.4 — an explicitly urgent event (e.g. an illness red-flag
|
||||
// escalation) reaches APNs's strongest no-entitlement tier:
|
||||
// time-sensitive + priority 10, regardless of eventType.
|
||||
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: "Seek care",
|
||||
message: "Sustained fever",
|
||||
eventType: "SYSTEM_ALERT",
|
||||
urgent: true,
|
||||
});
|
||||
const note = sendMock.mock.calls[0][0];
|
||||
expect(note.interruptionLevel).toBe("time-sensitive");
|
||||
expect(note.priority).toBe(10);
|
||||
});
|
||||
|
||||
it("urgent payload escalates to critical ONLY with the entitlement env set", async () => {
|
||||
// v1.18.4 — the `critical` level ignores DND/Focus but needs an
|
||||
// Apple-approved entitlement; gate it behind APNS_CRITICAL_ENTITLEMENT.
|
||||
const prev = process.env.APNS_CRITICAL_ENTITLEMENT;
|
||||
process.env.APNS_CRITICAL_ENTITLEMENT = "true";
|
||||
try {
|
||||
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: "Seek care",
|
||||
message: "Sustained fever",
|
||||
eventType: "SYSTEM_ALERT",
|
||||
urgent: true,
|
||||
});
|
||||
const note = sendMock.mock.calls[0][0];
|
||||
expect(note.interruptionLevel).toBe("critical");
|
||||
expect(note.priority).toBe(10);
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env.APNS_CRITICAL_ENTITLEMENT;
|
||||
else process.env.APNS_CRITICAL_ENTITLEMENT = prev;
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
"MOOD_REMINDER",
|
||||
"MEASUREMENT_ANOMALY",
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* v1.18.4 — per-channel urgency mapping.
|
||||
*
|
||||
* An urgent payload must reach EACH configured channel at its highest level,
|
||||
* with NO dependency on APNs (most self-hosters have no Apple Developer
|
||||
* account). These tests pin the mapping per sender and confirm an APNs-less
|
||||
* instance degrades gracefully (ntfy / Web Push / webhook still escalate).
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const {
|
||||
safeFetchMock,
|
||||
recordPushAttemptMock,
|
||||
sendNotificationMock,
|
||||
findManyMock,
|
||||
} = vi.hoisted(() => ({
|
||||
safeFetchMock: vi.fn(),
|
||||
recordPushAttemptMock: vi.fn(),
|
||||
sendNotificationMock: vi.fn(),
|
||||
findManyMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/safe-fetch", () => ({
|
||||
safeFetch: (...args: unknown[]) => safeFetchMock(...args),
|
||||
SafeFetchError: class SafeFetchError extends Error {},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
getEvent: () => ({
|
||||
addWarning: vi.fn(),
|
||||
addExternalCall: vi.fn(),
|
||||
setError: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/notifications/senders/push-attempt-record", () => ({
|
||||
recordPushAttempt: (...args: unknown[]) => recordPushAttemptMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: { pushSubscription: { findMany: findManyMock, deleteMany: vi.fn() } },
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
decrypt: (v: string) => v,
|
||||
encrypt: (v: string) => v,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/notifications/vapid-config", () => ({
|
||||
getVapidConfig: () =>
|
||||
Promise.resolve({
|
||||
subject: "mailto:a@b.c",
|
||||
publicKey: "pub",
|
||||
privateKey: "priv",
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/validations/notifications", () => ({
|
||||
isPublicUrl: () => true,
|
||||
}));
|
||||
|
||||
vi.mock("web-push", () => ({
|
||||
setVapidDetails: vi.fn(),
|
||||
sendNotification: (...args: unknown[]) => sendNotificationMock(...args),
|
||||
}));
|
||||
|
||||
import { sendViaNtfy } from "../ntfy";
|
||||
import { sendViaWebhook } from "../webhook";
|
||||
import { sendViaWebPush } from "../web-push";
|
||||
|
||||
function payload(over?: Record<string, unknown>) {
|
||||
return {
|
||||
eventType: "SYSTEM_ALERT" as const,
|
||||
userId: "user-1",
|
||||
title: "Seek care",
|
||||
message: "Sustained fever",
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
safeFetchMock.mockReset();
|
||||
recordPushAttemptMock.mockReset();
|
||||
sendNotificationMock.mockReset();
|
||||
findManyMock.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
describe("ntfy urgent mapping", () => {
|
||||
it("urgent → Priority 5 (max) + warning tag", async () => {
|
||||
safeFetchMock.mockResolvedValue({ ok: true, status: 200 });
|
||||
await sendViaNtfy(
|
||||
{ serverUrl: "https://ntfy.example.com", topic: "t" },
|
||||
payload({ urgent: true }),
|
||||
);
|
||||
const [, init] = safeFetchMock.mock.calls[0];
|
||||
const headers = (init as { headers: Record<string, string> }).headers;
|
||||
expect(headers.Priority).toBe("5");
|
||||
expect(headers.Tags).toContain("warning");
|
||||
});
|
||||
|
||||
it("non-urgent SYSTEM_ALERT → default priority, no warning tag", async () => {
|
||||
safeFetchMock.mockResolvedValue({ ok: true, status: 200 });
|
||||
await sendViaNtfy(
|
||||
{ serverUrl: "https://ntfy.example.com", topic: "t" },
|
||||
payload(),
|
||||
);
|
||||
const headers = (safeFetchMock.mock.calls[0][1] as {
|
||||
headers: Record<string, string>;
|
||||
}).headers;
|
||||
expect(headers.Priority).toBe("default");
|
||||
expect(headers.Tags).not.toContain("warning");
|
||||
});
|
||||
|
||||
it("discreet urgent keeps the generic tag (no warning leak)", async () => {
|
||||
safeFetchMock.mockResolvedValue({ ok: true, status: 200 });
|
||||
await sendViaNtfy(
|
||||
{ serverUrl: "https://ntfy.example.com", topic: "t" },
|
||||
payload({ urgent: true, discreet: true }),
|
||||
);
|
||||
const headers = (safeFetchMock.mock.calls[0][1] as {
|
||||
headers: Record<string, string>;
|
||||
}).headers;
|
||||
expect(headers.Priority).toBe("5");
|
||||
expect(headers.Tags).toBe("reminder");
|
||||
});
|
||||
});
|
||||
|
||||
describe("webhook urgent mapping", () => {
|
||||
it("urgent → priority 'urgent' in the JSON body", async () => {
|
||||
safeFetchMock.mockResolvedValue({ ok: true, status: 200 });
|
||||
await sendViaWebhook(
|
||||
{ url: "https://relay.example.com" },
|
||||
payload({ urgent: true }),
|
||||
);
|
||||
const body = JSON.parse(
|
||||
(safeFetchMock.mock.calls[0][1] as { body: string }).body,
|
||||
);
|
||||
expect(body.priority).toBe("urgent");
|
||||
});
|
||||
|
||||
it("non-urgent → priority 'default'", async () => {
|
||||
safeFetchMock.mockResolvedValue({ ok: true, status: 200 });
|
||||
await sendViaWebhook({ url: "https://relay.example.com" }, payload());
|
||||
const body = JSON.parse(
|
||||
(safeFetchMock.mock.calls[0][1] as { body: string }).body,
|
||||
);
|
||||
expect(body.priority).toBe("default");
|
||||
});
|
||||
});
|
||||
|
||||
describe("web-push urgent mapping", () => {
|
||||
it("urgent → Urgency:high option + requireInteraction in payload", async () => {
|
||||
findManyMock.mockResolvedValue([
|
||||
{ id: "s1", endpoint: "https://push.example.com/x", p256dh: "a", auth: "b" },
|
||||
]);
|
||||
sendNotificationMock.mockResolvedValue(undefined);
|
||||
await sendViaWebPush("user-1", payload({ urgent: true }));
|
||||
const [, body, options] = sendNotificationMock.mock.calls[0];
|
||||
expect((options as { urgency?: string }).urgency).toBe("high");
|
||||
expect(JSON.parse(body as string).requireInteraction).toBe(true);
|
||||
});
|
||||
|
||||
it("non-urgent → no urgency option, requireInteraction false", async () => {
|
||||
findManyMock.mockResolvedValue([
|
||||
{ id: "s1", endpoint: "https://push.example.com/x", p256dh: "a", auth: "b" },
|
||||
]);
|
||||
sendNotificationMock.mockResolvedValue(undefined);
|
||||
await sendViaWebPush("user-1", payload());
|
||||
const [, body, options] = sendNotificationMock.mock.calls[0];
|
||||
expect(options).toBeUndefined();
|
||||
expect(JSON.parse(body as string).requireInteraction).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("APNs-less instance degrades gracefully", () => {
|
||||
it("an urgent event still escalates ntfy + Web Push with no APNs configured", async () => {
|
||||
// No APNs env, no APNs channel — the urgent event reaches the channels
|
||||
// that ARE present at their top tier.
|
||||
safeFetchMock.mockResolvedValue({ ok: true, status: 200 });
|
||||
findManyMock.mockResolvedValue([
|
||||
{ id: "s1", endpoint: "https://push.example.com/x", p256dh: "a", auth: "b" },
|
||||
]);
|
||||
sendNotificationMock.mockResolvedValue(undefined);
|
||||
|
||||
const ntfy = await sendViaNtfy(
|
||||
{ serverUrl: "https://ntfy.example.com", topic: "t" },
|
||||
payload({ urgent: true }),
|
||||
);
|
||||
const web = await sendViaWebPush("user-1", payload({ urgent: true }));
|
||||
|
||||
expect(ntfy.ok).toBe(true);
|
||||
expect(web.ok).toBe(true);
|
||||
expect(
|
||||
(safeFetchMock.mock.calls[0][1] as { headers: Record<string, string> })
|
||||
.headers.Priority,
|
||||
).toBe("5");
|
||||
expect(
|
||||
(sendNotificationMock.mock.calls[0][2] as { urgency?: string }).urgency,
|
||||
).toBe("high");
|
||||
});
|
||||
});
|
||||
@@ -525,6 +525,7 @@ export async function sendViaApns(
|
||||
eventType: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
discreet?: boolean;
|
||||
urgent?: boolean;
|
||||
},
|
||||
): Promise<SendOutcome> {
|
||||
const config = loadApnsConfig();
|
||||
@@ -595,13 +596,28 @@ export async function sendViaApns(
|
||||
? (["production", "sandbox"] as const)
|
||||
: (["sandbox", "production"] as const);
|
||||
|
||||
// SB-5 v1.4.40 — only MEDICATION_REMINDER opts in to Focus-bypass.
|
||||
// The user explicitly asked the server to nudge them about a dose;
|
||||
// a missed dose is the textbook "time-sensitive" use case Apple
|
||||
// SB-5 v1.4.40 — only MEDICATION_REMINDER opts in to Focus-bypass by
|
||||
// default. The user explicitly asked the server to nudge them about a
|
||||
// dose; a missed dose is the textbook "time-sensitive" use case Apple
|
||||
// sanctions. Every other event-type (mood nudges, anomaly alerts,
|
||||
// PRs, system messages) must stay on the default `active` level so
|
||||
// Focus modes — including Sleep — silence them as the user expects.
|
||||
const isTimeSensitive = payload.eventType === "MEDICATION_REMINDER";
|
||||
// PRs, system messages) stays on the default `active` level so Focus
|
||||
// modes — including Sleep — silence them as the user expects.
|
||||
//
|
||||
// v1.18.4 — an explicitly urgent payload (e.g. an illness red-flag
|
||||
// "seek care" escalation) ALSO opts in to time-sensitive: it is the
|
||||
// strongest level the APNs sender can reach without an Apple-approved
|
||||
// entitlement, and the channel-agnostic urgency model wants every
|
||||
// channel at its highest tier for an urgent event.
|
||||
const isTimeSensitive =
|
||||
payload.eventType === "MEDICATION_REMINDER" || payload.urgent === true;
|
||||
// The `critical` interruption-level ignores DND/Focus completely but
|
||||
// is gated behind an Apple-approved entitlement (`com.apple.developer.
|
||||
// usernotifications.critical-alerts`). Only emit it when the operator
|
||||
// has confirmed the build carries the entitlement — otherwise APNs
|
||||
// rejects the push. Default off; time-sensitive is the safe floor.
|
||||
const useCritical =
|
||||
payload.urgent === true &&
|
||||
process.env.APNS_CRITICAL_ENTITLEMENT === "true";
|
||||
|
||||
let result: Awaited<ReturnType<typeof sendApnsPush>> | null = null;
|
||||
// Discreet mode (cycle privacy): the lock-screen-visible routing
|
||||
@@ -645,7 +661,9 @@ export async function sendViaApns(
|
||||
mutableContent: true,
|
||||
...(isTimeSensitive
|
||||
? {
|
||||
interruptionLevel: "time-sensitive" as const,
|
||||
interruptionLevel: useCritical
|
||||
? ("critical" as const)
|
||||
: ("time-sensitive" as const),
|
||||
priority: 10 as const,
|
||||
}
|
||||
: {}),
|
||||
|
||||
@@ -8,6 +8,7 @@ import { getEvent } from "@/lib/logging/context";
|
||||
import { recordPushAttempt } from "@/lib/notifications/senders/push-attempt-record";
|
||||
import { safeFetch } from "@/lib/safe-fetch";
|
||||
import { plainPushText } from "@/lib/notifications/strip-emoji";
|
||||
import { isUrgentPayload } from "@/lib/notifications/types";
|
||||
|
||||
/**
|
||||
* Send notification via ntfy (simple HTTP POST).
|
||||
@@ -26,16 +27,28 @@ export async function sendViaNtfy(
|
||||
try {
|
||||
const url = `${config.serverUrl.replace(/\/$/, "")}/${encodeURIComponent(config.topic)}`;
|
||||
|
||||
// v1.18.4 — an explicitly urgent payload escalates to ntfy's top
|
||||
// priority (`5` / max) so it bypasses the relay's batching and surfaces
|
||||
// loudest; MEDICATION_REMINDER keeps its long-standing `high` (4); the
|
||||
// rest stay `default` (3). `urgent` also adds a tag so the lock-screen
|
||||
// entry reads as an alert (unless discreet privacy collapses tags).
|
||||
const urgent = isUrgentPayload(payload) && payload.urgent === true;
|
||||
const priority = urgent
|
||||
? "5"
|
||||
: payload.eventType === "MEDICATION_REMINDER"
|
||||
? "high"
|
||||
: "default";
|
||||
const eventTag = payload.discreet
|
||||
? "reminder"
|
||||
: payload.eventType.toLowerCase().replace(/_/g, "-");
|
||||
const headers: Record<string, string> = {
|
||||
Title: plainPushText(payload.title, payload.eventType),
|
||||
Priority:
|
||||
payload.eventType === "MEDICATION_REMINDER" ? "high" : "default",
|
||||
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
|
||||
// cycle event name.
|
||||
Tags: payload.discreet
|
||||
? "reminder"
|
||||
: payload.eventType.toLowerCase().replace(/_/g, "-"),
|
||||
// cycle event name. An urgent (non-discreet) event also carries a
|
||||
// `warning` tag so the entry renders as an alert.
|
||||
Tags: urgent && !payload.discreet ? `warning,${eventTag}` : eventTag,
|
||||
};
|
||||
|
||||
if (config.authToken) {
|
||||
|
||||
@@ -78,6 +78,25 @@ export async function sendViaWebPush(
|
||||
};
|
||||
}
|
||||
|
||||
// v1.18.4 — a dispatcher payload may carry a stable per-slot tag in
|
||||
// `metadata.webPushTag` (e.g. a medication dose slot, see
|
||||
// `medicationDoseTag`). The matching `type:"clear"` push reuses the SAME
|
||||
// tag so the SW can replace/close the pending reminder when the dose is
|
||||
// logged — the PWA-only equivalent of ending a Live Activity. Discreet
|
||||
// mode still wins so no event name leaks even when a stable tag exists.
|
||||
const stableTag =
|
||||
typeof payload.metadata?.webPushTag === "string"
|
||||
? payload.metadata.webPushTag
|
||||
: undefined;
|
||||
// v1.18.4 — server-authoritative app-badge count. The dispatcher puts the
|
||||
// user's current outstanding-dose count in `metadata.badgeCount`; the SW
|
||||
// calls `navigator.setAppBadge(count)` (feature-detected, no-op elsewhere).
|
||||
const badgeCount =
|
||||
typeof payload.metadata?.badgeCount === "number" &&
|
||||
Number.isFinite(payload.metadata.badgeCount)
|
||||
? Math.max(0, Math.trunc(payload.metadata.badgeCount))
|
||||
: undefined;
|
||||
|
||||
const pushPayload = JSON.stringify({
|
||||
title: plainPushText(payload.title, payload.eventType),
|
||||
body: plainPushText(
|
||||
@@ -88,7 +107,8 @@ export async function sendViaWebPush(
|
||||
// event. Web-Push payloads are VAPID-encrypted (only the SW sees this),
|
||||
// but the discreet contract still requires no event name on the wire —
|
||||
// mirror the APNs GENERIC_EVENT collapse (QA M-sec3).
|
||||
tag: payload.discreet ? "REMINDER" : payload.eventType,
|
||||
tag: payload.discreet ? "REMINDER" : (stableTag ?? payload.eventType),
|
||||
...(badgeCount !== undefined ? { badge: badgeCount } : {}),
|
||||
// v1.15.20 — opt-in deep link: a dispatcher payload may carry a
|
||||
// same-origin path in `metadata.url` (e.g. the Coach nudge's
|
||||
// `/coach`); everything else keeps the legacy "/" click
|
||||
@@ -99,8 +119,19 @@ export async function sendViaWebPush(
|
||||
payload.metadata.url.startsWith("/")
|
||||
? 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`).
|
||||
requireInteraction: payload.urgent === true,
|
||||
});
|
||||
|
||||
// v1.18.4 — the Web Push `Urgency` header (RFC 8030) tells the push
|
||||
// service to deliver immediately rather than batching for power. An
|
||||
// urgent event uses `high`; everything else keeps the library default.
|
||||
const sendOptions = payload.urgent === true
|
||||
? ({ urgency: "high" as const } as const)
|
||||
: undefined;
|
||||
|
||||
let anySuccess = false;
|
||||
const expiredIds: string[] = [];
|
||||
let lastTransientStatus: number | undefined;
|
||||
@@ -128,6 +159,7 @@ export async function sendViaWebPush(
|
||||
keys: { p256dh, auth },
|
||||
},
|
||||
pushPayload,
|
||||
sendOptions,
|
||||
);
|
||||
anySuccess = true;
|
||||
allPermanentReject = false;
|
||||
|
||||
@@ -53,8 +53,15 @@ export async function sendViaWebhook(
|
||||
payload.eventType,
|
||||
),
|
||||
eventType: payload.discreet ? "reminder" : payload.eventType,
|
||||
// v1.18.4 — an explicitly urgent event maps to `urgent` so a relay
|
||||
// rule (Gotify priority, Discord mention, Home Assistant automation)
|
||||
// can escalate; MEDICATION_REMINDER keeps `high`; the rest `default`.
|
||||
priority:
|
||||
payload.eventType === "MEDICATION_REMINDER" ? "high" : "default",
|
||||
payload.urgent === true
|
||||
? "urgent"
|
||||
: payload.eventType === "MEDICATION_REMINDER"
|
||||
? "high"
|
||||
: "default",
|
||||
});
|
||||
|
||||
const res = await safeFetch(
|
||||
|
||||
@@ -219,4 +219,33 @@ export interface NotificationPayload {
|
||||
* — the title/body are already masked upstream.
|
||||
*/
|
||||
discreet?: boolean;
|
||||
/**
|
||||
* Urgent / escalation dimension (v1.18.4). When true, EVERY configured
|
||||
* channel delivers at the HIGHEST urgency it supports — never assuming
|
||||
* APNs is one of them (most self-hosters have no Apple Developer account):
|
||||
* - APNs → interruption-level `time-sensitive` + priority 10 (and
|
||||
* `critical` only when `APNS_CRITICAL_ENTITLEMENT=true`, since the
|
||||
* critical flag needs an Apple-approved entitlement; time-sensitive
|
||||
* does not).
|
||||
* - ntfy → `Priority: 5` (max) + an `urgent` tag.
|
||||
* - WebPush → `Urgency: high` header + `requireInteraction`.
|
||||
* - Webhook → `priority: "urgent"` in the JSON body.
|
||||
* - Telegram → normal delivery (no urgency tier exists; never silent).
|
||||
* Normal events leave `urgent` unset and behave exactly as before.
|
||||
*/
|
||||
urgent?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a payload should escalate to each channel's highest urgency.
|
||||
* Explicit `urgent: true` is the modern signal. `MEDICATION_REMINDER`
|
||||
* stays escalated for backward compatibility (it has shipped as APNs
|
||||
* time-sensitive / ntfy high since v1.4.40), so callers that don't set
|
||||
* the flag keep their existing behaviour.
|
||||
*/
|
||||
export function isUrgentPayload(payload: {
|
||||
urgent?: boolean;
|
||||
eventType: string;
|
||||
}): boolean {
|
||||
return payload.urgent === true || payload.eventType === "MEDICATION_REMINDER";
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* v1.18.4 — Web Push "clear" for a logged medication dose.
|
||||
*
|
||||
* A self-hoster on the installed PWA with no Apple Developer account has no
|
||||
* Live Activity / Home-Screen widget to reconcile when a dose is logged on
|
||||
* another tab/device. The native path (`dispatchMedicationIntakeSync`) ends
|
||||
* the Live Activity over APNs; this module is the PWA-only equivalent: it
|
||||
* pushes a `{ type: "clear", tag }` payload to the user's Web Push
|
||||
* subscriptions so the service worker closes the still-pending dose-due
|
||||
* reminder for that exact slot (same stable tag the reminder used) and
|
||||
* refreshes the app badge.
|
||||
*
|
||||
* Targeted + best-effort: it talks straight to the user's PushSubscription
|
||||
* rows (not the full dispatcher cascade) because a clear has no Telegram /
|
||||
* ntfy / email analogue — those channels can't retract a sent message. Every
|
||||
* failure is swallowed + annotated; a clear miss never fails the intake
|
||||
* mutation that triggered it. Expired (410/404) subscriptions are reaped, the
|
||||
* same as the alert sender.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { decrypt } from "@/lib/crypto";
|
||||
import { getVapidConfig } from "@/lib/notifications/vapid-config";
|
||||
import { getEvent } from "@/lib/logging/context";
|
||||
import { isPublicUrl } from "@/lib/validations/notifications";
|
||||
import { medicationDoseTag } from "@/lib/notifications/dose-tag";
|
||||
|
||||
export interface WebClearInput {
|
||||
userId: string;
|
||||
medicationId: string;
|
||||
/** ISO-8601 scheduled-for instant of the affected dose slot. */
|
||||
scheduledFor: string;
|
||||
/**
|
||||
* Server-authoritative count of the user's still-outstanding doses after
|
||||
* this mutation. The SW reflects it via `navigator.setAppBadge`. Omit to
|
||||
* leave the badge untouched.
|
||||
*/
|
||||
badgeCount?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a `type:"clear"` web push that closes the pending reminder for one
|
||||
* dose slot and updates the app badge. Best-effort, fire-and-forget safe.
|
||||
*/
|
||||
export async function dispatchMedicationIntakeWebClear(
|
||||
input: WebClearInput,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const config = await getVapidConfig();
|
||||
if (!config) return; // VAPID not configured — nothing to clear.
|
||||
|
||||
const subscriptions = await prisma.pushSubscription.findMany({
|
||||
where: { userId: input.userId },
|
||||
});
|
||||
if (!subscriptions.length) return;
|
||||
|
||||
const webpush = await import("web-push");
|
||||
webpush.setVapidDetails(config.subject, config.publicKey, config.privateKey);
|
||||
|
||||
const badgeCount =
|
||||
typeof input.badgeCount === "number" && Number.isFinite(input.badgeCount)
|
||||
? Math.max(0, Math.trunc(input.badgeCount))
|
||||
: undefined;
|
||||
|
||||
const clearPayload = JSON.stringify({
|
||||
type: "clear",
|
||||
tag: medicationDoseTag(input.medicationId, input.scheduledFor),
|
||||
...(badgeCount !== undefined ? { badge: badgeCount } : {}),
|
||||
});
|
||||
|
||||
const expiredIds: string[] = [];
|
||||
for (const sub of subscriptions) {
|
||||
try {
|
||||
if (!isPublicUrl(sub.endpoint)) {
|
||||
expiredIds.push(sub.id);
|
||||
continue;
|
||||
}
|
||||
const p256dh = decrypt(sub.p256dh);
|
||||
const auth = decrypt(sub.auth);
|
||||
await webpush.sendNotification(
|
||||
{ endpoint: sub.endpoint, keys: { p256dh, auth } },
|
||||
clearPayload,
|
||||
);
|
||||
} catch (err: unknown) {
|
||||
const status = (err as { statusCode?: number }).statusCode;
|
||||
if (status === 410 || status === 404) expiredIds.push(sub.id);
|
||||
// Soft errors on a best-effort clear are ignored — the next reminder
|
||||
// tick or badge refresh self-corrects.
|
||||
}
|
||||
}
|
||||
|
||||
if (expiredIds.length > 0) {
|
||||
await prisma.pushSubscription
|
||||
.deleteMany({ where: { id: { in: expiredIds } } })
|
||||
.catch(() => {
|
||||
/* best-effort cleanup */
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
getEvent()?.addWarning(`medication_intake_web_clear_failed: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk entry point — de-duplicates affected slots so one clear fires per
|
||||
* distinct `(medicationId, scheduledFor)` slot a batch touched. The badge
|
||||
* count is the same post-mutation outstanding total for every slot, so it is
|
||||
* passed through unchanged.
|
||||
*/
|
||||
export async function dispatchMedicationIntakeWebClearBulk(args: {
|
||||
userId: string;
|
||||
slots: Array<{ medicationId: string; scheduledFor: string }>;
|
||||
badgeCount?: number;
|
||||
}): Promise<void> {
|
||||
const seen = new Set<string>();
|
||||
for (const slot of args.slots) {
|
||||
const key = `${slot.medicationId}|${slot.scheduledFor}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
await dispatchMedicationIntakeWebClear({
|
||||
userId: args.userId,
|
||||
medicationId: slot.medicationId,
|
||||
scheduledFor: slot.scheduledFor,
|
||||
badgeCount: args.badgeCount,
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user