'', 'title' => '', 'content' => '', 'start_time' => '', 'end_time' => '', 'date' => '', 'buffer_before' => '', 'buffer_after' => '', 'duration' => '', 'status' => '', 'payment_status' => '', 'customer_id' => '', 'customer_name' => '', 'team_member_id' => '', 'service_id' => '', 'additional_duration_id' => '', 'group_booking' => array(), 'order_id' => '', 'quantity' => 0, 'price' => 0, 'subtotal' => 0, 'total' => 0, 'note' => '', 'source' => '', 'location_id' => '', 'location' => array(), 'extra_services' => array(), 'security_token' => '', 'total_attendees' => 1, 'timezone' => '', 'cancel_note' => '', ); /** * Accepted post statuses for appointments * * @var array */ public static $accepted_statuses = array( 'pending', 'active', 'cancelled', 'canceled', 'completed', 'approved', 'not_now', ); /** * Get post type * * @return string Post type name */ public function get_post_type() { return 'booktics-appointment'; } /** * Get accepted statuses * * @return array Accepted statuses */ public static function get_accepted_statuses() { return self::$accepted_statuses; } /** * Specify data which should be serialized to JSON. * * @return array */ public function jsonSerialize(): array { $data = $this->to_array(); $service = Service_Model::find( $this->service_id ); $db = new Booktics_Database(); $customer = ( new Guest_Model( $db ) )->find( array( 'id' => $this->customer_id ) ); $team_member = ( new Team_Member_Model() )->find( $this->team_member_id ); $appointment_data = array( 'service' => $service, 'team_member' => $team_member, 'customer' => $customer, ); return array_merge( $data, $appointment_data ); } /** * Get appointments for a specific date and service/team member * * @param string $date * @param int $team_member_id * * @return array * @throws Exception */ public function get_appointments_for_slot( string $date, int $team_member_id ): array { $query = new \WP_Query( self::slot_query_args( $date, $team_member_id ) ); return array_map( function ( $post ) { return new static( $post ); }, $query->posts ); } /** * Get appointment IDs for a date and team member, with their meta primed. * * The same query as get_appointments_for_slot() without hydrating a model * per row. Hydration runs load_attributes(), which fires an extra order * lookup for every appointment, so a caller that only needs a few meta * values pays N+1 queries for nothing. One update_meta_cache() call here * makes every later get_post_meta() a cache read. * * @param string $date Y-m-d. * @param int $team_member_id Team member user ID. * @param array $statuses Post statuses to count, empty for the site setting. * * @return int[] Appointment post IDs. */ public static function get_appointment_ids_for_slot( string $date, int $team_member_id, array $statuses = array() ): array { $args = self::slot_query_args( $date, $team_member_id ); if ( $statuses ) { $args['post_status'] = $statuses; } $args['fields'] = 'ids'; $args['no_found_rows'] = true; $ids = array_map( 'intval', ( new \WP_Query( $args ) )->posts ); if ( $ids ) { update_meta_cache( 'post', $ids ); } return $ids; } /** * Build the shared query args for one date and team member. * * @param string $date Y-m-d. * @param int $team_member_id Team member user ID. * * @return array */ private static function slot_query_args( string $date, int $team_member_id ): array { return array( 'post_type' => 'booktics-appointment', 'post_status' => booktics_get_option( 'appointment_statuses_for_blocked_slot', array( 'publish', 'active', 'pending' ) ), 'posts_per_page' => - 1, // 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' => $date, 'compare' => '=', ), array( 'key' => 'team_member_id', 'value' => $team_member_id, 'compare' => '=', ), ), ); } /** * Create an appointment and give it a security token straight away. * * The token is what lets a customer reschedule or cancel their own * booking. It used to appear only as a side effect of building the * confirmation email links, so a booking paid for up front, which sends * no email at create time, was handed back to the browser without one and * its own Reschedule and Cancel buttons were refused. * * @param array $attributes Collection of attributes. * * @return mixed The created model, or whatever the parent returned on failure. */ public static function create( array $attributes ) { $appointment = parent::create( $attributes ); if ( $appointment instanceof self ) { $appointment->add_security_token_if_not_exists(); } return $appointment; } /** * Override load_attributes to include payment status from orders * * @return void */ protected function load_attributes() { parent::load_attributes(); if ( $this->order_id ) { $db = new Booktics_Database(); $order_model = ( new Order_Model( $db ) )->find( array( 'id' => $this->order_id ) ); $this->attributes['payment_status'] = $order_model->payment_status ?? 'pending'; } else { $this->attributes['payment_status'] = 'pending'; } } /** * Get appointments count by order ID * * @param int $order_id Order ID * * @return int Number of appointments */ public function get_appointments_count_by_order_id( $order_id ) { $args = array( 'post_type' => $this->get_post_type(), 'post_status' => array( 'publish', 'active', 'approved', 'completed', 'pending' ), 'posts_per_page' => - 1, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Meta query is necessary for filtering appointments by meta fields 'meta_query' => array( array( 'key' => 'order_id', 'value' => $order_id, 'compare' => '=', ), ), ); $query = new \WP_Query( $args ); return $query->found_posts; } /** * Get all appointments attached to an order * * @param int $order_id Order ID * * @return array */ public function get_appointments_by_order_id( $order_id ) { $args = array( 'post_type' => $this->get_post_type(), 'post_status' => static::$accepted_statuses, 'posts_per_page' => - 1, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Meta query is necessary for filtering appointments by order. 'meta_query' => array( array( 'key' => 'order_id', 'value' => $order_id, 'compare' => '=', ), ), ); $query = new \WP_Query( $args ); return array_map( function ( $post ) { return new static( $post ); }, $query->posts ); } /** * Get appointments count by team member ID * * @param int $team_member_id Team member ID * * @return int Number of appointments */ public function get_appointments_count_by_team_member_id( $team_member_id ) { $args = array( 'post_type' => $this->get_post_type(), 'post_status' => array( 'publish', 'active', 'pending' ), 'posts_per_page' => - 1, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Meta query is necessary for filtering appointments by meta fields 'meta_query' => array( array( 'key' => 'team_member_id', 'value' => $team_member_id, 'compare' => '=', ), ), ); $query = new \WP_Query( $args ); return $query->found_posts; } /** * Get reschedule link * * @return string Reschedule link */ public function get_reschedule_link() { if ( ! booktics_get_option( 'allow_customer_rescheduling', false ) ) { return ''; } $this->add_security_token_if_not_exists(); $service_permalink = $this->get_associated_service_permalink(); $reschedule_url = add_query_arg( [ 'service_id' => $this->service_id, 'appointment_id' => $this->id, 'booking_action' => 'reschedule', 'appointment_token' => $this->security_token, 'order_id' => $this->order_id, 'order_key' => $this->get_order_view_key(), 'team_member_id' => $this->team_member_id, ], $service_permalink ); return esc_url( $reschedule_url ); } /** * Get cancel link * * @return string Cancel link */ public function get_cancel_link() { if ( ! booktics_get_option( 'allow_customer_cancellation', false ) ) { return ''; } $this->add_security_token_if_not_exists(); $service_permalink = $this->get_associated_service_permalink(); $cancel_url = add_query_arg( [ 'service_id' => $this->service_id, 'appointment_id' => $this->id, 'booking_action' => 'cancel', 'appointment_token' => $this->security_token, 'order_id' => $this->order_id, 'order_key' => $this->get_order_view_key(), 'team_member_id' => $this->team_member_id, ], $service_permalink ); return esc_url( $cancel_url ); } /** * Key that lets the customer open their own order without an account. * * The booking screen has to load the order before it can show the * reschedule or cancel dialog, and that request is refused for a visitor * with no session unless it carries this key. Someone following a link * from their confirmation email is exactly that visitor, so the key * travels in the link. * * @return string Empty when the appointment has no order. */ private function get_order_view_key() { if ( ! $this->order_id ) { return ''; } $db = new Booktics_Database(); $order = ( new Order_Model( $db ) )->find( array( 'id' => $this->order_id ) ); if ( ! $order ) { return ''; } return Order_Key::make( (int) $this->order_id, (string) $order->order_no ); } /** * Get associated service permalink * * @return string Service permalink */ private function get_associated_service_permalink() { if ( ! $this->service_id ) { return ''; } $service = Service_Model::find( $this->service_id ); return $service->link ?? ''; } /** * Generate a security token for the appointment * This generated token is used to verify access to the appointment rescheduling and cancellation * * @return string Security token */ public function generate_security_token() { // This token is the only thing standing between a stranger and // someone else's booking, and nothing rate limits the REST route, so // it has to be too wide to walk through. Tokens already issued keep // working; hash_equals() does not care how long they are. $token = bin2hex( random_bytes( 16 ) ); return $token; } /** * Check if the security token exists, if not generate and save it to the post meta. * This token is used to verify access to the appointment rescheduling and cancellation. * * @return void */ public function add_security_token_if_not_exists() { // Post_Model serves its fields through __get() and defines no // __isset(), so empty( $this->security_token ) is true even when a // token is stored. That minted a fresh token on every call, and since // the confirmation email builds the reschedule link and then the // cancel link, the reschedule link was already dead by the time the // mail went out. Read the value into a local variable first. $token = (string) $this->security_token; if ( '' !== $token ) { return; } $this->security_token = $this->generate_security_token(); // Store the one field directly. save() runs wp_update_post() over the // whole row, which is a lot of work for a single meta value and makes // WordPress check publish_post against the appointment post type. In // WP-CLI, cron and the test bootstrap that type is not registered yet, // so the check raises a _doing_it_wrong() notice. if ( $this->id ) { update_post_meta( (int) $this->id, 'security_token', $this->security_token ); return; } $this->save(); } }