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 +468 -209 1.0.1 → 1.0.29 View file →
@@ -1,209 +1,468 @@
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 Exception;
13 -
14 -/**
15 - * AppointmentModel class
16 - */
17 -class Appointment_Model extends Post_Model {
18 - /**
19 - * Store appointment data
20 - *
21 - * @var array
22 - */
23 - protected $fillable = [
24 - 'id' => '',
25 - 'title' => '',
26 - 'content' => '',
27 - 'start_time' => '',
28 - 'end_time' => '',
29 - 'date' => '',
30 - 'buffer_before' => '',
31 - 'buffer_after' => '',
32 - 'duration' => '',
33 - 'status' => '',
34 - 'payment_status' => '',
35 - 'customer_id' => '',
36 - 'customer_name' => '',
37 - 'team_member_id' => '',
38 - 'service_id' => '',
39 - 'additional_duration_id' => '',
40 - 'group_booking' => [],
41 - 'order_id' => '',
42 - 'quantity' => 0,
43 - 'price' => 0,
44 - 'subtotal' => 0,
45 - 'total' => 0,
46 - 'note' => '',
47 - 'source' => '',
48 - ];
49 -
50 - /**
51 - * Accepted post statuses for appointments
52 - *
53 - * @var array
54 - */
55 - public static $accepted_statuses = [
56 - 'pending',
57 - 'active',
58 - 'cancelled',
59 - 'canceled',
60 - 'completed',
61 - 'approved',
62 - 'not_now',
63 - ];
64 -
65 - /**
66 - * Get post type
67 - *
68 - * @return string Post type name
69 - */
70 - public function get_post_type() {
71 - return 'booktics-appointment';
72 - }
73 -
74 - /**
75 - * Get accepted statuses
76 - *
77 - * @return array Accepted statuses
78 - */
79 - public static function get_accepted_statuses() {
80 - return self::$accepted_statuses;
81 - }
82 -
83 - /**
84 - * Specify data which should be serialized to JSON.
85 - *
86 - * @return array
87 - */
88 - public function jsonSerialize(): array {
89 - $data = $this->to_array();
90 -
91 - $service = Service_Model::find( $this->service_id );
92 - $customer = ( new Customer_Model() )->find( $this->customer_id );
93 - $team_member = ( new Team_Member_Model() )->find( $this->team_member_id );
94 -
95 - $appointment_data = [
96 - 'service' => $service,
97 - 'team_member' => $team_member,
98 - 'customer' => $customer
99 - ];
100 -
101 - return array_merge( $data, $appointment_data );
102 - }
103 -
104 - /**
105 - * Get appointments for a specific date and service/team member
106 - *
107 - * @param string $date
108 - * @param int $team_member_id
109 - *
110 - * @return array
111 - * @throws Exception
112 - */
113 - 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' ],
117 - 'posts_per_page' => - 1,
118 - 'meta_query' => [
119 - 'relation' => 'AND',
120 - [
121 - 'key' => 'date',
122 - 'value' => $date,
123 - 'compare' => '='
124 - ],
125 - [
126 - 'key' => 'team_member_id',
127 - 'value' => $team_member_id,
128 - 'compare' => '='
129 - ]
130 - ]
131 - ];
132 -
133 - $query = new \WP_Query( $args );
134 -
135 - return array_map( function ( $post ) {
136 - return new static( $post );
137 - }, $query->posts );
138 - }
139 -
140 - /**
141 - * Override load_attributes to include payment status from orders
142 - *
143 - * @return void
144 - */
145 - protected function load_attributes() {
146 - parent::load_attributes();
147 -
148 - if ( $this->order_id ) {
149 - $db = new Booktics_Database();
150 - $order_model = ( new Order_Model( $db ) )->find( [ 'id' => $this->order_id ] );
151 -
152 - $this->attributes['payment_status'] = $order_model->payment_status ?? 'pending';
153 - } else {
154 - $this->attributes['payment_status'] = 'pending';
155 - }
156 - }
157 -
158 - /**
159 - * Get appointments count by order ID
160 - *
161 - * @param int $order_id Order ID
162 - *
163 - * @return int Number of appointments
164 - */
165 - public function get_appointments_count_by_order_id( $order_id ) {
166 - $args = [
167 - 'post_type' => $this->get_post_type(),
168 - 'post_status' => [ 'publish', 'active', 'pending' ],
169 - 'posts_per_page' => - 1,
170 - 'meta_query' => [
171 - [
172 - 'key' => 'order_id',
173 - 'value' => $order_id,
174 - 'compare' => '='
175 - ]
176 - ]
177 - ];
178 -
179 - $query = new \WP_Query( $args );
180 -
181 - return $query->found_posts;
182 - }
183 -
184 - /**
185 - * Get appointments count by team member ID
186 - *
187 - * @param int $team_member_id Team member ID
188 - *
189 - * @return int Number of appointments
190 - */
191 - public function get_appointments_count_by_team_member_id( $team_member_id ) {
192 - $args = [
193 - 'post_type' => $this->get_post_type(),
194 - 'post_status' => [ 'publish', 'active', 'pending' ],
195 - 'posts_per_page' => - 1,
196 - 'meta_query' => [
197 - [
198 - 'key' => 'team_member_id',
199 - 'value' => $team_member_id,
200 - 'compare' => '='
201 - ]
202 - ]
203 - ];
204 -
205 - $query = new \WP_Query( $args );
206 -
207 - return $query->found_posts;
208 - }
209 -}
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 +}