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 / core / appointment / controllers / appointment-controller.php

appointment-controller.php in Booktics – Appointment Booking Calendar for Service Businesses 1.0.27, at core/appointment/controllers/appointment-controller.php

1,303 lines 49.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace Booktics\Appointment\Controllers;
4
5 use Booktics\Abstracts\Base_Rest_Controller;
6 use Booktics\Abstracts\Booktics_Database;
7 use Booktics\Appointment\Schedule\Default_Schedule;
8 use Booktics\Appointment\Schedule\Service_Schedule;
9 use Booktics\Appointment\Schedule\Team_Member_Schedule;
10 use Booktics\Appointment\Schedule\Time_Slot_Manager;
11 use Booktics\Appointment\Services\Appointment_Service;
12 use Booktics\Appointment\Services\Appointment_Validator;
13 use Booktics\Models\Appointment_Model;
14 use Booktics\Models\Service_Model;
15 use Booktics\Models\Team_Member_Model;
16 use Booktics\Models\Guest_Model;
17 use Exception;
18 use WP_Error;
19 use WP_HTTP_Response;
20 use WP_REST_Request;
21 use WP_REST_Response;
22 use WP_REST_Server;
23
24 /**
25 * Appointment controller
26 *
27 * @package Booktics/Appointment
28 */
29 class Appointment_Controller extends Base_Rest_Controller {
30 /**
31 * Endpoint namespace
32 *
33 * @var string
34 */
35 protected $namespace = 'booktics/v1';
36
37 /**
38 * Route name
39 *
40 * @var string
41 */
42 protected $base = 'appointments';
43
44 /**
45 * Register routes
46 *
47 * @return void
48 */
49 public function register_routes(): void {
50 register_rest_route(
51 $this->namespace,
52 $this->base,
53 array(
54 array(
55 'methods' => WP_REST_Server::CREATABLE,
56 'callback' => array( $this, 'create_item' ),
57 'permission_callback' => array(
58 $this,
59 'create_item_permissions_check',
60 ),
61 ),
62 )
63 );
64
65 register_rest_route(
66 $this->namespace,
67 $this->base,
68 array(
69 array(
70 'methods' => WP_REST_Server::READABLE,
71 'callback' => array( $this, 'get_items' ),
72 'permission_callback' => array(
73 $this,
74 'get_items_permissions_check',
75 ),
76 ),
77 )
78 );
79
80 register_rest_route(
81 $this->namespace,
82 $this->base,
83 array(
84 array(
85 'methods' => WP_REST_Server::DELETABLE,
86 'callback' => array( $this, 'delete_items' ),
87 'permission_callback' => array(
88 $this,
89 'delete_items_permissions_check',
90 ),
91 ),
92 )
93 );
94
95 register_rest_route(
96 $this->namespace,
97 $this->base . '/(?P<id>[\d]+)',
98 array(
99 array(
100 'methods' => WP_REST_Server::READABLE,
101 'callback' => array( $this, 'get_item' ),
102 'permission_callback' => array(
103 $this,
104 'get_item_permissions_check',
105 ),
106 ),
107 )
108 );
109
110 register_rest_route(
111 $this->namespace,
112 $this->base . '/(?P<id>[\d]+)',
113 array(
114 array(
115 'methods' => WP_REST_Server::EDITABLE,
116 'callback' => array( $this, 'update_item' ),
117 'permission_callback' => array(
118 $this,
119 'update_item_permissions_check',
120 ),
121 ),
122 )
123 );
124
125 register_rest_route(
126 $this->namespace,
127 $this->base . '/(?P<id>[\d]+)',
128 array(
129 array(
130 'methods' => WP_REST_Server::DELETABLE,
131 'callback' => array( $this, 'delete_item' ),
132 'permission_callback' => array(
133 $this,
134 'delete_item_permissions_check',
135 ),
136 ),
137 )
138 );
139
140 register_rest_route(
141 $this->namespace,
142 $this->base . '/(?P<id>[\d]+)/reschedule',
143 array(
144 array(
145 'methods' => WP_REST_Server::EDITABLE,
146 'callback' => array( $this, 'reschedule_item' ),
147 'permission_callback' => array(
148 $this,
149 'reschedule_item_permissions_check',
150 ),
151 ),
152 )
153 );
154
155 register_rest_route(
156 $this->namespace,
157 $this->base . '/(?P<id>[\d]+)/cancel',
158 array(
159 array(
160 'methods' => WP_REST_Server::EDITABLE,
161 'callback' => array( $this, 'cancel_item' ),
162 'permission_callback' => array(
163 $this,
164 'cancel_item_permissions_check',
165 ),
166 ),
167 )
168 );
169
170 register_rest_route(
171 $this->namespace,
172 $this->base . '/time-slots',
173 array(
174 array(
175 'methods' => WP_REST_Server::READABLE,
176 'callback' => array( $this, 'get_time_slots' ),
177 'permission_callback' => array(
178 $this,
179 'get_time_slots_permissions_check',
180 ),
181 ),
182 )
183 );
184
185 register_rest_route(
186 $this->namespace,
187 $this->base . '/calendar',
188 array(
189 array(
190 'methods' => WP_REST_Server::READABLE,
191 'callback' => array(
192 $this,
193 'get_all_calendar_items',
194 ),
195 'permission_callback' => array(
196 $this,
197 'get_items_permissions_check',
198 ),
199 'args' => array(
200 'month' => array(
201 'description' => __( 'Month in YYYY-MM format', 'booktics' ),
202 'type' => 'string',
203 'validate_callback' => function ( $param ) {
204 return preg_match( '/^\d{4}-\d{2}$/', $param );
205 },
206 ),
207 ),
208 ),
209 )
210 );
211 }
212
213 /**
214 * Get a collection of items.
215 *
216 * @param WP_REST_Request $request Full data about the request.
217 *
218 * @return WP_HTTP_Response
219 */
220 public function get_items( $request ) {
221 $per_page = ! empty( $request['per_page'] ) ? intval( $request['per_page'] ) : 10;
222 $paged = ! empty( $request['paged'] ) ? intval( $request['paged'] ) : 1;
223 $date_from = ! empty( $request['date_from'] ) ? sanitize_text_field( $request['date_from'] ) : null;
224
225 $service_id = ! empty( $request['service_id'] ) ? intval( $request['service_id'] ) : null;
226 $customer_id = ! empty( $request['customer_id'] ) ? intval( $request['customer_id'] ) : null;
227 $payment_status = ! empty( $request['payment_status'] ) ? sanitize_text_field( $request['payment_status'] ) : null;
228 $team_member_id = ! empty( $request['team_member_id'] ) ? intval( $request['team_member_id'] ) : null;
229 $status = ! empty( $request['status'] ) ? sanitize_text_field( $request['status'] ) : null;
230
231 $search = ! empty( $request['search'] ) ? sanitize_text_field( $request['search'] ) : null;
232 // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Meta query is necessary for filtering appointments by meta fields
233 $args = array( 'meta_query' => array() );
234
235 if ( $search ) {
236 $service_ids = array();
237 $customer_ids = array();
238 $team_member_ids = array();
239 // Search Services
240 $services = Service_Model::all(
241 array(
242 's' => $search,
243 )
244 );
245 $service_ids = array_map(
246 function ( $service ) {
247 return $service->id;
248 }, $services
249 );
250
251 // Search Customers
252 $customer_query = new \WP_User_Query(
253 array(
254 'search' => '*' . esc_attr( $search ) . '*',
255 'search_columns' => array( 'display_name', 'user_email' ),
256 'role' => 'booktics_customer',
257 'fields' => 'ID',
258 )
259 );
260 $customer_ids = $customer_query->get_results();
261
262 // Search Team Members
263 $team_member_query = new \WP_User_Query(
264 array(
265 'search' => '*' . esc_attr( $search ) . '*',
266 'search_columns' => array( 'display_name', 'user_email' ),
267 'role' => 'booktics_team_member',
268 'fields' => 'ID',
269 )
270 );
271 $team_member_ids = $team_member_query->get_results();
272
273 if ( $service_ids || $customer_ids || $team_member_ids ) {
274 // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Meta query is necessary for filtering users by meta fields
275 $args['meta_query'] = array( 'relation' => 'OR' );
276 if ( $service_ids ) {
277 $args['meta_query'][] = array(
278 'key' => 'service_id',
279 'value' => $service_ids,
280 'compare' => 'IN',
281 );
282 }
283 if ( $customer_ids ) {
284 $args['meta_query'][] = array(
285 'key' => 'customer_id',
286 'value' => $customer_ids,
287 'compare' => 'IN',
288 );
289 }
290 if ( $team_member_ids ) {
291 $args['meta_query'][] = array(
292 'key' => 'team_member_id',
293 'value' => $team_member_ids,
294 'compare' => 'IN',
295 );
296 }
297 } else {
298 return $this->response(
299 array(
300 'items' => array(),
301 'total' => 0,
302 'per_page' => $per_page,
303 'current_page' => $paged,
304 'last_page' => 0,
305 ), __( 'No appointments found', 'booktics' )
306 );
307 }
308 }
309
310 if ( $date_from ) {
311 $args['meta_query'][] = array(
312 'key' => 'date',
313 'value' => $date_from,
314 'compare' => '>=',
315 'type' => 'DATE',
316 );
317 }
318
319 if ( $service_id ) {
320 $args['meta_query'][] = array(
321 'key' => 'service_id',
322 'value' => $service_id,
323 'compare' => '=',
324 );
325 }
326
327 if ( $customer_id ) {
328 $args['meta_query'][] = array(
329 'key' => 'customer_id',
330 'value' => $customer_id,
331 'compare' => '=',
332 );
333 }
334
335 if ( $payment_status ) {
336 $args['meta_query'][] = array(
337 'key' => 'payment_status',
338 'value' => $payment_status,
339 'compare' => '=',
340 );
341 }
342
343 if ( $team_member_id ) {
344 $args['meta_query'][] = array(
345 'key' => 'team_member_id',
346 'value' => $team_member_id,
347 'compare' => '=',
348 );
349 }
350
351 // Adds filtering by appointment status
352 if ( $status ) {
353 $args['post_status'] = is_array( $status ) ? $status : array( $status );
354 }
355
356 $args = apply_filters( 'booktics_appointment_args', $args );
357
358 // Scope to customer's own appointments when not admin/team-member
359 if ( ! booktics_is_admin_or_team_member() && is_user_logged_in() ) {
360 $db = new Booktics_Database();
361 $guest = ( new Guest_Model( $db ) )->find( array( 'wp_user_id' => get_current_user_id() ) );
362 if ( ! $guest ) {
363 return $this->response(
364 array(
365 'items' => array(),
366 'total' => 0,
367 'per_page' => $per_page,
368 'current_page' => $paged,
369 'last_page' => 0,
370 ), __( 'No appointments found', 'booktics' )
371 );
372 }
373 // Add customer_id filter to meta_query
374 $args['meta_query'][] = array(
375 'key' => 'customer_id',
376 'value' => $guest->id,
377 'compare' => '=',
378 );
379 }
380
381 $data = Appointment_Model::paginate( $per_page, $paged, $args );
382
383 $data = apply_filters( 'booktics_appointment_data', $data );
384
385 // Mask team member email for customers (non-admin/team-member)
386 if ( ! booktics_is_admin_or_team_member() && is_user_logged_in() && ! empty( $data['items'] ) ) {
387 foreach ( $data['items'] as &$appointment ) {
388 if ( isset( $appointment->team_member_id ) ) {
389 $team_member = new Team_Member_Model( $appointment->team_member_id );
390 $team_member_data = $team_member->find( $appointment->team_member_id );
391 if ( $team_member_data ) {
392 // Remove user_email from team data to protect privacy
393 $team_member_data->user_email = '';
394 }
395 }
396 }
397 }
398
399 if ( ! $data ) {
400 return $this->error( __( 'No appointment found', 'booktics' ), 404 );
401 }
402
403 return $this->response( $data );
404 }
405
406 /**
407 * Get one item from the collection.
408 *
409 * @param WP_REST_Request $request Full data about the request.
410 *
411 * @return WP_Error|WP_REST_Response
412 */
413 public function get_item( $request ) {
414 $id = intval( $request['id'] );
415
416 $appointment = Appointment_Model::find( $id );
417
418 if ( ! $appointment ) {
419 return $this->error( __( 'Appointment not found', 'booktics' ), 404 );
420 }
421
422 // Mask team member email for customers (non-admin/team-member)
423 if ( ! booktics_is_admin_or_team_member() && is_user_logged_in() ) {
424 $appointment_array = is_array( $appointment ) ? $appointment : ( is_object( $appointment ) ? get_object_vars( $appointment ) : array() );
425 if ( isset( $appointment_array['team_member_id'] ) ) {
426 $team_member = new Team_Member_Model( $appointment_array['team_member_id'] );
427 $team_member_data = $team_member->find( $appointment_array['team_member_id'] );
428 if ( $team_member_data && isset( $team_member_data->user_email ) ) {
429 // We can't easily unset from the object, but we can return a modified response
430 }
431 }
432 }
433
434 return $this->response( $appointment );
435 }
436
437 /**
438 * Get all calendar items within a date range
439 *
440 * @param WP_REST_Request $request Full data about the request.
441 *
442 * @return WP_Error|WP_REST_Response
443 */
444 public function get_all_calendar_items( $request ) {
445 $per_page = ! empty( $request['per_page'] ) ? intval( $request['per_page'] ) : 500;
446 $paged = ! empty( $request['paged'] ) ? intval( $request['paged'] ) : 1;
447
448 // Check if custom date range is provided
449 if ( ! empty( $request['start_date'] ) && ! empty( $request['end_date'] ) ) {
450 $adjusted_start_date = sanitize_text_field( $request['start_date'] );
451 $adjusted_end_date = sanitize_text_field( $request['end_date'] );
452 $month = gmdate( 'Y-m', strtotime( $adjusted_start_date ) );
453 } else {
454 // Fall back to month-based date range with week adjustment
455 $month = ! empty( $request['month'] ) ? sanitize_text_field( $request['month'] ) : gmdate( 'Y-m' );
456 $start_date = gmdate( 'Y-m-01', strtotime( $month ) );
457 $end_date = gmdate( 'Y-m-t', strtotime( $month ) );
458
459 // Get the first day of the week (Sunday = 0, Monday = 1, etc.)
460 $first_day_of_week = get_option( 'start_of_week', 1 );
461
462 // Adjust start date to include days from previous month to complete the first week
463 $start_date_obj = new \DateTime( $start_date );
464 $start_week_day = (int) $start_date_obj->format( 'w' ); // 0 (Sun) to 6 (Sat)
465 $days_to_subtract = ( $start_week_day - $first_day_of_week + 7 ) % 7;
466 $start_date_obj->modify( "-$days_to_subtract days" );
467 $adjusted_start_date = $start_date_obj->format( 'Y-m-d' );
468
469 // Adjust end date to include days from next month to complete the last week
470 $end_date_obj = new \DateTime( $end_date );
471 $end_week_day = (int) $end_date_obj->format( 'w' ); // 0 (Sun) to 6 (Sat)
472 $days_to_add = ( 6 - $end_week_day + $first_day_of_week ) % 7;
473 $end_date_obj->modify( "+$days_to_add days" );
474 $adjusted_end_date = $end_date_obj->format( 'Y-m-d' );
475 }
476
477 // Get all appointments within the date range using meta_query
478 $args = array(
479 // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Meta query is necessary for filtering appointments by meta fields
480 'meta_query' => array(
481 'relation' => 'AND',
482 array(
483 'key' => 'date',
484 'value' => array(
485 $adjusted_start_date,
486 $adjusted_end_date,
487 ),
488 'compare' => 'BETWEEN',
489 'type' => 'DATE',
490 ),
491 ),
492 'posts_per_page' => - 1, // Get all appointments in the range
493 'orderby' => 'meta_value',
494 'meta_key' => 'date', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Required for ordering by date meta field
495 'order' => 'ASC',
496 );
497
498 $service_id = ! empty( $request['service_id'] ) ? intval( $request['service_id'] ) : null;
499 $customer_id = ! empty( $request['customer_id'] ) ? intval( $request['customer_id'] ) : null;
500 $payment_status = ! empty( $request['payment_status'] ) ? sanitize_text_field( $request['payment_status'] ) : null;
501 $team_member_id = ! empty( $request['team_member_id'] ) ? intval( $request['team_member_id'] ) : null;
502 $status = ! empty( $request['status'] ) ? sanitize_text_field( $request['status'] ) : null;
503
504 if ( $service_id ) {
505 $args['meta_query'][] = array(
506 'key' => 'service_id',
507 'value' => $service_id,
508 'compare' => '=',
509 );
510 }
511
512 if ( $customer_id ) {
513 $args['meta_query'][] = array(
514 'key' => 'customer_id',
515 'value' => $customer_id,
516 'compare' => '=',
517 );
518 }
519
520 if ( $payment_status ) {
521 $args['meta_query'][] = array(
522 'key' => 'payment_status',
523 'value' => $payment_status,
524 'compare' => '=',
525 );
526 }
527
528 if ( $team_member_id ) {
529 $args['meta_query'][] = array(
530 'key' => 'team_member_id',
531 'value' => $team_member_id,
532 'compare' => '=',
533 );
534 }
535
536 if ( $status ) {
537 $args['meta_query'][] = array(
538 'key' => 'status',
539 'value' => $status,
540 'compare' => '=',
541 );
542 }
543
544 $args = apply_filters( 'booktics_appointment_args', $args );
545
546 $appointments = Appointment_Model::paginate( $per_page, $paged, $args );
547
548 // Format the response
549 $formatted_appointments = array();
550
551 if ( ! empty( $appointments ) ) {
552 foreach ( $appointments['items'] as $appointment ) {
553 if ( ! empty( $appointment ) ) {
554 $appointment->source = 'booktics';
555 }
556
557 $formatted_appointments[] = $appointment;
558 }
559 }
560
561 // Allow other developers to add/modify appointments
562 $formatted_appointments = apply_filters(
563 'booktics_appointments_for_calendar_view',
564 $formatted_appointments,
565 array(
566 'start_date' => $adjusted_start_date,
567 'end_date' => $adjusted_end_date,
568 )
569 );
570
571 $response = array(
572 'month' => gmdate( 'Y-m', strtotime( $month ) ),
573 'date_range' => array(
574 'start' => $adjusted_start_date,
575 'end' => $adjusted_end_date,
576 ),
577 'appointments' => $formatted_appointments,
578 );
579
580 return $this->response( $response, __( 'Calendar appointments retrieved successfully', 'booktics' ) );
581 }
582
583 /**
584 * Check if a given request has access to get a specific item.
585 *
586 * @param WP_REST_Request $request Full data about the request.
587 *
588 * @return WP_Error|boolean
589 */
590 public function get_item_permissions_check( $request ) {
591 if ( booktics_is_admin_or_team_member() ) {
592 return true;
593 }
594
595 if ( is_user_logged_in() ) {
596 $appointment_id = intval( $request['id'] );
597 $appointment = Appointment_Model::find( $appointment_id );
598
599 if ( $appointment ) {
600 $db = new Booktics_Database();
601 $customer = ( new Guest_Model( $db ) )->find( array( 'id' => $appointment->customer_id ) );
602
603 if ( $customer && intval( $customer->wp_user_id ) === get_current_user_id() ) {
604 return true;
605 }
606 }
607 }
608
609 return new WP_Error(
610 'booktics_forbidden',
611 __( 'You are not allowed to view this appointment.', 'booktics' ),
612 array( 'status' => 403 )
613 );
614 }
615
616 /**
617 * Check if a given request has access to get items.
618 *
619 * @param WP_REST_Request $request Full data about the request.
620 *
621 * @return WP_Error|boolean
622 */
623 public function get_items_permissions_check( $request ) {
624 if ( booktics_is_admin_or_team_member() ) {
625 return true;
626 }
627
628 if ( is_user_logged_in() ) {
629 return true;
630 }
631
632 return new WP_Error(
633 'booktics_forbidden',
634 __( 'You are not allowed to view appointments.', 'booktics' ),
635 array( 'status' => 403 )
636 );
637 }
638
639 /**
640 * Create one item from the collection.
641 *
642 * @param WP_REST_Request $request Full data about the request.
643 *
644 * @return WP_Error|WP_REST_Response
645 * @throws Exception
646 */
647 public function create_item( $request ) {
648 $data = json_decode( $request->get_body(), true );
649
650 /**
651 * Filters appointment data before it is validated and created.
652 *
653 * Runs before validation so an extension can supply required fields.
654 * Booktics Pro round robin fills team_member_id here when a service
655 * assigns its team members automatically.
656 *
657 * $data is the raw decoded request body: it has not been sanitised and
658 * every key reaches Appointment_Service::create(). A filter is
659 * responsible for sanitising whatever it adds.
660 *
661 * @since 1.0.26
662 *
663 * @param array $data Decoded request body.
664 * @param \WP_REST_Request $request The create request.
665 */
666 $data = apply_filters( 'booktics_appointment_create_data', $data, $request );
667
668 // A malformed body decodes to null, and a filter can return anything at
669 // all. validate_create() is typed array, so either would be a fatal.
670 if ( ! is_array( $data ) ) {
671 return $this->error( __( 'Invalid appointment data.', 'booktics' ) );
672 }
673
674 $error = Appointment_Validator::validate_create( $data );
675 if ( $error ) {
676 return $this->error( $error->get_error_message() );
677 }
678
679 $conflict = Appointment_Validator::validate_slot_available( $data );
680 if ( $conflict ) {
681 return $this->error( $conflict->get_error_message() );
682 }
683 $appointment = Appointment_Service::instance()->create( $data );
684 if ( is_wp_error( $appointment ) ) {
685 return $this->error( $appointment->get_error_message() );
686 }
687 do_action( 'booktics_after_appointment_created', $appointment );
688
689 return $this->response( $appointment, __( 'Successfully created appointment', 'booktics' ) );
690 }
691
692 /**
693 * Check if a given request has access to create items.
694 *
695 * @param WP_REST_Request $request Full data about the request.
696 *
697 * @return WP_Error|boolean
698 */
699 public function create_item_permissions_check( $request ) {
700 return current_user_can( 'manage_options' );
701 }
702
703 /**
704 * Update one item from the collection.
705 *
706 * @param WP_REST_Request $request Full data about the request.
707 *
708 * @return WP_REST_Response
709 */
710 public function update_item( $request ) {
711 $id = intval( $request['id'] );
712 $data = json_decode( $request->get_body(), true );
713 $error = Appointment_Validator::validate_update( $data );
714 if ( $error ) {
715 return $this->error( $error->get_error_message() );
716 }
717 $updated_appointment = Appointment_Service::instance()->update( $id, $data );
718 if ( is_wp_error( $updated_appointment ) ) {
719 return $this->error( $updated_appointment->get_error_message() );
720 }
721 do_action( 'booktics_after_appointment_updated', $updated_appointment );
722
723 return $this->response( $updated_appointment, __( 'Appointment updated successfully', 'booktics' ) );
724 }
725
726 /**
727 * Check if a given request has access to update a specific item.
728 *
729 * @param WP_REST_Request $request Full data about the request.
730 *
731 * @return WP_Error|boolean
732 */
733 public function update_item_permissions_check( $request ) {
734 return current_user_can( 'manage_options' );
735 }
736
737 /**
738 * Delete one item from the collection.
739 *
740 * @param WP_REST_Request $request Full data about the request.
741 *
742 * @return \WP_Error|\WP_HTTP_Response
743 */
744 public function delete_item( $request ) {
745 $id = intval( $request['id'] );
746 $result = Appointment_Service::instance()->delete( $id );
747 if ( is_wp_error( $result ) ) {
748 return $this->error( $result->get_error_message() );
749 }
750 do_action( 'booktics_after_appointment_deleted', $result );
751
752 return $this->response( $result, __( 'Successfully deleted appointment', 'booktics' ) );
753 }
754
755 /**
756 * Check if a given request has access to delete a specific item.
757 *
758 * @param WP_REST_Request $request Full data about the request.
759 *
760 * @return WP_Error|boolean
761 */
762 public function delete_item_permissions_check( $request ) {
763 return current_user_can( 'manage_options' );
764 }
765
766 /**
767 * Delete a collection of items
768 *
769 * @param WP_Rest_Request $request User request
770 */
771 public function delete_items( $request ) {
772 $ids = $request['ids'];
773 $result = Appointment_Service::instance()->delete_many( $ids );
774 if ( is_wp_error( $result ) ) {
775 return $this->error( $result->get_error_message() );
776 }
777
778 return $this->response( $result['data'], $result['message'] );
779 }
780
781 /**
782 * Check if a given request has access to delete a collection of items.
783 *
784 * @param WP_REST_Request $request Full data about the request.
785 *
786 * @return WP_Error|boolean
787 */
788 public function delete_items_permissions_check( $request ) {
789 return current_user_can( 'manage_options' );
790 }
791
792 /**
793 * Reschedule an appointment
794 *
795 * @param $request Full data about the request.
796 *
797 * @throws Exception
798 */
799 public function reschedule_item( $request ) {
800 $id = intval( $request['id'] );
801 $data = json_decode( $request->get_body(), true );
802
803 /**
804 * Filters reschedule data before validation.
805 *
806 * Returning a WP_Error refuses the reschedule with that message, which
807 * is how Booktics Pro keeps a booking with its original team member.
808 *
809 * @since 1.0.26
810 *
811 * @param array|\WP_Error $data Reschedule data.
812 * @param int $id Appointment being moved.
813 */
814 $data = apply_filters( 'booktics_appointment_reschedule_data', $data, $id );
815
816 if ( is_wp_error( $data ) ) {
817 return $this->error( $data->get_error_message() );
818 }
819
820 if ( ! is_array( $data ) ) {
821 return $this->error( __( 'Invalid reschedule data.', 'booktics' ) );
822 }
823
824 $error = Appointment_Validator::validate_reschedule( $data, $id );
825 if ( $error ) {
826 return $this->error( $error->get_error_message() );
827 }
828
829 // Same guard as create_item(): a reschedule is a booking too, and the
830 // owning customer can reach this route. The reschedule payload only
831 // carries the new date and time, so the stored appointment supplies
832 // service, duration and attendees, without which the check would fall
833 // back to a 30 minute window and a capacity of one and could refuse a
834 // group booking that still has room. Its own id is passed so the
835 // booking is never counted as a conflict with itself.
836 $moving = Appointment_Model::find( $id );
837
838 if ( $moving ) {
839 $conflict = Appointment_Validator::validate_slot_available(
840 array(
841 'id' => $id,
842 'date' => $data['date'] ?? $moving->date,
843 'start_time' => $data['start_time'] ?? $moving->start_time,
844 'team_member_id' => $data['team_member_id'] ?? $moving->team_member_id,
845 'service_id' => $moving->service_id,
846 'duration' => $moving->duration,
847 'total_attendees' => $moving->total_attendees,
848 )
849 );
850
851 if ( $conflict ) {
852 return $this->error( $conflict->get_error_message() );
853 }
854 }
855 $result = Appointment_Service::instance()->reschedule( $id, $data );
856 if ( is_wp_error( $result ) ) {
857 return $this->error( $result->get_error_message() );
858 }
859
860 $this->send_reschedule_email( $result );
861
862 do_action( 'booktics_after_appointment_rescheduled', $result );
863
864 return $this->response( $result, __( 'Successfully rescheduled appointment', 'booktics' ) );
865 }
866
867 /**
868 * Check if a given request has access to reschedule a specific item.
869 *
870 * @param $request Full data about the request.
871 */
872 public function reschedule_item_permissions_check( $request ) {
873 return $this->check_appointment_owner(
874 $request,
875 __( 'You are not allowed to reschedule this appointment.', 'booktics' )
876 );
877 }
878
879 /**
880 * Send reschedule email to customer
881 *
882 * @param Appointment_Model $appointment Appointment model.
883 */
884 private function send_reschedule_email( $appointment ) {
885 $db = new Booktics_Database();
886 $customer = ( new Guest_Model( $db ) )->find( [ 'id' => $appointment->customer_id ] );
887 $team_member = new Team_Member_Model( $appointment->team_member_id );
888
889 $appointment_reschedule_email_data = [
890 'date' => $appointment->date ?? '',
891 'start_time' => $appointment->start_time ?? '',
892 'end_time' => $appointment->end_time ?? '',
893 'customer_name' => $customer->name ?? '',
894 'user_email' => $customer->email ?? '',
895 'appointment_service' => $appointment->title ?? '',
896 'appointment_team_member' => $team_member->display_name ?? '',
897 'appointment_team_member_email' => $team_member->user_email ?? '',
898 ];
899
900 $appointment_reschedule_email_data = apply_filters( 'booktics_appointment_reschedule_email_data', $appointment_reschedule_email_data );
901
902 do_action( 'booktics_gln_hook', 'appointment_rescheduled', $appointment_reschedule_email_data );
903 }
904
905 /**
906 * Cancel an appointment
907 *
908 * @param WP_REST_Request $request Full data about the request.
909 *
910 * @throws Exception
911 */
912 public function cancel_item( $request ) {
913 $id = intval( $request['id'] );
914 $data = json_decode( $request->get_body(), true );
915 $cancel_note = ! empty( $data['cancel_note'] ) ? sanitize_text_field( $data['cancel_note'] ) : '';
916
917 $error = Appointment_Validator::instance()->validate_cancel( $id );
918
919 if ( $error ) {
920 return $this->error( $error->get_error_message() );
921 }
922
923 $result = Appointment_Service::instance()->cancel( $id, [ 'cancel_note' => $cancel_note ] );
924
925 if ( is_wp_error( $result ) ) {
926 return $this->error( $result->get_error_message() );
927 }
928
929 $this->send_cancel_email( $result );
930
931 // Note: booktics_after_appointment_cancelled is fired inside
932 // Appointment_Service::cancel(), the single choke point every cancel
933 // path routes through, so it is intentionally not re-fired here.
934
935 return $this->response( $result, __( 'Successfully cancelled appointment', 'booktics' ) );
936 }
937
938 /**
939 * Send cancel email to customer
940 *
941 * @param Appointment_Model $appointment Appointment model.
942 */
943 private function send_cancel_email( $appointment ) {
944 $db = new Booktics_Database();
945 $customer = ( new Guest_Model( $db ) )->find( [ 'id' => $appointment->customer_id ] );
946 $team_member = new Team_Member_Model( $appointment->team_member_id );
947
948 $appointment_cancel_email_data = [
949 'date' => $appointment->date ?? '',
950 'start_time' => $appointment->start_time ?? '',
951 'end_time' => $appointment->end_time ?? '',
952 'customer_name' => $customer->name ?? '',
953 'user_email' => $customer->email ?? '',
954 'appointment_service' => $appointment->title ?? '',
955 'appointment_team_member' => $team_member->display_name ?? '',
956 'appointment_team_member_email' => $team_member->user_email ?? '',
957 ];
958
959 $appointment_cancel_email_data = apply_filters( 'booktics_appointment_cancel_email_data', $appointment_cancel_email_data );
960
961 do_action( 'booktics_gln_hook', 'appointment_cancelled', $appointment_cancel_email_data );
962 }
963
964 /**
965 * Check if a given request has access to cancel a specific item.
966 *
967 * @param WP_REST_Request $request Full data about the request.
968 *
969 * @return WP_Error|boolean
970 */
971 public function cancel_item_permissions_check( $request ) {
972 return $this->check_appointment_owner(
973 $request,
974 __( 'You are not allowed to cancel this appointment.', 'booktics' )
975 );
976 }
977
978 /**
979 * Authorize a customer-facing action on a single appointment.
980 *
981 * Reschedule and cancel are reached from the booking confirmation screen
982 * and from the links in the confirmation email, so the caller is normally
983 * a guest with no WP session at all. Even a logged-in customer usually
984 * fails an account match, because the guest row only stores wp_user_id
985 * when the "use WordPress users as customers" setting is on, and it is off
986 * by default. The per-appointment security token that ships in those links
987 * is therefore the credential that actually identifies the customer, and
988 * it is checked here so the gate cannot be passed without it.
989 *
990 * @param WP_REST_Request $request Full data about the request.
991 * @param string $message Message returned when access is refused.
992 *
993 * @return bool|WP_Error True when the caller may act on the appointment.
994 */
995 private function check_appointment_owner( $request, $message ) {
996 if ( booktics_is_admin_or_team_member() ) {
997 return true;
998 }
999
1000 $appointment = Appointment_Model::find( intval( $request['id'] ) );
1001
1002 if ( $appointment ) {
1003 $token = $request->get_param( 'security_token' );
1004 $token = is_string( $token ) ? $token : '';
1005 $known_token = (string) $appointment->security_token;
1006
1007 // An appointment saved before tokens existed has an empty token,
1008 // and hash_equals( '', '' ) is true, so an empty submitted token
1009 // would unlock every one of those rows. Require a real value.
1010 if ( '' !== $token && '' !== $known_token && hash_equals( $known_token, $token ) ) {
1011 return true;
1012 }
1013
1014 if ( is_user_logged_in() ) {
1015 $db = new Booktics_Database();
1016 $customer = ( new Guest_Model( $db ) )->find( array( 'id' => $appointment->customer_id ) );
1017
1018 if ( $customer && ! empty( $customer->wp_user_id ) && intval( $customer->wp_user_id ) === get_current_user_id() ) {
1019 return true;
1020 }
1021 }
1022 }
1023
1024 return new WP_Error(
1025 'booktics_forbidden',
1026 $message,
1027 array( 'status' => 403 )
1028 );
1029 }
1030
1031 /**
1032 * Prepare the item for create or update operation.
1033 *
1034 * @param WP_REST_Request $request Request object.
1035 *
1036 * @return bool|\WP_Error $prepared_item
1037 */
1038 public function prepare_item_for_database( $request ) {
1039 $data = json_decode( $request->get_body(), true );
1040 $validate = booktics_validate(
1041 $data,
1042 array(
1043 'start_time' => array(
1044 'required',
1045 ),
1046 'end_time' => array(
1047 'required',
1048 ),
1049 'date' => array( 'required' ),
1050 'service_id' => array(
1051 'required',
1052 ),
1053 'team_member_id' => array(
1054 'required',
1055 ),
1056 )
1057 );
1058
1059 if ( is_wp_error( $validate ) ) {
1060 return $validate;
1061 }
1062
1063 $data = $this->get_sanitized_data( $data );
1064
1065 return $data;
1066 }
1067
1068 private function get_sanitized_data( $data ) {
1069 $data['start_time'] = ! empty( $data['start_time'] ) ? sanitize_text_field( $data['start_time'] ) : '';
1070 $data['end_time'] = ! empty( $data['end_time'] ) ? sanitize_text_field( $data['end_time'] ) : '';
1071 $data['date'] = ! empty( $data['date'] ) ? sanitize_text_field( $data['date'] ) : '';
1072 $data['service_id'] = ! empty( $data['service_id'] ) ? sanitize_text_field( $data['service_id'] ) : '';
1073 $data['team_member_id'] = ! empty( $data['team_member_id'] ) ? sanitize_text_field( $data['team_member_id'] ) : '';
1074 $data['customer_id'] = ! empty( $data['customer_id'] ) ? sanitize_text_field( $data['customer_id'] ) : '';
1075 $data['total_attendees'] = ! empty( $data['total_attendees'] ) ? sanitize_text_field( $data['total_attendees'] ) : '';
1076 $data['status'] = isset( $data['status'] ) ? $data['status'] : 'pending';
1077
1078 return $data;
1079 }
1080
1081 /**
1082 * Longest span of days the time slot route will answer for.
1083 */
1084 const MAX_SLOT_RANGE_DAYS = 62;
1085
1086 /**
1087 * Accept a Y-m-d date, or nothing.
1088 *
1089 * @param mixed $value Raw request value.
1090 *
1091 * @return string Y-m-d, or '' when it is not a date.
1092 */
1093 private static function sanitize_slot_date( $value ) {
1094 $value = sanitize_text_field( (string) $value );
1095
1096 if ( '' === $value ) {
1097 return '';
1098 }
1099
1100 $date = \DateTime::createFromFormat( 'Y-m-d', $value );
1101
1102 return ( $date && $date->format( 'Y-m-d' ) === $value ) ? $value : '';
1103 }
1104
1105 /**
1106 * Check if a given request has access to get time slots.
1107 *
1108 * @param $request
1109 *
1110 * @return boolean
1111 */
1112 public function get_time_slots_permissions_check( $request ) {
1113 return true;
1114 }
1115
1116 /**
1117 * Fetch appointment time slots
1118 *
1119 * @param WP_REST_Request $request Request object.
1120 *
1121 * @return array|\WP_HTTP_Response
1122 * @throws Exception If there's an error getting time slots.
1123 */
1124 public function get_time_slots( $request ) {
1125 $service_id = ! empty( $request['service_id'] ) ? absint( $request['service_id'] ) : 0;
1126 $additional_duration_id = ! empty( $request['additional_duration_id'] ) ? absint( $request['additional_duration_id'] ) : 0;
1127 $team_member_id = ! empty( $request['team_member_id'] ) ? absint( $request['team_member_id'] ) : 0;
1128 $start_date = self::sanitize_slot_date( $request['start_date'] ?? '' );
1129 $end_date = self::sanitize_slot_date( $request['end_date'] ?? '' );
1130
1131 // This route is public and takes no arguments schema, so both dates
1132 // arrive as free text. generate_slots_for_range() builds a DateTime
1133 // from them, and DateTime throws on anything it cannot read, which on
1134 // an unauthenticated route is a 500 with a stack trace.
1135 if ( ! empty( $request['start_date'] ) && '' === $start_date ) {
1136 return $this->error( __( 'Invalid start date.', 'booktics' ) );
1137 }
1138
1139 if ( ! empty( $request['end_date'] ) && '' === $end_date ) {
1140 return $this->error( __( 'Invalid end date.', 'booktics' ) );
1141 }
1142
1143 if ( $start_date && $end_date ) {
1144 if ( $end_date < $start_date ) {
1145 return $this->error( __( 'End date cannot be before start date.', 'booktics' ) );
1146 }
1147
1148 // The engine walks the range a day at a time, so an open-ended
1149 // span is a cheap request with a very expensive answer. A booking
1150 // calendar shows a month.
1151 $limit = strtotime( $start_date ) + ( self::MAX_SLOT_RANGE_DAYS * DAY_IN_SECONDS );
1152
1153 if ( strtotime( $end_date ) > $limit ) {
1154 $end_date = gmdate( 'Y-m-d', $limit );
1155 }
1156 }
1157 $total_attendees = isset( $request['total_attendees'] ) ? absint( $request['total_attendees'] ) : 1;
1158
1159 $db = new Booktics_Database();
1160
1161 $service_schedules = $db->table( booktics_get_table_name( 'booktics_schedules' ) )
1162 ->where( 'service_id', '=', $service_id )
1163 ->where_group(
1164 function ( $q ) use ( $start_date, $end_date ) {
1165 $q->where( 'custom_date', '=', '0' )
1166 ->or_where_group(
1167 function ( $q2 ) use ( $start_date, $end_date ) {
1168 $q2->where( 'custom_date', '>', $start_date )
1169 ->where( 'custom_date', '<', $end_date );
1170 }
1171 );
1172 }
1173 )
1174 ->to_array();
1175 $service_schedules = new Service_Schedule( $service_schedules, $service_id );
1176
1177 $team_member_schedules = $db->table( booktics_get_table_name( 'booktics_schedules' ) )
1178 ->where( 'team_member_id', '=', $team_member_id )
1179 ->where_group(
1180 function ( $q ) use ( $start_date, $end_date ) {
1181 $q->where( 'custom_date', '=', '0' )
1182 ->or_where_group(
1183 function ( $q2 ) use ( $start_date, $end_date ) {
1184 $q2->where( 'custom_date', '>', $start_date )
1185 ->where( 'custom_date', '<', $end_date );
1186 }
1187 );
1188 }
1189 )
1190 ->to_array();
1191 $team_member_schedules = new Team_Member_Schedule( $team_member_schedules, $team_member_id );
1192
1193 $default_schedules = $db->table( booktics_get_table_name( 'booktics_schedules' ) )
1194 ->where( 'service_id', '=', 0 )
1195 ->where( 'team_member_id', '=', 0 )
1196 ->where_group(
1197 function ( $q ) use ( $start_date, $end_date ) {
1198 $q->where( 'custom_date', '=', '0' )
1199 ->or_where_group(
1200 function ( $q2 ) use ( $start_date, $end_date ) {
1201 $q2->where( 'custom_date', '>', $start_date )
1202 ->where( 'custom_date', '<', $end_date );
1203 }
1204 );
1205 }
1206 )
1207 ->to_array();
1208 $default_schedules = new Default_Schedule( $default_schedules );
1209
1210 $service = Service_Model::find( $service_id );
1211 $service_duration = $this->get_service_duration_from_additional_durations( $service->additional_durations, $additional_duration_id );
1212 $service_duration = apply_filters( 'booktics_service_duration_for_appointment_time_slots', $service_duration, $request );
1213 $slot_length = $service->interval_time < 1 ? booktics_get_option( 'interval_time', 30 ) : $service->interval_time;
1214
1215 // Get buffer times from service
1216 $before_buffer_time = $service->before_buffer_time ? intval( $service->before_buffer_time ) : 0;
1217 $after_buffer_time = $service->after_buffer_time ? intval( $service->after_buffer_time ) : 0;
1218 $options = array(
1219 'default_schedule' => $default_schedules,
1220 'service_duration' => $service_duration,
1221 'slot_length' => $slot_length,
1222 'before_buffer_time' => $before_buffer_time,
1223 'after_buffer_time' => $after_buffer_time,
1224 'service_id' => $service_id,
1225 'total_attendees' => $total_attendees,
1226 );
1227 /**
1228 * Allows an extension to produce the slot set instead of core.
1229 *
1230 * Returning null (the default) leaves core's single team member
1231 * generation untouched. Returning an array replaces it entirely, which
1232 * is how Booktics Pro serves slots merged across several team members.
1233 *
1234 * @since 1.0.26
1235 *
1236 * @param array|null $slots Pre-computed slots, or null.
1237 * @param \WP_REST_Request $request The slot request.
1238 * @param array $options Options handed to Time_Slot_Manager.
1239 */
1240 $slots = apply_filters( 'booktics_pre_generate_time_slots', null, $request, $options );
1241
1242 // Anything that is not an array of slots falls back to core rather than
1243 // being emitted verbatim in the response.
1244 if ( ! is_array( $slots ) ) {
1245 // Generate time slots for the given date range by interval time
1246 $slots = ( new Time_Slot_Manager( $service_schedules, $team_member_schedules, $options ) )
1247 ->generate_slots_for_range( $start_date, $end_date );
1248 }
1249
1250
1251 $response = array(
1252 'service_id' => $service_id,
1253 'team_member_id' => $team_member_id,
1254 'additional_duration_id' => $additional_duration_id,
1255 'start_date' => $start_date,
1256 'end_date' => $end_date,
1257 'slots' => $slots,
1258 );
1259
1260 return $this->response( $response, __( 'Successfully fetched time slots', 'booktics' ) );
1261 }
1262
1263 /**
1264 * Get service duration from additional durations using additional_duration_id
1265 * If additional_duration_id is not found, return default duration (30 minutes)
1266 *
1267 * @param array $additional_durations Array of additional durations.
1268 * @param int $additional_duration_id ID of additional duration.
1269 *
1270 * @return int Service duration in minutes.
1271 */
1272 private function get_service_duration_from_additional_durations( $additional_durations, $additional_duration_id ) {
1273 $default_duration = 30;
1274 if ( empty( $additional_durations ) ) {
1275 return $default_duration; // Return default duration if additional durations are empty
1276 }
1277
1278 foreach ( $additional_durations as $additional_duration ) {
1279 if ( $additional_duration['id'] === $additional_duration_id ) {
1280 return $this->get_converted_duration( $additional_duration['duration_time'], $additional_duration['duration_unit'] );
1281 }
1282 }
1283
1284 return $default_duration;
1285 }
1286
1287 /**
1288 * Convert duration to minutes
1289 *
1290 * @param int $duration Duration in minutes or hours.
1291 * @param string $unit Unit of duration (min or hour).
1292 *
1293 * @return int Duration in minutes.
1294 */
1295 private function get_converted_duration($duration, $unit) {
1296 if ( $unit === 'min' ) {
1297 return $duration;
1298 } elseif ( $unit === 'hour' ) {
1299 return $duration * 60;
1300 }
1301 }
1302 }
1303