Jake Vanderwerf
2026-07-10 f94860aacd6200fb24c9e7431eb379a368cb392d
inc/integrations/Integrations.php
@@ -2,14 +2,22 @@
namespace JVBase\integrations;
use Exception;
use JVBase\managers\CacheManager;
use JVBase\managers\UploadManager;
use JVBase\meta\MetaManager;
use JVBase\managers\Cache;
use JVBase\managers\queue\executors\IntegrationExecutor;
use JVBase\managers\queue\mergers\DefaultMerger;
use JVBase\managers\queue\TypeConfig;
use JVBase\meta\Form;
use JVBase\meta\Meta;
use JVBase\managers\ErrorHandler;
use JVBase\registrar\helpers\AddIntegrationFields;
use JVBase\registrar\Registrar;
use WP_Error;
use WP_Post;
use WP_Query;
use WP_REST_Request;
use WP_REST_Response;
use WP_Term;
use WP_User;
if (!defined('ABSPATH')) {
   exit;
@@ -26,6 +34,21 @@
 */
abstract class Integrations
{
   //Flag to allow for custom settings (defaults, etc) for an integration in the dashboard
   public static bool $hasExtraOptions = false;
   public bool $hasBatchCreate = false;
   public bool $hasBatchUpdate = false;
   public bool $hasBatchDelete = false;
   public bool $canCreateOnUpdate = false;
   /**
    * Queue types
    * These types match with IntegrationExecutor
    */
   protected static string $syncTo;
   protected static string $deleteFrom;
   protected static string $syncFrom;
   protected static string $syncCustomer;
   protected static string $import;
   /**
    * API Configuration
    * These properties define how the integration connects to external services
@@ -55,14 +78,14 @@
    * Used for UI rendering in admin interfaces
    */
   public string $title;  // Human-readable service name (e.g., 'Google My Business')
   public string $icon;   // Phosphoricons icon slug
   public string $icon = '';   // Phosphoricons icon slug
   /**
    * Credentials & State
    */
   protected array $credentials = [];      // Service credentials (API keys, tokens, etc.)
   protected ?int $userID = null;          // User context for user-specific integrations
   private bool $token_refresh_attempted = false;  // Circuit breaker for token refresh
   protected array $fields = [];       // The fields to generate that become credentials
   protected array $advanced = [];  // The fields that are optional settings
@@ -91,12 +114,13 @@
      'test_connection' => 'Test Connection',
   ];
   protected array $allowedContent = [];
   /**
    * Caching Configuration
    */
   protected ?string $cacheName = null;
   protected CacheManager $cache;
   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)
@@ -131,8 +155,8 @@
      '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 querying the global JVB_CONTENT 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 querying the global JVB_CONTENT if the integration name exists as a key in [ 'integrations' => []]
   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
   /**
@@ -163,11 +187,11 @@
   protected bool $is_healthy = true;
   protected bool $handleWebhooks = false;
   public function __construct(?int $userID = null)
   protected function __construct(?int $userID = null)
   {
      $this->cacheName = $this->cacheName ?: $this->service_name;
      $this->userID = $userID;
      $this->cache = CacheManager::for('integrations_' . $this->cacheName, $this->ttl);
      $this->cache = Cache::for('integrations_' . $this->cacheName, $this->ttl);
      // Load error stats from cache
      $this->loadErrorStats();
@@ -194,9 +218,20 @@
         ];
      }
      self::$syncTo = $this->service_name.'_sync_to';
      self::$deleteFrom = $this->service_name.'_delete_from';
      self::$syncFrom = $this->service_name.'_sync_from';
      //TODO: What is the difference between sync_from and import?
      self::$import = $this->service_name.'import_from';
      add_filter('jvbShouldRenderMeta', [$this, 'checkRenderField'], 10, 4);
   }
   protected function addFilters():bool
   {
      return is_null($this->userID);
   }
   protected function setContentTypes():void
   {
@@ -258,10 +293,6 @@
      if (!empty($this->credentials['expires_at'])) {
         $expires_at = intval($this->credentials['expires_at']);
         if ($expires_at <= time()) {
            // Token expired, try to refresh
            if (!empty($this->credentials['refresh_token'])) {
               return $this->refreshOAuthToken();
            }
            return false;
         }
      }
@@ -295,23 +326,7 @@
   protected function getPostTypes(): void
   {
      $key = BASE . $this->service_name . '_sync_post_types';
      $postTypes = get_option($key, false);
      if (!$postTypes) {
         $postTypes = [];
         // Get from JVB_CONTENT
         foreach (JVB_CONTENT as $type => $config) {
            if (array_key_exists('integrations', $config) && array_key_exists($this->service_name, $config['integrations'])) {
               $postTypes[] = $type;
            }
         }
         update_option($key, $postTypes);
      }
      $this->syncPostTypes = $postTypes;
      $this->syncPostTypes = Registrar::withIntegration($this->service_name);
   }
   protected function getTaxonomies():void
@@ -322,11 +337,10 @@
      if (!$taxonomies) {
         // Combine both content and taxonomy filtering
         $taxonomies = [];
         // Get from JVB_TAXONOMY (content taxonomies)
         foreach (JVB_TAXONOMY as $type => $config) {
            if (jvbCheck('is_content', $config) &&
               isset($config['integrations'][$this->service_name])) {
               $taxonomies[] = $type;
         foreach (Registrar::withFeature('is_content', 'term') as $type) {
            $registrar = Registrar::getInstance($type);
            if ($registrar->hasIntegration($this->service_name)) {
               $taxonomies[] = $registrar->getSlug();
            }
         }
@@ -434,7 +448,6 @@
      } else {
         $result =  $this->$method();
      }
      error_log('Action result: '.print_r($result, true));
      if (is_wp_error($result)) {
         return [
            'success' => false,
@@ -655,9 +668,7 @@
      }
      try {
         error_log('Credentials to save: '.print_r($this->credentials, true));
         if ($this->isOAuthService && !$this->hasOAuthCredentials()){
            error_log('Just saving credentials, we don\'t have OAuth setup yet...');
            //If this is an OAuth service, we might only be saving the app credentials first
            $result = true;
         } else {
@@ -676,9 +687,9 @@
   protected function clearCache():array
   {
      $success = $this->cache->clear();
      $this->cache->flush();
      return [
         'success'   => $success,
         'success'   => true,
      ];
   }
@@ -765,6 +776,9 @@
      array  $options = []
   ): array|WP_Error
   {
      if (!$this->is_healthy) {
         return new WP_Error('unhealthy', 'Connection marked unhealthy. Skipping fetch');
      }
      $this->ensureInitialized();
      if (!$this->isSetUp()){
         $this->logError('Connection not setup for '.$this->service_name, [
@@ -778,24 +792,21 @@
         return new WP_Error('rate_limit', 'Rate limit exceeded. Please try again later.');
      }
      // Debug: Check if credentials are loaded
      error_log('['.$this->service_name.'] Make Request - Credentials loaded: ' . (!empty($this->credentials) ? 'Yes' : 'No'));
      error_log('With Credentials: '.print_r($this->credentials, true));
      $attempt = 0;
      $lastError = null;
      while ($attempt < $this->maxRetries) {
         try {
            $this->logDebug('[Integrations] Making request to '.$this->service_name);
//          $this->logDebug('[Integrations] Making request to '.$this->service_name);
            $result = $this->executeRequest($method, $endpoint, $data, $baseKey, $options);
            if (!$result) {
               return new WP_Error('Request Error');
            }
            $this->recordRequest($method, $endpoint);
            $this->logDebug('[Integrations]', [
               'response' => $result
            ]);
//          $this->logDebug('[Integrations]', [
//             'response' => $result
//          ]);
            // Reset error stats on success
            $this->resetErrorStats();
            return $result;
@@ -844,7 +855,7 @@
         return null;
      }
      $this->logDebug("$method request to: $url: ".print_r($args, true));
//    $this->logDebug("$method request to: $url: ".print_r($args, true));
      // Make the request
      $response = match($method) {
@@ -875,9 +886,9 @@
         if ($retry_count === 0) {
            $retry_count++;
            $this->logDebug('Got 401, attempting token refresh...');
//          $this->logDebug('Got 401, attempting token refresh...');
            if ($this->refreshOAuthToken()) {
               $this->logDebug('Token refreshed successfully, retrying request...');
//             $this->logDebug('Token refreshed successfully, retrying request...');
               // Rebuild request args with new token
               $args = $this->buildRequestArgs($method, $data, $options);
@@ -933,7 +944,8 @@
      if (!$force && $ttl > 0) {
         $cached = $this->cache->get($cacheKey);
         if ($cached !== false) {
            $this->logDebug("Cache hit for: $cacheKey");
//          $this->logDebug("Cache hit for: $cacheKey");
            return $cached;
         }
      }
@@ -1046,11 +1058,8 @@
   ):bool {
      $queue = JVB()->queue();
      // Add service prefix to operation type
      $operation_type = strtolower($this->service_name) . '_' . $type;
      $queued =  $queue->queueOperation(
         $operation_type,
         $type,
         $this->userID ?? 0,
         array_merge($data, ['service' => $this->service_name, 'user' => $this->userID??0]),
         array_merge([
@@ -1192,41 +1201,91 @@
    */
   protected function registerHooks(): void
   {
      add_filter(BASE . 'handle_bulk_operation', [$this, 'processOperation'], 10, 3);
      add_action( 'wp_ajax_'.BASE.$this->service_name.'_oauth_callback', [$this, 'handleAjaxResponse'] );
      add_action( 'wp_ajax_nopriv_'.BASE.$this->service_name.'_oauth_callback', [$this, 'handleAjaxResponse'] );
      if ($this->handleWebhooks){
      //Handled by IntegrationExecutor now
//    add_filter(BASE . 'handle_bulk_operation', [$this, 'processOperation'], 10, 3);
      if (is_null($this->userID)) {
         add_action( 'wp_ajax_'.BASE.$this->service_name.'_oauth_callback', [$this, 'handleAjaxResponse'] );
         add_action( 'wp_ajax_nopriv_'.BASE.$this->service_name.'_oauth_callback', [$this, 'handleAjaxResponse'] );
      }
      if ($this->handleWebhooks && is_null($this->userID)){
         $this->registerWebhookEndpoint();
      }
      // Let child classes register additional hooks if needed
      $this->registerAdditionalHooks();
      if (!empty($this->syncPostTypes)) {
         add_action('save_post', [$this, 'handleSavePost'], 20, 3);
      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)) {
      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.
         }
   public function handleAjaxResponse()
   {
      error_log('Ajax Response: '.print_r($_GET, true));
      $code = $_GET['code'];
      $state =  $_GET['state'];
      error_log('OAuth Callback - Code: ' . $code);
      error_log('OAuth Callback - State: ' . $state);
      $state_parts = explode('|', $state);
      $state_key = $state_parts[0] ?? '';
@@ -1234,16 +1293,13 @@
      $user_id = ($user_id === 0) ? null : $user_id;
      $return_url = isset($state_parts[2]) ? base64_decode($state_parts[2]) : admin_url('admin.php?page=jvb-integrations');
      error_log('Service: '.print_r($this->service_name, true));
      $state_data = get_transient('oauth_state_' . $state_key);
      error_log('State Data: '.print_r($state_data, true));
      if (!$state_data || $state_data['service'] !== $this->service_name) {
         wp_die('Invalid state parameter', 'OAuth Error');
      }
      // Delete the transient to prevent reuse
      delete_transient('oauth_state_' . $state_key);
      error_log('Return URL: '.print_r($return_url, true));
      // Handle error from OAuth provider
      if (array_key_exists('error', $_GET)) {
         $error_description = $_GET['error_description'] ?? 'Authorization denied';
@@ -1395,17 +1451,29 @@
         if ($this->isOAuthService && $this->hasOAuthCredentials()) {
            // Check if token is expired first
            if (!$this->isOAuthValid()) {
               $this->logDebug('OAuth token expired, attempting refresh');
               if (!$this->refreshOAuthToken()) {
                  $this->logError('Failed to refresh expired OAuth token');
               // Only attempt refresh once per request
               if (!$this->token_refresh_attempted) {
                  $this->token_refresh_attempted = true;
//                $this->logDebug('OAuth token expired, attempting refresh');
                  if (!$this->refreshOAuthToken()) {
                     $this->logError('Failed to refresh expired OAuth token - stopping execution');
                     // Token refresh failed - DO NOT continue making API requests
                     return;
                  }
               } else {
                  // Already attempted refresh in this request
//                $this->logDebug('Token refresh already attempted, skipping');
                  return;
               }
            }
            // Check if we should proactively refresh (before expiry)
            elseif ($this->shouldRefreshToken()) {
               $this->logDebug('OAuth token should be refreshed proactively');
            elseif ($this->shouldRefreshToken() && !$this->token_refresh_attempted) {
               $this->token_refresh_attempted = true;
//             $this->logDebug('OAuth token should be refreshed proactively');
               if (!$this->refreshOAuthToken()) {
                  $this->logError('Failed to proactively refresh OAuth token');
                  // Not critical - token is still valid
                  // Not critical - token is still valid, so continue
               }
            }
         }
@@ -1414,50 +1482,6 @@
   }
   /**
    * Switch user context
    */
   public function switchUser(int $user_id): void
   {
      if ($this->userID === $user_id) {
         return;
      }
      // Clean up current context
      $this->cleanup();
      // Switch context
      $this->userID = $user_id;
      $this->credentials = [];
      $this->ensureInitialized();
   }
   public function getAsUser(int $user_id) {
      return new $this($user_id);
   }
   /**
    * Clean up resources
    */
   protected function cleanup(): void
   {
      // Clear sensitive data
      $this->credentials = [];
      // Clear request history
      $this->request_history = [];
   }
   /**
    * Destructor - ensure cleanup
    */
   public function __destruct()
   {
      $this->cleanup();
   }
   /***************************************************************
      ERROR HANDLING
    ***************************************************************/
@@ -1626,12 +1650,7 @@
         $params = $this->addOAuthParams($params);
      }
      $auth_url = $this->oauth['authorize'] . '?' . http_build_query($params);
      // Debug log for troubleshooting
      error_log("Generated OAuth URL for {$this->service_name}: " . $auth_url);
      return $auth_url;
      return $this->oauth['authorize'] . '?' . http_build_query($params);
   }
   /**
@@ -1923,7 +1942,6 @@
         return false;
      }
      // Build refresh request data
      $request_data = [
         'client_id' => $this->credentials['client_id'],
         'client_secret' => $this->credentials['client_secret'],
@@ -1931,12 +1949,24 @@
         'grant_type' => 'refresh_token'
      ];
      // Use centralized OAuth request method
      $response = $this->makeOAuthRequest('POST', $this->oauth['token'], $request_data);
      if (is_wp_error($response)) {
         $this->logError('Failed to refresh Square token', [
            'error' => $response->get_error_message()
         $error_message = $response->get_error_message();
         if (str_contains($error_message, 'invalid_grant')) {
            $this->logError('OAuth refresh token is invalid - user must re-authorize', [
               'error' => $error_message
            ], 'critical');
            // Mark unhealthy immediately
            $this->error_stats['consecutive_errors'] = $this->error_threshold;
            $this->is_healthy = false;
            $this->saveErrorStats();
         }
         $this->logError('Failed to refresh OAuth token for '.$this->service_name, [
            'error' => $error_message
         ]);
         return false;
      }
@@ -1945,7 +1975,7 @@
         $this->credentials['access_token'] = $response['access_token'];
         $this->credentials['expires_at'] = time() + ($response['expires_in'] ?? 2592000); // 30 days
         // Note: Square returns the SAME refresh token
         // Note: Some services return the SAME refresh token
         if (isset($response['refresh_token'])) {
            $this->credentials['refresh_token'] = $response['refresh_token'];
         }
@@ -2075,20 +2105,11 @@
    */
   protected function mapFieldsToService(int $postID, array $mapping): array
   {
      $meta_manager = new MetaManager($postID, 'post');
      $post = get_post($postID);
      $meta_manager = Meta::forPost($postID);
      $service_data = [];
      foreach ($mapping as $wp_field => $service_field) {
         $value = null;
         // Check if it's a post field
         if (property_exists($post, $wp_field)) {
            $value = $post->$wp_field;
         } else {
            // It's a meta field
            $value = $meta_manager->getValue($wp_field);
         }
         $value = $meta_manager->get($wp_field);
         if ($value !== null && $value !== '') {
            $this->setNestedValue($service_data, $service_field, $value);
@@ -2127,52 +2148,69 @@
    */
   public function handleSavePost(int $postID, WP_Post $post, bool $update): void
   {
      error_log('Testing For Save Post');
      if (jvbNoSaveIt($postID, $post)) {
         error_log('Excluded by jvbNoSaveIt');
      if (!is_null($this->userID)) {
         return;
      }
      if (empty($this->syncPostTypes)) {
         error_log('No Syncable post types');
      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;
      }
      $config = JVB_CONTENT[jvbNoBase($post->post_type)]??null;
      if (!$config) {
         error_log('No Config set');
      $registrar = Registrar::getInstance($postType);
      if (!$registrar){
         return;
      }
      $settings = $config['integrations'][$this->service_name]??null;
      $settings = $registrar->hasIntegration($this->service_name)??null;
      if (!$settings) {
         error_log('No 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]);
      error_log('Fields to check: '.print_r($fields, true));
      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('Do not keep synced, not syncing with '.$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 published and not already shared');
         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 for processing...');
      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 MetaManager($postID, $type);
      $meta = new Meta($postID, $type);
      $fieldsToCheck = [
         'share_to_' . $this->service_name,
         '_keep_synced_' . $this->service_name,
@@ -2255,13 +2293,13 @@
      if (!in_array($noBase, $this->syncTaxonomies)) {
         return;
      }
      // Check if taxonomy is content-type
      $config = JVB_TAXONOMY[$noBase] ?? null;
      if (!$config || !($config['is_content'] ?? false)) {
      $registrar = Registrar::getInstance($noBase);
      if (!$registrar->hasFeature('is_content')) {
         return;
      }
      $settings = $config['integrations'][$this->service_name] ?? null;
      $settings = $registrar->getIntegrationConfig($this->service_name);
      if (!$settings) {
         return;
      }
@@ -2274,93 +2312,6 @@
   }
   protected function getTermFieldMapping(string $taxonomy): array
   {
      return apply_filters(
         "jvb_{$this->service_name}_term_field_mapping",
         [],
         $taxonomy,
         $this
      );
   }
   /**
    * Share post to external service (override in child classes)
    */
   protected function sharePost(WP_Post $post): ?string
   {
      throw new Exception('sharePost must be implemented by child class');
   }
   /**
    * Update shared post on external service (override in child classes)
    */
   protected function updateSharedPost(WP_Post $post, string $external_id): void
   {
      throw new Exception('updateSharedPost must be implemented by child class');
   }
   /**
    * Remove post from external service (override in child classes)
    */
   protected function unsharePost(WP_Post $post, string $external_id): void
   {
      throw new Exception('unsharePost must be implemented by child class');
   }
   /**
    * Get sync capabilities
    */
   public function getSyncCapabilities(): array
   {
      return $this->canSync;
   }
   /**
    * Check if a specific sync capability is supported
    */
   public function supportsSyncCapability(string $capability): bool
   {
      return $this->canSync[$capability] ?? false;
   }
   /**
    * Get posts shared to this service
    */
   public function getSharedPosts(array $args = []): array
   {
      $defaults = [
         'post_type' => $this->syncPostTypes,
         'meta_query' => [
            [
               'key' => BASE."_{$this->service_name}_item_id",
               'compare' => 'EXISTS'
            ]
         ],
         'posts_per_page' => -1
      ];
      $args = wp_parse_args($args, $defaults);
      return get_posts($args);
   }
   /**
    * Check if post is shared to this service
    */
   public function isPostShared(int $postID): bool
   {
      $external_id = get_post_meta($postID, BASE."_{$this->service_name}_item_id", true);
      return $external_id !== '';
   }
   /**
    * Get external ID for a post
    */
   public function getPostExternalId(int $postID): ?string
   {
      $external_id = get_post_meta($postID, BASE."_{$this->service_name}_item_id", true);
      return $external_id ?: null;
   }
   /*******************************************************************
    * UTILITIES
@@ -2638,11 +2589,6 @@
      ];
      $this->is_healthy = true;
      $this->saveErrorStats();
      $this->logDebug('Integration health manually reset', [
         'reset_by' => get_current_user_id(),
         'reset_time' => time()
      ]);
   }
   /**
@@ -2711,6 +2657,20 @@
      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
    *********************************************************************/
@@ -2746,14 +2706,6 @@
      return strtolower($this->service_name);
   }
   /**
    * Refresh credentials from storage and re-initialize
    */
   public function refreshCredentials(): void
   {
      $this->credentials = [];
      $this->ensureInitialized();
   }
   /**
    * Enhanced webhook handling with common patterns
@@ -2777,9 +2729,6 @@
      // Check for duplicate processing (idempotency)
      if ($this->isWebhookProcessed($payload)) {
         $this->logDebug('Webhook already processed', [
            'webhook_id' => $this->extractWebhookId($payload)
         ]);
         return true; // Return true to prevent retries
      }
@@ -2941,7 +2890,7 @@
         return '';
      }
      $meta = new MetaManager($this->userID, 'integrations');
      $meta = Meta::forOptions($this->userID.'_integrations');
      $is_connected = $this->isSetUp();
      $credentials = $this->getCredentials();
@@ -2954,7 +2903,7 @@
      ?>
      <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 btw">
         <div class="header row x-btw">
            <h3><?php echo esc_html($this->title); ?></h3>
            <div class="setup">
               <?php if ($is_connected): ?>
@@ -3018,7 +2967,7 @@
                  $config['value'] = $credentials[$name]??'';
                  $config['autocomplete'] = 'off';
                  $config['base'] = $this->service_name.'_';
                  $meta->render('form', $name, $config);
                  echo Form::render($name, '', $config);
               }
            }
            if ($this->handleWebhooks) {
@@ -3049,7 +2998,7 @@
                     $config['value'] = $credentials[$name]??'';
                     $config['base'] = $this->service_name.'_';
                     $config['autocomplete'] = 'off';
                     $meta->render('form', $name, $config);
                     Form::render($name,null, $config);
                  }
                  ?>
               </details>
@@ -3065,7 +3014,7 @@
            }
            ?>
         </div>
         <div class="actions row btw wrap">
         <div class="actions row x-btw wrap">
            <?php
            foreach ($this->buttons as $action => $label) {
               if (!$is_connected && $action !== 'save_credentials') {
@@ -3192,7 +3141,6 @@
      // Verify nonce
      $nonce_field = 'jvb_integration_nonce_' . $service;
      $nonce_action = 'jvb_integration_save_' . $service;
      error_log('handleAdminPost: '.print_r($_POST, true));
      if (!isset($_POST[$nonce_field]) || !wp_verify_nonce($_POST[$nonce_field], $nonce_action)) {
         wp_die('Security check failed');
      }
@@ -3289,7 +3237,7 @@
      if (empty($types)) {
         return;
      }
      $meta = new MetaManager($this->userID, 'integrations');
      $meta = Meta::forOptions($this->userID.'_integrations');
      ?>
      <form>
         <h1><?= $this->title?> Defaults:</h1>
@@ -3301,16 +3249,19 @@
            $config['base'] = $this->service_name.'_';
            $config['autocomplete'] = 'off';
            $meta->render('form', $name, $config);
            echo Form::render($name, null, $config);
         }
         foreach ($this->syncPostTypes as $type) {
            $config = JVB_CONTENT[$type];
            $registrar = Registrar::getInstance($type);
            $icon = $registrar->getIcon();
            $icon = $icon === '' ? jvbDefaultIcon() : $icon;
            ?>
            <details>
               <summary><?= jvbIcon($config['icon']) ?><?= $config['singular']?> Defaults</summary>
               <summary><?= jvbIcon($icon) ?><?= $registrar->getSingular()?> Defaults</summary>
               <?php
               $fields = new \JVBase\registry\providers\IntegrationFieldProvider();
               $fields = $fields->getIntegrationFields($this->service_name, $config);
               $fields = new AddIntegrationFields($this->service_name);
               $fields = $fields->getIntegrationFields();
               foreach($fields as $name=>$c) {
                  $c['required'] = false;
                  if ($c['type'] === 'number') {
@@ -3321,7 +3272,7 @@
                     $c['hint'] = $c['description'];
                     unset($c['description']);
                  }
                  $meta->render('form', $name, $c);
                  echo Form::render($name, null, $c);
               }
               ?>
            </details>
@@ -3347,28 +3298,10 @@
         return [];
      }
      $key = BASE.$this->service_name.'_enabled_content_types';
      $enabled = get_option($key);
      if (!$enabled) {
         $enabled = [];
         foreach (array_merge(JVB_CONTENT, JVB_TAXONOMY) as $content => $config) {
            if (!array_key_exists('integrations', $config)) {
               continue;
            }
            if (!array_key_exists($this->service_name, $config['integrations'])) {
               continue;
            }
            if (!array_key_exists('content_type', $config['integrations'][$this->service_name])) {
               continue;
            }
            $type = $config['integrations'][$this->service_name]['content_type'];
            if (!in_array($type, $enabled)) {
               $enabled[] = $type;
            }
         }
         update_option($key, $enabled);
      }
      return $enabled;
      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
@@ -3529,4 +3462,518 @@
         throw new Exception('Failed to save JPEG image');
      }
   }
   /**
    * Reset token refresh attempt flag
    * Called automatically when switching users
    */
   protected function resetTokenRefreshFlag(): void
   {
      $this->token_refresh_attempted = false;
   }
   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 getServiceItemID(int $itemID, string $type = 'post'):string
   {
      return match($type) {
         'post'   => Meta::forPost($itemID)->get("_{$this->service_name}_item_id"),
         'term','taxonomy' => Meta::forTerm($itemID)->get("_{$this->service_name}_item_id"),
         'user'   => Meta::forUser($itemID)->get("_{$this->service_name}_item_id"),
         default => ''
      };
   }
   public function canBatchUpdate():bool
   {
      return $this->hasBatchUpdate;
   }
   public function canBatchCreate():bool
   {
      return $this->hasBatchCreate;
   }
   public function canBatchDelete():bool
   {
      return $this->hasBatchDelete;
   }
   protected function updateItemStatus(int|array $items, string $status = 'pending', string $type = 'post'):void
   {
      if (!is_array($items)) {
         $items = [$items];
      }
      foreach ($items as $ID) {
         match ($type) {
            'post'   => Meta::forPost($ID)->set('_'.$this->service_name.'_sync_status', $status),
            'term'   => Meta::forTerm($ID)->set('_'.$this->service_name.'_sync_status', $status),
            'user'   => Meta::forUser($ID)->set('_'.$this->service_name.'_sync_status', $status),
            default => false
         };
      }
   }
   public function updateBatchToService(array $data):array
   {
      $type = $data['type']??'post';
      $newlyCreated = [];
      if (!$this->canCreateOnUpdate) {
         $created = array_filter($data['items'], function($ID) use ($type) {
            return !empty($this->getServiceItemID($ID, $type));
         });
         //Test to see if we have any that haven't been created yet.
         //For some services, items may have to be created before they can be updated
         if (count($created) !== count($data['items'])) {
            $notCreated = array_filter($data['items'], function($ID) use ($type) {
               return empty($this->getServiceItemID($ID, $type));
            });
            $newData = $data;
            $newData['items'] = $notCreated;
            $newlyCreated = $this->createBatchToService($newData);
         }
         // If we don't have any that are created, just send the noUpdatedItems response, with any newly created items added
         if (empty($created)) {
            return $this->noUpdatedItems($newlyCreated);
         }
         $data['items'] = $created;
      }
      $items = $this->formatItems($data['items'], $type);
      if (empty($items)) {
         return $this->noUpdatedItems($newlyCreated);
      }
      if ($this->hasBatchUpdate) {
         $response = $this->sendBatchUpdate($items);
         if (!is_wp_error($response)) {
            $result = $this->processBatchUpdateResponse($data, $response);
         } else {
            $this->logError('Batch update failed',[
               'method' => 'updateBatchToService',
               'item_ids'  => $data['items'],
               'error'     => $response
            ]);
            $this->updateItemStatus($data['items'], 'error');
            $result = [
               'outcome'   => 'failed_permanent',
               'result' => 'Could not update items'
            ];
         }
      } else {
         $success = $errors = [];
         //Does not have batch update, manually update each one
         foreach ($items as $item) {
            $itemResult = $this->updateOne($item);
            if (!is_wp_error($itemResult)) {
               $success[] = $itemResult;
            } else {
               $errors[] = $itemResult;
            }
         }
         $result = [
            'outcome'   => empty($errors) ? 'success' : (empty($success) ? 'failed' : 'partial'),
            'result' => [
               'success'   => $success,
               'errors' => $errors
            ]
         ];
      }
      return array_merge($result, $newlyCreated);
   }
   /**
    * To be implemented by integration extension. Updates a single item
    * @param array $item
    * @return array|WP_Error
    */
      protected function updateOne(array $item):array|WP_Error
      {
         return new WP_Error('invalid', 'Integration '.$this->service_name.' must implement its own updateOne method');
      }
   /**
    * Overridden by child classes
    * @param array $items
    * @return array|WP_Error
    */
      protected function sendBatchUpdate(array $items):array|WP_Error
      {
         return new WP_Error('invalid', 'Integration '.$this->service_name.' must implement its own sendBatchUpdate');
      }
   /**
    * Overridden by child classes
    * @param array $items
    * @return array|WP_Error
    */
      protected function sendBatchCreate(array $items):array|WP_Error
      {
         return new WP_Error('invalid', 'Integration '.$this->service_name.' must implement its own sendBatchCreate');
      }
   /**
    * To be implemented by extensions
    * @param array $data
    * @param array $response
    * @return array
    */
      protected function processBatchUpdateResponse(array $data, array $response):array
      {
         return [
            'outcome'   => 'failed',
            'result' => [
               'message'   => $this->service_name.' should implement processBatchUpdateResponse.'
            ]
         ];
      }
   /**
    * To be implemented by extensions
    * @param array $data
    * @param array $response
    * @return array
    */
      protected function processBatchCreateResponse(array $data, array $response):array
      {
         return [
            'outcome'   => 'failed',
            'result' => [
               'message'   => $this->service_name.' should implement processBatchCreateResponse.'
            ]
         ];
      }
   /**
    * Defaults to an array of formatted items. Can be overridden in child classes
    * @param array $itemIDs
    * @param string $type
    * @return array
    */
      protected function formatItems(array $itemIDs, string $type):array
      {
         $items = [];
         foreach ($itemIDs as $ID) {
            try {
               $items[] = $this->formatForService($ID, $type);
            } catch (Exception $e){
               $this->logError('Could not format Item for service',[
                  'itemID' => $ID,
                  'type'   => $type,
                  'message'   => $e->getMessage(),
               ]);
            }
         }
         return $items;
      }
   /**
    * Must be defined according to how each service needs it to be
    * @param int $itemID
    * @param string $type
    * @return array
    * @throws Exception
    */
      protected function formatForService(int $itemID, string $type = 'post'):array
      {
         throw new Exception('formatForService must be implemented by child class');
      }
      protected function noUpdatedItems(array $created):array
      {
         $result =  [
            'outcome'   => 'success',
            'result' => [
               'message'   => 'No items to update',
               'updated'   => [],
               'errors' => [],
            ]
         ];
         if (!empty($created)) {
            error_log('Result before: '.print_r($result, true));
            $result = array_merge($result, $created);
            error_log('Result after merge: '.print_r($result, true));
         }
         return $result;
      }
      protected function noCreatedItems(array $updated):array
      {
         $result = [
            'outcome'   => 'success',
            'result' => [
               'message'   => 'No items to create',
               'created'   => [],
               'errors' => [],
            ]
         ];
         if (!empty($updated)) {
            $result = array_merge($result, $updated);
         }
         return $result;
      }
      public function createBatchToService($data):array
      {
         $type = $data['type']??'post';
         //Check if any of the submitted ids are already created
         $created = array_filter($data['items'],
            function($ID) use ($type) {
               return !empty($this->getServiceItemID($ID, $type));
            });
         $updated = [];
         if (!empty($created)) {
            $updateData = $data;
            $updateData['items'] = $created;
            $updated = $this->updateBatchToService($data);
            //remove any updated items from the original items to process
            $data['items'] = array_filter($data['items'], function ($ID) use ($created) {
               return !in_array($ID, $created);
            });
         }
         $items = $this->formatItems($data['items'], $type);
         if (empty($items)) {
            return $this->noCreatedItems($updated);
         }
         $response = [
            'outcome'   => 'failed',
            'result' => [
               'message'   => 'No result'
            ]
         ];
         if ($this->hasBatchCreate) {
            $response = $this->sendBatchCreate($items);
            if (!is_wp_error($response)) {
               $result = $this->processBatchCreateResponse($data, $response);
            } else {
               $this->logError('Batch create failed',[
                  'method' => 'createBatchToService',
                  'item_ids'  => $data['items'],
                  'error'     => $response
               ]);
               $this->updateItemStatus($data['items'], 'error');
               $result = [
                  'outcome'   => 'failed_permanent',
                  'result' => 'Could not update items'
               ];
            }
         } else {
            $success = $errors = [];
            foreach ($items as $item) {
               $itemResult = $this->createOne($item);
               if (!is_wp_error($itemResult)) {
                  $success[] = $itemResult;
               } else {
                  $errors[] = $itemResult;
               }
            }
            $result = [
               'outcome'   => empty($errors) ? 'success' : (empty($success) ? 'failed' : 'partial'),
               'result' => [
                  'success'   => $success,
                  'errors' => $errors
               ]
            ];
         }
         return array_merge($result, $updated);
      }
      /**
       * To be implemented by integration extension. Updates a single item
       * @param array $item
       * @return array|WP_Error
       */
      protected function createOne(array $item):array|WP_Error
      {
         return new WP_Error('invalid', 'Integration '.$this->service_name.' must implement its own createOne');
      }
   public function findWPItem(string $integrationID, ?string $type = null):array|false
   {
      if (empty($integrationID)) {
         error_log('Integrations::findWPItem No Integration ID received.');
         return false;
      }
      $types = ['post', 'term', 'user'];
      if (!is_null($type) && !in_array($type, $types)) {
         error_log('Integrations::findWPItem invalid type sent: '.$type.'; defaulting to checking all');
         $type = null;
      }
      if (!is_null($type)) {
         return $this->queryWPType($integrationID, $type);
      } else {
         foreach ($types as $type) {
            $check = $this->queryWPType($integrationID, $type);
            if ($check) {
               return $check;
            }
         }
      }
      return false;
   }
   /**
    * @param string $integrationID
    * @param string|null $type
    * @return array|false if success, an array of $ID and $type
    */
      public function queryWPType(string $integrationID, ?string $type = null):array|false
      {
         if (!in_array($type, ['post', 'term', 'user'])) {
            return $this->findWPItem($integrationID);
         }
         return match($type) {
            'post'   => $this->findWPPost($integrationID),
            'term'   => $this->findWPTerm($integrationID),
            'user'   => $this->findWPUser($integrationID),
            default => false
         };
      }
      public function findWPPost(string $integrationID):array|false
      {
         if (empty($integrationID)) {
            return false;
         }
         $get = new WP_Query([
            'meta_query'   => [
               'key' => BASE."_{$this->service_name}_item_id",
               'value'  => $integrationID,
            ],
            'fields' => 'ids',
            'posts_per_page' => 1,
         ]);
         return $get->have_posts() && !empty($get->posts) ? [$get->posts[0], 'post'] : false;
      }
      public function findWPTerm(string $integrationID):array|false
      {
         if (empty($integrationID)) {
            return false;
         }
         $get = get_terms([
            'meta_query'   => [
               'key' => BASE."_{$this->service_name}_item_id",
               'value'  => $integrationID,
            ],
            'fields' => 'ids',
            'hide_empty'=> true,
            'number' => 1
         ]);
         return is_wp_error($get) || empty($get) ? false : [$get[0], 'term'];
      }
      public function findWPUser(string $integrationID):array|false
      {
         if (empty($integrationID)) {
            return false;
         }
         $get = get_users([
            'meta_query'   => [
               'key' => BASE."_{$this->service_name}_item_id",
               'value'  => $integrationID,
            ],
            'fields' => 'ids',
            'number' => 1,
         ]);
         return is_wp_error($get) || empty($get) ? false : [$get[0], 'user'];
      }
   /*********************************************************************************
    * Syncing WP items from Service
   *********************************************************************************/
   /**
    * @param array $itemData
    * @return bool
    * @throws Exception
    */
   protected function createItemFromService(array $itemData):bool
   {
      throw new Exception('createItemFromService must be implemented by child class '.$this->service_name);
   }
   /**
    * @param array $itemData
    * @return bool
    * @throws Exception
    */
   protected function updateItemFromService(array $itemData):bool
   {
      throw new Exception('updateItemFromService must be implemented by child class '.$this->service_name);
   }
   /**
    * @param array $itemData
    * @return bool
    * @throws Exception
    */
   protected function deleteItemFromService(array $itemData):bool
   {
      throw new Exception('deleteItemFromService must be implemented by child class '.$this->service_name);
   }
   /****************************************************************
    * CONNECT to a user's integration
   ****************************************************************/
   protected array $connections = [];
   public function connectAs(?int $userID = null):bool
   {
      if (!$this->userCanConnect($userID)) {
         return false;
      }
      $this->cleanup();
      $this->userID = $userID;
      $this->ensureInitialized();
      return true;
   }
   public function userCanConnect(?int $userID):bool
   {
      $user = get_userdata($userID);
      if (!$user || is_wp_error($user)) {
         return false;
      }
      if (current_user_can('manage_options')) {
         return true;
      }
      $role = jvbUserRole($userID);
      $registrar = Registrar::getInstance($role);
      if (!$registrar || !$registrar->hasIntegration($this->service_name)) {
         return false;
      }
      return (bool) Meta::forUser($userID)->get('integration_'.$this->service_name.'_connected');
   }
   protected function cleanup():void
   {
      $this->userID = false;
      $this->credentials = [];
      $this->request_history = [];
      $this->resetTokenRefreshFlag();
   }
}