| @@ -7,11 +7,12 @@ | ||
| 7 | 7 | * @package Timetics |
| 8 | 8 | */ |
| 9 | 9 | namespace Timetics\Core\Appointments; |
| 10 | 10 | |
| 11 | +defined( 'ABSPATH' ) || exit; | |
| 12 | + | |
| 11 | 13 | use DateTime; |
| 12 | 14 | use Timetics\Base\PostModel; |
| 13 | -use Timetics\Core\Bookings\Booking; | |
| 14 | 15 | use Timetics\Core\Bookings\Booking_Entry; |
| 15 | 16 | use Timetics\Core\Staffs\Staff; |
| 16 | 17 | use WP_Query; |
| 17 | 18 | |
| @@ -65,8 +66,12 @@ | ||
| 65 | 66 | 'schedule' => '', |
| 66 | 67 | 'blocked_schedule' => '', |
| 67 | 68 | 'price' => '', |
| 68 | 69 | 'buffer_time' => '', |
| 70 | + 'buffer_time_before_value'=> '', | |
| 71 | + 'buffer_time_before_unit' => '', | |
| 72 | + 'buffer_time_after_value' => '', | |
| 73 | + 'buffer_time_after_unit' => '', | |
| 69 | 74 | 'timezone' => '', |
| 70 | 75 | 'availability' => '', |
| 71 | 76 | 'visibility' => '', |
| 72 | 77 | 'capacity' => '', |
| @@ -80,8 +85,10 @@ | ||
| 80 | 85 | 'pabbly_hook_overwrite' => '', |
| 81 | 86 | 'zapier_hook_overwrite' => '', |
| 82 | 87 | 'pabbly_webook' => '', |
| 83 | 88 | 'zapier_webook' => '', |
| 89 | + 'flowmattic_hook_overwrite' => '', | |
| 90 | + 'flowmattic_webhook' => '', | |
| 84 | 91 | 'min_notice_time' => '', |
| 85 | 92 | 'notifications' => [ |
| 86 | 93 | 'booking_created_email_form' => '', |
| 87 | 94 | 'booking_created_email_title' => '', |
| @@ -209,9 +216,11 @@ | ||
| 209 | 216 | * |
| 210 | 217 | * @return array |
| 211 | 218 | */ |
| 212 | 219 | public function get_staff_ids() { |
| 213 | - return $this->get_prop( 'staff' ); | |
| 220 | + $ids = $this->get_prop( 'staff' ); | |
| 221 | + | |
| 222 | + return is_array( $ids ) ? array_values( $ids ) : []; | |
| 214 | 223 | } |
| 215 | 224 | |
| 216 | 225 | /** |
| 217 | 226 | * Get duplicate number |
| @@ -231,8 +240,25 @@ | ||
| 231 | 240 | return $this->get_prop( 'capacity' ); |
| 232 | 241 | } |
| 233 | 242 | |
| 234 | 243 | /** |
| 244 | + * Get the effective booking capacity used for availability checks. | |
| 245 | + * | |
| 246 | + * One-to-One meetings always allow a single booking per slot regardless of | |
| 247 | + * the stored capacity meta (which is only meaningful for group meetings), | |
| 248 | + * matching the one-to-one handling used elsewhere in the booking flow. | |
| 249 | + * | |
| 250 | + * @return integer | |
| 251 | + */ | |
| 252 | + public function get_effective_capacity() { | |
| 253 | + if ( 'one-to-one' === strtolower( (string) $this->get_type() ) ) { | |
| 254 | + return 1; | |
| 255 | + } | |
| 256 | + | |
| 257 | + return intval( $this->get_capacity() ); | |
| 258 | + } | |
| 259 | + | |
| 260 | + /** | |
| 235 | 261 | * Get location |
| 236 | 262 | * |
| 237 | 263 | * @since 1.0.0 |
| 238 | 264 | * |
| @@ -337,8 +363,44 @@ | ||
| 337 | 363 | public function get_buffer_time() { |
| 338 | 364 | return $this->get_prop( 'buffer_time' ); |
| 339 | 365 | } |
| 340 | 366 | |
| 367 | + /** | |
| 368 | + * Returns the amount of buffer time before the appointment (without unit like minute or hour) | |
| 369 | + * | |
| 370 | + * @return integer | |
| 371 | + */ | |
| 372 | + public function get_buffer_time_before_value() { | |
| 373 | + return $this->get_prop( 'buffer_time_before_value' ); | |
| 374 | + } | |
| 375 | + | |
| 376 | + /** | |
| 377 | + * Returns the unit of buffer time before the appointment (like minute or hour) | |
| 378 | + * | |
| 379 | + * @return string | |
| 380 | + */ | |
| 381 | + public function get_buffer_time_before_unit() { | |
| 382 | + return $this->get_prop( 'buffer_time_before_unit' ); | |
| 383 | + } | |
| 384 | + | |
| 385 | + /** | |
| 386 | + * Returns the amount of buffer time after the appointment (without unit like minute or hour) | |
| 387 | + * | |
| 388 | + * @return integer | |
| 389 | + */ | |
| 390 | + public function get_buffer_time_after_value() { | |
| 391 | + return $this->get_prop( 'buffer_time_after_value' ); | |
| 392 | + } | |
| 393 | + | |
| 394 | + /** | |
| 395 | + * Returns the unit of buffer time after the appointment (like minute or hour) | |
| 396 | + * | |
| 397 | + * @return string | |
| 398 | + */ | |
| 399 | + public function get_buffer_time_after_unit() { | |
| 400 | + return $this->get_prop( 'buffer_time_after_unit' ); | |
| 401 | + } | |
| 402 | + | |
| 341 | 403 | public function get_timezone() { |
| 342 | 404 | return $this->get_prop( 'timezone' ); |
| 343 | 405 | } |
| 344 | 406 | |
| @@ -348,9 +410,8 @@ | ||
| 348 | 410 | |
| 349 | 411 | public function get_visibility() { |
| 350 | 412 | return $this->get_prop( 'visibility' ); |
| 351 | 413 | } |
| 352 | - | |
| 353 | 414 | public function get_fleunt_crm_webhook() { |
| 354 | 415 | return $this->get_prop( 'fleunt_crm_webhook' ); |
| 355 | 416 | } |
| 356 | 417 | public function get_fluent_hook_overwrite() { |
| @@ -367,8 +428,14 @@ | ||
| 367 | 428 | } |
| 368 | 429 | public function get_zapier_hook_overwrite() { |
| 369 | 430 | return $this->get_prop( 'zapier_hook_overwrite' ); |
| 370 | 431 | } |
| 432 | + public function get_flowmattic_webhook() { | |
| 433 | + return $this->get_prop( 'flowmattic_webhook' ); | |
| 434 | + } | |
| 435 | + public function get_flowmattic_hook_overwrite() { | |
| 436 | + return $this->get_prop( 'flowmattic_hook_overwrite' ); | |
| 437 | + } | |
| 371 | 438 | public function get_min_notice_time() { |
| 372 | 439 | return $this->get_prop( 'min_notice_time' ); |
| 373 | 440 | } |
| 374 | 441 | public function get_guest_enabled() { |
| @@ -396,8 +463,38 @@ | ||
| 396 | 463 | return $this->get_prop( 'notifications' ); |
| 397 | 464 | } |
| 398 | 465 | |
| 399 | 466 | /** |
| 467 | + * Returns the buffer time before the appointment in seconds | |
| 468 | + * | |
| 469 | + * @return integer seconds | |
| 470 | + */ | |
| 471 | + public function get_buffer_time_before_in_seconds() { | |
| 472 | + $buffer_time_value = (int) $this->get_buffer_time_before_value() ?? 0; | |
| 473 | + | |
| 474 | + if ( 'hr' === $this->get_buffer_time_before_unit() ) { | |
| 475 | + return $buffer_time_value * 60 * 60; | |
| 476 | + } | |
| 477 | + | |
| 478 | + return $buffer_time_value * 60; | |
| 479 | + } | |
| 480 | + | |
| 481 | + /** | |
| 482 | + * Returns the buffer time after the appointment in seconds | |
| 483 | + * | |
| 484 | + * @return integer seconds | |
| 485 | + */ | |
| 486 | + public function get_buffer_time_after_in_seconds() { | |
| 487 | + $buffer_time_value = (int) $this->get_buffer_time_after_value() ?? 0; | |
| 488 | + | |
| 489 | + if ( 'hr' === $this->get_buffer_time_after_unit() ) { | |
| 490 | + return $buffer_time_value * 60 * 60; | |
| 491 | + } | |
| 492 | + | |
| 493 | + return $buffer_time_value * 60; | |
| 494 | + } | |
| 495 | + | |
| 496 | + /** | |
| 400 | 497 | * Get appointment categories |
| 401 | 498 | * |
| 402 | 499 | * @since 1.0.0 |
| 403 | 500 | * |
| @@ -466,9 +563,9 @@ | ||
| 466 | 563 | */ |
| 467 | 564 | public function get_author() { |
| 468 | 565 | $post = get_post( $this->id ); |
| 469 | 566 | |
| 470 | - return $post->post_author; | |
| 567 | + return $post ? (int) $post->post_author : 0; | |
| 471 | 568 | } |
| 472 | 569 | |
| 473 | 570 | /** |
| 474 | 571 | * Get seat plan settings |
| @@ -578,13 +675,15 @@ | ||
| 578 | 675 | $args = [ |
| 579 | 676 | 'post_title' => $this->data['name'], |
| 580 | 677 | 'post_type' => $this->post_type, |
| 581 | 678 | 'post_status' => 'publish', |
| 582 | - 'post_author' => get_current_user_id(), | |
| 583 | 679 | ]; |
| 584 | 680 | |
| 585 | 681 | if ( ! empty( $this->id ) ) { |
| 586 | 682 | $args['ID'] = $this->id; |
| 683 | + } else { | |
| 684 | + // Only on creation — setting this on update reassigns post_author to whoever edits it. | |
| 685 | + $args['post_author'] = get_current_user_id(); | |
| 587 | 686 | } |
| 588 | 687 | |
| 589 | 688 | // Insert or Update appointment. |
| 590 | 689 | $appoint_id = wp_insert_post( $args ); |
| @@ -676,9 +775,10 @@ | ||
| 676 | 775 | ]; |
| 677 | 776 | |
| 678 | 777 | $args = wp_parse_args( $args, $defaults ); |
| 679 | 778 | |
| 680 | - if ( ! empty( $args['staff'] ) ) { | |
| 779 | + // isset()/'' check, not empty() — empty(0) skipped this filter, leaking all staff data to guests. | |
| 780 | + if ( isset( $args['staff'] ) && '' !== $args['staff'] ) { | |
| 681 | 781 | $args['meta_query'][] = [ |
| 682 | 782 | 'key' => '_tt_apointment_staff', |
| 683 | 783 | 'value' => $args['staff'], |
| 684 | 784 | 'compare' => 'LIKE', |
| @@ -746,8 +846,10 @@ | ||
| 746 | 846 | 'fleunt_crm_webhook' => $this->get_fleunt_crm_webhook(), |
| 747 | 847 | 'pabbly_hook_overwrite' => $this->get_pabbly_hook_overwrite(), |
| 748 | 848 | 'pabbly_webook' => $this->get_pabbly_webook(), |
| 749 | 849 | 'zapier_webook' => $this->get_zapier_webook(), |
| 850 | + 'flowmattic_webhook' => $this->get_flowmattic_webhook(), | |
| 851 | + 'flowmattic_hook_overwrite' => $this->get_flowmattic_hook_overwrite(), | |
| 750 | 852 | 'min_notice_time' => $this->get_min_notice_time(), |
| 751 | 853 | 'zapier_hook_overwrite' => $this->get_zapier_hook_overwrite(), |
| 752 | 854 | 'guest_enabled' => $this->get_guest_enabled(), |
| 753 | 855 | 'guest_limit' => $this->get_guest_limit(), |
| @@ -783,31 +885,42 @@ | ||
| 783 | 885 | * Get meeting schedule by date |
| 784 | 886 | * |
| 785 | 887 | * @param string $date [$date description] |
| 786 | 888 | * @param integer $staff_id [$staff_id description] |
| 889 | + * @param string $timze_zone [$timze_zone description] | |
| 787 | 890 | * |
| 788 | 891 | * @return array |
| 789 | 892 | */ |
| 790 | - private function get_schedule_by_date( $date, $staff_id, $timze_zone = '' ) { | |
| 791 | - $day_name = ucfirst( date( 'D', strtotime( $date ) ) ); | |
| 792 | - $schedule = $this->get_schedule(); | |
| 893 | + private function get_schedule_by_date( $date, $staff_id, $time_zone = '' ) { | |
| 894 | + $day_name = ucfirst( gmdate( 'D', strtotime( $date ) ) ); | |
| 895 | + $schedule = $this->get_schedule(); | |
| 793 | 896 | $interval = $this->get_interval(); |
| 794 | - $tartget_schedule = []; | |
| 897 | + $target_schedule = []; | |
| 795 | 898 | |
| 796 | 899 | $slots = []; |
| 797 | 900 | |
| 798 | 901 | if ( ! empty( $schedule[$staff_id] ) ) { |
| 799 | - $tartget_schedule = $schedule[$staff_id][$day_name]; | |
| 902 | + $target_schedule = $schedule[$staff_id][$day_name]; | |
| 800 | 903 | } |
| 801 | 904 | |
| 802 | - foreach ( $tartget_schedule as $day ) { | |
| 905 | + foreach ( $target_schedule as $day ) { | |
| 803 | 906 | $start = strtotime( $day['start'] ); |
| 804 | 907 | $end = strtotime( $day['end'] ); |
| 805 | 908 | |
| 806 | - for ( $time = $start; $time <= $end; $time += $interval ) { | |
| 909 | + // Get buffer times in seconds | |
| 910 | + $buffer_before = $this->get_buffer_time_before_in_seconds(); | |
| 911 | + $buffer_after = $this->get_buffer_time_after_in_seconds(); | |
| 912 | + | |
| 913 | + // Adjust the start time to include buffer before | |
| 914 | + $adjusted_start = $start + $buffer_before; | |
| 915 | + | |
| 916 | + // Adjust the end time to ensure we have enough time for the slot + buffer after | |
| 917 | + $min_slot_duration = $interval + $buffer_after; | |
| 918 | + | |
| 919 | + for ( $time = $adjusted_start; $time + $min_slot_duration <= $end; $time += $interval + $buffer_after + $buffer_before ) { | |
| 807 | 920 | $booked_entry = $this->get_booking_entries( $date, $time, $staff_id ); |
| 808 | 921 | $status = 'available'; |
| 809 | - $capacity = $this->get_capacity(); | |
| 922 | + $capacity = $this->get_effective_capacity(); | |
| 810 | 923 | $booked = 0; |
| 811 | 924 | |
| 812 | 925 | if ( $booked_entry ) { |
| 813 | 926 | if ( $booked_entry->get_booked() >= $capacity ) { |
| @@ -816,9 +929,11 @@ | ||
| 816 | 929 | |
| 817 | 930 | $booked = $booked_entry->get_booked(); |
| 818 | 931 | } |
| 819 | 932 | |
| 820 | - $datetime = $this->convert_timezone( $time, $timze_zone ); | |
| 933 | + // $time is only a clock reading, so pass the slot's own date or | |
| 934 | + // today's DST offset gets applied to a date in the other season. | |
| 935 | + $datetime = $this->convert_timezone( $date . ' ' . gmdate( 'g:ia', $time ), $time_zone ); | |
| 821 | 936 | |
| 822 | 937 | $slot = [ |
| 823 | 938 | 'status' => $status, |
| 824 | 939 | 'start_time' => $datetime->format( 'g:ia' ), |
| @@ -850,19 +965,26 @@ | ||
| 850 | 965 | private function get_booking_entries( $date, $time, $staff_id ) { |
| 851 | 966 | $time = gmdate( 'h:i a', $time ); |
| 852 | 967 | $booking_entries = new Booking_Entry(); |
| 853 | 968 | $entries = $booking_entries->find( [ |
| 854 | - 'meeting_id' => $this->id, | |
| 855 | 969 | 'staff_id' => $staff_id, |
| 856 | 970 | 'date' => $this->convert_timezone( $date, $this->get_timezone())->format('Y-m-d'), |
| 857 | 971 | ] ); |
| 858 | 972 | |
| 859 | 973 | foreach ( $entries as $entry ) { |
| 860 | - $booking = new Booking( $entry->get_booking_id() ); | |
| 974 | + $start_time = $entry->get_start(); | |
| 975 | + $end_time = $entry->get_end(); | |
| 861 | 976 | |
| 862 | - if ( $entry->get_start() == $time ) { | |
| 863 | - return $entry; | |
| 977 | + // Check if current time slot falls within the booking duration | |
| 978 | + if ( ! $this->is_time_in_range( $time, $start_time, $end_time ) ) { | |
| 979 | + continue; | |
| 864 | 980 | } |
| 981 | + | |
| 982 | + if ( $this->is_orphan_entry( $entry ) ) { | |
| 983 | + continue; | |
| 984 | + } | |
| 985 | + | |
| 986 | + return $entry; | |
| 865 | 987 | } |
| 866 | 988 | |
| 867 | 989 | return false; |
| 868 | 990 | } |
| @@ -867,8 +989,47 @@ | ||
| 867 | 989 | return false; |
| 868 | 990 | } |
| 869 | 991 | |
| 870 | 992 | /** |
| 993 | + * Whether an entry outlived its booking and should stop blocking the slot. | |
| 994 | + * | |
| 995 | + * Only single-booking entries qualify: a shared entry names just its first | |
| 996 | + * booker in `booking_id`, and entries with none belong to events/imports. | |
| 997 | + * | |
| 998 | + * @param Booking_Entry $entry | |
| 999 | + * | |
| 1000 | + * @return bool | |
| 1001 | + */ | |
| 1002 | + private function is_orphan_entry( $entry ) { | |
| 1003 | + $booking_id = $entry->get_booking_id(); | |
| 1004 | + | |
| 1005 | + if ( ! $booking_id || intval( $entry->get_booked() ) > 1 ) { | |
| 1006 | + return false; | |
| 1007 | + } | |
| 1008 | + | |
| 1009 | + $booking = get_post( $booking_id ); | |
| 1010 | + | |
| 1011 | + return ! $booking || 'timetics-booking' !== $booking->post_type; | |
| 1012 | + } | |
| 1013 | + | |
| 1014 | + /** | |
| 1015 | + * Check if a time falls within a given time range | |
| 1016 | + * | |
| 1017 | + * @param string $current_time Current time in h:i a format | |
| 1018 | + * @param string $start_time Start time in h:i a format | |
| 1019 | + * @param string $end_time End time in h:i a format | |
| 1020 | + * | |
| 1021 | + * @return bool | |
| 1022 | + */ | |
| 1023 | + private function is_time_in_range( $current_time, $start_time, $end_time ) { | |
| 1024 | + $current = strtotime( $current_time ); | |
| 1025 | + $start = strtotime( $start_time ); | |
| 1026 | + $end = strtotime( $end_time ); | |
| 1027 | + | |
| 1028 | + return $current >= $start && $current < $end; | |
| 1029 | + } | |
| 1030 | + | |
| 1031 | + /** | |
| 871 | 1032 | * Convert timestamp in target timezone |
| 872 | 1033 | * |
| 873 | 1034 | * @param integer $timestamp |
| 874 | 1035 | * @param string $timze_zone |
| @@ -885,6 +1046,61 @@ | ||
| 885 | 1046 | $datetime->setTimezone( new \DateTimeZone( $timze_zone ) ); |
| 886 | 1047 | |
| 887 | 1048 | // Get the converted timestamp |
| 888 | 1049 | return $datetime; |
| 1050 | + } | |
| 1051 | + | |
| 1052 | + /** | |
| 1053 | + * Get appointmentt ids by author id | |
| 1054 | + * | |
| 1055 | + * @param integer $author_id Appointment author id | |
| 1056 | + * | |
| 1057 | + * @return array Appointment ids | |
| 1058 | + */ | |
| 1059 | + public static function get_appointment_ids_by_author( $author_id ) { | |
| 1060 | + $query = new WP_Query([ | |
| 1061 | + 'post_type' => 'timetics-appointment', | |
| 1062 | + 'post_status' => 'any', | |
| 1063 | + 'author' => $author_id, | |
| 1064 | + 'posts_per_page' => -1, | |
| 1065 | + 'fields' => 'ids' | |
| 1066 | + ]); | |
| 1067 | + | |
| 1068 | + return $query->posts; | |
| 1069 | + } | |
| 1070 | + | |
| 1071 | + /** | |
| 1072 | + * Get meeting available time slot | |
| 1073 | + * | |
| 1074 | + * @param string $date Start date | |
| 1075 | + * @param integer $staff_id Meeting assigned staff id | |
| 1076 | + * @param string $timze_zone Booking timezone | |
| 1077 | + * | |
| 1078 | + * @return array Available timeslots | |
| 1079 | + */ | |
| 1080 | + public function get_avilable_timeslots( $date, $staff_id, $time_zone = '' ) { | |
| 1081 | + $day_name = ucfirst( gmdate( 'D', strtotime( $date ) ) ); | |
| 1082 | + $schedule = $this->get_schedule(); | |
| 1083 | + $interval = $this->get_interval(); | |
| 1084 | + $tartget_schedule = []; | |
| 1085 | + | |
| 1086 | + $slots = []; | |
| 1087 | + | |
| 1088 | + if ( isset($schedule[$staff_id]) && ! empty(array_filter($schedule[$staff_id])) ) { | |
| 1089 | + $tartget_schedule = $schedule[$staff_id][$day_name]; | |
| 1090 | + } | |
| 1091 | + | |
| 1092 | + foreach ( $tartget_schedule as $day ) { | |
| 1093 | + $start = strtotime( $day['start'] ); | |
| 1094 | + $end = strtotime( $day['end'] ); | |
| 1095 | + | |
| 1096 | + for ( $time = $start; $time <= $end; $time += $interval ) { | |
| 1097 | + // Slot's own date, see get_schedule_by_date(). | |
| 1098 | + $datetime = $this->convert_timezone( $date . ' ' . gmdate( 'g:ia', $time ), $time_zone ); | |
| 1099 | + $slot = $datetime->format( 'g:ia' ); | |
| 1100 | + $slots[] = $slot; | |
| 1101 | + } | |
| 1102 | + } | |
| 1103 | + | |
| 1104 | + return $slots; | |
| 889 | 1105 | } |
| 890 | 1106 | } |