Class: SchedulerGoogleCalendarService

Inherits:
Object
  • Object
show all
Defined in:
app/services/scheduler_google_calendar_service.rb

Overview

Service object: scheduler google calendar service.

Constant Summary collapse

CALENDAR_ID =

Calendar id.

'primary'
TIMEZONE =

Timezone.

'America/Chicago'
MAX_REFRESH_ATTEMPTS =

One token refresh per service instance, mirroring
TimeOffRequests::GoogleCalendar::MAX_RETRIES. A successful refresh is good
for the better part of an hour, so a second attempt would only ever be
retrying a grant that is genuinely broken.

1
FREEBUSY_BATCH_SIZE =

Google's FreeBusy endpoint takes up to this many calendars per request.

50

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(employee) ⇒ SchedulerGoogleCalendarService

Returns a new instance of SchedulerGoogleCalendarService.



55
56
57
58
59
# File 'app/services/scheduler_google_calendar_service.rb', line 55

def initialize(employee)
  @employee = employee
  @client = TimeOffRequests::GoogleAuthService.authorize(employee)
  setup_service if @client
end

Class Method Details

.freebusy_for(employees, date, asker: nil) ⇒ Hash{Integer => Array<Hash>, nil}

Busy windows for a whole roster in ONE API call, keyed by employee id.

Reading a colleague's free/busy needs no service account and no
domain-wide delegation — the Workspace already permits it between users —
so any one healthy grant answers for everybody. That matters as much as the
call count: a per-rep loop fails per-rep, and a revoked grant then reads as
an empty calendar, which looks like "no meetings" rather than "no data".

FreeBusy returns intervals only: no titles (which we never use) and no
all-day flag, so all-day is inferred from a full-day span. That can only
ever over-state coverage, and CRM time off is the authority on whole-day
absence anyway.

Every employee gets a key. A missing key reads downstream as "no meetings",
which shows the rep free all day — so anyone we could not look up (no email
address, no working grant, a calendar Google omitted) is recorded as nil,
meaning "unreadable", not "free".

Parameters:

  • employees (Enumerable<Employee>)

    the roster to look up

  • date (Date)

    the day to query, in TIMEZONE

  • asker (Employee, nil) (defaults to: nil)

    whose grant to query with; defaults to the
    first employee with a working Google connection

Returns:

  • (Hash{Integer => Array<Hash>, nil})

    employee id => busy windows, nil when unreadable



41
42
43
44
45
46
47
48
49
50
51
52
53
# File 'app/services/scheduler_google_calendar_service.rb', line 41

def self.freebusy_for(employees, date, asker: nil)
  employees = employees.to_a
  return {} if employees.empty?

  unreadable = employees.to_h { |employee| [employee.id, nil] }
  addressable = employees.select { |employee| employee.email.present? }
  return unreadable if addressable.empty?

  client = authorize_any(asker ? [asker, *addressable] : addressable)
  return unreadable unless client

  unreadable.merge(new_freebusy_query(client, addressable, date))
end

Instance Method Details

#busy_times(date) ⇒ Object

Returns an array of busy time ranges [{start: Time, end: Time}, ...] for the given date



115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
# File 'app/services/scheduler_google_calendar_service.rb', line 115

def busy_times(date)
  return [] unless connected?

  tz = ActiveSupport::TimeZone[TIMEZONE]
  time_min = tz.local(date.year, date.month, date.day).iso8601
  time_max = tz.local(date.year, date.month, date.day, 23, 59, 59).iso8601

  events = fetch_events(time_min, time_max)
  events.filter_map do |event|
    next if event.transparency == 'transparent'

    start_time = parse_event_time(event.start)
    end_time = parse_event_time(event.end)
    next unless start_time && end_time

    # all_day comes from the source rather than being inferred downstream:
    # Google sends date-only endpoints for all-day events, and a timed event
    # that happens to run midnight-to-midnight is indistinguishable once the
    # times are compared. Callers that only want real meetings need the flag.
    { start: start_time, end: end_time, all_day: event.start&.date_time.nil? }
  end
rescue Google::Apis::AuthorizationError
  refresh_and_retry(:busy_times, date)
rescue Google::Apis::ClientError => e
  Rails.logger.error("SchedulerGoogleCalendarService#busy_times failed for #{@employee.email}: #{e.message}")
  []
end

#busy_times_range(start_date, end_date) ⇒ Object

Single API call to fetch busy times for an entire date range.
Returns { Date => [{start: Time, end: Time}, ...], ... }



157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
# File 'app/services/scheduler_google_calendar_service.rb', line 157

def busy_times_range(start_date, end_date)
  return {} unless connected?

  tz = ActiveSupport::TimeZone[TIMEZONE]
  time_min = tz.local(start_date.year, start_date.month, start_date.day).iso8601
  time_max = tz.local(end_date.year, end_date.month, end_date.day, 23, 59, 59).iso8601

  events = fetch_events(time_min, time_max)
  result = Hash.new { |h, k| h[k] = [] }

  events.each do |event|
    next if event.transparency == 'transparent'

    if event.start&.date
      event_start_date = event.start.date.is_a?(Date) ? event.start.date : Date.parse(event.start.date)
      event_end_date = event.end.date.is_a?(Date) ? event.end.date : Date.parse(event.end.date)
      (event_start_date...event_end_date).each do |d|
        next if d < start_date || d > end_date

        day_start = tz.local(d.year, d.month, d.day)
        result[d] << { start: day_start, end: day_start + 1.day }
      end
    else
      start_time = parse_event_time(event.start)
      end_time = parse_event_time(event.end)
      next unless start_time && end_time

      result[start_time.to_date] << { start: start_time, end: end_time }
    end
  end

  result
rescue Google::Apis::AuthorizationError
  refresh_and_retry(:busy_times_range, start_date, end_date)
rescue Google::Apis::ClientError => e
  Rails.logger.error("SchedulerGoogleCalendarService#busy_times_range failed for #{@employee.email}: #{e.message}")
  {}
end

#connected?Boolean

Returns:

  • (Boolean)


110
111
112
# File 'app/services/scheduler_google_calendar_service.rb', line 110

def connected?
  @client.present?
end

#create_event(booking) ⇒ Object



143
144
145
146
147
148
149
150
151
152
153
# File 'app/services/scheduler_google_calendar_service.rb', line 143

def create_event(booking)
  return unless connected?

  event = build_event(booking)
  created = @service.insert_event(CALENDAR_ID, event, send_updates: 'all')
  booking.update_column(:google_event_id, created.id)
rescue Google::Apis::AuthorizationError
  refresh_and_retry(:create_event, booking)
rescue Google::Apis::ClientError => e
  Rails.logger.error("SchedulerGoogleCalendarService#create_event failed: #{e.message}")
end

#delete_event(google_event_id) ⇒ Object



207
208
209
210
211
212
213
214
215
# File 'app/services/scheduler_google_calendar_service.rb', line 207

def delete_event(google_event_id)
  return unless connected? && google_event_id.present?

  @service.delete_event(CALENDAR_ID, google_event_id)
rescue Google::Apis::AuthorizationError
  refresh_and_retry(:delete_event, google_event_id)
rescue Google::Apis::ClientError => e
  Rails.logger.error("SchedulerGoogleCalendarService#delete_event failed: #{e.message}")
end

#update_event(booking) ⇒ Object



196
197
198
199
200
201
202
203
204
205
# File 'app/services/scheduler_google_calendar_service.rb', line 196

def update_event(booking)
  return create_event(booking) unless connected? && booking.google_event_id.present?

  event = build_event(booking)
  @service.patch_event(CALENDAR_ID, booking.google_event_id, event, send_updates: 'all')
rescue Google::Apis::AuthorizationError
  refresh_and_retry(:update_event, booking)
rescue Google::Apis::ClientError => e
  Rails.logger.error("SchedulerGoogleCalendarService#update_event failed: #{e.message}")
end