Property inspections require coordinating multiple schedules: staff availability, tenant presence, and property access timing. Manual calendar management creates friction—staff book inspections in LetAdmin then manually create matching Calendar events, tenants receive separate email confirmations requiring calendar entry, and schedule changes necessitate updates in multiple locations.
Week 43 implemented Google Calendar integration automatically creating inspection events when bookings are made. Scheduled inspections appear as Calendar entries with tenant attendees receiving automatic invitations, property addresses populate event locations enabling easy navigation, calendar reminders alert staff before appointments, and inspection reschedules update Calendar events maintaining synchronization. This article explores Calendar API integration, event management, timezone handling, and automated bulk scheduling.
What Your Team Will Notice
Creating inspections now includes "Add to Calendar" options. Staff schedule an inspection, tick "Create Calendar Event", and LetAdmin automatically creates the event in their Google Calendar: inspection date and time become the event schedule, property address fills the location field, tenant email adds as an attendee, and a note about the inspection type appears in the description.
Tenants receive standard Google Calendar invitations via email: clickable "Add to Calendar" buttons, event details with property address, calendar attachment files for non-Google calendar apps. They accept or decline through familiar Calendar interfaces, with responses updating in LetAdmin showing confirmed or pending tenant availability.
Inspection reschedules update Calendar events automatically: change the inspection time in LetAdmin, and the Calendar event updates with new timing, all attendees receive rescheduling notifications, and calendar blocks adjust preventing double-booking. This bidirectional synchronization maintains consistency without manual calendar editing.
For bulk scheduling (portfolio-wide inspection campaigns), rake tasks generate Calendar events automatically: hundreds of inspections scheduled across properties, tenant invitations sent in batches respecting rate limits, validation preventing duplicate bookings or invalid email addresses, and progress tracking showing scheduling completion.
Under the Bonnet: Shared OAuth Service
Calendar integration shares OAuth infrastructure with Gmail:
# app/services/google_oauth_service.rb (enhanced from W43-01)
class GoogleOauthService
CALENDAR_SCOPE = 'https://www.googleapis.com/auth/calendar.events'
# ... existing Gmail OAuth methods ...
def calendar_authorized?
return false unless valid_token?
# Check if Calendar scope was granted
@user.google_oauth_scopes&.include?(CALENDAR_SCOPE)
end
def request_calendar_access
client = create_client
client.scope = [*GOOGLE_OAUTH_SCOPE, CALENDAR_SCOPE]
client.authorization_uri.to_s
end
end
This shared service manages OAuth for both Gmail and Calendar, requesting appropriate scopes and handling token refresh consistently.
Calendar API Service
The Calendar service creates and manages events:
# app/services/google_calendar_service.rb
require 'google/apis/calendar_v3'
class GoogleCalendarService
def initialize(user)
@user = user
@oauth_service = GoogleOauthService.new(user)
end
def create_inspection_event(inspection)
calendar = build_calendar_service
event = build_event_from_inspection(inspection)
created_event = calendar.insert_event('primary', event,
send_updates: 'all')
# Store event ID for future updates
inspection.update!(google_calendar_event_id: created_event.id)
created_event
end
def update_inspection_event(inspection)
return unless inspection.google_calendar_event_id.present?
calendar = build_calendar_service
event = build_event_from_inspection(inspection)
calendar.update_event(
'primary',
inspection.google_calendar_event_id,
event,
send_updates: 'all'
)
end
def delete_inspection_event(inspection)
return unless inspection.google_calendar_event_id.present?
calendar = build_calendar_service
calendar.delete_event(
'primary',
inspection.google_calendar_event_id,
send_updates: 'all'
)
inspection.update!(google_calendar_event_id: nil)
end
private
def build_calendar_service
access_token = @oauth_service.ensure_valid_token!
calendar = Google::Apis::CalendarV3::CalendarService.new
calendar.authorization = access_token
calendar
end
def build_event_from_inspection(inspection)
property = inspection.property
tenant = property.current_tenant
Google::Apis::CalendarV3::Event.new(
summary: "Property Inspection - #{property.reference}",
description: inspection_description(inspection),
location: property.display_address,
start: {
date_time: inspection.inspection_datetime.iso8601,
time_zone: 'Europe/London'
},
end: {
date_time: (inspection.inspection_datetime + 1.hour).iso8601,
time_zone: 'Europe/London'
},
attendees: build_attendees(inspection, tenant),
reminders: {
use_default: false,
overrides: [
{ method: 'email', minutes: 24 * 60 }, # 1 day before
{ method: 'popup', minutes: 60 } # 1 hour before
]
},
color_id: '5' # Banana (yellow) for easy visual identification
)
end
def inspection_description(inspection)
<<~DESC
Inspection Type: #{inspection.inspection_type.titleize}
Property: #{inspection.property.display_address}
#{inspection.notes if inspection.notes.present?}
View in LetAdmin: #{inspection_url(inspection)}
DESC
end
def build_attendees(inspection, tenant)
attendees = []
# Add tenant if email available
if tenant&.email.present? && valid_email?(tenant.email)
attendees << {
email: tenant.email,
display_name: tenant.full_name,
response_status: 'needsAction'
}
end
# Add additional attendees if specified
if inspection.additional_attendees.present?
inspection.additional_attendees.each do |email|
attendees << { email: email } if valid_email?(email)
end
end
attendees
end
def valid_email?(email)
email.match?(URI::MailTo::EMAIL_REGEXP)
end
def inspection_url(inspection)
Rails.application.routes.url_helpers.inspection_url(
inspection,
host: Rails.application.config.action_mailer.default_url_options[:host]
)
end
end
This service handles complete event lifecycle: creation with proper attendees and reminders, updates when inspections reschedule, and deletion when inspections are cancelled.
Inspection Model Integration
The inspection model triggers Calendar events automatically:
# app/models/inspection.rb
class Inspection < ApplicationRecord
# ... existing code ...
after_create :create_calendar_event, if: :should_create_calendar_event?
after_update :update_calendar_event, if: :should_update_calendar_event?
before_destroy :delete_calendar_event, if: :google_calendar_event_id?
attr_accessor :create_calendar_event_flag
private
def should_create_calendar_event?
create_calendar_event_flag &&
conducted_by.present? &&
conducted_by.calendar_authorized?
end
def should_update_calendar_event?
google_calendar_event_id.present? &&
(saved_change_to_inspection_datetime? || saved_change_to_notes?)
end
def create_calendar_event
GoogleCalendarService.new(conducted_by).create_inspection_event(self)
rescue StandardError => e
# Log error but don't fail inspection creation
Rails.logger.error("Failed to create Calendar event: #{e.message}")
end
def update_calendar_event
GoogleCalendarService.new(conducted_by).update_inspection_event(self)
rescue StandardError => e
Rails.logger.error("Failed to update Calendar event: #{e.message}")
end
def delete_calendar_event
GoogleCalendarService.new(conducted_by).delete_inspection_event(self)
rescue StandardError => e
Rails.logger.error("Failed to delete Calendar event: #{e.message}")
end
end
These callbacks ensure Calendar events stay synchronized with inspection changes automatically.
Automated Bulk Scheduling
Rake tasks generate inspection schedules for property portfolios:
# lib/tasks/inspections.rake
namespace :inspections do
desc "Book inspections for properties"
task book: :environment do
agency = Agency.find_by!(subdomain: ENV['AGENCY_SUBDOMAIN'])
ActsAsTenant.current_tenant = agency
inspector = User.find_by!(email: ENV['INSPECTOR_EMAIL'])
properties = agency.properties
.with_current_tenancy
.where(marketing_status: 'let')
puts "Scheduling inspections for #{properties.count} properties..."
scheduled_count = 0
skipped_count = 0
failed_count = 0
properties.find_each do |property|
begin
# Skip if already scheduled
if property.inspections.upcoming.any?
puts "Skipping #{property.reference} - already scheduled"
skipped_count += 1
next
end
# Calculate next inspection date
inspection_date = calculate_next_inspection_date(property)
# Get tenant email for calendar invitation
tenant_email = property.current_tenant&.email
unless valid_email?(tenant_email)
puts "Skipping #{property.reference} - invalid tenant email"
skipped_count += 1
next
end
# Create inspection with calendar event
inspection = Inspection.create!(
property: property,
agency: agency,
inspection_type: 'routine',
inspection_datetime: inspection_date,
conducted_by: inspector,
status: 'scheduled',
create_calendar_event_flag: true
)
puts "Scheduled #{property.reference} for #{inspection_date.strftime('%d/%m/%Y %H:%M')}"
scheduled_count += 1
# Rate limiting to respect Google API quotas
sleep 0.5
rescue StandardError => e
puts "Failed to schedule #{property.reference}: #{e.message}"
failed_count += 1
end
end
puts "\nScheduling complete:"
puts " Scheduled: #{scheduled_count}"
puts " Skipped: #{skipped_count}"
puts " Failed: #{failed_count}"
end
def calculate_next_inspection_date(property)
# Schedule 3 months from last inspection, or 2 weeks from now for new properties
last_inspection = property.inspections.completed.order(inspection_datetime: :desc).first
if last_inspection.present?
last_inspection.inspection_datetime + 3.months
else
2.weeks.from_now
end
end
def valid_email?(email)
return false if email.blank?
email.match?(URI::MailTo::EMAIL_REGEXP)
end
end
This task automates portfolio-wide scheduling whilst respecting API rate limits and handling errors gracefully.
Timezone Handling
Calendar events require careful timezone management:
# config/application.rb
config.time_zone = 'Europe/London'
config.active_record.default_timezone = :utc
# app/services/google_calendar_service.rb
def build_event_from_inspection(inspection)
# Store times in UTC but specify timezone for display
inspection_time = inspection.inspection_datetime.in_time_zone('Europe/London')
Google::Apis::CalendarV3::Event.new(
start: {
date_time: inspection_time.iso8601,
time_zone: 'Europe/London'
},
end: {
date_time: (inspection_time + 1.hour).iso8601,
time_zone: 'Europe/London'
}
# ...
)
end
This approach stores times in UTC internally whilst displaying and transmitting times in the agency's local timezone.
Testing Calendar Integration
Testing Calendar operations requires mocking external API calls:
RSpec.describe GoogleCalendarService do
let(:user) { create(:user, :with_calendar_token) }
let(:inspection) { create(:inspection, conducted_by: user) }
let(:service) { described_class.new(user) }
before do
stub_request(:post, %r{https://www.googleapis.com/calendar/v3/calendars/primary/events})
.to_return(
status: 200,
body: {
id: 'event123',
htmlLink: 'https://calendar.google.com/event?eid=event123'
}.to_json
)
end
it "creates calendar event for inspection" do
event = service.create_inspection_event(inspection)
expect(event.id).to eq('event123')
expect(inspection.reload.google_calendar_event_id).to eq('event123')
end
it "includes tenant as attendee" do
inspection.property.current_tenant.update!(email: 'tenant@example.com')
service.create_inspection_event(inspection)
expect(WebMock).to have_requested(:post, %r{calendar/v3})
.with { |req|
body = JSON.parse(req.body)
attendees = body.dig('attendees')
attendees&.any? { |a| a['email'] == 'tenant@example.com' }
}
end
end
These tests verify event creation and attendee management without external API dependencies.
What's Next
The Calendar integration foundation enables sophisticated features: recurring inspection series scheduling regular property visits automatically, calendar conflict detection preventing double-booking staff or properties, inspection routing optimizing geographic scheduling, and calendar sync showing external appointments preventing booking during staff unavailability.
Future enhancements might include Meet integration creating video viewing links for remote tours, Calendar analysis showing optimal inspection timing based on historical completion rates, automated rescheduling for cancelled inspections, and tenant self-scheduling allowing tenants to book inspections through shared calendar availability.
By implementing comprehensive Google Calendar integration, LetAdmin automates inspection scheduling logistics, maintains synchronization between property management and staff calendars, and reduces manual coordination work whilst improving scheduling accuracy and tenant communication.