PluginProbe
Booktics – Appointment Booking Calendar for Service Businesses / 1.0.28
Booktics – Appointment Booking Calendar for Service Businesses v1.0.28
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 +165 -12 1.0.19 → 1.0.28 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
@@ -194,8 +262,39 @@
194 262 return $query->found_posts;
195 263 }
196 264
197 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 + /**
198 297 * Get appointments count by team member ID
199 298 *
200 299 * @param int $team_member_id Team member ID
201 300 *
@@ -240,8 +339,9 @@
240 339 'appointment_id' => $this->id,
241 340 'booking_action' => 'reschedule',
242 341 'appointment_token' => $this->security_token,
243 342 'order_id' => $this->order_id,
343 + 'order_key' => $this->get_order_view_key(),
244 344 'team_member_id' => $this->team_member_id,
245 345 ], $service_permalink );
246 346
247 347 return esc_url( $reschedule_url );
@@ -266,8 +366,9 @@
266 366 'appointment_id' => $this->id,
267 367 'booking_action' => 'cancel',
268 368 'appointment_token' => $this->security_token,
269 369 'order_id' => $this->order_id,
370 + 'order_key' => $this->get_order_view_key(),
270 371 'team_member_id' => $this->team_member_id,
271 372 ], $service_permalink );
272 373
273 374 return esc_url( $cancel_url );
@@ -273,8 +374,34 @@
273 374 return esc_url( $cancel_url );
274 375 }
275 376
276 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 + /**
277 404 * Get associated service permalink
278 405 *
279 406 * @return string Service permalink
280 407 */
@@ -294,9 +421,13 @@
294 421 *
295 422 * @return string Security token
296 423 */
297 424 public function generate_security_token() {
298 - $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 ) );
299 430
300 431 return $token;
301 432 }
302 433
@@ -306,10 +437,32 @@
306 437 *
307 438 * @return void
308 439 */
309 440 public function add_security_token_if_not_exists() {
310 - if ( empty( $this->security_token ) ) {
311 - $this->security_token = $this->generate_security_token();
312 - $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;
313 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();
314 467 }
315 468 }