From 59434cd463ccfe71dab5ab3f49e406a326df682c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 4 Nov 2026 21:05:51 +0100 Subject: [PATCH] feat(healthkit): list the accepted sample types on the integration read GET /api/integrations/healthkit carries acceptedTypes, the quantity and category identifiers the batch route maps today. The list is derived from APPLE_HEALTH_TYPE_MAP alone, so a deferred type is never in it and the client no longer needs a release to follow a mapping change. Refs #1340 --- CHANGELOG.md | 7 +++ docs/api/openapi.yaml | 9 ++++ .../healthkit/__tests__/route.test.ts | 21 ++++++++ src/app/api/integrations/healthkit/route.ts | 3 ++ .../healthkit-accepted-types.test.ts | 54 +++++++++++++++++++ .../measurements/healthkit-accepted-types.ts | 17 ++++++ src/lib/openapi/routes/integrations.ts | 6 +++ 7 files changed, 117 insertions(+) create mode 100644 src/lib/measurements/__tests__/healthkit-accepted-types.test.ts create mode 100644 src/lib/measurements/healthkit-accepted-types.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index fac8da7f1..dd9a927a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ ## [Unreleased] +### Added + +- `GET /api/integrations/healthkit` lists the HealthKit sample types the + server accepts as `acceptedTypes`, taken from the same mapping the batch + upload uses. The iOS app can then ask for and send only types the server + stores, instead of reading a type the server would skip. (#1340) + ### Fixed - **Typing into the smoking history.** "When you stopped" only took text diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 943986f2c..3d075ec93 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -83483,6 +83483,15 @@ components: anyOf: - $ref: "#/components/schemas/ConnectionHealth" - type: "null" + acceptedTypes: + description: The HealthKit quantity and category identifiers `POST /api/measurements/batch` maps today (e.g. + `HKQuantityTypeIdentifierBodyMass`, `HKCategoryTypeIdentifierSleepAnalysis`), sorted. Derived from the + server's mapping table, so a type the server defers is never listed. A client requests and sends only types + in this list and its own table; when the field is absent (an older server) it keeps its own table. Present + on the GET read; omitted from the PATCH echo. + type: array + items: + type: string required: - entries - lastSyncedAt diff --git a/src/app/api/integrations/healthkit/__tests__/route.test.ts b/src/app/api/integrations/healthkit/__tests__/route.test.ts index 57e944359..6b0e52f94 100644 --- a/src/app/api/integrations/healthkit/__tests__/route.test.ts +++ b/src/app/api/integrations/healthkit/__tests__/route.test.ts @@ -45,6 +45,10 @@ import { getSession } from "@/lib/auth/session"; import { listLiveMeasurementTypes } from "@/lib/measurements/live-types"; import { APPLE_HEALTH_DATA_STALE_AFTER_MS } from "@/lib/integrations/sync-verdict"; import { auditLog } from "@/lib/auth/audit"; +import { + APPLE_HEALTH_TYPE_MAP, + HK_QUANTITY_TYPE_DEFERRED, +} from "@/lib/measurements/apple-health-mapping"; const SESSION_OK = { session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, @@ -110,6 +114,23 @@ describe("GET /api/integrations/healthkit", () => { const bodyMass = body.data.entries.find((e) => e.id === "bodyMass"); expect(bodyMass?.direction).toBe("bidirectional"); }); + + it("lists the sample types the batch route maps, never a deferred one", async () => { + vi.mocked(getSession).mockResolvedValue( + SESSION_OK as Awaited>, + ); + const res = await callGet(makeGetReq()); + const body = (await res.json()) as { data: { acceptedTypes: string[] } }; + expect(body.data.acceptedTypes).toEqual( + Object.keys(APPLE_HEALTH_TYPE_MAP).sort(), + ); + expect(body.data.acceptedTypes).toContain( + "HKCategoryTypeIdentifierSleepAnalysis", + ); + for (const id of HK_QUANTITY_TYPE_DEFERRED) { + expect(body.data.acceptedTypes).not.toContain(id); + } + }); }); describe("PATCH /api/integrations/healthkit", () => { diff --git a/src/app/api/integrations/healthkit/route.ts b/src/app/api/integrations/healthkit/route.ts index 62a991514..8fd0ca1ff 100644 --- a/src/app/api/integrations/healthkit/route.ts +++ b/src/app/api/integrations/healthkit/route.ts @@ -21,6 +21,7 @@ import { annotate } from "@/lib/logging/context"; import { prisma, toJson } from "@/lib/db"; import { auditLog } from "@/lib/auth/audit"; import { getAppleHealthSyncProgress } from "@/lib/integrations/apple-health-progress"; +import { acceptedHealthKitTypes } from "@/lib/measurements/healthkit-accepted-types"; import { getHealthKitTypeArrivals, healthKitWriteBackTypes, @@ -241,6 +242,8 @@ export const GET = apiHandler(async () => { ), syncProgress, health, + // #1340 — what the batch route maps, so the app asks only for those. + acceptedTypes: acceptedHealthKitTypes(), }); }); diff --git a/src/lib/measurements/__tests__/healthkit-accepted-types.test.ts b/src/lib/measurements/__tests__/healthkit-accepted-types.test.ts new file mode 100644 index 000000000..dce617d84 --- /dev/null +++ b/src/lib/measurements/__tests__/healthkit-accepted-types.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; + +import { + APPLE_HEALTH_TYPE_MAP, + HK_QUANTITY_TYPE_DEFERRED, + mapAppleHealthEntry, +} from "../apple-health-mapping"; +import { acceptedHealthKitTypes } from "../healthkit-accepted-types"; + +describe("acceptedHealthKitTypes", () => { + it("is exactly the mapping table's identifiers, sorted", () => { + const accepted = acceptedHealthKitTypes(); + expect(accepted.length).toBeGreaterThan(0); + expect([...accepted]).toEqual(Object.keys(APPLE_HEALTH_TYPE_MAP).sort()); + }); + + it("carries quantity and category identifiers, never a deferred one", () => { + const accepted = acceptedHealthKitTypes(); + expect(accepted).toContain("HKQuantityTypeIdentifierBodyMass"); + expect(accepted).toContain("HKCategoryTypeIdentifierSleepAnalysis"); + for (const id of accepted) { + expect(HK_QUANTITY_TYPE_DEFERRED.has(id), id).toBe(false); + } + expect(accepted).not.toContain("HKQuantityTypeIdentifierHeight"); + expect(accepted).not.toContain("HKQuantityTypeIdentifierBasalEnergyBurned"); + }); + + it("lists only identifiers the batch mapper turns into a row", () => { + for (const id of acceptedHealthKitTypes()) { + const out = mapAppleHealthEntry({ + hkIdentifier: id, + value: 1, + unit: APPLE_HEALTH_TYPE_MAP[id]!.hkUnit, + startDate: "2026-01-01T10:00:00.000Z", + endDate: "2026-01-01T10:00:00.000Z", + sleepStage: 1, + categoryValue: 1, + }); + expect(out, id).not.toBeNull(); + } + for (const id of HK_QUANTITY_TYPE_DEFERRED) { + expect( + mapAppleHealthEntry({ + hkIdentifier: id, + value: 1, + unit: "count", + startDate: "2026-01-01T10:00:00.000Z", + endDate: "2026-01-01T10:00:00.000Z", + }), + id, + ).toBeNull(); + } + }); +}); diff --git a/src/lib/measurements/healthkit-accepted-types.ts b/src/lib/measurements/healthkit-accepted-types.ts new file mode 100644 index 000000000..a2e60be19 --- /dev/null +++ b/src/lib/measurements/healthkit-accepted-types.ts @@ -0,0 +1,17 @@ +/** + * The HealthKit sample types `POST /api/measurements/batch` maps today, + * derived from `APPLE_HEALTH_TYPE_MAP` and nothing else, so the list the + * iOS app reads on `GET /api/integrations/healthkit` cannot drift from what + * the batch route accepts (#1340). A type in `HK_QUANTITY_TYPE_DEFERRED` is + * by construction absent: the mapping test refuses a double-booking. + */ +import { APPLE_HEALTH_TYPE_MAP } from "@/lib/measurements/apple-health-mapping"; + +const ACCEPTED: readonly string[] = Object.freeze( + Object.keys(APPLE_HEALTH_TYPE_MAP).sort(), +); + +/** Quantity and category identifiers the batch route maps, sorted. */ +export function acceptedHealthKitTypes(): readonly string[] { + return ACCEPTED; +} diff --git a/src/lib/openapi/routes/integrations.ts b/src/lib/openapi/routes/integrations.ts index e8876da72..48b64bc3d 100644 --- a/src/lib/openapi/routes/integrations.ts +++ b/src/lib/openapi/routes/integrations.ts @@ -378,6 +378,12 @@ const healthKitConfigResponse = z .describe( "The unified Apple Health connection state. Null when the phone has never delivered anything (or the read failed). Present on the GET read; omitted from the PATCH echo.", ), + acceptedTypes: z + .array(z.string()) + .optional() + .describe( + "The HealthKit quantity and category identifiers `POST /api/measurements/batch` maps today (e.g. `HKQuantityTypeIdentifierBodyMass`, `HKCategoryTypeIdentifierSleepAnalysis`), sorted. Derived from the server's mapping table, so a type the server defers is never listed. A client requests and sends only types in this list and its own table; when the field is absent (an older server) it keeps its own table. Present on the GET read; omitted from the PATCH echo.", + ), }) .meta({ id: "HealthKitConfigResponse",