application/controllers/Google.php
<?php defined('BASEPATH') or exit('No direct script access allowed');

/* ----------------------------------------------------------------------------
 * Easy!Appointments - Online Appointment Scheduler
 *
 * @package     EasyAppointments
 * @author      A.Tselegidis <alextselegidis@gmail.com>
 * @copyright   Copyright (c) Alex Tselegidis
 * @license     https://opensource.org/licenses/GPL-3.0 - GPLv3
 * @link        https://easyappointments.org
 * @since       v1.0.0
 * ---------------------------------------------------------------------------- */

/**
 * Google controller.
 *
 * Handles the Google Calendar synchronization related operations.
 *
 * @package Controllers
 */
class Google extends EA_Controller
{
    /**
     * Google constructor.
     */
    public function __construct()
    {
        parent::__construct();

        $this->load->library('google_sync');

        $this->load->model('appointments_model');
        $this->load->model('providers_model');
        $this->load->model('roles_model');
    }

    /**
     * Complete synchronization of appointments between Google Calendar and Easy!Appointments.
     *
     * This method will completely sync the appointments of a provider with his Google Calendar account. The sync period
     * needs to be relatively small, because a lot of API calls might be necessary and this will lead to consuming the
     * Google limit for the Calendar API usage.
     */
    public static function sync(?string $provider_id = null): void
    {
        try {
            /** @var EA_Controller $CI */
            $CI = get_instance();

            $CI->load->library('google_sync');

            // Load the libraries as this method is called statically from the CLI command

            $CI->load->model('appointments_model');
            $CI->load->model('unavailabilities_model');
            $CI->load->model('providers_model');
            $CI->load->model('services_model');
            $CI->load->model('customers_model');
            $CI->load->model('settings_model');

            $user_id = session('user_id');

            if (!$user_id && !is_cli()) {
                return;
            }

            if (!$provider_id) {
                throw new InvalidArgumentException('No provider ID provided.');
            }

            $provider = $CI->providers_model->find($provider_id);

            // Check whether the selected provider has the Google Sync enabled.
            $google_sync = $CI->providers_model->get_setting($provider['id'], 'google_sync');

            if (!$google_sync) {
                return; // The selected provider does not have the Google Sync enabled.
            }

            $google_token = json_decode($provider['settings']['google_token'], true);

            $CI->google_sync->refresh_token($google_token['refresh_token']);

            // Fetch provider's appointments that belong to the sync time period.
            $sync_past_days = $provider['settings']['sync_past_days'];

            $sync_future_days = $provider['settings']['sync_future_days'];

            $start = strtotime('-' . $sync_past_days . ' days', strtotime(date('Y-m-d')));

            $end = strtotime('+' . $sync_future_days . ' days', strtotime(date('Y-m-d')));

            $where = [
                'start_datetime >=' => date('Y-m-d H:i:s', $start),
                'end_datetime <=' => date('Y-m-d H:i:s', $end),
                'id_users_provider' => $provider['id'],
            ];

            $appointments = $CI->appointments_model->get($where);

            $unavailabilities = $CI->unavailabilities_model->get($where);

            $local_events = [...$appointments, ...$unavailabilities];

            $company_color = setting('company_color');

            $settings = [
                'company_name' => setting('company_name'),
                'company_link' => setting('company_link'),
                'company_email' => setting('company_email'),
                'company_color' =>
                    !empty($company_color) && $company_color != DEFAULT_COMPANY_COLOR ? $company_color : null,
            ];

            $provider_timezone = new DateTimeZone($provider['timezone']);

            // Sync each appointment with Google Calendar by following the project's sync protocol (see documentation).
            foreach ($local_events as $local_event) {
                if (!$local_event['is_unavailability']) {
                    $service = $CI->services_model->find($local_event['id_services']);
                    $customer = $CI->customers_model->find($local_event['id_users_customer']);
                    $events_model = $CI->appointments_model;
                } else {
                    $service = null;
                    $customer = null;
                    $events_model = $CI->unavailabilities_model;
                }

                // If current appointment not synced yet, add to Google Calendar.
                if (!$local_event['id_google_calendar']) {
                    if (!$local_event['is_unavailability']) {
                        $google_event = $CI->google_sync->add_appointment(
                            $local_event,
                            $provider,
                            $service,
                            $customer,
                            $settings,
                        );
                    } else {
                        $google_event = $CI->google_sync->add_unavailability($provider, $local_event);
                    }

                    $local_event['id_google_calendar'] = $google_event->getId();

                    $events_model->save($local_event); // Save the Google Calendar ID.

                    continue;
                }

                // Appointment is synced with Google Calendar.

                try {
                    $google_event = $CI->google_sync->get_event($provider, $local_event['id_google_calendar']);

                    if ($google_event->getStatus() == 'cancelled') {
                        throw new Exception('Event is cancelled, remove the record from Easy!Appointments.');
                    }

                    // If Google Calendar event is different from Easy!Appointments appointment then update Easy!Appointments record.
                    $local_event_start = strtotime($local_event['start_datetime']);
                    $local_event_end = strtotime($local_event['end_datetime']);
                    $google_event_start = new DateTime(
                        $google_event->getStart()->getDateTime() ?? $google_event->getEnd()->getDate(),
                    );
                    $google_event_start->setTimezone($provider_timezone);
                    $google_event_end = new DateTime(
                        $google_event->getEnd()->getDateTime() ?? $google_event->getEnd()->getDate(),
                    );
                    $google_event_end->setTimezone($provider_timezone);

                    $google_event_notes = $local_event['is_unavailability']
                        ? $google_event->getSummary() . ' ' . $google_event->getDescription()
                        : $google_event->getDescription();

                    $is_different =
                        $local_event_start !== $google_event_start->getTimestamp() ||
                        $local_event_end !== $google_event_end->getTimestamp() ||
                        $local_event['notes'] !== $google_event_notes;

                    if ($is_different) {
                        $local_event['start_datetime'] = $google_event_start->format('Y-m-d H:i:s');
                        $local_event['end_datetime'] = $google_event_end->format('Y-m-d H:i:s');
                        $local_event['notes'] = $google_event_notes;
                        $events_model->save($local_event);
                    }
                } catch (Throwable) {
                    // Appointment not found on Google Calendar, delete from Easy!Appointments.
                    $events_model->delete($local_event['id']);

                    $local_event['id_google_calendar'] = null;
                }
            }

            // Add Google Calendar events that do not exist in Easy!Appointments.
            $google_calendar = $provider['settings']['google_calendar'];

            try {
                $google_events = $CI->google_sync->get_sync_events($google_calendar, $start, $end);
            } catch (Throwable $e) {
                if ($e->getCode() === 404) {
                    log_message('error', 'Google - Remote Calendar not found for provider ID: ' . $provider_id);

                    return; // The remote calendar was not found.
                } else {
                    throw $e;
                }
            }

            foreach ($google_events->getItems() as $google_event) {
                if ($google_event->getStatus() === 'cancelled') {
                    continue;
                }

                if ($google_event->getStart() === null || $google_event->getEnd() === null) {
                    continue;
                }

                if ($google_event->getStart()->getDateTime() === $google_event->getEnd()->getDateTime()) {
                    continue;
                }

                $google_event_start = new DateTime($google_event->getStart()->getDateTime());
                $google_event_start->setTimezone($provider_timezone);
                $google_event_end = new DateTime($google_event->getEnd()->getDateTime());
                $google_event_end->setTimezone($provider_timezone);

                $appointment_results = $CI->appointments_model->get(['id_google_calendar' => $google_event->getId()]);

                if (!empty($appointment_results)) {
                    continue;
                }

                $unavailability_results = $CI->unavailabilities_model->get([
                    'id_google_calendar' => $google_event->getId(),
                ]);

                if (!empty($unavailability_results)) {
                    continue;
                }

                // Record doesn't exist in the Easy!Appointments, so add the event now.
                $local_event = [
                    'start_datetime' => $google_event_start->format('Y-m-d H:i:s'),
                    'end_datetime' => $google_event_end->format('Y-m-d H:i:s'),
                    'is_unavailability' => true,
                    'location' => $google_event->getLocation(),
                    'notes' => $google_event->getSummary() . ' ' . $google_event->getDescription(),
                    'id_users_provider' => $provider_id,
                    'id_google_calendar' => $google_event->getId(),
                    'id_users_customer' => null,
                    'id_services' => null,
                ];

                $CI->unavailabilities_model->save($local_event);
            }

            json_response([
                'success' => true,
            ]);
        } catch (Throwable $e) {
            log_message(
                'error',
                'Google - Sync completed with an error (provider ID "' . $provider_id . '"): ' . $e->getMessage(),
            );

            json_exception($e);
        }
    }

    /**
     * Authorize Google Calendar API usage for a specific provider.
     *
     * Since it is required to follow the web application flow, in order to retrieve a refresh token from the Google API
     * service, this method is going to authorize the given provider.
     *
     * @param string $provider_id The provider id, for whom the sync authorization is made.
     */
    public function oauth(string $provider_id): void
    {
        if (!$this->session->userdata('user_id')) {
            show_error('Forbidden', 403);
        }

        // Store the provider id for use on the callback function.
        session(['oauth_provider_id' => $provider_id]);

        // Redirect browser to google user content page.
        header('Location: ' . $this->google_sync->get_auth_url());
    }

    /**
     * Callback method for the Google Calendar API authorization process.
     *
     * Once the user grants consent with his Google Calendar data usage, the Google OAuth service will redirect him back
     * in this page. Here we are going to store the refresh token, because this is what will be used to generate access
     * tokens in the future.
     *
     * IMPORTANT: Because it is necessary to authorize the application using the web server flow (see official
     * documentation of OAuth), every Easy!Appointments installation should use its own calendar api key. So in every
     * api console account, the "http://path-to-Easy!Appointments/google/oauth_callback" should be included in an
     * allowed redirect URL.
     *
     * @throws Exception
     */
    public function oauth_callback(): void
    {
        if (!session('user_id')) {
            abort(403, 'Forbidden');
        }

        $code = request('code');

        if (empty($code)) {
            response('Code authorization failed.');

            return;
        }

        $token = $this->google_sync->authenticate($code);

        if (empty($token)) {
            response('Token authorization failed.');

            return;
        }

        // Store the token into the database for future reference.
        $oauth_provider_id = session('oauth_provider_id');

        if ($oauth_provider_id) {
            $this->providers_model->set_setting($oauth_provider_id, 'google_sync', true);
            $this->providers_model->set_setting($oauth_provider_id, 'google_token', json_encode($token));
            $this->providers_model->set_setting($oauth_provider_id, 'google_calendar', 'primary');
        } else {
            response('Sync provider id not specified.');
        }
    }

    /**
     * This method will return a list of the available Google Calendars.
     *
     * The user will need to select a specific calendar from this list to sync his appointments with. Google access must
     * be already granted for the specific provider.
     */
    public function get_google_calendars(): void
    {
        try {
            $provider_id = (int) request('provider_id');

            if (empty($provider_id)) {
                throw new Exception('Provider id is required in order to fetch the google calendars.');
            }

            // Check if selected provider has sync enabled.
            $google_sync = $this->providers_model->get_setting($provider_id, 'google_sync');

            if (!$google_sync) {
                json_response([
                    'success' => false,
                ]);

                return;
            }

            $google_token = json_decode($this->providers_model->get_setting($provider_id, 'google_token'), true);

            $this->google_sync->refresh_token($google_token['refresh_token']);

            $calendars = $this->google_sync->get_google_calendars();

            json_response($calendars);
        } catch (Throwable $e) {
            json_exception($e);
        }
    }

    /**
     * Select a specific google calendar for a provider.
     *
     * All the appointments will be synced with this particular calendar.
     */
    public function select_google_calendar(): void
    {
        try {
            $provider_id = request('provider_id');

            $user_id = session('user_id');

            if (cannot('edit', PRIV_USERS) && (int) $user_id !== (int) $provider_id) {
                throw new RuntimeException('You do not have the required permissions for this task.');
            }

            $calendar_id = request('calendar_id');

            $this->providers_model->set_setting($provider_id, 'google_calendar', $calendar_id);

            json_response([
                'success' => true,
            ]);
        } catch (Throwable $e) {
            json_exception($e);
        }
    }

    /**
     * Disable a providers sync setting.
     *
     * This method deletes the "google_sync" and "google_token" settings from the database.
     *
     * After that the provider's appointments will be no longer synced with Google Calendar.
     */
    public function disable_provider_sync(): void
    {
        try {
            $provider_id = request('provider_id');

            if (!$provider_id) {
                throw new Exception('Provider id not specified.');
            }

            $user_id = session('user_id');

            if (cannot('edit', PRIV_USERS) && (int) $user_id !== (int) $provider_id) {
                throw new RuntimeException('You do not have the required permissions for this task.');
            }

            $this->providers_model->set_setting($provider_id, 'google_sync', false);

            $this->providers_model->set_setting($provider_id, 'google_token');

            $this->appointments_model->clear_google_sync_ids($provider_id);

            json_response([
                'success' => true,
            ]);
        } catch (Throwable $e) {
            json_exception($e);
        }
    }
}
