PluginProbe
Booktics – Appointment Booking Calendar for Service Businesses / 1.0.24
Booktics – Appointment Booking Calendar for Service Businesses v1.0.24
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 / Controller / calendar-sync-controller.php

calendar-sync-controller.php in Booktics – Appointment Booking Calendar for Service Businesses 1.0.24, at core/module/google-calendar/Controller/calendar-sync-controller.php

536 lines 17.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Calendar Sync Controller
4 *
5 * @package BookticsGoogleCalendar\Google\Controller
6 */
7
8 namespace Booktics\Module\Google_Calendar\Controller;
9
10 if ( ! defined( 'ABSPATH' ) ) exit;
11
12 use Booktics\Abstracts\Base_Rest_Controller;
13 use Booktics\Module\Google_Calendar\Service\Calendar;
14 use Booktics\Models\Appointment_Model;
15 use Booktics\Models\Service_Model;
16 use WP_REST_Request;
17 use WP_Error;
18
19 /**
20 * Class Calendar_Sync_Controller
21 */
22 class Calendar_Sync_Controller extends Base_Rest_Controller {
23
24 /**
25 * The namespace of this controller's route.
26 *
27 * @var string
28 */
29 protected $namespace = 'booktics/v1';
30
31 /**
32 * The base of this controller's route.
33 *
34 * @var string
35 */
36 protected $rest_base = 'sync/events';
37
38 /**
39 * Google Calendar service instance.
40 *
41 * @var Calendar
42 */
43 private $calendar;
44
45 /**
46 * Temporary storage for appointment data before deletion.
47 *
48 * @var array
49 */
50 private $deletion_data = array();
51
52 /**
53 * Initialize the class and set up hooks.
54 */
55 public function __construct() {
56 $this->calendar = new Calendar();
57 $this->register();
58 }
59
60 /**
61 * Register the routes for the controller.
62 */
63 public function register_routes(): void {
64 add_filter( 'booktics_appointments_for_calendar_view', array( $this, 'add_google_appointments' ), 10, 2 );
65 add_filter( 'booktics_appointment_scheduled_notification_flow_data', [ $this, 'get_meet_url' ], 10, 2 );
66 add_filter( 'booktics_order_created_notification_flow_data', [ $this, 'get_meet_url_for_order_mail' ], 10, 3 );
67 add_filter( 'booktics_booking_calendar_urls', array( $this, 'get_add_to_calendar_links' ), 10, 2 );
68
69 add_action( 'booktics_after_appointment_created', array( $this, 'handle_appointment_created' ), 10, 1 );
70 add_action( 'booktics_after_appointment_updated', array( $this, 'handle_appointment_updated' ), 10, 1 );
71 add_action( 'booktics_after_appointment_cancelled', array( $this, 'handle_appointment_cancelled' ), 10, 1 );
72 add_action( 'before_delete_post', array( $this, 'capture_appointment_data_before_delete' ), 10, 1 );
73 add_action( 'booktics_after_appointment_deleted', array( $this, 'handle_appointment_deleted' ), 10, 1 );
74 }
75
76 /**
77 * Add Google Calendar appointments to the appointments list.
78 *
79 * @param array $appointments The appointments list.
80 * @param array $args The arguments passed to the function.
81 * @return array The modified appointments list.
82 */
83 public function add_google_appointments( $appointments, $args ) {
84 $api_filters = $this->get_google_calendar_api_filters( $args );
85 $google_calendar_appointments = $this->calendar->get_google_calendar_events( get_current_user_id(), $api_filters );
86
87 if ( is_wp_error( $google_calendar_appointments ) ) {
88 return $appointments;
89 }
90
91 // Get all existing Google Calendar event IDs from Booktics appointments to avoid duplicates
92 $existing_event_ids = $this->get_existing_google_event_ids( $appointments );
93
94 foreach ( $google_calendar_appointments as $event ) {
95 // Skip events that already exist as Booktics appointments
96 if ( isset( $event['id'] ) && in_array( $event['id'], $existing_event_ids ) ) {
97 continue;
98 }
99
100 $appointments[] = $this->calendar->prepare_appointment_data_from_event( $event );
101 }
102
103 return $appointments;
104 }
105
106 /**
107 * Get existing Google Calendar event IDs from current appointments to avoid duplicates.
108 *
109 * @param array $appointments The current appointments list.
110 * @return array Array of Google Calendar event IDs.
111 */
112 private function get_existing_google_event_ids( $appointments ) {
113 $existing_event_ids = array();
114
115 // Get event IDs from the passed appointments array
116 foreach ( $appointments as $appointment ) {
117 $event_id = get_post_meta( $appointment->id, '_google_calendar_event_id', true );
118 if ( ! empty( $event_id ) ) {
119 $existing_event_ids[] = $event_id;
120 }
121 }
122
123 return array_unique( $existing_event_ids );
124 }
125
126 /**
127 * On appointment created, create a Google Calendar event for the booking.
128 *
129 * @param \Booktics\Models\Appointment_Model $appointment appointment data
130 */
131 public function handle_appointment_created( $appointment ) {
132 try {
133 $team_member_id = get_post_meta( $appointment->id, 'team_member_id', true );
134
135 // If not in meta, check if it's a property of the appointment object
136 if ( !$team_member_id && isset( $appointment->team_member_id ) ) {
137 $team_member_id = $appointment->team_member_id;
138 }
139
140 $event_id = $this->calendar->create_event( $appointment, $team_member_id );
141
142 if ( $event_id ) {
143 update_post_meta( $appointment->id, '_google_calendar_event_id', $event_id );
144 }
145 } catch ( \Throwable $e ) {
146 if ( defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) {
147 error_log( '[Booktics Google Calendar] Failed to create Google Calendar event: ' . $e->getMessage() ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
148 }
149 }
150 }
151
152 /**
153 * On appointment updated, update the Google Calendar event for the booking.
154 *
155 * @param \Booktics\Models\Appointment_Model $appointment appointment data
156 */
157 public function handle_appointment_updated( $appointment ) {
158 try {
159 $team_member_id = get_post_meta( $appointment->id, 'team_member_id', true );
160 $event_id = get_post_meta( $appointment->id, '_google_calendar_event_id', true );
161
162 if ( $event_id ) {
163 $this->calendar->update_event( $event_id, $appointment, $team_member_id );
164 }
165 } catch ( \Throwable $e ) {
166 if ( defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) {
167 error_log( 'Failed to updated Google Calendar event: ' . $e->getMessage() ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
168 }
169 }
170 }
171
172 /**
173 * On appointment cancelled, remove its Google Calendar event.
174 *
175 * A cancellation only flips the appointment status (no post deletion), so
176 * the delete hooks never fire. Without this the calendar event lingers in
177 * the team member's calendar for a booking that was auto-cancelled (e.g. an
178 * abandoned/unpaid online booking) or refunded, keeping the slot visually
179 * blocked. Deleting the event frees the calendar slot to match the freed
180 * appointment slot.
181 *
182 * @param \Booktics\Models\Appointment_Model $appointment appointment data
183 */
184 public function handle_appointment_cancelled( $appointment ) {
185 try {
186 if ( ! isset( $appointment->id ) ) {
187 return;
188 }
189
190 $event_id = get_post_meta( $appointment->id, '_google_calendar_event_id', true );
191 $team_member_id = get_post_meta( $appointment->id, 'team_member_id', true );
192
193 if ( ! $team_member_id && isset( $appointment->team_member_id ) ) {
194 $team_member_id = $appointment->team_member_id;
195 }
196
197 if ( ! $event_id || ! $team_member_id ) {
198 return;
199 }
200
201 if ( $this->calendar->delete_event( $event_id, $team_member_id ) ) {
202 delete_post_meta( $appointment->id, '_google_calendar_event_id' );
203 }
204 } catch ( \Throwable $e ) {
205 if ( defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) {
206 error_log( '[Booktics Google Calendar] Failed to delete Google Calendar event on cancel: ' . $e->getMessage() ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
207 }
208 }
209 }
210
211 /**
212 * Capture appointment Google Calendar data before WordPress deletes the post.
213 *
214 * @param int $post_id Post ID being deleted
215 */
216 public function capture_appointment_data_before_delete( $post_id ) {
217 // Only process booktics appointments
218 if ( get_post_type( $post_id ) !== 'booktics-appointment' ) {
219 return;
220 }
221
222 // Capture the data we need before deletion
223 $team_member_id = get_post_meta( $post_id, 'team_member_id', true );
224 $event_id = get_post_meta( $post_id, '_google_calendar_event_id', true );
225
226 if ( $team_member_id && $event_id ) {
227 $this->deletion_data[ $post_id ] = array(
228 'team_member_id' => $team_member_id,
229 'event_id' => $event_id,
230 );
231 }
232 }
233
234 public function handle_appointment_deleted( $appointment ) {
235 try {
236 // Get the data we captured before deletion
237 if ( ! isset( $this->deletion_data[ $appointment->id ] ) ) {
238 return;
239 }
240
241 $data = $this->deletion_data[ $appointment->id ];
242 $team_member_id = $data['team_member_id'];
243 $event_id = $data['event_id'];
244
245 if ( $event_id && $team_member_id ) {
246 $deleted = $this->calendar->delete_event( $event_id, $team_member_id );
247 if ( $deleted ) {
248 // Clean up the stored data
249 unset( $this->deletion_data[ $appointment->id ] );
250 }
251 }
252 } catch ( \Throwable $e ) {
253 if ( defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) {
254 error_log( '[Booktics Google Calendar] Exception in handle_appointment_deleted: ' . $e->getMessage() ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
255 }
256 }
257 }
258
259 /**
260 * Check if a given request has permission to sync events.
261 *
262 * @param WP_REST_Request $request Full data about the request.
263 * @return bool|WP_Error
264 */
265 public function permission_check( $request ) {
266 if ( ! current_user_can( 'manage_options' ) ) {
267 return new WP_Error(
268 'rest_forbidden',
269 __( 'You do not have sufficient permissions to access this endpoint.', 'booktics' ),
270 array( 'status' => 403 )
271 );
272 }
273
274 return true;
275 }
276
277 /**
278 * Get the query params for collections.
279 *
280 * @return array
281 */
282 public function get_collection_params() {
283 return array(
284 'team_member_id' => array(
285 'description' => __( 'Team member ID to sync calendar for.', 'booktics' ),
286 'type' => 'integer',
287 'required' => true,
288 'validate_callback' => 'absint',
289 'sanitize_callback' => 'absint',
290 ),
291 );
292 }
293
294 /**
295 * Get Google Meet URL for order mail
296 *
297 * @param array $notification_flow_data The notification flow data
298 * @param array $formatted_appointment The formatted appointment data
299 * @param Order_Model $order_model The order model for the notification data
300 *
301 * @return array The updated notification flow data with Google Meet URL
302 */
303 public function get_meet_url_for_order_mail( $notification_flow_data, $formatted_appointment, $order_model ) {
304 try {
305 return $this->get_meet_url( $notification_flow_data, $formatted_appointment );
306 } catch ( \Throwable $e) {
307 return $notification_flow_data;
308 }
309 }
310
311 /**
312 * Get Google Meet URL
313 *
314 * @param array $notification_flow_data The notification flow data
315 * @param array $formatted_appointment The formatted appointment data
316 *
317 * @return array The updated notification flow data with Google Meet URL
318 */
319 public function get_meet_url( $notification_flow_data, $formatted_appointment ) {
320 if ( ! isset ( $formatted_appointment['service']['enable_google_meet'] ) ) {
321 return $notification_flow_data;
322 }
323
324 // If Google Meet is not enabled for the service, return the original notification flow data
325 if ( ! $formatted_appointment['service']['enable_google_meet'] ) {
326 return $notification_flow_data;
327 }
328
329 $user_id = $formatted_appointment['team']['id'] ?? null;
330
331 if ( empty( $user_id ) ) {
332 return $notification_flow_data;
333 }
334
335 // get_access_token_for_api() also refreshes the token when it has expired.
336 $access_token = $this->calendar->get_access_token_for_api( $user_id );
337
338 if ( empty( $access_token ) ) {
339 return $notification_flow_data;
340 }
341 try {
342 // Get the WordPress timezone
343 $timezone = wp_timezone();
344 $timezone_string = $timezone->getName();
345
346 // Appointment date/time are stored in UTC. Combine them so the Meet event lands on the actual appointment date instead of "today".
347 $appointment_date = $formatted_appointment['date'] ?? '';
348 $start_timestamp = strtotime( $appointment_date . ' ' . $formatted_appointment['start_time'] . ' UTC' );
349 $end_timestamp = strtotime( $appointment_date . ' ' . $formatted_appointment['end_time'] . ' UTC' );
350
351 // Format dates in ISO 8601 format with timezone using wp_date
352 $start_time = wp_date( 'c', $start_timestamp, $timezone );
353 $end_time = wp_date( 'c', $end_timestamp, $timezone );
354
355 $event_data = array(
356 'summary' => __( 'Booktics Meeting', 'booktics' ),
357 'start' => array(
358 'dateTime' => $start_time,
359 'timeZone' => $timezone_string,
360 ),
361 'end' => array(
362 'dateTime' => $end_time,
363 'timeZone' => $timezone_string,
364 ),
365 'conferenceData' => array(
366 'createRequest' => array(
367 'requestId' => uniqid( 'meet_', true ),
368 'conferenceSolutionKey' => array(
369 'type' => 'hangoutsMeet',
370 ),
371 ),
372 ),
373 );
374
375 $response = wp_remote_post(
376 'https://www.googleapis.com/calendar/v3/calendars/primary/events?conferenceDataVersion=1',
377 array(
378 'headers' => array(
379 'Authorization' => 'Bearer ' . $access_token,
380 'Content-Type' => 'application/json',
381 ),
382 'body' => wp_json_encode( $event_data ),
383 'timeout' => 15,
384 )
385 );
386
387 if ( is_wp_error( $response ) ) {
388 return $notification_flow_data;
389 }
390
391 $body = json_decode( wp_remote_retrieve_body( $response ), true );
392
393 // Get the Meet link from conferenceData
394 if ( isset( $body['hangoutLink'] ) ) {
395 $notification_flow_data['google_meet_url'] = esc_url_raw( $body['hangoutLink'] );
396 return $notification_flow_data;
397 }
398
399 // Fallback or log error
400 return $notification_flow_data;
401 } catch ( \Throwable $e ) {
402 return $notification_flow_data;
403 }
404 }
405
406 /**
407 * Get Google Calendar API filters.
408 *
409 * @param array $args The arguments passed to the function.
410 * @return array The API filters.
411 */
412 private function get_google_calendar_api_filters( $args ) {
413 $api_filters = array();
414
415 // Get start and end dates from args in appropriate format
416 if ( isset( $args['start_date'] ) ) {
417 $api_filters['timeMin'] = rawurlencode( wp_date( DATE_RFC3339, strtotime( $args['start_date'] ) ) );
418 }
419
420 if ( isset( $args['end_date'] ) ) {
421 $api_filters['timeMax'] = rawurlencode( wp_date( DATE_RFC3339, strtotime( $args['end_date'] ) ) );
422 }
423
424 return $api_filters;
425 }
426
427 /**
428 * Adds add event to google calendar link for customers in the thank you page.
429 *
430 * @param array $calendars The calendars list.
431 * @param array $request The request data.
432 * @return array The modified calendars list.
433 */
434 public function get_add_to_calendar_links( $calendars, $request ) {
435 $meeting_id = ! empty( $request['meeting_id'] ) ? intval( $request['meeting_id'] ) : 0;
436 $booking_date = ! empty( $request['booking_date'] ) ? sanitize_text_field( $request['booking_date'] ) : '';
437 $start_time = ! empty( $request['start_time'] ) ? sanitize_text_field( $request['start_time'] ) : '';
438 $end_time = ! empty( $request['end_time'] ) ? sanitize_text_field( $request['end_time'] ) : '';
439
440 $meeting = ( new Appointment_Model( $meeting_id ) )->to_array();
441 $service = ( new Service_Model( $meeting['service_id'] ) )->to_array();
442 // Use the appointment's timezone if available, otherwise fallback to WordPress timezone
443 $appointment_timezone = ! empty( $meeting['timezone'] ) ? $meeting['timezone'] : wp_timezone_string();
444
445 $google_url = $this->get_add_to_google_calendar_url(
446 array(
447 'date' => $booking_date,
448 'start_time' => $start_time,
449 'end_time' => $end_time,
450 'title' => $service['title'],
451 'description' => $meeting['note'],
452 'timezone' => $appointment_timezone,
453 )
454 );
455
456 $calendars = array();
457
458 if ( $google_url ) {
459 $calendars[] = array(
460 'id' => 'google',
461 'name' => esc_html__( 'Google', 'booktics' ),
462 'url' => esc_url_raw( $google_url ),
463 );
464 }
465
466 return $calendars;
467 }
468
469 /**
470 * Get Google Calendar URL that will be used to add event to Google Calendar
471 * From the customer side.
472 *
473 * @param array $args {
474 * @type string $date Booking date in 'Y-m-d' format.
475 * @type string $start_time Start time in 'H:i' format.
476 * @type string $end_time End time in 'H:i' format.
477 * @type string $location Event location.
478 * @type string $title Event title.
479 * @type string $description Event description.
480 * @type string $timezone Event timezone.
481 * }
482 *
483 * @return string Google Calendar URL
484 */
485 private function get_add_to_google_calendar_url( $args = array() ) {
486 $defaults = array(
487 'date' => '',
488 'start_time' => '',
489 'end_time' => '',
490 'location' => '',
491 'title' => '',
492 'description' => '',
493 'timezone' => '',
494 );
495
496 $args = wp_parse_args( $args, $defaults );
497
498 if ( empty( $args['date'] ) || empty( $args['start_time'] ) || empty( $args['end_time'] ) ) {
499 return '';
500 }
501
502 // Create datetime objects with the appointment's timezone and convert to UTC
503 try {
504 // Use the provided timezone for the appointment
505 $appointment_timezone = new \DateTimeZone( $args['timezone'] ?: wp_timezone_string() );
506
507 // Create DateTime objects in the appointment's timezone
508 $start_dt = new \DateTime( $args['date'] . ' ' . $args['start_time'], $appointment_timezone );
509 $end_dt = new \DateTime( $args['date'] . ' ' . $args['end_time'], $appointment_timezone );
510
511 // Convert to UTC for Google Calendar
512 $start_dt->setTimezone( new \DateTimeZone( 'UTC' ) );
513 $end_dt->setTimezone( new \DateTimeZone( 'UTC' ) );
514
515 // Format as Google expects: YYYYMMDDTHHMMSSZ
516 $start_utc = $start_dt->format( 'Ymd\THis\Z' );
517 $end_utc = $end_dt->format( 'Ymd\THis\Z' );
518 } catch ( \Throwable $e ) {
519 return '';
520 }
521
522 $params = array(
523 'action' => 'TEMPLATE',
524 'text' => $args['title'],
525 'dates' => $start_utc . '/' . $end_utc,
526 'details' => $args['description'],
527 'location' => $args['location'],
528 'sf' => 'true',
529 'output' => 'xml',
530 'ctz' => $args['timezone'],
531 );
532
533 return 'https://calendar.google.com/calendar/render?' . http_build_query( $params );
534 }
535 }
536