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 +567 -97 1.0.2 → 1.0.29 View file →
@@ -11,8 +11,10 @@
11 11 use Booktics\Appointment\Services\Appointment_Service;
12 12 use Booktics\Appointment\Services\Appointment_Validator;
13 13 use Booktics\Models\Appointment_Model;
14 14 use Booktics\Models\Service_Model;
15 +use Booktics\Models\Team_Member_Model;
16 +use Booktics\Models\Guest_Model;
15 17 use Exception;
16 18 use WP_Error;
17 19 use WP_HTTP_Response;
18 20 use WP_REST_Request;
@@ -187,13 +189,13 @@
187 189 array(
188 190 'methods' => WP_REST_Server::READABLE,
189 191 'callback' => array(
190 192 $this,
191 - 'get_all_calendar_items'
193 + 'get_all_calendar_items',
192 194 ),
193 195 'permission_callback' => array(
194 196 $this,
195 - 'get_items_permissions_check'
197 + 'get_items_permissions_check',
196 198 ),
197 199 'args' => array(
198 200 'month' => array(
199 201 'description' => __( 'Month in YYYY-MM format', 'booktics' ),
@@ -226,71 +228,83 @@
226 228 $team_member_id = ! empty( $request['team_member_id'] ) ? intval( $request['team_member_id'] ) : null;
227 229 $status = ! empty( $request['status'] ) ? sanitize_text_field( $request['status'] ) : null;
228 230
229 231 $search = ! empty( $request['search'] ) ? sanitize_text_field( $request['search'] ) : null;
232 + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Meta query is necessary for filtering appointments by meta fields
230 233 $args = array( 'meta_query' => array() );
231 234
232 235 if ( $search ) {
233 - $service_ids = [];
234 - $customer_ids = [];
235 - $team_member_ids = [];
236 + $service_ids = array();
237 + $customer_ids = array();
238 + $team_member_ids = array();
236 239 // Search Services
237 - $services = Service_Model::all( [
238 - 's' => $search,
239 - ] );
240 - $service_ids = array_map( function ( $service ) {
241 - return $service->id;
242 - }, $services );
240 + $services = Service_Model::all(
241 + array(
242 + 's' => $search,
243 + )
244 + );
245 + $service_ids = array_map(
246 + function ( $service ) {
247 + return $service->id;
248 + }, $services
249 + );
243 250
244 251 // Search Customers
245 - $customer_query = new \WP_User_Query( [
246 - 'search' => '*' . esc_attr( $search ) . '*',
247 - 'search_columns' => [ 'display_name', 'user_email' ],
248 - 'role' => 'booktics_customer',
249 - 'fields' => 'ID'
250 - ] );
252 + $customer_query = new \WP_User_Query(
253 + array(
254 + 'search' => '*' . esc_attr( $search ) . '*',
255 + 'search_columns' => array( 'display_name', 'user_email' ),
256 + 'role' => 'booktics_customer',
257 + 'fields' => 'ID',
258 + )
259 + );
251 260 $customer_ids = $customer_query->get_results();
252 261
253 262 // Search Team Members
254 - $team_member_query = new \WP_User_Query( [
255 - 'search' => '*' . esc_attr( $search ) . '*',
256 - 'search_columns' => [ 'display_name', 'user_email' ],
257 - 'role' => 'booktics_team_member',
258 - 'fields' => 'ID'
259 - ] );
263 + $team_member_query = new \WP_User_Query(
264 + array(
265 + 'search' => '*' . esc_attr( $search ) . '*',
266 + 'search_columns' => array( 'display_name', 'user_email' ),
267 + 'role' => 'booktics_team_member',
268 + 'fields' => 'ID',
269 + )
270 + );
260 271 $team_member_ids = $team_member_query->get_results();
261 272
262 273 if ( $service_ids || $customer_ids || $team_member_ids ) {
263 - $args['meta_query'] = [ 'relation' => 'OR' ];
274 + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Meta query is necessary for filtering users by meta fields
275 + $args['meta_query'] = array( 'relation' => 'OR' );
264 276 if ( $service_ids ) {
265 - $args['meta_query'][] = [
277 + $args['meta_query'][] = array(
266 278 'key' => 'service_id',
267 279 'value' => $service_ids,
268 - 'compare' => 'IN'
269 - ];
280 + 'compare' => 'IN',
281 + );
270 282 }
271 283 if ( $customer_ids ) {
272 - $args['meta_query'][] = [
284 + $args['meta_query'][] = array(
273 285 'key' => 'customer_id',
274 286 'value' => $customer_ids,
275 - 'compare' => 'IN'
276 - ];
287 + 'compare' => 'IN',
288 + );
277 289 }
278 290 if ( $team_member_ids ) {
279 - $args['meta_query'][] = [
291 + $args['meta_query'][] = array(
280 292 'key' => 'team_member_id',
281 293 'value' => $team_member_ids,
282 - 'compare' => 'IN'
283 - ];
294 + 'compare' => 'IN',
295 + );
284 296 }
285 297 } else {
286 - return $this->response( [
287 - 'items' => [],
288 - 'total' => 0,
289 - 'per_page' => $per_page,
290 - 'current_page' => $paged,
291 - 'last_page' => 0,
292 - ], __( 'No appointments found', 'booktics' ) );
298 + return $this->response(
299 + array(
300 + 'items' => array(),
301 + 'total' => 0,
302 + 'per_page' => $per_page,
303 + 'current_page' => $paged,
304 + 'last_page' => 0,
305 + ), __( 'No appointments found', 'booktics' )
306 + );
293 307 }
294 308 }
295 309
296 310 if ( $date_from ) {
@@ -333,12 +347,34 @@
333 347 'compare' => '=',
334 348 );
335 349 }
336 350
351 + // Adds filtering by appointment status
337 352 if ( $status ) {
353 + $args['post_status'] = is_array( $status ) ? $status : array( $status );
354 + }
355 +
356 + $args = apply_filters( 'booktics_appointment_args', $args );
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
338 374 $args['meta_query'][] = array(
339 - 'key' => 'status',
340 - 'value' => $status,
375 + 'key' => 'customer_id',
376 + 'value' => $guest->id,
341 377 'compare' => '=',
342 378 );
343 379 }
344 380
@@ -343,8 +379,29 @@
343 379 }
344 380
345 381 $data = Appointment_Model::paginate( $per_page, $paged, $args );
346 382
383 + $data = apply_filters( 'booktics_appointment_data', $data );
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 +
347 404 if ( ! $data ) {
348 405 return $this->error( __( 'No appointment found', 'booktics' ), 404 );
349 406 }
350 407
@@ -366,8 +423,20 @@
366 423 if ( ! $appointment ) {
367 424 return $this->error( __( 'Appointment not found', 'booktics' ), 404 );
368 425 }
369 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 +
370 439 return $this->response( $appointment );
371 440 }
372 441
373 442 /**
@@ -384,9 +453,9 @@
384 453 // Check if custom date range is provided
385 454 if ( ! empty( $request['start_date'] ) && ! empty( $request['end_date'] ) ) {
386 455 $adjusted_start_date = sanitize_text_field( $request['start_date'] );
387 456 $adjusted_end_date = sanitize_text_field( $request['end_date'] );
388 - $month = date( 'Y-m', strtotime( $adjusted_start_date ) );
457 + $month = gmdate( 'Y-m', strtotime( $adjusted_start_date ) );
389 458 } else {
390 459 // Fall back to month-based date range with week adjustment
391 460 $month = ! empty( $request['month'] ) ? sanitize_text_field( $request['month'] ) : gmdate( 'Y-m' );
392 461 $start_date = gmdate( 'Y-m-01', strtotime( $month ) );
@@ -411,8 +480,9 @@
411 480 }
412 481
413 482 // Get all appointments within the date range using meta_query
414 483 $args = array(
484 + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Meta query is necessary for filtering appointments by meta fields
415 485 'meta_query' => array(
416 486 'relation' => 'AND',
417 487 array(
418 488 'key' => 'date',
@@ -417,9 +487,9 @@
417 487 array(
418 488 'key' => 'date',
419 489 'value' => array(
420 490 $adjusted_start_date,
421 - $adjusted_end_date
491 + $adjusted_end_date,
422 492 ),
423 493 'compare' => 'BETWEEN',
424 494 'type' => 'DATE',
425 495 ),
@@ -425,9 +495,9 @@
425 495 ),
426 496 ),
427 497 'posts_per_page' => - 1, // Get all appointments in the range
428 498 'orderby' => 'meta_value',
429 - 'meta_key' => 'date',
499 + 'meta_key' => 'date', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Required for ordering by date meta field
430 500 'order' => 'ASC',
431 501 );
432 502
433 503 $service_id = ! empty( $request['service_id'] ) ? intval( $request['service_id'] ) : null;
@@ -475,8 +545,37 @@
475 545 'compare' => '=',
476 546 );
477 547 }
478 548
549 + $args = apply_filters( 'booktics_appointment_args', $args );
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 +
479 578 $appointments = Appointment_Model::paginate( $per_page, $paged, $args );
480 579
481 580 // Format the response
482 581 $formatted_appointments = array();
@@ -484,8 +583,16 @@
484 583 if ( ! empty( $appointments ) ) {
485 584 foreach ( $appointments['items'] as $appointment ) {
486 585 if ( ! empty( $appointment ) ) {
487 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 + }
488 595 }
489 596
490 597 $formatted_appointments[] = $appointment;
491 598 }
@@ -520,9 +627,31 @@
520 627 *
521 628 * @return WP_Error|boolean
522 629 */
523 630 public function get_item_permissions_check( $request ) {
524 - 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 + );
525 654 }
526 655
527 656 /**
528 657 * Check if a given request has access to get items.
@@ -531,9 +660,21 @@
531 660 *
532 661 * @return WP_Error|boolean
533 662 */
534 663 public function get_items_permissions_check( $request ) {
535 - 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 + );
536 677 }
537 678
538 679 /**
539 680 * Create one item from the collection.
@@ -543,13 +684,43 @@
543 684 * @return WP_Error|WP_REST_Response
544 685 * @throws Exception
545 686 */
546 687 public function create_item( $request ) {
547 - $data = json_decode( $request->get_body(), true );
548 - $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 );
549 715 if ( $error ) {
550 716 return $this->error( $error->get_error_message() );
551 717 }
718 +
719 + $conflict = Appointment_Validator::validate_slot_available( $data );
720 + if ( $conflict ) {
721 + return $this->error( $conflict->get_error_message() );
722 + }
552 723 $appointment = Appointment_Service::instance()->create( $data );
553 724 if ( is_wp_error( $appointment ) ) {
554 725 return $this->error( $appointment->get_error_message() );
555 726 }
@@ -578,9 +749,9 @@
578 749 */
579 750 public function update_item( $request ) {
580 751 $id = intval( $request['id'] );
581 752 $data = json_decode( $request->get_body(), true );
582 - $error = Appointment_Validator::instance()->validate_update( $data );
753 + $error = Appointment_Validator::validate_update( $data );
583 754 if ( $error ) {
584 755 return $this->error( $error->get_error_message() );
585 756 }
586 757 $updated_appointment = Appointment_Service::instance()->update( $id, $data );
@@ -665,18 +836,70 @@
665 836 *
666 837 * @throws Exception
667 838 */
668 839 public function reschedule_item( $request ) {
669 - $id = intval( $request['id'] );
670 - $data = json_decode( $request->get_body(), true );
671 - $error = Appointment_Validator::instance()->validate_reschedule( $data );
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 );
672 865 if ( $error ) {
673 866 return $this->error( $error->get_error_message() );
674 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 + }
675 895 $result = Appointment_Service::instance()->reschedule( $id, $data );
676 896 if ( is_wp_error( $result ) ) {
677 897 return $this->error( $result->get_error_message() );
678 898 }
899 +
900 + $this->send_reschedule_email( $result );
901 +
679 902 do_action( 'booktics_after_appointment_rescheduled', $result );
680 903
681 904 return $this->response( $result, __( 'Successfully rescheduled appointment', 'booktics' ) );
682 905 }
@@ -686,12 +909,41 @@
686 909 *
687 910 * @param $request Full data about the request.
688 911 */
689 912 public function reschedule_item_permissions_check( $request ) {
690 - return true;
913 + return $this->check_appointment_owner(
914 + $request,
915 + __( 'You are not allowed to reschedule this appointment.', 'booktics' )
916 + );
691 917 }
692 918
693 919 /**
920 + * Send reschedule email to customer
921 + *
922 + * @param Appointment_Model $appointment Appointment model.
923 + */
924 + private function send_reschedule_email( $appointment ) {
925 + $db = new Booktics_Database();
926 + $customer = ( new Guest_Model( $db ) )->find( [ 'id' => $appointment->customer_id ] );
927 + $team_member = new Team_Member_Model( $appointment->team_member_id );
928 +
929 + $appointment_reschedule_email_data = [
930 + 'date' => $appointment->date ?? '',
931 + 'start_time' => $appointment->start_time ?? '',
932 + 'end_time' => $appointment->end_time ?? '',
933 + 'customer_name' => $customer->name ?? '',
934 + 'user_email' => $customer->email ?? '',
935 + 'appointment_service' => $appointment->title ?? '',
936 + 'appointment_team_member' => $team_member->display_name ?? '',
937 + 'appointment_team_member_email' => $team_member->user_email ?? '',
938 + ];
939 +
940 + $appointment_reschedule_email_data = apply_filters( 'booktics_appointment_reschedule_email_data', $appointment_reschedule_email_data );
941 +
942 + do_action( 'booktics_gln_hook', 'appointment_rescheduled', $appointment_reschedule_email_data );
943 + }
944 +
945 + /**
694 946 * Cancel an appointment
695 947 *
696 948 * @param WP_REST_Request $request Full data about the request.
697 949 *
@@ -698,18 +950,59 @@
698 950 * @throws Exception
699 951 */
700 952 public function cancel_item( $request ) {
701 953 $id = intval( $request['id'] );
702 - $result = Appointment_Service::instance()->cancel( $id );
954 + $data = json_decode( $request->get_body(), true );
955 + $cancel_note = ! empty( $data['cancel_note'] ) ? sanitize_text_field( $data['cancel_note'] ) : '';
956 +
957 + $error = Appointment_Validator::instance()->validate_cancel( $id );
958 +
959 + if ( $error ) {
960 + return $this->error( $error->get_error_message() );
961 + }
962 +
963 + $result = Appointment_Service::instance()->cancel( $id, [ 'cancel_note' => $cancel_note ] );
964 +
703 965 if ( is_wp_error( $result ) ) {
704 966 return $this->error( $result->get_error_message() );
705 967 }
706 - do_action( 'booktics_after_appointment_cancelled', $result );
707 968
969 + $this->send_cancel_email( $result );
970 +
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.
974 +
708 975 return $this->response( $result, __( 'Successfully cancelled appointment', 'booktics' ) );
709 976 }
710 977
711 978 /**
979 + * Send cancel email to customer
980 + *
981 + * @param Appointment_Model $appointment Appointment model.
982 + */
983 + private function send_cancel_email( $appointment ) {
984 + $db = new Booktics_Database();
985 + $customer = ( new Guest_Model( $db ) )->find( [ 'id' => $appointment->customer_id ] );
986 + $team_member = new Team_Member_Model( $appointment->team_member_id );
987 +
988 + $appointment_cancel_email_data = [
989 + 'date' => $appointment->date ?? '',
990 + 'start_time' => $appointment->start_time ?? '',
991 + 'end_time' => $appointment->end_time ?? '',
992 + 'customer_name' => $customer->name ?? '',
993 + 'user_email' => $customer->email ?? '',
994 + 'appointment_service' => $appointment->title ?? '',
995 + 'appointment_team_member' => $team_member->display_name ?? '',
996 + 'appointment_team_member_email' => $team_member->user_email ?? '',
997 + ];
998 +
999 + $appointment_cancel_email_data = apply_filters( 'booktics_appointment_cancel_email_data', $appointment_cancel_email_data );
1000 +
1001 + do_action( 'booktics_gln_hook', 'appointment_cancelled', $appointment_cancel_email_data );
1002 + }
1003 +
1004 + /**
712 1005 * Check if a given request has access to cancel a specific item.
713 1006 *
714 1007 * @param WP_REST_Request $request Full data about the request.
715 1008 *
@@ -715,12 +1008,68 @@
715 1008 *
716 1009 * @return WP_Error|boolean
717 1010 */
718 1011 public function cancel_item_permissions_check( $request ) {
719 - return current_user_can( 'manage_options' );
1012 + return $this->check_appointment_owner(
1013 + $request,
1014 + __( 'You are not allowed to cancel this appointment.', 'booktics' )
1015 + );
720 1016 }
721 1017
722 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 + /**
723 1072 * Prepare the item for create or update operation.
724 1073 *
725 1074 * @param WP_REST_Request $request Request object.
726 1075 *
@@ -769,8 +1118,32 @@
769 1118 return $data;
770 1119 }
771 1120
772 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 + /**
773 1146 * Check if a given request has access to get time slots.
774 1147 *
775 1148 * @param $request
776 1149 *
@@ -788,14 +1161,42 @@
788 1161 * @return array|\WP_HTTP_Response
789 1162 * @throws Exception If there's an error getting time slots.
790 1163 */
791 1164 public function get_time_slots( $request ) {
792 - $service_id = $request['service_id'];
793 - $additional_service_id = $request['additional_service_id'];
794 - $team_member_id = $request['team_member_id'];
795 - $start_date = $request['start_date'];
796 - $end_date = $request['end_date'];
1165 + $service_id = ! empty( $request['service_id'] ) ? absint( $request['service_id'] ) : 0;
1166 + $additional_duration_id = ! empty( $request['additional_duration_id'] ) ? absint( $request['additional_duration_id'] ) : 0;
1167 + $team_member_id = ! empty( $request['team_member_id'] ) ? absint( $request['team_member_id'] ) : 0;
1168 + $start_date = self::sanitize_slot_date( $request['start_date'] ?? '' );
1169 + $end_date = self::sanitize_slot_date( $request['end_date'] ?? '' );
797 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 + }
1197 + $total_attendees = isset( $request['total_attendees'] ) ? absint( $request['total_attendees'] ) : 1;
1198 +
798 1199 $db = new Booktics_Database();
799 1200
800 1201 $service_schedules = $db->table( booktics_get_table_name( 'booktics_schedules' ) )
801 1202 ->where( 'service_id', '=', $service_id )
@@ -801,14 +1202,14 @@
801 1202 ->where( 'service_id', '=', $service_id )
802 1203 ->where_group(
803 1204 function ( $q ) use ( $start_date, $end_date ) {
804 1205 $q->where( 'custom_date', '=', '0' )
805 - ->or_where_group(
806 - function ( $q2 ) use ( $start_date, $end_date ) {
807 - $q2->where( 'custom_date', '>', $start_date )
808 - ->where( 'custom_date', '<', $end_date );
809 - }
810 - );
1206 + ->or_where_group(
1207 + function ( $q2 ) use ( $start_date, $end_date ) {
1208 + $q2->where( 'custom_date', '>', $start_date )
1209 + ->where( 'custom_date', '<', $end_date );
1210 + }
1211 + );
811 1212 }
812 1213 )
813 1214 ->to_array();
814 1215 $service_schedules = new Service_Schedule( $service_schedules, $service_id );
@@ -817,14 +1218,14 @@
817 1218 ->where( 'team_member_id', '=', $team_member_id )
818 1219 ->where_group(
819 1220 function ( $q ) use ( $start_date, $end_date ) {
820 1221 $q->where( 'custom_date', '=', '0' )
821 - ->or_where_group(
822 - function ( $q2 ) use ( $start_date, $end_date ) {
823 - $q2->where( 'custom_date', '>', $start_date )
824 - ->where( 'custom_date', '<', $end_date );
825 - }
826 - );
1222 + ->or_where_group(
1223 + function ( $q2 ) use ( $start_date, $end_date ) {
1224 + $q2->where( 'custom_date', '>', $start_date )
1225 + ->where( 'custom_date', '<', $end_date );
1226 + }
1227 + );
827 1228 }
828 1229 )
829 1230 ->to_array();
830 1231 $team_member_schedules = new Team_Member_Schedule( $team_member_schedules, $team_member_id );
@@ -834,14 +1235,14 @@
834 1235 ->where( 'team_member_id', '=', 0 )
835 1236 ->where_group(
836 1237 function ( $q ) use ( $start_date, $end_date ) {
837 1238 $q->where( 'custom_date', '=', '0' )
838 - ->or_where_group(
839 - function ( $q2 ) use ( $start_date, $end_date ) {
840 - $q2->where( 'custom_date', '>', $start_date )
841 - ->where( 'custom_date', '<', $end_date );
842 - }
843 - );
1239 + ->or_where_group(
1240 + function ( $q2 ) use ( $start_date, $end_date ) {
1241 + $q2->where( 'custom_date', '>', $start_date )
1242 + ->where( 'custom_date', '<', $end_date );
1243 + }
1244 + );
844 1245 }
845 1246 )
846 1247 ->to_array();
847 1248 $default_schedules = new Default_Schedule( $default_schedules );
@@ -846,27 +1247,96 @@
846 1247 ->to_array();
847 1248 $default_schedules = new Default_Schedule( $default_schedules );
848 1249
849 1250 $service = Service_Model::find( $service_id );
850 - $service_additional_services = ! empty( $service->additional_durations )
851 - ? array_filter(
852 - $service->additional_durations,
853 - function ( $duration ) use ( $additional_service_id ) {
854 - return $duration['id'] === $additional_service_id;
855 - }
856 - ) : array();
857 - $service_duration = ! empty( $service_additional_services ) ? reset( $service_additional_services )['duration_time'] : 30;
1251 + $service_duration = $this->get_service_duration_from_additional_durations( $service->additional_durations, $additional_duration_id );
1252 + $service_duration = apply_filters( 'booktics_service_duration_for_appointment_time_slots', $service_duration, $request );
1253 + $slot_length = $service->interval_time < 1 ? booktics_get_option( 'interval_time', 30 ) : $service->interval_time;
858 1254
859 - $slots = ( new Time_Slot_Manager( $service_schedules, $team_member_schedules, $default_schedules, $service_duration ) )
860 - ->generate_slots_for_range( $start_date, $end_date );
861 - $response = array(
862 - 'service_id' => $service_id,
863 - 'team_member_id' => $team_member_id,
864 - 'additional_service_id' => $additional_service_id,
865 - 'start_date' => $start_date,
866 - 'end_date' => $end_date,
867 - 'slots' => $slots,
1255 + // Get buffer times from service
1256 + $before_buffer_time = $service->before_buffer_time ? intval( $service->before_buffer_time ) : 0;
1257 + $after_buffer_time = $service->after_buffer_time ? intval( $service->after_buffer_time ) : 0;
1258 + $options = array(
1259 + 'default_schedule' => $default_schedules,
1260 + 'service_duration' => $service_duration,
1261 + 'slot_length' => $slot_length,
1262 + 'before_buffer_time' => $before_buffer_time,
1263 + 'after_buffer_time' => $after_buffer_time,
1264 + 'service_id' => $service_id,
1265 + 'total_attendees' => $total_attendees,
868 1266 );
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 );
869 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 + }
1289 +
1290 +
1291 + $response = array(
1292 + 'service_id' => $service_id,
1293 + 'team_member_id' => $team_member_id,
1294 + 'additional_duration_id' => $additional_duration_id,
1295 + 'start_date' => $start_date,
1296 + 'end_date' => $end_date,
1297 + 'slots' => $slots,
1298 + );
1299 +
870 1300 return $this->response( $response, __( 'Successfully fetched time slots', 'booktics' ) );
1301 + }
1302 +
1303 + /**
1304 + * Get service duration from additional durations using additional_duration_id
1305 + * If additional_duration_id is not found, return default duration (30 minutes)
1306 + *
1307 + * @param array $additional_durations Array of additional durations.
1308 + * @param int $additional_duration_id ID of additional duration.
1309 + *
1310 + * @return int Service duration in minutes.
1311 + */
1312 + private function get_service_duration_from_additional_durations( $additional_durations, $additional_duration_id ) {
1313 + $default_duration = 30;
1314 + if ( empty( $additional_durations ) ) {
1315 + return $default_duration; // Return default duration if additional durations are empty
1316 + }
1317 +
1318 + foreach ( $additional_durations as $additional_duration ) {
1319 + if ( $additional_duration['id'] === $additional_duration_id ) {
1320 + return $this->get_converted_duration( $additional_duration['duration_time'], $additional_duration['duration_unit'] );
1321 + }
1322 + }
1323 +
1324 + return $default_duration;
1325 + }
1326 +
1327 + /**
1328 + * Convert duration to minutes
1329 + *
1330 + * @param int $duration Duration in minutes or hours.
1331 + * @param string $unit Unit of duration (min or hour).
1332 + *
1333 + * @return int Duration in minutes.
1334 + */
1335 + private function get_converted_duration($duration, $unit) {
1336 + if ( $unit === 'min' ) {
1337 + return $duration;
1338 + } elseif ( $unit === 'hour' ) {
1339 + return $duration * 60;
1340 + }
871 1341 }
872 1342 }