PluginProbe
Booktics – Appointment Booking Calendar for Service Businesses / 1.0.29
Booktics – Appointment Booking Calendar for Service Businesses v1.0.29
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 +246 -40 1.0.19 → 1.0.29 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 /**
@@ -867,9 +967,11 @@
867 967 }
868 968
869 969 $this->send_cancel_email( $result );
870 970
871 - do_action( 'booktics_after_appointment_cancelled', $result );
971 + // Note: booktics_after_appointment_cancelled is fired inside
972 + // Appointment_Service::cancel(), the single choke point every cancel
973 + // path routes through, so it is intentionally not re-fired here.
872 974
873 975 return $this->response( $result, __( 'Successfully cancelled appointment', 'booktics' ) );
874 976 }
875 977
@@ -906,21 +1008,55 @@
906 1008 *
907 1009 * @return WP_Error|boolean
908 1010 */
909 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 ) {
910 1036 if ( booktics_is_admin_or_team_member() ) {
911 1037 return true;
912 1038 }
913 1039
914 - if ( is_user_logged_in() ) {
915 - $appointment_id = intval( $request['id'] );
916 - $appointment = Appointment_Model::find( $appointment_id );
1040 + $appointment = Appointment_Model::find( intval( $request['id'] ) );
917 1041
918 - 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() ) {
919 1055 $db = new Booktics_Database();
920 1056 $customer = ( new Guest_Model( $db ) )->find( array( 'id' => $appointment->customer_id ) );
921 1057
922 - 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() ) {
923 1059 return true;
924 1060 }
925 1061 }
926 1062 }
@@ -926,9 +1062,9 @@
926 1062 }
927 1063
928 1064 return new WP_Error(
929 1065 'booktics_forbidden',
930 - __( 'You are not allowed to cancel this appointment.', 'booktics' ),
1066 + $message,
931 1067 array( 'status' => 403 )
932 1068 );
933 1069 }
934 1070
@@ -982,8 +1118,32 @@
982 1118 return $data;
983 1119 }
984 1120
985 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 + /**
986 1146 * Check if a given request has access to get time slots.
987 1147 *
988 1148 * @param $request
989 1149 *
@@ -1004,10 +1164,37 @@
1004 1164 public function get_time_slots( $request ) {
1005 1165 $service_id = ! empty( $request['service_id'] ) ? absint( $request['service_id'] ) : 0;
1006 1166 $additional_duration_id = ! empty( $request['additional_duration_id'] ) ? absint( $request['additional_duration_id'] ) : 0;
1007 1167 $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'] ) : '';
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 + }
1010 1197 $total_attendees = isset( $request['total_attendees'] ) ? absint( $request['total_attendees'] ) : 1;
1011 1198
1012 1199 $db = new Booktics_Database();
1013 1200
@@ -1076,11 +1263,30 @@
1076 1263 'after_buffer_time' => $after_buffer_time,
1077 1264 'service_id' => $service_id,
1078 1265 'total_attendees' => $total_attendees,
1079 1266 );
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 );
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 + }
1083 1289
1084 1290
1085 1291 $response = array(
1086 1292 'service_id' => $service_id,