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

+62 -8
View File
@@ -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)),
]),
);
});
+107 -1
View File
@@ -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);
});
});
@@ -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({
+20
View File
@@ -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);
});
@@ -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,
});
}
}