| @@ -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,12 +120,65 @@ | ||
| 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, |
| 180 | + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Meta query is necessary for filtering appointments by meta fields | |
| 127 | 181 | 'meta_query' => array( |
| 128 | 182 | 'relation' => 'AND', |
| 129 | 183 | array( |
| 130 | 184 | 'key' => 'date', |
| @@ -137,16 +191,31 @@ | ||
| 137 | 191 | 'compare' => '=', |
| 138 | 192 | ), |
| 139 | 193 | ), |
| 140 | 194 | ); |
| 195 | + } | |
| 141 | 196 | |
| 142 | - $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 ); | |
| 143 | 212 | |
| 144 | - return array_map( | |
| 145 | - function ( $post ) { | |
| 146 | - return new static( $post ); | |
| 147 | - }, $query->posts | |
| 148 | - ); | |
| 213 | + if ( $appointment instanceof self ) { | |
| 214 | + $appointment->add_security_token_if_not_exists(); | |
| 215 | + } | |
| 216 | + | |
| 217 | + return $appointment; | |
| 149 | 218 | } |
| 150 | 219 | |
| 151 | 220 | /** |
| 152 | 221 | * Override load_attributes to include payment status from orders |
| @@ -177,8 +246,9 @@ | ||
| 177 | 246 | $args = array( |
| 178 | 247 | 'post_type' => $this->get_post_type(), |
| 179 | 248 | 'post_status' => array( 'publish', 'active', 'approved', 'completed', 'pending' ), |
| 180 | 249 | 'posts_per_page' => - 1, |
| 250 | + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Meta query is necessary for filtering appointments by meta fields | |
| 181 | 251 | 'meta_query' => array( |
| 182 | 252 | array( |
| 183 | 253 | 'key' => 'order_id', |
| 184 | 254 | 'value' => $order_id, |
| @@ -192,8 +262,39 @@ | ||
| 192 | 262 | return $query->found_posts; |
| 193 | 263 | } |
| 194 | 264 | |
| 195 | 265 | /** |
| 266 | + * Get all appointments attached to an order | |
| 267 | + * | |
| 268 | + * @param int $order_id Order ID | |
| 269 | + * | |
| 270 | + * @return array<static> | |
| 271 | + */ | |
| 272 | + public function get_appointments_by_order_id( $order_id ) { | |
| 273 | + $args = array( | |
| 274 | + 'post_type' => $this->get_post_type(), | |
| 275 | + 'post_status' => static::$accepted_statuses, | |
| 276 | + 'posts_per_page' => - 1, | |
| 277 | + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Meta query is necessary for filtering appointments by order. | |
| 278 | + 'meta_query' => array( | |
| 279 | + array( | |
| 280 | + 'key' => 'order_id', | |
| 281 | + 'value' => $order_id, | |
| 282 | + 'compare' => '=', | |
| 283 | + ), | |
| 284 | + ), | |
| 285 | + ); | |
| 286 | + | |
| 287 | + $query = new \WP_Query( $args ); | |
| 288 | + | |
| 289 | + return array_map( | |
| 290 | + function ( $post ) { | |
| 291 | + return new static( $post ); | |
| 292 | + }, $query->posts | |
| 293 | + ); | |
| 294 | + } | |
| 295 | + | |
| 296 | + /** | |
| 196 | 297 | * Get appointments count by team member ID |
| 197 | 298 | * |
| 198 | 299 | * @param int $team_member_id Team member ID |
| 199 | 300 | * |
| @@ -203,8 +304,9 @@ | ||
| 203 | 304 | $args = array( |
| 204 | 305 | 'post_type' => $this->get_post_type(), |
| 205 | 306 | 'post_status' => array( 'publish', 'active', 'pending' ), |
| 206 | 307 | 'posts_per_page' => - 1, |
| 308 | + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Meta query is necessary for filtering appointments by meta fields | |
| 207 | 309 | 'meta_query' => array( |
| 208 | 310 | array( |
| 209 | 311 | 'key' => 'team_member_id', |
| 210 | 312 | 'value' => $team_member_id, |
| @@ -237,8 +339,9 @@ | ||
| 237 | 339 | 'appointment_id' => $this->id, |
| 238 | 340 | 'booking_action' => 'reschedule', |
| 239 | 341 | 'appointment_token' => $this->security_token, |
| 240 | 342 | 'order_id' => $this->order_id, |
| 343 | + 'order_key' => $this->get_order_view_key(), | |
| 241 | 344 | 'team_member_id' => $this->team_member_id, |
| 242 | 345 | ], $service_permalink ); |
| 243 | 346 | |
| 244 | 347 | return esc_url( $reschedule_url ); |
| @@ -263,8 +366,9 @@ | ||
| 263 | 366 | 'appointment_id' => $this->id, |
| 264 | 367 | 'booking_action' => 'cancel', |
| 265 | 368 | 'appointment_token' => $this->security_token, |
| 266 | 369 | 'order_id' => $this->order_id, |
| 370 | + 'order_key' => $this->get_order_view_key(), | |
| 267 | 371 | 'team_member_id' => $this->team_member_id, |
| 268 | 372 | ], $service_permalink ); |
| 269 | 373 | |
| 270 | 374 | return esc_url( $cancel_url ); |
| @@ -270,8 +374,34 @@ | ||
| 270 | 374 | return esc_url( $cancel_url ); |
| 271 | 375 | } |
| 272 | 376 | |
| 273 | 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 | + /** | |
| 274 | 404 | * Get associated service permalink |
| 275 | 405 | * |
| 276 | 406 | * @return string Service permalink |
| 277 | 407 | */ |
| @@ -291,9 +421,13 @@ | ||
| 291 | 421 | * |
| 292 | 422 | * @return string Security token |
| 293 | 423 | */ |
| 294 | 424 | public function generate_security_token() { |
| 295 | - $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 ) ); | |
| 296 | 430 | |
| 297 | 431 | return $token; |
| 298 | 432 | } |
| 299 | 433 | |
| @@ -303,10 +437,32 @@ | ||
| 303 | 437 | * |
| 304 | 438 | * @return void |
| 305 | 439 | */ |
| 306 | 440 | public function add_security_token_if_not_exists() { |
| 307 | - if ( empty( $this->security_token ) ) { | |
| 308 | - $this->security_token = $this->generate_security_token(); | |
| 309 | - $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; | |
| 310 | 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(); | |
| 311 | 467 | } |
| 312 | 468 | } |