Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ jobs:
env:
RAILS_ENV: test
DATABASE_URL: postgres://postgres:postgres@localhost:5432
# RAILS_MASTER_KEY: ${{ secrets.RAILS_MASTER_KEY }}
RAILS_MASTER_KEY: ${{ secrets.RAILS_MASTER_KEY }}
# REDIS_URL: redis://localhost:6379/0
run: bin/rails db:test:prepare test

Expand Down Expand Up @@ -139,7 +139,7 @@ jobs:
env:
RAILS_ENV: test
DATABASE_URL: postgres://postgres:postgres@localhost:5432
# RAILS_MASTER_KEY: ${{ secrets.RAILS_MASTER_KEY }}
RAILS_MASTER_KEY: ${{ secrets.RAILS_MASTER_KEY }}
# REDIS_URL: redis://localhost:6379/0
run: bin/rails db:test:prepare test:system

Expand Down
1 change: 1 addition & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ group :test do
# Use system testing [https://guides.rubyonrails.org/testing.html#system-testing]
gem "capybara"
gem "selenium-webdriver"
gem "webmock"
end

gem "rails-i18n", "~> 8.1"
22 changes: 17 additions & 5 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,9 @@ GEM
unaccent (~> 0.3)
country_select (11.0.0)
countries (> 6.0, < 9.0)
crack (1.0.1)
bigdecimal
rexml
crass (1.0.7)
csv (3.3.5)
date (3.5.1)
Expand Down Expand Up @@ -165,6 +168,7 @@ GEM
csv
nokogiri (~> 1.7)
rake
hashdiff (1.2.1)
highline (3.1.2)
reline
httparty (0.24.2)
Expand Down Expand Up @@ -226,7 +230,7 @@ GEM
launchy (>= 2.2, < 4)
lint_roller (1.1.0)
logger (1.7.0)
loofah (2.25.1)
loofah (2.25.2)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
mail (2.9.0)
Expand Down Expand Up @@ -341,8 +345,8 @@ GEM
activesupport (>= 5.0.0)
minitest
nokogiri (>= 1.6)
rails-html-sanitizer (1.7.0)
loofah (~> 2.25)
rails-html-sanitizer (1.7.1)
loofah (~> 2.25, >= 2.25.2)
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)
rails-i18n (8.1.0)
i18n (>= 0.7, < 2)
Expand Down Expand Up @@ -470,6 +474,10 @@ GEM
actionview (>= 8.0.0)
bindex (>= 0.4.0)
railties (>= 8.0.0)
webmock (3.26.2)
addressable (>= 2.8.0)
crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0)
websocket (1.2.11)
websocket-driver (0.8.2)
base64
Expand Down Expand Up @@ -531,6 +539,7 @@ DEPENDENCIES
tzinfo-data
view_component
web-console
webmock

CHECKSUMS
action_text-trix (2.1.19) sha256=7012f59421009cf284aa651294896414d653a61a2417c9b8714c8476d2f74009
Expand Down Expand Up @@ -571,6 +580,7 @@ CHECKSUMS
connection_pool (3.0.2) sha256=33fff5ba71a12d2aa26cb72b1db8bba2a1a01823559fb01d29eb74c286e62e0a
countries (8.1.0) sha256=4d6b318b8e906f1f769d5c021c13a418d33e917dc96ceb625a91d8e7ab2d192e
country_select (11.0.0) sha256=0ab0a385fa70eadc71473af4b21b9b4d09f75660cc1c282a5bf6ec873719204b
crack (1.0.1) sha256=ff4a10390cd31d66440b7524eb1841874db86201d5b70032028553130b6d4c7e
crass (1.0.7) sha256=94868719948664c89ddcaf0a37c65048413dfcb1c869470a5f7a7ceb5390b295
csv (3.3.5) sha256=6e5134ac3383ef728b7f02725d9872934f523cb40b961479f69cf3afa6c8e73f
date (3.5.1) sha256=750d06384d7b9c15d562c76291407d89e368dda4d4fff957eb94962d325a0dc0
Expand All @@ -590,6 +600,7 @@ CHECKSUMS
fugit (1.12.2) sha256=643f2bf28db263bd400cbf8e0dd8b76b2c9b94bdb130e12d2394de04d9c20e5e
globalid (1.3.0) sha256=05c639ad6eb4594522a0b07983022f04aa7254626ab69445a0e493aa3786ff11
gpx (1.2.2) sha256=78a1766381c9a6286b83495ecf08bb7362d4990c4f88fd7500925123fde4e51a
hashdiff (1.2.1) sha256=9c079dbc513dfc8833ab59c0c2d8f230fa28499cc5efb4b8dd276cf931457cd1
highline (3.1.2) sha256=67cbd34d19f6ef11a7ee1d82ffab5d36dfd5b3be861f450fc1716c7125f4bb4a
httparty (0.24.2) sha256=8fca6a54aa0c4aa4303a0fd33e5e2156175d6a5334f714263b458abd7fda9c38
i18n (1.15.2) sha256=00f9eb62412fe593b2a65a97daa75300d37abb8f7202ec748e94b6d46a9dd1b5
Expand All @@ -608,7 +619,7 @@ CHECKSUMS
letter_opener (1.10.0) sha256=2ff33f2e3b5c3c26d1959be54b395c086ca6d44826e8bf41a14ff96fdf1bdbb2
lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87
logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203
loofah (2.25.1) sha256=d436c73dbd0c1147b16c4a41db097942d217303e1f7728704b37e4df9f6d2e04
loofah (2.25.2) sha256=2007f746959ac65552456e04b433e83deb22759ab38c838b4445c70e43425918
mail (2.9.0) sha256=6fa6673ecd71c60c2d996260f9ee3dd387d4673b8169b502134659ece6d34941
marcel (1.2.1) sha256=1678e9360e32f9eafa917c80029e2f6d10b2715c66a4b87b6d0da9b9cd1f859f
matrix (0.4.3) sha256=a0d5ab7ddcc1973ff690ab361b67f359acbb16958d1dc072b8b956a286564c5b
Expand Down Expand Up @@ -657,7 +668,7 @@ CHECKSUMS
rackup (2.3.1) sha256=6c79c26753778e90983761d677a48937ee3192b3ffef6bc963c0950f94688868
rails (8.1.3) sha256=6d017ba5348c98fc909753a8169b21d44de14d2a0b92d140d1a966834c3c9cd3
rails-dom-testing (2.3.0) sha256=8acc7953a7b911ca44588bf08737bc16719f431a1cc3091a292bca7317925c1d
rails-html-sanitizer (1.7.0) sha256=28b145cceaf9cc214a9874feaa183c3acba036c9592b19886e0e45efc62b1e89
rails-html-sanitizer (1.7.1) sha256=e797a7c9b01e567307e317c576b49ab4168017e63eea4dba9ce3cb587e2f22c2
rails-i18n (8.1.0) sha256=52d5fd6c0abef28d84223cc05647f6ae0fd552637a1ede92deee9545755b6cf3
railties (8.1.3) sha256=913eb0e0cb520aac687ffd74916bd726d48fa21f47833c6292576ef6a286de22
rainbow (3.1.1) sha256=039491aa3a89f42efa1d6dec2fc4e62ede96eb6acd95e52f1ad581182b79bc6a
Expand Down Expand Up @@ -705,6 +716,7 @@ CHECKSUMS
useragent (0.16.11) sha256=700e6413ad4bb954bb63547fa098dddf7b0ebe75b40cc6f93b8d54255b173844
view_component (4.12.0) sha256=b9a5979d1c43eba2aa21a06a86f661aab3990104855f2909ef7c3779acdcdcb4
web-console (4.3.0) sha256=e13b71301cdfc2093f155b5aa3a622db80b4672d1f2f713119cc7ec7ac6a6da4
webmock (3.26.2) sha256=774556f2ea6371846cca68c01769b2eac0d134492d21f6d0ab5dd643965a4c90
websocket (1.2.11) sha256=b7e7a74e2410b5e85c25858b26b3322f29161e300935f70a0e0d3c35e0462737
websocket-driver (0.8.2) sha256=97c556b019bf3410b4961002ac501621e9322d3f8a7bc02161a09301cc4c4146
websocket-extensions (0.1.5) sha256=1c6ba63092cda343eb53fc657110c71c754c56484aad42578495227d717a8241
Expand Down
41 changes: 41 additions & 0 deletions app/controllers/strava_accounts_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
class StravaAccountsController < ApplicationController
before_action :require_authentication

def create
state = SecureRandom.hex(16)
session[:strava_oauth_state] = state
client = Strava::Client.new
redirect_to client.authorize_url(redirect_uri: callback_strava_account_url, state: state), allow_other_host: true
end

def callback
if params[:state].blank? || params[:state] != session[:strava_oauth_state]
redirect_to account_path, alert: "Invalid OAuth state. Please try again."
return
end
session.delete(:strava_oauth_state)

if params[:error] == "access_denied"
redirect_to account_path, alert: t(".access_denied")
return
end

if StravaAccount.connect!(user: Current.user, oauth_code: params[:code])
redirect_to account_path, notice: t(".success")
else
redirect_to account_path, alert: t(".already_connected")
end
rescue Strava::Client::ApiError => e
Rails.logger.error("[Strava] Failed to connect: #{e.message}")
redirect_to account_path, alert: t(".error")
end

def destroy
account = Current.user.strava_account
if account
Strava::Client.new.deauthorize(account.access_token) rescue Strava::Client::ApiError
account.destroy
end
redirect_to account_path, notice: t(".disconnected")
end
end
28 changes: 28 additions & 0 deletions app/controllers/strava_webhooks_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
class StravaWebhooksController < ActionController::API
def verify
verify_token = ENV["STRAVA_VERIFY_TOKEN"].presence || Rails.application.credentials.dig(:strava, :verify_token)
return head :forbidden if verify_token.blank?

if params["hub.mode"] == "subscribe" && params["hub.verify_token"] == verify_token
render json: { "hub.challenge" => params["hub.challenge"] }, status: :ok
else
head :forbidden
end
end

def receive
# Strava exige que os webhooks respondam com 200 OK dentro de 2 segundos,
# por isso apenas enfileiramos o trabalho e respondemos imediatamente.
Rails.logger.info("[Strava Webhook] Received: aspect=#{params[:aspect_type]}, object=#{params[:object_type]}, owner=#{params[:owner_id]}")

if params[:object_type] == "activity" && params[:aspect_type] == "create"
account = StravaAccount.find_by(strava_athlete_id: params[:owner_id])

if account
StravaSyncJob.perform_later(account.id)
end
end

head :ok
end
end
47 changes: 47 additions & 0 deletions app/jobs/strava_sync_job.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
class StravaSyncJob < ApplicationJob
queue_as :default

MAX_PAGES = 20 # 20 × 100 = 2000 atividades por sync; dentro do rate limit

def perform(strava_account_id)
account = StravaAccount.find_by(id: strava_account_id)
return unless account

account.syncing!
client = Strava::Client.new
account.refresh_token!

last_date = import_all(account, client)
account.sync_finished!(last_date)
rescue Strava::Client::ApiError => e
account&.sync_failed!(e.message)
raise
end

private

def import_all(account, client)
importer = Strava::ActivityImporter.new(account.user.profile)
after = account.last_synced_at&.to_i
last_processed_date = nil
Rails.logger.info("[StravaSyncJob] Starting sync for account #{account.id} (after: #{after})")

(1..MAX_PAGES).each do |page|
batch = client.activities(account.access_token, after: after, page: page)
Rails.logger.info("[StravaSyncJob] Fetched #{batch.size} activities from API (page #{page})")

batch.each { |payload| importer.import(payload) }

if batch.any?
last_processed_date = Time.iso8601(batch.last["start_date"])
end

if batch.size < 100
last_processed_date = Time.current
break
end
end
Rails.logger.info("[StravaSyncJob] Sync finished for account #{account.id}")
last_processed_date
end
end
74 changes: 74 additions & 0 deletions app/lib/strava/activity_importer.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
module Strava
class ActivityImporter
RUN_SPORT_TYPES = %w[Run TrailRun Walk].freeze

def initialize(profile)
@profile = profile
end

# @param payload [Hash] one activity from GET /athlete/activities
# @return [Activity, :duplicate, :skipped]
def import(payload)
external_id = payload["id"].to_s
sport = payload["sport_type"]

unless RUN_SPORT_TYPES.include?(sport)
Rails.logger.info("[StravaImporter] Skipped #{external_id} (unsupported sport: #{sport})")
return :skipped
end

if @profile.activities.exists?(source: "strava", external_id: external_id)
Rails.logger.info("[StravaImporter] Skipped #{external_id} (duplicate)")
return :duplicate
end

begin
Activity.log(
profile: @profile,
started_at: Time.iso8601(payload["start_date"]),
actable: build_run(payload),
name: payload["name"],
location: location_from(payload),
public: false,
source: "strava",
external_id: external_id,
map_polyline: payload.dig("map", "summary_polyline")
)

Rails.logger.info("[StravaImporter] Successfully imported #{sport} #{external_id}")
:imported
rescue ActiveRecord::RecordNotUnique
Rails.logger.info("[StravaImporter] Skipped #{external_id} (concurrent duplicate)")
:duplicate
end
end

private

def build_run(payload)
distance_km = payload["distance"].to_f / 1000.0
duration = payload["moving_time"].to_i

Run.new(
distance: distance_km,
duration: duration,
elevation: payload["total_elevation_gain"],
hr_avg: payload["average_heartrate"],
hr_peak: payload["max_heartrate"],
vo2max: estimate_vo2max(distance_km, duration)
)
end

def estimate_vo2max(distance_km, duration)
return nil if distance_km <= 0 || duration <= 0

Calcpace.new.estimate_vo2max(distance_km, duration)
rescue Calcpace::Error
nil
end

def location_from(payload)
[ payload["location_city"], payload["location_country"] ].compact.join(", ").presence
end
end
end
Loading