How to Synchronize Google Events with Laravel
Tags: laravel, php, googlecloud, webdev
Published: 2023-01-02 22:33:18

In the [previous article](https://dev.to/dnsinyukov/compact-guide-how-to-sync-google-calendar-with-laravel-145g), we learned the general principles of resource synchronization from Google and wrote code that synchronizes calendars for Google account. 

In this article, we will bind events to calendars. Let's look at what parameters should be sent and what they mean. 

When syncing events, it's important to understand what to do after receiving the data: <u>create</u>, <u>update</u>, or <u>delete</u> an event, to keep the data up to date and valid. 

## Concept of Synchronization

Google Event - is an object or resource that is associated with a specific date or time period. It often has additional parameters such as location, description, time zone, status, attachments, etc. 

_Remember when you log into your email client and are asked if you are coming?_

This attribute stores your answer, but has nothing to do with sync status, as we will pay attention to a bunch of factors when we sync.

### Types of events

There are only 2 types of events: **single** and **recurring** events.

<u>Single events</u> are tied to a single date or time period, as opposed to <u>recurring events</u> which happen several times on a regular schedule (holidays, rallies, birthdays) and have a recurrence rule ([RFC 5545](https://www.rfc-editor.org/rfc/rfc5545)).

We will work with an API which will return all the events together, we will not pay attention to this when saving the events. 

The parameter [singleEvents](https://developers.google.com/calendar/api/v3/reference/events/list#singleEvents) is responsible for expanding the events. The distinguishing feature of a repeating event is the `recurrence` parameter, and of all child events is the `recurringEventId` parameter.

## Database shema for Google Events

We will not enter all of the existing fields, only those that are meaningful at this point. The structure of the database table will look like:

```php
Schema::create('calendar_events', function (Blueprint $table) {
    $table->id();
    $table->string('calendar_id');
    $table->string('summary')->nullable();
    $table->string('provider_id');
    $table->string('provider_type');
    $table->longText('description')->nullable();
    $table->boolean('is_all_day')->default(false);
    $table->timestamp('start_at')->nullable();
    $table->timestamp('end_at')->nullable();
    $table->timestamps();

    $table->foreign('calendar_id')->references('provider_id')->on('calendars')->onDelete('CASCADE');
});
```

## Synchronizing Google Events

Following the `ProviderInterface` interface, we have defined a `synchronize` function that creates a resource synchronization object `GoogleSynchronizer`.

```php
 public function synchronize(string $resource, Account $account);
```

This object, in the previous article, helped us to perform calendar synchronization. Let's add the implementation of event synchronization work for calendar. 

For synchronization, we need a calendar ID. You can use the special value calendarId `primary` - it is a reference to the user's main calendar, which is used by default.

```php
public function synchronizeEvents(Account $account, array $options = [])
{
    $token = $account->getToken();
    $accountId = $account->getId();
    $calendarId = $options['calendarId'] ?? 'primary';
    $pageToken = $options['pageToken'] ?? null;
    $syncToken = $options['syncToken'] ?? null;

    $now = now();

    $query = Arr::only($options, ['timeMin', 'timeMax', 'maxResults']);
    $query = array_merge($query, [
        'maxResults' => 25,
        'timeMin' => $now->copy()->startOfMonth()->toRfc3339String(),
        'timeMax' => $now->copy()->addMonth()->toRfc3339String()
    ]);

    /** @var CalendarRepository $calendarRepository */
    $calendarRepository = $this->repository(CalendarRepository::class);

    if ($token->isExpired()) {
        return false;
    }

    if (isset($syncToken)) {
        $query = [
            'syncToken' => $syncToken,
        ];
    }

    /** @var EventRepository $eventRepository */
    $eventRepository = $this->repository(EventRepository::class);

    $eventIds = $eventRepository
        ->setColumns(['provider_id'])
        ->getByAttributes([
            'calendar_id' => $calendarId,
            'provider_type' => $this->provider->getProviderName()
        ])
        ->pluck('provider_id');

    $url = "/calendar/{$this->provider->getVersion()}/calendars/${calendarId}/events";

    do {
        if (isset($pageToken) && empty($syncToken)) {
            $query = [
                'pageToken' => $pageToken
            ];
        }

        Log::debug('Synchronize Events', [
            'query' => $query
        ]);

        $body = $this->call('GET', $url, [
            'headers' => ['Authorization' => 'Bearer ' . $token->getAccessToken()],
            'query' => $query
        ]);

        $items = $body['items'];

        $pageToken = $body['nextPageToken'] ?? null;

        // Skip loop
        if (count($items) === 0) {
            break;
        }

        $itemIterator = new \ArrayIterator($items);

        while ($itemIterator->valid()) {
            $event = $itemIterator->current();

            $this->synchronizeEvent($event, $calendarId, $eventIds);

            $itemIterator->next();
        }

    } while (is_null($pageToken) === false);

    $syncToken = $body['nextSyncToken'];
    $now = now();

    $calendarRepository->updateByAttributes(
        ['provider_id' => $calendarId, 'account_id' => $accountId],
        [
            'sync_token' => Crypt::encryptString($syncToken),
            'last_sync_at' => $now,
            'updated_at' => $now
        ]
    );
}
```

This function gets the access token from the sync account and generates a query to request resources from Google API. Endpoint to retrieve the data looks like:

```php
GET /calendars/v3/calendars/example@gmail.com/events?maxResults=25&timeMin=2023-01-01T00:00:00+00:00&timeMax=2023-02-02T20:54:27+00:00"
```

To get the second page of data, the query will look like this:

```php
GET /calendars/v3/calendars/example@gmail.com/events?pageToken=CiAKGjBpNDd2Nmp2Zml2cXRwYjBpOXA"
```

Once we have the resource page, we process each event separately with the `synchronizeEvent` function. As a result we have 3 scripts for each event.

### Delete Events

If the event status is `cancelled` we should delete it if it exists in our database.

```php
if ($event['status'] === 'cancelled') {

    if ($eventIds->contains($eventId)) {
        $eventRepository->deleteWhere([
            'calendar_id' => $calendarId,
            'provider_id' => $eventId,
            'provider_type' => $this->provider->getProviderName(),
    ]);
    }

    return;
}
```

### Update Events

Before performing the API query, we got the list of existed IDs associated with the given calendar within the account. We must check if the Event ID is present in the database, we should update it, since Event ID is a unique field.

```php
if ($eventIds->contains($eventId)) {

    $eventRepository->updateByAttributes(
        [
            'calendar_id' => $calendarId,
            'provider_id' => $eventId,
            'provider_type' => $this->provider->getProviderName()
        ],
        [
            'summary' => $event['summary'],
            'is_all_day' => $isAllDay,
            'description' => $event['description'] ?? null,
            'start_at' => $eventStart,
            'end_at' => $eventEnd,
            'updated_at' => new \DateTime(),
        ]
    );
}
```
 
### Create event

If this event is not found in the database, we need to create it.

```php
$eventRepository->insert([
    'calendar_id' => $calendarId,
    'provider_id' => $eventId,
    'provider_type' => $this->provider->getProviderName(),
    'summary' => $event['summary'],
    'description' => $event['description'] ?? null,
    'start_at' => $eventStart,
    'end_at' =>  $eventEnd,
    'is_all_day' => $isAllDay,
    'created_at' => new \DateTime(),
    'updated_at' => new \DateTime(),
]);
```

Note that events come with the specified timezone, but we convert them to UTC before saving them. Also, all-day events use the `start.date` and `end.date` fields to specify their time of occurrence, while temporary events use the `start.dateTime` and `end.dateTime` fields. To do this we will use the date conversion function.

```php
protected function parseDateTime($eventDateTime): Carbon
{
    if (isset($eventDateTime)) {
        $eventDateTime = $eventDateTime['dateTime'] ?? $eventDateTime['date'];
    }

    return Carbon::parse($eventDateTime)->setTimezone('UTC');
}
```

When the synchronization is complete, we save the synchronization token (**syncToken**) to the calendar record, for future use and optimization.

## Sync Calendar Events command

To check the synchronization result, we will use a command in Laravel. Let's call the command `synchronize:events`.

The commands will retrieve all calendars of the selected account from the database and synchronize their events.

```php
public function handle()
{
    $accountId = $this->argument('accountId');

    $accountModel = app(AccountRepository::class)->find($accountId);

    throw_if(empty($accountModel), ModelNotFoundException::class);

    /** @var GoogleProvider $provider */
    $provider = app(CalendarManager::class)->driver('google');

    $calendars = app(CalendarRepository::class)->getByAttributes([
        'account_id' => $accountId
    ]);

    $account = tap(new Account(), function ($account) use ($accountModel) {

        $token = Crypt::decrypt($accountModel->token);
        $syncToken = '';

        if (isset($accountModel->sync_token)) {
            $syncToken = Crypt::decryptString($accountModel->sync_token);
        }

        $account
            ->setId($accountModel->id)
            ->setProviderId($accountModel->provider_id)
            ->setUserId($accountModel->user_id)
            ->setName($accountModel->name)
            ->setEmail($accountModel->email)
            ->setPicture($accountModel->picture)
            ->setSyncToken($syncToken)
            ->setToken(TokenFactory::create($token));
    });

    foreach ($calendars as $calendar) {
        $options = ['calendarId' => $calendar->provider_id];

        if (isset($calendar->sync_token)) {
            $options['syncToken'] = Crypt::decryptString($calendar->sync_token);
        }

        $provider->synchronize('Event', $account, $options);
    }
}
```

## Conclusion

We looked at the event as a resource and configured the synchronization of calendars and their events. Next, we look at refresh access tokens of google accounts to automate the entire process. The full code of the article can be found in the [commit](https://github.com/dnsinyukov/sync-calendars/commit/fc858911998798881d26a93e5ae6a4184fc0b52c).

## Related Links
- [Google Developers - Event](https://developers.google.com/calendar/api/v3/reference/events)