diff --git a/src/lib/notifications/senders/__tests__/apns.test.ts b/src/lib/notifications/senders/__tests__/apns.test.ts index b06eeb199..1084f838d 100644 --- a/src/lib/notifications/senders/__tests__/apns.test.ts +++ b/src/lib/notifications/senders/__tests__/apns.test.ts @@ -669,6 +669,78 @@ describe("sendViaApns — dispatcher fan-out", () => { expect(note.priority).toBe(10); }); + it("collapses on the per-slot tag so one dose never replaces another", async () => { + // `apns-collapse-id` is a REPLACE directive. Every medication reminder + // used to carry the literal event type, so three medications due at + // 08:00 arrived as one notification naming one of them and the user + // missed the other two. The per-slot tag the Web Push sender already + // uses keys the collapse per dose; grouping stays on the thread id. + vi.mocked(prisma.device.findMany).mockResolvedValueOnce([ + { id: "d1", apnsToken: "tok-a", apnsEnvironment: "sandbox" }, + ] as never); + sendMock.mockResolvedValueOnce({ sent: [{ device: "tok-a" }], failed: [] }); + await sendViaApns("u-1", { + title: "t", + message: "m", + eventType: "MEDICATION_REMINDER", + metadata: { + medicationId: "med-1", + webPushTag: "med:med-1:2026-06-15T08:00:00.000Z", + }, + }); + const note = sendMock.mock.calls[0][0]; + expect(note.collapseId).toBe("med:med-1:2026-06-15T08:00:00.000Z"); + expect(note.threadId).toBe("MEDICATION_REMINDER"); + }); + + it("falls back to the event type when the payload carries no per-slot tag", async () => { + vi.mocked(prisma.device.findMany).mockResolvedValueOnce([ + { id: "d1", apnsToken: "tok-a", apnsEnvironment: "sandbox" }, + ] as never); + sendMock.mockResolvedValueOnce({ sent: [{ device: "tok-a" }], failed: [] }); + await sendViaApns("u-1", { + title: "t", + message: "m", + eventType: "MOOD_REMINDER", + }); + expect(sendMock.mock.calls[0][0].collapseId).toBe("MOOD_REMINDER"); + }); + + it("ignores a per-slot tag that would exceed the APNs collapse-id limit", async () => { + // Apple caps `apns-collapse-id` at 64 bytes and rejects the push + // outright above it. A reminder that never arrives is worse than one + // that collapses too eagerly, so an oversized tag falls back. + vi.mocked(prisma.device.findMany).mockResolvedValueOnce([ + { id: "d1", apnsToken: "tok-a", apnsEnvironment: "sandbox" }, + ] as never); + sendMock.mockResolvedValueOnce({ sent: [{ device: "tok-a" }], failed: [] }); + await sendViaApns("u-1", { + title: "t", + message: "m", + eventType: "MEDICATION_REMINDER", + metadata: { webPushTag: `med:${"x".repeat(70)}` }, + }); + expect(sendMock.mock.calls[0][0].collapseId).toBe("MEDICATION_REMINDER"); + }); + + it("discreet mode keeps the generic collapse id even with a per-slot tag", async () => { + // The lock-screen-visible routing metadata must not name the event or + // the record it belongs to; discreet wins over the per-slot tag, the + // same precedence the Web Push sender applies. + vi.mocked(prisma.device.findMany).mockResolvedValueOnce([ + { id: "d1", apnsToken: "tok-a", apnsEnvironment: "sandbox" }, + ] as never); + sendMock.mockResolvedValueOnce({ sent: [{ device: "tok-a" }], failed: [] }); + await sendViaApns("u-1", { + title: "t", + message: "m", + eventType: "CYCLE_PERIOD_SOON", + discreet: true, + metadata: { webPushTag: "cycle:period-soon:2026-06-15" }, + }); + expect(sendMock.mock.calls[0][0].collapseId).toBe("REMINDER"); + }); + it("urgent payload sets time-sensitive + priority=10 on a non-medication event", async () => { // v1.18.4 — an explicitly urgent event (e.g. an illness red-flag // escalation) reaches APNs's strongest no-entitlement tier: diff --git a/src/lib/notifications/senders/apns.ts b/src/lib/notifications/senders/apns.ts index 56e01c0b3..07fe39a75 100644 --- a/src/lib/notifications/senders/apns.ts +++ b/src/lib/notifications/senders/apns.ts @@ -139,6 +139,12 @@ const PERMANENT_APNS_REASONS = new Set([ "DeviceTokenNotForTopic", ]); +/** + * Apple's documented ceiling for the `apns-collapse-id` header. A longer + * value is rejected with `BadCollapseId`, i.e. the push is lost. + */ +const APNS_COLLAPSE_ID_MAX_BYTES = 64; + let cachedConfig: ApnsConfig | null | undefined; const providers = new Map<"sandbox" | "production", apn.Provider>(); @@ -626,6 +632,24 @@ export async function sendViaApns( // CYCLE_PERIOD_SOON never surfaces on the lock screen. const GENERIC_EVENT = "REMINDER"; const routingEvent = payload.discreet ? GENERIC_EVENT : payload.eventType; + // `apns-collapse-id` REPLACES an earlier undelivered push carrying the + // same id. Keyed on the event type it made every medication reminder + // replace the last one, so three medications due at 08:00 arrived as a + // single notification naming a single medication. Key it on the + // per-slot tag the Web Push sender already coalesces on + // (`medicationDoseTag`), and keep grouping — `threadId` — on the event. + // Discreet mode wins: no record-identifying string on the lock screen. + // Apple caps the header at 64 bytes and rejects anything longer, so an + // oversized tag falls back rather than costing the send. + const slotTag = + typeof payload.metadata?.webPushTag === "string" && + Buffer.byteLength(payload.metadata.webPushTag, "utf8") <= + APNS_COLLAPSE_ID_MAX_BYTES + ? payload.metadata.webPushTag + : undefined; + const collapseId = payload.discreet + ? routingEvent + : (slotTag ?? routingEvent); let workingEnv: "production" | "sandbox" | null = null; for (const attemptEnv of envSequence) { result = await sendApnsPush({ @@ -668,7 +692,7 @@ export async function sendViaApns( } : {}), }, - collapseId: routingEvent, + collapseId, }); if (result.ok) {