'', 'auth' => ''] protected array $apiEndpoints = []; // Valid endpoint paths for this service protected int $refresh_interval = 0; //seconds before expiry to refresh tokens. 0 to disable /** * Credentials & State */ protected string $defaultContent = 'post'; //Default integration content type, is none is set. MUST EXIST as array key in $this->>getContentTypes protected array $allowedContent = []; /** * Caching Configuration */ protected ?string $cacheName = null; protected Cache $cache; protected array $cacheStrategy = [ 'aggressive' => 3600, // 1 hour for stable data (e.g., profile info) 'moderate' => 300, // 5 minutes for semi-dynamic data (e.g., posts) 'minimal' => 60, // 1 minute for frequently changing data 'none' => 0 // No caching for real-time data ]; /** * Post Syncing Capabilities * Define what sync operations this integration supports */ protected array $canSync = [ 'initial' => false, // Can share new posts to the service 'update' => false, // Can update already-shared posts 'delete' => false, // Can remove posts from the service ]; protected array $syncPostTypes = []; // Post types that can be synced (e.g., ['artwork', 'tattoo']): usually built by Registrar.php if the integration name exists as a key in [ 'integrations' => []] protected array $syncTaxonomies = []; // Post types that can be synced (e.g., ['artwork', 'tattoo']): usually built by Registrar.php if the integration name exists as a key in [ 'integrations' => []] protected array $contentTypes = []; // Integration's available content types. Set by child classes' getContentTypes protected bool $has_content = false; // Whether integration has content that can sync /** * Error Handling Configuration */ protected array $lastError = []; protected array $retryDelays = [1, 2, 5]; // Exponential backoff in seconds protected function __construct(?int $userID = null) { $this->cacheName = $this->cacheName ?: $this->service_name; $this->userID = $userID; $this->cache = Cache::for('integrations_' . $this->cacheName); $this->getPostTypes(); $this->getTaxonomies(); $this->setContentTypes(); $this->registerHooks(); if (method_exists($this, 'setQueueTypes')) { $this->setQueueTypes(); } $this->initializeRateLimiters(); if (method_exists($this, 'initializeActions')) { $this->initializeActions(); } if (method_exists($this, 'addOAuthActions')) { $this->addOAuthActions(); } add_filter('jvbShouldRenderMeta', [$this, 'checkRenderField'], 10, 4); } protected function initializeRateLimiters():void { $key = $this->service_name; if (!is_null($this->userID)) { $key .= '_'.$this->userID; } if ($this->hasRequests && !isset($this->requestLimiter) && !is_null($this->oauthLimiter)) { $this->requestLimiter = new RateLimits($key); } if ($this->hasOAuth && !isset($this->oauthLimiter) && !is_null($this->oauthLimiter)) { $key .= '_oauth'; $this->oauthLimiter = new RateLimits($key, ['s' => 1,'m' => 10, 'h' => 100]); } } protected function setContentTypes():void { } public function getContentTypes(string $type):array { return (array_key_exists($type, $this->contentTypes)) ? $this->contentTypes[$type] : $this->contentTypes; } public function checkRenderField($shouldRender, $name, $type, $objectType):bool { if ($type !== 'form') { return $shouldRender; } if (!$this->isSetUp() && str_contains($name, $this->service_name)) { return false; } return $shouldRender; } public function handleOAuthConnect(): array { if (!$this->hasOAuth) { return ['success' => false, 'message' => 'Not an OAuth service']; } // This would typically redirect to OAuth provider // Child classes can override for specific behavior $auth_url = $this->getOAuthUrl(); if ($auth_url) { return [ 'success' => true, 'redirect' => $auth_url, ]; } return ['success' => false, 'message' => 'Failed to generate OAuth URL']; } /** * Check if OAuth token is valid and not expired * @return bool */ public function isOAuthValid(): bool { if (!$this->isOAuthService) { return false; } // Check if we have tokens if (empty($this->credentials['access_token'])) { return false; } // Check token expiry if stored if (!empty($this->credentials['expires_at'])) { $expires_at = intval($this->credentials['expires_at']); if ($expires_at <= time()) { return false; } } // For services without expiry info, do a test API call return true; // return $this->testConnection(); } public function getOAuthUrlAction(array $data = []): array { if (!$this->isOAuthService) { return ['success' => false, 'message' => 'Not an OAuth service']; } $return_url = $data['return_url'] ?? null; $auth_url = $this->getOAuthUrl($return_url); if ($auth_url) { return [ 'success' => true, 'auth_url' => $auth_url, 'popup' => true ]; } return ['success' => false, 'message' => 'Failed to generate OAuth URL']; } protected function getPostTypes(): void { $this->syncPostTypes = Registrar::withIntegration($this->service_name); } protected function getTaxonomies():void { $key = BASE . $this->service_name . '_sync_taxonomies'; $taxonomies = get_option($key, false); if (!$taxonomies) { // Combine both content and taxonomy filtering $taxonomies = []; foreach (Registrar::withFeature('is_content', 'term') as $type) { $registrar = Registrar::getInstance($type); if ($registrar->hasIntegration($this->service_name)) { $taxonomies[] = $registrar->getSlug(); } } update_option($key, $taxonomies); } $this->syncTaxonomies = $taxonomies; } protected function getRedirectUri(): string { // if (!empty($this->oauth['redirect_uri'])) { // return $this->oauth['redirect_uri']; // } if ($this->hasOAuth) { return admin_url('admin-ajax.php?action=' . BASE . $this->service_name . '_oauth_callback'); } // Changed from admin-ajax.php to REST endpoint return rest_url('jvb/v1/oauth/callback?service=' . $this->service_name); } /** * Used by IntegrationsRoutes.php * @param string $code * @param string $state * @return array */ public function handleOAuthCode(string $code, string $state): array { try { $this->loadCredentials(); $tokens = $this->exchangeOAuthCode($code); if (!$tokens) { return ['success' => false, 'message' => 'Failed to exchange authorization code']; } $credentials = array_merge($this->credentials, $tokens); $credentials = $this->addCredentialData($credentials, $tokens); $saved = $this->saveCredentials($credentials, false); if ($saved) { return ['success' => true, 'message' => 'Successfully connected']; } return ['success' => false, 'message' => 'Failed to save credentials']; } catch (Exception $e) { $this->logError('OAuth code exchange failed', ['error' => $e->getMessage()]); return ['success' => false, 'message' => 'OAuth error: ' . $e->getMessage()]; } } /********************************************************************* * ABSTRACT METHODS - MUST BE IMPLEMENTED BY CHILD CLASSES *********************************************************************/ /** * Initialize the integration with loaded credentials * * Called after credentials are loaded. Use this to: * - Set up API endpoints with dynamic values (e.g., account IDs) * - Initialize service-specific configurations * - Validate credentials format * * @return void */ abstract protected function initialize(): void; /** * Render the connection settings form * * Output HTML form fields for configuring this integration. * Use the provided $credentials array to populate existing values. * * Guidelines: * - Use proper escaping (esc_attr, esc_html, etc.) * - Include helpful descriptions for each field * - Mark required fields clearly * - Use appropriate input types (password for secrets, url for endpoints) * * @param array $credentials Current credentials (may be empty) * @return void */ // abstract public function renderConnectionSettings(): void; /********************************************************************* * OPTIONAL OVERRIDE METHODS - IMPLEMENT AS NEEDED *********************************************************************/ /** * Save credentials using Auth */ public function saveCredentials(array $credentials, bool $test = true):array { try { // Process and validate credentials if (!$this->validateCredentials($credentials)) { $this->logError('Invalid credentials'); return [ 'success' => false, 'message' => 'Credentials not formatted correctly' ]; } $old = $this->getCredentials(); $credentials = array_merge($old, $credentials); // Temporarily set credentials for testing $this->credentials = $credentials; $this->initialize(); // Test the connection before saving if ($test) { if (!$this->testConnection(true)) { $this->logError('Connection test failed'); // Revert to old credentials $this->credentials = $old; $this->initialize(); return [ 'success' => false, 'message' => 'Connection failed. Please check your credentials.', 'test_failed' => true ]; } } // Connection successful, save credentials $stored = Auth::getInstance()->storeCredentials( $this->service_name, $credentials, $this->userID ); if ($stored) { $this->updateLastTestedTime(); $this->clearCache(); return [ 'success' => true, 'message' => 'Credentials validated and saved successfully', 'reload' => true ]; } return [ 'success' => false, 'message' => 'Failed to save credentials to database' ]; } catch (\Exception $e) { // Revert to old credentials on any error $old = Auth::getInstance()->getCredentials( $this->service_name, $this->userID ); $this->credentials = $old; $this->initialize(); return [ 'success' => false, 'message' => 'Validation error: ' . $e->getMessage() ]; } } /** * Delete credentials */ public function deleteCredentials():array { $success = Auth::getInstance()->deleteCredentials($this->service_name, $this->userID); return [ 'success' => $success ]; } /** * Validate credentials before storing * * Override to add service-specific validation logic. * Check for required fields, format validation, etc. * * @param array $credentials Credentials to validate * @return bool True if valid */ protected function validateCredentials(array $credentials):bool { // Default: check that credentials is not empty if (empty($credentials)) { return false; } return true; } /** * Sanitize credentials before storing * * Override to clean/format credentials before storage. * Remove whitespace, normalize URLs, etc. * * @param array $credentials Raw credentials from form * @return array Sanitized credentials */ protected function sanitizeCredentials(array $credentials): array { $sanitized = []; foreach ($credentials as $key => $value) { if (is_string($value)) { $sanitized[$key] = sanitize_text_field($value); } else { $sanitized[$key] = $value; } } return $sanitized; } /** * Test connection to the external service * * Override to implement service-specific connection testing. * This method includes caching by default. * * @return bool True if connection successful */ public function testConnection(bool $force = false): bool { if (!$this->isSetUp()) { return false; } $this->ensureInitialized(); if (empty($this->credentials)) { return false; } // Cache test results to avoid excessive API calls $cacheKey = "connection_test_$this->service_name" . ($this->userID ? "_$this->userID" : ''); $cached = $this->cache->get($cacheKey); if ($cached !== false && !$force) { return (bool)$cached; } try { if ($this->isOAuthService && !$this->hasOAuthCredentials()){ //If this is an OAuth service, we might only be saving the app credentials first $result = true; } else { $result = $this->performConnectionTest(); } $this->updateLastTestedTime(); $this->cache->set($cacheKey, $result, 300); // Cache for 5 minutes return $result; } catch (Exception $e) { $this->logError('Connection test failed', ['error' => $e->getMessage()]); $this->cache->set($cacheKey, false, 60); // Cache failure for 1 minute return false; } } protected function clearCache():array { $this->cache->flush(); return [ 'success' => true, ]; } /** * Perform actual connection test * * Override this method to implement the actual connection test logic. * Default implementation returns true if credentials exist. * * @return bool True if connection successful * @throws Exception If connection fails */ protected function performConnectionTest(): bool { // Override in child class with actual test // Example: make a simple API call to verify credentials return !empty($this->credentials); } /** * Register additional WordPress hooks * * Override to register service-specific hooks beyond the default ones. * Called during construction after base hooks are registered. * * @return void */ protected function registerAdditionalHooks(): void { // Override in child classes to add service-specific hooks } /****************************************************************** POST SYNC ******************************************************************/ /** * Handle post save for syncing * * Override to implement custom sync logic when posts are saved. * Check the $settings array for post type specific configuration. * * @param int $postID The post ID * @param WP_Post $post The post object * @param bool $update Whether this is an update * @param array $settings Post type integration settings * @return void */ protected function handleTheSavePost(int $postID, WP_Post $post, bool $update, array $settings): void { // Override in child classes that support post syncing // Example implementation: /* $fields = $this->getSyncFields($postID, 'post', ['schedule_' . $this->service_name]); $options = $fields['schedule_' . $this->service_name] !== '' ? ['scheduled' => strtotime($fields['schedule_' . $this->service_name])] : []; $this->queueOperation( 'sync_post', ['post_id' => $postID, 'is_update' => $update], $options ); */ } /********************************************************************* * API REQUEST METHODS *********************************************************************/ /********************************************************************* * SYNC METHODS *********************************************************************/ /** * Queue a sync operation for processing, utilizing the OperationQueue.php * * @param string $type Operation type (sync_post, delete_post, etc.) * @param array $data Operation data * @param array $options Operation options (scheduled time, priority, etc.) * @return bool True if queued successfully */ public function queueOperation( string $type, array $data, array $options = [] ):bool { $queue = JVB()->queue(); $queued = $queue->queueOperation( $type, $this->userID ?? 0, array_merge($data, ['service' => $this->service_name, 'user' => $this->userID??0]), array_merge([ 'priority' => 'normal', 'chunk_key' => $options['batch_field'] ?? null, 'chunk_size' => $options['batch_size'] ?? 10 ], $options) ); return (!is_wp_error($queued)); } /** * The filter called by OperationQueue.php processOperation * 1) Test if the operation type is the type we set in queueOperation * I usually do a switch ($operation->type) { * case strtolower($this->service_name. '_update_post'): * return $this->processPostUpdate($data); * default: * return $result; * } * 2) Process the data * 3) Return an array in this format: * [ * 'success' => true|false, * 'result' => []//anything we should pass to the operation queue. If we have any dependent operations, it will refer to this data to proceed * ] * @param WP_Error|array $result * @param object $operation * @param array $data * @return WP_Error|array */ public function processOperation(WP_Error|array $result, object $operation, array $data): WP_Error|array { return $result; } /********************************************************************* * UTILITY METHODS *********************************************************************/ /** * Register WordPress hooks based on capabilities */ protected function registerHooks(): void { //Handled by IntegrationExecutor now // add_filter(BASE . 'handle_bulk_operation', [$this, 'processOperation'], 10, 3); if (method_exists($this, 'registerOAuthCallbacks')) { $this->registerOAuthCallbacks(); } if (method_exists($this, 'registerWebhookEndpoint')) { $this->registerWebhookEndpoint(); } // Let child classes register additional hooks if needed $this->registerAdditionalHooks(); if (!empty($this->syncPostTypes) && is_null($this->userID)) { $this->addSavePost(); add_action('transition_post_status', [$this, 'handlePostStatusTransition'], 10, 3); if ($this->canSync['delete']) { add_action('before_delete_post', [$this, 'handleDeletePost'], 10, 1); } } if (!empty($this->syncTaxonomies) && is_null($this->userID)) { add_action('saved_term', [$this, 'handleSaveTerm'], 20, 5); if ($this->canSync['delete']) { add_action('pre_delete_term', [$this, 'handleDeleteTerm'], 10, 2); } } add_action('init', [$this, 'registerQueueTypes'], 10); } public function addSavePost():void { if (!has_action('save_post', [$this, 'handleSavePost'])) { add_action('save_post', [$this, 'handleSavePost'], 20, 3); } } public function removeSavePost():void { remove_action('save_post', [$this, 'handleSavePost'], 20, 3); } public function registerQueueTypes():void { if (empty($this->syncTaxonomies) && empty($this->syncPostTypes)) { return; } $queue = JVB()->queue(); $executor = new IntegrationExecutor(); $queue->registry()->register(self::$syncTo, new TypeConfig( mergeable: new DefaultMerger('items'), executor: $executor, chunkKey: 'items', chunkSize: 50, maxRetries: 3, )); if ($this->canSync['delete']) { $queue->registry()->register(self::$deleteFrom, new TypeConfig( mergeable: new DefaultMerger('external_ids'), executor: $executor, chunkKey: 'external_ids', chunkSize: 200, maxRetries: 2 )); } $queue->registry()->register(self::$syncFrom, new TypeConfig( executor: $executor, maxRetries: 3 )); $this->registerAdditionalQueueTypes($executor); } protected function registerAdditionalQueueTypes(IntegrationExecutor $executor):void { //Empty. Integration extensions can register additional operation types from here. } /** * Handle connection setup and credential storage * * @param array $credentials The credentials to save * @return array Result with 'success' and 'message' keys */ public function handleConnection(array $credentials): array { try { // Sanitize credentials $sanitized = $this->sanitizeCredentials($credentials); // Validate if needed if (method_exists($this, 'validateCredentials')) { if (!$this->validateCredentials($sanitized)) { return ['success' => false, 'message' => 'Invalid Credentials']; } } // Store credentials $this->credentials = array_merge($this->credentials ?? [], $sanitized); $this->credentials['last_updated'] = time(); // Save to database $saved = $this->saveCredentials($this->credentials); if (!$saved) { return [ 'success' => false, 'message' => 'Failed to save credentials to database' ]; } // Test connection if method exists if (method_exists($this, 'testConnection')) { if (!$this->testConnection()) { // Still save but warn about connection return [ 'success' => true, 'message' => 'Credentials saved but connection test failed:' ]; } } return [ 'success' => true, 'message' => 'Connection established successfully' ]; } catch (\Exception $e) { return [ 'success' => false, 'message' => 'Error: ' . $e->getMessage() ]; } } public function getCredentials(): array { return $this->loadCredentials(); } public function hasOAuthCredentials(?int $userID = null): bool { if ($userID !== $this->userID) { $this->switchUser($userID); } return $this->hasOAuth && $this->hasValidOAuth(); } /** * Ensure service is initialized */ protected function ensureInitialized(): void { if (!$this->isSetUp()){ return; } $this->initialize(); } /*************************************************************** ERROR HANDLING ***************************************************************/ /** * Handle error responses */ protected function handleApiError(int $code, string $body, string $endpoint): void { $message = "API Error ({$code}): "; $decoded = json_decode($body, true); // Extract error details $error_details = $this->extractErrorDetails($decoded, $body); $message .= $error_details['message']; // Determine error severity based on HTTP code $severity = $this->getErrorSeverity($code); // Build comprehensive error context $error_context = [ 'service' => $this->service_name, 'endpoint' => $endpoint, 'http_code' => $code, 'error_type' => $this->categorizeApiError($code), 'error_details' => $error_details, 'user_id' => $this->userID, 'request_time' => time(), 'consecutive_errors' => $this->error_stats['consecutive_errors'], 'is_oauth' => $this->isOAuthService, 'api_version' => $this->apiVersion, 'integration_healthy' => $this->is_healthy ]; // Add rate limit information if present if ($code === 429) { $error_context['rate_limit'] = $this->extractRateLimitInfo($decoded, $body); } // Log to ErrorHandler with proper severity $this->logError($message, $error_context, $severity); // Update error statistics $this->updateErrorStats($code, $endpoint); // Store last error for debugging $this->lastError = [ 'code' => $code, 'message' => $message, 'endpoint' => $endpoint, 'timestamp' => time(), 'context' => $error_context ]; } /***************************************************************** OAUTH *****************************************************************/ /** * Get OAuth authorization URL */ public function getOAuthUrl(?string $return_url = null): string { if (!$this->hasOAuth) { return ''; } if (empty($this->credentials)) { $this->ensureInitialized(); } if (empty($this->oauth['authorize'])) { $this->logError('OAuth authorize URL not configured'); return ''; } // Build base parameters $params = [ 'client_id' => $this->credentials['client_id'] ?? $this->credentials['app_id'] ?? '', 'redirect_uri' => $this->getRedirectUri(), 'response_type' => 'code', 'scope' => implode(' ', $this->oauth['scopes'] ?? []) ]; $state_key = wp_generate_password(32, false); $user_id = $this->userID??0; // Store state data in transient (expires in 10 minutes) set_transient( 'oauth_state_' . $state_key, [ 'service' => $this->service_name, 'user_id' => $user_id, 'created' => time() ], 600 ); $state_parts = [ $state_key, $user_id ]; // Add return URL if provided if ($return_url) { $state_parts[] = base64_encode($return_url); } else { $state_parts[] = base64_encode(admin_url('admin.php?page=jvb-integrations')); } $params['state'] = implode('|', $state_parts); // Allow child classes to modify params (they can override/remove as needed) if (method_exists($this, 'addOAuthParams')) { $params = $this->addOAuthParams($params); } return $this->oauth['authorize'] . '?' . http_build_query($params); } /** * Add service-specific OAuth parameters */ protected function addOAuthParams(array $params): array { // Override in child classes to add service-specific params // e.g., access_type, prompt, etc. return $params; } /** * Extract retry-after header from response * * @param array|WP_Error $response WordPress HTTP response * @return int Seconds to wait before retry */ protected function extractRetryAfter($response): int { if (is_wp_error($response)) { return 5; } $headers = wp_remote_retrieve_headers($response); if (isset($headers['retry-after'])) { // Could be seconds or HTTP date $retry_after = $headers['retry-after']; if (is_numeric($retry_after)) { return (int) $retry_after; } // Try to parse as date $timestamp = strtotime($retry_after); if ($timestamp !== false) { return max(0, $timestamp - time()); } } return 5; // Default wait time } /** * Exchange OAuth code for tokens */ protected function exchangeOAuthCode(string $code): ?array { $this->ensureInitialized(); // Build request data $request_data = [ 'client_id' => $this->credentials['client_id'] ?? '', 'client_secret' => $this->credentials['client_secret'] ?? '', 'code' => $code, 'grant_type' => 'authorization_code', 'redirect_uri' => $this->getRedirectUri() ]; $oauth_endpoint = $this->oauth['token']; $response = $this->makeOAuthRequest('POST', $oauth_endpoint, $request_data); if (is_wp_error($response)) { $this->logError('OAuth token exchange failed', [ 'error' => $response->get_error_message(), 'code' => $response->get_error_code() ]); return null; } // 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' => $expires_in, 'expires_at' => time() + $expires_in, // Calculate expiry timestamp 'token_type' => $response['token_type'] ?? 'Bearer', 'merchant_id' => $response['merchant_id'] ?? '', 'scope' => $response['scope'] ?? '' ]; } $this->logError('Failed to obtain access token', ['response' => $response]); return null; } /** * Add service-specific credential data */ protected function addCredentialData(array $credentials, array $tokens): array { // Override in child classes to add service-specific data return $credentials; } /** * Check if token should be proactively refreshed * Different from isOAuthValid() which checks if token is actually expired */ protected function shouldRefreshToken(): bool { if (!$this->hasOAuth || $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->hasOAuth) { 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 { if (!$this->hasOAuth || empty($this->credentials['refresh_token'])) { return false; } $request_data = [ 'client_id' => $this->credentials['client_id'], 'client_secret' => $this->credentials['client_secret'], 'refresh_token' => $this->credentials['refresh_token'], 'grant_type' => 'refresh_token' ]; $response = $this->makeOAuthRequest('POST', $this->oauth['token'], $request_data); if (is_wp_error($response)) { $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; } if (isset($response['access_token'])) { $this->credentials['access_token'] = $response['access_token']; $this->credentials['expires_at'] = time() + ($response['expires_in'] ?? 2592000); // 30 days // Note: Some services return the SAME refresh token if (isset($response['refresh_token'])) { $this->credentials['refresh_token'] = $response['refresh_token']; } $this->saveCredentials($this->credentials); return true; } return false; } /** * Revoke OAuth access */ public function revokeOAuthAccess(): bool { if (!$this->hasOAuth || empty($this->oauth['revoke'])) { return false; } if (!empty($this->credentials['access_token'])) { wp_remote_post($this->oauth['revoke'], [ 'body' => ['token' => $this->credentials['access_token']] ]); } return Auth::getInstance()->deleteCredentials($this->service_name, $this->userID); } public function handleOAuthDisconnect(): array { try { // Revoke the token with Square using centralized request if (!empty($this->credentials['access_token'])) { $revoke_data = [ 'client_id' => $this->credentials['client_id'] ?? '', 'access_token' => $this->credentials['access_token'] ]; // Make revoke request (ignore response as revoke often returns empty) $this->makeOAuthRequest('POST', $this->oauth['revoke'], $revoke_data); } // Clear stored credentials (preserve app credentials) $this->credentials = [ 'client_id' => $this->credentials['client_id'] ?? '', 'client_secret' => $this->credentials['client_secret'] ?? '', 'environment' => $this->credentials['environment'] ?? 'sandbox' ]; $this->saveCredentials($this->credentials); $this->clearCache(); return [ 'success' => true, 'message' => 'Successfully disconnected from Square' ]; } catch (Exception $e) { return [ 'success' => false, 'message' => 'Failed to disconnect: ' . $e->getMessage() ]; } } /** * Generate webhook signature key for services that require it * @return string */ protected function generateWebhookSignature(): string { return wp_generate_password(32, false); } /** * Generate OAuth state parameter */ protected function generateOAuthState(?int $user_id, ?string $return_url = null): string { $user_id = $user_id ?? 0; $state = wp_create_nonce($this->service_name . '_oauth_' . $user_id) . '|' . $user_id; if ($return_url) { $state .= '|' . base64_encode($return_url); } return $state; } protected function getNonce(): string { return wp_create_nonce($this->service_name . '_oauth_' . $this->userID); } /** * Determine return URL after OAuth */ protected function determineReturnUrl(?int $user_id): string { if ($user_id > 0) { return home_url('/dash/integrations/#' . $this->service_name); } return admin_url('admin.php?page=jvb-integrations'); } /**************************************************************** POST SYNC ****************************************************************/ /** * Get field mapping for a post type */ protected function getFieldMapping(string $post_type): array { // Apply filter for custom mapping return apply_filters( "jvb_{$this->service_name}_field_mapping", [], $post_type, $this ); } /** * Map WordPress fields to service fields */ protected function mapFieldsToService(int $postID, array $mapping): array { $meta_manager = Meta::forPost($postID); $service_data = []; foreach ($mapping as $wp_field => $service_field) { $value = $meta_manager->get($wp_field); if ($value !== null && $value !== '') { $this->setNestedValue($service_data, $service_field, $value); } } return apply_filters( "jvb_{$this->service_name}_mapped_data", $service_data, $postID, $mapping ); } /** * Set nested array value using dot notation */ protected function setNestedValue(array &$array, string $path, $value): void { $keys = explode('.', $path); $current = &$array; foreach ($keys as $i => $key) { if ($i === count($keys) - 1) { $current[$key] = $value; } else { if (!isset($current[$key])) { $current[$key] = []; } $current = &$current[$key]; } } } /** * Handle post save */ public function handleSavePost(int $postID, WP_Post $post, bool $update): void { if (!is_null($this->userID)) { return; } error_log('=== ['.$this->service_name.']::handleSavePost called'); if (!$postID || $postID === 0) { return; } $postType = jvbNoBase($post->post_type); if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) return; if (wp_is_post_revision($postID)) return; if (empty($this->syncPostTypes) || !in_array(jvbNoBase($postType), $this->syncPostTypes)) { error_log('Not handling save for '.$this->service_name.' because there are no syncPostTypes: '.print_r($this->syncPostTypes, true)); return; } $registrar = Registrar::getInstance($postType); if (!$registrar){ return; } $settings = $registrar->hasIntegration($this->service_name)??null; if (!$settings) { error_log('Not handling save for '.$this->service_name.' because of no registrar settings'); return; } $settings = $registrar->getIntegrationConfig($this->service_name); if (!$settings){ error_log('Not handling save for '.$this->service_name.' because of no integration config '.print_r($settings, true)); return; } $fields = $this->getSyncFields($postID, 'post', ['schedule_'.$this->service_name]); if (!$fields['share_to_'.$this->service_name]) { error_log('Not handling save for '.$this->service_name.' because of no share_to_'.$this->service_name.' '.print_r($fields, true)); return; } $isShared = isset($fields["_{$this->service_name}_item_id"]); if ($update && $isShared && !$fields['_keep_synced_'.$this->service_name]) { error_log('Not handling save for '.$this->service_name.' because it is already shared, and not set to keep synced. '); return; } if ($post->post_status !== 'publish' && !$isShared) { error_log('Not handling save for '.$this->service_name.' because post status is not publish, and it is not already shared.'); return; } error_log('==== Sending to integration\'s handleTheSavePost '.$this->service_name.' ===='); $this->removeSavePost(); $this->handleTheSavePost($postID, $post, $update, $settings); $this->addSavePost(); } protected function getSyncFields(int $postID, string $type, array $additional = []):array { $meta = new Meta($postID, $type); $fieldsToCheck = [ 'share_to_' . $this->service_name, '_keep_synced_' . $this->service_name, "_{$this->service_name}_item_id", "_{$this->service_name}_last_sync", "_{$this->service_name}_shared_at", "_{$this->service_name}_sync_status", ... $additional ]; return $meta->getAll($fieldsToCheck); } /** * Handle post status transitions */ public function handlePostStatusTransition(string $new_status, string $old_status, WP_Post $post): void { if (empty($this->syncPostTypes)) { return; } if (!in_array(jvbNoBase($post->post_type), $this->syncPostTypes)) { return; } //Map fields from our custom post types to the fields expected by the integration $mappedFields = $this->getFieldMapping($post->post_type); $fields = $this->getSyncFields($post->ID, 'post', $mappedFields); // Handle unpublish action if ($old_status === 'publish' && $new_status !== 'publish') { if ($fields["_{$this->service_name}_item_id"] && $this->canSync['delete']) { try { $this->queueOperation( 'delete_post', [ $post, $fields["_{$this->service_name}_item_id"] ] ); } catch (Exception $e) { $this->logError("Failed to handle unpublish for post {$post->ID}: " . $e->getMessage()); } } } } /** * Handle post deletion */ public function handleDeletePost(int $postID): void { if (!$this->canSync['delete']) { return; } $post = get_post($postID); if (!$post || !in_array($post->post_type, $this->syncPostTypes)) { return; } $fields = $this->getSyncFields($postID, 'post'); if ($fields["_{$this->service_name}_item_id"] !== '') { $this->queueOperation( 'delete_post', [ 'post_id' => $post->ID, ] ); } } protected function handleSaveTerm($term_id, $tt_id, $taxonomy, $update, $args): void { $noBase = jvbNoBase($taxonomy); if (!in_array($noBase, $this->syncTaxonomies)) { return; } $registrar = Registrar::getInstance($noBase); if (!$registrar->hasFeature('is_content')) { return; } $settings = $registrar->getIntegrationConfig($this->service_name); if (!$settings) { return; } // Similar sync logic as handleSavePost but for terms $this->handleTheTermSave($term_id, $taxonomy, $update, $settings); } protected function handleTheTermSave($term_id, $taxonomy, $update, $settings) { } /******************************************************************* * UTILITIES *******************************************************************/ /** * Get API URL for endpoint */ protected function getApiUrl(string $endpoint, ?string $baseKey = null): string|false { if ($this->hasOAuth && in_array($endpoint, $this->oauth)) { return $endpoint; } if (is_array($this->apiBase)) { if ($baseKey && array_key_exists($baseKey, $this->apiBase)) { $base = $this->apiBase[$baseKey]; } else { $base = ($this->apiBase['base'] ?? reset($this->apiBase)); } } else { $base = $this->apiBase; } if (!$base || $base === '') { $this->logError('API base URL not configured for {$this->>service_name}'); return false; } // Handle named endpoints if (!$this->isValidEndpoint($endpoint)) { $this->logError("{$endpoint} is not a valid endpoint for {$this->service_name}"); return false; } // Build full URL $base = rtrim($base, '/'); $endpoint = ltrim($endpoint, '/'); return "{$base}/{$endpoint}"; } /** * Check if an endpoint is valid, supporting both exact matches and patterns * * @param string $endpoint * @return bool */ protected function isValidEndpoint(string $endpoint): bool { // Remove query parameters for validation $endpointPath = parse_url($endpoint, PHP_URL_PATH); if ($endpointPath === false) { $endpointPath = $endpoint; } foreach ($this->apiEndpoints as $pattern) { // Check for exact match first if ($endpointPath === $pattern) { return true; } if (str_starts_with($endpointPath, $pattern)) { return true; } // Check if pattern contains wildcards (indicated by square brackets) if (strpos($pattern, '[') !== false) { // Convert the pattern to a regex $regexPattern = '#^' . str_replace(['[^/]+'], ['[^/]+'], $pattern) . '(?:\?.*)?$#'; if (preg_match($regexPattern, $endpoint)) { return true; } } } return false; } /** * Categorize API errors for better tracking */ protected function categorizeApiError(int $code): string { return match(true) { $code >= 400 && $code < 404 => 'client_error', $code === 404 => 'not_found', $code === 401 => 'authentication', $code === 403 => 'authorization', $code === 429 => 'rate_limit', $code >= 500 && $code < 600 => 'server_error', default => 'unknown' }; } /** * Determine error severity based on HTTP code */ protected function getErrorSeverity(int $code): string { return match(true) { $code === 429 => 'warning', // Rate limiting $code >= 400 && $code < 500 => 'error', // Client errors $code >= 500 => 'critical', // Server errors default => 'error' }; } /** * Extract error details from response */ protected function extractErrorDetails($decoded, string $body): array { $details = [ 'message' => 'Unknown error', 'code' => null, 'details' => null ]; if ($decoded && isset($decoded['error'])) { if (is_array($decoded['error'])) { $details['message'] = $decoded['error']['message'] ?? json_encode($decoded['error']); $details['code'] = $decoded['error']['code'] ?? null; $details['details'] = $decoded['error']['details'] ?? null; } else { $details['message'] = $decoded['error']; } } elseif ($decoded && isset($decoded['message'])) { $details['message'] = $decoded['message']; } elseif (!empty($body)) { $details['message'] = $body; } return $details; } /** * Extract rate limit information from response */ protected function extractRateLimitInfo($decoded, string $body): array { $info = [ 'retry_after' => null, 'limit' => null, 'remaining' => null, 'reset' => null ]; // Try to extract from decoded response if ($decoded) { $info['retry_after'] = $decoded['retry_after'] ?? $decoded['retry-after'] ?? null; $info['limit'] = $decoded['x-rate-limit-limit'] ?? null; $info['remaining'] = $decoded['x-rate-limit-remaining'] ?? null; $info['reset'] = $decoded['x-rate-limit-reset'] ?? null; } return $info; } /** * Update error statistics */ protected function updateErrorStats(int $code, string $endpoint): void { $this->error_stats['total_errors']++; $this->error_stats['consecutive_errors']++; // Track error types $error_type = $this->categorizeApiError($code); if (!isset($this->error_stats['error_types'][$error_type])) { $this->error_stats['error_types'][$error_type] = 0; } $this->error_stats['error_types'][$error_type]++; // Save stats to cache $this->saveErrorStats(); } /** * Get service name */ public function getServiceName(): string { return $this->service_name; } public function getTitle():string { return $this->title; } public static function title():string { return static::getInstance()->getTitle(); } public static function icon():string { return static::getInstance()->getIcon(); } public static function hasExtraOptions():bool { return static::getInstance()::$hasExtraOptions; } /********************************************************************* RENDERING *********************************************************************/ // public function renderAdditionalOptions() // { // //Default: nothing. // } /** * Render additional action buttons (optional) * Override in integration classes that need extra actions */ // public function renderAdditionalActions(): void // { // // Default: no additional actions // // Override in extensions for service-specific actions // } /** * Get service description (optional) * Override in integration classes for custom descriptions */ public function getServiceDescription(): string { return "Manage your {$this->getServiceName()} integration settings."; } /** * Validate webhook signature * @param string $payload Raw payload body * @param string $signature Signature from headers * @param string $secret Secret key * @param string $algorithm Algorithm used (default: sha256) * @return bool */ protected function verifyWebhookSignature(string $payload, string $signature, string $secret, string $algorithm = 'sha256'): bool { if (empty($signature) || empty($secret)) { return false; } $expected = hash_hmac($algorithm, $payload, $secret); // Use hash_equals for timing-safe comparison return hash_equals($expected, $signature); } public function renderConnection(bool $return = false):string { if ($this->userID && !JVB()->userCanConnect($this->service_name, $this->userID)) { return ''; } $meta = Meta::forOptions($this->userID.'_integrations'); $is_connected = $this->isSetUp(); $credentials = $this->getCredentials(); $admin_only = $this->isOAuthService ? [ 'client_id', 'client_secret', ] : []; ob_start(); ?>

title); ?>

Set Up Not Set Up
0): ?>
Last updated:
instructions)) { ?>
Instructions
    instructions as $instruction) { echo '
  1. '.$instruction.'
  2. '; } ?>
> Initial Setup fields as $name => $config) { if ($is_connected && !empty($credentials[$name])) { if (in_array($name, $admin_only) && !current_user_can('manage_options')) { continue; } ?> : service_name.'_'; echo Form::render($name, '', $config); } } if ($this->hasWebhooks) { echo $this->renderWebhookUrl(); } ?>
isOAuthService) { $this->renderConnectedOAuthStatus(); } ?>
advanced)) { ?>
Advanced Settings advanced as $name => $config) { $config['value'] = $credentials[$name]??''; $config['base'] = $this->service_name.'_'; $config['autocomplete'] = 'off'; Form::render($name,null, $config); } ?>
defaults)) { ?> More Settings
buttons as $action => $label) { if (!$is_connected && $action !== 'save_credentials') { continue; } $title = $confirm = ''; switch ($action) { case 'save_credentials': $title = $label; $label = jvbIcon('floppy-disk'); break; case 'clear_credentials': $title = $label; $label = jvbIcon('plugs'); $confirm = ' data-confirm="Are you sure you want to delete these credentials?"'; break; case 'clear_cache': $title = $label; $label = jvbIcon('arrows-clockwise'); break; } $title = $title === '' ? '' : ' title ="'.$title.'"'; ?>
isSetup()) { return; } $credentials = $this->getCredentials(); $hasCredentials = $this->hasOAuthCredentials(); $returnURL = is_admin() ? admin_url('admin.php?page=jvb-integrations') : (get_the_permalink() ?: home_url()); ?>
> Connected Account
getRedirectUri(); ?>
Token expires:
renderOAuthConnectedOptions(); ?>
updateTested($this->service_name, $this->userID); } public function handleAdminPost(): void { if (!current_user_can('manage_options')) { wp_die('Insufficient permissions'); } $service = $this->getServiceName(); // Verify nonce $nonce_field = 'jvb_integration_nonce_' . $service; $nonce_action = 'jvb_integration_save_' . $service; if (!isset($_POST[$nonce_field]) || !wp_verify_nonce($_POST[$nonce_field], $nonce_action)) { wp_die('Security check failed'); } // Get the action type $action_type = sanitize_text_field($_POST['action_type'] ?? 'save'); // Prepare the request in the format handleAjaxRequest expects $_REQUEST['action'] = $action_type; $_REQUEST['service'] = $service; // Copy all POST data to REQUEST foreach ($_POST as $key => $value) { $_REQUEST[$key] = $value; } // Set up for JSON response capture ob_start(); try { // Call the existing AJAX handler $this->handleAjaxRequest(); $response = ob_get_clean(); // Parse the JSON response $result = json_decode($response, true); if ($result && isset($result['success'])) { if ($result['success']) { $message = $result['message'] ?? ucfirst($service) . ' settings saved successfully!'; $this->setAdminNotice($message, 'success'); } else { $message = $result['message'] ?? 'Failed to save ' . ucfirst($service) . ' settings.'; $this->setAdminNotice($message, 'error'); } } else { // If no proper JSON response, check if connection worked if ($action_type === 'test') { $connected = $this->testConnection(); $message = $connected ? 'Connection successful!' : 'Connection failed. Please check your credentials.'; $this->setAdminNotice($message, $connected ? 'success' : 'error'); } } } catch (Exception $e) { ob_end_clean(); $this->setAdminNotice('Error: ' . $e->getMessage(), 'error'); } // Redirect back to integrations page wp_redirect(admin_url('admin.php?page=jvb-integrations')); exit; } /** * Set admin notice using transients for redirect */ protected function setAdminNotice(string $message, string $type = 'info'): void { $notices = get_transient('jvb_admin_notices') ?: []; $notices[] = [ 'message' => $message, 'type' => $type === 'success' ? 'updated' : 'error' ]; set_transient('jvb_admin_notices', $notices, 30); } /** * Display admin notices from transient */ public static function displayAdminNotices(): void { $notices = get_transient('jvb_admin_notices'); if ($notices) { foreach ($notices as $notice) { ?>

defaults); } public function renderDefaults():void { $types = $this->enabledContentTypes(); if (empty($types)) { return; } $meta = Meta::forOptions($this->userID.'_integrations'); ?>

title?> Defaults:

Find yourself constantly repeating yourself?

Set defaults for different content types and title?>!

defaults as $name => $config) { $config['required'] = false; $config['base'] = $this->service_name.'_'; $config['autocomplete'] = 'off'; echo Form::render($name, null, $config); } foreach ($this->syncPostTypes as $type) { $registrar = Registrar::getInstance($type); $icon = $registrar->getIcon(); $icon = $icon === '' ? jvbDefaultIcon() : $icon; ?>
getSingular()?> Defaults service_name); $fields = $fields->getIntegrationFields(); foreach($fields as $name=>$c) { $c['required'] = false; if ($c['type'] === 'number') { $c['type'] = 'text'; $c['subtype'] = 'number'; } if (array_key_exists('description', $c)) { $c['hint'] = $c['description']; unset($c['description']); } echo Form::render($name, null, $c); } ?>
has_content; } public function getDefaultContentType():string { return $this->defaultContent; } public function enabledContentTypes():array { if (!$this->has_content) { return []; } return array_filter(array_map(function($registrar) { $registrar = Registrar::getInstance($registrar); return $registrar->getIntegration($this->service_name)->getContentType(); }, Registrar::withIntegration($this->service_name))); } protected function getSupportedImage(int $imgID):int { //If this integration supports webp, we can just send the original image id if ($this->supportsWebp) { return $imgID; } //Test if it is in webp format $mimeType = get_post_mime_type($imgID); if ($mimeType !== 'image/webp') { return $imgID; } //Test if we already have converted this image $jpegVersion = get_post_meta($imgID, BASE.'jpeg_version', true); if ($jpegVersion !== '' && is_int($jpegVersion)) { return $jpegVersion; } $uploader = new UploadManager(); $converted = $uploader->convertImageTo($imgID, 'jpeg', 80, false); if ($converted && !is_wp_error($converted)) { update_post_meta($imgID, BASE . 'jpeg_version', $converted['attachment_id']); return $converted['attachment_id']; } return $imgID; } public function getAllowedContent():array { return $this->allowedContent; } /** * Used by JVBase\registrar\helpers\AddIntegrationFields.php * @return array */ public function getAdditionalFields(?string $content_type = null):array { return []; } public function getIcon():string { return $this->icon; } public function canBatchUpdate():bool { return $this->hasBatchUpdate; } public function canBatchCreate():bool { return $this->hasBatchCreate; } public function canBatchDelete():bool { return $this->hasBatchDelete; } }