---
title: Google Calendar Integration — Per-User OAuth & Meeting Sync
slug: google-calendar-integration
category: Integrations
summary: 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.
tags: [google-calendar, oauth, google, api, rest, background-job, integrations, meetings]
status: stable
visibility: public
source_project: rails-crm.llamapress.ai
layers: [model, sql, controller, view]
related:
  - title: Google Search Console — OAuth Client, Callback URI & Pulling Query Data
    url: /cookbook/google-search-console-integration
    summary: Sibling recipe — the same Google Cloud OAuth web-client setup and zero-gem Net::HTTP pattern, but app-level (one refresh token in .env) instead of per-user.
  - title: Recall.ai Meeting Transcription
    url: /cookbook/recall-ai-meeting-transcription
    summary: Natural next step — once you know a user's meeting URLs, a bot can join and transcribe them.
  - title: Google Calendar API — Events.list reference
    url: https://developers.google.com/calendar/api/v3/reference/events/list
    summary: Official docs for the events.list endpoint used by the sync job — parameters, paging, sync tokens.
  - title: Google Identity — OAuth 2.0 for Web Server Applications
    url: https://developers.google.com/identity/protocols/oauth2/web-server
    summary: Google's own walkthrough of the authorization-code flow, offline access, and refresh tokens.
---

# 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](/cookbook/google-search-console-integration), 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 → **Credentials** → **Create
   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`:

```bash
# .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.

```ruby
# 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
```

```ruby
# 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
```

```ruby
# 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
```

```ruby
# 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

```ruby
# 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
```

```ruby
# 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).

```ruby
# 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:

```ruby
# 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
```

```yaml
# 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.

```erb
<%# 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):

```ruby
# 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
```

```erb
<%# 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.
