| @@ -49,26 +49,47 @@ | ||
| 49 | 49 | */ |
| 50 | 50 | function timetics_get_google_access_token( $user_id = 0 ) { |
| 51 | 51 | $data = timetics_get_google_auth( $user_id ); |
| 52 | 52 | |
| 53 | - if ( ! $data ) { | |
| 53 | + if ( empty( $data ) || empty( $data['access_token'] ) ) { | |
| 54 | 54 | return false; |
| 55 | 55 | } |
| 56 | 56 | |
| 57 | - if ( ( $data['expires_in'] - 30 ) < time() ) { | |
| 58 | - $refresh_toekn = timetics_get_google_refresh_token( $user_id ); | |
| 59 | - $client = timetics_get_google_client(); | |
| 57 | + $expires_in = isset( $data['expires_in'] ) ? (int) $data['expires_in'] : 0; | |
| 60 | 58 | |
| 61 | - $data = $client->fetch_access_token_with_refresh_token( $refresh_toekn ); | |
| 59 | + // Token still valid (30s safety margin) — use it as-is. | |
| 60 | + if ( ( $expires_in - 30 ) > time() ) { | |
| 61 | + return $data['access_token']; | |
| 62 | + } | |
| 62 | 63 | |
| 63 | - if ( ! $data ) { | |
| 64 | + // Needs a refresh. | |
| 65 | + $refresh_token = timetics_get_google_refresh_token( $user_id ); | |
| 66 | + | |
| 67 | + if ( empty( $refresh_token ) ) { | |
| 68 | + return false; | |
| 69 | + } | |
| 70 | + | |
| 71 | + $client = timetics_get_google_client(); | |
| 72 | + $response = $client->fetch_access_token_with_refresh_token( $refresh_token ); | |
| 73 | + | |
| 74 | + if ( is_wp_error( $response ) ) { | |
| 75 | + $error_data = $response->get_error_data(); | |
| 76 | + $transient = ! is_array( $error_data ) || ! empty( $error_data['transient'] ); | |
| 77 | + | |
| 78 | + if ( $transient ) { | |
| 64 | 79 | return false; |
| 65 | 80 | } |
| 66 | 81 | |
| 67 | - timetics_update_google_auth( $user_id, $data ); | |
| 82 | + // Permanent failure (invalid_grant): the refresh token is dead and the account genuinely needs to be re-connected. Flag it so the UI can reflect the real state. | |
| 83 | + update_user_meta( $user_id, 'timetics_google_auth_error', $response->get_error_code() ); | |
| 84 | + | |
| 85 | + return false; | |
| 68 | 86 | } |
| 69 | 87 | |
| 70 | - return $data['access_token']; | |
| 88 | + // Persisting a fresh credential also clears any stale auth-error flag | |
| 89 | + timetics_update_google_auth( $user_id, $response ); | |
| 90 | + | |
| 91 | + return $response['access_token']; | |
| 71 | 92 | } |
| 72 | 93 | } |
| 73 | 94 | |
| 74 | 95 | if ( ! function_exists( 'timetics_get_google_refresh_token' ) ) { |
| @@ -107,8 +128,12 @@ | ||
| 107 | 128 | * |
| 108 | 129 | * @return void |
| 109 | 130 | */ |
| 110 | 131 | function timetics_update_google_auth( $user_id = 0, $data = [] ) { |
| 132 | + if ( empty( $data ) || ! is_array( $data ) ) { | |
| 133 | + return; | |
| 134 | + } | |
| 135 | + | |
| 111 | 136 | if ( ! empty( $data['code'] ) ) { |
| 112 | 137 | update_user_meta( $user_id, 'timetics_google_auth_code', $data['code'] ); |
| 113 | 138 | } |
| 114 | 139 | |
| @@ -115,11 +140,17 @@ | ||
| 115 | 140 | if ( ! empty( $data['refresh_token'] ) ) { |
| 116 | 141 | update_user_meta( $user_id, 'timetics_google_refresh_token', $data['refresh_token'] ); |
| 117 | 142 | } |
| 118 | 143 | |
| 119 | - $data['expires_in'] = $data['expires_in'] + time(); | |
| 144 | + // Google returns expires_in as a lifetime in seconds (~3600). | |
| 145 | + // Guard against a missing/zero value: storing time() alone would make the | |
| 146 | + // token look permanently expired, forcing a refresh on every request and | |
| 147 | + // hammering Google's token endpoint until it rate-limits the app. | |
| 148 | + $lifetime = ! empty( $data['expires_in'] ) ? (int) $data['expires_in'] : 3600; | |
| 149 | + $data['expires_in'] = time() + $lifetime; | |
| 120 | 150 | |
| 121 | 151 | update_user_meta( $user_id, 'timetics_google_auth', $data ); |
| 152 | + delete_user_meta( $user_id, 'timetics_google_auth_error' ); | |
| 122 | 153 | } |
| 123 | 154 | } |
| 124 | 155 | |
| 125 | 156 | if ( ! function_exists( 'timetics_get_google_auth' ) ) { |
| @@ -314,8 +345,70 @@ | ||
| 314 | 345 | return $total; |
| 315 | 346 | } |
| 316 | 347 | } |
| 317 | 348 | |
| 349 | +if ( ! function_exists( 'timetics_can_view_all_data' ) ) { | |
| 350 | + /** | |
| 351 | + * Check the current user is allowed to see site wide data | |
| 352 | + * | |
| 353 | + * Staff hold `manage_timetics`, so that capability cannot be used to tell an | |
| 354 | + * administrator apart from a team member. | |
| 355 | + * | |
| 356 | + * @return bool | |
| 357 | + */ | |
| 358 | + function timetics_can_view_all_data() { | |
| 359 | + return current_user_can( 'manage_options' ); | |
| 360 | + } | |
| 361 | +} | |
| 362 | + | |
| 363 | +if ( ! function_exists( 'timetics_get_visible_booking_ids' ) ) { | |
| 364 | + /** | |
| 365 | + * Get booking ids a user is allowed to see | |
| 366 | + * | |
| 367 | + * @param integer $user_id | |
| 368 | + * | |
| 369 | + * @return array Never empty, so it is always safe to pass to `post__in`. | |
| 370 | + */ | |
| 371 | + function timetics_get_visible_booking_ids( $user_id = 0 ) { | |
| 372 | + if ( ! $user_id ) { | |
| 373 | + $user_id = get_current_user_id(); | |
| 374 | + } | |
| 375 | + | |
| 376 | + $booking_ids = Booking::get_visible_ids_for_user( $user_id ); | |
| 377 | + | |
| 378 | + // WP_Query ignores an empty post__in and returns everything, so fall back to a | |
| 379 | + // non-existent id to mean "nothing visible". | |
| 380 | + return ! empty( $booking_ids ) ? $booking_ids : [0]; | |
| 381 | + } | |
| 382 | +} | |
| 383 | + | |
| 384 | +if ( ! function_exists( 'timetics_get_visible_customer_ids' ) ) { | |
| 385 | + /** | |
| 386 | + * Get customer user ids a user is allowed to see, derived from their visible bookings | |
| 387 | + * | |
| 388 | + * @param integer $user_id | |
| 389 | + * | |
| 390 | + * @return array Never empty, so it is always safe to pass to `include`. | |
| 391 | + */ | |
| 392 | + function timetics_get_visible_customer_ids( $user_id = 0 ) { | |
| 393 | + $booking_ids = timetics_get_visible_booking_ids( $user_id ); | |
| 394 | + $customer_ids = []; | |
| 395 | + | |
| 396 | + foreach ( $booking_ids as $booking_id ) { | |
| 397 | + $customer_id = (int) get_post_meta( $booking_id, '_tt_booking_customer', true ); | |
| 398 | + | |
| 399 | + if ( $customer_id ) { | |
| 400 | + $customer_ids[] = $customer_id; | |
| 401 | + } | |
| 402 | + } | |
| 403 | + | |
| 404 | + $customer_ids = array_values( array_unique( $customer_ids ) ); | |
| 405 | + | |
| 406 | + // WP_User_Query ignores an empty include and returns everything. | |
| 407 | + return ! empty( $customer_ids ) ? $customer_ids : [0]; | |
| 408 | + } | |
| 409 | +} | |
| 410 | + | |
| 318 | 411 | if ( ! function_exists( 'timetics_get_staff_integrations' ) ) { |
| 319 | 412 | /** |
| 320 | 413 | * Get all staff integrations |
| 321 | 414 | * |
| @@ -321,9 +414,15 @@ | ||
| 321 | 414 | * |
| 322 | 415 | * @return array |
| 323 | 416 | */ |
| 324 | 417 | function timetics_get_staff_integrations( $user_id = 0 ) { |
| 325 | - $google_auth = timetics_get_google_access_token( $user_id ); | |
| 418 | + timetics_get_google_access_token( $user_id ); | |
| 419 | + | |
| 420 | + $refresh_token = timetics_get_google_refresh_token( $user_id ); | |
| 421 | + $auth_error = get_user_meta( $user_id, 'timetics_google_auth_error', true ); | |
| 422 | + $is_connected = ! empty( $refresh_token ) && empty( $auth_error ); | |
| 423 | + $needs_reauth = ! empty( $refresh_token ) && ! empty( $auth_error ); | |
| 424 | + | |
| 326 | 425 | $google_credentials = timetics_get_google_credentials(); |
| 327 | 426 | $is_setup = ! empty( $google_credentials['client_id'] ) && ! empty( $google_credentials['client_secret'] ); |
| 328 | 427 | $current_user_id = ( $user_id == get_current_user_id() ) ? true : false; |
| 329 | 428 | |
| @@ -332,9 +431,10 @@ | ||
| 332 | 431 | 'id' => 'google-calendar-meet', |
| 333 | 432 | 'name' => esc_html__( 'Google Meet', 'timetics' ), |
| 334 | 433 | 'description' => esc_html__( 'Connect your Meet to sync your booked events.', 'timetics' ), |
| 335 | 434 | 'auth_url' => timetics_get_google_auth_url(), |
| 336 | - 'connected' => ! empty( $google_auth ), | |
| 435 | + 'connected' => $is_connected, | |
| 436 | + 'needs_reauth' => $needs_reauth, | |
| 337 | 437 | 'setup' => $is_setup, |
| 338 | 438 | 'current_user' => $current_user_id, |
| 339 | 439 | ], |
| 340 | 440 | [ |
| @@ -341,9 +441,10 @@ | ||
| 341 | 441 | 'id' => 'google-calendar', |
| 342 | 442 | 'name' => esc_html__( 'Google Calendar', 'timetics' ), |
| 343 | 443 | 'description' => esc_html__( 'Connect your Meet to sync your booked events.', 'timetics' ), |
| 344 | 444 | 'auth_url' => timetics_get_google_auth_url(), |
| 345 | - 'connected' => ! empty( $google_auth ), | |
| 445 | + 'connected' => $is_connected, | |
| 446 | + 'needs_reauth' => $needs_reauth, | |
| 346 | 447 | 'setup' => $is_setup, |
| 347 | 448 | 'current_user' => $current_user_id, |
| 348 | 449 | ], |
| 349 | 450 | ]; |
| @@ -410,8 +511,9 @@ | ||
| 410 | 511 | 'default_booking_status' => 'pending', |
| 411 | 512 | 'currency' => 'USD', |
| 412 | 513 | 'primary_color' => '#3161F1', |
| 413 | 514 | 'secondary_color' => '#6188ff', |
| 515 | + 'unpaid_booking_expiry_minutes' => 5, | |
| 414 | 516 | ]; |
| 415 | 517 | |
| 416 | 518 | $settings = apply_filters( 'timetics_default_settings', $settings ); |
| 417 | 519 | |
| @@ -513,8 +615,14 @@ | ||
| 513 | 615 | * @return void |
| 514 | 616 | */ |
| 515 | 617 | function timetics_register_cron() { |
| 516 | 618 | wp_schedule_event( time(), 'daily', 'timetics_booking_clear_schedule' ); |
| 619 | + | |
| 620 | + // Guarded: Hooks::maybe_schedule_cleanup_cron() also calls this on | |
| 621 | + // every 'init', so without the guard it'd stack duplicate events. | |
| 622 | + if ( ! wp_next_scheduled( 'timetics_cleanup_unpaid_bookings' ) ) { | |
| 623 | + wp_schedule_event( time(), 'timetics_five_minutes', 'timetics_cleanup_unpaid_bookings' ); | |
| 624 | + } | |
| 517 | 625 | } |
| 518 | 626 | } |
| 519 | 627 | |
| 520 | 628 | if ( ! function_exists( 'timetics_is_woocommerce_active' ) ) { |
| @@ -837,15 +945,14 @@ | ||
| 837 | 945 | 'upgrade_link' => '', |
| 838 | 946 | 'status' => 'on', |
| 839 | 947 | 'is_pro' => false, |
| 840 | 948 | 'title' => __( 'Aisentic', 'timetics' ), |
| 841 | - 'description' => __( 'AI assistant for WordPress — automate content, replies and on-site tasks without leaving your dashboard.', 'timetics' ), | |
| 949 | + 'description' => __( 'Your AI assistant for Timetics. Manage bookings, analyze schedules and automate routine tasks using plain English — get work done in seconds instead of hours.', 'timetics' ), | |
| 842 | 950 | 'icon' => \Timetics\Core\Addon\Extension_Icon::get( 'aisentic' ), |
| 843 | 951 | 'notice' => '', |
| 844 | 952 | 'demo_link' => '', |
| 845 | 953 | 'settings_link' => '', |
| 846 | 954 | 'doc_link' => 'https://support.themewinter.com/docs/plugins/docs/aisentic/', |
| 847 | - 'download_url' => 'https://github.com/themewinter/aisentic-public/releases/download/v1.0.0/aisentic-1.0.0.zip', | |
| 848 | 955 | ], |
| 849 | 956 | 'optiontics' => [ |
| 850 | 957 | 'name' => 'optiontics', |
| 851 | 958 | 'slug' => 'optiontics', |
| @@ -854,9 +961,9 @@ | ||
| 854 | 961 | 'upgrade_link' => '', |
| 855 | 962 | 'status' => 'on', |
| 856 | 963 | 'is_pro' => false, |
| 857 | 964 | 'title' => __( 'Optiontics', 'timetics' ), |
| 858 | - 'description' => __( 'Product add-ons / extras for food items — let customers pick toppings, sizes and other paid options along with the products.', 'timetics' ), | |
| 965 | + 'description' => __( 'Offer paid add-ons and extra options with every booking — services, extras, packages or custom upgrades that customers can pick during checkout.', 'timetics' ), | |
| 859 | 966 | 'icon' => \Timetics\Core\Addon\Extension_Icon::get( 'optiontics' ), |
| 860 | 967 | 'notice' => '', |
| 861 | 968 | 'demo_link' => '', |
| 862 | 969 | 'settings_link' => '', |
| @@ -904,8 +1011,54 @@ | ||
| 904 | 1011 | return $enabled; |
| 905 | 1012 | } |
| 906 | 1013 | } |
| 907 | 1014 | |
| 1015 | +if ( ! function_exists( 'timetics_wp_timezone_string' ) ) { | |
| 1016 | + /** | |
| 1017 | + * wp_timezone_string() polyfill — core's version needs WP 5.3, plugin supports 5.2. | |
| 1018 | + * | |
| 1019 | + * @return string | |
| 1020 | + */ | |
| 1021 | + function timetics_wp_timezone_string() { | |
| 1022 | + if ( function_exists( 'wp_timezone_string' ) ) { | |
| 1023 | + return wp_timezone_string(); | |
| 1024 | + } | |
| 1025 | + | |
| 1026 | + $timezone_string = get_option( 'timezone_string' ); | |
| 1027 | + | |
| 1028 | + if ( $timezone_string ) { | |
| 1029 | + return $timezone_string; | |
| 1030 | + } | |
| 1031 | + | |
| 1032 | + $offset = (float) get_option( 'gmt_offset' ); | |
| 1033 | + $hours = (int) $offset; | |
| 1034 | + $minutes = abs( ( $offset - $hours ) * 60 ); | |
| 1035 | + | |
| 1036 | + return sprintf( '%s%02d:%02d', ( $offset < 0 ) ? '-' : '+', abs( $hours ), $minutes ); | |
| 1037 | + } | |
| 1038 | +} | |
| 1039 | + | |
| 1040 | +if ( ! function_exists( 'timetics_reminder_fallback_timezone' ) ) { | |
| 1041 | + /** | |
| 1042 | + * Timezone to interpret a booking's stored wall clock in when the booking | |
| 1043 | + * itself carries no usable timezone. | |
| 1044 | + * | |
| 1045 | + * A booking normally stores the customer's timezone next to the customer's | |
| 1046 | + * local start time, and that pair must be read together. Bookings created | |
| 1047 | + * before that meta existed, or from the admin side, have no timezone — those | |
| 1048 | + * times were entered against the site clock, so the site timezone is the | |
| 1049 | + * closer reading. Falling back to UTC put every reminder on such a booking | |
| 1050 | + * out by the site's GMT offset. | |
| 1051 | + * | |
| 1052 | + * @return string A timezone identifier or ±hh:mm offset accepted by DateTimeZone. | |
| 1053 | + */ | |
| 1054 | + function timetics_reminder_fallback_timezone() { | |
| 1055 | + $timezone = timetics_wp_timezone_string(); | |
| 1056 | + | |
| 1057 | + return $timezone ? $timezone : 'UTC'; | |
| 1058 | + } | |
| 1059 | +} | |
| 1060 | + | |
| 908 | 1061 | if ( ! function_exists( 'timetics_format_email_datetime' ) ) { |
| 909 | 1062 | /** |
| 910 | 1063 | * Format date, time and day according to WordPress admin settings |
| 911 | 1064 | * Used in email templates to display consistent date/time formats |
| @@ -918,10 +1071,10 @@ | ||
| 918 | 1071 | function timetics_format_email_datetime( $start_date, $start_time = '' ) { |
| 919 | 1072 | $wp_date_format = get_option( 'date_format' ); |
| 920 | 1073 | $wp_time_format = get_option( 'time_format' ); |
| 921 | 1074 | |
| 922 | - $datetime = $start_time | |
| 923 | - ? $start_date . ' ' . $start_time | |
| 1075 | + $datetime = $start_time | |
| 1076 | + ? $start_date . ' ' . $start_time | |
| 924 | 1077 | : $start_date; |
| 925 | 1078 | |
| 926 | 1079 | $timestamp = strtotime( $datetime ); |
| 927 | 1080 | |
| @@ -929,6 +1082,55 @@ | ||
| 929 | 1082 | 'day' => date_i18n( 'l', $timestamp ), |
| 930 | 1083 | 'time' => date_i18n( $wp_time_format, $timestamp ), |
| 931 | 1084 | 'date' => date_i18n( $wp_date_format, $timestamp ), |
| 932 | 1085 | ]; |
| 1086 | + } | |
| 1087 | +} | |
| 1088 | + | |
| 1089 | +if ( ! function_exists( 'timetics_aisentic_identity' ) ) { | |
| 1090 | + /** | |
| 1091 | + * Resolve the identity Timetics hands to Aisentic when the user connects. | |
| 1092 | + * | |
| 1093 | + * The consent UI shows these exact values before anything leaves the site, | |
| 1094 | + * so the connect request must read them from here too. Showing one email | |
| 1095 | + * and sending another would break the consent. | |
| 1096 | + * | |
| 1097 | + * The account belongs to the person who opts in, so it is the logged-in | |
| 1098 | + * user's name and email. The Ask AI setup dialog lets them edit the email, | |
| 1099 | + * which arrives here as $email. The site admin email is the last fallback. | |
| 1100 | + * | |
| 1101 | + * @param string $email Optional email the user typed in the consent UI. | |
| 1102 | + * @return array{name:string,email:string,site_url:string} | |
| 1103 | + */ | |
| 1104 | + function timetics_aisentic_identity( $email = '' ) { | |
| 1105 | + $user = wp_get_current_user(); | |
| 1106 | + $name = sanitize_text_field( (string) $user->display_name ); | |
| 1107 | + $email = sanitize_email( (string) ( $email ?: $user->user_email ?: get_option( 'admin_email', '' ) ) ); | |
| 1108 | + | |
| 1109 | + return [ | |
| 1110 | + 'name' => $name, | |
| 1111 | + 'email' => $email, | |
| 1112 | + 'site_url' => site_url(), | |
| 1113 | + ]; | |
| 1114 | + } | |
| 1115 | +} | |
| 1116 | + | |
| 1117 | +if ( ! function_exists( 'timetics_aisentic_is_registered' ) ) { | |
| 1118 | + /** | |
| 1119 | + * Whether Aisentic already holds a provider api key. | |
| 1120 | + * | |
| 1121 | + * Reads Aisentic's own settings, so a site the user registered from | |
| 1122 | + * Aisentic's own screens counts as connected as well. Without that the | |
| 1123 | + * dashboard banner would keep nagging people who are already set up. | |
| 1124 | + * | |
| 1125 | + * @return bool | |
| 1126 | + */ | |
| 1127 | + function timetics_aisentic_is_registered() { | |
| 1128 | + if ( function_exists( 'aisentic_get_option' ) ) { | |
| 1129 | + return ! empty( aisentic_get_option( 'llmProviders.aisentic.apiKey', '' ) ); | |
| 1130 | + } | |
| 1131 | + | |
| 1132 | + $settings = get_option( 'aisentic_settings', [] ); | |
| 1133 | + | |
| 1134 | + return ! empty( $settings['llmProviders']['aisentic']['apiKey'] ); | |
| 933 | 1135 | } |
| 934 | 1136 | } |