feat(healthkit): record Apple Health dose arrivals and the last background arrival per type

Migration 0401 turns healthkit_type_syncs.type into text so the ledger can
hold MEDICATION_DOSE, and adds last_background_at (seeded from rows whose
latest arrival was a background one). POST /api/medications/intake/bulk
takes syncTrigger and records APPLE_HEALTH dose arrivals; the Apple Health
status read serves a MEDICATION_DOSE freshness entry and lastBackgroundAt
on every entry with arrival facts.
This commit is contained in:
Marc-André Bombeck committed 2026-10-15 15:33:30 +02:00
1 parent 6313c0174a
commit 03b24461ad
22 files changed
+352 -54

No files matched your search

@@ -76,6 +76,19 @@ beforeEach(() => {
} as never);
});
/**
* The route issues two raw reads: the newest-sample probe per measurement type
* and (v1.42.4) the newest Apple Health dose. Answer each by its SQL.
*/
function mockProbes(rows: unknown, doseLastSeen: Date | null = null): void {
vi.mocked(prisma.$queryRaw).mockImplementation((async (
strings: TemplateStringsArray,
) =>
strings.join("?").includes("medication_intake_events")
? [{ last_seen: doseLastSeen }]
: rows) as never);
}
const callGet = GET as unknown as (req: NextRequest) => Promise<Response>;
function makeGetReq(): NextRequest {
return new NextRequest("http://localhost/api/integrations/healthkit");
@@ -313,9 +326,7 @@ describe("GET /api/integrations/healthkit — sync health", () => {
healthKitLastSyncedAt: null,
} as never);
vi.mocked(listLiveMeasurementTypes).mockResolvedValue(["PULSE"]);
vi.mocked(prisma.$queryRaw).mockResolvedValue([
{ type: "PULSE", last_seen: ago(DAY) },
] as never);
mockProbes([{ type: "PULSE", last_seen: ago(DAY) }] as never);
const body = await read();
expect(body.syncHealth.verdict).toBe("fresh");
expect(body.metricFreshness.map((e) => e.stale)).toEqual([false]);
@@ -330,7 +341,7 @@ describe("GET /api/integrations/healthkit — sync health", () => {
"RESPIRATORY_RATE",
"PULSE",
]);
vi.mocked(prisma.$queryRaw).mockResolvedValue([
mockProbes([
{ type: "RESPIRATORY_RATE", last_seen: ago(30 * DAY) },
{ type: "PULSE", last_seen: ago(60 * 60 * 1000) },
] as never);
@@ -441,7 +452,7 @@ describe("GET /api/integrations/healthkit — sync health", () => {
"RESPIRATORY_RATE",
"PULSE",
]);
vi.mocked(prisma.$queryRaw).mockResolvedValue([
mockProbes([
{ type: "RESPIRATORY_RATE", last_seen: ago(2 * DAY) },
{ type: "PULSE", last_seen: ago(60 * 60 * 1000) },
] as never);
@@ -476,15 +487,74 @@ describe("GET /api/integrations/healthkit — sync health", () => {
expect(byType.WORKOUTS).not.toHaveProperty("lastReceivedAt");
});
// v1.42.4 — Apple Health doses join the list with their own arrival facts,
// and every entry carries the last background arrival beside the last one.
it("serves a MEDICATION_DOSE entry and the last background arrival", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
healthKitConfigJson: null,
healthKitLastSyncedAt: ago(60 * 60 * 1000),
} as never);
vi.mocked(listLiveMeasurementTypes).mockResolvedValue(["PULSE"]);
const doseAt = ago(40 * DAY);
mockProbes([{ type: "PULSE", last_seen: ago(60 * 60 * 1000) }], doseAt);
const receivedAt = ago(60 * 60 * 1000);
const backgroundAt = ago(5 * 60 * 60 * 1000);
vi.mocked(prisma.healthKitTypeSync.findMany).mockResolvedValue([
{
type: "MEDICATION_DOSE",
lastReceivedAt: receivedAt,
lastTrigger: "foreground",
lastNewSampleAt: null,
lastBackgroundAt: backgroundAt,
},
{
type: "PULSE",
lastReceivedAt: receivedAt,
lastTrigger: "background",
lastNewSampleAt: receivedAt,
lastBackgroundAt: null,
},
] as never);
const body = await read();
expect(body.metricFreshness.map((e) => e.type)).toEqual([
"MEDICATION_DOSE",
"PULSE",
]);
const byType = Object.fromEntries(
body.metricFreshness.map((entry) => [entry.type, entry]),
);
expect(byType.MEDICATION_DOSE).toEqual({
type: "MEDICATION_DOSE",
lastSeenAt: doseAt.toISOString(),
// Forty days without a dose is not a dead pipe: as-needed medications.
stale: false,
lastReceivedAt: receivedAt.toISOString(),
lastTrigger: "foreground",
lastNewSampleAt: null,
lastBackgroundAt: backgroundAt.toISOString(),
});
expect(byType.PULSE).toMatchObject({ lastBackgroundAt: null });
});
it("leaves the dose entry out when there is no Apple Health dose", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
healthKitConfigJson: null,
healthKitLastSyncedAt: ago(DAY),
} as never);
vi.mocked(listLiveMeasurementTypes).mockResolvedValue(["PULSE"]);
mockProbes([{ type: "PULSE", last_seen: ago(DAY) }], null);
const body = await read();
expect(body.metricFreshness.map((e) => e.type)).toEqual(["PULSE"]);
});
it("leaves the arrival facts off when the ledger read fails", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
healthKitConfigJson: null,
healthKitLastSyncedAt: ago(DAY),
} as never);
vi.mocked(listLiveMeasurementTypes).mockResolvedValue(["PULSE"]);
vi.mocked(prisma.$queryRaw).mockResolvedValue([
{ type: "PULSE", last_seen: ago(DAY) },
] as never);
mockProbes([{ type: "PULSE", last_seen: ago(DAY) }] as never);
vi.mocked(prisma.healthKitTypeSync.findMany).mockRejectedValue(
new Error("ledger hiccup"),
);
+20 -7
View File
@@ -26,7 +26,10 @@ import {
newestAppleHealthDataAt,
withHealthKitArrivals,
} from "@/lib/integrations/healthkit-type-sync";
import { getSourceFreshness } from "@/lib/integrations/metric-freshness";
import {
getAppleHealthDoseFreshness,
getSourceFreshness,
} from "@/lib/integrations/metric-freshness";
import { loadOneConnectionHealth } from "@/lib/integrations/connection-health-load";
import {
classifyMetricFreshness,
@@ -177,12 +180,22 @@ export const GET = apiHandler(async () => {
// `health` is the unified connection state; fail-soft to null for the same
// reason as the reads beside it, so the config the phone needs never 500s
// over a diagnostic.
const [samples, arrivals, syncProgress, health] = await Promise.all([
getSourceFreshness(user.id, "APPLE_HEALTH").catch(() => []),
getHealthKitTypeArrivals(user.id).catch(() => null),
getAppleHealthSyncProgress(user.id).catch(() => null),
loadOneConnectionHealth(user.id, "apple-health").catch(() => null),
]);
// v1.42.4 — Apple Health doses join the list as `MEDICATION_DOSE`, so the
// phone can read when doses last arrived from the same entries as every
// other type. Fail-soft on its own: a failed dose read drops that one entry.
const [measurementSamples, doseSample, arrivals, syncProgress, health] =
await Promise.all([
getSourceFreshness(user.id, "APPLE_HEALTH").catch(() => []),
getAppleHealthDoseFreshness(user.id).catch(() => null),
getHealthKitTypeArrivals(user.id).catch(() => null),
getAppleHealthSyncProgress(user.id).catch(() => null),
loadOneConnectionHealth(user.id, "apple-health").catch(() => null),
]);
const samples = doseSample
? [...measurementSamples, doseSample].sort((a, b) =>
a.type.localeCompare(b.type),
)
: measurementSamples;
// No `connected` hint: Apple Health has nothing to connect or disconnect, so
// "never delivered" resolves to `pending_first_sync`, not `disconnected`.
// "Delivered" is read off the data, not only off the user column: that