Google Calendar Import Service

This service requires a Google Workspace Service Account with delegated access.

The Google Calendar import service pulls Google Workspace calendar data into Tempr's internal Calendar module.

This page documents the import path itself. For webhook setup and channel renewal, see Google Calendar Webhooks.

What The Import Does

The import service is implemented by:

src/Modules/Calendar/Services/GoogleCalendarImportService.php

It imports Google calendars and events into:

  • Calendar
  • CalendarSource
  • CalendarEvent
  • CalendarEventInvite
  • CalendarEventLink

The service is intentionally provider-specific at the edge. Once a Google event is normalized, the write path updates Tempr's internal calendar models so later workflows can treat CalendarEvent.php as the application model.

Entry Points

Entry pointPurpose
calendar:import-googleManual import command for one user, selected users, or Workspace users.
ImportGoogleWorkspaceUserCalendarsQueue job that resolves a user's Google calendars and dispatches page jobs.
ImportGoogleCalendarPageQueue job that imports one page of events for one Google calendar source.
GoogleCalendarWebhookControllerQueues ImportGoogleCalendarPage with sync-token mode after Google sends a change notification.

Required Configuration

The main config lives in:

src/Modules/Calendar/config/calendar.php

Relevant environment variables:

.env
CALENDAR_IMPORT_QUEUE=default
CALENDAR_GOOGLE_CREDENTIALS_JSON=/absolute/path/to/google-service-token.json
CALENDAR_GOOGLE_WORKSPACE_CUSTOMER=my_customer
CALENDAR_GOOGLE_DEFAULT_USER=admin-or-calendar-user@example.com
CALENDAR_GOOGLE_PAGE_SIZE=100

CALENDAR_GOOGLE_CREDENTIALS_JSON must point to service account credentials that can impersonate Workspace users.

CALENDAR_GOOGLE_DEFAULT_USER is used when the command is run without --user or --workspace.

CALENDAR_GOOGLE_PAGE_SIZE controls Google Calendar API page size for calendar list and event list calls.

Delegated Access Scopes

The service account must be configured for Google Workspace domain-wide delegation in the Google Admin console.

Delegate these OAuth scopes to the service account client ID:

https://www.googleapis.com/auth/calendar.readonly
https://www.googleapis.com/auth/calendar.calendarlist.readonly
https://www.googleapis.com/auth/admin.directory.user.readonly

Scope usage:

ScopeUsed for
calendar.readonlyReading Google Calendar event data for imported calendars.
calendar.calendarlist.readonlyDiscovering the calendars visible to an impersonated Google user.
admin.directory.user.readonlyDiscovering active Workspace users when running calendar:import-google --workspace.

The Directory scope is only needed for Workspace-wide discovery. User-specific imports with --user do not call the Admin SDK, but production service accounts normally include all three scopes so the same credentials can support both command modes.

After config changes:

php artisan optimize:clear

Manual Commands

Import the default configured user:

php artisan calendar:import-google

Import one user:

php artisan calendar:import-google --user=scheduler@example.com

Import multiple users:

php artisan calendar:import-google --user=scheduler@example.com --user=dispatch@example.com

Queue imports instead of running inline:

php artisan calendar:import-google --workspace --queue

Use saved Google sync tokens:

php artisan calendar:import-google --user=scheduler@example.com --sync

Count/discover without writing records:

php artisan calendar:import-google --user=scheduler@example.com --dry-run

Import selected Google calendar IDs:

php artisan calendar:import-google --user=scheduler@example.com --calendar=primary --calendar=dispatch@example.com

Include hidden Google calendars:

php artisan calendar:import-google --user=scheduler@example.com --include-hidden

Limit Workspace discovery:

php artisan calendar:import-google --workspace --max-users=25

--queue and --dry-run cannot be used together.

Calendar Selection Behavior

By default, the importer only imports each user's primary Google calendar.

When --calendar is supplied, the importer restricts Google calendar discovery to those external calendar IDs. In that mode, the command prompts for a local Tempr calendar unless it is running as a dry run.

If the local owner does not already have a calendar, the prompt can create one.

Runtime Flow

User Resolution

The importer first resolves the Google email to a local User by matching users.email case-insensitively.

If no local user exists, the import is skipped and no calendar records are written.

Google Calendar Discovery

GoogleCalendarGateway::listUserCalendars() calls Google Calendar's calendar list endpoint through an impersonated Google client.

The discovery rules are:

  • If no specific --calendar IDs are provided, use the first primary calendar.
  • If calendar IDs are provided, import only matching calendars.
  • Hidden calendars are skipped unless --include-hidden is set.

Local Calendar Resolution

The importer either:

  • uses an explicitly selected local calendar
  • finds the local user's existing Calendar
  • creates a user-owned Calendar

When it prepares a calendar for Google import, it can fill missing timezone and color from the primary Google calendar and writes Google owner metadata to Calendar.meta.google.

CalendarSource Sync

Each imported Google calendar gets a CalendarSource row where:

  • source is google
  • external_calendar_id is the Google calendar ID
  • external_owner_email is the impersonated Google user
  • remote name, timezone, access role, hidden state, primary state, color, and raw Google calendar payload are stored
  • sync_token is updated after the final event page
  • last_synced_at is updated after the final event page

CalendarSource is the durable bridge between a Google calendar and a local Tempr calendar.

Event Page Import

ImportGoogleCalendarPage calls:

GoogleCalendarImportService::importCalendarPage(...)

If Google returns next_page_token, the job dispatches another ImportGoogleCalendarPage for the next page.

When the final page is imported, the source's sync_token and last_synced_at are saved.

Sync Tokens

When useSyncToken is true and this is the first page of a run, the importer passes the saved CalendarSource.sync_token to Google.

If Google returns HTTP 410 for an expired sync token, the importer clears the saved token and retries without it. That retry becomes a full event list import.

This makes webhook-triggered delta imports resilient to token expiration without requiring manual cleanup.

CalendarEvent Mapping

Imported events are matched by:

source = google
external_calendar_id = CalendarSource.external_calendar_id
external_event_id = Google event id

The importer writes these notable fields on CalendarEvent:

  • calendar_id
  • calendar_source_id
  • source
  • external_calendar_id
  • external_event_id
  • external_recurring_event_id
  • google_organizer_email
  • google_status
  • google_event_type
  • html_link
  • title
  • description
  • location
  • starts_at
  • ends_at
  • all_day
  • timezone
  • status
  • visibility
  • recurrence_rule
  • recurrence_until
  • source_updated_at
  • search_text
  • uid
  • meta.google
  • meta.google_owner_email
  • meta.google_i_cal_uid

Cancelled Google events are only imported if a matching local event already exists. New cancelled events are skipped.

If an existing local imported event was soft-deleted and Google later sends it as non-cancelled, the importer restores it.

UID Handling

If an existing event already has a uid, the importer keeps it.

For new events, the importer prefers Google's iCalUID when it does not conflict with another event.

If the Google iCalUID is missing or already used, the importer creates a stable Tempr UID:

google-{sha1-prefix}@calendar.tempr

This avoids import failures when Google repeats an iCalUID across related events.

Invites And Attendees

Google attendees become CalendarEventInvite rows.

The importer:

  • lowercases attendee email addresses
  • stores attendee display names
  • stores Google response status and attendee flags in invite metadata
  • sets send_email=false and send_sms=false for imported invites
  • matches attendees to Customer records by customers.email_address
  • removes stale Google-imported invites that are no longer present on the Google event

Imported invites do not send notifications.

CalendarEventLinkGuesser inspects Google event content and attempts to link events to internal records.

Currently the importer supports guessed links for:

  • projects
  • tickets

Guessed links are stored as CalendarEventLink rows with meta.imported_source = google.

Search Text

The importer builds CalendarEvent.search_text from:

  • source calendar name
  • Google owner email
  • event title
  • event description
  • event location
  • organizer email and name
  • Google HTML link
  • attendee emails and names

This supports broader lookup and search hydration for imported events.

Operational Checks

Confirm the command exists:

php artisan list calendar

Run focused tests:

php artisan test tests/Feature/Calendar/GoogleCalendarImportTest.php

Inspect imported sources:

php artisan tinker
TemprApp\Server\Modules\Calendar\Models\CalendarSource::query()
    ->where('source', 'google')
    ->get([
        'id',
        'calendar_id',
        'external_owner_email',
        'external_calendar_id',
        'remote_name',
        'last_synced_at',
        'sync_token',
    ]);

Inspect imported events:

TemprApp\Server\Modules\Calendar\Models\CalendarEvent::query()
    ->where('source', 'google')
    ->latest('source_updated_at')
    ->limit(10)
    ->get([
        'id',
        'calendar_id',
        'calendar_source_id',
        'external_calendar_id',
        'external_event_id',
        'title',
        'starts_at',
        'ends_at',
        'google_status',
        'source_updated_at',
    ]);

Troubleshooting

User Is Skipped

The local user email must match the Google email.

Check:

App\Models\User::query()->whereRaw('lower(email) = ?', ['scheduler@example.com'])->first();

No Calendars Are Imported

Common causes:

  • The user has no primary calendar visible to the service account.
  • The requested --calendar IDs do not match Google calendar IDs.
  • The calendar is hidden and --include-hidden was not used.
  • Google credentials cannot impersonate the user.

Queue Mode Does Not Import Events

calendar:import-google --queue dispatches jobs. Make sure a queue worker is running for:

CALENDAR_IMPORT_QUEUE=default

Check failed jobs if CalendarSource rows exist but events are missing.

Sync Token Keeps Resetting

Google returns HTTP 410 when a sync token is invalid or expired. The importer clears the token and retries as a full import.

If this happens repeatedly, check whether a job is using the wrong calendar_source_id, wrong external_calendar_id, or stale Google calendar permissions.

Imported Event Is Missing Dates

Events without parseable Google start or end values are skipped unless a matching local event already exists. Existing events keep their previous dates when Google sends an incomplete payload.

Imported Invites Are Not Sending Email Or SMS

This is expected. Google attendees are imported as records with notification flags disabled. Tempr invite delivery should be triggered through internal calendar workflows, not by imported Google attendee data.