Add Wahoo activity sync with provider-agnostic framework

Provider-agnostic sync framework + Wahoo as first implementation:

Framework (apps/journal/app/lib/sync/):
- SyncProvider interface for OAuth2, webhooks, import, conversion
- Provider registry for settings UI iteration
- Generic sync_connections + sync_imports tables
- Token storage with auto-refresh

Wahoo provider:
- OAuth2 with workouts_read, user_read, offline_data scopes
- Webhook-based auto-import (workout_summary events)
- Manual import page with pagination
- FIT→GPX conversion via fit-file-parser
- Webhook token verification

Routes:
- /api/sync/connect/:provider — OAuth redirect
- /api/sync/callback/:provider — OAuth callback
- /api/sync/disconnect/:provider — remove connection
- /api/sync/webhook/:provider — webhook receiver
- /sync/import/:provider — manual import page

Settings: Connected Services section with per-provider connect/disconnect

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-04-04 11:19:15 +01:00
parent dd6543e313
commit b6711d23d6
No known key found for this signature in database
GPG key ID: A32FF691A0F752D9
25 changed files with 1124 additions and 5 deletions

View file

@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-04-04

View file

@ -0,0 +1,103 @@
## Context
Wahoo's Cloud API (https://cloud-api.wahooligan.com/) uses OAuth2 for authentication, provides workout data in FIT format, and supports webhooks for the `workout_summary` event. This is the first device integration — Garmin, Strava, and others will follow. The architecture must be provider-agnostic.
## Goals / Non-Goals
**Goals:**
- Provider-agnostic sync framework: common interface for OAuth2, webhooks, activity import
- Wahoo as first provider implementation
- Webhook-based automatic sync (new activities arrive without user action)
- Manual import for historical workouts
- FIT→GPX conversion for Wahoo's binary format
- Extensible to Garmin, Strava, Coros, etc.
**Non-Goals:**
- Two-way sync (no pushing data to providers)
- Importing structured workout plans or power zones
- Real-time streaming of in-progress activities
## Decisions
### D1: Provider interface
```typescript
interface SyncProvider {
id: string; // "wahoo", "garmin", etc.
name: string; // "Wahoo"
scopes: string[]; // OAuth scopes needed
getAuthUrl(state: string): string;
exchangeCode(code: string): Promise<TokenSet>;
refreshToken(refreshToken: string): Promise<TokenSet>;
listWorkouts(tokens: TokenSet, page: number): Promise<WorkoutList>;
downloadFile(tokens: TokenSet, workout: Workout): Promise<Buffer>;
convertToGpx(fileBuffer: Buffer): Promise<string>;
parseWebhook(body: unknown): WebhookEvent | null;
}
```
Each provider implements this interface. The framework handles OAuth flow, token storage, webhook routing, and activity creation. Adding a new provider means implementing one file.
### D2: Database schema
**`sync_connections`** — one row per user-provider pair:
- `id`, `user_id` (FK), `provider` (e.g., "wahoo"), `access_token`, `refresh_token`, `expires_at`, `provider_user_id`, `created_at`
**`sync_imports`** — tracks which external workouts have been imported:
- `id`, `user_id` (FK), `provider`, `external_workout_id`, `activity_id` (FK to activities), `imported_at`
This replaces the earlier `wahoo_tokens` table with a generic schema. No `wahoo_workout_id` column on activities — the `sync_imports` junction table handles duplicate detection for all providers.
### D3: Webhook sync
Wahoo sends `workout_summary` webhooks to a registered URL when a workout completes. Requires the `offline_data` OAuth scope.
**Webhook endpoint:** `POST /api/sync/webhook/wahoo`
- Verifies the request is from Wahoo (check payload structure)
- Looks up the user's `sync_connection` by `provider_user_id`
- Downloads the FIT file, converts to GPX, creates activity
- Stores import record in `sync_imports`
**Webhook registration:** Done via Wahoo's app settings dashboard (not API). The URL is `{ORIGIN}/api/sync/webhook/wahoo`.
### D4: OAuth2 flow
Standard authorization code flow. Scopes: `workouts_read`, `user_read`, `offline_data` (for webhooks).
**Routes:**
- `GET /api/sync/connect/wahoo` → redirect to Wahoo auth
- `GET /api/sync/callback/wahoo` → exchange code, store tokens, redirect to settings
- `POST /api/sync/disconnect/wahoo` → delete tokens
The route pattern `/api/sync/{action}/{provider}` is provider-agnostic — adding Garmin means the same routes with `garmin` instead of `wahoo`.
### D5: FIT→GPX conversion
Use `fit-file-parser` to parse FIT binary data. Extract records with `record_type === 'record'` containing `position_lat`, `position_long`, `altitude`, `timestamp`. FIT uses semicircles for coordinates — convert to degrees by dividing by `2^31 / 180`.
Convert to GPX using the existing `generateGpx` function, then feed through `setGeomFromGpx` for PostGIS geometry.
### D6: Manual import page
`/sync/import/wahoo` — lists workouts from Wahoo API with date, type, duration, distance. Marks already-imported ones via `sync_imports` lookup. Import button downloads FIT, converts, creates activity. Paginated (Wahoo returns 30 per page).
### D7: Settings integration
"Connected Services" section in settings. Shows each configured provider with connect/disconnect. When connected, shows provider user info and link to import page.
Provider registry:
```typescript
const providers = [wahooProvider]; // Add garminProvider, stravaProvider later
```
Settings iterates over registered providers to render the UI.
## Risks / Trade-offs
- **Webhook reliability:** If the webhook fails, the workout is missed. Mitigated by the manual import page as fallback, and idempotent import (duplicate detection via `sync_imports`).
- **FIT parsing:** Binary format parsing via npm package. If it fails for edge cases, the webhook silently drops that workout. Logging + manual import as fallback.
- **Provider-specific quirks:** Each provider has different OAuth flows, data formats, and webhook schemas. The interface abstracts the common pattern, but implementation details will vary. Accept some provider-specific code in each implementation file.
- **Webhook security:** Wahoo doesn't sign webhooks with HMAC. Verify by checking the `provider_user_id` in the payload matches a known `sync_connection`. This prevents arbitrary POST requests from creating activities.

View file

@ -0,0 +1,32 @@
## Why
Users with Wahoo cycling computers (ELEMNT, KICKR) record activities that are synced to Wahoo's cloud. Currently there's no way to get those activities into trails.cool without manually exporting GPX files. A direct Wahoo integration lets users connect their account once and have activities sync automatically.
This is the first of several planned device integrations (Garmin, Strava, Coros, etc.). The architecture should be provider-agnostic so adding new integrations is straightforward.
## What Changes
- **Provider abstraction**: A common interface for activity sync providers (OAuth2, webhook handling, activity listing, file download, format conversion). Wahoo is the first implementation.
- **OAuth2 flow**: "Connect Wahoo" button on the journal settings page. Redirects to Wahoo's authorization endpoint, handles callback, stores tokens.
- **Webhook sync**: Register for Wahoo's `workout_summary` webhook. When a new workout completes, automatically download the FIT file, convert to GPX, and create an activity.
- **Manual import**: Fallback import page for browsing and selectively importing older workouts.
- **FIT to GPX conversion**: Wahoo provides FIT format files. Convert to GPX server-side for storage.
- **Token management**: Store and refresh OAuth tokens per provider. Handle the 2-hour expiry with automatic refresh.
## Capabilities
### New Capabilities
- `activity-sync`: Provider-agnostic activity sync framework (OAuth2, webhooks, import, format conversion)
- `wahoo-import`: Wahoo-specific provider implementation (OAuth2 scopes, FIT files, webhook payload)
### Modified Capabilities
- `account-settings`: Add "Connected Services" section for managing provider connections
- `journal-auth`: Store OAuth tokens for external providers
## Impact
- `apps/journal/app/lib/sync/` — new directory for sync framework + provider implementations
- `apps/journal/app/routes/settings.tsx` — Connected Services section
- `apps/journal/app/routes/sync.*` — OAuth callback, webhook endpoint, import page
- `packages/db/src/schema/journal.ts``sync_connections` and `sync_imports` tables
- `infrastructure/secrets.app.env` — WAHOO_CLIENT_ID, WAHOO_CLIENT_SECRET

View file

@ -0,0 +1,10 @@
## MODIFIED Requirements
### Requirement: Connected Services section
The settings page SHALL include a "Connected Services" section for managing external integrations.
#### Scenario: Wahoo connection status
- **WHEN** a user views the settings page
- **THEN** a "Connected Services" section shows Wahoo as connected or disconnected
- **AND** connected state shows "Disconnect" button
- **AND** disconnected state shows "Connect Wahoo" button

View file

@ -0,0 +1,9 @@
## MODIFIED Requirements
### Requirement: Store external service tokens
The journal auth system SHALL store OAuth tokens for external services alongside user credentials.
#### Scenario: Wahoo token storage
- **WHEN** a user connects their Wahoo account
- **THEN** access token, refresh token, expiry time, and Wahoo user ID are stored in the `wahoo_tokens` table
- **AND** tokens are associated with the journal user ID

View file

@ -0,0 +1,70 @@
## ADDED Requirements
### Requirement: Provider-agnostic sync framework
The system SHALL provide a common interface for external activity sync providers.
#### Scenario: Add new provider
- **WHEN** a developer wants to add a new sync provider (e.g., Garmin)
- **THEN** they implement the `SyncProvider` interface in a single file
- **AND** register it in the provider registry
- **AND** all OAuth, webhook, import, and settings UI works automatically
### Requirement: Connect Wahoo account
Users SHALL be able to connect their Wahoo account via OAuth2.
#### Scenario: Connect Wahoo
- **WHEN** a user clicks "Connect Wahoo" in journal settings
- **THEN** they are redirected to Wahoo's OAuth authorization page with scopes `workouts_read`, `user_read`, `offline_data`
- **AND** after granting permission, redirected back to the journal
- **AND** access and refresh tokens are stored in `sync_connections`
#### Scenario: Disconnect Wahoo
- **WHEN** a user clicks "Disconnect" next to their Wahoo connection
- **THEN** the stored tokens are deleted from `sync_connections`
#### Scenario: Token refresh
- **WHEN** a Wahoo API call fails with an expired token
- **THEN** the refresh token is used to obtain a new access token automatically
### Requirement: Webhook-based automatic sync
New Wahoo workouts SHALL be automatically imported when they complete.
#### Scenario: Webhook receives new workout
- **WHEN** Wahoo sends a `workout_summary` webhook to `/api/sync/webhook/wahoo`
- **THEN** the system identifies the user via `provider_user_id`
- **AND** downloads the FIT file from Wahoo's CDN
- **AND** converts it to GPX
- **AND** creates a journal activity with the GPX, stats, and PostGIS geometry
- **AND** records the import in `sync_imports` to prevent duplicates
#### Scenario: Duplicate webhook
- **WHEN** a webhook arrives for a workout already imported
- **THEN** the import is skipped silently (idempotent)
#### Scenario: Unknown user webhook
- **WHEN** a webhook arrives with a `provider_user_id` not matching any connection
- **THEN** the request is ignored with a 200 response (don't reveal user existence)
### Requirement: Manual import
Users SHALL be able to browse and selectively import older Wahoo workouts.
#### Scenario: View workout list
- **WHEN** a user visits the Wahoo import page
- **THEN** their Wahoo workouts are listed with date, type, duration, and distance
- **AND** already-imported workouts are marked
#### Scenario: Import workout
- **WHEN** a user clicks "Import" on a Wahoo workout
- **THEN** the FIT file is downloaded, converted to GPX, and a new activity is created
### Requirement: FIT to GPX conversion
The system SHALL convert Wahoo's FIT binary files to GPX format.
#### Scenario: Convert FIT with GPS data
- **WHEN** a FIT file contains GPS track records
- **THEN** track points with lat, lon, elevation, and timestamp are extracted
- **AND** a valid GPX string is produced using `generateGpx`
#### Scenario: FIT without GPS data
- **WHEN** a FIT file has no GPS records (e.g., indoor trainer workout)
- **THEN** the activity is created without GPX or geometry (stats only)

View file

@ -0,0 +1,65 @@
## 1. Sync Framework & Database
- [x] 1.1 Add `sync_connections` table to `packages/db/src/schema/journal.ts` (user_id FK, provider, access_token, refresh_token, expires_at, provider_user_id, created_at)
- [x] 1.2 Add `sync_imports` table (user_id FK, provider, external_workout_id, activity_id FK, imported_at)
- [x] 1.3 Define `SyncProvider` TypeScript interface in `apps/journal/app/lib/sync/types.ts`
- [x] 1.4 Create provider registry in `apps/journal/app/lib/sync/registry.ts` (array of providers, lookup by id)
- [x] 1.5 Create token storage helpers: `saveConnection()`, `getConnection()`, `deleteConnection()`, `updateTokens()` in `apps/journal/app/lib/sync/connections.server.ts`
- [x] 1.6 Create import tracking helpers: `recordImport()`, `isAlreadyImported()`, `getImportedIds()` in `apps/journal/app/lib/sync/imports.server.ts`
- [x] 1.7 Add WAHOO_CLIENT_ID and WAHOO_CLIENT_SECRET to `infrastructure/secrets.app.env` via SOPS
- [x] 1.8 Run `pnpm db:push` to apply schema changes
## 2. Wahoo Provider Implementation
- [x] 2.1 Create `apps/journal/app/lib/sync/providers/wahoo.ts` implementing `SyncProvider`
- [x] 2.2 Implement `getAuthUrl()` with scopes `workouts_read`, `user_read`, `offline_data`
- [x] 2.3 Implement `exchangeCode()` and `refreshToken()` using Wahoo's OAuth endpoints
- [x] 2.4 Implement `listWorkouts()` with pagination (Wahoo's `GET /v1/workouts`)
- [x] 2.5 Implement `downloadFile()` to fetch FIT file from Wahoo CDN
- [x] 2.6 Implement `parseWebhook()` to extract workout info from `workout_summary` payload
## 3. FIT → GPX Conversion
- [x] 3.1 Add `fit-file-parser` dependency
- [x] 3.2 Implement `convertToGpx()` in Wahoo provider: parse FIT, extract track records, convert semicircles to degrees, generate GPX via `generateGpx`
- [x] 3.3 Handle indoor workouts (no GPS) — return null GPX, create activity with stats only
## 4. OAuth Routes
- [x] 4.1 Create `apps/journal/app/routes/api.sync.connect.$provider.ts` — generates auth URL from provider, redirects
- [x] 4.2 Create `apps/journal/app/routes/api.sync.callback.$provider.ts` — exchanges code, stores tokens, redirects to settings
- [x] 4.3 Create `apps/journal/app/routes/api.sync.disconnect.$provider.ts` — deletes connection
- [x] 4.4 Register routes in `apps/journal/app/routes.ts`
## 5. Webhook Route
- [x] 5.1 Create `apps/journal/app/routes/api.sync.webhook.$provider.ts` — receives webhook, looks up user, downloads file, converts, creates activity
- [x] 5.2 Verify webhook by matching `provider_user_id` to a `sync_connection`
- [x] 5.3 Handle duplicate detection via `sync_imports`
- [x] 5.4 Register route in `routes.ts`
## 6. Import Page
- [x] 6.1 Create `apps/journal/app/routes/sync.import.$provider.tsx` — lists workouts with import buttons
- [x] 6.2 Show workout date, type, duration, distance per row
- [x] 6.3 Mark already-imported workouts via `sync_imports` lookup
- [x] 6.4 Import action: download file, convert, create activity, record import
- [x] 6.5 Pagination for workout list
- [x] 6.6 Register route in `routes.ts`
## 7. Settings Integration
- [x] 7.1 Add "Connected Services" section to settings page
- [x] 7.2 Iterate over registered providers to show connect/disconnect per provider
- [x] 7.3 Show connection status and link to import page when connected
## 8. i18n
- [x] 8.1 Add translation keys for sync UI (en + de): connect/disconnect, import page, webhook status, provider names
## 9. Testing
- [x] 9.1 Unit test: FIT→GPX conversion with sample FIT data
- [x] 9.2 Unit test: duplicate detection via sync_imports
- [x] 9.3 Unit test: OAuth URL generation and token exchange (mock fetch)
- [x] 9.4 E2E test: settings page shows Connected Services section