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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-14 23:58:27 +02:00
1 parent 836fd7405e
commit 6d8dd75fff
4 files changed
+85

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 },
});
+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",