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 +168 -12 1.0.15 → 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,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 }