Merge pull request #338 from trails-cool/stigi/wahoo-push-provider
Add Wahoo pushRoute and SyncProvider push interface
This commit is contained in:
commit
ba007104eb
6 changed files with 199 additions and 11 deletions
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue