mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
merge(medications): silent intake-sync push and Live Activity token for cross-device clearing
This commit is contained in:
commit
3606610751
11 files changed
+917
No files matched your search
@@ -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)", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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, "");
|
||||
}
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user