PluginProbe
Timetics – Appointment Booking Calendar & Scheduling / 1.0.38
Timetics – Appointment Booking Calendar & Scheduling v1.0.38
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
← All changes | core/integrations/google/service/google-calendar-sync.php +125 -280 1.0.63 → 1.0.38 View file →
@@ -8,13 +8,13 @@
8 8 */
9 9
10 10 namespace Timetics\Core\Integrations\Google\Service;
11 11
12 -defined( 'ABSPATH' ) || exit;
13 -
14 12 use Timetics\Core\Appointments\Api_Appointment;
15 13 use Timetics\Core\Bookings\Booking;
14 +use Timetics\Core\Customers\Customer;
16 15 use Timetics\Core\Appointments\Appointment;
16 +use WP_Error;
17 17 use Timetics\Utils\Singleton;
18 18 use DateTime;
19 19 use DateTimeZone;
20 20
@@ -61,23 +61,13 @@
61 61 $this->calendar = new Calendar();
62 62 $this->appointment_api = new Api_Appointment();
63 63
64 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.
65 + add_action( 'timetics_after_booking_schedule', array( $this, 'sync_booking_to_google_calendar' ), 10, 4 );
72 66 add_filter( 'timetics/admin/booking/get_items', array( $this, 'get_events_from_google' ) );
73 67 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 68 } 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 - }
69 + error_log( $e->getMessage() );
80 70 }
81 71 }
82 72
83 73 /**
@@ -90,8 +80,88 @@
90 80 return $booking->get_all_google_event_ids();
91 81 }
92 82
93 83 /**
84 + * Sync booking to Google Calendar
85 + *
86 + * @param int $booking_id Booking ID
87 + * @param int $customer_id Customer ID
88 + * @param int $meeting_id Meeting ID
89 + * @param array $data Booking data
90 + *
91 + * @return void|WP_Error
92 + */
93 + public function sync_booking_to_google_calendar( $booking_id, $customer_id, $meeting_id, $data ) {
94 + try {
95 + if ( ! is_numeric( $booking_id ) || ! is_numeric( $customer_id ) || ! is_numeric( $meeting_id ) ) {
96 + return new WP_Error( 'invalid_parameters', 'Invalid parameters provided for Google Calendar sync.' );
97 + }
98 +
99 + $booking = new Booking( $booking_id );
100 + $meeting = new Appointment( $meeting_id );
101 + $customer = new Customer( $customer_id );
102 +
103 + // Get the current user's access token
104 + $user_id = get_current_user_id();
105 + $access_token = timetics_get_google_access_token( $user_id );
106 +
107 + if ( empty( $access_token ) ) {
108 + return new WP_Error( 'no_access_token', 'No Google Calendar access token found. Please reconnect your Google account.' );
109 + }
110 +
111 + // Prepare event data
112 + $event_data = array(
113 + 'access_token' => sanitize_text_field( $access_token ),
114 + 'summary' => sanitize_text_field( $meeting->get_name() ),
115 + 'description' => sanitize_text_field( $meeting->get_description() ),
116 + 'start' => array(
117 + 'dateTime' => $booking->get_start_date(),
118 + 'timeZone' => wp_timezone_string(),
119 + ),
120 + 'end' => array(
121 + 'dateTime' => $booking->get_end_date(),
122 + 'timeZone' => wp_timezone_string(),
123 + ),
124 + 'attendees' => array(
125 + array( 'email' => $customer->get_email() ),
126 + ),
127 + 'reminders' => array(
128 + 'useDefault' => true,
129 + ),
130 + 'guestsCanInviteOthers' => false,
131 + 'guestsCanModify' => false,
132 + 'guestsCanSeeOtherGuests' => false,
133 + 'timezone' => wp_timezone_string(),
134 + );
135 +
136 + // Check if this booking already has a Google Event ID
137 + $event_id = $booking->get_google_event_id();
138 +
139 + if ( $event_id ) {
140 + // Update existing event
141 + $result = $this->calendar->update_event( $event_id, $event_data );
142 + } else {
143 + // Create new event
144 + $result = $this->calendar->create_event( $event_data );
145 +
146 + // Save the event ID for future updates
147 + if ( ! empty( $result['id'] ) ) {
148 + $booking->set_google_event_id( $result['id'] );
149 + }
150 + }
151 +
152 + if ( is_wp_error( $result ) ) {
153 + $booking->set_sync_status( 'error' );
154 + return $result;
155 + }
156 +
157 + $booking->set_sync_status( 'synced' );
158 + } catch ( \Exception $e ) {
159 + return new WP_Error( 'google_calendar_sync_error', $e->getMessage() );
160 + }
161 + }
162 +
163 + /**
94 164 * Get events from Google Calendar
95 165 * Only returns events that were not created by Timetics
96 166 *
97 167 * @param array $bookings Existing bookings array
@@ -149,296 +219,71 @@
149 219
150 220 /**
151 221 * Block timeslots by Google events
152 222 *
153 - * @param array $data
154 - * @param int $staff_id
155 - * @param int $meeting_id
156 - * @param string $timezone
223 + * @param array $data
224 + * @param int $staff_id
225 + * @param int $meeting_id
226 + * @param string $timezone
157 227 *
158 228 * @return array Modified bookings array with Google Calendar events
159 229 */
160 230 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 -
231 +
232 + // Fixing minor array format issue
233 + $time_slot_data = $data[0];
234 +
235 + if ( ! timetics_get_option( 'google_calendar_overlap', false ) ) {
264 236 return $data;
265 - } catch (\Throwable $e) {
266 - // Silenty reverts to normal behavior incase of error
267 - return $data;
268 237 }
269 - }
238 + $date_of_event = $time_slot_data['date'];
270 239
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 - }
240 + $date_obj_start = new DateTime( $date_of_event . ' 00:00:00', new DateTimeZone( $timezone ) );
241 + $date_obj_end = new DateTime( $date_of_event . ' 23:59:59', new DateTimeZone( $timezone ) );
293 242
294 - if ( ! timetics_get_option( 'google_calendar_overlap', false ) ) {
295 - return $available;
296 - }
243 + $staff_id = get_current_user_id();
244 + $google_events = $this->calendar->get_events( $staff_id, [
245 + 'timeMin' => rawurlencode($date_obj_start->format( DateTime::RFC3339 )),
246 + 'timeMax' => rawurlencode($date_obj_end->format( DateTime::RFC3339 )),
247 + 'orderBy' => 'startTime',
248 + 'singleEvents' => 'true',
249 + 'timeZone' => $timezone,
250 + ]);
297 251
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;
252 + // if theres no google calendar event for the staff member, return the data as it is
253 + if ( is_wp_error( $google_events ) || empty( $google_events ) ) {
254 + return $data;
341 255 }
342 - }
343 256
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 = [];
257 + // Process each timeslot for the day
258 + $updated_slots = [];
358 259
359 - foreach ( $google_events as $event ) {
360 - if ( ! is_array( $event ) || empty( $event['start_date'] ) ) {
361 - continue;
362 - }
260 + foreach ( $time_slot_data['slots'] as $slot ) {
261 + $slot_start_time = strtotime( $slot['start_time'] );
363 262
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 );
263 + // Check against each Google Calendar event
264 + foreach ( $google_events as $event ) {
265 + $event_start = strtotime( $event['start_time'] );
266 + $event_end = strtotime( $event['end_time'] );
371 267
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;
268 + // If the slot overlaps with the event, mark it as unavailable
269 + if ( $slot_start_time >= $event_start && $slot_start_time < $event_end ) {
270 + $slot['status'] = 'unavailable';
271 + break; // No need to check more events if already unavailable
376 272 }
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 273 }
392 274
393 - if ( ! $start || ! $end || $end <= $start ) {
275 + if ( $slot['status'] === 'unavailable' ) {
394 276 continue;
395 277 }
396 278
397 - $intervals[] = [
398 - 'start' => $start,
399 - 'end' => $end,
400 - ];
279 + $updated_slots[] = $slot;
401 280 }
402 281
403 - return $intervals;
404 - }
282 + $time_slot_data['slots'] = $updated_slots;
405 283
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 - }
284 + // return the data with updated timeslots in its original format
285 + $data[0] = $time_slot_data;
420 286
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;
287 + return $data;
443 288 }
444 289 }