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 +206 -40 1.0.20 → 1.0.27 View file →
@@ -644,13 +644,43 @@
644 644 * @return WP_Error|WP_REST_Response
645 645 * @throws Exception
646 646 */
647 647 public function create_item( $request ) {
648 - $data = json_decode( $request->get_body(), true );
649 - $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 );
650 675 if ( $error ) {
651 676 return $this->error( $error->get_error_message() );
652 677 }
678 +
679 + $conflict = Appointment_Validator::validate_slot_available( $data );
680 + if ( $conflict ) {
681 + return $this->error( $conflict->get_error_message() );
682 + }
653 683 $appointment = Appointment_Service::instance()->create( $data );
654 684 if ( is_wp_error( $appointment ) ) {
655 685 return $this->error( $appointment->get_error_message() );
656 686 }
@@ -679,9 +709,9 @@
679 709 */
680 710 public function update_item( $request ) {
681 711 $id = intval( $request['id'] );
682 712 $data = json_decode( $request->get_body(), true );
683 - $error = Appointment_Validator::instance()->validate_update( $data );
713 + $error = Appointment_Validator::validate_update( $data );
684 714 if ( $error ) {
685 715 return $this->error( $error->get_error_message() );
686 716 }
687 717 $updated_appointment = Appointment_Service::instance()->update( $id, $data );
@@ -766,14 +796,63 @@
766 796 *
767 797 * @throws Exception
768 798 */
769 799 public function reschedule_item( $request ) {
770 - $id = intval( $request['id'] );
771 - $data = json_decode( $request->get_body(), true );
772 - $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 );
773 825 if ( $error ) {
774 826 return $this->error( $error->get_error_message() );
775 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 + }
776 855 $result = Appointment_Service::instance()->reschedule( $id, $data );
777 856 if ( is_wp_error( $result ) ) {
778 857 return $this->error( $result->get_error_message() );
779 858 }
@@ -790,30 +869,11 @@
790 869 *
791 870 * @param $request Full data about the request.
792 871 */
793 872 public function reschedule_item_permissions_check( $request ) {
794 - if ( booktics_is_admin_or_team_member() ) {
795 - return true;
796 - }
797 -
798 - if ( is_user_logged_in() ) {
799 - $appointment_id = intval( $request['id'] );
800 - $appointment = Appointment_Model::find( $appointment_id );
801 -
802 - if ( $appointment ) {
803 - $db = new Booktics_Database();
804 - $customer = ( new Guest_Model( $db ) )->find( array( 'id' => $appointment->customer_id ) );
805 -
806 - if ( $customer && intval( $customer->wp_user_id ) === get_current_user_id() ) {
807 - return true;
808 - }
809 - }
810 - }
811 -
812 - return new WP_Error(
813 - 'booktics_forbidden',
814 - __( 'You are not allowed to reschedule this appointment.', 'booktics' ),
815 - array( 'status' => 403 )
873 + return $this->check_appointment_owner(
874 + $request,
875 + __( 'You are not allowed to reschedule this appointment.', 'booktics' )
816 876 );
817 877 }
818 878
819 879 /**
@@ -867,9 +927,11 @@
867 927 }
868 928
869 929 $this->send_cancel_email( $result );
870 930
871 - 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.
872 934
873 935 return $this->response( $result, __( 'Successfully cancelled appointment', 'booktics' ) );
874 936 }
875 937
@@ -906,21 +968,55 @@
906 968 *
907 969 * @return WP_Error|boolean
908 970 */
909 971 public function cancel_item_permissions_check( $request ) {
972 + return $this->check_appointment_owner(
973 + $request,
974 + __( 'You are not allowed to cancel this appointment.', 'booktics' )
975 + );
976 + }
977 +
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 ) {
910 996 if ( booktics_is_admin_or_team_member() ) {
911 997 return true;
912 998 }
913 999
914 - if ( is_user_logged_in() ) {
915 - $appointment_id = intval( $request['id'] );
916 - $appointment = Appointment_Model::find( $appointment_id );
1000 + $appointment = Appointment_Model::find( intval( $request['id'] ) );
917 1001
918 - if ( $appointment ) {
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() ) {
919 1015 $db = new Booktics_Database();
920 1016 $customer = ( new Guest_Model( $db ) )->find( array( 'id' => $appointment->customer_id ) );
921 1017
922 - if ( $customer && intval( $customer->wp_user_id ) === get_current_user_id() ) {
1018 + if ( $customer && ! empty( $customer->wp_user_id ) && intval( $customer->wp_user_id ) === get_current_user_id() ) {
923 1019 return true;
924 1020 }
925 1021 }
926 1022 }
@@ -926,9 +1022,9 @@
926 1022 }
927 1023
928 1024 return new WP_Error(
929 1025 'booktics_forbidden',
930 - __( 'You are not allowed to cancel this appointment.', 'booktics' ),
1026 + $message,
931 1027 array( 'status' => 403 )
932 1028 );
933 1029 }
934 1030
@@ -982,8 +1078,32 @@
982 1078 return $data;
983 1079 }
984 1080
985 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 + /**
986 1106 * Check if a given request has access to get time slots.
987 1107 *
988 1108 * @param $request
989 1109 *
@@ -1004,10 +1124,37 @@
1004 1124 public function get_time_slots( $request ) {
1005 1125 $service_id = ! empty( $request['service_id'] ) ? absint( $request['service_id'] ) : 0;
1006 1126 $additional_duration_id = ! empty( $request['additional_duration_id'] ) ? absint( $request['additional_duration_id'] ) : 0;
1007 1127 $team_member_id = ! empty( $request['team_member_id'] ) ? absint( $request['team_member_id'] ) : 0;
1008 - $start_date = ! empty( $request['start_date'] ) ? sanitize_text_field( $request['start_date'] ) : '';
1009 - $end_date = ! empty( $request['end_date'] ) ? sanitize_text_field( $request['end_date'] ) : '';
1128 + $start_date = self::sanitize_slot_date( $request['start_date'] ?? '' );
1129 + $end_date = self::sanitize_slot_date( $request['end_date'] ?? '' );
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 + }
1010 1157 $total_attendees = isset( $request['total_attendees'] ) ? absint( $request['total_attendees'] ) : 1;
1011 1158
1012 1159 $db = new Booktics_Database();
1013 1160
@@ -1076,11 +1223,30 @@
1076 1223 'after_buffer_time' => $after_buffer_time,
1077 1224 'service_id' => $service_id,
1078 1225 'total_attendees' => $total_attendees,
1079 1226 );
1080 - // Generate time slots for the given date range by interval time
1081 - $slots = ( new Time_Slot_Manager( $service_schedules, $team_member_schedules, $options ) )
1082 - ->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 + }
1083 1249
1084 1250
1085 1251 $response = array(
1086 1252 'service_id' => $service_id,