mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
1 parent
0163f159d5
commit
604aec631f
11 files changed
+666
-10
No files matched your search
+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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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"] } },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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