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 +260 -64 1.0.17 → 1.0.29 View file →
@@ -383,22 +383,23 @@
383 383 $data = apply_filters( 'booktics_appointment_data', $data );
384 384
385 385 // Mask team member email for customers (non-admin/team-member)
386 386 if ( ! booktics_is_admin_or_team_member() && is_user_logged_in() && ! empty( $data['items'] ) ) {
387 - foreach ( $data['items'] as $key => $appointment ) {
388 - $appointment_array = is_array( $appointment ) ? $appointment : get_object_vars( $appointment );
389 -
390 - // team_member may not be present on all appointments; isset guards this safely.
391 - if ( isset( $appointment_array['team_member'] ) ) {
392 - if ( is_object( $appointment_array['team_member'] ) ) {
393 - $appointment_array['team_member']->user_email = '';
394 - } elseif ( is_array( $appointment_array['team_member'] ) && isset( $appointment_array['team_member']['user_email'] ) ) {
395 - $appointment_array['team_member']['user_email'] = '';
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 = '';
396 394 }
397 395 }
398 396
399 - $data['items'][ $key ] = (object) $appointment_array;
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 = '';
400 400 }
401 + unset( $appointment );
401 402 }
402 403
403 404 if ( ! $data ) {
404 405 return $this->error( __( 'No appointment found', 'booktics' ), 404 );
@@ -424,18 +425,16 @@
424 425 }
425 426
426 427 // Mask team member email for customers (non-admin/team-member)
427 428 if ( ! booktics_is_admin_or_team_member() && is_user_logged_in() ) {
428 - $appointment_array = is_array( $appointment ) ? $appointment : get_object_vars( $appointment );
429 - // team_member may not be present on all appointments; isset guards this safely.
430 - if ( isset( $appointment_array['team_member'] ) ) {
431 - if ( is_object( $appointment_array['team_member'] ) ) {
432 - $appointment_array['team_member']->user_email = '';
433 - } elseif ( is_array( $appointment_array['team_member'] ) && isset( $appointment_array['team_member']['user_email'] ) ) {
434 - $appointment_array['team_member']['user_email'] = '';
429 + $appointment_array = is_array( $appointment ) ? $appointment : ( is_object( $appointment ) ? get_object_vars( $appointment ) : array() );
430 + if ( isset( $appointment_array['team_member_id'] ) ) {
431 + $team_member = new Team_Member_Model( $appointment_array['team_member_id'] );
432 + $team_member_data = $team_member->find( $appointment_array['team_member_id'] );
433 + if ( $team_member_data && isset( $team_member_data->user_email ) ) {
434 + // We can't easily unset from the object, but we can return a modified response
435 435 }
436 436 }
437 - return $this->response( (object) $appointment_array );
438 437 }
439 438
440 439 return $this->response( $appointment );
441 440 }
@@ -548,8 +547,35 @@
548 547 }
549 548
550 549 $args = apply_filters( 'booktics_appointment_args', $args );
551 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 +
552 578 $appointments = Appointment_Model::paginate( $per_page, $paged, $args );
553 579
554 580 // Format the response
555 581 $formatted_appointments = array();
@@ -557,8 +583,16 @@
557 583 if ( ! empty( $appointments ) ) {
558 584 foreach ( $appointments['items'] as $appointment ) {
559 585 if ( ! empty( $appointment ) ) {
560 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 + }
561 595 }
562 596
563 597 $formatted_appointments[] = $appointment;
564 598 }
@@ -626,16 +660,12 @@
626 660 *
627 661 * @return WP_Error|boolean
628 662 */
629 663 public function get_items_permissions_check( $request ) {
630 - // Admins and team members get the full, unfiltered appointment list.
631 664 if ( booktics_is_admin_or_team_member() ) {
632 665 return true;
633 666 }
634 667
635 - // Authenticated customers may access this endpoint; get_items() automatically
636 - // scopes the query to only their own appointments via customer_id meta_query
637 - // in get_items methods (see lines 358–379 of this file).
638 668 if ( is_user_logged_in() ) {
639 669 return true;
640 670 }
641 671
@@ -654,13 +684,43 @@
654 684 * @return WP_Error|WP_REST_Response
655 685 * @throws Exception
656 686 */
657 687 public function create_item( $request ) {
658 - $data = json_decode( $request->get_body(), true );
659 - $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 );
660 715 if ( $error ) {
661 716 return $this->error( $error->get_error_message() );
662 717 }
718 +
719 + $conflict = Appointment_Validator::validate_slot_available( $data );
720 + if ( $conflict ) {
721 + return $this->error( $conflict->get_error_message() );
722 + }
663 723 $appointment = Appointment_Service::instance()->create( $data );
664 724 if ( is_wp_error( $appointment ) ) {
665 725 return $this->error( $appointment->get_error_message() );
666 726 }
@@ -689,9 +749,9 @@
689 749 */
690 750 public function update_item( $request ) {
691 751 $id = intval( $request['id'] );
692 752 $data = json_decode( $request->get_body(), true );
693 - $error = Appointment_Validator::instance()->validate_update( $data );
753 + $error = Appointment_Validator::validate_update( $data );
694 754 if ( $error ) {
695 755 return $this->error( $error->get_error_message() );
696 756 }
697 757 $updated_appointment = Appointment_Service::instance()->update( $id, $data );
@@ -776,14 +836,63 @@
776 836 *
777 837 * @throws Exception
778 838 */
779 839 public function reschedule_item( $request ) {
780 - $id = intval( $request['id'] );
781 - $data = json_decode( $request->get_body(), true );
782 - $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 );
783 865 if ( $error ) {
784 866 return $this->error( $error->get_error_message() );
785 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 + }
786 895 $result = Appointment_Service::instance()->reschedule( $id, $data );
787 896 if ( is_wp_error( $result ) ) {
788 897 return $this->error( $result->get_error_message() );
789 898 }
@@ -800,30 +909,11 @@
800 909 *
801 910 * @param $request Full data about the request.
802 911 */
803 912 public function reschedule_item_permissions_check( $request ) {
804 - if ( booktics_is_admin_or_team_member() ) {
805 - return true;
806 - }
807 -
808 - if ( is_user_logged_in() ) {
809 - $appointment_id = intval( $request['id'] );
810 - $appointment = Appointment_Model::find( $appointment_id );
811 -
812 - if ( $appointment ) {
813 - $db = new Booktics_Database();
814 - $customer = ( new Guest_Model( $db ) )->find( array( 'id' => $appointment->customer_id ) );
815 -
816 - if ( $customer && intval( $customer->wp_user_id ) === get_current_user_id() ) {
817 - return true;
818 - }
819 - }
820 - }
821 -
822 - return new WP_Error(
823 - 'booktics_forbidden',
824 - __( 'You are not allowed to reschedule this appointment.', 'booktics' ),
825 - array( 'status' => 403 )
913 + return $this->check_appointment_owner(
914 + $request,
915 + __( 'You are not allowed to reschedule this appointment.', 'booktics' )
826 916 );
827 917 }
828 918
829 919 /**
@@ -848,9 +938,9 @@
848 938 ];
849 939
850 940 $appointment_reschedule_email_data = apply_filters( 'booktics_appointment_reschedule_email_data', $appointment_reschedule_email_data );
851 941
852 - do_action( 'booktics_global_notification_hook', 'appointment_rescheduled', $appointment_reschedule_email_data );
942 + do_action( 'booktics_gln_hook', 'appointment_rescheduled', $appointment_reschedule_email_data );
853 943 }
854 944
855 945 /**
856 946 * Cancel an appointment
@@ -877,9 +967,11 @@
877 967 }
878 968
879 969 $this->send_cancel_email( $result );
880 970
881 - 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.
882 974
883 975 return $this->response( $result, __( 'Successfully cancelled appointment', 'booktics' ) );
884 976 }
885 977
@@ -905,9 +997,9 @@
905 997 ];
906 998
907 999 $appointment_cancel_email_data = apply_filters( 'booktics_appointment_cancel_email_data', $appointment_cancel_email_data );
908 1000
909 - do_action( 'booktics_global_notification_hook', 'appointment_cancelled', $appointment_cancel_email_data );
1001 + do_action( 'booktics_gln_hook', 'appointment_cancelled', $appointment_cancel_email_data );
910 1002 }
911 1003
912 1004 /**
913 1005 * Check if a given request has access to cancel a specific item.
@@ -916,21 +1008,55 @@
916 1008 *
917 1009 * @return WP_Error|boolean
918 1010 */
919 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 ) {
920 1036 if ( booktics_is_admin_or_team_member() ) {
921 1037 return true;
922 1038 }
923 1039
924 - if ( is_user_logged_in() ) {
925 - $appointment_id = intval( $request['id'] );
926 - $appointment = Appointment_Model::find( $appointment_id );
1040 + $appointment = Appointment_Model::find( intval( $request['id'] ) );
927 1041
928 - 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() ) {
929 1055 $db = new Booktics_Database();
930 1056 $customer = ( new Guest_Model( $db ) )->find( array( 'id' => $appointment->customer_id ) );
931 1057
932 - 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() ) {
933 1059 return true;
934 1060 }
935 1061 }
936 1062 }
@@ -936,9 +1062,9 @@
936 1062 }
937 1063
938 1064 return new WP_Error(
939 1065 'booktics_forbidden',
940 - __( 'You are not allowed to cancel this appointment.', 'booktics' ),
1066 + $message,
941 1067 array( 'status' => 403 )
942 1068 );
943 1069 }
944 1070
@@ -992,8 +1118,32 @@
992 1118 return $data;
993 1119 }
994 1120
995 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 + /**
996 1146 * Check if a given request has access to get time slots.
997 1147 *
998 1148 * @param $request
999 1149 *
@@ -1014,10 +1164,37 @@
1014 1164 public function get_time_slots( $request ) {
1015 1165 $service_id = ! empty( $request['service_id'] ) ? absint( $request['service_id'] ) : 0;
1016 1166 $additional_duration_id = ! empty( $request['additional_duration_id'] ) ? absint( $request['additional_duration_id'] ) : 0;
1017 1167 $team_member_id = ! empty( $request['team_member_id'] ) ? absint( $request['team_member_id'] ) : 0;
1018 - $start_date = ! empty( $request['start_date'] ) ? sanitize_text_field( $request['start_date'] ) : '';
1019 - $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 + }
1020 1197 $total_attendees = isset( $request['total_attendees'] ) ? absint( $request['total_attendees'] ) : 1;
1021 1198
1022 1199 $db = new Booktics_Database();
1023 1200
@@ -1086,11 +1263,30 @@
1086 1263 'after_buffer_time' => $after_buffer_time,
1087 1264 'service_id' => $service_id,
1088 1265 'total_attendees' => $total_attendees,
1089 1266 );
1090 - // Generate time slots for the given date range by interval time
1091 - $slots = ( new Time_Slot_Manager( $service_schedules, $team_member_schedules, $options ) )
1092 - ->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 + }
1093 1289
1094 1290
1095 1291 $response = array(
1096 1292 'service_id' => $service_id,