From 6d8dd75fffcd4b6b710cb77727ea85bf59b75241 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 23:58:27 +0200 Subject: [PATCH] feat(devices): accept a Live Activity push token on registration Add an optional hex liveActivityPushToken to device registration. The iOS client registers the current per-Activity ActivityKit push token so the server can address a Live Activity update or end push on a medication-intake mutation, and sends null to clear it when no Activity is running. Following the medicationDelivery override convention, the field is only touched when present, so an ordinary re-register keeps the prior value. Regenerate the OpenAPI contract. --- docs/api/openapi.yaml | 10 +++++ src/app/api/devices/__tests__/route.test.ts | 46 +++++++++++++++++++++ src/app/api/devices/route.ts | 19 +++++++++ src/lib/openapi/routes/devices.ts | 10 +++++ 4 files changed, 85 insertions(+) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index f06755f3a..abfd1c6c3 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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 diff --git a/src/app/api/devices/__tests__/route.test.ts b/src/app/api/devices/__tests__/route.test.ts index 5b76b5850..89ca90a10 100644 --- a/src/app/api/devices/__tests__/route.test.ts +++ b/src/app/api/devices/__tests__/route.test.ts @@ -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)", () => { diff --git a/src/app/api/devices/route.ts b/src/app/api/devices/route.ts index 98dc17e38..caeca15f2 100644 --- a/src/app/api/devices/route.ts +++ b/src/app/api/devices/route.ts @@ -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 }, }); diff --git a/src/lib/openapi/routes/devices.ts b/src/lib/openapi/routes/devices.ts index f88f7f9e3..361b31b51 100644 --- a/src/lib/openapi/routes/devices.ts +++ b/src/lib/openapi/routes/devices.ts @@ -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",