mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
fix(notifications): collapse APNs pushes per dose slot, not per event type
`apns-collapse-id` is a replace directive: a push carrying an id supersedes the earlier undelivered push with the same id. Every medication reminder carried the literal string MEDICATION_REMINDER, so three medications due at 08:00 arrived on the phone as one notification naming one of them. The user takes that one and misses the others with nothing on screen to suggest anything was dropped. Key the collapse on the per-slot tag the Web Push sender already coalesces on, falling back to the event type when a payload carries none. Grouping stays on `threadId`, which is what grouping is for. Discreet mode keeps the generic id — no record-identifying string on the lock screen — and a tag over Apple's 64-byte ceiling falls back rather than costing the send.
This commit is contained in:
1 parent
c6b944b7fb
commit
ff076e80b1
2 files changed
+97
-1
No files matched your search
@@ -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:
|
||||
|
||||
@@ -139,6 +139,12 @@ const PERMANENT_APNS_REASONS = new Set<string>([
|
||||
"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) {
|
||||
|
||||
Reference in new issue
Block a user