Google Calendar Integration With OAuth | LlamaPress Cookbook
LlamaPress
Cookbook
Integrations Stable

Google Calendar Integration — Per-User OAuth & Meeting Sync

Let each signed-in user connect their own Google Calendar with OAuth (offline access + refresh tokens), sync their upcoming events into a local table with a zero-gem Net::HTTP background job, extract the Meet/Zoom/Teams join link, and render an "Upcoming meetings" widget — including token refresh, revoked-token recovery, and keeping moved meetings up to date.

Proven on rails-crm.llamapress.ai model · sql · controller · view

Google Calendar Integration — Per-User OAuth & Meeting Sync

⚠️ Cookbook example — not live code. (KEEP THIS CALLOUT.) Every code block below is an example snippet, not part of the llamapress.ai codebase, and not running on this server. This is a reference recipe for a Leo instance (an AI coding agent) to implement in its own app — read it to understand the pattern, then recreate it there.

Each user of your app clicks "Connect Google Calendar", approves access on Google's consent screen, and from then on their upcoming meetings live in a local calendar_events table — title, start/end time, and the Meet/Zoom/Teams join link — refreshed by a background job. The app renders an "Upcoming meetings" widget with a working Join button, and can hang any other feature off the data (agenda emails, "who are we meeting today" dashboards, prep reminders, transcription bots). Zero gems: plain Net::HTTP for both the OAuth token exchange and the Calendar API, so it works on any Leo box as-is.

This is the per-user flavor of Google OAuth: every user connects their own Google account, and the app stores a token pair per user. (Contrast with the Search Console recipe, where the app itself owns one refresh token in .env.) The pattern is proven in production on our CRM; this guide also fixes five real defects found in that first implementation — they're marked [fix] below (and the worst one — snake_case query params that Google silently ignores — leads the Gotchas section) so you don't re-create them.

When to use: a CRM or client portal that should show each user their own meetings; anything that needs to know "what's on this user's calendar next"; feeding meeting URLs to a transcription bot. When not to: you only need ONE shared/company calendar — embed Google's iframe or use a single service-owned token instead of per-user OAuth. Not for writing events either — this recipe uses the read-only scope (see "How to adapt" for the write scope).


The 80/20 in one breath

  1. In Google Cloud Console: create a project → enable the Google Calendar API → configure the OAuth consent screen (External, publish to production) → create an OAuth client ID (type: Web application) with your callback URL as an Authorized redirect URI → put the client ID/secret in .env.
  2. Create two tables: integrations (one row per user per provider, holds access_token, refresh_token, token_expires_at) and calendar_events (synced copies of upcoming events, unique on user_id + google_event_id).
  3. Add an Auth::GoogleController with three actions: authorize (redirect to Google with access_type=offline&prompt=consent and a CSRF state token), callback (verify state, exchange the code for tokens, save the integration, enqueue a sync), disconnect.
  4. Add SyncCalendarEventsJob: refresh the access token if it's near expiry, GET calendars/primary/events for the next 30 days, upsert each event (create new, update changed), delete local events that vanished, extract the meeting URL.
  5. Re-enqueue the job on a timer (self-rescheduling loop or your scheduler) so the data stays fresh without anyone clicking anything.
  6. Render a connect/disconnect card in settings and an "Upcoming meetings" list from current_user.calendar_events.upcoming.

Layer 0 — Google Cloud setup (the part outside your codebase)

This happens once, in the browser, by a human (or by you walking the human through it). Nothing below works until this is done.

  1. Project: console.cloud.google.com → select or create a project.
  2. Enable the API: APIs & Services → Library → search "Google Calendar API" → Enable. (Skipping this yields a 403 later even with perfect OAuth.)
  3. Consent screen: APIs & Services → OAuth consent screen → User type External (unless every user is on your own Google Workspace). Add the scope https://www.googleapis.com/auth/calendar.readonly. Then Publish to production. A consent screen left in "Testing" expires every refresh token after 7 days — the #1 cause of "it worked last week, now everyone is disconnected."
  4. Create the OAuth client ID: APIs & Services → CredentialsCreate Credentials → OAuth client ID:
    • Application type: Web application.
    • Authorized redirect URIs: the EXACT callback URL your app will handle:
      https://yourapp.example.com/google_oauth/callback
      
      Add http://localhost:3000/google_oauth/callback too if you test locally. Google matches the string exactly — scheme, host, port, path. A mismatch fails with Error 400: redirect_uri_mismatch.
  5. Copy the Client ID and Client secret into .env:
# .env
GOOGLE_CLIENT_ID=1234567890-abc123.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=GOCSPX-xxxxxxxxxxxx

Then recreate the container so the new env vars are actually loaded — docker compose up -d --force-recreate web (or your app service's name). A plain docker compose restart does not reload .env.


Layer 1 — Model & SQL

Two tables. integrations is deliberately generic (a provider string, a JSONB settings bag) so the same table can later hold other per-user connections without a new migration.

# db/migrate/XXXXXXXXXXXXXX_create_integrations.rb
class CreateIntegrations < ActiveRecord::Migration[8.0]
  def change
    create_table :integrations do |t|
      t.references :user, null: false, foreign_key: true
      t.string   :provider, null: false            # "google_calendar"
      t.text     :access_token
      t.text     :refresh_token
      t.datetime :token_expires_at
      t.string   :calendar_email                   # which Google account is connected
      t.jsonb    :settings, default: {}            # last_synced_at, scopes, sync errors
      t.timestamps
    end
    add_index :integrations, [:user_id, :provider], unique: true
  end
end
# db/migrate/XXXXXXXXXXXXXX_create_calendar_events.rb
class CreateCalendarEvents < ActiveRecord::Migration[8.0]
  def change
    create_table :calendar_events do |t|
      t.references :user, null: false, foreign_key: true
      t.references :integration, null: false, foreign_key: true
      t.string   :google_event_id, null: false
      t.string   :title
      t.text     :description
      t.datetime :start_time, null: false
      t.datetime :end_time
      t.string   :meeting_url                      # Meet/Zoom/Teams join link, if any
      t.boolean  :all_day, default: false
      t.jsonb    :raw_data                         # the full Google event, for anything else you need later
      t.timestamps
    end
    add_index :calendar_events, [:user_id, :google_event_id], unique: true
    add_index :calendar_events, :start_time
  end
end
# app/models/integration.rb
class Integration < ApplicationRecord
  belongs_to :user
  has_many :calendar_events, dependent: :destroy

  validates :provider, presence: true, inclusion: { in: %w[google_calendar] }
  validates :provider, uniqueness: { scope: :user_id, message: "already connected for this user" }
  validates :access_token, presence: true

  scope :connected,       -> { where.not(access_token: nil) }
  scope :google_calendar, -> { where(provider: "google_calendar") }

  def connected?
    access_token.present? && !needs_reconnect?
  end

  # Refresh a little EARLY — an access token that expires mid-request still 401s.
  def token_stale?
    token_expires_at.blank? || token_expires_at < 5.minutes.from_now
  end

  # [fix] Set when Google says invalid_grant (user revoked access, or the consent
  # screen was in Testing and the refresh token aged out). The UI reads this to show
  # a "Reconnect" button instead of silently showing stale data forever.
  def needs_reconnect?
    refresh_token.blank? || settings["needs_reconnect"] == true
  end

  def last_synced_at
    settings["last_synced_at"] && Time.zone.parse(settings["last_synced_at"].to_s)
  end
end
# app/models/calendar_event.rb
class CalendarEvent < ApplicationRecord
  belongs_to :user
  belongs_to :integration

  validates :google_event_id, presence: true, uniqueness: { scope: :user_id }
  validates :start_time, presence: true
  validates :title, presence: true

  scope :upcoming, -> { where("start_time > ?", Time.current).order(start_time: :asc) }
  scope :today,    -> { where(start_time: Time.current.all_day) }
  scope :recent,   ->(limit = 5) { upcoming.limit(limit) }

  def has_meeting? = meeting_url.present?
end

Do NOT reach for encrypts :access_token here. Rails Active Record encryption keys derive from the app's secrets; on LlamaPress instances the secret key base can rotate across a sleep/wake cycle, which makes every encrypted column permanently unreadable (ActiveRecord::Encryption::Errors::Decryption). Plain text columns in the instance's own Postgres are the pragmatic choice on this platform. If you do encrypt, you must also pin the encryption keys somewhere that survives restores.

Layer 2 — Controller (the OAuth dance) & routes

# config/routes.rb (add inside the draw block)
get    "/google_oauth/authorize",  to: "auth/google#authorize",  as: :auth_google
get    "/google_oauth/callback",   to: "auth/google#callback",   as: :auth_google_callback
delete "/google_oauth/disconnect", to: "auth/google#disconnect", as: :auth_google_disconnect
# app/controllers/auth/google_controller.rb
class Auth::GoogleController < ApplicationController
  before_action :authenticate_user!

  GOOGLE_AUTH_URL  = "https://accounts.google.com/o/oauth2/auth"
  GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"
  # Read-only calendar access. Swap for .../auth/calendar.events if you also create events.
  GOOGLE_CALENDAR_SCOPE = "https://www.googleapis.com/auth/calendar.readonly"

  # Step 1: send the user to Google's consent screen.
  def authorize
    state_token = SecureRandom.hex(24)
    session[:google_oauth_state] = state_token

    query = {
      client_id: ENV["GOOGLE_CLIENT_ID"],
      redirect_uri: auth_google_callback_url,
      response_type: "code",
      scope: GOOGLE_CALENDAR_SCOPE,
      access_type: "offline",   # REQUIRED to get a refresh_token at all
      prompt: "consent",        # REQUIRED to get a refresh_token on RE-connects (see Gotchas)
      state: state_token
    }

    redirect_to "#{GOOGLE_AUTH_URL}?#{query.to_query}", allow_other_host: true
  end

  # Step 2: Google redirects back here with ?code=...&state=...
  def callback
    if params[:state] != session[:google_oauth_state]
      return redirect_to root_path, alert: "Security check failed. Please try again."
    end
    session.delete(:google_oauth_state)

    if params[:error].present? # user clicked "Cancel" on the consent screen
      return redirect_to root_path, alert: "Google Calendar connection was cancelled."
    end

    token_data = exchange_code_for_tokens(params[:code])
    unless token_data
      return redirect_to root_path, alert: "Failed to connect Google Calendar. Please try again."
    end

    integration = current_user.integrations.find_or_initialize_by(provider: "google_calendar")
    integration.update!(
      access_token: token_data["access_token"],
      # [fix] Keep the OLD refresh token if Google didn't send a new one — it only
      # sends refresh_token on some exchanges; overwriting with nil bricks the sync.
      refresh_token: token_data["refresh_token"].presence || integration.refresh_token,
      token_expires_at: Time.current + token_data["expires_in"].to_i.seconds,
      calendar_email: fetch_calendar_email(token_data["access_token"]),
      settings: integration.settings.merge("scopes" => token_data["scope"], "needs_reconnect" => false)
    )

    SyncCalendarEventsJob.perform_later(integration.id)
    redirect_to root_path, notice: "Google Calendar connected! Your meetings will appear shortly."
  rescue => e
    Rails.logger.error("Google OAuth callback error: #{e.class}: #{e.message}")
    redirect_to root_path, alert: "Something went wrong connecting your calendar. Please try again."
  end

  def disconnect
    integration = current_user.integrations.google_calendar.first
    if integration
      integration.destroy # calendar_events go with it (dependent: :destroy)
      redirect_to root_path, notice: "Google Calendar disconnected."
    else
      redirect_to root_path, alert: "No Google Calendar connection found."
    end
  end

  private

  def exchange_code_for_tokens(code)
    response = Net::HTTP.post_form(URI(GOOGLE_TOKEN_URL), {
      code: code,
      client_id: ENV["GOOGLE_CLIENT_ID"],
      client_secret: ENV["GOOGLE_CLIENT_SECRET"],
      redirect_uri: auth_google_callback_url, # must EXACTLY match the one used in #authorize
      grant_type: "authorization_code"
    })
    return JSON.parse(response.body) if response.is_a?(Net::HTTPOK)

    Rails.logger.error("Google token exchange failed: #{response.body}")
    nil
  end

  # [fix] Ask the CALENDAR API which account this is. The primary calendar's id IS
  # the account email, and it's inside the calendar.readonly scope we already hold.
  # (Calling the /oauth2/v2/userinfo endpoint instead FAILS here — it needs the
  # separate "email" scope, and the failure is a silent nil.)
  def fetch_calendar_email(access_token)
    uri = URI("https://www.googleapis.com/calendar/v3/calendars/primary")
    http = Net::HTTP.new(uri.host, uri.port)
    http.use_ssl = true
    request = Net::HTTP::Get.new(uri)
    request["Authorization"] = "Bearer #{access_token}"
    response = http.request(request)
    response.is_a?(Net::HTTPOK) ? JSON.parse(response.body)["id"] : nil
  rescue => e
    Rails.logger.error("Failed to fetch calendar email: #{e.message}")
    nil
  end
end

Layer 3 — The sync job (zero-gem Net::HTTP)

One job, two modes: with an integration_id it syncs one user (used right after connect and for a "Sync now" button); with no argument it sweeps every connected integration (used by the scheduler).

# app/jobs/sync_calendar_events_job.rb
class SyncCalendarEventsJob < ApplicationJob
  queue_as :default

  GOOGLE_CALENDAR_API = "https://www.googleapis.com/calendar/v3"
  SYNC_WINDOW = 30.days
  PAGE_SIZE = 250

  def perform(integration_id = nil)
    if integration_id
      sync_integration(Integration.find(integration_id))
    else
      Integration.google_calendar.connected.find_each { |i| sync_integration(i) }
    end
  end

  private

  def sync_integration(integration)
    return if integration.needs_reconnect?

    access_token = fresh_access_token(integration)
    return unless access_token

    events = fetch_upcoming_events(access_token)
    return unless events

    seen_ids = []
    events.each do |event|
      next if event["status"] == "cancelled"
      seen_ids << event["id"]
      upsert_event(integration, event)
    end

    # Anything we had locally that Google no longer returns in the window was
    # deleted (or declined off) — mirror that.
    integration.calendar_events
               .where(start_time: Time.current..(Time.current + SYNC_WINDOW))
               .where.not(google_event_id: seen_ids)
               .destroy_all

    integration.update!(settings: integration.settings.merge("last_synced_at" => Time.current.iso8601))
  rescue => e
    Rails.logger.error("Calendar sync failed for integration #{integration.id}: #{e.class}: #{e.message}")
  end

  # [fix] UPSERT, don't skip-if-known. The first version of this pattern skipped any
  # event id it had already stored — so when a meeting was MOVED or renamed, the app
  # kept showing the old time forever. find_or_initialize + assign fixes that class
  # of bug for every field at once.
  def upsert_event(integration, event)
    start_raw = event.dig("start", "dateTime") || event.dig("start", "date")
    end_raw   = event.dig("end", "dateTime")   || event.dig("end", "date")
    all_day   = event.dig("start", "date").present?

    record = integration.calendar_events.find_or_initialize_by(google_event_id: event["id"])
    record.assign_attributes(
      user: integration.user,
      title: event["summary"].presence || "Untitled event",
      description: event["description"],
      start_time: Time.zone.parse(start_raw),
      end_time: end_raw ? Time.zone.parse(end_raw) : nil,
      all_day: all_day,
      meeting_url: extract_meeting_url(event),
      raw_data: event
    )
    record.save!
  end

  # Refresh when stale; on invalid_grant flag the integration for reconnect instead
  # of erroring on every sweep forever.
  def fresh_access_token(integration)
    return integration.access_token unless integration.token_stale?
    return nil if integration.refresh_token.blank?

    response = Net::HTTP.post_form(URI("https://oauth2.googleapis.com/token"), {
      refresh_token: integration.refresh_token,
      client_id: ENV["GOOGLE_CLIENT_ID"],
      client_secret: ENV["GOOGLE_CLIENT_SECRET"],
      grant_type: "refresh_token"
    })

    if response.is_a?(Net::HTTPOK)
      data = JSON.parse(response.body)
      integration.update!(
        access_token: data["access_token"],
        token_expires_at: Time.current + data["expires_in"].to_i.seconds,
        # Google occasionally rotates the refresh token — persist it when present.
        refresh_token: data["refresh_token"].presence || integration.refresh_token
      )
      data["access_token"]
    else
      Rails.logger.error("Token refresh failed for integration #{integration.id}: #{response.body}")
      # [fix] invalid_grant = the user revoked access, or a Testing-mode consent
      # screen expired the token. Mark it so the UI can ask them to reconnect.
      if response.body.include?("invalid_grant")
        integration.update!(settings: integration.settings.merge("needs_reconnect" => true))
      end
      nil
    end
  end

  # [fix] Follows nextPageToken — a busy calendar (or a small maxResults) silently
  # truncated the first version of this fetch.
  def fetch_upcoming_events(access_token)
    items = []
    page_token = nil

    loop do
      query = {
        timeMin: Time.current.iso8601,
        timeMax: (Time.current + SYNC_WINDOW).iso8601,
        singleEvents: true,       # expand recurring events into instances
        orderBy: "startTime",
        maxResults: PAGE_SIZE
      }
      query[:pageToken] = page_token if page_token

      uri = URI("#{GOOGLE_CALENDAR_API}/calendars/primary/events")
      uri.query = URI.encode_www_form(query)
      http = Net::HTTP.new(uri.host, uri.port)
      http.use_ssl = true
      request = Net::HTTP::Get.new(uri)
      request["Authorization"] = "Bearer #{access_token}"

      response = http.request(request)
      unless response.is_a?(Net::HTTPOK)
        Rails.logger.error("Calendar API error: #{response.code} - #{response.body}")
        return nil
      end

      data = JSON.parse(response.body)
      items.concat(data["items"] || [])
      page_token = data["nextPageToken"]
      break if page_token.blank?
    end

    items
  end

  # Best-effort join-link extraction: structured conferenceData first, then URL
  # patterns in the description/location, then hangoutLink.
  def extract_meeting_url(event)
    entry_points = event.dig("conferenceData", "entryPoints") || []
    video = entry_points.find { |ep| ep["entryPointType"] == "video" }
    return video["uri"] if video

    text = [event["description"], event["location"], event["hangoutLink"]].compact.join(" ")
    [
      %r{https://meet\.google\.com/[a-z\-]+},
      %r{https://[\w\-]*\.?zoom\.us/j/[\w?=\-]+},
      %r{https://teams\.microsoft\.com/l/meetup-join/[\w%./\-]+}
    ].each do |pattern|
      match = text.match(pattern)
      return match[0] if match
    end

    event["hangoutLink"]
  end
end

Keeping it fresh (the step the first implementation forgot)

The job above only runs when something enqueues it. Without a recurring trigger, the calendar is a snapshot from the moment the user connected — permanently. Pick ONE:

# Option A — self-rescheduling sweep (works with any ActiveJob backend, zero config).
# Kick it off ONCE from the console: CalendarSyncLoopJob.perform_later
# app/jobs/calendar_sync_loop_job.rb
class CalendarSyncLoopJob < ApplicationJob
  queue_as :default

  def perform
    SyncCalendarEventsJob.perform_now
  ensure
    # Always re-arm, even if the sweep raised — a dead loop is silent staleness.
    self.class.set(wait: 15.minutes).perform_later
  end
end
# Option B — if the app runs Solid Queue, declare it in config/recurring.yml instead:
production:
  calendar_sync:
    class: SyncCalendarEventsJob
    schedule: every 15 minutes

Guard against the loop doubling up (two console kickoffs = two loops): before kicking off Option A, check SolidQueue::Job.where(class_name: "CalendarSyncLoopJob", finished_at: nil).none? (or your backend's equivalent), or just use Option B where available.

Layer 4 — The View

Two pieces: a connect/disconnect card (settings page) and the upcoming-meetings widget (dashboard). Tailwind, no JS needed.

<%# app/views/settings/_google_calendar_card.html.erb %>
<% integration = current_user.integrations.google_calendar.first %>
<div class="rounded-lg border border-slate-200 bg-white p-4">
  <div class="flex items-center justify-between gap-4">
    <div>
      <h3 class="font-semibold text-slate-900">Google Calendar</h3>
      <% if integration&.connected? %>
        <p class="text-sm text-slate-500">
          Connected as <%= integration.calendar_email || "your Google account" %>
          <% if integration.last_synced_at %>
            · synced <%= time_ago_in_words(integration.last_synced_at) %> ago
          <% end %>
        </p>
      <% elsif integration&.needs_reconnect? %>
        <p class="text-sm text-amber-600">Connection expired — please reconnect.</p>
      <% else %>
        <p class="text-sm text-slate-500">Show your upcoming meetings inside the app.</p>
      <% end %>
    </div>
    <div class="flex items-center gap-2">
      <% if integration&.connected? %>
        <%= button_to "Sync now", sync_calendar_path, method: :post,
              class: "rounded-md border border-slate-300 px-3 py-1.5 text-sm text-slate-700 hover:bg-slate-50" %>
        <%= button_to "Disconnect", auth_google_disconnect_path, method: :delete,
              data: { turbo_confirm: "Disconnect Google Calendar? Synced events will be removed." },
              class: "rounded-md border border-slate-300 px-3 py-1.5 text-sm text-slate-700 hover:bg-slate-50" %>
      <% else %>
        <%= link_to "Connect Google Calendar", auth_google_path,
              class: "rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700" %>
      <% end %>
    </div>
  </div>
</div>

The "Sync now" button needs a one-line endpoint (rate-limit it if your users are click-happy):

# config/routes.rb
post "/calendar/sync", to: "calendars#sync", as: :sync_calendar

# app/controllers/calendars_controller.rb
class CalendarsController < ApplicationController
  before_action :authenticate_user!

  def sync
    integration = current_user.integrations.google_calendar.connected.first
    SyncCalendarEventsJob.perform_later(integration.id) if integration
    redirect_back fallback_location: root_path, notice: "Sync started — refresh in a few seconds."
  end
end
<%# app/views/shared/_upcoming_meetings.html.erb %>
<% events = current_user.calendar_events.recent(5) %>
<div class="rounded-lg border border-slate-200 bg-white">
  <div class="border-b border-slate-100 px-4 py-3">
    <h3 class="font-semibold text-slate-900">Upcoming meetings</h3>
  </div>
  <% if events.any? %>
    <ul class="divide-y divide-slate-100">
      <% events.each do |event| %>
        <li class="flex items-center justify-between gap-3 px-4 py-3">
          <div class="min-w-0">
            <p class="truncate font-medium text-slate-800"><%= event.title %></p>
            <p class="text-sm text-slate-500">
              <% if event.all_day %>
                <%= event.start_time.strftime("%a %b %-d") %> · all day
              <% else %>
                <%= event.start_time.strftime("%a %b %-d, %-l:%M %p") %>
              <% end %>
            </p>
          </div>
          <% if event.has_meeting? %>
            <%= link_to "Join", event.meeting_url, target: "_blank", rel: "noopener",
                  class: "shrink-0 rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700" %>
          <% end %>
        </li>
      <% end %>
    </ul>
  <% else %>
    <p class="px-4 py-6 text-sm text-slate-500">No upcoming meetings in the next 30 days.</p>
  <% end %>
</div>

Gotchas (the hard-won stuff)

  • Google's query params are camelCase — and unknown params are silently IGNORED. time_min, single_events, order_by, max_results don't error: Google drops them and applies its defaults, so the "next 30 days" fetch actually returns the OLDEST events in the calendar, unfiltered, unordered, recurring events unexpanded. The production CRM ran this way undetected — 50 stale events stored, zero upcoming, and the widget looked "empty" rather than broken. Always timeMin, timeMax, singleEvents, orderBy, maxResults, and verify the FIRST sync stores events with future start times.
  • No refresh token? It's access_type + prompt. Google only issues a refresh_token when the auth request carries access_type=offline, and on RE-authorizations it only re-issues one if you also send prompt=consent. Miss either and the integration works for exactly one hour, then dies at the first refresh. And even WITH prompt=consent, defensively keep the old refresh token when the token response omits one (the callback code above does).
  • Consent screen in "Testing" mode = every refresh token dies after 7 days. Publish the OAuth consent screen to production. This failure looks exactly like users randomly disconnecting a week after they connect.
  • redirect_uri_mismatch on a Leo/LlamaPress box usually means the app generated a localhost or http:// callback URL. auth_google_callback_url builds from the request/host config; behind the platform proxy that can come out as http://localhost:3000/..., which will never match the registered URI. Fix the app's default_url_options (host + protocol: "https") for production, and register the exact public URL in Google Cloud. Compare the redirect_uri in the failing Google URL character-by-character with the console entry.
  • Upsert, never skip-if-known. The original implementation skipped any google_event_id it had seen before, so a meeting moved from 2pm to 4pm showed 2pm forever. Sync means "make local match remote," not "insert what's new."
  • Don't call /oauth2/v2/userinfo to learn which account connected. That endpoint needs the email/profile scope; with only calendar.readonly it 401s — and if you wrapped it in a rescue, it fails silently and your "Connected as …" line is blank forever. GET /calendar/v3/calendars/primary returns the account email as id using the scope you already have.
  • invalid_grant on refresh is a STATE, not an error to retry. The user revoked access in their Google account settings, or a Testing-mode token aged out. Retrying every sweep just spams logs. Flag the integration (needs_reconnect), surface a Reconnect button, and skip it in sweeps until the user re-consents.
  • All-day events come as start.date (no time, no zone). Time.zone.parse("2026-08-19") pins it to midnight in the APP's zone — fine for display, but remember the row's start_time is a zone-dependent interpretation. Keep the all_day boolean and branch on it when formatting, and don't build "meetings in the next hour" alerts off all-day rows.
  • Always request singleEvents: true. Without it, a weekly recurring meeting returns as ONE master event with recurrence rules you'd have to expand yourself. With it, Google expands instances for you (that's also what makes orderBy: "startTime" legal — it errors without singleEvents).
  • Scope stale-event deletion to the sync window. The job fetches the next 30 days only, so it may only delete local rows inside that window. Delete everything Google didn't return and you'd wipe past events (if you keep history) the moment they age out of the window.
  • .env edits need a container RECREATE, not a restart. docker compose restart reuses the old process environment; docker compose up -d --force-recreate <service> picks up new values. Symptom of forgetting: The OAuth client was not found / invalid_client because GOOGLE_CLIENT_ID is still nil inside the container.
  • The CSRF state check is load-bearing. Without it, an attacker can complete the callback with their code and attach their calendar to the victim's account (or vice versa). Generate per-request, store in session, compare, delete.
  • Google API responses can be large — store raw_data, but index what you query. The JSONB raw_data column keeps attendees, organizer, recurrence, etc. available without another API round-trip, while queries run against the extracted columns (start_time is indexed for the upcoming scope).

Files this pattern touches

db/migrate/XXXXXXXXXXXXXX_create_integrations.rb
db/migrate/XXXXXXXXXXXXXX_create_calendar_events.rb
app/models/integration.rb
app/models/calendar_event.rb
app/controllers/auth/google_controller.rb
app/controllers/calendars_controller.rb
app/jobs/sync_calendar_events_job.rb
app/jobs/calendar_sync_loop_job.rb            (Option A recurring sync only)
config/recurring.yml                          (Option B recurring sync only)
app/views/settings/_google_calendar_card.html.erb
app/views/shared/_upcoming_meetings.html.erb
config/routes.rb
.env                                          (GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET)

How to adapt to your schema

  1. Different user model / no Devise: the pattern only needs current_user and a user_id foreign key. Swap authenticate_user! for your auth check.
  2. Company-wide calendar instead of per-user: keep one Integration row owned by an admin, drop user_id from calendar_events, and render the widget for everyone. The OAuth flow is unchanged — one person connects once.
  3. Creating events (booking, scheduling): change the scope to https://www.googleapis.com/auth/calendar.events (read + write), re-run the consent flow (scope changes require re-consent), and POST to #{GOOGLE_CALENDAR_API}/calendars/primary/events with a JSON body. Keep the readonly scope if you only display — smaller scopes get easier Google review and more user trust.
  4. More calendars than primary: GET /users/me/calendarList enumerates all the user's calendars; loop the sync over each calendarList entry's id and add a calendar_id column to calendar_events.
  5. Very busy calendars / lower quota use: replace the 30-day window fetch with Google's incremental sync — store the nextSyncToken from a full fetch in integration.settings, pass it as syncToken on later runs, and handle the 410 Gone that tells you to do a fresh full sync.
  6. Safe to drop: the meeting-URL extraction (if you never show Join buttons), raw_data (if storage matters more than flexibility), the "Sync now" button, and the calendar_email lookup — none of them are load-bearing for the core sync.

Related