| @@ -9,8 +9,9 @@ | ||
| 9 | 9 | |
| 10 | 10 | use Booktics\Abstracts\Booktics_Database; |
| 11 | 11 | use Booktics\Abstracts\Post_Model; |
| 12 | 12 | use Booktics\Models\Service_Model; |
| 13 | +use Booktics\Order\Services\Order_Key; | |
| 13 | 14 | use Exception; |
| 14 | 15 | |
| 15 | 16 | /** |
| 16 | 17 | * AppointmentModel class |
| @@ -119,10 +120,62 @@ | ||
| 119 | 120 | * @return array |
| 120 | 121 | * @throws Exception |
| 121 | 122 | */ |
| 122 | 123 | public function get_appointments_for_slot( string $date, int $team_member_id ): array { |
| 123 | - $args = array( | |
| 124 | - 'post_type' => $this->get_post_type(), | |
| 124 | + $query = new \WP_Query( self::slot_query_args( $date, $team_member_id ) ); | |
| 125 | + | |
| 126 | + return array_map( | |
| 127 | + function ( $post ) { | |
| 128 | + return new static( $post ); | |
| 129 | + }, $query->posts | |
| 130 | + ); | |
| 131 | + } | |
| 132 | + | |
| 133 | + /** | |
| 134 | + * Get appointment IDs for a date and team member, with their meta primed. | |
| 135 | + * | |
| 136 | + * The same query as get_appointments_for_slot() without hydrating a model | |
| 137 | + * per row. Hydration runs load_attributes(), which fires an extra order | |
| 138 | + * lookup for every appointment, so a caller that only needs a few meta | |
| 139 | + * values pays N+1 queries for nothing. One update_meta_cache() call here | |
| 140 | + * makes every later get_post_meta() a cache read. | |
| 141 | + * | |
| 142 | + * @param string $date Y-m-d. | |
| 143 | + * @param int $team_member_id Team member user ID. | |
| 144 | + * @param array $statuses Post statuses to count, empty for the site setting. | |
| 145 | + * | |
| 146 | + * @return int[] Appointment post IDs. | |
| 147 | + */ | |
| 148 | + public static function get_appointment_ids_for_slot( string $date, int $team_member_id, array $statuses = array() ): array { | |
| 149 | + $args = self::slot_query_args( $date, $team_member_id ); | |
| 150 | + | |
| 151 | + if ( $statuses ) { | |
| 152 | + $args['post_status'] = $statuses; | |
| 153 | + } | |
| 154 | + | |
| 155 | + $args['fields'] = 'ids'; | |
| 156 | + $args['no_found_rows'] = true; | |
| 157 | + | |
| 158 | + $ids = array_map( 'intval', ( new \WP_Query( $args ) )->posts ); | |
| 159 | + | |
| 160 | + if ( $ids ) { | |
| 161 | + update_meta_cache( 'post', $ids ); | |
| 162 | + } | |
| 163 | + | |
| 164 | + return $ids; | |
| 165 | + } | |
| 166 | + | |
| 167 | + /** | |
| 168 | + * Build the shared query args for one date and team member. | |
| 169 | + * | |
| 170 | + * @param string $date Y-m-d. | |
| 171 | + * @param int $team_member_id Team member user ID. | |
| 172 | + * | |
| 173 | + * @return array | |
| 174 | + */ | |
| 175 | + private static function slot_query_args( string $date, int $team_member_id ): array { | |
| 176 | + return array( | |
| 177 | + 'post_type' => 'booktics-appointment', | |
| 125 | 178 | 'post_status' => booktics_get_option( 'appointment_statuses_for_blocked_slot', array( 'publish', 'active', 'pending' ) ), |
| 126 | 179 | 'posts_per_page' => - 1, |
| 127 | 180 | // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Meta query is necessary for filtering appointments by meta fields |
| 128 | 181 | 'meta_query' => array( |
| @@ -138,16 +191,31 @@ | ||
| 138 | 191 | 'compare' => '=', |
| 139 | 192 | ), |
| 140 | 193 | ), |
| 141 | 194 | ); |
| 195 | + } | |
| 142 | 196 | |
| 143 | - $query = new \WP_Query( $args ); | |
| 197 | + /** | |
| 198 | + * Create an appointment and give it a security token straight away. | |
| 199 | + * | |
| 200 | + * The token is what lets a customer reschedule or cancel their own | |
| 201 | + * booking. It used to appear only as a side effect of building the | |
| 202 | + * confirmation email links, so a booking paid for up front, which sends | |
| 203 | + * no email at create time, was handed back to the browser without one and | |
| 204 | + * its own Reschedule and Cancel buttons were refused. | |
| 205 | + * | |
| 206 | + * @param array $attributes Collection of attributes. | |
| 207 | + * | |
| 208 | + * @return mixed The created model, or whatever the parent returned on failure. | |
| 209 | + */ | |
| 210 | + public static function create( array $attributes ) { | |
| 211 | + $appointment = parent::create( $attributes ); | |
| 144 | 212 | |
| 145 | - return array_map( | |
| 146 | - function ( $post ) { | |
| 147 | - return new static( $post ); | |
| 148 | - }, $query->posts | |
| 149 | - ); | |
| 213 | + if ( $appointment instanceof self ) { | |
| 214 | + $appointment->add_security_token_if_not_exists(); | |
| 215 | + } | |
| 216 | + | |
| 217 | + return $appointment; | |
| 150 | 218 | } |
| 151 | 219 | |
| 152 | 220 | /** |
| 153 | 221 | * Override load_attributes to include payment status from orders |
| @@ -271,8 +339,9 @@ | ||
| 271 | 339 | 'appointment_id' => $this->id, |
| 272 | 340 | 'booking_action' => 'reschedule', |
| 273 | 341 | 'appointment_token' => $this->security_token, |
| 274 | 342 | 'order_id' => $this->order_id, |
| 343 | + 'order_key' => $this->get_order_view_key(), | |
| 275 | 344 | 'team_member_id' => $this->team_member_id, |
| 276 | 345 | ], $service_permalink ); |
| 277 | 346 | |
| 278 | 347 | return esc_url( $reschedule_url ); |
| @@ -297,8 +366,9 @@ | ||
| 297 | 366 | 'appointment_id' => $this->id, |
| 298 | 367 | 'booking_action' => 'cancel', |
| 299 | 368 | 'appointment_token' => $this->security_token, |
| 300 | 369 | 'order_id' => $this->order_id, |
| 370 | + 'order_key' => $this->get_order_view_key(), | |
| 301 | 371 | 'team_member_id' => $this->team_member_id, |
| 302 | 372 | ], $service_permalink ); |
| 303 | 373 | |
| 304 | 374 | return esc_url( $cancel_url ); |
| @@ -304,8 +374,34 @@ | ||
| 304 | 374 | return esc_url( $cancel_url ); |
| 305 | 375 | } |
| 306 | 376 | |
| 307 | 377 | /** |
| 378 | + * Key that lets the customer open their own order without an account. | |
| 379 | + * | |
| 380 | + * The booking screen has to load the order before it can show the | |
| 381 | + * reschedule or cancel dialog, and that request is refused for a visitor | |
| 382 | + * with no session unless it carries this key. Someone following a link | |
| 383 | + * from their confirmation email is exactly that visitor, so the key | |
| 384 | + * travels in the link. | |
| 385 | + * | |
| 386 | + * @return string Empty when the appointment has no order. | |
| 387 | + */ | |
| 388 | + private function get_order_view_key() { | |
| 389 | + if ( ! $this->order_id ) { | |
| 390 | + return ''; | |
| 391 | + } | |
| 392 | + | |
| 393 | + $db = new Booktics_Database(); | |
| 394 | + $order = ( new Order_Model( $db ) )->find( array( 'id' => $this->order_id ) ); | |
| 395 | + | |
| 396 | + if ( ! $order ) { | |
| 397 | + return ''; | |
| 398 | + } | |
| 399 | + | |
| 400 | + return Order_Key::make( (int) $this->order_id, (string) $order->order_no ); | |
| 401 | + } | |
| 402 | + | |
| 403 | + /** | |
| 308 | 404 | * Get associated service permalink |
| 309 | 405 | * |
| 310 | 406 | * @return string Service permalink |
| 311 | 407 | */ |
| @@ -325,9 +421,13 @@ | ||
| 325 | 421 | * |
| 326 | 422 | * @return string Security token |
| 327 | 423 | */ |
| 328 | 424 | public function generate_security_token() { |
| 329 | - $token = bin2hex( random_bytes(4) ); // 8 hex chars | |
| 425 | + // This token is the only thing standing between a stranger and | |
| 426 | + // someone else's booking, and nothing rate limits the REST route, so | |
| 427 | + // it has to be too wide to walk through. Tokens already issued keep | |
| 428 | + // working; hash_equals() does not care how long they are. | |
| 429 | + $token = bin2hex( random_bytes( 16 ) ); | |
| 330 | 430 | |
| 331 | 431 | return $token; |
| 332 | 432 | } |
| 333 | 433 | |
| @@ -337,10 +437,32 @@ | ||
| 337 | 437 | * |
| 338 | 438 | * @return void |
| 339 | 439 | */ |
| 340 | 440 | public function add_security_token_if_not_exists() { |
| 341 | - if ( empty( $this->security_token ) ) { | |
| 342 | - $this->security_token = $this->generate_security_token(); | |
| 343 | - $this->save(); | |
| 441 | + // Post_Model serves its fields through __get() and defines no | |
| 442 | + // __isset(), so empty( $this->security_token ) is true even when a | |
| 443 | + // token is stored. That minted a fresh token on every call, and since | |
| 444 | + // the confirmation email builds the reschedule link and then the | |
| 445 | + // cancel link, the reschedule link was already dead by the time the | |
| 446 | + // mail went out. Read the value into a local variable first. | |
| 447 | + $token = (string) $this->security_token; | |
| 448 | + | |
| 449 | + if ( '' !== $token ) { | |
| 450 | + return; | |
| 344 | 451 | } |
| 452 | + | |
| 453 | + $this->security_token = $this->generate_security_token(); | |
| 454 | + | |
| 455 | + // Store the one field directly. save() runs wp_update_post() over the | |
| 456 | + // whole row, which is a lot of work for a single meta value and makes | |
| 457 | + // WordPress check publish_post against the appointment post type. In | |
| 458 | + // WP-CLI, cron and the test bootstrap that type is not registered yet, | |
| 459 | + // so the check raises a _doing_it_wrong() notice. | |
| 460 | + if ( $this->id ) { | |
| 461 | + update_post_meta( (int) $this->id, 'security_token', $this->security_token ); | |
| 462 | + | |
| 463 | + return; | |
| 464 | + } | |
| 465 | + | |
| 466 | + $this->save(); | |
| 345 | 467 | } |
| 346 | 468 | } |