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
← All changes | core/appointment/controllers/appointment-controller.php +1342 -1011 1.0.1 → 1.0.28 View file →
@@ -1,1011 +1,1342 @@
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\Models\Appointment_Model;
12 -use Booktics\Models\Customer_Model;
13 -use Booktics\Models\Service_Model;
14 -use Booktics\Models\Team_Member_Model;
15 -use Exception;
16 -use WP_Error;
17 -use WP_HTTP_Response;
18 -use WP_REST_Request;
19 -use WP_REST_Response;
20 -use WP_REST_Server;
21 -
22 -/**
23 - * Appointment controller
24 - *
25 - * @package Booktics/Appointment
26 - */
27 -class Appointment_Controller extends Base_Rest_Controller {
28 - /**
29 - * Endpoint namespace
30 - *
31 - * @var string
32 - */
33 - protected $namespace = 'booktics/v1';
34 -
35 - /**
36 - * Route name
37 - *
38 - * @var string
39 - */
40 - protected $base = 'appointments';
41 -
42 - /**
43 - * Register routes
44 - *
45 - * @return void
46 - */
47 - public function register_routes(): void {
48 - register_rest_route(
49 - $this->namespace,
50 - $this->base,
51 - array(
52 - array(
53 - 'methods' => WP_REST_Server::CREATABLE,
54 - 'callback' => array( $this, 'create_item' ),
55 - 'permission_callback' => array(
56 - $this,
57 - 'create_item_permissions_check',
58 - ),
59 - ),
60 - )
61 - );
62 -
63 - register_rest_route(
64 - $this->namespace,
65 - $this->base,
66 - array(
67 - array(
68 - 'methods' => WP_REST_Server::READABLE,
69 - 'callback' => array( $this, 'get_items' ),
70 - 'permission_callback' => array(
71 - $this,
72 - 'get_items_permissions_check',
73 - ),
74 - ),
75 - )
76 - );
77 -
78 - register_rest_route(
79 - $this->namespace,
80 - $this->base,
81 - array(
82 - array(
83 - 'methods' => WP_REST_Server::DELETABLE,
84 - 'callback' => array( $this, 'delete_items' ),
85 - 'permission_callback' => array(
86 - $this,
87 - 'delete_items_permissions_check',
88 - ),
89 - ),
90 - )
91 - );
92 -
93 - register_rest_route(
94 - $this->namespace,
95 - $this->base . '/(?P<id>[\d]+)',
96 - array(
97 - array(
98 - 'methods' => WP_REST_Server::READABLE,
99 - 'callback' => array( $this, 'get_item' ),
100 - 'permission_callback' => array(
101 - $this,
102 - 'get_item_permissions_check',
103 - ),
104 - ),
105 - )
106 - );
107 -
108 - register_rest_route(
109 - $this->namespace,
110 - $this->base . '/(?P<id>[\d]+)',
111 - array(
112 - array(
113 - 'methods' => WP_REST_Server::EDITABLE,
114 - 'callback' => array( $this, 'update_item' ),
115 - 'permission_callback' => array(
116 - $this,
117 - 'update_item_permissions_check',
118 - ),
119 - ),
120 - )
121 - );
122 -
123 - register_rest_route(
124 - $this->namespace,
125 - $this->base . '/(?P<id>[\d]+)',
126 - array(
127 - array(
128 - 'methods' => WP_REST_Server::DELETABLE,
129 - 'callback' => array( $this, 'delete_item' ),
130 - 'permission_callback' => array(
131 - $this,
132 - 'delete_item_permissions_check',
133 - ),
134 - ),
135 - )
136 - );
137 -
138 - register_rest_route(
139 - $this->namespace,
140 - $this->base . '/(?P<id>[\d]+)/reschedule',
141 - array(
142 - array(
143 - 'methods' => WP_REST_Server::EDITABLE,
144 - 'callback' => array( $this, 'reschedule_item' ),
145 - 'permission_callback' => array(
146 - $this,
147 - 'reschedule_item_permissions_check',
148 - ),
149 - ),
150 - )
151 - );
152 -
153 - register_rest_route(
154 - $this->namespace,
155 - $this->base . '/(?P<id>[\d]+)/cancel',
156 - array(
157 - array(
158 - 'methods' => WP_REST_Server::EDITABLE,
159 - 'callback' => array( $this, 'cancel_item' ),
160 - 'permission_callback' => array(
161 - $this,
162 - 'cancel_item_permissions_check',
163 - ),
164 - ),
165 - )
166 - );
167 -
168 - register_rest_route(
169 - $this->namespace,
170 - $this->base . '/time-slots',
171 - array(
172 - array(
173 - 'methods' => WP_REST_Server::READABLE,
174 - 'callback' => array( $this, 'get_time_slots' ),
175 - 'permission_callback' => array(
176 - $this,
177 - 'get_time_slots_permissions_check',
178 - ),
179 - ),
180 - )
181 - );
182 -
183 - register_rest_route(
184 - $this->namespace,
185 - $this->base . '/calendar',
186 - array(
187 - array(
188 - 'methods' => WP_REST_Server::READABLE,
189 - 'callback' => array(
190 - $this,
191 - 'get_all_calendar_items'
192 - ),
193 - 'permission_callback' => array(
194 - $this,
195 - 'get_items_permissions_check'
196 - ),
197 - 'args' => array(
198 - 'month' => array(
199 - 'description' => __( 'Month in YYYY-MM format', 'booktics' ),
200 - 'type' => 'string',
201 - 'validate_callback' => function ( $param ) {
202 - return preg_match( '/^\d{4}-\d{2}$/', $param );
203 - },
204 - ),
205 - ),
206 - ),
207 - )
208 - );
209 - }
210 -
211 - /**
212 - * Get a collection of items.
213 - *
214 - * @param WP_REST_Request $request Full data about the request.
215 - *
216 - * @return WP_HTTP_Response
217 - */
218 - public function get_items( $request ) {
219 - $per_page = ! empty( $request['per_page'] ) ? intval( $request['per_page'] ) : 10;
220 - $paged = ! empty( $request['paged'] ) ? intval( $request['paged'] ) : 1;
221 - $date_from = ! empty( $request['date_from'] ) ? sanitize_text_field( $request['date_from'] ) : null;
222 -
223 - $service_id = ! empty( $request['service_id'] ) ? intval( $request['service_id'] ) : null;
224 - $customer_id = ! empty( $request['customer_id'] ) ? intval( $request['customer_id'] ) : null;
225 - $payment_status = ! empty( $request['payment_status'] ) ? sanitize_text_field( $request['payment_status'] ) : null;
226 - $team_member_id = ! empty( $request['team_member_id'] ) ? intval( $request['team_member_id'] ) : null;
227 - $status = ! empty( $request['status'] ) ? sanitize_text_field( $request['status'] ) : null;
228 -
229 - $search = ! empty( $request['search'] ) ? sanitize_text_field( $request['search'] ) : null;
230 - $args = array( 'meta_query' => array() );
231 -
232 - if ( $search ) {
233 - $service_ids = [];
234 - $customer_ids = [];
235 - $team_member_ids = [];
236 - // Search Services
237 - $services = Service_Model::all( [
238 - 's' => $search,
239 - ] );
240 - $service_ids = array_map( function ( $service ) {
241 - return $service->id;
242 - }, $services );
243 -
244 - // Search Customers
245 - $customer_query = new \WP_User_Query( [
246 - 'search' => '*' . esc_attr( $search ) . '*',
247 - 'search_columns' => [ 'display_name', 'user_email' ],
248 - 'role' => 'booktics_customer',
249 - 'fields' => 'ID'
250 - ] );
251 - $customer_ids = $customer_query->get_results();
252 -
253 - // Search Team Members
254 - $team_member_query = new \WP_User_Query( [
255 - 'search' => '*' . esc_attr( $search ) . '*',
256 - 'search_columns' => [ 'display_name', 'user_email' ],
257 - 'role' => 'booktics_team_member',
258 - 'fields' => 'ID'
259 - ] );
260 - $team_member_ids = $team_member_query->get_results();
261 -
262 - if ( $service_ids || $customer_ids || $team_member_ids ) {
263 - $args['meta_query'] = [ 'relation' => 'OR' ];
264 - if ( $service_ids ) {
265 - $args['meta_query'][] = [
266 - 'key' => 'service_id',
267 - 'value' => $service_ids,
268 - 'compare' => 'IN'
269 - ];
270 - }
271 - if ( $customer_ids ) {
272 - $args['meta_query'][] = [
273 - 'key' => 'customer_id',
274 - 'value' => $customer_ids,
275 - 'compare' => 'IN'
276 - ];
277 - }
278 - if ( $team_member_ids ) {
279 - $args['meta_query'][] = [
280 - 'key' => 'team_member_id',
281 - 'value' => $team_member_ids,
282 - 'compare' => 'IN'
283 - ];
284 - }
285 - } else {
286 - return $this->response( [
287 - 'items' => [],
288 - 'total' => 0,
289 - 'per_page' => $per_page,
290 - 'current_page' => $paged,
291 - 'last_page' => 0,
292 - ], __( 'No appointments found', 'booktics' ) );
293 - }
294 - }
295 -
296 - if ( $date_from ) {
297 - $args['meta_query'][] = array(
298 - 'key' => 'date',
299 - 'value' => $date_from,
300 - 'compare' => '>=',
301 - 'type' => 'DATE',
302 - );
303 - }
304 -
305 - if ( $service_id ) {
306 - $args['meta_query'][] = array(
307 - 'key' => 'service_id',
308 - 'value' => $service_id,
309 - 'compare' => '=',
310 - );
311 - }
312 -
313 - if ( $customer_id ) {
314 - $args['meta_query'][] = array(
315 - 'key' => 'customer_id',
316 - 'value' => $customer_id,
317 - 'compare' => '=',
318 - );
319 - }
320 -
321 - if ( $payment_status ) {
322 - $args['meta_query'][] = array(
323 - 'key' => 'payment_status',
324 - 'value' => $payment_status,
325 - 'compare' => '=',
326 - );
327 - }
328 -
329 - if ( $team_member_id ) {
330 - $args['meta_query'][] = array(
331 - 'key' => 'team_member_id',
332 - 'value' => $team_member_id,
333 - 'compare' => '=',
334 - );
335 - }
336 -
337 - if ( $status ) {
338 - $args['meta_query'][] = array(
339 - 'key' => 'status',
340 - 'value' => $status,
341 - 'compare' => '=',
342 - );
343 - }
344 -
345 - $data = Appointment_Model::paginate( $per_page, $paged, $args );
346 -
347 - if ( ! $data ) {
348 - return $this->error( __( 'No appointment found', 'booktics' ), 404 );
349 - }
350 -
351 - return $this->response( $data );
352 - }
353 -
354 - /**
355 - * Get one item from the collection.
356 - *
357 - * @param WP_REST_Request $request Full data about the request.
358 - *
359 - * @return WP_Error|WP_REST_Response
360 - */
361 - public function get_item( $request ) {
362 - $id = intval( $request['id'] );
363 -
364 - $appointment = Appointment_Model::find( $id );
365 -
366 - if ( ! $appointment ) {
367 - return $this->error( __( 'Appointment not found', 'booktics' ), 404 );
368 - }
369 -
370 - return $this->response( $appointment );
371 - }
372 -
373 - /**
374 - * Get all calendar items within a date range
375 - *
376 - * @param WP_REST_Request $request Full data about the request.
377 - *
378 - * @return WP_Error|WP_REST_Response
379 - */
380 - public function get_all_calendar_items( $request ) {
381 - $per_page = ! empty( $request['per_page'] ) ? intval( $request['per_page'] ) : 500;
382 - $paged = ! empty( $request['paged'] ) ? intval( $request['paged'] ) : 1;
383 -
384 - // Check if custom date range is provided
385 - if ( ! empty( $request['start_date'] ) && ! empty( $request['end_date'] ) ) {
386 - $adjusted_start_date = sanitize_text_field( $request['start_date'] );
387 - $adjusted_end_date = sanitize_text_field( $request['end_date'] );
388 - $month = date( 'Y-m', strtotime( $adjusted_start_date ) );
389 - } else {
390 - // Fall back to month-based date range with week adjustment
391 - $month = ! empty( $request['month'] ) ? sanitize_text_field( $request['month'] ) : gmdate( 'Y-m' );
392 - $start_date = gmdate( 'Y-m-01', strtotime( $month ) );
393 - $end_date = gmdate( 'Y-m-t', strtotime( $month ) );
394 -
395 - // Get the first day of the week (Sunday = 0, Monday = 1, etc.)
396 - $first_day_of_week = get_option( 'start_of_week', 1 );
397 -
398 - // Adjust start date to include days from previous month to complete the first week
399 - $start_date_obj = new \DateTime( $start_date );
400 - $start_week_day = (int) $start_date_obj->format( 'w' ); // 0 (Sun) to 6 (Sat)
401 - $days_to_subtract = ( $start_week_day - $first_day_of_week + 7 ) % 7;
402 - $start_date_obj->modify( "-$days_to_subtract days" );
403 - $adjusted_start_date = $start_date_obj->format( 'Y-m-d' );
404 -
405 - // Adjust end date to include days from next month to complete the last week
406 - $end_date_obj = new \DateTime( $end_date );
407 - $end_week_day = (int) $end_date_obj->format( 'w' ); // 0 (Sun) to 6 (Sat)
408 - $days_to_add = ( 6 - $end_week_day + $first_day_of_week ) % 7;
409 - $end_date_obj->modify( "+$days_to_add days" );
410 - $adjusted_end_date = $end_date_obj->format( 'Y-m-d' );
411 - }
412 -
413 - // Get all appointments within the date range using meta_query
414 - $args = array(
415 - 'meta_query' => array(
416 - 'relation' => 'AND',
417 - array(
418 - 'key' => 'date',
419 - 'value' => array(
420 - $adjusted_start_date,
421 - $adjusted_end_date
422 - ),
423 - 'compare' => 'BETWEEN',
424 - 'type' => 'DATE',
425 - ),
426 - ),
427 - 'posts_per_page' => - 1, // Get all appointments in the range
428 - 'orderby' => 'meta_value',
429 - 'meta_key' => 'date',
430 - 'order' => 'ASC',
431 - );
432 -
433 - $service_id = ! empty( $request['service_id'] ) ? intval( $request['service_id'] ) : null;
434 - $customer_id = ! empty( $request['customer_id'] ) ? intval( $request['customer_id'] ) : null;
435 - $payment_status = ! empty( $request['payment_status'] ) ? sanitize_text_field( $request['payment_status'] ) : null;
436 - $team_member_id = ! empty( $request['team_member_id'] ) ? intval( $request['team_member_id'] ) : null;
437 - $status = ! empty( $request['status'] ) ? sanitize_text_field( $request['status'] ) : null;
438 -
439 - if ( $service_id ) {
440 - $args['meta_query'][] = array(
441 - 'key' => 'service_id',
442 - 'value' => $service_id,
443 - 'compare' => '=',
444 - );
445 - }
446 -
447 - if ( $customer_id ) {
448 - $args['meta_query'][] = array(
449 - 'key' => 'customer_id',
450 - 'value' => $customer_id,
451 - 'compare' => '=',
452 - );
453 - }
454 -
455 - if ( $payment_status ) {
456 - $args['meta_query'][] = array(
457 - 'key' => 'payment_status',
458 - 'value' => $payment_status,
459 - 'compare' => '=',
460 - );
461 - }
462 -
463 - if ( $team_member_id ) {
464 - $args['meta_query'][] = array(
465 - 'key' => 'team_member_id',
466 - 'value' => $team_member_id,
467 - 'compare' => '=',
468 - );
469 - }
470 -
471 - if ( $status ) {
472 - $args['meta_query'][] = array(
473 - 'key' => 'status',
474 - 'value' => $status,
475 - 'compare' => '=',
476 - );
477 - }
478 -
479 - $appointments = Appointment_Model::paginate( $per_page, $paged, $args );
480 -
481 - // Format the response
482 - $formatted_appointments = array();
483 -
484 - if ( ! empty( $appointments ) ) {
485 - foreach ( $appointments['items'] as $appointment ) {
486 - if ( ! empty( $appointment ) ) {
487 - $appointment->source = 'booktics';
488 - }
489 -
490 - $formatted_appointments[] = $appointment;
491 - }
492 - }
493 -
494 - // Allow other developers to add/modify appointments
495 - $formatted_appointments = apply_filters(
496 - 'booktics_appointments_for_calendar_view',
497 - $formatted_appointments,
498 - array(
499 - 'start_date' => $adjusted_start_date,
500 - 'end_date' => $adjusted_end_date,
501 - )
502 - );
503 -
504 - $response = array(
505 - 'month' => gmdate( 'Y-m', strtotime( $month ) ),
506 - 'date_range' => array(
507 - 'start' => $adjusted_start_date,
508 - 'end' => $adjusted_end_date,
509 - ),
510 - 'appointments' => $formatted_appointments,
511 - );
512 -
513 - return $this->response( $response, __( 'Calendar appointments retrieved successfully', 'booktics' ) );
514 - }
515 -
516 - /**
517 - * Check if a given request has access to get a specific item.
518 - *
519 - * @param WP_REST_Request $request Full data about the request.
520 - *
521 - * @return WP_Error|boolean
522 - */
523 - public function get_item_permissions_check( $request ) {
524 - return true;
525 - }
526 -
527 - /**
528 - * Check if a given request has access to get items.
529 - *
530 - * @param WP_REST_Request $request Full data about the request.
531 - *
532 - * @return WP_Error|boolean
533 - */
534 - public function get_items_permissions_check( $request ) {
535 - return true;
536 - }
537 -
538 - /**
539 - * Create one item from the collection.
540 - *
541 - * @param WP_REST_Request $request Full data about the request.
542 - *
543 - * @return WP_Error|WP_REST_Response
544 - * @throws Exception
545 - */
546 - public function create_item( $request ) {
547 - $data = $this->prepare_item_for_database( $request );
548 -
549 - if ( is_wp_error( $data ) ) {
550 - return $this->error( $data->get_error_message() );
551 - }
552 -
553 - $appointment = Appointment_Model::create( $data );
554 -
555 - if ( is_wp_error( $appointment ) ) {
556 - return $this->error( $appointment->get_error_message() );
557 - }
558 -
559 - do_action( 'booktics_after_appointment_created', $appointment );
560 -
561 - return $this->response( $appointment, __( 'Successfully created appointment', 'booktics' ) );
562 - }
563 -
564 - /**
565 - * Check if a given request has access to create items.
566 - *
567 - * @param WP_REST_Request $request Full data about the request.
568 - *
569 - * @return WP_Error|boolean
570 - */
571 - public function create_item_permissions_check( $request ) {
572 - return current_user_can( 'manage_options' );
573 - }
574 -
575 - /**
576 - * Update one item from the collection.
577 - *
578 - * @param WP_REST_Request $request Full data about the request.
579 - *
580 - * @return WP_REST_Response
581 - */
582 - public function update_item( $request ) {
583 - $id = intval( $request['id'] );
584 - $data = json_decode( $request->get_body(), true );
585 -
586 - if ( empty( $data ) || ! is_array( $data ) ) {
587 - return $this->error( __( 'No data provided for update', 'booktics' ), 400 );
588 - }
589 -
590 - // Get the appointment to update
591 - $appointment = Appointment_Model::find( $id );
592 -
593 - if ( ! $appointment ) {
594 - return $this->error( __( 'Appointment not found', 'booktics' ), 404 );
595 - }
596 -
597 - if ( isset( $data ['status'] ) ) {
598 - if ( ! in_array( $data ['status'], Appointment_Model::get_accepted_statuses() ) ) {
599 - return $this->error( __( 'Invalid status', 'booktics' ), 400 );
600 - }
601 - $appointment->status = $data ['status'];
602 - }
603 -
604 - if ( ! empty( $data['customer'] ) ) {
605 - $customer = ( new Customer_Model() )->find( $appointment->customer_id );
606 - if ( $customer ) {
607 - $updated_customer = $customer->update( $data['customer'] );
608 - unset( $data['customer'] );
609 - }
610 - }
611 -
612 - $updated_appointment = $appointment->update( $data );
613 -
614 - if ( isset( $updated_customer ) ) {
615 - $updated_appointment->customer = $updated_customer;
616 - }
617 -
618 - if ( is_wp_error( $updated_appointment ) ) {
619 - return $this->error( $updated_appointment->get_error_message() );
620 - }
621 -
622 - do_action( 'booktics_after_appointment_updated', $appointment );
623 -
624 - return $this->response( $updated_appointment, __( 'Appointment updated successfully', 'booktics' ) );
625 - }
626 -
627 - /**
628 - * Check if a given request has access to update a specific item.
629 - *
630 - * @param WP_REST_Request $request Full data about the request.
631 - *
632 - * @return WP_Error|boolean
633 - */
634 - public function update_item_permissions_check( $request ) {
635 - return current_user_can( 'manage_options' );
636 - }
637 -
638 - /**
639 - * Delete one item from the collection.
640 - *
641 - * @param WP_REST_Request $request Full data about the request.
642 - *
643 - * @return \WP_Error|\WP_HTTP_Response
644 - */
645 - public function delete_item( $request ) {
646 - $id = intval( $request['id'] );
647 -
648 - $appointment = Appointment_Model::find( $id );
649 -
650 - if ( ! $appointment ) {
651 - return $this->error( __( 'Appointment not found', 'booktics' ), 404 );
652 - }
653 -
654 - $deleted = $appointment->delete();
655 -
656 - if ( is_wp_error( $deleted ) ) {
657 - return $this->error( $deleted->get_error_message() );
658 - }
659 -
660 - do_action( 'booktics_after_appointment_deleted', $appointment );
661 -
662 - return $this->response( $appointment, __( 'Successfully deleted appointment', 'booktics' ) );
663 - }
664 -
665 - /**
666 - * Check if a given request has access to delete a specific item.
667 - *
668 - * @param WP_REST_Request $request Full data about the request.
669 - *
670 - * @return WP_Error|boolean
671 - */
672 - public function delete_item_permissions_check( $request ) {
673 - return current_user_can( 'manage_options' );
674 - }
675 -
676 - /**
677 - * Delete a collection of items
678 - *
679 - * @param WP_Rest_Request $request User request
680 - */
681 - public function delete_items( $request ) {
682 - $ids = $request['ids'];
683 -
684 - if ( ! is_array( $ids ) ) {
685 - return $this->error( __( 'Ids should be an array of items ids', 'booktics' ) );
686 - }
687 -
688 - $count = 0;
689 - $total = count( $ids );
690 -
691 - foreach ( $ids as $id ) {
692 - $appointment = Appointment_Model::find( $id );
693 -
694 - if ( $appointment ) {
695 - $appointment->delete();
696 - ++ $count;
697 - }
698 - }
699 -
700 - if ( $count === 0 ) {
701 - return $this->error( __( 'No item deleted.', 'booktics' ) );
702 - }
703 -
704 - $message = sprintf( 'Deleted %d items of %d', $count, $total );
705 -
706 - return $this->response( array(), $message );
707 - }
708 -
709 - /**
710 - * Check if a given request has access to delete a collection of items.
711 - *
712 - * @param WP_REST_Request $request Full data about the request.
713 - *
714 - * @return WP_Error|boolean
715 - */
716 - public function delete_items_permissions_check( $request ) {
717 - return current_user_can( 'manage_options' );
718 - }
719 -
720 - /**
721 - * Reschedule an appointment
722 - *
723 - * @param $request Full data about the request.
724 - *
725 - * @throws Exception
726 - */
727 - public function reschedule_item( $request ) {
728 - $id = intval( $request['id'] );
729 - $data = json_decode( $request->get_body(), true );
730 -
731 - $validate = booktics_validate(
732 - $data,
733 - array(
734 - 'start_time' => array( 'required' ),
735 - 'end_time' => array( 'required' ),
736 - 'date' => array( 'required' ),
737 - )
738 - );
739 -
740 - if ( is_wp_error( $validate ) ) {
741 - return $this->error( $validate->get_error_message() );
742 - }
743 -
744 - $appointment = Appointment_Model::find( $id );
745 - if ( ! $appointment ) {
746 - return $this->error( __( 'Appointment not found', 'booktics' ), 404 );
747 - }
748 -
749 - $db = new Booktics_Database();
750 - $service_schedules = $db->table( 'booktics_schedules' )
751 - ->where( 'service_id', '=', $appointment->service_id )
752 - ->where( 'custom_date', '=', '0' )
753 - ->to_array();
754 - $service_schedules = new Service_Schedule( $service_schedules, $appointment->service_id );
755 -
756 - $team_member_schedules = $db->table( 'booktics_schedules' )
757 - ->where( 'team_member_id', '=', $appointment->team_member_id )
758 - ->where( 'custom_date', '=', '0' )
759 - ->to_array();
760 - $team_member_schedules = new Team_Member_Schedule( $team_member_schedules, $appointment->team_member_id );
761 -
762 - $default_schedules = $db->table( 'booktics_schedules' )
763 - ->where( 'service_id', '=', 0 )
764 - ->where( 'team_member_id', '=', 0 )
765 - ->where( 'custom_date', '=', '0' )
766 - ->to_array();
767 - $default_schedules = new Default_Schedule( $default_schedules );
768 -
769 - $slots = ( new Time_Slot_Manager( $service_schedules, $team_member_schedules, $default_schedules ) )
770 - ->generate_slots_for_range( $data['date'], $data['date'] );
771 -
772 - if ( is_wp_error( $validate ) ) {
773 - return $this->error( $validate->get_error_message() );
774 - }
775 -
776 - $updated = $appointment->update(
777 - array(
778 - 'start_time' => sanitize_text_field( $data['start_time'] ),
779 - 'end_time' => sanitize_text_field( $data['end_time'] ),
780 - 'date' => sanitize_text_field( $data['date'] ),
781 - )
782 - );
783 -
784 - if ( is_wp_error( $updated ) ) {
785 - return $this->error( __( 'Appointment reschedule error.', 'booktics' ) );
786 - }
787 -
788 - $service = Service_Model::find( $appointment->service_id );
789 - $team_member = ( new Team_Member_Model() )->find( $appointment->team_member_id );
790 - $customer = ( new Customer_Model() )->find( $appointment->customer_id );
791 - do_action( 'booktics_after_appointment_rescheduled', $appointment );
792 - do_action( 'global_notification_hook', 'appointment_rescheduled', [
793 - 'appointment_service' => $service->title,
794 - 'date' => $appointment->date,
795 - 'appointment_team_member' => $team_member->display_name,
796 - 'start_time' => $appointment->start_time,
797 - 'end_time' => $appointment->end_time,
798 - 'user_email' => $customer->user_email,
799 - ] );
800 -
801 - return $this->response( $appointment, __( 'Successfully rescheduled appointment', 'booktics' ) );
802 - }
803 -
804 - /**
805 - * Check if a given request has access to reschedule a specific item.
806 - *
807 - * @param $request Full data about the request.
808 - */
809 - public function reschedule_item_permissions_check( $request ) {
810 - return true;
811 - }
812 -
813 - /**
814 - * Cancel an appointment
815 - *
816 - * @param WP_REST_Request $request Full data about the request.
817 - *
818 - * @throws Exception
819 - */
820 - public function cancel_item( $request ) {
821 - $id = intval( $request['id'] );
822 -
823 - $appointment = Appointment_Model::find( $id );
824 -
825 - if ( ! $appointment ) {
826 - return $this->error( __( 'Appointment not found', 'booktics' ), 404 );
827 - }
828 - $updated = $appointment->update( array( 'status' => 'cancelled' ) );
829 -
830 - if ( is_wp_error( $updated ) ) {
831 - return $this->error( __( 'Appointment cancelletion error.', 'booktics' ) );
832 - }
833 - $service = Service_Model::find( $appointment->service_id );
834 - $team_member = ( new Team_Member_Model() )->find( $appointment->team_member_id );
835 - $customer = ( new Customer_Model() )->find( $appointment->customer_id );
836 -
837 - do_action( 'booktics_after_appointment_cancelled', $appointment );
838 - do_action( 'global_notification_hook', 'appointment_cancelled', [
839 - 'appointment_service' => $service->title,
840 - 'date' => $appointment->date,
841 - 'appointment_team_member' => $team_member->display_name,
842 - 'start_time' => $appointment->start_time,
843 - 'end_time' => $appointment->end_time,
844 - 'user_email' => $customer->user_email,
845 - ] );
846 -
847 - return $this->response( $appointment, __( 'Successfully cancelled appointment', 'booktics' ) );
848 - }
849 -
850 - /**
851 - * Check if a given request has access to cancel a specific item.
852 - *
853 - * @param WP_REST_Request $request Full data about the request.
854 - *
855 - * @return WP_Error|boolean
856 - */
857 - public function cancel_item_permissions_check( $request ) {
858 - return current_user_can( 'manage_options' );
859 - }
860 -
861 - /**
862 - * Prepare the item for create or update operation.
863 - *
864 - * @param WP_REST_Request $request Request object.
865 - *
866 - * @return bool|\WP_Error $prepared_item
867 - */
868 - public function prepare_item_for_database( $request ) {
869 - $data = json_decode( $request->get_body(), true );
870 - $validate = booktics_validate(
871 - $data,
872 - array(
873 - 'start_time' => array(
874 - 'required',
875 - ),
876 - 'end_time' => array(
877 - 'required',
878 - ),
879 - 'date' => array( 'required' ),
880 - 'service_id' => array(
881 - 'required',
882 - ),
883 - 'team_member_id' => array(
884 - 'required',
885 - ),
886 - )
887 - );
888 -
889 - if ( is_wp_error( $validate ) ) {
890 - return $validate;
891 - }
892 -
893 - $data = $this->get_sanitized_data( $data );
894 -
895 - return $data;
896 - }
897 -
898 - private function get_sanitized_data( $data ) {
899 - $data['start_time'] = ! empty( $data['start_time'] ) ? sanitize_text_field( $data['start_time'] ) : '';
900 - $data['end_time'] = ! empty( $data['end_time'] ) ? sanitize_text_field( $data['end_time'] ) : '';
901 - $data['date'] = ! empty( $data['date'] ) ? sanitize_text_field( $data['date'] ) : '';
902 - $data['service_id'] = ! empty( $data['service_id'] ) ? sanitize_text_field( $data['service_id'] ) : '';
903 - $data['team_member_id'] = ! empty( $data['team_member_id'] ) ? sanitize_text_field( $data['team_member_id'] ) : '';
904 - $data['customer_id'] = ! empty( $data['customer_id'] ) ? sanitize_text_field( $data['customer_id'] ) : '';
905 - $data['total_attendees'] = ! empty( $data['total_attendees'] ) ? sanitize_text_field( $data['total_attendees'] ) : '';
906 - $data['status'] = isset( $data['status'] ) ? $data['status'] : 'pending';
907 -
908 - return $data;
909 - }
910 -
911 - /**
912 - * Check if a given request has access to get time slots.
913 - *
914 - * @param $request
915 - *
916 - * @return boolean
917 - */
918 - public function get_time_slots_permissions_check( $request ) {
919 - return true;
920 - }
921 -
922 - /**
923 - * Fetch appointment time slots
924 - *
925 - * @param WP_REST_Request $request Request object.
926 - *
927 - * @return array|\WP_HTTP_Response
928 - * @throws Exception If there's an error getting time slots.
929 - */
930 - public function get_time_slots( $request ) {
931 - $service_id = $request['service_id'];
932 - $additional_service_id = $request['additional_service_id'];
933 - $team_member_id = $request['team_member_id'];
934 - $start_date = $request['start_date'];
935 - $end_date = $request['end_date'];
936 -
937 - $db = new Booktics_Database();
938 -
939 - $service_schedules = $db->table( 'booktics_schedules' )
940 - ->where( 'service_id', '=', $service_id )
941 - ->where_group(
942 - function ( $q ) use ( $start_date, $end_date ) {
943 - $q->where( 'custom_date', '=', '0' )
944 - ->or_where_group(
945 - function ( $q2 ) use ( $start_date, $end_date ) {
946 - $q2->where( 'custom_date', '>', $start_date )
947 - ->where( 'custom_date', '<', $end_date );
948 - }
949 - );
950 - }
951 - )
952 - ->to_array();
953 - $service_schedules = new Service_Schedule( $service_schedules, $service_id );
954 -
955 - $team_member_schedules = $db->table( 'booktics_schedules' )
956 - ->where( 'team_member_id', '=', $team_member_id )
957 - ->where_group(
958 - function ( $q ) use ( $start_date, $end_date ) {
959 - $q->where( 'custom_date', '=', '0' )
960 - ->or_where_group(
961 - function ( $q2 ) use ( $start_date, $end_date ) {
962 - $q2->where( 'custom_date', '>', $start_date )
963 - ->where( 'custom_date', '<', $end_date );
964 - }
965 - );
966 - }
967 - )
968 - ->to_array();
969 - $team_member_schedules = new Team_Member_Schedule( $team_member_schedules, $team_member_id );
970 -
971 - $default_schedules = $db->table( 'booktics_schedules' )
972 - ->where( 'service_id', '=', 0 )
973 - ->where( 'team_member_id', '=', 0 )
974 - ->where_group(
975 - function ( $q ) use ( $start_date, $end_date ) {
976 - $q->where( 'custom_date', '=', '0' )
977 - ->or_where_group(
978 - function ( $q2 ) use ( $start_date, $end_date ) {
979 - $q2->where( 'custom_date', '>', $start_date )
980 - ->where( 'custom_date', '<', $end_date );
981 - }
982 - );
983 - }
984 - )
985 - ->to_array();
986 - $default_schedules = new Default_Schedule( $default_schedules );
987 -
988 - $service = Service_Model::find( $service_id );
989 - $service_additional_services = ! empty( $service->additional_durations )
990 - ? array_filter(
991 - $service->additional_durations,
992 - function ( $duration ) use ( $additional_service_id ) {
993 - return $duration['id'] === $additional_service_id;
994 - }
995 - ) : array();
996 - $service_duration = ! empty( $service_additional_services ) ? reset( $service_additional_services )['duration_time'] : 30;
997 -
998 - $slots = ( new Time_Slot_Manager( $service_schedules, $team_member_schedules, $default_schedules, $service_duration ) )
999 - ->generate_slots_for_range( $start_date, $end_date );
1000 - $response = array(
1001 - 'service_id' => $service_id,
1002 - 'team_member_id' => $team_member_id,
1003 - 'additional_service_id' => $additional_service_id,
1004 - 'start_date' => $start_date,
1005 - 'end_date' => $end_date,
1006 - 'slots' => $slots,
1007 - );
1008 -
1009 - return $this->response( $response, __( 'Successfully fetched time slots', 'booktics' ) );
1010 - }
1011 -}
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 +}