{"slug":"google-calendar-integration","meta":{"title":"Google Calendar Integration — Per-User OAuth \u0026 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 \u0026 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."}]},"body":"# Google Calendar Integration — Per-User OAuth \u0026 Meeting Sync\n\n\u003e ⚠️ **Cookbook example — not live code.** (KEEP THIS CALLOUT.) Every code block below\n\u003e is an **example snippet**, **not part of the llamapress.ai codebase**, and **not\n\u003e running on this server**. This is a reference recipe for a **Leo instance (an AI coding\n\u003e agent) to implement in its own app** — read it to understand the pattern, then recreate\n\u003e it there.\n\nEach user of your app clicks **\"Connect Google Calendar\"**, approves access on\nGoogle's consent screen, and from then on their upcoming meetings live in a local\n`calendar_events` table — title, start/end time, and the **Meet/Zoom/Teams join\nlink** — refreshed by a background job. The app renders an \"Upcoming meetings\"\nwidget with a working **Join** button, and can hang any other feature off the data\n(agenda emails, \"who are we meeting today\" dashboards, prep reminders, transcription\nbots). Zero gems: plain `Net::HTTP` for both the OAuth token exchange and the\nCalendar API, so it works on any Leo box as-is.\n\nThis is the **per-user** flavor of Google OAuth: every user connects their *own*\nGoogle account, and the app stores a token pair *per user*. (Contrast with the\n[Search Console recipe](/cookbook/google-search-console-integration), where the app\nitself owns one refresh token in `.env`.) The pattern is proven in production on our\nCRM; this guide also fixes five real defects found in that first implementation —\nthey're marked **[fix]** below (and the worst one — snake_case query params that\nGoogle silently ignores — leads the Gotchas section) so you don't re-create them.\n\n\u003e **When to use:** a CRM or client portal that should show each user their own\n\u003e meetings; anything that needs to know \"what's on this user's calendar next\";\n\u003e feeding meeting URLs to a transcription bot.\n\u003e **When not to:** you only need ONE shared/company calendar — embed Google's iframe\n\u003e or use a single service-owned token instead of per-user OAuth. Not for *writing*\n\u003e events either — this recipe uses the read-only scope (see \"How to adapt\" for the\n\u003e write scope).\n\n---\n\n## The 80/20 in one breath\n\n1. In **Google Cloud Console**: create a project → enable the **Google Calendar\n   API** → configure the OAuth consent screen (External, **publish to production**)\n   → create an **OAuth client ID** (type: Web application) with your callback URL as\n   an Authorized redirect URI → put the client ID/secret in `.env`.\n2. Create two tables: `integrations` (one row per user per provider, holds\n   `access_token`, `refresh_token`, `token_expires_at`) and `calendar_events`\n   (synced copies of upcoming events, unique on `user_id` + `google_event_id`).\n3. Add an `Auth::GoogleController` with three actions: `authorize` (redirect to\n   Google with `access_type=offline\u0026prompt=consent` and a CSRF `state` token),\n   `callback` (verify state, exchange the code for tokens, save the integration,\n   enqueue a sync), `disconnect`.\n4. Add `SyncCalendarEventsJob`: refresh the access token if it's near expiry, GET\n   `calendars/primary/events` for the next 30 days, **upsert** each event (create\n   new, update changed), delete local events that vanished, extract the meeting URL.\n5. Re-enqueue the job on a timer (self-rescheduling loop or your scheduler) so the\n   data stays fresh without anyone clicking anything.\n6. Render a connect/disconnect card in settings and an \"Upcoming meetings\" list from\n   `current_user.calendar_events.upcoming`.\n\n---\n\n## Layer 0 — Google Cloud setup (the part outside your codebase)\n\nThis happens once, in the browser, by a human (or by you walking the human through\nit). Nothing below works until this is done.\n\n1. **Project**: console.cloud.google.com → select or create a project.\n2. **Enable the API**: APIs \u0026 Services → **Library** → search \"Google Calendar API\"\n   → **Enable**. (Skipping this yields a 403 later even with perfect OAuth.)\n3. **Consent screen**: APIs \u0026 Services → **OAuth consent screen** → User type\n   **External** (unless every user is on your own Google Workspace). Add the scope\n   `https://www.googleapis.com/auth/calendar.readonly`. Then **Publish to\n   production**. A consent screen left in **\"Testing\" expires every refresh token\n   after 7 days** — the #1 cause of \"it worked last week, now everyone is\n   disconnected.\"\n4. **Create the OAuth client ID**: APIs \u0026 Services → **Credentials** → **Create\n   Credentials → OAuth client ID**:\n   - **Application type:** `Web application`.\n   - **Authorized redirect URIs:** the EXACT callback URL your app will handle:\n     ```\n     https://yourapp.example.com/google_oauth/callback\n     ```\n     Add `http://localhost:3000/google_oauth/callback` too if you test locally.\n     Google matches the string **exactly** — scheme, host, port, path. A mismatch\n     fails with `Error 400: redirect_uri_mismatch`.\n5. Copy the **Client ID** and **Client secret** into `.env`:\n\n```bash\n# .env\nGOOGLE_CLIENT_ID=1234567890-abc123.apps.googleusercontent.com\nGOOGLE_CLIENT_SECRET=GOCSPX-xxxxxxxxxxxx\n```\n\nThen **recreate** the container so the new env vars are actually loaded —\n`docker compose up -d --force-recreate web` (or your app service's name). A plain\n`docker compose restart` does **not** reload `.env`.\n\n---\n\n## Layer 1 — Model \u0026 SQL\n\nTwo tables. `integrations` is deliberately generic (a `provider` string, a JSONB\n`settings` bag) so the same table can later hold other per-user connections without\na new migration.\n\n```ruby\n# db/migrate/XXXXXXXXXXXXXX_create_integrations.rb\nclass CreateIntegrations \u003c ActiveRecord::Migration[8.0]\n  def change\n    create_table :integrations do |t|\n      t.references :user, null: false, foreign_key: true\n      t.string   :provider, null: false            # \"google_calendar\"\n      t.text     :access_token\n      t.text     :refresh_token\n      t.datetime :token_expires_at\n      t.string   :calendar_email                   # which Google account is connected\n      t.jsonb    :settings, default: {}            # last_synced_at, scopes, sync errors\n      t.timestamps\n    end\n    add_index :integrations, [:user_id, :provider], unique: true\n  end\nend\n```\n\n```ruby\n# db/migrate/XXXXXXXXXXXXXX_create_calendar_events.rb\nclass CreateCalendarEvents \u003c ActiveRecord::Migration[8.0]\n  def change\n    create_table :calendar_events do |t|\n      t.references :user, null: false, foreign_key: true\n      t.references :integration, null: false, foreign_key: true\n      t.string   :google_event_id, null: false\n      t.string   :title\n      t.text     :description\n      t.datetime :start_time, null: false\n      t.datetime :end_time\n      t.string   :meeting_url                      # Meet/Zoom/Teams join link, if any\n      t.boolean  :all_day, default: false\n      t.jsonb    :raw_data                         # the full Google event, for anything else you need later\n      t.timestamps\n    end\n    add_index :calendar_events, [:user_id, :google_event_id], unique: true\n    add_index :calendar_events, :start_time\n  end\nend\n```\n\n```ruby\n# app/models/integration.rb\nclass Integration \u003c ApplicationRecord\n  belongs_to :user\n  has_many :calendar_events, dependent: :destroy\n\n  validates :provider, presence: true, inclusion: { in: %w[google_calendar] }\n  validates :provider, uniqueness: { scope: :user_id, message: \"already connected for this user\" }\n  validates :access_token, presence: true\n\n  scope :connected,       -\u003e { where.not(access_token: nil) }\n  scope :google_calendar, -\u003e { where(provider: \"google_calendar\") }\n\n  def connected?\n    access_token.present? \u0026\u0026 !needs_reconnect?\n  end\n\n  # Refresh a little EARLY — an access token that expires mid-request still 401s.\n  def token_stale?\n    token_expires_at.blank? || token_expires_at \u003c 5.minutes.from_now\n  end\n\n  # [fix] Set when Google says invalid_grant (user revoked access, or the consent\n  # screen was in Testing and the refresh token aged out). The UI reads this to show\n  # a \"Reconnect\" button instead of silently showing stale data forever.\n  def needs_reconnect?\n    refresh_token.blank? || settings[\"needs_reconnect\"] == true\n  end\n\n  def last_synced_at\n    settings[\"last_synced_at\"] \u0026\u0026 Time.zone.parse(settings[\"last_synced_at\"].to_s)\n  end\nend\n```\n\n```ruby\n# app/models/calendar_event.rb\nclass CalendarEvent \u003c ApplicationRecord\n  belongs_to :user\n  belongs_to :integration\n\n  validates :google_event_id, presence: true, uniqueness: { scope: :user_id }\n  validates :start_time, presence: true\n  validates :title, presence: true\n\n  scope :upcoming, -\u003e { where(\"start_time \u003e ?\", Time.current).order(start_time: :asc) }\n  scope :today,    -\u003e { where(start_time: Time.current.all_day) }\n  scope :recent,   -\u003e(limit = 5) { upcoming.limit(limit) }\n\n  def has_meeting? = meeting_url.present?\nend\n```\n\n\u003e **Do NOT reach for `encrypts :access_token` here.** Rails Active Record encryption\n\u003e keys derive from the app's secrets; on LlamaPress instances the secret key base can\n\u003e rotate across a sleep/wake cycle, which makes every encrypted column permanently\n\u003e unreadable (`ActiveRecord::Encryption::Errors::Decryption`). Plain `text` columns\n\u003e in the instance's own Postgres are the pragmatic choice on this platform. If you do\n\u003e encrypt, you must also pin the encryption keys somewhere that survives restores.\n\n## Layer 2 — Controller (the OAuth dance) \u0026 routes\n\n```ruby\n# config/routes.rb (add inside the draw block)\nget    \"/google_oauth/authorize\",  to: \"auth/google#authorize\",  as: :auth_google\nget    \"/google_oauth/callback\",   to: \"auth/google#callback\",   as: :auth_google_callback\ndelete \"/google_oauth/disconnect\", to: \"auth/google#disconnect\", as: :auth_google_disconnect\n```\n\n```ruby\n# app/controllers/auth/google_controller.rb\nclass Auth::GoogleController \u003c ApplicationController\n  before_action :authenticate_user!\n\n  GOOGLE_AUTH_URL  = \"https://accounts.google.com/o/oauth2/auth\"\n  GOOGLE_TOKEN_URL = \"https://oauth2.googleapis.com/token\"\n  # Read-only calendar access. Swap for .../auth/calendar.events if you also create events.\n  GOOGLE_CALENDAR_SCOPE = \"https://www.googleapis.com/auth/calendar.readonly\"\n\n  # Step 1: send the user to Google's consent screen.\n  def authorize\n    state_token = SecureRandom.hex(24)\n    session[:google_oauth_state] = state_token\n\n    query = {\n      client_id: ENV[\"GOOGLE_CLIENT_ID\"],\n      redirect_uri: auth_google_callback_url,\n      response_type: \"code\",\n      scope: GOOGLE_CALENDAR_SCOPE,\n      access_type: \"offline\",   # REQUIRED to get a refresh_token at all\n      prompt: \"consent\",        # REQUIRED to get a refresh_token on RE-connects (see Gotchas)\n      state: state_token\n    }\n\n    redirect_to \"#{GOOGLE_AUTH_URL}?#{query.to_query}\", allow_other_host: true\n  end\n\n  # Step 2: Google redirects back here with ?code=...\u0026state=...\n  def callback\n    if params[:state] != session[:google_oauth_state]\n      return redirect_to root_path, alert: \"Security check failed. Please try again.\"\n    end\n    session.delete(:google_oauth_state)\n\n    if params[:error].present? # user clicked \"Cancel\" on the consent screen\n      return redirect_to root_path, alert: \"Google Calendar connection was cancelled.\"\n    end\n\n    token_data = exchange_code_for_tokens(params[:code])\n    unless token_data\n      return redirect_to root_path, alert: \"Failed to connect Google Calendar. Please try again.\"\n    end\n\n    integration = current_user.integrations.find_or_initialize_by(provider: \"google_calendar\")\n    integration.update!(\n      access_token: token_data[\"access_token\"],\n      # [fix] Keep the OLD refresh token if Google didn't send a new one — it only\n      # sends refresh_token on some exchanges; overwriting with nil bricks the sync.\n      refresh_token: token_data[\"refresh_token\"].presence || integration.refresh_token,\n      token_expires_at: Time.current + token_data[\"expires_in\"].to_i.seconds,\n      calendar_email: fetch_calendar_email(token_data[\"access_token\"]),\n      settings: integration.settings.merge(\"scopes\" =\u003e token_data[\"scope\"], \"needs_reconnect\" =\u003e false)\n    )\n\n    SyncCalendarEventsJob.perform_later(integration.id)\n    redirect_to root_path, notice: \"Google Calendar connected! Your meetings will appear shortly.\"\n  rescue =\u003e e\n    Rails.logger.error(\"Google OAuth callback error: #{e.class}: #{e.message}\")\n    redirect_to root_path, alert: \"Something went wrong connecting your calendar. Please try again.\"\n  end\n\n  def disconnect\n    integration = current_user.integrations.google_calendar.first\n    if integration\n      integration.destroy # calendar_events go with it (dependent: :destroy)\n      redirect_to root_path, notice: \"Google Calendar disconnected.\"\n    else\n      redirect_to root_path, alert: \"No Google Calendar connection found.\"\n    end\n  end\n\n  private\n\n  def exchange_code_for_tokens(code)\n    response = Net::HTTP.post_form(URI(GOOGLE_TOKEN_URL), {\n      code: code,\n      client_id: ENV[\"GOOGLE_CLIENT_ID\"],\n      client_secret: ENV[\"GOOGLE_CLIENT_SECRET\"],\n      redirect_uri: auth_google_callback_url, # must EXACTLY match the one used in #authorize\n      grant_type: \"authorization_code\"\n    })\n    return JSON.parse(response.body) if response.is_a?(Net::HTTPOK)\n\n    Rails.logger.error(\"Google token exchange failed: #{response.body}\")\n    nil\n  end\n\n  # [fix] Ask the CALENDAR API which account this is. The primary calendar's id IS\n  # the account email, and it's inside the calendar.readonly scope we already hold.\n  # (Calling the /oauth2/v2/userinfo endpoint instead FAILS here — it needs the\n  # separate \"email\" scope, and the failure is a silent nil.)\n  def fetch_calendar_email(access_token)\n    uri = URI(\"https://www.googleapis.com/calendar/v3/calendars/primary\")\n    http = Net::HTTP.new(uri.host, uri.port)\n    http.use_ssl = true\n    request = Net::HTTP::Get.new(uri)\n    request[\"Authorization\"] = \"Bearer #{access_token}\"\n    response = http.request(request)\n    response.is_a?(Net::HTTPOK) ? JSON.parse(response.body)[\"id\"] : nil\n  rescue =\u003e e\n    Rails.logger.error(\"Failed to fetch calendar email: #{e.message}\")\n    nil\n  end\nend\n```\n\n## Layer 3 — The sync job (zero-gem Net::HTTP)\n\nOne job, two modes: with an `integration_id` it syncs one user (used right after\nconnect and for a \"Sync now\" button); with no argument it sweeps every connected\nintegration (used by the scheduler).\n\n```ruby\n# app/jobs/sync_calendar_events_job.rb\nclass SyncCalendarEventsJob \u003c ApplicationJob\n  queue_as :default\n\n  GOOGLE_CALENDAR_API = \"https://www.googleapis.com/calendar/v3\"\n  SYNC_WINDOW = 30.days\n  PAGE_SIZE = 250\n\n  def perform(integration_id = nil)\n    if integration_id\n      sync_integration(Integration.find(integration_id))\n    else\n      Integration.google_calendar.connected.find_each { |i| sync_integration(i) }\n    end\n  end\n\n  private\n\n  def sync_integration(integration)\n    return if integration.needs_reconnect?\n\n    access_token = fresh_access_token(integration)\n    return unless access_token\n\n    events = fetch_upcoming_events(access_token)\n    return unless events\n\n    seen_ids = []\n    events.each do |event|\n      next if event[\"status\"] == \"cancelled\"\n      seen_ids \u003c\u003c event[\"id\"]\n      upsert_event(integration, event)\n    end\n\n    # Anything we had locally that Google no longer returns in the window was\n    # deleted (or declined off) — mirror that.\n    integration.calendar_events\n               .where(start_time: Time.current..(Time.current + SYNC_WINDOW))\n               .where.not(google_event_id: seen_ids)\n               .destroy_all\n\n    integration.update!(settings: integration.settings.merge(\"last_synced_at\" =\u003e Time.current.iso8601))\n  rescue =\u003e e\n    Rails.logger.error(\"Calendar sync failed for integration #{integration.id}: #{e.class}: #{e.message}\")\n  end\n\n  # [fix] UPSERT, don't skip-if-known. The first version of this pattern skipped any\n  # event id it had already stored — so when a meeting was MOVED or renamed, the app\n  # kept showing the old time forever. find_or_initialize + assign fixes that class\n  # of bug for every field at once.\n  def upsert_event(integration, event)\n    start_raw = event.dig(\"start\", \"dateTime\") || event.dig(\"start\", \"date\")\n    end_raw   = event.dig(\"end\", \"dateTime\")   || event.dig(\"end\", \"date\")\n    all_day   = event.dig(\"start\", \"date\").present?\n\n    record = integration.calendar_events.find_or_initialize_by(google_event_id: event[\"id\"])\n    record.assign_attributes(\n      user: integration.user,\n      title: event[\"summary\"].presence || \"Untitled event\",\n      description: event[\"description\"],\n      start_time: Time.zone.parse(start_raw),\n      end_time: end_raw ? Time.zone.parse(end_raw) : nil,\n      all_day: all_day,\n      meeting_url: extract_meeting_url(event),\n      raw_data: event\n    )\n    record.save!\n  end\n\n  # Refresh when stale; on invalid_grant flag the integration for reconnect instead\n  # of erroring on every sweep forever.\n  def fresh_access_token(integration)\n    return integration.access_token unless integration.token_stale?\n    return nil if integration.refresh_token.blank?\n\n    response = Net::HTTP.post_form(URI(\"https://oauth2.googleapis.com/token\"), {\n      refresh_token: integration.refresh_token,\n      client_id: ENV[\"GOOGLE_CLIENT_ID\"],\n      client_secret: ENV[\"GOOGLE_CLIENT_SECRET\"],\n      grant_type: \"refresh_token\"\n    })\n\n    if response.is_a?(Net::HTTPOK)\n      data = JSON.parse(response.body)\n      integration.update!(\n        access_token: data[\"access_token\"],\n        token_expires_at: Time.current + data[\"expires_in\"].to_i.seconds,\n        # Google occasionally rotates the refresh token — persist it when present.\n        refresh_token: data[\"refresh_token\"].presence || integration.refresh_token\n      )\n      data[\"access_token\"]\n    else\n      Rails.logger.error(\"Token refresh failed for integration #{integration.id}: #{response.body}\")\n      # [fix] invalid_grant = the user revoked access, or a Testing-mode consent\n      # screen expired the token. Mark it so the UI can ask them to reconnect.\n      if response.body.include?(\"invalid_grant\")\n        integration.update!(settings: integration.settings.merge(\"needs_reconnect\" =\u003e true))\n      end\n      nil\n    end\n  end\n\n  # [fix] Follows nextPageToken — a busy calendar (or a small maxResults) silently\n  # truncated the first version of this fetch.\n  def fetch_upcoming_events(access_token)\n    items = []\n    page_token = nil\n\n    loop do\n      query = {\n        timeMin: Time.current.iso8601,\n        timeMax: (Time.current + SYNC_WINDOW).iso8601,\n        singleEvents: true,       # expand recurring events into instances\n        orderBy: \"startTime\",\n        maxResults: PAGE_SIZE\n      }\n      query[:pageToken] = page_token if page_token\n\n      uri = URI(\"#{GOOGLE_CALENDAR_API}/calendars/primary/events\")\n      uri.query = URI.encode_www_form(query)\n      http = Net::HTTP.new(uri.host, uri.port)\n      http.use_ssl = true\n      request = Net::HTTP::Get.new(uri)\n      request[\"Authorization\"] = \"Bearer #{access_token}\"\n\n      response = http.request(request)\n      unless response.is_a?(Net::HTTPOK)\n        Rails.logger.error(\"Calendar API error: #{response.code} - #{response.body}\")\n        return nil\n      end\n\n      data = JSON.parse(response.body)\n      items.concat(data[\"items\"] || [])\n      page_token = data[\"nextPageToken\"]\n      break if page_token.blank?\n    end\n\n    items\n  end\n\n  # Best-effort join-link extraction: structured conferenceData first, then URL\n  # patterns in the description/location, then hangoutLink.\n  def extract_meeting_url(event)\n    entry_points = event.dig(\"conferenceData\", \"entryPoints\") || []\n    video = entry_points.find { |ep| ep[\"entryPointType\"] == \"video\" }\n    return video[\"uri\"] if video\n\n    text = [event[\"description\"], event[\"location\"], event[\"hangoutLink\"]].compact.join(\" \")\n    [\n      %r{https://meet\\.google\\.com/[a-z\\-]+},\n      %r{https://[\\w\\-]*\\.?zoom\\.us/j/[\\w?=\\-]+},\n      %r{https://teams\\.microsoft\\.com/l/meetup-join/[\\w%./\\-]+}\n    ].each do |pattern|\n      match = text.match(pattern)\n      return match[0] if match\n    end\n\n    event[\"hangoutLink\"]\n  end\nend\n```\n\n### Keeping it fresh (the step the first implementation forgot)\n\nThe job above only runs when something enqueues it. Without a recurring trigger, the\ncalendar is a snapshot from the moment the user connected — permanently. Pick ONE:\n\n```ruby\n# Option A — self-rescheduling sweep (works with any ActiveJob backend, zero config).\n# Kick it off ONCE from the console: CalendarSyncLoopJob.perform_later\n# app/jobs/calendar_sync_loop_job.rb\nclass CalendarSyncLoopJob \u003c ApplicationJob\n  queue_as :default\n\n  def perform\n    SyncCalendarEventsJob.perform_now\n  ensure\n    # Always re-arm, even if the sweep raised — a dead loop is silent staleness.\n    self.class.set(wait: 15.minutes).perform_later\n  end\nend\n```\n\n```yaml\n# Option B — if the app runs Solid Queue, declare it in config/recurring.yml instead:\nproduction:\n  calendar_sync:\n    class: SyncCalendarEventsJob\n    schedule: every 15 minutes\n```\n\nGuard against the loop doubling up (two console kickoffs = two loops): before\nkicking off Option A, check\n`SolidQueue::Job.where(class_name: \"CalendarSyncLoopJob\", finished_at: nil).none?`\n(or your backend's equivalent), or just use Option B where available.\n\n## Layer 4 — The View\n\nTwo pieces: a connect/disconnect card (settings page) and the upcoming-meetings\nwidget (dashboard). Tailwind, no JS needed.\n\n```erb\n\u003c%# app/views/settings/_google_calendar_card.html.erb %\u003e\n\u003c% integration = current_user.integrations.google_calendar.first %\u003e\n\u003cdiv class=\"rounded-lg border border-slate-200 bg-white p-4\"\u003e\n  \u003cdiv class=\"flex items-center justify-between gap-4\"\u003e\n    \u003cdiv\u003e\n      \u003ch3 class=\"font-semibold text-slate-900\"\u003eGoogle Calendar\u003c/h3\u003e\n      \u003c% if integration\u0026.connected? %\u003e\n        \u003cp class=\"text-sm text-slate-500\"\u003e\n          Connected as \u003c%= integration.calendar_email || \"your Google account\" %\u003e\n          \u003c% if integration.last_synced_at %\u003e\n            · synced \u003c%= time_ago_in_words(integration.last_synced_at) %\u003e ago\n          \u003c% end %\u003e\n        \u003c/p\u003e\n      \u003c% elsif integration\u0026.needs_reconnect? %\u003e\n        \u003cp class=\"text-sm text-amber-600\"\u003eConnection expired — please reconnect.\u003c/p\u003e\n      \u003c% else %\u003e\n        \u003cp class=\"text-sm text-slate-500\"\u003eShow your upcoming meetings inside the app.\u003c/p\u003e\n      \u003c% end %\u003e\n    \u003c/div\u003e\n    \u003cdiv class=\"flex items-center gap-2\"\u003e\n      \u003c% if integration\u0026.connected? %\u003e\n        \u003c%= button_to \"Sync now\", sync_calendar_path, method: :post,\n              class: \"rounded-md border border-slate-300 px-3 py-1.5 text-sm text-slate-700 hover:bg-slate-50\" %\u003e\n        \u003c%= button_to \"Disconnect\", auth_google_disconnect_path, method: :delete,\n              data: { turbo_confirm: \"Disconnect Google Calendar? Synced events will be removed.\" },\n              class: \"rounded-md border border-slate-300 px-3 py-1.5 text-sm text-slate-700 hover:bg-slate-50\" %\u003e\n      \u003c% else %\u003e\n        \u003c%= link_to \"Connect Google Calendar\", auth_google_path,\n              class: \"rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700\" %\u003e\n      \u003c% end %\u003e\n    \u003c/div\u003e\n  \u003c/div\u003e\n\u003c/div\u003e\n```\n\nThe \"Sync now\" button needs a one-line endpoint (rate-limit it if your users are\nclick-happy):\n\n```ruby\n# config/routes.rb\npost \"/calendar/sync\", to: \"calendars#sync\", as: :sync_calendar\n\n# app/controllers/calendars_controller.rb\nclass CalendarsController \u003c ApplicationController\n  before_action :authenticate_user!\n\n  def sync\n    integration = current_user.integrations.google_calendar.connected.first\n    SyncCalendarEventsJob.perform_later(integration.id) if integration\n    redirect_back fallback_location: root_path, notice: \"Sync started — refresh in a few seconds.\"\n  end\nend\n```\n\n```erb\n\u003c%# app/views/shared/_upcoming_meetings.html.erb %\u003e\n\u003c% events = current_user.calendar_events.recent(5) %\u003e\n\u003cdiv class=\"rounded-lg border border-slate-200 bg-white\"\u003e\n  \u003cdiv class=\"border-b border-slate-100 px-4 py-3\"\u003e\n    \u003ch3 class=\"font-semibold text-slate-900\"\u003eUpcoming meetings\u003c/h3\u003e\n  \u003c/div\u003e\n  \u003c% if events.any? %\u003e\n    \u003cul class=\"divide-y divide-slate-100\"\u003e\n      \u003c% events.each do |event| %\u003e\n        \u003cli class=\"flex items-center justify-between gap-3 px-4 py-3\"\u003e\n          \u003cdiv class=\"min-w-0\"\u003e\n            \u003cp class=\"truncate font-medium text-slate-800\"\u003e\u003c%= event.title %\u003e\u003c/p\u003e\n            \u003cp class=\"text-sm text-slate-500\"\u003e\n              \u003c% if event.all_day %\u003e\n                \u003c%= event.start_time.strftime(\"%a %b %-d\") %\u003e · all day\n              \u003c% else %\u003e\n                \u003c%= event.start_time.strftime(\"%a %b %-d, %-l:%M %p\") %\u003e\n              \u003c% end %\u003e\n            \u003c/p\u003e\n          \u003c/div\u003e\n          \u003c% if event.has_meeting? %\u003e\n            \u003c%= link_to \"Join\", event.meeting_url, target: \"_blank\", rel: \"noopener\",\n                  class: \"shrink-0 rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700\" %\u003e\n          \u003c% end %\u003e\n        \u003c/li\u003e\n      \u003c% end %\u003e\n    \u003c/ul\u003e\n  \u003c% else %\u003e\n    \u003cp class=\"px-4 py-6 text-sm text-slate-500\"\u003eNo upcoming meetings in the next 30 days.\u003c/p\u003e\n  \u003c% end %\u003e\n\u003c/div\u003e\n```\n\n---\n\n## Gotchas (the hard-won stuff)\n\n- **Google's query params are camelCase — and unknown params are silently IGNORED.**\n  `time_min`, `single_events`, `order_by`, `max_results` don't error: Google drops\n  them and applies its defaults, so the \"next 30 days\" fetch actually returns the\n  OLDEST events in the calendar, unfiltered, unordered, recurring events unexpanded.\n  The production CRM ran this way undetected — 50 stale events stored, zero upcoming,\n  and the widget looked \"empty\" rather than broken. Always `timeMin`, `timeMax`,\n  `singleEvents`, `orderBy`, `maxResults`, and verify the FIRST sync stores events\n  with future start times.\n- **No refresh token? It's `access_type` + `prompt`.** Google only issues a\n  `refresh_token` when the auth request carries `access_type=offline`, and on\n  RE-authorizations it only re-issues one if you also send `prompt=consent`. Miss\n  either and the integration works for exactly one hour, then dies at the first\n  refresh. And even WITH `prompt=consent`, defensively keep the old refresh token\n  when the token response omits one (the callback code above does).\n- **Consent screen in \"Testing\" mode = every refresh token dies after 7 days.**\n  Publish the OAuth consent screen to production. This failure looks exactly like\n  users randomly disconnecting a week after they connect.\n- **`redirect_uri_mismatch` on a Leo/LlamaPress box usually means the app generated\n  a `localhost` or `http://` callback URL.** `auth_google_callback_url` builds from\n  the request/host config; behind the platform proxy that can come out as\n  `http://localhost:3000/...`, which will never match the registered URI. Fix the\n  app's `default_url_options` (host + `protocol: \"https\"`) for production, and\n  register the exact public URL in Google Cloud. Compare the `redirect_uri` in the\n  failing Google URL character-by-character with the console entry.\n- **Upsert, never skip-if-known.** The original implementation skipped any\n  `google_event_id` it had seen before, so a meeting moved from 2pm to 4pm showed\n  2pm forever. Sync means \"make local match remote,\" not \"insert what's new.\"\n- **Don't call `/oauth2/v2/userinfo` to learn which account connected.** That\n  endpoint needs the `email`/`profile` scope; with only `calendar.readonly` it\n  401s — and if you wrapped it in a rescue, it fails silently and your\n  \"Connected as …\" line is blank forever. `GET /calendar/v3/calendars/primary`\n  returns the account email as `id` using the scope you already have.\n- **`invalid_grant` on refresh is a STATE, not an error to retry.** The user revoked\n  access in their Google account settings, or a Testing-mode token aged out.\n  Retrying every sweep just spams logs. Flag the integration (`needs_reconnect`),\n  surface a Reconnect button, and skip it in sweeps until the user re-consents.\n- **All-day events come as `start.date` (no time, no zone).** `Time.zone.parse(\"2026-08-19\")`\n  pins it to midnight in the APP's zone — fine for display, but remember the row's\n  `start_time` is a zone-dependent interpretation. Keep the `all_day` boolean and\n  branch on it when formatting, and don't build \"meetings in the next hour\" alerts\n  off all-day rows.\n- **Always request `singleEvents: true`.** Without it, a weekly recurring meeting\n  returns as ONE master event with recurrence rules you'd have to expand yourself.\n  With it, Google expands instances for you (that's also what makes `orderBy:\n  \"startTime\"` legal — it errors without `singleEvents`).\n- **Scope stale-event deletion to the sync window.** The job fetches the next 30\n  days only, so it may only delete local rows *inside* that window. Delete\n  everything Google didn't return and you'd wipe past events (if you keep history)\n  the moment they age out of the window.\n- **`.env` edits need a container RECREATE, not a restart.** `docker compose\n  restart` reuses the old process environment; `docker compose up -d\n  --force-recreate \u003cservice\u003e` picks up new values. Symptom of forgetting:\n  `The OAuth client was not found` / `invalid_client` because `GOOGLE_CLIENT_ID`\n  is still nil inside the container.\n- **The CSRF `state` check is load-bearing.** Without it, an attacker can complete\n  the callback with *their* code and attach *their* calendar to the victim's\n  account (or vice versa). Generate per-request, store in session, compare, delete.\n- **Google API responses can be large — store `raw_data`, but index what you\n  query.** The JSONB `raw_data` column keeps attendees, organizer, recurrence, etc.\n  available without another API round-trip, while queries run against the extracted\n  columns (`start_time` is indexed for the `upcoming` scope).\n\n---\n\n## Files this pattern touches\n\n```\ndb/migrate/XXXXXXXXXXXXXX_create_integrations.rb\ndb/migrate/XXXXXXXXXXXXXX_create_calendar_events.rb\napp/models/integration.rb\napp/models/calendar_event.rb\napp/controllers/auth/google_controller.rb\napp/controllers/calendars_controller.rb\napp/jobs/sync_calendar_events_job.rb\napp/jobs/calendar_sync_loop_job.rb            (Option A recurring sync only)\nconfig/recurring.yml                          (Option B recurring sync only)\napp/views/settings/_google_calendar_card.html.erb\napp/views/shared/_upcoming_meetings.html.erb\nconfig/routes.rb\n.env                                          (GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET)\n```\n\n## How to adapt to your schema\n\n1. **Different user model / no Devise:** the pattern only needs `current_user` and a\n   `user_id` foreign key. Swap `authenticate_user!` for your auth check.\n2. **Company-wide calendar instead of per-user:** keep one `Integration` row owned\n   by an admin, drop `user_id` from `calendar_events`, and render the widget for\n   everyone. The OAuth flow is unchanged — one person connects once.\n3. **Creating events (booking, scheduling):** change the scope to\n   `https://www.googleapis.com/auth/calendar.events` (read + write), re-run the\n   consent flow (scope changes require re-consent), and POST to\n   `#{GOOGLE_CALENDAR_API}/calendars/primary/events` with a JSON body. Keep the\n   readonly scope if you only display — smaller scopes get easier Google review and\n   more user trust.\n4. **More calendars than `primary`:** `GET /users/me/calendarList` enumerates all\n   the user's calendars; loop the sync over each `calendarList` entry's `id` and add\n   a `calendar_id` column to `calendar_events`.\n5. **Very busy calendars / lower quota use:** replace the 30-day window fetch with\n   Google's incremental sync — store the `nextSyncToken` from a full fetch in\n   `integration.settings`, pass it as `syncToken` on later runs, and handle the\n   `410 Gone` that tells you to do a fresh full sync.\n6. **Safe to drop:** the meeting-URL extraction (if you never show Join buttons),\n   `raw_data` (if storage matters more than flexibility), the \"Sync now\" button, and\n   the `calendar_email` lookup — none of them are load-bearing for the core sync.\n"}