Re-pushing an edited route to Wahoo now updates the existing remote route via PUT against the stored remote_id instead of POSTing a new copy. external_id drops the version suffix and identifies the logical route. sync_pushes is keyed by (user, route, provider) and tracks last_pushed_version for the "local newer" UI state. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
291 lines
10 KiB
TypeScript
291 lines
10 KiB
TypeScript
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<PushRouteResult> {
|
|
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<TokenSet> {
|
|
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<TokenSet> {
|
|
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<WorkoutList> {
|
|
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<Buffer> {
|
|
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<string | null> {
|
|
const parsed = await new Promise<Record<string, unknown>>((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<PushRouteResult> {
|
|
return wahooRouteRequest(tokens, "POST", `${WAHOO_API}/v1/routes`, payload);
|
|
},
|
|
|
|
async updateRoute(
|
|
tokens: TokenSet,
|
|
remoteId: string,
|
|
payload: PushRoutePayload,
|
|
): Promise<PushRouteResult> {
|
|
return wahooRouteRequest(
|
|
tokens,
|
|
"PUT",
|
|
`${WAHOO_API}/v1/routes/${encodeURIComponent(remoteId)}`,
|
|
payload,
|
|
{ fallbackRemoteId: remoteId },
|
|
);
|
|
},
|
|
|
|
async revoke(tokens: TokenSet): Promise<void> {
|
|
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,
|
|
};
|
|
},
|
|
};
|