# booktics/1.0.29/core/module/google-calendar/Controller/calendar-sync-controller.php

Booktics – Appointment Booking Calendar for Service Businesses, version 1.0.29. 544 lines.

- Page: https://pluginprobe.com/plugins/booktics/1.0.29/code/core/module/google-calendar/Controller/calendar-sync-controller.php
- Raw: https://pluginprobe.com/plugins/booktics/1.0.29/raw/core/module/google-calendar/Controller/calendar-sync-controller.php
- Modified: 2026-10-11T10:01:32+00:00

Line numbers below start at 1. Link to a line or a range by appending a fragment to the
page URL, for example `https://pluginprobe.com/plugins/booktics/1.0.29/code/core/module/google-calendar/Controller/calendar-sync-controller.php#L10-L20`.

```php
<?php
/**
 * Calendar Sync Controller
 *
 * @package BookticsGoogleCalendar\Google\Controller
 */

namespace Booktics\Module\Google_Calendar\Controller;

if ( ! defined( 'ABSPATH' ) ) exit;

use Booktics\Abstracts\Base_Rest_Controller;
use Booktics\Module\Google_Calendar\Service\Calendar;
use Booktics\Models\Appointment_Model;
use Booktics\Models\Service_Model;
use WP_REST_Request;
use WP_Error;

/**
 * Class Calendar_Sync_Controller
 */
class Calendar_Sync_Controller extends Base_Rest_Controller {

	/**
	 * The namespace of this controller's route.
	 *
	 * @var string
	 */
	protected $namespace = 'booktics/v1';

	/**
	 * The base of this controller's route.
	 *
	 * @var string
	 */
	protected $rest_base = 'sync/events';

	/**
	 * Google Calendar service instance.
	 *
	 * @var Calendar
	 */
	private $calendar;

	/**
	 * Temporary storage for appointment data before deletion.
	 *
	 * @var array
	 */
	private $deletion_data = array();

	/**
	 * Initialize the class and set up hooks.
	 */
	public function __construct() {
		$this->calendar = new Calendar();
		$this->register();
	}

	/**
	 * Register the routes for the controller.
	 */
	public function register_routes(): void {
		add_filter( 'booktics_appointments_for_calendar_view', array( $this, 'add_google_appointments' ), 10, 2 );
		add_filter( 'booktics_appointment_scheduled_notification_flow_data', [ $this, 'get_meet_url' ], 10, 2 );
		add_filter( 'booktics_order_created_notification_flow_data', [ $this, 'get_meet_url_for_order_mail' ], 10, 3 );
		add_filter( 'booktics_booking_calendar_urls', array( $this, 'get_add_to_calendar_links' ), 10, 2 );

		add_action( 'booktics_after_appointment_created', array( $this, 'handle_appointment_created' ), 10, 1 );
		add_action( 'booktics_after_appointment_updated', array( $this, 'handle_appointment_updated' ), 10, 1 );
		add_action( 'booktics_after_appointment_cancelled', array( $this, 'handle_appointment_cancelled' ), 10, 1 );
		add_action( 'before_delete_post', array( $this, 'capture_appointment_data_before_delete' ), 10, 1 );
		add_action( 'booktics_after_appointment_deleted', array( $this, 'handle_appointment_deleted' ), 10, 1 );
	}

	/**
	 * Add Google Calendar appointments to the appointments list.
	 *
	 * @param array $appointments The appointments list.
	 * @param array $args The arguments passed to the function.
	 * @return array The modified appointments list.
	 */
	public function add_google_appointments( $appointments, $args ) {
		$api_filters                  = $this->get_google_calendar_api_filters( $args );
		$google_calendar_appointments = $this->calendar->get_google_calendar_events( get_current_user_id(), $api_filters );

		if ( is_wp_error( $google_calendar_appointments ) ) {
			return $appointments;
		}

		// Get all existing Google Calendar event IDs from Booktics appointments to avoid duplicates
		$existing_event_ids = $this->get_existing_google_event_ids( $appointments );

		foreach ( $google_calendar_appointments as $event ) {
			// Skip events that already exist as Booktics appointments
			if ( isset( $event['id'] ) && in_array( $event['id'], $existing_event_ids ) ) {
				continue;
			}

			$appointments[] = $this->calendar->prepare_appointment_data_from_event( $event );
		}

		return $appointments;
	}

	/**
	 * Get existing Google Calendar event IDs from current appointments to avoid duplicates.
	 *
	 * @param array $appointments The current appointments list.
	 * @return array Array of Google Calendar event IDs.
	 */
	private function get_existing_google_event_ids( $appointments ) {
		$existing_event_ids = array();

		// Get event IDs from the passed appointments array
		foreach ( $appointments as $appointment ) {
			$event_id = get_post_meta( $appointment->id, '_google_calendar_event_id', true );
			if ( ! empty( $event_id ) ) {
				$existing_event_ids[] = $event_id;
			}
		}

		return array_unique( $existing_event_ids );
	}

	/**
	 * On appointment created, create a Google Calendar event for the booking.
	 *
	 * @param \Booktics\Models\Appointment_Model $appointment appointment data
	 */
	public function handle_appointment_created( $appointment ) {
		try {
			$team_member_id = get_post_meta( $appointment->id, 'team_member_id', true );

			// If not in meta, check if it's a property of the appointment object
			if ( !$team_member_id && isset( $appointment->team_member_id ) ) {
				$team_member_id = $appointment->team_member_id;
			}

			$event_id = $this->calendar->create_event( $appointment, $team_member_id );

			if ( $event_id ) {
				update_post_meta( $appointment->id, '_google_calendar_event_id', $event_id );
			}
		} catch ( \Throwable $e ) {
			if ( defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) {
				error_log( '[Booktics Google Calendar] Failed to create Google Calendar event: ' . $e->getMessage() ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
			}
		}
	}

	/**
	 * On appointment updated, update the Google Calendar event for the booking.
	 *
	 * @param \Booktics\Models\Appointment_Model $appointment appointment data
	 */
	public function handle_appointment_updated( $appointment ) {
		try {
			$team_member_id = get_post_meta( $appointment->id, 'team_member_id', true );
			$event_id       = get_post_meta( $appointment->id, '_google_calendar_event_id', true );

			if ( $event_id ) {
				$this->calendar->update_event( $event_id, $appointment, $team_member_id );
			}
		} catch ( \Throwable $e ) {
			if ( defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) {
				error_log( 'Failed to updated Google Calendar event: ' . $e->getMessage() ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
			}
		}
	}

	/**
	 * On appointment cancelled, remove its Google Calendar event.
	 *
	 * A cancellation only flips the appointment status (no post deletion), so
	 * the delete hooks never fire. Without this the calendar event lingers in
	 * the team member's calendar for a booking that was auto-cancelled (e.g. an
	 * abandoned/unpaid online booking) or refunded, keeping the slot visually
	 * blocked. Deleting the event frees the calendar slot to match the freed
	 * appointment slot.
	 *
	 * @param \Booktics\Models\Appointment_Model $appointment appointment data
	 */
	public function handle_appointment_cancelled( $appointment ) {
		try {
			if ( ! isset( $appointment->id ) ) {
				return;
			}

			$event_id       = get_post_meta( $appointment->id, '_google_calendar_event_id', true );
			$team_member_id = get_post_meta( $appointment->id, 'team_member_id', true );

			if ( ! $team_member_id && isset( $appointment->team_member_id ) ) {
				$team_member_id = $appointment->team_member_id;
			}

			if ( ! $event_id || ! $team_member_id ) {
				return;
			}

			if ( $this->calendar->delete_event( $event_id, $team_member_id ) ) {
				delete_post_meta( $appointment->id, '_google_calendar_event_id' );
			}
		} catch ( \Throwable $e ) {
			if ( defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) {
				error_log( '[Booktics Google Calendar] Failed to delete Google Calendar event on cancel: ' . $e->getMessage() ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
			}
		}
	}

	/**
	 * Capture appointment Google Calendar data before WordPress deletes the post.
	 *
	 * @param int $post_id Post ID being deleted
	 */
	public function capture_appointment_data_before_delete( $post_id ) {
		// Only process booktics appointments
		if ( get_post_type( $post_id ) !== 'booktics-appointment' ) {
			return;
		}

		// Capture the data we need before deletion
		$team_member_id = get_post_meta( $post_id, 'team_member_id', true );
		$event_id = get_post_meta( $post_id, '_google_calendar_event_id', true );

		if ( $team_member_id && $event_id ) {
			$this->deletion_data[ $post_id ] = array(
				'team_member_id' => $team_member_id,
				'event_id' => $event_id,
			);
		}
	}

	public function handle_appointment_deleted( $appointment ) {
		try {
			// Get the data we captured before deletion
			if ( ! isset( $this->deletion_data[ $appointment->id ] ) ) {
				return;
			}

			$data = $this->deletion_data[ $appointment->id ];
			$team_member_id = $data['team_member_id'];
			$event_id = $data['event_id'];

			if ( $event_id && $team_member_id ) {
				$deleted = $this->calendar->delete_event( $event_id, $team_member_id );
				if ( $deleted ) {
					// Clean up the stored data
					unset( $this->deletion_data[ $appointment->id ] );
				}
			}
		} catch ( \Throwable $e ) {
			if ( defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) {
				error_log( '[Booktics Google Calendar] Exception in handle_appointment_deleted: ' . $e->getMessage() ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
			}
		}
	}

	/**
	 * Check if a given request has permission to sync events.
	 *
	 * @param WP_REST_Request $request Full data about the request.
	 * @return bool|WP_Error
	 */
	public function permission_check( $request ) {
		if ( ! current_user_can( 'manage_options' ) ) {
			return new WP_Error(
				'rest_forbidden',
				__( 'You do not have sufficient permissions to access this endpoint.', 'booktics' ),
				array( 'status' => 403 )
			);
		}

		return true;
	}

	/**
	 * Get the query params for collections.
	 *
	 * @return array
	 */
	public function get_collection_params() {
		return array(
			'team_member_id' => array(
				'description'       => __( 'Team member ID to sync calendar for.', 'booktics' ),
				'type'              => 'integer',
				'required'          => true,
				'validate_callback' => 'absint',
				'sanitize_callback' => 'absint',
			),
		);
	}

	/**
	 * Get Google Meet URL for order mail
	 *
	 * @param   array 		$notification_flow_data The notification flow data
	 * @param   array 		$formatted_appointment  The formatted appointment data
	 * @param   Order_Model $order_model  			The order model for the notification data
	 *
	 * @return  array  The updated notification flow data with Google Meet URL
	 */
	public function get_meet_url_for_order_mail( $notification_flow_data, $formatted_appointment, $order_model ) {
		try {
			return $this->get_meet_url( $notification_flow_data, $formatted_appointment );
		} catch ( \Throwable $e) {
			return $notification_flow_data;
		}
	}

	/**
	 * Get Google Meet URL
	 *
	 * @param   array $notification_flow_data  The notification flow data
	 * @param   array $formatted_appointment  The formatted appointment data
	 *
	 * @return  array  The updated notification flow data with Google Meet URL
	 */
	public function get_meet_url( $notification_flow_data, $formatted_appointment ) {
		if ( ! isset ( $formatted_appointment['service']['enable_google_meet'] ) ) {
			return $notification_flow_data;
		}

		// If Google Meet is not enabled for the service, return the original notification flow data
		if ( ! $formatted_appointment['service']['enable_google_meet'] ) {
			return $notification_flow_data;
		}

		$user_id = $formatted_appointment['team']['id'] ?? null;

		if ( empty( $user_id ) ) {
			return $notification_flow_data;
		}

		// get_access_token_for_api() also refreshes the token when it has expired.
		$access_token = $this->calendar->get_access_token_for_api( $user_id );

		if ( empty( $access_token ) ) {
			// The booking's own team member owns the Meet link, so a member who never
			// connected Google - or whose token has died - silently drops the link
			// from the email. Log it, otherwise the only symptom is an empty
			// google_meet_url smart tag with nothing to explain it.
			if ( defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) {
				error_log( '[Booktics Google Calendar] No usable Google token for team member ' . $user_id . '; skipping Meet link.' ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
			}

			return $notification_flow_data;
		}
		try {
			// Get the WordPress timezone
			$timezone        = wp_timezone();
			$timezone_string = $timezone->getName();

			// Appointment date/time are stored in UTC. Combine them so the Meet event lands on the actual appointment date instead of "today".
			$appointment_date = $formatted_appointment['date'] ?? '';
			$start_timestamp  = strtotime( $appointment_date . ' ' . $formatted_appointment['start_time'] . ' UTC' );
			$end_timestamp    = strtotime( $appointment_date . ' ' . $formatted_appointment['end_time'] . ' UTC' );

			// Format dates in ISO 8601 format with timezone using wp_date
			$start_time = wp_date( 'c', $start_timestamp, $timezone );
			$end_time   = wp_date( 'c', $end_timestamp, $timezone );

			$event_data = array(
				'summary'        => __( 'Booktics Meeting', 'booktics' ),
				'start'          => array(
					'dateTime' => $start_time,
					'timeZone' => $timezone_string,
				),
				'end'            => array(
					'dateTime' => $end_time,
					'timeZone' => $timezone_string,
				),
				'conferenceData' => array(
					'createRequest' => array(
						'requestId'             => uniqid( 'meet_', true ),
						'conferenceSolutionKey' => array(
							'type' => 'hangoutsMeet',
						),
					),
				),
			);

			$response = wp_remote_post(
				'https://www.googleapis.com/calendar/v3/calendars/primary/events?conferenceDataVersion=1',
				array(
					'headers' => array(
						'Authorization' => 'Bearer ' . $access_token,
						'Content-Type'  => 'application/json',
					),
					'body'    => wp_json_encode( $event_data ),
					'timeout' => 15,
				)
			);

			if ( is_wp_error( $response ) ) {
				return $notification_flow_data;
			}

			$body = json_decode( wp_remote_retrieve_body( $response ), true );

			// Get the Meet link from conferenceData
			if ( isset( $body['hangoutLink'] ) ) {
				$notification_flow_data['google_meet_url'] = esc_url_raw( $body['hangoutLink'] );
				return $notification_flow_data;
			}

			// Fallback or log error
			return $notification_flow_data;
		} catch ( \Throwable $e ) {
			return $notification_flow_data;
		}
	}

	/**
	 * Get Google Calendar API filters.
	 *
	 * @param array $args The arguments passed to the function.
	 * @return array The API filters.
	 */
	private function get_google_calendar_api_filters( $args ) {
		$api_filters = array();

		// Get start and end dates from args in appropriate format
		if ( isset( $args['start_date'] ) ) {
			$api_filters['timeMin'] = rawurlencode( wp_date( DATE_RFC3339, strtotime( $args['start_date'] ) ) );
		}

		if ( isset( $args['end_date'] ) ) {
			$api_filters['timeMax'] = rawurlencode( wp_date( DATE_RFC3339, strtotime( $args['end_date'] ) ) );
		}

		return $api_filters;
	}

	/**
	 * Adds add event to google calendar link for customers in the thank you page.
	 *
	 * @param array $calendars The calendars list.
	 * @param array $request The request data.
	 * @return array The modified calendars list.
	 */
	public function get_add_to_calendar_links( $calendars, $request ) {
		$meeting_id   = ! empty( $request['meeting_id'] ) ? intval( $request['meeting_id'] ) : 0;
		$booking_date = ! empty( $request['booking_date'] ) ? sanitize_text_field( $request['booking_date'] ) : '';
		$start_time   = ! empty( $request['start_time'] ) ? sanitize_text_field( $request['start_time'] ) : '';
		$end_time     = ! empty( $request['end_time'] ) ? sanitize_text_field( $request['end_time'] ) : '';

		$meeting = ( new Appointment_Model( $meeting_id ) )->to_array();
		$service = ( new Service_Model( $meeting['service_id'] ) )->to_array();
		// Use the appointment's timezone if available, otherwise fallback to WordPress timezone
		$appointment_timezone = ! empty( $meeting['timezone'] ) ? $meeting['timezone'] : wp_timezone_string();

		$google_url = $this->get_add_to_google_calendar_url(
			array(
				'date'        => $booking_date,
				'start_time'  => $start_time,
				'end_time'    => $end_time,
				'title'       => $service['title'],
				'description' => $meeting['note'],
				'timezone'    => $appointment_timezone,
			)
		);

		$calendars = array();

		if ( $google_url ) {
			$calendars[] = array(
				'id'   => 'google',
				'name' => esc_html__( 'Google', 'booktics' ),
				'url'  => esc_url_raw( $google_url ),
			);
		}

		return $calendars;
	}

	/**
	 * Get Google Calendar URL that will be used to add event to Google Calendar
	 * From the customer side.
	 *
	 * @param array $args {
	 *     @type string $date        Booking date in 'Y-m-d' format.
	 *     @type string $start_time  Start time in 'H:i' format.
	 *     @type string $end_time    End time in 'H:i' format.
	 *     @type string $location    Event location.
	 *     @type string $title       Event title.
	 *     @type string $description Event description.
	 *     @type string $timezone    Event timezone.
	 * }
	 *
	 * @return string Google Calendar URL
	 */
	private function get_add_to_google_calendar_url( $args = array() ) {
		$defaults = array(
			'date'        => '',
			'start_time'  => '',
			'end_time'    => '',
			'location'    => '',
			'title'       => '',
			'description' => '',
			'timezone'    => '',
		);

		$args = wp_parse_args( $args, $defaults );

		if ( empty( $args['date'] ) || empty( $args['start_time'] ) || empty( $args['end_time'] ) ) {
			return '';
		}

		// Create datetime objects with the appointment's timezone and convert to UTC
		try {
			// Use the provided timezone for the appointment
			$appointment_timezone = new \DateTimeZone( $args['timezone'] ?: wp_timezone_string() );

			// Create DateTime objects in the appointment's timezone
			$start_dt = new \DateTime( $args['date'] . ' ' . $args['start_time'], $appointment_timezone );
			$end_dt   = new \DateTime( $args['date'] . ' ' . $args['end_time'], $appointment_timezone );

			// Convert to UTC for Google Calendar
			$start_dt->setTimezone( new \DateTimeZone( 'UTC' ) );
			$end_dt->setTimezone( new \DateTimeZone( 'UTC' ) );

			// Format as Google expects: YYYYMMDDTHHMMSSZ
			$start_utc = $start_dt->format( 'Ymd\THis\Z' );
			$end_utc   = $end_dt->format( 'Ymd\THis\Z' );
		} catch ( \Throwable $e ) {
			return '';
		}

		$params = array(
			'action'   => 'TEMPLATE',
			'text'     => $args['title'],
			'dates'    => $start_utc . '/' . $end_utc,
			'details'  => $args['description'],
			'location' => $args['location'],
			'sf'       => 'true',
			'output'   => 'xml',
			'ctz'      => $args['timezone'],
		);

		return 'https://calendar.google.com/calendar/render?' . http_build_query( $params );
	}
}

```
