PluginProbe
Booktics – Appointment Booking Calendar for Service Businesses / 1.0.27
Booktics – Appointment Booking Calendar for Service Businesses v1.0.27
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 +527 -97 1.0.2 → 1.0.27 View file →
@@ -11,8 +11,10 @@
11 11 use Booktics\Appointment\Services\Appointment_Service;
12 12 use Booktics\Appointment\Services\Appointment_Validator;
13 13 use Booktics\Models\Appointment_Model;
14 14 use Booktics\Models\Service_Model;
15 +use Booktics\Models\Team_Member_Model;
16 +use Booktics\Models\Guest_Model;
15 17 use Exception;
16 18 use WP_Error;
17 19 use WP_HTTP_Response;
18 20 use WP_REST_Request;
@@ -187,13 +189,13 @@
187 189 array(
188 190 'methods' => WP_REST_Server::READABLE,
189 191 'callback' => array(
190 192 $this,
191 - 'get_all_calendar_items'
193 + 'get_all_calendar_items',
192 194 ),
193 195 'permission_callback' => array(
194 196 $this,
195 - 'get_items_permissions_check'
197 + 'get_items_permissions_check',
196 198 ),
197 199 'args' => array(
198 200 'month' => array(
199 201 'description' => __( 'Month in YYYY-MM format', 'booktics' ),
@@ -226,71 +228,83 @@
226 228 $team_member_id = ! empty( $request['team_member_id'] ) ? intval( $request['team_member_id'] ) : null;
227 229 $status = ! empty( $request['status'] ) ? sanitize_text_field( $request['status'] ) : null;
228 230
229 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
230 233 $args = array( 'meta_query' => array() );
231 234
232 235 if ( $search ) {
233 - $service_ids = [];
234 - $customer_ids = [];
235 - $team_member_ids = [];
236 + $service_ids = array();
237 + $customer_ids = array();
238 + $team_member_ids = array();
236 239 // Search Services
237 - $services = Service_Model::all( [
238 - 's' => $search,
239 - ] );
240 - $service_ids = array_map( function ( $service ) {
241 - return $service->id;
242 - }, $services );
240 + $services = Service_Model::all(
241 + array(
242 + 's' => $search,
243 + )
244 + );
245 + $service_ids = array_map(
246 + function ( $service ) {
247 + return $service->id;
248 + }, $services
249 + );
243 250
244 251 // Search Customers
245 - $customer_query = new \WP_User_Query( [
246 - 'search' => '*' . esc_attr( $search ) . '*',
247 - 'search_columns' => [ 'display_name', 'user_email' ],
248 - 'role' => 'booktics_customer',
249 - 'fields' => 'ID'
250 - ] );
252 + $customer_query = new \WP_User_Query(
253 + array(
254 + 'search' => '*' . esc_attr( $search ) . '*',
255 + 'search_columns' => array( 'display_name', 'user_email' ),
256 + 'role' => 'booktics_customer',
257 + 'fields' => 'ID',
258 + )
259 + );
251 260 $customer_ids = $customer_query->get_results();
252 261
253 262 // Search Team Members
254 - $team_member_query = new \WP_User_Query( [
255 - 'search' => '*' . esc_attr( $search ) . '*',
256 - 'search_columns' => [ 'display_name', 'user_email' ],
257 - 'role' => 'booktics_team_member',
258 - 'fields' => 'ID'
259 - ] );
263 + $team_member_query = new \WP_User_Query(
264 + array(
265 + 'search' => '*' . esc_attr( $search ) . '*',
266 + 'search_columns' => array( 'display_name', 'user_email' ),
267 + 'role' => 'booktics_team_member',
268 + 'fields' => 'ID',
269 + )
270 + );
260 271 $team_member_ids = $team_member_query->get_results();
261 272
262 273 if ( $service_ids || $customer_ids || $team_member_ids ) {
263 - $args['meta_query'] = [ 'relation' => 'OR' ];
274 + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Meta query is necessary for filtering users by meta fields
275 + $args['meta_query'] = array( 'relation' => 'OR' );
264 276 if ( $service_ids ) {
265 - $args['meta_query'][] = [
277 + $args['meta_query'][] = array(
266 278 'key' => 'service_id',
267 279 'value' => $service_ids,
268 - 'compare' => 'IN'
269 - ];
280 + 'compare' => 'IN',
281 + );
270 282 }
271 283 if ( $customer_ids ) {
272 - $args['meta_query'][] = [
284 + $args['meta_query'][] = array(
273 285 'key' => 'customer_id',
274 286 'value' => $customer_ids,
275 - 'compare' => 'IN'
276 - ];
287 + 'compare' => 'IN',
288 + );
277 289 }
278 290 if ( $team_member_ids ) {
279 - $args['meta_query'][] = [
291 + $args['meta_query'][] = array(
280 292 'key' => 'team_member_id',
281 293 'value' => $team_member_ids,
282 - 'compare' => 'IN'
283 - ];
294 + 'compare' => 'IN',
295 + );
284 296 }
285 297 } else {
286 - return $this->response( [
287 - 'items' => [],
288 - 'total' => 0,
289 - 'per_page' => $per_page,
290 - 'current_page' => $paged,
291 - 'last_page' => 0,
292 - ], __( 'No appointments found', 'booktics' ) );
298 + return $this->response(
299 + array(
300 + 'items' => array(),
301 + 'total' => 0,
302 + 'per_page' => $per_page,
303 + 'current_page' => $paged,
304 + 'last_page' => 0,
305 + ), __( 'No appointments found', 'booktics' )
306 + );
293 307 }
294 308 }
295 309
296 310 if ( $date_from ) {
@@ -333,12 +347,34 @@
333 347 'compare' => '=',
334 348 );
335 349 }
336 350
351 + // Adds filtering by appointment status
337 352 if ( $status ) {
353 + $args['post_status'] = is_array( $status ) ? $status : array( $status );
354 + }
355 +
356 + $args = apply_filters( 'booktics_appointment_args', $args );
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
338 374 $args['meta_query'][] = array(
339 - 'key' => 'status',
340 - 'value' => $status,
375 + 'key' => 'customer_id',
376 + 'value' => $guest->id,
341 377 'compare' => '=',
342 378 );
343 379 }
344 380
@@ -343,8 +379,24 @@
343 379 }
344 380
345 381 $data = Appointment_Model::paginate( $per_page, $paged, $args );
346 382
383 + $data = apply_filters( 'booktics_appointment_data', $data );
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 + }
398 +
347 399 if ( ! $data ) {
348 400 return $this->error( __( 'No appointment found', 'booktics' ), 404 );
349 401 }
350 402
@@ -366,8 +418,20 @@
366 418 if ( ! $appointment ) {
367 419 return $this->error( __( 'Appointment not found', 'booktics' ), 404 );
368 420 }
369 421
422 + // Mask team member email for customers (non-admin/team-member)
423 + if ( ! booktics_is_admin_or_team_member() && is_user_logged_in() ) {
424 + $appointment_array = is_array( $appointment ) ? $appointment : ( is_object( $appointment ) ? get_object_vars( $appointment ) : array() );
425 + if ( isset( $appointment_array['team_member_id'] ) ) {
426 + $team_member = new Team_Member_Model( $appointment_array['team_member_id'] );
427 + $team_member_data = $team_member->find( $appointment_array['team_member_id'] );
428 + if ( $team_member_data && isset( $team_member_data->user_email ) ) {
429 + // We can't easily unset from the object, but we can return a modified response
430 + }
431 + }
432 + }
433 +
370 434 return $this->response( $appointment );
371 435 }
372 436
373 437 /**
@@ -384,9 +448,9 @@
384 448 // Check if custom date range is provided
385 449 if ( ! empty( $request['start_date'] ) && ! empty( $request['end_date'] ) ) {
386 450 $adjusted_start_date = sanitize_text_field( $request['start_date'] );
387 451 $adjusted_end_date = sanitize_text_field( $request['end_date'] );
388 - $month = date( 'Y-m', strtotime( $adjusted_start_date ) );
452 + $month = gmdate( 'Y-m', strtotime( $adjusted_start_date ) );
389 453 } else {
390 454 // Fall back to month-based date range with week adjustment
391 455 $month = ! empty( $request['month'] ) ? sanitize_text_field( $request['month'] ) : gmdate( 'Y-m' );
392 456 $start_date = gmdate( 'Y-m-01', strtotime( $month ) );
@@ -411,8 +475,9 @@
411 475 }
412 476
413 477 // Get all appointments within the date range using meta_query
414 478 $args = array(
479 + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Meta query is necessary for filtering appointments by meta fields
415 480 'meta_query' => array(
416 481 'relation' => 'AND',
417 482 array(
418 483 'key' => 'date',
@@ -417,9 +482,9 @@
417 482 array(
418 483 'key' => 'date',
419 484 'value' => array(
420 485 $adjusted_start_date,
421 - $adjusted_end_date
486 + $adjusted_end_date,
422 487 ),
423 488 'compare' => 'BETWEEN',
424 489 'type' => 'DATE',
425 490 ),
@@ -425,9 +490,9 @@
425 490 ),
426 491 ),
427 492 'posts_per_page' => - 1, // Get all appointments in the range
428 493 'orderby' => 'meta_value',
429 - 'meta_key' => 'date',
494 + 'meta_key' => 'date', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Required for ordering by date meta field
430 495 'order' => 'ASC',
431 496 );
432 497
433 498 $service_id = ! empty( $request['service_id'] ) ? intval( $request['service_id'] ) : null;
@@ -475,8 +540,10 @@
475 540 'compare' => '=',
476 541 );
477 542 }
478 543
544 + $args = apply_filters( 'booktics_appointment_args', $args );
545 +
479 546 $appointments = Appointment_Model::paginate( $per_page, $paged, $args );
480 547
481 548 // Format the response
482 549 $formatted_appointments = array();
@@ -520,9 +587,31 @@
520 587 *
521 588 * @return WP_Error|boolean
522 589 */
523 590 public function get_item_permissions_check( $request ) {
524 - return true;
591 + if ( booktics_is_admin_or_team_member() ) {
592 + return true;
593 + }
594 +
595 + if ( is_user_logged_in() ) {
596 + $appointment_id = intval( $request['id'] );
597 + $appointment = Appointment_Model::find( $appointment_id );
598 +
599 + if ( $appointment ) {
600 + $db = new Booktics_Database();
601 + $customer = ( new Guest_Model( $db ) )->find( array( 'id' => $appointment->customer_id ) );
602 +
603 + if ( $customer && intval( $customer->wp_user_id ) === get_current_user_id() ) {
604 + return true;
605 + }
606 + }
607 + }
608 +
609 + return new WP_Error(
610 + 'booktics_forbidden',
611 + __( 'You are not allowed to view this appointment.', 'booktics' ),
612 + array( 'status' => 403 )
613 + );
525 614 }
526 615
527 616 /**
528 617 * Check if a given request has access to get items.
@@ -531,9 +620,21 @@
531 620 *
532 621 * @return WP_Error|boolean
533 622 */
534 623 public function get_items_permissions_check( $request ) {
535 - return true;
624 + if ( booktics_is_admin_or_team_member() ) {
625 + return true;
626 + }
627 +
628 + if ( is_user_logged_in() ) {
629 + return true;
630 + }
631 +
632 + return new WP_Error(
633 + 'booktics_forbidden',
634 + __( 'You are not allowed to view appointments.', 'booktics' ),
635 + array( 'status' => 403 )
636 + );
536 637 }
537 638
538 639 /**
539 640 * Create one item from the collection.
@@ -543,13 +644,43 @@
543 644 * @return WP_Error|WP_REST_Response
544 645 * @throws Exception
545 646 */
546 647 public function create_item( $request ) {
547 - $data = json_decode( $request->get_body(), true );
548 - $error = Appointment_Validator::instance()->validate_create( $data );
648 + $data = json_decode( $request->get_body(), true );
649 +
650 + /**
651 + * Filters appointment data before it is validated and created.
652 + *
653 + * Runs before validation so an extension can supply required fields.
654 + * Booktics Pro round robin fills team_member_id here when a service
655 + * assigns its team members automatically.
656 + *
657 + * $data is the raw decoded request body: it has not been sanitised and
658 + * every key reaches Appointment_Service::create(). A filter is
659 + * responsible for sanitising whatever it adds.
660 + *
661 + * @since 1.0.26
662 + *
663 + * @param array $data Decoded request body.
664 + * @param \WP_REST_Request $request The create request.
665 + */
666 + $data = apply_filters( 'booktics_appointment_create_data', $data, $request );
667 +
668 + // A malformed body decodes to null, and a filter can return anything at
669 + // all. validate_create() is typed array, so either would be a fatal.
670 + if ( ! is_array( $data ) ) {
671 + return $this->error( __( 'Invalid appointment data.', 'booktics' ) );
672 + }
673 +
674 + $error = Appointment_Validator::validate_create( $data );
549 675 if ( $error ) {
550 676 return $this->error( $error->get_error_message() );
551 677 }
678 +
679 + $conflict = Appointment_Validator::validate_slot_available( $data );
680 + if ( $conflict ) {
681 + return $this->error( $conflict->get_error_message() );
682 + }
552 683 $appointment = Appointment_Service::instance()->create( $data );
553 684 if ( is_wp_error( $appointment ) ) {
554 685 return $this->error( $appointment->get_error_message() );
555 686 }
@@ -578,9 +709,9 @@
578 709 */
579 710 public function update_item( $request ) {
580 711 $id = intval( $request['id'] );
581 712 $data = json_decode( $request->get_body(), true );
582 - $error = Appointment_Validator::instance()->validate_update( $data );
713 + $error = Appointment_Validator::validate_update( $data );
583 714 if ( $error ) {
584 715 return $this->error( $error->get_error_message() );
585 716 }
586 717 $updated_appointment = Appointment_Service::instance()->update( $id, $data );
@@ -665,18 +796,70 @@
665 796 *
666 797 * @throws Exception
667 798 */
668 799 public function reschedule_item( $request ) {
669 - $id = intval( $request['id'] );
670 - $data = json_decode( $request->get_body(), true );
671 - $error = Appointment_Validator::instance()->validate_reschedule( $data );
800 + $id = intval( $request['id'] );
801 + $data = json_decode( $request->get_body(), true );
802 +
803 + /**
804 + * Filters reschedule data before validation.
805 + *
806 + * Returning a WP_Error refuses the reschedule with that message, which
807 + * is how Booktics Pro keeps a booking with its original team member.
808 + *
809 + * @since 1.0.26
810 + *
811 + * @param array|\WP_Error $data Reschedule data.
812 + * @param int $id Appointment being moved.
813 + */
814 + $data = apply_filters( 'booktics_appointment_reschedule_data', $data, $id );
815 +
816 + if ( is_wp_error( $data ) ) {
817 + return $this->error( $data->get_error_message() );
818 + }
819 +
820 + if ( ! is_array( $data ) ) {
821 + return $this->error( __( 'Invalid reschedule data.', 'booktics' ) );
822 + }
823 +
824 + $error = Appointment_Validator::validate_reschedule( $data, $id );
672 825 if ( $error ) {
673 826 return $this->error( $error->get_error_message() );
674 827 }
828 +
829 + // Same guard as create_item(): a reschedule is a booking too, and the
830 + // owning customer can reach this route. The reschedule payload only
831 + // carries the new date and time, so the stored appointment supplies
832 + // service, duration and attendees, without which the check would fall
833 + // back to a 30 minute window and a capacity of one and could refuse a
834 + // group booking that still has room. Its own id is passed so the
835 + // booking is never counted as a conflict with itself.
836 + $moving = Appointment_Model::find( $id );
837 +
838 + if ( $moving ) {
839 + $conflict = Appointment_Validator::validate_slot_available(
840 + array(
841 + 'id' => $id,
842 + 'date' => $data['date'] ?? $moving->date,
843 + 'start_time' => $data['start_time'] ?? $moving->start_time,
844 + 'team_member_id' => $data['team_member_id'] ?? $moving->team_member_id,
845 + 'service_id' => $moving->service_id,
846 + 'duration' => $moving->duration,
847 + 'total_attendees' => $moving->total_attendees,
848 + )
849 + );
850 +
851 + if ( $conflict ) {
852 + return $this->error( $conflict->get_error_message() );
853 + }
854 + }
675 855 $result = Appointment_Service::instance()->reschedule( $id, $data );
676 856 if ( is_wp_error( $result ) ) {
677 857 return $this->error( $result->get_error_message() );
678 858 }
859 +
860 + $this->send_reschedule_email( $result );
861 +
679 862 do_action( 'booktics_after_appointment_rescheduled', $result );
680 863
681 864 return $this->response( $result, __( 'Successfully rescheduled appointment', 'booktics' ) );
682 865 }
@@ -686,12 +869,41 @@
686 869 *
687 870 * @param $request Full data about the request.
688 871 */
689 872 public function reschedule_item_permissions_check( $request ) {
690 - return true;
873 + return $this->check_appointment_owner(
874 + $request,
875 + __( 'You are not allowed to reschedule this appointment.', 'booktics' )
876 + );
691 877 }
692 878
693 879 /**
880 + * Send reschedule email to customer
881 + *
882 + * @param Appointment_Model $appointment Appointment model.
883 + */
884 + private function send_reschedule_email( $appointment ) {
885 + $db = new Booktics_Database();
886 + $customer = ( new Guest_Model( $db ) )->find( [ 'id' => $appointment->customer_id ] );
887 + $team_member = new Team_Member_Model( $appointment->team_member_id );
888 +
889 + $appointment_reschedule_email_data = [
890 + 'date' => $appointment->date ?? '',
891 + 'start_time' => $appointment->start_time ?? '',
892 + 'end_time' => $appointment->end_time ?? '',
893 + 'customer_name' => $customer->name ?? '',
894 + 'user_email' => $customer->email ?? '',
895 + 'appointment_service' => $appointment->title ?? '',
896 + 'appointment_team_member' => $team_member->display_name ?? '',
897 + 'appointment_team_member_email' => $team_member->user_email ?? '',
898 + ];
899 +
900 + $appointment_reschedule_email_data = apply_filters( 'booktics_appointment_reschedule_email_data', $appointment_reschedule_email_data );
901 +
902 + do_action( 'booktics_gln_hook', 'appointment_rescheduled', $appointment_reschedule_email_data );
903 + }
904 +
905 + /**
694 906 * Cancel an appointment
695 907 *
696 908 * @param WP_REST_Request $request Full data about the request.
697 909 *
@@ -698,18 +910,59 @@
698 910 * @throws Exception
699 911 */
700 912 public function cancel_item( $request ) {
701 913 $id = intval( $request['id'] );
702 - $result = Appointment_Service::instance()->cancel( $id );
914 + $data = json_decode( $request->get_body(), true );
915 + $cancel_note = ! empty( $data['cancel_note'] ) ? sanitize_text_field( $data['cancel_note'] ) : '';
916 +
917 + $error = Appointment_Validator::instance()->validate_cancel( $id );
918 +
919 + if ( $error ) {
920 + return $this->error( $error->get_error_message() );
921 + }
922 +
923 + $result = Appointment_Service::instance()->cancel( $id, [ 'cancel_note' => $cancel_note ] );
924 +
703 925 if ( is_wp_error( $result ) ) {
704 926 return $this->error( $result->get_error_message() );
705 927 }
706 - do_action( 'booktics_after_appointment_cancelled', $result );
707 928
929 + $this->send_cancel_email( $result );
930 +
931 + // Note: booktics_after_appointment_cancelled is fired inside
932 + // Appointment_Service::cancel(), the single choke point every cancel
933 + // path routes through, so it is intentionally not re-fired here.
934 +
708 935 return $this->response( $result, __( 'Successfully cancelled appointment', 'booktics' ) );
709 936 }
710 937
711 938 /**
939 + * Send cancel email to customer
940 + *
941 + * @param Appointment_Model $appointment Appointment model.
942 + */
943 + private function send_cancel_email( $appointment ) {
944 + $db = new Booktics_Database();
945 + $customer = ( new Guest_Model( $db ) )->find( [ 'id' => $appointment->customer_id ] );
946 + $team_member = new Team_Member_Model( $appointment->team_member_id );
947 +
948 + $appointment_cancel_email_data = [
949 + 'date' => $appointment->date ?? '',
950 + 'start_time' => $appointment->start_time ?? '',
951 + 'end_time' => $appointment->end_time ?? '',
952 + 'customer_name' => $customer->name ?? '',
953 + 'user_email' => $customer->email ?? '',
954 + 'appointment_service' => $appointment->title ?? '',
955 + 'appointment_team_member' => $team_member->display_name ?? '',
956 + 'appointment_team_member_email' => $team_member->user_email ?? '',
957 + ];
958 +
959 + $appointment_cancel_email_data = apply_filters( 'booktics_appointment_cancel_email_data', $appointment_cancel_email_data );
960 +
961 + do_action( 'booktics_gln_hook', 'appointment_cancelled', $appointment_cancel_email_data );
962 + }
963 +
964 + /**
712 965 * Check if a given request has access to cancel a specific item.
713 966 *
714 967 * @param WP_REST_Request $request Full data about the request.
715 968 *
@@ -715,12 +968,68 @@
715 968 *
716 969 * @return WP_Error|boolean
717 970 */
718 971 public function cancel_item_permissions_check( $request ) {
719 - return current_user_can( 'manage_options' );
972 + return $this->check_appointment_owner(
973 + $request,
974 + __( 'You are not allowed to cancel this appointment.', 'booktics' )
975 + );
720 976 }
721 977
722 978 /**
979 + * Authorize a customer-facing action on a single appointment.
980 + *
981 + * Reschedule and cancel are reached from the booking confirmation screen
982 + * and from the links in the confirmation email, so the caller is normally
983 + * a guest with no WP session at all. Even a logged-in customer usually
984 + * fails an account match, because the guest row only stores wp_user_id
985 + * when the "use WordPress users as customers" setting is on, and it is off
986 + * by default. The per-appointment security token that ships in those links
987 + * is therefore the credential that actually identifies the customer, and
988 + * it is checked here so the gate cannot be passed without it.
989 + *
990 + * @param WP_REST_Request $request Full data about the request.
991 + * @param string $message Message returned when access is refused.
992 + *
993 + * @return bool|WP_Error True when the caller may act on the appointment.
994 + */
995 + private function check_appointment_owner( $request, $message ) {
996 + if ( booktics_is_admin_or_team_member() ) {
997 + return true;
998 + }
999 +
1000 + $appointment = Appointment_Model::find( intval( $request['id'] ) );
1001 +
1002 + if ( $appointment ) {
1003 + $token = $request->get_param( 'security_token' );
1004 + $token = is_string( $token ) ? $token : '';
1005 + $known_token = (string) $appointment->security_token;
1006 +
1007 + // An appointment saved before tokens existed has an empty token,
1008 + // and hash_equals( '', '' ) is true, so an empty submitted token
1009 + // would unlock every one of those rows. Require a real value.
1010 + if ( '' !== $token && '' !== $known_token && hash_equals( $known_token, $token ) ) {
1011 + return true;
1012 + }
1013 +
1014 + if ( is_user_logged_in() ) {
1015 + $db = new Booktics_Database();
1016 + $customer = ( new Guest_Model( $db ) )->find( array( 'id' => $appointment->customer_id ) );
1017 +
1018 + if ( $customer && ! empty( $customer->wp_user_id ) && intval( $customer->wp_user_id ) === get_current_user_id() ) {
1019 + return true;
1020 + }
1021 + }
1022 + }
1023 +
1024 + return new WP_Error(
1025 + 'booktics_forbidden',
1026 + $message,
1027 + array( 'status' => 403 )
1028 + );
1029 + }
1030 +
1031 + /**
723 1032 * Prepare the item for create or update operation.
724 1033 *
725 1034 * @param WP_REST_Request $request Request object.
726 1035 *
@@ -769,8 +1078,32 @@
769 1078 return $data;
770 1079 }
771 1080
772 1081 /**
1082 + * Longest span of days the time slot route will answer for.
1083 + */
1084 + const MAX_SLOT_RANGE_DAYS = 62;
1085 +
1086 + /**
1087 + * Accept a Y-m-d date, or nothing.
1088 + *
1089 + * @param mixed $value Raw request value.
1090 + *
1091 + * @return string Y-m-d, or '' when it is not a date.
1092 + */
1093 + private static function sanitize_slot_date( $value ) {
1094 + $value = sanitize_text_field( (string) $value );
1095 +
1096 + if ( '' === $value ) {
1097 + return '';
1098 + }
1099 +
1100 + $date = \DateTime::createFromFormat( 'Y-m-d', $value );
1101 +
1102 + return ( $date && $date->format( 'Y-m-d' ) === $value ) ? $value : '';
1103 + }
1104 +
1105 + /**
773 1106 * Check if a given request has access to get time slots.
774 1107 *
775 1108 * @param $request
776 1109 *
@@ -788,14 +1121,42 @@
788 1121 * @return array|\WP_HTTP_Response
789 1122 * @throws Exception If there's an error getting time slots.
790 1123 */
791 1124 public function get_time_slots( $request ) {
792 - $service_id = $request['service_id'];
793 - $additional_service_id = $request['additional_service_id'];
794 - $team_member_id = $request['team_member_id'];
795 - $start_date = $request['start_date'];
796 - $end_date = $request['end_date'];
1125 + $service_id = ! empty( $request['service_id'] ) ? absint( $request['service_id'] ) : 0;
1126 + $additional_duration_id = ! empty( $request['additional_duration_id'] ) ? absint( $request['additional_duration_id'] ) : 0;
1127 + $team_member_id = ! empty( $request['team_member_id'] ) ? absint( $request['team_member_id'] ) : 0;
1128 + $start_date = self::sanitize_slot_date( $request['start_date'] ?? '' );
1129 + $end_date = self::sanitize_slot_date( $request['end_date'] ?? '' );
797 1130
1131 + // This route is public and takes no arguments schema, so both dates
1132 + // arrive as free text. generate_slots_for_range() builds a DateTime
1133 + // from them, and DateTime throws on anything it cannot read, which on
1134 + // an unauthenticated route is a 500 with a stack trace.
1135 + if ( ! empty( $request['start_date'] ) && '' === $start_date ) {
1136 + return $this->error( __( 'Invalid start date.', 'booktics' ) );
1137 + }
1138 +
1139 + if ( ! empty( $request['end_date'] ) && '' === $end_date ) {
1140 + return $this->error( __( 'Invalid end date.', 'booktics' ) );
1141 + }
1142 +
1143 + if ( $start_date && $end_date ) {
1144 + if ( $end_date < $start_date ) {
1145 + return $this->error( __( 'End date cannot be before start date.', 'booktics' ) );
1146 + }
1147 +
1148 + // The engine walks the range a day at a time, so an open-ended
1149 + // span is a cheap request with a very expensive answer. A booking
1150 + // calendar shows a month.
1151 + $limit = strtotime( $start_date ) + ( self::MAX_SLOT_RANGE_DAYS * DAY_IN_SECONDS );
1152 +
1153 + if ( strtotime( $end_date ) > $limit ) {
1154 + $end_date = gmdate( 'Y-m-d', $limit );
1155 + }
1156 + }
1157 + $total_attendees = isset( $request['total_attendees'] ) ? absint( $request['total_attendees'] ) : 1;
1158 +
798 1159 $db = new Booktics_Database();
799 1160
800 1161 $service_schedules = $db->table( booktics_get_table_name( 'booktics_schedules' ) )
801 1162 ->where( 'service_id', '=', $service_id )
@@ -801,14 +1162,14 @@
801 1162 ->where( 'service_id', '=', $service_id )
802 1163 ->where_group(
803 1164 function ( $q ) use ( $start_date, $end_date ) {
804 1165 $q->where( 'custom_date', '=', '0' )
805 - ->or_where_group(
806 - function ( $q2 ) use ( $start_date, $end_date ) {
807 - $q2->where( 'custom_date', '>', $start_date )
808 - ->where( 'custom_date', '<', $end_date );
809 - }
810 - );
1166 + ->or_where_group(
1167 + function ( $q2 ) use ( $start_date, $end_date ) {
1168 + $q2->where( 'custom_date', '>', $start_date )
1169 + ->where( 'custom_date', '<', $end_date );
1170 + }
1171 + );
811 1172 }
812 1173 )
813 1174 ->to_array();
814 1175 $service_schedules = new Service_Schedule( $service_schedules, $service_id );
@@ -817,14 +1178,14 @@
817 1178 ->where( 'team_member_id', '=', $team_member_id )
818 1179 ->where_group(
819 1180 function ( $q ) use ( $start_date, $end_date ) {
820 1181 $q->where( 'custom_date', '=', '0' )
821 - ->or_where_group(
822 - function ( $q2 ) use ( $start_date, $end_date ) {
823 - $q2->where( 'custom_date', '>', $start_date )
824 - ->where( 'custom_date', '<', $end_date );
825 - }
826 - );
1182 + ->or_where_group(
1183 + function ( $q2 ) use ( $start_date, $end_date ) {
1184 + $q2->where( 'custom_date', '>', $start_date )
1185 + ->where( 'custom_date', '<', $end_date );
1186 + }
1187 + );
827 1188 }
828 1189 )
829 1190 ->to_array();
830 1191 $team_member_schedules = new Team_Member_Schedule( $team_member_schedules, $team_member_id );
@@ -834,14 +1195,14 @@
834 1195 ->where( 'team_member_id', '=', 0 )
835 1196 ->where_group(
836 1197 function ( $q ) use ( $start_date, $end_date ) {
837 1198 $q->where( 'custom_date', '=', '0' )
838 - ->or_where_group(
839 - function ( $q2 ) use ( $start_date, $end_date ) {
840 - $q2->where( 'custom_date', '>', $start_date )
841 - ->where( 'custom_date', '<', $end_date );
842 - }
843 - );
1199 + ->or_where_group(
1200 + function ( $q2 ) use ( $start_date, $end_date ) {
1201 + $q2->where( 'custom_date', '>', $start_date )
1202 + ->where( 'custom_date', '<', $end_date );
1203 + }
1204 + );
844 1205 }
845 1206 )
846 1207 ->to_array();
847 1208 $default_schedules = new Default_Schedule( $default_schedules );
@@ -846,27 +1207,96 @@
846 1207 ->to_array();
847 1208 $default_schedules = new Default_Schedule( $default_schedules );
848 1209
849 1210 $service = Service_Model::find( $service_id );
850 - $service_additional_services = ! empty( $service->additional_durations )
851 - ? array_filter(
852 - $service->additional_durations,
853 - function ( $duration ) use ( $additional_service_id ) {
854 - return $duration['id'] === $additional_service_id;
855 - }
856 - ) : array();
857 - $service_duration = ! empty( $service_additional_services ) ? reset( $service_additional_services )['duration_time'] : 30;
1211 + $service_duration = $this->get_service_duration_from_additional_durations( $service->additional_durations, $additional_duration_id );
1212 + $service_duration = apply_filters( 'booktics_service_duration_for_appointment_time_slots', $service_duration, $request );
1213 + $slot_length = $service->interval_time < 1 ? booktics_get_option( 'interval_time', 30 ) : $service->interval_time;
858 1214
859 - $slots = ( new Time_Slot_Manager( $service_schedules, $team_member_schedules, $default_schedules, $service_duration ) )
860 - ->generate_slots_for_range( $start_date, $end_date );
861 - $response = array(
862 - 'service_id' => $service_id,
863 - 'team_member_id' => $team_member_id,
864 - 'additional_service_id' => $additional_service_id,
865 - 'start_date' => $start_date,
866 - 'end_date' => $end_date,
867 - 'slots' => $slots,
1215 + // Get buffer times from service
1216 + $before_buffer_time = $service->before_buffer_time ? intval( $service->before_buffer_time ) : 0;
1217 + $after_buffer_time = $service->after_buffer_time ? intval( $service->after_buffer_time ) : 0;
1218 + $options = array(
1219 + 'default_schedule' => $default_schedules,
1220 + 'service_duration' => $service_duration,
1221 + 'slot_length' => $slot_length,
1222 + 'before_buffer_time' => $before_buffer_time,
1223 + 'after_buffer_time' => $after_buffer_time,
1224 + 'service_id' => $service_id,
1225 + 'total_attendees' => $total_attendees,
868 1226 );
1227 + /**
1228 + * Allows an extension to produce the slot set instead of core.
1229 + *
1230 + * Returning null (the default) leaves core's single team member
1231 + * generation untouched. Returning an array replaces it entirely, which
1232 + * is how Booktics Pro serves slots merged across several team members.
1233 + *
1234 + * @since 1.0.26
1235 + *
1236 + * @param array|null $slots Pre-computed slots, or null.
1237 + * @param \WP_REST_Request $request The slot request.
1238 + * @param array $options Options handed to Time_Slot_Manager.
1239 + */
1240 + $slots = apply_filters( 'booktics_pre_generate_time_slots', null, $request, $options );
869 1241
1242 + // Anything that is not an array of slots falls back to core rather than
1243 + // being emitted verbatim in the response.
1244 + if ( ! is_array( $slots ) ) {
1245 + // Generate time slots for the given date range by interval time
1246 + $slots = ( new Time_Slot_Manager( $service_schedules, $team_member_schedules, $options ) )
1247 + ->generate_slots_for_range( $start_date, $end_date );
1248 + }
1249 +
1250 +
1251 + $response = array(
1252 + 'service_id' => $service_id,
1253 + 'team_member_id' => $team_member_id,
1254 + 'additional_duration_id' => $additional_duration_id,
1255 + 'start_date' => $start_date,
1256 + 'end_date' => $end_date,
1257 + 'slots' => $slots,
1258 + );
1259 +
870 1260 return $this->response( $response, __( 'Successfully fetched time slots', 'booktics' ) );
1261 + }
1262 +
1263 + /**
1264 + * Get service duration from additional durations using additional_duration_id
1265 + * If additional_duration_id is not found, return default duration (30 minutes)
1266 + *
1267 + * @param array $additional_durations Array of additional durations.
1268 + * @param int $additional_duration_id ID of additional duration.
1269 + *
1270 + * @return int Service duration in minutes.
1271 + */
1272 + private function get_service_duration_from_additional_durations( $additional_durations, $additional_duration_id ) {
1273 + $default_duration = 30;
1274 + if ( empty( $additional_durations ) ) {
1275 + return $default_duration; // Return default duration if additional durations are empty
1276 + }
1277 +
1278 + foreach ( $additional_durations as $additional_duration ) {
1279 + if ( $additional_duration['id'] === $additional_duration_id ) {
1280 + return $this->get_converted_duration( $additional_duration['duration_time'], $additional_duration['duration_unit'] );
1281 + }
1282 + }
1283 +
1284 + return $default_duration;
1285 + }
1286 +
1287 + /**
1288 + * Convert duration to minutes
1289 + *
1290 + * @param int $duration Duration in minutes or hours.
1291 + * @param string $unit Unit of duration (min or hour).
1292 + *
1293 + * @return int Duration in minutes.
1294 + */
1295 + private function get_converted_duration($duration, $unit) {
1296 + if ( $unit === 'min' ) {
1297 + return $duration;
1298 + } elseif ( $unit === 'hour' ) {
1299 + return $duration * 60;
1300 + }
871 1301 }
872 1302 }