PluginProbe
Timetics – Appointment Booking Calendar & Scheduling / 1.0.63
Timetics – Appointment Booking Calendar & Scheduling v1.0.63
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 1.0.22 All 64 releases
timetics / core / integrations / google / service / google-calendar-sync.php

google-calendar-sync.php in Timetics – Appointment Booking Calendar & Scheduling 1.0.63, at core/integrations/google/service/google-calendar-sync.php

445 lines 16.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Google Calendar Sync Class
4 *
5 * Handles two-way synchronization between Google Calendar and Timetics appointments.
6 *
7 * @package Timetics
8 */
9
10 namespace Timetics\Core\Integrations\Google\Service;
11
12 defined( 'ABSPATH' ) || exit;
13
14 use Timetics\Core\Appointments\Api_Appointment;
15 use Timetics\Core\Bookings\Booking;
16 use Timetics\Core\Appointments\Appointment;
17 use Timetics\Utils\Singleton;
18 use DateTime;
19 use DateTimeZone;
20
21 /**
22 * Class Google_Calendar_Sync
23 */
24 class Google_Calendar_Sync {
25 use Singleton;
26
27 /**
28 * Google Calendar service instance
29 *
30 * @var Calendar
31 */
32 private $calendar;
33
34 /**
35 * Appointment API instance
36 *
37 * @var Api_Appointment
38 */
39 private $appointment_api;
40
41 /**
42 * Meta key for storing Google Event ID
43 */
44 const EVENT_ID_META_KEY = 'tt_google_calendar_event_id';
45
46 /**
47 * Meta key for storing sync status
48 */
49 const SYNC_STATUS_META_KEY = 'tt_google_calendar_sync_status';
50
51 /**
52 * Meta key for storing ETag
53 */
54 const ETAG_META_KEY = 'tt_google_calendar_etag';
55
56 /**
57 * Constructor
58 */
59 public function __construct() {
60 try {
61 $this->calendar = new Calendar();
62 $this->appointment_api = new Api_Appointment();
63
64 // Add hooks
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.
72 add_filter( 'timetics/admin/booking/get_items', array( $this, 'get_events_from_google' ) );
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 );
75 } catch ( \Throwable $e ) {
76 if ( defined( 'WP_DEBUG' ) && WP_DEBUG && defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) {
77 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Debug logging is properly guarded by WP_DEBUG checks
78 error_log( 'Timetics Google Calendar Sync: ' . $e->getMessage() );
79 }
80 }
81 }
82
83 /**
84 * Get all Google Event IDs that were created by Timetics
85 *
86 * @return array
87 */
88 private function get_timetics_google_event_ids() {
89 $booking = new Booking();
90 return $booking->get_all_google_event_ids();
91 }
92
93 /**
94 * Get events from Google Calendar
95 * Only returns events that were not created by Timetics
96 *
97 * @param array $bookings Existing bookings array
98 *
99 * @return array Modified bookings array with Google Calendar events
100 */
101 public function get_events_from_google( $bookings ) {
102 try {
103 // Get Google Calendar events
104 $events = $this->calendar->get_events( get_current_user_id() );
105
106 if ( is_wp_error( $events ) || empty( $events ) ) {
107 return $bookings;
108 }
109
110 // Get all Timetics-created Google Event IDs
111 $timetics_event_ids = $this->get_timetics_google_event_ids();
112
113 $google_events = array();
114
115 foreach ( $events as $event ) {
116 // Skip events that were created by Timetics
117 if ( in_array( $event['id'] ?? null, $timetics_event_ids, true ) ) {
118 continue;
119 }
120
121 // mapping google events data to timetics booking format
122 $google_events[] = array(
123 'id' => $event['id'] ?? '',
124 'order_total' => '',
125 'title' => $event['summary'] ?? '',
126 'description' => $event['description'] ?? '',
127 'date' => $event['start_date'] ?? '',
128 'start_date' => $event['start_date'] ?? '',
129 'end_date' => $event['end_date'] ?? '',
130 'start_time' => $event['start_time'] ?? '',
131 'end_time' => $event['end_time'] ?? '',
132 'source' => 'google',
133 'status' => 'approved',
134 'random_id' => 'I' . ( $event['id'] ?? '' ),
135 'appointment' => array(
136 'id' => $event['id'] ?? '',
137 'name' => $event['summary'] ?? '',
138 'timezone' => $event['timezone'] ?? '',
139 ),
140 );
141 }
142
143 // Merge with existing bookings
144 return array_merge( $bookings, $google_events );
145 } catch ( \Throwable $e ) {
146 return $bookings;
147 }
148 }
149
150 /**
151 * Block timeslots by Google events
152 *
153 * @param array $data
154 * @param int $staff_id
155 * @param int $meeting_id
156 * @param string $timezone
157 *
158 * @return array Modified bookings array with Google Calendar events
159 */
160 public function block_timeslots_by_google_events( $data, $staff_id, $meeting_id, $timezone ) {
161 try {
162 if ( ! timetics_get_option( 'google_calendar_overlap', false ) ) {
163 return $data;
164 }
165
166 if ( empty( $data ) ) {
167 return $data;
168 }
169
170 // Fetch Google events for the full date range in one request
171 $first_day = reset( $data );
172 $last_day = end( $data );
173
174 $date_obj_start = new DateTime( $first_day['date'] . ' 00:00:00', new DateTimeZone( $timezone ) );
175 $date_obj_end = new DateTime( $last_day['date'] . ' 23:59:59', new DateTimeZone( $timezone ) );
176
177 $google_events = $this->calendar->get_events( $staff_id, [
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 ),
182 'orderBy' => 'startTime',
183 'singleEvents' => 'true',
184 'timeZone' => $timezone,
185 ]);
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
199 // if theres no google calendar event for the staff member, return the data as it is
200 if ( is_wp_error( $google_events ) || empty( $google_events ) ) {
201 return $data;
202 }
203
204 $busy = $this->get_busy_intervals( $google_events, $timezone );
205
206 if ( ! $busy ) {
207 return $data;
208 }
209
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
214 foreach ( $data as $index => $time_slot_data ) {
215 if ( empty( $time_slot_data['slots'] ) ) {
216 continue;
217 }
218
219 $date = $time_slot_data['date'];
220 $updated_slots = [];
221
222 foreach ( $time_slot_data['slots'] as $slot ) {
223 $slot_start = $this->to_timestamp( $date . ' ' . $slot['start_time'], $timezone );
224
225 if ( ! $slot_start ) {
226 $updated_slots[] = $slot;
227 continue;
228 }
229
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;
240 break;
241 }
242 }
243
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 ) {
248 continue;
249 }
250
251 $updated_slots[] = $slot;
252 }
253
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 }
262 }
263
264 return $data;
265 } catch (\Throwable $e) {
266 // Silenty reverts to normal behavior incase of error
267 return $data;
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;
443 }
444 }
445