mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
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.
This commit is contained in:
1 parent
67909f2d9d
commit
e2ba86b12f
119 files changed
+5798
-400
No files matched your search
@@ -340,7 +340,7 @@ describe("/api/integrations/status — the freshness read failed", () => {
|
||||
expect(envelope.metricFreshnessDegraded).toBe(true);
|
||||
// Still fail-soft: the rest of the envelope survives, all eight entries
|
||||
// present, because a freshness hiccup is never worth the settings surface.
|
||||
expect(envelope.integrations).toHaveLength(9);
|
||||
expect(envelope.integrations).toHaveLength(10);
|
||||
for (const entry of envelope.integrations) {
|
||||
expect(entry.metricFreshness).toEqual([]);
|
||||
}
|
||||
@@ -472,6 +472,7 @@ describe("/api/integrations/status — the unified connection health", () => {
|
||||
strava: null,
|
||||
nightscout: null,
|
||||
dawarich: null,
|
||||
garmin: null,
|
||||
"apple-health": null,
|
||||
} as never);
|
||||
userFind.mockResolvedValue({ ouraAccessTokenEncrypted: "tok" });
|
||||
|
||||
@@ -15,6 +15,15 @@
|
||||
* callers; the web cards no longer hit them.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { isGarminRegion, type GarminRegion } from "@/lib/garmin/client";
|
||||
import {
|
||||
resolveGarminKeyExpiry,
|
||||
type GarminKeyExpiry,
|
||||
} from "@/lib/garmin/key-expiry";
|
||||
import {
|
||||
garminBackfillShare,
|
||||
readGarminBackfillProgress,
|
||||
} from "@/lib/garmin/backfill-progress";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
@@ -83,6 +92,7 @@ export const GET = apiHandler(async () => {
|
||||
stravaStatus,
|
||||
nightscoutStatus,
|
||||
dawarichStatus,
|
||||
garminStatus,
|
||||
dbUser,
|
||||
withingsConn,
|
||||
whoopConn,
|
||||
@@ -108,6 +118,7 @@ export const GET = apiHandler(async () => {
|
||||
getIntegrationStatus(user.id, "strava"),
|
||||
getIntegrationStatus(user.id, "nightscout"),
|
||||
getIntegrationStatus(user.id, "dawarich"),
|
||||
getIntegrationStatus(user.id, "garmin"),
|
||||
prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: {
|
||||
@@ -135,6 +146,12 @@ export const GET = apiHandler(async () => {
|
||||
nightscoutTokenEncrypted: true,
|
||||
nightscoutAllowPrivateHost: true,
|
||||
dawarichUrlEncrypted: true,
|
||||
// Garmin: the connect marker and the facts its card warns from.
|
||||
garminApiKeyEncrypted: true,
|
||||
garminRegion: true,
|
||||
garminKeyExpiresAt: true,
|
||||
garminBackfillProgress: true,
|
||||
garminBackfillCompletedAt: true,
|
||||
},
|
||||
}),
|
||||
prisma.withingsConnection.findUnique({
|
||||
@@ -194,6 +211,7 @@ export const GET = apiHandler(async () => {
|
||||
"strava",
|
||||
"nightscout",
|
||||
"dawarich",
|
||||
"garmin",
|
||||
]),
|
||||
// See `metricFreshnessDegraded` below.
|
||||
getSourceMetricFreshness(user.id).catch(() => {
|
||||
@@ -319,6 +337,7 @@ export const GET = apiHandler(async () => {
|
||||
const stravaConnected = !!dbUser?.stravaAccessTokenEncrypted;
|
||||
const nightscoutConfigured = !!dbUser?.nightscoutUrlEncrypted;
|
||||
const dawarichConfigured = !!dbUser?.dawarichUrlEncrypted;
|
||||
const garmin = garminExtras(dbUser);
|
||||
|
||||
if (metricFreshnessDegraded) {
|
||||
annotate({ meta: { integrations_metric_freshness_degraded: true } });
|
||||
@@ -512,6 +531,20 @@ export const GET = apiHandler(async () => {
|
||||
configured: dawarichConfigured,
|
||||
}),
|
||||
} satisfies IntegrationViewModel & DawarichExtras,
|
||||
{
|
||||
...publish(garminStatus),
|
||||
connected: garmin.connected,
|
||||
configured: garmin.configured,
|
||||
region: garmin.region,
|
||||
keyExpiry: garmin.keyExpiry,
|
||||
backfillCompleted: garmin.backfillCompleted,
|
||||
backfillShare: garmin.backfillShare,
|
||||
health: health["garmin"],
|
||||
...resolve("garmin", garminStatus, {
|
||||
connected: garmin.connected,
|
||||
configured: garmin.configured,
|
||||
}),
|
||||
} satisfies IntegrationViewModel & GarminExtras,
|
||||
],
|
||||
});
|
||||
});
|
||||
@@ -617,6 +650,59 @@ interface NightscoutExtras {
|
||||
allowPrivateHost: boolean;
|
||||
}
|
||||
|
||||
// Garmin: the connect marker plus the key's expiry, the region its
|
||||
// key was accepted on, and how far the history backfill has got.
|
||||
interface GarminExtras {
|
||||
connected: boolean;
|
||||
configured: boolean;
|
||||
region: GarminRegion | null;
|
||||
keyExpiry: GarminKeyExpiry | null;
|
||||
backfillCompleted: boolean | null;
|
||||
backfillShare: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Garmin entry's own facts. A Personal API key the person pastes once is
|
||||
* the connect marker; the key is never on the wire, only when it expires,
|
||||
* resolved here so every client warns alike.
|
||||
*/
|
||||
function garminExtras(
|
||||
dbUser: {
|
||||
garminApiKeyEncrypted: string | null;
|
||||
garminRegion: string | null;
|
||||
garminKeyExpiresAt: Date | null;
|
||||
garminBackfillProgress: unknown;
|
||||
garminBackfillCompletedAt: Date | null;
|
||||
} | null,
|
||||
): GarminExtras {
|
||||
const configured = !!dbUser?.garminApiKeyEncrypted;
|
||||
if (!dbUser || !configured) {
|
||||
return {
|
||||
connected: false,
|
||||
configured: false,
|
||||
region: null,
|
||||
keyExpiry: null,
|
||||
backfillCompleted: null,
|
||||
backfillShare: null,
|
||||
};
|
||||
}
|
||||
const complete = dbUser.garminBackfillCompletedAt !== null;
|
||||
return {
|
||||
connected: true,
|
||||
configured: true,
|
||||
region: isGarminRegion(dbUser.garminRegion)
|
||||
? dbUser.garminRegion
|
||||
: "global",
|
||||
keyExpiry: resolveGarminKeyExpiry(dbUser.garminKeyExpiresAt),
|
||||
backfillCompleted: complete,
|
||||
backfillShare: complete
|
||||
? 1
|
||||
: garminBackfillShare(
|
||||
readGarminBackfillProgress(dbUser.garminBackfillProgress),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
// v1.43 — Dawarich: the connect marker only; the card reads `health` and its
|
||||
// own status route.
|
||||
interface DawarichExtras {
|
||||
|
||||
Reference in new issue
Block a user