PluginProbe
Booktics – Appointment Booking Calendar for Service Businesses / 1.0.28
Booktics – Appointment Booking Calendar for Service Businesses v1.0.28
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 +243 -39 1.0.24 → 1.0.28 View file →
@@ -392,9 +392,14 @@
392 392 // Remove user_email from team data to protect privacy
393 393 $team_member_data->user_email = '';
394 394 }
395 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 = '';
396 400 }
401 + unset( $appointment );
397 402 }
398 403
399 404 if ( ! $data ) {
400 405 return $this->error( __( 'No appointment found', 'booktics' ), 404 );
@@ -542,8 +547,35 @@
542 547 }
543 548
544 549 $args = apply_filters( 'booktics_appointment_args', $args );
545 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 +
546 578 $appointments = Appointment_Model::paginate( $per_page, $paged, $args );
547 579
548 580 // Format the response
549 581 $formatted_appointments = array();
@@ -551,8 +583,16 @@
551 583 if ( ! empty( $appointments ) ) {
552 584 foreach ( $appointments['items'] as $appointment ) {
553 585 if ( ! empty( $appointment ) ) {
554 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 + }
555 595 }
556 596
557 597 $formatted_appointments[] = $appointment;
558 598 }
@@ -644,13 +684,43 @@
644 684 * @return WP_Error|WP_REST_Response
645 685 * @throws Exception
646 686 */
647 687 public function create_item( $request ) {
648 - $data = json_decode( $request->get_body(), true );
649 - $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 );
650 715 if ( $error ) {
651 716 return $this->error( $error->get_error_message() );
652 717 }
718 +
719 + $conflict = Appointment_Validator::validate_slot_available( $data );
720 + if ( $conflict ) {
721 + return $this->error( $conflict->get_error_message() );
722 + }
653 723 $appointment = Appointment_Service::instance()->create( $data );
654 724 if ( is_wp_error( $appointment ) ) {
655 725 return $this->error( $appointment->get_error_message() );
656 726 }
@@ -679,9 +749,9 @@
679 749 */
680 750 public function update_item( $request ) {
681 751 $id = intval( $request['id'] );
682 752 $data = json_decode( $request->get_body(), true );
683 - $error = Appointment_Validator::instance()->validate_update( $data );
753 + $error = Appointment_Validator::validate_update( $data );
684 754 if ( $error ) {
685 755 return $this->error( $error->get_error_message() );
686 756 }
687 757 $updated_appointment = Appointment_Service::instance()->update( $id, $data );
@@ -766,14 +836,63 @@
766 836 *
767 837 * @throws Exception
768 838 */
769 839 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 );
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 );
773 865 if ( $error ) {
774 866 return $this->error( $error->get_error_message() );
775 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 + }
776 895 $result = Appointment_Service::instance()->reschedule( $id, $data );
777 896 if ( is_wp_error( $result ) ) {
778 897 return $this->error( $result->get_error_message() );
779 898 }
@@ -790,30 +909,11 @@
790 909 *
791 910 * @param $request Full data about the request.
792 911 */
793 912 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 )
913 + return $this->check_appointment_owner(
914 + $request,
915 + __( 'You are not allowed to reschedule this appointment.', 'booktics' )
816 916 );
817 917 }
818 918
819 919 /**
@@ -908,21 +1008,55 @@
908 1008 *
909 1009 * @return WP_Error|boolean
910 1010 */
911 1011 public function cancel_item_permissions_check( $request ) {
1012 + return $this->check_appointment_owner(
1013 + $request,
1014 + __( 'You are not allowed to cancel this appointment.', 'booktics' )
1015 + );
1016 + }
1017 +
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 ) {
912 1036 if ( booktics_is_admin_or_team_member() ) {
913 1037 return true;
914 1038 }
915 1039
916 - if ( is_user_logged_in() ) {
917 - $appointment_id = intval( $request['id'] );
918 - $appointment = Appointment_Model::find( $appointment_id );
1040 + $appointment = Appointment_Model::find( intval( $request['id'] ) );
919 1041
920 - if ( $appointment ) {
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() ) {
921 1055 $db = new Booktics_Database();
922 1056 $customer = ( new Guest_Model( $db ) )->find( array( 'id' => $appointment->customer_id ) );
923 1057
924 - if ( $customer && intval( $customer->wp_user_id ) === get_current_user_id() ) {
1058 + if ( $customer && ! empty( $customer->wp_user_id ) && intval( $customer->wp_user_id ) === get_current_user_id() ) {
925 1059 return true;
926 1060 }
927 1061 }
928 1062 }
@@ -928,9 +1062,9 @@
928 1062 }
929 1063
930 1064 return new WP_Error(
931 1065 'booktics_forbidden',
932 - __( 'You are not allowed to cancel this appointment.', 'booktics' ),
1066 + $message,
933 1067 array( 'status' => 403 )
934 1068 );
935 1069 }
936 1070
@@ -984,8 +1118,32 @@
984 1118 return $data;
985 1119 }
986 1120
987 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 + /**
988 1146 * Check if a given request has access to get time slots.
989 1147 *
990 1148 * @param $request
991 1149 *
@@ -1006,10 +1164,37 @@
1006 1164 public function get_time_slots( $request ) {
1007 1165 $service_id = ! empty( $request['service_id'] ) ? absint( $request['service_id'] ) : 0;
1008 1166 $additional_duration_id = ! empty( $request['additional_duration_id'] ) ? absint( $request['additional_duration_id'] ) : 0;
1009 1167 $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'] ) : '';
1168 + $start_date = self::sanitize_slot_date( $request['start_date'] ?? '' );
1169 + $end_date = self::sanitize_slot_date( $request['end_date'] ?? '' );
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 + }
1012 1197 $total_attendees = isset( $request['total_attendees'] ) ? absint( $request['total_attendees'] ) : 1;
1013 1198
1014 1199 $db = new Booktics_Database();
1015 1200
@@ -1078,11 +1263,30 @@
1078 1263 'after_buffer_time' => $after_buffer_time,
1079 1264 'service_id' => $service_id,
1080 1265 'total_attendees' => $total_attendees,
1081 1266 );
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 );
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 + }
1085 1289
1086 1290
1087 1291 $response = array(
1088 1292 'service_id' => $service_id,