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:
CalendarCalendarSourceCalendarEventCalendarEventInviteCalendarEventLink
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 point | Purpose |
|---|---|
calendar:import-google | Manual import command for one user, selected users, or Workspace users. |
ImportGoogleWorkspaceUserCalendars | Queue job that resolves a user's Google calendars and dispatches page jobs. |
ImportGoogleCalendarPage | Queue job that imports one page of events for one Google calendar source. |
GoogleCalendarWebhookController | Queues 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:
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:
| Scope | Used for |
|---|---|
calendar.readonly | Reading Google Calendar event data for imported calendars. |
calendar.calendarlist.readonly | Discovering the calendars visible to an impersonated Google user. |
admin.directory.user.readonly | Discovering 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
--calendarIDs are provided, use the first primary calendar. - If calendar IDs are provided, import only matching calendars.
- Hidden calendars are skipped unless
--include-hiddenis 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:
sourceisgoogleexternal_calendar_idis the Google calendar IDexternal_owner_emailis the impersonated Google user- remote name, timezone, access role, hidden state, primary state, color, and raw Google calendar payload are stored
sync_tokenis updated after the final event pagelast_synced_atis 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_idcalendar_source_idsourceexternal_calendar_idexternal_event_idexternal_recurring_event_idgoogle_organizer_emailgoogle_statusgoogle_event_typehtml_linktitledescriptionlocationstarts_atends_atall_daytimezonestatusvisibilityrecurrence_rulerecurrence_untilsource_updated_atsearch_textuidmeta.googlemeta.google_owner_emailmeta.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=falseandsend_sms=falsefor imported invites - matches attendees to
Customerrecords bycustomers.email_address - removes stale Google-imported invites that are no longer present on the Google event
Imported invites do not send notifications.
Event Links
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
--calendarIDs do not match Google calendar IDs. - The calendar is hidden and
--include-hiddenwas 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.