|
| 1 | +<?php |
| 2 | + |
| 3 | +namespace WPGraphQL\Webhooks\Events; |
| 4 | + |
| 5 | +use GraphQLRelay\Relay; |
| 6 | + |
| 7 | +/** |
| 8 | + * Handles Smart Cache events and consolidates them before triggering webhooks |
| 9 | + */ |
| 10 | +class SmartCacheEventHandler { |
| 11 | + /** |
| 12 | + * Stores Smart Cache events temporarily to consolidate them |
| 13 | + * @var array |
| 14 | + */ |
| 15 | + private array $buffer = []; |
| 16 | + |
| 17 | + /** |
| 18 | + * Timer to process buffered Smart Cache events |
| 19 | + * @var int|false |
| 20 | + */ |
| 21 | + private $timer = false; |
| 22 | + |
| 23 | + /** |
| 24 | + * Callback to trigger webhooks |
| 25 | + * @var callable |
| 26 | + */ |
| 27 | + private $webhook_trigger_callback; |
| 28 | + |
| 29 | + /** |
| 30 | + * Event action mapping |
| 31 | + * @var array |
| 32 | + */ |
| 33 | + private const EVENT_MAP = [ |
| 34 | + 'create' => 'smart_cache_created', |
| 35 | + 'update' => 'smart_cache_updated', |
| 36 | + 'delete' => 'smart_cache_deleted', |
| 37 | + ]; |
| 38 | + |
| 39 | + /** |
| 40 | + * Constructor |
| 41 | + * |
| 42 | + * @param callable $webhook_trigger_callback Callback to trigger webhooks |
| 43 | + */ |
| 44 | + public function __construct( callable $webhook_trigger_callback ) { |
| 45 | + $this->webhook_trigger_callback = $webhook_trigger_callback; |
| 46 | + } |
| 47 | + |
| 48 | + /** |
| 49 | + * Initialize hooks |
| 50 | + */ |
| 51 | + public function init() { |
| 52 | + add_action( 'graphql_purge', [ $this, 'handle_graphql_purge' ], 10, 3 ); |
| 53 | + add_action( 'wpgraphql_cache_purge_nodes', [ $this, 'handle_cache_purge_nodes' ], 10, 2 ); |
| 54 | + add_action( 'shutdown', [ $this, 'process_buffer' ] ); |
| 55 | + } |
| 56 | + |
| 57 | + /** |
| 58 | + * Handle graphql_purge event |
| 59 | + * |
| 60 | + * @param string $key Cache key being purged |
| 61 | + * @param string $event Event type (e.g., post_UPDATE) |
| 62 | + * @param string $graphql_endpoint GraphQL endpoint URL |
| 63 | + */ |
| 64 | + public function handle_graphql_purge( $key, $event, $graphql_endpoint ) { |
| 65 | + $parsed = $this->parse_event( $event ); |
| 66 | + if ( ! $parsed ) { |
| 67 | + return; |
| 68 | + } |
| 69 | + |
| 70 | + $this->buffer_event( $key, $parsed['post_type'], $parsed['action'], $graphql_endpoint ); |
| 71 | + } |
| 72 | + |
| 73 | + /** |
| 74 | + * Handle cache purge nodes event |
| 75 | + * |
| 76 | + * @param string $key Cache key |
| 77 | + * @param array $nodes Nodes being purged |
| 78 | + */ |
| 79 | + public function handle_cache_purge_nodes( $key, $nodes ) { |
| 80 | + $payload = [ |
| 81 | + 'cache_key' => $key, |
| 82 | + 'nodes' => $nodes, |
| 83 | + 'nodes_count' => count( $nodes ), |
| 84 | + 'timestamp' => current_time( 'c' ), |
| 85 | + ]; |
| 86 | + |
| 87 | + call_user_func( $this->webhook_trigger_callback, 'smart_cache_nodes_purged', $payload ); |
| 88 | + } |
| 89 | + |
| 90 | + /** |
| 91 | + * Parse event string into components |
| 92 | + * |
| 93 | + * @param string $event Event string (e.g., post_UPDATE) |
| 94 | + * @return array|null Array with 'post_type' and 'action' keys, or null if invalid |
| 95 | + */ |
| 96 | + private function parse_event( string $event ): ?array { |
| 97 | + $parts = explode( '_', $event ); |
| 98 | + if ( count( $parts ) !== 2 ) { |
| 99 | + return null; |
| 100 | + } |
| 101 | + |
| 102 | + return [ |
| 103 | + 'post_type' => $parts[0], |
| 104 | + 'action' => strtolower( $parts[1] ), |
| 105 | + ]; |
| 106 | + } |
| 107 | + |
| 108 | + /** |
| 109 | + * Buffer an event for consolidated processing |
| 110 | + * |
| 111 | + * @param string $key Cache key |
| 112 | + * @param string $post_type Post type |
| 113 | + * @param string $action Action (create, update, delete) |
| 114 | + * @param string $graphql_endpoint GraphQL endpoint URL |
| 115 | + */ |
| 116 | + private function buffer_event( string $key, string $post_type, string $action, string $graphql_endpoint ) { |
| 117 | + $buffer_key = "{$post_type}_{$action}"; |
| 118 | + |
| 119 | + if ( ! isset( $this->buffer[ $buffer_key ] ) ) { |
| 120 | + $this->buffer[ $buffer_key ] = [ |
| 121 | + 'post_type' => $post_type, |
| 122 | + 'action' => $action, |
| 123 | + 'graphql_endpoint' => $graphql_endpoint, |
| 124 | + 'keys' => [], |
| 125 | + 'objects' => [], |
| 126 | + ]; |
| 127 | + } |
| 128 | + |
| 129 | + $key_info = $this->analyze_cache_key( $key ); |
| 130 | + |
| 131 | + // Extract object information if it's a Relay ID |
| 132 | + if ( $key_info['type'] === 'relay_id' && isset( $key_info['decoded'] ) ) { |
| 133 | + $this->add_object_to_buffer( $buffer_key, $key_info['decoded'], $action ); |
| 134 | + } |
| 135 | + |
| 136 | + $this->buffer[ $buffer_key ]['keys'][] = $key_info; |
| 137 | + $this->schedule_processing(); |
| 138 | + } |
| 139 | + |
| 140 | + /** |
| 141 | + * Analyze a cache key and determine its type |
| 142 | + * |
| 143 | + * @param string $key Cache key |
| 144 | + * @return array Key information with 'key', 'type', and optionally 'decoded' |
| 145 | + */ |
| 146 | + private function analyze_cache_key( string $key ): array { |
| 147 | + $info = [ |
| 148 | + 'key' => $key, |
| 149 | + 'type' => $this->classify_key_type( $key ), |
| 150 | + ]; |
| 151 | + |
| 152 | + // Try to decode Relay IDs |
| 153 | + if ( $info['type'] === 'relay_id' && class_exists( Relay::class ) ) { |
| 154 | + try { |
| 155 | + $decoded = Relay::fromGlobalId( $key ); |
| 156 | + if ( ! empty( $decoded['type'] ) && ! empty( $decoded['id'] ) ) { |
| 157 | + $info['decoded'] = $decoded; |
| 158 | + } |
| 159 | + } catch ( \Exception $e ) { |
| 160 | + // Not a valid Relay ID after all |
| 161 | + $info['type'] = 'unknown'; |
| 162 | + } |
| 163 | + } |
| 164 | + |
| 165 | + return $info; |
| 166 | + } |
| 167 | + |
| 168 | + /** |
| 169 | + * Classify the type of cache key |
| 170 | + * |
| 171 | + * @param string $key Cache key |
| 172 | + * @return string Key type: 'list', 'skipped', 'relay_id', or 'unknown' |
| 173 | + */ |
| 174 | + private function classify_key_type( string $key ): string { |
| 175 | + if ( strpos( $key, 'list:' ) === 0 ) { |
| 176 | + return 'list'; |
| 177 | + } |
| 178 | + |
| 179 | + if ( strpos( $key, 'skipped:' ) === 0 ) { |
| 180 | + return 'skipped'; |
| 181 | + } |
| 182 | + |
| 183 | + // Assume it might be a Relay ID if it looks like base64 |
| 184 | + if ( preg_match( '/^[A-Za-z0-9+\/]+=*$/', $key ) ) { |
| 185 | + return 'relay_id'; |
| 186 | + } |
| 187 | + |
| 188 | + return 'unknown'; |
| 189 | + } |
| 190 | + |
| 191 | + /** |
| 192 | + * Add object data to buffer |
| 193 | + * |
| 194 | + * @param string $buffer_key Buffer key |
| 195 | + * @param array $decoded Decoded Relay ID data |
| 196 | + * @param string $action Action being performed |
| 197 | + */ |
| 198 | + private function add_object_to_buffer( string $buffer_key, array $decoded, string $action ) { |
| 199 | + $object_key = "{$decoded['type']}:{$decoded['id']}"; |
| 200 | + |
| 201 | + if ( isset( $this->buffer[ $buffer_key ]['objects'][ $object_key ] ) ) { |
| 202 | + return; // Already added |
| 203 | + } |
| 204 | + |
| 205 | + $object_data = $this->fetch_object_data( $decoded['type'], (int) $decoded['id'], $action ); |
| 206 | + if ( $object_data ) { |
| 207 | + $this->buffer[ $buffer_key ]['objects'][ $object_key ] = $object_data; |
| 208 | + } |
| 209 | + } |
| 210 | + |
| 211 | + /** |
| 212 | + * Fetch object data based on type and ID |
| 213 | + * |
| 214 | + * @param string $type Object type |
| 215 | + * @param int $id Object ID |
| 216 | + * @param string $action The action being performed |
| 217 | + * @return array|null Object data or null if not found |
| 218 | + */ |
| 219 | + private function fetch_object_data( string $type, int $id, string $action ): ?array { |
| 220 | + // For delete actions, just return minimal data |
| 221 | + if ( $action === 'delete' ) { |
| 222 | + return [ |
| 223 | + 'id' => $id, |
| 224 | + 'type' => $type, |
| 225 | + 'deleted' => true, |
| 226 | + ]; |
| 227 | + } |
| 228 | + |
| 229 | + $fetchers = [ |
| 230 | + 'post' => [ $this, 'fetch_post_data' ], |
| 231 | + 'term' => [ $this, 'fetch_term_data' ], |
| 232 | + 'user' => [ $this, 'fetch_user_data' ], |
| 233 | + ]; |
| 234 | + |
| 235 | + if ( isset( $fetchers[ $type ] ) ) { |
| 236 | + return call_user_func( $fetchers[ $type ], $id ); |
| 237 | + } |
| 238 | + |
| 239 | + return null; |
| 240 | + } |
| 241 | + |
| 242 | + /** |
| 243 | + * Fetch post data |
| 244 | + * |
| 245 | + * @param int $id Post ID |
| 246 | + * @return array|null |
| 247 | + */ |
| 248 | + private function fetch_post_data( int $id ): ?array { |
| 249 | + $post = get_post( $id ); |
| 250 | + if ( ! $post ) { |
| 251 | + return null; |
| 252 | + } |
| 253 | + |
| 254 | + return [ |
| 255 | + 'id' => $post->ID, |
| 256 | + 'title' => $post->post_title, |
| 257 | + 'status' => $post->post_status, |
| 258 | + 'type' => $post->post_type, |
| 259 | + 'url' => get_permalink( $post ), |
| 260 | + ]; |
| 261 | + } |
| 262 | + |
| 263 | + /** |
| 264 | + * Fetch term data |
| 265 | + * |
| 266 | + * @param int $id Term ID |
| 267 | + * @return array|null |
| 268 | + */ |
| 269 | + private function fetch_term_data( int $id ): ?array { |
| 270 | + $term = get_term( $id ); |
| 271 | + if ( ! $term || is_wp_error( $term ) ) { |
| 272 | + return null; |
| 273 | + } |
| 274 | + |
| 275 | + return [ |
| 276 | + 'id' => $term->term_id, |
| 277 | + 'name' => $term->name, |
| 278 | + 'taxonomy' => $term->taxonomy, |
| 279 | + 'url' => get_term_link( $term ), |
| 280 | + ]; |
| 281 | + } |
| 282 | + |
| 283 | + /** |
| 284 | + * Fetch user data |
| 285 | + * |
| 286 | + * @param int $id User ID |
| 287 | + * @return array|null |
| 288 | + */ |
| 289 | + private function fetch_user_data( int $id ): ?array { |
| 290 | + $user = get_user_by( 'id', $id ); |
| 291 | + if ( ! $user ) { |
| 292 | + return null; |
| 293 | + } |
| 294 | + |
| 295 | + return [ |
| 296 | + 'id' => $user->ID, |
| 297 | + 'login' => $user->user_login, |
| 298 | + 'display_name' => $user->display_name, |
| 299 | + 'url' => get_author_posts_url( $user->ID ), |
| 300 | + ]; |
| 301 | + } |
| 302 | + |
| 303 | + /** |
| 304 | + * Schedule buffer processing |
| 305 | + */ |
| 306 | + private function schedule_processing() { |
| 307 | + if ( $this->timer !== false ) { |
| 308 | + return; // Already scheduled |
| 309 | + } |
| 310 | + |
| 311 | + $this->timer = wp_schedule_single_event( time() + 1, 'wpgraphql_webhooks_process_smart_cache' ); |
| 312 | + add_action( 'wpgraphql_webhooks_process_smart_cache', [ $this, 'process_buffer' ] ); |
| 313 | + } |
| 314 | + |
| 315 | + /** |
| 316 | + * Process the buffered events |
| 317 | + */ |
| 318 | + public function process_buffer() { |
| 319 | + if ( empty( $this->buffer ) ) { |
| 320 | + return; |
| 321 | + } |
| 322 | + |
| 323 | + foreach ( $this->buffer as $data ) { |
| 324 | + $webhook_event = self::EVENT_MAP[ $data['action'] ] ?? null; |
| 325 | + if ( ! $webhook_event ) { |
| 326 | + continue; |
| 327 | + } |
| 328 | + |
| 329 | + $payload = $this->build_payload( $data ); |
| 330 | + call_user_func( $this->webhook_trigger_callback, $webhook_event, $payload ); |
| 331 | + } |
| 332 | + |
| 333 | + $this->buffer = []; |
| 334 | + $this->timer = false; |
| 335 | + } |
| 336 | + |
| 337 | + /** |
| 338 | + * Build webhook payload from buffered data |
| 339 | + * |
| 340 | + * @param array $data Buffered event data |
| 341 | + * @return array |
| 342 | + */ |
| 343 | + private function build_payload( array $data ): array { |
| 344 | + return [ |
| 345 | + 'post_type' => $data['post_type'], |
| 346 | + 'action' => $data['action'], |
| 347 | + 'graphql_endpoint' => $data['graphql_endpoint'], |
| 348 | + 'timestamp' => current_time( 'c' ), |
| 349 | + 'cache_keys_purged' => count( $data['keys'] ), |
| 350 | + 'objects_affected' => array_values( $data['objects'] ), |
| 351 | + 'cache_key_summary' => $this->summarize_keys( $data['keys'] ), |
| 352 | + ]; |
| 353 | + } |
| 354 | + |
| 355 | + /** |
| 356 | + * Summarize cache keys by type |
| 357 | + * |
| 358 | + * @param array $keys Array of key information |
| 359 | + * @return array Summary with counts by type |
| 360 | + */ |
| 361 | + private function summarize_keys( array $keys ): array { |
| 362 | + $summary = array_fill_keys( [ 'relay_id', 'list', 'skipped', 'unknown' ], 0 ); |
| 363 | + |
| 364 | + foreach ( $keys as $key_info ) { |
| 365 | + $summary[ $key_info['type'] ]++; |
| 366 | + } |
| 367 | + |
| 368 | + return array_filter( $summary ); // Remove zeros |
| 369 | + } |
| 370 | +} |
0 commit comments