PluginProbe
Booktics – Appointment Booking Calendar for Service Businesses / 1.0.27
Booktics – Appointment Booking Calendar for Service Businesses v1.0.27
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 +619 -216 1.0.0 → 1.0.27 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,24 @@
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 + }
398 +
281 399 if ( ! $data ) {
282 400 return $this->error( __( 'No appointment found', 'booktics' ), 404 );
283 401 }
284 402
@@ -300,8 +418,20 @@
300 418 if ( ! $appointment ) {
301 419 return $this->error( __( 'Appointment not found', 'booktics' ), 404 );
302 420 }
303 421
422 + // Mask team member email for customers (non-admin/team-member)
423 + if ( ! booktics_is_admin_or_team_member() && is_user_logged_in() ) {
424 + $appointment_array = is_array( $appointment ) ? $appointment : ( is_object( $appointment ) ? get_object_vars( $appointment ) : array() );
425 + if ( isset( $appointment_array['team_member_id'] ) ) {
426 + $team_member = new Team_Member_Model( $appointment_array['team_member_id'] );
427 + $team_member_data = $team_member->find( $appointment_array['team_member_id'] );
428 + if ( $team_member_data && isset( $team_member_data->user_email ) ) {
429 + // We can't easily unset from the object, but we can return a modified response
430 + }
431 + }
432 + }
433 +
304 434 return $this->response( $appointment );
305 435 }
306 436
307 437 /**
@@ -318,9 +448,9 @@
318 448 // Check if custom date range is provided
319 449 if ( ! empty( $request['start_date'] ) && ! empty( $request['end_date'] ) ) {
320 450 $adjusted_start_date = sanitize_text_field( $request['start_date'] );
321 451 $adjusted_end_date = sanitize_text_field( $request['end_date'] );
322 - $month = date( 'Y-m', strtotime( $adjusted_start_date ) );
452 + $month = gmdate( 'Y-m', strtotime( $adjusted_start_date ) );
323 453 } else {
324 454 // Fall back to month-based date range with week adjustment
325 455 $month = ! empty( $request['month'] ) ? sanitize_text_field( $request['month'] ) : gmdate( 'Y-m' );
326 456 $start_date = gmdate( 'Y-m-01', strtotime( $month ) );
@@ -345,8 +475,9 @@
345 475 }
346 476
347 477 // Get all appointments within the date range using meta_query
348 478 $args = array(
479 + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Meta query is necessary for filtering appointments by meta fields
349 480 'meta_query' => array(
350 481 'relation' => 'AND',
351 482 array(
352 483 'key' => 'date',
@@ -351,9 +482,9 @@
351 482 array(
352 483 'key' => 'date',
353 484 'value' => array(
354 485 $adjusted_start_date,
355 - $adjusted_end_date
486 + $adjusted_end_date,
356 487 ),
357 488 'compare' => 'BETWEEN',
358 489 'type' => 'DATE',
359 490 ),
@@ -359,12 +490,60 @@
359 490 ),
360 491 ),
361 492 'posts_per_page' => - 1, // Get all appointments in the range
362 493 'orderby' => 'meta_value',
363 - 'meta_key' => 'date',
494 + 'meta_key' => 'date', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Required for ordering by date meta field
364 495 'order' => 'ASC',
365 496 );
366 497
498 + $service_id = ! empty( $request['service_id'] ) ? intval( $request['service_id'] ) : null;
499 + $customer_id = ! empty( $request['customer_id'] ) ? intval( $request['customer_id'] ) : null;
500 + $payment_status = ! empty( $request['payment_status'] ) ? sanitize_text_field( $request['payment_status'] ) : null;
501 + $team_member_id = ! empty( $request['team_member_id'] ) ? intval( $request['team_member_id'] ) : null;
502 + $status = ! empty( $request['status'] ) ? sanitize_text_field( $request['status'] ) : null;
503 +
504 + if ( $service_id ) {
505 + $args['meta_query'][] = array(
506 + 'key' => 'service_id',
507 + 'value' => $service_id,
508 + 'compare' => '=',
509 + );
510 + }
511 +
512 + if ( $customer_id ) {
513 + $args['meta_query'][] = array(
514 + 'key' => 'customer_id',
515 + 'value' => $customer_id,
516 + 'compare' => '=',
517 + );
518 + }
519 +
520 + if ( $payment_status ) {
521 + $args['meta_query'][] = array(
522 + 'key' => 'payment_status',
523 + 'value' => $payment_status,
524 + 'compare' => '=',
525 + );
526 + }
527 +
528 + if ( $team_member_id ) {
529 + $args['meta_query'][] = array(
530 + 'key' => 'team_member_id',
531 + 'value' => $team_member_id,
532 + 'compare' => '=',
533 + );
534 + }
535 +
536 + if ( $status ) {
537 + $args['meta_query'][] = array(
538 + 'key' => 'status',
539 + 'value' => $status,
540 + 'compare' => '=',
541 + );
542 + }
543 +
544 + $args = apply_filters( 'booktics_appointment_args', $args );
545 +
367 546 $appointments = Appointment_Model::paginate( $per_page, $paged, $args );
368 547
369 548 // Format the response
370 549 $formatted_appointments = array();
@@ -408,9 +587,31 @@
408 587 *
409 588 * @return WP_Error|boolean
410 589 */
411 590 public function get_item_permissions_check( $request ) {
412 - return true;
591 + if ( booktics_is_admin_or_team_member() ) {
592 + return true;
593 + }
594 +
595 + if ( is_user_logged_in() ) {
596 + $appointment_id = intval( $request['id'] );
597 + $appointment = Appointment_Model::find( $appointment_id );
598 +
599 + if ( $appointment ) {
600 + $db = new Booktics_Database();
601 + $customer = ( new Guest_Model( $db ) )->find( array( 'id' => $appointment->customer_id ) );
602 +
603 + if ( $customer && intval( $customer->wp_user_id ) === get_current_user_id() ) {
604 + return true;
605 + }
606 + }
607 + }
608 +
609 + return new WP_Error(
610 + 'booktics_forbidden',
611 + __( 'You are not allowed to view this appointment.', 'booktics' ),
612 + array( 'status' => 403 )
613 + );
413 614 }
414 615
415 616 /**
416 617 * Check if a given request has access to get items.
@@ -419,9 +620,21 @@
419 620 *
420 621 * @return WP_Error|boolean
421 622 */
422 623 public function get_items_permissions_check( $request ) {
423 - return true;
624 + if ( booktics_is_admin_or_team_member() ) {
625 + return true;
626 + }
627 +
628 + if ( is_user_logged_in() ) {
629 + return true;
630 + }
631 +
632 + return new WP_Error(
633 + 'booktics_forbidden',
634 + __( 'You are not allowed to view appointments.', 'booktics' ),
635 + array( 'status' => 403 )
636 + );
424 637 }
425 638
426 639 /**
427 640 * Create one item from the collection.
@@ -431,20 +644,47 @@
431 644 * @return WP_Error|WP_REST_Response
432 645 * @throws Exception
433 646 */
434 647 public function create_item( $request ) {
435 - $data = $this->prepare_item_for_database( $request );
648 + $data = json_decode( $request->get_body(), true );
436 649
437 - if ( is_wp_error( $data ) ) {
438 - return $this->error( $data->get_error_message() );
650 + /**
651 + * Filters appointment data before it is validated and created.
652 + *
653 + * Runs before validation so an extension can supply required fields.
654 + * Booktics Pro round robin fills team_member_id here when a service
655 + * assigns its team members automatically.
656 + *
657 + * $data is the raw decoded request body: it has not been sanitised and
658 + * every key reaches Appointment_Service::create(). A filter is
659 + * responsible for sanitising whatever it adds.
660 + *
661 + * @since 1.0.26
662 + *
663 + * @param array $data Decoded request body.
664 + * @param \WP_REST_Request $request The create request.
665 + */
666 + $data = apply_filters( 'booktics_appointment_create_data', $data, $request );
667 +
668 + // A malformed body decodes to null, and a filter can return anything at
669 + // all. validate_create() is typed array, so either would be a fatal.
670 + if ( ! is_array( $data ) ) {
671 + return $this->error( __( 'Invalid appointment data.', 'booktics' ) );
439 672 }
440 673
441 - $appointment = Appointment_Model::create( $data );
674 + $error = Appointment_Validator::validate_create( $data );
675 + if ( $error ) {
676 + return $this->error( $error->get_error_message() );
677 + }
442 678
679 + $conflict = Appointment_Validator::validate_slot_available( $data );
680 + if ( $conflict ) {
681 + return $this->error( $conflict->get_error_message() );
682 + }
683 + $appointment = Appointment_Service::instance()->create( $data );
443 684 if ( is_wp_error( $appointment ) ) {
444 685 return $this->error( $appointment->get_error_message() );
445 686 }
446 -
447 687 do_action( 'booktics_after_appointment_created', $appointment );
448 688
449 689 return $this->response( $appointment, __( 'Successfully created appointment', 'booktics' ) );
450 690 }
@@ -467,49 +707,20 @@
467 707 *
468 708 * @return WP_REST_Response
469 709 */
470 710 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 );
711 + $id = intval( $request['id'] );
712 + $data = json_decode( $request->get_body(), true );
713 + $error = Appointment_Validator::validate_update( $data );
714 + if ( $error ) {
715 + return $this->error( $error->get_error_message() );
476 716 }
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 -
717 + $updated_appointment = Appointment_Service::instance()->update( $id, $data );
506 718 if ( is_wp_error( $updated_appointment ) ) {
507 719 return $this->error( $updated_appointment->get_error_message() );
508 720 }
721 + do_action( 'booktics_after_appointment_updated', $updated_appointment );
509 722
510 - do_action( 'booktics_after_appointment_updated', $appointment );
511 -
512 723 return $this->response( $updated_appointment, __( 'Appointment updated successfully', 'booktics' ) );
513 724 }
514 725
515 726 /**
@@ -530,25 +741,16 @@
530 741 *
531 742 * @return \WP_Error|\WP_HTTP_Response
532 743 */
533 744 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 );
745 + $id = intval( $request['id'] );
746 + $result = Appointment_Service::instance()->delete( $id );
747 + if ( is_wp_error( $result ) ) {
748 + return $this->error( $result->get_error_message() );
540 749 }
750 + do_action( 'booktics_after_appointment_deleted', $result );
541 751
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' ) );
752 + return $this->response( $result, __( 'Successfully deleted appointment', 'booktics' ) );
551 753 }
552 754
553 755 /**
554 756 * Check if a given request has access to delete a specific item.
@@ -566,33 +768,15 @@
566 768 *
567 769 * @param WP_Rest_Request $request User request
568 770 */
569 771 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' ) );
772 + $ids = $request['ids'];
773 + $result = Appointment_Service::instance()->delete_many( $ids );
774 + if ( is_wp_error( $result ) ) {
775 + return $this->error( $result->get_error_message() );
574 776 }
575 777
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 );
778 + return $this->response( $result['data'], $result['message'] );
595 779 }
596 780
597 781 /**
598 782 * Check if a given request has access to delete a collection of items.
@@ -615,79 +799,70 @@
615 799 public function reschedule_item( $request ) {
616 800 $id = intval( $request['id'] );
617 801 $data = json_decode( $request->get_body(), true );
618 802
619 - $validate = booktics_validate(
620 - $data,
621 - array(
622 - 'start_time' => array( 'required' ),
623 - 'end_time' => array( 'required' ),
624 - 'date' => array( 'required' ),
625 - )
626 - );
803 + /**
804 + * Filters reschedule data before validation.
805 + *
806 + * Returning a WP_Error refuses the reschedule with that message, which
807 + * is how Booktics Pro keeps a booking with its original team member.
808 + *
809 + * @since 1.0.26
810 + *
811 + * @param array|\WP_Error $data Reschedule data.
812 + * @param int $id Appointment being moved.
813 + */
814 + $data = apply_filters( 'booktics_appointment_reschedule_data', $data, $id );
627 815
628 - if ( is_wp_error( $validate ) ) {
629 - return $this->error( $validate->get_error_message() );
816 + if ( is_wp_error( $data ) ) {
817 + return $this->error( $data->get_error_message() );
630 818 }
631 819
632 - $appointment = Appointment_Model::find( $id );
633 - if ( ! $appointment ) {
634 - return $this->error( __( 'Appointment not found', 'booktics' ), 404 );
820 + if ( ! is_array( $data ) ) {
821 + return $this->error( __( 'Invalid reschedule data.', 'booktics' ) );
635 822 }
636 823
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 );
824 + $error = Appointment_Validator::validate_reschedule( $data, $id );
825 + if ( $error ) {
826 + return $this->error( $error->get_error_message() );
827 + }
643 828
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 );
829 + // Same guard as create_item(): a reschedule is a booking too, and the
830 + // owning customer can reach this route. The reschedule payload only
831 + // carries the new date and time, so the stored appointment supplies
832 + // service, duration and attendees, without which the check would fall
833 + // back to a 30 minute window and a capacity of one and could refuse a
834 + // group booking that still has room. Its own id is passed so the
835 + // booking is never counted as a conflict with itself.
836 + $moving = Appointment_Model::find( $id );
649 837
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 );
838 + if ( $moving ) {
839 + $conflict = Appointment_Validator::validate_slot_available(
840 + array(
841 + 'id' => $id,
842 + 'date' => $data['date'] ?? $moving->date,
843 + 'start_time' => $data['start_time'] ?? $moving->start_time,
844 + 'team_member_id' => $data['team_member_id'] ?? $moving->team_member_id,
845 + 'service_id' => $moving->service_id,
846 + 'duration' => $moving->duration,
847 + 'total_attendees' => $moving->total_attendees,
848 + )
849 + );
656 850
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() );
851 + if ( $conflict ) {
852 + return $this->error( $conflict->get_error_message() );
853 + }
662 854 }
855 + $result = Appointment_Service::instance()->reschedule( $id, $data );
856 + if ( is_wp_error( $result ) ) {
857 + return $this->error( $result->get_error_message() );
858 + }
663 859
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 - );
860 + $this->send_reschedule_email( $result );
671 861
672 - if ( is_wp_error( $updated ) ) {
673 - return $this->error( __( 'Appointment reschedule error.', 'booktics' ) );
674 - }
862 + do_action( 'booktics_after_appointment_rescheduled', $result );
675 863
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' ) );
864 + return $this->response( $result, __( 'Successfully rescheduled appointment', 'booktics' ) );
690 865 }
691 866
692 867 /**
693 868 * Check if a given request has access to reschedule a specific item.
@@ -694,12 +869,41 @@
694 869 *
695 870 * @param $request Full data about the request.
696 871 */
697 872 public function reschedule_item_permissions_check( $request ) {
698 - return true;
873 + return $this->check_appointment_owner(
874 + $request,
875 + __( 'You are not allowed to reschedule this appointment.', 'booktics' )
876 + );
699 877 }
700 878
701 879 /**
880 + * Send reschedule email to customer
881 + *
882 + * @param Appointment_Model $appointment Appointment model.
883 + */
884 + private function send_reschedule_email( $appointment ) {
885 + $db = new Booktics_Database();
886 + $customer = ( new Guest_Model( $db ) )->find( [ 'id' => $appointment->customer_id ] );
887 + $team_member = new Team_Member_Model( $appointment->team_member_id );
888 +
889 + $appointment_reschedule_email_data = [
890 + 'date' => $appointment->date ?? '',
891 + 'start_time' => $appointment->start_time ?? '',
892 + 'end_time' => $appointment->end_time ?? '',
893 + 'customer_name' => $customer->name ?? '',
894 + 'user_email' => $customer->email ?? '',
895 + 'appointment_service' => $appointment->title ?? '',
896 + 'appointment_team_member' => $team_member->display_name ?? '',
897 + 'appointment_team_member_email' => $team_member->user_email ?? '',
898 + ];
899 +
900 + $appointment_reschedule_email_data = apply_filters( 'booktics_appointment_reschedule_email_data', $appointment_reschedule_email_data );
901 +
902 + do_action( 'booktics_gln_hook', 'appointment_rescheduled', $appointment_reschedule_email_data );
903 + }
904 +
905 + /**
702 906 * Cancel an appointment
703 907 *
704 908 * @param WP_REST_Request $request Full data about the request.
705 909 *
@@ -705,38 +909,60 @@
705 909 *
706 910 * @throws Exception
707 911 */
708 912 public function cancel_item( $request ) {
709 - $id = intval( $request['id'] );
913 + $id = intval( $request['id'] );
914 + $data = json_decode( $request->get_body(), true );
915 + $cancel_note = ! empty( $data['cancel_note'] ) ? sanitize_text_field( $data['cancel_note'] ) : '';
916 +
917 + $error = Appointment_Validator::instance()->validate_cancel( $id );
918 +
919 + if ( $error ) {
920 + return $this->error( $error->get_error_message() );
921 + }
710 922
711 - $appointment = Appointment_Model::find( $id );
923 + $result = Appointment_Service::instance()->cancel( $id, [ 'cancel_note' => $cancel_note ] );
712 924
713 - if ( ! $appointment ) {
714 - return $this->error( __( 'Appointment not found', 'booktics' ), 404 );
925 + if ( is_wp_error( $result ) ) {
926 + return $this->error( $result->get_error_message() );
715 927 }
716 - $updated = $appointment->update( array( 'status' => 'cancelled' ) );
717 928
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 );
929 + $this->send_cancel_email( $result );
724 930
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 - ] );
931 + // Note: booktics_after_appointment_cancelled is fired inside
932 + // Appointment_Service::cancel(), the single choke point every cancel
933 + // path routes through, so it is intentionally not re-fired here.
734 934
735 - return $this->response( $appointment, __( 'Successfully cancelled appointment', 'booktics' ) );
935 + return $this->response( $result, __( 'Successfully cancelled appointment', 'booktics' ) );
736 936 }
737 937
738 938 /**
939 + * Send cancel email to customer
940 + *
941 + * @param Appointment_Model $appointment Appointment model.
942 + */
943 + private function send_cancel_email( $appointment ) {
944 + $db = new Booktics_Database();
945 + $customer = ( new Guest_Model( $db ) )->find( [ 'id' => $appointment->customer_id ] );
946 + $team_member = new Team_Member_Model( $appointment->team_member_id );
947 +
948 + $appointment_cancel_email_data = [
949 + 'date' => $appointment->date ?? '',
950 + 'start_time' => $appointment->start_time ?? '',
951 + 'end_time' => $appointment->end_time ?? '',
952 + 'customer_name' => $customer->name ?? '',
953 + 'user_email' => $customer->email ?? '',
954 + 'appointment_service' => $appointment->title ?? '',
955 + 'appointment_team_member' => $team_member->display_name ?? '',
956 + 'appointment_team_member_email' => $team_member->user_email ?? '',
957 + ];
958 +
959 + $appointment_cancel_email_data = apply_filters( 'booktics_appointment_cancel_email_data', $appointment_cancel_email_data );
960 +
961 + do_action( 'booktics_gln_hook', 'appointment_cancelled', $appointment_cancel_email_data );
962 + }
963 +
964 + /**
739 965 * Check if a given request has access to cancel a specific item.
740 966 *
741 967 * @param WP_REST_Request $request Full data about the request.
742 968 *
@@ -742,12 +968,68 @@
742 968 *
743 969 * @return WP_Error|boolean
744 970 */
745 971 public function cancel_item_permissions_check( $request ) {
746 - return current_user_can( 'manage_options' );
972 + return $this->check_appointment_owner(
973 + $request,
974 + __( 'You are not allowed to cancel this appointment.', 'booktics' )
975 + );
747 976 }
748 977
749 978 /**
979 + * Authorize a customer-facing action on a single appointment.
980 + *
981 + * Reschedule and cancel are reached from the booking confirmation screen
982 + * and from the links in the confirmation email, so the caller is normally
983 + * a guest with no WP session at all. Even a logged-in customer usually
984 + * fails an account match, because the guest row only stores wp_user_id
985 + * when the "use WordPress users as customers" setting is on, and it is off
986 + * by default. The per-appointment security token that ships in those links
987 + * is therefore the credential that actually identifies the customer, and
988 + * it is checked here so the gate cannot be passed without it.
989 + *
990 + * @param WP_REST_Request $request Full data about the request.
991 + * @param string $message Message returned when access is refused.
992 + *
993 + * @return bool|WP_Error True when the caller may act on the appointment.
994 + */
995 + private function check_appointment_owner( $request, $message ) {
996 + if ( booktics_is_admin_or_team_member() ) {
997 + return true;
998 + }
999 +
1000 + $appointment = Appointment_Model::find( intval( $request['id'] ) );
1001 +
1002 + if ( $appointment ) {
1003 + $token = $request->get_param( 'security_token' );
1004 + $token = is_string( $token ) ? $token : '';
1005 + $known_token = (string) $appointment->security_token;
1006 +
1007 + // An appointment saved before tokens existed has an empty token,
1008 + // and hash_equals( '', '' ) is true, so an empty submitted token
1009 + // would unlock every one of those rows. Require a real value.
1010 + if ( '' !== $token && '' !== $known_token && hash_equals( $known_token, $token ) ) {
1011 + return true;
1012 + }
1013 +
1014 + if ( is_user_logged_in() ) {
1015 + $db = new Booktics_Database();
1016 + $customer = ( new Guest_Model( $db ) )->find( array( 'id' => $appointment->customer_id ) );
1017 +
1018 + if ( $customer && ! empty( $customer->wp_user_id ) && intval( $customer->wp_user_id ) === get_current_user_id() ) {
1019 + return true;
1020 + }
1021 + }
1022 + }
1023 +
1024 + return new WP_Error(
1025 + 'booktics_forbidden',
1026 + $message,
1027 + array( 'status' => 403 )
1028 + );
1029 + }
1030 +
1031 + /**
750 1032 * Prepare the item for create or update operation.
751 1033 *
752 1034 * @param WP_REST_Request $request Request object.
753 1035 *
@@ -796,8 +1078,32 @@
796 1078 return $data;
797 1079 }
798 1080
799 1081 /**
1082 + * Longest span of days the time slot route will answer for.
1083 + */
1084 + const MAX_SLOT_RANGE_DAYS = 62;
1085 +
1086 + /**
1087 + * Accept a Y-m-d date, or nothing.
1088 + *
1089 + * @param mixed $value Raw request value.
1090 + *
1091 + * @return string Y-m-d, or '' when it is not a date.
1092 + */
1093 + private static function sanitize_slot_date( $value ) {
1094 + $value = sanitize_text_field( (string) $value );
1095 +
1096 + if ( '' === $value ) {
1097 + return '';
1098 + }
1099 +
1100 + $date = \DateTime::createFromFormat( 'Y-m-d', $value );
1101 +
1102 + return ( $date && $date->format( 'Y-m-d' ) === $value ) ? $value : '';
1103 + }
1104 +
1105 + /**
800 1106 * Check if a given request has access to get time slots.
801 1107 *
802 1108 * @param $request
803 1109 *
@@ -815,60 +1121,88 @@
815 1121 * @return array|\WP_HTTP_Response
816 1122 * @throws Exception If there's an error getting time slots.
817 1123 */
818 1124 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'];
1125 + $service_id = ! empty( $request['service_id'] ) ? absint( $request['service_id'] ) : 0;
1126 + $additional_duration_id = ! empty( $request['additional_duration_id'] ) ? absint( $request['additional_duration_id'] ) : 0;
1127 + $team_member_id = ! empty( $request['team_member_id'] ) ? absint( $request['team_member_id'] ) : 0;
1128 + $start_date = self::sanitize_slot_date( $request['start_date'] ?? '' );
1129 + $end_date = self::sanitize_slot_date( $request['end_date'] ?? '' );
824 1130
1131 + // This route is public and takes no arguments schema, so both dates
1132 + // arrive as free text. generate_slots_for_range() builds a DateTime
1133 + // from them, and DateTime throws on anything it cannot read, which on
1134 + // an unauthenticated route is a 500 with a stack trace.
1135 + if ( ! empty( $request['start_date'] ) && '' === $start_date ) {
1136 + return $this->error( __( 'Invalid start date.', 'booktics' ) );
1137 + }
1138 +
1139 + if ( ! empty( $request['end_date'] ) && '' === $end_date ) {
1140 + return $this->error( __( 'Invalid end date.', 'booktics' ) );
1141 + }
1142 +
1143 + if ( $start_date && $end_date ) {
1144 + if ( $end_date < $start_date ) {
1145 + return $this->error( __( 'End date cannot be before start date.', 'booktics' ) );
1146 + }
1147 +
1148 + // The engine walks the range a day at a time, so an open-ended
1149 + // span is a cheap request with a very expensive answer. A booking
1150 + // calendar shows a month.
1151 + $limit = strtotime( $start_date ) + ( self::MAX_SLOT_RANGE_DAYS * DAY_IN_SECONDS );
1152 +
1153 + if ( strtotime( $end_date ) > $limit ) {
1154 + $end_date = gmdate( 'Y-m-d', $limit );
1155 + }
1156 + }
1157 + $total_attendees = isset( $request['total_attendees'] ) ? absint( $request['total_attendees'] ) : 1;
1158 +
825 1159 $db = new Booktics_Database();
826 1160
827 - $service_schedules = $db->table( 'booktics_schedules' )
1161 + $service_schedules = $db->table( booktics_get_table_name( 'booktics_schedules' ) )
828 1162 ->where( 'service_id', '=', $service_id )
829 1163 ->where_group(
830 1164 function ( $q ) use ( $start_date, $end_date ) {
831 1165 $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 - );
1166 + ->or_where_group(
1167 + function ( $q2 ) use ( $start_date, $end_date ) {
1168 + $q2->where( 'custom_date', '>', $start_date )
1169 + ->where( 'custom_date', '<', $end_date );
1170 + }
1171 + );
838 1172 }
839 1173 )
840 1174 ->to_array();
841 1175 $service_schedules = new Service_Schedule( $service_schedules, $service_id );
842 1176
843 - $team_member_schedules = $db->table( 'booktics_schedules' )
1177 + $team_member_schedules = $db->table( booktics_get_table_name( 'booktics_schedules' ) )
844 1178 ->where( 'team_member_id', '=', $team_member_id )
845 1179 ->where_group(
846 1180 function ( $q ) use ( $start_date, $end_date ) {
847 1181 $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 - );
1182 + ->or_where_group(
1183 + function ( $q2 ) use ( $start_date, $end_date ) {
1184 + $q2->where( 'custom_date', '>', $start_date )
1185 + ->where( 'custom_date', '<', $end_date );
1186 + }
1187 + );
854 1188 }
855 1189 )
856 1190 ->to_array();
857 1191 $team_member_schedules = new Team_Member_Schedule( $team_member_schedules, $team_member_id );
858 1192
859 - $default_schedules = $db->table( 'booktics_schedules' )
1193 + $default_schedules = $db->table( booktics_get_table_name( 'booktics_schedules' ) )
860 1194 ->where( 'service_id', '=', 0 )
861 1195 ->where( 'team_member_id', '=', 0 )
862 1196 ->where_group(
863 1197 function ( $q ) use ( $start_date, $end_date ) {
864 1198 $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 - );
1199 + ->or_where_group(
1200 + function ( $q2 ) use ( $start_date, $end_date ) {
1201 + $q2->where( 'custom_date', '>', $start_date )
1202 + ->where( 'custom_date', '<', $end_date );
1203 + }
1204 + );
871 1205 }
872 1206 )
873 1207 ->to_array();
874 1208 $default_schedules = new Default_Schedule( $default_schedules );
@@ -873,27 +1207,96 @@
873 1207 ->to_array();
874 1208 $default_schedules = new Default_Schedule( $default_schedules );
875 1209
876 1210 $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;
1211 + $service_duration = $this->get_service_duration_from_additional_durations( $service->additional_durations, $additional_duration_id );
1212 + $service_duration = apply_filters( 'booktics_service_duration_for_appointment_time_slots', $service_duration, $request );
1213 + $slot_length = $service->interval_time < 1 ? booktics_get_option( 'interval_time', 30 ) : $service->interval_time;
885 1214
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,
1215 + // Get buffer times from service
1216 + $before_buffer_time = $service->before_buffer_time ? intval( $service->before_buffer_time ) : 0;
1217 + $after_buffer_time = $service->after_buffer_time ? intval( $service->after_buffer_time ) : 0;
1218 + $options = array(
1219 + 'default_schedule' => $default_schedules,
1220 + 'service_duration' => $service_duration,
1221 + 'slot_length' => $slot_length,
1222 + 'before_buffer_time' => $before_buffer_time,
1223 + 'after_buffer_time' => $after_buffer_time,
1224 + 'service_id' => $service_id,
1225 + 'total_attendees' => $total_attendees,
895 1226 );
1227 + /**
1228 + * Allows an extension to produce the slot set instead of core.
1229 + *
1230 + * Returning null (the default) leaves core's single team member
1231 + * generation untouched. Returning an array replaces it entirely, which
1232 + * is how Booktics Pro serves slots merged across several team members.
1233 + *
1234 + * @since 1.0.26
1235 + *
1236 + * @param array|null $slots Pre-computed slots, or null.
1237 + * @param \WP_REST_Request $request The slot request.
1238 + * @param array $options Options handed to Time_Slot_Manager.
1239 + */
1240 + $slots = apply_filters( 'booktics_pre_generate_time_slots', null, $request, $options );
896 1241
1242 + // Anything that is not an array of slots falls back to core rather than
1243 + // being emitted verbatim in the response.
1244 + if ( ! is_array( $slots ) ) {
1245 + // Generate time slots for the given date range by interval time
1246 + $slots = ( new Time_Slot_Manager( $service_schedules, $team_member_schedules, $options ) )
1247 + ->generate_slots_for_range( $start_date, $end_date );
1248 + }
1249 +
1250 +
1251 + $response = array(
1252 + 'service_id' => $service_id,
1253 + 'team_member_id' => $team_member_id,
1254 + 'additional_duration_id' => $additional_duration_id,
1255 + 'start_date' => $start_date,
1256 + 'end_date' => $end_date,
1257 + 'slots' => $slots,
1258 + );
1259 +
897 1260 return $this->response( $response, __( 'Successfully fetched time slots', 'booktics' ) );
1261 + }
1262 +
1263 + /**
1264 + * Get service duration from additional durations using additional_duration_id
1265 + * If additional_duration_id is not found, return default duration (30 minutes)
1266 + *
1267 + * @param array $additional_durations Array of additional durations.
1268 + * @param int $additional_duration_id ID of additional duration.
1269 + *
1270 + * @return int Service duration in minutes.
1271 + */
1272 + private function get_service_duration_from_additional_durations( $additional_durations, $additional_duration_id ) {
1273 + $default_duration = 30;
1274 + if ( empty( $additional_durations ) ) {
1275 + return $default_duration; // Return default duration if additional durations are empty
1276 + }
1277 +
1278 + foreach ( $additional_durations as $additional_duration ) {
1279 + if ( $additional_duration['id'] === $additional_duration_id ) {
1280 + return $this->get_converted_duration( $additional_duration['duration_time'], $additional_duration['duration_unit'] );
1281 + }
1282 + }
1283 +
1284 + return $default_duration;
1285 + }
1286 +
1287 + /**
1288 + * Convert duration to minutes
1289 + *
1290 + * @param int $duration Duration in minutes or hours.
1291 + * @param string $unit Unit of duration (min or hour).
1292 + *
1293 + * @return int Duration in minutes.
1294 + */
1295 + private function get_converted_duration($duration, $unit) {
1296 + if ( $unit === 'min' ) {
1297 + return $duration;
1298 + } elseif ( $unit === 'hour' ) {
1299 + return $duration * 60;
1300 + }
898 1301 }
899 1302 }