← All changes
|
core/integrations/google/service/google-calendar-sync.php
+238
-114
1.0.56
→
1.0.64
View file →
| @@ -12,11 +12,9 @@ | ||
| 12 | 12 | defined( 'ABSPATH' ) || exit; |
| 13 | 13 | |
| 14 | 14 | use Timetics\Core\Appointments\Api_Appointment; |
| 15 | 15 | use Timetics\Core\Bookings\Booking; |
| 16 | -use Timetics\Core\Customers\Customer; | |
| 17 | 16 | use Timetics\Core\Appointments\Appointment; |
| 18 | -use WP_Error; | |
| 19 | 17 | use Timetics\Utils\Singleton; |
| 20 | 18 | use DateTime; |
| 21 | 19 | use DateTimeZone; |
| 22 | 20 | |
| @@ -63,11 +61,18 @@ | ||
| 63 | 61 | $this->calendar = new Calendar(); |
| 64 | 62 | $this->appointment_api = new Api_Appointment(); |
| 65 | 63 | |
| 66 | 64 | // Add hooks |
| 67 | - add_action( 'timetics_after_booking_schedule', array( $this, 'sync_booking_to_google_calendar' ), 10, 4 ); | |
| 65 | + // | |
| 66 | + // Booking -> Google is handled by Booking::create_event() / | |
| 67 | + // update_event() / delete_event(), which run on the staff's token, | |
| 68 | + // invite both host and customer, honour the meeting's location type | |
| 69 | + // and are wired to reschedule and cancellation. This class only | |
| 70 | + // reads from Google; pushing here as well produced a second, | |
| 71 | + // duplicate event for every booking. | |
| 68 | 72 | add_filter( 'timetics/admin/booking/get_items', array( $this, 'get_events_from_google' ) ); |
| 69 | 73 | add_filter( 'timetics_schedule_data_for_selected_date', array( $this, 'block_timeslots_by_google_events' ), 10, 5 ); |
| 74 | + add_filter( 'timetics_is_slot_available', array( $this, 'reject_slot_overlapping_google_event' ), 10, 3 ); | |
| 70 | 75 | } catch ( \Throwable $e ) { |
| 71 | 76 | if ( defined( 'WP_DEBUG' ) && WP_DEBUG && defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) { |
| 72 | 77 | // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Debug logging is properly guarded by WP_DEBUG checks |
| 73 | 78 | error_log( 'Timetics Google Calendar Sync: ' . $e->getMessage() ); |
| @@ -85,92 +90,8 @@ | ||
| 85 | 90 | return $booking->get_all_google_event_ids(); |
| 86 | 91 | } |
| 87 | 92 | |
| 88 | 93 | /** |
| 89 | - * Sync booking to Google Calendar | |
| 90 | - * | |
| 91 | - * @param int $booking_id Booking ID | |
| 92 | - * @param int $customer_id Customer ID | |
| 93 | - * @param int $meeting_id Meeting ID | |
| 94 | - * @param array $data Booking data | |
| 95 | - * | |
| 96 | - * @return void|WP_Error | |
| 97 | - */ | |
| 98 | - public function sync_booking_to_google_calendar( $booking_id, $customer_id, $meeting_id, $data ) { | |
| 99 | - try { | |
| 100 | - if ( ! is_numeric( $booking_id ) || ! is_numeric( $customer_id ) || ! is_numeric( $meeting_id ) ) { | |
| 101 | - return new WP_Error( 'invalid_parameters', 'Invalid parameters provided for Google Calendar sync.' ); | |
| 102 | - } | |
| 103 | - | |
| 104 | - $booking = new Booking( $booking_id ); | |
| 105 | - $meeting = new Appointment( $meeting_id ); | |
| 106 | - $customer = new Customer( $customer_id ); | |
| 107 | - | |
| 108 | - // Get the current user's access token | |
| 109 | - $user_id = get_current_user_id(); | |
| 110 | - $access_token = timetics_get_google_access_token( $user_id ); | |
| 111 | - | |
| 112 | - if ( empty( $access_token ) ) { | |
| 113 | - return new WP_Error( 'no_access_token', 'No Google Calendar access token found. Please reconnect your Google account.' ); | |
| 114 | - } | |
| 115 | - | |
| 116 | - // Prepare event data | |
| 117 | - $event_data = array( | |
| 118 | - 'access_token' => sanitize_text_field( $access_token ), | |
| 119 | - 'summary' => sanitize_text_field( $meeting->get_name() ), | |
| 120 | - 'description' => sanitize_text_field( $meeting->get_description() ), | |
| 121 | - 'start' => array( | |
| 122 | - 'date' => $booking->get_start_date(), | |
| 123 | - 'time' => $booking->get_start_time(), | |
| 124 | - ), | |
| 125 | - 'end' => array( | |
| 126 | - 'date' => $booking->get_end_date(), | |
| 127 | - 'time' => $booking->get_end_time(), | |
| 128 | - ), | |
| 129 | - 'attendees' => array( | |
| 130 | - array( 'email' => $customer->get_email() ), | |
| 131 | - ), | |
| 132 | - 'reminders' => array( | |
| 133 | - 'useDefault' => true, | |
| 134 | - ), | |
| 135 | - 'guestsCanInviteOthers' => false, | |
| 136 | - 'guestsCanModify' => false, | |
| 137 | - 'guestsCanSeeOtherGuests' => false, | |
| 138 | - 'timezone' => $booking->get_timezone(), | |
| 139 | - ); | |
| 140 | - | |
| 141 | - // Check if this booking already has a Google Event ID | |
| 142 | - $event_id = $booking->get_google_event_id(); | |
| 143 | - | |
| 144 | - if ( $event_id ) { | |
| 145 | - // Update existing event | |
| 146 | - $result = $this->calendar->update_event( $event_id, $event_data ); | |
| 147 | - } else { | |
| 148 | - // Create new event | |
| 149 | - $result = $this->calendar->create_event( $event_data ); | |
| 150 | - | |
| 151 | - // Save the event ID for future updates | |
| 152 | - if ( ! empty( $result['id'] ) ) { | |
| 153 | - $booking->set_google_event_id( $result['id'] ); | |
| 154 | - } | |
| 155 | - } | |
| 156 | - | |
| 157 | - if ( is_wp_error( $result ) ) { | |
| 158 | - $booking->set_sync_status( 'error' ); | |
| 159 | - return $result; | |
| 160 | - } | |
| 161 | - | |
| 162 | - $booking->set_sync_status( 'synced' ); | |
| 163 | - } catch ( \Throwable $e ) { | |
| 164 | - // If sync fails silenty exits to ensure no other process gets hampered | |
| 165 | - if ( defined( 'WP_DEBUG' ) && WP_DEBUG && defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) { | |
| 166 | - // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Debug logging is properly guarded by WP_DEBUG checks | |
| 167 | - error_log( 'Timetics Google Calendar Sync: ' . $e->getMessage() ); | |
| 168 | - } | |
| 169 | - } | |
| 170 | - } | |
| 171 | - | |
| 172 | - /** | |
| 173 | 94 | * Get events from Google Calendar |
| 174 | 95 | * Only returns events that were not created by Timetics |
| 175 | 96 | * |
| 176 | 97 | * @param array $bookings Existing bookings array |
| @@ -228,12 +149,12 @@ | ||
| 228 | 149 | |
| 229 | 150 | /** |
| 230 | 151 | * Block timeslots by Google events |
| 231 | 152 | * |
| 232 | - * @param array $data | |
| 233 | - * @param int $staff_id | |
| 234 | - * @param int $meeting_id | |
| 235 | - * @param string $timezone | |
| 153 | + * @param array $data | |
| 154 | + * @param int $staff_id | |
| 155 | + * @param int $meeting_id | |
| 156 | + * @param string $timezone | |
| 236 | 157 | * |
| 237 | 158 | * @return array Modified bookings array with Google Calendar events |
| 238 | 159 | */ |
| 239 | 160 | public function block_timeslots_by_google_events( $data, $staff_id, $meeting_id, $timezone ) { |
| @@ -253,56 +174,78 @@ | ||
| 253 | 174 | $date_obj_start = new DateTime( $first_day['date'] . ' 00:00:00', new DateTimeZone( $timezone ) ); |
| 254 | 175 | $date_obj_end = new DateTime( $last_day['date'] . ' 23:59:59', new DateTimeZone( $timezone ) ); |
| 255 | 176 | |
| 256 | 177 | $google_events = $this->calendar->get_events( $staff_id, [ |
| 257 | - 'timeMin' => rawurlencode( $date_obj_start->format( DateTime::RFC3339 ) ), | |
| 258 | - 'timeMax' => rawurlencode( $date_obj_end->format( DateTime::RFC3339 ) ), | |
| 178 | + // UTC "Z" form: an unescaped "+hh:mm" offset reaches Google as a | |
| 179 | + // space and the bounds are silently rejected. | |
| 180 | + 'timeMin' => Calendar::to_rfc3339_utc( $date_obj_start ), | |
| 181 | + 'timeMax' => Calendar::to_rfc3339_utc( $date_obj_end ), | |
| 259 | 182 | 'orderBy' => 'startTime', |
| 260 | 183 | 'singleEvents' => 'true', |
| 261 | 184 | 'timeZone' => $timezone, |
| 262 | 185 | ]); |
| 263 | 186 | |
| 187 | + /** | |
| 188 | + * Filter the Google events used for slot blocking. | |
| 189 | + * | |
| 190 | + * Also the seam the test suite uses to exercise this logic without | |
| 191 | + * calling the Google API. | |
| 192 | + * | |
| 193 | + * @param array $google_events | |
| 194 | + * @param int $staff_id | |
| 195 | + * @param string $timezone Timezone the slots are rendered in. | |
| 196 | + */ | |
| 197 | + $google_events = apply_filters( 'timetics_google_overlap_events', $google_events, $staff_id, $timezone ); | |
| 198 | + | |
| 264 | 199 | // if theres no google calendar event for the staff member, return the data as it is |
| 265 | 200 | if ( is_wp_error( $google_events ) || empty( $google_events ) ) { |
| 266 | 201 | return $data; |
| 267 | 202 | } |
| 268 | 203 | |
| 269 | - // Index events by date for efficient per-day lookup | |
| 270 | - $events_by_date = []; | |
| 271 | - foreach ( $google_events as $event ) { | |
| 272 | - $event_date = $event['start_date'] ?? ''; | |
| 273 | - if ( $event_date ) { | |
| 274 | - $events_by_date[ $event_date ][] = $event; | |
| 275 | - } | |
| 204 | + $busy = $this->get_busy_intervals( $google_events, $timezone ); | |
| 205 | + | |
| 206 | + if ( ! $busy ) { | |
| 207 | + return $data; | |
| 276 | 208 | } |
| 277 | 209 | |
| 278 | - // Process each day in the range | |
| 210 | + // A slot occupies the meeting's full duration, but the slot array | |
| 211 | + // only carries a start time, so the length comes from the meeting. | |
| 212 | + $duration = $this->get_meeting_duration_in_seconds( $meeting_id ); | |
| 213 | + | |
| 279 | 214 | foreach ( $data as $index => $time_slot_data ) { |
| 280 | - $date = $time_slot_data['date']; | |
| 281 | - | |
| 282 | - if ( empty( $events_by_date[ $date ] ) ) { | |
| 215 | + if ( empty( $time_slot_data['slots'] ) ) { | |
| 283 | 216 | continue; |
| 284 | 217 | } |
| 285 | 218 | |
| 286 | - $day_events = $events_by_date[ $date ]; | |
| 219 | + $date = $time_slot_data['date']; | |
| 287 | 220 | $updated_slots = []; |
| 288 | 221 | |
| 289 | 222 | foreach ( $time_slot_data['slots'] as $slot ) { |
| 290 | - $slot_start_time = strtotime( $slot['start_time'] ); | |
| 223 | + $slot_start = $this->to_timestamp( $date . ' ' . $slot['start_time'], $timezone ); | |
| 291 | 224 | |
| 292 | - // Check against each Google Calendar event for this day | |
| 293 | - foreach ( $day_events as $event ) { | |
| 294 | - $event_start = strtotime( $event['start_time'] ); | |
| 295 | - $event_end = strtotime( $event['end_time'] ); | |
| 225 | + if ( ! $slot_start ) { | |
| 226 | + $updated_slots[] = $slot; | |
| 227 | + continue; | |
| 228 | + } | |
| 296 | 229 | |
| 297 | - // If the slot overlaps with the event, mark it as unavailable | |
| 298 | - if ( $slot_start_time >= $event_start && $slot_start_time < $event_end ) { | |
| 299 | - $slot['status'] = 'unavailable'; | |
| 230 | + $slot_end = $slot_start + $duration; | |
| 231 | + $blocked = false; | |
| 232 | + | |
| 233 | + foreach ( $busy as $interval ) { | |
| 234 | + // Standard half-open interval overlap. The previous | |
| 235 | + // check only tested the slot's start instant, so an | |
| 236 | + // event beginning mid-slot was missed entirely and the | |
| 237 | + // slot stayed bookable. | |
| 238 | + if ( $slot_start < $interval['end'] && $slot_end > $interval['start'] ) { | |
| 239 | + $blocked = true; | |
| 300 | 240 | break; |
| 301 | 241 | } |
| 302 | 242 | } |
| 303 | 243 | |
| 304 | - if ( $slot['status'] === 'unavailable' ) { | |
| 244 | + // Slots unavailable for other reasons (capacity) are kept so | |
| 245 | + // that turning this setting on does not change the response | |
| 246 | + // shape for them. | |
| 247 | + if ( $blocked ) { | |
| 305 | 248 | continue; |
| 306 | 249 | } |
| 307 | 250 | |
| 308 | 251 | $updated_slots[] = $slot; |
| @@ -308,8 +251,15 @@ | ||
| 308 | 251 | $updated_slots[] = $slot; |
| 309 | 252 | } |
| 310 | 253 | |
| 311 | 254 | $data[ $index ]['slots'] = $updated_slots; |
| 255 | + | |
| 256 | + // A day whose slots were all blocked out must not still present | |
| 257 | + // itself as available, or the calendar offers a date that opens | |
| 258 | + // onto an empty time list. | |
| 259 | + if ( empty( $updated_slots ) ) { | |
| 260 | + $data[ $index ]['status'] = 'unavailable'; | |
| 261 | + } | |
| 312 | 262 | } |
| 313 | 263 | |
| 314 | 264 | return $data; |
| 315 | 265 | } catch (\Throwable $e) { |
| @@ -315,6 +265,180 @@ | ||
| 315 | 265 | } catch (\Throwable $e) { |
| 316 | 266 | // Silenty reverts to normal behavior incase of error |
| 317 | 267 | return $data; |
| 318 | 268 | } |
| 269 | + } | |
| 270 | + | |
| 271 | + /** | |
| 272 | + * Reject a booking whose slot collides with a Google Calendar event. | |
| 273 | + * | |
| 274 | + * The slot listing filter only affects what the UI shows; without this a | |
| 275 | + * request posted straight to the REST endpoint could still take a slot the | |
| 276 | + * host has blocked out, which is the actual double booking. | |
| 277 | + * | |
| 278 | + * Fails open on purpose: if the setting is off, the token is missing or the | |
| 279 | + * API errors, the booking proceeds. A Google outage must not stop every | |
| 280 | + * booking on the site. | |
| 281 | + * | |
| 282 | + * @param bool $available | |
| 283 | + * @param Appointment $meeting | |
| 284 | + * @param array $booking_data | |
| 285 | + * | |
| 286 | + * @return bool | |
| 287 | + */ | |
| 288 | + public function reject_slot_overlapping_google_event( $available, $meeting, $booking_data ) { | |
| 289 | + try { | |
| 290 | + if ( ! $available ) { | |
| 291 | + return $available; | |
| 292 | + } | |
| 293 | + | |
| 294 | + if ( ! timetics_get_option( 'google_calendar_overlap', false ) ) { | |
| 295 | + return $available; | |
| 296 | + } | |
| 297 | + | |
| 298 | + $staff_id = ! empty( $booking_data['staff_id'] ) ? intval( $booking_data['staff_id'] ) : 0; | |
| 299 | + $start_date = ! empty( $booking_data['start_date'] ) ? $booking_data['start_date'] : ''; | |
| 300 | + $start_time = ! empty( $booking_data['start_time'] ) ? $booking_data['start_time'] : ''; | |
| 301 | + $timezone = ! empty( $booking_data['timezone'] ) ? $booking_data['timezone'] : timetics_wp_timezone_string(); | |
| 302 | + | |
| 303 | + if ( ! $staff_id || ! $start_date || ! $start_time ) { | |
| 304 | + return $available; | |
| 305 | + } | |
| 306 | + | |
| 307 | + $slot_start = $this->to_timestamp( $start_date . ' ' . $start_time, $timezone ); | |
| 308 | + | |
| 309 | + if ( ! $slot_start ) { | |
| 310 | + return $available; | |
| 311 | + } | |
| 312 | + | |
| 313 | + $slot_end = $slot_start + $this->get_meeting_duration_in_seconds( $meeting->get_id() ); | |
| 314 | + | |
| 315 | + $day_start = new DateTime( $start_date . ' 00:00:00', new DateTimeZone( $timezone ) ); | |
| 316 | + $day_end = new DateTime( $start_date . ' 23:59:59', new DateTimeZone( $timezone ) ); | |
| 317 | + | |
| 318 | + $google_events = $this->calendar->get_events( $staff_id, [ | |
| 319 | + 'timeMin' => Calendar::to_rfc3339_utc( $day_start ), | |
| 320 | + 'timeMax' => Calendar::to_rfc3339_utc( $day_end ), | |
| 321 | + 'orderBy' => 'startTime', | |
| 322 | + 'singleEvents' => 'true', | |
| 323 | + 'timeZone' => $timezone, | |
| 324 | + ] ); | |
| 325 | + | |
| 326 | + $google_events = apply_filters( 'timetics_google_overlap_events', $google_events, $staff_id, $timezone ); | |
| 327 | + | |
| 328 | + if ( is_wp_error( $google_events ) || empty( $google_events ) || ! empty( $google_events['error'] ) ) { | |
| 329 | + return $available; | |
| 330 | + } | |
| 331 | + | |
| 332 | + foreach ( $this->get_busy_intervals( $google_events, $timezone ) as $interval ) { | |
| 333 | + if ( $slot_start < $interval['end'] && $slot_end > $interval['start'] ) { | |
| 334 | + return false; | |
| 335 | + } | |
| 336 | + } | |
| 337 | + | |
| 338 | + return $available; | |
| 339 | + } catch ( \Throwable $e ) { | |
| 340 | + return $available; | |
| 341 | + } | |
| 342 | + } | |
| 343 | + | |
| 344 | + /** | |
| 345 | + * Reduce Google events to absolute busy intervals. | |
| 346 | + * | |
| 347 | + * Working in UTC timestamps keeps the comparison correct when the visitor's | |
| 348 | + * timezone differs from the calendar's, and lets multi-day and | |
| 349 | + * cross-midnight events be handled without any per-day special casing. | |
| 350 | + * | |
| 351 | + * @param array $google_events Events as returned by Calendar::get_events(). | |
| 352 | + * @param string $timezone Timezone the slots are rendered in. | |
| 353 | + * | |
| 354 | + * @return array<int, array{start:int,end:int}> | |
| 355 | + */ | |
| 356 | + private function get_busy_intervals( $google_events, $timezone ) { | |
| 357 | + $intervals = []; | |
| 358 | + | |
| 359 | + foreach ( $google_events as $event ) { | |
| 360 | + if ( ! is_array( $event ) || empty( $event['start_date'] ) ) { | |
| 361 | + continue; | |
| 362 | + } | |
| 363 | + | |
| 364 | + if ( ! empty( $event['all_day'] ) ) { | |
| 365 | + // Date-only bounds carry no timezone of their own, so a day | |
| 366 | + // blocked out in Google means midnight to midnight for whoever | |
| 367 | + // is looking at the calendar. Google's end date is exclusive, | |
| 368 | + // which is exactly the half-open interval wanted here. | |
| 369 | + $start = $this->to_timestamp( $event['start_date'] . ' 00:00:00', $timezone ); | |
| 370 | + $end = $this->to_timestamp( ( $event['end_date'] ?? $event['start_date'] ) . ' 00:00:00', $timezone ); | |
| 371 | + | |
| 372 | + // Guard against a malformed event whose end is not after its | |
| 373 | + // start, which would otherwise block nothing or everything. | |
| 374 | + if ( $start && ( ! $end || $end <= $start ) ) { | |
| 375 | + $end = $start + DAY_IN_SECONDS; | |
| 376 | + } | |
| 377 | + } elseif ( isset( $event['start_timestamp'], $event['end_timestamp'] ) ) { | |
| 378 | + $start = (int) $event['start_timestamp']; | |
| 379 | + $end = (int) $event['end_timestamp']; | |
| 380 | + } else { | |
| 381 | + // Timed event without absolute bounds — older payload shape, or | |
| 382 | + // a fixture. Resolve the wall-clock values in the event's own | |
| 383 | + // timezone when it has one. | |
| 384 | + $event_tz = ! empty( $event['timezone'] ) ? $event['timezone'] : $timezone; | |
| 385 | + | |
| 386 | + $start = $this->to_timestamp( $event['start_date'] . ' ' . ( $event['start_time'] ?? '00:00:00' ), $event_tz ); | |
| 387 | + $end = $this->to_timestamp( | |
| 388 | + ( $event['end_date'] ?? $event['start_date'] ) . ' ' . ( $event['end_time'] ?? '00:00:00' ), | |
| 389 | + $event_tz | |
| 390 | + ); | |
| 391 | + } | |
| 392 | + | |
| 393 | + if ( ! $start || ! $end || $end <= $start ) { | |
| 394 | + continue; | |
| 395 | + } | |
| 396 | + | |
| 397 | + $intervals[] = [ | |
| 398 | + 'start' => $start, | |
| 399 | + 'end' => $end, | |
| 400 | + ]; | |
| 401 | + } | |
| 402 | + | |
| 403 | + return $intervals; | |
| 404 | + } | |
| 405 | + | |
| 406 | + /** | |
| 407 | + * Resolve a wall-clock string in a given timezone to a UTC timestamp. | |
| 408 | + * | |
| 409 | + * @param string $datetime e.g. "2026-08-10 3:00pm" or "2026-08-10 15:00:00". | |
| 410 | + * @param string $timezone | |
| 411 | + * | |
| 412 | + * @return int Timestamp, or 0 when the value cannot be parsed. | |
| 413 | + */ | |
| 414 | + private function to_timestamp( $datetime, $timezone ) { | |
| 415 | + try { | |
| 416 | + $tz = new DateTimeZone( $timezone ); | |
| 417 | + } catch ( \Exception $e ) { | |
| 418 | + $tz = new DateTimeZone( 'UTC' ); | |
| 419 | + } | |
| 420 | + | |
| 421 | + try { | |
| 422 | + return ( new DateTime( $datetime, $tz ) )->getTimestamp(); | |
| 423 | + } catch ( \Exception $e ) { | |
| 424 | + return 0; | |
| 425 | + } | |
| 426 | + } | |
| 427 | + | |
| 428 | + /** | |
| 429 | + * Length of a booking for the given meeting, in seconds. | |
| 430 | + * | |
| 431 | + * Slots are emitted with a start time only, so the end has to be derived | |
| 432 | + * from the meeting itself. | |
| 433 | + * | |
| 434 | + * @param int $meeting_id | |
| 435 | + * | |
| 436 | + * @return int | |
| 437 | + */ | |
| 438 | + private function get_meeting_duration_in_seconds( $meeting_id ) { | |
| 439 | + $meeting = new Appointment( $meeting_id ); | |
| 440 | + $duration = (int) $meeting->get_interval(); | |
| 441 | + | |
| 442 | + return $duration > 0 ? $duration : 30 * MINUTE_IN_SECONDS; | |
| 319 | 443 | } |
| 320 | 444 | } |