From a5ec4ef8ddf8c861251b506dc98a1bd34621401c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 23:58:11 +0200 Subject: [PATCH 1/3] feat(notifications): add silent medication-intake cross-device sync push Introduce the MEDICATION_INTAKE_SYNC event and the server-side dispatcher that wakes a user's other iOS devices after a dose is logged on one of them, so a running Live Activity or Home-Screen widget reconciles against the canonical server state. The dispatch is APNs-only by construction: it sends a silent apns-push-type: background push (content-available, no alert) carrying the affected medicationId + scheduledFor, and never fans out to Telegram, ntfy, or Web Push, which have no silent-sync transport. A new raw APNs send helper carries the background and liveactivity push types without the alert/sound/badge enrichment the alert path injects. Devices APNs reports as permanently dead are reaped; the originating device is excluded from the fan-out via its registered device token. When a device has a stored Live Activity push token, an additional liveactivity end push goes to the per-Activity topic so the lock-screen Activity ends immediately rather than waiting for the next background wake. --- .../__tests__/medication-intake-sync.test.ts | 326 ++++++++++++++++++ .../notifications/medication-intake-sync.ts | 282 +++++++++++++++ src/lib/notifications/senders/apns.ts | 114 ++++++ src/lib/notifications/types.ts | 16 + 4 files changed, 738 insertions(+) create mode 100644 src/lib/notifications/__tests__/medication-intake-sync.test.ts create mode 100644 src/lib/notifications/medication-intake-sync.ts diff --git a/src/lib/notifications/__tests__/medication-intake-sync.test.ts b/src/lib/notifications/__tests__/medication-intake-sync.test.ts new file mode 100644 index 000000000..ad6485b2c --- /dev/null +++ b/src/lib/notifications/__tests__/medication-intake-sync.test.ts @@ -0,0 +1,326 @@ +/** + * v1.17.1 (#22) — medication-intake cross-device sync dispatcher tests. + * + * Exercises the server half of iOS issue #22: + * 1. Silent background push to the user's OTHER devices (origin skip). + * 2. APNs-only: the dispatch never reaches the dispatcher cascade, so + * Telegram / ntfy / Web Push senders are never imported here. + * 3. Live Activity end push only when a `liveActivityPushToken` is stored. + * 4. Bulk de-dup: one silent push per device per distinct slot. + * 5. Explicit APNS-channel opt-out suppresses the fan-out. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const rawSendMock = vi.fn(); +const loadConfigMock = vi.fn(); +const recordPushAttemptMock = vi.fn(); + +vi.mock("@/lib/notifications/senders/apns", () => ({ + loadApnsConfig: () => loadConfigMock(), + sendApnsRawPush: (...args: unknown[]) => rawSendMock(...args), +})); + +vi.mock("@/lib/notifications/senders/push-attempt-record", () => ({ + recordPushAttempt: (...args: unknown[]) => recordPushAttemptMock(...args), +})); + +vi.mock("@/lib/logging/context", () => ({ + getEvent: () => ({ addWarning: vi.fn(), addMeta: vi.fn() }), +})); + +const deviceFindMany = vi.fn(); +const channelFindUnique = vi.fn(); +const deviceDeleteMany = vi.fn(); + +vi.mock("@/lib/db", () => ({ + prisma: { + device: { + findMany: (...a: unknown[]) => deviceFindMany(...a), + deleteMany: (...a: unknown[]) => deviceDeleteMany(...a), + }, + notificationChannel: { + findUnique: (...a: unknown[]) => channelFindUnique(...a), + }, + }, +})); + +import { + dispatchMedicationIntakeSync, + dispatchMedicationIntakeSyncBulk, +} from "@/lib/notifications/medication-intake-sync"; + +const APNS_CONFIG = { bundleId: "test.healthlog.ios" }; + +beforeEach(() => { + vi.clearAllMocks(); + loadConfigMock.mockReturnValue(APNS_CONFIG); + channelFindUnique.mockResolvedValue({ enabled: true, preferences: [] }); + rawSendMock.mockResolvedValue({ ok: true, status: 200 }); + deviceDeleteMany.mockResolvedValue({ count: 0 }); +}); + +describe("dispatchMedicationIntakeSync — origin skip + other-devices-only", () => { + it("sends a silent background push to every device EXCEPT the origin", async () => { + deviceFindMany.mockResolvedValue([ + { + id: "d-origin", + token: "origin-token", + apnsToken: "aaaa1111", + apnsEnvironment: "production", + liveActivityPushToken: null, + }, + { + id: "d-other", + token: "other-token", + apnsToken: "bbbb2222", + apnsEnvironment: "sandbox", + liveActivityPushToken: null, + }, + ]); + + await dispatchMedicationIntakeSync({ + userId: "u1", + medicationId: "m1", + scheduledFor: "2026-06-14T07:00:00.000Z", + originDeviceToken: "origin-token", + }); + + // Exactly one push — to the non-origin device, silent background. + expect(rawSendMock).toHaveBeenCalledTimes(1); + const call = rawSendMock.mock.calls[0][0]; + expect(call.deviceToken).toBe("bbbb2222"); + expect(call.pushType).toBe("background"); + expect(call.priority).toBe(5); + expect(call.payload).toMatchObject({ + aps: { "content-available": 1 }, + eventType: "MEDICATION_INTAKE_SYNC", + medicationId: "m1", + scheduledFor: "2026-06-14T07:00:00.000Z", + }); + // No alert/sound/badge keys in the silent payload. + expect(call.payload.aps.alert).toBeUndefined(); + expect(call.payload.aps.sound).toBeUndefined(); + }); + + it("skips nothing when the origin token has no matching device (web caller)", async () => { + deviceFindMany.mockResolvedValue([ + { + id: "d1", + token: "tok-1", + apnsToken: "aaaa1111", + apnsEnvironment: "sandbox", + liveActivityPushToken: null, + }, + ]); + + await dispatchMedicationIntakeSync({ + userId: "u1", + medicationId: "m1", + scheduledFor: "2026-06-14T07:00:00.000Z", + originDeviceToken: "no-such-token", + }); + + expect(rawSendMock).toHaveBeenCalledTimes(1); + expect(rawSendMock.mock.calls[0][0].deviceToken).toBe("aaaa1111"); + }); + + it("records a skipped attempt when only the origin device exists", async () => { + deviceFindMany.mockResolvedValue([ + { + id: "d-origin", + token: "origin-token", + apnsToken: "aaaa1111", + apnsEnvironment: "sandbox", + liveActivityPushToken: null, + }, + ]); + + await dispatchMedicationIntakeSync({ + userId: "u1", + medicationId: "m1", + scheduledFor: "2026-06-14T07:00:00.000Z", + originDeviceToken: "origin-token", + }); + + expect(rawSendMock).not.toHaveBeenCalled(); + expect(recordPushAttemptMock).toHaveBeenCalledWith( + expect.objectContaining({ + channel: "APNS", + eventType: "MEDICATION_INTAKE_SYNC", + result: "skipped", + reason: "intake_sync_no_other_devices", + }), + ); + }); +}); + +describe("dispatchMedicationIntakeSync — Live Activity push", () => { + it("sends a liveactivity end push only for devices with a stored token", async () => { + deviceFindMany.mockResolvedValue([ + { + id: "d1", + token: "tok-1", + apnsToken: "aaaa1111", + apnsEnvironment: "production", + liveActivityPushToken: "la-token-1", + }, + ]); + + await dispatchMedicationIntakeSync({ + userId: "u1", + medicationId: "m1", + scheduledFor: "2026-06-14T07:00:00.000Z", + }); + + // One silent + one liveactivity push. + expect(rawSendMock).toHaveBeenCalledTimes(2); + const types = rawSendMock.mock.calls.map((c) => c[0].pushType); + expect(types).toEqual(["background", "liveactivity"]); + + const la = rawSendMock.mock.calls.find( + (c) => c[0].pushType === "liveactivity", + )![0]; + expect(la.deviceToken).toBe("la-token-1"); + expect(la.topic).toBe("test.healthlog.ios.push-type.liveactivity"); + expect(la.payload.aps.event).toBe("end"); + }); + + it("sends no liveactivity push when no token is stored", async () => { + deviceFindMany.mockResolvedValue([ + { + id: "d1", + token: "tok-1", + apnsToken: "aaaa1111", + apnsEnvironment: "sandbox", + liveActivityPushToken: null, + }, + ]); + + await dispatchMedicationIntakeSync({ + userId: "u1", + medicationId: "m1", + scheduledFor: "2026-06-14T07:00:00.000Z", + }); + + expect(rawSendMock).toHaveBeenCalledTimes(1); + expect(rawSendMock.mock.calls[0][0].pushType).toBe("background"); + }); +}); + +describe("dispatchMedicationIntakeSync — gating", () => { + it("no-ops silently when APNs is not configured", async () => { + loadConfigMock.mockReturnValue(null); + + await dispatchMedicationIntakeSync({ + userId: "u1", + medicationId: "m1", + scheduledFor: "2026-06-14T07:00:00.000Z", + }); + + expect(deviceFindMany).not.toHaveBeenCalled(); + expect(rawSendMock).not.toHaveBeenCalled(); + expect(recordPushAttemptMock).not.toHaveBeenCalled(); + }); + + it("suppresses the fan-out on an explicit APNS-channel opt-out", async () => { + channelFindUnique.mockResolvedValue({ + enabled: true, + preferences: [{ enabled: false }], + }); + + await dispatchMedicationIntakeSync({ + userId: "u1", + medicationId: "m1", + scheduledFor: "2026-06-14T07:00:00.000Z", + }); + + expect(deviceFindMany).not.toHaveBeenCalled(); + expect(rawSendMock).not.toHaveBeenCalled(); + expect(recordPushAttemptMock).toHaveBeenCalledWith( + expect.objectContaining({ + result: "skipped", + reason: "intake_sync_disabled", + }), + ); + }); + + it("suppresses when the APNS channel row itself is disabled", async () => { + channelFindUnique.mockResolvedValue({ enabled: false, preferences: [] }); + + await dispatchMedicationIntakeSync({ + userId: "u1", + medicationId: "m1", + scheduledFor: "2026-06-14T07:00:00.000Z", + }); + + expect(rawSendMock).not.toHaveBeenCalled(); + }); + + it("reaps devices APNs reports as permanently dead", async () => { + deviceFindMany.mockResolvedValue([ + { + id: "d-dead", + token: "tok-1", + apnsToken: "aaaa1111", + apnsEnvironment: "sandbox", + liveActivityPushToken: null, + }, + ]); + rawSendMock.mockResolvedValue({ + ok: false, + reason: "BadDeviceToken", + shouldDisable: true, + }); + + await dispatchMedicationIntakeSync({ + userId: "u1", + medicationId: "m1", + scheduledFor: "2026-06-14T07:00:00.000Z", + }); + + expect(deviceDeleteMany).toHaveBeenCalledWith({ + where: { id: { in: ["d-dead"] } }, + }); + expect(recordPushAttemptMock).toHaveBeenCalledWith( + expect.objectContaining({ result: "error" }), + ); + }); +}); + +describe("dispatchMedicationIntakeSyncBulk — slot de-dup", () => { + it("fires one silent push per device per DISTINCT slot, not per row", async () => { + deviceFindMany.mockResolvedValue([ + { + id: "d-other", + token: "other-token", + apnsToken: "bbbb2222", + apnsEnvironment: "sandbox", + liveActivityPushToken: null, + }, + ]); + + await dispatchMedicationIntakeSyncBulk({ + userId: "u1", + originDeviceToken: "origin-token", + slots: [ + { medicationId: "m1", scheduledFor: "2026-06-14T07:00:00.000Z" }, + // duplicate of the first — must collapse + { medicationId: "m1", scheduledFor: "2026-06-14T07:00:00.000Z" }, + { medicationId: "m1", scheduledFor: "2026-06-14T19:00:00.000Z" }, + { medicationId: "m2", scheduledFor: "2026-06-14T07:00:00.000Z" }, + ], + }); + + // 3 distinct slots × 1 recipient device = 3 silent pushes. + expect(rawSendMock).toHaveBeenCalledTimes(3); + const slots = rawSendMock.mock.calls.map((c) => [ + c[0].payload.medicationId, + c[0].payload.scheduledFor, + ]); + expect(slots).toEqual([ + ["m1", "2026-06-14T07:00:00.000Z"], + ["m1", "2026-06-14T19:00:00.000Z"], + ["m2", "2026-06-14T07:00:00.000Z"], + ]); + }); +}); diff --git a/src/lib/notifications/medication-intake-sync.ts b/src/lib/notifications/medication-intake-sync.ts new file mode 100644 index 000000000..90f20af8c --- /dev/null +++ b/src/lib/notifications/medication-intake-sync.ts @@ -0,0 +1,282 @@ +/** + * v1.17.1 (#22) — server-authoritative medication-intake cross-device sync. + * + * When a dose is logged / skipped / snoozed on ONE device, the user's + * OTHER iOS devices need to reconcile: end or update the running Live + * Activity, refresh the Home-Screen widget, drop the local reminder. The + * server is the single source of truth for the intake state, so it owns + * the fan-out. This module dispatches two APNs pushes per affected device: + * + * 1. A SILENT background push (`apns-push-type: background`, + * `content-available: 1`, no alert) carrying + * `{ eventType: "MEDICATION_INTAKE_SYNC", medicationId, scheduledFor }`. + * The iOS `NotificationService` consumes it and runs + * `BackgroundSyncCoordinator.runMedicationReconcile`. + * + * 2. A Live Activity end/update push (`apns-push-type: liveactivity`, + * topic `.push-type.liveactivity`) to any device that has a + * stored `liveActivityPushToken`, so the lock-screen Activity ends + * immediately rather than waiting for the next background wake. + * + * APNs-ONLY by design. This event has no Telegram / ntfy / Web Push + * semantics (those channels have no silent-sync transport), so it never + * touches the dispatcher cascade — it talks straight to the APNs senders. + * + * Origin skip: the device that POSTed the mutation already knows the new + * state and must not be woken. The caller passes the originating device's + * registered `token` (the value the iOS client sends as `X-Device-Id`); + * the matching `Device` row is excluded from both fan-outs. A web caller — + * or a token with no matching row — skips nothing, which is harmless + * (the iOS reconcile is idempotent). + * + * Best-effort: every failure is swallowed + annotated. A sync-push miss + * must never fail the intake mutation that triggered it; the canonical + * row is already persisted. + */ +import { prisma } from "@/lib/db"; +import { getEvent } from "@/lib/logging/context"; +import { + loadApnsConfig, + sendApnsRawPush, +} from "@/lib/notifications/senders/apns"; +import { recordPushAttempt } from "@/lib/notifications/senders/push-attempt-record"; +import { EVENT_DEFAULT_ENABLED } from "@/lib/notifications/types"; + +const EVENT_TYPE = "MEDICATION_INTAKE_SYNC"; + +export interface IntakeSyncInput { + userId: string; + medicationId: string; + /** ISO-8601 scheduled-for instant of the affected dose slot. */ + scheduledFor: string; + /** + * The originating device's registered `Device.token` (sent by the iOS + * client as the `X-Device-Id` header). The matching device is excluded + * from the fan-out. `null` / unmatched skips nothing. + */ + originDeviceToken?: string | null; +} + +/** + * Whether the user has explicitly opted the APNS channel out of + * MEDICATION_INTAKE_SYNC. There is no second user-facing toggle — this is + * coherence plumbing — but an explicit per-channel `NotificationPreference` + * row (APNS / MEDICATION_INTAKE_SYNC / enabled=false) is still honoured so + * an operator can disable the fan-out. Returns false (suppressed) only on + * such an explicit opt-out. + */ +async function apnsSyncEnabled(userId: string): Promise { + const channel = await prisma.notificationChannel.findUnique({ + where: { userId_type: { userId, type: "APNS" } }, + select: { + enabled: true, + preferences: { + where: { eventType: EVENT_TYPE }, + select: { enabled: true }, + }, + }, + }); + if (!channel || !channel.enabled) return false; + const pref = channel.preferences[0]; + if (pref) return pref.enabled; + return EVENT_DEFAULT_ENABLED.MEDICATION_INTAKE_SYNC; +} + +/** + * Dispatch the silent intake-sync push (+ Live Activity push) to the + * user's other devices. De-duplicated to ONE silent push per device + * regardless of how many intake rows the triggering mutation touched — + * the caller passes a single `(medicationId, scheduledFor)` pair per + * affected slot, but a bulk call should call this once with the affected + * slot, not once per row (see `dispatchIntakeSyncBulk`). + */ +export async function dispatchMedicationIntakeSync( + input: IntakeSyncInput, +): Promise { + try { + const config = loadApnsConfig(); + if (!config) { + // APNs not configured on this deployment — nothing to do. No ledger + // row: the silent-sync channel is a no-op, not a delivery attempt. + return; + } + + if (!(await apnsSyncEnabled(input.userId))) { + recordPushAttempt({ + userId: input.userId, + channel: "APNS", + eventType: EVENT_TYPE, + result: "skipped", + reason: "intake_sync_disabled", + }); + return; + } + + const devices = await prisma.device.findMany({ + where: { userId: input.userId, apnsToken: { not: null } }, + select: { + id: true, + token: true, + apnsToken: true, + apnsEnvironment: true, + liveActivityPushToken: true, + }, + }); + + // Exclude the originating device — it already holds the new state. + const recipients = devices.filter( + (d) => !input.originDeviceToken || d.token !== input.originDeviceToken, + ); + + if (recipients.length === 0) { + recordPushAttempt({ + userId: input.userId, + channel: "APNS", + eventType: EVENT_TYPE, + result: "skipped", + reason: "intake_sync_no_other_devices", + }); + return; + } + + const silentPayload = { + aps: { "content-available": 1 }, + eventType: EVENT_TYPE, + medicationId: input.medicationId, + scheduledFor: input.scheduledFor, + }; + + let anySuccess = false; + const deadDeviceIds: string[] = []; + let lastFailureReason: string | undefined; + + for (const device of recipients) { + if (!device.apnsToken) continue; + const environment: "sandbox" | "production" = + device.apnsEnvironment === "production" ? "production" : "sandbox"; + + // 1) Silent background reconcile push — one per device. + const silent = await sendApnsRawPush({ + deviceToken: device.apnsToken, + environment, + pushType: "background", + payload: silentPayload, + priority: 5, + }); + if (silent.ok) { + anySuccess = true; + } else { + lastFailureReason = silent.reason; + if (silent.shouldDisable) deadDeviceIds.push(device.id); + } + + // 2) Live Activity end/update push — only when a token is stored. + if (device.liveActivityPushToken) { + await sendLiveActivityEnd({ + token: device.liveActivityPushToken, + environment, + bundleId: config.bundleId, + medicationId: input.medicationId, + scheduledFor: input.scheduledFor, + }); + } + } + + // Reap devices APNs reported as permanently dead on the silent send. + if (deadDeviceIds.length > 0) { + await prisma.device + .deleteMany({ where: { id: { in: deadDeviceIds } } }) + .catch(() => { + /* best-effort cleanup */ + }); + } + + recordPushAttempt({ + userId: input.userId, + channel: "APNS", + eventType: EVENT_TYPE, + result: anySuccess ? "ok" : "error", + reason: anySuccess ? null : (lastFailureReason ?? "intake_sync_failed"), + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + getEvent()?.addWarning(`medication_intake_sync_dispatch_failed: ${message}`); + } +} + +/** + * Bulk-call entry point: de-duplicates the affected slots so the fan-out + * fires ONE silent push per device per distinct `(medicationId, + * scheduledFor)` slot the batch touched — not one per intake row. iOS + * coalesces multiple background wakes anyway, but de-duping here keeps the + * APNs quota proportional to slots, not to row count. + */ +export async function dispatchMedicationIntakeSyncBulk(args: { + userId: string; + slots: Array<{ medicationId: string; scheduledFor: string }>; + originDeviceToken?: string | null; +}): Promise { + const seen = new Set(); + const distinct: Array<{ medicationId: string; scheduledFor: string }> = []; + for (const slot of args.slots) { + const key = `${slot.medicationId}|${slot.scheduledFor}`; + if (seen.has(key)) continue; + seen.add(key); + distinct.push(slot); + } + for (const slot of distinct) { + await dispatchMedicationIntakeSync({ + userId: args.userId, + medicationId: slot.medicationId, + scheduledFor: slot.scheduledFor, + originDeviceToken: args.originDeviceToken, + }); + } +} + +/** + * Push an ActivityKit `end` event to a running Live Activity. The Activity + * push topic is the app bundle id suffixed with `.push-type.liveactivity`; + * the body carries the ActivityKit envelope (`event: "end"`, a timestamp, + * an empty content-state). Best-effort: a failure is annotated, never + * thrown. + */ +async function sendLiveActivityEnd(args: { + token: string; + environment: "sandbox" | "production"; + bundleId: string; + medicationId: string; + scheduledFor: string; +}): Promise { + try { + const result = await sendApnsRawPush({ + deviceToken: args.token, + environment: args.environment, + pushType: "liveactivity", + topic: `${args.bundleId}.push-type.liveactivity`, + priority: 10, + payload: { + aps: { + timestamp: Math.floor(Date.now() / 1000), + event: "end", + "content-state": {}, + // Dismiss the ended Activity from the lock screen promptly. + "dismissal-date": Math.floor(Date.now() / 1000), + }, + medicationId: args.medicationId, + scheduledFor: args.scheduledFor, + }, + }); + if (!result.ok) { + getEvent()?.addMeta( + "medication_intake_sync_liveactivity_failed", + result.reason ?? "unknown", + ); + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + getEvent()?.addWarning( + `medication_intake_sync_liveactivity_threw: ${message}`, + ); + } +} diff --git a/src/lib/notifications/senders/apns.ts b/src/lib/notifications/senders/apns.ts index f38eeffc6..511989863 100644 --- a/src/lib/notifications/senders/apns.ts +++ b/src/lib/notifications/senders/apns.ts @@ -744,6 +744,120 @@ export async function sendViaApns( }; } +export interface ApnsRawSendInput { + deviceToken: string; + environment: "sandbox" | "production"; + /** + * APNs `apns-push-type` header. `background` for a silent + * content-available push (no alert); `liveactivity` for an + * ActivityKit update / end push. + */ + pushType: "background" | "liveactivity"; + /** + * Custom JSON payload merged into the notification body. For a + * `background` push this is the app's userInfo; for a `liveactivity` + * push it carries the `aps` content-state / event / timestamp the iOS + * Activity reads. Sent verbatim (no alert / sound / badge enrichment). + */ + payload: Record; + /** + * APNs topic. The silent-sync push uses the app bundle id (default); + * the Live Activity push MUST use `.push-type.liveactivity`. + */ + topic?: string; + /** `apns-priority`. `5` for background (power-conserving) is required. */ + priority?: 5 | 10; +} + +/** + * v1.17.1 (#22) — send one raw APNs push (silent background or Live + * Activity) to one device. Unlike `sendApnsPush`, this sets NO + * `aps.alert` / `aps.sound` / `aps.badge`: a `background` push must be + * content-only or APNs rejects it, and a `liveactivity` push carries its + * own `aps` content-state in the supplied `payload`. The `rawPayload` + * escape hatch bypasses node-apn's convenience setters so the caller + * controls the exact wire body. + */ +export async function sendApnsRawPush( + input: ApnsRawSendInput, +): Promise { + const config = loadApnsConfig(); + if (!config) { + return { + ok: false, + reason: "apns_not_configured", + message: "APNs env vars are not set", + }; + } + + const provider = getProvider(config, input.environment); + + const note = new apn.Notification(); + note.topic = input.topic ?? config.bundleId; + note.pushType = input.pushType; + if (input.pushType === "background") { + // A silent push declares `content-available: 1` and carries no + // alert/sound/badge. Apple rejects a content-available-only push at + // priority 10, so default to the power-conserving 5. + note.contentAvailable = true; + note.priority = input.priority ?? 5; + } else { + note.priority = input.priority ?? 10; + } + // Bypass the convenience setters entirely so the body is exactly what + // the silent-sync / Live-Activity contract specifies — no injected + // `aps.alert` shell. + note.rawPayload = input.payload; + + const start = performance.now(); + let result: Awaited>; + try { + result = await provider.send(note, input.deviceToken); + } catch (err) { + const message = err instanceof Error ? err.message : "send_failed"; + getEvent()?.addExternalCall({ + service: "apns", + method: `send_${input.pushType}`, + duration_ms: Math.round(performance.now() - start), + error: message, + }); + return { ok: false, reason: "apns_network_error", message }; + } + + const duration = Math.round(performance.now() - start); + + if (result.sent.length > 0) { + getEvent()?.addExternalCall({ + service: "apns", + method: `send_${input.pushType}`, + duration_ms: duration, + status: 200, + }); + return { ok: true, status: 200 }; + } + + const failure = result.failed[0]; + const reason = failure?.response?.reason ?? failure?.error?.message; + const status = failure?.status ? Number(failure.status) : undefined; + const shouldDisable = reason ? PERMANENT_APNS_REASONS.has(reason) : false; + + getEvent()?.addExternalCall({ + service: "apns", + method: `send_${input.pushType}`, + duration_ms: duration, + status: status ?? undefined, + error: reason ?? "all_failed", + }); + + return { + ok: false, + status, + reason: reason ?? "apns_unknown_failure", + shouldDisable, + message: failure?.error?.message, + }; +} + function stripHtml(s: string): string { return s.replace(/<[^>]*>/g, ""); } diff --git a/src/lib/notifications/types.ts b/src/lib/notifications/types.ts index ca4eb6759..658f6b03d 100644 --- a/src/lib/notifications/types.ts +++ b/src/lib/notifications/types.ts @@ -66,6 +66,16 @@ export const EVENT_TYPES = [ // `notificationPrefs.measurementReminder.clientManaged` suppression. // NOT time-sensitive — a Vorsorge nudge is not a Focus-bypass case. "MEASUREMENT_REMINDER", + // v1.17.1 (#22) — silent medication-intake cross-device sync. Fired on a + // medication-intake mutation (single + bulk) to the user's OTHER iOS + // devices so a Live Activity / Home-Screen widget reconciles after a dose + // is logged on one device. APNs-ONLY by construction: it is a silent + // `apns-push-type: background` push (content-available, no alert) and has + // no Telegram / ntfy / Web Push semantics, so it never fans out to those + // channels (the dispatcher would have nothing meaningful to deliver and a + // user-visible Telegram message on every dose would be noise). Routed + // through the dedicated intake-sync sender, not the alert cascade. + "MEDICATION_INTAKE_SYNC", ] as const; export type EventType = (typeof EVENT_TYPES)[number]; @@ -117,6 +127,12 @@ export const EVENT_DEFAULT_ENABLED: Record = { // suppression the cron reads. An explicit per-channel // `NotificationPreference` row still wins. MEASUREMENT_REMINDER: true, + // v1.17.1 (#22) — ON at the channel layer; an explicit per-channel + // `NotificationPreference` row (APNS, eventType MEDICATION_INTAKE_SYNC, + // enabled=false) still suppresses it. There is no second user-facing + // toggle: this is plumbing that keeps the user's own devices coherent, + // not a notification the user chooses to receive. + MEDICATION_INTAKE_SYNC: true, }; export const CHANNEL_TYPE_LABELS: Record = { From 836fd7405ef5ac129e9ab3b6252ee6fbb6e21d43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 23:58:21 +0200 Subject: [PATCH 2/3] feat(medications): dispatch the cross-device intake sync on a dose mutation Wire the silent intake-sync push into both intake writers. The single route fires once for the affected slot after the row is persisted; the bulk route collects the distinct (medicationId, scheduledFor) slots whose state actually changed and fires one push per slot, so a 500-row backfill wakes a device per affected slot rather than per row. Pending echoes, duplicates, and no-downgrade no-ops change nothing visible and are excluded. Both pass the originating device through the X-Device-Id header so the device that recorded the dose is skipped. The dispatch is best-effort and fire-and-forget: the canonical rows are already written, so a push miss never affects the response. --- .../intake/__tests__/route.test.ts | 46 +++++++++++++++++++ src/app/api/medications/intake/bulk/route.ts | 34 ++++++++++++++ src/app/api/medications/intake/route.ts | 14 ++++++ 3 files changed, 94 insertions(+) diff --git a/src/app/api/medications/intake/__tests__/route.test.ts b/src/app/api/medications/intake/__tests__/route.test.ts index d1c15d0f9..ae320ccb6 100644 --- a/src/app/api/medications/intake/__tests__/route.test.ts +++ b/src/app/api/medications/intake/__tests__/route.test.ts @@ -53,6 +53,13 @@ vi.mock("@/lib/medications/inventory/consumption", () => ({ restoreForIntake: vi.fn().mockResolvedValue(undefined), })); +// v1.17.1 (#22) — silent cross-device intake sync. Mocked so the route +// test asserts the wiring (called with the right slot + origin token) +// without reaching the APNs senders. +vi.mock("@/lib/notifications/medication-intake-sync", () => ({ + dispatchMedicationIntakeSync: vi.fn().mockResolvedValue(undefined), +})); + vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); vi.mock("@/lib/db-compat", () => ({ @@ -75,6 +82,7 @@ import { consumeForIntake, restoreForIntake, } from "@/lib/medications/inventory/consumption"; +import { dispatchMedicationIntakeSync } from "@/lib/notifications/medication-intake-sync"; import { __resetAllCachesForTests } from "@/lib/cache/server-cache"; const SESSION_OK = { @@ -388,6 +396,44 @@ describe("POST /api/medications/intake", () => { expect(consumeForIntake).not.toHaveBeenCalled(); }); + // ── v1.17.1 (#22) — silent cross-device intake sync wiring ────────── + + it("dispatches the silent intake sync with the affected slot + origin device", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const slot = new Date("2026-05-18T10:00:00.000Z"); + vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValue({ + id: "e1", + userId: "user-1", + medicationId: "m1", + scheduledFor: slot, + } as never); + vi.mocked(prisma.medicationIntakeEvent.update).mockResolvedValue({ + id: "e1", + skipped: true, + takenAt: null, + scheduledFor: slot, + } as never); + + const r = new NextRequest("http://localhost/api/medications/intake", { + method: "POST", + headers: { + "content-type": "application/json", + "x-device-id": "origin-device-token", + }, + body: JSON.stringify({ intakeId: "e1", status: "skipped" }), + }); + const res = await POST(r); + expect(res.status).toBe(200); + + expect(dispatchMedicationIntakeSync).toHaveBeenCalledTimes(1); + expect(dispatchMedicationIntakeSync).toHaveBeenCalledWith({ + userId: "user-1", + medicationId: "m1", + scheduledFor: "2026-05-18T10:00:00.000Z", + originDeviceToken: "origin-device-token", + }); + }); + // ── v1.16.10 — inventory consumption seams ────────────────────────── it("taken (no slot move) consumes inventory exactly once on the toggled row", async () => { diff --git a/src/app/api/medications/intake/bulk/route.ts b/src/app/api/medications/intake/bulk/route.ts index b923db783..2c05515e1 100644 --- a/src/app/api/medications/intake/bulk/route.ts +++ b/src/app/api/medications/intake/bulk/route.ts @@ -68,6 +68,7 @@ import { type InjectionSiteValue, } from "@/lib/validations/medication"; import type { InjectionSiteKey } from "@/lib/medications/injection-sites"; +import { dispatchMedicationIntakeSyncBulk } from "@/lib/notifications/medication-intake-sync"; const MAX_ENTRIES_PER_BATCH = 500; const BATCH_RATE_LIMIT_MAX = 60; @@ -239,6 +240,14 @@ async function postBulk(request: NextRequest): Promise { string, { medicationId: string; dayKey: string } >(); + // v1.17.1 (#22) — distinct `(medicationId, scheduledFor)` slots whose + // state actually changed in this batch (a row inserted / updated), so the + // silent cross-device sync fires once per affected slot — not once per + // row, and not for pending echoes / duplicates that changed nothing. + const syncSlots = new Map< + string, + { medicationId: string; scheduledFor: string } + >(); for (let i = 0; i < entries.length; i++) { const entry = entries[i]; @@ -583,6 +592,18 @@ async function postBulk(request: NextRequest): Promise { medicationId: entry.medicationId, dayKey, }); + // v1.17.1 (#22) — collect the slot for the silent cross-device sync + // only when the row's state actually changed (inserted / updated). A + // pending echo, duplicate, or no-downgrade no-op leaves the visible + // dose state untouched, so it must not wake the user's other devices. + const lastStatus = results[results.length - 1]?.status; + if (lastStatus === "inserted" || lastStatus === "updated") { + const scheduledForIso = effectiveScheduledFor.toISOString(); + syncSlots.set(`${entry.medicationId}|${scheduledForIso}`, { + medicationId: entry.medicationId, + scheduledFor: scheduledForIso, + }); + } } catch (err: unknown) { // P2002 = unique-constraint violation. Two shapes reach here: // 1. an idempotencyKey collision on the unscheduled/PRN insert → @@ -679,6 +700,19 @@ async function postBulk(request: NextRequest): Promise { } } + // v1.17.1 (#22) — silent cross-device intake sync. One push per device + // per distinct affected slot (the map de-dupes), excluding the + // originating device (`X-Device-Id` = registered `Device.token`). + // APNs-only, best-effort, fire-and-forget: the canonical rows are already + // persisted, so a sync-push miss never affects the batch response. + if (syncSlots.size > 0) { + void dispatchMedicationIntakeSyncBulk({ + userId: user.id, + slots: Array.from(syncSlots.values()), + originDeviceToken: request.headers.get("x-device-id"), + }); + } + return apiSuccess({ processed: entries.length, inserted, diff --git a/src/app/api/medications/intake/route.ts b/src/app/api/medications/intake/route.ts index 002c91589..506fb6607 100644 --- a/src/app/api/medications/intake/route.ts +++ b/src/app/api/medications/intake/route.ts @@ -50,6 +50,7 @@ import { injectionSiteEnum, type InjectionSiteValue, } from "@/lib/validations/medication"; +import { dispatchMedicationIntakeSync } from "@/lib/notifications/medication-intake-sync"; const querySchema = z.object({ scope: z.enum(["today", "compliance"]), @@ -684,5 +685,18 @@ export const POST = apiHandler(async (request: NextRequest) => { }); } + // v1.17.1 (#22) — silent cross-device intake sync. Wake the user's OTHER + // iOS devices so a running Live Activity / Home-Screen widget reconciles + // the dose state changed here. APNs-only, best-effort, fire-and-forget: + // the canonical row is already persisted, so a sync-push miss never + // affects the response. The originating device (the one that POSTed) is + // excluded via its `X-Device-Id` (= registered `Device.token`). + void dispatchMedicationIntakeSync({ + userId: user.id, + medicationId: existing.medicationId, + scheduledFor: (movedTo ?? existing.scheduledFor).toISOString(), + originDeviceToken: request.headers.get("x-device-id"), + }); + return apiSuccess(updated); }); From 6d8dd75fffcd4b6b710cb77727ea85bf59b75241 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 23:58:27 +0200 Subject: [PATCH 3/3] feat(devices): accept a Live Activity push token on registration Add an optional hex liveActivityPushToken to device registration. The iOS client registers the current per-Activity ActivityKit push token so the server can address a Live Activity update or end push on a medication-intake mutation, and sends null to clear it when no Activity is running. Following the medicationDelivery override convention, the field is only touched when present, so an ordinary re-register keeps the prior value. Regenerate the OpenAPI contract. --- docs/api/openapi.yaml | 10 +++++ src/app/api/devices/__tests__/route.test.ts | 46 +++++++++++++++++++++ src/app/api/devices/route.ts | 19 +++++++++ src/lib/openapi/routes/devices.ts | 10 +++++ 4 files changed, 85 insertions(+) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index f06755f3a..abfd1c6c3 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -5874,6 +5874,16 @@ components: - server - client - type: "null" + liveActivityPushToken: + description: v1.17.1 (#22) hex-encoded ActivityKit Live Activity push token (distinct from the device APNs token). The + server addresses a Live Activity update / end push on a medication-intake mutation. Omitted = keep the prior + value; null = clear it when the Activity ends. + anyOf: + - type: string + minLength: 8 + maxLength: 256 + pattern: ^[A-Fa-f0-9]+$ + - type: "null" required: - token - bundleId diff --git a/src/app/api/devices/__tests__/route.test.ts b/src/app/api/devices/__tests__/route.test.ts index 5b76b5850..89ca90a10 100644 --- a/src/app/api/devices/__tests__/route.test.ts +++ b/src/app/api/devices/__tests__/route.test.ts @@ -250,6 +250,52 @@ describe("POST /api/devices", () => { ); expect(res.status).toBe(201); }); + + // v1.17.1 (#22) — Live Activity push-token registration. + it("persists liveActivityPushToken on create when supplied", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const res = await POST( + req({ + token: "abcd1234efgh5678", + bundleId: "io.healthlog.app", + liveActivityPushToken: "cafebabe".repeat(8), + }), + ); + expect(res.status).toBe(201); + const data = vi.mocked(prisma.device.create).mock.calls[0][0].data; + expect(data.liveActivityPushToken).toBe("cafebabe".repeat(8)); + }); + + it("clears liveActivityPushToken on re-register when null is sent", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.device.findUnique).mockResolvedValue({ + id: "dev-1", + userId: "user-1", + token: "abcd1234efgh5678", + } as never); + const res = await POST( + req({ + token: "abcd1234efgh5678", + bundleId: "io.healthlog.app", + liveActivityPushToken: null, + }), + ); + expect(res.status).toBe(201); + const data = vi.mocked(prisma.device.update).mock.calls[0][0].data; + expect(data.liveActivityPushToken).toBeNull(); + }); + + it("rejects a non-hex liveActivityPushToken with 422", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const res = await POST( + req({ + token: "abcd1234efgh5678", + bundleId: "io.healthlog.app", + liveActivityPushToken: "not-hex-!!!", + }), + ); + expect(res.status).toBe(422); + }); }); describe("POST /api/devices — 422 multi-issue envelope (v1.4.43 W6)", () => { diff --git a/src/app/api/devices/route.ts b/src/app/api/devices/route.ts index 98dc17e38..caeca15f2 100644 --- a/src/app/api/devices/route.ts +++ b/src/app/api/devices/route.ts @@ -63,6 +63,19 @@ const deviceSchema = z // server APNs for this device; "client" forces local. Stored + // echoed; cron suppression stays user-level. medicationDelivery: z.enum(["server", "client"]).nullable().optional(), + // v1.17.1 (#22) — ActivityKit issues a per-Activity push token distinct + // from the device APNs token. The iOS client registers the current one + // here so the server can address a Live Activity update / end push on a + // medication-intake mutation; it sends `null` to clear the token when no + // Activity is running. Hex, like the APNs token. Only touched when the + // field is present so an ordinary re-register keeps the prior value. + liveActivityPushToken: z + .string() + .min(8) + .max(256) + .regex(/^[A-Fa-f0-9]+$/, "liveActivityPushToken must be hex") + .nullable() + .optional(), }) .refine( (v) => @@ -146,6 +159,7 @@ export const POST = apiHandler(async (request: NextRequest) => { apnsToken, apnsEnvironment, medicationDelivery, + liveActivityPushToken, } = parsed.data; // APNs-token collision lookup. Two cases: @@ -212,6 +226,9 @@ export const POST = apiHandler(async (request: NextRequest) => { // v1.7.0 — only touch the override when the client sends the // field, so a re-register that omits it keeps the prior value. ...(medicationDelivery !== undefined && { medicationDelivery }), + // v1.17.1 (#22) — same omit-keeps-prior rule; `null` explicitly + // clears the token when the Activity ends. + ...(liveActivityPushToken !== undefined && { liveActivityPushToken }), lastSeen: new Date(), }, select: { id: true }, @@ -231,6 +248,8 @@ export const POST = apiHandler(async (request: NextRequest) => { apnsEnvironment: apnsEnvironment ?? null, // v1.7.0 — per-device delivery override (null = inherit default). ...(medicationDelivery !== undefined && { medicationDelivery }), + // v1.17.1 (#22) — Live Activity push token (null = no Activity). + ...(liveActivityPushToken !== undefined && { liveActivityPushToken }), }, select: { id: true }, }); diff --git a/src/lib/openapi/routes/devices.ts b/src/lib/openapi/routes/devices.ts index f88f7f9e3..361b31b51 100644 --- a/src/lib/openapi/routes/devices.ts +++ b/src/lib/openapi/routes/devices.ts @@ -43,6 +43,16 @@ const deviceRegisterRequest = z .describe( 'v1.7.0 per-device medication-delivery override. NULL / omitted = inherit the user-level roaming default. "server" forces server APNs for this device; "client" forces local. Stored + echoed; cron suppression stays user-level.', ), + liveActivityPushToken: z + .string() + .min(8) + .max(256) + .regex(/^[A-Fa-f0-9]+$/) + .nullable() + .optional() + .describe( + "v1.17.1 (#22) hex-encoded ActivityKit Live Activity push token (distinct from the device APNs token). The server addresses a Live Activity update / end push on a medication-intake mutation. Omitted = keep the prior value; null = clear it when the Activity ends.", + ), }) .meta({ id: "DeviceRegisterRequest",