Merge pull request #341 from trails-cool/stigi/archive-wahoo-push

Archive wahoo-route-push change
This commit is contained in:
Ullrich Schäfer 2026-05-01 08:17:04 +02:00 committed by GitHub
commit b0f8be2765
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 127 additions and 1 deletions

View file

@ -18,9 +18,10 @@ Users SHALL be able to connect their Wahoo account via OAuth2.
#### Scenario: Connect Wahoo #### Scenario: Connect Wahoo
- **WHEN** a user clicks "Connect Wahoo" in journal settings - **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` - **THEN** they are redirected to Wahoo's OAuth authorization page with scopes `workouts_read`, `user_read`, `offline_data`, `routes_write`
- **AND** after granting permission, redirected back to the journal - **AND** after granting permission, redirected back to the journal
- **AND** access and refresh tokens are stored in `sync_connections` - **AND** access and refresh tokens are stored in `sync_connections`
- **AND** the granted scopes are recorded in `sync_connections.granted_scopes` so feature gates can detect missing scopes without round-tripping to Wahoo
#### Scenario: Disconnect Wahoo #### Scenario: Disconnect Wahoo
- **WHEN** a user clicks "Disconnect" next to their Wahoo connection - **WHEN** a user clicks "Disconnect" next to their Wahoo connection
@ -30,6 +31,11 @@ Users SHALL be able to connect their Wahoo account via OAuth2.
- **WHEN** a Wahoo API call fails with an expired token - **WHEN** a Wahoo API call fails with an expired token
- **THEN** the refresh token is used to obtain a new access token automatically - **THEN** the refresh token is used to obtain a new access token automatically
#### Scenario: Existing connection without routes_write
- **WHEN** a user connected before the `routes_write` scope was added attempts an action that requires it (such as pushing a route)
- **THEN** the system detects the missing scope from `sync_connections.granted_scopes` and routes the user through OAuth re-authorization to grant the new scope
- **AND** the existing tokens remain valid until re-auth completes; ongoing read flows (workout import, webhook ingestion) continue to work
### Requirement: Webhook-based automatic sync ### Requirement: Webhook-based automatic sync
New Wahoo workouts SHALL be automatically imported when they complete. New Wahoo workouts SHALL be automatically imported when they complete.

View file

@ -0,0 +1,120 @@
## Purpose
Push planned routes from the Journal to a connected Wahoo account as FIT Course files, so users can ride routes they planned in trails.cool on their Wahoo head unit.
## Requirements
### Requirement: Send to Wahoo action on route detail page
The Journal SHALL show a "Send to Wahoo" button on the route detail page when all of the following hold: the viewer owns the route, the viewer has a connected Wahoo account, and the route has stored geometry (`routes.geom` is non-null and `routes.gpx` is non-empty). The button SHALL trigger a server action that pushes the current route version to Wahoo.
#### Scenario: Owner with connected Wahoo and a route with geometry
- **WHEN** the route owner loads a route detail page for a route that has geometry
- **AND** the owner has a `sync_connections` row with `provider = 'wahoo'`
- **THEN** a "Send to Wahoo" button is visible alongside the existing "Export GPX" action
#### Scenario: Owner without a connected Wahoo account
- **WHEN** the route owner loads a route detail page
- **AND** the owner has no Wahoo `sync_connections` row
- **THEN** the "Send to Wahoo" button is not rendered
#### Scenario: Non-owner viewing the route
- **WHEN** any visitor who is not the route owner loads the route detail page
- **THEN** the "Send to Wahoo" button is not rendered, regardless of the viewer's own Wahoo connection
#### Scenario: Route without geometry
- **WHEN** the route owner loads a route detail page for a route whose `geom` is null or whose `gpx` is empty
- **THEN** the "Send to Wahoo" button is not rendered
### Requirement: Server-side route push pipeline
The Journal SHALL convert the current route version to a FIT Course file and POST it to `https://api.wahooligan.com/v1/routes` with the user's stored Wahoo access token, recording the outcome in `sync_pushes`.
#### Scenario: Successful push
- **WHEN** the route owner triggers the push action for a route version that has not been pushed before
- **THEN** the server reads the route's GPX, runs `gpxToFitCourse`, base64-encodes the result, and POSTs it to `/v1/routes` with the required fields (`external_id`, `provider_updated_at`, `name`, `workout_type_family_id`, `start_lat`, `start_lng`, `distance`, `ascent`)
- **AND** on a 2xx response a `sync_pushes` row is inserted with `pushed_at = now()`, `remote_id` set from Wahoo's response, and `error = null`
- **AND** the user is shown a "Sent to Wahoo" confirmation
#### Scenario: Wahoo returns an error
- **WHEN** Wahoo responds with a non-2xx status
- **THEN** a `sync_pushes` row is inserted with `pushed_at = null`, `remote_id = null`, and `error` populated with the response body or status
- **AND** the user is shown a generic "Sending to Wahoo failed — try again later" message
- **AND** no exception leaks to the browser
#### Scenario: Route file omits records
- **WHEN** the route's GPX has no track points (zero-segment route)
- **THEN** the server returns HTTP 422 to the client and does not call Wahoo
- **AND** no `sync_pushes` row is created
### Requirement: Push idempotency per route version
Each combination of `(user_id, route_id, route_version, provider)` SHALL be pushed at most once successfully. Re-clicking the button for the same version SHALL be a no-op surfacing the existing remote id.
#### Scenario: Re-push of an already-pushed version
- **WHEN** the route owner clicks "Send to Wahoo" on a version that already has a `sync_pushes` row with `pushed_at` set
- **THEN** the server skips the Wahoo call and returns the existing `remote_id`
- **AND** the UI shows "Already on Wahoo" with the timestamp from the existing row
#### Scenario: Push of a new version after editing
- **WHEN** the route owner edits the route, creating a new version, and clicks "Send to Wahoo"
- **THEN** the new version has no `sync_pushes` row yet, so the push proceeds normally
- **AND** a new `sync_pushes` row is inserted for the new version
#### Scenario: Retry after a failed push
- **WHEN** a previous push attempt for this version failed (`pushed_at` null, `error` populated)
- **THEN** clicking "Send to Wahoo" again retries the push
- **AND** the existing `sync_pushes` row is updated in place (no duplicate row)
### Requirement: Stable external_id per route version
The `external_id` field sent to Wahoo SHALL be deterministic per `(route_id, route_version)` so that retries and accidental races do not create duplicate routes on Wahoo's side.
#### Scenario: External id is deterministic
- **WHEN** the server constructs the Wahoo payload for a route version
- **THEN** `external_id` is set to `route:<route_id>:v<route_version>`
- **AND** identical calls for the same version produce the same `external_id`
### Requirement: Re-auth flow when `routes_write` scope is missing
The Journal SHALL detect when a connected Wahoo account lacks the `routes_write` scope before calling Wahoo, redirect the user through OAuth to grant it, and resume the push automatically after the user returns.
#### Scenario: Existing connection lacks routes_write
- **WHEN** the route owner clicks "Send to Wahoo"
- **AND** the user's `sync_connections.granted_scopes` does not include `routes_write`
- **THEN** the server redirects the user to Wahoo's authorization URL with the full updated scope list and a `state` that encodes `{ return_to: <route_url>, push_after: true }`
- **AND** no Wahoo `/v1/routes` call is attempted
#### Scenario: Push completes after re-auth
- **WHEN** the user returns from Wahoo's OAuth callback with `push_after = true` in the state
- **THEN** the connection is updated with the new scopes
- **AND** the push action runs automatically against the route encoded in `return_to`
- **AND** the user lands back on the route detail page with the "Sent to Wahoo" confirmation visible
#### Scenario: User declines the new scope
- **WHEN** the user reaches Wahoo's authorization page and clicks "Deny"
- **THEN** the user is redirected back to the route detail page with an inline notice "Sending to Wahoo needs route permission — please reconnect your account in Settings"
- **AND** no `sync_pushes` row is created
### Requirement: GPX to FIT Course conversion
The system SHALL provide a `gpxToFitCourse` function in the `@trails-cool/fit` package that converts a GPX string to a FIT Course binary suitable for `POST /v1/routes`.
#### Scenario: GPX with track points produces a valid FIT Course
- **WHEN** `gpxToFitCourse({ gpx, name })` is called with a GPX string containing one or more track points
- **THEN** the returned `Uint8Array` is a valid FIT file that decodes via `fit-file-parser` to a Course with the same number of records as the input had track points
- **AND** each record's lat/lon round-trips to within 1e-5 degrees of the source
#### Scenario: GPX with elevation data preserves elevation
- **WHEN** the input GPX track points include `<ele>` values
- **THEN** the encoded FIT records carry `altitude` values that round-trip to within 0.5 m of the source
#### Scenario: GPX without track points is rejected
- **WHEN** the input GPX has no track points
- **THEN** `gpxToFitCourse` throws an error and produces no output
### Requirement: Push status surfaced on the route detail page
The route detail page SHALL show whether the current route version has been pushed to Wahoo, with timestamp.
#### Scenario: Already-pushed route shows status
- **WHEN** the route owner views a route version with a successful `sync_pushes` row
- **THEN** the page shows "Sent to Wahoo on <date>" near the action buttons
- **AND** the "Send to Wahoo" button is replaced with disabled "Already on Wahoo" text
#### Scenario: Push failed previously shows retry affordance
- **WHEN** the route owner views a route version whose latest `sync_pushes` row has `error` set and `pushed_at` null
- **THEN** the page shows "Last attempt failed: <short error>" with a "Send to Wahoo" button still active