mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
`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.
328 lines
13 KiB
TypeScript
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"
|
|
);
|
|
}
|