Tuesday, October 21, 2025

Automating Inspection Scheduling with Google Calendar Integration

Paul (Founder)
Development

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.