Merge pull request #350 from trails-cool/stigi/wahoo-openspec
Add openspec changes for Wahoo production cutover and route updates
This commit is contained in:
commit
4853a116ff
10 changed files with 384 additions and 0 deletions
2
openspec/changes/wahoo-production-cutover/.openspec.yaml
Normal file
2
openspec/changes/wahoo-production-cutover/.openspec.yaml
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
schema: spec-driven
|
||||
created: 2026-05-01
|
||||
63
openspec/changes/wahoo-production-cutover/design.md
Normal file
63
openspec/changes/wahoo-production-cutover/design.md
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
## Context
|
||||
|
||||
trails.cool's Wahoo integration was developed against a sandbox OAuth app. Sandbox is self-serve with low rate limits and no branding; production requires a Wahoo-side review where they verify our app, scopes, branding, and redirect URI before flipping the rate-limit tier and (likely) issuing production credentials. We have shipped working sandbox flows for both directions:
|
||||
|
||||
- **Inbound**: import workouts via `workouts_read` + `offline_data` (`wahoo-import` capability).
|
||||
- **Outbound**: push routes via `routes_write` (`wahoo-route-push` capability), with `routes_read` recently added so we can round-trip-verify what Wahoo stored.
|
||||
|
||||
While building these we hit three Wahoo behaviors that aren't documented prominently and cost meaningful time:
|
||||
|
||||
1. **Per-(app, user) unrevoked-token cap.** Wahoo refuses new tokens with `"Too many unrevoked access tokens"` until the user revokes. We now call `DELETE /v1/permissions` on disconnect (#346).
|
||||
2. **`route[file]` must be a data URI**, not raw base64. Sending raw base64 results in `file.url: null` server-side and an empty map in the Wahoo app (#349).
|
||||
3. **FIT Course `capabilities` bitfield** must include `valid | distance | position` (`0x1A`); `0x04` ("time") alone misleads consumers (#348).
|
||||
|
||||
These are application-level fixes already in main. They are listed here so the cutover record explains why our sandbox testing surfaced them and so anyone redoing this integration in another fork has the receipts.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Get the trails.cool Wahoo app promoted to production rate limits.
|
||||
- Ensure all five scopes we use are approved on production.
|
||||
- Make sure the trails.cool logo appears in the Wahoo mobile app for routes/workouts associated with our `fitness_app_id`.
|
||||
- Have a tested plan for whatever credential/redirect-URI changes Wahoo requires, including any forced reauthorization.
|
||||
|
||||
**Non-Goals:**
|
||||
- No application code changes are planned by this cutover. If Wahoo's review surfaces something we have to change in code (e.g., they want a different OAuth scope set), that should be a separate, narrowly-scoped change.
|
||||
- Not changing how `wahoo-import` or `wahoo-route-push` work — those specs are unaffected.
|
||||
- Not introducing new providers or generalizing the sync framework.
|
||||
|
||||
## Decisions
|
||||
|
||||
**Treat this as an ops checklist, not a spec change.** Sandbox vs production is invisible to the application: same base URL (`api.wahooligan.com`), same OAuth endpoints, same response shapes. The only deltas are rate-limit ceilings and what Wahoo's UI shows. So the deliverable is a `tasks.md` that we work through, with the codebase untouched.
|
||||
|
||||
**Plan for a new production app, not in-place promotion.** Best read of Wahoo's flow is that production requires registering a separate app, which means new `client_id`/`client_secret` and a forced reauthorization for every currently-connected user. This is not data-destructive (we keep no Wahoo state besides tokens), but it is a one-time UX disruption we should announce. The sandbox app stays available as a fallback during cutover.
|
||||
|
||||
**Don't pre-empt Wahoo's review.** Submit, then react to whatever they ask. We have no signal that any spec or behavior needs to change before they look.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **[Expected] Forced reauthorization for all current users.**
|
||||
Mitigation: pre-announce in release notes / status page; the connect UI is already prominent. Token revocation on our side already handles the case where a refresh fails (the user just sees "disconnected" and reconnects). Treat this as planned, not a surprise.
|
||||
- **[Risk] Production rate limits still aren't enough at scale.**
|
||||
Mitigation: 5000/day across all users is generous for the current user count, but if the user base 10×s we'd need to ask Wahoo for a higher tier or move workouts polling behind webhooks (we already accept webhooks; we just don't depend on them yet).
|
||||
- **[Risk] Logo not surfaced even after upload (Wahoo-side propagation delay).**
|
||||
Mitigation: verify by pushing a fresh route from a real account ~24h after logo upload; if not visible, email Wahoo support.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
This is a coordinated external/internal cutover, not a code deploy.
|
||||
|
||||
1. Submit production review request via Wahoo developer console.
|
||||
2. Wait for approval; respond to any questions.
|
||||
3. On approval:
|
||||
- If credentials change: rotate `WAHOO_CLIENT_ID` / `WAHOO_CLIENT_SECRET` in `infrastructure/secrets.app.env`, redeploy.
|
||||
- If redirect URI changed (it shouldn't): re-register on Wahoo's side.
|
||||
4. Smoke-test from a real account: connect, import a workout, push a route, disconnect.
|
||||
5. Update CLAUDE.md / `.env.example` to note the app is on the production tier.
|
||||
|
||||
**Rollback:** if anything in step 3 breaks, revert the secrets to the sandbox credentials and redeploy. Sandbox app continues to work; users just see lower throughput.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- What does Wahoo require in the production-review submission (description fields, screenshots, privacy policy URL, app store listing)? Need to discover this when filling out the request form.
|
||||
- What's Wahoo's expected SLA on production review? Affects when we announce the forced-reconnect to users.
|
||||
30
openspec/changes/wahoo-production-cutover/proposal.md
Normal file
30
openspec/changes/wahoo-production-cutover/proposal.md
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
## Why
|
||||
|
||||
The trails.cool Wahoo Cloud API integration is currently registered as a **sandbox** application. Sandbox apps are rate-limited (250 req/day, 100/hr, 25 per 5 min) and Wahoo does not surface our logo or treat us as a first-class fitness app. To support real users at scale we need to be promoted to the production tier, which requires a Wahoo-side review of our app, scopes, redirect URI, and branding assets — none of which are visible in this codebase. This change captures the cutover as an auditable checklist so we don't lose track of the external steps and so we have a record of the sandbox-only quirks we already worked around.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Submit our Wahoo app for production review and obtain production rate-limit tier (sandbox 250/day → production 5000/day; 25 per 5 min → 200; 100/hr → 1000/hr).
|
||||
- Confirm all five requested scopes are configured on the production app: `workouts_read`, `user_read`, `offline_data`, `routes_read`, `routes_write`.
|
||||
- Upload a trails.cool logo to Wahoo's developer console so it appears next to routes pushed by us in the Wahoo mobile app (logo is keyed off `fitness_app_id`, currently `2156` in sandbox).
|
||||
- Verify the production app's redirect URI is exactly `https://trails.cool/api/sync/callback/wahoo` (Wahoo is byte-strict on this).
|
||||
- Register a separate production app (Wahoo issues new `client_id`/`client_secret`) and plan a coordinated reauthorization for already-connected users — every existing Wahoo connection breaks at cutover and users are prompted to reconnect. Document the user-visible disruption.
|
||||
- Document, for posterity, the sandbox-only workarounds we already shipped so future maintainers don't repeat the discovery cost: per-(app, user) unrevoked-token cap (revoke on disconnect — #346), `route[file]` must be a `data:application/vnd.fit;base64,...` URI (#349), FIT Course `capabilities` must include `valid|distance|position` not just `time` (#348).
|
||||
|
||||
This is an operations change, not a code change. No spec deltas — `wahoo-import` and `wahoo-route-push` describe behaviors that don't change between sandbox and production.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
<!-- None -->
|
||||
|
||||
### Modified Capabilities
|
||||
<!-- None: this is an external/operational cutover. -->
|
||||
|
||||
## Impact
|
||||
|
||||
- **No application code changes.**
|
||||
- **Secrets**: `infrastructure/secrets.app.env` is updated with the new production `client_id`/`client_secret` and the journal is redeployed.
|
||||
- **Existing users**: every connected Wahoo user has to reauthorize once at cutover; the disconnect/connect UX already handles this, but we should communicate it ahead of time.
|
||||
- **Wahoo developer console**: branding (logo), redirect URI, scopes — all set externally.
|
||||
- **Documentation**: `apps/journal/.env.example` and CLAUDE.md may want a one-line note about which tier the app is on.
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
## MODIFIED Requirements
|
||||
|
||||
### 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`. The FIT bytes SHALL be sent in the `route[file]` form field as a data URI of the form `data:application/vnd.fit;base64,<base64>` — Wahoo silently discards routes whose `route[file]` is plain base64 (the route metadata is stored but the track is not). The encoded FIT Course SHALL set the COURSE message `capabilities` field to a bitmask containing at least `valid | distance | position` (`0x1A`); a `time`-only capabilities value (`0x04`) causes consuming devices and the Wahoo app to render the route without a track. The encoded LAP message SHALL have non-zero `total_elapsed_time` and `total_timer_time` derived from the record stream.
|
||||
|
||||
#### 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`, encodes the result as `data:application/vnd.fit;base64,<base64>`, 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** the produced FIT Course advertises capabilities including `valid | distance | position`, not `time` alone
|
||||
- **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
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Revoke Wahoo tokens on disconnect
|
||||
When a user disconnects their Wahoo account from trails.cool, the Journal SHALL revoke the access token at Wahoo via `DELETE https://api.wahooligan.com/v1/permissions` before deleting the local `sync_connections` row. Wahoo enforces a per-(app, user) cap on unrevoked access tokens; failing to revoke causes subsequent reconnect attempts to fail with `"Too many unrevoked access tokens exist for this app and user"` until the user manually revokes via the Wahoo app or website.
|
||||
|
||||
#### Scenario: Disconnect with a live token
|
||||
- **WHEN** the user submits the disconnect form for a Wahoo connection that has a non-expired access token
|
||||
- **THEN** the server calls `DELETE /v1/permissions` with the bearer token before deleting the local row
|
||||
- **AND** the local `sync_connections` row is deleted regardless of whether the revoke call succeeded (best-effort)
|
||||
|
||||
#### Scenario: Disconnect when revoke fails
|
||||
- **WHEN** the revoke call returns a non-2xx response (e.g., the token is already expired)
|
||||
- **THEN** the server logs a warning and proceeds to delete the local row
|
||||
- **AND** the user sees the same disconnect confirmation as on a successful revoke
|
||||
|
||||
### Requirement: Surface Wahoo-side OAuth errors to the user
|
||||
The Journal SHALL distinguish Wahoo's `"Too many unrevoked access tokens"` failure during OAuth code exchange from generic OAuth failures and display a localized banner on `/settings/connections` instructing the user to revoke trails.cool in the Wahoo app and try again.
|
||||
|
||||
#### Scenario: Token-cap error during connect
|
||||
- **WHEN** the user completes the Wahoo authorize flow and the token exchange returns a 400 whose body matches `"Too many unrevoked access tokens"`
|
||||
- **THEN** the user is redirected to `/settings/connections?error=too_many_tokens`
|
||||
- **AND** the page renders a red banner explaining the cap and pointing the user at the Wahoo app to revoke
|
||||
|
||||
#### Scenario: Generic connect error
|
||||
- **WHEN** the token exchange returns any other non-2xx status
|
||||
- **THEN** the user is redirected to `/settings/connections?error=generic` (or the legacy `sync_failed`)
|
||||
- **AND** the page renders a generic "Couldn't connect — try again" banner
|
||||
35
openspec/changes/wahoo-production-cutover/tasks.md
Normal file
35
openspec/changes/wahoo-production-cutover/tasks.md
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
## 1. Pre-submission audit
|
||||
|
||||
- [ ] 1.1 Capture the current sandbox `fitness_app_id` (2156 at time of writing) and `client_id` for cross-reference after cutover.
|
||||
- [ ] 1.2 Smoke-test connect / import / push / disconnect against sandbox one more time so we have a known-good baseline.
|
||||
|
||||
## 2. Register and submit the production app
|
||||
|
||||
- [ ] 2.1 Register a new production app in the Wahoo developer console (separate from the sandbox app).
|
||||
- [ ] 2.2 Configure the production app's redirect URI as exactly `https://trails.cool/api/sync/callback/wahoo` and select all five scopes we use (`workouts_read`, `user_read`, `offline_data`, `routes_read`, `routes_write`).
|
||||
- [ ] 2.3 Upload a trails.cool logo asset suitable for display next to routes/workouts in the Wahoo mobile app.
|
||||
- [ ] 2.4 Submit the production review request with a concise description of trails.cool's use of the API (route planning + workout sync) and whatever supporting material Wahoo's form requires; capture that material list in `infrastructure/wahoo-production-app.md` so we have a paper trail.
|
||||
|
||||
## 3. React to Wahoo's review feedback
|
||||
|
||||
- [ ] 3.1 Respond to any clarification requests from Wahoo within 1 business day to keep the queue moving.
|
||||
- [ ] 3.2 If Wahoo asks for spec/policy artifacts (privacy policy URL, screenshots, etc.), gather and resubmit; track each request as it comes in.
|
||||
|
||||
## 4. Cut over to production
|
||||
|
||||
- [ ] 4.1 Pre-announce the forced reconnect: short notice in release notes / status, plus an in-app banner on `/settings/connections` for users with an existing Wahoo connection.
|
||||
- [ ] 4.2 Rotate `WAHOO_CLIENT_ID` and `WAHOO_CLIENT_SECRET` in `infrastructure/secrets.app.env` (SOPS-encrypted) to the new production credentials and redeploy via `cd-apps.yml`.
|
||||
- [ ] 4.3 After deploy, confirm that existing `sync_connections` rows fail their next refresh cleanly and the user lands on the connect screen (rather than a stuck/error state).
|
||||
|
||||
## 5. Post-cutover verification
|
||||
|
||||
- [ ] 5.1 From a real Wahoo account, run the full happy path: connect → import a workout → push a route → confirm the route renders in the Wahoo mobile app with the trails.cool logo → disconnect.
|
||||
- [ ] 5.2 Inspect the pushed route via `GET /v1/routes/:id` and confirm `file.url` is non-null and `fitness_app_id` matches the production app id.
|
||||
- [ ] 5.3 Wait ~24h, then confirm the trails.cool logo is visible in the Wahoo app for the test route. If not, email Wahoo support.
|
||||
- [ ] 5.4 Verify rate-limit headers (`X-RateLimit-Limit`) on a routine API call show the production tier (e.g., 5000 daily).
|
||||
|
||||
## 6. Documentation updates
|
||||
|
||||
- [ ] 6.1 Update `apps/journal/.env.example` with a one-line note that `WAHOO_CLIENT_ID` / `WAHOO_CLIENT_SECRET` are production-tier credentials.
|
||||
- [ ] 6.2 Update `CLAUDE.md` (or wherever the local-HTTPS Wahoo note lives) to note the production tier and link this change for context.
|
||||
- [ ] 6.3 Archive this change via `/opsx:archive` once verification is complete.
|
||||
2
openspec/changes/wahoo-route-update/.openspec.yaml
Normal file
2
openspec/changes/wahoo-route-update/.openspec.yaml
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
schema: spec-driven
|
||||
created: 2026-05-01
|
||||
69
openspec/changes/wahoo-route-update/design.md
Normal file
69
openspec/changes/wahoo-route-update/design.md
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
## Context
|
||||
|
||||
`wahoo-route-push` currently POSTs each route version as a fresh route. The push pipeline:
|
||||
|
||||
1. Looks up `sync_pushes` keyed by `(user_id, route_id, route_version, provider)`.
|
||||
2. If a successful row exists for the exact version, returns the existing `remote_id` (no-op).
|
||||
3. Otherwise calls `wahooProvider.pushRoute()` which always `POST`s `/v1/routes` with `external_id = route:<routeId>:v<version>`.
|
||||
|
||||
Wahoo confirmed in their docs that POST with a duplicate `external_id` does *not* update — only `PUT /v1/routes/:id` does. So even though our `external_id` is "stable per version", it does not help us update across versions; the second push of the same version is the only thing it dedupes (which is also already covered by our local `sync_pushes` check).
|
||||
|
||||
The user-visible problem is exactly the case the current spec doesn't address: re-pushing a *new* version. Today that creates a duplicate Wahoo route. We need to detect "we already have a remote_id for this route" and PUT to it.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- After this change, editing a route locally and re-pushing it updates the Wahoo route in place: same Wahoo URL, same id in the Wahoo app, just refreshed track and metadata.
|
||||
- Idempotency for retrying a failed push of the *same* version still works.
|
||||
- Existing successfully-pushed routes (one row per old version on our side) collapse cleanly to one row per logical route without losing the `remote_id` of the most recent push.
|
||||
|
||||
**Non-Goals:**
|
||||
- Not changing how the user triggers a push (button stays the same).
|
||||
- Not deleting old Wahoo routes that were already duplicated by previous behavior — users can clean those up in the Wahoo app. We just stop creating new duplicates.
|
||||
- Not generalizing this to other providers — the sync framework allows per-provider update semantics, but only Wahoo needs it now.
|
||||
|
||||
## Decisions
|
||||
|
||||
**Switch idempotency key from `(user, route, version, provider)` to `(user, route, provider)`.** A logical Wahoo route is per-route, not per-version. The version that's currently on Wahoo is a property of that row (`last_pushed_version`), not a key. This matches the new "PUT or POST" branching we need.
|
||||
|
||||
**Send `external_id = route:<routeId>` (drop the version suffix).** The `external_id` is meant to be a stable third-party id; "the route" is what's stable, not "this exact edit of the route". This also makes the data correct if a future Wahoo behavior change starts respecting `external_id` for upserts.
|
||||
|
||||
**POST on first push, PUT on subsequent pushes.** Decision rule:
|
||||
- If there's no `sync_pushes` row for `(user, route, provider)` → POST `/v1/routes` (and store the returned `remote_id`).
|
||||
- If a row exists with a non-null `remote_id` → PUT `/v1/routes/<remote_id>`.
|
||||
- If a row exists with a null `remote_id` (previous push failed) → POST again, just like a fresh push.
|
||||
|
||||
If a PUT returns 404 (the user deleted the Wahoo route from their side), fall back to POST and overwrite `remote_id`. This keeps the system self-healing.
|
||||
|
||||
**Version handling on the row.** The single `sync_pushes` row tracks `last_pushed_version` (integer) and `pushed_at` (timestamp). The route detail page compares `last_pushed_version` to the local `routes.current_version`:
|
||||
- equal → "On Wahoo (vN, sent at …)"
|
||||
- local newer → "On Wahoo (vN-1) · Send updated version"
|
||||
- local older (shouldn't happen, but…) → fall back to the equal case.
|
||||
|
||||
**Migration: collapse existing rows.** For each `(user_id, route_id, provider='wahoo')` group, keep only the row with the highest `route_version` and copy that version into the new `last_pushed_version` column. Drop the rest. Failed rows for already-pushed routes are dropped entirely (they're not reachable for retry under the new key anyway). No external Wahoo state changes — the `remote_id`s on the kept rows are still the live Wahoo route ids.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **[Risk] Wahoo's PUT requires fields we currently send on POST (e.g., file, name, distance) and rejects partials silently.**
|
||||
Mitigation: send the same body shape we send on POST. Test against a real Wahoo account before flipping the default.
|
||||
- **[Risk] PUT against a deleted Wahoo route returns 404 and confuses the user.**
|
||||
Mitigation: catch 404, fall back to POST automatically, log the recovery. Already in the decision rule.
|
||||
- **[Risk] Schema migration deletes a sync_pushes row a user might want to retry.**
|
||||
Mitigation: only collapse rows where the *highest-versioned* row was successful. If the highest version's row failed, keep that row (it's still the canonical one under the new key) and drop only strictly older rows.
|
||||
- **[Trade-off] We lose per-version visibility ("which of my edits made it to Wahoo and when").**
|
||||
Acceptable: the route detail page is already version-aware locally, and Wahoo doesn't store our versions, only its own copy. If we ever want a full push history, add a `sync_push_log` table later.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. Add `last_pushed_version` column to `sync_pushes` (nullable initially).
|
||||
2. Backfill `last_pushed_version` from the existing `route_version` column on each row.
|
||||
3. Collapse rows: per `(user, route, provider)` group, keep the highest-version row, delete the rest.
|
||||
4. Drop the `route_version` column from the unique constraint, add a unique index on `(user_id, route_id, provider)`.
|
||||
5. Drop the `route_version` column itself.
|
||||
6. Ship the code change to send PUT instead of POST when a `remote_id` exists.
|
||||
|
||||
This is one Drizzle migration. The old code path (per-version rows) and new code path (per-route rows) cannot coexist on the same DB, so we ship migration + code change together.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- Does Wahoo's PUT response include the same fields as POST (`id`, etc.) so we can keep the same parsing path? Likely yes — sanity-check during implementation.
|
||||
24
openspec/changes/wahoo-route-update/proposal.md
Normal file
24
openspec/changes/wahoo-route-update/proposal.md
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
## Why
|
||||
|
||||
When a user pushes a route to Wahoo, edits the route, and pushes again, today we POST a brand-new route to `/v1/routes`. Wahoo treats it as unrelated to the first push (it does not de-duplicate by `external_id`), so the user's Wahoo route library accumulates one entry per version. The user expects "edit and re-send" to *update* the route they already saved on Wahoo, not pile new copies on top.
|
||||
|
||||
## What Changes
|
||||
|
||||
- When the Journal pushes a route version and a previous version of the same route was already successfully pushed to Wahoo, the Journal SHALL `PUT /v1/routes/:id` against the previously stored `remote_id` instead of posting a new route.
|
||||
- Change the `external_id` sent to Wahoo from per-version (`route:<routeId>:v<version>`) to per-route (`route:<routeId>`). The version is still tracked locally for idempotency and "what's currently on Wahoo" display, but `external_id` should identify the logical route.
|
||||
- Restructure `sync_pushes` semantics so a single logical Wahoo route maps to one row per `(user_id, route_id, provider)` whose `last_pushed_version` and `pushed_at` reflect the latest successful push, instead of one row per `(user_id, route_id, route_version, provider)`.
|
||||
- The "Already on Wahoo" UI now shows the version Wahoo currently has, with a "Send updated version" CTA when the local route is newer.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
<!-- None -->
|
||||
|
||||
### Modified Capabilities
|
||||
- `wahoo-route-push`: push pipeline switches from POST-per-version to POST-then-PUT, idempotency key narrows from version-scoped to route-scoped, and the `external_id` format changes.
|
||||
|
||||
## Impact
|
||||
|
||||
- **Code**: `apps/journal/app/lib/sync/providers/wahoo.ts` (add update path); `apps/journal/app/lib/sync/pushes.server.ts` (decide POST vs PUT based on existing `sync_pushes` row); `apps/journal/app/routes/api.sync.push.$provider.$routeId.ts` (no change in surface, internals only); `sync_pushes` table schema (drop `route_version` from the unique key, add `last_pushed_version` column).
|
||||
- **Data migration**: existing `sync_pushes` rows are per-version; we collapse them so the highest-versioned row per `(user, route, provider)` becomes the canonical row and lower-versioned rows are deleted.
|
||||
- **No changes** to authentication, scope set, or the Wahoo data URI / FIT capabilities work shipped in the cutover prep.
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Server-side route push pipeline
|
||||
The Journal SHALL convert the current route version to a FIT Course file and send it to Wahoo, recording the outcome in `sync_pushes`. When no successful push exists for the `(user_id, route_id, provider='wahoo')` tuple, the request is `POST https://api.wahooligan.com/v1/routes`. When a successful push exists with a non-null `remote_id`, the request is `PUT https://api.wahooligan.com/v1/routes/<remote_id>` so subsequent pushes update the existing Wahoo route in place rather than creating duplicates. If a PUT returns 404 (the user deleted the Wahoo route on their side), the server SHALL fall back to POST and overwrite `remote_id` with the response. The body shape is identical for POST and PUT and matches the existing field set (`external_id`, `provider_updated_at`, `name`, `workout_type_family_id`, `start_lat`, `start_lng`, `distance`, `ascent`, `route[file]` data URI).
|
||||
|
||||
#### Scenario: First push of a route
|
||||
- **WHEN** the route owner triggers the push action and no `sync_pushes` row exists for `(user, route, wahoo)`
|
||||
- **THEN** the server reads the route's GPX, runs `gpxToFitCourse`, encodes the result as `data:application/vnd.fit;base64,<base64>`, and POSTs it to `/v1/routes`
|
||||
- **AND** on a 2xx response, a `sync_pushes` row is inserted with `pushed_at = now()`, `remote_id` set from Wahoo's response, `last_pushed_version` set to the local route version, and `error = null`
|
||||
- **AND** the user is shown a "Sent to Wahoo" confirmation
|
||||
|
||||
#### Scenario: Re-push after editing the route
|
||||
- **WHEN** the route owner edits a previously-pushed route, then triggers the push action
|
||||
- **AND** a `sync_pushes` row exists for `(user, route, wahoo)` with non-null `remote_id`
|
||||
- **THEN** the server PUTs the new FIT and metadata to `/v1/routes/<remote_id>` (not POST)
|
||||
- **AND** on a 2xx response, the existing `sync_pushes` row is updated in place: `pushed_at = now()`, `last_pushed_version` advanced to the new local version, `error = null`
|
||||
- **AND** the Wahoo route id remains the same (no duplicate route created on Wahoo's side)
|
||||
|
||||
#### Scenario: PUT against a deleted Wahoo route falls back to POST
|
||||
- **WHEN** the server PUTs to `/v1/routes/<remote_id>` and Wahoo returns 404
|
||||
- **THEN** the server retries the request as POST `/v1/routes`
|
||||
- **AND** the existing `sync_pushes` row is updated with the new `remote_id` from the POST response
|
||||
|
||||
#### Scenario: Wahoo returns an error
|
||||
- **WHEN** Wahoo responds with a non-2xx status that is not the 404 fallback
|
||||
- **THEN** the `sync_pushes` row is created or updated in place with `error` populated and `pushed_at` unchanged
|
||||
- **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 or modified
|
||||
|
||||
### Requirement: Push idempotency per route
|
||||
Each `(user_id, route_id, provider)` SHALL map to at most one `sync_pushes` row. Re-clicking the button when the local version equals `last_pushed_version` and the row's `pushed_at` is non-null SHALL be a no-op surfacing the existing remote id; clicking after editing (local version > `last_pushed_version`) SHALL trigger an update via PUT.
|
||||
|
||||
#### Scenario: Re-push of an already-pushed unchanged route
|
||||
- **WHEN** the route owner clicks "Send to Wahoo" and the existing `sync_pushes` row has `last_pushed_version` equal to the current route version and a non-null `pushed_at`
|
||||
- **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 row
|
||||
|
||||
#### Scenario: Push after editing the route (new version)
|
||||
- **WHEN** the route owner edits the route (incrementing the local version) and clicks "Send to Wahoo"
|
||||
- **THEN** the server takes the PUT path against the existing `remote_id`
|
||||
- **AND** exactly one `sync_pushes` row exists for `(user, route, wahoo)` after the operation completes
|
||||
|
||||
#### Scenario: Retry after a failed push
|
||||
- **WHEN** the existing `sync_pushes` row has `pushed_at = null` and `error` populated
|
||||
- **THEN** clicking "Send to Wahoo" retries — POST if `remote_id` is null, PUT if `remote_id` is non-null
|
||||
- **AND** the existing row is updated in place (no duplicate row)
|
||||
|
||||
### Requirement: Stable external_id per route
|
||||
The `external_id` field sent to Wahoo SHALL be deterministic per `(route_id)` so that the value identifies the *logical* route, not a specific edit of it. This makes the source-of-truth identifier stable across re-pushes.
|
||||
|
||||
#### Scenario: External id is deterministic
|
||||
- **WHEN** the server constructs the Wahoo payload for any version of a route
|
||||
- **THEN** `external_id` is set to `route:<route_id>` (no version suffix)
|
||||
- **AND** subsequent pushes of the same route — including PUTs after edits — send the same `external_id`
|
||||
|
||||
### Requirement: Push status surfaced on the route detail page
|
||||
The route detail page SHALL show whether the route has been pushed to Wahoo and whether the local version is newer than what Wahoo currently has.
|
||||
|
||||
#### Scenario: Local version matches Wahoo's
|
||||
- **WHEN** the route owner views a route whose `sync_pushes` row has `last_pushed_version` equal to the current local version and a non-null `pushed_at`
|
||||
- **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: Local version is newer than Wahoo's
|
||||
- **WHEN** the route owner views a route whose `last_pushed_version` is less than the current local version
|
||||
- **THEN** the page shows "On Wahoo (v<last_pushed_version>) — local version is newer"
|
||||
- **AND** the action button reads "Send updated version" and triggers the same push action (which will PUT)
|
||||
|
||||
#### Scenario: Push failed previously shows retry affordance
|
||||
- **WHEN** the route owner views a route whose `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
|
||||
33
openspec/changes/wahoo-route-update/tasks.md
Normal file
33
openspec/changes/wahoo-route-update/tasks.md
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
## 1. Schema migration
|
||||
|
||||
- [ ] 1.1 Add `last_pushed_version` (integer, nullable) to `sync_pushes` in the Drizzle schema.
|
||||
- [ ] 1.2 Write a Drizzle migration that backfills `last_pushed_version` from `route_version` for every existing row.
|
||||
- [ ] 1.3 Extend the migration to collapse rows: per `(user_id, route_id, provider)` group, keep the row with the highest `route_version` and delete the rest. If the highest-versioned row failed, keep it (it's still the canonical one under the new key).
|
||||
- [ ] 1.4 Drop the unique constraint that includes `route_version`; add a unique index on `(user_id, route_id, provider)`.
|
||||
- [ ] 1.5 Drop the `route_version` column.
|
||||
- [ ] 1.6 Run the migration locally against a copy of prod data (or a synthesized dataset that has multiple versions) and verify counts.
|
||||
|
||||
## 2. Provider plumbing
|
||||
|
||||
- [ ] 2.1 Extend `wahooProvider.pushRoute` (or add a sibling `updateRoute`) so the wahoo provider exposes a way to PUT against an existing remote id; share the body construction with the POST path.
|
||||
- [ ] 2.2 Treat a 404 response from PUT as a signal to fall back to POST and return the new `remote_id` to the caller.
|
||||
- [ ] 2.3 Update the wahoo provider's unit tests: a successful PUT, a 404 PUT that falls back to a successful POST, and the existing POST/error matrix.
|
||||
|
||||
## 3. Push pipeline
|
||||
|
||||
- [ ] 3.1 In `pushes.server.ts`, look up `sync_pushes` by `(user_id, route_id, provider)` instead of `(…, route_version, …)`.
|
||||
- [ ] 3.2 If the row has a non-null `remote_id`, call the provider's update path; otherwise call the create path.
|
||||
- [ ] 3.3 On success, upsert the row with `last_pushed_version` set to the just-pushed local version, `pushed_at = now()`, `error = null`.
|
||||
- [ ] 3.4 Change `external_id` construction to `route:<route_id>` (drop the `:v<version>` suffix).
|
||||
- [ ] 3.5 Update existing pushes.server tests to assert PUT is used on the second push and that exactly one row exists afterwards.
|
||||
|
||||
## 4. UI
|
||||
|
||||
- [ ] 4.1 Update the route detail page status block to compare `last_pushed_version` with the local route's current version and render the three states (matches, local newer, failed).
|
||||
- [ ] 4.2 i18n: add "On Wahoo (v{{n}}) — local version is newer" and "Send updated version" strings (en + de).
|
||||
- [ ] 4.3 E2E test: connect Wahoo (mocked), push v1, edit the route to v2, push again, assert the request was a PUT to the same remote id and the UI returns to "Sent to Wahoo on <date>".
|
||||
|
||||
## 5. Spec & docs
|
||||
|
||||
- [ ] 5.1 Verify `openspec validate wahoo-route-update` passes.
|
||||
- [ ] 5.2 After merge, run `/opsx:archive` to fold the delta into `openspec/specs/wahoo-route-push/spec.md`.
|
||||
Loading…
Add table
Add a link
Reference in a new issue