PluginProbe
Timetics – Appointment Booking Calendar & Scheduling / trunk
Timetics – Appointment Booking Calendar & Scheduling vtrunk
1.0.61 1.0.60 1.0.59 1.0.58 1.0.57 1.0.56 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.19 1.0.2 1.0.20 1.0.21 1.0.22 1.0.23 1.0.24 All 62 releases
timetics / core / admin / notification-flow-guard.php

notification-flow-guard.php in Timetics – Appointment Booking Calendar & Scheduling trunk, at core/admin/notification-flow-guard.php

397 lines 12.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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