| | |
| | | |
| | | use Exception; |
| | | use JVBase\managers\Cache; |
| | | use JVBase\managers\ErrorHandler; |
| | | use JVBase\managers\queue\executors\IntegrationExecutor; |
| | | use JVBase\managers\queue\mergers\DefaultMerger; |
| | | use JVBase\managers\queue\TypeConfig; |
| | | use JVBase\managers\UploadManager; |
| | | use JVBase\meta\Form; |
| | | use JVBase\meta\Meta; |
| | | use JVBase\registrar\helpers\AddIntegrationFields; |
| | | use JVBase\registrar\Registrar; |
| | | use WP_Error; |
| | | use WP_Post; |
| | | |
| | | if (!defined('ABSPATH')) { |
| | | exit; |
| | |
| | | { |
| | | use _Base; |
| | | |
| | | //Flag to allow for custom settings (defaults, etc) for an integration in the dashboard |
| | | public bool $hasExtraOptions = false; |
| | | public bool $hasBatchCreate = false; |
| | | public bool $hasBatchUpdate = false; |
| | | public bool $hasBatchDelete = false; |
| | | public bool $canCreateOnUpdate = false; |
| | | |
| | | /** |
| | | * API Configuration |
| | | * These properties define how the integration connects to external services |
| | | */ |
| | | protected string|array $apiBase = ''; // Base URL(s) for API endpoints. Array format: ['base' => '', 'auth' => ''] |
| | | /** |
| | | * @var array<APIEndpoint> An array of endpoints objects, with optional create, update, delete, batch<x>, and batchImport |
| | | */ |
| | | 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) |
| | |
| | | '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->getPostTypes(); |
| | | $this->getTaxonomies(); |
| | | $this->setContentTypes(); |
| | | $this->getUsers(); |
| | | |
| | | if (method_exists($this, 'setContentTypes')) { |
| | | $this->setContentTypes(); |
| | | } |
| | | $this->registerHooks(); |
| | | |
| | | if (method_exists($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); |
| | | $this->syncPostTypes = Registrar::withIntegration($this->service_name, 'post'); |
| | | } |
| | | |
| | | protected function getUsers():void |
| | | { |
| | | $this->syncUsers = Registrar::withIntegration($this->service_name, 'user'); |
| | | } |
| | | |
| | | 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; |
| | | $this->syncTaxonomies = Registrar::withIntegration($this->service_name, 'term'); |
| | | } |
| | | |
| | | 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 |
| | | *********************************************************************/ |
| | |
| | | try { |
| | | // Process and validate credentials |
| | | if (!$this->validateCredentials($credentials)) { |
| | | $this->logError('Invalid credentials'); |
| | | return [ |
| | | 'success' => false, |
| | | 'message' => 'Credentials not formatted correctly' |
| | | ]; |
| | | $this->logError('saveCredentials', 'Invalid credentials'); |
| | | return $this->response(false, 'Credentials not formatted correctly'); |
| | | } |
| | | |
| | | //Merge the new credentials with the old ones (new stuff overriding any old stuff) |
| | | $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(); |
| | | $this->logError('saveCredentials', 'Connection test failed'); |
| | | |
| | | return [ |
| | | 'success' => false, |
| | | 'message' => 'Connection failed. Please check your credentials.', |
| | | 'test_failed' => true |
| | | ]; |
| | | return $this->response(false, 'Connection failed. Please check your credentials'); |
| | | } |
| | | } |
| | | |
| | |
| | | $this->updateLastTestedTime(); |
| | | $this->clearCache(); |
| | | |
| | | return [ |
| | | 'success' => true, |
| | | 'message' => 'Credentials validated and saved successfully', |
| | | 'reload' => true |
| | | ]; |
| | | return $this->response(true, 'Credentials validated and saved successfully'); |
| | | } |
| | | |
| | | return [ |
| | | 'success' => false, |
| | | 'message' => 'Failed to save credentials to database' |
| | | ]; |
| | | return $this->response(false, 'Failed to save credentials to database'); |
| | | |
| | | } catch (\Exception $e) { |
| | | // Revert to old credentials on any error |
| | |
| | | $this->service_name, |
| | | $this->userID |
| | | ); |
| | | $this->credentials = $old; |
| | | $this->initialize(); |
| | | |
| | | return [ |
| | | 'success' => false, |
| | | 'message' => 'Validation error: ' . $e->getMessage() |
| | | ]; |
| | | return $this->response(false, 'Validation error: '.print_r($e->getMessage(), true)); |
| | | } |
| | | } |
| | | |
| | |
| | | public function deleteCredentials():array |
| | | { |
| | | $success = Auth::getInstance()->deleteCredentials($this->service_name, $this->userID); |
| | | return [ |
| | | 'success' => $success |
| | | ]; |
| | | return $this->response(true, 'Deleted successfully'); |
| | | } |
| | | /** |
| | | * Validate credentials before storing |
| | |
| | | /****************************************************************** |
| | | 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 |
| | |
| | | // Let child classes register additional hooks if needed |
| | | $this->registerAdditionalHooks(); |
| | | |
| | | if (!empty($this->syncPostTypes) && is_null($this->userID)) { |
| | | |
| | | if (!empty($this->syncPostTypes)) { |
| | | $this->addSavePost(); |
| | | add_action('transition_post_status', [$this, 'handlePostStatusTransition'], 10, 3); |
| | | |
| | |
| | | add_action('before_delete_post', [$this, 'handleDeletePost'], 10, 1); |
| | | } |
| | | } |
| | | if (!empty($this->syncTaxonomies) && is_null($this->userID)) { |
| | | if (!empty($this->syncTaxonomies)) { |
| | | 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); |
| | | if (method_exists($this, 'registerQueueTypes')) { |
| | | 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 |
| | |
| | | } |
| | | |
| | | // Store credentials |
| | | $this->credentials = array_merge($this->credentials ?? [], $sanitized); |
| | | $this->credentials['last_updated'] = time(); |
| | | $credentials = array_merge($this->loadCredentials() ?? [], $sanitized); |
| | | $credentials['last_updated'] = time(); |
| | | |
| | | // Save to database |
| | | $saved = $this->saveCredentials($this->credentials); |
| | | $saved = $this->saveCredentials($credentials); |
| | | |
| | | if (!$saved) { |
| | | return [ |
| | |
| | | |
| | | /** |
| | | * Ensure service is initialized |
| | | * TODO: I don't think this is necessary anymore. |
| | | */ |
| | | protected function ensureInitialized(): void |
| | | { |
| | |
| | | $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 |
| | |
| | | { |
| | | 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) { |
| | | |
| | | } |
| | | |
| | | |
| | | /******************************************************************* |
| | |
| | | *******************************************************************/ |
| | | |
| | | /** |
| | | * 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 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 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(); |
| | | ?> |
| | | <form id="<?=$this->service_name?>" class="integration <?php echo $is_connected ? 'connected' : 'disconnected'; ?>" |
| | | data-service="<?php echo esc_attr($this->service_name); ?>"> |
| | | <div class="header row x-btw"> |
| | | <h3><?php echo esc_html($this->title); ?></h3> |
| | | <div class="setup"> |
| | | <?php if ($is_connected): ?> |
| | | <span class="indicator connected">●</span> |
| | | <span class="text">Set Up</span> |
| | | <?php else: ?> |
| | | <span class="indicator disconnected">●</span> |
| | | <span class="text">Not Set Up</span> |
| | | <?php endif; ?> |
| | | </div> |
| | | </div> |
| | | |
| | | <?php if ($is_connected && array_key_exists('updated_at', $credentials) && $credentials['updated_at'] > 0): ?> |
| | | <div class="meta"> |
| | | <small>Last updated: <?php echo human_time_diff($credentials['updated_at']) . ' ago'; ?></small> |
| | | </div> |
| | | <?php endif; ?> |
| | | |
| | | <?php |
| | | if (!empty($this->instructions)) { |
| | | ?> |
| | | <details> |
| | | <summary> |
| | | Instructions |
| | | </summary> |
| | | <ol> |
| | | <?php |
| | | foreach ($this->instructions as $instruction) { |
| | | echo '<li>'.$instruction.'</li>'; |
| | | } |
| | | ?> |
| | | </ol> |
| | | </details> |
| | | <?php |
| | | } |
| | | ?> |
| | | <details class="initial-setup"<?= $is_connected?'' : ' open'?>> |
| | | <summary>Initial Setup</summary> |
| | | <?php |
| | | foreach ($this->fields as $name => $config) { |
| | | if ($is_connected && !empty($credentials[$name])) { |
| | | if (in_array($name, $admin_only) && !current_user_can('manage_options')) { |
| | | continue; |
| | | } |
| | | ?> |
| | | <span class="label"><?=$config['label']?>:</span> |
| | | <code> |
| | | <?php |
| | | if (str_contains($name, 'secret')) { |
| | | for ($i = 1; $i<=strlen($credentials[$name]) - 8; $i++) { |
| | | echo '*'; |
| | | } |
| | | echo substr($credentials[$name], -8); |
| | | } else { |
| | | echo $credentials[$name]; |
| | | } |
| | | ?> |
| | | </code> |
| | | <?php |
| | | } else { |
| | | $config['value'] = $credentials[$name]??''; |
| | | $config['autocomplete'] = 'off'; |
| | | $config['base'] = $this->service_name.'_'; |
| | | echo Form::render($name, '', $config); |
| | | } |
| | | } |
| | | if ($this->hasWebhooks) { |
| | | echo $this->renderWebhookUrl(); |
| | | } |
| | | ?> |
| | | </details> |
| | | <?php |
| | | |
| | | if ($this->isOAuthService) { |
| | | $this->renderConnectedOAuthStatus(); |
| | | } |
| | | |
| | | ?> |
| | | |
| | | <div class="integration-content"> |
| | | |
| | | <?php |
| | | |
| | | |
| | | |
| | | if (!empty($this->advanced)) { |
| | | ?> |
| | | <details> |
| | | <summary>Advanced Settings</summary> |
| | | <?php |
| | | foreach ($this->advanced as $name => $config) { |
| | | $config['value'] = $credentials[$name]??''; |
| | | $config['base'] = $this->service_name.'_'; |
| | | $config['autocomplete'] = 'off'; |
| | | Form::render($name,null, $config); |
| | | } |
| | | ?> |
| | | </details> |
| | | <?php |
| | | } |
| | | if (!empty($this->defaults)) { |
| | | ?> |
| | | <a href="<?php echo admin_url('admin.php?page=jvb-integration-' . $this->service_name); ?>" |
| | | class="button"> |
| | | More Settings |
| | | </a> |
| | | <?php |
| | | } |
| | | ?> |
| | | </div> |
| | | <div class="actions row x-btw wrap"> |
| | | <?php |
| | | foreach ($this->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.'"'; |
| | | ?> |
| | | <button type="button" data-action="<?=$action?>"<?=$title?><?=$confirm?>><?=$label?></button> |
| | | <?php |
| | | } |
| | | ?> |
| | | </div> |
| | | </form> |
| | | <?php |
| | | $result = ob_get_clean(); |
| | | if(!$return) { |
| | | echo $result; |
| | | } |
| | | return $result; |
| | | } |
| | | |
| | | protected function renderConnectedOAuthStatus(): void |
| | | { |
| | | if (!$this->isSetup()) { |
| | | return; |
| | | } |
| | | $credentials = $this->getCredentials(); |
| | | $hasCredentials = $this->hasOAuthCredentials(); |
| | | $returnURL = is_admin() ? admin_url('admin.php?page=jvb-integrations') : (get_the_permalink() ?: home_url()); |
| | | ?> |
| | | |
| | | <details <?= $hasCredentials?' open':''?>> |
| | | <summary> |
| | | <?php if ($hasCredentials) { ?> |
| | | Connected Account |
| | | <?php } else { ?> |
| | | <div class="oauth-connect"> |
| | | <a href="<?php echo esc_url($this->getOAuthUrl($returnURL)); ?>" |
| | | class="button button-primary jvb-oauth-connect" |
| | | data-service="<?php echo esc_attr($this->service_name); ?>"> |
| | | <?php echo jvbIcon($this->icon); ?> |
| | | Authorize Connection |
| | | </a> |
| | | </div> |
| | | <?php } ?> |
| | | <div class="connection-status <?= $hasCredentials ? 'connected' : 'disconnected' ?>"> |
| | | <span class="status-indicator">●</span> |
| | | <span><?= $hasCredentials ? 'Connected' : 'Not Connected' ?></span> |
| | | </div> |
| | | </summary> |
| | | <label>OAuth Redirect URL:</label> |
| | | <code> |
| | | <?= $this->getRedirectUri(); ?> |
| | | </code> |
| | | |
| | | |
| | | <?php if (!empty($credentials['updated_at'])): ?> |
| | | <div class="oauth-meta"> |
| | | <small>Token expires: <?php |
| | | echo isset($credentials['expires_at']) |
| | | ? human_time_diff($credentials['expires_at']) |
| | | : 'Never'; |
| | | ?></small> |
| | | </div> |
| | | <?php endif; |
| | | // Allow child classes to add service-specific connected UI |
| | | $this->renderOAuthConnectedOptions(); |
| | | ?> |
| | | </details> |
| | | <?php |
| | | } |
| | | |
| | | protected function renderOAuthConnectedOptions():void |
| | | { |
| | | |
| | | } |
| | | |
| | | |
| | | /** |
| | | * Update last tested time |
| | | */ |
| | |
| | | $cred->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) { |
| | | ?> |
| | | <div class="notice notice-<?php echo esc_attr($notice['type']); ?> is-dismissible"> |
| | | <p><?php echo esc_html($notice['message']); ?></p> |
| | | </div> |
| | | <?php |
| | | } |
| | | delete_transient('jvb_admin_notices'); |
| | | } |
| | | } |
| | | |
| | | public function hasDefaults():bool |
| | | { |
| | | return !empty($this->defaults); |
| | | } |
| | | |
| | | public function renderDefaults():void |
| | | { |
| | | $types = $this->enabledContentTypes(); |
| | | if (empty($types)) { |
| | | return; |
| | | } |
| | | $meta = Meta::forOptions($this->userID.'_integrations'); |
| | | ?> |
| | | <form> |
| | | <h1><?= $this->title?> Defaults:</h1> |
| | | <p>Find yourself constantly repeating yourself?</p> |
| | | <p>Set defaults for different content types and <?=$this->title?>!</p> |
| | | <?php |
| | | foreach ($this->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; |
| | | ?> |
| | | <details> |
| | | <summary><?= jvbIcon($icon) ?><?= $registrar->getSingular()?> Defaults</summary> |
| | | <?php |
| | | $fields = new AddIntegrationFields($this->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); |
| | | } |
| | | ?> |
| | | </details> |
| | | <?php |
| | | } |
| | | ?> |
| | | </form> |
| | | <?php |
| | | } |
| | | |
| | | public function hasContent():bool |
| | | { |
| | | return $this->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 |
| | |
| | | } |
| | | |
| | | |
| | | public function getAllowedContent():array |
| | | { |
| | | return $this->allowedContent; |
| | | } |
| | | |
| | | /** |
| | | * Used by JVBase\registrar\helpers\AddIntegrationFields.php |
| | | * @return array |
| | |
| | | return $this->icon; |
| | | } |
| | | |
| | | |
| | | |
| | | public function canBatchUpdate():bool |
| | | { |
| | | return $this->hasBatchUpdate; |
| | | } |
| | | public function canBatchCreate():bool |
| | | { |
| | | return $this->hasBatchCreate; |
| | | } |
| | | public function canBatchDelete():bool |
| | | { |
| | | return $this->hasBatchDelete; |
| | | } |
| | | } |