Add Wahoo push action route + OAuth callback resume

Adds /api/sync/push/:provider/:routeId as the user-triggered entry
point for pushing a route. The route action delegates to a new
pushes.server.ts pipeline that:

- resolves the latest route_versions row and uses that GPX (not
  routes.gpx) so the bytes Wahoo gets match the snapshot the user
  sees
- short-circuits on (user, route, version, provider) idempotency:
  a successful prior push returns the existing remote_id without
  re-calling Wahoo
- detects scope_missing before hitting Wahoo and redirects through
  getAuthUrl with a base64url-encoded state carrying pushAfter +
  returnTo
- refreshes tokens once on PushError({ code: "token_expired" }) and
  retries the push, then updates sync_connections in place
- records every outcome in sync_pushes (insert on first attempt,
  update on retry) so the UI can show success/failure state

The OAuth callback handler now decodes the state, resumes a
pushAfter pipeline server-side after exchangeCode, and handles the
?error=access_denied path with a needs_permission notice.

Also flips packages/fit/src/fitsdk.d.ts to a regular .ts side-effect
shim so journal's tsc picks up the @garmin/fitsdk module declaration
when consuming the workspace package via source.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-04-30 22:41:24 +02:00
parent 5b0bf40b97
commit a9c0093877
No known key found for this signature in database
GPG key ID: A32FF691A0F752D9
8 changed files with 526 additions and 12 deletions

View file

@ -31,17 +31,17 @@
## 5. Push action route and idempotency layer
- [ ] 5.1 Add an action route at `/api/sync/push/wahoo/:routeId` that resolves the route, verifies the requesting user is the owner, looks up the user's Wahoo connection, and runs the push
- [ ] 5.2 Before calling Wahoo, check `sync_connections.granted_scopes` for `routes_write`; if missing, redirect to `getAuthUrl` with state `{ return_to, push_after }`
- [ ] 5.3 Look up an existing `sync_pushes` row for `(user_id, route_id, route_version, 'wahoo')` — if `pushed_at` is set, return the existing `remote_id` without calling Wahoo
- [ ] 5.4 On a fresh push, build the payload (`external_id = "route:<routeId>:v<version>"`, current timestamp, route name/description, start lat/lng, computed distance and ascent), call `provider.pushRoute`, and write the result to `sync_pushes` (insert on success, update existing row on retry of a failed attempt)
- [ ] 5.5 Map error types to user-facing copy: scope missing → re-auth redirect; token expired after refresh → reconnect prompt; 422 validation → "We couldn't send this route — try editing the name or description"; 5xx / network → generic retry
- [x] 5.1 Add an action route at `/api/sync/push/wahoo/:routeId` that resolves the route, verifies the requesting user is the owner, looks up the user's Wahoo connection, and runs the push
- [x] 5.2 Before calling Wahoo, check `sync_connections.granted_scopes` for `routes_write`; if missing, redirect to `getAuthUrl` with state `{ return_to, push_after }`
- [x] 5.3 Look up an existing `sync_pushes` row for `(user_id, route_id, route_version, 'wahoo')` — if `pushed_at` is set, return the existing `remote_id` without calling Wahoo
- [x] 5.4 On a fresh push, build the payload (`external_id = "route:<routeId>:v<version>"`, current timestamp, route name/description, start lat/lng, computed distance and ascent), call `provider.pushRoute`, and write the result to `sync_pushes` (insert on success, update existing row on retry of a failed attempt)
- [x] 5.5 Map error types to user-facing copy: scope missing → re-auth redirect; token expired after refresh → reconnect prompt; 422 validation → "We couldn't send this route — try editing the name or description"; 5xx / network → generic retry
## 6. OAuth callback resumes pending push
- [ ] 6.1 Extend the OAuth callback handler at `/api/sync/callback/wahoo` to parse `state.push_after` and `state.return_to`
- [ ] 6.2 If `push_after`, after persisting the new tokens and scopes, run the push pipeline server-side and redirect to `return_to` with a success or failure flash message
- [ ] 6.3 If the user denied the new scope (callback arrives with `?error=access_denied`), redirect to `return_to` with the "needs route permission" notice
- [x] 6.1 Extend the OAuth callback handler at `/api/sync/callback/wahoo` to parse `state.push_after` and `state.return_to`
- [x] 6.2 If `push_after`, after persisting the new tokens and scopes, run the push pipeline server-side and redirect to `return_to` with a success or failure flash message
- [x] 6.3 If the user denied the new scope (callback arrives with `?error=access_denied`), redirect to `return_to` with the "needs route permission" notice
## 7. Route detail page UI
@ -60,8 +60,8 @@
## 9. Tests
- [x] 9.1 Unit tests for `gpxToFitCourse` — already covered in §1.7
- [ ] 9.2 Unit tests for the action route's idempotency logic (mock Wahoo HTTP layer): fresh push, re-push of pushed version, retry of failed push, push of new version after edit
- [ ] 9.3 Unit tests for the scope-mismatch redirect flow
- [x] 9.2 Unit tests for the action route's idempotency logic (mock Wahoo HTTP layer): fresh push, re-push of pushed version, retry of failed push, push of new version after edit
- [x] 9.3 Unit tests for the scope-mismatch redirect flow
- [ ] 9.4 E2E test in `e2e/`: log in as a user with a (mocked) Wahoo connection, open a route detail page, click "Send to Wahoo", assert the success toast and the "Sent to Wahoo on …" status appear
- [ ] 9.5 E2E test for the re-auth flow: user without `routes_write` clicks Send to Wahoo, redirects to OAuth (mocked), returns, push completes, status renders