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
+2 -1
View File
@@ -73,6 +73,7 @@ import {
findFoldedWindowDuplicates,
} from "@/lib/measurements/folded-window";
import {
HEALTHKIT_SYNC_TRIGGERS,
recordHealthKitTypeArrivals,
type HealthKitTypeArrival,
} from "@/lib/integrations/healthkit-type-sync";
@@ -198,7 +199,7 @@ const batchEntrySchema = z.object({
// pre-#66 caller stays byte-for-byte unchanged.
// v1.42 (#1173) — `manual` names a user-initiated "Sync all" run, as opposed to
// the app simply being open (`foreground`).
const syncTriggerEnum = z.enum(["foreground", "background", "push", "manual"]);
const syncTriggerEnum = z.enum(HEALTHKIT_SYNC_TRIGGERS);
const batchPayloadSchema = z.object({
entries: z.array(batchEntrySchema).min(1),
+36 -1
View File
@@ -29,6 +29,10 @@
* per Apple dose)
* }
*
* Top level also takes `syncTrigger?` (foreground | background | push |
* manual, v1.42.4), diagnostic only: it dates the `MEDICATION_DOSE` entry of
* the Apple Health per-type arrival record and changes no row.
*
* Response (always 200): processed / inserted / duplicates / skipped /
* entries[] mirroring the measurements + workouts batch envelope.
*
@@ -53,6 +57,11 @@ import {
import { withIdempotency } from "@/lib/idempotency";
import { checkRateLimit } from "@/lib/rate-limit";
import { markSyncCheckpoint } from "@/lib/sync/checkpoint";
import {
HEALTHKIT_SYNC_TRIGGERS,
MEDICATION_DOSE_ARRIVAL_TYPE,
recordHealthKitTypeArrivals,
} from "@/lib/integrations/healthkit-type-sync";
import { invalidateUserMedications } from "@/lib/cache/invalidate";
import {
recomputeMedicationComplianceForDay,
@@ -164,6 +173,10 @@ const bulkEntrySchema = z
const bulkPayloadSchema = z.object({
entries: z.array(bulkEntrySchema).min(1).max(MAX_ENTRIES_PER_BATCH),
// v1.42.4 — what woke the phone for this sync, the same diagnostic-only
// field `POST /api/measurements/batch` takes. It changes no row; it only
// feeds the `MEDICATION_DOSE` arrival record below.
syncTrigger: z.enum(HEALTHKIT_SYNC_TRIGGERS).optional(),
});
// v1.8.2 — `updated` joins the per-entry status vocabulary: a write that
@@ -243,7 +256,7 @@ async function postBulk(request: NextRequest): Promise<Response> {
});
}
const { entries } = parsed.data;
const { entries, syncTrigger } = parsed.data;
// Up-front ownership check — the user can only POST intake events
// against medications they own. One read covers every medicationId
@@ -992,6 +1005,28 @@ async function postBulk(request: NextRequest): Promise<Response> {
})();
}
// v1.42.4 — Apple Health doses get the per-type arrival record the
// measurement batch keeps for its types: when they last arrived, under which
// trigger, when a sync last brought a new or changed dose, and when a
// background sync last carried them. Entries sent with `source:
// "APPLE_HEALTH"` only; a dose taken in the app is not an Apple Health
// arrival. A replay answered `duplicate` still counts as an arrival, the
// same rule as the batch route.
const doseArrival = { accepted: 0, newSamples: 0 };
for (const result of results) {
if (entries[result.index]?.source !== "APPLE_HEALTH") continue;
if (result.status === "skipped") continue;
doseArrival.accepted += 1;
if (result.status !== "duplicate") doseArrival.newSamples += 1;
}
if (doseArrival.accepted > 0) {
await recordHealthKitTypeArrivals(prisma, {
userId: user.id,
counts: new Map([[MEDICATION_DOSE_ARRIVAL_TYPE, doseArrival]]),
trigger: syncTrigger ?? null,
});
}
// The SyncMode checkpoint belongs to the push that moved the data, not to
// the `/api/sync/state` handshake that reports it. This endpoint is one of
// the three domains that handshake summarises, so an intake drain advances