Skip to content
Draft
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
2 changes: 1 addition & 1 deletion .ruby-version
Original file line number Diff line number Diff line change
@@ -1 +1 @@
3.4.6
3.4.8
4 changes: 3 additions & 1 deletion Gemfile
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
source "https://rubygems.org"

ruby "3.4.6"
ruby file: ".ruby-version"

# Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main"
gem "rails", "~> 8.0.3"
Expand All @@ -20,6 +20,8 @@ gem "stimulus-rails"
gem "jbuilder"
# API wrapper for Strava
gem "strava-ruby-client"
# Google Calendar API
gem "google-apis-calendar_v3"
# Vector support for SQLite
gem "sqlite-vec"
# OpenAI wrapper
Expand Down
42 changes: 41 additions & 1 deletion Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ GEM
debug (1.11.0)
irb (~> 1.10)
reline (>= 0.3.8)
declarative (0.0.20)
dotenv (3.1.8)
drb (2.2.3)
ed25519 (1.4.0)
Expand All @@ -117,6 +118,8 @@ GEM
faraday-net_http (>= 2.0, < 3.5)
json
logger
faraday-follow_redirects (0.5.0)
faraday (>= 1, < 3)
faraday-multipart (1.1.1)
multipart-post (~> 2.0)
faraday-net_http (3.4.1)
Expand All @@ -130,6 +133,28 @@ GEM
raabro (~> 1.4)
globalid (1.3.0)
activesupport (>= 6.1)
google-apis-calendar_v3 (0.52.0)
google-apis-core (>= 0.15.0, < 2.a)
google-apis-core (1.0.2)
addressable (~> 2.8, >= 2.8.7)
faraday (~> 2.13)
faraday-follow_redirects (~> 0.3)
googleauth (~> 1.14)
mini_mime (~> 1.1)
representable (~> 3.0)
retriable (~> 3.1)
google-cloud-env (2.3.1)
base64 (~> 0.2)
faraday (>= 1.0, < 3.a)
google-logging-utils (0.2.0)
googleauth (1.16.2)
faraday (>= 1.0, < 3.a)
google-cloud-env (~> 2.2)
google-logging-utils (~> 0.1)
jwt (>= 1.4, < 4.0)
multi_json (~> 1.11)
os (>= 0.9, < 2.0)
signet (>= 0.16, < 2.a)
hashdiff (1.2.0)
hashie (5.0.0)
i18n (1.14.7)
Expand Down Expand Up @@ -193,6 +218,7 @@ GEM
mini_mime (1.1.5)
minitest (5.25.5)
msgpack (1.8.0)
multi_json (1.19.1)
multi_xml (0.7.2)
bigdecimal (~> 3.1)
multipart-post (2.4.1)
Expand Down Expand Up @@ -242,6 +268,7 @@ GEM
omniauth-rails_csrf_protection (1.0.2)
actionpack (>= 4.2)
omniauth (~> 2.0)
os (1.1.4)
ostruct (0.6.3)
parallel (1.27.0)
parser (3.3.9.0)
Expand Down Expand Up @@ -314,6 +341,11 @@ GEM
regexp_parser (2.10.0)
reline (0.6.2)
io-console (~> 0.5)
representable (3.2.0)
declarative (< 0.1.0)
trailblazer-option (>= 0.1.1, < 0.2.0)
uber (< 0.2.0)
retriable (3.2.1)
rexml (3.4.1)
rubocop (1.79.0)
json (~> 2.3)
Expand Down Expand Up @@ -369,6 +401,11 @@ GEM
rexml (~> 3.2, >= 3.2.5)
rubyzip (>= 1.2.2, < 3.0)
websocket (~> 1.0)
signet (0.21.0)
addressable (~> 2.8)
faraday (>= 0.17.5, < 3.a)
jwt (>= 1.5, < 4.0)
multi_json (~> 1.10)
snaky_hash (2.0.3)
hashie (>= 0.1.0, < 6)
version_gem (>= 1.1.8, < 3)
Expand Down Expand Up @@ -416,12 +453,14 @@ GEM
thruster (0.1.14-arm64-darwin)
thruster (0.1.14-x86_64-linux)
timeout (0.4.3)
trailblazer-option (0.1.2)
tsort (0.2.0)
turbo-rails (2.0.16)
actionpack (>= 7.1.0)
railties (>= 7.1.0)
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
uber (0.1.0)
unicode-display_width (3.1.4)
unicode-emoji (~> 4.0, >= 4.0.4)
unicode-emoji (4.0.4)
Expand Down Expand Up @@ -458,6 +497,7 @@ DEPENDENCIES
brakeman
capybara
debug
google-apis-calendar_v3
image_processing (~> 1.2)
importmap-rails
jbuilder
Expand Down Expand Up @@ -491,7 +531,7 @@ DEPENDENCIES
webmock

RUBY VERSION
ruby 3.4.6p54
ruby 3.4.8p72

BUNDLED WITH
2.6.9
16 changes: 15 additions & 1 deletion app/controllers/plans_controller.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
class PlansController < ApplicationController
before_action :set_plan, only: %i[ show edit update destroy processing_status edit_workouts update_workouts create_blank_schedule ]
before_action :set_plan, only: %i[ show edit update destroy processing_status edit_workouts update_workouts create_blank_schedule toggle_calendar_sync ]

# GET /plans or /plans.json
def index
Expand Down Expand Up @@ -108,6 +108,20 @@ def processing_status
end
end

# PATCH /plans/1/toggle_calendar_sync
def toggle_calendar_sync
unless Current.user.google_calendar_ready?
redirect_to @plan, alert: "Please connect your Google account first to enable calendar sync."
return
end

new_state = !@plan.calendar_sync_enabled?
@plan.update!(calendar_sync_enabled: new_state)

notice = new_state ? "Calendar sync enabled. Your workouts will be synced to Google Calendar." : "Calendar sync disabled."
redirect_to @plan, notice: notice
end

private
# Use callbacks to share common setup or constraints between actions.
def set_plan
Expand Down
20 changes: 20 additions & 0 deletions app/jobs/delete_calendar_event_job.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
class DeleteCalendarEventJob < ApplicationJob
queue_as :default

def perform(user_id, calendar_id, event_id)
user = User.find(user_id)

return unless user.has_google_access_token?
return if calendar_id.blank? || event_id.blank?

service = GoogleCalendarService.new(user)
service.delete_event(calendar_id, event_id)

Rails.logger.info "Deleted calendar event #{event_id}"
rescue GoogleCalendarService::AuthenticationError => e
Rails.logger.error "Google auth error deleting calendar event #{event_id}: #{e.message}"
rescue StandardError => e
Rails.logger.error "Error deleting calendar event #{event_id}: #{e.message}"
raise e
end
end
23 changes: 23 additions & 0 deletions app/jobs/remove_plan_calendar_events_job.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
class RemovePlanCalendarEventsJob < ApplicationJob
queue_as :default

def perform(plan_id)
plan = Plan.find(plan_id)
user = plan.user

unless user.has_google_access_token?
Rails.logger.warn "User #{user.id} has no Google credentials, skipping calendar event removal"
return
end

service = GoogleCalendarService.new(user)
service.remove_plan_events(plan)

Rails.logger.info "Removed calendar events for plan #{plan_id}"
rescue GoogleCalendarService::AuthenticationError => e
Rails.logger.error "Google auth error removing calendar events for plan #{plan_id}: #{e.message}"
rescue StandardError => e
Rails.logger.error "Error removing calendar events for plan #{plan_id}: #{e.message}"
raise e
end
end
28 changes: 28 additions & 0 deletions app/jobs/sync_plan_to_calendar_job.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
class SyncPlanToCalendarJob < ApplicationJob
queue_as :default

def perform(plan_id)
plan = Plan.find(plan_id)
user = plan.user

unless user.has_google_access_token?
Rails.logger.warn "User #{user.id} has no Google credentials, skipping calendar sync"
return
end

unless plan.calendar_sync_enabled?
Rails.logger.info "Calendar sync not enabled for plan #{plan.id}, skipping"
return
end

service = GoogleCalendarService.new(user)
service.sync_plan(plan)

Rails.logger.info "Successfully synced plan #{plan.id} to Google Calendar"
rescue GoogleCalendarService::AuthenticationError => e
Rails.logger.error "Google auth error syncing plan #{plan_id}: #{e.message}"
rescue StandardError => e
Rails.logger.error "Error syncing plan #{plan_id} to calendar: #{e.message}"
raise e
end
end
23 changes: 23 additions & 0 deletions app/jobs/update_calendar_event_job.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
class UpdateCalendarEventJob < ApplicationJob
queue_as :default

def perform(activity_id)
activity = Activity.find(activity_id)
plan = activity.plan

return unless plan&.calendar_sync_enabled?

user = plan.user
return unless user.has_google_access_token?

service = GoogleCalendarService.new(user)
service.update_event(activity)

Rails.logger.info "Updated calendar event for activity #{activity_id}"
rescue GoogleCalendarService::AuthenticationError => e
Rails.logger.error "Google auth error updating calendar event for activity #{activity_id}: #{e.message}"
rescue StandardError => e
Rails.logger.error "Error updating calendar event for activity #{activity_id}: #{e.message}"
raise e
end
end
27 changes: 27 additions & 0 deletions app/models/activity.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,35 @@ class Activity < ApplicationRecord
belongs_to :user, optional: true
has_many :strava_activities, dependent: :destroy

after_update :sync_calendar_event, if: :calendar_sync_needed?
before_destroy :enqueue_calendar_event_deletion

# Returns the first matched or linked StravaActivity, if any
def matched_strava_activity
strava_activities.where(match_status: %w[matched linked]).first
end

private

def calendar_sync_needed?
return false unless plan&.calendar_sync_enabled?
return false unless plan&.user&.has_google_access_token?

saved_change_to_distance? || saved_change_to_description? || saved_change_to_start_date_local?
end

def sync_calendar_event
UpdateCalendarEventJob.perform_later(id)
end

def enqueue_calendar_event_deletion
return unless google_calendar_event_id.present?
return unless plan&.calendar_sync_enabled?

user = plan&.user
return unless user&.has_google_access_token?
return unless user&.google_calendar_id.present?

DeleteCalendarEventJob.perform_later(user.id, user.google_calendar_id, google_calendar_event_id)
end
end
13 changes: 11 additions & 2 deletions app/models/plan.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
class Plan < ApplicationRecord
after_create :process_uploaded_photos
after_update :sync_calendar_if_enabled

enum :plan_type, { template: "template", custom: "custom" }
enum :processing_status, { idle: "idle", queued: "queued", processing: "processing", completed: "completed", failed: "failed" }
Expand Down Expand Up @@ -30,13 +31,21 @@ def photos_are_images
end
end

private

def process_uploaded_photos
# Note that right now this is being called for all after_create calls
return unless photos.attached?

job = PlanPhotoProcessorJob.perform_later(self)
update!(processing_status: "queued", job_id: job.job_id)
end

def sync_calendar_if_enabled
return unless saved_change_to_calendar_sync_enabled?

if calendar_sync_enabled?
SyncPlanToCalendarJob.perform_later(id)
else
RemovePlanCalendarEventsJob.perform_later(id)
end
end
end
16 changes: 16 additions & 0 deletions app/models/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,22 @@ def strava_connected?
strava_id.present?
end

def google_connected?
google_uid.present?
end

def google_calendar_ready?
google_connected? && has_google_access_token?
end

def has_google_access_token?
# Use parameterized SQL to check for token presence without triggering ActiveRecord encryption
sql = self.class.sanitize_sql_array(
[ "SELECT 1 FROM users WHERE id = ? AND google_access_token IS NOT NULL AND google_access_token != ''", id ]
)
self.class.connection.select_value(sql).present?
end

def link_strava!(auth)
update!(
strava_id: auth.uid,
Expand Down
Loading