Garmin Connect as the third connected-services provider (spec: garmin-import). The interesting parts: - Push-first ingestion: Garmin has no list endpoint. The webhook normalizes ping (callbackURL) and push (inline) notification batches into events; the slow work (authorized FIT download, FIT→GPX via the shared converter, activity creation) runs in a garmin-import-activity pg-boss job so the webhook answers fast. Callback URLs are validated against Garmin's API host before any fetch (SSRF guard). - History via backfill requests: /sync/import/garmin is a date-range requester with honest async progress (no pick list — the concept doesn't exist in a push model). Ranges chunk to Garmin's 90-day cap; overlaps are free via sync_imports dedupe. Requests persist in import_batches via two new nullable columns (range_start/range_end). - OAuth2 + PKCE on the existing oauth credential kind. Design correction from apply: the verifier rides a short-lived httpOnly cookie scoped to the callback path — the state param is visible in redirect URLs and must never carry it. Manifests opt in via pkce:true. - Deregistration notifications flip the connection to 'revoked' (row kept for audit, imports retained, re-connect prompt shown). - Framework evolutions, all additive: parseWebhook returns WebhookEvent[] (Garmin batches; Wahoo adapted), manifest gains configured()/importUrl/pkce, importActivity accepts summary stats for FIT-less imports, manager gains markRevoked. - Env-gated: no GARMIN_CLIENT_ID → provider hidden on /settings/connections. Privacy manifest entry (DE+EN). i18n en+de. Rollout (§6) stays gated on the Garmin Developer Program application (submitted 2026-06-07). Fixtures are doc-shaped; the staging soak swaps in recorded payloads if shapes differ. Gate: typecheck ✓ lint ✓ unit+integration ✓ e2e 70/72 + both known flakes green isolated ✓ openspec validate ✓ Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
111 lines
3.9 KiB
TypeScript
111 lines
3.9 KiB
TypeScript
// Garmin historical import via the Activity API backfill endpoint
|
|
// (spec: garmin-import, "Historical import via backfill").
|
|
//
|
|
// Garmin has no list-activities endpoint: you ask for a time range and
|
|
// Garmin re-delivers those activities asynchronously through the same
|
|
// notification pipeline the live webhook uses. Each accepted request
|
|
// returns 202; the data arrives whenever Garmin gets to it.
|
|
|
|
import { randomUUID } from "node:crypto";
|
|
import { fetchWithTimeout } from "../../../http.server.ts";
|
|
import { withFreshCredentials } from "../../manager.ts";
|
|
import type { OAuthCredentials } from "../../types.ts";
|
|
import { GARMIN_API } from "./constants.ts";
|
|
|
|
// Garmin caps a single backfill request's window. 90 days per the
|
|
// Activity API docs; if program onboarding reveals a different cap for
|
|
// our key, this constant is the only thing to change (design.md, open
|
|
// questions).
|
|
export const BACKFILL_CHUNK_MS = 90 * 24 * 60 * 60 * 1000;
|
|
|
|
const BACKFILL_URL = `${GARMIN_API}/wellness-api/rest/backfill/activities`;
|
|
|
|
/**
|
|
* Split [from, to] into Garmin-sized chunks (inclusive bounds, ms).
|
|
* Returns [] for empty/inverted ranges.
|
|
*/
|
|
export function chunkRange(
|
|
fromMs: number,
|
|
toMs: number,
|
|
chunkMs: number = BACKFILL_CHUNK_MS,
|
|
): Array<{ fromMs: number; toMs: number }> {
|
|
if (!(fromMs < toMs) || chunkMs <= 0) return [];
|
|
const chunks: Array<{ fromMs: number; toMs: number }> = [];
|
|
for (let start = fromMs; start < toMs; start += chunkMs) {
|
|
chunks.push({ fromMs: start, toMs: Math.min(start + chunkMs, toMs) });
|
|
}
|
|
return chunks;
|
|
}
|
|
|
|
export interface BackfillDeps {
|
|
requestChunk(
|
|
serviceId: string,
|
|
fromSec: number,
|
|
toSec: number,
|
|
): Promise<void>;
|
|
}
|
|
|
|
function defaultDeps(): BackfillDeps {
|
|
return {
|
|
async requestChunk(serviceId, fromSec, toSec) {
|
|
await withFreshCredentials(serviceId, async (credentials) => {
|
|
const creds = credentials as OAuthCredentials;
|
|
const url = `${BACKFILL_URL}?summaryStartTimeInSeconds=${fromSec}&summaryEndTimeInSeconds=${toSec}`;
|
|
const resp = await fetchWithTimeout(url, {
|
|
headers: { Authorization: `Bearer ${creds.access_token}` },
|
|
});
|
|
// 202 = accepted. 409 = an identical/overlapping request is
|
|
// already in flight — fine, the data will arrive either way
|
|
// and sync_imports dedupes.
|
|
if (!resp.ok && resp.status !== 409) {
|
|
const text = await resp.text().catch(() => "");
|
|
throw new Error(`Garmin backfill request failed: ${resp.status} ${text}`);
|
|
}
|
|
});
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Issue backfill requests covering [from, to] and persist one
|
|
* import_batches row describing the whole request (progress UX reads
|
|
* it back on the import page).
|
|
*/
|
|
export async function requestBackfill(
|
|
service: { id: string; userId: string },
|
|
from: Date,
|
|
to: Date,
|
|
deps: BackfillDeps = defaultDeps(),
|
|
): Promise<{ batchId: string; chunks: number }> {
|
|
const chunks = chunkRange(from.getTime(), to.getTime());
|
|
if (chunks.length === 0) throw new Error("Empty backfill range");
|
|
|
|
for (const chunk of chunks) {
|
|
await deps.requestChunk(
|
|
service.id,
|
|
Math.floor(chunk.fromMs / 1000),
|
|
Math.floor(chunk.toMs / 1000),
|
|
);
|
|
}
|
|
|
|
// Record the request for the import page. Lazy import keeps the DB
|
|
// out of this module's graph for pure-function tests (chunkRange).
|
|
const { getDb } = await import("../../../db.ts");
|
|
const { importBatches } = await import("@trails-cool/db/schema/journal");
|
|
const batchId = randomUUID();
|
|
await getDb()
|
|
.insert(importBatches)
|
|
.values({
|
|
id: batchId,
|
|
userId: service.userId,
|
|
connectionId: service.id,
|
|
provider: "garmin",
|
|
// Garmin delivers asynchronously — the batch is "running" from
|
|
// our perspective until the operator-facing page stops caring.
|
|
// totalFound is unknowable up front (no list endpoint).
|
|
status: "running",
|
|
rangeStart: from,
|
|
rangeEnd: to,
|
|
});
|
|
return { batchId, chunks: chunks.length };
|
|
}
|