From 4a97ef6a1088a9350c42aec0e3a2786a45601cd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Thu, 30 Apr 2026 22:03:28 +0200 Subject: [PATCH] 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 --- openspec/changes/wahoo-route-push/design.md | 16 +++++++++------- openspec/changes/wahoo-route-push/proposal.md | 2 +- .../specs/wahoo-route-push/spec.md | 4 ++-- openspec/changes/wahoo-route-push/tasks.md | 8 ++++---- 4 files changed, 16 insertions(+), 14 deletions(-) diff --git a/openspec/changes/wahoo-route-push/design.md b/openspec/changes/wahoo-route-push/design.md index d68888d..09d57d6 100644 --- a/openspec/changes/wahoo-route-push/design.md +++ b/openspec/changes/wahoo-route-push/design.md @@ -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::v` — 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::v` — 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::v` — 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. diff --git a/openspec/changes/wahoo-route-push/proposal.md b/openspec/changes/wahoo-route-push/proposal.md index f9cf9af..e3654ad 100644 --- a/openspec/changes/wahoo-route-push/proposal.md +++ b/openspec/changes/wahoo-route-push/proposal.md @@ -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. diff --git a/openspec/changes/wahoo-route-push/specs/wahoo-route-push/spec.md b/openspec/changes/wahoo-route-push/specs/wahoo-route-push/spec.md index 45145fa..f4ffeb3 100644 --- a/openspec/changes/wahoo-route-push/specs/wahoo-route-push/spec.md +++ b/openspec/changes/wahoo-route-push/specs/wahoo-route-push/spec.md @@ -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 diff --git a/openspec/changes/wahoo-route-push/tasks.md b/openspec/changes/wahoo-route-push/tasks.md index 5d9871a..948f905 100644 --- a/openspec/changes/wahoo-route-push/tasks.md +++ b/openspec/changes/wahoo-route-push/tasks.md @@ -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)