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
- 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. - Create two tables:
integrations(one row per user per provider, holdsaccess_token,refresh_token,token_expires_at) andcalendar_events(synced copies of upcoming events, unique onuser_id+google_event_id). - Add an
Auth::GoogleControllerwith three actions:authorize(redirect to Google withaccess_type=offline&prompt=consentand a CSRFstatetoken),callback(verify state, exchange the code for tokens, save the integration, enqueue a sync),disconnect. - Add
SyncCalendarEventsJob: refresh the access token if it's near expiry, GETcalendars/primary/eventsfor the next 30 days, upsert each event (create new, update changed), delete local events that vanished, extract the meeting URL. - Re-enqueue the job on a timer (self-rescheduling loop or your scheduler) so the data stays fresh without anyone clicking anything.
- 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.
- Project: console.cloud.google.com → select or create a project.
- Enable the API: APIs & Services → Library → search "Google Calendar API" → Enable. (Skipping this yields a 403 later even with perfect OAuth.)
- 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." - 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:
Addhttps://yourapp.example.com/google_oauth/callbackhttp://localhost:3000/google_oauth/callbacktoo if you test locally. Google matches the string exactly — scheme, host, port, path. A mismatch fails withError 400: redirect_uri_mismatch.
- Application type:
- 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_tokenhere. 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). Plaintextcolumns 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_resultsdon'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. AlwaystimeMin,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 arefresh_tokenwhen the auth request carriesaccess_type=offline, and on RE-authorizations it only re-issues one if you also sendprompt=consent. Miss either and the integration works for exactly one hour, then dies at the first refresh. And even WITHprompt=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_mismatchon a Leo/LlamaPress box usually means the app generated alocalhostorhttp://callback URL.auth_google_callback_urlbuilds from the request/host config; behind the platform proxy that can come out ashttp://localhost:3000/..., which will never match the registered URI. Fix the app'sdefault_url_options(host +protocol: "https") for production, and register the exact public URL in Google Cloud. Compare theredirect_uriin the failing Google URL character-by-character with the console entry.- Upsert, never skip-if-known. The original implementation skipped any
google_event_idit 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/userinfoto learn which account connected. That endpoint needs theemail/profilescope; with onlycalendar.readonlyit 401s — and if you wrapped it in a rescue, it fails silently and your "Connected as …" line is blank forever.GET /calendar/v3/calendars/primaryreturns the account email asidusing the scope you already have. invalid_granton 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'sstart_timeis a zone-dependent interpretation. Keep theall_dayboolean 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 makesorderBy: "startTime"legal — it errors withoutsingleEvents). - 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.
.envedits need a container RECREATE, not a restart.docker compose restartreuses 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_clientbecauseGOOGLE_CLIENT_IDis still nil inside the container.- The CSRF
statecheck 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 JSONBraw_datacolumn keeps attendees, organizer, recurrence, etc. available without another API round-trip, while queries run against the extracted columns (start_timeis indexed for theupcomingscope).
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
- Different user model / no Devise: the pattern only needs
current_userand auser_idforeign key. Swapauthenticate_user!for your auth check. - Company-wide calendar instead of per-user: keep one
Integrationrow owned by an admin, dropuser_idfromcalendar_events, and render the widget for everyone. The OAuth flow is unchanged — one person connects once. - 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/eventswith a JSON body. Keep the readonly scope if you only display — smaller scopes get easier Google review and more user trust. - More calendars than
primary:GET /users/me/calendarListenumerates all the user's calendars; loop the sync over eachcalendarListentry'sidand add acalendar_idcolumn tocalendar_events. - Very busy calendars / lower quota use: replace the 30-day window fetch with
Google's incremental sync — store the
nextSyncTokenfrom a full fetch inintegration.settings, pass it assyncTokenon later runs, and handle the410 Gonethat tells you to do a fresh full sync. - 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 thecalendar_emaillookup — none of them are load-bearing for the core sync.