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 +320 -24 1.0.12 → 1.0.27 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,49 @@
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 + }
398 +
360 399 if ( ! $data ) {
361 400 return $this->error( __( 'No appointment found', 'booktics' ), 404 );
362 401 }
363 402
@@ -379,8 +418,20 @@
379 418 if ( ! $appointment ) {
380 419 return $this->error( __( 'Appointment not found', 'booktics' ), 404 );
381 420 }
382 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 +
383 434 return $this->response( $appointment );
384 435 }
385 436
386 437 /**
@@ -397,9 +448,9 @@
397 448 // Check if custom date range is provided
398 449 if ( ! empty( $request['start_date'] ) && ! empty( $request['end_date'] ) ) {
399 450 $adjusted_start_date = sanitize_text_field( $request['start_date'] );
400 451 $adjusted_end_date = sanitize_text_field( $request['end_date'] );
401 - $month = date( 'Y-m', strtotime( $adjusted_start_date ) );
452 + $month = gmdate( 'Y-m', strtotime( $adjusted_start_date ) );
402 453 } else {
403 454 // Fall back to month-based date range with week adjustment
404 455 $month = ! empty( $request['month'] ) ? sanitize_text_field( $request['month'] ) : gmdate( 'Y-m' );
405 456 $start_date = gmdate( 'Y-m-01', strtotime( $month ) );
@@ -424,8 +475,9 @@
424 475 }
425 476
426 477 // Get all appointments within the date range using meta_query
427 478 $args = array(
479 + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Meta query is necessary for filtering appointments by meta fields
428 480 'meta_query' => array(
429 481 'relation' => 'AND',
430 482 array(
431 483 'key' => 'date',
@@ -438,9 +490,9 @@
438 490 ),
439 491 ),
440 492 'posts_per_page' => - 1, // Get all appointments in the range
441 493 'orderby' => 'meta_value',
442 - 'meta_key' => 'date',
494 + 'meta_key' => 'date', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Required for ordering by date meta field
443 495 'order' => 'ASC',
444 496 );
445 497
446 498 $service_id = ! empty( $request['service_id'] ) ? intval( $request['service_id'] ) : null;
@@ -535,9 +587,31 @@
535 587 *
536 588 * @return WP_Error|boolean
537 589 */
538 590 public function get_item_permissions_check( $request ) {
539 - 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 + );
540 614 }
541 615
542 616 /**
543 617 * Check if a given request has access to get items.
@@ -546,9 +620,21 @@
546 620 *
547 621 * @return WP_Error|boolean
548 622 */
549 623 public function get_items_permissions_check( $request ) {
550 - 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 + );
551 637 }
552 638
553 639 /**
554 640 * Create one item from the collection.
@@ -558,13 +644,43 @@
558 644 * @return WP_Error|WP_REST_Response
559 645 * @throws Exception
560 646 */
561 647 public function create_item( $request ) {
562 - $data = json_decode( $request->get_body(), true );
563 - $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 );
564 675 if ( $error ) {
565 676 return $this->error( $error->get_error_message() );
566 677 }
678 +
679 + $conflict = Appointment_Validator::validate_slot_available( $data );
680 + if ( $conflict ) {
681 + return $this->error( $conflict->get_error_message() );
682 + }
567 683 $appointment = Appointment_Service::instance()->create( $data );
568 684 if ( is_wp_error( $appointment ) ) {
569 685 return $this->error( $appointment->get_error_message() );
570 686 }
@@ -593,9 +709,9 @@
593 709 */
594 710 public function update_item( $request ) {
595 711 $id = intval( $request['id'] );
596 712 $data = json_decode( $request->get_body(), true );
597 - $error = Appointment_Validator::instance()->validate_update( $data );
713 + $error = Appointment_Validator::validate_update( $data );
598 714 if ( $error ) {
599 715 return $this->error( $error->get_error_message() );
600 716 }
601 717 $updated_appointment = Appointment_Service::instance()->update( $id, $data );
@@ -680,14 +796,63 @@
680 796 *
681 797 * @throws Exception
682 798 */
683 799 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 );
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 );
687 825 if ( $error ) {
688 826 return $this->error( $error->get_error_message() );
689 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 + }
690 855 $result = Appointment_Service::instance()->reschedule( $id, $data );
691 856 if ( is_wp_error( $result ) ) {
692 857 return $this->error( $result->get_error_message() );
693 858 }
@@ -704,9 +869,12 @@
704 869 *
705 870 * @param $request Full data about the request.
706 871 */
707 872 public function reschedule_item_permissions_check( $request ) {
708 - return true;
873 + return $this->check_appointment_owner(
874 + $request,
875 + __( 'You are not allowed to reschedule this appointment.', 'booktics' )
876 + );
709 877 }
710 878
711 879 /**
712 880 * Send reschedule email to customer
@@ -730,9 +898,9 @@
730 898 ];
731 899
732 900 $appointment_reschedule_email_data = apply_filters( 'booktics_appointment_reschedule_email_data', $appointment_reschedule_email_data );
733 901
734 - do_action( 'global_notification_hook', 'appointment_rescheduled', $appointment_reschedule_email_data );
902 + do_action( 'booktics_gln_hook', 'appointment_rescheduled', $appointment_reschedule_email_data );
735 903 }
736 904
737 905 /**
738 906 * Cancel an appointment
@@ -759,9 +927,11 @@
759 927 }
760 928
761 929 $this->send_cancel_email( $result );
762 930
763 - do_action( 'booktics_after_appointment_cancelled', $result );
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.
764 934
765 935 return $this->response( $result, __( 'Successfully cancelled appointment', 'booktics' ) );
766 936 }
767 937
@@ -787,9 +957,9 @@
787 957 ];
788 958
789 959 $appointment_cancel_email_data = apply_filters( 'booktics_appointment_cancel_email_data', $appointment_cancel_email_data );
790 960
791 - do_action( 'global_notification_hook', 'appointment_cancelled', $appointment_cancel_email_data );
961 + do_action( 'booktics_gln_hook', 'appointment_cancelled', $appointment_cancel_email_data );
792 962 }
793 963
794 964 /**
795 965 * Check if a given request has access to cancel a specific item.
@@ -798,12 +968,68 @@
798 968 *
799 969 * @return WP_Error|boolean
800 970 */
801 971 public function cancel_item_permissions_check( $request ) {
802 - return true;
972 + return $this->check_appointment_owner(
973 + $request,
974 + __( 'You are not allowed to cancel this appointment.', 'booktics' )
975 + );
803 976 }
804 977
805 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 + /**
806 1032 * Prepare the item for create or update operation.
807 1033 *
808 1034 * @param WP_REST_Request $request Request object.
809 1035 *
@@ -852,8 +1078,32 @@
852 1078 return $data;
853 1079 }
854 1080
855 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 + /**
856 1106 * Check if a given request has access to get time slots.
857 1107 *
858 1108 * @param $request
859 1109 *
@@ -871,15 +1121,42 @@
871 1121 * @return array|\WP_HTTP_Response
872 1122 * @throws Exception If there's an error getting time slots.
873 1123 */
874 1124 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;
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'] ?? '' );
881 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 +
882 1159 $db = new Booktics_Database();
883 1160
884 1161 $service_schedules = $db->table( booktics_get_table_name( 'booktics_schedules' ) )
885 1162 ->where( 'service_id', '=', $service_id )
@@ -946,11 +1223,30 @@
946 1223 'after_buffer_time' => $after_buffer_time,
947 1224 'service_id' => $service_id,
948 1225 'total_attendees' => $total_attendees,
949 1226 );
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 );
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 );
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 + }
953 1249
954 1250
955 1251 $response = array(
956 1252 'service_id' => $service_id,