How to Synchronize Google Events with Laravel

In the previous article, 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: create, update, or delete 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.

Single events are tied to a single date or time period, as opposed to recurring events which happen several times on a regular schedule (holidays, rallies, birthdays) and have a recurrence rule (RFC 5545).

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 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:

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.

 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.

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:

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:

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.

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.

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.

$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.

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.

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.

Related Links

Similar Articles