Files
HealthLog/src/lib/strava/client.ts
T
Marc-André Bombeck b6f45c0d88 refactor(integrations): move every provider OAuth client onto one factory
`oauth-kit/client.ts` builds a provider's consent URL, code exchange,
refresh and deauthorization from a declaration of what differs: endpoints
and scope, client authentication (form or HTTP Basic, with or without the
client id beside it), the fields a grant adds, how an answer is classified
and the refusal named, the error type, and where the token sits in the
body. The seven hand-written copies of the redirect URI, authorization URL,
token post, Basic header and PKCE pair are gone; each provider keeps an
`oauth.ts` with its declaration.

Shared behaviour is now uniform: a 2xx without a token object (an empty or
non-JSON body) is a transient `empty_token_body` failure for every
provider. WHOOP, Strava and Google Health used to return null, and
Withings threw an unclassified SyntaxError; the OAuth wire golden records
exactly these lines changing and nothing else. Oura's live/legacy endpoint
fallback, Google's invalid_grant lift, Strava's Fault summary and Fitbit's
errorType are declared per provider.
2026-10-23 12:57:47 +02:00

328 lines
13 KiB
TypeScript

/**
* Strava API v3 client: activity reads.
* Docs: https://developers.strava.com/docs/reference/ (re-verify at build).
*
* Hand-rolled fetch over `safeFetch` (no vendor SDK): typed collection
* fetchers and a single field → `Workout` mapper. The OAuth handshake is
* `./oauth.ts`.
*
* 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 { parseRetryAfterMs } from "@/lib/integrations/oauth-kit/retry-after";
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";
// ─── 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;
/** Epoch SECONDS — return activities started before this instant. */
before?: 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,
...(res.status === 429
? { retryAfterSeconds: stravaRateLimitRetryAfterSeconds(res.headers) }
: {}),
});
}
return (await res.json().catch(() => null)) as T;
}
const QUARTER_HOUR_MS = 15 * 60 * 1000;
const DAY_MS = 24 * 60 * 60 * 1000;
/**
* How long to wait after a 429, from the response's own headers. Strava sends
* no `Retry-After` (one is honoured should it appear); it sends
* `X-RateLimit-Limit` / `X-RateLimit-Usage` and, for reads,
* `X-ReadRateLimit-Limit` / `X-ReadRateLimit-Usage`, each `<15-min>,<daily>`.
* The 15-minute window resets at :00, :15, :30 and :45, the daily one at
* midnight UTC (developers.strava.com/docs/rate-limits). A spent daily count
* waits for midnight; anything else, the next quarter hour.
*/
export function stravaRateLimitRetryAfterSeconds(
headers: Headers,
now: Date = new Date(),
): number {
const retryAfterMs = parseRetryAfterMs(
headers.get("retry-after"),
now.getTime(),
);
if (retryAfterMs !== undefined && retryAfterMs > 0) {
return Math.ceil(retryAfterMs / 1000);
}
const pair = (name: string): [number, number] | null => {
const parts = (headers.get(name) ?? "").split(",").map(Number);
return parts.length === 2 && parts.every(Number.isFinite)
? [parts[0]!, parts[1]!]
: null;
};
const dailySpent = (limitName: string, usageName: string): boolean => {
const limit = pair(limitName);
const usage = pair(usageName);
return limit !== null && usage !== null && usage[1] >= limit[1];
};
const nowMs = now.getTime();
const resetAt =
dailySpent("x-ratelimit-limit", "x-ratelimit-usage") ||
dailySpent("x-readratelimit-limit", "x-readratelimit-usage")
? Math.floor(nowMs / DAY_MS) * DAY_MS + DAY_MS
: Math.floor(nowMs / QUARTER_HOUR_MS) * QUARTER_HOUR_MS + QUARTER_HOUR_MS;
return Math.max(1, Math.ceil((resetAt - nowMs) / 1000));
}
/** 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)));
if (typeof query.before === "number")
params.set("before", String(Math.floor(query.before)));
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;
/**
* True when the row was mapped from the summary alone. The calories, the
* description and (when the summary lacks it) the heart rate exist only on
* the detail read, so an update from such a row must not write their
* absence over what an earlier detail read stored.
*/
summaryOnly?: boolean;
}
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,
summaryOnly: detail == null,
};
}
/** 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"
);
}