PluginProbe
Booktics – Appointment Booking Calendar for Service Businesses / 1.0.29
Booktics – Appointment Booking Calendar for Service Businesses v1.0.29
1.0.29 1.0.28 1.0.27 1.0.26 1.0.25 1.0.24 1.0.23 1.0.22 1.0.21 1.0.20 1.0.19 trunk 1.0.0 1.0.1 1.0.10 1.0.11 1.0.12 1.0.13 1.0.14 1.0.15 1.0.16 1.0.17 1.0.18 1.0.2 1.0.3 All 31 releases
← All changes | base/models/appointment-model.php +134 -12 1.0.24 → 1.0.29 View file →
@@ -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 }