Skip to content
Open
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,4 @@ i18n_plan.md
ENVIRONMENTAL_INTEGRATION_PLAN.md
ENVIRONMENTAL_UI_STRATEGY.md
docs/superpowers/
strava_webhook.env
16 changes: 11 additions & 5 deletions app/controllers/strava_webhooks_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,17 @@ def receive
# 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)
if params[:object_type] == "activity"
if %w[create update].include?(params[:aspect_type])
account = StravaAccount.find_by(strava_athlete_id: params[:owner_id])
StravaSyncJob.perform_later(account.id, params[:object_id]) if account
elsif params[:aspect_type] == "delete"
account = StravaAccount.find_by(strava_athlete_id: params[:owner_id])
if account
activity = account.user.profile.activities.find_by(source: "strava", external_id: params[:object_id].to_s)
activity&.destroy
Rails.logger.info("[Strava Webhook] Deleted activity #{params[:object_id]}")
end
end
end

Expand Down
13 changes: 10 additions & 3 deletions app/jobs/strava_sync_job.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,23 @@ class StravaSyncJob < ApplicationJob

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

def perform(strava_account_id)
def perform(strava_account_id, activity_id = nil)
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)
if activity_id.present?
payload = client.activity(account.access_token, activity_id)
importer = Strava::ActivityImporter.new(account.user.profile)
importer.import(payload)
account.sync_finished!(Time.current)
else
last_date = import_all(account, client)
account.sync_finished!(last_date)
end
rescue Strava::Client::ApiError => e
account&.sync_failed!(e.message)
raise
Expand Down
53 changes: 32 additions & 21 deletions app/lib/strava/activity_importer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,26 +17,37 @@ def import(payload)
return :skipped
end

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

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
if activity
activity.transaction do
run_attrs = build_run_attributes(payload)
activity.actable.update!(run_attrs)
activity.update!(
started_at: Time.iso8601(payload["start_date"]),
name: payload["name"],
location: location_from(payload),
map_polyline: payload.dig("map", "summary_polyline")
)
end
Rails.logger.info("[StravaImporter] Successfully updated #{sport} #{external_id}")
:updated
else
Activity.log(
profile: @profile,
started_at: Time.iso8601(payload["start_date"]),
actable: Run.new(build_run_attributes(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
end
rescue ActiveRecord::RecordNotUnique
Rails.logger.info("[StravaImporter] Skipped #{external_id} (concurrent duplicate)")
:duplicate
Expand All @@ -45,18 +56,18 @@ def import(payload)

private

def build_run(payload)
def build_run_attributes(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)
Expand Down
4 changes: 4 additions & 0 deletions app/lib/strava/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ def activities(access_token, after: nil, page: 1, per_page: 100)
get("/api/v3/athlete/activities", access_token, query)
end

def activity(access_token, id)
get("/api/v3/activities/#{id}", access_token, {})
end

def deauthorize(access_token)
post("/oauth/deauthorize", access_token: access_token)
end
Expand Down
17 changes: 17 additions & 0 deletions scripts/register_strava_webhook.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
#!/bin/bash

# Carrega as variáveis do arquivo .env
source strava_webhook.env

echo "Registrando Webhook no Strava..."
echo "Client ID: $STRAVA_CLIENT_ID"
echo "Callback URL: $STRAVA_CALLBACK_URL"

curl -X POST https://www.strava.com/api/v3/push_subscriptions \
-F client_id="$STRAVA_CLIENT_ID" \
-F client_secret="$STRAVA_CLIENT_SECRET" \
-F callback_url="$STRAVA_CALLBACK_URL" \
-F verify_token="$STRAVA_VERIFY_TOKEN"

echo ""
echo "Concluído!"
29 changes: 25 additions & 4 deletions test/controllers/strava_webhooks_controller_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,8 @@ class StravaWebhooksControllerTest < ActionDispatch::IntegrationTest
assert_response :forbidden
end

test "POST receive enqueues sync job for activity creation" do
assert_enqueued_with(job: StravaSyncJob, args: [ @account.id ]) do
test "POST receive enqueues sync job for activity creation with object_id" do
assert_enqueued_with(job: StravaSyncJob, args: [ @account.id, 123456 ]) do
post webhooks_strava_url, params: {
"object_type" => "activity",
"aspect_type" => "create",
Expand All @@ -65,8 +65,8 @@ class StravaWebhooksControllerTest < ActionDispatch::IntegrationTest
assert_response :success
end

test "POST receive ignores other aspect types but returns 200" do
assert_no_enqueued_jobs do
test "POST receive enqueues sync job for activity update with object_id" do
assert_enqueued_with(job: StravaSyncJob, args: [ @account.id, 123456 ]) do
post webhooks_strava_url, params: {
"object_type" => "activity",
"aspect_type" => "update",
Expand All @@ -78,6 +78,27 @@ class StravaWebhooksControllerTest < ActionDispatch::IntegrationTest
assert_response :success
end

test "POST receive destroys activity for activity delete" do
activity = @account.user.profile.activities.create!(
started_at: Time.current,
actable: Run.new(distance: 5, duration: 1800),
source: "strava",
external_id: "123456"
)

assert_difference -> { Activity.count }, -1 do
post webhooks_strava_url, params: {
"object_type" => "activity",
"aspect_type" => "delete",
"owner_id" => 111,
"object_id" => 123456
}, as: :json
end

assert_response :success
assert_not Activity.exists?(activity.id)
end

test "POST receive ignores unknown athletes but returns 200" do
assert_no_enqueued_jobs do
post webhooks_strava_url, params: {
Expand Down
12 changes: 12 additions & 0 deletions test/jobs/strava_sync_job_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,18 @@
assert_requested stub
end

test "fetches specific activity if activity_id is provided" do
stub = stub_request(:get, "https://www.strava.com/api/v3/activities/12345")
.with(headers: { "Authorization" => "Bearer at" })
.to_return(status: 200, headers: { "Content-Type" => "application/json" }, body: run_payload(id: 12345).to_json)

StravaSyncJob.perform_now(@account.id, 12345)

Check failure on line 68 in test/jobs/strava_sync_job_test.rb

View workflow job for this annotation

GitHub Actions / lint

Layout/TrailingWhitespace: Trailing whitespace detected.
assert_requested stub
assert_equal 1, @user.profile.activities.where(source: "strava", external_id: "12345").count
assert_equal "idle", @account.reload.sync_status
end

private

def run_payload(id:)
Expand Down
17 changes: 14 additions & 3 deletions test/lib/strava/activity_importer_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -38,14 +38,25 @@ class Strava::ActivityImporterTest < ActiveSupport::TestCase
assert_not_nil run.vo2max
end

test "skips duplicates by external_id" do
test "updates existing activity by external_id" do
importer = Strava::ActivityImporter.new(@profile)
importer.import(@payload)

updated_payload = @payload.merge(
"name" => "Evening Run",
"distance" => 12_000.0,
"moving_time" => 3000
)

assert_no_difference "Activity.count" do
result = importer.import(@payload)
assert_equal :duplicate, result
result = importer.import(updated_payload)
assert_equal :updated, result
end

activity = Activity.find_by!(source: "strava", external_id: "987654")
assert_equal "Evening Run", activity.name
assert_in_delta 12.0, activity.actable.distance.to_f, 0.001
assert_equal 3000, activity.actable.duration
end

test "skips non-run sports" do
Expand Down
Loading