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
This commit is contained in:
Marc-André Bombeck committed 2026-11-04 21:18:30 +01:00
1 parent 66533497de
commit 59434cd463
7 files changed
+117

No files matched your search

+7
View File
@@ -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
+9
View File
@@ -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
@@ -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<ReturnType<typeof getSession>>,
);
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", () => {
@@ -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(),
});
});
@@ -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();
}
});
});
@@ -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;
}
+6
View File
@@ -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",