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 +203 -39 1.0.24 → 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 /**
@@ -908,21 +968,55 @@
908 968 *
909 969 * @return WP_Error|boolean
910 970 */
911 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 ) {
912 996 if ( booktics_is_admin_or_team_member() ) {
913 997 return true;
914 998 }
915 999
916 - if ( is_user_logged_in() ) {
917 - $appointment_id = intval( $request['id'] );
918 - $appointment = Appointment_Model::find( $appointment_id );
1000 + $appointment = Appointment_Model::find( intval( $request['id'] ) );
919 1001
920 - 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() ) {
921 1015 $db = new Booktics_Database();
922 1016 $customer = ( new Guest_Model( $db ) )->find( array( 'id' => $appointment->customer_id ) );
923 1017
924 - 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() ) {
925 1019 return true;
926 1020 }
927 1021 }
928 1022 }
@@ -928,9 +1022,9 @@
928 1022 }
929 1023
930 1024 return new WP_Error(
931 1025 'booktics_forbidden',
932 - __( 'You are not allowed to cancel this appointment.', 'booktics' ),
1026 + $message,
933 1027 array( 'status' => 403 )
934 1028 );
935 1029 }
936 1030
@@ -984,8 +1078,32 @@
984 1078 return $data;
985 1079 }
986 1080
987 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 + /**
988 1106 * Check if a given request has access to get time slots.
989 1107 *
990 1108 * @param $request
991 1109 *
@@ -1006,10 +1124,37 @@
1006 1124 public function get_time_slots( $request ) {
1007 1125 $service_id = ! empty( $request['service_id'] ) ? absint( $request['service_id'] ) : 0;
1008 1126 $additional_duration_id = ! empty( $request['additional_duration_id'] ) ? absint( $request['additional_duration_id'] ) : 0;
1009 1127 $team_member_id = ! empty( $request['team_member_id'] ) ? absint( $request['team_member_id'] ) : 0;
1010 - $start_date = ! empty( $request['start_date'] ) ? sanitize_text_field( $request['start_date'] ) : '';
1011 - $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 + }
1012 1157 $total_attendees = isset( $request['total_attendees'] ) ? absint( $request['total_attendees'] ) : 1;
1013 1158
1014 1159 $db = new Booktics_Database();
1015 1160
@@ -1078,11 +1223,30 @@
1078 1223 'after_buffer_time' => $after_buffer_time,
1079 1224 'service_id' => $service_id,
1080 1225 'total_attendees' => $total_attendees,
1081 1226 );
1082 - // Generate time slots for the given date range by interval time
1083 - $slots = ( new Time_Slot_Manager( $service_schedules, $team_member_schedules, $options ) )
1084 - ->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 + }
1085 1249
1086 1250
1087 1251 $response = array(
1088 1252 'service_id' => $service_id,