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

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