PluginProbe
Booktics – Appointment Booking Calendar for Service Businesses / 1.0.27
Booktics – Appointment Booking Calendar for Service Businesses v1.0.27
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.27, at core/module/google-calendar/Controller/calendar-sync-controller.php

544 lines 17.8 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 // The booking's own team member owns the Meet link, so a member who never
340 // connected Google - or whose token has died - silently drops the link
341 // from the email. Log it, otherwise the only symptom is an empty
342 // google_meet_url smart tag with nothing to explain it.
343 if ( defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) {
344 error_log( '[Booktics Google Calendar] No usable Google token for team member ' . $user_id . '; skipping Meet link.' ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
345 }
346
347 return $notification_flow_data;
348 }
349 try {
350 // Get the WordPress timezone
351 $timezone = wp_timezone();
352 $timezone_string = $timezone->getName();
353
354 // Appointment date/time are stored in UTC. Combine them so the Meet event lands on the actual appointment date instead of "today".
355 $appointment_date = $formatted_appointment['date'] ?? '';
356 $start_timestamp = strtotime( $appointment_date . ' ' . $formatted_appointment['start_time'] . ' UTC' );
357 $end_timestamp = strtotime( $appointment_date . ' ' . $formatted_appointment['end_time'] . ' UTC' );
358
359 // Format dates in ISO 8601 format with timezone using wp_date
360 $start_time = wp_date( 'c', $start_timestamp, $timezone );
361 $end_time = wp_date( 'c', $end_timestamp, $timezone );
362
363 $event_data = array(
364 'summary' => __( 'Booktics Meeting', 'booktics' ),
365 'start' => array(
366 'dateTime' => $start_time,
367 'timeZone' => $timezone_string,
368 ),
369 'end' => array(
370 'dateTime' => $end_time,
371 'timeZone' => $timezone_string,
372 ),
373 'conferenceData' => array(
374 'createRequest' => array(
375 'requestId' => uniqid( 'meet_', true ),
376 'conferenceSolutionKey' => array(
377 'type' => 'hangoutsMeet',
378 ),
379 ),
380 ),
381 );
382
383 $response = wp_remote_post(
384 'https://www.googleapis.com/calendar/v3/calendars/primary/events?conferenceDataVersion=1',
385 array(
386 'headers' => array(
387 'Authorization' => 'Bearer ' . $access_token,
388 'Content-Type' => 'application/json',
389 ),
390 'body' => wp_json_encode( $event_data ),
391 'timeout' => 15,
392 )
393 );
394
395 if ( is_wp_error( $response ) ) {
396 return $notification_flow_data;
397 }
398
399 $body = json_decode( wp_remote_retrieve_body( $response ), true );
400
401 // Get the Meet link from conferenceData
402 if ( isset( $body['hangoutLink'] ) ) {
403 $notification_flow_data['google_meet_url'] = esc_url_raw( $body['hangoutLink'] );
404 return $notification_flow_data;
405 }
406
407 // Fallback or log error
408 return $notification_flow_data;
409 } catch ( \Throwable $e ) {
410 return $notification_flow_data;
411 }
412 }
413
414 /**
415 * Get Google Calendar API filters.
416 *
417 * @param array $args The arguments passed to the function.
418 * @return array The API filters.
419 */
420 private function get_google_calendar_api_filters( $args ) {
421 $api_filters = array();
422
423 // Get start and end dates from args in appropriate format
424 if ( isset( $args['start_date'] ) ) {
425 $api_filters['timeMin'] = rawurlencode( wp_date( DATE_RFC3339, strtotime( $args['start_date'] ) ) );
426 }
427
428 if ( isset( $args['end_date'] ) ) {
429 $api_filters['timeMax'] = rawurlencode( wp_date( DATE_RFC3339, strtotime( $args['end_date'] ) ) );
430 }
431
432 return $api_filters;
433 }
434
435 /**
436 * Adds add event to google calendar link for customers in the thank you page.
437 *
438 * @param array $calendars The calendars list.
439 * @param array $request The request data.
440 * @return array The modified calendars list.
441 */
442 public function get_add_to_calendar_links( $calendars, $request ) {
443 $meeting_id = ! empty( $request['meeting_id'] ) ? intval( $request['meeting_id'] ) : 0;
444 $booking_date = ! empty( $request['booking_date'] ) ? sanitize_text_field( $request['booking_date'] ) : '';
445 $start_time = ! empty( $request['start_time'] ) ? sanitize_text_field( $request['start_time'] ) : '';
446 $end_time = ! empty( $request['end_time'] ) ? sanitize_text_field( $request['end_time'] ) : '';
447
448 $meeting = ( new Appointment_Model( $meeting_id ) )->to_array();
449 $service = ( new Service_Model( $meeting['service_id'] ) )->to_array();
450 // Use the appointment's timezone if available, otherwise fallback to WordPress timezone
451 $appointment_timezone = ! empty( $meeting['timezone'] ) ? $meeting['timezone'] : wp_timezone_string();
452
453 $google_url = $this->get_add_to_google_calendar_url(
454 array(
455 'date' => $booking_date,
456 'start_time' => $start_time,
457 'end_time' => $end_time,
458 'title' => $service['title'],
459 'description' => $meeting['note'],
460 'timezone' => $appointment_timezone,
461 )
462 );
463
464 $calendars = array();
465
466 if ( $google_url ) {
467 $calendars[] = array(
468 'id' => 'google',
469 'name' => esc_html__( 'Google', 'booktics' ),
470 'url' => esc_url_raw( $google_url ),
471 );
472 }
473
474 return $calendars;
475 }
476
477 /**
478 * Get Google Calendar URL that will be used to add event to Google Calendar
479 * From the customer side.
480 *
481 * @param array $args {
482 * @type string $date Booking date in 'Y-m-d' format.
483 * @type string $start_time Start time in 'H:i' format.
484 * @type string $end_time End time in 'H:i' format.
485 * @type string $location Event location.
486 * @type string $title Event title.
487 * @type string $description Event description.
488 * @type string $timezone Event timezone.
489 * }
490 *
491 * @return string Google Calendar URL
492 */
493 private function get_add_to_google_calendar_url( $args = array() ) {
494 $defaults = array(
495 'date' => '',
496 'start_time' => '',
497 'end_time' => '',
498 'location' => '',
499 'title' => '',
500 'description' => '',
501 'timezone' => '',
502 );
503
504 $args = wp_parse_args( $args, $defaults );
505
506 if ( empty( $args['date'] ) || empty( $args['start_time'] ) || empty( $args['end_time'] ) ) {
507 return '';
508 }
509
510 // Create datetime objects with the appointment's timezone and convert to UTC
511 try {
512 // Use the provided timezone for the appointment
513 $appointment_timezone = new \DateTimeZone( $args['timezone'] ?: wp_timezone_string() );
514
515 // Create DateTime objects in the appointment's timezone
516 $start_dt = new \DateTime( $args['date'] . ' ' . $args['start_time'], $appointment_timezone );
517 $end_dt = new \DateTime( $args['date'] . ' ' . $args['end_time'], $appointment_timezone );
518
519 // Convert to UTC for Google Calendar
520 $start_dt->setTimezone( new \DateTimeZone( 'UTC' ) );
521 $end_dt->setTimezone( new \DateTimeZone( 'UTC' ) );
522
523 // Format as Google expects: YYYYMMDDTHHMMSSZ
524 $start_utc = $start_dt->format( 'Ymd\THis\Z' );
525 $end_utc = $end_dt->format( 'Ymd\THis\Z' );
526 } catch ( \Throwable $e ) {
527 return '';
528 }
529
530 $params = array(
531 'action' => 'TEMPLATE',
532 'text' => $args['title'],
533 'dates' => $start_utc . '/' . $end_utc,
534 'details' => $args['description'],
535 'location' => $args['location'],
536 'sf' => 'true',
537 'output' => 'xml',
538 'ctz' => $args['timezone'],
539 );
540
541 return 'https://calendar.google.com/calendar/render?' . http_build_query( $params );
542 }
543 }
544