config = array( 'client_id' => booktics_get_option( 'google_client_id' ), 'client_secret' => booktics_get_option( 'google_secret_key' ), 'base_url' => 'https://www.googleapis.com/calendar/v3', ); } /** * Get valid access token for API calls with automatic refresh. * * @param int $user_id User ID * @return string|false Access token or false on failure */ private function get_valid_access_token( $user_id ) { $tokens = get_user_meta( $user_id, 'booktics_google_auth_token', true ); if ( empty( $tokens ) ) { return false; } // Handle both string (old format) and array (new format) tokens if ( is_string( $tokens ) ) { $tokens = json_decode( $tokens, true ); } if ( ! is_array( $tokens ) || empty( $tokens['access_token'] ) ) { return false; } // Check if token needs refreshing if ( $this->is_token_expired( $tokens ) ) { $refreshed_tokens = $this->refresh_access_token( $tokens, $user_id ); if ( ! $refreshed_tokens ) { return false; } return $refreshed_tokens['access_token']; } return $tokens['access_token']; } /** * Check if token is expired * * @param array $tokens Token array * @return bool True if expired */ private function is_token_expired( $tokens ) { if ( empty( $tokens['expires_at'] ) ) { return true; } $expires_at = is_numeric( $tokens['expires_at'] ) ? $tokens['expires_at'] : strtotime( $tokens['expires_at'] ); $buffer_time = 300; // 5 minutes buffer return ( time() + $buffer_time ) >= $expires_at; } /** * Refresh access token using refresh token * * @param array $tokens Current tokens * @param int $user_id User ID * @return array|false Refreshed tokens or false on failure */ private function refresh_access_token( $tokens, $user_id ) { if ( empty( $tokens['refresh_token'] ) ) { return false; } $response = wp_remote_post( 'https://oauth2.googleapis.com/token', array( 'body' => array( 'client_id' => $this->config['client_id'], 'client_secret' => $this->config['client_secret'], 'refresh_token' => $tokens['refresh_token'], 'grant_type' => 'refresh_token', ), ) ); if ( is_wp_error( $response ) ) { return false; } $body = wp_remote_retrieve_body( $response ); $new_token = json_decode( $body, true ); if ( isset( $new_token['error'] ) ) { // `invalid_grant` means the refresh token was revoked or expired (e.g., 7-day limit in "Testing" mode). // Clear it so status reports correctly and prompts reconnection. Other errors are transient. if ( 'invalid_grant' === $new_token['error'] ) { delete_user_meta( $user_id, 'booktics_google_auth_token' ); } if ( defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) { error_log( '[Booktics Google Calendar] Token refresh failed for user ' . $user_id . ': ' . $new_token['error'] ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log } return false; } // Merge with existing tokens to preserve refresh_token $refreshed_tokens = array_merge( $tokens, $new_token ); // Set expiry time if ( isset( $refreshed_tokens['expires_in'] ) ) { $refreshed_tokens['expires_at'] = time() + $refreshed_tokens['expires_in']; } // Save the refreshed tokens update_user_meta( $user_id, 'booktics_google_auth_token', $refreshed_tokens ); return $refreshed_tokens; } /** * Get access token for API calls with automatic refresh. * * @param int $user_id User ID * @return string|false Access token or false on failure */ public function get_access_token_for_api( $user_id = null ) { if ( ! $user_id ) { $user_id = get_current_user_id(); } return $this->get_valid_access_token( $user_id ); } /** * Create event using direct Google Calendar API calls. * * @param \Booktics\Models\AppointmentModel $appointment appointment data * @param int $user_id user id * @param array $args additional arguments * @return string|false Event ID on success, false on failure */ public function create_event($appointment, $user_id = null, $args = array()) { if (!$user_id) { $user_id = get_current_user_id(); } $access_token = $this->get_access_token_for_api($user_id); if ( !$access_token ) { return false; } // Handle customer data safely $customer_email = ''; if (!empty($appointment->customer_id) && $appointment->customer_id > 0) { try { $customer = new Customer_Model($appointment->customer_id); $customer_email = $customer->user_email ?? ''; } catch (Exception $e) { if ( defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) { error_log('[Booktics Google Calendar] Failed to load customer data: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log } } } $service = new Service_Model( $appointment->service_id ); // Combine date with start and end times $start_datetime_string = $appointment->date . ' ' . $appointment->start_time; $end_datetime_string = $appointment->date . ' ' . $appointment->end_time; // Get timezone from the appointment times (fallback to WordPress timezone) $appointment_timezone = $this->get_appointment_timezone( $appointment ); // Create DateTime objects with proper timezone handling try { $timezone_obj = new \DateTimeZone($appointment_timezone); // Convert 12-hour format to 24-hour format if needed $start_time_24h = gmdate('H:i', strtotime($appointment->start_time)); $end_time_24h = gmdate('H:i', strtotime($appointment->end_time)); $start_datetime_24h = $appointment->date . ' ' . $start_time_24h; $end_datetime_24h = $appointment->date . ' ' . $end_time_24h; $start_dt = new \DateTime($start_datetime_24h, $timezone_obj); $end_dt = new \DateTime($end_datetime_24h, $timezone_obj); $start_datetime_formatted = $start_dt->format('c'); // RFC3339 format $end_datetime_formatted = $end_dt->format('c'); // RFC3339 format } catch (\Exception $e) { // Fallback to original method if timezone creation fails if ( defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) { error_log('[Booktics Google Calendar] Timezone error: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log } $start_datetime_formatted = wp_date('Y-m-d\TH:i:s', strtotime( $start_datetime_string )); $end_datetime_formatted = wp_date('Y-m-d\TH:i:s', strtotime( $end_datetime_string )); } $defaults = array( 'summary' => $service->title ?? 'Booktics Appointment', 'description' => $appointment->note ?? '', 'location' => $appointment->location ?? '', 'start' => array( 'dateTime' => $start_datetime_formatted, 'timeZone' => $appointment_timezone, ), 'end' => array( 'dateTime' => $end_datetime_formatted, 'timeZone' => $appointment_timezone, ), 'attendees' => !empty($customer_email) ? array( array('email' => $customer_email), ) : array(), 'google_meet' => false, ); $args = wp_parse_args($args, $defaults); $data = array( 'headers' => array( 'Authorization' => 'Bearer ' . $access_token, 'Content-Type' => 'application/json; charset=utf-8', ), 'body' => wp_json_encode($args), ); $query_params = build_query( array( 'conferenceDataVersion' => '1', ) ); $response = wp_remote_post(self::BOOKTICS_GOOGLE_CALENDAR_EVENT . '?' . $query_params, $data); if ( is_wp_error( $response ) ) { return false; } $status_code = wp_remote_retrieve_response_code( $response ); if (200 != $status_code && 201 != $status_code) { return false; } $response_data = json_decode(wp_remote_retrieve_body($response), true); return $response_data['id'] ?? false; } /** * Update event using direct Google Calendar API calls. * * @param string $event_id Event ID * @param \Booktics\Models\AppointmentModel $appointment Appointment data * @param int $user_id user id * @param array $args additional arguments * @return bool True on success, false on failure */ public function update_event(string $event_id, $appointment, $user_id = null, $args = array()): bool { if (!$user_id) { $user_id = get_current_user_id(); } $access_token = $this->get_access_token_for_api($user_id); if (!$access_token) { return false; } // Handle customer data safely $customer_email = ''; if (!empty($appointment->customer_id) && $appointment->customer_id > 0) { try { $customer = new Customer_Model($appointment->customer_id); $customer_email = $customer->user_email ?? ''; } catch (Exception $e) { if ( defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) { error_log('[Booktics Google Calendar] Failed to load customer data: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log } } } $service = new Service_Model($appointment->service_id); // Combine date with start and end times $start_datetime_string = $appointment->date . ' ' . $appointment->start_time; $end_datetime_string = $appointment->date . ' ' . $appointment->end_time; // Get timezone from the appointment times (fallback to WordPress timezone) $appointment_timezone = $this->get_appointment_timezone( $appointment ); // Create DateTime objects with proper timezone handling try { $timezone_obj = new \DateTimeZone($appointment_timezone); // Convert 12-hour format to 24-hour format if needed $start_time_24h = gmdate('H:i', strtotime($appointment->start_time)); $end_time_24h = gmdate('H:i', strtotime($appointment->end_time)); $start_datetime_24h = $appointment->date . ' ' . $start_time_24h; $end_datetime_24h = $appointment->date . ' ' . $end_time_24h; $start_dt = new \DateTime($start_datetime_24h, $timezone_obj); $end_dt = new \DateTime($end_datetime_24h, $timezone_obj); $start_datetime_formatted = $start_dt->format('c'); // RFC3339 format $end_datetime_formatted = $end_dt->format('c'); // RFC3339 format } catch (\Exception $e) { // Fallback to original method if timezone creation fails if ( defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) { error_log('[Booktics Google Calendar] Timezone error: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log } $start_datetime_formatted = wp_date('Y-m-d\TH:i:s', strtotime($start_datetime_string)); $end_datetime_formatted = wp_date('Y-m-d\TH:i:s', strtotime($end_datetime_string)); } $defaults = array( 'summary' => $service->title ?? 'Booktics Appointment', 'description' => $appointment->note ?? '', 'location' => $appointment->location ?? '', 'start' => array( 'dateTime' => $start_datetime_formatted, 'timeZone' => $appointment_timezone, ), 'end' => array( 'dateTime' => $end_datetime_formatted, 'timeZone' => $appointment_timezone, ), 'attendees' => !empty($customer_email) ? array( array('email' => $customer_email), ) : array(), 'google_meet' => false, ); $args = wp_parse_args($args, $defaults); $data = array( 'headers' => array( 'Authorization' => 'Bearer ' . $access_token, 'Content-Type' => 'application/json; charset=utf-8', ), 'body' => wp_json_encode($args), 'method' => 'PUT', ); $query_params = build_query( array( 'conferenceDataVersion' => '1', 'sendUpdates' => 'all', ) ); $response = wp_remote_post(self::BOOKTICS_GOOGLE_CALENDAR_EVENT . '/' . $event_id . '?' . $query_params, $data); if (is_wp_error($response)) { return false; } $status_code = wp_remote_retrieve_response_code($response); if (200 != $status_code) { return false; } $response_data = json_decode(wp_remote_retrieve_body($response), true); return true; } /** * Delete event using direct Google Calendar API calls. * * @param string $event_id Event ID * @param int $user_id user id * @return bool True on success, false on failure */ public function delete_event(string $event_id, $user_id = null): bool { if (!$user_id) { $user_id = get_current_user_id(); } $access_token = $this->get_access_token_for_api($user_id); if (!$access_token) { return false; } $data = array( 'headers' => array( 'Authorization' => 'Bearer ' . $access_token, 'Content-Type' => 'application/json; charset=utf-8', ), 'method' => 'DELETE', ); $query_params = build_query( array( 'conferenceDataVersion' => '1', 'sendUpdates' => 'all', ) ); $response = wp_remote_post(self::BOOKTICS_GOOGLE_CALENDAR_EVENT . '/' . $event_id . '?' . $query_params, $data); if (is_wp_error($response)) { return false; } $status_code = wp_remote_retrieve_response_code($response); if (204 != $status_code && 200 != $status_code) { return false; } return true; } /** * Get Google Calendar events using direct API calls. * * @param int $user_id Team member user ID * @param array $api_filters Additional API filters * @return array|WP_Error Array of events or WP_Error on failure */ public function get_google_calendar_events($user_id = null, $api_filters = array()) { if (!$user_id) { $user_id = get_current_user_id(); } $access_token = $this->get_access_token_for_api($user_id); if (!$access_token) { return new WP_Error('invalid_tokens', __('Failed to get valid access token.', 'booktics')); } // Define the time range $three_months_ago = wp_date('c', strtotime('-3 months')); $three_months_ahead = wp_date('c', strtotime('+3 months')); $filters = array( 'timeMin' => rawurlencode($three_months_ago), 'timeMax' => rawurlencode($three_months_ahead), 'orderBy' => 'startTime', 'singleEvents' => 'true', ); $filters = array_merge($filters, $api_filters); $api_url = add_query_arg($filters, self::BOOKTICS_GOOGLE_CALENDAR_EVENT); $args = array( 'headers' => array( 'Authorization' => 'Bearer ' . $access_token, 'Content-Type' => 'application/json', ), ); $response = wp_remote_get($api_url, $args); if (is_wp_error($response)) { return array(); } $body = wp_remote_retrieve_body($response); $events = json_decode($body, true); if (empty($events['items'])) { return array(); } return $events['items']; } /** * Prepare appointment data from Google Calendar event. * * @param array $event Event data from Google Calendar API * @return array */ public function prepare_appointment_data_from_event( $event ) { try { $start = $event['start']; $end = $event['end']; // Parse date and time $start_datetime = new \DateTime( $start['dateTime'] ); $end_datetime = new \DateTime( $end['dateTime'] ); // Extract service and customer names from summary $summary = $event['summary']; $parts = explode( ' - ', $summary, 2 ); $service_name = $parts[0] ?? ''; $event_name = $parts[1] ?? $summary; // Build appointment data $appointment_data = array( 'title' => $event_name, 'content' => $event['summary'], 'status' => 'approved', 'type' => 'booktics-appointment', 'source' => 'google', 'email' => '', 'date' => $start_datetime->format( 'Y-m-d' ), 'start_time' => $start_datetime->format( 'g:i A' ), 'end_time' => $end_datetime->format( 'g:i A' ), 'customer' => array( 'display_name' => $event_name, 'note' => '', ), 'service' => array( 'title' => $service_name, ), ); return $appointment_data; } catch ( Exception $e ) { throw $e; } } /** * Check if calendar is configured for a team member. * * @since 1.0.0 * * @param int $team_member_id Team member user ID. * @return bool True if configured, false otherwise. */ public function is_configured( int $team_member_id ): bool { $tokens = get_user_meta( $team_member_id, 'booktics_google_auth_token', true ); return ! empty( $tokens ); } /** * Returns the id for the calendar. * * @return string */ public static function get_id(): string { return 'google_calendar'; } /** * Implemented to ensure woring code for extending the calendar interface */ public function sync_events(int $team_member_id) { // intentionally kept empty to implement calendar interface; } /** * Selects the timezone for the appointment. Including legacy appointments which doesnt have timezone. * * @return string timezone string. */ private function get_appointment_timezone($appointment) { // Try different ways to access the timezone $timezone_sources = [ 'direct_property' => $appointment->timezone ?? null, 'to_array' => method_exists($appointment, 'to_array') ? ($appointment->to_array()['timezone'] ?? null) : null, ]; // Check each source foreach ($timezone_sources as $source => $value) { if (!empty($value) && $value !== '+00:00') { return $value; } } // Try to get from post meta if (!empty($appointment->id)) { $meta_timezone = get_post_meta($appointment->id, 'timezone', true); if (!empty($meta_timezone) && $meta_timezone !== '+00:00') { return $meta_timezone; } } if (!empty($appointment->order_id)) { $db = new Booktics_Database(); $order = new \Booktics\Models\Order_Model( $db ); $order_data = $order->find(['id' => $appointment->order_id]); if ($order_data && $order_data->timezone) { $order_timezone = $order_data->timezone instanceof \DateTimeZone ? $order_data->timezone->getName() : $order_data->timezone; if (!empty($order_timezone) && $order_timezone !== '+00:00') { return $order_timezone; } } } // Fallback to WordPress timezone return wp_timezone_string(); } /** * Register the service. * * @since 1.0.0 */ public function register() { // Register this calendar with the registry. add_action( 'booktics_calendars_register', function () { \Booktics\Calendar\Calendar_Registry::register_calendar( static::class ); } ); } }