# Google Calendar Plugin - Technical Specification ## Overview Native Google Calendar integration for Leantime using OAuth 2.0. Allows users to connect their Google Calendar account and sync events bidirectionally with Leantime. **Scope:** This is a standalone plugin that does NOT touch OIDC (login) or CalDAV (different protocol). --- ## User Experience ### Connection Flow 1. User goes to Settings → Calendar → Google Calendar 2. Clicks "Connect Google Calendar" 3. Redirected to Google consent screen 4. Grants calendar permissions 5. Redirected back to Leantime 6. Selects which calendars to sync 7. Done - events appear in Leantime calendar ### Ongoing Sync - Pull: Google events appear in Leantime calendar view - Push: Leantime events (optionally) sync to Google - Background sync via cron job - Manual sync button in settings --- ## Prerequisites ### Google Cloud Console Setup (Manual - Done Once) 1. Go to https://console.cloud.google.com/ 2. Create new project or select existing 3. Enable Google Calendar API: - APIs & Services → Library → Search "Google Calendar API" → Enable 4. Create OAuth credentials: - APIs & Services → Credentials → Create Credentials → OAuth client ID - Application type: Web application - Name: "Leantime Calendar Integration" - Authorized redirect URIs: `https://your-leantime.com/googlecalendar/callback` 5. Download or copy: - Client ID - Client Secret ### Environment Variables ```env GOOGLE_CALENDAR_CLIENT_ID=your-client-id.apps.googleusercontent.com GOOGLE_CALENDAR_CLIENT_SECRET=your-client-secret GOOGLE_CALENDAR_REDIRECT_URI=${APP_URL}/googlecalendar/callback ``` --- ## Plugin Structure ``` app/Plugins/GoogleCalendar/ ├── Controllers/ │ ├── Connect.php # Initiates OAuth flow │ ├── Callback.php # Handles OAuth callback │ ├── Settings.php # Account management UI │ ├── Sync.php # Manual sync trigger │ └── Disconnect.php # Remove account ├── Services/ │ ├── GoogleCalendar.php # Main service (API calls, sync logic) │ ├── GoogleAuth.php # OAuth token management │ └── EventMapper.php # Maps Google ↔ Leantime events ├── Repositories/ │ └── GoogleCalendarAccounts.php # Database operations ├── Models/ │ ├── GoogleCalendarAccount.php # Account entity │ └── GoogleCalendarEvent.php # Synced event entity ├── Migrations/ │ └── CreateGoogleCalendarTables.php ├── Templates/ │ ├── settings.blade.php # Settings page │ ├── connect.blade.php # Pre-connect info │ └── partials/ │ └── accountCard.blade.php ├── Language/ │ └── en-US.ini ├── Commands/ │ └── SyncCalendars.php # Artisan command for cron ├── register.php # Plugin registration & hooks ├── composer.json # Dependencies └── index.php # Plugin metadata ``` --- ## Database Schema ### Table: `zp_google_calendar_accounts` ```sql CREATE TABLE `zp_google_calendar_accounts` ( `id` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, `userId` INT UNSIGNED NOT NULL, `googleEmail` VARCHAR(255) NOT NULL, `accessToken` TEXT NOT NULL, `refreshToken` TEXT NOT NULL, `tokenExpiresAt` DATETIME NOT NULL, `selectedCalendars` JSON DEFAULT NULL, -- ["primary", "cal_id_2"] `syncDirection` ENUM('pull', 'push', 'both') DEFAULT 'pull', `syncEnabled` TINYINT(1) DEFAULT 1, `lastSyncAt` DATETIME DEFAULT NULL, `lastSyncStatus` VARCHAR(50) DEFAULT NULL, -- 'success', 'error', 'partial' `lastSyncError` TEXT DEFAULT NULL, `createdAt` DATETIME DEFAULT CURRENT_TIMESTAMP, `updatedAt` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, INDEX `idx_userId` (`userId`), INDEX `idx_syncEnabled` (`syncEnabled`), UNIQUE KEY `unique_user_email` (`userId`, `googleEmail`), FOREIGN KEY (`userId`) REFERENCES `zp_user`(`id`) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ``` ### Table: `zp_google_calendar_events` ```sql CREATE TABLE `zp_google_calendar_events` ( `id` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, `accountId` INT UNSIGNED NOT NULL, `googleEventId` VARCHAR(255) NOT NULL, `googleCalendarId` VARCHAR(255) NOT NULL, `leantimeEventId` INT UNSIGNED DEFAULT NULL, `title` VARCHAR(500) NOT NULL, `description` TEXT DEFAULT NULL, `startDate` DATETIME NOT NULL, `endDate` DATETIME NOT NULL, `allDay` TINYINT(1) DEFAULT 0, `location` VARCHAR(500) DEFAULT NULL, `googleUpdatedAt` DATETIME NOT NULL, `syncedAt` DATETIME DEFAULT CURRENT_TIMESTAMP, `syncDirection` ENUM('from_google', 'to_google') NOT NULL, INDEX `idx_accountId` (`accountId`), INDEX `idx_googleEventId` (`googleEventId`), INDEX `idx_leantimeEventId` (`leantimeEventId`), UNIQUE KEY `unique_google_event` (`accountId`, `googleEventId`), FOREIGN KEY (`accountId`) REFERENCES `zp_google_calendar_accounts`(`id`) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ``` --- ## Core Files Implementation ### index.php ```php 'GoogleCalendar', 'description' => 'Native Google Calendar integration with OAuth 2.0', 'version' => '1.0.0', 'author' => 'Leantime', 'authorUrl' => 'https://leantime.io', 'license' => 'AGPL-3.0', 'minVersion' => '3.0.0', 'premium' => false, ]; ``` ### composer.json ```json { "name": "leantime/googlecalendar", "description": "Google Calendar integration for Leantime", "require": { "google/apiclient": "^2.15" }, "autoload": { "psr-4": { "Leantime\\Plugins\\GoogleCalendar\\": "" } } } ``` ### register.php ```php exists('userdata.id')) { return $events; } $userId = session('userdata.id'); $googleEvents = $this->googleCalendarService->getEventsForUser($userId); return array_merge($events, $googleEvents); } ); // Hook into external calendar events Events::add_filter_listener( 'leantime.domain.calendar.services.calendar.getExternalCalendarEvents.result', function (array $events, array $params) { if (!session()->exists('userdata.id')) { return $events; } $userId = session('userdata.id'); $googleEvents = $this->googleCalendarService->getEventsForUser($userId); return array_merge($events, $googleEvents); } ); } /** * Register menu items */ public function registerMenuItems(): void { Events::add_filter_listener( 'leantime.domain.menu.services.menu.getSettingsMenu.result', function (array $menuItems) { $menuItems['googlecalendar'] = [ 'label' => 'Google Calendar', 'icon' => 'fa-google', 'url' => '/googlecalendar/settings', 'section' => 'integrations', ]; return $menuItems; } ); } /** * Register scheduled tasks */ public function registerScheduledTasks(Schedule $schedule): void { // Sync every 15 minutes $schedule->command('googlecalendar:sync') ->everyFifteenMinutes() ->withoutOverlapping(); } } ``` --- ## OAuth Flow ### Step 1: Connect Controller ```php googleAuth->getAuthUrl(); return $this->tpl->redirect($authUrl); } } ``` ### Step 2: Callback Controller ```php tpl->setNotification('Google Calendar connection was cancelled.', 'error'); return $this->tpl->redirect('/googlecalendar/settings'); } if (!$code) { $this->tpl->setNotification('Invalid callback. Please try again.', 'error'); return $this->tpl->redirect('/googlecalendar/settings'); } try { // Exchange code for tokens $tokens = $this->googleAuth->exchangeCode($code); // Get user info from Google $googleUser = $this->googleAuth->getUserInfo($tokens['access_token']); // Store account $this->accountRepo->createOrUpdate([ 'userId' => session('userdata.id'), 'googleEmail' => $googleUser['email'], 'accessToken' => $tokens['access_token'], 'refreshToken' => $tokens['refresh_token'], 'tokenExpiresAt' => date('Y-m-d H:i:s', time() + $tokens['expires_in']), ]); $this->tpl->setNotification('Google Calendar connected successfully!', 'success'); } catch (\Exception $e) { $this->tpl->setNotification('Failed to connect: ' . $e->getMessage(), 'error'); } return $this->tpl->redirect('/googlecalendar/settings'); } } ``` ### Step 3: GoogleAuth Service ```php client = new Client(); $this->client->setClientId($config->googleCalendarClientId); $this->client->setClientSecret($config->googleCalendarClientSecret); $this->client->setRedirectUri($config->googleCalendarRedirectUri); $this->client->setAccessType('offline'); // Get refresh token $this->client->setPrompt('consent'); // Force consent to get refresh token $this->client->addScope([ 'https://www.googleapis.com/auth/calendar.readonly', 'https://www.googleapis.com/auth/calendar.events', 'https://www.googleapis.com/auth/userinfo.email', ]); } public function getAuthUrl(): string { return $this->client->createAuthUrl(); } public function exchangeCode(string $code): array { $token = $this->client->fetchAccessTokenWithAuthCode($code); if (isset($token['error'])) { throw new \Exception($token['error_description'] ?? $token['error']); } return $token; } public function refreshToken(string $refreshToken): array { $this->client->fetchAccessTokenWithRefreshToken($refreshToken); return $this->client->getAccessToken(); } public function getUserInfo(string $accessToken): array { $this->client->setAccessToken($accessToken); $oauth2 = new \Google\Service\Oauth2($this->client); $userInfo = $oauth2->userinfo->get(); return [ 'email' => $userInfo->getEmail(), 'name' => $userInfo->getName(), ]; } public function getClient(): Client { return $this->client; } } ``` --- ## Calendar Sync Service ### GoogleCalendar Service ```php accountRepo->getByUserId($userId); $allEvents = []; foreach ($accounts as $account) { if (!$account['syncEnabled']) { continue; } try { $events = $this->fetchEventsFromGoogle($account); $allEvents = array_merge($allEvents, $events); } catch (\Exception $e) { // Log error but continue with other accounts error_log("Google Calendar sync error for account {$account['id']}: " . $e->getMessage()); } } return $allEvents; } /** * Fetch events from Google Calendar API */ private function fetchEventsFromGoogle(array $account): array { // Refresh token if expired if (strtotime($account['tokenExpiresAt']) < time()) { $newTokens = $this->googleAuth->refreshToken($account['refreshToken']); $this->accountRepo->updateTokens($account['id'], $newTokens); $account['accessToken'] = $newTokens['access_token']; } // Initialize Google Calendar service $client = $this->googleAuth->getClient(); $client->setAccessToken($account['accessToken']); $calendarService = new Calendar($client); // Get selected calendars or default to primary $calendarIds = json_decode($account['selectedCalendars'] ?? '["primary"]', true); $events = []; $timeMin = (new \DateTime('-1 month'))->format(\DateTime::RFC3339); $timeMax = (new \DateTime('+3 months'))->format(\DateTime::RFC3339); foreach ($calendarIds as $calendarId) { try { $googleEvents = $calendarService->events->listEvents($calendarId, [ 'timeMin' => $timeMin, 'timeMax' => $timeMax, 'singleEvents' => true, 'orderBy' => 'startTime', 'maxResults' => 250, ]); foreach ($googleEvents->getItems() as $googleEvent) { $events[] = $this->eventMapper->googleToLeantime($googleEvent, $account); } } catch (\Exception $e) { error_log("Error fetching calendar {$calendarId}: " . $e->getMessage()); } } return $events; } /** * Sync a Leantime event to Google Calendar */ public function pushEventToGoogle(int $userId, array $leantimeEvent): ?string { $account = $this->accountRepo->getPrimaryAccount($userId); if (!$account || $account['syncDirection'] === 'pull') { return null; } // Refresh token if needed if (strtotime($account['tokenExpiresAt']) < time()) { $newTokens = $this->googleAuth->refreshToken($account['refreshToken']); $this->accountRepo->updateTokens($account['id'], $newTokens); $account['accessToken'] = $newTokens['access_token']; } $client = $this->googleAuth->getClient(); $client->setAccessToken($account['accessToken']); $calendarService = new Calendar($client); $googleEvent = $this->eventMapper->leantimeToGoogle($leantimeEvent); $createdEvent = $calendarService->events->insert('primary', $googleEvent); return $createdEvent->getId(); } /** * Get user's available calendars from Google */ public function getAvailableCalendars(int $accountId): array { $account = $this->accountRepo->getById($accountId); if (!$account) { return []; } // Refresh token if expired if (strtotime($account['tokenExpiresAt']) < time()) { $newTokens = $this->googleAuth->refreshToken($account['refreshToken']); $this->accountRepo->updateTokens($account['id'], $newTokens); $account['accessToken'] = $newTokens['access_token']; } $client = $this->googleAuth->getClient(); $client->setAccessToken($account['accessToken']); $calendarService = new Calendar($client); $calendarList = $calendarService->calendarList->listCalendarList(); $calendars = []; foreach ($calendarList->getItems() as $calendar) { $calendars[] = [ 'id' => $calendar->getId(), 'summary' => $calendar->getSummary(), 'primary' => $calendar->getPrimary() ?? false, 'backgroundColor' => $calendar->getBackgroundColor(), ]; } return $calendars; } } ``` ### EventMapper Service ```php getStart(); $end = $googleEvent->getEnd(); // Handle all-day events (date) vs timed events (dateTime) $allDay = !empty($start->getDate()); if ($allDay) { $dateFrom = $start->getDate() . ' 00:00:00'; $dateTo = $end->getDate() . ' 23:59:59'; } else { $dateFrom = date('Y-m-d H:i:s', strtotime($start->getDateTime())); $dateTo = date('Y-m-d H:i:s', strtotime($end->getDateTime())); } return [ 'id' => 'google_' . $googleEvent->getId(), 'title' => $googleEvent->getSummary() ?? '(No title)', 'description' => $googleEvent->getDescription() ?? '', 'dateFrom' => $dateFrom, 'dateTo' => $dateTo, 'allDay' => $allDay, 'eventType' => 'external', 'source' => 'Google: ' . $account['googleEmail'], 'color' => '#4285f4', // Google blue 'projectId' => null, 'taskId' => null, 'userId' => $account['userId'], 'editable' => false, // External events not editable in Leantime ]; } /** * Convert Leantime event to Google Calendar format */ public function leantimeToGoogle(array $leantimeEvent): Event { $event = new Event(); $event->setSummary($leantimeEvent['title'] ?? $leantimeEvent['description']); $event->setDescription($leantimeEvent['description'] ?? ''); $start = new EventDateTime(); $end = new EventDateTime(); if (!empty($leantimeEvent['allDay'])) { // All-day event $start->setDate(date('Y-m-d', strtotime($leantimeEvent['dateFrom']))); $end->setDate(date('Y-m-d', strtotime($leantimeEvent['dateTo']))); } else { // Timed event $start->setDateTime(date('c', strtotime($leantimeEvent['dateFrom']))); $end->setDateTime(date('c', strtotime($leantimeEvent['dateTo']))); } $event->setStart($start); $event->setEnd($end); return $event; } } ``` --- ## Settings UI ### Settings Controller ```php accountRepo->getByUserId($userId); // Get available calendars for each account foreach ($accounts as &$account) { $account['availableCalendars'] = $this->googleCalendarService->getAvailableCalendars($account['id']); $account['selectedCalendarsArray'] = json_decode($account['selectedCalendars'] ?? '[]', true); } $this->tpl->assign('accounts', $accounts); return $this->tpl->display('GoogleCalendar::settings'); } public function post(): Response { $accountId = $_POST['accountId'] ?? null; $selectedCalendars = $_POST['selectedCalendars'] ?? []; $syncDirection = $_POST['syncDirection'] ?? 'pull'; $syncEnabled = isset($_POST['syncEnabled']) ? 1 : 0; if ($accountId) { $this->accountRepo->update($accountId, [ 'selectedCalendars' => json_encode($selectedCalendars), 'syncDirection' => $syncDirection, 'syncEnabled' => $syncEnabled, ]); $this->tpl->setNotification('Settings saved successfully!', 'success'); } return $this->tpl->redirect('/googlecalendar/settings'); } } ``` ### settings.blade.php Template ```blade @extends('layout.main') @section('content')

Google Calendar Integration

@if(empty($accounts))

Connect Your Google Calendar

Connect your Google Calendar to see your events alongside your Leantime tasks and meetings.

Connect Google Calendar
@else @foreach($accounts as $account)
{{ $account['googleEmail'] }}
Disconnect
@foreach($account['availableCalendars'] as $calendar)
@endforeach
Sync Now
@if($account['lastSyncAt']) Last synced: {{ $account['lastSyncAt'] }} @if($account['lastSyncStatus'] == 'error') (Error: {{ $account['lastSyncError'] }}) @endif @endif
@endforeach
Connect Another Google Account @endif
About Google Calendar Integration

This integration allows you to:

  • See Google Calendar events in your Leantime calendar
  • Optionally push Leantime events to Google Calendar
  • Connect multiple Google accounts

Events sync automatically every 15 minutes, or you can trigger a manual sync.

@endsection ``` --- ## Terminal Setup Commands Run these commands in order to scaffold the plugin: ```bash # Navigate to plugins directory cd /Users/gloriafolaron/Herd/leantime/app/Plugins # Create plugin directory structure mkdir -p GoogleCalendar/{Controllers,Services,Repositories,Models,Migrations,Templates/partials,Language,Commands} # Create empty files touch GoogleCalendar/index.php touch GoogleCalendar/register.php touch GoogleCalendar/composer.json touch GoogleCalendar/Controllers/Connect.php touch GoogleCalendar/Controllers/Callback.php touch GoogleCalendar/Controllers/Settings.php touch GoogleCalendar/Controllers/Sync.php touch GoogleCalendar/Controllers/Disconnect.php touch GoogleCalendar/Services/GoogleCalendar.php touch GoogleCalendar/Services/GoogleAuth.php touch GoogleCalendar/Services/EventMapper.php touch GoogleCalendar/Repositories/GoogleCalendarAccounts.php touch GoogleCalendar/Models/GoogleCalendarAccount.php touch GoogleCalendar/Models/GoogleCalendarEvent.php touch GoogleCalendar/Migrations/CreateGoogleCalendarTables.php touch GoogleCalendar/Templates/settings.blade.php touch GoogleCalendar/Templates/connect.blade.php touch GoogleCalendar/Language/en-US.ini touch GoogleCalendar/Commands/SyncCalendars.php # Install Google API client (from plugin directory) cd GoogleCalendar composer require google/apiclient:^2.15 # Go back to Leantime root and dump autoload cd /Users/gloriafolaron/Herd/leantime composer dump-autoload ``` --- ## Environment Configuration Add to `.env`: ```env # Google Calendar Integration GOOGLE_CALENDAR_CLIENT_ID= GOOGLE_CALENDAR_CLIENT_SECRET= GOOGLE_CALENDAR_REDIRECT_URI=${APP_URL}/googlecalendar/callback ``` Add to `config/configuration.php` (if using config file): ```php 'googleCalendarClientId' => env('GOOGLE_CALENDAR_CLIENT_ID', ''), 'googleCalendarClientSecret' => env('GOOGLE_CALENDAR_CLIENT_SECRET', ''), 'googleCalendarRedirectUri' => env('GOOGLE_CALENDAR_REDIRECT_URI', ''), ``` --- ## Cron Job Setup Add to your server's crontab: ```bash * * * * * cd /path/to/leantime && php artisan schedule:run >> /dev/null 2>&1 ``` Or run manually for testing: ```bash php artisan googlecalendar:sync ``` --- ## Testing Checklist - [ ] Google Cloud Console project created - [ ] OAuth credentials generated - [ ] Environment variables configured - [ ] Plugin files created - [ ] Composer dependencies installed - [ ] Database migration run - [ ] Can click "Connect Google Calendar" - [ ] OAuth redirect works - [ ] Callback stores tokens - [ ] Settings page shows connected account - [ ] Calendar list loads - [ ] Events appear in Leantime calendar - [ ] Manual sync works - [ ] Token refresh works (test after 1 hour) - [ ] Disconnect removes account --- ## Future Enhancements 1. **Outlook Calendar Plugin** - Same pattern, Microsoft Graph API 2. **Webhook support** - Real-time sync instead of polling 3. **Conflict resolution** - Handle overlapping events 4. **Event editing** - Edit Google events from Leantime 5. **Color sync** - Match calendar colors 6. **Recurring events** - Handle recurrence rules