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 +301 -42 1.0.3 → 1.0.29 View file →
@@ -8,8 +8,10 @@
8 8 namespace Booktics\Models;
9 9
10 10 use Booktics\Abstracts\Booktics_Database;
11 11 use Booktics\Abstracts\Post_Model;
12 +use Booktics\Models\Service_Model;
13 +use Booktics\Order\Services\Order_Key;
12 14 use Exception;
13 15
14 16 /**
15 17 * AppointmentModel class
@@ -19,9 +21,9 @@
19 21 * Store appointment data
20 22 *
21 23 * @var array
22 24 */
23 - protected $fillable = [
25 + protected $fillable = array(
24 26 'id' => '',
25 27 'title' => '',
26 28 'content' => '',
27 29 'start_time' => '',
@@ -36,9 +38,9 @@
36 38 'customer_name' => '',
37 39 'team_member_id' => '',
38 40 'service_id' => '',
39 41 'additional_duration_id' => '',
40 - 'group_booking' => [],
42 + 'group_booking' => array(),
41 43 'order_id' => '',
42 44 'quantity' => 0,
43 45 'price' => 0,
44 46 'subtotal' => 0,
@@ -44,9 +46,16 @@
44 46 'subtotal' => 0,
45 47 'total' => 0,
46 48 'note' => '',
47 49 'source' => '',
48 - ];
50 + 'location_id' => '',
51 + 'location' => array(),
52 + 'extra_services' => array(),
53 + 'security_token' => '',
54 + 'total_attendees' => 1,
55 + 'timezone' => '',
56 + 'cancel_note' => '',
57 + );
49 58
50 59 /**
51 60 * Accepted post statuses for appointments
52 61 *
@@ -51,9 +60,9 @@
51 60 * Accepted post statuses for appointments
52 61 *
53 62 * @var array
54 63 */
55 - public static $accepted_statuses = [
64 + public static $accepted_statuses = array(
56 65 'pending',
57 66 'active',
58 67 'cancelled',
59 68 'canceled',
@@ -59,9 +68,9 @@
59 68 'canceled',
60 69 'completed',
61 70 'approved',
62 71 'not_now',
63 - ];
72 + );
64 73
65 74 /**
66 75 * Get post type
67 76 *
@@ -88,16 +97,17 @@
88 97 public function jsonSerialize(): array {
89 98 $data = $this->to_array();
90 99
91 100 $service = Service_Model::find( $this->service_id );
92 - $customer = ( new Customer_Model() )->find( $this->customer_id );
101 + $db = new Booktics_Database();
102 + $customer = ( new Guest_Model( $db ) )->find( array( 'id' => $this->customer_id ) );
93 103 $team_member = ( new Team_Member_Model() )->find( $this->team_member_id );
94 104
95 - $appointment_data = [
105 + $appointment_data = array(
96 106 'service' => $service,
97 107 'team_member' => $team_member,
98 - 'customer' => $customer
99 - ];
108 + 'customer' => $customer,
109 + );
100 110
101 111 return array_merge( $data, $appointment_data );
102 112 }
103 113
@@ -110,32 +120,102 @@
110 120 * @return array
111 121 * @throws Exception
112 122 */
113 123 public function get_appointments_for_slot( string $date, int $team_member_id ): array {
114 - $args = [
115 - 'post_type' => $this->get_post_type(),
116 - 'post_status' => [ 'publish', 'active', 'pending' ],
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',
178 + 'post_status' => booktics_get_option( 'appointment_statuses_for_blocked_slot', array( 'publish', 'active', 'pending' ) ),
117 179 'posts_per_page' => - 1,
118 - 'meta_query' => [
180 + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Meta query is necessary for filtering appointments by meta fields
181 + 'meta_query' => array(
119 182 'relation' => 'AND',
120 - [
183 + array(
121 184 'key' => 'date',
122 185 'value' => $date,
123 - 'compare' => '='
124 - ],
125 - [
186 + 'compare' => '=',
187 + ),
188 + array(
126 189 'key' => 'team_member_id',
127 190 'value' => $team_member_id,
128 - 'compare' => '='
129 - ]
130 - ]
131 - ];
191 + 'compare' => '=',
192 + ),
193 + ),
194 + );
195 + }
132 196
133 - $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 );
134 212
135 - return array_map( function ( $post ) {
136 - return new static( $post );
137 - }, $query->posts );
213 + if ( $appointment instanceof self ) {
214 + $appointment->add_security_token_if_not_exists();
215 + }
216 +
217 + return $appointment;
138 218 }
139 219
140 220 /**
141 221 * Override load_attributes to include payment status from orders
@@ -146,9 +226,9 @@
146 226 parent::load_attributes();
147 227
148 228 if ( $this->order_id ) {
149 229 $db = new Booktics_Database();
150 - $order_model = ( new Order_Model( $db ) )->find( [ 'id' => $this->order_id ] );
230 + $order_model = ( new Order_Model( $db ) )->find( array( 'id' => $this->order_id ) );
151 231
152 232 $this->attributes['payment_status'] = $order_model->payment_status ?? 'pending';
153 233 } else {
154 234 $this->attributes['payment_status'] = 'pending';
@@ -162,20 +242,21 @@
162 242 *
163 243 * @return int Number of appointments
164 244 */
165 245 public function get_appointments_count_by_order_id( $order_id ) {
166 - $args = [
246 + $args = array(
167 247 'post_type' => $this->get_post_type(),
168 - 'post_status' => [ 'publish', 'active', 'pending' ],
248 + 'post_status' => array( 'publish', 'active', 'approved', 'completed', 'pending' ),
169 249 'posts_per_page' => - 1,
170 - 'meta_query' => [
171 - [
250 + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Meta query is necessary for filtering appointments by meta fields
251 + 'meta_query' => array(
252 + array(
172 253 'key' => 'order_id',
173 254 'value' => $order_id,
174 - 'compare' => '='
175 - ]
176 - ]
177 - ];
255 + 'compare' => '=',
256 + ),
257 + ),
258 + );
178 259
179 260 $query = new \WP_Query( $args );
180 261
181 262 return $query->found_posts;
@@ -181,8 +262,39 @@
181 262 return $query->found_posts;
182 263 }
183 264
184 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 + /**
185 297 * Get appointments count by team member ID
186 298 *
187 299 * @param int $team_member_id Team member ID
188 300 *
@@ -188,22 +300,169 @@
188 300 *
189 301 * @return int Number of appointments
190 302 */
191 303 public function get_appointments_count_by_team_member_id( $team_member_id ) {
192 - $args = [
304 + $args = array(
193 305 'post_type' => $this->get_post_type(),
194 - 'post_status' => [ 'publish', 'active', 'pending' ],
306 + 'post_status' => array( 'publish', 'active', 'pending' ),
195 307 'posts_per_page' => - 1,
196 - 'meta_query' => [
197 - [
308 + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Meta query is necessary for filtering appointments by meta fields
309 + 'meta_query' => array(
310 + array(
198 311 'key' => 'team_member_id',
199 312 'value' => $team_member_id,
200 - 'compare' => '='
201 - ]
202 - ]
203 - ];
313 + 'compare' => '=',
314 + ),
315 + ),
316 + );
204 317
205 318 $query = new \WP_Query( $args );
206 319
207 320 return $query->found_posts;
321 + }
322 +
323 + /**
324 + * Get reschedule link
325 + *
326 + * @return string Reschedule link
327 + */
328 + public function get_reschedule_link() {
329 + if ( ! booktics_get_option( 'allow_customer_rescheduling', false ) ) {
330 + return '';
331 + }
332 +
333 + $this->add_security_token_if_not_exists();
334 +
335 + $service_permalink = $this->get_associated_service_permalink();
336 +
337 + $reschedule_url = add_query_arg( [
338 + 'service_id' => $this->service_id,
339 + 'appointment_id' => $this->id,
340 + 'booking_action' => 'reschedule',
341 + 'appointment_token' => $this->security_token,
342 + 'order_id' => $this->order_id,
343 + 'order_key' => $this->get_order_view_key(),
344 + 'team_member_id' => $this->team_member_id,
345 + ], $service_permalink );
346 +
347 + return esc_url( $reschedule_url );
348 + }
349 +
350 + /**
351 + * Get cancel link
352 + *
353 + * @return string Cancel link
354 + */
355 + public function get_cancel_link() {
356 + if ( ! booktics_get_option( 'allow_customer_cancellation', false ) ) {
357 + return '';
358 + }
359 +
360 + $this->add_security_token_if_not_exists();
361 +
362 + $service_permalink = $this->get_associated_service_permalink();
363 +
364 + $cancel_url = add_query_arg( [
365 + 'service_id' => $this->service_id,
366 + 'appointment_id' => $this->id,
367 + 'booking_action' => 'cancel',
368 + 'appointment_token' => $this->security_token,
369 + 'order_id' => $this->order_id,
370 + 'order_key' => $this->get_order_view_key(),
371 + 'team_member_id' => $this->team_member_id,
372 + ], $service_permalink );
373 +
374 + return esc_url( $cancel_url );
375 + }
376 +
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 + /**
404 + * Get associated service permalink
405 + *
406 + * @return string Service permalink
407 + */
408 + private function get_associated_service_permalink() {
409 + if ( ! $this->service_id ) {
410 + return '';
411 + }
412 +
413 + $service = Service_Model::find( $this->service_id );
414 +
415 + return $service->link ?? '';
416 + }
417 +
418 + /**
419 + * Generate a security token for the appointment
420 + * This generated token is used to verify access to the appointment rescheduling and cancellation
421 + *
422 + * @return string Security token
423 + */
424 + public function generate_security_token() {
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 ) );
430 +
431 + return $token;
432 + }
433 +
434 + /**
435 + * Check if the security token exists, if not generate and save it to the post meta.
436 + * This token is used to verify access to the appointment rescheduling and cancellation.
437 + *
438 + * @return void
439 + */
440 + public function add_security_token_if_not_exists() {
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;
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();
208 467 }
209 468 }