feat(notifications): strengthen the Web Push path for PWA-only self-hosters

Self-hosters without an Apple Developer account rely on Web Push to the
installed PWA — there is no Live Activity or lock-screen widget to drive.
Make that path carry the dose lifecycle on its own:

- Stable per-slot tag. The medication dose-due reminder now sets a
  `med:<id>:<iso-slot>` Web Push tag (medicationDoseTag), so a re-fired
  reminder for the same slot coalesces instead of stacking, and a later
  clear can target it.
- Clear-on-taken. When a dose is marked taken or skipped, push a
  `type:"clear"` notification reusing that slot tag. The service worker
  closes the matching pending reminder and refreshes the badge — the PWA
  equivalent of ending a Live Activity. A snooze leaves the dose
  outstanding and clears nothing. Wired into the single + bulk intake
  routes alongside the existing APNs cross-device sync.
- App badge. The dispatcher carries a server-authoritative outstanding-
  dose count; the SW reflects it via the Badging API
  (setAppBadge/clearAppBadge), feature-detected and a no-op where
  unsupported.
- The SW push handler honours requireInteraction for urgent events and
  renotify so a re-fired reminder re-alerts.

Dose-due, preventive-care, and low-stock reminders already cascade to
Web Push when no APNs channel exists; tests pin that an APNs-less
instance still delivers.
This commit is contained in:
Marc-André Bombeck committed 2026-06-18 11:39:55 +02:00
1 parent 0163f159d5
commit 604aec631f
11 files changed
+666 -10

No files matched your search

@@ -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) {
+39
View File
@@ -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"] } },
});
});
});
+24
View File
@@ -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}`;
}
+21 -1
View File
@@ -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
+127
View File
@@ -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,
});
}
}