mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Merge pull request #455 from MBombeck/release/v1.28.11
release/v1.28.11 — Strava workouts + Garmin guidance
This commit is contained in:
54 files changed
+2710
-18
No files matched your search
@@ -86,6 +86,22 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
|
||||
# OURA_CLIENT_SECRET=""
|
||||
# OURA_REDIRECT_URI=""
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Strava -- optional integration (full OAuth, shared app from env)
|
||||
# -----------------------------------------------------------------------------
|
||||
# Register an app at https://www.strava.com/settings/api and set its
|
||||
# "Authorization Callback Domain" to your instance host. Both vars together
|
||||
# enable the Strava connect button; set neither to leave the integration
|
||||
# disabled. Per-user BYO client id/secret in Settings take precedence over these
|
||||
# shared env values. Strava caps every new app at single-player mode (athlete
|
||||
# capacity 1) until you request a capacity increase — fine for a personal
|
||||
# self-host. Strava rotates its refresh token on every refresh; the sync
|
||||
# refreshes reactively on a 401. STRAVA_REDIRECT_URI overrides the derived
|
||||
# `${NEXT_PUBLIC_APP_URL}/api/strava/callback`.
|
||||
# STRAVA_CLIENT_ID=""
|
||||
# STRAVA_CLIENT_SECRET=""
|
||||
# STRAVA_REDIRECT_URI=""
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Web Push -- VAPID keys (optional; the admin panel is the easier path)
|
||||
|
||||
@@ -2,6 +2,16 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.28.11] — 2026-07-09 — Strava workouts + Garmin guidance
|
||||
|
||||
Connect Strava to bring your activities in as workouts. Like the other wearable
|
||||
connections, you register your own Strava application and paste its keys, so no
|
||||
shared account is involved. A run that also arrives through Apple Health is not
|
||||
double-counted — the existing source-priority picker collapses the duplicate.
|
||||
Strava is a workout source only (no sleep, recovery, or body metrics). Also adds
|
||||
a Garmin page and an in-app note: Garmin has no direct connection, but its data
|
||||
flows in through Apple Health or Google Health Connect.
|
||||
|
||||
## [1.28.10] — 2026-07-09 — Onboarding shows the AI value early
|
||||
|
||||
New accounts now meet the AI value up front instead of discovering it as a cold
|
||||
|
||||
@@ -131,6 +131,15 @@ services:
|
||||
OURA_CLIENT_ID: "${OURA_CLIENT_ID:-}"
|
||||
OURA_CLIENT_SECRET: "${OURA_CLIENT_SECRET:-}"
|
||||
OURA_REDIRECT_URI: "${OURA_REDIRECT_URI:-}"
|
||||
# v1.28.x — Strava OAuth (shared app from env; per-user BYO keys override).
|
||||
# Both together enable the connect button; an unset pair leaves it
|
||||
# disabled. STRAVA_REDIRECT_URI overrides the derived
|
||||
# `${NEXT_PUBLIC_APP_URL}/api/strava/callback`. Listed here so operator
|
||||
# values in .env reach the container (the `environment:` block is a
|
||||
# whitelist).
|
||||
STRAVA_CLIENT_ID: "${STRAVA_CLIENT_ID:-}"
|
||||
STRAVA_CLIENT_SECRET: "${STRAVA_CLIENT_SECRET:-}"
|
||||
STRAVA_REDIRECT_URI: "${STRAVA_REDIRECT_URI:-}"
|
||||
# Deploy-result webhook (optional). DEPLOY_WEBHOOK_SECRET authenticates
|
||||
# POST /api/internal/deploy-webhook; DEPLOY_LOGS_URL is the
|
||||
# deployment-dashboard URL (e.g. the Coolify UI) linked in
|
||||
|
||||
+123
-1
@@ -1,7 +1,7 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: HealthLog API
|
||||
version: 1.28.10
|
||||
version: 1.28.11
|
||||
description: >-
|
||||
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
|
||||
|
||||
@@ -2243,6 +2243,7 @@ paths:
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- STRAVA
|
||||
- in: query
|
||||
name: valueMin
|
||||
schema:
|
||||
@@ -4016,6 +4017,7 @@ paths:
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- STRAVA
|
||||
activeEnergy:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -4035,6 +4037,7 @@ paths:
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- STRAVA
|
||||
walkingRunningDistance:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -4054,6 +4057,7 @@ paths:
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- STRAVA
|
||||
flightsClimbed:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -4073,6 +4077,7 @@ paths:
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- STRAVA
|
||||
sleep:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -4092,6 +4097,7 @@ paths:
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- STRAVA
|
||||
weight:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -4111,6 +4117,7 @@ paths:
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- STRAVA
|
||||
bloodPressure:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -4130,6 +4137,7 @@ paths:
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- STRAVA
|
||||
pulse:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -4149,6 +4157,7 @@ paths:
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- STRAVA
|
||||
bodyFat:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -4168,6 +4177,7 @@ paths:
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- STRAVA
|
||||
bodyTemperature:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -4187,6 +4197,7 @@ paths:
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- STRAVA
|
||||
spo2:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -4206,6 +4217,7 @@ paths:
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- STRAVA
|
||||
hrv:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -4225,6 +4237,7 @@ paths:
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- STRAVA
|
||||
restingHeartRate:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -4244,6 +4257,7 @@ paths:
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- STRAVA
|
||||
vo2Max:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -4263,6 +4277,7 @@ paths:
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- STRAVA
|
||||
skinTemperature:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -4282,6 +4297,7 @@ paths:
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- STRAVA
|
||||
respiratoryRate:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -4301,6 +4317,7 @@ paths:
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- STRAVA
|
||||
recovery:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -4320,6 +4337,7 @@ paths:
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- STRAVA
|
||||
stress:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -4339,6 +4357,7 @@ paths:
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- STRAVA
|
||||
sleepDebt:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -4358,6 +4377,7 @@ paths:
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- STRAVA
|
||||
metricPriority:
|
||||
type: object
|
||||
properties:
|
||||
@@ -4380,6 +4400,7 @@ paths:
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- STRAVA
|
||||
activeEnergy:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -4399,6 +4420,7 @@ paths:
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- STRAVA
|
||||
walkingRunningDistance:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -4418,6 +4440,7 @@ paths:
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- STRAVA
|
||||
flightsClimbed:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -4437,6 +4460,7 @@ paths:
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- STRAVA
|
||||
sleep:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -4456,6 +4480,7 @@ paths:
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- STRAVA
|
||||
weight:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -4475,6 +4500,7 @@ paths:
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- STRAVA
|
||||
bloodPressure:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -4494,6 +4520,7 @@ paths:
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- STRAVA
|
||||
pulse:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -4513,6 +4540,7 @@ paths:
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- STRAVA
|
||||
bodyFat:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -4532,6 +4560,7 @@ paths:
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- STRAVA
|
||||
bodyTemperature:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -4551,6 +4580,7 @@ paths:
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- STRAVA
|
||||
spo2:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -4570,6 +4600,7 @@ paths:
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- STRAVA
|
||||
hrv:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -4589,6 +4620,7 @@ paths:
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- STRAVA
|
||||
restingHeartRate:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -4608,6 +4640,7 @@ paths:
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- STRAVA
|
||||
vo2Max:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -4627,6 +4660,7 @@ paths:
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- STRAVA
|
||||
skinTemperature:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -4646,6 +4680,7 @@ paths:
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- STRAVA
|
||||
respiratoryRate:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -4665,6 +4700,7 @@ paths:
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- STRAVA
|
||||
recovery:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -4684,6 +4720,7 @@ paths:
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- STRAVA
|
||||
stress:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -4703,6 +4740,7 @@ paths:
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- STRAVA
|
||||
sleepDebt:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -4722,6 +4760,7 @@ paths:
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- STRAVA
|
||||
deviceTypePriority:
|
||||
type: object
|
||||
properties:
|
||||
@@ -14677,6 +14716,7 @@ components:
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- STRAVA
|
||||
notes:
|
||||
anyOf:
|
||||
- type: string
|
||||
@@ -15434,6 +15474,7 @@ components:
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- STRAVA
|
||||
notes:
|
||||
anyOf:
|
||||
- type: string
|
||||
@@ -15679,6 +15720,7 @@ components:
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- STRAVA
|
||||
- type: "null"
|
||||
start:
|
||||
type: string
|
||||
@@ -15808,6 +15850,7 @@ components:
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- STRAVA
|
||||
- type: "null"
|
||||
start:
|
||||
type: string
|
||||
@@ -15988,6 +16031,7 @@ components:
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- STRAVA
|
||||
description: v1.25.0 — the active source the debt is resolved FROM, picked off the user's `sleepDebt` source ladder.
|
||||
`COMPUTED` is HealthLog's own rolling-balance estimate (the only producer today); a provider value would
|
||||
be a device-native debt. Clients explain the figure when it is `COMPUTED`.
|
||||
@@ -16203,6 +16247,7 @@ components:
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- STRAVA
|
||||
externalId:
|
||||
anyOf:
|
||||
- type: string
|
||||
@@ -16314,6 +16359,7 @@ components:
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- STRAVA
|
||||
externalId:
|
||||
anyOf:
|
||||
- type: string
|
||||
@@ -18931,6 +18977,7 @@ components:
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- STRAVA
|
||||
activeEnergy:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -18950,6 +18997,7 @@ components:
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- STRAVA
|
||||
walkingRunningDistance:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -18969,6 +19017,7 @@ components:
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- STRAVA
|
||||
flightsClimbed:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -18988,6 +19037,7 @@ components:
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- STRAVA
|
||||
sleep:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -19007,6 +19057,7 @@ components:
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- STRAVA
|
||||
weight:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -19026,6 +19077,7 @@ components:
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- STRAVA
|
||||
bloodPressure:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -19045,6 +19097,7 @@ components:
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- STRAVA
|
||||
pulse:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -19064,6 +19117,7 @@ components:
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- STRAVA
|
||||
bodyFat:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -19083,6 +19137,7 @@ components:
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- STRAVA
|
||||
bodyTemperature:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -19102,6 +19157,7 @@ components:
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- STRAVA
|
||||
spo2:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -19121,6 +19177,7 @@ components:
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- STRAVA
|
||||
hrv:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -19140,6 +19197,7 @@ components:
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- STRAVA
|
||||
restingHeartRate:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -19159,6 +19217,7 @@ components:
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- STRAVA
|
||||
vo2Max:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -19178,6 +19237,7 @@ components:
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- STRAVA
|
||||
skinTemperature:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -19197,6 +19257,7 @@ components:
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- STRAVA
|
||||
respiratoryRate:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -19216,6 +19277,7 @@ components:
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- STRAVA
|
||||
recovery:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -19235,6 +19297,7 @@ components:
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- STRAVA
|
||||
stress:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -19254,6 +19317,7 @@ components:
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- STRAVA
|
||||
sleepDebt:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -19273,6 +19337,7 @@ components:
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- STRAVA
|
||||
metricPriority:
|
||||
type: object
|
||||
properties:
|
||||
@@ -19295,6 +19360,7 @@ components:
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- STRAVA
|
||||
activeEnergy:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -19314,6 +19380,7 @@ components:
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- STRAVA
|
||||
walkingRunningDistance:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -19333,6 +19400,7 @@ components:
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- STRAVA
|
||||
flightsClimbed:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -19352,6 +19420,7 @@ components:
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- STRAVA
|
||||
sleep:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -19371,6 +19440,7 @@ components:
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- STRAVA
|
||||
weight:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -19390,6 +19460,7 @@ components:
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- STRAVA
|
||||
bloodPressure:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -19409,6 +19480,7 @@ components:
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- STRAVA
|
||||
pulse:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -19428,6 +19500,7 @@ components:
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- STRAVA
|
||||
bodyFat:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -19447,6 +19520,7 @@ components:
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- STRAVA
|
||||
bodyTemperature:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -19466,6 +19540,7 @@ components:
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- STRAVA
|
||||
spo2:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -19485,6 +19560,7 @@ components:
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- STRAVA
|
||||
hrv:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -19504,6 +19580,7 @@ components:
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- STRAVA
|
||||
restingHeartRate:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -19523,6 +19600,7 @@ components:
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- STRAVA
|
||||
vo2Max:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -19542,6 +19620,7 @@ components:
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- STRAVA
|
||||
skinTemperature:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -19561,6 +19640,7 @@ components:
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- STRAVA
|
||||
respiratoryRate:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -19580,6 +19660,7 @@ components:
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- STRAVA
|
||||
recovery:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -19599,6 +19680,7 @@ components:
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- STRAVA
|
||||
stress:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -19618,6 +19700,7 @@ components:
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- STRAVA
|
||||
sleepDebt:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -19637,6 +19720,7 @@ components:
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- STRAVA
|
||||
additionalProperties: false
|
||||
deviceTypePriority:
|
||||
type: object
|
||||
@@ -19705,6 +19789,7 @@ components:
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- STRAVA
|
||||
activeEnergy:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -19724,6 +19809,7 @@ components:
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- STRAVA
|
||||
walkingRunningDistance:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -19743,6 +19829,7 @@ components:
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- STRAVA
|
||||
flightsClimbed:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -19762,6 +19849,7 @@ components:
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- STRAVA
|
||||
sleep:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -19781,6 +19869,7 @@ components:
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- STRAVA
|
||||
weight:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -19800,6 +19889,7 @@ components:
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- STRAVA
|
||||
bloodPressure:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -19819,6 +19909,7 @@ components:
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- STRAVA
|
||||
pulse:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -19838,6 +19929,7 @@ components:
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- STRAVA
|
||||
bodyFat:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -19857,6 +19949,7 @@ components:
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- STRAVA
|
||||
bodyTemperature:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -19876,6 +19969,7 @@ components:
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- STRAVA
|
||||
spo2:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -19895,6 +19989,7 @@ components:
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- STRAVA
|
||||
hrv:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -19914,6 +20009,7 @@ components:
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- STRAVA
|
||||
restingHeartRate:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -19933,6 +20029,7 @@ components:
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- STRAVA
|
||||
vo2Max:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -19952,6 +20049,7 @@ components:
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- STRAVA
|
||||
skinTemperature:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -19971,6 +20069,7 @@ components:
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- STRAVA
|
||||
respiratoryRate:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -19990,6 +20089,7 @@ components:
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- STRAVA
|
||||
recovery:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -20009,6 +20109,7 @@ components:
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- STRAVA
|
||||
stress:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -20028,6 +20129,7 @@ components:
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- STRAVA
|
||||
sleepDebt:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -20047,6 +20149,7 @@ components:
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- STRAVA
|
||||
metricPriority:
|
||||
type: object
|
||||
properties:
|
||||
@@ -20069,6 +20172,7 @@ components:
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- STRAVA
|
||||
activeEnergy:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -20088,6 +20192,7 @@ components:
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- STRAVA
|
||||
walkingRunningDistance:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -20107,6 +20212,7 @@ components:
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- STRAVA
|
||||
flightsClimbed:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -20126,6 +20232,7 @@ components:
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- STRAVA
|
||||
sleep:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -20145,6 +20252,7 @@ components:
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- STRAVA
|
||||
weight:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -20164,6 +20272,7 @@ components:
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- STRAVA
|
||||
bloodPressure:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -20183,6 +20292,7 @@ components:
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- STRAVA
|
||||
pulse:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -20202,6 +20312,7 @@ components:
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- STRAVA
|
||||
bodyFat:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -20221,6 +20332,7 @@ components:
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- STRAVA
|
||||
bodyTemperature:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -20240,6 +20352,7 @@ components:
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- STRAVA
|
||||
spo2:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -20259,6 +20372,7 @@ components:
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- STRAVA
|
||||
hrv:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -20278,6 +20392,7 @@ components:
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- STRAVA
|
||||
restingHeartRate:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -20297,6 +20412,7 @@ components:
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- STRAVA
|
||||
vo2Max:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -20316,6 +20432,7 @@ components:
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- STRAVA
|
||||
skinTemperature:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -20335,6 +20452,7 @@ components:
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- STRAVA
|
||||
respiratoryRate:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -20354,6 +20472,7 @@ components:
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- STRAVA
|
||||
recovery:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -20373,6 +20492,7 @@ components:
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- STRAVA
|
||||
stress:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -20392,6 +20512,7 @@ components:
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- STRAVA
|
||||
sleepDebt:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -20411,6 +20532,7 @@ components:
|
||||
- TELEGRAM
|
||||
- MCP
|
||||
- GOOGLE_HEALTH
|
||||
- STRAVA
|
||||
additionalProperties: false
|
||||
deviceTypePriority:
|
||||
type: object
|
||||
|
||||
@@ -199,3 +199,10 @@ The wide-event log line `import.apple-health.kickoff` (or
|
||||
`import.apple-health.kickoff.denied` on failure) captures every
|
||||
attempt with the upload size, SHA-256, and the resolved `ImportJob`
|
||||
id for debugging.
|
||||
|
||||
## Garmin owners
|
||||
|
||||
Garmin has no direct connector for a self-hosted instance. Garmin Connect writes
|
||||
to Apple Health automatically, so the import path on this page is how Garmin data
|
||||
reaches HealthLog on iOS. See [garmin.md](./garmin.md) for what comes through and
|
||||
what Garmin withholds.
|
||||
@@ -0,0 +1,52 @@
|
||||
# Garmin (via Apple Health or Google Health Connect)
|
||||
|
||||
**There is no direct Garmin connector for a self-hosted HealthLog instance, and
|
||||
there cannot be one.** Garmin's data-sharing program is business-partner-only:
|
||||
it is approval-gated, requires a public callback endpoint, and is not the
|
||||
"register your own app, paste a key" model the wearable integrations here rely
|
||||
on. So instead of a broken half-integration, HealthLog reads Garmin data the
|
||||
honest way — through the phone health platform your Garmin already feeds.
|
||||
|
||||
## The path that works
|
||||
|
||||
```
|
||||
Garmin device → Garmin Connect → Apple Health (iOS) ─┐
|
||||
→ Google Health Connect ├─→ HealthLog
|
||||
(Android) ─┘
|
||||
```
|
||||
|
||||
- **iOS:** Garmin Connect writes to Apple Health automatically. Import it with
|
||||
the existing [Apple Health](./apple-health.md) path (the `export.zip` upload or
|
||||
the native client's background sync).
|
||||
- **Android:** since Garmin Connect 5.14.1 (July 2025) the app writes to **Google
|
||||
Health Connect**. Connect HealthLog through the existing
|
||||
[Google Health](./google-health.md) integration.
|
||||
|
||||
No extra code, no Garmin developer account, no approval queue — if your Garmin
|
||||
already syncs to your phone's health platform, HealthLog already reads it.
|
||||
|
||||
## What comes through
|
||||
|
||||
Everything Garmin shares with Apple Health / Health Connect flows in: steps,
|
||||
heart rate (including beat-to-beat variability), sleep stages (REM / deep /
|
||||
light), calories (active and total), distance, weight and body composition,
|
||||
cadence, and — partially — blood-oxygen saturation.
|
||||
|
||||
## What Garmin withholds
|
||||
|
||||
These are Garmin's **proprietary** scores. Garmin does **not** share them with
|
||||
Apple Health or Health Connect, so no self-hosted integration — and no amount of
|
||||
configuration — can obtain them. They are only available inside Garmin Connect
|
||||
itself:
|
||||
|
||||
- Body Battery
|
||||
- HRV Status
|
||||
- VO₂ max
|
||||
- Training Load and Training Effect
|
||||
- Recovery Time
|
||||
- Intensity Minutes
|
||||
- Stress score
|
||||
- Running dynamics
|
||||
|
||||
If you rely on those specific numbers, keep the Garmin Connect app for them;
|
||||
everything else lands in HealthLog through the platform path above.
|
||||
@@ -141,3 +141,10 @@ per metric.
|
||||
|
||||
The Settings **Disconnect** button removes the connection and its stored tokens.
|
||||
Previously synced measurements stay in the database.
|
||||
|
||||
## Garmin owners
|
||||
|
||||
Garmin has no direct connector for a self-hosted instance. Since Garmin Connect
|
||||
5.14.1 (July 2025) the app writes to Google Health Connect, so this integration
|
||||
is how Garmin data reaches HealthLog on Android. See [garmin.md](./garmin.md) for
|
||||
what comes through and what Garmin withholds.
|
||||
@@ -0,0 +1,74 @@
|
||||
# Strava integration
|
||||
|
||||
HealthLog reads your Strava activities and folds them into the same workout
|
||||
history as every other source. Strava is a **workout source only** — its API is
|
||||
activity-centric and exposes no sleep, recovery, body-composition or glucose
|
||||
data, so connecting it adds runs, rides, swims and the rest to your workout
|
||||
surface and nothing else.
|
||||
|
||||
The OAuth app is brought **per user**: each self-hoster registers their own
|
||||
Strava API application and pastes the client id/secret into Settings. Strava
|
||||
caps every newly-created app at "single-player mode" (an athlete capacity of 1)
|
||||
until you request a capacity increase — perfectly fine for a personal
|
||||
self-host, and the reason a single shared app cannot serve many operators.
|
||||
|
||||
## What you get — and what you don't
|
||||
|
||||
**Covered** (one `Workout` row per activity): sport type, start and end time,
|
||||
moving duration, distance, elevation gain, average and maximum heart rate (when
|
||||
the activity recorded it), and calories. Power, cadence, the "trainer"/"commute"
|
||||
flags and the activity name are kept on the row's metadata for provenance but
|
||||
are **not** surfaced as training analytics — HealthLog treats Strava as another
|
||||
workout source, not a training-load platform.
|
||||
|
||||
**Not covered**: Strava exposes no daily metrics (steps, sleep, resting heart
|
||||
rate, weight, …). Those come from your wearable's own integration or from Apple
|
||||
Health / Google Health Connect.
|
||||
|
||||
## Duplicate activities are collapsed automatically
|
||||
|
||||
If you record a run on an Apple Watch **and** it also lands in Strava, the same
|
||||
run arrives twice — once via Apple Health, once via Strava. HealthLog keeps one
|
||||
canonical row per logical workout using your **source-priority ladder**: by
|
||||
default the device-native capture (Apple Watch, WHOOP, Withings) wins over the
|
||||
Strava copy, because a Strava activity is often a re-upload with lower-fidelity
|
||||
or missing heart-rate data. A run you record **only** on Strava (phone GPS)
|
||||
survives untouched. You can promote Strava above the wearables in
|
||||
**Settings → Sources** if you prefer the Strava copy.
|
||||
|
||||
## 1. Register a Strava API application
|
||||
|
||||
1. Sign in at <https://www.strava.com/settings/api>.
|
||||
2. Create an application. For **Authorization Callback Domain** enter the host
|
||||
of your HealthLog instance (the domain only, no scheme or path).
|
||||
3. Copy the **Client ID** and **Client Secret**.
|
||||
|
||||
## 2. Paste the credentials into HealthLog
|
||||
|
||||
1. Open **Settings → Integrations** and find the **Strava** card.
|
||||
2. Paste the Client ID and Client Secret into the **API Credentials** fields and
|
||||
save. (An operator can instead configure a shared app via the
|
||||
`STRAVA_CLIENT_ID` / `STRAVA_CLIENT_SECRET` environment variables; per-user
|
||||
credentials always take precedence.)
|
||||
3. Click **Connect Strava** and approve the access request. HealthLog requests
|
||||
the `activity:read_all` scope so it can read every activity, including those
|
||||
you marked "only me".
|
||||
|
||||
## 3. First sync
|
||||
|
||||
The first history import runs in the background shortly after you connect;
|
||||
afterwards an hourly poll pulls new activities. Strava rotates its refresh token
|
||||
on every refresh, and HealthLog persists the rotated token each time, so the
|
||||
connection stays healthy without you re-authorising.
|
||||
|
||||
## Disconnecting
|
||||
|
||||
Disconnecting revokes the grant at Strava and clears the stored tokens.
|
||||
Activities already imported stay in your history; reconnecting resumes the sync.
|
||||
|
||||
## Related
|
||||
|
||||
- Wearable that syncs to Strava but not directly to HealthLog? See the device's
|
||||
own page, or ingest via [Apple Health](./apple-health.md) /
|
||||
[Google Health Connect](./google-health.md).
|
||||
- **Garmin owners:** Garmin has no direct connector — see [garmin.md](./garmin.md).
|
||||
@@ -95,6 +95,20 @@ test.describe("/settings/integrations Pixel-5 layout", () => {
|
||||
webhookSecret: "ml_xxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
||||
entryCount: 42,
|
||||
},
|
||||
// v1.28.x — Strava OAuth card reads off this consolidated
|
||||
// envelope; without a `strava` entry the new card's query key
|
||||
// never resolves and the integrations page hangs.
|
||||
{
|
||||
integration: "strava",
|
||||
state: "disconnected",
|
||||
lastSuccessAt: null,
|
||||
lastAttemptAt: null,
|
||||
lastError: null,
|
||||
connected: false,
|
||||
configured: false,
|
||||
available: false,
|
||||
hasOwnCredentials: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
error: null,
|
||||
@@ -143,9 +157,10 @@ test.describe("/settings/integrations Pixel-5 layout", () => {
|
||||
|
||||
// Wait for the pills to mount (queries resolve after first paint).
|
||||
// One pill per card: Withings, WHOOP, Fitbit, Google Health, Polar,
|
||||
// Oura, Nightscout.
|
||||
// Oura, Strava, Nightscout. (The Garmin info note is not an OAuth card and
|
||||
// carries no pill.)
|
||||
const pills = page.locator('[data-testid="integration-status-pill"]');
|
||||
await expect(pills).toHaveCount(7, { timeout: 10_000 });
|
||||
await expect(pills).toHaveCount(8, { timeout: 10_000 });
|
||||
|
||||
// Withings is connected in this fixture; WHOOP is a BYO-keys card that
|
||||
// stays dormant until an operator adds credentials, so assert the one
|
||||
|
||||
@@ -5478,6 +5478,38 @@
|
||||
"connectionRevoke": "Trennen",
|
||||
"connectionRevokeDescription": "Dies beendet den Zugriff des Assistenten sofort und verhindert eine Erneuerung. Für eine erneute Verbindung ist eine neue Autorisierung nötig.",
|
||||
"connectionRevokeFailed": "Assistent konnte nicht getrennt werden."
|
||||
},
|
||||
"strava": "Strava",
|
||||
"stravaDescription": "Verbinde dein Strava-Konto, um deine Aktivitäten in deinen Trainingsverlauf zu übernehmen.",
|
||||
"stravaDescriptionSecondary": "Aktivitäten fließen im Hintergrund in dein Dashboard. Neu bei der Einrichtung? Folge der",
|
||||
"stravaViewData": "Trainings ansehen",
|
||||
"stravaCredentials": "API-Zugangsdaten",
|
||||
"stravaClientId": "Client-ID",
|
||||
"stravaClientSecret": "Client-Secret",
|
||||
"stravaCredentialsSaved": "Zugangsdaten gespeichert",
|
||||
"stravaCredentialsSavedPlaceholder": "Gespeichert — neue eingeben zum Ersetzen",
|
||||
"stravaSaveCredentials": "Speichern",
|
||||
"stravaUnavailable": "Strava ist auf diesem Server nicht konfiguriert. Füge oben deine eigenen Strava-API-Zugangsdaten hinzu oder bitte deinen Administrator, die gemeinsame App zu konfigurieren.",
|
||||
"stravaConnect": "Strava verbinden",
|
||||
"stravaDisconnect": "Trennen",
|
||||
"stravaDisconnectTitle": "Strava trennen?",
|
||||
"stravaDisconnectDescription": "Künftige Synchronisierungen stoppen. Bereits importierte Aktivitäten bleiben in deinem Verlauf.",
|
||||
"stravaOauthConnected": "Strava verbunden — die erste Synchronisierung startet im Hintergrund.",
|
||||
"stravaOauthFailed": "Strava-Verbindung fehlgeschlagen",
|
||||
"stravaOauthError": {
|
||||
"csrf1": "Die Sicherheitsprüfung ist fehlgeschlagen, weil das Browser-Cookie fehlte. Bitte starte die Verbindung im selben Browser erneut.",
|
||||
"state": "Die Antwort von Strava passte zu keiner offenen Verbindungsanfrage. Bitte starte die Verbindung erneut.",
|
||||
"cross_user": "Die Antwort gehört zu einer Anfrage eines anderen Kontos. Bitte starte die Verbindung von deinem eigenen Konto aus erneut.",
|
||||
"nocode": "Strava hat keinen Autorisierungscode zurückgegeben. Versuche es erneut und erlaube den Zugriff im Strava-Dialog.",
|
||||
"nocreds": "Strava ist auf diesem Server nicht konfiguriert. Bitte deinen Administrator, die Strava-API-Zugangsdaten hinzuzufügen.",
|
||||
"token": "Der Token-Austausch mit Strava ist fehlgeschlagen. Bitte starte die Verbindung erneut.",
|
||||
"rate_limited": "Zu viele Verbindungsversuche. Warte einen Moment und versuche es erneut.",
|
||||
"generic": "Unbekannter Fehler bei der Verbindung. Bitte starte die Verbindung erneut."
|
||||
},
|
||||
"garminInfo": {
|
||||
"title": "Garmin",
|
||||
"body": "Garmin hat hier keine direkte Verbindung. Verbinde über Apple Health (iOS) oder Google Health Connect (Android). Garmins proprietäre Werte — Body Battery, Training Load, HRV-Status, VO₂max — werden von Garmin an keine der beiden Plattformen weitergegeben.",
|
||||
"docsLink": "Zur Anleitung"
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
|
||||
@@ -5478,6 +5478,38 @@
|
||||
"connectionRevoke": "Disconnect",
|
||||
"connectionRevokeDescription": "This ends the assistant's access immediately and stops it from renewing. It will need to be re-authorized to reconnect.",
|
||||
"connectionRevokeFailed": "Could not disconnect the assistant."
|
||||
},
|
||||
"strava": "Strava",
|
||||
"stravaDescription": "Connect your Strava account to sync your activities into your workout history.",
|
||||
"stravaDescriptionSecondary": "Activities flow into your dashboard in the background. New to the setup? Follow the",
|
||||
"stravaViewData": "View your workouts",
|
||||
"stravaCredentials": "API Credentials",
|
||||
"stravaClientId": "Client ID",
|
||||
"stravaClientSecret": "Client Secret",
|
||||
"stravaCredentialsSaved": "Credentials saved",
|
||||
"stravaCredentialsSavedPlaceholder": "Saved — enter new to replace",
|
||||
"stravaSaveCredentials": "Save",
|
||||
"stravaUnavailable": "Strava is not configured on this server. Add your own Strava API credentials above, or ask your administrator to configure the shared app.",
|
||||
"stravaConnect": "Connect Strava",
|
||||
"stravaDisconnect": "Disconnect",
|
||||
"stravaDisconnectTitle": "Disconnect Strava?",
|
||||
"stravaDisconnectDescription": "Future syncs stop. Activities already imported stay in your history.",
|
||||
"stravaOauthConnected": "Strava connected — the first sync starts in the background.",
|
||||
"stravaOauthFailed": "Strava connection failed",
|
||||
"stravaOauthError": {
|
||||
"csrf1": "The security check failed because the browser cookie was missing. Please start the connection again in the same browser.",
|
||||
"state": "The response from Strava did not match any open connection request. Please start the connection again.",
|
||||
"cross_user": "The response belongs to a request from a different account. Please restart the connection from your own account.",
|
||||
"nocode": "Strava did not return an authorization code. Try again and allow access in the Strava dialog.",
|
||||
"nocreds": "Strava is not configured on this server. Ask your administrator to add the Strava API credentials.",
|
||||
"token": "The token exchange with Strava failed. Please start the connection again.",
|
||||
"rate_limited": "Too many connection attempts. Wait a moment and try again.",
|
||||
"generic": "Unknown error while connecting. Please start the connection again."
|
||||
},
|
||||
"garminInfo": {
|
||||
"title": "Garmin",
|
||||
"body": "Garmin has no direct connection here. Connect via Apple Health (iOS) or Google Health Connect (Android). Garmin's proprietary scores — Body Battery, Training Load, HRV Status, VO₂ max — are not shared by Garmin to either platform.",
|
||||
"docsLink": "Read the guide"
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
|
||||
@@ -5478,6 +5478,38 @@
|
||||
"connectionRevoke": "Desconectar",
|
||||
"connectionRevokeDescription": "Esto finaliza el acceso del asistente de inmediato e impide su renovación. Será necesario autorizarlo de nuevo para volver a conectarlo.",
|
||||
"connectionRevokeFailed": "No se pudo desconectar el asistente."
|
||||
},
|
||||
"strava": "Strava",
|
||||
"stravaDescription": "Conecta tu cuenta de Strava para sincronizar tus actividades con tu historial de entrenamientos.",
|
||||
"stravaDescriptionSecondary": "Las actividades llegan a tu panel en segundo plano. ¿Nuevo en la configuración? Sigue la",
|
||||
"stravaViewData": "Ver tus entrenamientos",
|
||||
"stravaCredentials": "Credenciales de API",
|
||||
"stravaClientId": "ID de cliente",
|
||||
"stravaClientSecret": "Secreto de cliente",
|
||||
"stravaCredentialsSaved": "Credenciales guardadas",
|
||||
"stravaCredentialsSavedPlaceholder": "Guardadas — introduce nuevas para reemplazar",
|
||||
"stravaSaveCredentials": "Guardar",
|
||||
"stravaUnavailable": "Strava no está configurado en este servidor. Añade arriba tus propias credenciales de API de Strava o pide a tu administrador que configure la app compartida.",
|
||||
"stravaConnect": "Conectar Strava",
|
||||
"stravaDisconnect": "Desconectar",
|
||||
"stravaDisconnectTitle": "¿Desconectar Strava?",
|
||||
"stravaDisconnectDescription": "Las sincronizaciones futuras se detienen. Las actividades ya importadas permanecen en tu historial.",
|
||||
"stravaOauthConnected": "Strava conectado: la primera sincronización comienza en segundo plano.",
|
||||
"stravaOauthFailed": "La conexión con Strava falló",
|
||||
"stravaOauthError": {
|
||||
"csrf1": "La comprobación de seguridad falló porque faltaba la cookie del navegador. Inicia la conexión de nuevo en el mismo navegador.",
|
||||
"state": "La respuesta de Strava no coincidió con ninguna solicitud de conexión abierta. Inicia la conexión de nuevo.",
|
||||
"cross_user": "La respuesta pertenece a una solicitud de otra cuenta. Reinicia la conexión desde tu propia cuenta.",
|
||||
"nocode": "Strava no devolvió un código de autorización. Inténtalo de nuevo y permite el acceso en el diálogo de Strava.",
|
||||
"nocreds": "Strava no está configurado en este servidor. Pide a tu administrador que añada las credenciales de API de Strava.",
|
||||
"token": "El intercambio de tokens con Strava falló. Inicia la conexión de nuevo.",
|
||||
"rate_limited": "Demasiados intentos de conexión. Espera un momento e inténtalo de nuevo.",
|
||||
"generic": "Error desconocido al conectar. Inicia la conexión de nuevo."
|
||||
},
|
||||
"garminInfo": {
|
||||
"title": "Garmin",
|
||||
"body": "Garmin no tiene conexión directa aquí. Conéctalo a través de Apple Health (iOS) o Google Health Connect (Android). Las métricas propias de Garmin — Body Battery, Training Load, estado de HRV, VO₂ máx.— no las comparte Garmin con ninguna de las dos plataformas.",
|
||||
"docsLink": "Leer la guía"
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
|
||||
@@ -5478,6 +5478,38 @@
|
||||
"connectionRevoke": "Déconnecter",
|
||||
"connectionRevokeDescription": "Cela met fin immédiatement à l'accès de l'assistant et l'empêche de se renouveler. Une nouvelle autorisation sera nécessaire pour le reconnecter.",
|
||||
"connectionRevokeFailed": "Impossible de déconnecter l'assistant."
|
||||
},
|
||||
"strava": "Strava",
|
||||
"stravaDescription": "Connecte ton compte Strava pour synchroniser tes activités avec ton historique d'entraînement.",
|
||||
"stravaDescriptionSecondary": "Les activités arrivent sur ton tableau de bord en arrière-plan. Nouveau dans la configuration ? Suis le",
|
||||
"stravaViewData": "Voir tes entraînements",
|
||||
"stravaCredentials": "Identifiants API",
|
||||
"stravaClientId": "ID client",
|
||||
"stravaClientSecret": "Secret client",
|
||||
"stravaCredentialsSaved": "Identifiants enregistrés",
|
||||
"stravaCredentialsSavedPlaceholder": "Enregistrés — saisis-en de nouveaux pour remplacer",
|
||||
"stravaSaveCredentials": "Enregistrer",
|
||||
"stravaUnavailable": "Strava n'est pas configuré sur ce serveur. Ajoute tes propres identifiants API Strava ci-dessus ou demande à ton administrateur de configurer l'application partagée.",
|
||||
"stravaConnect": "Connecter Strava",
|
||||
"stravaDisconnect": "Déconnecter",
|
||||
"stravaDisconnectTitle": "Déconnecter Strava ?",
|
||||
"stravaDisconnectDescription": "Les synchronisations futures s'arrêtent. Les activités déjà importées restent dans ton historique.",
|
||||
"stravaOauthConnected": "Strava connecté — la première synchronisation démarre en arrière-plan.",
|
||||
"stravaOauthFailed": "La connexion à Strava a échoué",
|
||||
"stravaOauthError": {
|
||||
"csrf1": "Le contrôle de sécurité a échoué car le cookie du navigateur était manquant. Relance la connexion dans le même navigateur.",
|
||||
"state": "La réponse de Strava ne correspondait à aucune demande de connexion ouverte. Relance la connexion.",
|
||||
"cross_user": "La réponse appartient à une demande d'un autre compte. Relance la connexion depuis ton propre compte.",
|
||||
"nocode": "Strava n'a pas renvoyé de code d'autorisation. Réessaie et autorise l'accès dans la boîte de dialogue Strava.",
|
||||
"nocreds": "Strava n'est pas configuré sur ce serveur. Demande à ton administrateur d'ajouter les identifiants API Strava.",
|
||||
"token": "L'échange de jeton avec Strava a échoué. Relance la connexion.",
|
||||
"rate_limited": "Trop de tentatives de connexion. Attends un instant et réessaie.",
|
||||
"generic": "Erreur inconnue lors de la connexion. Relance la connexion."
|
||||
},
|
||||
"garminInfo": {
|
||||
"title": "Garmin",
|
||||
"body": "Garmin n'a pas de connexion directe ici. Connecte-le via Apple Health (iOS) ou Google Health Connect (Android). Les scores propriétaires de Garmin — Body Battery, Training Load, statut VFC, VO₂ max — ne sont partagés par Garmin avec aucune des deux plateformes.",
|
||||
"docsLink": "Lire le guide"
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
|
||||
@@ -5478,6 +5478,38 @@
|
||||
"connectionRevoke": "Disconnetti",
|
||||
"connectionRevokeDescription": "Questo termina immediatamente l'accesso dell'assistente e ne impedisce il rinnovo. Per riconnetterlo sarà necessaria una nuova autorizzazione.",
|
||||
"connectionRevokeFailed": "Impossibile disconnettere l'assistente."
|
||||
},
|
||||
"strava": "Strava",
|
||||
"stravaDescription": "Collega il tuo account Strava per sincronizzare le tue attività con la cronologia degli allenamenti.",
|
||||
"stravaDescriptionSecondary": "Le attività arrivano nella tua dashboard in background. Nuovo alla configurazione? Segui la",
|
||||
"stravaViewData": "Vedi i tuoi allenamenti",
|
||||
"stravaCredentials": "Credenziali API",
|
||||
"stravaClientId": "ID client",
|
||||
"stravaClientSecret": "Secret client",
|
||||
"stravaCredentialsSaved": "Credenziali salvate",
|
||||
"stravaCredentialsSavedPlaceholder": "Salvate — inseriscine di nuove per sostituirle",
|
||||
"stravaSaveCredentials": "Salva",
|
||||
"stravaUnavailable": "Strava non è configurato su questo server. Aggiungi sopra le tue credenziali API di Strava o chiedi al tuo amministratore di configurare l'app condivisa.",
|
||||
"stravaConnect": "Collega Strava",
|
||||
"stravaDisconnect": "Disconnetti",
|
||||
"stravaDisconnectTitle": "Disconnettere Strava?",
|
||||
"stravaDisconnectDescription": "Le sincronizzazioni future si interrompono. Le attività già importate restano nella cronologia.",
|
||||
"stravaOauthConnected": "Strava collegato — la prima sincronizzazione parte in background.",
|
||||
"stravaOauthFailed": "Connessione a Strava non riuscita",
|
||||
"stravaOauthError": {
|
||||
"csrf1": "Il controllo di sicurezza non è riuscito perché mancava il cookie del browser. Riavvia la connessione nello stesso browser.",
|
||||
"state": "La risposta di Strava non corrispondeva ad alcuna richiesta di connessione aperta. Riavvia la connessione.",
|
||||
"cross_user": "La risposta appartiene a una richiesta di un altro account. Riavvia la connessione dal tuo account.",
|
||||
"nocode": "Strava non ha restituito un codice di autorizzazione. Riprova e consenti l'accesso nella finestra di Strava.",
|
||||
"nocreds": "Strava non è configurato su questo server. Chiedi al tuo amministratore di aggiungere le credenziali API di Strava.",
|
||||
"token": "Lo scambio del token con Strava non è riuscito. Riavvia la connessione.",
|
||||
"rate_limited": "Troppi tentativi di connessione. Attendi un momento e riprova.",
|
||||
"generic": "Errore sconosciuto durante la connessione. Riavvia la connessione."
|
||||
},
|
||||
"garminInfo": {
|
||||
"title": "Garmin",
|
||||
"body": "Garmin non ha una connessione diretta qui. Collegalo tramite Apple Health (iOS) o Google Health Connect (Android). I punteggi proprietari di Garmin — Body Battery, Training Load, stato HRV, VO₂ max — non vengono condivisi da Garmin con nessuna delle due piattaforme.",
|
||||
"docsLink": "Leggi la guida"
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
|
||||
@@ -5478,6 +5478,38 @@
|
||||
"connectionRevoke": "Rozłącz",
|
||||
"connectionRevokeDescription": "To natychmiast kończy dostęp asystenta i uniemożliwia odnawianie. Ponowne połączenie będzie wymagać nowej autoryzacji.",
|
||||
"connectionRevokeFailed": "Nie udało się rozłączyć asystenta."
|
||||
},
|
||||
"strava": "Strava",
|
||||
"stravaDescription": "Połącz konto Strava, aby synchronizować aktywności z historią treningów.",
|
||||
"stravaDescriptionSecondary": "Aktywności trafiają na pulpit w tle. Nowy w konfiguracji? Skorzystaj z",
|
||||
"stravaViewData": "Zobacz treningi",
|
||||
"stravaCredentials": "Dane API",
|
||||
"stravaClientId": "Identyfikator klienta",
|
||||
"stravaClientSecret": "Sekret klienta",
|
||||
"stravaCredentialsSaved": "Dane zapisane",
|
||||
"stravaCredentialsSavedPlaceholder": "Zapisane — wpisz nowe, aby zastąpić",
|
||||
"stravaSaveCredentials": "Zapisz",
|
||||
"stravaUnavailable": "Strava nie jest skonfigurowana na tym serwerze. Dodaj powyżej własne dane API Strava lub poproś administratora o skonfigurowanie wspólnej aplikacji.",
|
||||
"stravaConnect": "Połącz Strava",
|
||||
"stravaDisconnect": "Rozłącz",
|
||||
"stravaDisconnectTitle": "Rozłączyć Strava?",
|
||||
"stravaDisconnectDescription": "Przyszłe synchronizacje zostaną zatrzymane. Już zaimportowane aktywności pozostaną w historii.",
|
||||
"stravaOauthConnected": "Strava połączona — pierwsza synchronizacja rusza w tle.",
|
||||
"stravaOauthFailed": "Połączenie ze Strava nie powiodło się",
|
||||
"stravaOauthError": {
|
||||
"csrf1": "Kontrola bezpieczeństwa nie powiodła się, ponieważ brakowało pliku cookie przeglądarki. Rozpocznij połączenie ponownie w tej samej przeglądarce.",
|
||||
"state": "Odpowiedź ze Strava nie pasowała do żadnego otwartego żądania połączenia. Rozpocznij połączenie ponownie.",
|
||||
"cross_user": "Odpowiedź należy do żądania z innego konta. Rozpocznij połączenie ponownie z własnego konta.",
|
||||
"nocode": "Strava nie zwróciła kodu autoryzacji. Spróbuj ponownie i zezwól na dostęp w oknie Strava.",
|
||||
"nocreds": "Strava nie jest skonfigurowana na tym serwerze. Poproś administratora o dodanie danych API Strava.",
|
||||
"token": "Wymiana tokenu ze Strava nie powiodła się. Rozpocznij połączenie ponownie.",
|
||||
"rate_limited": "Zbyt wiele prób połączenia. Poczekaj chwilę i spróbuj ponownie.",
|
||||
"generic": "Nieznany błąd podczas łączenia. Rozpocznij połączenie ponownie."
|
||||
},
|
||||
"garminInfo": {
|
||||
"title": "Garmin",
|
||||
"body": "Garmin nie ma tutaj bezpośredniego połączenia. Połącz przez Apple Health (iOS) lub Google Health Connect (Android). Zastrzeżone wskaźniki Garmina — Body Battery, Training Load, status HRV, VO₂ max — nie są udostępniane przez Garmina żadnej z tych platform.",
|
||||
"docsLink": "Przeczytaj przewodnik"
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.28.10",
|
||||
"version": "1.28.11",
|
||||
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"homepage": "https://healthlog.dev",
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
-- v1.28.x — Strava OAuth integration (schema + data layer).
|
||||
--
|
||||
-- Strava is a per-user BYO-OAuth workout source, mirroring WHOOP/Oura/Polar:
|
||||
-- Strava caps every newly-created API app at single-player mode (athlete
|
||||
-- capacity 1), so a single shared app cannot serve many self-hosters — each
|
||||
-- registers their own Strava app and pastes the client id/secret into Settings.
|
||||
-- Strava feeds `Workout` rows only (it exposes no sleep / recovery / body
|
||||
-- metrics) and rotates its refresh token on every refresh, so it follows Oura's
|
||||
-- reactive-refresh + compare-and-set token model (tokens live on `users`, no
|
||||
-- token-expiry column).
|
||||
--
|
||||
-- 1. `measurement_source` += `STRAVA` for the server-side native ingest.
|
||||
-- 2. `users` += the encrypted BYO-app credentials + granted tokens, the
|
||||
-- plaintext athlete id, the incremental-sync cursor, and the backfill
|
||||
-- marker. Purely-additive: nullable columns, no backfill, no row touched.
|
||||
--
|
||||
-- Idempotent guards (`IF NOT EXISTS`) make reruns safe. Forward-only.
|
||||
--
|
||||
-- Reversibility: Postgres cannot remove an enum value, so `STRAVA` stays; with
|
||||
-- no rows carrying it it is inert. The columns drop with `DROP COLUMN IF EXISTS`.
|
||||
|
||||
-- ── 1. measurement_source — append the Strava server-owned source ──────
|
||||
ALTER TYPE "measurement_source" ADD VALUE IF NOT EXISTS 'STRAVA';
|
||||
|
||||
-- ── 2. users — per-user Strava credentials, tokens, cursor, backfill marker ──
|
||||
ALTER TABLE "users"
|
||||
ADD COLUMN IF NOT EXISTS "strava_client_id_encrypted" TEXT,
|
||||
ADD COLUMN IF NOT EXISTS "strava_client_secret_encrypted" TEXT,
|
||||
ADD COLUMN IF NOT EXISTS "strava_access_token_encrypted" TEXT,
|
||||
ADD COLUMN IF NOT EXISTS "strava_refresh_token_encrypted" TEXT,
|
||||
ADD COLUMN IF NOT EXISTS "strava_athlete_id" TEXT,
|
||||
ADD COLUMN IF NOT EXISTS "strava_last_activity_at" TIMESTAMP(3),
|
||||
ADD COLUMN IF NOT EXISTS "strava_backfill_completed_at" TIMESTAMP(3);
|
||||
@@ -187,6 +187,29 @@ model User {
|
||||
ouraClientIdEncrypted String? @map("oura_client_id_encrypted")
|
||||
ouraClientSecretEncrypted String? @map("oura_client_secret_encrypted")
|
||||
|
||||
// v1.28.x — Strava OAuth credentials (per-user BYO-app, encrypted at app
|
||||
// level). Strava caps every newly-created API app at single-player mode
|
||||
// (athlete capacity 1), so a single shared app cannot serve many
|
||||
// self-hosters — each registers their own Strava app and pastes the client
|
||||
// id/secret into Settings, mirroring the WHOOP/Oura/Polar posture. Strava
|
||||
// rotates its refresh token on EVERY refresh, so both tokens are persisted
|
||||
// (Oura's reactive-refresh + compare-and-set model, no token-expiry column —
|
||||
// the sync refreshes reactively on a 401).
|
||||
stravaClientIdEncrypted String? @map("strava_client_id_encrypted")
|
||||
stravaClientSecretEncrypted String? @map("strava_client_secret_encrypted")
|
||||
stravaAccessTokenEncrypted String? @map("strava_access_token_encrypted")
|
||||
stravaRefreshTokenEncrypted String? @map("strava_refresh_token_encrypted")
|
||||
// Strava's numeric athlete id (plaintext opaque id): pins the granted
|
||||
// account so a cross-account re-auth can be rejected, and is the fan-out key
|
||||
// if webhooks are added later. Mirrors polarUserId / fitbitUserId intent.
|
||||
stravaAthleteId String? @map("strava_athlete_id")
|
||||
// Incremental-sync cursor: the start instant of the newest activity already
|
||||
// imported. The poll walks `/athlete/activities?after=<epoch>` from here.
|
||||
stravaLastActivityAt DateTime? @map("strava_last_activity_at")
|
||||
// First-connect full-history backfill marker (self-converging discovery
|
||||
// predicate `strava_backfill_completed_at IS NULL`), mirroring WHOOP/Fitbit.
|
||||
stravaBackfillCompletedAt DateTime? @map("strava_backfill_completed_at")
|
||||
|
||||
// moodLog integration (per-user, encrypted)
|
||||
moodLogUrlEncrypted String? @map("mood_log_url_encrypted")
|
||||
moodLogApiKeyEncrypted String? @map("mood_log_api_key_encrypted")
|
||||
@@ -1504,6 +1527,12 @@ enum MeasurementSource {
|
||||
// path); rejected on batch + single POST like WITHINGS/WHOOP/FITBIT. iOS DTO
|
||||
// mirrors this exact spelling so /api/sync/changes decodes the rows.
|
||||
GOOGLE_HEALTH
|
||||
// v1.28.x — Strava OAuth integration. Native Strava activities ingest
|
||||
// server-side as `source = STRAVA` Workout rows (no client write path);
|
||||
// rejected on the batch + single POST like WITHINGS/WHOOP/FITBIT. iOS DTO
|
||||
// mirrors this exact spelling so the `/api/sync/changes` delta feed decodes
|
||||
// the rows. Feeds Workout rows only — Strava exposes no daily metrics.
|
||||
STRAVA
|
||||
|
||||
@@map("measurement_source")
|
||||
}
|
||||
|
||||
+1
-1
@@ -36,7 +36,7 @@ try {
|
||||
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
|
||||
const CACHE_VERSION =
|
||||
(typeof self !== "undefined" && self.__APP_VERSION__) ||
|
||||
/* @sw-version-fallback */ "v1.28.10";
|
||||
/* @sw-version-fallback */ "v1.28.11";
|
||||
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
|
||||
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
|
||||
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
|
||||
|
||||
@@ -168,7 +168,9 @@ async function main() {
|
||||
// "polarUserIdEncrypted" "polarClientIdEncrypted"
|
||||
// "polarClientSecretEncrypted" "ouraAccessTokenEncrypted"
|
||||
// "ouraRefreshTokenEncrypted" "ouraClientIdEncrypted"
|
||||
// "ouraClientSecretEncrypted" "aiAnthropicKeyEncrypted"
|
||||
// "ouraClientSecretEncrypted" "stravaClientIdEncrypted"
|
||||
// "stravaClientSecretEncrypted" "stravaAccessTokenEncrypted"
|
||||
// "stravaRefreshTokenEncrypted" "aiAnthropicKeyEncrypted"
|
||||
// "aiLocalKeyEncrypted" "aiOpenaiKeyEncrypted" "insuranceNumberEncrypted"
|
||||
const userFields = [
|
||||
"codexAccessTokenEncrypted",
|
||||
@@ -195,6 +197,10 @@ async function main() {
|
||||
"ouraRefreshTokenEncrypted",
|
||||
"ouraClientIdEncrypted",
|
||||
"ouraClientSecretEncrypted",
|
||||
"stravaClientIdEncrypted",
|
||||
"stravaClientSecretEncrypted",
|
||||
"stravaAccessTokenEncrypted",
|
||||
"stravaRefreshTokenEncrypted",
|
||||
"aiAnthropicKeyEncrypted",
|
||||
"aiLocalKeyEncrypted",
|
||||
"aiOpenaiKeyEncrypted",
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
} from "@/lib/integrations/metric-freshness";
|
||||
import { getOuraClientCredentials } from "@/lib/oura/credentials";
|
||||
import { getPolarClientCredentials } from "@/lib/polar/credentials";
|
||||
import { getStravaClientCredentials } from "@/lib/strava/credentials";
|
||||
import { hasActivityScope } from "@/lib/withings/client";
|
||||
import { readMoodLogSecret } from "@/lib/moodlog-secret";
|
||||
|
||||
@@ -47,6 +48,7 @@ export const GET = apiHandler(async () => {
|
||||
googleHealthStatus,
|
||||
polarStatus,
|
||||
ouraStatus,
|
||||
stravaStatus,
|
||||
dbUser,
|
||||
withingsConn,
|
||||
whoopConn,
|
||||
@@ -58,6 +60,7 @@ export const GET = apiHandler(async () => {
|
||||
// /api/<provider>/status the consolidated envelope now replaces.
|
||||
polarAvailable,
|
||||
ouraAvailable,
|
||||
stravaAvailable,
|
||||
] = await Promise.all([
|
||||
getIntegrationStatus(user.id, "withings"),
|
||||
getIntegrationStatus(user.id, "moodlog"),
|
||||
@@ -66,6 +69,7 @@ export const GET = apiHandler(async () => {
|
||||
getIntegrationStatus(user.id, "google-health"),
|
||||
getIntegrationStatus(user.id, "polar"),
|
||||
getIntegrationStatus(user.id, "oura"),
|
||||
getIntegrationStatus(user.id, "strava"),
|
||||
prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: {
|
||||
@@ -83,6 +87,9 @@ export const GET = apiHandler(async () => {
|
||||
ouraAccessTokenEncrypted: true,
|
||||
ouraClientIdEncrypted: true,
|
||||
ouraClientSecretEncrypted: true,
|
||||
stravaAccessTokenEncrypted: true,
|
||||
stravaClientIdEncrypted: true,
|
||||
stravaClientSecretEncrypted: true,
|
||||
moodLogUrlEncrypted: true,
|
||||
moodLogApiKeyEncrypted: true,
|
||||
moodLogEnabled: true,
|
||||
@@ -133,6 +140,7 @@ export const GET = apiHandler(async () => {
|
||||
}),
|
||||
getPolarClientCredentials(user.id).then((c) => !!c),
|
||||
getOuraClientCredentials(user.id).then((c) => !!c),
|
||||
getStravaClientCredentials(user.id).then((c) => !!c),
|
||||
]);
|
||||
|
||||
// F-SYNC-1 — per-metric-type last-value timestamps so the card can show that a
|
||||
@@ -259,6 +267,18 @@ export const GET = apiHandler(async () => {
|
||||
!!dbUser?.ouraClientSecretEncrypted,
|
||||
metricFreshness: metricFreshness.oura ?? [],
|
||||
} satisfies IntegrationViewModel & OAuthProviderExtras,
|
||||
{
|
||||
...stravaStatus,
|
||||
connected: !!dbUser?.stravaAccessTokenEncrypted,
|
||||
configured: !!dbUser?.stravaAccessTokenEncrypted,
|
||||
available: stravaAvailable,
|
||||
hasOwnCredentials:
|
||||
!!dbUser?.stravaClientIdEncrypted &&
|
||||
!!dbUser?.stravaClientSecretEncrypted,
|
||||
// Strava writes Workout rows, not Measurements, so it has no
|
||||
// per-metric measurement freshness (like moodLog).
|
||||
metricFreshness: metricFreshness.strava ?? [],
|
||||
} satisfies IntegrationViewModel & OAuthProviderExtras,
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { encrypt } from "@/lib/crypto";
|
||||
import { exchangeCode } from "@/lib/strava/client";
|
||||
import { getStravaClientCredentials } from "@/lib/strava/credentials";
|
||||
import {
|
||||
oauthStateCookieName,
|
||||
stateMatchesCookie,
|
||||
verifySignedState,
|
||||
} from "@/lib/oauth/signed-state";
|
||||
import { markReconnected } from "@/lib/integrations/status";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
/**
|
||||
* v1.28.x — OAuth callback from Strava.
|
||||
*
|
||||
* Identity + CSRF mirror the Polar / Oura callbacks: the `state` param is a
|
||||
* stateless HMAC-signed token, enforced as a byte-exact double-submit against
|
||||
* the httpOnly cookie AND verified for signature / expiry / `strava` provider
|
||||
* binding. The `userId` is read from inside the verified token, cross-checked
|
||||
* against the web session when present.
|
||||
*
|
||||
* On success: exchange the code, persist the encrypted access + rotated refresh
|
||||
* token + the numeric athlete id on `User`, clear any prior reauth state, and
|
||||
* redirect back to Settings. A re-auth that returns a DIFFERENT athlete id than
|
||||
* the one already stored is rejected (`cross_user`) so a second Strava account
|
||||
* cannot silently overwrite the first's grant.
|
||||
*/
|
||||
const ERR = (reason: string) =>
|
||||
NextResponse.redirect(
|
||||
new URL(
|
||||
`/settings/integrations?strava=error&reason=${reason}`,
|
||||
process.env.NEXT_PUBLIC_APP_URL!,
|
||||
),
|
||||
);
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
annotate({ action: { name: "strava.callback" } });
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const code = searchParams.get("code");
|
||||
const state = searchParams.get("state");
|
||||
const cookieState = request.cookies.get(
|
||||
oauthStateCookieName("strava"),
|
||||
)?.value;
|
||||
|
||||
if (!stateMatchesCookie(state, cookieState)) {
|
||||
annotate({ meta: { reason: "csrf1" } });
|
||||
return ERR("csrf1");
|
||||
}
|
||||
|
||||
const verified = verifySignedState("strava", state);
|
||||
if (!verified) {
|
||||
annotate({ meta: { reason: "state" } });
|
||||
return ERR("state");
|
||||
}
|
||||
const userId = verified.userId;
|
||||
|
||||
const evt = getEvent();
|
||||
if (evt) evt.setAuth({ user_id: userId, auth_method: "session" });
|
||||
|
||||
const sessionData = await getSession();
|
||||
if (sessionData && sessionData.user.id !== userId) {
|
||||
annotate({ meta: { reason: "cross_user" } });
|
||||
return ERR("cross_user");
|
||||
}
|
||||
|
||||
if (!code) return ERR("nocode");
|
||||
|
||||
try {
|
||||
const creds = await getStravaClientCredentials(userId);
|
||||
if (!creds) return ERR("nocreds");
|
||||
|
||||
const tokens = await exchangeCode(code, creds);
|
||||
|
||||
const athleteId =
|
||||
typeof tokens.athlete?.id === "number" ? String(tokens.athlete.id) : null;
|
||||
|
||||
// Reject a re-auth that swaps the connected Strava account. A different
|
||||
// athlete id than the one already pinned means a second account is trying
|
||||
// to overwrite the first's grant.
|
||||
const existing = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { stravaAthleteId: true },
|
||||
});
|
||||
if (
|
||||
existing?.stravaAthleteId &&
|
||||
athleteId &&
|
||||
existing.stravaAthleteId !== athleteId
|
||||
) {
|
||||
annotate({ meta: { reason: "cross_user" } });
|
||||
return ERR("cross_user");
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
stravaAccessTokenEncrypted: encrypt(tokens.access_token),
|
||||
stravaRefreshTokenEncrypted: encrypt(tokens.refresh_token),
|
||||
stravaAthleteId: athleteId,
|
||||
},
|
||||
});
|
||||
|
||||
await auditLog("strava.connect", { userId });
|
||||
await markReconnected(userId, "strava");
|
||||
|
||||
const response = NextResponse.redirect(
|
||||
new URL(
|
||||
"/settings/integrations?strava=connected",
|
||||
process.env.NEXT_PUBLIC_APP_URL!,
|
||||
),
|
||||
);
|
||||
response.cookies.delete(oauthStateCookieName("strava"));
|
||||
return response;
|
||||
} catch (err) {
|
||||
getEvent()?.setError(err);
|
||||
return ERR("token");
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiError } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { getAuthorizationUrl } from "@/lib/strava/client";
|
||||
import { getStravaClientCredentials } from "@/lib/strava/credentials";
|
||||
import {
|
||||
OAUTH_STATE_TTL_MS,
|
||||
mintSignedState,
|
||||
oauthStateCookieName,
|
||||
} from "@/lib/oauth/signed-state";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
/**
|
||||
* v1.28.x — redirect the user to the Strava consent screen.
|
||||
*
|
||||
* Credentials resolve DB-first then env (`getStravaClientCredentials`): a
|
||||
* user's BYO Strava client id/secret wins, falling back to the shared env app
|
||||
* (`STRAVA_CLIENT_ID` / `STRAVA_CLIENT_SECRET`) when none is stored. CSRF via
|
||||
* the stateless signed state (`src/lib/oauth/signed-state.ts`): the same token
|
||||
* is the `state` URL param AND an httpOnly cookie. Rate-limited per user.
|
||||
*/
|
||||
const CONNECT_RATE_LIMIT = 10;
|
||||
const CONNECT_WINDOW_MS = 60_000;
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "strava.connect" } });
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`strava:connect:${user.id}`,
|
||||
CONNECT_RATE_LIMIT,
|
||||
CONNECT_WINDOW_MS,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
annotate({ action: { name: "strava.connect.rate_limited" } });
|
||||
return NextResponse.redirect(
|
||||
new URL(
|
||||
"/settings/integrations?strava=error&reason=rate_limited",
|
||||
process.env.NEXT_PUBLIC_APP_URL!,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const creds = await getStravaClientCredentials(user.id);
|
||||
if (!creds) {
|
||||
return apiError(
|
||||
"Strava integration is not configured on this server.",
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
const state = mintSignedState("strava", user.id);
|
||||
const url = getAuthorizationUrl(state, creds);
|
||||
|
||||
const response = NextResponse.redirect(url);
|
||||
response.cookies.set(oauthStateCookieName("strava"), state, {
|
||||
httpOnly: true,
|
||||
secure: shouldEmitSecureCookie(),
|
||||
sameSite: "lax",
|
||||
maxAge: Math.floor(OAUTH_STATE_TTL_MS / 1000),
|
||||
path: "/",
|
||||
});
|
||||
return response;
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { markDisconnected } from "@/lib/integrations/status";
|
||||
import {
|
||||
storeStravaClientCredentials,
|
||||
clearStravaClientCredentials,
|
||||
} from "@/lib/strava/credentials";
|
||||
import { stravaCredentialsSchema } from "@/lib/validations/strava";
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
/**
|
||||
* v1.28.x — per-user Strava BYO-key credentials.
|
||||
*
|
||||
* Whether the user has their own Strava client id/secret stored. The
|
||||
* connect/callback/status routes resolve credentials DB-first then env, so an
|
||||
* unset pair simply falls back to the shared env app.
|
||||
*/
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "strava.credentials.get" } });
|
||||
|
||||
const dbUser = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: {
|
||||
stravaClientIdEncrypted: true,
|
||||
stravaClientSecretEncrypted: true,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
hasCredentials:
|
||||
!!dbUser?.stravaClientIdEncrypted &&
|
||||
!!dbUser?.stravaClientSecretEncrypted,
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Save Strava OAuth client credentials (encrypted at rest).
|
||||
*/
|
||||
export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "strava.credentials.update" } });
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 64 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const result = z.safeParse(stravaCredentialsSchema, body);
|
||||
if (!result.success) {
|
||||
return apiError("Client ID and Client Secret are required", 422);
|
||||
}
|
||||
|
||||
await storeStravaClientCredentials(
|
||||
user.id,
|
||||
result.data.clientId,
|
||||
result.data.clientSecret,
|
||||
);
|
||||
|
||||
return apiSuccess({ updated: true });
|
||||
});
|
||||
|
||||
/**
|
||||
* Delete Strava credentials and the active connection.
|
||||
*
|
||||
* Deleting the BYO credentials drops a live connection (a token minted against
|
||||
* the deleted app is now orphaned), so when an access token was present this
|
||||
* mirrors `/api/strava/disconnect`: audit the event and park the integration
|
||||
* ledger at `disconnected` rather than leaving it stale at its last state.
|
||||
*/
|
||||
export const DELETE = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "strava.credentials.delete" } });
|
||||
|
||||
const dbUser = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { stravaAccessTokenEncrypted: true },
|
||||
});
|
||||
const wasConnected = !!dbUser?.stravaAccessTokenEncrypted;
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
stravaAccessTokenEncrypted: null,
|
||||
stravaRefreshTokenEncrypted: null,
|
||||
stravaAthleteId: null,
|
||||
},
|
||||
});
|
||||
await clearStravaClientCredentials(user.id);
|
||||
|
||||
if (wasConnected) {
|
||||
await auditLog("strava.credentials.delete", { userId: user.id });
|
||||
await markDisconnected(user.id, "strava");
|
||||
}
|
||||
|
||||
return apiSuccess({ deleted: true });
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiSuccess, apiError } from "@/lib/api-response";
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { markDisconnected } from "@/lib/integrations/status";
|
||||
import { deauthorize } from "@/lib/strava/client";
|
||||
import { getStravaConnection } from "@/lib/strava/credentials";
|
||||
|
||||
/**
|
||||
* v1.28.x — disconnect Strava for the current user.
|
||||
*
|
||||
* Best-effort deauthorize at Strava, then clear the stored access + refresh
|
||||
* token + athlete id on `User` and park the integration status at
|
||||
* `disconnected`. Imported `source = STRAVA` workouts are left intact; a
|
||||
* reconnect resumes sync.
|
||||
*/
|
||||
export const POST = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "strava.disconnect" } });
|
||||
|
||||
const rl = await checkRateLimit(`strava-disconnect:${user.id}`, 20, 60_000);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many requests", 429, {
|
||||
headers: rateLimitHeaders(rl),
|
||||
});
|
||||
}
|
||||
|
||||
const connection = await getStravaConnection(user.id);
|
||||
if (!connection) {
|
||||
return apiError("No Strava connection", 404);
|
||||
}
|
||||
|
||||
// Best-effort revoke at Strava; a Strava-side outage must never block the
|
||||
// local disconnect.
|
||||
try {
|
||||
await deauthorize(connection.accessToken);
|
||||
} catch (err) {
|
||||
getEvent()?.addWarning(`strava: deauthorize failed on disconnect: ${err}`);
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
stravaAccessTokenEncrypted: null,
|
||||
stravaRefreshTokenEncrypted: null,
|
||||
stravaAthleteId: null,
|
||||
},
|
||||
});
|
||||
|
||||
await auditLog("strava.disconnect", { userId: user.id });
|
||||
await markDisconnected(user.id, "strava");
|
||||
|
||||
return apiSuccess({ disconnected: true });
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { getStravaClientCredentials } from "@/lib/strava/credentials";
|
||||
import { getIntegrationStatus } from "@/lib/integrations/status";
|
||||
|
||||
/**
|
||||
* v1.28.x — Strava connection status for the current user.
|
||||
*
|
||||
* `available` reports whether usable OAuth credentials resolve for this user —
|
||||
* per-user BYO keys first, then the shared env app. `hasOwnCredentials` reports
|
||||
* whether the user has stored their own BYO pair. `connected` is whether a
|
||||
* token is stored. The ledger snapshot comes off the shared `strava`
|
||||
* IntegrationKey. The access / refresh tokens + client secret are NEVER
|
||||
* returned.
|
||||
*/
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "strava.status" } });
|
||||
|
||||
const dbUser = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: {
|
||||
stravaAccessTokenEncrypted: true,
|
||||
stravaClientIdEncrypted: true,
|
||||
stravaClientSecretEncrypted: true,
|
||||
},
|
||||
});
|
||||
|
||||
const available = !!(await getStravaClientCredentials(user.id));
|
||||
const connected = !!dbUser?.stravaAccessTokenEncrypted;
|
||||
const hasOwnCredentials =
|
||||
!!dbUser?.stravaClientIdEncrypted && !!dbUser?.stravaClientSecretEncrypted;
|
||||
|
||||
if (!connected) {
|
||||
return apiSuccess({
|
||||
connected: false,
|
||||
configured: false,
|
||||
available,
|
||||
hasOwnCredentials,
|
||||
});
|
||||
}
|
||||
|
||||
const status = await getIntegrationStatus(user.id, "strava");
|
||||
|
||||
return apiSuccess({
|
||||
connected: true,
|
||||
configured: true,
|
||||
available,
|
||||
hasOwnCredentials,
|
||||
state: status.state,
|
||||
lastSuccessAt: status.lastSuccessAt,
|
||||
lastAttemptAt: status.lastAttemptAt,
|
||||
lastError: status.lastError,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess, apiError } from "@/lib/api-response";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { getStravaConnection } from "@/lib/strava/credentials";
|
||||
import { safeFetch, SafeFetchError } from "@/lib/safe-fetch";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// Lightweight token probe — fetch the authenticated athlete, the cheapest
|
||||
// authenticated Strava call that confirms the stored grant is still valid.
|
||||
// Reuses `getStravaConnection`; no new credential path.
|
||||
const STRAVA_ATHLETE_URL = "https://www.strava.com/api/v3/athlete";
|
||||
const TIMEOUT_MS = 5_000;
|
||||
|
||||
function categoriseHttpStatus(status: number): {
|
||||
code: string;
|
||||
message: string;
|
||||
} {
|
||||
if (status === 401 || status === 403) {
|
||||
return {
|
||||
code: "credentials_rejected",
|
||||
message: "Strava rejected the token",
|
||||
};
|
||||
}
|
||||
if (status === 429) {
|
||||
return { code: "rate_limited", message: "Strava rate-limited the request" };
|
||||
}
|
||||
if (status >= 500) {
|
||||
return {
|
||||
code: "upstream_error",
|
||||
message: "Strava returned a server error",
|
||||
};
|
||||
}
|
||||
return { code: "connection_failed", message: "Strava connection failed" };
|
||||
}
|
||||
|
||||
export const POST = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "strava.test" } });
|
||||
|
||||
const rl = await checkRateLimit(`strava-test:${user.id}`, 5, 60_000);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many test requests", 429, {
|
||||
errorCode: "rate_limited_self",
|
||||
});
|
||||
}
|
||||
|
||||
const connection = await getStravaConnection(user.id);
|
||||
if (!connection) {
|
||||
return apiError("Strava not connected", 422, {
|
||||
errorCode: "not_configured",
|
||||
});
|
||||
}
|
||||
|
||||
const start = performance.now();
|
||||
|
||||
try {
|
||||
const res = await safeFetch(
|
||||
STRAVA_ATHLETE_URL,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${connection.accessToken}`,
|
||||
Accept: "application/json",
|
||||
},
|
||||
},
|
||||
{ timeoutMs: TIMEOUT_MS },
|
||||
);
|
||||
const latencyMs = Math.round(performance.now() - start);
|
||||
|
||||
if (!res.ok) {
|
||||
const cat = categoriseHttpStatus(res.status);
|
||||
annotate({
|
||||
meta: { strava_test_status: res.status, strava_test_code: cat.code },
|
||||
});
|
||||
return apiError(cat.message, 502, { errorCode: cat.code });
|
||||
}
|
||||
|
||||
return apiSuccess({ ok: true, latencyMs });
|
||||
} catch (e) {
|
||||
const isTimeout = e instanceof SafeFetchError && e.kind === "timeout";
|
||||
const code = isTimeout ? "timeout" : "connection_failed";
|
||||
annotate({ meta: { strava_test_code: code } });
|
||||
return apiError(
|
||||
isTimeout ? "Strava request timed out" : "Strava connection failed",
|
||||
502,
|
||||
{ errorCode: code },
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -131,10 +131,11 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => {
|
||||
});
|
||||
|
||||
const html = render();
|
||||
// Exactly one pill per card → 7 pills total (Withings, WHOOP, Fitbit,
|
||||
// Google Health, Polar, Oura, Nightscout). The moodLog integration was
|
||||
// removed; Polar + Oura (F4) were added in v1.17.0; Google Health in v1.27.0.
|
||||
expect(count(html, 'data-testid="integration-status-pill"')).toBe(7);
|
||||
// Exactly one pill per card → 8 pills total (Withings, WHOOP, Fitbit,
|
||||
// Google Health, Polar, Oura, Strava, Nightscout). The moodLog integration
|
||||
// was removed; Polar + Oura (F4) were added in v1.17.0; Google Health in
|
||||
// v1.27.0; Strava in v1.28.x. (The Garmin info note carries no pill.)
|
||||
expect(count(html, 'data-testid="integration-status-pill"')).toBe(8);
|
||||
// The redundant banner from v1.4.15 is gone.
|
||||
expect(html).not.toContain('data-testid="integration-status-banner"');
|
||||
// Card-body "letzter Sync" repetition is gone — no
|
||||
@@ -326,8 +327,9 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => {
|
||||
});
|
||||
|
||||
const html = render();
|
||||
// One setup-guide link per card → seven providers.
|
||||
expect(count(html, 'data-slot="integration-setup-guide"')).toBe(7);
|
||||
// One setup-guide link per card → eight providers (the Garmin info note is
|
||||
// not an OAuth card and carries its own docs link, not a setup-guide).
|
||||
expect(count(html, 'data-slot="integration-setup-guide"')).toBe(8);
|
||||
for (const provider of [
|
||||
"withings",
|
||||
"whoop",
|
||||
@@ -335,6 +337,7 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => {
|
||||
"google-health",
|
||||
"polar",
|
||||
"oura",
|
||||
"strava",
|
||||
"nightscout",
|
||||
]) {
|
||||
expect(html).toContain(`data-testid="${provider}-setup-guide"`);
|
||||
|
||||
@@ -11,7 +11,7 @@ import { parseOAuthOutcome, oauthReasonKey } from "../integrations-section";
|
||||
|
||||
describe("parseOAuthOutcome", () => {
|
||||
it("reads a connected outcome for every OAuth provider", () => {
|
||||
for (const p of ["polar", "oura", "whoop", "fitbit"] as const) {
|
||||
for (const p of ["polar", "oura", "whoop", "fitbit", "strava"] as const) {
|
||||
expect(parseOAuthOutcome(`?${p}=connected`)).toEqual({
|
||||
provider: p,
|
||||
kind: "connected",
|
||||
@@ -25,6 +25,11 @@ describe("parseOAuthOutcome", () => {
|
||||
kind: "error",
|
||||
reason: "token",
|
||||
});
|
||||
expect(parseOAuthOutcome("?strava=error&reason=cross_user")).toEqual({
|
||||
provider: "strava",
|
||||
kind: "error",
|
||||
reason: "cross_user",
|
||||
});
|
||||
expect(parseOAuthOutcome("?fitbit=error&reason=expired")).toEqual({
|
||||
provider: "fitbit",
|
||||
kind: "error",
|
||||
|
||||
@@ -17,11 +17,13 @@ import { useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { FitbitCard } from "@/components/settings/integrations/fitbit-card";
|
||||
import { GarminInfoNote } from "@/components/settings/integrations/garmin-info-note";
|
||||
import { GoogleHealthCard } from "@/components/settings/integrations/google-health-card";
|
||||
import { NightscoutCard } from "@/components/settings/integrations/nightscout-card";
|
||||
import type { OAuthProviderStatus } from "@/components/settings/integrations/oauth-provider-card";
|
||||
import { OuraCard } from "@/components/settings/integrations/oura-card";
|
||||
import { PolarCard } from "@/components/settings/integrations/polar-card";
|
||||
import { StravaCard } from "@/components/settings/integrations/strava-card";
|
||||
import {
|
||||
pickStatus,
|
||||
useIntegrationStatuses,
|
||||
@@ -71,6 +73,8 @@ const OAUTH_OUTCOME_PROVIDERS = [
|
||||
"whoop",
|
||||
"fitbit",
|
||||
"googleHealth",
|
||||
// v1.28.x — Strava callback redirects with `?strava=connected|error&reason=…`.
|
||||
"strava",
|
||||
] as const;
|
||||
type OAuthOutcomeProvider = (typeof OAUTH_OUTCOME_PROVIDERS)[number];
|
||||
|
||||
@@ -83,6 +87,7 @@ const OAUTH_OUTCOME_KEYS: Record<
|
||||
whoop: queryKeys.whoop,
|
||||
fitbit: queryKeys.fitbit,
|
||||
googleHealth: queryKeys.googleHealth,
|
||||
strava: queryKeys.strava,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -259,6 +264,10 @@ export function ConnectionsPanel() {
|
||||
// no longer fire their own /api/<provider>/status round-trip.
|
||||
const polarViewModel = toOAuthStatus(pickStatus(integrationStatus, "polar"));
|
||||
const ouraViewModel = toOAuthStatus(pickStatus(integrationStatus, "oura"));
|
||||
// v1.28.x — Strava reads off the same consolidated envelope.
|
||||
const stravaViewModel = toOAuthStatus(
|
||||
pickStatus(integrationStatus, "strava"),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6" data-slot="connections-panel">
|
||||
@@ -282,9 +291,18 @@ export function ConnectionsPanel() {
|
||||
<div id="oura" className="scroll-mt-28">
|
||||
<OuraCard enabled={isAuthenticated} viewModel={ouraViewModel} />
|
||||
</div>
|
||||
<div id="strava" className="scroll-mt-28">
|
||||
<StravaCard enabled={isAuthenticated} viewModel={stravaViewModel} />
|
||||
</div>
|
||||
<div id="nightscout" className="scroll-mt-28">
|
||||
<NightscoutCard enabled={isAuthenticated} />
|
||||
</div>
|
||||
{/* Garmin: no direct connector (business-partner-only). A quiet
|
||||
non-connector note pointing to the Apple Health / Health Connect
|
||||
path — not an OAuthProviderCard (nothing to connect). */}
|
||||
<div id="garmin" className="scroll-mt-28">
|
||||
<GarminInfoNote />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
// v1.28.x — Garmin is NOT a direct connector: Garmin's developer program is
|
||||
// business-partner-only (approval-gated, needs public callbacks), so the
|
||||
// self-hosted BYO model cannot register a paste-your-key Garmin app. The honest
|
||||
// path is Apple Health (iOS) or Google Health Connect (Android), which already
|
||||
// works with zero extra code. This quiet, non-connector info note pre-empts the
|
||||
// recurring "where's Garmin?" question and points to the runbook.
|
||||
|
||||
import { ExternalLink, Watch } from "lucide-react";
|
||||
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { INTEGRATION_DOCS_BASE } from "@/components/settings/integrations/setup-guide-link";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
export function GarminInfoNote() {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<SettingsCard data-testid="garmin-info">
|
||||
<SettingsCardHeader
|
||||
icon={Watch}
|
||||
title={t("settings.garminInfo.title")}
|
||||
description={
|
||||
<p className="text-muted-foreground">
|
||||
{t("settings.garminInfo.body")}{" "}
|
||||
<a
|
||||
href={`${INTEGRATION_DOCS_BASE}/garmin`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
data-testid="garmin-docs-link"
|
||||
data-slot="garmin-docs-link"
|
||||
className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1 underline underline-offset-2"
|
||||
>
|
||||
{t("settings.garminInfo.docsLink")}
|
||||
<ExternalLink className="h-3 w-3" aria-hidden="true" />
|
||||
</a>
|
||||
</p>
|
||||
}
|
||||
/>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
@@ -89,7 +89,7 @@ function pillStateFor(
|
||||
|
||||
export interface OAuthProviderCardProps {
|
||||
/** Lower-case provider key, used for routes + query keys + testids. */
|
||||
provider: "polar" | "oura";
|
||||
provider: "polar" | "oura" | "strava";
|
||||
/** The query-key array shared by the status read + the invalidations. */
|
||||
statusQueryKey: readonly unknown[];
|
||||
/** i18n key prefix (e.g. `settings.polar`). */
|
||||
|
||||
@@ -30,7 +30,9 @@ export type IntegrationDocsProvider =
|
||||
| "oura"
|
||||
| "nightscout"
|
||||
// v1.27.0 — Google Health runbook (docs.healthlog.dev/integrations/google-health).
|
||||
| "google-health";
|
||||
| "google-health"
|
||||
// v1.28.x — Strava runbook (docs.healthlog.dev/integrations/strava).
|
||||
| "strava";
|
||||
|
||||
export function integrationDocsHref(provider: IntegrationDocsProvider): string {
|
||||
return `${INTEGRATION_DOCS_BASE}/${provider}`;
|
||||
|
||||
@@ -26,7 +26,9 @@ export type IntegrationKey =
|
||||
| "polar"
|
||||
| "oura"
|
||||
// v1.27.0 — Google Health (Fitbit + Pixel Watch + Fitbit Air).
|
||||
| "google-health";
|
||||
| "google-health"
|
||||
// v1.28.x — Strava OAuth workout source.
|
||||
| "strava";
|
||||
export type IntegrationState =
|
||||
"connected" | "error_transient" | "error_reauth" | "disconnected" | "parked";
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
// v1.28.x — Strava integration card. Thin wrapper over the shared OAuth card
|
||||
// (per-user BYO OAuth credentials, DB-first then env). Strava is a workout
|
||||
// source: connecting it syncs your activities into the workout surface, where
|
||||
// the cross-source picker collapses any twin already captured by a wearable.
|
||||
|
||||
import { Activity } from "lucide-react";
|
||||
|
||||
import {
|
||||
OAuthProviderCard,
|
||||
type OAuthProviderStatus,
|
||||
} from "@/components/settings/integrations/oauth-provider-card";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
export function StravaCard({
|
||||
enabled = true,
|
||||
viewModel,
|
||||
}: {
|
||||
enabled?: boolean;
|
||||
viewModel?: OAuthProviderStatus;
|
||||
}) {
|
||||
return (
|
||||
<OAuthProviderCard
|
||||
provider="strava"
|
||||
statusQueryKey={queryKeys.strava()}
|
||||
i18nPrefix="settings.strava"
|
||||
icon={Activity}
|
||||
dataHref="/insights/workouts"
|
||||
credentials
|
||||
enabled={enabled}
|
||||
viewModel={viewModel}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -77,6 +77,10 @@ export const ENCRYPTED_COLUMNS: readonly EncryptedColumn[] = [
|
||||
{ model: "User", field: "ouraRefreshTokenEncrypted", kind: "string" },
|
||||
{ model: "User", field: "ouraClientIdEncrypted", kind: "string" },
|
||||
{ model: "User", field: "ouraClientSecretEncrypted", kind: "string" },
|
||||
{ model: "User", field: "stravaClientIdEncrypted", kind: "string" },
|
||||
{ model: "User", field: "stravaClientSecretEncrypted", kind: "string" },
|
||||
{ model: "User", field: "stravaAccessTokenEncrypted", kind: "string" },
|
||||
{ model: "User", field: "stravaRefreshTokenEncrypted", kind: "string" },
|
||||
{ model: "User", field: "aiAnthropicKeyEncrypted", kind: "string" },
|
||||
{ model: "User", field: "aiLocalKeyEncrypted", kind: "string" },
|
||||
{ model: "User", field: "aiOpenaiKeyEncrypted", kind: "string" },
|
||||
|
||||
@@ -52,6 +52,8 @@ export type IntegrationKey =
|
||||
// successor Google Health API. Separate connection from the classic
|
||||
// `fitbit` transport, which sunsets Sept 2026.
|
||||
| "google-health"
|
||||
// v1.28.x — Strava OAuth workout source.
|
||||
| "strava"
|
||||
| "moodlog";
|
||||
|
||||
/**
|
||||
@@ -814,6 +816,7 @@ const INTEGRATION_LABELS: Record<IntegrationKey, string> = {
|
||||
polar: "Polar",
|
||||
oura: "Oura",
|
||||
"google-health": "Google Health",
|
||||
strava: "Strava",
|
||||
moodlog: "moodLog",
|
||||
};
|
||||
|
||||
|
||||
@@ -35,6 +35,13 @@ import {
|
||||
enqueueBootTimeGoogleHealthBackfill,
|
||||
type GoogleHealthBackfillPayload,
|
||||
} from "@/lib/jobs/google-health-backfill";
|
||||
import {
|
||||
STRAVA_BACKFILL_QUEUE,
|
||||
STRAVA_BACKFILL_CONCURRENCY,
|
||||
runStravaBackfillForUser,
|
||||
enqueueBootTimeStravaBackfill,
|
||||
type StravaBackfillPayload,
|
||||
} from "@/lib/jobs/strava-backfill";
|
||||
import {
|
||||
SLEEP_TIMELINE_BACKFILL_QUEUE,
|
||||
SLEEP_TIMELINE_BACKFILL_CONCURRENCY,
|
||||
@@ -76,6 +83,7 @@ import {
|
||||
import { NightscoutSyncPayload, handleNightscoutSync } from "./nightscout-sync";
|
||||
import { PolarSyncPayload, handlePolarSync } from "./polar-sync";
|
||||
import { OuraSyncPayload, handleOuraSync } from "./oura-sync";
|
||||
import { StravaSyncPayload, handleStravaSync } from "./strava-sync";
|
||||
import {
|
||||
FitbitSyncPayload,
|
||||
handleFitbitSync,
|
||||
@@ -197,6 +205,13 @@ const POLAR_SYNC_QUEUE = "polar-sync";
|
||||
const POLAR_SYNC_CRON = "13 * * * *"; // every hour at :13
|
||||
const OURA_SYNC_QUEUE = "oura-sync";
|
||||
const OURA_SYNC_CRON = "15 * * * *"; // every hour at :15
|
||||
// v1.28.x — Strava OAuth poll sync. Poll-only (webhook deferred): one hourly
|
||||
// tick re-walks every connected user. :17 staggers off the Polar (:13) / Oura
|
||||
// (:15) ticks so the polls don't pile up on one boss poll. The queue MUST be
|
||||
// registered in `allQueues` below or pg-boss never provisions it and the
|
||||
// schedule silently no-ops (the v1.4.37 dead-queue class).
|
||||
const STRAVA_SYNC_QUEUE = "strava-sync";
|
||||
const STRAVA_SYNC_CRON = "17 * * * *"; // every hour at :17
|
||||
|
||||
// pg-boss v12 requires explicit queue creation before scheduling. Every queue
|
||||
// MUST be registered here or pg-boss never provisions it and both the webhook
|
||||
@@ -244,6 +259,9 @@ const allQueues = [
|
||||
// v1.17.0 (F4) — Polar + Oura OAuth poll sync.
|
||||
POLAR_SYNC_QUEUE,
|
||||
OURA_SYNC_QUEUE,
|
||||
// v1.28.x — Strava OAuth poll sync + self-converging boot backfill.
|
||||
STRAVA_SYNC_QUEUE,
|
||||
STRAVA_BACKFILL_QUEUE,
|
||||
];
|
||||
|
||||
const schedules: ScheduleEntry[] = [
|
||||
@@ -279,6 +297,8 @@ const schedules: ScheduleEntry[] = [
|
||||
// v1.17.0 (F4) — hourly Polar (:13) + Oura (:15) OAuth polls.
|
||||
[POLAR_SYNC_QUEUE, POLAR_SYNC_CRON],
|
||||
[OURA_SYNC_QUEUE, OURA_SYNC_CRON],
|
||||
// v1.28.x — hourly Strava (:17) OAuth poll.
|
||||
[STRAVA_SYNC_QUEUE, STRAVA_SYNC_CRON],
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -475,6 +495,31 @@ export async function registerIntegrationSyncQueues(
|
||||
{ localConcurrency: 1 },
|
||||
handleOuraSync,
|
||||
);
|
||||
// v1.28.x — Strava OAuth poll-cohort sync (poll-only; webhook deferred). The
|
||||
// hourly cron tick (no `userId`) re-walks every connected user; one user's
|
||||
// revoked grant is warned, not fatal.
|
||||
await boss.work<StravaSyncPayload>(
|
||||
STRAVA_SYNC_QUEUE,
|
||||
{ localConcurrency: 1 },
|
||||
handleStravaSync,
|
||||
);
|
||||
// v1.28.x — self-converging Strava backfill. The boot enqueue below sends one
|
||||
// full-history sync per un-backfilled connection; this handler runs it and
|
||||
// stamps `stravaBackfillCompletedAt` so the discovery query drops the account.
|
||||
await boss.work<StravaBackfillPayload>(
|
||||
STRAVA_BACKFILL_QUEUE,
|
||||
{ localConcurrency: STRAVA_BACKFILL_CONCURRENCY },
|
||||
async (jobs) => {
|
||||
for (const job of jobs) {
|
||||
const { userId } = job.data;
|
||||
const { imported } = await runStravaBackfillForUser(userId);
|
||||
workerLog(
|
||||
"info",
|
||||
`[strava-backfill] user=${userId} imported=${imported}`,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
await boss.work<MoodLogSyncPayload>(
|
||||
MOODLOG_SYNC_QUEUE,
|
||||
{ localConcurrency: 1 },
|
||||
@@ -580,6 +625,26 @@ export async function enqueueIntegrationSyncBootDiscovery(): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
// v1.28.x — Strava backfill. Finds every Strava connection not yet
|
||||
// backfilled and enqueues one full-history sync per account.
|
||||
try {
|
||||
const { enqueued, skipped, error } = await enqueueBootTimeStravaBackfill();
|
||||
if (error) {
|
||||
workerLog("error", `[strava-backfill] boot discovery failed: ${error}`);
|
||||
} else {
|
||||
workerLog(
|
||||
"info",
|
||||
`[strava-backfill] boot discovery: enqueued=${enqueued} skipped=${skipped}`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
workerLog(
|
||||
"error",
|
||||
"[strava-backfill] boot discovery threw an unexpected error",
|
||||
err,
|
||||
);
|
||||
}
|
||||
|
||||
// v1.18.1 — one-shot lab-biomarker backfill. Finds every user holding an
|
||||
// un-linked live lab reading and enqueues one job each. A completed pass
|
||||
// links every reading, dropping the user from the discovery set.
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* v1.28.x — Strava poll-cohort sync handler.
|
||||
*
|
||||
* Poll-only: Strava has a Webhook Events API, but this ship runs the simpler
|
||||
* poll model (matching Polar/Oura/Fitbit). One hourly cron tick carries no
|
||||
* `userId` and the handler iterates every user with a stored Strava token,
|
||||
* re-syncing each via `syncUserStrava`. One user's revoked grant / outage is
|
||||
* warned and the pass continues. The shared control flow lives in
|
||||
* `./poll-cohort`.
|
||||
*
|
||||
* The queue name, cron schedule, and boss.work registration live in
|
||||
* `register-integration-sync.ts`.
|
||||
*/
|
||||
import { syncUserStrava } from "@/lib/strava/sync";
|
||||
import { makePollCohortHandler, type PollCohortPayload } from "./poll-cohort";
|
||||
|
||||
export type StravaSyncPayload = PollCohortPayload;
|
||||
|
||||
export const handleStravaSync = makePollCohortHandler({
|
||||
taskName: "job.strava_sync",
|
||||
findCohort: async (prisma) => {
|
||||
const users = await prisma.user.findMany({
|
||||
where: { stravaAccessTokenEncrypted: { not: null } },
|
||||
select: { id: true },
|
||||
});
|
||||
return users.map((u) => u.id);
|
||||
},
|
||||
syncUser: (userId) => syncUserStrava(userId),
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* v1.28.x — pg-boss queue + boot-time self-converging backfill for newly
|
||||
* connected Strava accounts. Modelled on the WHOOP / Fitbit backfill: a
|
||||
* discovery query enqueues one job per connection that has NOT yet been
|
||||
* backfilled, and the pass is idempotent across reboots (the predicate
|
||||
* `strava_backfill_completed_at IS NULL` drops a connection once its backfill
|
||||
* finishes).
|
||||
*
|
||||
* Unlike WHOOP/Fitbit (which carry the marker on a dedicated connection table),
|
||||
* Strava rides `User` columns like Oura/Polar, so the marker is
|
||||
* `User.stravaBackfillCompletedAt`.
|
||||
*
|
||||
* The queue name MUST be registered in `allQueues` in
|
||||
* `register-integration-sync.ts` or pg-boss never provisions it and the boot
|
||||
* enqueue silently never drains (the v1.4.37 dead-queue class).
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
|
||||
import { syncUserStrava } from "@/lib/strava/sync";
|
||||
|
||||
export const STRAVA_BACKFILL_QUEUE = "strava-backfill";
|
||||
|
||||
/**
|
||||
* Serial concurrency — a backfill walks the account's activity history and is
|
||||
* rate-bounded by Strava's 200-req / 15-min cap; concurrency-1 keeps it from
|
||||
* crowding the request pool, matching `WHOOP_BACKFILL_CONCURRENCY`.
|
||||
*/
|
||||
export const STRAVA_BACKFILL_CONCURRENCY = 1;
|
||||
|
||||
export interface StravaBackfillPayload {
|
||||
userId: string;
|
||||
enqueuedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-user backfill handler. Runs a full-history sync for one account and
|
||||
* stamps `stravaBackfillCompletedAt` so the discovery query drops it.
|
||||
* Idempotent: the `(userId, source, externalId)` workout upsert is key-stable,
|
||||
* so a re-run (e.g. a reboot mid-walk) overwrites rather than duplicating.
|
||||
*/
|
||||
export async function runStravaBackfillForUser(
|
||||
userId: string,
|
||||
): Promise<{ imported: number }> {
|
||||
const imported = await syncUserStrava(userId, { fullSync: true });
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { stravaBackfillCompletedAt: new Date() },
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: {
|
||||
name: "strava.backfill.complete",
|
||||
details: { imported },
|
||||
},
|
||||
});
|
||||
return { imported };
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot-time discovery. Finds every Strava connection not yet backfilled
|
||||
* (a stored access token + `strava_backfill_completed_at IS NULL`) and enqueues
|
||||
* one backfill job per account.
|
||||
*
|
||||
* Idempotent across reboots: once a connection's backfill completes,
|
||||
* `stravaBackfillCompletedAt` is set and the predicate drops it. pg-boss
|
||||
* `singletonKey` coalesces duplicate sends so a fast restart while a job is
|
||||
* queued doesn't double up.
|
||||
*
|
||||
* Best-effort: errors come back through the result value so the worker boot
|
||||
* never fails because of a backfill miss.
|
||||
*/
|
||||
export async function enqueueBootTimeStravaBackfill(): Promise<{
|
||||
enqueued: number;
|
||||
skipped: number;
|
||||
error: string | null;
|
||||
}> {
|
||||
const boss = getGlobalBoss();
|
||||
if (!boss) {
|
||||
return { enqueued: 0, skipped: 0, error: null };
|
||||
}
|
||||
|
||||
try {
|
||||
const connections = await prisma.user.findMany({
|
||||
where: {
|
||||
stravaAccessTokenEncrypted: { not: null },
|
||||
stravaBackfillCompletedAt: null,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (connections.length === 0) {
|
||||
return { enqueued: 0, skipped: 0, error: null };
|
||||
}
|
||||
|
||||
let enqueued = 0;
|
||||
let skipped = 0;
|
||||
for (const { id: userId } of connections) {
|
||||
const payload: StravaBackfillPayload = {
|
||||
userId,
|
||||
enqueuedAt: new Date().toISOString(),
|
||||
};
|
||||
const jobId = await boss.send(STRAVA_BACKFILL_QUEUE, payload, {
|
||||
retryLimit: 3,
|
||||
retryDelay: 60,
|
||||
retryBackoff: true,
|
||||
singletonKey: `strava-backfill|${userId}`,
|
||||
});
|
||||
if (jobId) {
|
||||
enqueued += 1;
|
||||
} else {
|
||||
skipped += 1;
|
||||
}
|
||||
}
|
||||
return { enqueued, skipped, error: null };
|
||||
} catch (err) {
|
||||
return {
|
||||
enqueued: 0,
|
||||
skipped: 0,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,7 @@
|
||||
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
||||
|
||||
/** Providers that share this signed-state machinery. */
|
||||
export type OAuthStateProvider = "polar" | "oura";
|
||||
export type OAuthStateProvider = "polar" | "oura" | "strava";
|
||||
|
||||
/** 10-minute TTL — long enough to approve a consent screen, short enough that
|
||||
* an abandoned handshake's token is useless within the hour. Mirrors the
|
||||
|
||||
@@ -57,6 +57,13 @@ export const integrationKeys = {
|
||||
oura: () => ["oura"] as const,
|
||||
ouraStatus: () => ["oura", "status"] as const,
|
||||
|
||||
// v1.28.x — Strava OAuth integration card. Reads status off the consolidated
|
||||
// /api/integrations/status envelope (like WHOOP/Fitbit/Polar/Oura); the
|
||||
// connect/disconnect/credentials mutations invalidate this key so the card
|
||||
// repaints.
|
||||
strava: () => ["strava"] as const,
|
||||
stravaStatus: () => ["strava", "status"] as const,
|
||||
|
||||
moodlogStatus: () => ["moodlog-status"] as const,
|
||||
integrationsStatus: () => ["integrations", "status"] as const,
|
||||
|
||||
|
||||
@@ -99,6 +99,12 @@ export const DEFAULT_WORKOUT_SOURCE_PRIORITY: readonly MeasurementSource[] = [
|
||||
// session.
|
||||
"FITBIT",
|
||||
"WITHINGS",
|
||||
// v1.28.x — Strava ranks below the device-native captures (Apple/WHOOP/
|
||||
// Fitbit/Withings on-wrist HR) but above MANUAL/IMPORT: a Strava activity is
|
||||
// often itself a re-upload of an Apple/Garmin recording, so its HR/calories
|
||||
// are lower-fidelity or absent on the summary. A user who records primarily
|
||||
// on Strava (phone GPS) can promote it in Settings → Sources.
|
||||
"STRAVA",
|
||||
"MANUAL",
|
||||
"IMPORT",
|
||||
] as const;
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
mapActivity,
|
||||
summaryHasHeartRate,
|
||||
type StravaSummaryActivity,
|
||||
type StravaDetailedActivity,
|
||||
} from "../client";
|
||||
import { classifyStravaResponse } from "../response-classifier";
|
||||
|
||||
const baseSummary: StravaSummaryActivity = {
|
||||
id: 123456,
|
||||
name: "Morning Run",
|
||||
distance: 5000,
|
||||
moving_time: 1500,
|
||||
elapsed_time: 1600,
|
||||
total_elevation_gain: 42,
|
||||
type: "Run",
|
||||
sport_type: "TrailRun",
|
||||
start_date: "2026-07-01T06:30:00Z",
|
||||
};
|
||||
|
||||
describe("mapActivity", () => {
|
||||
it("maps a summary-only activity (no detail → no calories)", () => {
|
||||
const row = mapActivity({
|
||||
...baseSummary,
|
||||
average_heartrate: 148,
|
||||
max_heartrate: 171,
|
||||
});
|
||||
expect(row).not.toBeNull();
|
||||
expect(row!.externalId).toBe("123456");
|
||||
expect(row!.sportType).toBe("TrailRun");
|
||||
expect(row!.startedAt.toISOString()).toBe("2026-07-01T06:30:00.000Z");
|
||||
// endedAt = start + elapsed_time (clock window), not moving_time.
|
||||
expect(row!.endedAt.toISOString()).toBe("2026-07-01T06:56:40.000Z");
|
||||
// durationSec prefers moving_time.
|
||||
expect(row!.durationSec).toBe(1500);
|
||||
expect(row!.totalDistanceM).toBe(5000);
|
||||
expect(row!.elevationM).toBe(42);
|
||||
expect(row!.avgHeartRate).toBe(148);
|
||||
expect(row!.maxHeartRate).toBe(171);
|
||||
// No detail → calories null.
|
||||
expect(row!.totalEnergyKcal).toBeNull();
|
||||
});
|
||||
|
||||
it("fills calories + HR from the DetailedActivity when the summary lacks them", () => {
|
||||
const summary: StravaSummaryActivity = { ...baseSummary };
|
||||
delete (summary as unknown as Record<string, unknown>).average_heartrate;
|
||||
const detail: StravaDetailedActivity = {
|
||||
...baseSummary,
|
||||
average_heartrate: 150,
|
||||
max_heartrate: 175,
|
||||
calories: 480,
|
||||
};
|
||||
const row = mapActivity(summary, detail);
|
||||
expect(row!.totalEnergyKcal).toBe(480);
|
||||
expect(row!.avgHeartRate).toBe(150);
|
||||
expect(row!.maxHeartRate).toBe(175);
|
||||
});
|
||||
|
||||
it("keeps the activity name in metadata as data, never surfaced as a field", () => {
|
||||
const row = mapActivity(baseSummary);
|
||||
const meta = row!.metadata as Record<string, unknown>;
|
||||
expect(meta.stravaName).toBe("Morning Run");
|
||||
// The row shape has no top-level name/description — free-text stays in meta.
|
||||
expect((row as unknown as Record<string, unknown>).name).toBeUndefined();
|
||||
});
|
||||
|
||||
it("falls back to `type` then a generic label for the sport", () => {
|
||||
const noSport: StravaSummaryActivity = { ...baseSummary };
|
||||
delete (noSport as unknown as Record<string, unknown>).sport_type;
|
||||
expect(mapActivity(noSport)!.sportType).toBe("Run");
|
||||
|
||||
const noneAtAll: StravaSummaryActivity = { ...baseSummary };
|
||||
delete (noneAtAll as unknown as Record<string, unknown>).sport_type;
|
||||
delete (noneAtAll as unknown as Record<string, unknown>).type;
|
||||
expect(mapActivity(noneAtAll)!.sportType).toBe("workout");
|
||||
});
|
||||
|
||||
it("returns null for an activity with no id or no start instant", () => {
|
||||
expect(
|
||||
mapActivity({ ...baseSummary, id: undefined as unknown as number }),
|
||||
).toBeNull();
|
||||
const noStart: StravaSummaryActivity = { ...baseSummary };
|
||||
delete (noStart as unknown as Record<string, unknown>).start_date;
|
||||
expect(mapActivity(noStart)).toBeNull();
|
||||
});
|
||||
|
||||
it("drops negative / non-finite numeric fields rather than storing them", () => {
|
||||
const row = mapActivity({
|
||||
...baseSummary,
|
||||
distance: -1,
|
||||
average_heartrate: -5,
|
||||
});
|
||||
expect(row!.totalDistanceM).toBeNull();
|
||||
expect(row!.avgHeartRate).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("summaryHasHeartRate", () => {
|
||||
it("detects HR presence via flag or values", () => {
|
||||
expect(summaryHasHeartRate({ id: 1, has_heartrate: true })).toBe(true);
|
||||
expect(summaryHasHeartRate({ id: 1, average_heartrate: 140 })).toBe(true);
|
||||
expect(summaryHasHeartRate({ id: 1 })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("classifyStravaResponse", () => {
|
||||
it("classifies the status classes the sync depends on", () => {
|
||||
expect(classifyStravaResponse(200).classification).toBe("success");
|
||||
expect(classifyStravaResponse(401).classification).toBe("reauth_required");
|
||||
expect(classifyStravaResponse(429).classification).toBe("transient");
|
||||
expect(classifyStravaResponse(500).classification).toBe("transient");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const findUnique = vi.fn();
|
||||
const updateMany = vi.fn();
|
||||
const getStravaCredentials = vi.fn();
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: {
|
||||
findUnique: (...args: unknown[]) => findUnique(...args),
|
||||
updateMany: (...args: unknown[]) => updateMany(...args),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
decrypt: (cipher: string) => `dec(${cipher})`,
|
||||
encrypt: (plain: string) => `enc(${plain})`,
|
||||
}));
|
||||
|
||||
vi.mock("../client", () => ({
|
||||
getStravaCredentials: () => getStravaCredentials(),
|
||||
}));
|
||||
|
||||
import { getStravaClientCredentials, storeStravaTokens } from "../credentials";
|
||||
|
||||
describe("getStravaClientCredentials — DB-first then env", () => {
|
||||
beforeEach(() => {
|
||||
findUnique.mockReset();
|
||||
getStravaCredentials.mockReset();
|
||||
});
|
||||
|
||||
it("returns the per-user BYO pair when both columns are set", async () => {
|
||||
findUnique.mockResolvedValue({
|
||||
stravaClientIdEncrypted: "enc-id",
|
||||
stravaClientSecretEncrypted: "enc-secret",
|
||||
});
|
||||
const creds = await getStravaClientCredentials("user-1");
|
||||
expect(creds).toEqual({
|
||||
clientId: "dec(enc-id)",
|
||||
clientSecret: "dec(enc-secret)",
|
||||
});
|
||||
expect(getStravaCredentials).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to the shared env app when the user has no BYO pair", async () => {
|
||||
findUnique.mockResolvedValue({
|
||||
stravaClientIdEncrypted: null,
|
||||
stravaClientSecretEncrypted: null,
|
||||
});
|
||||
getStravaCredentials.mockReturnValue({
|
||||
clientId: "env-id",
|
||||
clientSecret: "env-secret",
|
||||
});
|
||||
const creds = await getStravaClientCredentials("user-1");
|
||||
expect(creds).toEqual({ clientId: "env-id", clientSecret: "env-secret" });
|
||||
expect(getStravaCredentials).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe("storeStravaTokens — rotating-refresh compare-and-swap", () => {
|
||||
beforeEach(() => {
|
||||
findUnique.mockReset();
|
||||
updateMany.mockReset();
|
||||
});
|
||||
|
||||
it("persists and returns the fresh access token when the CAS wins", async () => {
|
||||
updateMany.mockResolvedValue({ count: 1 });
|
||||
const token = await storeStravaTokens(
|
||||
"user-1",
|
||||
"fresh-access",
|
||||
"fresh-refresh",
|
||||
"stored-refresh-ciphertext",
|
||||
);
|
||||
expect(token).toBe("fresh-access");
|
||||
// The guard keys on the exact stored ciphertext, not a re-encryption.
|
||||
expect(updateMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
id: "user-1",
|
||||
stravaRefreshTokenEncrypted: "stored-refresh-ciphertext",
|
||||
},
|
||||
data: {
|
||||
stravaAccessTokenEncrypted: "enc(fresh-access)",
|
||||
stravaRefreshTokenEncrypted: "enc(fresh-refresh)",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("returns the peer's rotated access token when the CAS loses the race", async () => {
|
||||
updateMany.mockResolvedValue({ count: 0 });
|
||||
findUnique.mockResolvedValue({
|
||||
stravaAccessTokenEncrypted: "peer-access-cipher",
|
||||
});
|
||||
const token = await storeStravaTokens(
|
||||
"user-1",
|
||||
"fresh-access",
|
||||
"fresh-refresh",
|
||||
"stale-ciphertext",
|
||||
);
|
||||
expect(token).toBe("dec(peer-access-cipher)");
|
||||
});
|
||||
|
||||
it("returns null when the connection vanished mid-race", async () => {
|
||||
updateMany.mockResolvedValue({ count: 0 });
|
||||
findUnique.mockResolvedValue({ stravaAccessTokenEncrypted: null });
|
||||
const token = await storeStravaTokens(
|
||||
"user-1",
|
||||
"fresh-access",
|
||||
"fresh-refresh",
|
||||
"stale-ciphertext",
|
||||
);
|
||||
expect(token).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { pickCanonicalWorkoutRows } from "@/lib/measurements/pick-canonical-workout-rows";
|
||||
import { DEFAULT_WORKOUT_SOURCE_PRIORITY } from "@/lib/sources/pick-canonical-workout";
|
||||
import { DEFAULT_SOURCE_PRIORITY } from "@/lib/validations/source-priority";
|
||||
|
||||
/**
|
||||
* v1.28.x — Strava rides the SAME source-agnostic workout dedup engine as every
|
||||
* other source: adding `STRAVA` to the ladders is all that is needed for an
|
||||
* Apple-Watch + Strava twin of one run to collapse to a single canonical row.
|
||||
* No Strava-specific dedup path exists (nor should it).
|
||||
*/
|
||||
const D = (iso: string) => new Date(iso);
|
||||
|
||||
describe("STRAVA workout dedup — read-time canonical picker", () => {
|
||||
it("collapses an Apple-Health + Strava twin of the same run to the device row", () => {
|
||||
const rows = [
|
||||
{
|
||||
startedAt: D("2026-07-01T06:30:00Z"),
|
||||
sportType: "running",
|
||||
source: "APPLE_HEALTH" as const,
|
||||
},
|
||||
// Same run, re-uploaded to Strava a minute later — same sport, same
|
||||
// 5-minute slot.
|
||||
{
|
||||
startedAt: D("2026-07-01T06:31:00Z"),
|
||||
sportType: "running",
|
||||
source: "STRAVA" as const,
|
||||
},
|
||||
];
|
||||
const canonical = pickCanonicalWorkoutRows(rows);
|
||||
expect(canonical).toHaveLength(1);
|
||||
// The device-native capture outranks the Strava re-upload.
|
||||
expect(canonical[0].source).toBe("APPLE_HEALTH");
|
||||
});
|
||||
|
||||
it("keeps a Strava-only run (no competing source) untouched", () => {
|
||||
const rows = [
|
||||
{
|
||||
startedAt: D("2026-07-02T18:00:00Z"),
|
||||
sportType: "cycling",
|
||||
source: "STRAVA" as const,
|
||||
},
|
||||
];
|
||||
const canonical = pickCanonicalWorkoutRows(rows);
|
||||
expect(canonical).toHaveLength(1);
|
||||
expect(canonical[0].source).toBe("STRAVA");
|
||||
});
|
||||
|
||||
it("ranks STRAVA above MANUAL when both cover the same workout", () => {
|
||||
const rows = [
|
||||
{
|
||||
startedAt: D("2026-07-03T07:00:00Z"),
|
||||
sportType: "running",
|
||||
source: "MANUAL" as const,
|
||||
},
|
||||
{
|
||||
startedAt: D("2026-07-03T07:00:30Z"),
|
||||
sportType: "running",
|
||||
source: "STRAVA" as const,
|
||||
},
|
||||
];
|
||||
const canonical = pickCanonicalWorkoutRows(rows);
|
||||
expect(canonical).toHaveLength(1);
|
||||
expect(canonical[0].source).toBe("STRAVA");
|
||||
});
|
||||
});
|
||||
|
||||
describe("STRAVA sits in the workout source ladders", () => {
|
||||
it("is present in the workout default ladder, below the wearables, above MANUAL", () => {
|
||||
const strava = DEFAULT_WORKOUT_SOURCE_PRIORITY.indexOf("STRAVA");
|
||||
const withings = DEFAULT_WORKOUT_SOURCE_PRIORITY.indexOf("WITHINGS");
|
||||
const manual = DEFAULT_WORKOUT_SOURCE_PRIORITY.indexOf("MANUAL");
|
||||
expect(strava).toBeGreaterThan(withings);
|
||||
expect(strava).toBeLessThan(manual);
|
||||
});
|
||||
|
||||
it("is present in the `steps` ladder the read picker resolves against", () => {
|
||||
const steps = DEFAULT_SOURCE_PRIORITY.steps;
|
||||
const strava = steps.indexOf("STRAVA");
|
||||
const apple = steps.indexOf("APPLE_HEALTH");
|
||||
const manual = steps.indexOf("MANUAL");
|
||||
expect(strava).toBeGreaterThan(apple);
|
||||
expect(strava).toBeLessThan(manual);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,415 @@
|
||||
/**
|
||||
* 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";
|
||||
|
||||
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: string;
|
||||
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`; free-text at the DB layer. 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 sportType =
|
||||
summary.sport_type ?? summary.type ?? detail?.sport_type ?? "workout";
|
||||
|
||||
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 } : {}),
|
||||
...(summary.type ? { stravaType: summary.type } : {}),
|
||||
...(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"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* v1.28.x — per-user Strava token + BYO-key accessors.
|
||||
*
|
||||
* Like WHOOP / Oura / Polar, Strava is a per-user BYO-key integration: each
|
||||
* user registers their own Strava app and stores the client id/secret encrypted
|
||||
* on `User`, with a fall-back to the shared env app for single-app deploys.
|
||||
* Also per-user is the granted access + refresh token. The schema carries NO
|
||||
* token-expiry column, so the sync layer refreshes REACTIVELY on a 401 rather
|
||||
* than proactively on an expiry timestamp — and because Strava ROTATES its
|
||||
* refresh token on every refresh, both rotated tokens are persisted with a
|
||||
* compare-and-set on the stored refresh ciphertext (Oura's model verbatim).
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { decrypt, encrypt } from "@/lib/crypto";
|
||||
import { persistRotatedToken } from "@/lib/integrations/oauth-refresh";
|
||||
import { getStravaCredentials, type StravaCredentials } from "./client";
|
||||
|
||||
/**
|
||||
* Resolve the user's Strava OAuth client id/secret, DB-first then env. A
|
||||
* self-hoster's own BYO pair wins; when none is stored we fall back to the
|
||||
* shared env app (`STRAVA_CLIENT_ID` / `STRAVA_CLIENT_SECRET`). Returns null
|
||||
* when neither source is configured.
|
||||
*/
|
||||
export async function getStravaClientCredentials(
|
||||
userId: string,
|
||||
): Promise<StravaCredentials | null> {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
stravaClientIdEncrypted: true,
|
||||
stravaClientSecretEncrypted: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (user?.stravaClientIdEncrypted && user?.stravaClientSecretEncrypted) {
|
||||
return {
|
||||
clientId: decrypt(user.stravaClientIdEncrypted),
|
||||
clientSecret: decrypt(user.stravaClientSecretEncrypted),
|
||||
};
|
||||
}
|
||||
|
||||
return getStravaCredentials();
|
||||
}
|
||||
|
||||
/** Persist the user's Strava OAuth client id/secret, encrypted at rest. */
|
||||
export async function storeStravaClientCredentials(
|
||||
userId: string,
|
||||
clientId: string,
|
||||
clientSecret: string,
|
||||
): Promise<void> {
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
stravaClientIdEncrypted: encrypt(clientId),
|
||||
stravaClientSecretEncrypted: encrypt(clientSecret),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Clear the user's stored Strava OAuth client id/secret. */
|
||||
export async function clearStravaClientCredentials(
|
||||
userId: string,
|
||||
): Promise<void> {
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
stravaClientIdEncrypted: null,
|
||||
stravaClientSecretEncrypted: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export interface StravaConnection {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
/**
|
||||
* The exact stored refresh-token ciphertext (NOT the decrypted value). The
|
||||
* rotating-refresh compare-and-swap in `storeStravaTokens` matches on this so
|
||||
* a concurrent sync that already rotated the token is not clobbered.
|
||||
*/
|
||||
refreshTokenCiphertext: string;
|
||||
}
|
||||
|
||||
export async function getStravaConnection(
|
||||
userId: string,
|
||||
): Promise<StravaConnection | null> {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
stravaAccessTokenEncrypted: true,
|
||||
stravaRefreshTokenEncrypted: true,
|
||||
},
|
||||
});
|
||||
if (!user?.stravaAccessTokenEncrypted || !user?.stravaRefreshTokenEncrypted) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
accessToken: decrypt(user.stravaAccessTokenEncrypted),
|
||||
refreshToken: decrypt(user.stravaRefreshTokenEncrypted),
|
||||
refreshTokenCiphertext: user.stravaRefreshTokenEncrypted,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist a rotated Strava token pair with compare-and-swap on the stored
|
||||
* refresh ciphertext. Strava's tokens live on `User` and the refresh is
|
||||
* REACTIVE on a 401, so two overlapping syncs can race the same one-time-use
|
||||
* refresh token. The CAS guard writes only when the stored ciphertext still
|
||||
* equals the one this caller spent; on a lost race (0 rows) it re-reads and
|
||||
* returns the peer's freshly rotated access token.
|
||||
*
|
||||
* Returns the access token the caller should use for the current sync (its own
|
||||
* on a win, the peer's on a loss), or null if the connection vanished.
|
||||
*/
|
||||
export async function storeStravaTokens(
|
||||
userId: string,
|
||||
accessToken: string,
|
||||
refreshToken: string,
|
||||
expectedRefreshCiphertext: string,
|
||||
): Promise<string | null> {
|
||||
return persistRotatedToken(accessToken, {
|
||||
conditionalUpdate: async () => {
|
||||
const { count } = await prisma.user.updateMany({
|
||||
where: {
|
||||
id: userId,
|
||||
stravaRefreshTokenEncrypted: expectedRefreshCiphertext,
|
||||
},
|
||||
data: {
|
||||
stravaAccessTokenEncrypted: encrypt(accessToken),
|
||||
stravaRefreshTokenEncrypted: encrypt(refreshToken),
|
||||
},
|
||||
});
|
||||
return count;
|
||||
},
|
||||
readPeerAccessToken: async () => {
|
||||
const fresh = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { stravaAccessTokenEncrypted: true },
|
||||
});
|
||||
return fresh?.stravaAccessTokenEncrypted
|
||||
? decrypt(fresh.stravaAccessTokenEncrypted)
|
||||
: null;
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* v1.28.x — off-response classifier for Strava API v3 replies.
|
||||
* HTTP-status-driven; the shared core lives in
|
||||
* `src/lib/integrations/http-status-classifier.ts` and is re-exported here
|
||||
* under the Strava-specific names callers already use.
|
||||
*
|
||||
* - `success` → HTTP 2xx
|
||||
* - `transient` → 429 (rate-limited — respect X-RateLimit headers),
|
||||
* 5xx, 3xx, unknown.
|
||||
* - `reauth_required` → 401 / 403 — token rejected / grant revoked, plus an
|
||||
* `invalid_grant` on the refresh endpoint.
|
||||
* - `persistent` → any other 4xx — malformed request / contract drift.
|
||||
*/
|
||||
|
||||
import {
|
||||
classifyHttpStatus,
|
||||
classifyIntegrationError,
|
||||
IntegrationApiError,
|
||||
isOAuthInvalidGrant,
|
||||
type ClassifiedHttpResponse,
|
||||
type IntegrationClassification,
|
||||
} from "@/lib/integrations/http-status-classifier";
|
||||
|
||||
export function classifyStravaResponse(
|
||||
httpStatus: number,
|
||||
): ClassifiedHttpResponse {
|
||||
return classifyHttpStatus(httpStatus);
|
||||
}
|
||||
|
||||
export class StravaApiError extends IntegrationApiError {
|
||||
constructor(opts: {
|
||||
verb: string;
|
||||
classification: IntegrationClassification;
|
||||
httpStatus: number | undefined;
|
||||
reason: string;
|
||||
upstreamError?: string;
|
||||
}) {
|
||||
super({ vendor: "Strava", ...opts });
|
||||
this.name = "StravaApiError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a caught error is an OAuth `invalid_grant` on the token endpoint —
|
||||
* the canonical revoked / rotated-away refresh-token signal. Lifts a 400
|
||||
* carrying `invalid_grant` from `persistent` to `reauth_required` so the
|
||||
* connection surfaces the reconnect prompt.
|
||||
*/
|
||||
function isInvalidGrant(err: unknown): boolean {
|
||||
return isOAuthInvalidGrant(err, "Strava");
|
||||
}
|
||||
|
||||
export function classifyStravaError(err: unknown): IntegrationClassification {
|
||||
if (isInvalidGrant(err)) return "reauth_required";
|
||||
return classifyIntegrationError(err, "Strava");
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
/**
|
||||
* v1.28.x — Strava API v3 sync.
|
||||
*
|
||||
* Pulls the athlete's activities and upserts each into the `Workout` table as
|
||||
* `source = STRAVA`, keyed on `(userId, source, externalId)` so a re-fetch /
|
||||
* `activity.update` overwrites in place. Strava is a WORKOUT source only — it
|
||||
* exposes no daily metrics, so unlike the Oura/Polar sync this writes `Workout`
|
||||
* rows and stops (no measurement rollup fold).
|
||||
*
|
||||
* Cross-source dedup is NOT done here: a Strava run and the same run via Apple
|
||||
* Health remain two distinct `Workout` rows (different `source`); the read-time
|
||||
* `pickCanonicalWorkoutRows` picker collapses the cross-source twin at read
|
||||
* time via the user's source-priority ladder. One engine, no parallel path.
|
||||
*
|
||||
* Token model: Strava ROTATES its refresh token on every refresh. The schema
|
||||
* has no expiry column, so the sync refreshes REACTIVELY — the first read that
|
||||
* 401s triggers one refresh (persisting BOTH rotated tokens via a compare-and-
|
||||
* set) and a single retry. A failed refresh (`invalid_grant`) records
|
||||
* `reauth_required` on the `strava` ledger.
|
||||
*
|
||||
* Rate limits: 200 req / 15 min + 2000 / day per app. The walk is page-bounded
|
||||
* and the per-activity detail fetch (for `calories`, absent from the summary)
|
||||
* is budget-capped; a 429 mid-walk records a transient failure and the next
|
||||
* cron tick resumes from the cursor.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import {
|
||||
recordSyncFailure,
|
||||
recordSyncSuccess,
|
||||
toFailureKind,
|
||||
type FailureKind,
|
||||
} from "@/lib/integrations/status";
|
||||
import {
|
||||
fetchActivities,
|
||||
fetchActivityById,
|
||||
mapActivity,
|
||||
refreshAccessToken,
|
||||
summaryHasHeartRate,
|
||||
type StravaDetailedActivity,
|
||||
type StravaWorkoutRow,
|
||||
} from "./client";
|
||||
import {
|
||||
getStravaClientCredentials,
|
||||
getStravaConnection,
|
||||
storeStravaTokens,
|
||||
} from "./credentials";
|
||||
import { StravaApiError, classifyStravaError } from "./response-classifier";
|
||||
|
||||
/** Default first-incremental lookback (days) when no cursor exists yet. */
|
||||
export const STRAVA_SYNC_LOOKBACK_DAYS = 30;
|
||||
|
||||
/** Overlap (ms) subtracted from the stored cursor on an incremental sync so an
|
||||
* activity uploaded late (started before the cursor, synced after) is still
|
||||
* caught. The upsert is idempotent, so re-fetching the overlap window is cheap. */
|
||||
export const STRAVA_SYNC_OVERLAP_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
const PER_PAGE = 100;
|
||||
const INCREMENTAL_MAX_PAGES = 5;
|
||||
const BACKFILL_MAX_PAGES = 40;
|
||||
/** Per-activity detail calls (for `calories`) are capped per sync to stay well
|
||||
* under Strava's 200-req / 15-min ceiling on a heavy backfill. */
|
||||
const INCREMENTAL_DETAIL_BUDGET = 100;
|
||||
const BACKFILL_DETAIL_BUDGET = 150;
|
||||
|
||||
export function classifyStravaFailure(err: unknown): FailureKind {
|
||||
return toFailureKind(classifyStravaError(err));
|
||||
}
|
||||
|
||||
export interface SyncUserStravaOptions {
|
||||
fullSync?: boolean;
|
||||
lookbackDays?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync one user's Strava activities. Returns the count of `Workout` rows
|
||||
* written. A user with no Strava connection is a clean no-op (returns 0,
|
||||
* touches no status row).
|
||||
*/
|
||||
export async function syncUserStrava(
|
||||
userId: string,
|
||||
opts: SyncUserStravaOptions = {},
|
||||
): Promise<number> {
|
||||
const conn = await getStravaConnection(userId);
|
||||
if (!conn) return 0;
|
||||
|
||||
const fullSync = opts.fullSync ?? false;
|
||||
|
||||
// Resolve the incremental cursor. On a full backfill we walk all history
|
||||
// (`after` omitted). Otherwise start at the stored newest-activity instant
|
||||
// minus the overlap, or a default lookback when no cursor exists yet.
|
||||
let afterEpoch: number | undefined;
|
||||
if (!fullSync) {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { stravaLastActivityAt: true },
|
||||
});
|
||||
const lookbackMs =
|
||||
(opts.lookbackDays ?? STRAVA_SYNC_LOOKBACK_DAYS) * 24 * 60 * 60 * 1000;
|
||||
const base = user?.stravaLastActivityAt
|
||||
? user.stravaLastActivityAt.getTime() - STRAVA_SYNC_OVERLAP_MS
|
||||
: Date.now() - lookbackMs;
|
||||
afterEpoch = Math.floor(base / 1000);
|
||||
}
|
||||
|
||||
// Reactive refresh state — one refresh per sync, shared across every call.
|
||||
let accessToken = conn.accessToken;
|
||||
let refreshDone = false;
|
||||
|
||||
async function refreshOnce(): Promise<boolean> {
|
||||
if (refreshDone) return false;
|
||||
const creds = await getStravaClientCredentials(userId);
|
||||
if (!creds) return false;
|
||||
const rotated = await refreshAccessToken(conn!.refreshToken, creds);
|
||||
const usable = await storeStravaTokens(
|
||||
userId,
|
||||
rotated.access_token,
|
||||
rotated.refresh_token,
|
||||
conn!.refreshTokenCiphertext,
|
||||
);
|
||||
if (!usable) return false;
|
||||
accessToken = usable;
|
||||
refreshDone = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
async function authed<T>(fn: (token: string) => Promise<T>): Promise<T> {
|
||||
try {
|
||||
return await fn(accessToken);
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof StravaApiError &&
|
||||
err.httpStatus === 401 &&
|
||||
(await refreshOnce())
|
||||
) {
|
||||
return fn(accessToken);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
const maxPages = fullSync ? BACKFILL_MAX_PAGES : INCREMENTAL_MAX_PAGES;
|
||||
let detailBudget = fullSync
|
||||
? BACKFILL_DETAIL_BUDGET
|
||||
: INCREMENTAL_DETAIL_BUDGET;
|
||||
|
||||
const rows: StravaWorkoutRow[] = [];
|
||||
let newestStart: Date | null = null;
|
||||
|
||||
try {
|
||||
for (let page = 1; page <= maxPages; page++) {
|
||||
const activities = await authed((t) =>
|
||||
fetchActivities(t, { after: afterEpoch, page, perPage: PER_PAGE }),
|
||||
);
|
||||
if (activities.length === 0) break;
|
||||
|
||||
for (const a of activities) {
|
||||
// Fetch detail only to fill `calories` (absent from the summary), and
|
||||
// only while the budget allows. When the summary already lacks HR we
|
||||
// also let the detail top it up. A detail failure is non-fatal — map
|
||||
// from the summary — but a 429 aborts the whole walk (transient).
|
||||
let detail: StravaDetailedActivity | null = null;
|
||||
if (detailBudget > 0) {
|
||||
try {
|
||||
detail = await authed((t) => fetchActivityById(t, a.id));
|
||||
detailBudget -= 1;
|
||||
} catch (err) {
|
||||
if (err instanceof StravaApiError && err.httpStatus === 429) {
|
||||
throw err;
|
||||
}
|
||||
getEvent()?.addWarning(
|
||||
`strava: activity detail fetch failed for ${a.id}: ${err}`,
|
||||
);
|
||||
detail = null;
|
||||
}
|
||||
} else if (!summaryHasHeartRate(a)) {
|
||||
getEvent()?.addWarning(
|
||||
`strava: detail budget exhausted; mapping activity ${a.id} from summary only`,
|
||||
);
|
||||
}
|
||||
|
||||
const row = mapActivity(a, detail);
|
||||
if (row) {
|
||||
rows.push(row);
|
||||
if (a.start_date) {
|
||||
const d = new Date(a.start_date);
|
||||
if (
|
||||
!Number.isNaN(d.getTime()) &&
|
||||
(!newestStart || d > newestStart)
|
||||
) {
|
||||
newestStart = d;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (activities.length < PER_PAGE) break;
|
||||
}
|
||||
} catch (err) {
|
||||
await recordSyncFailure({
|
||||
userId,
|
||||
integration: "strava",
|
||||
kind: classifyStravaFailure(err),
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
errorCode:
|
||||
err instanceof StravaApiError && err.httpStatus != null
|
||||
? String(err.httpStatus)
|
||||
: undefined,
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
|
||||
const imported = await upsertStravaWorkouts(userId, rows);
|
||||
|
||||
// Advance the incremental cursor to the newest activity start we saw, but
|
||||
// never move it backwards (a bounded backfill page-walk may not reach the
|
||||
// very newest activity in one pass).
|
||||
if (newestStart) {
|
||||
await prisma.user.updateMany({
|
||||
where: {
|
||||
id: userId,
|
||||
OR: [
|
||||
{ stravaLastActivityAt: null },
|
||||
{ stravaLastActivityAt: { lt: newestStart } },
|
||||
],
|
||||
},
|
||||
data: { stravaLastActivityAt: newestStart },
|
||||
});
|
||||
}
|
||||
|
||||
await recordSyncSuccess(userId, "strava");
|
||||
annotate({
|
||||
action: { name: "strava.sync.complete", details: { imported } },
|
||||
});
|
||||
return imported;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert a batch of mapped Strava activities into `Workout`, keyed on
|
||||
* `(userId, source = STRAVA, externalId)` so a re-fetch overwrites in place.
|
||||
* Best-effort per row: a single bad row is logged, never thrown, so it can't
|
||||
* fail the surrounding sync.
|
||||
*/
|
||||
export async function upsertStravaWorkouts(
|
||||
userId: string,
|
||||
rows: readonly StravaWorkoutRow[],
|
||||
): Promise<number> {
|
||||
if (rows.length === 0) return 0;
|
||||
|
||||
let imported = 0;
|
||||
for (const r of rows) {
|
||||
const data = {
|
||||
sportType: r.sportType,
|
||||
startedAt: r.startedAt,
|
||||
endedAt: r.endedAt,
|
||||
durationSec: r.durationSec,
|
||||
totalEnergyKcal: r.totalEnergyKcal,
|
||||
totalDistanceM: r.totalDistanceM,
|
||||
avgHeartRate: r.avgHeartRate,
|
||||
maxHeartRate: r.maxHeartRate,
|
||||
elevationM: r.elevationM,
|
||||
metadata: r.metadata,
|
||||
};
|
||||
try {
|
||||
await prisma.workout.upsert({
|
||||
where: {
|
||||
userId_source_externalId: {
|
||||
userId,
|
||||
source: "STRAVA",
|
||||
externalId: r.externalId,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
userId,
|
||||
source: "STRAVA",
|
||||
externalId: r.externalId,
|
||||
...data,
|
||||
},
|
||||
update: data,
|
||||
});
|
||||
imported += 1;
|
||||
} catch (err) {
|
||||
getEvent()?.addWarning(`strava: failed to upsert workout: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
annotate({
|
||||
action: { name: "strava.workout.ingest", details: { imported } },
|
||||
});
|
||||
return imported;
|
||||
}
|
||||
@@ -215,6 +215,13 @@ export const measurementSourceEnum = z.enum([
|
||||
// deliberately absent from `WRITABLE_MEASUREMENT_SOURCES` + the batch
|
||||
// allowlist so a client can never forge a GOOGLE_HEALTH-attributed row.
|
||||
"GOOGLE_HEALTH",
|
||||
// v1.28.x — Strava OAuth integration. Native Strava activities ingest
|
||||
// server-side as `source = STRAVA` Workout rows (no client write path),
|
||||
// exactly like WHOOP/FITBIT. Part of this enum so the read/response shapes
|
||||
// (and the iOS decoder) can decode the rows it surfaces; deliberately absent
|
||||
// from `WRITABLE_MEASUREMENT_SOURCES` + the batch allowlist so a client can
|
||||
// never forge a STRAVA-attributed row.
|
||||
"STRAVA",
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
@@ -238,6 +238,11 @@ export const DEFAULT_SOURCE_PRIORITY: Required<MetricPriority> = {
|
||||
// successor API to the classic Fitbit Web API); it rides in the same
|
||||
// wrist-wearable class as FITBIT and ranks immediately below it in every
|
||||
// device ladder, above OURA/POLAR and MANUAL.
|
||||
// v1.28.x — the `steps` ladder is also the ladder the cross-source WORKOUT
|
||||
// picker (`pickCanonicalWorkoutRows`) resolves against. STRAVA slots below the
|
||||
// device-native wearables and above MANUAL so an Apple-Watch + Strava twin of
|
||||
// the same run collapses onto the richer device row, while a Strava-only run
|
||||
// still survives.
|
||||
steps: [
|
||||
"APPLE_HEALTH",
|
||||
"WITHINGS",
|
||||
@@ -245,6 +250,7 @@ export const DEFAULT_SOURCE_PRIORITY: Required<MetricPriority> = {
|
||||
"GOOGLE_HEALTH",
|
||||
"OURA",
|
||||
"POLAR",
|
||||
"STRAVA",
|
||||
"MANUAL",
|
||||
],
|
||||
activeEnergy: [
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
/**
|
||||
* Per-user Strava BYO-key credentials. Strava caps every newly-created API app
|
||||
* at single-player mode (athlete capacity 1), so a single shared app cannot
|
||||
* serve many self-hosters — each registers their own Strava app and pastes the
|
||||
* client id/secret into Settings. Stored encrypted on `User`. When unset, the
|
||||
* integration falls back to the shared env-configured OAuth app.
|
||||
*/
|
||||
export const stravaCredentialsSchema = z.object({
|
||||
// Trimmed: a trailing space or newline from the portal's copy button reaches
|
||||
// Strava verbatim and answers as "invalid client".
|
||||
clientId: z.string().trim().min(1).max(200),
|
||||
clientSecret: z.string().trim().min(1).max(200),
|
||||
});
|
||||
|
||||
export type StravaCredentialsInput = z.infer<typeof stravaCredentialsSchema>;
|
||||
+10
-1
@@ -410,6 +410,15 @@ export function proxy(request: NextRequest) {
|
||||
pathname.startsWith("/settings/integrations/whoop") ||
|
||||
pathname.startsWith("/api/whoop/");
|
||||
const whoopConnectSrc = isWhoopRoute ? " https://api.prod.whoop.com" : "";
|
||||
// v1.28.x — `www.strava.com` gated to the Strava settings surface +
|
||||
// `/api/strava/*`, mirroring the WHOOP gating shape. The Strava data client
|
||||
// lives server-side and the OAuth handshake is a browser redirect (not a
|
||||
// fetch), so this is belt-and-suspenders parity — no other surface ever needs
|
||||
// to reach the Strava host from the browser.
|
||||
const isStravaRoute =
|
||||
pathname.startsWith("/settings/integrations/strava") ||
|
||||
pathname.startsWith("/api/strava/");
|
||||
const stravaConnectSrc = isStravaRoute ? " https://www.strava.com" : "";
|
||||
// v1.5.5 — Gravatar host removed from `img-src`. The /me payload
|
||||
// used to return `gravatarUrl: https://www.gravatar.com/avatar/<sha256(email)>`,
|
||||
// which leaked the email digest to Automattic on every authenticated
|
||||
@@ -433,7 +442,7 @@ export function proxy(request: NextRequest) {
|
||||
`default-src 'none'; frame-ancestors 'self';`
|
||||
: isDev
|
||||
? `default-src 'self'; script-src 'self' 'unsafe-eval' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; font-src 'self';`
|
||||
: `default-src 'self'; script-src 'self' 'nonce-${nonce}'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'${aiConnectSrc}${withingsConnectSrc}${whoopConnectSrc}; font-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; worker-src 'self'; report-uri ${cspReportEndpoint}; report-to csp-endpoint;`;
|
||||
: `default-src 'self'; script-src 'self' 'nonce-${nonce}'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'${aiConnectSrc}${withingsConnectSrc}${whoopConnectSrc}${stravaConnectSrc}; font-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; worker-src 'self'; report-uri ${cspReportEndpoint}; report-to csp-endpoint;`;
|
||||
response.headers.set("Content-Security-Policy", csp);
|
||||
|
||||
// Production-only headers. HSTS carries `preload` so the domain stays
|
||||
|
||||
Reference in new issue
Block a user