Skip to content

Commit 1d2f7a0

Browse files
authored
fix: stop orphaning google calendar events on schedule re-upload (#501)
1 parent 296a5e5 commit 1d2f7a0

9 files changed

Lines changed: 464 additions & 5 deletions
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
# frozen_string_literal: true
2+
3+
# Deletes events that exist in a user's Google Calendar but have no tracking
4+
# row in google_calendar_events. These orphans were left behind when tracking
5+
# rows were cascade-destroyed (e.g. meeting times destroyed on schedule
6+
# re-upload) without deleting the real events, so every subsequent sync
7+
# created a duplicate next to them.
8+
#
9+
# The app owns these calendars end-to-end, so any untracked event is one we
10+
# created and lost the pointer to. Modified instances of a tracked recurring
11+
# series are preserved (user-edited occurrences carry their own event id).
12+
#
13+
# Run with dry_run: true first to log what would be deleted without deleting.
14+
class CleanupUntrackedGoogleEventsJob < ApplicationJob
15+
include GoogleApiRateLimiter
16+
17+
queue_as :low
18+
19+
def perform(google_calendar_id = nil, dry_run: false)
20+
calendars = if google_calendar_id
21+
GoogleCalendar.where(id: google_calendar_id)
22+
else
23+
GoogleCalendar.all
24+
end
25+
26+
totals = { scanned: 0, deleted: 0, errors: 0 }
27+
28+
calendars.find_each do |calendar|
29+
stats = reconcile_calendar(calendar, dry_run: dry_run)
30+
totals.each_key { |key| totals[key] += stats[key] }
31+
rescue => e
32+
totals[:errors] += 1
33+
Rails.logger.error "[CleanupUntrackedGoogleEventsJob] Failed for calendar #{calendar.id}: #{e.message}"
34+
end
35+
36+
Rails.logger.info "[CleanupUntrackedGoogleEventsJob] Completed (dry_run: #{dry_run}): #{totals.to_json}"
37+
totals
38+
end
39+
40+
private
41+
42+
def reconcile_calendar(calendar, dry_run:)
43+
stats = { scanned: 0, deleted: 0, errors: 0 }
44+
tracked_ids = calendar.google_calendar_events.pluck(:google_event_id).to_set
45+
46+
untracked = []
47+
page_token = nil
48+
49+
loop do
50+
response = with_rate_limit_handling do
51+
calendar_service.list_events(
52+
calendar.google_calendar_id,
53+
max_results: 2500,
54+
page_token: page_token,
55+
show_deleted: false
56+
)
57+
end
58+
59+
Array(response.items).each do |event|
60+
stats[:scanned] += 1
61+
next if event.status == "cancelled"
62+
next if tracked_ids.include?(event.id)
63+
# A user-edited occurrence of a tracked recurring series gets its own
64+
# event id; deleting it would wipe the user's edit.
65+
next if event.recurring_event_id.present? && tracked_ids.include?(event.recurring_event_id)
66+
67+
untracked << event
68+
end
69+
70+
page_token = response.next_page_token
71+
break if page_token.blank?
72+
end
73+
74+
if dry_run
75+
untracked.each do |event|
76+
Rails.logger.info "[CleanupUntrackedGoogleEventsJob] [dry run] Would delete #{event.id} " \
77+
"(#{event.summary.inspect}) from calendar #{calendar.id}"
78+
end
79+
stats[:deleted] = untracked.size
80+
return stats
81+
end
82+
83+
with_batch_throttling(untracked) do |event|
84+
delete_untracked_event(calendar, event)
85+
stats[:deleted] += 1
86+
rescue Google::Apis::Error => e
87+
stats[:errors] += 1
88+
Rails.logger.error "[CleanupUntrackedGoogleEventsJob] Failed to delete #{event.id} " \
89+
"from calendar #{calendar.id}: #{e.message}"
90+
end
91+
92+
Rails.logger.info "[CleanupUntrackedGoogleEventsJob] Calendar #{calendar.id}: " \
93+
"#{stats[:deleted]} untracked events deleted (#{stats[:scanned]} scanned)"
94+
stats
95+
end
96+
97+
def delete_untracked_event(calendar, event)
98+
calendar_service.delete_event(calendar.google_calendar_id, event.id)
99+
Rails.logger.info "[CleanupUntrackedGoogleEventsJob] Deleted #{event.id} " \
100+
"(#{event.summary.inspect}) from calendar #{calendar.id}"
101+
rescue Google::Apis::ClientError => e
102+
raise unless e.status_code == 404
103+
end
104+
105+
# The service account created and owns every app-managed calendar, so it can
106+
# list and delete regardless of the state of the user's OAuth tokens.
107+
def calendar_service
108+
@calendar_service ||= begin
109+
service = Google::Apis::CalendarV3::CalendarService.new
110+
service.authorization = service_account_credentials
111+
service
112+
end
113+
end
114+
115+
def service_account_credentials
116+
service_account_config = Rails.application.credentials.dig(:google, :service_account)
117+
118+
credentials_json = if service_account_config.is_a?(String)
119+
service_account_config
120+
else
121+
service_account_config.to_json
122+
end
123+
124+
Google::Auth::ServiceAccountCredentials.make_creds(
125+
json_key_io: StringIO.new(credentials_json),
126+
scope: Google::Apis::CalendarV3::AUTH_CALENDAR
127+
)
128+
end
129+
end

app/models/course/meeting_time.rb

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,10 @@ class Course::MeetingTime < ApplicationRecord
3838
has_many :meeting_time_rooms, class_name: "Course::MeetingTimeRoom",
3939
foreign_key: :meeting_time_id, dependent: :destroy, inverse_of: :meeting_time
4040
has_many :rooms, through: :meeting_time_rooms
41-
has_many :google_calendar_events, dependent: :destroy
41+
# Nullify, never destroy: the tracking row is the only pointer to the real
42+
# event in Google Calendar. CleanupOrphanedCalendarEventsJob deletes orphans
43+
# from Google before removing the row.
44+
has_many :google_calendar_events, dependent: :nullify
4245
has_one :event_preference, as: :preferenceable, dependent: :destroy
4346

4447
def room

app/models/final_exam.rb

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,9 @@ class FinalExam < ApplicationRecord
3535

3636
belongs_to :term
3737
belongs_to :course, optional: true
38-
has_many :google_calendar_events, dependent: :destroy
38+
# Nullify, never destroy: see Course::MeetingTime — destroying tracking rows
39+
# strands the real events in Google Calendar.
40+
has_many :google_calendar_events, dependent: :nullify
3941

4042
validates :crn, presence: true
4143
validates :exam_date, :start_time, :end_time, presence: true

app/services/course_processor_service.rb

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -191,12 +191,16 @@ def call
191191
Rails.logger.info("Linked FinalExam for CRN #{course.crn} to course #{course.id}")
192192
end
193193

194-
course.meeting_times.destroy_all
195-
196-
MeetingTimesIngestService.call(
194+
# Upsert in place so meeting time IDs stay stable — destroying them
195+
# cascades to google_calendar_events tracking rows, which strands the
196+
# real events in Google Calendar and duplicates them on the next sync.
197+
# Only rows absent from the upload are removed; their calendar events
198+
# are nullified and cleaned up by CleanupOrphanedCalendarEventsJob.
199+
touched_meeting_time_ids = MeetingTimesIngestService.call(
197200
course: course,
198201
raw_meeting_times: meeting_times
199202
)
203+
course.meeting_times.where.not(id: touched_meeting_time_ids).destroy_all
200204

201205
process_faculty(course, faculty_data)
202206

app/services/meeting_times_ingest_service.rb

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,15 @@ def initialize(course:, raw_meeting_times:, **options)
1212
super()
1313
end
1414

15+
# Upserts meeting times in place and returns the IDs of every row that was
16+
# created or updated, so callers can prune stale rows without touching the rest.
1517
def call
1618
preload_buildings_and_rooms
19+
@touched_meeting_time_ids = []
1720
raw_meeting_times.each do |mt|
1821
ingest_one(mt)
1922
end
23+
@touched_meeting_time_ids
2024
end
2125

2226
private
@@ -170,6 +174,7 @@ def ingest_one(mt)
170174
end
171175

172176
@meeting_time_cache[cache_key] = meeting_time if @meeting_time_cache
177+
@touched_meeting_time_ids << meeting_time.id
173178
end
174179
end
175180

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
# frozen_string_literal: true
2+
3+
require "rails_helper"
4+
5+
RSpec.describe CleanupUntrackedGoogleEventsJob do
6+
let(:user) { User.create!(email: "student@wit.edu", password: "password123") }
7+
let(:credential) do
8+
user.oauth_credentials.create!(
9+
provider: "google", uid: "google-uid", email: user.email, access_token: "token"
10+
)
11+
end
12+
let(:calendar) { credential.create_google_calendar!(google_calendar_id: "cal_123") }
13+
14+
let(:term) { Term.create!(uid: 202710, season: :fall, year: 2026) }
15+
let(:course) do
16+
Course.create!(
17+
crn: 12345, term: term, title: "Data Structures", subject: "COMP",
18+
course_number: 2000, section_number: "01", schedule_type: "LEC",
19+
start_date: Date.new(2026, 9, 8), end_date: Date.new(2026, 12, 15)
20+
)
21+
end
22+
let(:meeting_time) do
23+
Course::MeetingTime.create!(
24+
course: course,
25+
start_date: Time.zone.local(2026, 9, 8),
26+
end_date: Time.zone.local(2026, 12, 15, 23, 59, 59),
27+
begin_time: 1300, end_time: 1445,
28+
day_of_week: :monday,
29+
meeting_schedule_type: :lecture, meeting_type: :class_meeting
30+
)
31+
end
32+
33+
let!(:tracked_event) do
34+
calendar.google_calendar_events.create!(google_event_id: "tracked_1", meeting_time: meeting_time)
35+
end
36+
37+
let(:fake_service) { instance_double(Google::Apis::CalendarV3::CalendarService) }
38+
let(:job) { described_class.new }
39+
40+
def gcal_event(id:, status: "confirmed", summary: "Data Structures", recurring_event_id: nil)
41+
instance_double(
42+
Google::Apis::CalendarV3::Event,
43+
id: id, status: status, summary: summary, recurring_event_id: recurring_event_id
44+
)
45+
end
46+
47+
let(:listed_events) do
48+
[
49+
gcal_event(id: "tracked_1"),
50+
gcal_event(id: "orphan_1"),
51+
gcal_event(id: "cancelled_1", status: "cancelled"),
52+
gcal_event(id: "tracked_1_20260914", recurring_event_id: "tracked_1")
53+
]
54+
end
55+
56+
before do
57+
allow(job).to receive(:calendar_service).and_return(fake_service)
58+
allow(fake_service).to receive(:list_events).and_return(
59+
instance_double(Google::Apis::CalendarV3::Events, items: listed_events, next_page_token: nil)
60+
)
61+
job.rate_limit_config = GoogleApiRateLimiter::RateLimitConfig.new.tap { |c| c.batch_throttle_delay = 0 }
62+
end
63+
64+
it "deletes only untracked events, keeping tracked events and edited instances of tracked series" do
65+
expect(fake_service).to receive(:delete_event).with("cal_123", "orphan_1")
66+
67+
result = job.perform(calendar.id)
68+
69+
expect(result[:deleted]).to eq(1)
70+
expect(result[:errors]).to eq(0)
71+
expect(GoogleCalendarEvent.exists?(tracked_event.id)).to be(true)
72+
end
73+
74+
it "does not delete anything in dry run mode" do
75+
expect(fake_service).not_to receive(:delete_event)
76+
77+
result = job.perform(calendar.id, dry_run: true)
78+
79+
expect(result[:deleted]).to eq(1)
80+
end
81+
82+
it "treats an already-deleted event as success" do
83+
error = Google::Apis::ClientError.new("notFound", status_code: 404)
84+
allow(fake_service).to receive(:delete_event).and_raise(error)
85+
86+
result = job.perform(calendar.id)
87+
88+
expect(result[:errors]).to eq(0)
89+
end
90+
end
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
# frozen_string_literal: true
2+
3+
require "rails_helper"
4+
5+
RSpec.describe GoogleCalendarEvent, type: :model do
6+
let(:user) { User.create!(email: "student@wit.edu", password: "password123") }
7+
let(:credential) do
8+
user.oauth_credentials.create!(
9+
provider: "google", uid: "google-uid", email: user.email, access_token: "token"
10+
)
11+
end
12+
let(:calendar) { credential.create_google_calendar!(google_calendar_id: "cal_123") }
13+
14+
let(:term) { Term.create!(uid: 202710, season: :fall, year: 2026) }
15+
let(:course) do
16+
Course.create!(
17+
crn: 12345, term: term, title: "Data Structures", subject: "COMP",
18+
course_number: 2000, section_number: "01", schedule_type: "LEC",
19+
start_date: Date.new(2026, 9, 8), end_date: Date.new(2026, 12, 15)
20+
)
21+
end
22+
23+
describe "orphaning behavior" do
24+
it "is nullified, not destroyed, when its meeting time is destroyed" do
25+
meeting_time = Course::MeetingTime.create!(
26+
course: course,
27+
start_date: Time.zone.local(2026, 9, 8),
28+
end_date: Time.zone.local(2026, 12, 15, 23, 59, 59),
29+
begin_time: 1300, end_time: 1445,
30+
day_of_week: :monday,
31+
meeting_schedule_type: :lecture, meeting_type: :class_meeting
32+
)
33+
event = calendar.google_calendar_events.create!(
34+
google_event_id: "evt_1", meeting_time: meeting_time
35+
)
36+
37+
expect { meeting_time.destroy! }.not_to change(GoogleCalendarEvent, :count)
38+
expect(event.reload.meeting_time_id).to be_nil
39+
expect(GoogleCalendarEvent.orphaned).to include(event)
40+
end
41+
42+
it "is nullified, not destroyed, when its final exam is destroyed" do
43+
final_exam = FinalExam.create!(
44+
term: term, course: course, crn: 12345,
45+
exam_date: Date.new(2026, 12, 17),
46+
start_time: Time.zone.local(2026, 12, 17, 10, 0),
47+
end_time: Time.zone.local(2026, 12, 17, 12, 0)
48+
)
49+
event = calendar.google_calendar_events.create!(
50+
google_event_id: "evt_2", final_exam: final_exam
51+
)
52+
53+
expect { final_exam.destroy! }.not_to change(GoogleCalendarEvent, :count)
54+
expect(event.reload.final_exam_id).to be_nil
55+
expect(GoogleCalendarEvent.orphaned).to include(event)
56+
end
57+
end
58+
end

0 commit comments

Comments
 (0)