trails/apps/journal/app/lib/connected-services/providers/garmin/backfill.ts
Ullrich Schäfer 0360757ae8 feat(journal): Garmin activity import — provider, webhook pipeline, backfill (§1–5)
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>
2026-06-07 17:47:22 +02:00

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 };
}