PluginProbe
Timetics – Appointment Booking Calendar & Scheduling / 1.0.64
Timetics – Appointment Booking Calendar & Scheduling v1.0.64
1.0.64 1.0.62 1.0.63 1.0.61 1.0.60 1.0.59 1.0.58 1.0.57 1.0.56 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.19 1.0.2 1.0.20 1.0.21 All 65 releases
← 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 }