Merge pull request #338 from trails-cool/stigi/wahoo-push-provider

Add Wahoo pushRoute and SyncProvider push interface
This commit is contained in:
Ullrich Schäfer 2026-05-01 07:47:16 +02:00 committed by GitHub
commit ba007104eb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 199 additions and 11 deletions

View file

@ -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,
});
}

View file

@ -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("<ele>");
});
});
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<typeof vi.fn>;
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);
});
});

View file

@ -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<PushRouteResult> {
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;

View file

@ -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:<routeId>:v<version>` 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<string | null>;
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<PushRouteResult>;
}
export function providerSupportsPush(
provider: SyncProvider,
): provider is SyncProvider & { pushRoute: NonNullable<SyncProvider["pushRoute"]> } {
return typeof provider.pushRoute === "function";
}

View file

@ -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");

View file

@ -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