PluginProbe
Booktics – Appointment Booking Calendar for Service Businesses / 1.0.28
Booktics – Appointment Booking Calendar for Service Businesses v1.0.28
1.0.29 1.0.28 1.0.27 1.0.26 1.0.25 1.0.24 1.0.23 1.0.22 1.0.21 1.0.20 1.0.19 trunk 1.0.0 1.0.1 1.0.10 1.0.11 1.0.12 1.0.13 1.0.14 1.0.15 1.0.16 1.0.17 1.0.18 1.0.2 1.0.3 All 31 releases
booktics / core / module / google-calendar / Service / calendar.php

calendar.php in Booktics – Appointment Booking Calendar for Service Businesses 1.0.28, at core/module/google-calendar/Service/calendar.php

664 lines 18.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * Google Calendar Service with Token Refresh
5 *
6 * @package BookticsGoogleCalendar
7 * @subpackage Google\Service
8 * @since 1.0.0
9 */
10
11 namespace Booktics\Module\Google_Calendar\Service;
12
13 use Booktics\Abstracts\Booktics_Database;
14 use Booktics\Calendar\Calendar_Interface;
15 use Booktics\Models\Customer_Model;
16 use Booktics\Models\Service_Model;
17 use Exception;
18 use WP_Error;
19
20 // Exit if accessed directly.
21 defined( 'ABSPATH' ) || exit;
22
23 /**
24 * Class Calendar
25 *
26 * Handles Google Calendar integration and operations using direct API calls.
27 *
28 * @since 1.0.0
29 */
30 class Calendar implements Calendar_Interface {
31
32 /**
33 * Google API configuration.
34 *
35 * @since 1.0.0
36 * @var array
37 */
38 protected $config;
39
40 /**
41 * Default calendar ID.
42 *
43 * @since 1.0.0
44 * @var string
45 */
46 private $calendar_id = 'primary';
47
48 const BOOKTICS_GOOGLE_CALENDAR_EVENT = 'https://www.googleapis.com/calendar/v3/calendars/primary/events';
49
50 /**
51 * Constructor with API configuration.
52 *
53 * @since 1.0.0
54 */
55 public function __construct() {
56 $this->config = array(
57 'client_id' => booktics_get_option( 'google_client_id' ),
58 'client_secret' => booktics_get_option( 'google_secret_key' ),
59 'base_url' => 'https://www.googleapis.com/calendar/v3',
60 );
61 }
62
63 /**
64 * Get valid access token for API calls with automatic refresh.
65 *
66 * @param int $user_id User ID
67 * @return string|false Access token or false on failure
68 */
69 private function get_valid_access_token( $user_id ) {
70 $tokens = get_user_meta( $user_id, 'booktics_google_auth_token', true );
71
72 if ( empty( $tokens ) ) {
73 return false;
74 }
75
76 // Handle both string (old format) and array (new format) tokens
77 if ( is_string( $tokens ) ) {
78 $tokens = json_decode( $tokens, true );
79 }
80
81 if ( ! is_array( $tokens ) || empty( $tokens['access_token'] ) ) {
82 return false;
83 }
84
85 // Check if token needs refreshing
86 if ( $this->is_token_expired( $tokens ) ) {
87 $refreshed_tokens = $this->refresh_access_token( $tokens, $user_id );
88 if ( ! $refreshed_tokens ) {
89 return false;
90 }
91 return $refreshed_tokens['access_token'];
92 }
93
94 return $tokens['access_token'];
95 }
96
97 /**
98 * Check if token is expired
99 *
100 * @param array $tokens Token array
101 * @return bool True if expired
102 */
103 private function is_token_expired( $tokens ) {
104 if ( empty( $tokens['expires_at'] ) ) {
105 return true;
106 }
107
108 $expires_at = is_numeric( $tokens['expires_at'] ) ? $tokens['expires_at'] : strtotime( $tokens['expires_at'] );
109 $buffer_time = 300; // 5 minutes buffer
110
111 return ( time() + $buffer_time ) >= $expires_at;
112 }
113
114 /**
115 * Refresh access token using refresh token
116 *
117 * @param array $tokens Current tokens
118 * @param int $user_id User ID
119 * @return array|false Refreshed tokens or false on failure
120 */
121 private function refresh_access_token( $tokens, $user_id ) {
122 if ( empty( $tokens['refresh_token'] ) ) {
123 return false;
124 }
125
126 $response = wp_remote_post(
127 'https://oauth2.googleapis.com/token',
128 array(
129 'body' => array(
130 'client_id' => $this->config['client_id'],
131 'client_secret' => $this->config['client_secret'],
132 'refresh_token' => $tokens['refresh_token'],
133 'grant_type' => 'refresh_token',
134 ),
135 )
136 );
137
138 if ( is_wp_error( $response ) ) {
139 return false;
140 }
141
142 $body = wp_remote_retrieve_body( $response );
143 $new_token = json_decode( $body, true );
144
145 if ( isset( $new_token['error'] ) ) {
146 // `invalid_grant` means the refresh token was revoked or expired (e.g., 7-day limit in "Testing" mode).
147 // Clear it so status reports correctly and prompts reconnection. Other errors are transient.
148 if ( 'invalid_grant' === $new_token['error'] ) {
149 delete_user_meta( $user_id, 'booktics_google_auth_token' );
150 }
151
152 if ( defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) {
153 error_log( '[Booktics Google Calendar] Token refresh failed for user ' . $user_id . ': ' . $new_token['error'] ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
154 }
155
156 return false;
157 }
158
159 // Merge with existing tokens to preserve refresh_token
160 $refreshed_tokens = array_merge( $tokens, $new_token );
161
162 // Set expiry time
163 if ( isset( $refreshed_tokens['expires_in'] ) ) {
164 $refreshed_tokens['expires_at'] = time() + $refreshed_tokens['expires_in'];
165 }
166
167 // Save the refreshed tokens
168 update_user_meta( $user_id, 'booktics_google_auth_token', $refreshed_tokens );
169
170 return $refreshed_tokens;
171 }
172
173 /**
174 * Get access token for API calls with automatic refresh.
175 *
176 * @param int $user_id User ID
177 * @return string|false Access token or false on failure
178 */
179 public function get_access_token_for_api( $user_id = null ) {
180 if ( ! $user_id ) {
181 $user_id = get_current_user_id();
182 }
183
184 return $this->get_valid_access_token( $user_id );
185 }
186
187 /**
188 * Create event using direct Google Calendar API calls.
189 *
190 * @param \Booktics\Models\AppointmentModel $appointment appointment data
191 * @param int $user_id user id
192 * @param array $args additional arguments
193 * @return string|false Event ID on success, false on failure
194 */
195 public function create_event($appointment, $user_id = null, $args = array()) {
196 if (!$user_id) {
197 $user_id = get_current_user_id();
198 }
199
200 $access_token = $this->get_access_token_for_api($user_id);
201
202 if ( !$access_token ) {
203 return false;
204 }
205
206 // Handle customer data safely
207 $customer_email = '';
208 if (!empty($appointment->customer_id) && $appointment->customer_id > 0) {
209 try {
210 $customer = new Customer_Model($appointment->customer_id);
211 $customer_email = $customer->user_email ?? '';
212 } catch (Exception $e) {
213 if ( defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) {
214 error_log('[Booktics Google Calendar] Failed to load customer data: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
215 }
216 }
217 }
218
219 $service = new Service_Model( $appointment->service_id );
220
221 // Combine date with start and end times
222 $start_datetime_string = $appointment->date . ' ' . $appointment->start_time;
223 $end_datetime_string = $appointment->date . ' ' . $appointment->end_time;
224
225 // Get timezone from the appointment times (fallback to WordPress timezone)
226 $appointment_timezone = $this->get_appointment_timezone( $appointment );
227
228 // Create DateTime objects with proper timezone handling
229 try {
230 $timezone_obj = new \DateTimeZone($appointment_timezone);
231
232 // Convert 12-hour format to 24-hour format if needed
233 $start_time_24h = gmdate('H:i', strtotime($appointment->start_time));
234 $end_time_24h = gmdate('H:i', strtotime($appointment->end_time));
235
236 $start_datetime_24h = $appointment->date . ' ' . $start_time_24h;
237 $end_datetime_24h = $appointment->date . ' ' . $end_time_24h;
238
239 $start_dt = new \DateTime($start_datetime_24h, $timezone_obj);
240 $end_dt = new \DateTime($end_datetime_24h, $timezone_obj);
241
242 $start_datetime_formatted = $start_dt->format('c'); // RFC3339 format
243 $end_datetime_formatted = $end_dt->format('c'); // RFC3339 format
244
245 } catch (\Exception $e) {
246 // Fallback to original method if timezone creation fails
247 if ( defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) {
248 error_log('[Booktics Google Calendar] Timezone error: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
249 }
250 $start_datetime_formatted = wp_date('Y-m-d\TH:i:s', strtotime( $start_datetime_string ));
251 $end_datetime_formatted = wp_date('Y-m-d\TH:i:s', strtotime( $end_datetime_string ));
252 }
253
254 $defaults = array(
255 'summary' => $service->title ?? 'Booktics Appointment',
256 'description' => $appointment->note ?? '',
257 'location' => $appointment->location ?? '',
258 'start' => array(
259 'dateTime' => $start_datetime_formatted,
260 'timeZone' => $appointment_timezone,
261 ),
262 'end' => array(
263 'dateTime' => $end_datetime_formatted,
264 'timeZone' => $appointment_timezone,
265 ),
266 'attendees' => !empty($customer_email) ? array(
267 array('email' => $customer_email),
268 ) : array(),
269 'google_meet' => false,
270 );
271
272 $args = wp_parse_args($args, $defaults);
273
274 $data = array(
275 'headers' => array(
276 'Authorization' => 'Bearer ' . $access_token,
277 'Content-Type' => 'application/json; charset=utf-8',
278 ),
279 'body' => wp_json_encode($args),
280 );
281 $query_params = build_query(
282 array(
283 'conferenceDataVersion' => '1',
284 )
285 );
286
287 $response = wp_remote_post(self::BOOKTICS_GOOGLE_CALENDAR_EVENT . '?' . $query_params, $data);
288
289 if ( is_wp_error( $response ) ) {
290 return false;
291 }
292
293 $status_code = wp_remote_retrieve_response_code( $response );
294
295 if (200 != $status_code && 201 != $status_code) {
296 return false;
297 }
298
299 $response_data = json_decode(wp_remote_retrieve_body($response), true);
300 return $response_data['id'] ?? false;
301 }
302
303 /**
304 * Update event using direct Google Calendar API calls.
305 *
306 * @param string $event_id Event ID
307 * @param \Booktics\Models\AppointmentModel $appointment Appointment data
308 * @param int $user_id user id
309 * @param array $args additional arguments
310 * @return bool True on success, false on failure
311 */
312 public function update_event(string $event_id, $appointment, $user_id = null, $args = array()): bool {
313 if (!$user_id) {
314 $user_id = get_current_user_id();
315 }
316
317 $access_token = $this->get_access_token_for_api($user_id);
318
319 if (!$access_token) {
320 return false;
321 }
322
323 // Handle customer data safely
324 $customer_email = '';
325 if (!empty($appointment->customer_id) && $appointment->customer_id > 0) {
326 try {
327 $customer = new Customer_Model($appointment->customer_id);
328 $customer_email = $customer->user_email ?? '';
329 } catch (Exception $e) {
330 if ( defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) {
331 error_log('[Booktics Google Calendar] Failed to load customer data: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
332 }
333 }
334 }
335
336 $service = new Service_Model($appointment->service_id);
337
338 // Combine date with start and end times
339 $start_datetime_string = $appointment->date . ' ' . $appointment->start_time;
340 $end_datetime_string = $appointment->date . ' ' . $appointment->end_time;
341
342 // Get timezone from the appointment times (fallback to WordPress timezone)
343 $appointment_timezone = $this->get_appointment_timezone( $appointment );
344
345 // Create DateTime objects with proper timezone handling
346 try {
347 $timezone_obj = new \DateTimeZone($appointment_timezone);
348
349 // Convert 12-hour format to 24-hour format if needed
350 $start_time_24h = gmdate('H:i', strtotime($appointment->start_time));
351 $end_time_24h = gmdate('H:i', strtotime($appointment->end_time));
352
353 $start_datetime_24h = $appointment->date . ' ' . $start_time_24h;
354 $end_datetime_24h = $appointment->date . ' ' . $end_time_24h;
355
356 $start_dt = new \DateTime($start_datetime_24h, $timezone_obj);
357 $end_dt = new \DateTime($end_datetime_24h, $timezone_obj);
358
359 $start_datetime_formatted = $start_dt->format('c'); // RFC3339 format
360 $end_datetime_formatted = $end_dt->format('c'); // RFC3339 format
361
362 } catch (\Exception $e) {
363 // Fallback to original method if timezone creation fails
364 if ( defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) {
365 error_log('[Booktics Google Calendar] Timezone error: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
366 }
367 $start_datetime_formatted = wp_date('Y-m-d\TH:i:s', strtotime($start_datetime_string));
368 $end_datetime_formatted = wp_date('Y-m-d\TH:i:s', strtotime($end_datetime_string));
369 }
370
371 $defaults = array(
372 'summary' => $service->title ?? 'Booktics Appointment',
373 'description' => $appointment->note ?? '',
374 'location' => $appointment->location ?? '',
375 'start' => array(
376 'dateTime' => $start_datetime_formatted,
377 'timeZone' => $appointment_timezone,
378 ),
379 'end' => array(
380 'dateTime' => $end_datetime_formatted,
381 'timeZone' => $appointment_timezone,
382 ),
383 'attendees' => !empty($customer_email) ? array(
384 array('email' => $customer_email),
385 ) : array(),
386 'google_meet' => false,
387 );
388
389 $args = wp_parse_args($args, $defaults);
390
391 $data = array(
392 'headers' => array(
393 'Authorization' => 'Bearer ' . $access_token,
394 'Content-Type' => 'application/json; charset=utf-8',
395 ),
396 'body' => wp_json_encode($args),
397 'method' => 'PUT',
398 );
399
400 $query_params = build_query(
401 array(
402 'conferenceDataVersion' => '1',
403 'sendUpdates' => 'all',
404 )
405 );
406
407 $response = wp_remote_post(self::BOOKTICS_GOOGLE_CALENDAR_EVENT . '/' . $event_id . '?' . $query_params, $data);
408
409 if (is_wp_error($response)) {
410 return false;
411 }
412
413 $status_code = wp_remote_retrieve_response_code($response);
414
415 if (200 != $status_code) {
416 return false;
417 }
418
419 $response_data = json_decode(wp_remote_retrieve_body($response), true);
420
421 return true;
422 }
423
424 /**
425 * Delete event using direct Google Calendar API calls.
426 *
427 * @param string $event_id Event ID
428 * @param int $user_id user id
429 * @return bool True on success, false on failure
430 */
431 public function delete_event(string $event_id, $user_id = null): bool {
432 if (!$user_id) {
433 $user_id = get_current_user_id();
434 }
435
436 $access_token = $this->get_access_token_for_api($user_id);
437
438 if (!$access_token) {
439 return false;
440 }
441
442 $data = array(
443 'headers' => array(
444 'Authorization' => 'Bearer ' . $access_token,
445 'Content-Type' => 'application/json; charset=utf-8',
446 ),
447 'method' => 'DELETE',
448 );
449
450 $query_params = build_query(
451 array(
452 'conferenceDataVersion' => '1',
453 'sendUpdates' => 'all',
454 )
455 );
456
457 $response = wp_remote_post(self::BOOKTICS_GOOGLE_CALENDAR_EVENT . '/' . $event_id . '?' . $query_params, $data);
458
459 if (is_wp_error($response)) {
460 return false;
461 }
462
463 $status_code = wp_remote_retrieve_response_code($response);
464
465 if (204 != $status_code && 200 != $status_code) {
466 return false;
467 }
468
469 return true;
470 }
471
472 /**
473 * Get Google Calendar events using direct API calls.
474 *
475 * @param int $user_id Team member user ID
476 * @param array $api_filters Additional API filters
477 * @return array|WP_Error Array of events or WP_Error on failure
478 */
479 public function get_google_calendar_events($user_id = null, $api_filters = array()) {
480 if (!$user_id) {
481 $user_id = get_current_user_id();
482 }
483
484 $access_token = $this->get_access_token_for_api($user_id);
485
486 if (!$access_token) {
487 return new WP_Error('invalid_tokens', __('Failed to get valid access token.', 'booktics'));
488 }
489
490 // Define the time range
491 $three_months_ago = wp_date('c', strtotime('-3 months'));
492 $three_months_ahead = wp_date('c', strtotime('+3 months'));
493
494 $filters = array(
495 'timeMin' => rawurlencode($three_months_ago),
496 'timeMax' => rawurlencode($three_months_ahead),
497 'orderBy' => 'startTime',
498 'singleEvents' => 'true',
499 );
500
501 $filters = array_merge($filters, $api_filters);
502 $api_url = add_query_arg($filters, self::BOOKTICS_GOOGLE_CALENDAR_EVENT);
503
504 $args = array(
505 'headers' => array(
506 'Authorization' => 'Bearer ' . $access_token,
507 'Content-Type' => 'application/json',
508 ),
509 );
510
511 $response = wp_remote_get($api_url, $args);
512
513 if (is_wp_error($response)) {
514 return array();
515 }
516
517 $body = wp_remote_retrieve_body($response);
518 $events = json_decode($body, true);
519
520 if (empty($events['items'])) {
521 return array();
522 }
523
524 return $events['items'];
525 }
526
527 /**
528 * Prepare appointment data from Google Calendar event.
529 *
530 * @param array $event Event data from Google Calendar API
531 * @return array
532 */
533 public function prepare_appointment_data_from_event( $event ) {
534 try {
535 $start = $event['start'];
536 $end = $event['end'];
537
538 // Parse date and time
539 $start_datetime = new \DateTime( $start['dateTime'] );
540 $end_datetime = new \DateTime( $end['dateTime'] );
541
542 // Extract service and customer names from summary
543 $summary = $event['summary'];
544 $parts = explode( ' - ', $summary, 2 );
545 $service_name = $parts[0] ?? '';
546 $event_name = $parts[1] ?? $summary;
547
548 // Build appointment data
549 $appointment_data = array(
550 'title' => $event_name,
551 'content' => $event['summary'],
552 'status' => 'approved',
553 'type' => 'booktics-appointment',
554 'source' => 'google',
555 'email' => '',
556 'date' => $start_datetime->format( 'Y-m-d' ),
557 'start_time' => $start_datetime->format( 'g:i A' ),
558 'end_time' => $end_datetime->format( 'g:i A' ),
559 'customer' => array(
560 'display_name' => $event_name,
561 'note' => '',
562 ),
563 'service' => array(
564 'title' => $service_name,
565 ),
566 );
567
568 return $appointment_data;
569 } catch ( Exception $e ) {
570 throw $e;
571 }
572 }
573
574 /**
575 * Check if calendar is configured for a team member.
576 *
577 * @since 1.0.0
578 *
579 * @param int $team_member_id Team member user ID.
580 * @return bool True if configured, false otherwise.
581 */
582 public function is_configured( int $team_member_id ): bool {
583 $tokens = get_user_meta( $team_member_id, 'booktics_google_auth_token', true );
584 return ! empty( $tokens );
585 }
586 /**
587 * Returns the id for the calendar.
588 *
589 * @return string
590 */
591 public static function get_id(): string {
592 return 'google_calendar';
593 }
594
595 /**
596 * Implemented to ensure woring code for extending the calendar interface
597 */
598 public function sync_events(int $team_member_id) {
599 // intentionally kept empty to implement calendar interface;
600 }
601
602 /**
603 * Selects the timezone for the appointment. Including legacy appointments which doesnt have timezone.
604 *
605 * @return string timezone string.
606 */
607 private function get_appointment_timezone($appointment) {
608 // Try different ways to access the timezone
609 $timezone_sources = [
610 'direct_property' => $appointment->timezone ?? null,
611 'to_array' => method_exists($appointment, 'to_array') ? ($appointment->to_array()['timezone'] ?? null) : null,
612 ];
613
614 // Check each source
615 foreach ($timezone_sources as $source => $value) {
616 if (!empty($value) && $value !== '+00:00') {
617 return $value;
618 }
619 }
620
621 // Try to get from post meta
622 if (!empty($appointment->id)) {
623 $meta_timezone = get_post_meta($appointment->id, 'timezone', true);
624 if (!empty($meta_timezone) && $meta_timezone !== '+00:00') {
625 return $meta_timezone;
626 }
627 }
628
629 if (!empty($appointment->order_id)) {
630 $db = new Booktics_Database();
631 $order = new \Booktics\Models\Order_Model( $db );
632 $order_data = $order->find(['id' => $appointment->order_id]);
633
634 if ($order_data && $order_data->timezone) {
635 $order_timezone = $order_data->timezone instanceof \DateTimeZone
636 ? $order_data->timezone->getName()
637 : $order_data->timezone;
638
639 if (!empty($order_timezone) && $order_timezone !== '+00:00') {
640 return $order_timezone;
641 }
642 }
643 }
644
645 // Fallback to WordPress timezone
646 return wp_timezone_string();
647 }
648
649 /**
650 * Register the service.
651 *
652 * @since 1.0.0
653 */
654 public function register() {
655 // Register this calendar with the registry.
656 add_action(
657 'booktics_calendars_register',
658 function () {
659 \Booktics\Calendar\Calendar_Registry::register_calendar( static::class );
660 }
661 );
662 }
663 }
664