mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 22:50:51 +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
@@ -3,9 +3,12 @@
|
||||
*
|
||||
* The batch route records, per measurement type it received, when the type
|
||||
* last arrived, under which `syncTrigger`, and when it last brought a new or
|
||||
* updated sample (as opposed to duplicates only). The Apple Health card reads
|
||||
* it to show which types are actually flowing. A scoped ingest credential
|
||||
* does not write it, the same rule as `lastSyncedAt`.
|
||||
* updated sample (as opposed to duplicates only). Since v1.42.4 the intake
|
||||
* bulk route records Apple Health doses the same way under the key
|
||||
* `MEDICATION_DOSE`, and every row also keeps when a `background` batch last
|
||||
* carried the type. The Apple Health card reads it to show which types are
|
||||
* actually flowing. A scoped ingest credential does not write it, the same
|
||||
* rule as `lastSyncedAt`.
|
||||
*
|
||||
* Sample time (`max(measuredAt)`, what the freshness list showed before) and
|
||||
* arrival time are different questions. A type the phone only hands over
|
||||
@@ -17,19 +20,32 @@ import type { MeasurementType, Prisma } from "@/generated/prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { logCaught } from "@/lib/logging/signal";
|
||||
import {
|
||||
MEDICATION_DOSE_FRESHNESS_TYPE,
|
||||
WORKOUT_FRESHNESS_TYPE,
|
||||
type MetricFreshnessEntry,
|
||||
} from "./sync-verdict";
|
||||
|
||||
export type HealthKitSyncTrigger =
|
||||
"foreground" | "background" | "push" | "manual";
|
||||
|
||||
const TRIGGERS: ReadonlySet<string> = new Set<HealthKitSyncTrigger>([
|
||||
/** The `syncTrigger` values the Apple Health ingest routes accept. */
|
||||
export const HEALTHKIT_SYNC_TRIGGERS = [
|
||||
"foreground",
|
||||
"background",
|
||||
"push",
|
||||
"manual",
|
||||
]);
|
||||
] as const;
|
||||
|
||||
export type HealthKitSyncTrigger = (typeof HEALTHKIT_SYNC_TRIGGERS)[number];
|
||||
|
||||
const TRIGGERS: ReadonlySet<string> = new Set<string>(HEALTHKIT_SYNC_TRIGGERS);
|
||||
|
||||
/**
|
||||
* The ledger key for Apple Health medication doses, which are not a
|
||||
* `MeasurementType`. Also the `type` of their `metricFreshness` entry.
|
||||
*/
|
||||
export const MEDICATION_DOSE_ARRIVAL_TYPE = MEDICATION_DOSE_FRESHNESS_TYPE;
|
||||
|
||||
/** A ledger key: a measurement type, or the dose key above. */
|
||||
export type HealthKitArrivalType =
|
||||
MeasurementType | typeof MEDICATION_DOSE_ARRIVAL_TYPE;
|
||||
|
||||
/** What one batch did for one type. */
|
||||
export interface HealthKitTypeArrival {
|
||||
@@ -44,7 +60,9 @@ export interface HealthKitTypeArrival {
|
||||
* types). `last_received_at` and `last_trigger` describe the latest batch
|
||||
* that carried the type, so they are overwritten; `last_new_sample_at` only
|
||||
* moves when this batch brought something new, so a duplicates-only re-send
|
||||
* keeps the earlier date standing.
|
||||
* keeps the earlier date standing. `last_background_at` moves only on a
|
||||
* `background` batch, so opening the app afterwards does not hide the
|
||||
* earlier background arrival.
|
||||
*
|
||||
* Best effort: the ledger is a diagnostic beside rows that are already
|
||||
* written, so a failure here leaves a `warn` line and never fails the ingest.
|
||||
@@ -53,7 +71,7 @@ export async function recordHealthKitTypeArrivals(
|
||||
tx: Prisma.TransactionClient,
|
||||
input: {
|
||||
userId: string;
|
||||
counts: ReadonlyMap<MeasurementType, HealthKitTypeArrival>;
|
||||
counts: ReadonlyMap<HealthKitArrivalType, HealthKitTypeArrival>;
|
||||
trigger: HealthKitSyncTrigger | null;
|
||||
at?: Date;
|
||||
},
|
||||
@@ -70,13 +88,16 @@ export async function recordHealthKitTypeArrivals(
|
||||
// serialised in the process's local zone and these are zone-less UTC
|
||||
// columns (same reasoning as the access-token sunset in refresh-token.ts).
|
||||
const at = (input.at ?? new Date()).toISOString();
|
||||
const background = input.trigger === "background";
|
||||
try {
|
||||
await tx.$executeRaw`
|
||||
INSERT INTO "healthkit_type_syncs"
|
||||
("user_id", "type", "last_received_at", "last_trigger", "last_new_sample_at")
|
||||
SELECT ${input.userId}, u.t::"measurement_type",
|
||||
("user_id", "type", "last_received_at", "last_trigger",
|
||||
"last_new_sample_at", "last_background_at")
|
||||
SELECT ${input.userId}, u.t,
|
||||
${at}::timestamptz AT TIME ZONE 'UTC', ${input.trigger},
|
||||
CASE WHEN u.n THEN ${at}::timestamptz AT TIME ZONE 'UTC' END
|
||||
CASE WHEN u.n THEN ${at}::timestamptz AT TIME ZONE 'UTC' END,
|
||||
CASE WHEN ${background}::boolean THEN ${at}::timestamptz AT TIME ZONE 'UTC' END
|
||||
FROM unnest(${types}::text[], ${brought}::boolean[]) AS u(t, n)
|
||||
ON CONFLICT ("user_id", "type") DO UPDATE SET
|
||||
"last_received_at" = EXCLUDED."last_received_at",
|
||||
@@ -84,6 +105,10 @@ export async function recordHealthKitTypeArrivals(
|
||||
"last_new_sample_at" = COALESCE(
|
||||
EXCLUDED."last_new_sample_at",
|
||||
"healthkit_type_syncs"."last_new_sample_at"
|
||||
),
|
||||
"last_background_at" = COALESCE(
|
||||
EXCLUDED."last_background_at",
|
||||
"healthkit_type_syncs"."last_background_at"
|
||||
)
|
||||
`;
|
||||
} catch (error) {
|
||||
@@ -98,10 +123,11 @@ export interface HealthKitTypeArrivalRecord {
|
||||
lastReceivedAt: string;
|
||||
lastTrigger: HealthKitSyncTrigger | null;
|
||||
lastNewSampleAt: string | null;
|
||||
lastBackgroundAt: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every recorded type for one account, keyed by `MeasurementType`. A stored
|
||||
* Every recorded type for one account, keyed by the ledger key. A stored
|
||||
* trigger outside the known set (none is written, but the column is text)
|
||||
* reads as null rather than reaching the client as an unknown label.
|
||||
*/
|
||||
@@ -115,6 +141,7 @@ export async function getHealthKitTypeArrivals(
|
||||
lastReceivedAt: true,
|
||||
lastTrigger: true,
|
||||
lastNewSampleAt: true,
|
||||
lastBackgroundAt: true,
|
||||
},
|
||||
});
|
||||
const out = new Map<string, HealthKitTypeArrivalRecord>();
|
||||
@@ -126,6 +153,7 @@ export async function getHealthKitTypeArrivals(
|
||||
? (row.lastTrigger as HealthKitSyncTrigger)
|
||||
: null,
|
||||
lastNewSampleAt: row.lastNewSampleAt?.toISOString() ?? null,
|
||||
lastBackgroundAt: row.lastBackgroundAt?.toISOString() ?? null,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
@@ -165,11 +193,13 @@ export interface HealthKitFreshnessEntry extends MetricFreshnessEntry {
|
||||
lastTrigger?: HealthKitSyncTrigger | null;
|
||||
/** When a live sync last brought a new or updated sample of it. */
|
||||
lastNewSampleAt?: string | null;
|
||||
/** When a `background` sync last carried it; a later foreground one keeps it. */
|
||||
lastBackgroundAt?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lay the arrival ledger over the per-type freshness entries. Every
|
||||
* measurement type gets the three fields, null when no live sync has carried
|
||||
* measurement type and the `MEDICATION_DOSE` entry get the four fields, null when no live sync has carried
|
||||
* it yet (a type that so far came only from the export import, or from before
|
||||
* the ledger existed). The `WORKOUTS` entry is left without them: workouts
|
||||
* arrive through their own batch route, which the ledger does not cover, and
|
||||
@@ -189,6 +219,7 @@ export function withHealthKitArrivals(
|
||||
lastReceivedAt: arrival?.lastReceivedAt ?? null,
|
||||
lastTrigger: arrival?.lastTrigger ?? null,
|
||||
lastNewSampleAt: arrival?.lastNewSampleAt ?? null,
|
||||
lastBackgroundAt: arrival?.lastBackgroundAt ?? null,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import type {
|
||||
import { listLiveMeasurementTypes } from "@/lib/measurements/live-types";
|
||||
import type { IntegrationKey } from "./status";
|
||||
import {
|
||||
MEDICATION_DOSE_FRESHNESS_TYPE,
|
||||
WORKOUT_FRESHNESS_TYPE,
|
||||
type MetricFreshnessSample,
|
||||
} from "./sync-verdict";
|
||||
@@ -185,6 +186,34 @@ export async function getSourceFreshness(
|
||||
return sortByType(entries);
|
||||
}
|
||||
|
||||
/**
|
||||
* The newest Apple Health medication dose, as the `MEDICATION_DOSE` sample of
|
||||
* the Apple Health freshness list (v1.42.4). A dose row carries an
|
||||
* `externalId` only when it came from Apple Health (the intake bulk route
|
||||
* accepts one solely with `source: "APPLE_HEALTH"`, and a slot write that
|
||||
* converges onto an existing row keeps the id), so that column, not
|
||||
* `source`, is what identifies them. A taken dose is dated by when it was
|
||||
* taken, a skipped one by its slot. Null when there is no live Apple Health
|
||||
* dose, so the entry is absent rather than invented.
|
||||
*/
|
||||
export async function getAppleHealthDoseFreshness(
|
||||
userId: string,
|
||||
): Promise<MetricFreshnessSample | null> {
|
||||
const rows = await prisma.$queryRaw<Array<{ last_seen: Date | null }>>`
|
||||
SELECT max(COALESCE(e."taken_at", e."scheduled_for")) AS "last_seen"
|
||||
FROM "medication_intake_events" e
|
||||
WHERE e."user_id" = ${userId}
|
||||
AND e."external_id" IS NOT NULL
|
||||
AND e."deleted_at" IS NULL
|
||||
`;
|
||||
const lastSeen = rows[0]?.last_seen;
|
||||
if (!lastSeen) return null;
|
||||
return {
|
||||
type: MEDICATION_DOSE_FRESHNESS_TYPE,
|
||||
lastSeenAt: new Date(lastSeen).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute per-`(integration, type)` last-value timestamps for the sync
|
||||
* integrations, keyed by `IntegrationKey`. Two grouped queries (measurements +
|
||||
|
||||
@@ -271,14 +271,25 @@ export const SPARSE_METRIC_TYPES: ReadonlySet<string> = new Set([
|
||||
"AUDIO_EXPOSURE_EVENT",
|
||||
// People train irregularly; a quiet training week is not a broken pipe.
|
||||
"WORKOUTS",
|
||||
// As-needed and paused medications leave weeks without a dose by design.
|
||||
"MEDICATION_DOSE",
|
||||
]);
|
||||
|
||||
/** The pseudo-type carrying a source's newest workout, next to its metrics. */
|
||||
export const WORKOUT_FRESHNESS_TYPE = "WORKOUTS";
|
||||
|
||||
/**
|
||||
* The pseudo-type carrying Apple Health medication doses on the Apple Health
|
||||
* freshness list (v1.42.4), and their key in the per-type arrival ledger.
|
||||
*/
|
||||
export const MEDICATION_DOSE_FRESHNESS_TYPE = "MEDICATION_DOSE";
|
||||
|
||||
/** Newest recorded reading for one `(source, type)` pair, as queried. */
|
||||
export interface MetricFreshnessSample {
|
||||
/** The `MeasurementType` (e.g. "RESPIRATORY_RATE"), or `WORKOUTS`. */
|
||||
/**
|
||||
* The `MeasurementType` (e.g. "RESPIRATORY_RATE"), `WORKOUTS`, or
|
||||
* `MEDICATION_DOSE` on the Apple Health list.
|
||||
*/
|
||||
type: string;
|
||||
/** ISO timestamp of the newest (non-deleted) reading for this metric. */
|
||||
lastSeenAt: string;
|
||||
|
||||
@@ -203,7 +203,7 @@ const metricFreshnessEntry = z
|
||||
type: z
|
||||
.string()
|
||||
.describe(
|
||||
"The measurement type (e.g. `RESPIRATORY_RATE`), or `WORKOUTS` for the workout leg.",
|
||||
"The measurement type (e.g. `RESPIRATORY_RATE`), `WORKOUTS` for the workout leg, or (Apple Health only, since v1.42.4) `MEDICATION_DOSE` for medication doses imported from Apple Health. Clients should show or skip a type they do not know rather than fail.",
|
||||
),
|
||||
lastSeenAt: z.iso
|
||||
.datetime({ offset: true })
|
||||
@@ -245,11 +245,18 @@ const appleHealthMetricFreshnessEntry = metricFreshnessEntry
|
||||
.describe(
|
||||
"When a live sync batch last brought a new or updated sample of this type, as opposed to duplicates only. Null when none has. Same omission rules as `lastReceivedAt`.",
|
||||
),
|
||||
lastBackgroundAt: z.iso
|
||||
.datetime({ offset: true })
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe(
|
||||
'When a batch with `syncTrigger: "background"` last carried this type (since v1.42.4). A later `foreground`, `push` or `manual` batch moves `lastReceivedAt` and `lastTrigger` but leaves this standing, so opening the app does not hide an earlier background arrival. Null when the type has never arrived in the background as far as the server recorded: before v1.42.4 only the latest arrival was kept, so a type whose latest arrival then was a background one starts with that date and every other type starts null. Same omission rules as `lastReceivedAt`.',
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
id: "AppleHealthMetricFreshnessEntry",
|
||||
description:
|
||||
"Per-type Apple Health freshness: the newest sample's timestamp and staleness flag, plus the per-type arrival facts recorded by `POST /api/measurements/batch` (v1.42).",
|
||||
'Per-type Apple Health freshness: the newest sample\'s timestamp and staleness flag, plus the per-type arrival facts recorded by `POST /api/measurements/batch` (v1.42). Since v1.42.4 the list can also hold a `MEDICATION_DOSE` entry: `lastSeenAt` is the newest live dose imported from Apple Health (its `takenAt`, or its slot for a skipped dose), and the arrival facts come from `POST /api/medications/intake/bulk` entries sent with `source: "APPLE_HEALTH"`. A dose recorded in the app does not move them. Doses are never flagged `stale`.',
|
||||
});
|
||||
|
||||
// Issue #778 — the two backfill-progress figures the server genuinely holds.
|
||||
|
||||
@@ -130,7 +130,7 @@ const batchPayloadSchema = z
|
||||
.enum(["foreground", "background", "push", "manual"])
|
||||
.optional()
|
||||
.describe(
|
||||
'Diagnostic-only. Names what triggered this sync (foreground app open, background refresh, a push wake, or a user-initiated `manual` "Sync all" run, since v1.42). Recorded on the ingest wide event and kept on the account as `lastSyncTrigger` — plus `lastBackgroundSyncAt` for a `background` or `push` trigger — both readable from `GET /api/integrations/healthkit`. Since v1.42 it is also recorded per measurement type the batch carried (Apple Health rows only), served as `lastReceivedAt` / `lastTrigger` / `lastNewSampleAt` on each `metricFreshness` entry of that read. It does not affect dedup, attribution, or how any sample is stored. Optional and backward-compatible: pre-#66 clients omit it.',
|
||||
'Diagnostic-only. Names what triggered this sync (foreground app open, background refresh, a push wake, or a user-initiated `manual` "Sync all" run, since v1.42). Recorded on the ingest wide event and kept on the account as `lastSyncTrigger` — plus `lastBackgroundSyncAt` for a `background` or `push` trigger — both readable from `GET /api/integrations/healthkit`. Since v1.42 it is also recorded per measurement type the batch carried (Apple Health rows only), served as `lastReceivedAt` / `lastTrigger` / `lastNewSampleAt` on each `metricFreshness` entry of that read, plus `lastBackgroundAt` (since v1.42.4) for a `background` trigger. It does not affect dedup, attribution, or how any sample is stored. Optional and backward-compatible: pre-#66 clients omit it.',
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
|
||||
@@ -198,6 +198,12 @@ const bulkIntakeEntry = z
|
||||
const bulkIntakePayload = z
|
||||
.object({
|
||||
entries: z.array(bulkIntakeEntry).min(1).max(500),
|
||||
syncTrigger: z
|
||||
.enum(["foreground", "background", "push", "manual"])
|
||||
.optional()
|
||||
.describe(
|
||||
'Diagnostic-only, the same field `POST /api/measurements/batch` takes (since v1.42.4): what woke the client for this sync. When the batch carries entries with `source: "APPLE_HEALTH"` that land (`inserted`, `updated` or `duplicate`), it is recorded on the `MEDICATION_DOSE` entry of `metricFreshness` in `GET /api/integrations/healthkit` as `lastTrigger`, and a `background` value also dates its `lastBackgroundAt`. It changes no dose row. Optional; omitted records a null trigger.',
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
id: "BulkMedicationIntakeRequest",
|
||||
|
||||
Reference in new issue
Block a user