| @@ -53,8 +53,9 @@ | ||
| 53 | 53 | ) |
| 54 | 54 | ) |
| 55 | 55 | ->init(); |
| 56 | 56 | |
| 57 | + add_filter( 'rest_pre_dispatch', array( $this, 'check_notification_flow_permission' ), 10, 3 ); | |
| 57 | 58 | add_filter( 'ens_tt_available_actions', array( $this, 'register_triggers' ) ); |
| 58 | 59 | add_filter( 'timetics_notification_sdk_email_body', array( $this, 'wrap_email_body' ), 10, 1 ); |
| 59 | 60 | add_filter( 'timetics_notification_sdk_to_emails', array( $this, 'expand_custom_email' ), 10, 2 ); |
| 60 | 61 | |
| @@ -61,8 +62,33 @@ | ||
| 61 | 62 | add_action( 'admin_init', array( 'Timetics\Core\Admin\Notification_Seeder', 'maybe_seed' ), 20 ); |
| 62 | 63 | } |
| 63 | 64 | |
| 64 | 65 | /** |
| 66 | + * Block non-admins from the SDK notification-flow REST endpoints. | |
| 67 | + * | |
| 68 | + * The SDK's FlowAPI registers these routes with permission_callback => true, | |
| 69 | + * so we enforce the capability here from the consumer plugin. | |
| 70 | + * | |
| 71 | + * @param mixed $result Short-circuit value (null to continue). | |
| 72 | + * @param \WP_REST_Server $server | |
| 73 | + * @param \WP_REST_Request $request | |
| 74 | + * @return mixed WP_Error on failure, original $result otherwise. | |
| 75 | + */ | |
| 76 | + public function check_notification_flow_permission( $result, $server, $request ) { | |
| 77 | + if ( strpos( $request->get_route(), '/timetics/v1/notification-flow' ) === 0 ) { | |
| 78 | + if ( ! current_user_can( 'manage_options' ) ) { | |
| 79 | + return new \WP_Error( | |
| 80 | + 'rest_forbidden', | |
| 81 | + __( 'Sorry, you are not allowed to do that.', 'timetics' ), | |
| 82 | + array( 'status' => 403 ) | |
| 83 | + ); | |
| 84 | + } | |
| 85 | + } | |
| 86 | + | |
| 87 | + return $result; | |
| 88 | + } | |
| 89 | + | |
| 90 | + /** | |
| 65 | 91 | * Register the 3 booking triggers with their tag fields and receivers. |
| 66 | 92 | * |
| 67 | 93 | * @param array $actions |
| 68 | 94 | * @return array |
| @@ -150,19 +176,23 @@ | ||
| 150 | 176 | 'value' => 'custom_email', |
| 151 | 177 | ), |
| 152 | 178 | ); |
| 153 | 179 | |
| 180 | + // Resolves against hook_data['meeting_date_timestamp'] (see get_hook_data), | |
| 181 | + // a true UTC timestamp, so a delay node anchored to it schedules correctly. | |
| 182 | + $delay_dependencies = array( | |
| 183 | + array( | |
| 184 | + 'label' => __( 'Meeting Date', 'timetics' ), | |
| 185 | + 'value' => 'meeting_date', | |
| 186 | + ), | |
| 187 | + ); | |
| 188 | + | |
| 154 | 189 | $actions = array( |
| 155 | 190 | array( |
| 156 | 191 | 'trigger_label' => __( 'After Booking Confirmation', 'timetics' ), |
| 157 | 192 | 'trigger_value' => 'booking_created', |
| 158 | 193 | 'trigger_data' => $trigger_data, |
| 159 | - 'delay_dependencies' => array( | |
| 160 | - array( | |
| 161 | - 'label' => __( 'Meeting Date', 'timetics' ), | |
| 162 | - 'value' => 'meeting_date', | |
| 163 | - ), | |
| 164 | - ), | |
| 194 | + 'delay_dependencies' => $delay_dependencies, | |
| 165 | 195 | 'email_receivers' => $email_receivers, |
| 166 | 196 | ), |
| 167 | 197 | array( |
| 168 | 198 | 'trigger_label' => __( 'After Booking Cancellation', 'timetics' ), |
| @@ -167,9 +197,15 @@ | ||
| 167 | 197 | array( |
| 168 | 198 | 'trigger_label' => __( 'After Booking Cancellation', 'timetics' ), |
| 169 | 199 | 'trigger_value' => 'booking_canceled', |
| 170 | 200 | 'trigger_data' => $trigger_data, |
| 171 | - 'delay_dependencies' => array(), | |
| 201 | + // Offering Meeting Date here as well, not just on booking_created. | |
| 202 | + // An empty list left a delay node with nothing to anchor to, and | |
| 203 | + // the SDK then falls back to current_time( 'timestamp' ) — which is | |
| 204 | + // UTC plus the site's GMT offset, handed straight to | |
| 205 | + // wp_schedule_single_event() where a true UTC timestamp is expected. | |
| 206 | + // The email then fires GMT-offset hours away from the intended time. | |
| 207 | + 'delay_dependencies' => $delay_dependencies, | |
| 172 | 208 | 'email_receivers' => $email_receivers, |
| 173 | 209 | ), |
| 174 | 210 | array( |
| 175 | 211 | 'trigger_label' => __( 'After Booking Rescheduled', 'timetics' ), |
| @@ -174,9 +210,9 @@ | ||
| 174 | 210 | array( |
| 175 | 211 | 'trigger_label' => __( 'After Booking Rescheduled', 'timetics' ), |
| 176 | 212 | 'trigger_value' => 'booking_rescheduled', |
| 177 | 213 | 'trigger_data' => $trigger_data, |
| 178 | - 'delay_dependencies' => array(), | |
| 214 | + 'delay_dependencies' => $delay_dependencies, | |
| 179 | 215 | 'email_receivers' => $email_receivers, |
| 180 | 216 | ), |
| 181 | 217 | ); |
| 182 | 218 | |
| @@ -259,11 +295,12 @@ | ||
| 259 | 295 | $meeting = new Appointment( $booking->get_appointment() ); |
| 260 | 296 | $staff = new Staff( $booking->get_staff_id() ); |
| 261 | 297 | $customer = new Customer( $booking->get_customer_id() ); |
| 262 | 298 | |
| 263 | - $formatted = timetics_format_email_datetime( $booking->get_start_date(), $booking->get_start_time() ); | |
| 264 | - $booking_timestamp = strtotime( $booking->get_start_date() . ' ' . $booking->get_start_time() ); | |
| 299 | + $formatted = timetics_format_email_datetime( $booking->get_start_date(), $booking->get_start_time() ); | |
| 265 | 300 | |
| 301 | + $booking_timestamp = self::get_booking_timestamp( $booking ); | |
| 302 | + | |
| 266 | 303 | /* Pull the Google Meet link from the stored calendar event so it can be inserted as the {%meeting_meet_link%} tag in automation emails. |
| 267 | 304 | */ |
| 268 | 305 | $calendar_event = $booking->get_event(); |
| 269 | 306 | $meet_link = ( is_array( $calendar_event ) && ! empty( $calendar_event['hangoutLink'] ) ) ? $calendar_event['hangoutLink'] : ''; |
| @@ -280,8 +317,17 @@ | ||
| 280 | 317 | } |
| 281 | 318 | } |
| 282 | 319 | |
| 283 | 320 | return array( |
| 321 | + // The booking id lets delayed flows be re-validated or re-scheduled | |
| 322 | + // when the booking changes after the flow started. `post_id` is the | |
| 323 | + // key the email-notification-sdk itself looks for; `booking_id` is | |
| 324 | + // the readable alias used inside Timetics. | |
| 325 | + 'post_id' => $booking->get_id(), | |
| 326 | + 'booking_id' => $booking->get_id(), | |
| 327 | + // Not $booking->get_status(): the cancel-by-delete path fires this | |
| 328 | + // trigger after the post row is gone, where get_post() returns null. | |
| 329 | + 'booking_status' => (string) get_post_status( $booking->get_id() ), | |
| 284 | 330 | 'customer_email' => $customer->get_email(), |
| 285 | 331 | 'host_email' => $staff->get_email(), |
| 286 | 332 | 'custom_email' => apply_filters( 'timetics_custom_notification_email', timetics_get_option( 'custom_notification_email', get_option( 'admin_email' ) ) ), |
| 287 | 333 | 'meeting_title' => $meeting->get_name(), |
| @@ -296,6 +342,39 @@ | ||
| 296 | 342 | 'login_url' => wp_login_url(), |
| 297 | 343 | 'login_username' => $customer->get_email(), |
| 298 | 344 | 'set_password_url' => $set_password_url, |
| 299 | 345 | ); |
| 346 | + } | |
| 347 | + | |
| 348 | + /** | |
| 349 | + * Resolve a booking's start date/time to a real UTC timestamp. | |
| 350 | + * | |
| 351 | + * The stored date and time are wall-clock values in the booking's own | |
| 352 | + * timezone, so they must be interpreted in that timezone — strtotime() | |
| 353 | + * would read them as server time and shift every delay by the offset. | |
| 354 | + * | |
| 355 | + * @param Booking $booking | |
| 356 | + * @return int Unix timestamp, 0 when the booking has no start date. | |
| 357 | + */ | |
| 358 | + public static function get_booking_timestamp( Booking $booking ) { | |
| 359 | + $date = $booking->get_start_date(); | |
| 360 | + $time = $booking->get_start_time(); | |
| 361 | + | |
| 362 | + if ( ! $date ) { | |
| 363 | + return 0; | |
| 364 | + } | |
| 365 | + | |
| 366 | + $timezone = $booking->get_timezone(); | |
| 367 | + | |
| 368 | + if ( ! $timezone || ! timetics_is_valid_timezone( $timezone ) ) { | |
| 369 | + $timezone = timetics_reminder_fallback_timezone(); | |
| 370 | + } | |
| 371 | + | |
| 372 | + try { | |
| 373 | + $datetime = new \DateTime( $date . ' ' . $time, new \DateTimeZone( $timezone ) ); | |
| 374 | + } catch ( \Exception $e ) { | |
| 375 | + return 0; | |
| 376 | + } | |
| 377 | + | |
| 378 | + return $datetime->getTimestamp(); | |
| 300 | 379 | } |
| 301 | 380 | } |