From 2bb9aaaf24b794b528e3894ee9f9c42ca6d7fe93 Mon Sep 17 00:00:00 2001
From: Jake Vanderwerf <get@jakevanderwerf.ca>
Date: Thu, 01 Jan 2026 21:08:58 +0000
Subject: [PATCH] =FeedRoutes: extractTaxonomies added

---
 inc/integrations/Integrations.php |  233 ++++++++++++++++++++++++++++++++++++++++++++++++----------
 1 files changed, 192 insertions(+), 41 deletions(-)

diff --git a/inc/integrations/Integrations.php b/inc/integrations/Integrations.php
index 76f0c56..b1958bc 100644
--- a/inc/integrations/Integrations.php
+++ b/inc/integrations/Integrations.php
@@ -35,6 +35,7 @@
 	protected array $apiEndpoints = [];   // Valid endpoint paths for this service
 	protected string $apiVersion = '';     // API version string (e.g., 'v2', '2024-01-01')
 
+	protected int $refresh_interval = 0; //seconds before expiry to refresh tokens. 0 to disable
 
 	/**
 	 * OAuth Configuration
@@ -61,7 +62,7 @@
 	 */
 	protected array $credentials = [];      // Service credentials (API keys, tokens, etc.)
 	protected ?int $userID = null;          // User context for user-specific integrations
-
+	private bool $token_refresh_attempted = false;  // Circuit breaker for token refresh
 
 	protected array $fields = []; 		// The fields to generate that become credentials
 	protected array $advanced = []; 	// The fields that are optional settings
@@ -166,7 +167,7 @@
 	{
 		$this->cacheName = $this->cacheName ?: $this->service_name;
 		$this->userID = $userID;
-		$this->cache = new CacheManager('integrations_' . $this->cacheName, $this->ttl);
+		$this->cache = CacheManager::for('integrations_' . $this->cacheName, $this->ttl);
 
 		// Load error stats from cache
 		$this->loadErrorStats();
@@ -257,10 +258,6 @@
 		if (!empty($this->credentials['expires_at'])) {
 			$expires_at = intval($this->credentials['expires_at']);
 			if ($expires_at <= time()) {
-				// Token expired, try to refresh
-				if (!empty($this->credentials['refresh_token'])) {
-					return $this->refreshOAuthToken();
-				}
 				return false;
 			}
 		}
@@ -764,6 +761,12 @@
 		array  $options = []
 	): array|WP_Error
 	{
+		if (!$this->is_healthy) {
+			$this->logDebug('Skipping request - integration is unhealthy', [
+				'consecutive_errors' => $this->error_stats['consecutive_errors'],
+				'last_success' => $this->error_stats['last_success']
+			]);
+		}
 		$this->ensureInitialized();
 		if (!$this->isSetUp()){
 			$this->logError('Connection not setup for '.$this->service_name, [
@@ -777,9 +780,6 @@
 			return new WP_Error('rate_limit', 'Rate limit exceeded. Please try again later.');
 		}
 
-		// Debug: Check if credentials are loaded
-		error_log('['.$this->service_name.'] Make Request - Credentials loaded: ' . (!empty($this->credentials) ? 'Yes' : 'No'));
-		error_log('With Credentials: '.print_r($this->credentials, true));
 
 		$attempt = 0;
 		$lastError = null;
@@ -806,7 +806,9 @@
 
 				// Retry with backoff for server errors
 				if ($attempt < $this->maxRetries && !$this->isClientError($e)) {
-					sleep($this->retryDelays[$attempt - 1] ?? 5);
+					$delay = pow(2, $attempt) * 1000000; // 2^attempt seconds in microseconds
+					$jitter = rand(0, $delay * 0.3); // Add 0-30% jitter
+					usleep($delay + $jitter);
 				} else {
 					break;
 				}
@@ -843,14 +845,14 @@
 
 		$this->logDebug("$method request to: $url: ".print_r($args, true));
 
-		// Standard WordPress HTTP API
-		// Use appropriate WordPress HTTP function
+		// Make the request
 		$response = match($method) {
 			'GET' => wp_remote_get($url, $args),
 			'POST' => wp_remote_post($url, $args),
 			'PUT', 'PATCH', 'DELETE' => wp_remote_request($url, array_merge($args, ['method' => $method])),
 			default => null
 		};
+
 		if (!$response) {
 			$this->logError("Unsupported HTTP method $method for $this->service_name");
 			return null;
@@ -864,9 +866,42 @@
 		$response_code = wp_remote_retrieve_response_code($response);
 		$body = wp_remote_retrieve_body($response);
 
+		// Handle 401 - try to refresh token and retry once
+		if ($response_code === 401 && $this->isOAuthService && !empty($this->credentials['refresh_token'])) {
+			// Avoid infinite retry loop - only retry once
+			static $retry_count = 0;
+
+			if ($retry_count === 0) {
+				$retry_count++;
+
+				$this->logDebug('Got 401, attempting token refresh...');
+				if ($this->refreshOAuthToken()) {
+					$this->logDebug('Token refreshed successfully, retrying request...');
+
+					// Rebuild request args with new token
+					$args = $this->buildRequestArgs($method, $data, $options);
+
+					// Retry the request
+					$response = match($method) {
+						'GET' => wp_remote_get($url, $args),
+						'POST' => wp_remote_post($url, $args),
+						'PUT', 'PATCH', 'DELETE' => wp_remote_request($url, array_merge($args, ['method' => $method])),
+						default => null
+					};
+
+					if ($response && !is_wp_error($response)) {
+						$response_code = wp_remote_retrieve_response_code($response);
+						$body = wp_remote_retrieve_body($response);
+					}
+				}
+				$retry_count = 0; // Reset for next request
+			}
+		}
+
 		if ($response_code >= 400) {
 			$this->handleApiError($response_code, $body, $endpoint);
 		}
+
 		$decoded = json_decode($body, true);
 		if (json_last_error() !== JSON_ERROR_NONE) {
 			return ['raw_response' => $body];
@@ -888,8 +923,11 @@
 		bool   $force = false
 	): ?array
 	{
-		$cacheKey = $this->buildCacheKey('GET', $endpoint, $params);
-		$ttl = $this->cacheStrategy[$cacheStrategy] ?? $this->ttl;
+		$cacheKey = $this->buildCacheKey('GET', $endpoint, $params, $baseKey);
+
+		$ttl = is_int($cacheStrategy)
+			? max(0, $cacheStrategy)
+			: ($this->cacheStrategy[$cacheStrategy] ?? $this->ttl);
 
 		if (!$force && $ttl > 0) {
 			$cached = $this->cache->get($cacheKey);
@@ -901,12 +939,24 @@
 
 		$result = $this->makeRequest('GET', $endpoint, $params, $baseKey);
 
-		if ($result && $ttl > 0) {
+		// Only cache successful responses (not WP_Error and not error objects)
+		if ($result && !is_wp_error($result) && !$this->isErrorResponse($result) && $ttl > 0) {
 			$this->cache->set($cacheKey, $result, $ttl);
 		}
 
 		return $result;
 	}
+	/**
+	 * Check if response contains an error
+	 * Override in child classes for service-specific error detection
+	 */
+	protected function isErrorResponse(array $response): bool
+	{
+		// Common error patterns across APIs
+		return isset($response['error'])
+			|| isset($response['errors'])
+			|| isset($response['error_description']);
+	}
 
 	/**
 	 * POST request
@@ -1168,14 +1218,10 @@
 
 	public function handleAjaxResponse()
 	{
-		error_log('Ajax Response: '.print_r($_GET, true));
 
 		$code = $_GET['code'];
 		$state =  $_GET['state'];
 
-		error_log('OAuth Callback - Code: ' . $code);
-		error_log('OAuth Callback - State: ' . $state);
-
 
 		$state_parts = explode('|', $state);
 		$state_key = $state_parts[0] ?? '';
@@ -1183,16 +1229,13 @@
 		$user_id = ($user_id === 0) ? null : $user_id;
 		$return_url = isset($state_parts[2]) ? base64_decode($state_parts[2]) : admin_url('admin.php?page=jvb-integrations');
 
-		error_log('Service: '.print_r($this->service_name, true));
 		$state_data = get_transient('oauth_state_' . $state_key);
-		error_log('State Data: '.print_r($state_data, true));
 		if (!$state_data || $state_data['service'] !== $this->service_name) {
 			wp_die('Invalid state parameter', 'OAuth Error');
 		}
 
 		// Delete the transient to prevent reuse
 		delete_transient('oauth_state_' . $state_key);
-		error_log('Return URL: '.print_r($return_url, true));
 		// Handle error from OAuth provider
 		if (array_key_exists('error', $_GET)) {
 			$error_description = $_GET['error_description'] ?? 'Authorization denied';
@@ -1341,10 +1384,33 @@
 		}
 
 		if (!empty($this->credentials)) {
-			if ($this->isOAuthService && $this->hasOAuthCredentials() && !$this->isOAuthValid()) {
-				$this->logDebug('OAuth token expired, attempting refresh');
-				if (!$this->refreshOAuthToken()) {
-					$this->logError('Failed to refresh OAuth token');
+			if ($this->isOAuthService && $this->hasOAuthCredentials()) {
+				// Check if token is expired first
+				if (!$this->isOAuthValid()) {
+					// Only attempt refresh once per request
+					if (!$this->token_refresh_attempted) {
+						$this->token_refresh_attempted = true;
+						$this->logDebug('OAuth token expired, attempting refresh');
+
+						if (!$this->refreshOAuthToken()) {
+							$this->logError('Failed to refresh expired OAuth token - stopping execution');
+							// Token refresh failed - DO NOT continue making API requests
+							return;
+						}
+					} else {
+						// Already attempted refresh in this request
+						$this->logDebug('Token refresh already attempted, skipping');
+						return;
+					}
+				}
+				// Check if we should proactively refresh (before expiry)
+				elseif ($this->shouldRefreshToken() && !$this->token_refresh_attempted) {
+					$this->token_refresh_attempted = true;
+					$this->logDebug('OAuth token should be refreshed proactively');
+					if (!$this->refreshOAuthToken()) {
+						$this->logError('Failed to proactively refresh OAuth token');
+						// Not critical - token is still valid, so continue
+					}
 				}
 			}
 			$this->initialize();
@@ -1367,6 +1433,7 @@
 		// Switch context
 		$this->userID = $user_id;
 		$this->credentials = [];
+		$this->resetTokenRefreshFlag();  // ADD THIS LINE
 
 		$this->ensureInitialized();
 	}
@@ -1566,8 +1633,6 @@
 
 		$auth_url = $this->oauth['authorize'] . '?' . http_build_query($params);
 
-		// Debug log for troubleshooting
-		error_log("Generated OAuth URL for {$this->service_name}: " . $auth_url);
 
 		return $auth_url;
 	}
@@ -1744,12 +1809,7 @@
 			'redirect_uri' => $this->getRedirectUri()
 		];
 
-		// Use a custom endpoint key for OAuth (not part of regular API)
-		// We need to handle this specially since OAuth endpoints are different
 		$oauth_endpoint = $this->oauth['token'];
-
-		// Make the request using the centralized method
-		// This automatically includes rate limiting and error handling
 		$response = $this->makeOAuthRequest('POST', $oauth_endpoint, $request_data);
 
 		if (is_wp_error($response)) {
@@ -1762,10 +1822,13 @@
 
 		// Parse response
 		if (isset($response['access_token'])) {
+			$expires_in = $response['expires_in'] ?? 2592000; // 30 days default
+
 			return [
 				'access_token' => $response['access_token'],
 				'refresh_token' => $response['refresh_token'] ?? '',
-				'expires_in' => $response['expires_in'] ?? 2592000, // 30 days default
+				'expires_in' => $expires_in,
+				'expires_at' => time() + $expires_in, // Calculate expiry timestamp
 				'token_type' => $response['token_type'] ?? 'Bearer',
 				'merchant_id' => $response['merchant_id'] ?? '',
 				'scope' => $response['scope'] ?? ''
@@ -1786,6 +1849,75 @@
 	}
 
 	/**
+	 * Check if token should be proactively refreshed
+	 * Different from isOAuthValid() which checks if token is actually expired
+	 */
+	protected function shouldRefreshToken(): bool
+	{
+		if (!$this->isOAuthService || $this->refresh_interval === 0) {
+			return false;
+		}
+
+		// If no expiry info, we can't proactively refresh
+		if (empty($this->credentials['expires_at'])) {
+			return false;
+		}
+
+		$expires_at = intval($this->credentials['expires_at']);
+		$time_until_expiry = $expires_at - time();
+
+		// Refresh if we're within the refresh interval window
+		return $time_until_expiry > 0 && $time_until_expiry <= $this->refresh_interval;
+	}
+	/**
+	 * Get time until token refresh is recommended
+	 * Useful for displaying in admin UI
+	 */
+	public function getTimeUntilRefresh(): ?int
+	{
+		if ($this->refresh_interval === 0 || empty($this->credentials['expires_at'])) {
+			return null;
+		}
+
+		$expires_at = intval($this->credentials['expires_at']);
+		$refresh_at = $expires_at - $this->refresh_interval;
+		$time_until_refresh = $refresh_at - time();
+
+		return max(0, $time_until_refresh);
+	}
+
+	/**
+	 * Get token freshness status
+	 * Returns: 'fresh', 'should_refresh', 'expired', or 'no_expiry_info'
+	 */
+	public function getTokenStatus(): string
+	{
+		if (!$this->isOAuthService) {
+			return 'not_oauth';
+		}
+
+		if (empty($this->credentials['access_token'])) {
+			return 'no_token';
+		}
+
+		if (empty($this->credentials['expires_at'])) {
+			return 'no_expiry_info';
+		}
+
+		$expires_at = intval($this->credentials['expires_at']);
+		$now = time();
+
+		if ($expires_at <= $now) {
+			return 'expired';
+		}
+
+		if ($this->shouldRefreshToken()) {
+			return 'should_refresh';
+		}
+
+		return 'fresh';
+	}
+	/**
 	 * Refresh OAuth token
 	 */
 	protected function refreshOAuthToken(): bool
@@ -1794,7 +1926,6 @@
 			return false;
 		}
 
-		// Build refresh request data
 		$request_data = [
 			'client_id' => $this->credentials['client_id'],
 			'client_secret' => $this->credentials['client_secret'],
@@ -1802,12 +1933,24 @@
 			'grant_type' => 'refresh_token'
 		];
 
-		// Use centralized OAuth request method
 		$response = $this->makeOAuthRequest('POST', $this->oauth['token'], $request_data);
 
 		if (is_wp_error($response)) {
-			$this->logError('Failed to refresh Square token', [
-				'error' => $response->get_error_message()
+			$error_message = $response->get_error_message();
+
+			if (str_contains($error_message, 'invalid_grant')) {
+				$this->logError('OAuth refresh token is invalid - user must re-authorize', [
+					'error' => $error_message
+				], 'critical');
+
+				// Mark unhealthy immediately
+				$this->error_stats['consecutive_errors'] = $this->error_threshold;
+				$this->is_healthy = false;
+				$this->saveErrorStats();
+			}
+
+			$this->logError('Failed to refresh OAuth token for '.$this->service_name, [
+				'error' => $error_message
 			]);
 			return false;
 		}
@@ -1816,7 +1959,7 @@
 			$this->credentials['access_token'] = $response['access_token'];
 			$this->credentials['expires_at'] = time() + ($response['expires_in'] ?? 2592000); // 30 days
 
-			// Note: Square returns the SAME refresh token
+			// Note: Some services return the SAME refresh token
 			if (isset($response['refresh_token'])) {
 				$this->credentials['refresh_token'] = $response['refresh_token'];
 			}
@@ -2946,7 +3089,7 @@
 					switch ($action) {
 						case 'save_credentials':
 							$title = $label;
-							$label = jvbIcon('save');
+							$label = jvbIcon('floppy-disk');
 							break;
 						case 'clear_credentials':
 							$title = $label;
@@ -2981,7 +3124,7 @@
 		}
 		$credentials = $this->getCredentials();
 		$hasCredentials = $this->hasOAuthCredentials();
-		$returnURL = (is_admin()) ? :get_the_permalink();
+		$returnURL = is_admin() ? admin_url('admin.php?page=jvb-integrations') : (get_the_permalink() ?: home_url());
 		?>
 
 		<details <?= $hasCredentials?' open':''?>>
@@ -3400,4 +3543,12 @@
 			throw new Exception('Failed to save JPEG image');
 		}
 	}
+	/**
+	 * Reset token refresh attempt flag
+	 * Called automatically when switching users
+	 */
+	protected function resetTokenRefreshFlag(): void
+	{
+		$this->token_refresh_attempted = false;
+	}
 }

--
Gitblit v1.10.0