Skip to content

Commit b4f2497

Browse files
committed
feat: smart-cache integration
1 parent 3ff04a5 commit b4f2497

File tree

6 files changed

+692
-234
lines changed

6 files changed

+692
-234
lines changed
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
<?php
2+
namespace WPGraphQL\Webhooks\Events;
3+
4+
class SmartCacheEventMapper {
5+
6+
/**
7+
* Map WPGraphQL Smart Cache event names to internal webhook event keys.
8+
*
9+
* @var array<string, string>
10+
*/
11+
private static $event_map = [
12+
// Post Events (lowercase and uppercase variants)
13+
'post_create' => 'post_published',
14+
'post_update' => 'post_updated',
15+
'post_delete' => 'post_deleted',
16+
'post_updated' => 'post_updated',
17+
'post_deleted' => 'post_deleted',
18+
'post_CREATE' => 'post_published',
19+
'post_UPDATE' => 'post_updated',
20+
'post_DELETE' => 'post_deleted',
21+
'post_reassigned_to_user' => 'post_updated',
22+
'postmeta_changed (meta_key' => 'post_meta_change', // This will match partial string
23+
24+
// Term Events
25+
'term_created' => 'term_created',
26+
'term_updated' => 'term_updated',
27+
'term_saved' => 'term_updated',
28+
'term_deleted' => 'term_deleted',
29+
'term_CREATE' => 'term_created',
30+
'term_UPDATE' => 'term_updated',
31+
'term_DELETE' => 'term_deleted',
32+
'term_relationship_added' => 'term_assigned',
33+
'term_relationship_deleted' => 'term_unassigned',
34+
35+
// User Events
36+
'user_profile_updated' => 'user_updated',
37+
'user_meta_updated' => 'user_updated',
38+
'user_deleted' => 'user_deleted',
39+
'user_reassigned' => 'user_reassigned',
40+
'user_UPDATE' => 'user_updated',
41+
'user_DELETE' => 'user_deleted',
42+
43+
// Menu Events
44+
'updated_nav_menu' => 'menu_updated',
45+
'nav_menu_created' => 'menu_created',
46+
'set_nav_menu_location' => 'menu_updated',
47+
'menu_meta_updated' => 'menu_updated',
48+
'nav_menu_item_added' => 'menu_item_created',
49+
'update_menu_item' => 'menu_item_updated',
50+
'nav_menu_item_deleted' => 'menu_item_deleted',
51+
'menu_item_meta_changed' => 'menu_item_updated',
52+
53+
// Media Events
54+
'add_attachment' => 'media_uploaded',
55+
'attachment_edited' => 'media_updated',
56+
'attachment_deleted' => 'media_deleted',
57+
'media_UPDATE' => 'media_updated',
58+
'media_DELETE' => 'media_deleted',
59+
60+
// Comment Events
61+
'comment_transition' => 'comment_status',
62+
'comment_approved' => 'comment_inserted',
63+
'comment_UPDATE' => 'comment_status',
64+
'comment_DELETE' => 'comment_status',
65+
66+
// Cache Events
67+
'purge all' => 'cache_purged',
68+
69+
// Node type mappings (for handle_purge_nodes)
70+
'post' => 'post_updated',
71+
'term' => 'term_updated',
72+
'user' => 'user_updated',
73+
'comment' => 'comment_status',
74+
'mediaitem' => 'media_updated',
75+
'menu' => 'menu_updated',
76+
'menuitem' => 'menu_item_updated',
77+
];
78+
79+
/**
80+
* Get the mapped webhook event key for a given Smart Cache event.
81+
*
82+
* @param string $smart_cache_event
83+
* @return string|null Returns mapped event key or null if no mapping found.
84+
*/
85+
public static function mapEvent( string $smart_cache_event ): ?string {
86+
// First try direct lookup
87+
if ( isset( self::$event_map[ $smart_cache_event ] ) ) {
88+
return self::$event_map[ $smart_cache_event ];
89+
}
90+
91+
// Try lowercase version
92+
$lowercase_event = strtolower( $smart_cache_event );
93+
if ( isset( self::$event_map[ $lowercase_event ] ) ) {
94+
return self::$event_map[ $lowercase_event ];
95+
}
96+
97+
// Handle postmeta_changed partial match
98+
if ( strpos( $smart_cache_event, 'postmeta_changed (meta_key' ) === 0 ) {
99+
return 'post_meta_change';
100+
}
101+
102+
// Handle list: prefixed events (from purge method calls)
103+
if ( strpos( $smart_cache_event, 'list:' ) === 0 ) {
104+
$type = substr( $smart_cache_event, 5 );
105+
return self::mapEvent( $type ); // Recursive call to handle the type
106+
}
107+
108+
// Handle skipped: prefixed events
109+
if ( strpos( $smart_cache_event, 'skipped:' ) === 0 ) {
110+
$type = substr( $smart_cache_event, 8 );
111+
return self::mapEvent( $type ); // Recursive call to handle the type
112+
}
113+
114+
return null;
115+
}
116+
117+
/**
118+
* Get all mapped webhook events.
119+
*
120+
* @return array<string, string>
121+
*/
122+
public static function getMappedEvents(): array {
123+
return self::$event_map;
124+
}
125+
}
Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,256 @@
1+
<?php
2+
3+
namespace WPGraphQL\Webhooks\Events;
4+
5+
use GraphQLRelay\Relay;
6+
use WPGraphQL\Webhooks\Events\Interfaces\EventManager;
7+
use WPGraphQL\Webhooks\Repository\Interfaces\WebhookRepositoryInterface;
8+
use WPGraphQL\Webhooks\Handlers\Interfaces\Handler;
9+
10+
/**
11+
* Smart Cache Webhook Manager
12+
*
13+
* Listens to WPGraphQL Smart Cache purge events and triggers webhooks
14+
*/
15+
class SmartCacheWebhookManager implements EventManager {
16+
17+
private WebhookRepositoryInterface $repository;
18+
private Handler $handler;
19+
20+
public function __construct( WebhookRepositoryInterface $repository, Handler $handler ) {
21+
$this->repository = $repository;
22+
$this->handler = $handler;
23+
}
24+
25+
/**
26+
* Register Smart Cache purge hooks
27+
*/
28+
public function register_hooks(): void {
29+
add_action( 'wpgraphql_cache_purge_nodes', [ $this, 'handle_purge_nodes' ], 10, 2 );
30+
add_action( 'graphql_purge', [ $this, 'handle_graphql_purge' ], 10, 3 );
31+
}
32+
33+
/**
34+
* Handle node purge events from Smart Cache
35+
*/
36+
public function handle_purge_nodes( string $key, array $nodes ): void {
37+
error_log( "[Webhook] handle_purge_nodes - Key: $key, Node count: " . count( $nodes ) );
38+
39+
// Handle empty nodes array
40+
if ( empty( $nodes ) ) {
41+
error_log( "[Webhook] handle_purge_nodes - No nodes provided for key: $key" );
42+
return;
43+
}
44+
45+
$node_type = $nodes[0]['type'] ?? null;
46+
47+
if ( empty( $node_type ) ) {
48+
error_log( "[Webhook] handle_purge_nodes - No node type found in first node for key: $key" );
49+
return;
50+
}
51+
52+
$event = SmartCacheEventMapper::mapEvent( strtolower( $node_type ) );
53+
54+
if ( $event === null ) {
55+
error_log( "[Webhook] handle_purge_nodes - No mapped event found for node type: $node_type" );
56+
return;
57+
}
58+
59+
error_log( "[Webhook] handle_purge_nodes - Mapped '$node_type' to event: $event" );
60+
61+
$path = $this->get_path_from_key( $key );
62+
$smart_cache_keys = $this->get_smart_cache_keys( $nodes );
63+
64+
$this->trigger_webhooks( $event, [
65+
'key' => $key,
66+
'path' => $path,
67+
'nodes' => $nodes,
68+
'smart_cache_keys' => $smart_cache_keys
69+
] );
70+
}
71+
72+
/**
73+
* Handle general purge events from Smart Cache
74+
*/
75+
public function handle_graphql_purge( string $key, string $event, string $graphql_endpoint ): void {
76+
error_log( "[Webhook] handle_graphql_purge - Key: $key, Event: $event, Endpoint: $graphql_endpoint" );
77+
78+
// Skip special prefixed keys (they're not actual entity IDs)
79+
if ( strpos( $key, 'skipped:' ) === 0 || strpos( $key, 'list:' ) === 0 ) {
80+
error_log( "[Webhook] Skipping webhook trigger for special key: $key" );
81+
return;
82+
}
83+
$mapped_event = SmartCacheEventMapper::mapEvent( strtolower( $event ) );
84+
85+
if ( $mapped_event === null ) {
86+
error_log( "[Webhook] handle_graphql_purge - No mapped event found for Smart Cache event: $event" );
87+
return;
88+
}
89+
90+
error_log( "[Webhook] handle_graphql_purge - Mapped '$event' to event: $mapped_event" );
91+
92+
$path = $this->get_path_from_key( $key );
93+
94+
$this->trigger_webhooks( $mapped_event, [
95+
'key' => $key,
96+
'path' => $path,
97+
'graphql_endpoint' => $graphql_endpoint,
98+
'smart_cache_keys' => [ $key ]
99+
] );
100+
}
101+
102+
/**
103+
* Trigger webhooks with Smart Cache formatted payload
104+
*/
105+
private function trigger_webhooks( string $event, array $payload ): void {
106+
// Event is already mapped, no need to map again
107+
$allowed_events = $this->repository->get_allowed_events();
108+
109+
if ( ! array_key_exists( $event, $allowed_events ) ) {
110+
error_log( "[Webhook] Event '$event' is not in allowed events list." );
111+
return;
112+
}
113+
114+
// Set uri fallback if smart_cache_keys is empty
115+
if ( empty( $payload['smart_cache_keys'] ) ) {
116+
$payload['uri'] = $payload['path'] ?? '';
117+
}
118+
119+
error_log( "[Webhook] Triggering webhooks for event: $event with payload: " . var_export( $payload, true ) );
120+
121+
do_action( 'graphql_webhooks_before_trigger', $event, $payload );
122+
123+
$webhooks = $this->repository->get_all();
124+
error_log( "[Webhook] Found " . count( $webhooks ) . " webhooks for event: $event" );
125+
$triggered_count = 0;
126+
127+
foreach ( $webhooks as $webhook ) {
128+
if ( $webhook->event === $event ) {
129+
$this->handler->handle( $webhook, $payload );
130+
$triggered_count++;
131+
}
132+
}
133+
134+
error_log( "[Webhook] Triggered $triggered_count webhooks for event: $event" );
135+
136+
do_action( 'graphql_webhooks_after_trigger', $event, $payload );
137+
}
138+
139+
/**
140+
* Extract Smart Cache keys from nodes
141+
*/
142+
private function get_smart_cache_keys( array $nodes ): array {
143+
$keys = [];
144+
145+
foreach ( $nodes as $node ) {
146+
if ( isset( $node['id'] ) && ! empty( $node['id'] ) ) {
147+
$keys[] = $node['id'];
148+
} elseif ( isset( $node['databaseId'] ) && ! empty( $node['databaseId'] ) ) {
149+
// Fallback to databaseId if id is not available
150+
$keys[] = $node['databaseId'];
151+
}
152+
}
153+
154+
return array_filter( $keys ); // Remove empty values
155+
}
156+
157+
/**
158+
* Get the path from the key
159+
*
160+
* Supports all post types, terms, users, and falls back gracefully.
161+
* Handles special prefixed keys like 'skipped:post', 'list:post', etc.
162+
*
163+
* @param string $key The key to get the path from
164+
*
165+
* @return string
166+
*/
167+
public function get_path_from_key( $key ) {
168+
$path = '';
169+
170+
if ( empty( $key ) ) {
171+
error_log( "[Webhook] Empty key provided to get_path_from_key" );
172+
return $path;
173+
}
174+
175+
// Handle special prefixed keys (skipped:, list:, etc.)
176+
if ( strpos( $key, ':' ) !== false ) {
177+
error_log( "[Webhook] Prefixed key detected: $key - cannot generate path for non-entity keys" );
178+
return $path;
179+
}
180+
181+
try {
182+
$node_id = Relay::fromGlobalId( $key );
183+
} catch (Exception $e) {
184+
error_log( "[Webhook] Failed to decode GraphQL global ID: $key - " . $e->getMessage() );
185+
return $path;
186+
}
187+
188+
$node_type = $node_id['type'] ?? null;
189+
$database_id = $node_id['id'] ?? null;
190+
191+
if ( empty( $node_type ) || empty( $database_id ) ) {
192+
error_log( "[Webhook] Invalid node ID structure for key: $key (type: $node_type, id: $database_id)" );
193+
return $path;
194+
}
195+
196+
$permalink = null;
197+
error_log( "[Webhook] Processing key: $key (type: $node_type, database_id: $database_id)" );
198+
199+
switch ( $node_type ) {
200+
case 'post':
201+
case 'page':
202+
default:
203+
$post_id = absint( $database_id );
204+
if ( $post_id > 0 ) {
205+
$post = get_post( $post_id );
206+
if ( $post && ! is_wp_error( $post ) ) {
207+
$permalink = get_permalink( $post_id );
208+
error_log( "[Webhook] Generated permalink for post $post_id: $permalink" );
209+
} else {
210+
error_log( "[Webhook] Post not found or error for ID: $post_id" );
211+
}
212+
}
213+
break;
214+
215+
case 'term':
216+
$term_id = absint( $database_id );
217+
if ( $term_id > 0 ) {
218+
$term = get_term( $term_id );
219+
if ( $term && ! is_wp_error( $term ) ) {
220+
$permalink = get_term_link( $term_id );
221+
error_log( "[Webhook] Generated permalink for term $term_id: $permalink" );
222+
} else {
223+
error_log( "[Webhook] Term not found or error for ID: $term_id" );
224+
}
225+
}
226+
break;
227+
228+
case 'user':
229+
$user_id = absint( $database_id );
230+
if ( $user_id > 0 ) {
231+
$user = get_user_by( 'id', $user_id );
232+
if ( $user instanceof \WP_User ) {
233+
$permalink = home_url( '/author/' . $user->user_nicename . '/' );
234+
error_log( "[Webhook] Generated permalink for user $user_id: $permalink" );
235+
} else {
236+
error_log( "[Webhook] User not found for ID: $user_id" );
237+
}
238+
}
239+
break;
240+
}
241+
242+
if ( ! empty( $permalink ) && is_string( $permalink ) && ! is_wp_error( $permalink ) ) {
243+
$parsed_path = parse_url( $permalink, PHP_URL_PATH );
244+
if ( $parsed_path !== false ) {
245+
$path = $parsed_path;
246+
error_log( "[Webhook] Final path for key $key: $path" );
247+
} else {
248+
error_log( "[Webhook] Failed to parse URL path from permalink: $permalink" );
249+
}
250+
} else {
251+
error_log( "[Webhook] No valid permalink generated for key: $key" );
252+
}
253+
254+
return $path;
255+
}
256+
}

0 commit comments

Comments
 (0)