| 1 |
<?php |
| 2 |
/** |
| 3 |
* Notification_Flow_Guard — keeps delayed automation flows in sync with the |
| 4 |
* booking they were triggered for. |
| 5 |
* |
| 6 |
* The email-notification-sdk implements "wait N hours before the meeting" by |
| 7 |
* scheduling a `tt_resume_flow_after_delay` cron event and freezing the whole |
| 8 |
* trigger payload into a `flow_checkpoint_{flow_id}_{resume_time}` transient. |
| 9 |
* When the event fires, the SDK replays that frozen payload without ever |
| 10 |
* looking at the booking again, so a booking that was cancelled, deleted or |
| 11 |
* moved after the flow started still gets its reminder — with the old details. |
| 12 |
* |
| 13 |
* This class closes that gap from the plugin side: |
| 14 |
* |
| 15 |
* 1. Proactively, when a booking is cancelled/deleted the pending resume is |
| 16 |
* unscheduled and its checkpoint removed; when a booking is rescheduled the |
| 17 |
* pending resume is re-scheduled against the new meeting time with a |
| 18 |
* refreshed payload, so the reminder survives but quotes the right time. |
| 19 |
* 2. Defensively, on `tt_resume_flow_after_delay` at priority 1 — before the |
| 20 |
* SDK's own priority 10 callback — the checkpoint is re-validated against |
| 21 |
* the live booking. Deleting the transient there is enough to stop the SDK, |
| 22 |
* because `resume_flow_callback()` bails when the checkpoint is missing. |
| 23 |
* |
| 24 |
* @package Timetics |
| 25 |
*/ |
| 26 |
|
| 27 |
namespace Timetics\Core\Admin; |
| 28 |
|
| 29 |
use Timetics\Core\Bookings\Booking; |
| 30 |
use Timetics\Core\Bookings\Hooks as Booking_Hooks; |
| 31 |
use Timetics\Utils\Singleton; |
| 32 |
|
| 33 |
defined( 'ABSPATH' ) || exit; |
| 34 |
|
| 35 |
class Notification_Flow_Guard { |
| 36 |
|
| 37 |
use Singleton; |
| 38 |
|
| 39 |
/** |
| 40 |
* Cron hook the SDK schedules delayed flow resumes on. |
| 41 |
* |
| 42 |
* `tt` is the `general_prefix` Timetics registers the SDK with. |
| 43 |
*/ |
| 44 |
const RESUME_HOOK = 'tt_resume_flow_after_delay'; |
| 45 |
|
| 46 |
/** |
| 47 |
* Booking statuses that must never receive a delayed notification. |
| 48 |
* |
| 49 |
* @var string[] |
| 50 |
*/ |
| 51 |
const DEAD_STATUSES = array( 'cancel', 'cancelled', 'failed', 'trash' ); |
| 52 |
|
| 53 |
/** |
| 54 |
* Register hooks. |
| 55 |
* |
| 56 |
* @return void |
| 57 |
*/ |
| 58 |
public function init() { |
| 59 |
add_action( self::RESUME_HOOK, array( $this, 'validate_pending_flow' ), 1, 2 ); |
| 60 |
|
| 61 |
// Catching the status transition instead of each cancel call site means |
| 62 |
// every route into a dead status is covered — REST, admin, WooCommerce |
| 63 |
// order sync, failed payments — with one registration. |
| 64 |
add_action( 'transition_post_status', array( $this, 'on_status_change' ), 10, 3 ); |
| 65 |
add_action( 'before_delete_post', array( $this, 'on_delete' ), 10, 1 ); |
| 66 |
add_action( 'wp_trash_post', array( $this, 'on_delete' ), 10, 1 ); |
| 67 |
} |
| 68 |
|
| 69 |
/** |
| 70 |
* Drop pending notifications when a booking moves into a dead status. |
| 71 |
* |
| 72 |
* @param string $new_status |
| 73 |
* @param string $old_status |
| 74 |
* @param \WP_Post $post |
| 75 |
* @return void |
| 76 |
*/ |
| 77 |
public function on_status_change( $new_status, $old_status, $post ) { |
| 78 |
if ( ! $post instanceof \WP_Post || 'timetics-booking' !== $post->post_type ) { |
| 79 |
return; |
| 80 |
} |
| 81 |
|
| 82 |
if ( $new_status === $old_status || ! in_array( $new_status, self::DEAD_STATUSES, true ) ) { |
| 83 |
return; |
| 84 |
} |
| 85 |
|
| 86 |
self::purge( $post->ID ); |
| 87 |
} |
| 88 |
|
| 89 |
/** |
| 90 |
* Drop pending notifications when a booking is trashed or deleted. |
| 91 |
* |
| 92 |
* @param int $post_id |
| 93 |
* @return void |
| 94 |
*/ |
| 95 |
public function on_delete( $post_id ) { |
| 96 |
if ( 'timetics-booking' !== get_post_type( $post_id ) ) { |
| 97 |
return; |
| 98 |
} |
| 99 |
|
| 100 |
self::purge( $post_id ); |
| 101 |
} |
| 102 |
|
| 103 |
/** |
| 104 |
* Remove both notification mechanisms queued for a booking: the SDK's |
| 105 |
* delayed flow resumes and the built-in reminder cron. |
| 106 |
* |
| 107 |
* @param int $booking_id |
| 108 |
* @return void |
| 109 |
*/ |
| 110 |
private static function purge( $booking_id ) { |
| 111 |
self::clear_pending_flows( $booking_id ); |
| 112 |
Booking_Hooks::clear_reminders( $booking_id ); |
| 113 |
} |
| 114 |
|
| 115 |
/** |
| 116 |
* Re-validate a checkpoint against the live booking just before the SDK |
| 117 |
* replays it. Removing the transient makes the SDK's resume a no-op. |
| 118 |
* |
| 119 |
* @param string $flow_id |
| 120 |
* @param int $resume_time |
| 121 |
* @return void |
| 122 |
*/ |
| 123 |
public function validate_pending_flow( $flow_id, $resume_time ) { |
| 124 |
$key = self::checkpoint_key( $flow_id, $resume_time ); |
| 125 |
$checkpoint = get_transient( $key ); |
| 126 |
|
| 127 |
if ( ! is_array( $checkpoint ) || empty( $checkpoint['hook_data'] ) ) { |
| 128 |
return; |
| 129 |
} |
| 130 |
|
| 131 |
$booking_id = self::checkpoint_booking_id( $checkpoint ); |
| 132 |
|
| 133 |
// Checkpoints saved before this fix carry no booking id. Nothing can be |
| 134 |
// re-checked, so leave the SDK's original behaviour untouched. |
| 135 |
if ( ! $booking_id ) { |
| 136 |
return; |
| 137 |
} |
| 138 |
|
| 139 |
if ( ! self::is_deliverable( $booking_id, $checkpoint['hook_data'] ) ) { |
| 140 |
delete_transient( $key ); |
| 141 |
} |
| 142 |
} |
| 143 |
|
| 144 |
/** |
| 145 |
* Drop every pending delayed flow belonging to a booking. |
| 146 |
* |
| 147 |
* Used when the booking is cancelled or deleted — the reminder is no longer |
| 148 |
* wanted at any time. |
| 149 |
* |
| 150 |
* @param int $booking_id |
| 151 |
* @return int Number of pending resumes cleared. |
| 152 |
*/ |
| 153 |
public static function clear_pending_flows( $booking_id ) { |
| 154 |
$cleared = 0; |
| 155 |
|
| 156 |
foreach ( self::find_pending_flows( $booking_id ) as $pending ) { |
| 157 |
self::unschedule( $pending['args'] ); |
| 158 |
delete_transient( self::checkpoint_key( $pending['flow_id'], $pending['resume_time'] ) ); |
| 159 |
$cleared++; |
| 160 |
} |
| 161 |
|
| 162 |
return $cleared; |
| 163 |
} |
| 164 |
|
| 165 |
/** |
| 166 |
* Move every pending delayed flow of a booking to match its new meeting |
| 167 |
* time, refreshing the frozen payload at the same time. |
| 168 |
* |
| 169 |
* The offset between the original meeting timestamp and the original resume |
| 170 |
* time is preserved, so a "24 hours before" reminder stays 24 hours before |
| 171 |
* the new date. A resume that would land in the past is dropped rather than |
| 172 |
* fired immediately. |
| 173 |
* |
| 174 |
* @param int $booking_id |
| 175 |
* @param array $fresh_hook_data New payload from Notification::get_hook_data(). |
| 176 |
* @return int Number of pending resumes re-scheduled. |
| 177 |
*/ |
| 178 |
public static function reschedule_pending_flows( $booking_id, $fresh_hook_data ) { |
| 179 |
if ( empty( $fresh_hook_data['meeting_date_timestamp'] ) ) { |
| 180 |
return 0; |
| 181 |
} |
| 182 |
|
| 183 |
$new_meeting_ts = (int) $fresh_hook_data['meeting_date_timestamp']; |
| 184 |
$moved = 0; |
| 185 |
|
| 186 |
foreach ( self::find_pending_flows( $booking_id ) as $pending ) { |
| 187 |
$checkpoint = $pending['checkpoint']; |
| 188 |
$old_hook = $checkpoint['hook_data']; |
| 189 |
$old_meet = isset( $old_hook['meeting_date_timestamp'] ) ? (int) $old_hook['meeting_date_timestamp'] : 0; |
| 190 |
|
| 191 |
// Always drop the stale event + checkpoint first. |
| 192 |
self::unschedule( $pending['args'] ); |
| 193 |
delete_transient( self::checkpoint_key( $pending['flow_id'], $pending['resume_time'] ) ); |
| 194 |
|
| 195 |
if ( ! $old_meet ) { |
| 196 |
continue; |
| 197 |
} |
| 198 |
|
| 199 |
$offset = (int) $pending['resume_time'] - $old_meet; |
| 200 |
$new_resume_time = $new_meeting_ts + $offset; |
| 201 |
|
| 202 |
if ( $new_resume_time <= time() ) { |
| 203 |
continue; |
| 204 |
} |
| 205 |
|
| 206 |
$new_flow_id = uniqid( 'flow_', true ); |
| 207 |
|
| 208 |
$checkpoint['hook_data'] = array_merge( $old_hook, $fresh_hook_data ); |
| 209 |
$checkpoint['resume_after'] = $new_resume_time; |
| 210 |
$checkpoint['_flow_id'] = $new_flow_id; |
| 211 |
$checkpoint['_resume_time'] = $new_resume_time; |
| 212 |
$checkpoint['_saved_at'] = time(); |
| 213 |
|
| 214 |
self::save_checkpoint( $new_flow_id, $new_resume_time, $checkpoint ); |
| 215 |
|
| 216 |
wp_schedule_single_event( |
| 217 |
$new_resume_time, |
| 218 |
self::RESUME_HOOK, |
| 219 |
array( |
| 220 |
'flow_id' => $new_flow_id, |
| 221 |
'resume_time' => $new_resume_time, |
| 222 |
) |
| 223 |
); |
| 224 |
|
| 225 |
$moved++; |
| 226 |
} |
| 227 |
|
| 228 |
return $moved; |
| 229 |
} |
| 230 |
|
| 231 |
/** |
| 232 |
* Collect the scheduled resume events that belong to a booking. |
| 233 |
* |
| 234 |
* The SDK does not index its cron events by booking, so the cron array is |
| 235 |
* walked and each checkpoint inspected. Cancels and reschedules are rare |
| 236 |
* enough that the cost is irrelevant. |
| 237 |
* |
| 238 |
* @param int $booking_id |
| 239 |
* @return array<int, array{flow_id:string,resume_time:int,args:array,checkpoint:array}> |
| 240 |
*/ |
| 241 |
private static function find_pending_flows( $booking_id ) { |
| 242 |
$booking_id = (int) $booking_id; |
| 243 |
$found = array(); |
| 244 |
|
| 245 |
if ( ! $booking_id ) { |
| 246 |
return $found; |
| 247 |
} |
| 248 |
|
| 249 |
$cron = _get_cron_array(); |
| 250 |
|
| 251 |
if ( ! is_array( $cron ) ) { |
| 252 |
return $found; |
| 253 |
} |
| 254 |
|
| 255 |
foreach ( $cron as $events ) { |
| 256 |
if ( ! is_array( $events ) || empty( $events[ self::RESUME_HOOK ] ) ) { |
| 257 |
continue; |
| 258 |
} |
| 259 |
|
| 260 |
foreach ( $events[ self::RESUME_HOOK ] as $event ) { |
| 261 |
$args = isset( $event['args'] ) ? $event['args'] : array(); |
| 262 |
|
| 263 |
if ( ! isset( $args['flow_id'], $args['resume_time'] ) ) { |
| 264 |
continue; |
| 265 |
} |
| 266 |
|
| 267 |
$checkpoint = get_transient( self::checkpoint_key( $args['flow_id'], $args['resume_time'] ) ); |
| 268 |
|
| 269 |
if ( ! is_array( $checkpoint ) || empty( $checkpoint['hook_data'] ) ) { |
| 270 |
continue; |
| 271 |
} |
| 272 |
|
| 273 |
if ( self::checkpoint_booking_id( $checkpoint ) !== $booking_id ) { |
| 274 |
continue; |
| 275 |
} |
| 276 |
|
| 277 |
$found[] = array( |
| 278 |
'flow_id' => $args['flow_id'], |
| 279 |
'resume_time' => (int) $args['resume_time'], |
| 280 |
'args' => $args, |
| 281 |
'checkpoint' => $checkpoint, |
| 282 |
); |
| 283 |
} |
| 284 |
} |
| 285 |
|
| 286 |
return $found; |
| 287 |
} |
| 288 |
|
| 289 |
/** |
| 290 |
* Whether a booking should still receive a delayed notification. |
| 291 |
* |
| 292 |
* @param int $booking_id |
| 293 |
* @param array $hook_data Frozen payload from the checkpoint. |
| 294 |
* @return bool |
| 295 |
*/ |
| 296 |
private static function is_deliverable( $booking_id, $hook_data ) { |
| 297 |
$post = get_post( $booking_id ); |
| 298 |
|
| 299 |
if ( ! $post || 'timetics-booking' !== $post->post_type ) { |
| 300 |
return false; |
| 301 |
} |
| 302 |
|
| 303 |
if ( in_array( $post->post_status, self::DEAD_STATUSES, true ) ) { |
| 304 |
return false; |
| 305 |
} |
| 306 |
|
| 307 |
// A payload frozen before a reschedule quotes the old date/time. If the |
| 308 |
// proactive re-schedule did not run for some reason, suppress rather |
| 309 |
// than send wrong details. |
| 310 |
if ( ! empty( $hook_data['meeting_date_timestamp'] ) ) { |
| 311 |
$booking = new Booking( $booking_id ); |
| 312 |
$current = Notification::get_booking_timestamp( $booking ); |
| 313 |
|
| 314 |
if ( $current && (int) $current !== (int) $hook_data['meeting_date_timestamp'] ) { |
| 315 |
return false; |
| 316 |
} |
| 317 |
} |
| 318 |
|
| 319 |
return true; |
| 320 |
} |
| 321 |
|
| 322 |
/** |
| 323 |
* Read the booking id out of a checkpoint payload. |
| 324 |
* |
| 325 |
* `post_id` is the key the SDK itself looks for; `booking_id` is kept as an |
| 326 |
* explicit alias for readability in flow templates. |
| 327 |
* |
| 328 |
* @param array $checkpoint |
| 329 |
* @return int |
| 330 |
*/ |
| 331 |
private static function checkpoint_booking_id( $checkpoint ) { |
| 332 |
$hook_data = isset( $checkpoint['hook_data'] ) ? $checkpoint['hook_data'] : array(); |
| 333 |
|
| 334 |
if ( ! empty( $hook_data['post_id'] ) ) { |
| 335 |
return (int) $hook_data['post_id']; |
| 336 |
} |
| 337 |
|
| 338 |
if ( ! empty( $hook_data['booking_id'] ) ) { |
| 339 |
return (int) $hook_data['booking_id']; |
| 340 |
} |
| 341 |
|
| 342 |
return 0; |
| 343 |
} |
| 344 |
|
| 345 |
/** |
| 346 |
* Build the SDK's checkpoint transient key. |
| 347 |
* |
| 348 |
* @param string $flow_id |
| 349 |
* @param int $resume_time |
| 350 |
* @return string |
| 351 |
*/ |
| 352 |
private static function checkpoint_key( $flow_id, $resume_time ) { |
| 353 |
return sprintf( 'flow_checkpoint_%s_%s', $flow_id, $resume_time ); |
| 354 |
} |
| 355 |
|
| 356 |
/** |
| 357 |
* Persist a checkpoint using the SDK's expiry rules. |
| 358 |
* |
| 359 |
* @param string $flow_id |
| 360 |
* @param int $resume_time |
| 361 |
* @param array $checkpoint |
| 362 |
* @return void |
| 363 |
*/ |
| 364 |
private static function save_checkpoint( $flow_id, $resume_time, $checkpoint ) { |
| 365 |
$expiration = max( |
| 366 |
HOUR_IN_SECONDS, |
| 367 |
min( $resume_time - time() + DAY_IN_SECONDS, 30 * DAY_IN_SECONDS ) |
| 368 |
); |
| 369 |
|
| 370 |
set_transient( self::checkpoint_key( $flow_id, $resume_time ), $checkpoint, $expiration ); |
| 371 |
} |
| 372 |
|
| 373 |
/** |
| 374 |
* Remove a scheduled resume event. |
| 375 |
* |
| 376 |
* WP hashes cron args, so the array must be passed through exactly as the |
| 377 |
* SDK registered it. |
| 378 |
* |
| 379 |
* @param array $args |
| 380 |
* @return void |
| 381 |
*/ |
| 382 |
private static function unschedule( $args ) { |
| 383 |
$timestamp = wp_next_scheduled( self::RESUME_HOOK, $args ); |
| 384 |
|
| 385 |
while ( $timestamp ) { |
| 386 |
wp_unschedule_event( $timestamp, self::RESUME_HOOK, $args ); |
| 387 |
$next = wp_next_scheduled( self::RESUME_HOOK, $args ); |
| 388 |
|
| 389 |
if ( $next === $timestamp ) { |
| 390 |
break; |
| 391 |
} |
| 392 |
|
| 393 |
$timestamp = $next; |
| 394 |
} |
| 395 |
} |
| 396 |
} |
| 397 |
|