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>
This commit is contained in:
Ullrich Schäfer 2026-06-07 17:47:22 +02:00
parent 192481fedb
commit 0360757ae8
36 changed files with 1368 additions and 71 deletions

View file

@ -340,6 +340,11 @@ export const importBatches = journalSchema.table("import_batches", {
importedCount: integer("imported_count").notNull().default(0),
duplicateCount: integer("duplicate_count").notNull().default(0),
errorMessage: text("error_message"),
// Ranged backfill requests (Garmin): the activity time window this
// batch asked the provider to re-deliver. NULL for pick-list style
// imports (komoot bulk) that aren't range-shaped.
rangeStart: timestamp("range_start", { withTimezone: true }),
rangeEnd: timestamp("range_end", { withTimezone: true }),
startedAt: timestamp("started_at", { withTimezone: true }).notNull().defaultNow(),
completedAt: timestamp("completed_at", { withTimezone: true }),
});

View file

@ -478,6 +478,29 @@ export default {
},
},
sync: {
garmin: {
title: "Von Garmin importieren",
subtitle:
"Garmin liefert Aktivitäten automatisch an trails.cool, sobald sie entstehen. Für ältere Aktivitäten fordere unten einen Zeitraum an — Garmin liefert sie asynchron nach.",
notConnected: "Dein Garmin-Konto ist noch nicht verbunden.",
goConnect: "Garmin in den Einstellungen verbinden",
needsRelink: "Deine Garmin-Verbindung muss neu verknüpft werden, bevor du Importe anfordern kannst.",
from: "Von",
to: "Bis",
request: "Import anfordern",
requesting: "Wird angefordert…",
requested: "Angefordert! Aktivitäten erscheinen, sobald Garmin sie liefert.",
asyncNote:
"Garmin verarbeitet Verlaufs-Anfragen auf ihrer Seite — große Zeiträume können dauern. Überlappende Zeiträume erneut anzufordern ist unproblematisch; nichts wird doppelt importiert.",
importedSince_one: "{{count}} Aktivität seit dieser Anfrage importiert",
importedSince_other: "{{count}} Aktivitäten seit dieser Anfrage importiert",
errors: {
not_connected: "Dein Garmin-Konto ist nicht verbunden.",
needs_relink: "Deine Garmin-Verbindung muss zuerst neu verknüpft werden.",
invalid_range: "Wähle einen gültigen Zeitraum in der Vergangenheit (Start vor Ende).",
request_failed: "Garmin hat die Anfrage abgelehnt — versuche es in ein paar Minuten erneut.",
},
},
import: "Importieren",
importFrom: "Import von {{provider}}",
imported: "Importiert",

View file

@ -478,6 +478,29 @@ export default {
},
},
sync: {
garmin: {
title: "Import from Garmin",
subtitle:
"Garmin delivers activities to trails.cool as they happen. To pull in your history, request a date range below — Garmin sends those activities over asynchronously.",
notConnected: "Your Garmin account isn't connected yet.",
goConnect: "Connect Garmin in settings",
needsRelink: "Your Garmin connection needs to be re-linked before requesting imports.",
from: "From",
to: "To",
request: "Request import",
requesting: "Requesting…",
requested: "Requested! Activities will appear as Garmin delivers them.",
asyncNote:
"Garmin processes history requests on their side — large ranges can take a while to arrive. Re-requesting an overlapping range is safe; nothing gets imported twice.",
importedSince_one: "{{count}} activity imported since this request",
importedSince_other: "{{count}} activities imported since this request",
errors: {
not_connected: "Your Garmin account isn't connected.",
needs_relink: "Your Garmin connection needs to be re-linked first.",
invalid_range: "Pick a valid date range in the past (start before end).",
request_failed: "Garmin rejected the request — try again in a few minutes.",
},
},
import: "Import",
importFrom: "Import from {{provider}}",
imported: "Imported",