Files
HealthLog/src/lib/integrations/connection-health-load.ts
T
Marc-André Bombeck e2ba86b12f feat(garmin): sync Garmin Connect through the Personal API
Garmin Connect now offers a Personal API: a person creates a key for
their own account and any client can buy short-lived tokens with it.
HealthLog takes that key in a new Garmin card, checks it with Garmin
before storing it sealed on the user row, and polls every hour.

- Transport: key exchange with a per-process token cache keyed on the
  key, one re-exchange on a refused token and then reauth_required, the
  429 Retry-After carried to the caller, the China host tried when the
  global one does not know the key, answers read with a byte cap, all
  egress through safeFetch to the two fixed hosts.
- Sync: daily summaries, sleep summaries, HRV, weigh-ins and the
  activity search, chunked to each resource's documented range, read one
  after another and stopped at the first rate limit; today's menstrual
  cycle summary adds logged period days the person has not logged.
- Backfill: a resumable walk through the shared admission lane that
  learns where the history starts from the daily summaries and the
  activities, waits out Garmin's rate limit and stamps its marker.
- Types: GARMIN source; BODY_BATTERY and STRESS_LEVEL for Garmin's own
  day scores, kept apart from the computed STRESS_SCORE; TRAINING_READINESS
  and ACUTE_TRAINING_LOAD for figures a bridge pushes through the ingest
  token. All four chart on the recovery page and sit on the ladders,
  units, ranges, labels and report groups like the device-native scores.
- Key expiry: learned from a token capped short of a day, else estimated
  from the validity picked at connect; the card warns a week ahead and
  asks for a new key once Garmin refuses the old one.
- Ledger, health, freshness, watchdog, backup classes, wipe plan,
  encrypted-column registry, rotation script, OpenAPI and all ten
  locales carry the new integration. The old "no Garmin connector" note
  is gone.
2026-11-04 10:14:10 +01:00

471 lines
15 KiB
TypeScript

/**
* Gather what `resolveConnectionHealth` needs, for one account, in one place.
*
* Every surface that publishes a connection's health (the consolidated
* integrations envelope, the per-provider status routes, the Apple Health
* config read, the watchdog) calls this loader, so the four states cannot
* drift into one opinion per route. It reads:
*
* - the sync ledger rows (state, streak, failing legs, recent calls),
* - which connections exist and the provider-side extras that bear on
* health (Withings plan + activity scope, Oura scope gaps, Google Health
* `needsReauth`),
* - the watchdog's silence flags,
* - the newest data per connected source (one index probe each).
*
* Read-only. A connection that does not exist resolves to null.
*/
import { BACKFILL_LEDGER_PREFIX } from "./healthkit-type-sync";
import { prisma } from "@/lib/db";
import type { MeasurementSource } from "@/generated/prisma/client";
import type { IntegrationKey } from "./status";
import {
INTEGRATION_CADENCE,
PUSH_CADENCE,
resolveSyncVerdict,
type SyncHealth,
} from "./sync-verdict";
import {
CONNECTION_KEYS,
RECENT_CALLS_LIMIT,
readRecentCalls,
resolveConnectionHealth,
type ConnectionHealth,
type ConnectionKey,
type RecentCall,
} from "./connection-health";
import { SOURCE_PROFILES } from "./watchdog-classify";
import {
INTEGRATION_MEASUREMENT_SOURCE,
INTEGRATION_WORKOUT_SOURCE,
} from "./metric-freshness";
import { parseWithingsApiPlanState } from "@/lib/withings/api-plan";
import { withingsPlanUnavailableFeatures } from "@/lib/withings/plan-features";
import { hasActivityScope } from "@/lib/withings/oauth";
import { readOuraScopeDenied } from "@/lib/oura/credentials";
import { ouraScopeReconnectList } from "@/lib/oura/scope";
import { localDayWindow } from "@/lib/tz/local-day";
import { resolveUserTimezone } from "@/lib/tz/resolver";
import { isFitbitRetired } from "@/lib/fitbit/retirement";
export type ConnectionHealthMap = Record<
ConnectionKey,
ConnectionHealth | null
>;
/** Everything the loader read for one connection, beside the resolved health. */
export interface ConnectionFacts {
key: ConnectionKey;
connected: boolean;
syncHealth: SyncHealth;
lastDataAt: Date | null;
/** The sync ledger holds an unresolved operator page for this streak. */
ledgerAlerted: boolean;
/** Health resolved WITHOUT the watchdog's silence flag (the watchdog's own input). */
healthWithoutSilence: ConnectionHealth | null;
/** Health as published (silence flag folded in). */
health: ConnectionHealth | null;
silentSince: Date | null;
}
interface LedgerRow {
integration: string;
state: string;
lastSuccessAt: Date | null;
lastAttemptAt: Date | null;
failingSinceAt: Date | null;
failingLegs: unknown;
recentCalls: unknown;
alertedAt: Date | null;
}
/** Newest measurement of one source: one probe on `(userId, source, measuredAt DESC)`. */
async function newestMeasurementAt(
userId: string,
source: MeasurementSource,
): Promise<Date | null> {
const row = await prisma.measurement.findFirst({
where: { userId, source, deletedAt: null },
orderBy: { measuredAt: "desc" },
select: { measuredAt: true },
});
return row?.measuredAt ?? null;
}
async function newestWorkoutAt(
userId: string,
source: MeasurementSource,
): Promise<Date | null> {
const row = await prisma.workout.findFirst({
where: { userId, source },
orderBy: { startedAt: "desc" },
select: { startedAt: true },
});
return row?.startedAt ?? null;
}
/**
* Dawarich stores days, not readings: its newest data is the start of the
* newest stored day, in the account's zone.
*/
async function newestDawarichDayAt(userId: string): Promise<Date | null> {
const row = await prisma.dawarichDay.findFirst({
where: { userId },
orderBy: { date: "desc" },
select: { date: true },
});
if (!row) return null;
return localDayWindow(row.date, await resolveUserTimezone(userId)).dayStart;
}
function newestOf(...values: Array<Date | null | undefined>): Date | null {
let max: Date | null = null;
for (const v of values) {
if (v && (max === null || v.getTime() > max.getTime())) max = v;
}
return max;
}
function iso(value: Date | null | undefined): string | null {
return value ? value.toISOString() : null;
}
function readLegs(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return value.filter(
(v): v is string =>
typeof v === "string" && /^[A-Za-z0-9_.:-]{1,48}$/.test(v),
);
}
/**
* Load the facts and resolved health for every connection key of one account.
* `keys` narrows the work for a single-provider route.
*/
export async function loadConnectionFacts(
userId: string,
keys: readonly ConnectionKey[] = CONNECTION_KEYS,
now: Date = new Date(),
): Promise<Map<ConnectionKey, ConnectionFacts>> {
const wanted = new Set(keys);
const [
ledgerRows,
watchRows,
dbUser,
withingsConn,
whoopConn,
fitbitConn,
googleHealthConn,
healthKitArrivals,
] = await Promise.all([
prisma.integrationStatus.findMany({
where: { userId },
select: {
integration: true,
state: true,
lastSuccessAt: true,
lastAttemptAt: true,
failingSinceAt: true,
failingLegs: true,
recentCalls: true,
alertedAt: true,
},
}),
prisma.integrationWatch.findMany({
where: { userId },
select: { integration: true, silentSince: true },
}),
prisma.user.findUnique({
where: { id: userId },
select: {
polarAccessTokenEncrypted: true,
ouraAccessTokenEncrypted: true,
ouraScopeDenied: true,
stravaAccessTokenEncrypted: true,
nightscoutUrlEncrypted: true,
dawarichUrlEncrypted: true,
garminApiKeyEncrypted: true,
healthKitLastSyncedAt: true,
},
}),
wanted.has("withings")
? prisma.withingsConnection.findUnique({
where: { userId },
select: { lastSyncedAt: true, scope: true, apiPlanState: true },
})
: null,
wanted.has("whoop")
? prisma.whoopConnection.findUnique({
where: { userId },
select: { lastSyncedAt: true, whoopUserId: true },
})
: null,
wanted.has("fitbit")
? prisma.fitbitConnection.findUnique({
where: { userId },
select: { lastSyncedAt: true },
})
: null,
wanted.has("google-health")
? prisma.googleHealthConnection.findUnique({
where: { userId },
select: { lastSyncedAt: true, needsReauth: true },
})
: null,
wanted.has("apple-health")
? prisma.healthKitTypeSync.findMany({
// A backfill's own ledger row is a catch-up, not a live arrival,
// and must not make the connection look alive (ios #77).
where: {
userId,
NOT: { type: { startsWith: BACKFILL_LEDGER_PREFIX } },
},
select: { type: true, lastReceivedAt: true, lastNewSampleAt: true },
orderBy: { lastReceivedAt: "desc" },
})
: [],
]);
const ledger = new Map<string, LedgerRow>(
ledgerRows.map((row) => [row.integration, row]),
);
const silence = new Map<string, Date | null>(
watchRows.map((row) => [row.integration, row.silentSince]),
);
const connected: Record<ConnectionKey, boolean> = {
withings: !!withingsConn,
whoop: !!whoopConn?.whoopUserId,
// Once Google has turned the Fitbit Web API off the row stays (it carries
// the imported history) but nothing will sync through it: no health to
// judge and nothing for the watchdog to call silent.
fitbit: !!fitbitConn && !isFitbitRetired(now),
"google-health": !!googleHealthConn,
polar: !!dbUser?.polarAccessTokenEncrypted,
oura: !!dbUser?.ouraAccessTokenEncrypted,
strava: !!dbUser?.stravaAccessTokenEncrypted,
nightscout: !!dbUser?.nightscoutUrlEncrypted,
dawarich: !!dbUser?.dawarichUrlEncrypted,
garmin: !!dbUser?.garminApiKeyEncrypted,
// Apple Health has nothing to connect; it is "connected" once the phone
// has delivered anything (a live batch, or rows from the export import).
"apple-health": false,
};
const legacyLastSyncedAt: Partial<Record<ConnectionKey, Date | null>> = {
withings: withingsConn?.lastSyncedAt ?? null,
whoop: whoopConn?.whoopUserId ? (whoopConn.lastSyncedAt ?? null) : null,
fitbit: fitbitConn?.lastSyncedAt ?? null,
"google-health": googleHealthConn?.lastSyncedAt ?? null,
};
// Newest data per wanted, connected source. Apple Health is probed even
// before it counts as connected, because delivered data is what connects it.
const lastData = new Map<ConnectionKey, Date | null>();
await Promise.all(
[...wanted].map(async (key) => {
if (key === "apple-health") {
const measured = await newestMeasurementAt(userId, "APPLE_HEALTH");
lastData.set(key, measured);
return;
}
if (!connected[key]) return;
if (key === "dawarich") {
lastData.set(key, await newestDawarichDayAt(userId));
return;
}
const mSource = INTEGRATION_MEASUREMENT_SOURCE[key as IntegrationKey];
const wSource = INTEGRATION_WORKOUT_SOURCE[key as IntegrationKey];
const [m, w] = await Promise.all([
mSource ? newestMeasurementAt(userId, mSource) : null,
wSource ? newestWorkoutAt(userId, wSource) : null,
]);
lastData.set(key, newestOf(m, w));
}),
);
const out = new Map<ConnectionKey, ConnectionFacts>();
const nowMs = now.getTime();
for (const key of keys) {
if (key === "apple-health") {
out.set(
key,
appleHealthFacts({
lastSyncedAt: dbUser?.healthKitLastSyncedAt ?? null,
arrivals: healthKitArrivals,
newestMeasuredAt: lastData.get("apple-health") ?? null,
silentSince: silence.get(key) ?? null,
now,
}),
);
continue;
}
const row = ledger.get(key);
const isConnected = connected[key];
const syncHealth = resolveSyncVerdict({
connected: isConnected,
configured: isConnected,
state: row?.state ?? "unknown",
lastSuccessAt: row?.lastSuccessAt ?? null,
lastAttemptAt: row?.lastAttemptAt ?? null,
failingSinceAt: row?.failingSinceAt ?? null,
legacyLastSyncedAt: legacyLastSyncedAt[key] ?? null,
cadence: INTEGRATION_CADENCE[key],
now: nowMs,
});
let planUnavailable: string[] = [];
let permissionUnavailable: string[] = [];
if (key === "withings" && withingsConn) {
planUnavailable = withingsPlanUnavailableFeatures(
parseWithingsApiPlanState(withingsConn.apiPlanState),
);
// A connection whose grant predates the activity scope cannot read
// activity or sleep; one reconnect asks for it. A null scope is the
// same legacy case `hasActivityScope` already treats as missing.
if (!hasActivityScope(withingsConn.scope ?? null)) {
permissionUnavailable = ["activity", "sleep"];
}
}
if (key === "oura" && isConnected) {
permissionUnavailable = ouraScopeReconnectList(
readOuraScopeDenied(dbUser?.ouraScopeDenied),
);
}
const lastDataAt = lastData.get(key) ?? null;
const silentSince = silence.get(key) ?? null;
const common = {
syncHealth,
connected: isConnected,
ledgerState: row?.state ?? null,
lastSuccessAt: iso(row?.lastSuccessAt ?? legacyLastSyncedAt[key] ?? null),
lastAttemptAt: iso(row?.lastAttemptAt),
failingSince: iso(row?.failingSinceAt),
failingLegs: readLegs(row?.failingLegs),
recentCalls: readRecentCalls(row?.recentCalls),
lastDataAt: iso(lastDataAt),
needsReauth: key === "google-health" && !!googleHealthConn?.needsReauth,
planUnavailable,
permissionUnavailable,
silenceWindowMs: SOURCE_PROFILES[key].silenceWindowMs,
now: nowMs,
};
out.set(key, {
key,
connected: isConnected,
syncHealth,
lastDataAt,
ledgerAlerted: row?.alertedAt != null,
healthWithoutSilence: resolveConnectionHealth({
...common,
silentSince: null,
}),
health: resolveConnectionHealth({
...common,
silentSince: iso(silentSince),
}),
silentSince,
});
}
return out;
}
function appleHealthFacts(input: {
lastSyncedAt: Date | null;
arrivals: Array<{
type: string;
lastReceivedAt: Date;
lastNewSampleAt: Date | null;
}>;
newestMeasuredAt: Date | null;
silentSince: Date | null;
now: Date;
}): ConnectionFacts {
const newestReceived = newestOf(
...input.arrivals.map((a) => a.lastReceivedAt),
);
const newestNewSample = newestOf(
...input.arrivals.map((a) => a.lastNewSampleAt),
);
// "Last import with data": a sync that brought a new sample. Rows from the
// export import carry no arrival record, so their newest sample stands in.
const lastDataAt = newestNewSample ?? input.newestMeasuredAt;
const connected =
input.lastSyncedAt !== null ||
newestReceived !== null ||
input.newestMeasuredAt !== null;
// Same rule the config read uses (`newestAppleHealthDataAt`): anything that
// arrived counts as liveness for the push cadence.
const syncHealth = resolveSyncVerdict({
configured: connected,
connected,
lastDataAt: newestOf(
input.lastSyncedAt,
newestReceived,
input.newestMeasuredAt,
),
legacyLastSyncedAt: input.lastSyncedAt,
cadence: PUSH_CADENCE,
now: input.now,
});
const recentCalls: RecentCall[] = input.arrivals
.slice(0, RECENT_CALLS_LIMIT)
.map((a) => ({
endpoint: `batch:${a.type}`,
status: "ok",
at: a.lastReceivedAt.toISOString(),
errorCode: null,
}));
const common = {
syncHealth,
connected,
ledgerState: null,
lastSuccessAt: iso(input.lastSyncedAt ?? newestReceived),
lastAttemptAt: iso(newestReceived),
failingSince: null,
failingLegs: [],
recentCalls,
lastDataAt: iso(lastDataAt),
silenceWindowMs: SOURCE_PROFILES["apple-health"].silenceWindowMs,
now: input.now.getTime(),
};
return {
key: "apple-health",
connected,
syncHealth,
lastDataAt,
ledgerAlerted: false,
healthWithoutSilence: resolveConnectionHealth({
...common,
silentSince: null,
}),
health: resolveConnectionHealth({
...common,
silentSince: iso(input.silentSince),
}),
silentSince: input.silentSince,
};
}
/** The published health per connection key, for routes. */
export async function loadConnectionHealth(
userId: string,
keys: readonly ConnectionKey[] = CONNECTION_KEYS,
): Promise<ConnectionHealthMap> {
const facts = await loadConnectionFacts(userId, keys);
const out = {} as ConnectionHealthMap;
for (const key of CONNECTION_KEYS) out[key] = facts.get(key)?.health ?? null;
return out;
}
/** One connection's published health, for a single-provider route. */
export async function loadOneConnectionHealth(
userId: string,
key: ConnectionKey,
): Promise<ConnectionHealth | null> {
const facts = await loadConnectionFacts(userId, [key]);
return facts.get(key)?.health ?? null;
}