Fix wahoo-route-push spec: correct schema path and id types

Update sync_pushes schema to use text ids matching the existing journal
tables, and fix the schema path references to packages/db/src/schema/journal.ts.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-04-30 22:03:28 +02:00
parent efe70c2c3a
commit 4a97ef6a10
No known key found for this signature in database
GPG key ID: A32FF691A0F752D9
4 changed files with 16 additions and 14 deletions

View file

@ -1,6 +1,6 @@
## Context
The Journal already runs a one-way sync with Wahoo: webhooks deliver `workout_summary` events, we download the FIT, decode it with `fit-file-parser`, and store a Journal activity. The Planner emits GPX as the route exchange format and the Journal stores route geometry as PostGIS LineStrings (see `apps/journal/app/lib/sync/providers/wahoo.ts`, `apps/journal/db/schema.ts`).
The Journal already runs a one-way sync with Wahoo: webhooks deliver `workout_summary` events, we download the FIT, decode it with `fit-file-parser`, and store a Journal activity. The Planner emits GPX as the route exchange format and the Journal stores route geometry as PostGIS LineStrings (see `apps/journal/app/lib/sync/providers/wahoo.ts`, `packages/db/src/schema/journal.ts`).
Wahoo's `POST /v1/routes` accepts a base64-encoded FIT **Course** file (different message profile from a workout/activity) plus `external_id`, `provider_updated_at`, `name`, `workout_type_family_id`, `start_lat`, `start_lng`, `distance`, `ascent`, optionally `description`, `descent`, `filename`. The OAuth scope `routes_write` is required, which the existing connection does not hold. Wahoo confirms routes uploaded this way reach the Wahoo App and ELEMNT/BOLT/ROAM head units; only the legacy ELEMNT App does not see them.
@ -56,12 +56,12 @@ export function gpxToFitCourse(input: {
```
sync_pushes (
id uuid pk,
user_id uuid not null,
route_id uuid not null,
id text pk,
user_id text not null references users(id),
route_id text not null references routes(id),
route_version int not null,
provider text not null, -- 'wahoo'
external_id text not null, -- our own UUID, stable per (route, version)
external_id text not null, -- stable per (route, version)
remote_id text, -- Wahoo's route id, populated on success
pushed_at timestamptz,
error text, -- last error if push failed
@ -69,7 +69,9 @@ sync_pushes (
)
```
The `external_id` we send to Wahoo is `route:<route_id>:v<version>` — deterministic, so two attempts to push the same route version produce the same `external_id` and Wahoo's de-dup applies if our own check ever races. The user-facing rule: each *version* of a route can be pushed once per provider. Editing the route bumps the version (existing behavior in `route-management`) and unlocks a fresh push.
The `external_id` we send to Wahoo is `route:<route_id>:v<version>` — deterministic, so two attempts to push the same route version produce the same `external_id` and Wahoo's de-dup applies if our own check ever races. The user-facing rule: each *version* of a route can be pushed once per provider. Editing the route appends a new row to `route_versions` (existing behavior in `route-management`) and unlocks a fresh push.
**What "current version" means**: The `routes` table has no `version` column; versions live in the existing `route_versions` table keyed by `(route_id, version)`. At push time we resolve the current version as `MAX(route_versions.version) WHERE route_id = ?` and use that integer for both the `sync_pushes.route_version` column and the `external_id` suffix. The push reads the GPX from that `route_versions` row, not from `routes.gpx`, so the bytes we send are exactly the snapshot the user is looking at.
### 4. Re-auth on scope upgrade is explicit, not silent
@ -89,7 +91,7 @@ The `external_id` we send to Wahoo is `route:<route_id>:v<version>` — determin
- **FIT Course encoder bugs send unusable files to bike computers** → Mitigation: round-trip every encoded file through `fit-file-parser` in tests and assert track-point parity with the input GPX. Add a fixture set of real-world routes (short, long, with/without elevation, multi-day) to `packages/fit/__fixtures__/` and snapshot the round-trip output. Manual smoke test on at least one ELEMNT before merging.
- **Wahoo API rate limits unknown** → Mitigation: log every push outcome to `sync_pushes.error`. If we observe rate limiting in dev, add a per-user 1-push-per-second throttle. Defer until observed.
- **Routes without GPS geometry can't be pushed** (e.g. a route created from a handwritten description) → Mitigation: button is hidden when `route.geojson` is null; action route 422s with a clear message if it slips through.
- **Routes without GPS geometry can't be pushed** (e.g. a route created from a handwritten description) → Mitigation: button is hidden when `routes.geom` is null or `routes.gpx` is empty; action route 422s with a clear message if it slips through.
- **Wahoo deletes a pushed route on their side** → We don't reconcile. Re-pushing requires editing the route to bump the version. Acceptable for v1; revisit if it's a real complaint.
- **`routes_write` scope rejection by Wahoo for new OAuth apps** → Wahoo's docs don't gate this scope behind a partner agreement, but if they do reject our app, we'd be stuck. Mitigation: confirm via a test push from a dev sandbox before announcing the feature externally.
- **GPX-only routes lose Planner-specific metadata** (e.g. routing profile, no-go avoidance) → Acceptable; FIT Course only carries geometry. Wahoo head unit does its own re-routing if the rider deviates.

View file

@ -25,7 +25,7 @@ The existing Wahoo integration is read-only: we pull completed workouts back int
- `apps/journal/app/lib/sync/providers/wahoo.ts` — add `routes_write` scope, implement `pushRoute`, surface scope-mismatch errors.
- `apps/journal/app/lib/sync/types.ts` — extend `SyncProvider` interface.
- `apps/journal/app/routes/` — new `/api/sync/push/wahoo/:routeId` action; "Send to Wahoo" button on the route detail page.
- `apps/journal/db/schema.ts` — new `sync_pushes` table + Drizzle migration.
- `packages/db/src/schema/journal.ts` — new `sync_pushes` table + `granted_scopes` column on `sync_connections`, plus the generated Drizzle migration in `packages/db/migrations/`.
- **New package** `packages/fit/` — GPX→FIT Course encoder, co-located tests with FIT-decoder round-trip fixtures.
- **Dependencies**: One new dep — a JS FIT encoder. Candidates to evaluate in design.md: `@garmin/fitsdk-javascript` (official, large), `fit-file-writer`, or a thin hand-rolled encoder against the FIT SDK profile (Course message + Record + Lap).
- **External API**: Net-new outbound calls to `api.wahooligan.com/v1/routes`. No webhook changes.

View file

@ -1,7 +1,7 @@
## ADDED 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 (`geojson` is non-null). The button SHALL trigger a server action that pushes the current route version to Wahoo.
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
@ -18,7 +18,7 @@ The Journal SHALL show a "Send to Wahoo" button on the route detail page when al
- **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 `geojson` is null
- **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

View file

@ -11,15 +11,15 @@
## 2. Database schema for push tracking
- [ ] 2.1 Add `sync_pushes` table to `apps/journal/db/schema.ts` with columns from design.md §3 and a unique index on `(user_id, route_id, route_version, provider)`
- [ ] 2.2 Add a `granted_scopes text[]` column to `sync_connections`, defaulting to an empty array, and backfill existing rows with the legacy scopes (`workouts_read`, `user_read`, `offline_data`)
- [ ] 2.3 Generate the Drizzle migration via `pnpm --filter @trails-cool/journal db:generate`
- [ ] 2.1 Add `sync_pushes` table to `packages/db/src/schema/journal.ts` with columns from design.md §3 and a unique index on `(user_id, route_id, route_version, provider)`
- [ ] 2.2 Add a `granted_scopes text[]` column to `sync_connections` in the same schema file, defaulting to an empty array, and backfill existing rows with the legacy scopes (`workouts_read`, `user_read`, `offline_data`)
- [ ] 2.3 Generate the Drizzle migration via `pnpm --filter @trails-cool/db db:generate` (migration files land in `packages/db/migrations/`)
- [ ] 2.4 Apply locally via `pnpm db:push` and verify with `pnpm db:studio`
## 3. Wahoo provider scope upgrade
- [ ] 3.1 Add `routes_write` to the Wahoo scopes array in `apps/journal/app/lib/sync/providers/wahoo.ts:14`
- [ ] 3.2 Persist `granted_scopes` on the `sync_connections` row at `exchangeCode` time (Wahoo returns granted scopes in the token response, or we record the requested set as granted)
- [ ] 3.2 Persist `granted_scopes` on the `sync_connections` row at `exchangeCode` time. Wahoo's token endpoint does not return a `scope` field and grants scopes all-or-nothing, so record the requested scope set as granted. The scope-mismatch detection in §5.2 is therefore a comparison against our own constant, not against provider-reported state — that's intentional and only needs to flag pre-`routes_write` connections.
- [ ] 3.3 Implement `pushRoute(connection, { gpx, name, description, externalId })` on the Wahoo provider: base64-encode the FIT, build the form-encoded body, POST `/v1/routes`, return `{ remoteId }` on success or throw a typed error on failure (token expired, scope missing, validation error, rate limit, generic)
- [ ] 3.4 Wire token-refresh-on-401 through the new push call (reuse the existing `withFreshToken` helper or equivalent)