import FitParser from "fit-file-parser"; import { generateGpx } from "@trails-cool/gpx"; import type { SyncProvider, TokenSet, Workout, WorkoutList, WebhookEvent } 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 ?? ""; export const wahooProvider: SyncProvider = { id: "wahoo", name: "Wahoo", scopes: ["workouts_read", "user_read", "offline_data"], 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/json" }, body: JSON.stringify({ client_id: clientId(), client_secret: clientSecret(), code, grant_type: "authorization_code", redirect_uri: redirectUri, }), }); if (!resp.ok) throw new Error(`Wahoo token exchange failed: ${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/json" }, body: JSON.stringify({ client_id: clientId(), client_secret: clientSecret(), grant_type: "refresh_token", refresh_token: refreshToken, }), }); if (!resp.ok) throw new Error(`Wahoo token refresh failed: ${resp.status}`); 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], }); }, 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, }; }, };