diff --git a/.gitignore b/.gitignore index a2e8b4b..74f8ba9 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,4 @@ i18n_plan.md ENVIRONMENTAL_INTEGRATION_PLAN.md ENVIRONMENTAL_UI_STRATEGY.md docs/superpowers/ +strava_webhook.env diff --git a/app/controllers/strava_webhooks_controller.rb b/app/controllers/strava_webhooks_controller.rb index f613937..62a4163 100644 --- a/app/controllers/strava_webhooks_controller.rb +++ b/app/controllers/strava_webhooks_controller.rb @@ -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 diff --git a/app/jobs/strava_sync_job.rb b/app/jobs/strava_sync_job.rb index 8b13ade..fe0a152 100644 --- a/app/jobs/strava_sync_job.rb +++ b/app/jobs/strava_sync_job.rb @@ -3,7 +3,7 @@ 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 @@ -11,8 +11,15 @@ def perform(strava_account_id) 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 diff --git a/app/lib/strava/activity_importer.rb b/app/lib/strava/activity_importer.rb index 814e4df..8b8f33d 100644 --- a/app/lib/strava/activity_importer.rb +++ b/app/lib/strava/activity_importer.rb @@ -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 @@ -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) diff --git a/app/lib/strava/client.rb b/app/lib/strava/client.rb index 0d8009e..41eb5f6 100644 --- a/app/lib/strava/client.rb +++ b/app/lib/strava/client.rb @@ -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 diff --git a/scripts/register_strava_webhook.sh b/scripts/register_strava_webhook.sh new file mode 100755 index 0000000..d109a6e --- /dev/null +++ b/scripts/register_strava_webhook.sh @@ -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!" diff --git a/test/controllers/strava_webhooks_controller_test.rb b/test/controllers/strava_webhooks_controller_test.rb index 52302aa..1529a73 100644 --- a/test/controllers/strava_webhooks_controller_test.rb +++ b/test/controllers/strava_webhooks_controller_test.rb @@ -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", @@ -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", @@ -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: { diff --git a/test/jobs/strava_sync_job_test.rb b/test/jobs/strava_sync_job_test.rb index c3ef861..6205d86 100644 --- a/test/jobs/strava_sync_job_test.rb +++ b/test/jobs/strava_sync_job_test.rb @@ -59,6 +59,18 @@ class StravaSyncJobTest < ActiveJob::TestCase 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) + + 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:) diff --git a/test/lib/strava/activity_importer_test.rb b/test/lib/strava/activity_importer_test.rb index 816df8b..248da44 100644 --- a/test/lib/strava/activity_importer_test.rb +++ b/test/lib/strava/activity_importer_test.rb @@ -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