PluginProbe
Booktics – Appointment Booking Calendar for Service Businesses / 1.0.20
Booktics – Appointment Booking Calendar for Service Businesses v1.0.20
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.20, at core/module/google-calendar/Service/calendar.php

653 lines 18.3 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 return false;
147 }
148
149 // Merge with existing tokens to preserve refresh_token
150 $refreshed_tokens = array_merge( $tokens, $new_token );
151
152 // Set expiry time
153 if ( isset( $refreshed_tokens['expires_in'] ) ) {
154 $refreshed_tokens['expires_at'] = time() + $refreshed_tokens['expires_in'];
155 }
156
157 // Save the refreshed tokens
158 update_user_meta( $user_id, 'booktics_google_auth_token', $refreshed_tokens );
159
160 return $refreshed_tokens;
161 }
162
163 /**
164 * Get access token for API calls with automatic refresh.
165 *
166 * @param int $user_id User ID
167 * @return string|false Access token or false on failure
168 */
169 public function get_access_token_for_api( $user_id = null ) {
170 if ( ! $user_id ) {
171 $user_id = get_current_user_id();
172 }
173
174 return $this->get_valid_access_token( $user_id );
175 }
176
177 /**
178 * Create event using direct Google Calendar API calls.
179 *
180 * @param \Booktics\Models\AppointmentModel $appointment appointment data
181 * @param int $user_id user id
182 * @param array $args additional arguments
183 * @return string|false Event ID on success, false on failure
184 */
185 public function create_event($appointment, $user_id = null, $args = array()) {
186 if (!$user_id) {
187 $user_id = get_current_user_id();
188 }
189
190 $access_token = $this->get_access_token_for_api($user_id);
191
192 if ( !$access_token ) {
193 return false;
194 }
195
196 // Handle customer data safely
197 $customer_email = '';
198 if (!empty($appointment->customer_id) && $appointment->customer_id > 0) {
199 try {
200 $customer = new Customer_Model($appointment->customer_id);
201 $customer_email = $customer->user_email ?? '';
202 } catch (Exception $e) {
203 if ( defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) {
204 error_log('[Booktics Google Calendar] Failed to load customer data: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
205 }
206 }
207 }
208
209 $service = new Service_Model( $appointment->service_id );
210
211 // Combine date with start and end times
212 $start_datetime_string = $appointment->date . ' ' . $appointment->start_time;
213 $end_datetime_string = $appointment->date . ' ' . $appointment->end_time;
214
215 // Get timezone from the appointment times (fallback to WordPress timezone)
216 $appointment_timezone = $this->get_appointment_timezone( $appointment );
217
218 // Create DateTime objects with proper timezone handling
219 try {
220 $timezone_obj = new \DateTimeZone($appointment_timezone);
221
222 // Convert 12-hour format to 24-hour format if needed
223 $start_time_24h = gmdate('H:i', strtotime($appointment->start_time));
224 $end_time_24h = gmdate('H:i', strtotime($appointment->end_time));
225
226 $start_datetime_24h = $appointment->date . ' ' . $start_time_24h;
227 $end_datetime_24h = $appointment->date . ' ' . $end_time_24h;
228
229 $start_dt = new \DateTime($start_datetime_24h, $timezone_obj);
230 $end_dt = new \DateTime($end_datetime_24h, $timezone_obj);
231
232 $start_datetime_formatted = $start_dt->format('c'); // RFC3339 format
233 $end_datetime_formatted = $end_dt->format('c'); // RFC3339 format
234
235 } catch (\Exception $e) {
236 // Fallback to original method if timezone creation fails
237 if ( defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) {
238 error_log('[Booktics Google Calendar] Timezone error: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
239 }
240 $start_datetime_formatted = wp_date('Y-m-d\TH:i:s', strtotime( $start_datetime_string ));
241 $end_datetime_formatted = wp_date('Y-m-d\TH:i:s', strtotime( $end_datetime_string ));
242 }
243
244 $defaults = array(
245 'summary' => $service->title ?? 'Booktics Appointment',
246 'description' => $appointment->note ?? '',
247 'location' => $appointment->location ?? '',
248 'start' => array(
249 'dateTime' => $start_datetime_formatted,
250 'timeZone' => $appointment_timezone,
251 ),
252 'end' => array(
253 'dateTime' => $end_datetime_formatted,
254 'timeZone' => $appointment_timezone,
255 ),
256 'attendees' => !empty($customer_email) ? array(
257 array('email' => $customer_email),
258 ) : array(),
259 'google_meet' => false,
260 );
261
262 $args = wp_parse_args($args, $defaults);
263
264 $data = array(
265 'headers' => array(
266 'Authorization' => 'Bearer ' . $access_token,
267 'Content-Type' => 'application/json; charset=utf-8',
268 ),
269 'body' => wp_json_encode($args),
270 );
271 $query_params = build_query(
272 array(
273 'conferenceDataVersion' => '1',
274 )
275 );
276
277 $response = wp_remote_post(self::BOOKTICS_GOOGLE_CALENDAR_EVENT . '?' . $query_params, $data);
278
279 if ( is_wp_error( $response ) ) {
280 return false;
281 }
282
283 $status_code = wp_remote_retrieve_response_code( $response );
284
285 if (200 != $status_code && 201 != $status_code) {
286 return false;
287 }
288
289 $response_data = json_decode(wp_remote_retrieve_body($response), true);
290 return $response_data['id'] ?? false;
291 }
292
293 /**
294 * Update event using direct Google Calendar API calls.
295 *
296 * @param string $event_id Event ID
297 * @param \Booktics\Models\AppointmentModel $appointment Appointment data
298 * @param int $user_id user id
299 * @param array $args additional arguments
300 * @return bool True on success, false on failure
301 */
302 public function update_event(string $event_id, $appointment, $user_id = null, $args = array()): bool {
303 if (!$user_id) {
304 $user_id = get_current_user_id();
305 }
306
307 $access_token = $this->get_access_token_for_api($user_id);
308
309 if (!$access_token) {
310 return false;
311 }
312
313 // Handle customer data safely
314 $customer_email = '';
315 if (!empty($appointment->customer_id) && $appointment->customer_id > 0) {
316 try {
317 $customer = new Customer_Model($appointment->customer_id);
318 $customer_email = $customer->user_email ?? '';
319 } catch (Exception $e) {
320 if ( defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) {
321 error_log('[Booktics Google Calendar] Failed to load customer data: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
322 }
323 }
324 }
325
326 $service = new Service_Model($appointment->service_id);
327
328 // Combine date with start and end times
329 $start_datetime_string = $appointment->date . ' ' . $appointment->start_time;
330 $end_datetime_string = $appointment->date . ' ' . $appointment->end_time;
331
332 // Get timezone from the appointment times (fallback to WordPress timezone)
333 $appointment_timezone = $this->get_appointment_timezone( $appointment );
334
335 // Create DateTime objects with proper timezone handling
336 try {
337 $timezone_obj = new \DateTimeZone($appointment_timezone);
338
339 // Convert 12-hour format to 24-hour format if needed
340 $start_time_24h = gmdate('H:i', strtotime($appointment->start_time));
341 $end_time_24h = gmdate('H:i', strtotime($appointment->end_time));
342
343 $start_datetime_24h = $appointment->date . ' ' . $start_time_24h;
344 $end_datetime_24h = $appointment->date . ' ' . $end_time_24h;
345
346 $start_dt = new \DateTime($start_datetime_24h, $timezone_obj);
347 $end_dt = new \DateTime($end_datetime_24h, $timezone_obj);
348
349 $start_datetime_formatted = $start_dt->format('c'); // RFC3339 format
350 $end_datetime_formatted = $end_dt->format('c'); // RFC3339 format
351
352 } catch (\Exception $e) {
353 // Fallback to original method if timezone creation fails
354 if ( defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) {
355 error_log('[Booktics Google Calendar] Timezone error: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
356 }
357 $start_datetime_formatted = wp_date('Y-m-d\TH:i:s', strtotime($start_datetime_string));
358 $end_datetime_formatted = wp_date('Y-m-d\TH:i:s', strtotime($end_datetime_string));
359 }
360
361 $defaults = array(
362 'summary' => $service->title ?? 'Booktics Appointment',
363 'description' => $appointment->note ?? '',
364 'location' => $appointment->location ?? '',
365 'start' => array(
366 'dateTime' => $start_datetime_formatted,
367 'timeZone' => $appointment_timezone,
368 ),
369 'end' => array(
370 'dateTime' => $end_datetime_formatted,
371 'timeZone' => $appointment_timezone,
372 ),
373 'attendees' => !empty($customer_email) ? array(
374 array('email' => $customer_email),
375 ) : array(),
376 'google_meet' => false,
377 );
378
379 $args = wp_parse_args($args, $defaults);
380
381 $data = array(
382 'headers' => array(
383 'Authorization' => 'Bearer ' . $access_token,
384 'Content-Type' => 'application/json; charset=utf-8',
385 ),
386 'body' => wp_json_encode($args),
387 'method' => 'PUT',
388 );
389
390 $query_params = build_query(
391 array(
392 'conferenceDataVersion' => '1',
393 'sendUpdates' => 'all',
394 )
395 );
396
397 $response = wp_remote_post(self::BOOKTICS_GOOGLE_CALENDAR_EVENT . '/' . $event_id . '?' . $query_params, $data);
398
399 if (is_wp_error($response)) {
400 return false;
401 }
402
403 $status_code = wp_remote_retrieve_response_code($response);
404
405 if (200 != $status_code) {
406 return false;
407 }
408
409 $response_data = json_decode(wp_remote_retrieve_body($response), true);
410
411 return true;
412 }
413
414 /**
415 * Delete event using direct Google Calendar API calls.
416 *
417 * @param string $event_id Event ID
418 * @param int $user_id user id
419 * @return bool True on success, false on failure
420 */
421 public function delete_event(string $event_id, $user_id = null): bool {
422 if (!$user_id) {
423 $user_id = get_current_user_id();
424 }
425
426 $access_token = $this->get_access_token_for_api($user_id);
427
428 if (!$access_token) {
429 return false;
430 }
431
432 $data = array(
433 'headers' => array(
434 'Authorization' => 'Bearer ' . $access_token,
435 'Content-Type' => 'application/json; charset=utf-8',
436 ),
437 'method' => 'DELETE',
438 );
439
440 $query_params = build_query(
441 array(
442 'conferenceDataVersion' => '1',
443 'sendUpdates' => 'all',
444 )
445 );
446
447 $response = wp_remote_post(self::BOOKTICS_GOOGLE_CALENDAR_EVENT . '/' . $event_id . '?' . $query_params, $data);
448
449 if (is_wp_error($response)) {
450 return false;
451 }
452
453 $status_code = wp_remote_retrieve_response_code($response);
454
455 if (204 != $status_code && 200 != $status_code) {
456 return false;
457 }
458
459 return true;
460 }
461
462 /**
463 * Get Google Calendar events using direct API calls.
464 *
465 * @param int $user_id Team member user ID
466 * @param array $api_filters Additional API filters
467 * @return array|WP_Error Array of events or WP_Error on failure
468 */
469 public function get_google_calendar_events($user_id = null, $api_filters = array()) {
470 if (!$user_id) {
471 $user_id = get_current_user_id();
472 }
473
474 $access_token = $this->get_access_token_for_api($user_id);
475
476 if (!$access_token) {
477 return new WP_Error('invalid_tokens', __('Failed to get valid access token.', 'booktics'));
478 }
479
480 // Define the time range
481 $three_months_ago = wp_date('c', strtotime('-3 months'));
482 $three_months_ahead = wp_date('c', strtotime('+3 months'));
483
484 $filters = array(
485 'timeMin' => rawurlencode($three_months_ago),
486 'timeMax' => rawurlencode($three_months_ahead),
487 'orderBy' => 'startTime',
488 'singleEvents' => 'true',
489 );
490
491 $filters = array_merge($filters, $api_filters);
492 $api_url = add_query_arg($filters, self::BOOKTICS_GOOGLE_CALENDAR_EVENT);
493
494 $args = array(
495 'headers' => array(
496 'Authorization' => 'Bearer ' . $access_token,
497 'Content-Type' => 'application/json',
498 ),
499 );
500
501 $response = wp_remote_get($api_url, $args);
502
503 if (is_wp_error($response)) {
504 return array();
505 }
506
507 $body = wp_remote_retrieve_body($response);
508 $events = json_decode($body, true);
509
510 if (empty($events['items'])) {
511 return array();
512 }
513
514 return $events['items'];
515 }
516
517 /**
518 * Prepare appointment data from Google Calendar event.
519 *
520 * @param array $event Event data from Google Calendar API
521 * @return array
522 */
523 public function prepare_appointment_data_from_event( $event ) {
524 try {
525 $start = $event['start'];
526 $end = $event['end'];
527
528 // Parse date and time
529 $start_datetime = new \DateTime( $start['dateTime'] );
530 $end_datetime = new \DateTime( $end['dateTime'] );
531
532 // Extract service and customer names from summary
533 $summary = $event['summary'];
534 $parts = explode( ' - ', $summary, 2 );
535 $service_name = $parts[0] ?? '';
536 $event_name = $parts[1] ?? $summary;
537
538 // Build appointment data
539 $appointment_data = array(
540 'title' => $event_name,
541 'content' => $event['summary'],
542 'status' => 'approved',
543 'type' => 'booktics-appointment',
544 'source' => 'google',
545 'email' => '',
546 'date' => $start_datetime->format( 'Y-m-d' ),
547 'start_time' => $start_datetime->format( 'g:i A' ),
548 'end_time' => $end_datetime->format( 'g:i A' ),
549 'customer' => array(
550 'display_name' => $event_name,
551 'note' => '',
552 ),
553 'service' => array(
554 'title' => $service_name,
555 ),
556 );
557
558 return $appointment_data;
559 } catch ( Exception $e ) {
560 throw $e;
561 }
562 }
563
564 /**
565 * Check if calendar is configured for a team member.
566 *
567 * @since 1.0.0
568 *
569 * @param int $team_member_id Team member user ID.
570 * @return bool True if configured, false otherwise.
571 */
572 public function is_configured( int $team_member_id ): bool {
573 $tokens = get_user_meta( $team_member_id, 'booktics_google_auth_token', true );
574 return ! empty( $tokens );
575 }
576 /**
577 * Returns the id for the calendar.
578 *
579 * @return string
580 */
581 public static function get_id(): string {
582 return 'google_calendar';
583 }
584
585 /**
586 * Implemented to ensure woring code for extending the calendar interface
587 */
588 public function sync_events(int $team_member_id) {
589 // intentionally kept empty to implement calendar interface;
590 }
591
592 /**
593 * Selects the timezone for the appointment. Including legacy appointments which doesnt have timezone.
594 *
595 * @return string timezone string.
596 */
597 private function get_appointment_timezone($appointment) {
598 // Try different ways to access the timezone
599 $timezone_sources = [
600 'direct_property' => $appointment->timezone ?? null,
601 'to_array' => method_exists($appointment, 'to_array') ? ($appointment->to_array()['timezone'] ?? null) : null,
602 ];
603
604 // Check each source
605 foreach ($timezone_sources as $source => $value) {
606 if (!empty($value) && $value !== '+00:00') {
607 return $value;
608 }
609 }
610
611 // Try to get from post meta
612 if (!empty($appointment->id)) {
613 $meta_timezone = get_post_meta($appointment->id, 'timezone', true);
614 if (!empty($meta_timezone) && $meta_timezone !== '+00:00') {
615 return $meta_timezone;
616 }
617 }
618
619 if (!empty($appointment->order_id)) {
620 $db = new Booktics_Database();
621 $order = new \Booktics\Models\Order_Model( $db );
622 $order_data = $order->find(['id' => $appointment->order_id]);
623
624 if ($order_data && $order_data->timezone) {
625 $order_timezone = $order_data->timezone instanceof \DateTimeZone
626 ? $order_data->timezone->getName()
627 : $order_data->timezone;
628
629 if (!empty($order_timezone) && $order_timezone !== '+00:00') {
630 return $order_timezone;
631 }
632 }
633 }
634
635 // Fallback to WordPress timezone
636 return wp_timezone_string();
637 }
638
639 /**
640 * Register the service.
641 *
642 * @since 1.0.0
643 */
644 public function register() {
645 // Register this calendar with the registry.
646 add_action(
647 'booktics_calendars_register',
648 function () {
649 \Booktics\Calendar\Calendar_Registry::register_calendar( static::class );
650 }
651 );
652 }
653 }