From f94860aacd6200fb24c9e7431eb379a368cb392d Mon Sep 17 00:00:00 2001
From: Jake Vanderwerf <get@jakevanderwerf.ca>
Date: Fri, 10 Jul 2026 00:35:44 +0000
Subject: [PATCH] =Refactored CredentialsManager.php to utilize a CustomTable instance instead
---
inc/integrations/Integrations.php | 1057 +++++++++++++++++++++++++++++++++++++++++----------------
1 files changed, 753 insertions(+), 304 deletions(-)
diff --git a/inc/integrations/Integrations.php b/inc/integrations/Integrations.php
index e3ef859..6f6232c 100644
--- a/inc/integrations/Integrations.php
+++ b/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);
@@ -924,13 +935,17 @@
bool $force = false
): ?array
{
- $cacheKey = $this->buildCacheKey('GET', $endpoint, $params);
- $ttl = $this->cacheStrategy[$cacheStrategy] ?? $this->ttl;
+ $cacheKey = $this->buildCacheKey('GET', $endpoint, $params, $baseKey);
+
+ $ttl = is_int($cacheStrategy)
+ ? max(0, $cacheStrategy)
+ : ($this->cacheStrategy[$cacheStrategy] ?? $this->ttl);
if (!$force && $ttl > 0) {
$cached = $this->cache->get($cacheKey);
if ($cached !== false) {
- $this->logDebug("Cache hit for: $cacheKey");
+
+// $this->logDebug("Cache hit for: $cacheKey");
return $cached;
}
}
@@ -944,7 +959,6 @@
return $result;
}
-
/**
* Check if response contains an error
* Override in child classes for service-specific error detection
@@ -1044,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([
@@ -1190,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] ?? '';
@@ -1232,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';
@@ -1393,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
}
}
}
@@ -1412,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
***************************************************************/
@@ -1624,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);
}
/**
@@ -1921,7 +1942,6 @@
return false;
}
- // Build refresh request data
$request_data = [
'client_id' => $this->credentials['client_id'],
'client_secret' => $this->credentials['client_secret'],
@@ -1929,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;
}
@@ -1943,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'];
}
@@ -2073,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);
@@ -2125,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,
@@ -2253,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;
}
@@ -2272,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
@@ -2636,11 +2589,6 @@
];
$this->is_healthy = true;
$this->saveErrorStats();
-
- $this->logDebug('Integration health manually reset', [
- 'reset_by' => get_current_user_id(),
- 'reset_time' => time()
- ]);
}
/**
@@ -2709,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
*********************************************************************/
@@ -2744,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
@@ -2775,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
}
@@ -2939,7 +2890,7 @@
return '';
}
- $meta = new MetaManager($this->userID, 'integrations');
+ $meta = Meta::forOptions($this->userID.'_integrations');
$is_connected = $this->isSetUp();
$credentials = $this->getCredentials();
@@ -2952,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): ?>
@@ -3016,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) {
@@ -3047,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>
@@ -3063,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') {
@@ -3073,7 +3024,7 @@
switch ($action) {
case 'save_credentials':
$title = $label;
- $label = jvbIcon('save');
+ $label = jvbIcon('floppy-disk');
break;
case 'clear_credentials':
$title = $label;
@@ -3190,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');
}
@@ -3287,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>
@@ -3299,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') {
@@ -3319,7 +3272,7 @@
$c['hint'] = $c['description'];
unset($c['description']);
}
- $meta->render('form', $name, $c);
+ echo Form::render($name, null, $c);
}
?>
</details>
@@ -3345,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
@@ -3527,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();
+ }
}
--
Gitblit v1.10.0