Skip to content

Commit c806ba1

Browse files
authored
feat: Strava Integration (OAuth, Jobs, UI) (#114)
* feat: add strava_accounts, activity source tracking, and run HR columns * feat: add StravaAccount model with encrypted tokens * feat: add Strava API client (oauth + activities) * feat: import Strava runs as activities with dedup and sport filter * feat: add StravaSyncJob with pagination, token refresh, and incremental sync * feat: add StravaAccountsController for OAuth flow and disconnection * feat: add Strava connect UI and translations * feat: add Strava webhooks to auto-sync new activities * feat: include Walk activities in Strava sync * feat: add verbose logging to Strava sync process * feat: decode and display Strava map polylines without storing GPS trackpoints * fix: provide dummy encryption keys for CI and uncomment master key * fix: refactor to 37signals pattern, address PR feedback, and include encrypted credentials * style: fix rubocop Layout/TrailingWhitespace offenses * chore: update loofah and rails-html-sanitizer for security
1 parent f1b079b commit c806ba1

29 files changed

Lines changed: 949 additions & 15 deletions

.github/workflows/ci.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ jobs:
100100
env:
101101
RAILS_ENV: test
102102
DATABASE_URL: postgres://postgres:postgres@localhost:5432
103-
# RAILS_MASTER_KEY: ${{ secrets.RAILS_MASTER_KEY }}
103+
RAILS_MASTER_KEY: ${{ secrets.RAILS_MASTER_KEY }}
104104
# REDIS_URL: redis://localhost:6379/0
105105
run: bin/rails db:test:prepare test
106106

@@ -139,7 +139,7 @@ jobs:
139139
env:
140140
RAILS_ENV: test
141141
DATABASE_URL: postgres://postgres:postgres@localhost:5432
142-
# RAILS_MASTER_KEY: ${{ secrets.RAILS_MASTER_KEY }}
142+
RAILS_MASTER_KEY: ${{ secrets.RAILS_MASTER_KEY }}
143143
# REDIS_URL: redis://localhost:6379/0
144144
run: bin/rails db:test:prepare test:system
145145

Gemfile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ group :test do
103103
# Use system testing [https://guides.rubyonrails.org/testing.html#system-testing]
104104
gem "capybara"
105105
gem "selenium-webdriver"
106+
gem "webmock"
106107
end
107108

108109
gem "rails-i18n", "~> 8.1"

Gemfile.lock

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,9 @@ GEM
137137
unaccent (~> 0.3)
138138
country_select (11.0.0)
139139
countries (> 6.0, < 9.0)
140+
crack (1.0.1)
141+
bigdecimal
142+
rexml
140143
crass (1.0.7)
141144
csv (3.3.5)
142145
date (3.5.1)
@@ -165,6 +168,7 @@ GEM
165168
csv
166169
nokogiri (~> 1.7)
167170
rake
171+
hashdiff (1.2.1)
168172
highline (3.1.2)
169173
reline
170174
httparty (0.24.2)
@@ -226,7 +230,7 @@ GEM
226230
launchy (>= 2.2, < 4)
227231
lint_roller (1.1.0)
228232
logger (1.7.0)
229-
loofah (2.25.1)
233+
loofah (2.25.2)
230234
crass (~> 1.0.2)
231235
nokogiri (>= 1.12.0)
232236
mail (2.9.0)
@@ -338,8 +342,8 @@ GEM
338342
activesupport (>= 5.0.0)
339343
minitest
340344
nokogiri (>= 1.6)
341-
rails-html-sanitizer (1.7.0)
342-
loofah (~> 2.25)
345+
rails-html-sanitizer (1.7.1)
346+
loofah (~> 2.25, >= 2.25.2)
343347
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
344348
rails-i18n (8.1.0)
345349
i18n (>= 0.7, < 2)
@@ -471,6 +475,10 @@ GEM
471475
actionview (>= 8.0.0)
472476
bindex (>= 0.4.0)
473477
railties (>= 8.0.0)
478+
webmock (3.26.2)
479+
addressable (>= 2.8.0)
480+
crack (>= 0.3.2)
481+
hashdiff (>= 0.4.0, < 2.0.0)
474482
websocket (1.2.11)
475483
websocket-driver (0.8.2)
476484
base64
@@ -532,6 +540,7 @@ DEPENDENCIES
532540
tzinfo-data
533541
view_component
534542
web-console
543+
webmock
535544

536545
CHECKSUMS
537546
action_text-trix (2.1.19) sha256=7012f59421009cf284aa651294896414d653a61a2417c9b8714c8476d2f74009
@@ -572,6 +581,7 @@ CHECKSUMS
572581
connection_pool (3.0.2) sha256=33fff5ba71a12d2aa26cb72b1db8bba2a1a01823559fb01d29eb74c286e62e0a
573582
countries (8.1.0) sha256=4d6b318b8e906f1f769d5c021c13a418d33e917dc96ceb625a91d8e7ab2d192e
574583
country_select (11.0.0) sha256=0ab0a385fa70eadc71473af4b21b9b4d09f75660cc1c282a5bf6ec873719204b
584+
crack (1.0.1) sha256=ff4a10390cd31d66440b7524eb1841874db86201d5b70032028553130b6d4c7e
575585
crass (1.0.7) sha256=94868719948664c89ddcaf0a37c65048413dfcb1c869470a5f7a7ceb5390b295
576586
csv (3.3.5) sha256=6e5134ac3383ef728b7f02725d9872934f523cb40b961479f69cf3afa6c8e73f
577587
date (3.5.1) sha256=750d06384d7b9c15d562c76291407d89e368dda4d4fff957eb94962d325a0dc0
@@ -591,6 +601,7 @@ CHECKSUMS
591601
fugit (1.12.2) sha256=643f2bf28db263bd400cbf8e0dd8b76b2c9b94bdb130e12d2394de04d9c20e5e
592602
globalid (1.3.0) sha256=05c639ad6eb4594522a0b07983022f04aa7254626ab69445a0e493aa3786ff11
593603
gpx (1.2.2) sha256=78a1766381c9a6286b83495ecf08bb7362d4990c4f88fd7500925123fde4e51a
604+
hashdiff (1.2.1) sha256=9c079dbc513dfc8833ab59c0c2d8f230fa28499cc5efb4b8dd276cf931457cd1
594605
highline (3.1.2) sha256=67cbd34d19f6ef11a7ee1d82ffab5d36dfd5b3be861f450fc1716c7125f4bb4a
595606
httparty (0.24.2) sha256=8fca6a54aa0c4aa4303a0fd33e5e2156175d6a5334f714263b458abd7fda9c38
596607
i18n (1.15.2) sha256=00f9eb62412fe593b2a65a97daa75300d37abb8f7202ec748e94b6d46a9dd1b5
@@ -609,7 +620,7 @@ CHECKSUMS
609620
letter_opener (1.10.0) sha256=2ff33f2e3b5c3c26d1959be54b395c086ca6d44826e8bf41a14ff96fdf1bdbb2
610621
lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87
611622
logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203
612-
loofah (2.25.1) sha256=d436c73dbd0c1147b16c4a41db097942d217303e1f7728704b37e4df9f6d2e04
623+
loofah (2.25.2) sha256=2007f746959ac65552456e04b433e83deb22759ab38c838b4445c70e43425918
613624
mail (2.9.0) sha256=6fa6673ecd71c60c2d996260f9ee3dd387d4673b8169b502134659ece6d34941
614625
marcel (1.2.1) sha256=1678e9360e32f9eafa917c80029e2f6d10b2715c66a4b87b6d0da9b9cd1f859f
615626
matrix (0.4.3) sha256=a0d5ab7ddcc1973ff690ab361b67f359acbb16958d1dc072b8b956a286564c5b
@@ -657,7 +668,7 @@ CHECKSUMS
657668
rackup (2.3.1) sha256=6c79c26753778e90983761d677a48937ee3192b3ffef6bc963c0950f94688868
658669
rails (8.1.3) sha256=6d017ba5348c98fc909753a8169b21d44de14d2a0b92d140d1a966834c3c9cd3
659670
rails-dom-testing (2.3.0) sha256=8acc7953a7b911ca44588bf08737bc16719f431a1cc3091a292bca7317925c1d
660-
rails-html-sanitizer (1.7.0) sha256=28b145cceaf9cc214a9874feaa183c3acba036c9592b19886e0e45efc62b1e89
671+
rails-html-sanitizer (1.7.1) sha256=e797a7c9b01e567307e317c576b49ab4168017e63eea4dba9ce3cb587e2f22c2
661672
rails-i18n (8.1.0) sha256=52d5fd6c0abef28d84223cc05647f6ae0fd552637a1ede92deee9545755b6cf3
662673
railties (8.1.3) sha256=913eb0e0cb520aac687ffd74916bd726d48fa21f47833c6292576ef6a286de22
663674
rainbow (3.1.1) sha256=039491aa3a89f42efa1d6dec2fc4e62ede96eb6acd95e52f1ad581182b79bc6a
@@ -705,6 +716,7 @@ CHECKSUMS
705716
useragent (0.16.11) sha256=700e6413ad4bb954bb63547fa098dddf7b0ebe75b40cc6f93b8d54255b173844
706717
view_component (4.12.0) sha256=b9a5979d1c43eba2aa21a06a86f661aab3990104855f2909ef7c3779acdcdcb4
707718
web-console (4.3.0) sha256=e13b71301cdfc2093f155b5aa3a622db80b4672d1f2f713119cc7ec7ac6a6da4
719+
webmock (3.26.2) sha256=774556f2ea6371846cca68c01769b2eac0d134492d21f6d0ab5dd643965a4c90
708720
websocket (1.2.11) sha256=b7e7a74e2410b5e85c25858b26b3322f29161e300935f70a0e0d3c35e0462737
709721
websocket-driver (0.8.2) sha256=97c556b019bf3410b4961002ac501621e9322d3f8a7bc02161a09301cc4c4146
710722
websocket-extensions (0.1.5) sha256=1c6ba63092cda343eb53fc657110c71c754c56484aad42578495227d717a8241
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
class StravaAccountsController < ApplicationController
2+
before_action :require_authentication
3+
4+
def create
5+
state = SecureRandom.hex(16)
6+
session[:strava_oauth_state] = state
7+
client = Strava::Client.new
8+
redirect_to client.authorize_url(redirect_uri: callback_strava_account_url, state: state), allow_other_host: true
9+
end
10+
11+
def callback
12+
if params[:state].blank? || params[:state] != session[:strava_oauth_state]
13+
redirect_to account_path, alert: "Invalid OAuth state. Please try again."
14+
return
15+
end
16+
session.delete(:strava_oauth_state)
17+
18+
if params[:error] == "access_denied"
19+
redirect_to account_path, alert: t(".access_denied")
20+
return
21+
end
22+
23+
if StravaAccount.connect!(user: Current.user, oauth_code: params[:code])
24+
redirect_to account_path, notice: t(".success")
25+
else
26+
redirect_to account_path, alert: t(".already_connected")
27+
end
28+
rescue Strava::Client::ApiError => e
29+
Rails.logger.error("[Strava] Failed to connect: #{e.message}")
30+
redirect_to account_path, alert: t(".error")
31+
end
32+
33+
def destroy
34+
account = Current.user.strava_account
35+
if account
36+
Strava::Client.new.deauthorize(account.access_token) rescue Strava::Client::ApiError
37+
account.destroy
38+
end
39+
redirect_to account_path, notice: t(".disconnected")
40+
end
41+
end
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
class StravaWebhooksController < ActionController::API
2+
def verify
3+
verify_token = ENV["STRAVA_VERIFY_TOKEN"].presence || Rails.application.credentials.dig(:strava, :verify_token)
4+
return head :forbidden if verify_token.blank?
5+
6+
if params["hub.mode"] == "subscribe" && params["hub.verify_token"] == verify_token
7+
render json: { "hub.challenge" => params["hub.challenge"] }, status: :ok
8+
else
9+
head :forbidden
10+
end
11+
end
12+
13+
def receive
14+
# Strava exige que os webhooks respondam com 200 OK dentro de 2 segundos,
15+
# por isso apenas enfileiramos o trabalho e respondemos imediatamente.
16+
Rails.logger.info("[Strava Webhook] Received: aspect=#{params[:aspect_type]}, object=#{params[:object_type]}, owner=#{params[:owner_id]}")
17+
18+
if params[:object_type] == "activity" && params[:aspect_type] == "create"
19+
account = StravaAccount.find_by(strava_athlete_id: params[:owner_id])
20+
21+
if account
22+
StravaSyncJob.perform_later(account.id)
23+
end
24+
end
25+
26+
head :ok
27+
end
28+
end

app/jobs/strava_sync_job.rb

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
class StravaSyncJob < ApplicationJob
2+
queue_as :default
3+
4+
MAX_PAGES = 20 # 20 × 100 = 2000 atividades por sync; dentro do rate limit
5+
6+
def perform(strava_account_id)
7+
account = StravaAccount.find_by(id: strava_account_id)
8+
return unless account
9+
10+
account.syncing!
11+
client = Strava::Client.new
12+
account.refresh_token!
13+
14+
last_date = import_all(account, client)
15+
account.sync_finished!(last_date)
16+
rescue Strava::Client::ApiError => e
17+
account&.sync_failed!(e.message)
18+
raise
19+
end
20+
21+
private
22+
23+
def import_all(account, client)
24+
importer = Strava::ActivityImporter.new(account.user.profile)
25+
after = account.last_synced_at&.to_i
26+
last_processed_date = nil
27+
Rails.logger.info("[StravaSyncJob] Starting sync for account #{account.id} (after: #{after})")
28+
29+
(1..MAX_PAGES).each do |page|
30+
batch = client.activities(account.access_token, after: after, page: page)
31+
Rails.logger.info("[StravaSyncJob] Fetched #{batch.size} activities from API (page #{page})")
32+
33+
batch.each { |payload| importer.import(payload) }
34+
35+
if batch.any?
36+
last_processed_date = Time.iso8601(batch.last["start_date"])
37+
end
38+
39+
if batch.size < 100
40+
last_processed_date = Time.current
41+
break
42+
end
43+
end
44+
Rails.logger.info("[StravaSyncJob] Sync finished for account #{account.id}")
45+
last_processed_date
46+
end
47+
end
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
module Strava
2+
class ActivityImporter
3+
RUN_SPORT_TYPES = %w[Run TrailRun Walk].freeze
4+
5+
def initialize(profile)
6+
@profile = profile
7+
end
8+
9+
# @param payload [Hash] one activity from GET /athlete/activities
10+
# @return [Activity, :duplicate, :skipped]
11+
def import(payload)
12+
external_id = payload["id"].to_s
13+
sport = payload["sport_type"]
14+
15+
unless RUN_SPORT_TYPES.include?(sport)
16+
Rails.logger.info("[StravaImporter] Skipped #{external_id} (unsupported sport: #{sport})")
17+
return :skipped
18+
end
19+
20+
if @profile.activities.exists?(source: "strava", external_id: external_id)
21+
Rails.logger.info("[StravaImporter] Skipped #{external_id} (duplicate)")
22+
return :duplicate
23+
end
24+
25+
begin
26+
Activity.log(
27+
profile: @profile,
28+
started_at: Time.iso8601(payload["start_date"]),
29+
actable: build_run(payload),
30+
name: payload["name"],
31+
location: location_from(payload),
32+
public: false,
33+
source: "strava",
34+
external_id: external_id,
35+
map_polyline: payload.dig("map", "summary_polyline")
36+
)
37+
38+
Rails.logger.info("[StravaImporter] Successfully imported #{sport} #{external_id}")
39+
:imported
40+
rescue ActiveRecord::RecordNotUnique
41+
Rails.logger.info("[StravaImporter] Skipped #{external_id} (concurrent duplicate)")
42+
:duplicate
43+
end
44+
end
45+
46+
private
47+
48+
def build_run(payload)
49+
distance_km = payload["distance"].to_f / 1000.0
50+
duration = payload["moving_time"].to_i
51+
52+
Run.new(
53+
distance: distance_km,
54+
duration: duration,
55+
elevation: payload["total_elevation_gain"],
56+
hr_avg: payload["average_heartrate"],
57+
hr_peak: payload["max_heartrate"],
58+
vo2max: estimate_vo2max(distance_km, duration)
59+
)
60+
end
61+
62+
def estimate_vo2max(distance_km, duration)
63+
return nil if distance_km <= 0 || duration <= 0
64+
65+
Calcpace.new.estimate_vo2max(distance_km, duration)
66+
rescue Calcpace::Error
67+
nil
68+
end
69+
70+
def location_from(payload)
71+
[ payload["location_city"], payload["location_country"] ].compact.join(", ").presence
72+
end
73+
end
74+
end

0 commit comments

Comments
 (0)