From 5b0bf40b97fcacafabce710d625305c8747588d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Thu, 30 Apr 2026 22:36:45 +0200 Subject: [PATCH] Add Wahoo pushRoute and SyncProvider push interface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the SyncProvider interface with an optional pushRoute method plus PushRoutePayload, PushRouteResult, and a typed PushError so the slice-4 action route can map error codes to user-facing copy without parsing HTTP statuses itself. Wahoo provider: - adds routes_write to the requested OAuth scope set - implements pushRoute: base64-encodes the FIT, builds the form body Wahoo's POST /v1/routes expects, parses the remote id out of the response, and throws typed PushError on 401/403/422/429/5xx - saveConnection now persists the requested scopes as grantedScopes on exchangeCode (Wahoo doesn't return a scope field, so the requested set is the granted set) Token refresh on 401 is deferred to the slice-4 action route — same pattern as the existing webhook handler in this repo, which does inline refresh rather than wrapping every call. Co-Authored-By: Claude Opus 4.7 --- .../app/lib/sync/connections.server.ts | 2 + .../app/lib/sync/providers/wahoo.test.ts | 87 ++++++++++++++++++- apps/journal/app/lib/sync/providers/wahoo.ts | 53 ++++++++++- apps/journal/app/lib/sync/types.ts | 50 +++++++++++ .../app/routes/api.sync.callback.$provider.ts | 4 +- openspec/changes/wahoo-route-push/tasks.md | 14 +-- 6 files changed, 199 insertions(+), 11 deletions(-) diff --git a/apps/journal/app/lib/sync/connections.server.ts b/apps/journal/app/lib/sync/connections.server.ts index 4e47594..4337fcf 100644 --- a/apps/journal/app/lib/sync/connections.server.ts +++ b/apps/journal/app/lib/sync/connections.server.ts @@ -8,6 +8,7 @@ export async function saveConnection( userId: string, provider: string, tokens: TokenSet, + grantedScopes: string[] = [], ) { const db = getDb(); // Upsert: delete existing connection for this user+provider, then insert @@ -22,6 +23,7 @@ export async function saveConnection( refreshToken: tokens.refreshToken, expiresAt: tokens.expiresAt, providerUserId: tokens.providerUserId ?? null, + grantedScopes, }); } diff --git a/apps/journal/app/lib/sync/providers/wahoo.test.ts b/apps/journal/app/lib/sync/providers/wahoo.test.ts index 412ff05..443b0ba 100644 --- a/apps/journal/app/lib/sync/providers/wahoo.test.ts +++ b/apps/journal/app/lib/sync/providers/wahoo.test.ts @@ -1,7 +1,8 @@ import { readFileSync } from "node:fs"; import { resolve } from "node:path"; -import { describe, it, expect } from "vitest"; +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { wahooProvider } from "./wahoo"; +import { PushError } from "../types.ts"; const fixturePath = resolve(__dirname, "__fixtures__/wahoo-ride.fit"); const fitBuffer = readFileSync(fixturePath); @@ -61,3 +62,87 @@ describe("wahooProvider.convertToGpx", () => { expect(gpx).toContain(""); }); }); + +describe("wahooProvider.pushRoute", () => { + const tokens = { + accessToken: "test-token", + refreshToken: "refresh", + expiresAt: new Date(Date.now() + 3600_000), + }; + const fit = new Uint8Array([0x01, 0x02, 0x03]); + const basePayload = { + fit, + externalId: "route:abc:v1", + providerUpdatedAt: new Date("2026-04-30T12:00:00Z"), + name: "Test route", + description: "A test", + startLat: 52.52, + startLng: 13.405, + distance: 12345, + ascent: 200, + }; + + let fetchMock: ReturnType; + + beforeEach(() => { + fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("posts a base64 FIT and returns the remote id", async () => { + fetchMock.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ id: 9876 }), + }); + + const result = await wahooProvider.pushRoute!(tokens, basePayload); + + expect(result.remoteId).toBe("9876"); + expect(fetchMock).toHaveBeenCalledOnce(); + const [url, init] = fetchMock.mock.calls[0]!; + expect(url).toBe("https://api.wahooligan.com/v1/routes"); + expect(init.method).toBe("POST"); + expect(init.headers.Authorization).toBe("Bearer test-token"); + expect(init.headers["Content-Type"]).toBe("application/x-www-form-urlencoded"); + const body = new URLSearchParams(init.body as string); + expect(body.get("route[external_id]")).toBe("route:abc:v1"); + expect(body.get("route[name]")).toBe("Test route"); + expect(body.get("route[description]")).toBe("A test"); + expect(body.get("route[start_lat]")).toBe("52.52"); + expect(body.get("route[file]")).toBe(Buffer.from(fit).toString("base64")); + }); + + it.each([ + [401, "token_expired"], + [403, "scope_missing"], + [422, "validation"], + [429, "rate_limit"], + [500, "generic"], + ])("maps HTTP %i to PushError code %s", async (status, code) => { + fetchMock.mockResolvedValueOnce({ + ok: false, + status, + text: async () => "boom", + }); + + await expect(wahooProvider.pushRoute!(tokens, basePayload)).rejects.toMatchObject({ + name: "PushError", + code, + status, + }); + }); + + it("throws generic PushError when response lacks an id", async () => { + fetchMock.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({}), + }); + await expect(wahooProvider.pushRoute!(tokens, basePayload)).rejects.toBeInstanceOf(PushError); + }); +}); diff --git a/apps/journal/app/lib/sync/providers/wahoo.ts b/apps/journal/app/lib/sync/providers/wahoo.ts index ae54850..f9f998f 100644 --- a/apps/journal/app/lib/sync/providers/wahoo.ts +++ b/apps/journal/app/lib/sync/providers/wahoo.ts @@ -1,6 +1,15 @@ import FitParser from "fit-file-parser"; import { generateGpx } from "@trails-cool/gpx"; -import type { SyncProvider, TokenSet, Workout, WorkoutList, WebhookEvent } from "../types.ts"; +import type { + SyncProvider, + TokenSet, + Workout, + WorkoutList, + WebhookEvent, + PushRoutePayload, + PushRouteResult, +} from "../types.ts"; +import { PushError } from "../types.ts"; const WAHOO_API = "https://api.wahooligan.com"; const WAHOO_AUTH = "https://api.wahooligan.com/oauth"; @@ -11,7 +20,7 @@ const clientSecret = () => process.env.WAHOO_CLIENT_SECRET ?? ""; export const wahooProvider: SyncProvider = { id: "wahoo", name: "Wahoo", - scopes: ["workouts_read", "user_read", "offline_data"], + scopes: ["workouts_read", "user_read", "offline_data", "routes_write"], getAuthUrl(redirectUri: string, state: string): string { const params = new URLSearchParams({ @@ -162,6 +171,46 @@ export const wahooProvider: SyncProvider = { }); }, + async pushRoute(tokens: TokenSet, payload: PushRoutePayload): Promise { + const fitBase64 = Buffer.from(payload.fit).toString("base64"); + const body = new URLSearchParams({ + "route[external_id]": payload.externalId, + "route[provider_updated_at]": payload.providerUpdatedAt.toISOString(), + "route[name]": payload.name, + "route[workout_type_family_id]": "0", + "route[start_lat]": payload.startLat.toString(), + "route[start_lng]": payload.startLng.toString(), + "route[distance]": payload.distance.toString(), + "route[ascent]": payload.ascent.toString(), + "route[file]": fitBase64, + }); + if (payload.description) body.set("route[description]", payload.description); + if (payload.filename) body.set("route[filename]", payload.filename); + + const resp = await fetch(`${WAHOO_API}/v1/routes`, { + method: "POST", + headers: { + Authorization: `Bearer ${tokens.accessToken}`, + "Content-Type": "application/x-www-form-urlencoded", + }, + body: body.toString(), + }); + + if (resp.ok) { + const data = (await resp.json()) as { id?: number | string }; + const remoteId = data.id?.toString(); + if (!remoteId) throw new PushError("generic", "Wahoo response missing route id", resp.status); + return { remoteId }; + } + + const text = await resp.text().catch(() => ""); + if (resp.status === 401) throw new PushError("token_expired", text || "Unauthorized", 401); + if (resp.status === 403) throw new PushError("scope_missing", text || "Forbidden", 403); + if (resp.status === 422) throw new PushError("validation", text || "Validation failed", 422); + if (resp.status === 429) throw new PushError("rate_limit", text || "Rate limited", 429); + throw new PushError("generic", `Wahoo route push failed: ${resp.status} ${text}`, resp.status); + }, + parseWebhook(body: unknown): WebhookEvent | null { const payload = body as { event_type?: string; diff --git a/apps/journal/app/lib/sync/types.ts b/apps/journal/app/lib/sync/types.ts index db21795..fdccf82 100644 --- a/apps/journal/app/lib/sync/types.ts +++ b/apps/journal/app/lib/sync/types.ts @@ -29,6 +29,47 @@ export interface WebhookEvent { fileUrl?: string; } +export interface PushRoutePayload { + /** Pre-encoded FIT Course bytes — the action route runs gpxToFitCourse before calling pushRoute. */ + fit: Uint8Array; + /** Stable per (route, version) — `route::v` for trails.cool. */ + externalId: string; + providerUpdatedAt: Date; + name: string; + description?: string; + /** Decimal degrees. */ + startLat: number; + startLng: number; + /** Meters. */ + distance: number; + /** Total elevation gain in meters. */ + ascent: number; + filename?: string; +} + +export interface PushRouteResult { + remoteId: string; +} + +export type PushErrorCode = + | "scope_missing" + | "token_expired" + | "validation" + | "rate_limit" + | "generic"; + +export class PushError extends Error { + code: PushErrorCode; + status?: number; + + constructor(code: PushErrorCode, message: string, status?: number) { + super(message); + this.name = "PushError"; + this.code = code; + this.status = status; + } +} + export interface SyncProvider { id: string; name: string; @@ -43,4 +84,13 @@ export interface SyncProvider { convertToGpx(fileBuffer: Buffer): Promise; parseWebhook(body: unknown): WebhookEvent | null; + + /** Optional: providers that can accept routes implement this. UI hides the action when undefined. */ + pushRoute?: (tokens: TokenSet, payload: PushRoutePayload) => Promise; +} + +export function providerSupportsPush( + provider: SyncProvider, +): provider is SyncProvider & { pushRoute: NonNullable } { + return typeof provider.pushRoute === "function"; } diff --git a/apps/journal/app/routes/api.sync.callback.$provider.ts b/apps/journal/app/routes/api.sync.callback.$provider.ts index 5dbc951..7f3b88b 100644 --- a/apps/journal/app/routes/api.sync.callback.$provider.ts +++ b/apps/journal/app/routes/api.sync.callback.$provider.ts @@ -20,7 +20,9 @@ export async function loader({ params, request }: Route.LoaderArgs) { try { const tokens = await provider.exchangeCode(code, redirectUri); - await saveConnection(user.id, provider.id, tokens); + // Wahoo's token endpoint doesn't return a `scope` field and grants + // scopes all-or-nothing, so the requested set is the granted set. + await saveConnection(user.id, provider.id, tokens, provider.scopes); } catch (e) { console.error(`OAuth callback failed for ${params.provider}:`, e); return redirect("/settings?error=sync_failed"); diff --git a/openspec/changes/wahoo-route-push/tasks.md b/openspec/changes/wahoo-route-push/tasks.md index 7917559..9d3fdcc 100644 --- a/openspec/changes/wahoo-route-push/tasks.md +++ b/openspec/changes/wahoo-route-push/tasks.md @@ -18,16 +18,16 @@ ## 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'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) +- [x] 3.1 Add `routes_write` to the Wahoo scopes array in `apps/journal/app/lib/sync/providers/wahoo.ts:14` +- [x] 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. +- [x] 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) +- [x] 3.4 Wire token-refresh-on-401 through the new push call. Implemented at the boundary instead of inside `pushRoute`: the provider throws `PushError({ code: "token_expired" })` on 401 and the slice-4 action route catches it, calls `provider.refreshToken`, persists, and retries once. (No `withFreshToken` helper exists in this repo today; the existing webhook handler does the same inline.) ## 4. Sync provider interface extension -- [ ] 4.1 Add an optional `pushRoute?: (connection, payload) => Promise<{ remoteId: string }>` method to the `SyncProvider` interface in `apps/journal/app/lib/sync/types.ts` -- [ ] 4.2 Define the `PushRoutePayload` and `PushRouteResult` types alongside it -- [ ] 4.3 Add a `providerSupportsPush(provider)` helper for use in loaders/UI +- [x] 4.1 Add an optional `pushRoute?: (connection, payload) => Promise<{ remoteId: string }>` method to the `SyncProvider` interface in `apps/journal/app/lib/sync/types.ts` +- [x] 4.2 Define the `PushRoutePayload` and `PushRouteResult` types alongside it +- [x] 4.3 Add a `providerSupportsPush(provider)` helper for use in loaders/UI ## 5. Push action route and idempotency layer