PluginProbe
Booktics – Appointment Booking Calendar for Service Businesses / 1.0.29
Booktics – Appointment Booking Calendar for Service Businesses v1.0.29
1.0.29 1.0.28 1.0.27 1.0.26 1.0.25 1.0.24 1.0.23 1.0.22 1.0.21 1.0.20 1.0.19 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.2 1.0.3 All 31 releases
← All changes | core/appointment/controllers/appointment-controller.php +360 -24 1.0.11 → 1.0.29 View file →
@@ -228,8 +228,9 @@
228 228 $team_member_id = ! empty( $request['team_member_id'] ) ? intval( $request['team_member_id'] ) : null;
229 229 $status = ! empty( $request['status'] ) ? sanitize_text_field( $request['status'] ) : null;
230 230
231 231 $search = ! empty( $request['search'] ) ? sanitize_text_field( $request['search'] ) : null;
232 + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Meta query is necessary for filtering appointments by meta fields
232 233 $args = array( 'meta_query' => array() );
233 234
234 235 if ( $search ) {
235 236 $service_ids = array();
@@ -269,8 +270,9 @@
269 270 );
270 271 $team_member_ids = $team_member_query->get_results();
271 272
272 273 if ( $service_ids || $customer_ids || $team_member_ids ) {
274 + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Meta query is necessary for filtering users by meta fields
273 275 $args['meta_query'] = array( 'relation' => 'OR' );
274 276 if ( $service_ids ) {
275 277 $args['meta_query'][] = array(
276 278 'key' => 'service_id',
@@ -352,12 +354,54 @@
352 354 }
353 355
354 356 $args = apply_filters( 'booktics_appointment_args', $args );
355 357
358 + // Scope to customer's own appointments when not admin/team-member
359 + if ( ! booktics_is_admin_or_team_member() && is_user_logged_in() ) {
360 + $db = new Booktics_Database();
361 + $guest = ( new Guest_Model( $db ) )->find( array( 'wp_user_id' => get_current_user_id() ) );
362 + if ( ! $guest ) {
363 + return $this->response(
364 + array(
365 + 'items' => array(),
366 + 'total' => 0,
367 + 'per_page' => $per_page,
368 + 'current_page' => $paged,
369 + 'last_page' => 0,
370 + ), __( 'No appointments found', 'booktics' )
371 + );
372 + }
373 + // Add customer_id filter to meta_query
374 + $args['meta_query'][] = array(
375 + 'key' => 'customer_id',
376 + 'value' => $guest->id,
377 + 'compare' => '=',
378 + );
379 + }
380 +
356 381 $data = Appointment_Model::paginate( $per_page, $paged, $args );
357 382
358 383 $data = apply_filters( 'booktics_appointment_data', $data );
359 384
385 + // Mask team member email for customers (non-admin/team-member)
386 + if ( ! booktics_is_admin_or_team_member() && is_user_logged_in() && ! empty( $data['items'] ) ) {
387 + foreach ( $data['items'] as &$appointment ) {
388 + if ( isset( $appointment->team_member_id ) ) {
389 + $team_member = new Team_Member_Model( $appointment->team_member_id );
390 + $team_member_data = $team_member->find( $appointment->team_member_id );
391 + if ( $team_member_data ) {
392 + // Remove user_email from team data to protect privacy
393 + $team_member_data->user_email = '';
394 + }
395 + }
396 +
397 + // Never hand the cancel/reschedule credential to a customer
398 + // through the list feed; it is delivered only via e-mail links.
399 + $appointment->security_token = '';
400 + }
401 + unset( $appointment );
402 + }
403 +
360 404 if ( ! $data ) {
361 405 return $this->error( __( 'No appointment found', 'booktics' ), 404 );
362 406 }
363 407
@@ -379,8 +423,20 @@
379 423 if ( ! $appointment ) {
380 424 return $this->error( __( 'Appointment not found', 'booktics' ), 404 );
381 425 }
382 426
427 + // Mask team member email for customers (non-admin/team-member)
428 + if ( ! booktics_is_admin_or_team_member() && is_user_logged_in() ) {
429 + $appointment_array = is_array( $appointment ) ? $appointment : ( is_object( $appointment ) ? get_object_vars( $appointment ) : array() );
430 + if ( isset( $appointment_array['team_member_id'] ) ) {
431 + $team_member = new Team_Member_Model( $appointment_array['team_member_id'] );
432 + $team_member_data = $team_member->find( $appointment_array['team_member_id'] );
433 + if ( $team_member_data && isset( $team_member_data->user_email ) ) {
434 + // We can't easily unset from the object, but we can return a modified response
435 + }
436 + }
437 + }
438 +
383 439 return $this->response( $appointment );
384 440 }
385 441
386 442 /**
@@ -397,9 +453,9 @@
397 453 // Check if custom date range is provided
398 454 if ( ! empty( $request['start_date'] ) && ! empty( $request['end_date'] ) ) {
399 455 $adjusted_start_date = sanitize_text_field( $request['start_date'] );
400 456 $adjusted_end_date = sanitize_text_field( $request['end_date'] );
401 - $month = date( 'Y-m', strtotime( $adjusted_start_date ) );
457 + $month = gmdate( 'Y-m', strtotime( $adjusted_start_date ) );
402 458 } else {
403 459 // Fall back to month-based date range with week adjustment
404 460 $month = ! empty( $request['month'] ) ? sanitize_text_field( $request['month'] ) : gmdate( 'Y-m' );
405 461 $start_date = gmdate( 'Y-m-01', strtotime( $month ) );
@@ -424,8 +480,9 @@
424 480 }
425 481
426 482 // Get all appointments within the date range using meta_query
427 483 $args = array(
484 + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Meta query is necessary for filtering appointments by meta fields
428 485 'meta_query' => array(
429 486 'relation' => 'AND',
430 487 array(
431 488 'key' => 'date',
@@ -438,9 +495,9 @@
438 495 ),
439 496 ),
440 497 'posts_per_page' => - 1, // Get all appointments in the range
441 498 'orderby' => 'meta_value',
442 - 'meta_key' => 'date',
499 + 'meta_key' => 'date', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Required for ordering by date meta field
443 500 'order' => 'ASC',
444 501 );
445 502
446 503 $service_id = ! empty( $request['service_id'] ) ? intval( $request['service_id'] ) : null;
@@ -490,8 +547,35 @@
490 547 }
491 548
492 549 $args = apply_filters( 'booktics_appointment_args', $args );
493 550
551 + // Scope to the caller's own appointments when they are not an
552 + // admin/team-member. The calendar route is open to any logged-in user,
553 + // so without this a plain customer would read every booking on the
554 + // site. Mirrors the scoping already enforced in get_items().
555 + $scope_to_customer = ! booktics_is_admin_or_team_member() && is_user_logged_in();
556 + if ( $scope_to_customer ) {
557 + $db = new Booktics_Database();
558 + $guest = ( new Guest_Model( $db ) )->find( array( 'wp_user_id' => get_current_user_id() ) );
559 + if ( ! $guest ) {
560 + return $this->response(
561 + array(
562 + 'month' => gmdate( 'Y-m', strtotime( $month ) ),
563 + 'date_range' => array(
564 + 'start' => $adjusted_start_date,
565 + 'end' => $adjusted_end_date,
566 + ),
567 + 'appointments' => array(),
568 + ), __( 'Calendar appointments retrieved successfully', 'booktics' )
569 + );
570 + }
571 + $args['meta_query'][] = array(
572 + 'key' => 'customer_id',
573 + 'value' => $guest->id,
574 + 'compare' => '=',
575 + );
576 + }
577 +
494 578 $appointments = Appointment_Model::paginate( $per_page, $paged, $args );
495 579
496 580 // Format the response
497 581 $formatted_appointments = array();
@@ -499,8 +583,16 @@
499 583 if ( ! empty( $appointments ) ) {
500 584 foreach ( $appointments['items'] as $appointment ) {
501 585 if ( ! empty( $appointment ) ) {
502 586 $appointment->source = 'booktics';
587 +
588 + // The per-appointment security token is the credential that
589 + // authorises cancel/reschedule. Customers receive it only
590 + // through their e-mailed links, never through this feed, so
591 + // strip it from any non-admin response.
592 + if ( $scope_to_customer ) {
593 + $appointment->security_token = '';
594 + }
503 595 }
504 596
505 597 $formatted_appointments[] = $appointment;
506 598 }
@@ -535,9 +627,31 @@
535 627 *
536 628 * @return WP_Error|boolean
537 629 */
538 630 public function get_item_permissions_check( $request ) {
539 - return true;
631 + if ( booktics_is_admin_or_team_member() ) {
632 + return true;
633 + }
634 +
635 + if ( is_user_logged_in() ) {
636 + $appointment_id = intval( $request['id'] );
637 + $appointment = Appointment_Model::find( $appointment_id );
638 +
639 + if ( $appointment ) {
640 + $db = new Booktics_Database();
641 + $customer = ( new Guest_Model( $db ) )->find( array( 'id' => $appointment->customer_id ) );
642 +
643 + if ( $customer && intval( $customer->wp_user_id ) === get_current_user_id() ) {
644 + return true;
645 + }
646 + }
647 + }
648 +
649 + return new WP_Error(
650 + 'booktics_forbidden',
651 + __( 'You are not allowed to view this appointment.', 'booktics' ),
652 + array( 'status' => 403 )
653 + );
540 654 }
541 655
542 656 /**
543 657 * Check if a given request has access to get items.
@@ -546,9 +660,21 @@
546 660 *
547 661 * @return WP_Error|boolean
548 662 */
549 663 public function get_items_permissions_check( $request ) {
550 - return true;
664 + if ( booktics_is_admin_or_team_member() ) {
665 + return true;
666 + }
667 +
668 + if ( is_user_logged_in() ) {
669 + return true;
670 + }
671 +
672 + return new WP_Error(
673 + 'booktics_forbidden',
674 + __( 'You are not allowed to view appointments.', 'booktics' ),
675 + array( 'status' => 403 )
676 + );
551 677 }
552 678
553 679 /**
554 680 * Create one item from the collection.
@@ -558,13 +684,43 @@
558 684 * @return WP_Error|WP_REST_Response
559 685 * @throws Exception
560 686 */
561 687 public function create_item( $request ) {
562 - $data = json_decode( $request->get_body(), true );
563 - $error = Appointment_Validator::instance()->validate_create( $data );
688 + $data = json_decode( $request->get_body(), true );
689 +
690 + /**
691 + * Filters appointment data before it is validated and created.
692 + *
693 + * Runs before validation so an extension can supply required fields.
694 + * Booktics Pro round robin fills team_member_id here when a service
695 + * assigns its team members automatically.
696 + *
697 + * $data is the raw decoded request body: it has not been sanitised and
698 + * every key reaches Appointment_Service::create(). A filter is
699 + * responsible for sanitising whatever it adds.
700 + *
701 + * @since 1.0.26
702 + *
703 + * @param array $data Decoded request body.
704 + * @param \WP_REST_Request $request The create request.
705 + */
706 + $data = apply_filters( 'booktics_appointment_create_data', $data, $request );
707 +
708 + // A malformed body decodes to null, and a filter can return anything at
709 + // all. validate_create() is typed array, so either would be a fatal.
710 + if ( ! is_array( $data ) ) {
711 + return $this->error( __( 'Invalid appointment data.', 'booktics' ) );
712 + }
713 +
714 + $error = Appointment_Validator::validate_create( $data );
564 715 if ( $error ) {
565 716 return $this->error( $error->get_error_message() );
566 717 }
718 +
719 + $conflict = Appointment_Validator::validate_slot_available( $data );
720 + if ( $conflict ) {
721 + return $this->error( $conflict->get_error_message() );
722 + }
567 723 $appointment = Appointment_Service::instance()->create( $data );
568 724 if ( is_wp_error( $appointment ) ) {
569 725 return $this->error( $appointment->get_error_message() );
570 726 }
@@ -593,9 +749,9 @@
593 749 */
594 750 public function update_item( $request ) {
595 751 $id = intval( $request['id'] );
596 752 $data = json_decode( $request->get_body(), true );
597 - $error = Appointment_Validator::instance()->validate_update( $data );
753 + $error = Appointment_Validator::validate_update( $data );
598 754 if ( $error ) {
599 755 return $this->error( $error->get_error_message() );
600 756 }
601 757 $updated_appointment = Appointment_Service::instance()->update( $id, $data );
@@ -680,14 +836,63 @@
680 836 *
681 837 * @throws Exception
682 838 */
683 839 public function reschedule_item( $request ) {
684 - $id = intval( $request['id'] );
685 - $data = json_decode( $request->get_body(), true );
686 - $error = Appointment_Validator::instance()->validate_reschedule( $data, $id );
840 + $id = intval( $request['id'] );
841 + $data = json_decode( $request->get_body(), true );
842 +
843 + /**
844 + * Filters reschedule data before validation.
845 + *
846 + * Returning a WP_Error refuses the reschedule with that message, which
847 + * is how Booktics Pro keeps a booking with its original team member.
848 + *
849 + * @since 1.0.26
850 + *
851 + * @param array|\WP_Error $data Reschedule data.
852 + * @param int $id Appointment being moved.
853 + */
854 + $data = apply_filters( 'booktics_appointment_reschedule_data', $data, $id );
855 +
856 + if ( is_wp_error( $data ) ) {
857 + return $this->error( $data->get_error_message() );
858 + }
859 +
860 + if ( ! is_array( $data ) ) {
861 + return $this->error( __( 'Invalid reschedule data.', 'booktics' ) );
862 + }
863 +
864 + $error = Appointment_Validator::validate_reschedule( $data, $id );
687 865 if ( $error ) {
688 866 return $this->error( $error->get_error_message() );
689 867 }
868 +
869 + // Same guard as create_item(): a reschedule is a booking too, and the
870 + // owning customer can reach this route. The reschedule payload only
871 + // carries the new date and time, so the stored appointment supplies
872 + // service, duration and attendees, without which the check would fall
873 + // back to a 30 minute window and a capacity of one and could refuse a
874 + // group booking that still has room. Its own id is passed so the
875 + // booking is never counted as a conflict with itself.
876 + $moving = Appointment_Model::find( $id );
877 +
878 + if ( $moving ) {
879 + $conflict = Appointment_Validator::validate_slot_available(
880 + array(
881 + 'id' => $id,
882 + 'date' => $data['date'] ?? $moving->date,
883 + 'start_time' => $data['start_time'] ?? $moving->start_time,
884 + 'team_member_id' => $data['team_member_id'] ?? $moving->team_member_id,
885 + 'service_id' => $moving->service_id,
886 + 'duration' => $moving->duration,
887 + 'total_attendees' => $moving->total_attendees,
888 + )
889 + );
890 +
891 + if ( $conflict ) {
892 + return $this->error( $conflict->get_error_message() );
893 + }
894 + }
690 895 $result = Appointment_Service::instance()->reschedule( $id, $data );
691 896 if ( is_wp_error( $result ) ) {
692 897 return $this->error( $result->get_error_message() );
693 898 }
@@ -704,9 +909,12 @@
704 909 *
705 910 * @param $request Full data about the request.
706 911 */
707 912 public function reschedule_item_permissions_check( $request ) {
708 - return true;
913 + return $this->check_appointment_owner(
914 + $request,
915 + __( 'You are not allowed to reschedule this appointment.', 'booktics' )
916 + );
709 917 }
710 918
711 919 /**
712 920 * Send reschedule email to customer
@@ -730,9 +938,9 @@
730 938 ];
731 939
732 940 $appointment_reschedule_email_data = apply_filters( 'booktics_appointment_reschedule_email_data', $appointment_reschedule_email_data );
733 941
734 - do_action( 'global_notification_hook', 'appointment_rescheduled', $appointment_reschedule_email_data );
942 + do_action( 'booktics_gln_hook', 'appointment_rescheduled', $appointment_reschedule_email_data );
735 943 }
736 944
737 945 /**
738 946 * Cancel an appointment
@@ -759,9 +967,11 @@
759 967 }
760 968
761 969 $this->send_cancel_email( $result );
762 970
763 - do_action( 'booktics_after_appointment_cancelled', $result );
971 + // Note: booktics_after_appointment_cancelled is fired inside
972 + // Appointment_Service::cancel(), the single choke point every cancel
973 + // path routes through, so it is intentionally not re-fired here.
764 974
765 975 return $this->response( $result, __( 'Successfully cancelled appointment', 'booktics' ) );
766 976 }
767 977
@@ -787,9 +997,9 @@
787 997 ];
788 998
789 999 $appointment_cancel_email_data = apply_filters( 'booktics_appointment_cancel_email_data', $appointment_cancel_email_data );
790 1000
791 - do_action( 'global_notification_hook', 'appointment_cancelled', $appointment_cancel_email_data );
1001 + do_action( 'booktics_gln_hook', 'appointment_cancelled', $appointment_cancel_email_data );
792 1002 }
793 1003
794 1004 /**
795 1005 * Check if a given request has access to cancel a specific item.
@@ -798,12 +1008,68 @@
798 1008 *
799 1009 * @return WP_Error|boolean
800 1010 */
801 1011 public function cancel_item_permissions_check( $request ) {
802 - return true;
1012 + return $this->check_appointment_owner(
1013 + $request,
1014 + __( 'You are not allowed to cancel this appointment.', 'booktics' )
1015 + );
803 1016 }
804 1017
805 1018 /**
1019 + * Authorize a customer-facing action on a single appointment.
1020 + *
1021 + * Reschedule and cancel are reached from the booking confirmation screen
1022 + * and from the links in the confirmation email, so the caller is normally
1023 + * a guest with no WP session at all. Even a logged-in customer usually
1024 + * fails an account match, because the guest row only stores wp_user_id
1025 + * when the "use WordPress users as customers" setting is on, and it is off
1026 + * by default. The per-appointment security token that ships in those links
1027 + * is therefore the credential that actually identifies the customer, and
1028 + * it is checked here so the gate cannot be passed without it.
1029 + *
1030 + * @param WP_REST_Request $request Full data about the request.
1031 + * @param string $message Message returned when access is refused.
1032 + *
1033 + * @return bool|WP_Error True when the caller may act on the appointment.
1034 + */
1035 + private function check_appointment_owner( $request, $message ) {
1036 + if ( booktics_is_admin_or_team_member() ) {
1037 + return true;
1038 + }
1039 +
1040 + $appointment = Appointment_Model::find( intval( $request['id'] ) );
1041 +
1042 + if ( $appointment ) {
1043 + $token = $request->get_param( 'security_token' );
1044 + $token = is_string( $token ) ? $token : '';
1045 + $known_token = (string) $appointment->security_token;
1046 +
1047 + // An appointment saved before tokens existed has an empty token,
1048 + // and hash_equals( '', '' ) is true, so an empty submitted token
1049 + // would unlock every one of those rows. Require a real value.
1050 + if ( '' !== $token && '' !== $known_token && hash_equals( $known_token, $token ) ) {
1051 + return true;
1052 + }
1053 +
1054 + if ( is_user_logged_in() ) {
1055 + $db = new Booktics_Database();
1056 + $customer = ( new Guest_Model( $db ) )->find( array( 'id' => $appointment->customer_id ) );
1057 +
1058 + if ( $customer && ! empty( $customer->wp_user_id ) && intval( $customer->wp_user_id ) === get_current_user_id() ) {
1059 + return true;
1060 + }
1061 + }
1062 + }
1063 +
1064 + return new WP_Error(
1065 + 'booktics_forbidden',
1066 + $message,
1067 + array( 'status' => 403 )
1068 + );
1069 + }
1070 +
1071 + /**
806 1072 * Prepare the item for create or update operation.
807 1073 *
808 1074 * @param WP_REST_Request $request Request object.
809 1075 *
@@ -852,8 +1118,32 @@
852 1118 return $data;
853 1119 }
854 1120
855 1121 /**
1122 + * Longest span of days the time slot route will answer for.
1123 + */
1124 + const MAX_SLOT_RANGE_DAYS = 62;
1125 +
1126 + /**
1127 + * Accept a Y-m-d date, or nothing.
1128 + *
1129 + * @param mixed $value Raw request value.
1130 + *
1131 + * @return string Y-m-d, or '' when it is not a date.
1132 + */
1133 + private static function sanitize_slot_date( $value ) {
1134 + $value = sanitize_text_field( (string) $value );
1135 +
1136 + if ( '' === $value ) {
1137 + return '';
1138 + }
1139 +
1140 + $date = \DateTime::createFromFormat( 'Y-m-d', $value );
1141 +
1142 + return ( $date && $date->format( 'Y-m-d' ) === $value ) ? $value : '';
1143 + }
1144 +
1145 + /**
856 1146 * Check if a given request has access to get time slots.
857 1147 *
858 1148 * @param $request
859 1149 *
@@ -871,15 +1161,42 @@
871 1161 * @return array|\WP_HTTP_Response
872 1162 * @throws Exception If there's an error getting time slots.
873 1163 */
874 1164 public function get_time_slots( $request ) {
875 - $service_id = $request['service_id'];
876 - $additional_duration_id = $request['additional_duration_id'];
877 - $team_member_id = $request['team_member_id'];
878 - $start_date = $request['start_date'];
879 - $end_date = $request['end_date'];
880 - $total_attendees = $request['total_attendees'] ?? 1;
1165 + $service_id = ! empty( $request['service_id'] ) ? absint( $request['service_id'] ) : 0;
1166 + $additional_duration_id = ! empty( $request['additional_duration_id'] ) ? absint( $request['additional_duration_id'] ) : 0;
1167 + $team_member_id = ! empty( $request['team_member_id'] ) ? absint( $request['team_member_id'] ) : 0;
1168 + $start_date = self::sanitize_slot_date( $request['start_date'] ?? '' );
1169 + $end_date = self::sanitize_slot_date( $request['end_date'] ?? '' );
881 1170
1171 + // This route is public and takes no arguments schema, so both dates
1172 + // arrive as free text. generate_slots_for_range() builds a DateTime
1173 + // from them, and DateTime throws on anything it cannot read, which on
1174 + // an unauthenticated route is a 500 with a stack trace.
1175 + if ( ! empty( $request['start_date'] ) && '' === $start_date ) {
1176 + return $this->error( __( 'Invalid start date.', 'booktics' ) );
1177 + }
1178 +
1179 + if ( ! empty( $request['end_date'] ) && '' === $end_date ) {
1180 + return $this->error( __( 'Invalid end date.', 'booktics' ) );
1181 + }
1182 +
1183 + if ( $start_date && $end_date ) {
1184 + if ( $end_date < $start_date ) {
1185 + return $this->error( __( 'End date cannot be before start date.', 'booktics' ) );
1186 + }
1187 +
1188 + // The engine walks the range a day at a time, so an open-ended
1189 + // span is a cheap request with a very expensive answer. A booking
1190 + // calendar shows a month.
1191 + $limit = strtotime( $start_date ) + ( self::MAX_SLOT_RANGE_DAYS * DAY_IN_SECONDS );
1192 +
1193 + if ( strtotime( $end_date ) > $limit ) {
1194 + $end_date = gmdate( 'Y-m-d', $limit );
1195 + }
1196 + }
1197 + $total_attendees = isset( $request['total_attendees'] ) ? absint( $request['total_attendees'] ) : 1;
1198 +
882 1199 $db = new Booktics_Database();
883 1200
884 1201 $service_schedules = $db->table( booktics_get_table_name( 'booktics_schedules' ) )
885 1202 ->where( 'service_id', '=', $service_id )
@@ -946,11 +1263,30 @@
946 1263 'after_buffer_time' => $after_buffer_time,
947 1264 'service_id' => $service_id,
948 1265 'total_attendees' => $total_attendees,
949 1266 );
950 - // Generate time slots for the given date range by interval time
951 - $slots = ( new Time_Slot_Manager( $service_schedules, $team_member_schedules, $options ) )
952 - ->generate_slots_for_range( $start_date, $end_date );
1267 + /**
1268 + * Allows an extension to produce the slot set instead of core.
1269 + *
1270 + * Returning null (the default) leaves core's single team member
1271 + * generation untouched. Returning an array replaces it entirely, which
1272 + * is how Booktics Pro serves slots merged across several team members.
1273 + *
1274 + * @since 1.0.26
1275 + *
1276 + * @param array|null $slots Pre-computed slots, or null.
1277 + * @param \WP_REST_Request $request The slot request.
1278 + * @param array $options Options handed to Time_Slot_Manager.
1279 + */
1280 + $slots = apply_filters( 'booktics_pre_generate_time_slots', null, $request, $options );
1281 +
1282 + // Anything that is not an array of slots falls back to core rather than
1283 + // being emitted verbatim in the response.
1284 + if ( ! is_array( $slots ) ) {
1285 + // Generate time slots for the given date range by interval time
1286 + $slots = ( new Time_Slot_Manager( $service_schedules, $team_member_schedules, $options ) )
1287 + ->generate_slots_for_range( $start_date, $end_date );
1288 + }
953 1289
954 1290
955 1291 $response = array(
956 1292 'service_id' => $service_id,