# booktics/1.0.28/core/appointment/controllers/appointment-controller.php

Booktics – Appointment Booking Calendar for Service Businesses, version 1.0.28. 1,343 lines.

- Page: https://pluginprobe.com/plugins/booktics/1.0.28/code/core/appointment/controllers/appointment-controller.php
- Raw: https://pluginprobe.com/plugins/booktics/1.0.28/raw/core/appointment/controllers/appointment-controller.php
- Modified: 2026-10-05T09:02:00+00:00

Line numbers below start at 1. Link to a line or a range by appending a fragment to the
page URL, for example `https://pluginprobe.com/plugins/booktics/1.0.28/code/core/appointment/controllers/appointment-controller.php#L10-L20`.

```php
<?php

namespace Booktics\Appointment\Controllers;

use Booktics\Abstracts\Base_Rest_Controller;
use Booktics\Abstracts\Booktics_Database;
use Booktics\Appointment\Schedule\Default_Schedule;
use Booktics\Appointment\Schedule\Service_Schedule;
use Booktics\Appointment\Schedule\Team_Member_Schedule;
use Booktics\Appointment\Schedule\Time_Slot_Manager;
use Booktics\Appointment\Services\Appointment_Service;
use Booktics\Appointment\Services\Appointment_Validator;
use Booktics\Models\Appointment_Model;
use Booktics\Models\Service_Model;
use Booktics\Models\Team_Member_Model;
use Booktics\Models\Guest_Model;
use Exception;
use WP_Error;
use WP_HTTP_Response;
use WP_REST_Request;
use WP_REST_Response;
use WP_REST_Server;

/**
 * Appointment controller
 *
 * @package Booktics/Appointment
 */
class Appointment_Controller extends Base_Rest_Controller {
    /**
     * Endpoint namespace
     *
     * @var string
     */
    protected $namespace = 'booktics/v1';

    /**
     * Route name
     *
     * @var string
     */
    protected $base = 'appointments';

    /**
     * Register routes
     *
     * @return  void
     */
    public function register_routes(): void {
        register_rest_route(
            $this->namespace,
            $this->base,
            array(
                array(
                    'methods'             => WP_REST_Server::CREATABLE,
                    'callback'            => array( $this, 'create_item' ),
                    'permission_callback' => array(
                        $this,
                        'create_item_permissions_check',
                    ),
                ),
            )
        );

        register_rest_route(
            $this->namespace,
            $this->base,
            array(
                array(
                    'methods'             => WP_REST_Server::READABLE,
                    'callback'            => array( $this, 'get_items' ),
                    'permission_callback' => array(
                        $this,
                        'get_items_permissions_check',
                    ),
                ),
            )
        );

        register_rest_route(
            $this->namespace,
            $this->base,
            array(
                array(
                    'methods'             => WP_REST_Server::DELETABLE,
                    'callback'            => array( $this, 'delete_items' ),
                    'permission_callback' => array(
                        $this,
                        'delete_items_permissions_check',
                    ),
                ),
            )
        );

        register_rest_route(
            $this->namespace,
            $this->base . '/(?P<id>[\d]+)',
            array(
                array(
                    'methods'             => WP_REST_Server::READABLE,
                    'callback'            => array( $this, 'get_item' ),
                    'permission_callback' => array(
                        $this,
                        'get_item_permissions_check',
                    ),
                ),
            )
        );

        register_rest_route(
            $this->namespace,
            $this->base . '/(?P<id>[\d]+)',
            array(
                array(
                    'methods'             => WP_REST_Server::EDITABLE,
                    'callback'            => array( $this, 'update_item' ),
                    'permission_callback' => array(
                        $this,
                        'update_item_permissions_check',
                    ),
                ),
            )
        );

        register_rest_route(
            $this->namespace,
            $this->base . '/(?P<id>[\d]+)',
            array(
                array(
                    'methods'             => WP_REST_Server::DELETABLE,
                    'callback'            => array( $this, 'delete_item' ),
                    'permission_callback' => array(
                        $this,
                        'delete_item_permissions_check',
                    ),
                ),
            )
        );

        register_rest_route(
            $this->namespace,
            $this->base . '/(?P<id>[\d]+)/reschedule',
            array(
                array(
                    'methods'             => WP_REST_Server::EDITABLE,
                    'callback'            => array( $this, 'reschedule_item' ),
                    'permission_callback' => array(
                        $this,
                        'reschedule_item_permissions_check',
                    ),
                ),
            )
        );

        register_rest_route(
            $this->namespace,
            $this->base . '/(?P<id>[\d]+)/cancel',
            array(
                array(
                    'methods'             => WP_REST_Server::EDITABLE,
                    'callback'            => array( $this, 'cancel_item' ),
                    'permission_callback' => array(
                        $this,
                        'cancel_item_permissions_check',
                    ),
                ),
            )
        );

        register_rest_route(
            $this->namespace,
            $this->base . '/time-slots',
            array(
                array(
                    'methods'             => WP_REST_Server::READABLE,
                    'callback'            => array( $this, 'get_time_slots' ),
                    'permission_callback' => array(
                        $this,
                        'get_time_slots_permissions_check',
                    ),
                ),
            )
        );

        register_rest_route(
            $this->namespace,
            $this->base . '/calendar',
            array(
                array(
                    'methods'             => WP_REST_Server::READABLE,
                    'callback'            => array(
                        $this,
                        'get_all_calendar_items',
                    ),
                    'permission_callback' => array(
                        $this,
                        'get_items_permissions_check',
                    ),
                    'args'                => array(
                        'month' => array(
                            'description'       => __( 'Month in YYYY-MM format', 'booktics' ),
                            'type'              => 'string',
                            'validate_callback' => function ( $param ) {
                                return preg_match( '/^\d{4}-\d{2}$/', $param );
                            },
                        ),
                    ),
                ),
            )
        );
    }

    /**
     * Get a collection of items.
     *
     * @param WP_REST_Request $request Full data about the request.
     *
     * @return WP_HTTP_Response
     */
    public function get_items( $request ) {
        $per_page  = ! empty( $request['per_page'] ) ? intval( $request['per_page'] ) : 10;
        $paged     = ! empty( $request['paged'] ) ? intval( $request['paged'] ) : 1;
        $date_from = ! empty( $request['date_from'] ) ? sanitize_text_field( $request['date_from'] ) : null;

        $service_id     = ! empty( $request['service_id'] ) ? intval( $request['service_id'] ) : null;
        $customer_id    = ! empty( $request['customer_id'] ) ? intval( $request['customer_id'] ) : null;
        $payment_status = ! empty( $request['payment_status'] ) ? sanitize_text_field( $request['payment_status'] ) : null;
        $team_member_id = ! empty( $request['team_member_id'] ) ? intval( $request['team_member_id'] ) : null;
        $status         = ! empty( $request['status'] ) ? sanitize_text_field( $request['status'] ) : null;

        $search = ! empty( $request['search'] ) ? sanitize_text_field( $request['search'] ) : null;
        // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Meta query is necessary for filtering appointments by meta fields
        $args   = array( 'meta_query' => array() );

        if ( $search ) {
            $service_ids     = array();
            $customer_ids    = array();
            $team_member_ids = array();
            // Search Services
            $services    = Service_Model::all(
                array(
					's' => $search,
                )
            );
            $service_ids = array_map(
                function ( $service ) {
                    return $service->id;
                }, $services
            );

            // Search Customers
            $customer_query = new \WP_User_Query(
                array(
					'search'         => '*' . esc_attr( $search ) . '*',
					'search_columns' => array( 'display_name', 'user_email' ),
					'role'           => 'booktics_customer',
					'fields'         => 'ID',
                )
            );
            $customer_ids   = $customer_query->get_results();

            // Search Team Members
            $team_member_query = new \WP_User_Query(
                array(
					'search'         => '*' . esc_attr( $search ) . '*',
					'search_columns' => array( 'display_name', 'user_email' ),
					'role'           => 'booktics_team_member',
					'fields'         => 'ID',
                )
            );
            $team_member_ids   = $team_member_query->get_results();

            if ( $service_ids || $customer_ids || $team_member_ids ) {
                // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Meta query is necessary for filtering users by meta fields
                $args['meta_query'] = array( 'relation' => 'OR' );
                if ( $service_ids ) {
                    $args['meta_query'][] = array(
                        'key'     => 'service_id',
                        'value'   => $service_ids,
                        'compare' => 'IN',
                    );
                }
                if ( $customer_ids ) {
                    $args['meta_query'][] = array(
                        'key'     => 'customer_id',
                        'value'   => $customer_ids,
                        'compare' => 'IN',
                    );
                }
                if ( $team_member_ids ) {
                    $args['meta_query'][] = array(
                        'key'     => 'team_member_id',
                        'value'   => $team_member_ids,
                        'compare' => 'IN',
                    );
                }
            } else {
                return $this->response(
                    array(
						'items'        => array(),
						'total'        => 0,
						'per_page'     => $per_page,
						'current_page' => $paged,
						'last_page'    => 0,
                    ), __( 'No appointments found', 'booktics' )
                );
            }
        }

        if ( $date_from ) {
            $args['meta_query'][] = array(
                'key'     => 'date',
                'value'   => $date_from,
                'compare' => '>=',
                'type'    => 'DATE',
            );
        }

        if ( $service_id ) {
            $args['meta_query'][] = array(
                'key'     => 'service_id',
                'value'   => $service_id,
                'compare' => '=',
            );
        }

        if ( $customer_id ) {
            $args['meta_query'][] = array(
                'key'     => 'customer_id',
                'value'   => $customer_id,
                'compare' => '=',
            );
        }

        if ( $payment_status ) {
            $args['meta_query'][] = array(
                'key'     => 'payment_status',
                'value'   => $payment_status,
                'compare' => '=',
            );
        }

        if ( $team_member_id ) {
            $args['meta_query'][] = array(
                'key'     => 'team_member_id',
                'value'   => $team_member_id,
                'compare' => '=',
            );
        }

        // Adds filtering by appointment status
        if ( $status ) {
            $args['post_status'] = is_array( $status ) ? $status : array( $status );
        }

        $args = apply_filters( 'booktics_appointment_args', $args );

        // Scope to customer's own appointments when not admin/team-member
        if ( ! booktics_is_admin_or_team_member() && is_user_logged_in() ) {
            $db = new Booktics_Database();
            $guest = ( new Guest_Model( $db ) )->find( array( 'wp_user_id' => get_current_user_id() ) );
            if ( ! $guest ) {
                return $this->response(
                    array(
                        'items'        => array(),
                        'total'        => 0,
                        'per_page'     => $per_page,
                        'current_page' => $paged,
                        'last_page'    => 0,
                    ), __( 'No appointments found', 'booktics' )
                );
            }
            // Add customer_id filter to meta_query
            $args['meta_query'][] = array(
                'key'     => 'customer_id',
                'value'   => $guest->id,
                'compare' => '=',
            );
        }

        $data = Appointment_Model::paginate( $per_page, $paged, $args );

        $data = apply_filters( 'booktics_appointment_data', $data );

        // Mask team member email for customers (non-admin/team-member)
        if ( ! booktics_is_admin_or_team_member() && is_user_logged_in() && ! empty( $data['items'] ) ) {
            foreach ( $data['items'] as &$appointment ) {
                if ( isset( $appointment->team_member_id ) ) {
                    $team_member = new Team_Member_Model( $appointment->team_member_id );
                    $team_member_data = $team_member->find( $appointment->team_member_id );
                    if ( $team_member_data ) {
                        // Remove user_email from team data to protect privacy
                        $team_member_data->user_email = '';
                    }
                }

                // Never hand the cancel/reschedule credential to a customer
                // through the list feed; it is delivered only via e-mail links.
                $appointment->security_token = '';
            }
            unset( $appointment );
        }

        if ( ! $data ) {
            return $this->error( __( 'No appointment found', 'booktics' ), 404 );
        }

        return $this->response( $data );
    }

    /**
     * Get one item from the collection.
     *
     * @param WP_REST_Request $request Full data about the request.
     *
     * @return WP_Error|WP_REST_Response
     */
    public function get_item( $request ) {
        $id = intval( $request['id'] );

        $appointment = Appointment_Model::find( $id );

        if ( ! $appointment ) {
            return $this->error( __( 'Appointment not found', 'booktics' ), 404 );
        }

        // Mask team member email for customers (non-admin/team-member)
        if ( ! booktics_is_admin_or_team_member() && is_user_logged_in() ) {
            $appointment_array = is_array( $appointment ) ? $appointment : ( is_object( $appointment ) ? get_object_vars( $appointment ) : array() );
            if ( isset( $appointment_array['team_member_id'] ) ) {
                $team_member = new Team_Member_Model( $appointment_array['team_member_id'] );
                $team_member_data = $team_member->find( $appointment_array['team_member_id'] );
                if ( $team_member_data && isset( $team_member_data->user_email ) ) {
                    // We can't easily unset from the object, but we can return a modified response
                }
            }
        }

        return $this->response( $appointment );
    }

    /**
     * Get all calendar items within a date range
     *
     * @param WP_REST_Request $request Full data about the request.
     *
     * @return WP_Error|WP_REST_Response
     */
    public function get_all_calendar_items( $request ) {
        $per_page = ! empty( $request['per_page'] ) ? intval( $request['per_page'] ) : 500;
        $paged    = ! empty( $request['paged'] ) ? intval( $request['paged'] ) : 1;

        // Check if custom date range is provided
        if ( ! empty( $request['start_date'] ) && ! empty( $request['end_date'] ) ) {
            $adjusted_start_date = sanitize_text_field( $request['start_date'] );
            $adjusted_end_date   = sanitize_text_field( $request['end_date'] );
            $month               = gmdate( 'Y-m', strtotime( $adjusted_start_date ) );
        } else {
            // Fall back to month-based date range with week adjustment
            $month      = ! empty( $request['month'] ) ? sanitize_text_field( $request['month'] ) : gmdate( 'Y-m' );
            $start_date = gmdate( 'Y-m-01', strtotime( $month ) );
            $end_date   = gmdate( 'Y-m-t', strtotime( $month ) );

            // Get the first day of the week (Sunday = 0, Monday = 1, etc.)
            $first_day_of_week = get_option( 'start_of_week', 1 );

            // Adjust start date to include days from previous month to complete the first week
            $start_date_obj   = new \DateTime( $start_date );
            $start_week_day   = (int) $start_date_obj->format( 'w' ); // 0 (Sun) to 6 (Sat)
            $days_to_subtract = ( $start_week_day - $first_day_of_week + 7 ) % 7;
            $start_date_obj->modify( "-$days_to_subtract days" );
            $adjusted_start_date = $start_date_obj->format( 'Y-m-d' );

            // Adjust end date to include days from next month to complete the last week
            $end_date_obj = new \DateTime( $end_date );
            $end_week_day = (int) $end_date_obj->format( 'w' ); // 0 (Sun) to 6 (Sat)
            $days_to_add  = ( 6 - $end_week_day + $first_day_of_week ) % 7;
            $end_date_obj->modify( "+$days_to_add days" );
            $adjusted_end_date = $end_date_obj->format( 'Y-m-d' );
        }

        // Get all appointments within the date range using meta_query
        $args = array(
            // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Meta query is necessary for filtering appointments by meta fields
            'meta_query'     => array(
                'relation' => 'AND',
                array(
                    'key'     => 'date',
                    'value'   => array(
                        $adjusted_start_date,
                        $adjusted_end_date,
                    ),
                    'compare' => 'BETWEEN',
                    'type'    => 'DATE',
                ),
            ),
            'posts_per_page' => - 1, // Get all appointments in the range
            'orderby'        => 'meta_value',
            'meta_key'       => 'date',  // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Required for ordering by date meta field
            'order'          => 'ASC',
        );

        $service_id     = ! empty( $request['service_id'] ) ? intval( $request['service_id'] ) : null;
        $customer_id    = ! empty( $request['customer_id'] ) ? intval( $request['customer_id'] ) : null;
        $payment_status = ! empty( $request['payment_status'] ) ? sanitize_text_field( $request['payment_status'] ) : null;
        $team_member_id = ! empty( $request['team_member_id'] ) ? intval( $request['team_member_id'] ) : null;
        $status         = ! empty( $request['status'] ) ? sanitize_text_field( $request['status'] ) : null;

        if ( $service_id ) {
            $args['meta_query'][] = array(
                'key'     => 'service_id',
                'value'   => $service_id,
                'compare' => '=',
            );
        }

        if ( $customer_id ) {
            $args['meta_query'][] = array(
                'key'     => 'customer_id',
                'value'   => $customer_id,
                'compare' => '=',
            );
        }

        if ( $payment_status ) {
            $args['meta_query'][] = array(
                'key'     => 'payment_status',
                'value'   => $payment_status,
                'compare' => '=',
            );
        }

        if ( $team_member_id ) {
            $args['meta_query'][] = array(
                'key'     => 'team_member_id',
                'value'   => $team_member_id,
                'compare' => '=',
            );
        }

        if ( $status ) {
            $args['meta_query'][] = array(
                'key'     => 'status',
                'value'   => $status,
                'compare' => '=',
            );
        }

        $args = apply_filters( 'booktics_appointment_args', $args );

        // Scope to the caller's own appointments when they are not an
        // admin/team-member. The calendar route is open to any logged-in user,
        // so without this a plain customer would read every booking on the
        // site. Mirrors the scoping already enforced in get_items().
        $scope_to_customer = ! booktics_is_admin_or_team_member() && is_user_logged_in();
        if ( $scope_to_customer ) {
            $db    = new Booktics_Database();
            $guest = ( new Guest_Model( $db ) )->find( array( 'wp_user_id' => get_current_user_id() ) );
            if ( ! $guest ) {
                return $this->response(
                    array(
                        'month'        => gmdate( 'Y-m', strtotime( $month ) ),
                        'date_range'   => array(
                            'start' => $adjusted_start_date,
                            'end'   => $adjusted_end_date,
                        ),
                        'appointments' => array(),
                    ), __( 'Calendar appointments retrieved successfully', 'booktics' )
                );
            }
            $args['meta_query'][] = array(
                'key'     => 'customer_id',
                'value'   => $guest->id,
                'compare' => '=',
            );
        }

        $appointments = Appointment_Model::paginate( $per_page, $paged, $args );

        // Format the response
        $formatted_appointments = array();

        if ( ! empty( $appointments ) ) {
            foreach ( $appointments['items'] as $appointment ) {
                if ( ! empty( $appointment ) ) {
                    $appointment->source = 'booktics';

                    // The per-appointment security token is the credential that
                    // authorises cancel/reschedule. Customers receive it only
                    // through their e-mailed links, never through this feed, so
                    // strip it from any non-admin response.
                    if ( $scope_to_customer ) {
                        $appointment->security_token = '';
                    }
                }

                $formatted_appointments[] = $appointment;
            }
        }

        // Allow other developers to add/modify appointments
        $formatted_appointments = apply_filters(
            'booktics_appointments_for_calendar_view',
            $formatted_appointments,
            array(
                'start_date' => $adjusted_start_date,
                'end_date'   => $adjusted_end_date,
            )
        );

        $response = array(
            'month'        => gmdate( 'Y-m', strtotime( $month ) ),
            'date_range'   => array(
                'start' => $adjusted_start_date,
                'end'   => $adjusted_end_date,
            ),
            'appointments' => $formatted_appointments,
        );

        return $this->response( $response, __( 'Calendar appointments retrieved successfully', 'booktics' ) );
    }

    /**
     * Check if a given request has access to get a specific item.
     *
     * @param WP_REST_Request $request Full data about the request.
     *
     * @return WP_Error|boolean
     */
    public function get_item_permissions_check( $request ) {
        if ( booktics_is_admin_or_team_member() ) {
            return true;
        }

        if ( is_user_logged_in() ) {
            $appointment_id = intval( $request['id'] );
            $appointment    = Appointment_Model::find( $appointment_id );

            if ( $appointment ) {
                $db       = new Booktics_Database();
                $customer = ( new Guest_Model( $db ) )->find( array( 'id' => $appointment->customer_id ) );

                if ( $customer && intval( $customer->wp_user_id ) === get_current_user_id() ) {
                    return true;
                }
            }
        }

        return new WP_Error(
            'booktics_forbidden',
            __( 'You are not allowed to view this appointment.', 'booktics' ),
            array( 'status' => 403 )
        );
    }

    /**
     * Check if a given request has access to get items.
     *
     * @param WP_REST_Request $request Full data about the request.
     *
     * @return WP_Error|boolean
     */
    public function get_items_permissions_check( $request ) {
        if ( booktics_is_admin_or_team_member() ) {
            return true;
        }

        if ( is_user_logged_in() ) {
            return true;
        }

        return new WP_Error(
            'booktics_forbidden',
            __( 'You are not allowed to view appointments.', 'booktics' ),
            array( 'status' => 403 )
        );
    }

    /**
     * Create one item from the collection.
     *
     * @param WP_REST_Request $request Full data about the request.
     *
     * @return WP_Error|WP_REST_Response
     * @throws Exception
     */
    public function create_item( $request ) {
        $data = json_decode( $request->get_body(), true );

        /**
         * Filters appointment data before it is validated and created.
         *
         * Runs before validation so an extension can supply required fields.
         * Booktics Pro round robin fills team_member_id here when a service
         * assigns its team members automatically.
         *
         * $data is the raw decoded request body: it has not been sanitised and
         * every key reaches Appointment_Service::create(). A filter is
         * responsible for sanitising whatever it adds.
         *
         * @since 1.0.26
         *
         * @param array            $data    Decoded request body.
         * @param \WP_REST_Request $request The create request.
         */
        $data = apply_filters( 'booktics_appointment_create_data', $data, $request );

        // A malformed body decodes to null, and a filter can return anything at
        // all. validate_create() is typed array, so either would be a fatal.
        if ( ! is_array( $data ) ) {
            return $this->error( __( 'Invalid appointment data.', 'booktics' ) );
        }

        $error = Appointment_Validator::validate_create( $data );
        if ( $error ) {
            return $this->error( $error->get_error_message() );
        }

        $conflict = Appointment_Validator::validate_slot_available( $data );
        if ( $conflict ) {
            return $this->error( $conflict->get_error_message() );
        }
        $appointment = Appointment_Service::instance()->create( $data );
        if ( is_wp_error( $appointment ) ) {
            return $this->error( $appointment->get_error_message() );
        }
        do_action( 'booktics_after_appointment_created', $appointment );

        return $this->response( $appointment, __( 'Successfully created appointment', 'booktics' ) );
    }

    /**
     * Check if a given request has access to create items.
     *
     * @param WP_REST_Request $request Full data about the request.
     *
     * @return WP_Error|boolean
     */
    public function create_item_permissions_check( $request ) {
        return current_user_can( 'manage_options' );
    }

    /**
     * Update one item from the collection.
     *
     * @param WP_REST_Request $request Full data about the request.
     *
     * @return WP_REST_Response
     */
    public function update_item( $request ) {
        $id    = intval( $request['id'] );
        $data  = json_decode( $request->get_body(), true );
        $error = Appointment_Validator::validate_update( $data );
        if ( $error ) {
            return $this->error( $error->get_error_message() );
        }
        $updated_appointment = Appointment_Service::instance()->update( $id, $data );
        if ( is_wp_error( $updated_appointment ) ) {
            return $this->error( $updated_appointment->get_error_message() );
        }
        do_action( 'booktics_after_appointment_updated', $updated_appointment );

        return $this->response( $updated_appointment, __( 'Appointment updated successfully', 'booktics' ) );
    }

    /**
     * Check if a given request has access to update a specific item.
     *
     * @param WP_REST_Request $request Full data about the request.
     *
     * @return WP_Error|boolean
     */
    public function update_item_permissions_check( $request ) {
        return current_user_can( 'manage_options' );
    }

    /**
     * Delete one item from the collection.
     *
     * @param WP_REST_Request $request Full data about the request.
     *
     * @return \WP_Error|\WP_HTTP_Response
     */
    public function delete_item( $request ) {
        $id     = intval( $request['id'] );
        $result = Appointment_Service::instance()->delete( $id );
        if ( is_wp_error( $result ) ) {
            return $this->error( $result->get_error_message() );
        }
        do_action( 'booktics_after_appointment_deleted', $result );

        return $this->response( $result, __( 'Successfully deleted appointment', 'booktics' ) );
    }

    /**
     * Check if a given request has access to delete a specific item.
     *
     * @param WP_REST_Request $request Full data about the request.
     *
     * @return WP_Error|boolean
     */
    public function delete_item_permissions_check( $request ) {
        return current_user_can( 'manage_options' );
    }

    /**
     * Delete a collection of items
     *
     * @param WP_Rest_Request $request User request
     */
    public function delete_items( $request ) {
        $ids    = $request['ids'];
        $result = Appointment_Service::instance()->delete_many( $ids );
        if ( is_wp_error( $result ) ) {
            return $this->error( $result->get_error_message() );
        }

        return $this->response( $result['data'], $result['message'] );
    }

    /**
     * Check if a given request has access to delete a collection of items.
     *
     * @param WP_REST_Request $request Full data about the request.
     *
     * @return WP_Error|boolean
     */
    public function delete_items_permissions_check( $request ) {
        return current_user_can( 'manage_options' );
    }

    /**
     * Reschedule an appointment
     *
     * @param $request Full data about the request.
     *
     * @throws Exception
     */
    public function reschedule_item( $request ) {
        $id   = intval( $request['id'] );
        $data = json_decode( $request->get_body(), true );

        /**
         * Filters reschedule data before validation.
         *
         * Returning a WP_Error refuses the reschedule with that message, which
         * is how Booktics Pro keeps a booking with its original team member.
         *
         * @since 1.0.26
         *
         * @param array|\WP_Error $data Reschedule data.
         * @param int             $id   Appointment being moved.
         */
        $data = apply_filters( 'booktics_appointment_reschedule_data', $data, $id );

        if ( is_wp_error( $data ) ) {
            return $this->error( $data->get_error_message() );
        }

        if ( ! is_array( $data ) ) {
            return $this->error( __( 'Invalid reschedule data.', 'booktics' ) );
        }

        $error = Appointment_Validator::validate_reschedule( $data, $id );
        if ( $error ) {
            return $this->error( $error->get_error_message() );
        }

        // Same guard as create_item(): a reschedule is a booking too, and the
        // owning customer can reach this route. The reschedule payload only
        // carries the new date and time, so the stored appointment supplies
        // service, duration and attendees, without which the check would fall
        // back to a 30 minute window and a capacity of one and could refuse a
        // group booking that still has room. Its own id is passed so the
        // booking is never counted as a conflict with itself.
        $moving = Appointment_Model::find( $id );

        if ( $moving ) {
            $conflict = Appointment_Validator::validate_slot_available(
                array(
                    'id'              => $id,
                    'date'            => $data['date'] ?? $moving->date,
                    'start_time'      => $data['start_time'] ?? $moving->start_time,
                    'team_member_id'  => $data['team_member_id'] ?? $moving->team_member_id,
                    'service_id'      => $moving->service_id,
                    'duration'        => $moving->duration,
                    'total_attendees' => $moving->total_attendees,
                )
            );

            if ( $conflict ) {
                return $this->error( $conflict->get_error_message() );
            }
        }
        $result = Appointment_Service::instance()->reschedule( $id, $data );
        if ( is_wp_error( $result ) ) {
            return $this->error( $result->get_error_message() );
        }

        $this->send_reschedule_email( $result );

        do_action( 'booktics_after_appointment_rescheduled', $result );

        return $this->response( $result, __( 'Successfully rescheduled appointment', 'booktics' ) );
    }

    /**
     * Check if a given request has access to reschedule a specific item.
     *
     * @param $request Full data about the request.
     */
    public function reschedule_item_permissions_check( $request ) {
        return $this->check_appointment_owner(
            $request,
            __( 'You are not allowed to reschedule this appointment.', 'booktics' )
        );
    }

    /**
     * Send reschedule email to customer
     *
     * @param Appointment_Model $appointment Appointment model.
     */
    private function send_reschedule_email( $appointment ) {
        $db          = new Booktics_Database();
        $customer    = ( new Guest_Model( $db ) )->find( [ 'id' => $appointment->customer_id ] );
        $team_member = new Team_Member_Model( $appointment->team_member_id );

        $appointment_reschedule_email_data = [
            'date'          => $appointment->date ?? '',
            'start_time'    => $appointment->start_time ?? '',
            'end_time'      => $appointment->end_time ?? '',
            'customer_name' => $customer->name ?? '',
            'user_email'    => $customer->email ?? '',
            'appointment_service' => $appointment->title ?? '',
            'appointment_team_member' => $team_member->display_name ?? '',
            'appointment_team_member_email' => $team_member->user_email ?? '',  
        ];

        $appointment_reschedule_email_data = apply_filters( 'booktics_appointment_reschedule_email_data', $appointment_reschedule_email_data );

        do_action( 'booktics_gln_hook', 'appointment_rescheduled', $appointment_reschedule_email_data );
    }

    /**
     * Cancel an appointment
     *
     * @param WP_REST_Request $request Full data about the request.
     *
     * @throws Exception
     */
    public function cancel_item( $request ) {
        $id     = intval( $request['id'] );
        $data   = json_decode( $request->get_body(), true );
        $cancel_note = ! empty( $data['cancel_note'] ) ? sanitize_text_field( $data['cancel_note'] ) : '';
        
        $error = Appointment_Validator::instance()->validate_cancel( $id );
        
        if ( $error ) {
            return $this->error( $error->get_error_message() );
        }

        $result = Appointment_Service::instance()->cancel( $id, [ 'cancel_note' => $cancel_note ] );

        if ( is_wp_error( $result ) ) {
            return $this->error( $result->get_error_message() );
        }

        $this->send_cancel_email( $result );

        // Note: booktics_after_appointment_cancelled is fired inside
        // Appointment_Service::cancel(), the single choke point every cancel
        // path routes through, so it is intentionally not re-fired here.

        return $this->response( $result, __( 'Successfully cancelled appointment', 'booktics' ) );
    }

    /**
     * Send cancel email to customer
     *
     * @param Appointment_Model $appointment Appointment model.
     */
    private function send_cancel_email( $appointment ) {
        $db          = new Booktics_Database();
        $customer    = ( new Guest_Model( $db ) )->find( [ 'id' => $appointment->customer_id ] );
        $team_member = new Team_Member_Model( $appointment->team_member_id );

        $appointment_cancel_email_data = [
            'date'          => $appointment->date ?? '',
            'start_time'    => $appointment->start_time ?? '',
            'end_time'      => $appointment->end_time ?? '',
            'customer_name' => $customer->name ?? '',
            'user_email'    => $customer->email ?? '',
            'appointment_service' => $appointment->title ?? '',
            'appointment_team_member' => $team_member->display_name ?? '',  
            'appointment_team_member_email' => $team_member->user_email ?? '',
        ];

        $appointment_cancel_email_data = apply_filters( 'booktics_appointment_cancel_email_data', $appointment_cancel_email_data );

        do_action( 'booktics_gln_hook', 'appointment_cancelled', $appointment_cancel_email_data );
    }

    /**
     * Check if a given request has access to cancel a specific item.
     *
     * @param WP_REST_Request $request Full data about the request.
     *
     * @return WP_Error|boolean
     */
    public function cancel_item_permissions_check( $request ) {
        return $this->check_appointment_owner(
            $request,
            __( 'You are not allowed to cancel this appointment.', 'booktics' )
        );
    }

    /**
     * Authorize a customer-facing action on a single appointment.
     *
     * Reschedule and cancel are reached from the booking confirmation screen
     * and from the links in the confirmation email, so the caller is normally
     * a guest with no WP session at all. Even a logged-in customer usually
     * fails an account match, because the guest row only stores wp_user_id
     * when the "use WordPress users as customers" setting is on, and it is off
     * by default. The per-appointment security token that ships in those links
     * is therefore the credential that actually identifies the customer, and
     * it is checked here so the gate cannot be passed without it.
     *
     * @param WP_REST_Request $request Full data about the request.
     * @param string          $message Message returned when access is refused.
     *
     * @return bool|WP_Error True when the caller may act on the appointment.
     */
    private function check_appointment_owner( $request, $message ) {
        if ( booktics_is_admin_or_team_member() ) {
            return true;
        }

        $appointment = Appointment_Model::find( intval( $request['id'] ) );

        if ( $appointment ) {
            $token       = $request->get_param( 'security_token' );
            $token       = is_string( $token ) ? $token : '';
            $known_token = (string) $appointment->security_token;

            // An appointment saved before tokens existed has an empty token,
            // and hash_equals( '', '' ) is true, so an empty submitted token
            // would unlock every one of those rows. Require a real value.
            if ( '' !== $token && '' !== $known_token && hash_equals( $known_token, $token ) ) {
                return true;
            }

            if ( is_user_logged_in() ) {
                $db       = new Booktics_Database();
                $customer = ( new Guest_Model( $db ) )->find( array( 'id' => $appointment->customer_id ) );

                if ( $customer && ! empty( $customer->wp_user_id ) && intval( $customer->wp_user_id ) === get_current_user_id() ) {
                    return true;
                }
            }
        }

        return new WP_Error(
            'booktics_forbidden',
            $message,
            array( 'status' => 403 )
        );
    }

    /**
     * Prepare the item for create or update operation.
     *
     * @param WP_REST_Request $request Request object.
     *
     * @return bool|\WP_Error $prepared_item
     */
    public function prepare_item_for_database( $request ) {
        $data     = json_decode( $request->get_body(), true );
        $validate = booktics_validate(
            $data,
            array(
                'start_time'     => array(
                    'required',
                ),
                'end_time'       => array(
                    'required',
                ),
                'date'           => array( 'required' ),
                'service_id'     => array(
                    'required',
                ),
                'team_member_id' => array(
                    'required',
                ),
            )
        );

        if ( is_wp_error( $validate ) ) {
            return $validate;
        }

        $data = $this->get_sanitized_data( $data );

        return $data;
    }

    private function get_sanitized_data( $data ) {
        $data['start_time']      = ! empty( $data['start_time'] ) ? sanitize_text_field( $data['start_time'] ) : '';
        $data['end_time']        = ! empty( $data['end_time'] ) ? sanitize_text_field( $data['end_time'] ) : '';
        $data['date']            = ! empty( $data['date'] ) ? sanitize_text_field( $data['date'] ) : '';
        $data['service_id']      = ! empty( $data['service_id'] ) ? sanitize_text_field( $data['service_id'] ) : '';
        $data['team_member_id']  = ! empty( $data['team_member_id'] ) ? sanitize_text_field( $data['team_member_id'] ) : '';
        $data['customer_id']     = ! empty( $data['customer_id'] ) ? sanitize_text_field( $data['customer_id'] ) : '';
        $data['total_attendees'] = ! empty( $data['total_attendees'] ) ? sanitize_text_field( $data['total_attendees'] ) : '';
        $data['status']          = isset( $data['status'] ) ? $data['status'] : 'pending';

        return $data;
    }

    /**
     * Longest span of days the time slot route will answer for.
     */
    const MAX_SLOT_RANGE_DAYS = 62;

    /**
     * Accept a Y-m-d date, or nothing.
     *
     * @param mixed $value Raw request value.
     *
     * @return string Y-m-d, or '' when it is not a date.
     */
    private static function sanitize_slot_date( $value ) {
        $value = sanitize_text_field( (string) $value );

        if ( '' === $value ) {
            return '';
        }

        $date = \DateTime::createFromFormat( 'Y-m-d', $value );

        return ( $date && $date->format( 'Y-m-d' ) === $value ) ? $value : '';
    }

    /**
     * Check if a given request has access to get time slots.
     *
     * @param $request
     *
     * @return boolean
     */
    public function get_time_slots_permissions_check( $request ) {
        return true;
    }

    /**
     * Fetch appointment time slots
     *
     * @param WP_REST_Request $request Request object.
     *
     * @return array|\WP_HTTP_Response
     * @throws Exception If there's an error getting time slots.
     */
    public function get_time_slots( $request ) {
        $service_id             = ! empty( $request['service_id'] ) ? absint( $request['service_id'] ) : 0;
        $additional_duration_id = ! empty( $request['additional_duration_id'] ) ? absint( $request['additional_duration_id'] ) : 0;
        $team_member_id         = ! empty( $request['team_member_id'] ) ? absint( $request['team_member_id'] ) : 0;
        $start_date             = self::sanitize_slot_date( $request['start_date'] ?? '' );
        $end_date               = self::sanitize_slot_date( $request['end_date'] ?? '' );

        // This route is public and takes no arguments schema, so both dates
        // arrive as free text. generate_slots_for_range() builds a DateTime
        // from them, and DateTime throws on anything it cannot read, which on
        // an unauthenticated route is a 500 with a stack trace.
        if ( ! empty( $request['start_date'] ) && '' === $start_date ) {
            return $this->error( __( 'Invalid start date.', 'booktics' ) );
        }

        if ( ! empty( $request['end_date'] ) && '' === $end_date ) {
            return $this->error( __( 'Invalid end date.', 'booktics' ) );
        }

        if ( $start_date && $end_date ) {
            if ( $end_date < $start_date ) {
                return $this->error( __( 'End date cannot be before start date.', 'booktics' ) );
            }

            // The engine walks the range a day at a time, so an open-ended
            // span is a cheap request with a very expensive answer. A booking
            // calendar shows a month.
            $limit = strtotime( $start_date ) + ( self::MAX_SLOT_RANGE_DAYS * DAY_IN_SECONDS );

            if ( strtotime( $end_date ) > $limit ) {
                $end_date = gmdate( 'Y-m-d', $limit );
            }
        }
        $total_attendees        = isset( $request['total_attendees'] ) ? absint( $request['total_attendees'] ) : 1;

        $db = new Booktics_Database();

        $service_schedules = $db->table( booktics_get_table_name( 'booktics_schedules' ) )
                                ->where( 'service_id', '=', $service_id )
                                ->where_group(
                                    function ( $q ) use ( $start_date, $end_date ) {
                                        $q->where( 'custom_date', '=', '0' )
                                            ->or_where_group(
												function ( $q2 ) use ( $start_date, $end_date ) {
													$q2->where( 'custom_date', '>', $start_date )
                                                    ->where( 'custom_date', '<', $end_date );
												}
                                            );
                                    }
                                )
                                ->to_array();
        $service_schedules = new Service_Schedule( $service_schedules, $service_id );

        $team_member_schedules = $db->table( booktics_get_table_name( 'booktics_schedules' ) )
                                    ->where( 'team_member_id', '=', $team_member_id )
                                    ->where_group(
                                        function ( $q ) use ( $start_date, $end_date ) {
                                            $q->where( 'custom_date', '=', '0' )
                                                ->or_where_group(
													function ( $q2 ) use ( $start_date, $end_date ) {
														$q2->where( 'custom_date', '>', $start_date )
                                                        ->where( 'custom_date', '<', $end_date );
													}
                                                );
                                        }
                                    )
                                    ->to_array();
        $team_member_schedules = new Team_Member_Schedule( $team_member_schedules, $team_member_id );

        $default_schedules = $db->table( booktics_get_table_name( 'booktics_schedules' ) )
                                ->where( 'service_id', '=', 0 )
                                ->where( 'team_member_id', '=', 0 )
                                ->where_group(
                                    function ( $q ) use ( $start_date, $end_date ) {
                                        $q->where( 'custom_date', '=', '0' )
                                            ->or_where_group(
												function ( $q2 ) use ( $start_date, $end_date ) {
													$q2->where( 'custom_date', '>', $start_date )
                                                    ->where( 'custom_date', '<', $end_date );
												}
                                            );
                                    }
                                )
                                ->to_array();
        $default_schedules = new Default_Schedule( $default_schedules );

        $service                     = Service_Model::find( $service_id );
        $service_duration            = $this->get_service_duration_from_additional_durations( $service->additional_durations, $additional_duration_id );
        $service_duration            = apply_filters( 'booktics_service_duration_for_appointment_time_slots', $service_duration, $request );
        $slot_length                 = $service->interval_time < 1 ? booktics_get_option( 'interval_time', 30 ) : $service->interval_time;

        // Get buffer times from service
        $before_buffer_time = $service->before_buffer_time ? intval( $service->before_buffer_time ) : 0;
        $after_buffer_time  = $service->after_buffer_time ? intval( $service->after_buffer_time ) : 0;
        $options = array(
            'default_schedule'    => $default_schedules,
            'service_duration'    => $service_duration,
            'slot_length'         => $slot_length,
            'before_buffer_time'  => $before_buffer_time,
            'after_buffer_time'   => $after_buffer_time,
            'service_id'          => $service_id,
            'total_attendees'     => $total_attendees,
        );
        /**
         * Allows an extension to produce the slot set instead of core.
         *
         * Returning null (the default) leaves core's single team member
         * generation untouched. Returning an array replaces it entirely, which
         * is how Booktics Pro serves slots merged across several team members.
         *
         * @since 1.0.26
         *
         * @param array|null       $slots   Pre-computed slots, or null.
         * @param \WP_REST_Request $request The slot request.
         * @param array            $options Options handed to Time_Slot_Manager.
         */
        $slots = apply_filters( 'booktics_pre_generate_time_slots', null, $request, $options );

        // Anything that is not an array of slots falls back to core rather than
        // being emitted verbatim in the response.
        if ( ! is_array( $slots ) ) {
            // Generate time slots for the given date range by interval time
            $slots = ( new Time_Slot_Manager( $service_schedules, $team_member_schedules, $options ) )
                ->generate_slots_for_range( $start_date, $end_date );
        }


        $response                    = array(
            'service_id'             => $service_id,
            'team_member_id'         => $team_member_id,
            'additional_duration_id' => $additional_duration_id,
            'start_date'             => $start_date,
            'end_date'               => $end_date,
            'slots'                  => $slots,
        );

        return $this->response( $response, __( 'Successfully fetched time slots', 'booktics' ) );
    }

    /**
     * Get service duration from additional durations using additional_duration_id
     * If additional_duration_id is not found, return default duration (30 minutes)
     *
     * @param array $additional_durations Array of additional durations.
     * @param int $additional_duration_id ID of additional duration.
     *
     * @return int Service duration in minutes.
     */
    private function get_service_duration_from_additional_durations( $additional_durations, $additional_duration_id ) {
        $default_duration = 30;
        if ( empty( $additional_durations ) ) {
            return $default_duration; // Return default duration if additional durations are empty
        }

        foreach ( $additional_durations as $additional_duration ) {
            if ( $additional_duration['id'] === $additional_duration_id ) {
                return $this->get_converted_duration( $additional_duration['duration_time'], $additional_duration['duration_unit'] );
            }
        }

        return $default_duration;
    }

    /**
     * Convert duration to minutes
     *
     * @param int $duration Duration in minutes or hours.
     * @param string $unit Unit of duration (min or hour).
     *
     * @return int Duration in minutes.
     */
    private function get_converted_duration($duration, $unit) {
        if ( $unit === 'min' ) {
            return $duration;
        } elseif ( $unit === 'hour' ) {
            return $duration * 60;
        }
    }
}

```
