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 +595 -79 1.0.16 → 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,8 +49,15 @@
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 */
@@ -51,33 +62,38 @@
51 62 * @var array
52 63 */
53 64 protected $data = [
54 65
55 - 'post_status' => '',
56 - 'customer' => '',
57 - 'appointment' => '',
58 - 'appointment_name' => '',
59 - 'staff' => '',
60 - 'location' => '',
61 - 'description' => '',
62 - 'location_type' => '',
63 - 'payment_method' => '',
64 - 'payment_status' => '',
65 - 'payment_details' => '',
66 - 'order_total' => '',
67 - 'start_date' => '',
68 - 'end_date' => '',
69 - 'start_time' => '',
70 - 'end_time' => '',
71 - 'calendar_event' => '',
72 - 'random_id' => '',
73 - 'seats' => '',
74 - 'guests' => '',
75 - 'zoom' => '',
76 - 'custom_form_data' => '',
77 - 'timezone' => '',
78 - 'recurrence' => [],
79 - 'parent' => 0,
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' => ''
80 96 ];
81 97
82 98 /**
83 99 * Booking Constructor
@@ -112,8 +128,28 @@
112 128 return $this->get_prop( 'customer' );
113 129 }
114 130
115 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 + /**
116 152 * Get staff for the current booking
117 153 *
118 154 * @return integer
119 155 */
@@ -121,8 +157,35 @@
121 157 return $this->get_prop( 'staff' );
122 158 }
123 159
124 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 + /**
125 188 * Get customer
126 189 *
127 190 * @return User Object
128 191 */
@@ -184,8 +247,17 @@
184 247 return $this->get_prop( 'location_type' );
185 248 }
186 249
187 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 + /**
188 260 * Get last name
189 261 *
190 262 * @return string
191 263 */
@@ -285,8 +357,17 @@
285 357 return $this->get_prop( 'payment_method' );
286 358 }
287 359
288 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 + /**
289 370 * Get appointment
290 371 *
291 372 * @return Appointment
292 373 */
@@ -419,8 +500,17 @@
419 500 return $this->get_prop( 'parent' );
420 501 }
421 502
422 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 + /**
423 513 * Get booking data
424 514 *
425 515 * @param string $prop
426 516 *
@@ -448,9 +538,9 @@
448 538 *
449 539 * @return string
450 540 */
451 541 private function generate_randon_id() {
452 - 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 );
453 543 }
454 544
455 545 // Setter.
456 546
@@ -474,8 +564,12 @@
474 564 */
475 565 public function set_props( $args = [] ) {
476 566 $this->data = wp_parse_args( $args, $this->data );
477 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 +
478 572 }
479 573
480 574 /**
481 575 * Save metada for booking
@@ -506,9 +600,9 @@
506 600 * Save appointment
507 601 *
508 602 * @return void
509 603 */
510 - public function save() {
604 + public function save( $type = '' ) {
511 605 $args = [
512 606 'post_title' => $this->appointment->get_name(),
513 607 'post_type' => $this->pos_type,
514 608 'post_status' => $this->data['post_status'],
@@ -514,8 +608,17 @@
514 608 'post_status' => $this->data['post_status'],
515 609 'post_author' => get_current_user_id(),
516 610 ];
517 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 +
518 621 $updated = false;
519 622
520 623 if ( ! empty( $this->id ) ) {
521 624 $this->send_notification( 'update' );
@@ -525,8 +628,9 @@
525 628
526 629 // Insert or Update appointment.
527 630 $booking_id = wp_insert_post( $args );
528 631 $this->data['random_id'] = $this->generate_randon_id();
632 + $this->data = apply_filters( 'timetics/event/booking/all_data', $this->data);
529 633
530 634 if ( ! is_wp_error( $booking_id ) ) {
531 635 $this->set_id( $booking_id );
532 636 $this->save_metadata( $this->data );
@@ -557,8 +661,190 @@
557 661 return $updated;
558 662 }
559 663
560 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 + /**
561 847 * Delete booking
562 848 *
563 849 * @return bool | WP_Error
564 850 */
@@ -607,44 +893,45 @@
607 893 ];
608 894
609 895 $args = wp_parse_args( $args, $defaults );
610 896
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 +
611 901 if ( ! empty( $args['start_date'] ) ) {
612 - //_tt_booking_start_date
613 - $args['meta_query'] = [
614 - [
615 - 'key' => '_tt_booking_start_date',
616 - 'value' => $args['start_date'],
617 - 'compare' => '>=',
618 - 'type' => 'DATE',
619 - ],
902 + $meta_query[] = [
903 + 'key' => '_tt_booking_start_date',
904 + 'value' => $args['start_date'],
905 + 'compare' => '>=',
906 + 'type' => 'DATE',
620 907 ];
621 908 }
622 909
623 910 if ( ! empty( $args['meeting'] ) ) {
624 - // @codingStandardsIgnoreStart
625 - $args['meta_query'] = [
626 - [
627 - 'key' => '_tt_booking_appointment',
628 - 'value' => $args['meeting'],
629 - 'compare' => '=',
630 - ],
911 + $meta_query[] = [
912 + 'key' => '_tt_booking_appointment',
913 + 'value' => $args['meeting'],
914 + 'compare' => '=',
631 915 ];
632 - // @codingStandardsIgnoreEnd
633 916 }
634 917
635 918 if ( ! empty( $args['staff'] ) ) {
636 - // @codingStandardsIgnoreStart
637 - $args['meta_query'] = [
638 - [
639 - 'key' => '_tt_booking_staff',
640 - 'value' => $args['staff'],
641 - 'compare' => '=',
642 - ],
919 + $meta_query[] = [
920 + 'key' => '_tt_booking_staff',
921 + 'value' => $args['staff'],
922 + 'compare' => '=',
643 923 ];
644 - // @codingStandardsIgnoreEnd
645 924 }
646 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 +
647 934 $post = new WP_Query( $args );
648 935
649 936 return [
650 937 'total' => $post->found_posts,
@@ -662,37 +949,34 @@
662 949 if ( ! timetics_google_setup() ) {
663 950 return;
664 951 }
665 952
666 - $data = [
667 - 'summary' => timetics_get_option( 'booking_created_customer_email_title' ),
668 - 'description' => timetics_get_option( 'booking_created_customer_email_body' ),
669 - ];
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 + }
670 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.
671 967 $calendar = new Calendar();
672 - $event_data = $this->prepare_event( $data );
968 + $event_data = $this->prepare_event();
673 969
674 970 if ( ! $event_data ) {
675 971 return;
676 972 }
677 973
678 - $booking_entry = new Booking_Entry();
679 - $meeting = new Appointment( $this->get_appointment() );
974 + $entry = $this->find_slot_entry();
680 975
681 - $date_time = timetics_convert_timezone( $this->get_start_date() .' '. $this->get_start_time(), $this->get_timezone(), $meeting->get_timezone() );
976 + $event = false;
682 977
683 - $entries = $booking_entry->find(
684 - [
685 - 'staff_id' => $this->get_staff_id(),
686 - 'meeting_id' => $this->get_appointment(),
687 - 'date' => $date_time->format('Y-m-d'),
688 - 'start' => $date_time->format('h:i a'),
689 - ]
690 - );
691 -
692 - if ( $entries ) {
693 - $entry = $booking_entry->first();
694 -
978 + if ( $entry ) {
695 979 $event = $entry->get_google_event();
696 980
697 981 if ( ! $event ) {
698 982 $event = $calendar->create_event( $event_data );
@@ -703,8 +987,15 @@
703 987 }
704 988
705 989 if ( $event ) {
706 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 + }
707 998 }
708 999 }
709 1000
710 1001 /**
@@ -716,15 +1007,13 @@
716 1007 if ( ! timetics_google_setup() ) {
717 1008 return;
718 1009 }
719 1010
720 - $data = [
721 - 'summary' => timetics_get_option( 'booking_rescheduled_customer_email_title' ),
722 - 'description' => timetics_get_option( 'booking_rescheduled_customer_email_body' ),
723 - ];
724 -
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.
725 1014 $calendar = new Calendar();
726 - $event_data = $this->prepare_event( $data );
1015 + $event_data = $this->prepare_event();
727 1016 $calendar_event = $this->get_event();
728 1017
729 1018 if ( ! is_array( $calendar_event ) ) {
730 1019 return;
@@ -766,8 +1055,12 @@
766 1055 $event = $calendar->delete_event( $calendar_event['id'], $access_token );
767 1056
768 1057 // update calendar event data.
769 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( '' );
770 1063 }
771 1064 }
772 1065
773 1066 /**
@@ -795,13 +1088,13 @@
795 1088 $date = gmdate( 'd F Y', strtotime( $this->get_start_date() ) );
796 1089 $location = $this->get_location();
797 1090
798 1091 $placeholders = [
799 - '{%meeting_title%}' => $meeting->name,
1092 + '{%meeting_title%}' => $meeting->get_name(),
800 1093 '{%meeting_date%}' => $date,
801 1094 '{%meeting_time%}' => $time,
802 1095 '{%meeting_location%}' => $location,
803 - '{%meeting_duration%}' => $meeting->duration,
1096 + '{%meeting_duration%}' => $meeting->get_duration(),
804 1097 '{%host_name%}' => $staff->get_display_name(),
805 1098 '{%host_email%}' => $staff->get_email(),
806 1099 '{%customer_name%}' => $customer->get_display_name(),
807 1100 '{%customer_email%}' => $customer->get_email(),
@@ -826,8 +1119,9 @@
826 1119 'attendees' => [
827 1120 ['email' => $staff->get_email()],
828 1121 ['email' => $customer->get_email()],
829 1122 ],
1123 + 'timezone' => $this->get_timezone(),
830 1124 'google_meet' => 'google-meet' === $location_type,
831 1125 'access_token' => $access_token,
832 1126 ];
833 1127
@@ -857,6 +1151,228 @@
857 1151 */
858 1152 private function send_notification( $action ) {
859 1153
860 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 ) );
861 1377 }
862 1378 }