Files
HealthLog/src/lib/strava/client.ts
T
Marc-André Bombeck 4f7420905a fix(strava): normalise raw sport labels to canonical WorkoutSportType
Strava was the second workout integration, after WHOOP, that wrote its
raw vendor sport label ("Run", "Ride", "VirtualRide", ...) straight into
Workout.sportType instead of a canonical bucket. Every Strava row read
as generic, and the read-time picker treats two generic rows as
sport-compatible — a same-source brick session (Strava ride ending
~15:00, Strava run starting 15:03) could cluster into one and silently
drop a leg.

Add mapStravaSportType() (54 documented Strava sport_type values,
mirroring the WHOOP sport-map shape) and apply it at write time in
mapActivity(); the raw label stays in metadata.stravaType for
provenance. Backfill existing Strava rows via a hand-written migration
generated from the same table. Extend the cross-provider sport-map
parity test to cover Strava, and add a picker-level test proving a
back-to-back Ride+Run pair now survives as two rows instead of one.
2026-07-17 23:12:12 +02:00

425 lines
15 KiB
TypeScript

/**
* v1.28.x — Strava API v3 client for OAuth + activity fetching.
* Docs: https://developers.strava.com/docs/reference/ (re-verify at build).
*
* Mirrors the WHOOP / Polar / Oura client structure: hand-rolled fetch over
* `safeFetch` (no vendor SDK), an OAuth handshake (`getAuthorizationUrl` /
* `exchangeCode` / `refreshAccessToken`), typed collection fetchers, and a
* single field → `Workout` mapper.
*
* Strava specifics:
* - It is a WORKOUT source only — the API is activity-centric and exposes no
* sleep / recovery / body / glucose data. It feeds `Workout` rows and only
* `Workout` rows. No training-depth analytics (FTP/TSS/power-curve) are
* derived — Strava is just another workout source.
* - It ROTATES its refresh token on every refresh, so the sync persists BOTH
* rotated tokens (the Oura reactive-refresh + compare-and-set model). There
* is no token-expiry column; the sync refreshes reactively on a 401.
* - The Activities list (`SummaryActivity`) carries distance / moving_time /
* elapsed_time / elevation / HR, but `calories` lives only on the
* per-activity `DetailedActivity` (`GET /activities/{id}`).
* - Rate limits: 200 requests / 15 min and 2000 / day per app. The sync walks
* bounded pages and fetches detail budget-aware.
*/
import { getEvent } from "@/lib/logging/context";
import { safeFetch } from "@/lib/safe-fetch";
import type { Prisma } from "@/generated/prisma/client";
import { StravaApiError, classifyStravaResponse } from "./response-classifier";
import { mapStravaSportType } from "./sport-map";
import type { WorkoutSportType } from "@/lib/validations/workout";
const STRAVA_API_BASE = "https://www.strava.com/api/v3";
const STRAVA_OAUTH_AUTH_URL = "https://www.strava.com/oauth/authorize";
const STRAVA_OAUTH_TOKEN_URL = "https://www.strava.com/oauth/token";
const STRAVA_DEAUTHORIZE_URL = "https://www.strava.com/oauth/deauthorize";
export interface StravaCredentials {
clientId: string;
clientSecret: string;
}
/**
* Env-configured shared OAuth credentials — the FALLBACK app used when a user
* has not registered their own BYO Strava client id/secret. The per-user
* resolver `getStravaClientCredentials` (DB-first) is the primary path and
* calls this only when no per-user pair is stored.
*/
export function getStravaCredentials(): StravaCredentials | null {
const clientId = process.env.STRAVA_CLIENT_ID;
const clientSecret = process.env.STRAVA_CLIENT_SECRET;
if (!clientId || !clientSecret) return null;
return { clientId, clientSecret };
}
export function getStravaRedirectUri(): string {
// `||`, not `??`: the compose whitelist materialises an unset var as an empty
// string, which must still fall through to the derived URI.
return (
process.env.STRAVA_REDIRECT_URI ||
`${process.env.NEXT_PUBLIC_APP_URL}/api/strava/callback`
);
}
/**
* Strava OAuth scope. `activity:read_all` reads every activity including those
* marked "only me" (`activity:read` omits private ones). Comma-separated on the
* wire per Strava's authorization endpoint.
*/
export const STRAVA_OAUTH_SCOPE = "activity:read_all" as const;
export function getAuthorizationUrl(
state: string,
creds: StravaCredentials,
): string {
const params = new URLSearchParams({
client_id: creds.clientId,
response_type: "code",
redirect_uri: getStravaRedirectUri(),
approval_prompt: "auto",
scope: STRAVA_OAUTH_SCOPE,
state,
});
return `${STRAVA_OAUTH_AUTH_URL}?${params}`;
}
/** Strava's `SummaryAthlete` — only the numeric id is retained (the granted
* account pin + webhook fan-out key). */
export interface StravaAthlete {
id?: number | null;
}
export interface StravaTokenResponse {
access_token: string;
refresh_token: string;
expires_at?: number;
expires_in?: number;
token_type?: string;
athlete?: StravaAthlete | null;
}
async function postToken(
params: URLSearchParams,
verb: string,
): Promise<StravaTokenResponse> {
const start = performance.now();
const res = await safeFetch(STRAVA_OAUTH_TOKEN_URL, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: params.toString(),
});
const json = await res.json().catch(() => null);
const verdict = classifyStravaResponse(res.status);
getEvent()?.addExternalCall({
service: "strava",
method: verb,
duration_ms: Math.round(performance.now() - start),
status: res.status,
error: verdict.classification === "success" ? undefined : verdict.reason,
});
if (verdict.classification !== "success") {
throw new StravaApiError({
verb,
classification: verdict.classification,
httpStatus: verdict.httpStatus,
reason: verdict.reason,
upstreamError:
typeof json?.message === "string" ? json.message : undefined,
});
}
return json as StravaTokenResponse;
}
export async function exchangeCode(
code: string,
creds: StravaCredentials,
): Promise<StravaTokenResponse> {
return postToken(
new URLSearchParams({
client_id: creds.clientId,
client_secret: creds.clientSecret,
code,
grant_type: "authorization_code",
}),
"exchangeCode",
);
}
export async function refreshAccessToken(
refreshToken: string,
creds: StravaCredentials,
): Promise<StravaTokenResponse> {
return postToken(
new URLSearchParams({
client_id: creds.clientId,
client_secret: creds.clientSecret,
grant_type: "refresh_token",
refresh_token: refreshToken,
}),
"refreshAccessToken",
);
}
/**
* Best-effort deauthorize at Strava on disconnect. Failure is swallowed by the
* caller — the local grant is cleared regardless, so a Strava-side outage never
* strands the user with a card that refuses to disconnect.
*/
export async function deauthorize(accessToken: string): Promise<void> {
const start = performance.now();
const res = await safeFetch(STRAVA_DEAUTHORIZE_URL, {
method: "POST",
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: "application/json",
},
});
getEvent()?.addExternalCall({
service: "strava",
method: "deauthorize",
duration_ms: Math.round(performance.now() - start),
status: res.status,
error: res.ok ? undefined : `http_${res.status}`,
});
}
// ─── Activity reads (v3) ───────────────────────────────────────
/** Strava `SummaryActivity` — the list-endpoint shape. Distances are metres,
* durations seconds, `start_date` a UTC ISO-8601 instant. HR / power fields are
* present when the activity recorded them; `calories` is NOT (detail only). */
export interface StravaSummaryActivity {
id: number;
name?: string | null;
distance?: number | null;
moving_time?: number | null;
elapsed_time?: number | null;
total_elevation_gain?: number | null;
type?: string | null;
sport_type?: string | null;
start_date?: string | null;
start_date_local?: string | null;
average_heartrate?: number | null;
max_heartrate?: number | null;
has_heartrate?: boolean | null;
average_watts?: number | null;
weighted_average_watts?: number | null;
device_watts?: boolean | null;
average_cadence?: number | null;
trainer?: boolean | null;
commute?: boolean | null;
gear_id?: string | null;
}
/** Strava `DetailedActivity` — the per-activity shape. Superset of the summary
* that additionally carries `calories` (kcal). */
export interface StravaDetailedActivity extends StravaSummaryActivity {
calories?: number | null;
description?: string | null;
}
export interface FetchActivitiesQuery {
/** Epoch SECONDS cursor — return activities started after this instant. */
after?: number;
page?: number;
perPage?: number;
}
async function stravaGet<T>(
path: string,
accessToken: string,
verb: string,
): Promise<T> {
const start = performance.now();
const res = await safeFetch(`${STRAVA_API_BASE}${path}`, {
method: "GET",
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: "application/json",
},
});
const verdict = classifyStravaResponse(res.status);
getEvent()?.addExternalCall({
service: "strava",
method: verb,
duration_ms: Math.round(performance.now() - start),
status: res.status,
error: verdict.classification === "success" ? undefined : verdict.reason,
});
if (verdict.classification !== "success") {
throw new StravaApiError({
verb,
classification: verdict.classification,
httpStatus: verdict.httpStatus,
reason: verdict.reason,
});
}
return (await res.json().catch(() => null)) as T;
}
/** One page of the athlete's activities (newest first). */
export async function fetchActivities(
accessToken: string,
query: FetchActivitiesQuery = {},
): Promise<StravaSummaryActivity[]> {
const params = new URLSearchParams();
if (typeof query.after === "number")
params.set("after", String(Math.floor(query.after)));
params.set("page", String(query.page ?? 1));
params.set("per_page", String(query.perPage ?? 100));
const json = await stravaGet<StravaSummaryActivity[] | null>(
`/athlete/activities?${params}`,
accessToken,
"fetchActivities",
);
return Array.isArray(json) ? json : [];
}
/** One activity's `DetailedActivity` — the only source of `calories`. */
export function fetchActivityById(
accessToken: string,
id: number | string,
): Promise<StravaDetailedActivity> {
return stravaGet<StravaDetailedActivity>(
`/activities/${encodeURIComponent(String(id))}?include_all_efforts=false`,
accessToken,
"fetchActivityById",
);
}
// ─── Activity → Workout mapping ────────────────────────────────
/** The `Workout`-row payload a mapped Strava activity produces. Field names
* mirror `prisma.workout` columns; the sync layer stamps `source = STRAVA` +
* `externalId` and upserts on `(userId, source, externalId)`. */
export interface StravaWorkoutRow {
externalId: string;
sportType: WorkoutSportType;
startedAt: Date;
endedAt: Date;
durationSec: number;
totalEnergyKcal: number | null;
totalDistanceM: number | null;
avgHeartRate: number | null;
maxHeartRate: number | null;
elevationM: number | null;
metadata: Prisma.InputJsonValue;
}
function nonNegInt(n: number | null | undefined): number | null {
if (typeof n !== "number" || !Number.isFinite(n) || n < 0) return null;
return Math.round(n);
}
function nonNegFloat(n: number | null | undefined): number | null {
if (typeof n !== "number" || !Number.isFinite(n) || n < 0) return null;
return n;
}
/**
* Map one Strava activity into a `Workout` row. `sport_type` is preferred over
* the legacy `type`, then mapped through `mapStravaSportType()` to a canonical
* `WorkoutSportType` — the raw Strava label is never written to `sportType`
* directly (see `src/lib/strava/sport-map.ts` for why). The raw label is kept
* verbatim in `metadata.stravaType` for provenance/debugging. Duration uses
* `moving_time` (active duration) with `elapsed_time` kept in `metadata`. HR /
* calories come from the summary when present, else from the optional
* `DetailedActivity`.
*
* The activity `name` / `description` are the user's own free-text DATA — they
* are stored in `metadata` only and NEVER interpolated into any prompt as an
* instruction (the MCP/coach free-text rule). Power / cadence / commute flags
* ride `metadata` for provenance but are NOT surfaced as training analytics.
* Returns null for an activity with no id / start instant (nothing to store).
*/
export function mapActivity(
summary: StravaSummaryActivity,
detail?: StravaDetailedActivity | null,
): StravaWorkoutRow | null {
if (typeof summary.id !== "number") return null;
const startIso = summary.start_date ?? detail?.start_date ?? null;
if (!startIso) return null;
const startedAt = new Date(startIso);
if (Number.isNaN(startedAt.getTime())) return null;
const elapsedSec = nonNegInt(summary.elapsed_time ?? detail?.elapsed_time);
const movingSec = nonNegInt(summary.moving_time ?? detail?.moving_time);
const durationSec = movingSec ?? elapsedSec ?? 0;
// The clock window is elapsed time (moving time excludes pauses); fall back to
// moving time when elapsed is absent so `endedAt` is never before `startedAt`.
const endedAt = new Date(
startedAt.getTime() + (elapsedSec ?? durationSec) * 1000,
);
const avgHeartRate = nonNegInt(
summary.average_heartrate ?? detail?.average_heartrate,
);
const maxHeartRate = nonNegInt(
summary.max_heartrate ?? detail?.max_heartrate,
);
const calories = nonNegFloat(detail?.calories);
const rawSportType =
summary.sport_type ?? summary.type ?? detail?.sport_type ?? "Workout";
const sportType = mapStravaSportType(rawSportType);
const metadata: Prisma.InputJsonValue = {
// Activity name/description are user free-text → DATA, never instructions.
...(summary.name ? { stravaName: summary.name } : {}),
...(detail?.description ? { stravaDescription: detail.description } : {}),
...(elapsedSec != null ? { elapsedTimeSec: elapsedSec } : {}),
// Raw pre-mapping label, kept for provenance/debugging — never used as
// the canonical `sportType` itself (see `mapStravaSportType()`).
...(rawSportType ? { stravaType: rawSportType } : {}),
...(typeof summary.average_watts === "number"
? { averageWatts: summary.average_watts }
: {}),
...(typeof summary.weighted_average_watts === "number"
? { weightedAverageWatts: summary.weighted_average_watts }
: {}),
...(typeof summary.device_watts === "boolean"
? { deviceWatts: summary.device_watts }
: {}),
...(typeof summary.average_cadence === "number"
? { averageCadence: summary.average_cadence }
: {}),
...(typeof summary.trainer === "boolean"
? { trainer: summary.trainer }
: {}),
...(typeof summary.commute === "boolean"
? { commute: summary.commute }
: {}),
...(summary.gear_id ? { gearId: summary.gear_id } : {}),
};
return {
externalId: String(summary.id),
sportType,
startedAt,
endedAt,
durationSec,
totalEnergyKcal: calories,
totalDistanceM: nonNegFloat(summary.distance ?? detail?.distance),
avgHeartRate,
maxHeartRate,
elevationM: nonNegFloat(
summary.total_elevation_gain ?? detail?.total_elevation_gain,
),
metadata,
};
}
/** True when the summary already carries HR + calories are irrelevant — i.e.
* the summary alone is enough and a detail fetch can be skipped. Strava never
* puts `calories` on the summary, so a detail fetch is what fills it; callers
* use this only to decide whether HR is already covered. */
export function summaryHasHeartRate(a: StravaSummaryActivity): boolean {
return (
a.has_heartrate === true ||
typeof a.average_heartrate === "number" ||
typeof a.max_heartrate === "number"
);
}