PluginProbe
Booktics – Appointment Booking Calendar for Service Businesses / 1.0.27
Booktics – Appointment Booking Calendar for Service Businesses v1.0.27
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
booktics / base / models / appointment-model.php

appointment-model.php in Booktics – Appointment Booking Calendar for Service Businesses 1.0.27, at base/models/appointment-model.php

469 lines 14.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Appoint Model
4 *
5 * @package Booktics/Models
6 */
7
8 namespace Booktics\Models;
9
10 use Booktics\Abstracts\Booktics_Database;
11 use Booktics\Abstracts\Post_Model;
12 use Booktics\Models\Service_Model;
13 use Booktics\Order\Services\Order_Key;
14 use Exception;
15
16 /**
17 * AppointmentModel class
18 */
19 class Appointment_Model extends Post_Model {
20 /**
21 * Store appointment data
22 *
23 * @var array
24 */
25 protected $fillable = array(
26 'id' => '',
27 'title' => '',
28 'content' => '',
29 'start_time' => '',
30 'end_time' => '',
31 'date' => '',
32 'buffer_before' => '',
33 'buffer_after' => '',
34 'duration' => '',
35 'status' => '',
36 'payment_status' => '',
37 'customer_id' => '',
38 'customer_name' => '',
39 'team_member_id' => '',
40 'service_id' => '',
41 'additional_duration_id' => '',
42 'group_booking' => array(),
43 'order_id' => '',
44 'quantity' => 0,
45 'price' => 0,
46 'subtotal' => 0,
47 'total' => 0,
48 'note' => '',
49 'source' => '',
50 'location_id' => '',
51 'location' => array(),
52 'extra_services' => array(),
53 'security_token' => '',
54 'total_attendees' => 1,
55 'timezone' => '',
56 'cancel_note' => '',
57 );
58
59 /**
60 * Accepted post statuses for appointments
61 *
62 * @var array
63 */
64 public static $accepted_statuses = array(
65 'pending',
66 'active',
67 'cancelled',
68 'canceled',
69 'completed',
70 'approved',
71 'not_now',
72 );
73
74 /**
75 * Get post type
76 *
77 * @return string Post type name
78 */
79 public function get_post_type() {
80 return 'booktics-appointment';
81 }
82
83 /**
84 * Get accepted statuses
85 *
86 * @return array Accepted statuses
87 */
88 public static function get_accepted_statuses() {
89 return self::$accepted_statuses;
90 }
91
92 /**
93 * Specify data which should be serialized to JSON.
94 *
95 * @return array
96 */
97 public function jsonSerialize(): array {
98 $data = $this->to_array();
99
100 $service = Service_Model::find( $this->service_id );
101 $db = new Booktics_Database();
102 $customer = ( new Guest_Model( $db ) )->find( array( 'id' => $this->customer_id ) );
103 $team_member = ( new Team_Member_Model() )->find( $this->team_member_id );
104
105 $appointment_data = array(
106 'service' => $service,
107 'team_member' => $team_member,
108 'customer' => $customer,
109 );
110
111 return array_merge( $data, $appointment_data );
112 }
113
114 /**
115 * Get appointments for a specific date and service/team member
116 *
117 * @param string $date
118 * @param int $team_member_id
119 *
120 * @return array
121 * @throws Exception
122 */
123 public function get_appointments_for_slot( string $date, int $team_member_id ): array {
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' ) ),
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
181 'meta_query' => array(
182 'relation' => 'AND',
183 array(
184 'key' => 'date',
185 'value' => $date,
186 'compare' => '=',
187 ),
188 array(
189 'key' => 'team_member_id',
190 'value' => $team_member_id,
191 'compare' => '=',
192 ),
193 ),
194 );
195 }
196
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 );
212
213 if ( $appointment instanceof self ) {
214 $appointment->add_security_token_if_not_exists();
215 }
216
217 return $appointment;
218 }
219
220 /**
221 * Override load_attributes to include payment status from orders
222 *
223 * @return void
224 */
225 protected function load_attributes() {
226 parent::load_attributes();
227
228 if ( $this->order_id ) {
229 $db = new Booktics_Database();
230 $order_model = ( new Order_Model( $db ) )->find( array( 'id' => $this->order_id ) );
231
232 $this->attributes['payment_status'] = $order_model->payment_status ?? 'pending';
233 } else {
234 $this->attributes['payment_status'] = 'pending';
235 }
236 }
237
238 /**
239 * Get appointments count by order ID
240 *
241 * @param int $order_id Order ID
242 *
243 * @return int Number of appointments
244 */
245 public function get_appointments_count_by_order_id( $order_id ) {
246 $args = array(
247 'post_type' => $this->get_post_type(),
248 'post_status' => array( 'publish', 'active', 'approved', 'completed', 'pending' ),
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
251 'meta_query' => array(
252 array(
253 'key' => 'order_id',
254 'value' => $order_id,
255 'compare' => '=',
256 ),
257 ),
258 );
259
260 $query = new \WP_Query( $args );
261
262 return $query->found_posts;
263 }
264
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 /**
297 * Get appointments count by team member ID
298 *
299 * @param int $team_member_id Team member ID
300 *
301 * @return int Number of appointments
302 */
303 public function get_appointments_count_by_team_member_id( $team_member_id ) {
304 $args = array(
305 'post_type' => $this->get_post_type(),
306 'post_status' => array( 'publish', 'active', 'pending' ),
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
309 'meta_query' => array(
310 array(
311 'key' => 'team_member_id',
312 'value' => $team_member_id,
313 'compare' => '=',
314 ),
315 ),
316 );
317
318 $query = new \WP_Query( $args );
319
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();
467 }
468 }
469