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 +659 -216 1.0.0 → 1.0.29 View file →
@@ -7,12 +7,14 @@
7 7 use Booktics\Appointment\Schedule\Default_Schedule;
8 8 use Booktics\Appointment\Schedule\Service_Schedule;
9 9 use Booktics\Appointment\Schedule\Team_Member_Schedule;
10 10 use Booktics\Appointment\Schedule\Time_Slot_Manager;
11 +use Booktics\Appointment\Services\Appointment_Service;
12 +use Booktics\Appointment\Services\Appointment_Validator;
11 13 use Booktics\Models\Appointment_Model;
12 -use Booktics\Models\Customer_Model;
13 14 use Booktics\Models\Service_Model;
14 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' ),
@@ -212,9 +214,9 @@
212 214 * Get a collection of items.
213 215 *
214 216 * @param WP_REST_Request $request Full data about the request.
215 217 *
216 - * @return WP_Error|WP_REST_Response
218 + * @return WP_HTTP_Response
217 219 */
218 220 public function get_items( $request ) {
219 221 $per_page = ! empty( $request['per_page'] ) ? intval( $request['per_page'] ) : 10;
220 222 $paged = ! empty( $request['paged'] ) ? intval( $request['paged'] ) : 1;
@@ -225,9 +227,87 @@
225 227 $payment_status = ! empty( $request['payment_status'] ) ? sanitize_text_field( $request['payment_status'] ) : null;
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 - $args = array( 'meta_query' => array() );
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
233 + $args = array( 'meta_query' => array() );
234 +
235 + if ( $search ) {
236 + $service_ids = array();
237 + $customer_ids = array();
238 + $team_member_ids = array();
239 + // Search 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 + );
250 +
251 + // Search Customers
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 + );
260 + $customer_ids = $customer_query->get_results();
261 +
262 + // Search Team Members
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 + );
271 + $team_member_ids = $team_member_query->get_results();
272 +
273 + if ( $service_ids || $customer_ids || $team_member_ids ) {
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' );
276 + if ( $service_ids ) {
277 + $args['meta_query'][] = array(
278 + 'key' => 'service_id',
279 + 'value' => $service_ids,
280 + 'compare' => 'IN',
281 + );
282 + }
283 + if ( $customer_ids ) {
284 + $args['meta_query'][] = array(
285 + 'key' => 'customer_id',
286 + 'value' => $customer_ids,
287 + 'compare' => 'IN',
288 + );
289 + }
290 + if ( $team_member_ids ) {
291 + $args['meta_query'][] = array(
292 + 'key' => 'team_member_id',
293 + 'value' => $team_member_ids,
294 + 'compare' => 'IN',
295 + );
296 + }
297 + } else {
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 + );
307 + }
308 + }
309 +
230 310 if ( $date_from ) {
231 311 $args['meta_query'][] = array(
232 312 'key' => 'date',
233 313 'value' => $date_from,
@@ -267,12 +347,34 @@
267 347 'compare' => '=',
268 348 );
269 349 }
270 350
351 + // Adds filtering by appointment status
271 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
272 374 $args['meta_query'][] = array(
273 - 'key' => 'status',
274 - 'value' => $status,
375 + 'key' => 'customer_id',
376 + 'value' => $guest->id,
275 377 'compare' => '=',
276 378 );
277 379 }
278 380
@@ -277,8 +379,29 @@
277 379 }
278 380
279 381 $data = Appointment_Model::paginate( $per_page, $paged, $args );
280 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 +
281 404 if ( ! $data ) {
282 405 return $this->error( __( 'No appointment found', 'booktics' ), 404 );
283 406 }
284 407
@@ -300,8 +423,20 @@
300 423 if ( ! $appointment ) {
301 424 return $this->error( __( 'Appointment not found', 'booktics' ), 404 );
302 425 }
303 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 +
304 439 return $this->response( $appointment );
305 440 }
306 441
307 442 /**
@@ -318,9 +453,9 @@
318 453 // Check if custom date range is provided
319 454 if ( ! empty( $request['start_date'] ) && ! empty( $request['end_date'] ) ) {
320 455 $adjusted_start_date = sanitize_text_field( $request['start_date'] );
321 456 $adjusted_end_date = sanitize_text_field( $request['end_date'] );
322 - $month = date( 'Y-m', strtotime( $adjusted_start_date ) );
457 + $month = gmdate( 'Y-m', strtotime( $adjusted_start_date ) );
323 458 } else {
324 459 // Fall back to month-based date range with week adjustment
325 460 $month = ! empty( $request['month'] ) ? sanitize_text_field( $request['month'] ) : gmdate( 'Y-m' );
326 461 $start_date = gmdate( 'Y-m-01', strtotime( $month ) );
@@ -345,8 +480,9 @@
345 480 }
346 481
347 482 // Get all appointments within the date range using meta_query
348 483 $args = array(
484 + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Meta query is necessary for filtering appointments by meta fields
349 485 'meta_query' => array(
350 486 'relation' => 'AND',
351 487 array(
352 488 'key' => 'date',
@@ -351,9 +487,9 @@
351 487 array(
352 488 'key' => 'date',
353 489 'value' => array(
354 490 $adjusted_start_date,
355 - $adjusted_end_date
491 + $adjusted_end_date,
356 492 ),
357 493 'compare' => 'BETWEEN',
358 494 'type' => 'DATE',
359 495 ),
@@ -359,12 +495,87 @@
359 495 ),
360 496 ),
361 497 'posts_per_page' => - 1, // Get all appointments in the range
362 498 'orderby' => 'meta_value',
363 - 'meta_key' => 'date',
499 + 'meta_key' => 'date', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Required for ordering by date meta field
364 500 'order' => 'ASC',
365 501 );
366 502
503 + $service_id = ! empty( $request['service_id'] ) ? intval( $request['service_id'] ) : null;
504 + $customer_id = ! empty( $request['customer_id'] ) ? intval( $request['customer_id'] ) : null;
505 + $payment_status = ! empty( $request['payment_status'] ) ? sanitize_text_field( $request['payment_status'] ) : null;
506 + $team_member_id = ! empty( $request['team_member_id'] ) ? intval( $request['team_member_id'] ) : null;
507 + $status = ! empty( $request['status'] ) ? sanitize_text_field( $request['status'] ) : null;
508 +
509 + if ( $service_id ) {
510 + $args['meta_query'][] = array(
511 + 'key' => 'service_id',
512 + 'value' => $service_id,
513 + 'compare' => '=',
514 + );
515 + }
516 +
517 + if ( $customer_id ) {
518 + $args['meta_query'][] = array(
519 + 'key' => 'customer_id',
520 + 'value' => $customer_id,
521 + 'compare' => '=',
522 + );
523 + }
524 +
525 + if ( $payment_status ) {
526 + $args['meta_query'][] = array(
527 + 'key' => 'payment_status',
528 + 'value' => $payment_status,
529 + 'compare' => '=',
530 + );
531 + }
532 +
533 + if ( $team_member_id ) {
534 + $args['meta_query'][] = array(
535 + 'key' => 'team_member_id',
536 + 'value' => $team_member_id,
537 + 'compare' => '=',
538 + );
539 + }
540 +
541 + if ( $status ) {
542 + $args['meta_query'][] = array(
543 + 'key' => 'status',
544 + 'value' => $status,
545 + 'compare' => '=',
546 + );
547 + }
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 +
367 578 $appointments = Appointment_Model::paginate( $per_page, $paged, $args );
368 579
369 580 // Format the response
370 581 $formatted_appointments = array();
@@ -372,8 +583,16 @@
372 583 if ( ! empty( $appointments ) ) {
373 584 foreach ( $appointments['items'] as $appointment ) {
374 585 if ( ! empty( $appointment ) ) {
375 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 + }
376 595 }
377 596
378 597 $formatted_appointments[] = $appointment;
379 598 }
@@ -408,9 +627,31 @@
408 627 *
409 628 * @return WP_Error|boolean
410 629 */
411 630 public function get_item_permissions_check( $request ) {
412 - 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 + );
413 654 }
414 655
415 656 /**
416 657 * Check if a given request has access to get items.
@@ -419,9 +660,21 @@
419 660 *
420 661 * @return WP_Error|boolean
421 662 */
422 663 public function get_items_permissions_check( $request ) {
423 - 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 + );
424 677 }
425 678
426 679 /**
427 680 * Create one item from the collection.
@@ -431,20 +684,47 @@
431 684 * @return WP_Error|WP_REST_Response
432 685 * @throws Exception
433 686 */
434 687 public function create_item( $request ) {
435 - $data = $this->prepare_item_for_database( $request );
688 + $data = json_decode( $request->get_body(), true );
436 689
437 - if ( is_wp_error( $data ) ) {
438 - return $this->error( $data->get_error_message() );
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' ) );
439 712 }
440 713
441 - $appointment = Appointment_Model::create( $data );
714 + $error = Appointment_Validator::validate_create( $data );
715 + if ( $error ) {
716 + return $this->error( $error->get_error_message() );
717 + }
442 718
719 + $conflict = Appointment_Validator::validate_slot_available( $data );
720 + if ( $conflict ) {
721 + return $this->error( $conflict->get_error_message() );
722 + }
723 + $appointment = Appointment_Service::instance()->create( $data );
443 724 if ( is_wp_error( $appointment ) ) {
444 725 return $this->error( $appointment->get_error_message() );
445 726 }
446 -
447 727 do_action( 'booktics_after_appointment_created', $appointment );
448 728
449 729 return $this->response( $appointment, __( 'Successfully created appointment', 'booktics' ) );
450 730 }
@@ -467,49 +747,20 @@
467 747 *
468 748 * @return WP_REST_Response
469 749 */
470 750 public function update_item( $request ) {
471 - $id = intval( $request['id'] );
472 - $data = json_decode( $request->get_body(), true );
473 -
474 - if ( empty( $data ) || ! is_array( $data ) ) {
475 - return $this->error( __( 'No data provided for update', 'booktics' ), 400 );
751 + $id = intval( $request['id'] );
752 + $data = json_decode( $request->get_body(), true );
753 + $error = Appointment_Validator::validate_update( $data );
754 + if ( $error ) {
755 + return $this->error( $error->get_error_message() );
476 756 }
477 -
478 - // Get the appointment to update
479 - $appointment = Appointment_Model::find( $id );
480 -
481 - if ( ! $appointment ) {
482 - return $this->error( __( 'Appointment not found', 'booktics' ), 404 );
483 - }
484 -
485 - if ( isset( $data ['status'] ) ) {
486 - if ( ! in_array( $data ['status'], Appointment_Model::get_accepted_statuses() ) ) {
487 - return $this->error( __( 'Invalid status', 'booktics' ), 400 );
488 - }
489 - $appointment->status = $data ['status'];
490 - }
491 -
492 - if ( ! empty( $data['customer'] ) ) {
493 - $customer = ( new Customer_Model() )->find( $appointment->customer_id );
494 - if ( $customer ) {
495 - $updated_customer = $customer->update( $data['customer'] );
496 - unset( $data['customer'] );
497 - }
498 - }
499 -
500 - $updated_appointment = $appointment->update( $data );
501 -
502 - if ( isset( $updated_customer ) ) {
503 - $updated_appointment->customer = $updated_customer;
504 - }
505 -
757 + $updated_appointment = Appointment_Service::instance()->update( $id, $data );
506 758 if ( is_wp_error( $updated_appointment ) ) {
507 759 return $this->error( $updated_appointment->get_error_message() );
508 760 }
761 + do_action( 'booktics_after_appointment_updated', $updated_appointment );
509 762
510 - do_action( 'booktics_after_appointment_updated', $appointment );
511 -
512 763 return $this->response( $updated_appointment, __( 'Appointment updated successfully', 'booktics' ) );
513 764 }
514 765
515 766 /**
@@ -530,25 +781,16 @@
530 781 *
531 782 * @return \WP_Error|\WP_HTTP_Response
532 783 */
533 784 public function delete_item( $request ) {
534 - $id = intval( $request['id'] );
535 -
536 - $appointment = Appointment_Model::find( $id );
537 -
538 - if ( ! $appointment ) {
539 - return $this->error( __( 'Appointment not found', 'booktics' ), 404 );
785 + $id = intval( $request['id'] );
786 + $result = Appointment_Service::instance()->delete( $id );
787 + if ( is_wp_error( $result ) ) {
788 + return $this->error( $result->get_error_message() );
540 789 }
790 + do_action( 'booktics_after_appointment_deleted', $result );
541 791
542 - $deleted = $appointment->delete();
543 -
544 - if ( is_wp_error( $deleted ) ) {
545 - return $this->error( $deleted->get_error_message() );
546 - }
547 -
548 - do_action( 'booktics_after_appointment_deleted', $appointment );
549 -
550 - return $this->response( $appointment, __( 'Successfully deleted appointment', 'booktics' ) );
792 + return $this->response( $result, __( 'Successfully deleted appointment', 'booktics' ) );
551 793 }
552 794
553 795 /**
554 796 * Check if a given request has access to delete a specific item.
@@ -566,33 +808,15 @@
566 808 *
567 809 * @param WP_Rest_Request $request User request
568 810 */
569 811 public function delete_items( $request ) {
570 - $ids = $request['ids'];
571 -
572 - if ( ! is_array( $ids ) ) {
573 - return $this->error( __( 'Ids should be an array of items ids', 'booktics' ) );
812 + $ids = $request['ids'];
813 + $result = Appointment_Service::instance()->delete_many( $ids );
814 + if ( is_wp_error( $result ) ) {
815 + return $this->error( $result->get_error_message() );
574 816 }
575 817
576 - $count = 0;
577 - $total = count( $ids );
578 -
579 - foreach ( $ids as $id ) {
580 - $appointment = Appointment_Model::find( $id );
581 -
582 - if ( $appointment ) {
583 - $appointment->delete();
584 - ++ $count;
585 - }
586 - }
587 -
588 - if ( $count === 0 ) {
589 - return $this->error( __( 'No item deleted.', 'booktics' ) );
590 - }
591 -
592 - $message = sprintf( 'Deleted %d items of %d', $count, $total );
593 -
594 - return $this->response( array(), $message );
818 + return $this->response( $result['data'], $result['message'] );
595 819 }
596 820
597 821 /**
598 822 * Check if a given request has access to delete a collection of items.
@@ -615,79 +839,70 @@
615 839 public function reschedule_item( $request ) {
616 840 $id = intval( $request['id'] );
617 841 $data = json_decode( $request->get_body(), true );
618 842
619 - $validate = booktics_validate(
620 - $data,
621 - array(
622 - 'start_time' => array( 'required' ),
623 - 'end_time' => array( 'required' ),
624 - 'date' => array( 'required' ),
625 - )
626 - );
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 );
627 855
628 - if ( is_wp_error( $validate ) ) {
629 - return $this->error( $validate->get_error_message() );
856 + if ( is_wp_error( $data ) ) {
857 + return $this->error( $data->get_error_message() );
630 858 }
631 859
632 - $appointment = Appointment_Model::find( $id );
633 - if ( ! $appointment ) {
634 - return $this->error( __( 'Appointment not found', 'booktics' ), 404 );
860 + if ( ! is_array( $data ) ) {
861 + return $this->error( __( 'Invalid reschedule data.', 'booktics' ) );
635 862 }
636 863
637 - $db = new Booktics_Database();
638 - $service_schedules = $db->table( 'booktics_schedules' )
639 - ->where( 'service_id', '=', $appointment->service_id )
640 - ->where( 'custom_date', '=', '0' )
641 - ->to_array();
642 - $service_schedules = new Service_Schedule( $service_schedules, $appointment->service_id );
864 + $error = Appointment_Validator::validate_reschedule( $data, $id );
865 + if ( $error ) {
866 + return $this->error( $error->get_error_message() );
867 + }
643 868
644 - $team_member_schedules = $db->table( 'booktics_schedules' )
645 - ->where( 'team_member_id', '=', $appointment->team_member_id )
646 - ->where( 'custom_date', '=', '0' )
647 - ->to_array();
648 - $team_member_schedules = new Team_Member_Schedule( $team_member_schedules, $appointment->team_member_id );
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 );
649 877
650 - $default_schedules = $db->table( 'booktics_schedules' )
651 - ->where( 'service_id', '=', 0 )
652 - ->where( 'team_member_id', '=', 0 )
653 - ->where( 'custom_date', '=', '0' )
654 - ->to_array();
655 - $default_schedules = new Default_Schedule( $default_schedules );
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 + );
656 890
657 - $slots = ( new Time_Slot_Manager( $service_schedules, $team_member_schedules, $default_schedules ) )
658 - ->generate_slots_for_range( $data['date'], $data['date'] );
659 -
660 - if ( is_wp_error( $validate ) ) {
661 - return $this->error( $validate->get_error_message() );
891 + if ( $conflict ) {
892 + return $this->error( $conflict->get_error_message() );
893 + }
662 894 }
895 + $result = Appointment_Service::instance()->reschedule( $id, $data );
896 + if ( is_wp_error( $result ) ) {
897 + return $this->error( $result->get_error_message() );
898 + }
663 899
664 - $updated = $appointment->update(
665 - array(
666 - 'start_time' => sanitize_text_field( $data['start_time'] ),
667 - 'end_time' => sanitize_text_field( $data['end_time'] ),
668 - 'date' => sanitize_text_field( $data['date'] ),
669 - )
670 - );
900 + $this->send_reschedule_email( $result );
671 901
672 - if ( is_wp_error( $updated ) ) {
673 - return $this->error( __( 'Appointment reschedule error.', 'booktics' ) );
674 - }
902 + do_action( 'booktics_after_appointment_rescheduled', $result );
675 903
676 - $service = Service_Model::find( $appointment->service_id );
677 - $team_member = ( new Team_Member_Model() )->find( $appointment->team_member_id );
678 - $customer = ( new Customer_Model() )->find( $appointment->customer_id );
679 - do_action( 'booktics_after_appointment_rescheduled', $appointment );
680 - do_action( 'global_notification_hook', 'appointment_rescheduled', [
681 - 'appointment_service' => $service->title,
682 - 'date' => $appointment->date,
683 - 'appointment_team_member' => $team_member->display_name,
684 - 'start_time' => $appointment->start_time,
685 - 'end_time' => $appointment->end_time,
686 - 'user_email' => $customer->user_email,
687 - ] );
688 -
689 - return $this->response( $appointment, __( 'Successfully rescheduled appointment', 'booktics' ) );
904 + return $this->response( $result, __( 'Successfully rescheduled appointment', 'booktics' ) );
690 905 }
691 906
692 907 /**
693 908 * Check if a given request has access to reschedule a specific item.
@@ -694,12 +909,41 @@
694 909 *
695 910 * @param $request Full data about the request.
696 911 */
697 912 public function reschedule_item_permissions_check( $request ) {
698 - return true;
913 + return $this->check_appointment_owner(
914 + $request,
915 + __( 'You are not allowed to reschedule this appointment.', 'booktics' )
916 + );
699 917 }
700 918
701 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 + /**
702 946 * Cancel an appointment
703 947 *
704 948 * @param WP_REST_Request $request Full data about the request.
705 949 *
@@ -705,38 +949,60 @@
705 949 *
706 950 * @throws Exception
707 951 */
708 952 public function cancel_item( $request ) {
709 - $id = intval( $request['id'] );
953 + $id = intval( $request['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 + }
710 962
711 - $appointment = Appointment_Model::find( $id );
963 + $result = Appointment_Service::instance()->cancel( $id, [ 'cancel_note' => $cancel_note ] );
712 964
713 - if ( ! $appointment ) {
714 - return $this->error( __( 'Appointment not found', 'booktics' ), 404 );
965 + if ( is_wp_error( $result ) ) {
966 + return $this->error( $result->get_error_message() );
715 967 }
716 - $updated = $appointment->update( array( 'status' => 'cancelled' ) );
717 968
718 - if ( is_wp_error( $updated ) ) {
719 - return $this->error( __( 'Appointment cancelletion error.', 'booktics' ) );
720 - }
721 - $service = Service_Model::find( $appointment->service_id );
722 - $team_member = ( new Team_Member_Model() )->find( $appointment->team_member_id );
723 - $customer = ( new Customer_Model() )->find( $appointment->customer_id );
969 + $this->send_cancel_email( $result );
724 970
725 - do_action( 'booktics_after_appointment_cancelled', $appointment );
726 - do_action( 'global_notification_hook', 'appointment_cancelled', [
727 - 'appointment_service' => $service->title,
728 - 'date' => $appointment->date,
729 - 'appointment_team_member' => $team_member->display_name,
730 - 'start_time' => $appointment->start_time,
731 - 'end_time' => $appointment->end_time,
732 - 'user_email' => $customer->user_email,
733 - ] );
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.
734 974
735 - return $this->response( $appointment, __( 'Successfully cancelled appointment', 'booktics' ) );
975 + return $this->response( $result, __( 'Successfully cancelled appointment', 'booktics' ) );
736 976 }
737 977
738 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 + /**
739 1005 * Check if a given request has access to cancel a specific item.
740 1006 *
741 1007 * @param WP_REST_Request $request Full data about the request.
742 1008 *
@@ -742,12 +1008,68 @@
742 1008 *
743 1009 * @return WP_Error|boolean
744 1010 */
745 1011 public function cancel_item_permissions_check( $request ) {
746 - 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 + );
747 1016 }
748 1017
749 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 + /**
750 1072 * Prepare the item for create or update operation.
751 1073 *
752 1074 * @param WP_REST_Request $request Request object.
753 1075 *
@@ -796,8 +1118,32 @@
796 1118 return $data;
797 1119 }
798 1120
799 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 + /**
800 1146 * Check if a given request has access to get time slots.
801 1147 *
802 1148 * @param $request
803 1149 *
@@ -815,60 +1161,88 @@
815 1161 * @return array|\WP_HTTP_Response
816 1162 * @throws Exception If there's an error getting time slots.
817 1163 */
818 1164 public function get_time_slots( $request ) {
819 - $service_id = $request['service_id'];
820 - $additional_service_id = $request['additional_service_id'];
821 - $team_member_id = $request['team_member_id'];
822 - $start_date = $request['start_date'];
823 - $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'] ?? '' );
824 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 +
825 1199 $db = new Booktics_Database();
826 1200
827 - $service_schedules = $db->table( 'booktics_schedules' )
1201 + $service_schedules = $db->table( booktics_get_table_name( 'booktics_schedules' ) )
828 1202 ->where( 'service_id', '=', $service_id )
829 1203 ->where_group(
830 1204 function ( $q ) use ( $start_date, $end_date ) {
831 1205 $q->where( 'custom_date', '=', '0' )
832 - ->or_where_group(
833 - function ( $q2 ) use ( $start_date, $end_date ) {
834 - $q2->where( 'custom_date', '>', $start_date )
835 - ->where( 'custom_date', '<', $end_date );
836 - }
837 - );
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 + );
838 1212 }
839 1213 )
840 1214 ->to_array();
841 1215 $service_schedules = new Service_Schedule( $service_schedules, $service_id );
842 1216
843 - $team_member_schedules = $db->table( 'booktics_schedules' )
1217 + $team_member_schedules = $db->table( booktics_get_table_name( 'booktics_schedules' ) )
844 1218 ->where( 'team_member_id', '=', $team_member_id )
845 1219 ->where_group(
846 1220 function ( $q ) use ( $start_date, $end_date ) {
847 1221 $q->where( 'custom_date', '=', '0' )
848 - ->or_where_group(
849 - function ( $q2 ) use ( $start_date, $end_date ) {
850 - $q2->where( 'custom_date', '>', $start_date )
851 - ->where( 'custom_date', '<', $end_date );
852 - }
853 - );
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 + );
854 1228 }
855 1229 )
856 1230 ->to_array();
857 1231 $team_member_schedules = new Team_Member_Schedule( $team_member_schedules, $team_member_id );
858 1232
859 - $default_schedules = $db->table( 'booktics_schedules' )
1233 + $default_schedules = $db->table( booktics_get_table_name( 'booktics_schedules' ) )
860 1234 ->where( 'service_id', '=', 0 )
861 1235 ->where( 'team_member_id', '=', 0 )
862 1236 ->where_group(
863 1237 function ( $q ) use ( $start_date, $end_date ) {
864 1238 $q->where( 'custom_date', '=', '0' )
865 - ->or_where_group(
866 - function ( $q2 ) use ( $start_date, $end_date ) {
867 - $q2->where( 'custom_date', '>', $start_date )
868 - ->where( 'custom_date', '<', $end_date );
869 - }
870 - );
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 + );
871 1245 }
872 1246 )
873 1247 ->to_array();
874 1248 $default_schedules = new Default_Schedule( $default_schedules );
@@ -873,27 +1247,96 @@
873 1247 ->to_array();
874 1248 $default_schedules = new Default_Schedule( $default_schedules );
875 1249
876 1250 $service = Service_Model::find( $service_id );
877 - $service_additional_services = ! empty( $service->additional_durations )
878 - ? array_filter(
879 - $service->additional_durations,
880 - function ( $duration ) use ( $additional_service_id ) {
881 - return $duration['id'] === $additional_service_id;
882 - }
883 - ) : array();
884 - $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;
885 1254
886 - $slots = ( new Time_Slot_Manager( $service_schedules, $team_member_schedules, $default_schedules, $service_duration ) )
887 - ->generate_slots_for_range( $start_date, $end_date );
888 - $response = array(
889 - 'service_id' => $service_id,
890 - 'team_member_id' => $team_member_id,
891 - 'additional_service_id' => $additional_service_id,
892 - 'start_date' => $start_date,
893 - 'end_date' => $end_date,
894 - '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,
895 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 );
896 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 +
897 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 + }
898 1341 }
899 1342 }