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

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

1,343 lines 51.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 // Never hand the cancel/reschedule credential to a customer
398 // through the list feed; it is delivered only via e-mail links.
399 $appointment->security_token = '';
400 }
401 unset( $appointment );
402 }
403
404 if ( ! $data ) {
405 return $this->error( __( 'No appointment found', 'booktics' ), 404 );
406 }
407
408 return $this->response( $data );
409 }
410
411 /**
412 * Get one item from the collection.
413 *
414 * @param WP_REST_Request $request Full data about the request.
415 *
416 * @return WP_Error|WP_REST_Response
417 */
418 public function get_item( $request ) {
419 $id = intval( $request['id'] );
420
421 $appointment = Appointment_Model::find( $id );
422
423 if ( ! $appointment ) {
424 return $this->error( __( 'Appointment not found', 'booktics' ), 404 );
425 }
426
427 // Mask team member email for customers (non-admin/team-member)
428 if ( ! booktics_is_admin_or_team_member() && is_user_logged_in() ) {
429 $appointment_array = is_array( $appointment ) ? $appointment : ( is_object( $appointment ) ? get_object_vars( $appointment ) : array() );
430 if ( isset( $appointment_array['team_member_id'] ) ) {
431 $team_member = new Team_Member_Model( $appointment_array['team_member_id'] );
432 $team_member_data = $team_member->find( $appointment_array['team_member_id'] );
433 if ( $team_member_data && isset( $team_member_data->user_email ) ) {
434 // We can't easily unset from the object, but we can return a modified response
435 }
436 }
437 }
438
439 return $this->response( $appointment );
440 }
441
442 /**
443 * Get all calendar items within a date range
444 *
445 * @param WP_REST_Request $request Full data about the request.
446 *
447 * @return WP_Error|WP_REST_Response
448 */
449 public function get_all_calendar_items( $request ) {
450 $per_page = ! empty( $request['per_page'] ) ? intval( $request['per_page'] ) : 500;
451 $paged = ! empty( $request['paged'] ) ? intval( $request['paged'] ) : 1;
452
453 // Check if custom date range is provided
454 if ( ! empty( $request['start_date'] ) && ! empty( $request['end_date'] ) ) {
455 $adjusted_start_date = sanitize_text_field( $request['start_date'] );
456 $adjusted_end_date = sanitize_text_field( $request['end_date'] );
457 $month = gmdate( 'Y-m', strtotime( $adjusted_start_date ) );
458 } else {
459 // Fall back to month-based date range with week adjustment
460 $month = ! empty( $request['month'] ) ? sanitize_text_field( $request['month'] ) : gmdate( 'Y-m' );
461 $start_date = gmdate( 'Y-m-01', strtotime( $month ) );
462 $end_date = gmdate( 'Y-m-t', strtotime( $month ) );
463
464 // Get the first day of the week (Sunday = 0, Monday = 1, etc.)
465 $first_day_of_week = get_option( 'start_of_week', 1 );
466
467 // Adjust start date to include days from previous month to complete the first week
468 $start_date_obj = new \DateTime( $start_date );
469 $start_week_day = (int) $start_date_obj->format( 'w' ); // 0 (Sun) to 6 (Sat)
470 $days_to_subtract = ( $start_week_day - $first_day_of_week + 7 ) % 7;
471 $start_date_obj->modify( "-$days_to_subtract days" );
472 $adjusted_start_date = $start_date_obj->format( 'Y-m-d' );
473
474 // Adjust end date to include days from next month to complete the last week
475 $end_date_obj = new \DateTime( $end_date );
476 $end_week_day = (int) $end_date_obj->format( 'w' ); // 0 (Sun) to 6 (Sat)
477 $days_to_add = ( 6 - $end_week_day + $first_day_of_week ) % 7;
478 $end_date_obj->modify( "+$days_to_add days" );
479 $adjusted_end_date = $end_date_obj->format( 'Y-m-d' );
480 }
481
482 // Get all appointments within the date range using meta_query
483 $args = array(
484 // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Meta query is necessary for filtering appointments by meta fields
485 'meta_query' => array(
486 'relation' => 'AND',
487 array(
488 'key' => 'date',
489 'value' => array(
490 $adjusted_start_date,
491 $adjusted_end_date,
492 ),
493 'compare' => 'BETWEEN',
494 'type' => 'DATE',
495 ),
496 ),
497 'posts_per_page' => - 1, // Get all appointments in the range
498 'orderby' => 'meta_value',
499 'meta_key' => 'date', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Required for ordering by date meta field
500 'order' => 'ASC',
501 );
502
503 $service_id = ! empty( $request['service_id'] ) ? intval( $request['service_id'] ) : null;
504 $customer_id = ! empty( $request['customer_id'] ) ? intval( $request['customer_id'] ) : null;
505 $payment_status = ! empty( $request['payment_status'] ) ? sanitize_text_field( $request['payment_status'] ) : null;
506 $team_member_id = ! empty( $request['team_member_id'] ) ? intval( $request['team_member_id'] ) : null;
507 $status = ! empty( $request['status'] ) ? sanitize_text_field( $request['status'] ) : null;
508
509 if ( $service_id ) {
510 $args['meta_query'][] = array(
511 'key' => 'service_id',
512 'value' => $service_id,
513 'compare' => '=',
514 );
515 }
516
517 if ( $customer_id ) {
518 $args['meta_query'][] = array(
519 'key' => 'customer_id',
520 'value' => $customer_id,
521 'compare' => '=',
522 );
523 }
524
525 if ( $payment_status ) {
526 $args['meta_query'][] = array(
527 'key' => 'payment_status',
528 'value' => $payment_status,
529 'compare' => '=',
530 );
531 }
532
533 if ( $team_member_id ) {
534 $args['meta_query'][] = array(
535 'key' => 'team_member_id',
536 'value' => $team_member_id,
537 'compare' => '=',
538 );
539 }
540
541 if ( $status ) {
542 $args['meta_query'][] = array(
543 'key' => 'status',
544 'value' => $status,
545 'compare' => '=',
546 );
547 }
548
549 $args = apply_filters( 'booktics_appointment_args', $args );
550
551 // Scope to the caller's own appointments when they are not an
552 // admin/team-member. The calendar route is open to any logged-in user,
553 // so without this a plain customer would read every booking on the
554 // site. Mirrors the scoping already enforced in get_items().
555 $scope_to_customer = ! booktics_is_admin_or_team_member() && is_user_logged_in();
556 if ( $scope_to_customer ) {
557 $db = new Booktics_Database();
558 $guest = ( new Guest_Model( $db ) )->find( array( 'wp_user_id' => get_current_user_id() ) );
559 if ( ! $guest ) {
560 return $this->response(
561 array(
562 'month' => gmdate( 'Y-m', strtotime( $month ) ),
563 'date_range' => array(
564 'start' => $adjusted_start_date,
565 'end' => $adjusted_end_date,
566 ),
567 'appointments' => array(),
568 ), __( 'Calendar appointments retrieved successfully', 'booktics' )
569 );
570 }
571 $args['meta_query'][] = array(
572 'key' => 'customer_id',
573 'value' => $guest->id,
574 'compare' => '=',
575 );
576 }
577
578 $appointments = Appointment_Model::paginate( $per_page, $paged, $args );
579
580 // Format the response
581 $formatted_appointments = array();
582
583 if ( ! empty( $appointments ) ) {
584 foreach ( $appointments['items'] as $appointment ) {
585 if ( ! empty( $appointment ) ) {
586 $appointment->source = 'booktics';
587
588 // The per-appointment security token is the credential that
589 // authorises cancel/reschedule. Customers receive it only
590 // through their e-mailed links, never through this feed, so
591 // strip it from any non-admin response.
592 if ( $scope_to_customer ) {
593 $appointment->security_token = '';
594 }
595 }
596
597 $formatted_appointments[] = $appointment;
598 }
599 }
600
601 // Allow other developers to add/modify appointments
602 $formatted_appointments = apply_filters(
603 'booktics_appointments_for_calendar_view',
604 $formatted_appointments,
605 array(
606 'start_date' => $adjusted_start_date,
607 'end_date' => $adjusted_end_date,
608 )
609 );
610
611 $response = array(
612 'month' => gmdate( 'Y-m', strtotime( $month ) ),
613 'date_range' => array(
614 'start' => $adjusted_start_date,
615 'end' => $adjusted_end_date,
616 ),
617 'appointments' => $formatted_appointments,
618 );
619
620 return $this->response( $response, __( 'Calendar appointments retrieved successfully', 'booktics' ) );
621 }
622
623 /**
624 * Check if a given request has access to get a specific item.
625 *
626 * @param WP_REST_Request $request Full data about the request.
627 *
628 * @return WP_Error|boolean
629 */
630 public function get_item_permissions_check( $request ) {
631 if ( booktics_is_admin_or_team_member() ) {
632 return true;
633 }
634
635 if ( is_user_logged_in() ) {
636 $appointment_id = intval( $request['id'] );
637 $appointment = Appointment_Model::find( $appointment_id );
638
639 if ( $appointment ) {
640 $db = new Booktics_Database();
641 $customer = ( new Guest_Model( $db ) )->find( array( 'id' => $appointment->customer_id ) );
642
643 if ( $customer && intval( $customer->wp_user_id ) === get_current_user_id() ) {
644 return true;
645 }
646 }
647 }
648
649 return new WP_Error(
650 'booktics_forbidden',
651 __( 'You are not allowed to view this appointment.', 'booktics' ),
652 array( 'status' => 403 )
653 );
654 }
655
656 /**
657 * Check if a given request has access to get items.
658 *
659 * @param WP_REST_Request $request Full data about the request.
660 *
661 * @return WP_Error|boolean
662 */
663 public function get_items_permissions_check( $request ) {
664 if ( booktics_is_admin_or_team_member() ) {
665 return true;
666 }
667
668 if ( is_user_logged_in() ) {
669 return true;
670 }
671
672 return new WP_Error(
673 'booktics_forbidden',
674 __( 'You are not allowed to view appointments.', 'booktics' ),
675 array( 'status' => 403 )
676 );
677 }
678
679 /**
680 * Create one item from the collection.
681 *
682 * @param WP_REST_Request $request Full data about the request.
683 *
684 * @return WP_Error|WP_REST_Response
685 * @throws Exception
686 */
687 public function create_item( $request ) {
688 $data = json_decode( $request->get_body(), true );
689
690 /**
691 * Filters appointment data before it is validated and created.
692 *
693 * Runs before validation so an extension can supply required fields.
694 * Booktics Pro round robin fills team_member_id here when a service
695 * assigns its team members automatically.
696 *
697 * $data is the raw decoded request body: it has not been sanitised and
698 * every key reaches Appointment_Service::create(). A filter is
699 * responsible for sanitising whatever it adds.
700 *
701 * @since 1.0.26
702 *
703 * @param array $data Decoded request body.
704 * @param \WP_REST_Request $request The create request.
705 */
706 $data = apply_filters( 'booktics_appointment_create_data', $data, $request );
707
708 // A malformed body decodes to null, and a filter can return anything at
709 // all. validate_create() is typed array, so either would be a fatal.
710 if ( ! is_array( $data ) ) {
711 return $this->error( __( 'Invalid appointment data.', 'booktics' ) );
712 }
713
714 $error = Appointment_Validator::validate_create( $data );
715 if ( $error ) {
716 return $this->error( $error->get_error_message() );
717 }
718
719 $conflict = Appointment_Validator::validate_slot_available( $data );
720 if ( $conflict ) {
721 return $this->error( $conflict->get_error_message() );
722 }
723 $appointment = Appointment_Service::instance()->create( $data );
724 if ( is_wp_error( $appointment ) ) {
725 return $this->error( $appointment->get_error_message() );
726 }
727 do_action( 'booktics_after_appointment_created', $appointment );
728
729 return $this->response( $appointment, __( 'Successfully created appointment', 'booktics' ) );
730 }
731
732 /**
733 * Check if a given request has access to create items.
734 *
735 * @param WP_REST_Request $request Full data about the request.
736 *
737 * @return WP_Error|boolean
738 */
739 public function create_item_permissions_check( $request ) {
740 return current_user_can( 'manage_options' );
741 }
742
743 /**
744 * Update one item from the collection.
745 *
746 * @param WP_REST_Request $request Full data about the request.
747 *
748 * @return WP_REST_Response
749 */
750 public function update_item( $request ) {
751 $id = intval( $request['id'] );
752 $data = json_decode( $request->get_body(), true );
753 $error = Appointment_Validator::validate_update( $data );
754 if ( $error ) {
755 return $this->error( $error->get_error_message() );
756 }
757 $updated_appointment = Appointment_Service::instance()->update( $id, $data );
758 if ( is_wp_error( $updated_appointment ) ) {
759 return $this->error( $updated_appointment->get_error_message() );
760 }
761 do_action( 'booktics_after_appointment_updated', $updated_appointment );
762
763 return $this->response( $updated_appointment, __( 'Appointment updated successfully', 'booktics' ) );
764 }
765
766 /**
767 * Check if a given request has access to update a specific item.
768 *
769 * @param WP_REST_Request $request Full data about the request.
770 *
771 * @return WP_Error|boolean
772 */
773 public function update_item_permissions_check( $request ) {
774 return current_user_can( 'manage_options' );
775 }
776
777 /**
778 * Delete one item from the collection.
779 *
780 * @param WP_REST_Request $request Full data about the request.
781 *
782 * @return \WP_Error|\WP_HTTP_Response
783 */
784 public function delete_item( $request ) {
785 $id = intval( $request['id'] );
786 $result = Appointment_Service::instance()->delete( $id );
787 if ( is_wp_error( $result ) ) {
788 return $this->error( $result->get_error_message() );
789 }
790 do_action( 'booktics_after_appointment_deleted', $result );
791
792 return $this->response( $result, __( 'Successfully deleted appointment', 'booktics' ) );
793 }
794
795 /**
796 * Check if a given request has access to delete a specific item.
797 *
798 * @param WP_REST_Request $request Full data about the request.
799 *
800 * @return WP_Error|boolean
801 */
802 public function delete_item_permissions_check( $request ) {
803 return current_user_can( 'manage_options' );
804 }
805
806 /**
807 * Delete a collection of items
808 *
809 * @param WP_Rest_Request $request User request
810 */
811 public function delete_items( $request ) {
812 $ids = $request['ids'];
813 $result = Appointment_Service::instance()->delete_many( $ids );
814 if ( is_wp_error( $result ) ) {
815 return $this->error( $result->get_error_message() );
816 }
817
818 return $this->response( $result['data'], $result['message'] );
819 }
820
821 /**
822 * Check if a given request has access to delete a collection of items.
823 *
824 * @param WP_REST_Request $request Full data about the request.
825 *
826 * @return WP_Error|boolean
827 */
828 public function delete_items_permissions_check( $request ) {
829 return current_user_can( 'manage_options' );
830 }
831
832 /**
833 * Reschedule an appointment
834 *
835 * @param $request Full data about the request.
836 *
837 * @throws Exception
838 */
839 public function reschedule_item( $request ) {
840 $id = intval( $request['id'] );
841 $data = json_decode( $request->get_body(), true );
842
843 /**
844 * Filters reschedule data before validation.
845 *
846 * Returning a WP_Error refuses the reschedule with that message, which
847 * is how Booktics Pro keeps a booking with its original team member.
848 *
849 * @since 1.0.26
850 *
851 * @param array|\WP_Error $data Reschedule data.
852 * @param int $id Appointment being moved.
853 */
854 $data = apply_filters( 'booktics_appointment_reschedule_data', $data, $id );
855
856 if ( is_wp_error( $data ) ) {
857 return $this->error( $data->get_error_message() );
858 }
859
860 if ( ! is_array( $data ) ) {
861 return $this->error( __( 'Invalid reschedule data.', 'booktics' ) );
862 }
863
864 $error = Appointment_Validator::validate_reschedule( $data, $id );
865 if ( $error ) {
866 return $this->error( $error->get_error_message() );
867 }
868
869 // Same guard as create_item(): a reschedule is a booking too, and the
870 // owning customer can reach this route. The reschedule payload only
871 // carries the new date and time, so the stored appointment supplies
872 // service, duration and attendees, without which the check would fall
873 // back to a 30 minute window and a capacity of one and could refuse a
874 // group booking that still has room. Its own id is passed so the
875 // booking is never counted as a conflict with itself.
876 $moving = Appointment_Model::find( $id );
877
878 if ( $moving ) {
879 $conflict = Appointment_Validator::validate_slot_available(
880 array(
881 'id' => $id,
882 'date' => $data['date'] ?? $moving->date,
883 'start_time' => $data['start_time'] ?? $moving->start_time,
884 'team_member_id' => $data['team_member_id'] ?? $moving->team_member_id,
885 'service_id' => $moving->service_id,
886 'duration' => $moving->duration,
887 'total_attendees' => $moving->total_attendees,
888 )
889 );
890
891 if ( $conflict ) {
892 return $this->error( $conflict->get_error_message() );
893 }
894 }
895 $result = Appointment_Service::instance()->reschedule( $id, $data );
896 if ( is_wp_error( $result ) ) {
897 return $this->error( $result->get_error_message() );
898 }
899
900 $this->send_reschedule_email( $result );
901
902 do_action( 'booktics_after_appointment_rescheduled', $result );
903
904 return $this->response( $result, __( 'Successfully rescheduled appointment', 'booktics' ) );
905 }
906
907 /**
908 * Check if a given request has access to reschedule a specific item.
909 *
910 * @param $request Full data about the request.
911 */
912 public function reschedule_item_permissions_check( $request ) {
913 return $this->check_appointment_owner(
914 $request,
915 __( 'You are not allowed to reschedule this appointment.', 'booktics' )
916 );
917 }
918
919 /**
920 * Send reschedule email to customer
921 *
922 * @param Appointment_Model $appointment Appointment model.
923 */
924 private function send_reschedule_email( $appointment ) {
925 $db = new Booktics_Database();
926 $customer = ( new Guest_Model( $db ) )->find( [ 'id' => $appointment->customer_id ] );
927 $team_member = new Team_Member_Model( $appointment->team_member_id );
928
929 $appointment_reschedule_email_data = [
930 'date' => $appointment->date ?? '',
931 'start_time' => $appointment->start_time ?? '',
932 'end_time' => $appointment->end_time ?? '',
933 'customer_name' => $customer->name ?? '',
934 'user_email' => $customer->email ?? '',
935 'appointment_service' => $appointment->title ?? '',
936 'appointment_team_member' => $team_member->display_name ?? '',
937 'appointment_team_member_email' => $team_member->user_email ?? '',
938 ];
939
940 $appointment_reschedule_email_data = apply_filters( 'booktics_appointment_reschedule_email_data', $appointment_reschedule_email_data );
941
942 do_action( 'booktics_gln_hook', 'appointment_rescheduled', $appointment_reschedule_email_data );
943 }
944
945 /**
946 * Cancel an appointment
947 *
948 * @param WP_REST_Request $request Full data about the request.
949 *
950 * @throws Exception
951 */
952 public function cancel_item( $request ) {
953 $id = intval( $request['id'] );
954 $data = json_decode( $request->get_body(), true );
955 $cancel_note = ! empty( $data['cancel_note'] ) ? sanitize_text_field( $data['cancel_note'] ) : '';
956
957 $error = Appointment_Validator::instance()->validate_cancel( $id );
958
959 if ( $error ) {
960 return $this->error( $error->get_error_message() );
961 }
962
963 $result = Appointment_Service::instance()->cancel( $id, [ 'cancel_note' => $cancel_note ] );
964
965 if ( is_wp_error( $result ) ) {
966 return $this->error( $result->get_error_message() );
967 }
968
969 $this->send_cancel_email( $result );
970
971 // Note: booktics_after_appointment_cancelled is fired inside
972 // Appointment_Service::cancel(), the single choke point every cancel
973 // path routes through, so it is intentionally not re-fired here.
974
975 return $this->response( $result, __( 'Successfully cancelled appointment', 'booktics' ) );
976 }
977
978 /**
979 * Send cancel email to customer
980 *
981 * @param Appointment_Model $appointment Appointment model.
982 */
983 private function send_cancel_email( $appointment ) {
984 $db = new Booktics_Database();
985 $customer = ( new Guest_Model( $db ) )->find( [ 'id' => $appointment->customer_id ] );
986 $team_member = new Team_Member_Model( $appointment->team_member_id );
987
988 $appointment_cancel_email_data = [
989 'date' => $appointment->date ?? '',
990 'start_time' => $appointment->start_time ?? '',
991 'end_time' => $appointment->end_time ?? '',
992 'customer_name' => $customer->name ?? '',
993 'user_email' => $customer->email ?? '',
994 'appointment_service' => $appointment->title ?? '',
995 'appointment_team_member' => $team_member->display_name ?? '',
996 'appointment_team_member_email' => $team_member->user_email ?? '',
997 ];
998
999 $appointment_cancel_email_data = apply_filters( 'booktics_appointment_cancel_email_data', $appointment_cancel_email_data );
1000
1001 do_action( 'booktics_gln_hook', 'appointment_cancelled', $appointment_cancel_email_data );
1002 }
1003
1004 /**
1005 * Check if a given request has access to cancel a specific item.
1006 *
1007 * @param WP_REST_Request $request Full data about the request.
1008 *
1009 * @return WP_Error|boolean
1010 */
1011 public function cancel_item_permissions_check( $request ) {
1012 return $this->check_appointment_owner(
1013 $request,
1014 __( 'You are not allowed to cancel this appointment.', 'booktics' )
1015 );
1016 }
1017
1018 /**
1019 * Authorize a customer-facing action on a single appointment.
1020 *
1021 * Reschedule and cancel are reached from the booking confirmation screen
1022 * and from the links in the confirmation email, so the caller is normally
1023 * a guest with no WP session at all. Even a logged-in customer usually
1024 * fails an account match, because the guest row only stores wp_user_id
1025 * when the "use WordPress users as customers" setting is on, and it is off
1026 * by default. The per-appointment security token that ships in those links
1027 * is therefore the credential that actually identifies the customer, and
1028 * it is checked here so the gate cannot be passed without it.
1029 *
1030 * @param WP_REST_Request $request Full data about the request.
1031 * @param string $message Message returned when access is refused.
1032 *
1033 * @return bool|WP_Error True when the caller may act on the appointment.
1034 */
1035 private function check_appointment_owner( $request, $message ) {
1036 if ( booktics_is_admin_or_team_member() ) {
1037 return true;
1038 }
1039
1040 $appointment = Appointment_Model::find( intval( $request['id'] ) );
1041
1042 if ( $appointment ) {
1043 $token = $request->get_param( 'security_token' );
1044 $token = is_string( $token ) ? $token : '';
1045 $known_token = (string) $appointment->security_token;
1046
1047 // An appointment saved before tokens existed has an empty token,
1048 // and hash_equals( '', '' ) is true, so an empty submitted token
1049 // would unlock every one of those rows. Require a real value.
1050 if ( '' !== $token && '' !== $known_token && hash_equals( $known_token, $token ) ) {
1051 return true;
1052 }
1053
1054 if ( is_user_logged_in() ) {
1055 $db = new Booktics_Database();
1056 $customer = ( new Guest_Model( $db ) )->find( array( 'id' => $appointment->customer_id ) );
1057
1058 if ( $customer && ! empty( $customer->wp_user_id ) && intval( $customer->wp_user_id ) === get_current_user_id() ) {
1059 return true;
1060 }
1061 }
1062 }
1063
1064 return new WP_Error(
1065 'booktics_forbidden',
1066 $message,
1067 array( 'status' => 403 )
1068 );
1069 }
1070
1071 /**
1072 * Prepare the item for create or update operation.
1073 *
1074 * @param WP_REST_Request $request Request object.
1075 *
1076 * @return bool|\WP_Error $prepared_item
1077 */
1078 public function prepare_item_for_database( $request ) {
1079 $data = json_decode( $request->get_body(), true );
1080 $validate = booktics_validate(
1081 $data,
1082 array(
1083 'start_time' => array(
1084 'required',
1085 ),
1086 'end_time' => array(
1087 'required',
1088 ),
1089 'date' => array( 'required' ),
1090 'service_id' => array(
1091 'required',
1092 ),
1093 'team_member_id' => array(
1094 'required',
1095 ),
1096 )
1097 );
1098
1099 if ( is_wp_error( $validate ) ) {
1100 return $validate;
1101 }
1102
1103 $data = $this->get_sanitized_data( $data );
1104
1105 return $data;
1106 }
1107
1108 private function get_sanitized_data( $data ) {
1109 $data['start_time'] = ! empty( $data['start_time'] ) ? sanitize_text_field( $data['start_time'] ) : '';
1110 $data['end_time'] = ! empty( $data['end_time'] ) ? sanitize_text_field( $data['end_time'] ) : '';
1111 $data['date'] = ! empty( $data['date'] ) ? sanitize_text_field( $data['date'] ) : '';
1112 $data['service_id'] = ! empty( $data['service_id'] ) ? sanitize_text_field( $data['service_id'] ) : '';
1113 $data['team_member_id'] = ! empty( $data['team_member_id'] ) ? sanitize_text_field( $data['team_member_id'] ) : '';
1114 $data['customer_id'] = ! empty( $data['customer_id'] ) ? sanitize_text_field( $data['customer_id'] ) : '';
1115 $data['total_attendees'] = ! empty( $data['total_attendees'] ) ? sanitize_text_field( $data['total_attendees'] ) : '';
1116 $data['status'] = isset( $data['status'] ) ? $data['status'] : 'pending';
1117
1118 return $data;
1119 }
1120
1121 /**
1122 * Longest span of days the time slot route will answer for.
1123 */
1124 const MAX_SLOT_RANGE_DAYS = 62;
1125
1126 /**
1127 * Accept a Y-m-d date, or nothing.
1128 *
1129 * @param mixed $value Raw request value.
1130 *
1131 * @return string Y-m-d, or '' when it is not a date.
1132 */
1133 private static function sanitize_slot_date( $value ) {
1134 $value = sanitize_text_field( (string) $value );
1135
1136 if ( '' === $value ) {
1137 return '';
1138 }
1139
1140 $date = \DateTime::createFromFormat( 'Y-m-d', $value );
1141
1142 return ( $date && $date->format( 'Y-m-d' ) === $value ) ? $value : '';
1143 }
1144
1145 /**
1146 * Check if a given request has access to get time slots.
1147 *
1148 * @param $request
1149 *
1150 * @return boolean
1151 */
1152 public function get_time_slots_permissions_check( $request ) {
1153 return true;
1154 }
1155
1156 /**
1157 * Fetch appointment time slots
1158 *
1159 * @param WP_REST_Request $request Request object.
1160 *
1161 * @return array|\WP_HTTP_Response
1162 * @throws Exception If there's an error getting time slots.
1163 */
1164 public function get_time_slots( $request ) {
1165 $service_id = ! empty( $request['service_id'] ) ? absint( $request['service_id'] ) : 0;
1166 $additional_duration_id = ! empty( $request['additional_duration_id'] ) ? absint( $request['additional_duration_id'] ) : 0;
1167 $team_member_id = ! empty( $request['team_member_id'] ) ? absint( $request['team_member_id'] ) : 0;
1168 $start_date = self::sanitize_slot_date( $request['start_date'] ?? '' );
1169 $end_date = self::sanitize_slot_date( $request['end_date'] ?? '' );
1170
1171 // This route is public and takes no arguments schema, so both dates
1172 // arrive as free text. generate_slots_for_range() builds a DateTime
1173 // from them, and DateTime throws on anything it cannot read, which on
1174 // an unauthenticated route is a 500 with a stack trace.
1175 if ( ! empty( $request['start_date'] ) && '' === $start_date ) {
1176 return $this->error( __( 'Invalid start date.', 'booktics' ) );
1177 }
1178
1179 if ( ! empty( $request['end_date'] ) && '' === $end_date ) {
1180 return $this->error( __( 'Invalid end date.', 'booktics' ) );
1181 }
1182
1183 if ( $start_date && $end_date ) {
1184 if ( $end_date < $start_date ) {
1185 return $this->error( __( 'End date cannot be before start date.', 'booktics' ) );
1186 }
1187
1188 // The engine walks the range a day at a time, so an open-ended
1189 // span is a cheap request with a very expensive answer. A booking
1190 // calendar shows a month.
1191 $limit = strtotime( $start_date ) + ( self::MAX_SLOT_RANGE_DAYS * DAY_IN_SECONDS );
1192
1193 if ( strtotime( $end_date ) > $limit ) {
1194 $end_date = gmdate( 'Y-m-d', $limit );
1195 }
1196 }
1197 $total_attendees = isset( $request['total_attendees'] ) ? absint( $request['total_attendees'] ) : 1;
1198
1199 $db = new Booktics_Database();
1200
1201 $service_schedules = $db->table( booktics_get_table_name( 'booktics_schedules' ) )
1202 ->where( 'service_id', '=', $service_id )
1203 ->where_group(
1204 function ( $q ) use ( $start_date, $end_date ) {
1205 $q->where( 'custom_date', '=', '0' )
1206 ->or_where_group(
1207 function ( $q2 ) use ( $start_date, $end_date ) {
1208 $q2->where( 'custom_date', '>', $start_date )
1209 ->where( 'custom_date', '<', $end_date );
1210 }
1211 );
1212 }
1213 )
1214 ->to_array();
1215 $service_schedules = new Service_Schedule( $service_schedules, $service_id );
1216
1217 $team_member_schedules = $db->table( booktics_get_table_name( 'booktics_schedules' ) )
1218 ->where( 'team_member_id', '=', $team_member_id )
1219 ->where_group(
1220 function ( $q ) use ( $start_date, $end_date ) {
1221 $q->where( 'custom_date', '=', '0' )
1222 ->or_where_group(
1223 function ( $q2 ) use ( $start_date, $end_date ) {
1224 $q2->where( 'custom_date', '>', $start_date )
1225 ->where( 'custom_date', '<', $end_date );
1226 }
1227 );
1228 }
1229 )
1230 ->to_array();
1231 $team_member_schedules = new Team_Member_Schedule( $team_member_schedules, $team_member_id );
1232
1233 $default_schedules = $db->table( booktics_get_table_name( 'booktics_schedules' ) )
1234 ->where( 'service_id', '=', 0 )
1235 ->where( 'team_member_id', '=', 0 )
1236 ->where_group(
1237 function ( $q ) use ( $start_date, $end_date ) {
1238 $q->where( 'custom_date', '=', '0' )
1239 ->or_where_group(
1240 function ( $q2 ) use ( $start_date, $end_date ) {
1241 $q2->where( 'custom_date', '>', $start_date )
1242 ->where( 'custom_date', '<', $end_date );
1243 }
1244 );
1245 }
1246 )
1247 ->to_array();
1248 $default_schedules = new Default_Schedule( $default_schedules );
1249
1250 $service = Service_Model::find( $service_id );
1251 $service_duration = $this->get_service_duration_from_additional_durations( $service->additional_durations, $additional_duration_id );
1252 $service_duration = apply_filters( 'booktics_service_duration_for_appointment_time_slots', $service_duration, $request );
1253 $slot_length = $service->interval_time < 1 ? booktics_get_option( 'interval_time', 30 ) : $service->interval_time;
1254
1255 // Get buffer times from service
1256 $before_buffer_time = $service->before_buffer_time ? intval( $service->before_buffer_time ) : 0;
1257 $after_buffer_time = $service->after_buffer_time ? intval( $service->after_buffer_time ) : 0;
1258 $options = array(
1259 'default_schedule' => $default_schedules,
1260 'service_duration' => $service_duration,
1261 'slot_length' => $slot_length,
1262 'before_buffer_time' => $before_buffer_time,
1263 'after_buffer_time' => $after_buffer_time,
1264 'service_id' => $service_id,
1265 'total_attendees' => $total_attendees,
1266 );
1267 /**
1268 * Allows an extension to produce the slot set instead of core.
1269 *
1270 * Returning null (the default) leaves core's single team member
1271 * generation untouched. Returning an array replaces it entirely, which
1272 * is how Booktics Pro serves slots merged across several team members.
1273 *
1274 * @since 1.0.26
1275 *
1276 * @param array|null $slots Pre-computed slots, or null.
1277 * @param \WP_REST_Request $request The slot request.
1278 * @param array $options Options handed to Time_Slot_Manager.
1279 */
1280 $slots = apply_filters( 'booktics_pre_generate_time_slots', null, $request, $options );
1281
1282 // Anything that is not an array of slots falls back to core rather than
1283 // being emitted verbatim in the response.
1284 if ( ! is_array( $slots ) ) {
1285 // Generate time slots for the given date range by interval time
1286 $slots = ( new Time_Slot_Manager( $service_schedules, $team_member_schedules, $options ) )
1287 ->generate_slots_for_range( $start_date, $end_date );
1288 }
1289
1290
1291 $response = array(
1292 'service_id' => $service_id,
1293 'team_member_id' => $team_member_id,
1294 'additional_duration_id' => $additional_duration_id,
1295 'start_date' => $start_date,
1296 'end_date' => $end_date,
1297 'slots' => $slots,
1298 );
1299
1300 return $this->response( $response, __( 'Successfully fetched time slots', 'booktics' ) );
1301 }
1302
1303 /**
1304 * Get service duration from additional durations using additional_duration_id
1305 * If additional_duration_id is not found, return default duration (30 minutes)
1306 *
1307 * @param array $additional_durations Array of additional durations.
1308 * @param int $additional_duration_id ID of additional duration.
1309 *
1310 * @return int Service duration in minutes.
1311 */
1312 private function get_service_duration_from_additional_durations( $additional_durations, $additional_duration_id ) {
1313 $default_duration = 30;
1314 if ( empty( $additional_durations ) ) {
1315 return $default_duration; // Return default duration if additional durations are empty
1316 }
1317
1318 foreach ( $additional_durations as $additional_duration ) {
1319 if ( $additional_duration['id'] === $additional_duration_id ) {
1320 return $this->get_converted_duration( $additional_duration['duration_time'], $additional_duration['duration_unit'] );
1321 }
1322 }
1323
1324 return $default_duration;
1325 }
1326
1327 /**
1328 * Convert duration to minutes
1329 *
1330 * @param int $duration Duration in minutes or hours.
1331 * @param string $unit Unit of duration (min or hour).
1332 *
1333 * @return int Duration in minutes.
1334 */
1335 private function get_converted_duration($duration, $unit) {
1336 if ( $unit === 'min' ) {
1337 return $duration;
1338 } elseif ( $unit === 'hour' ) {
1339 return $duration * 60;
1340 }
1341 }
1342 }
1343