post_type ) { return; } if ( $new_status === $old_status || ! in_array( $new_status, self::DEAD_STATUSES, true ) ) { return; } self::purge( $post->ID ); } /** * Drop pending notifications when a booking is trashed or deleted. * * @param int $post_id * @return void */ public function on_delete( $post_id ) { if ( 'timetics-booking' !== get_post_type( $post_id ) ) { return; } self::purge( $post_id ); } /** * Remove both notification mechanisms queued for a booking: the SDK's * delayed flow resumes and the built-in reminder cron. * * @param int $booking_id * @return void */ private static function purge( $booking_id ) { self::clear_pending_flows( $booking_id ); Booking_Hooks::clear_reminders( $booking_id ); } /** * Re-validate a checkpoint against the live booking just before the SDK * replays it. Removing the transient makes the SDK's resume a no-op. * * @param string $flow_id * @param int $resume_time * @return void */ public function validate_pending_flow( $flow_id, $resume_time ) { $key = self::checkpoint_key( $flow_id, $resume_time ); $checkpoint = get_transient( $key ); if ( ! is_array( $checkpoint ) || empty( $checkpoint['hook_data'] ) ) { return; } $booking_id = self::checkpoint_booking_id( $checkpoint ); // Checkpoints saved before this fix carry no booking id. Nothing can be // re-checked, so leave the SDK's original behaviour untouched. if ( ! $booking_id ) { return; } if ( ! self::is_deliverable( $booking_id, $checkpoint['hook_data'] ) ) { delete_transient( $key ); } } /** * Drop every pending delayed flow belonging to a booking. * * Used when the booking is cancelled or deleted — the reminder is no longer * wanted at any time. * * @param int $booking_id * @return int Number of pending resumes cleared. */ public static function clear_pending_flows( $booking_id ) { $cleared = 0; foreach ( self::find_pending_flows( $booking_id ) as $pending ) { self::unschedule( $pending['args'] ); delete_transient( self::checkpoint_key( $pending['flow_id'], $pending['resume_time'] ) ); $cleared++; } return $cleared; } /** * Move every pending delayed flow of a booking to match its new meeting * time, refreshing the frozen payload at the same time. * * The offset between the original meeting timestamp and the original resume * time is preserved, so a "24 hours before" reminder stays 24 hours before * the new date. A resume that would land in the past is dropped rather than * fired immediately. * * @param int $booking_id * @param array $fresh_hook_data New payload from Notification::get_hook_data(). * @return int Number of pending resumes re-scheduled. */ public static function reschedule_pending_flows( $booking_id, $fresh_hook_data ) { if ( empty( $fresh_hook_data['meeting_date_timestamp'] ) ) { return 0; } $new_meeting_ts = (int) $fresh_hook_data['meeting_date_timestamp']; $moved = 0; foreach ( self::find_pending_flows( $booking_id ) as $pending ) { $checkpoint = $pending['checkpoint']; $old_hook = $checkpoint['hook_data']; $old_meet = isset( $old_hook['meeting_date_timestamp'] ) ? (int) $old_hook['meeting_date_timestamp'] : 0; // Always drop the stale event + checkpoint first. self::unschedule( $pending['args'] ); delete_transient( self::checkpoint_key( $pending['flow_id'], $pending['resume_time'] ) ); if ( ! $old_meet ) { continue; } $offset = (int) $pending['resume_time'] - $old_meet; $new_resume_time = $new_meeting_ts + $offset; if ( $new_resume_time <= time() ) { continue; } $new_flow_id = uniqid( 'flow_', true ); $checkpoint['hook_data'] = array_merge( $old_hook, $fresh_hook_data ); $checkpoint['resume_after'] = $new_resume_time; $checkpoint['_flow_id'] = $new_flow_id; $checkpoint['_resume_time'] = $new_resume_time; $checkpoint['_saved_at'] = time(); self::save_checkpoint( $new_flow_id, $new_resume_time, $checkpoint ); wp_schedule_single_event( $new_resume_time, self::RESUME_HOOK, array( 'flow_id' => $new_flow_id, 'resume_time' => $new_resume_time, ) ); $moved++; } return $moved; } /** * Collect the scheduled resume events that belong to a booking. * * The SDK does not index its cron events by booking, so the cron array is * walked and each checkpoint inspected. Cancels and reschedules are rare * enough that the cost is irrelevant. * * @param int $booking_id * @return array */ private static function find_pending_flows( $booking_id ) { $booking_id = (int) $booking_id; $found = array(); if ( ! $booking_id ) { return $found; } $cron = _get_cron_array(); if ( ! is_array( $cron ) ) { return $found; } foreach ( $cron as $events ) { if ( ! is_array( $events ) || empty( $events[ self::RESUME_HOOK ] ) ) { continue; } foreach ( $events[ self::RESUME_HOOK ] as $event ) { $args = isset( $event['args'] ) ? $event['args'] : array(); if ( ! isset( $args['flow_id'], $args['resume_time'] ) ) { continue; } $checkpoint = get_transient( self::checkpoint_key( $args['flow_id'], $args['resume_time'] ) ); if ( ! is_array( $checkpoint ) || empty( $checkpoint['hook_data'] ) ) { continue; } if ( self::checkpoint_booking_id( $checkpoint ) !== $booking_id ) { continue; } $found[] = array( 'flow_id' => $args['flow_id'], 'resume_time' => (int) $args['resume_time'], 'args' => $args, 'checkpoint' => $checkpoint, ); } } return $found; } /** * Whether a booking should still receive a delayed notification. * * @param int $booking_id * @param array $hook_data Frozen payload from the checkpoint. * @return bool */ private static function is_deliverable( $booking_id, $hook_data ) { $post = get_post( $booking_id ); if ( ! $post || 'timetics-booking' !== $post->post_type ) { return false; } if ( in_array( $post->post_status, self::DEAD_STATUSES, true ) ) { return false; } // A payload frozen before a reschedule quotes the old date/time. If the // proactive re-schedule did not run for some reason, suppress rather // than send wrong details. if ( ! empty( $hook_data['meeting_date_timestamp'] ) ) { $booking = new Booking( $booking_id ); $current = Notification::get_booking_timestamp( $booking ); if ( $current && (int) $current !== (int) $hook_data['meeting_date_timestamp'] ) { return false; } } return true; } /** * Read the booking id out of a checkpoint payload. * * `post_id` is the key the SDK itself looks for; `booking_id` is kept as an * explicit alias for readability in flow templates. * * @param array $checkpoint * @return int */ private static function checkpoint_booking_id( $checkpoint ) { $hook_data = isset( $checkpoint['hook_data'] ) ? $checkpoint['hook_data'] : array(); if ( ! empty( $hook_data['post_id'] ) ) { return (int) $hook_data['post_id']; } if ( ! empty( $hook_data['booking_id'] ) ) { return (int) $hook_data['booking_id']; } return 0; } /** * Build the SDK's checkpoint transient key. * * @param string $flow_id * @param int $resume_time * @return string */ private static function checkpoint_key( $flow_id, $resume_time ) { return sprintf( 'flow_checkpoint_%s_%s', $flow_id, $resume_time ); } /** * Persist a checkpoint using the SDK's expiry rules. * * @param string $flow_id * @param int $resume_time * @param array $checkpoint * @return void */ private static function save_checkpoint( $flow_id, $resume_time, $checkpoint ) { $expiration = max( HOUR_IN_SECONDS, min( $resume_time - time() + DAY_IN_SECONDS, 30 * DAY_IN_SECONDS ) ); set_transient( self::checkpoint_key( $flow_id, $resume_time ), $checkpoint, $expiration ); } /** * Remove a scheduled resume event. * * WP hashes cron args, so the array must be passed through exactly as the * SDK registered it. * * @param array $args * @return void */ private static function unschedule( $args ) { $timestamp = wp_next_scheduled( self::RESUME_HOOK, $args ); while ( $timestamp ) { wp_unschedule_event( $timestamp, self::RESUME_HOOK, $args ); $next = wp_next_scheduled( self::RESUME_HOOK, $args ); if ( $next === $timestamp ) { break; } $timestamp = $next; } } }