import FitParser from "fit-file-parser"; import { generateGpx } from "@trails-cool/gpx"; import type { SyncProvider, TokenSet, Workout, WorkoutList, WebhookEvent, PushRoutePayload, PushRouteResult, } from "../types.ts"; import { PushError, OAuthError } from "../types.ts"; const WAHOO_API = "https://api.wahooligan.com"; const WAHOO_AUTH = "https://api.wahooligan.com/oauth"; const clientId = () => process.env.WAHOO_CLIENT_ID ?? ""; const clientSecret = () => process.env.WAHOO_CLIENT_SECRET ?? ""; function buildRouteFormBody(payload: PushRoutePayload): URLSearchParams { // Wahoo expects route[file] as a data URI, not raw base64. Sending plain // base64 results in a route record where file.url is null and the Wahoo // app shows the route in the list (with metadata) but renders no track. const fitDataUri = `data:application/vnd.fit;base64,${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]": fitDataUri, }); if (payload.description) body.set("route[description]", payload.description); if (payload.filename) body.set("route[filename]", payload.filename); return body; } async function wahooRouteRequest( tokens: TokenSet, method: "POST" | "PUT", url: string, payload: PushRoutePayload, opts: { fallbackRemoteId?: string } = {}, ): Promise { const resp = await fetch(url, { method, headers: { Authorization: `Bearer ${tokens.accessToken}`, "Content-Type": "application/x-www-form-urlencoded", }, body: buildRouteFormBody(payload).toString(), }); if (resp.ok) { // PUT may respond 200/204 without a body. The remote id is unchanged in // that case, so fall back to the one we just targeted. let remoteId: string | undefined; if (resp.status !== 204) { const data = (await resp.json().catch(() => null)) as { id?: number | string } | null; remoteId = data?.id?.toString(); } remoteId ??= opts.fallbackRemoteId; 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 === 404) throw new PushError("not_found", text || "Not Found", 404); 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 ${method} failed: ${resp.status} ${text}`, resp.status); } export const wahooProvider: SyncProvider = { id: "wahoo", name: "Wahoo", scopes: ["workouts_read", "user_read", "offline_data", "routes_read", "routes_write"], getAuthUrl(redirectUri: string, state: string): string { const params = new URLSearchParams({ client_id: clientId(), redirect_uri: redirectUri, response_type: "code", scope: this.scopes.join(" "), state, }); return `${WAHOO_AUTH}/authorize?${params}`; }, async exchangeCode(code: string, redirectUri: string): Promise { const resp = await fetch(`${WAHOO_AUTH}/token`, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ client_id: clientId(), client_secret: clientSecret(), code, grant_type: "authorization_code", redirect_uri: redirectUri, }).toString(), }); if (!resp.ok) { const text = await resp.text().catch(() => ""); if (text.includes("Too many unrevoked access tokens")) { throw new OAuthError("too_many_tokens", text, resp.status); } throw new OAuthError("generic", `Wahoo token exchange failed: ${resp.status} ${text}`, resp.status); } const data = await resp.json() as { access_token: string; refresh_token: string; expires_in: number }; // Fetch user info to get provider user ID const userResp = await fetch(`${WAHOO_API}/v1/user`, { headers: { Authorization: `Bearer ${data.access_token}` }, }); const user = userResp.ok ? (await userResp.json() as { id: number }) : null; return { accessToken: data.access_token, refreshToken: data.refresh_token, expiresAt: new Date(Date.now() + data.expires_in * 1000), providerUserId: user?.id?.toString(), }; }, async refreshToken(refreshToken: string): Promise { const resp = await fetch(`${WAHOO_AUTH}/token`, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ client_id: clientId(), client_secret: clientSecret(), grant_type: "refresh_token", refresh_token: refreshToken, }).toString(), }); if (!resp.ok) { const text = await resp.text().catch(() => ""); throw new Error(`Wahoo token refresh failed: ${resp.status} ${text}`); } const data = await resp.json() as { access_token: string; refresh_token: string; expires_in: number }; return { accessToken: data.access_token, refreshToken: data.refresh_token, expiresAt: new Date(Date.now() + data.expires_in * 1000), }; }, async listWorkouts(tokens: TokenSet, page: number): Promise { const params = new URLSearchParams({ page: String(page), per_page: "30" }); const resp = await fetch(`${WAHOO_API}/v1/workouts?${params}`, { headers: { Authorization: `Bearer ${tokens.accessToken}` }, }); if (!resp.ok) throw new Error(`Wahoo list workouts failed: ${resp.status}`); const data = await resp.json() as { workouts: Array<{ id: number; name: string; workout_type: string; starts: string; fitness_app_id?: number; workout_summary?: { duration_active_accum?: number; distance_accum?: number; file?: { url?: string }; }; }>; total: number; page: number; per_page: number; }; // Wahoo does not share workout data from third-party apps (fitness_app_id >= 1000) const wahooWorkouts = data.workouts.filter((w) => !w.fitness_app_id || w.fitness_app_id < 1000); return { workouts: wahooWorkouts.map((w) => ({ id: String(w.id), name: w.name || `Workout ${w.id}`, type: w.workout_type ?? "unknown", startedAt: w.starts, duration: w.workout_summary?.duration_active_accum ? Math.round(w.workout_summary.duration_active_accum) : null, distance: w.workout_summary?.distance_accum ? Math.round(w.workout_summary.distance_accum) : null, fileUrl: w.workout_summary?.file?.url, })), total: data.total, page: data.page, perPage: data.per_page, }; }, async downloadFile(tokens: TokenSet, workout: Workout): Promise { if (!workout.fileUrl) throw new Error("No file URL for workout"); const resp = await fetch(workout.fileUrl); if (!resp.ok) throw new Error(`Wahoo file download failed: ${resp.status}`); return Buffer.from(await resp.arrayBuffer()); }, async convertToGpx(fileBuffer: Buffer): Promise { const parsed = await new Promise>((resolve, reject) => { const parser = new FitParser({ force: true }); // eslint-disable-next-line @typescript-eslint/no-explicit-any parser.parse(fileBuffer as any, (error: unknown, data: any) => { if (error) reject(error); else resolve(data ?? {}); }); }); const records = (parsed.records ?? []) as Array<{ position_lat?: number; position_long?: number; altitude?: number; timestamp?: string | Date; }>; // fit-file-parser already converts semicircles to degrees const trackPoints = records .filter((r) => r.position_lat != null && r.position_long != null) .map((r) => ({ lat: r.position_lat!, lon: r.position_long!, ele: r.altitude, time: r.timestamp instanceof Date ? r.timestamp.toISOString() : r.timestamp, })); if (trackPoints.length < 2) return null; return generateGpx({ name: "Wahoo workout", tracks: [trackPoints], }); }, async pushRoute(tokens: TokenSet, payload: PushRoutePayload): Promise { return wahooRouteRequest(tokens, "POST", `${WAHOO_API}/v1/routes`, payload); }, async updateRoute( tokens: TokenSet, remoteId: string, payload: PushRoutePayload, ): Promise { return wahooRouteRequest( tokens, "PUT", `${WAHOO_API}/v1/routes/${encodeURIComponent(remoteId)}`, payload, { fallbackRemoteId: remoteId }, ); }, async revoke(tokens: TokenSet): Promise { const resp = await fetch(`${WAHOO_API}/v1/permissions`, { method: "DELETE", headers: { Authorization: `Bearer ${tokens.accessToken}` }, }); if (!resp.ok) { const text = await resp.text().catch(() => ""); throw new Error(`Wahoo revoke failed: ${resp.status} ${text}`); } }, parseWebhook(body: unknown): WebhookEvent | null { const payload = body as { event_type?: string; user?: { id?: number }; workout_summary?: { id?: number; workout?: { id?: number }; file?: { url?: string }; }; }; if (payload.event_type !== "workout_summary" || !payload.user?.id) return null; return { eventType: payload.event_type, providerUserId: String(payload.user.id), workoutId: String(payload.workout_summary?.workout?.id ?? payload.workout_summary?.id ?? ""), fileUrl: payload.workout_summary?.file?.url, }; }, };