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/bookings/booking.php +800 -50 1.0.0 → 1.0.64 View file →
@@ -6,12 +6,16 @@
6 6 */
7 7
8 8 namespace Timetics\Core\Bookings;
9 9
10 +defined( 'ABSPATH' ) || exit;
11 +
10 12 use Timetics\Core\Appointments\Appointment;
11 13 use Timetics\Core\Customers\Customer;
12 14 use Timetics\Core\Integrations\Google\Service\Calendar;
13 15 use Timetics\Core\Staffs\Staff;
16 +use TimeticsEvent\Core\Event_Bookings\Event_Booking;
17 +use TimeticsEvent\Core\Events\Event;
14 18 use WP_Query;
15 19
16 20 /**
17 21 * Class Booking
@@ -45,29 +49,51 @@
45 49 */
46 50 protected $appointment;
47 51
48 52 /**
53 + * Store appointment object
54 + *
55 + * @var Object
56 + */
57 + protected $event;
58 +
59 + /**
49 60 * Store booking data
50 61 *
51 62 * @var array
52 63 */
53 64 protected $data = [
54 - 'post_status' => '',
55 - 'customer' => '',
56 - 'appointment' => '',
57 - 'staff' => '',
58 - 'location' => '',
59 - 'location_type' => '',
60 - 'payment_method' => '',
61 - 'payment_status' => '',
62 - 'payment_details' => '',
63 - 'order_total' => '',
64 - 'start_date' => '',
65 - 'end_date' => '',
66 - 'start_time' => '',
67 - 'end_time' => '',
68 - 'calendar_event' => '',
69 - 'random_id' => '',
65 +
66 + 'post_status' => '',
67 + 'customer' => '',
68 + 'appointment' => '',
69 + 'appointment_name' => '',
70 + 'staff' => '',
71 + 'speaker' => '',
72 + 'event' => '',
73 + 'location' => '',
74 + 'description' => '',
75 + 'location_type' => '',
76 + 'payment_method' => '',
77 + 'payment_status' => '',
78 + 'payment_details' => '',
79 + 'order_total' => '',
80 + 'start_date' => '',
81 + 'end_date' => '',
82 + 'start_time' => '',
83 + 'end_time' => '',
84 + 'calendar_event' => '',
85 + 'random_id' => '',
86 + 'seats' => '',
87 + 'guests' => '',
88 + 'zoom' => '',
89 + 'custom_form_data' => '',
90 + 'timezone' => '',
91 + 'recurrence' => [],
92 + 'parent' => 0,
93 + 'cancel_reason' => '',
94 + 'custom_location_url' => '',
95 + 'security_token' => ''
70 96 ];
71 97
72 98 /**
73 99 * Booking Constructor
@@ -102,8 +128,28 @@
102 128 return $this->get_prop( 'customer' );
103 129 }
104 130
105 131 /**
132 + * Get attendee id
133 + *
134 + * @return integer
135 + */
136 + public function get_attendees() {
137 + $attendees = $this->get_prop( 'attendees' );
138 + return $attendees ? $attendees : [];
139 + }
140 +
141 + /**
142 + * Get custom url
143 + *
144 + * @return string
145 + */
146 + public function get_custom_location_url() {
147 + $custom_url = $this->get_prop( 'custom_location_url' );
148 + return $custom_url ? $custom_url : '';
149 + }
150 +
151 + /**
106 152 * Get staff for the current booking
107 153 *
108 154 * @return integer
109 155 */
@@ -111,8 +157,35 @@
111 157 return $this->get_prop( 'staff' );
112 158 }
113 159
114 160 /**
161 + * Get speaker id
162 + *
163 + * @return integer
164 + */
165 + public function get_speaker_id() {
166 + return $this->get_prop( 'speaker' );
167 + }
168 +
169 + /**
170 + * Get booking type
171 + *
172 + * @return string
173 + */
174 + public function get_type() {
175 + return $this->get_prop( 'type' );
176 + }
177 +
178 + /**
179 + * Get event id
180 + *
181 + * @return integer
182 + */
183 + public function get_event_id() {
184 + return $this->get_prop( 'event' );
185 + }
186 +
187 + /**
115 188 * Get customer
116 189 *
117 190 * @return User Object
118 191 */
@@ -120,8 +193,35 @@
120 193 return get_userdata( $this->get_customer_id() );
121 194 }
122 195
123 196 /**
197 + * Get seats of a booking
198 + *
199 + * @return array
200 + */
201 + public function get_seat() {
202 + return $this->get_prop( 'seats' );
203 + }
204 +
205 + /**
206 + * Get guests
207 + *
208 + * @return array
209 + */
210 + public function get_guests() {
211 + return $this->get_prop( 'guests' );
212 + }
213 +
214 + /**
215 + * Get custom fields
216 + *
217 + * @return array
218 + */
219 + public function get_custom_fields() {
220 + return $this->get_prop( 'custom_form_data' );
221 + }
222 +
223 + /**
124 224 * Get billing first name
125 225 *
126 226 * @return string
127 227 */
@@ -147,8 +247,17 @@
147 247 return $this->get_prop( 'location_type' );
148 248 }
149 249
150 250 /**
251 + * Get booking time
252 + *
253 + * @return string
254 + */
255 + public function get_booking_time() {
256 + return $this->get_prop( 'booking_time' );
257 + }
258 +
259 + /**
151 260 * Get last name
152 261 *
153 262 * @return string
154 263 */
@@ -174,8 +283,26 @@
174 283 return $this->get_prop( 'phone' );
175 284 }
176 285
177 286 /**
287 + * Get timezone
288 + *
289 + * @return string
290 + */
291 + public function get_timezone() {
292 + return $this->get_prop( 'timezone' );
293 + }
294 +
295 + /**
296 + * Get description
297 + *
298 + * @return string
299 + */
300 + public function get_description() {
301 + return $this->get_prop( 'description' );
302 + }
303 +
304 + /**
178 305 * Get billing address
179 306 *
180 307 * @param string $adress_type Booking billing address
181 308 *
@@ -230,8 +357,17 @@
230 357 return $this->get_prop( 'payment_method' );
231 358 }
232 359
233 360 /**
361 + * Get payment status
362 + *
363 + * @return string
364 + */
365 + public function get_payment_status() {
366 + return $this->get_prop( 'payment_status' );
367 + }
368 +
369 + /**
234 370 * Get appointment
235 371 *
236 372 * @return Appointment
237 373 */
@@ -239,8 +375,17 @@
239 375 return $this->get_prop( 'appointment' );
240 376 }
241 377
242 378 /**
379 + * Get appointment name
380 + *
381 + * @return string
382 + */
383 + public function get_appointment_name() {
384 + return $this->get_prop( 'appointment_name' );
385 + }
386 +
387 + /**
243 388 * Get calendar event
244 389 *
245 390 * @return array
246 391 */
@@ -319,8 +464,53 @@
319 464 return $this->get_prop( 'random_id' );
320 465 }
321 466
322 467 /**
468 + * Get payment details
469 + *
470 + * @return array
471 + */
472 + public function get_payment_details() {
473 + return $this->get_prop( 'payment_details' );
474 + }
475 +
476 + /**
477 + * Get zoom meeting details
478 + *
479 + * @return array
480 + */
481 + public function get_zoom() {
482 + return $this->get_prop( 'zoom' );
483 + }
484 +
485 + /**
486 + * Get recurrences
487 + *
488 + * @return array
489 + */
490 + public function get_recurrence() {
491 + return $this->get_prop( 'recurrence' );
492 + }
493 +
494 + /**
495 + * Get parent
496 + *
497 + * @return integer
498 + */
499 + public function get_parent() {
500 + return $this->get_prop( 'parent' );
501 + }
502 +
503 + /**
504 + * Get booking cancel reason
505 + *
506 + * @return integer
507 + */
508 + public function get_cancel_reason() {
509 + return $this->get_prop( 'cancel_reason' );
510 + }
511 +
512 + /**
323 513 * Get booking data
324 514 *
325 515 * @param string $prop
326 516 *
@@ -348,9 +538,9 @@
348 538 *
349 539 * @return string
350 540 */
351 541 private function generate_randon_id() {
352 - return chr(rand(ord('A'), ord('Z'))) . rand(575639, 575639 + 400);
542 + return chr( wp_rand( ord( 'A' ), ord( 'Z' ) ) ) . wp_rand( 575639, 575639 + 400 );
353 543 }
354 544
355 545 // Setter.
356 546
@@ -374,8 +564,12 @@
374 564 */
375 565 public function set_props( $args = [] ) {
376 566 $this->data = wp_parse_args( $args, $this->data );
377 567 $this->appointment = new Appointment( $this->data['appointment'] );
568 + if ( isset( $this->data['type'] ) && 'timetics-event' == $this->data['type'] ) {
569 + $this->event = new Event( $this->data['event'] );
570 + }
571 +
378 572 }
379 573
380 574 /**
381 575 * Save metada for booking
@@ -392,8 +586,12 @@
392 586
393 587 // Prepare meta key.
394 588 $meta_key = $this->meta_prefix . $key;
395 589
590 + if ( ! $value ) {
591 + $value = $this->get_prop( $key );
592 + }
593 +
396 594 // Update appointment meta data.
397 595 update_post_meta( $this->id, $meta_key, $value );
398 596 }
399 597 }
@@ -402,9 +600,9 @@
402 600 * Save appointment
403 601 *
404 602 * @return void
405 603 */
406 - public function save() {
604 + public function save( $type = '' ) {
407 605 $args = [
408 606 'post_title' => $this->appointment->get_name(),
409 607 'post_type' => $this->pos_type,
410 608 'post_status' => $this->data['post_status'],
@@ -410,20 +608,38 @@
410 608 'post_status' => $this->data['post_status'],
411 609 'post_author' => get_current_user_id(),
412 610 ];
413 611
612 + if ( 'timetics-event' == $type ) {
613 + $args = [
614 + 'post_title' => $this->event->get_name(),
615 + 'post_type' => $this->pos_type,
616 + 'post_status' => $this->data['post_status'],
617 + 'post_author' => get_current_user_id(),
618 + ];
619 + }
620 +
621 + $updated = false;
622 +
414 623 if ( ! empty( $this->id ) ) {
624 + $this->send_notification( 'update' );
415 625 $args['ID'] = $this->id;
626 + $updated = true;
416 627 }
417 628
418 629 // Insert or Update appointment.
419 - $booking_id = wp_insert_post( $args );
630 + $booking_id = wp_insert_post( $args );
420 631 $this->data['random_id'] = $this->generate_randon_id();
632 + $this->data = apply_filters( 'timetics/event/booking/all_data', $this->data);
421 633
422 634 if ( ! is_wp_error( $booking_id ) ) {
423 635 $this->set_id( $booking_id );
424 636 $this->save_metadata( $this->data );
425 637 }
638 +
639 + if ( ! $updated ) {
640 + $this->send_notification( 'create' );
641 + }
426 642 }
427 643
428 644 /**
429 645 * Update booking
@@ -445,13 +661,198 @@
445 661 return $updated;
446 662 }
447 663
448 664 /**
665 + * Find the Booking_Entry (shared per-slot schedule record) this booking occupies.
666 + *
667 + * @param Appointment|null $meeting Optional pre-built meeting for this booking.
668 + * @param string $start_date Slot date. Defaults to the booking's own.
669 + * @param string $start_time Slot start. Defaults to the booking's own.
670 + * @param string $timezone Timezone of the two above. Defaults to the booking's own.
671 + * @return Booking_Entry|null The first matching entry, or null if none.
672 + */
673 + private function find_slot_entry( $meeting = null, $start_date = '', $start_time = '', $timezone = '' ) {
674 + $meeting = $meeting ?: new Appointment( $this->get_appointment() );
675 + $booking_entry = new Booking_Entry();
676 +
677 + $start_date = $start_date ?: $this->get_start_date();
678 + $start_time = $start_time ?: $this->get_start_time();
679 + $timezone = $timezone ?: $this->get_timezone();
680 +
681 + $date_time = timetics_convert_timezone(
682 + $start_date . ' ' . $start_time,
683 + $timezone,
684 + $meeting->get_timezone()
685 + );
686 +
687 + $entries = $booking_entry->find(
688 + [
689 + 'staff_id' => $this->get_staff_id(),
690 + 'meeting_id' => $this->get_appointment(),
691 + 'date' => $date_time->format( 'Y-m-d' ),
692 + 'start' => $date_time->format( 'h:i a' ),
693 + ]
694 + );
695 +
696 + return $entries ? $booking_entry->first() : null;
697 + }
698 +
699 + /**
700 + * Release the slot held by this booking.
701 + *
702 + * @return void
703 + */
704 + public function release_slot() {
705 + if ( ! $this->is_booking() ) {
706 + return;
707 + }
708 +
709 + // Idempotency guard: never release the same booking's slot twice.
710 + if ( $this->get_prop( 'slot_released' ) ) {
711 + return;
712 + }
713 +
714 + $this->release_slot_at( $this->get_start_date(), $this->get_start_time() );
715 +
716 + update_post_meta( $this->id, $this->meta_prefix . 'slot_released', 1 );
717 + }
718 +
719 + /**
720 + * Take back the slot this booking released.
721 + *
722 + * A declined payment marks the booking failed and releases its slot, so an
723 + * attempt the customer walks away from does not block the time forever. When
724 + * the customer retries on that same booking the slot has to be taken back
725 + * before any money moves, otherwise the booking finalizes while the slot still
726 + * reads as free and the next customer can book straight over it.
727 + *
728 + * @return bool True when the slot is held, false when it is no longer free.
729 + */
730 + public function reserve_slot() {
731 + if ( ! $this->is_booking() ) {
732 + return false;
733 + }
734 +
735 + // Nothing was released, so the slot is still held from booking creation.
736 + if ( ! $this->get_prop( 'slot_released' ) ) {
737 + return true;
738 + }
739 +
740 + $meeting = new Appointment( $this->get_appointment() );
741 + $entry = $this->find_slot_entry( $meeting );
742 + $seats = ! empty( $this->get_seat() ) ? (array) $this->get_seat() : [];
743 + $taking = max( 1, count( $seats ) );
744 +
745 + if ( $entry ) {
746 + $booked = intval( $entry->get_booked() ) + $taking;
747 +
748 + // Someone else took the time while this booking was in the failed state.
749 + if ( $booked > $meeting->get_effective_capacity() ) {
750 + return false;
751 + }
752 +
753 + $existing = ! empty( $entry->get_seats() ) ? (array) $entry->get_seats() : [];
754 +
755 + $entry->update(
756 + [
757 + 'booked' => $booked,
758 + 'seats' => array_values( array_unique( array_merge( $existing, $seats ) ) ),
759 + ]
760 + );
761 + } else {
762 + // A one-to-one release deletes the entry outright, so recreate it. Entries
763 + // are stored in the meeting's timezone, the booking's in the customer's.
764 + $start = timetics_convert_timezone( $this->get_start_date() . ' ' . $this->get_start_time(), $this->get_timezone(), $meeting->get_timezone() );
765 + $end = timetics_convert_timezone( $this->get_start_date() . ' ' . $this->get_end_time(), $this->get_timezone(), $meeting->get_timezone() );
766 +
767 + $booking_entry = new Booking_Entry();
768 +
769 + $booking_entry->create(
770 + [
771 + 'meeting_id' => $meeting->get_id(),
772 + 'staff_id' => $this->get_staff_id(),
773 + 'customer_id' => $this->get_customer(),
774 + 'booking_id' => $this->id,
775 + 'booked' => $taking,
776 + 'date' => $start->format( 'Y-m-d' ),
777 + 'start' => $start->format( 'h:i a' ),
778 + 'end' => $end->format( 'h:i a' ),
779 + 'seats' => $seats,
780 + ]
781 + );
782 + }
783 +
784 + delete_post_meta( $this->id, $this->meta_prefix . 'slot_released' );
785 +
786 + return true;
787 + }
788 +
789 + /**
790 + * Release the entry for one named slot of this booking.
791 + *
792 + * Rescheduling saves the new time first, so the old slot has to be named
793 + * rather than read off the booking. No idempotency guard: the booking lives
794 + * on and may release more slots as it moves.
795 + *
796 + * @param string $start_date Slot date, Y-m-d.
797 + * @param string $start_time Slot start.
798 + * @param string $timezone Timezone of the two above. Defaults to the booking's own.
799 + *
800 + * @return void
801 + */
802 + public function release_slot_at( $start_date, $start_time, $timezone = '' ) {
803 + if ( ! $this->is_booking() ) {
804 + return;
805 + }
806 +
807 + $meeting = new Appointment( $this->get_appointment() );
808 + $entry = $this->find_slot_entry( $meeting, $start_date, $start_time, $timezone );
809 +
810 + if ( ! $entry ) {
811 + if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
812 + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Debug-only diagnostics for a missing booking entry.
813 + error_log(
814 + sprintf(
815 + 'Timetics release_slot: no Booking_Entry found for booking #%d ( staff %s, meeting %s, slot %s %s ).',
816 + $this->id,
817 + $this->get_staff_id(),
818 + $this->get_appointment(),
819 + $start_date,
820 + $start_time
821 + )
822 + );
823 + }
824 +
825 + return;
826 + }
827 +
828 + if ( 'one-to-one' === strtolower( $meeting->get_type() ) ) {
829 + $entry->delete();
830 +
831 + return;
832 + }
833 +
834 + $booked = max( 0, intval( $entry->get_booked() ) - 1 );
835 + $booked_seat = ! empty( $this->get_seat() ) ? $this->get_seat() : [];
836 + $existing_seat = ! empty( $entry->get_seats() ) ? $entry->get_seats() : [];
837 +
838 + $entry->update(
839 + [
840 + 'booked' => $booked,
841 + 'seats' => array_values( array_diff( $existing_seat, $booked_seat ) ),
842 + ]
843 + );
844 + }
845 +
846 + /**
449 847 * Delete booking
450 848 *
451 849 * @return bool | WP_Error
452 850 */
453 851 public function delete() {
852 + // Set action for sending notification.
853 + $this->send_notification( 'delete' );
854 +
454 855 return wp_delete_post( $this->id, true );
455 856 }
456 857
457 858 /**
@@ -492,32 +893,45 @@
492 893 ];
493 894
494 895 $args = wp_parse_args( $args, $defaults );
495 896
496 - if ( $args['meeting'] ) {
497 - // @codingStandardsIgnoreStart
498 - $args['meta_query'] = [
499 - [
500 - 'key' => '_tt_booking_appointment',
501 - 'value' => $args['meeting'],
502 - 'compare' => '=',
503 - ],
897 + $args = apply_filters( 'timetics/admin/bookings/all', $args, $defaults );
898 +
899 + $meta_query = ! empty( $args['meta_query'] ) && is_array( $args['meta_query'] ) ? $args['meta_query'] : [];
900 +
901 + if ( ! empty( $args['start_date'] ) ) {
902 + $meta_query[] = [
903 + 'key' => '_tt_booking_start_date',
904 + 'value' => $args['start_date'],
905 + 'compare' => '>=',
906 + 'type' => 'DATE',
504 907 ];
505 - // @codingStandardsIgnoreEnd
506 908 }
507 909
508 - if ( $args['staff'] ) {
509 - // @codingStandardsIgnoreStart
510 - $args['meta_query'] = [
511 - [
512 - 'key' => '_tt_booking_staff',
513 - 'value' => $args['staff'],
514 - 'compare' => '=',
515 - ],
910 + if ( ! empty( $args['meeting'] ) ) {
911 + $meta_query[] = [
912 + 'key' => '_tt_booking_appointment',
913 + 'value' => $args['meeting'],
914 + 'compare' => '=',
516 915 ];
517 - // @codingStandardsIgnoreEnd
518 916 }
519 917
918 + if ( ! empty( $args['staff'] ) ) {
919 + $meta_query[] = [
920 + 'key' => '_tt_booking_staff',
921 + 'value' => $args['staff'],
922 + 'compare' => '=',
923 + ];
924 + }
925 +
926 + if ( ! empty( $meta_query ) ) {
927 + if ( count( $meta_query ) > 1 && empty( $meta_query['relation'] ) ) {
928 + $meta_query['relation'] = 'AND';
929 + }
930 + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Meta query is necessary for filtering bookings
931 + $args['meta_query'] = $meta_query;
932 + }
933 +
520 934 $post = new WP_Query( $args );
521 935
522 936 return [
523 937 'total' => $post->found_posts,
@@ -535,13 +949,54 @@
535 949 if ( ! timetics_google_setup() ) {
536 950 return;
537 951 }
538 952
953 + if ('timetics-event' == $this->get_type()) {
954 + Event_Booking::instance()->create_online_event($this->get_id());
955 + }else {
956 + $this->create_appointment_event();
957 + }
958 +
959 + }
960 +
961 + public function create_appointment_event() {
962 + // No summary/description passed, so prepare_event() falls back to the
963 + // meeting's own name and description. The booking-created email subject
964 + // and body used to be reused here, which put the notification copy
965 + // ("New meeting scheduled!", greeting, date and time lines) into the
966 + // calendar entry instead of the meeting's details.
539 967 $calendar = new Calendar();
540 968 $event_data = $this->prepare_event();
541 - $event = $calendar->create_event( $event_data );
542 969
543 - update_post_meta( $this->id, $this->meta_prefix . 'calendar_event', $event );
970 + if ( ! $event_data ) {
971 + return;
972 + }
973 +
974 + $entry = $this->find_slot_entry();
975 +
976 + $event = false;
977 +
978 + if ( $entry ) {
979 + $event = $entry->get_google_event();
980 +
981 + if ( ! $event ) {
982 + $event = $calendar->create_event( $event_data );
983 + $entry->update( [
984 + 'google_event' => $event,
985 + ] );
986 + }
987 + }
988 +
989 + if ( $event ) {
990 + update_post_meta( $this->id, $this->meta_prefix . 'calendar_event', $event );
991 +
992 + // Also record the bare event id. Google_Calendar_Sync uses this meta
993 + // to recognise events Timetics itself created, so that they are not
994 + // pulled back in as external events and used to block their own slot.
995 + if ( ! empty( $event['id'] ) ) {
996 + $this->set_google_event_id( $event['id'] );
997 + }
998 + }
544 999 }
545 1000
546 1001 /**
547 1002 * Update calendar event
@@ -552,12 +1007,19 @@
552 1007 if ( ! timetics_google_setup() ) {
553 1008 return;
554 1009 }
555 1010
1011 + // Same as create_appointment_event(): the meeting's name and description
1012 + // belong in the calendar entry, not the reschedule email copy. Keeping
1013 + // them consistent also stops a reschedule from rewriting the title.
556 1014 $calendar = new Calendar();
557 1015 $event_data = $this->prepare_event();
558 1016 $calendar_event = $this->get_event();
559 1017
1018 + if ( ! is_array( $calendar_event ) ) {
1019 + return;
1020 + }
1021 +
560 1022 if ( ! empty( $calendar_event['id'] ) ) {
561 1023 // Update calendar event.
562 1024 $event = $calendar->update_event( $calendar_event['id'], $event_data );
563 1025
@@ -579,8 +1041,16 @@
579 1041 $calendar = new Calendar();
580 1042 $calendar_event = $this->get_event();
581 1043 $access_token = timetics_get_google_access_token( $this->get_staff_id() );
582 1044
1045 + if ( ! is_array( $calendar_event ) ) {
1046 + return;
1047 + }
1048 +
1049 + if ( ! $access_token ) {
1050 + return;
1051 + }
1052 +
583 1053 if ( ! empty( $calendar_event['id'] ) ) {
584 1054 // Update calendar event.
585 1055 $event = $calendar->delete_event( $calendar_event['id'], $access_token );
586 1056
@@ -585,8 +1055,12 @@
585 1055 $event = $calendar->delete_event( $calendar_event['id'], $access_token );
586 1056
587 1057 // update calendar event data.
588 1058 update_post_meta( $this->id, $this->meta_prefix . 'calendar_event', $event );
1059 +
1060 + // The event no longer exists in Google, so drop the id used by
1061 + // Google_Calendar_Sync to skip Timetics-created events.
1062 + $this->set_google_event_id( '' );
589 1063 }
590 1064 }
591 1065
592 1066 /**
@@ -593,36 +1067,312 @@
593 1067 * Prepare event data
594 1068 *
595 1069 * @return array
596 1070 */
597 - private function prepare_event() {
598 - $meeting_id = $this->get_appointment();
599 - $staff_id = $this->get_staff_id();
600 - $customer_id = $this->get_customer_id();
1071 + private function prepare_event( $data = [] ) {
1072 +
1073 + $meeting_id = $this->get_appointment();
1074 + $staff_id = $this->get_staff_id();
1075 + $customer_id = $this->get_customer_id();
601 1076 $location_type = $this->get_location_type();
1077 + $access_token = timetics_get_google_access_token( $staff_id );
602 1078
1079 + if ( ! $access_token ) {
1080 + return false;
1081 + }
1082 +
603 1083 $meeting = new Appointment( $meeting_id );
604 1084 $staff = new Staff( $staff_id );
605 1085 $customer = new Customer( $customer_id );
606 1086
1087 + $time = gmdate( 'h:i a', strtotime( $this->get_start_time() ) );
1088 + $date = gmdate( 'd F Y', strtotime( $this->get_start_date() ) );
1089 + $location = $this->get_location();
1090 +
1091 + $placeholders = [
1092 + '{%meeting_title%}' => $meeting->get_name(),
1093 + '{%meeting_date%}' => $date,
1094 + '{%meeting_time%}' => $time,
1095 + '{%meeting_location%}' => $location,
1096 + '{%meeting_duration%}' => $meeting->get_duration(),
1097 + '{%host_name%}' => $staff->get_display_name(),
1098 + '{%host_email%}' => $staff->get_email(),
1099 + '{%customer_name%}' => $customer->get_display_name(),
1100 + '{%customer_email%}' => $customer->get_email(),
1101 + ];
1102 +
1103 + $summary = ! empty( $data['summary'] ) ? timetics_replace_placeholder( $data['summary'], $placeholders ) : $meeting->get_name();
1104 + $description = ! empty( $data['description'] ) ? timetics_replace_placeholder( $data['description'], $placeholders ) : $meeting->get_description();
1105 +
1106 + $description = apply_filters( 'timetics_booking_event_data', $description, $summary, $this );
1107 +
607 1108 $event = [
608 - 'summary' => $meeting->get_name(),
609 - 'description' => $meeting->get_description(),
610 - 'start' => [
1109 + 'summary' => $summary,
1110 + 'description' => $description,
1111 + 'start' => [
611 1112 'date' => $this->get_start_date(),
612 1113 'time' => $this->get_start_time(),
613 1114 ],
614 - 'end' => [
1115 + 'end' => [
615 1116 'date' => $this->get_end_date(),
616 1117 'time' => $this->get_end_time(),
617 1118 ],
618 - 'attendees' => [
1119 + 'attendees' => [
619 1120 ['email' => $staff->get_email()],
620 1121 ['email' => $customer->get_email()],
621 1122 ],
622 - 'google_meet' => 'google-meet' === $location_type,
623 - 'access_token' => timetics_get_google_access_token( $staff_id ),
1123 + 'timezone' => $this->get_timezone(),
1124 + 'google_meet' => 'google-meet' === $location_type,
1125 + 'access_token' => $access_token,
624 1126 ];
625 1127
626 1128 return $event;
1129 + }
1130 +
1131 + /**
1132 + * Get all data of an object
1133 + *
1134 + * @return array
1135 + */
1136 + public function to_array() {
1137 + $data = [];
1138 +
1139 + foreach ( $this->data as $key => $value ) {
1140 +
1141 + $data[$key] = $this->get_prop( $key );
1142 + }
1143 +
1144 + return $data;
1145 + }
1146 +
1147 + /**
1148 + * Send notification after booking
1149 + *
1150 + * @return void
1151 + */
1152 + private function send_notification( $action ) {
1153 +
1154 + do_action( 'timetics_booking_notification', $this, $action );
1155 + }
1156 +
1157 + /**
1158 + * Get booking ids by appointment id
1159 + *
1160 + * @param array Collection of appointment ids
1161 + *
1162 + * @return array Collection of booking ids
1163 + */
1164 + public static function get_booking_ids_by_appointment( $appointment_ids, $per_page = -1 ) {
1165 + if ( empty( $appointment_ids ) ) {
1166 + return [];
1167 + }
1168 +
1169 + $query = new WP_Query([
1170 + 'post_type' => 'timetics-booking',
1171 + 'post_status' => 'any',
1172 + 'posts_per_page' => $per_page,
1173 + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Meta query is necessary for filtering bookings by appointment
1174 + 'meta_query' => [
1175 + [
1176 + 'key' => '_tt_booking_appointment',
1177 + 'value' => $appointment_ids,
1178 + 'compare' => 'IN',
1179 + ],
1180 + ],
1181 + ]);
1182 +
1183 + return [
1184 + 'total' => $query->found_posts,
1185 + 'items' => $query->posts,
1186 + ];
1187 + }
1188 +
1189 + /**
1190 + * Get booking ids visible to a given user.
1191 + *
1192 + * Union of:
1193 + * - bookings where the user is the assigned staff (`_tt_booking_staff`)
1194 + * - bookings whose appointment is authored by the user
1195 + *
1196 + * @param integer $user_id WP user id.
1197 + *
1198 + * @return int[] Deduped list of booking post ids.
1199 + */
1200 + public static function get_visible_ids_for_user( $user_id ) {
1201 + $user_id = (int) $user_id;
1202 +
1203 + if ( ! $user_id ) {
1204 + return [];
1205 + }
1206 +
1207 + $staff_ids = get_posts( [
1208 + 'post_type' => 'timetics-booking',
1209 + 'post_status' => 'any',
1210 + 'posts_per_page' => -1,
1211 + 'fields' => 'ids',
1212 + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- needed to scope bookings to current staff user
1213 + 'meta_query' => [
1214 + [
1215 + 'key' => '_tt_booking_staff',
1216 + 'value' => $user_id,
1217 + ],
1218 + ],
1219 + ] );
1220 +
1221 + $appointment_ids = Appointment::get_appointment_ids_by_author( $user_id );
1222 +
1223 + $appointment_booking_ids = [];
1224 + if ( ! empty( $appointment_ids ) ) {
1225 + $appointment_booking_ids = get_posts( [
1226 + 'post_type' => 'timetics-booking',
1227 + 'post_status' => 'any',
1228 + 'posts_per_page' => -1,
1229 + 'fields' => 'ids',
1230 + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- needed to scope bookings to appointments owned by current user
1231 + 'meta_query' => [
1232 + [
1233 + 'key' => '_tt_booking_appointment',
1234 + 'value' => $appointment_ids,
1235 + 'compare' => 'IN',
1236 + ],
1237 + ],
1238 + ] );
1239 + }
1240 +
1241 + return array_values( array_unique( array_map( 'intval', array_merge( $staff_ids, $appointment_booking_ids ) ) ) );
1242 + }
1243 +
1244 + /**
1245 + * Get Google Calendar Event ID for this booking
1246 + *
1247 + * @return string
1248 + */
1249 + public function get_google_event_id() {
1250 + return $this->get_metadata( 'google_event_id' );
1251 + }
1252 +
1253 + /**
1254 + * Set Google Calendar Event ID for this booking
1255 + *
1256 + * @param string $event_id
1257 + * @return bool
1258 + */
1259 + public function set_google_event_id( $event_id ) {
1260 + // Written directly rather than through save_metadata(), which takes an
1261 + // array and only accepts keys declared in $this->data — neither is true
1262 + // here, so it silently discarded every write.
1263 + $meta_key = $this->meta_prefix . 'google_event_id';
1264 +
1265 + if ( ! $event_id ) {
1266 + return delete_post_meta( $this->id, $meta_key );
1267 + }
1268 +
1269 + return update_post_meta( $this->id, $meta_key, $event_id );
1270 + }
1271 +
1272 + public function set_sync_status( $status ) {
1273 + $meta_key = $this->meta_prefix . 'google_calendar_sync_status';
1274 +
1275 + if ( ! $status ) {
1276 + return delete_post_meta( $this->id, $meta_key );
1277 + }
1278 +
1279 + return update_post_meta( $this->id, $meta_key, $status );
1280 + }
1281 +
1282 + public function get_sync_status() {
1283 + return $this->get_metadata( 'google_calendar_sync_status' );
1284 + }
1285 + /**
1286 + * Get all Google Event IDs for all bookings
1287 + *
1288 + * @return array
1289 + */
1290 + public function get_all_google_event_ids() {
1291 + $meta_key = $this->meta_prefix . 'google_event_id';
1292 +
1293 + $posts = get_posts(
1294 + array(
1295 + 'post_type' => 'any',
1296 + 'posts_per_page' => -1,
1297 + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Meta query is necessary for filtering posts by meta key
1298 + 'meta_query' => array(
1299 + array(
1300 + 'key' => $meta_key,
1301 + 'value' => '',
1302 + 'compare' => '!=',
1303 + ),
1304 + ),
1305 + 'fields' => 'ids',
1306 + )
1307 + );
1308 +
1309 + if ( empty( $posts ) ) {
1310 + return array();
1311 + }
1312 +
1313 + $event_ids = array();
1314 + foreach ( $posts as $post_id ) {
1315 + $event_id = get_post_meta( $post_id, $meta_key, true );
1316 + if ( ! empty( $event_id ) ) {
1317 + $event_ids[] = $event_id;
1318 + }
1319 + }
1320 +
1321 + return $event_ids;
1322 + }
1323 +
1324 + /**
1325 + * Get name.
1326 + *
1327 + * @since 1.0.0
1328 + *
1329 + * @return string
1330 + */
1331 + public function get_security_token() {
1332 + return $this->get_prop( 'security_token' );
1333 + }
1334 +
1335 + /**
1336 + * Generate and store a secure reschedule token for a booking
1337 + *
1338 + * @param int $booking_id
1339 + * @return string The generated token
1340 + */
1341 + public function generate_security_token() {
1342 + $token = bin2hex(random_bytes(4)); // 8 hex chars
1343 + return $token;
1344 + }
1345 +
1346 + /**
1347 + * Rotate the booking's security token so the previously-issued one cannot
1348 + * be reused (e.g. after a payment is approved).
1349 + *
1350 + * @return void
1351 + */
1352 + public function rotate_security_token() {
1353 + $meta_key = $this->meta_prefix . 'security_token';
1354 + $new_token = $this->generate_security_token();
1355 + update_post_meta( $this->id, $meta_key, $new_token );
1356 + }
1357 +
1358 + /**
1359 + * Get the Stripe PaymentIntent id bound to this booking, if any.
1360 + *
1361 + * @return string
1362 + */
1363 + public function get_stripe_payment_intent_id() {
1364 + return (string) get_post_meta( $this->id, '_tt_stripe_payment_intent_id', true );
1365 + }
1366 +
1367 + /**
1368 + * Bind a Stripe PaymentIntent id to this booking. Used for replay
1369 + * protection: a second make_payment call with a different intent will be
1370 + * rejected.
1371 + *
1372 + * @param string $intent_id
1373 + * @return void
1374 + */
1375 + public function set_stripe_payment_intent_id( $intent_id ) {
1376 + update_post_meta( $this->id, '_tt_stripe_payment_intent_id', sanitize_text_field( (string) $intent_id ) );
627 1377 }
628 1378 }