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 = {