mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
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:
1 parent
66533497de
commit
59434cd463
7 files changed
+117
No files matched your search
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user