merge(medications): silent intake-sync push and Live Activity token for cross-device clearing

This commit is contained in:
Marc-André Bombeck committed 2026-06-14 23:59:14 +02:00
commit 3606610751
11 files changed
+917

No files matched your search

+10
View File
@@ -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
@@ -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)", () => {
+19
View File
@@ -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 },
});
@@ -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 () => {
@@ -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<Response> {
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<Response> {
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<Response> {
}
}
// 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,
+14
View File
@@ -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);
});
@@ -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"],
]);
});
});
@@ -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 `<bundle>.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<boolean> {
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<void> {
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<void> {
const seen = new Set<string>();
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<void> {
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}`,
);
}
}
+114
View File
@@ -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<string, unknown>;
/**
* APNs topic. The silent-sync push uses the app bundle id (default);
* the Live Activity push MUST use `<bundle>.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<ApnsSendResult> {
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<ReturnType<apn.Provider["send"]>>;
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, "");
}
+16
View File
@@ -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<EventType, boolean> = {
// 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<ChannelType, string> = {
+10
View File
@@ -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",