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 +351 -18 1.0.16 → 1.0.28 View file →
@@ -354,12 +354,54 @@
354 354 }
355 355
356 356 $args = apply_filters( 'booktics_appointment_args', $args );
357 357
358 + // Scope to customer's own appointments when not admin/team-member
359 + if ( ! booktics_is_admin_or_team_member() && is_user_logged_in() ) {
360 + $db = new Booktics_Database();
361 + $guest = ( new Guest_Model( $db ) )->find( array( 'wp_user_id' => get_current_user_id() ) );
362 + if ( ! $guest ) {
363 + return $this->response(
364 + array(
365 + 'items' => array(),
366 + 'total' => 0,
367 + 'per_page' => $per_page,
368 + 'current_page' => $paged,
369 + 'last_page' => 0,
370 + ), __( 'No appointments found', 'booktics' )
371 + );
372 + }
373 + // Add customer_id filter to meta_query
374 + $args['meta_query'][] = array(
375 + 'key' => 'customer_id',
376 + 'value' => $guest->id,
377 + 'compare' => '=',
378 + );
379 + }
380 +
358 381 $data = Appointment_Model::paginate( $per_page, $paged, $args );
359 382
360 383 $data = apply_filters( 'booktics_appointment_data', $data );
361 384
385 + // Mask team member email for customers (non-admin/team-member)
386 + if ( ! booktics_is_admin_or_team_member() && is_user_logged_in() && ! empty( $data['items'] ) ) {
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 = '';
394 + }
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 = '';
400 + }
401 + unset( $appointment );
402 + }
403 +
362 404 if ( ! $data ) {
363 405 return $this->error( __( 'No appointment found', 'booktics' ), 404 );
364 406 }
365 407
@@ -381,8 +423,20 @@
381 423 if ( ! $appointment ) {
382 424 return $this->error( __( 'Appointment not found', 'booktics' ), 404 );
383 425 }
384 426
427 + // Mask team member email for customers (non-admin/team-member)
428 + if ( ! booktics_is_admin_or_team_member() && is_user_logged_in() ) {
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 + }
436 + }
437 + }
438 +
385 439 return $this->response( $appointment );
386 440 }
387 441
388 442 /**
@@ -493,8 +547,35 @@
493 547 }
494 548
495 549 $args = apply_filters( 'booktics_appointment_args', $args );
496 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 +
497 578 $appointments = Appointment_Model::paginate( $per_page, $paged, $args );
498 579
499 580 // Format the response
500 581 $formatted_appointments = array();
@@ -502,8 +583,16 @@
502 583 if ( ! empty( $appointments ) ) {
503 584 foreach ( $appointments['items'] as $appointment ) {
504 585 if ( ! empty( $appointment ) ) {
505 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 + }
506 595 }
507 596
508 597 $formatted_appointments[] = $appointment;
509 598 }
@@ -538,9 +627,31 @@
538 627 *
539 628 * @return WP_Error|boolean
540 629 */
541 630 public function get_item_permissions_check( $request ) {
542 - return true;
631 + if ( booktics_is_admin_or_team_member() ) {
632 + return true;
633 + }
634 +
635 + if ( is_user_logged_in() ) {
636 + $appointment_id = intval( $request['id'] );
637 + $appointment = Appointment_Model::find( $appointment_id );
638 +
639 + if ( $appointment ) {
640 + $db = new Booktics_Database();
641 + $customer = ( new Guest_Model( $db ) )->find( array( 'id' => $appointment->customer_id ) );
642 +
643 + if ( $customer && intval( $customer->wp_user_id ) === get_current_user_id() ) {
644 + return true;
645 + }
646 + }
647 + }
648 +
649 + return new WP_Error(
650 + 'booktics_forbidden',
651 + __( 'You are not allowed to view this appointment.', 'booktics' ),
652 + array( 'status' => 403 )
653 + );
543 654 }
544 655
545 656 /**
546 657 * Check if a given request has access to get items.
@@ -549,9 +660,21 @@
549 660 *
550 661 * @return WP_Error|boolean
551 662 */
552 663 public function get_items_permissions_check( $request ) {
553 - return true;
664 + if ( booktics_is_admin_or_team_member() ) {
665 + return true;
666 + }
667 +
668 + if ( is_user_logged_in() ) {
669 + return true;
670 + }
671 +
672 + return new WP_Error(
673 + 'booktics_forbidden',
674 + __( 'You are not allowed to view appointments.', 'booktics' ),
675 + array( 'status' => 403 )
676 + );
554 677 }
555 678
556 679 /**
557 680 * Create one item from the collection.
@@ -561,13 +684,43 @@
561 684 * @return WP_Error|WP_REST_Response
562 685 * @throws Exception
563 686 */
564 687 public function create_item( $request ) {
565 - $data = json_decode( $request->get_body(), true );
566 - $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 );
567 715 if ( $error ) {
568 716 return $this->error( $error->get_error_message() );
569 717 }
718 +
719 + $conflict = Appointment_Validator::validate_slot_available( $data );
720 + if ( $conflict ) {
721 + return $this->error( $conflict->get_error_message() );
722 + }
570 723 $appointment = Appointment_Service::instance()->create( $data );
571 724 if ( is_wp_error( $appointment ) ) {
572 725 return $this->error( $appointment->get_error_message() );
573 726 }
@@ -596,9 +749,9 @@
596 749 */
597 750 public function update_item( $request ) {
598 751 $id = intval( $request['id'] );
599 752 $data = json_decode( $request->get_body(), true );
600 - $error = Appointment_Validator::instance()->validate_update( $data );
753 + $error = Appointment_Validator::validate_update( $data );
601 754 if ( $error ) {
602 755 return $this->error( $error->get_error_message() );
603 756 }
604 757 $updated_appointment = Appointment_Service::instance()->update( $id, $data );
@@ -683,14 +836,63 @@
683 836 *
684 837 * @throws Exception
685 838 */
686 839 public function reschedule_item( $request ) {
687 - $id = intval( $request['id'] );
688 - $data = json_decode( $request->get_body(), true );
689 - $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 );
690 865 if ( $error ) {
691 866 return $this->error( $error->get_error_message() );
692 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 + }
693 895 $result = Appointment_Service::instance()->reschedule( $id, $data );
694 896 if ( is_wp_error( $result ) ) {
695 897 return $this->error( $result->get_error_message() );
696 898 }
@@ -707,9 +909,12 @@
707 909 *
708 910 * @param $request Full data about the request.
709 911 */
710 912 public function reschedule_item_permissions_check( $request ) {
711 - return true;
913 + return $this->check_appointment_owner(
914 + $request,
915 + __( 'You are not allowed to reschedule this appointment.', 'booktics' )
916 + );
712 917 }
713 918
714 919 /**
715 920 * Send reschedule email to customer
@@ -733,9 +938,9 @@
733 938 ];
734 939
735 940 $appointment_reschedule_email_data = apply_filters( 'booktics_appointment_reschedule_email_data', $appointment_reschedule_email_data );
736 941
737 - do_action( 'booktics_global_notification_hook', 'appointment_rescheduled', $appointment_reschedule_email_data );
942 + do_action( 'booktics_gln_hook', 'appointment_rescheduled', $appointment_reschedule_email_data );
738 943 }
739 944
740 945 /**
741 946 * Cancel an appointment
@@ -762,9 +967,11 @@
762 967 }
763 968
764 969 $this->send_cancel_email( $result );
765 970
766 - 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.
767 974
768 975 return $this->response( $result, __( 'Successfully cancelled appointment', 'booktics' ) );
769 976 }
770 977
@@ -790,9 +997,9 @@
790 997 ];
791 998
792 999 $appointment_cancel_email_data = apply_filters( 'booktics_appointment_cancel_email_data', $appointment_cancel_email_data );
793 1000
794 - do_action( 'booktics_global_notification_hook', 'appointment_cancelled', $appointment_cancel_email_data );
1001 + do_action( 'booktics_gln_hook', 'appointment_cancelled', $appointment_cancel_email_data );
795 1002 }
796 1003
797 1004 /**
798 1005 * Check if a given request has access to cancel a specific item.
@@ -801,12 +1008,68 @@
801 1008 *
802 1009 * @return WP_Error|boolean
803 1010 */
804 1011 public function cancel_item_permissions_check( $request ) {
805 - return true;
1012 + return $this->check_appointment_owner(
1013 + $request,
1014 + __( 'You are not allowed to cancel this appointment.', 'booktics' )
1015 + );
806 1016 }
807 1017
808 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 ) {
1036 + if ( booktics_is_admin_or_team_member() ) {
1037 + return true;
1038 + }
1039 +
1040 + $appointment = Appointment_Model::find( intval( $request['id'] ) );
1041 +
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() ) {
1055 + $db = new Booktics_Database();
1056 + $customer = ( new Guest_Model( $db ) )->find( array( 'id' => $appointment->customer_id ) );
1057 +
1058 + if ( $customer && ! empty( $customer->wp_user_id ) && intval( $customer->wp_user_id ) === get_current_user_id() ) {
1059 + return true;
1060 + }
1061 + }
1062 + }
1063 +
1064 + return new WP_Error(
1065 + 'booktics_forbidden',
1066 + $message,
1067 + array( 'status' => 403 )
1068 + );
1069 + }
1070 +
1071 + /**
809 1072 * Prepare the item for create or update operation.
810 1073 *
811 1074 * @param WP_REST_Request $request Request object.
812 1075 *
@@ -855,8 +1118,32 @@
855 1118 return $data;
856 1119 }
857 1120
858 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 + /**
859 1146 * Check if a given request has access to get time slots.
860 1147 *
861 1148 * @param $request
862 1149 *
@@ -877,10 +1164,37 @@
877 1164 public function get_time_slots( $request ) {
878 1165 $service_id = ! empty( $request['service_id'] ) ? absint( $request['service_id'] ) : 0;
879 1166 $additional_duration_id = ! empty( $request['additional_duration_id'] ) ? absint( $request['additional_duration_id'] ) : 0;
880 1167 $team_member_id = ! empty( $request['team_member_id'] ) ? absint( $request['team_member_id'] ) : 0;
881 - $start_date = ! empty( $request['start_date'] ) ? sanitize_text_field( $request['start_date'] ) : '';
882 - $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 + }
883 1197 $total_attendees = isset( $request['total_attendees'] ) ? absint( $request['total_attendees'] ) : 1;
884 1198
885 1199 $db = new Booktics_Database();
886 1200
@@ -949,11 +1263,30 @@
949 1263 'after_buffer_time' => $after_buffer_time,
950 1264 'service_id' => $service_id,
951 1265 'total_attendees' => $total_attendees,
952 1266 );
953 - // Generate time slots for the given date range by interval time
954 - $slots = ( new Time_Slot_Manager( $service_schedules, $team_member_schedules, $options ) )
955 - ->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 + }
956 1289
957 1290
958 1291 $response = array(
959 1292 'service_id' => $service_id,