mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +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
+37
-5
@@ -25592,8 +25592,9 @@ components:
|
||||
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.'
|
||||
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.'
|
||||
type: string
|
||||
enum:
|
||||
- foreground
|
||||
@@ -26181,6 +26182,18 @@ components:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/BulkMedicationIntakeEntry"
|
||||
syncTrigger:
|
||||
description: '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.'
|
||||
type: string
|
||||
enum:
|
||||
- foreground
|
||||
- background
|
||||
- push
|
||||
- manual
|
||||
required:
|
||||
- entries
|
||||
description: iOS SyncMode bulk intake backfill. 1–500 entries per call; idempotent via `Idempotency-Key` and per-entry
|
||||
@@ -73668,7 +73681,9 @@ components:
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
description: The measurement type (e.g. `RESPIRATORY_RATE`), or `WORKOUTS` for the workout leg.
|
||||
description: 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:
|
||||
type: string
|
||||
format: date-time
|
||||
@@ -73709,13 +73724,28 @@ components:
|
||||
format: date-time
|
||||
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
|
||||
- type: "null"
|
||||
lastBackgroundAt:
|
||||
description: '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`.'
|
||||
anyOf:
|
||||
- type: string
|
||||
format: date-time
|
||||
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
|
||||
- type: "null"
|
||||
required:
|
||||
- type
|
||||
- lastSeenAt
|
||||
- stale
|
||||
additionalProperties: false
|
||||
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)."
|
||||
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`."
|
||||
AppleHealthSyncProgress:
|
||||
type: object
|
||||
properties:
|
||||
@@ -75840,7 +75870,9 @@ components:
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
description: The measurement type (e.g. `RESPIRATORY_RATE`), or `WORKOUTS` for the workout leg.
|
||||
description: 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:
|
||||
type: string
|
||||
format: date-time
|
||||
|
||||
+3
-1
@@ -6555,7 +6555,8 @@
|
||||
"title": "Empfangene Daten",
|
||||
"summary": "{count} Datentypen, neuestes {relative}",
|
||||
"quiet": "{count} still",
|
||||
"workouts": "Workouts"
|
||||
"workouts": "Workouts",
|
||||
"medicationDoses": "Medikamenteneinnahmen"
|
||||
},
|
||||
"integrationFallback": {
|
||||
"description": "Für dieses Konto eingerichtet, hat aber keine eigene Einstellungskarte."
|
||||
@@ -7131,6 +7132,7 @@
|
||||
"title": "Nach Datentyp",
|
||||
"lastReceived": "Zuletzt empfangen {relative}",
|
||||
"lastNewValue": "Letzter neuer Wert {relative}",
|
||||
"lastBackground": "Zuletzt im Hintergrund {relative}",
|
||||
"never": "Noch nicht empfangen"
|
||||
}
|
||||
},
|
||||
|
||||
+3
-1
@@ -6555,7 +6555,8 @@
|
||||
"title": "Data received",
|
||||
"summary": "{count} data types, newest {relative}",
|
||||
"quiet": "{count} quiet",
|
||||
"workouts": "Workouts"
|
||||
"workouts": "Workouts",
|
||||
"medicationDoses": "Medication doses"
|
||||
},
|
||||
"integrationFallback": {
|
||||
"description": "Configured on this account without a settings card of its own."
|
||||
@@ -7131,6 +7132,7 @@
|
||||
"title": "By data type",
|
||||
"lastReceived": "Last received {relative}",
|
||||
"lastNewValue": "Last new value {relative}",
|
||||
"lastBackground": "Last in background {relative}",
|
||||
"never": "Not received yet"
|
||||
}
|
||||
},
|
||||
|
||||
+3
-1
@@ -6555,7 +6555,8 @@
|
||||
"title": "Datos recibidos",
|
||||
"summary": "{count} tipos de datos, más reciente {relative}",
|
||||
"quiet": "{count} en silencio",
|
||||
"workouts": "Entrenamientos"
|
||||
"workouts": "Entrenamientos",
|
||||
"medicationDoses": "Tomas de medicación"
|
||||
},
|
||||
"integrationFallback": {
|
||||
"description": "Configurado en esta cuenta pero sin tarjeta de ajustes propia."
|
||||
@@ -7131,6 +7132,7 @@
|
||||
"title": "Por tipo de dato",
|
||||
"lastReceived": "Última recepción {relative}",
|
||||
"lastNewValue": "Último valor nuevo {relative}",
|
||||
"lastBackground": "Última vez en segundo plano {relative}",
|
||||
"never": "Aún no recibido"
|
||||
}
|
||||
},
|
||||
|
||||
+3
-1
@@ -6555,7 +6555,8 @@
|
||||
"title": "Données reçues",
|
||||
"summary": "{count} types de données, plus récent {relative}",
|
||||
"quiet": "{count} silencieux",
|
||||
"workouts": "Séances"
|
||||
"workouts": "Séances",
|
||||
"medicationDoses": "Prises de médicaments"
|
||||
},
|
||||
"integrationFallback": {
|
||||
"description": "Configuré sur ce compte sans carte de réglages dédiée."
|
||||
@@ -7131,6 +7132,7 @@
|
||||
"title": "Par type de donnée",
|
||||
"lastReceived": "Dernière réception {relative}",
|
||||
"lastNewValue": "Dernière nouvelle valeur {relative}",
|
||||
"lastBackground": "Dernière fois en arrière-plan {relative}",
|
||||
"never": "Pas encore reçu"
|
||||
}
|
||||
},
|
||||
|
||||
+3
-1
@@ -6555,7 +6555,8 @@
|
||||
"title": "Dati ricevuti",
|
||||
"summary": "{count} tipi di dati, più recente {relative}",
|
||||
"quiet": "{count} in silenzio",
|
||||
"workouts": "Allenamenti"
|
||||
"workouts": "Allenamenti",
|
||||
"medicationDoses": "Assunzioni di farmaci"
|
||||
},
|
||||
"integrationFallback": {
|
||||
"description": "Configurato su questo account ma senza una scheda di impostazioni propria."
|
||||
@@ -7131,6 +7132,7 @@
|
||||
"title": "Per tipo di dato",
|
||||
"lastReceived": "Ultima ricezione {relative}",
|
||||
"lastNewValue": "Ultimo nuovo valore {relative}",
|
||||
"lastBackground": "Ultima volta in background {relative}",
|
||||
"never": "Non ancora ricevuto"
|
||||
}
|
||||
},
|
||||
|
||||
+3
-1
@@ -6555,7 +6555,8 @@
|
||||
"title": "받은 데이터",
|
||||
"summary": "데이터 유형 {count}개, 최신 {relative}",
|
||||
"quiet": "{count}개 잠잠",
|
||||
"workouts": "운동"
|
||||
"workouts": "운동",
|
||||
"medicationDoses": "약 복용"
|
||||
},
|
||||
"integrationFallback": {
|
||||
"description": "이 계정에 설정돼 있지만 전용 설정 카드는 없어요."
|
||||
@@ -7131,6 +7132,7 @@
|
||||
"title": "데이터 유형별",
|
||||
"lastReceived": "마지막 수신 {relative}",
|
||||
"lastNewValue": "마지막 새 값 {relative}",
|
||||
"lastBackground": "마지막 백그라운드 수신 {relative}",
|
||||
"never": "아직 수신되지 않음"
|
||||
}
|
||||
},
|
||||
|
||||
+3
-1
@@ -6555,7 +6555,8 @@
|
||||
"title": "Odebrane dane",
|
||||
"summary": "{count} typów danych, najnowsze {relative}",
|
||||
"quiet": "{count} bez danych",
|
||||
"workouts": "Treningi"
|
||||
"workouts": "Treningi",
|
||||
"medicationDoses": "Dawki leków"
|
||||
},
|
||||
"integrationFallback": {
|
||||
"description": "Skonfigurowane na tym koncie, ale bez własnej karty ustawień."
|
||||
@@ -7131,6 +7132,7 @@
|
||||
"title": "Według typu danych",
|
||||
"lastReceived": "Ostatnio odebrano {relative}",
|
||||
"lastNewValue": "Ostatnia nowa wartość {relative}",
|
||||
"lastBackground": "Ostatnio w tle {relative}",
|
||||
"never": "Jeszcze nie odebrano"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
-- Per-type HealthKit arrival ledger: room for non-measurement types, and the
|
||||
-- last background arrival per type.
|
||||
--
|
||||
-- `type` moves from the `measurement_type` enum to text so the ledger can
|
||||
-- also record Apple Health medication doses (`MEDICATION_DOSE`), which are
|
||||
-- not a measurement type. Every stored enum value converts to its own label
|
||||
-- through the enum's text output, so no row changes meaning. The table holds
|
||||
-- at most one row per (user, delivered type), a few dozen per account; the
|
||||
-- rewrite and the primary-key rebuild it implies are cheap at any size this
|
||||
-- table reaches, and the key keeps its name and columns.
|
||||
ALTER TABLE "healthkit_type_syncs"
|
||||
ALTER COLUMN "type" SET DATA TYPE TEXT USING "type"::text;
|
||||
|
||||
-- When a batch with `syncTrigger = background` last carried the type. A
|
||||
-- later foreground or manual arrival leaves it standing.
|
||||
ALTER TABLE "healthkit_type_syncs" ADD COLUMN "last_background_at" TIMESTAMP(3);
|
||||
|
||||
-- A row whose latest arrival was already a background one says exactly that,
|
||||
-- so it starts with that date rather than null. Rows whose latest arrival
|
||||
-- came another way cannot know an earlier background date and stay null.
|
||||
UPDATE "healthkit_type_syncs"
|
||||
SET "last_background_at" = "last_received_at"
|
||||
WHERE "last_trigger" = 'background';
|
||||
+14
-5
@@ -4005,16 +4005,25 @@ model ManagedProfileHandover {
|
||||
/// batch route; read by the Apple Health card's per-type freshness list.
|
||||
/// Diagnostic state: rebuilt by the next sync, so it is not backed up.
|
||||
model HealthKitTypeSync {
|
||||
userId String @map("user_id")
|
||||
type MeasurementType
|
||||
userId String @map("user_id")
|
||||
/// A `MeasurementType` name for rows the measurement batch writes, or
|
||||
/// `MEDICATION_DOSE` for Apple Health doses the intake bulk route
|
||||
/// receives. Text, not the enum, since v1.42.4 (migration 0401) because a
|
||||
/// dose is not a measurement type.
|
||||
type String
|
||||
/// Last batch that carried this type.
|
||||
lastReceivedAt DateTime @map("last_received_at")
|
||||
lastReceivedAt DateTime @map("last_received_at")
|
||||
/// `syncTrigger` of that batch
|
||||
/// (`foreground` / `background` / `push` / `manual`), NULL when absent.
|
||||
lastTrigger String? @map("last_trigger")
|
||||
lastTrigger String? @map("last_trigger")
|
||||
/// Last batch that inserted or updated a sample of
|
||||
/// this type (as opposed to sending only duplicates).
|
||||
lastNewSampleAt DateTime? @map("last_new_sample_at")
|
||||
lastNewSampleAt DateTime? @map("last_new_sample_at")
|
||||
/// Last batch with `syncTrigger = background` that carried this type. A
|
||||
/// later foreground, push or manual arrival leaves it standing; NULL when
|
||||
/// none has (or only before v1.42.4, except a row whose latest arrival was
|
||||
/// a background one, which migration 0401 seeded).
|
||||
lastBackgroundAt DateTime? @map("last_background_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -133,7 +133,8 @@ function DeliveryDiagnostic({
|
||||
* under which trigger, and when one last brought a new value. The newest
|
||||
* sample's time on the right of the row says when the reading was TAKEN; this
|
||||
* says when the server RECEIVED it, which is what tells a type that only
|
||||
* arrives on "Sync all" from one the phone delivers by itself (#1173). Nothing
|
||||
* arrives on "Sync all" from one the phone delivers by itself (#1173), plus
|
||||
* the last background arrival when a later one came another way. Nothing
|
||||
* renders when the server sent no arrival facts (the workouts row, or a failed
|
||||
* ledger read), so absence is never painted as "not received".
|
||||
*/
|
||||
@@ -176,6 +177,18 @@ export function ArrivalDetail({
|
||||
}),
|
||||
);
|
||||
}
|
||||
// The last background arrival, when a later one came another way: opening
|
||||
// the app to look would otherwise hide that the phone delivers by itself.
|
||||
if (
|
||||
entry.lastBackgroundAt &&
|
||||
entry.lastBackgroundAt !== entry.lastReceivedAt
|
||||
) {
|
||||
parts.push(
|
||||
t("settings.appleHealth.freshness.lastBackground", {
|
||||
relative: relative(entry.lastBackgroundAt),
|
||||
}),
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span
|
||||
data-testid="apple-health-arrival"
|
||||
|
||||
@@ -30,6 +30,8 @@ import { cn } from "@/lib/utils";
|
||||
|
||||
/** The pseudo-type carrying a source's newest workout. */
|
||||
const WORKOUT_TYPE = "WORKOUTS";
|
||||
/** The pseudo-type carrying Apple Health medication doses. */
|
||||
const MEDICATION_DOSE_TYPE = "MEDICATION_DOSE";
|
||||
|
||||
/**
|
||||
* Turn an unmapped enum name into something readable. `MEASUREMENT_TYPE_LABEL_KEYS`
|
||||
@@ -68,6 +70,8 @@ export function MetricFreshnessList<
|
||||
const label = (type: string): string => {
|
||||
if (type === WORKOUT_TYPE)
|
||||
return t("settings.integrationFreshness.workouts");
|
||||
if (type === MEDICATION_DOSE_TYPE)
|
||||
return t("settings.integrationFreshness.medicationDoses");
|
||||
const key = MEASUREMENT_TYPE_LABEL_KEYS[type];
|
||||
return key ? t(key) : humanise(type);
|
||||
};
|
||||
|
||||
@@ -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