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:
parent
192481fedb
commit
0360757ae8
36 changed files with 1368 additions and 71 deletions
|
|
@ -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 }),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue