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[\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[\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[\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[\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[\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; } } }