mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
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:
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"),
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user