mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +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
@@ -0,0 +1,161 @@
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess } from "@/lib/api-response";
|
||||
import { parseJsonBody } from "@/lib/api/body";
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { encrypt } from "@/lib/crypto";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { markReconnected } from "@/lib/integrations/status";
|
||||
import { GARMIN_SYNC_QUEUE } from "@/lib/jobs/integration-poll-queues";
|
||||
import { GARMIN_BACKFILL_QUEUE } from "@/lib/jobs/garmin-backfill-queue";
|
||||
import { integrationBackfillSourceOptions } from "@/lib/jobs/integration-backfill-admission";
|
||||
import { enqueueWhenBossReady } from "@/lib/jobs/boss-ready";
|
||||
import {
|
||||
clearGarminTokenCache,
|
||||
exchangeGarminKey,
|
||||
GARMIN_REGIONS,
|
||||
GarminApiError,
|
||||
type GarminRegion,
|
||||
type GarminToken,
|
||||
} from "@/lib/garmin/client";
|
||||
import {
|
||||
estimateGarminKeyExpiry,
|
||||
resolveGarminKeyExpiry,
|
||||
} from "@/lib/garmin/key-expiry";
|
||||
import { garminConnectSchema } from "@/lib/validations/garmin";
|
||||
|
||||
/**
|
||||
* Connect (or replace) the caller's Garmin Personal API key (#1333).
|
||||
*
|
||||
* The key is validated by buying a token with it before anything is stored:
|
||||
* a mistyped, revoked or expired key answers here, not on the first cron
|
||||
* tick. Garmin runs a separate service for accounts in China; a key the
|
||||
* global service refuses is tried there once, and the region that accepted it
|
||||
* is kept so the sync talks to the right host.
|
||||
*
|
||||
* On success the key is sealed at rest, the expiry is noted (Garmin's own
|
||||
* when the token reveals it, else the validity the person picked), the
|
||||
* history backfill is reopened, the `garmin` ledger is reset to connected,
|
||||
* and a first sync plus the backfill are sent best-effort. The key is never
|
||||
* logged, audited or echoed: the audit row carries only the region.
|
||||
*
|
||||
* Rate-limited 10/60 s per user: every attempt is an outbound exchange.
|
||||
*/
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "garmin.connect" } });
|
||||
|
||||
const rl = await checkRateLimit(`garmin-connect:${user.id}`, 10, 60_000);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many connection attempts", 429, {
|
||||
headers: rateLimitHeaders(rl),
|
||||
});
|
||||
}
|
||||
|
||||
const parsed = await parseJsonBody(request, garminConnectSchema, {
|
||||
maxBytes: 16 * 1024,
|
||||
message: "A valid Garmin Personal API key is required",
|
||||
});
|
||||
if (!parsed.ok) return parsed.error;
|
||||
|
||||
const { apiKey, validityDays } = parsed.data;
|
||||
|
||||
let token: GarminToken | null = null;
|
||||
let region: GarminRegion | null = null;
|
||||
let lastError: unknown = null;
|
||||
for (const candidate of GARMIN_REGIONS) {
|
||||
try {
|
||||
token = await exchangeGarminKey(apiKey, candidate);
|
||||
region = candidate;
|
||||
break;
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
// Only a refused key is worth asking the other service about; an
|
||||
// outage or a rate limit would answer the same there.
|
||||
if (!(err instanceof GarminApiError && err.keyRefused)) break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!token || !region) {
|
||||
annotate({
|
||||
meta: {
|
||||
"garmin.connect.refused":
|
||||
lastError instanceof GarminApiError ? lastError.reason : "network",
|
||||
},
|
||||
});
|
||||
if (lastError instanceof GarminApiError && lastError.keyRefused) {
|
||||
return apiError(
|
||||
"Garmin did not accept this key. Check that it was copied whole and has not expired or been revoked.",
|
||||
422,
|
||||
{ errorCode: "garmin.key_refused" },
|
||||
);
|
||||
}
|
||||
if (lastError instanceof GarminApiError && lastError.httpStatus === 429) {
|
||||
return apiError(
|
||||
"Garmin is limiting requests right now. Try again in a few minutes.",
|
||||
422,
|
||||
{ errorCode: "garmin.rate_limited" },
|
||||
);
|
||||
}
|
||||
return apiError(
|
||||
"Could not reach Garmin. Try again in a few minutes.",
|
||||
422,
|
||||
{ errorCode: "garmin.unreachable" },
|
||||
);
|
||||
}
|
||||
|
||||
const connectedAt = new Date();
|
||||
const keyExpiresAt =
|
||||
token.keyExpiresAt !== null
|
||||
? new Date(token.keyExpiresAt)
|
||||
: validityDays
|
||||
? estimateGarminKeyExpiry(connectedAt, validityDays)
|
||||
: null;
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
// Field by field; never the parsed body.
|
||||
garminApiKeyEncrypted: encrypt(apiKey),
|
||||
garminRegion: region,
|
||||
garminConnectedAt: connectedAt,
|
||||
garminKeyExpiresAt: keyExpiresAt,
|
||||
// A new key reopens the history walk from today.
|
||||
garminBackfillProgress: Prisma.DbNull,
|
||||
garminBackfillCompletedAt: null,
|
||||
},
|
||||
});
|
||||
clearGarminTokenCache(user.id);
|
||||
|
||||
await markReconnected(user.id, "garmin");
|
||||
await auditLog("garmin.connect", { userId: user.id, details: { region } });
|
||||
|
||||
await enqueueWhenBossReady("garmin-sync", async (boss) => {
|
||||
await boss
|
||||
.send(GARMIN_SYNC_QUEUE, { userId: user.id })
|
||||
.catch((err) =>
|
||||
getEvent()?.addWarning(`garmin-sync connect enqueue failed: ${err}`),
|
||||
);
|
||||
await boss
|
||||
.send(
|
||||
GARMIN_BACKFILL_QUEUE,
|
||||
{ userId: user.id, enqueuedAt: new Date().toISOString() },
|
||||
integrationBackfillSourceOptions(`garmin-backfill|${user.id}`),
|
||||
)
|
||||
.catch((err) =>
|
||||
getEvent()?.addWarning(
|
||||
`garmin-backfill connect enqueue failed: ${err}`,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
connected: true,
|
||||
region,
|
||||
keyExpiry: resolveGarminKeyExpiry(keyExpiresAt),
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess, apiError } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { markDisconnected } from "@/lib/integrations/status";
|
||||
import { clearGarminTokenCache } from "@/lib/garmin/client";
|
||||
|
||||
/**
|
||||
* Disconnect the caller's Garmin key.
|
||||
*
|
||||
* Clears the sealed key and everything stored about it (region, expiry,
|
||||
* backfill progress) and parks the `garmin` ledger at `disconnected`. Rows
|
||||
* already imported stay: disconnecting stops future syncs, it does not
|
||||
* delete history. The key stays valid at Garmin until it is revoked there.
|
||||
*/
|
||||
export const POST = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "garmin.disconnect" } });
|
||||
|
||||
const rl = await checkRateLimit(`garmin-disconnect:${user.id}`, 20, 60_000);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many requests", 429, {
|
||||
headers: rateLimitHeaders(rl),
|
||||
});
|
||||
}
|
||||
|
||||
const dbUser = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { garminApiKeyEncrypted: true },
|
||||
});
|
||||
if (!dbUser?.garminApiKeyEncrypted) {
|
||||
return apiError("No Garmin connection", 404);
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
garminApiKeyEncrypted: null,
|
||||
garminRegion: null,
|
||||
garminConnectedAt: null,
|
||||
garminKeyExpiresAt: null,
|
||||
garminBackfillProgress: Prisma.DbNull,
|
||||
garminBackfillCompletedAt: null,
|
||||
},
|
||||
});
|
||||
clearGarminTokenCache(user.id);
|
||||
|
||||
await auditLog("garmin.disconnect", { userId: user.id });
|
||||
await markDisconnected(user.id, "garmin");
|
||||
|
||||
return apiSuccess({ disconnected: true });
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { loadOneConnectionHealth } from "@/lib/integrations/connection-health-load";
|
||||
import { getIntegrationStatus } from "@/lib/integrations/status";
|
||||
import {
|
||||
INTEGRATION_CADENCE,
|
||||
resolveSyncVerdict,
|
||||
} from "@/lib/integrations/sync-verdict";
|
||||
import { resolveGarminKeyExpiry } from "@/lib/garmin/key-expiry";
|
||||
import {
|
||||
garminBackfillShare,
|
||||
readGarminBackfillProgress,
|
||||
} from "@/lib/garmin/backfill-progress";
|
||||
import { isGarminRegion } from "@/lib/garmin/client";
|
||||
|
||||
/**
|
||||
* Garmin connection status for the current user.
|
||||
*
|
||||
* `configured` is whether a key is stored. The ledger snapshot and the
|
||||
* server-resolved health come off the shared `garmin` IntegrationKey, the
|
||||
* same as every sibling. `keyExpiry` is the one Garmin-specific fact: when
|
||||
* the key stops working, and whether that is close, resolved here so web and
|
||||
* iOS warn alike. The key itself is never returned.
|
||||
*/
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "garmin.status" } });
|
||||
|
||||
const dbUser = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: {
|
||||
garminApiKeyEncrypted: true,
|
||||
garminRegion: true,
|
||||
garminKeyExpiresAt: true,
|
||||
garminBackfillProgress: true,
|
||||
garminBackfillCompletedAt: true,
|
||||
},
|
||||
});
|
||||
if (!dbUser?.garminApiKeyEncrypted) {
|
||||
return apiSuccess({ connected: false, configured: false });
|
||||
}
|
||||
|
||||
const status = await getIntegrationStatus(user.id, "garmin");
|
||||
return apiSuccess({
|
||||
connected: true,
|
||||
configured: true,
|
||||
region: isGarminRegion(dbUser.garminRegion)
|
||||
? dbUser.garminRegion
|
||||
: "global",
|
||||
keyExpiry: resolveGarminKeyExpiry(dbUser.garminKeyExpiresAt),
|
||||
backfill: {
|
||||
complete: dbUser.garminBackfillCompletedAt !== null,
|
||||
share: dbUser.garminBackfillCompletedAt
|
||||
? 1
|
||||
: garminBackfillShare(
|
||||
readGarminBackfillProgress(dbUser.garminBackfillProgress),
|
||||
),
|
||||
},
|
||||
state: status.state,
|
||||
lastSuccessAt: status.lastSuccessAt,
|
||||
lastAttemptAt: status.lastAttemptAt,
|
||||
lastError: status.lastError,
|
||||
syncHealth: resolveSyncVerdict({
|
||||
connected: true,
|
||||
configured: true,
|
||||
state: status.state,
|
||||
lastSuccessAt: status.lastSuccessAt,
|
||||
lastAttemptAt: status.lastAttemptAt,
|
||||
failingSinceAt: status.failingSince,
|
||||
cadence: INTEGRATION_CADENCE["garmin"],
|
||||
}),
|
||||
health: await loadOneConnectionHealth(user.id, "garmin"),
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess, apiError } from "@/lib/api-response";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { syncUserGarmin } from "@/lib/garmin/sync";
|
||||
import { isGarminKeyRefusal } from "@/lib/garmin/client";
|
||||
import { resolveSyncOutcome } from "@/lib/outcome/written-outcome";
|
||||
|
||||
/**
|
||||
* Manually trigger a Garmin sync for the current user.
|
||||
*
|
||||
* Reads the recent window of every resource; the history is the backfill's.
|
||||
* `imported` counts rows written this run and `outcome` says how that count
|
||||
* reads. No body. The error arm answers with a fixed message: the classified
|
||||
* failure is already on the `garmin` ledger.
|
||||
*
|
||||
* Rate-limited 5/60 s per user, like the sibling sync routes.
|
||||
*/
|
||||
export const POST = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "garmin.sync" } });
|
||||
|
||||
const rl = await checkRateLimit(`garmin-sync:${user.id}`, 5, 60_000);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many sync requests", 429, {
|
||||
errorCode: "rate_limited_self",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await syncUserGarmin(user.id);
|
||||
return apiSuccess(resolveSyncOutcome(result));
|
||||
} catch (error) {
|
||||
if (isGarminKeyRefusal(error)) {
|
||||
return apiError(
|
||||
"Garmin no longer accepts the stored key. Create a new one and connect again.",
|
||||
422,
|
||||
{ errorCode: "garmin.key_refused" },
|
||||
);
|
||||
}
|
||||
return apiError("Garmin sync failed", 502);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess, apiError } from "@/lib/api-response";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { getGarminCredentials } from "@/lib/garmin/credentials";
|
||||
import { exchangeGarminKey, GarminApiError } from "@/lib/garmin/client";
|
||||
import { SafeFetchError } from "@/lib/safe-fetch";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* Cheap live probe of the stored Garmin key: buy one token with it,
|
||||
* through the same exchange the sync uses. Nothing is written; the token is
|
||||
* dropped.
|
||||
*/
|
||||
export const POST = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "garmin.test" } });
|
||||
|
||||
const rl = await checkRateLimit(`garmin-test:${user.id}`, 5, 60_000);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many test requests", 429, {
|
||||
errorCode: "rate_limited_self",
|
||||
});
|
||||
}
|
||||
|
||||
const creds = await getGarminCredentials(user.id);
|
||||
if (!creds) {
|
||||
return apiError("Garmin not connected", 422, {
|
||||
errorCode: "not_configured",
|
||||
});
|
||||
}
|
||||
|
||||
const start = performance.now();
|
||||
try {
|
||||
await exchangeGarminKey(creds.apiKey, creds.region);
|
||||
return apiSuccess({
|
||||
ok: true,
|
||||
latencyMs: Math.round(performance.now() - start),
|
||||
});
|
||||
} catch (e) {
|
||||
if (e instanceof GarminApiError) {
|
||||
const code = e.keyRefused
|
||||
? "credentials_rejected"
|
||||
: e.httpStatus === 429
|
||||
? "rate_limited"
|
||||
: e.httpStatus !== undefined && e.httpStatus >= 500
|
||||
? "upstream_error"
|
||||
: "connection_failed";
|
||||
annotate({
|
||||
meta: { garmin_test_status: e.httpStatus, garmin_test_code: code },
|
||||
});
|
||||
return apiError(
|
||||
code === "credentials_rejected"
|
||||
? "Garmin rejected the key"
|
||||
: code === "rate_limited"
|
||||
? "Garmin rate-limited the request"
|
||||
: code === "upstream_error"
|
||||
? "Garmin returned an error"
|
||||
: "Garmin connection failed",
|
||||
502,
|
||||
{ errorCode: code },
|
||||
);
|
||||
}
|
||||
const isTimeout = e instanceof SafeFetchError && e.kind === "timeout";
|
||||
annotate({
|
||||
meta: { garmin_test_code: isTimeout ? "timeout" : "connection_failed" },
|
||||
});
|
||||
return apiError(
|
||||
isTimeout ? "Garmin request timed out" : "Garmin connection failed",
|
||||
502,
|
||||
{ errorCode: isTimeout ? "timeout" : "connection_failed" },
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -62,6 +62,10 @@ const METRIC_MODULE: Partial<Record<MetricStatusMetricId, ModuleKey>> = {
|
||||
DAY_STRAIN: "recovery",
|
||||
WORKOUT_STRAIN: "recovery",
|
||||
CARDIO_LOAD: "recovery",
|
||||
BODY_BATTERY: "recovery",
|
||||
STRESS_LEVEL: "recovery",
|
||||
TRAINING_READINESS: "recovery",
|
||||
ACUTE_TRAINING_LOAD: "recovery",
|
||||
AVERAGE_HEART_RATE: "recovery",
|
||||
MAX_HEART_RATE: "recovery",
|
||||
ENERGY_EXPENDITURE_KJ: "recovery",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -87,6 +87,9 @@ export const GET = apiHandler(async () => {
|
||||
// Not offered on a managed profile (no managed integrations before
|
||||
// v1.44); listed so the map stays total.
|
||||
dawarich: false,
|
||||
// Garmin is a key the record's owner pastes on their own settings page;
|
||||
// a managed profile has none to offer.
|
||||
garmin: false,
|
||||
};
|
||||
// The one server-resolved state per connection, from the same loader the
|
||||
// owner's own envelope uses, so a guardian reads the record's connections in
|
||||
|
||||
Reference in new issue
Block a user