Skip to main content

Google Calendar

Calendar has two paths, like Asana and Fathom: a read mirror (calendar-sync) that keeps two different caches current, and a gated write executor (calendar-act) that only creates or changes Google Calendar events after a human approves the specific action.

How it works

Mirror (calendar-sync)

calendar-sync authenticates to Google as a service account, using a JWT-bearer exchange (RS256, signed with crypto.subtle) for an OAuth token scoped calendar.readonly. It reuses the same service-account JSON as Drive (GOOGLE_DRIVE_JSON from the Vault, or GDRIVE_SA_JSON / GOOGLE_DRIVE_JSON env), just requesting a different scope. Reading another person's free/busy data requires domain-wide delegation impersonation via GOOGLE_CALENDAR_SUBJECT.

The function supports three independent request shapes:

  • { dryRun: true } — authenticate and list calendars plus a small event sample; writes nothing.
  • { freeBusy: true } (or refreshAvailability: true) — refresh calendar_busy from Google free/busy. This produces opaque busy/free blocks only — no titles, no attendees.
  • { events: true } — full event-detail ingest into calendar_events, including titles, attendees, and project matching.

Free/busy refresh. For each person with a readable calendar, the sync pulls their busy blocks and writes calendar_availability_status (one row per person/source) plus calendar_busy rows. Per-person read errors are collected as warnings rather than aborting the run — the only fatal condition is zero readable calendars across the whole org. The prune step that removes stale busy rows only deletes rows for people who successfully refreshed this run; it deliberately never prunes org-wide, because an earlier version let one person's calendar error wipe out everyone's cached availability.

Event-detail ingest. For each readable calendar, the sync pages through events (following nextPageToken to completion), and for each event determines project linkage using project codes/names, client names, explicit project signals, external contacts, and attendee emails/domains — in that preference order. If matching is ambiguous or wrong, an admin/lead can set a durable manual override from the Calendar page (see below); overrides live in their own table so the next hourly sync doesn't wipe out the correction. Events are upserted into calendar_events on (org_id, calendar_id, external_id). Stale events are marked cancelled only for calendars that were read successfully and not truncated by pagination — a truncated page is never treated as proof that unseen events were cancelled.

Act executor (calendar-act)

calendar-act executes one approved calendar_event_create, calendar_event_update, or calendar_event_cancel approval per call, using the calendar.events scope and impersonating a per-approval Workspace subject (or GOOGLE_CALENDAR_SUBJECT as a fallback). It requires the same admin/lead-or-cron-with-actorPersonId auth pattern as the other act executors, plus CALENDAR_ACT_WRITES_ENABLED=true. On success it upserts/updates calendar_events and writes an audit row.

Configuration

SettingValue
SecretGoogle service-account JSON (shared with Drive): GOOGLE_DRIVE_JSON / GDRIVE_SA_JSON
ImpersonationGOOGLE_CALENDAR_SUBJECT (domain-wide delegation subject for free/busy reads)
Write gateCALENDAR_ACT_WRITES_ENABLED=true (edge secret; unset by default)
Workspace setupDomain-wide delegation on the existing service account, calendar.readonly for sync + calendar.events for approved writes; per-calendar sharing works for reads only and doesn't scale for cross-team writes
API enablementCalendar API must be enabled on the GCP project (sable-500515)

Cron cadence

TaskScheduleCalls
sable.calendar.freebusyHourly, :40 (40 * * * *)calendar-sync with {freeBusy:true, events:true, days:14}

A legacy pg_cron job (sable-calendar-sync) also exists from before Trigger.dev took over scheduling; only one scheduler should be relied on per connector to avoid overlapping refreshes. calendar-act has no schedule — it only runs against a specific human approval.

Table mapping

TableWritten byContents
calendar_availability_statuscalendar-syncOne row per (org_id, email, source): readable/error/unknown status, window bounds
calendar_busycalendar-syncBusy/free blocks only — no titles, no attendees, ever
calendar_eventscalendar-sync, calendar-actFull event detail: summary, attendees, project match, conference info; unique on (org_id, calendar_id, external_id)
calendar_event_project_overridesCalendar page (via calendar.js)Durable manual project link/unlink per event, checked before automatic matching
activity_eventscalendar-syncIngest activity trail
connector_checkpointscalendar-syncHealth bookkeeping
auditcalendar-actOne row per executed write

What can go wrong

  • The Availability timeline looks fine but the Calendar intelligence panel is empty (or vice versa). They read from different caches: Availability reads calendar_busy + calendar_availability_status (opaque blocks), while the intelligence panel reads calendar_events (full detail). A failure in one request shape (freeBusy vs events) doesn't affect the other.
  • One person's calendar errors used to blank out everyone's availability. This was fixed — the prune step only removes rows for people who refreshed successfully this run. If you see a gap now, it's a genuine per-person read failure, not a prune bug; check calendar_availability_status.error for that person.
  • An event is linked to the wrong project, or should stay unmatched. Set an explicit override from the Calendar page — set_project_override or clear_project_override via calendar.js — rather than fighting the automatic matcher. Overrides are checked first on every future sync.
  • Writes never reach Google Calendar. Check CALENDAR_ACT_WRITES_ENABLED (unset by default), then confirm the approval's kind/status, and that the domain-wide delegation subject used for the write actually has calendar.events scope for that calendar.
  • Free/busy reads fail entirely. Confirm the Calendar API is enabled on the GCP project and that the service account has either domain-wide delegation or an explicit per-calendar share with "See all event details."

Where the code lives

  • supabase/functions/calendar-sync/index.ts — free/busy and event-detail mirror
  • supabase/functions/calendar-act/index.ts — approved write executor
  • web/api/availability.js — team free/busy API (reads calendar_busy + calendar_availability_status)
  • web/api/calendar.js — calendar intelligence API (reads/writes calendar_events, project overrides, event proposals)
  • trigger/connectors.tssable.calendar.freebusy schedule
  • supabase/migrations/0036_calendar_availability.sql, 0040_calendar_availability_status.sql, 20260710135637_calendar_intelligence.sql, 20260710144647_calendar_event_project_overrides.sql