Jake Vanderwerf
2026-07-12 c204185ae86a98994f80010abf35a190c9406739
=Refactor of Integrations.php. Separated different functionality into traits that classes can use to add that functionality. Hopefully will make maintaining it a little easier. Still have to finish up, as well as refactoring the individual classes to utilize the new system.
11 files renamed
16 files added
10 files modified
4117 ■■■■ changed files
JVBase.php 72 ●●●● patch | view | raw | blame | history
build/menu/style-index-rtl.css 2 ●●● patch | view | raw | blame | history
build/menu/style-index.css 2 ●●● patch | view | raw | blame | history
inc/integrations/Admin.php 233 ●●●●● patch | view | raw | blame | history
inc/integrations/AdminActionResult.php 11 ●●●●● patch | view | raw | blame | history
inc/integrations/AdminActions.php 105 ●●●●● patch | view | raw | blame | history
inc/integrations/Ajax.php 10 ●●●●● patch | view | raw | blame | history
inc/integrations/Auth.php 60 ●●●● patch | view | raw | blame | history
inc/integrations/Field.php 68 ●●●●● patch | view | raw | blame | history
inc/integrations/Integrations.php 1902 ●●●●● patch | view | raw | blame | history
inc/integrations/OAuth.php 351 ●●●●● patch | view | raw | blame | history
inc/integrations/OAuthCredentials.php 47 ●●●●● patch | view | raw | blame | history
inc/integrations/RateLimits.php 103 ●●●●● patch | view | raw | blame | history
inc/integrations/Requests.php 114 ●●●●● patch | view | raw | blame | history
inc/integrations/SyncDefaults.php 13 ●●●●● patch | view | raw | blame | history
inc/integrations/SyncFrom.php 41 ●●●●● patch | view | raw | blame | history
inc/integrations/SyncHelpers.php 144 ●●●●● patch | view | raw | blame | history
inc/integrations/SyncTo.php 310 ●●●●● patch | view | raw | blame | history
inc/integrations/UserConnection.php 62 ●●●●● patch | view | raw | blame | history
inc/integrations/Webhooks.php 125 ●●●●● patch | view | raw | blame | history
inc/integrations/_Base.php 110 ●●●●● patch | view | raw | blame | history
inc/integrations/_setup.php 29 ●●●●● patch | view | raw | blame | history
inc/integrations/services/BlueSky.php 8 ●●●●● patch | view | raw | blame | history
inc/integrations/services/Cloudflare.php 4 ●●●● patch | view | raw | blame | history
inc/integrations/services/Facebook.php 11 ●●●● patch | view | raw | blame | history
inc/integrations/services/GoogleMaps.php 4 ●●●● patch | view | raw | blame | history
inc/integrations/services/GoogleMyBusiness.php 8 ●●●● patch | view | raw | blame | history
inc/integrations/services/Helcim.php 16 ●●●●● patch | view | raw | blame | history
inc/integrations/services/Instagram.php 16 ●●●●● patch | view | raw | blame | history
inc/integrations/services/PostMark.php 7 ●●●●● patch | view | raw | blame | history
inc/integrations/services/Square.php 55 ●●●●● patch | view | raw | blame | history
inc/integrations/services/Umami.php 12 ●●●●● patch | view | raw | blame | history
inc/managers/Dashboard/DashboardManager.php 34 ●●●●● patch | view | raw | blame | history
inc/rest/routes/FeedRoutes.php 8 ●●●● patch | view | raw | blame | history
inc/rest/routes/IntegrationsSquareRoutes.php 4 ●●●● patch | view | raw | blame | history
inc/ui/Checkout.php 12 ●●●●● patch | view | raw | blame | history
src/menu/style.scss 4 ●●●● patch | view | raw | blame | history
JVBase.php
@@ -1,52 +1,52 @@
<?php
namespace JVBase;
use JVBase\blocks\CustomBlocks;
use JVBase\admin\SEOAdmin;
use JVBase\base\SchemaHelper;
use JVBase\base\Site;
use JVBase\blocks\CustomBlocks;
use JVBase\managers\AdminPages;
use JVBase\managers\ApprovalManager;
use JVBase\managers\Dashboard\DashboardManager;
use JVBase\managers\DirectoryManager;
use JVBase\managers\EmailManager;
use JVBase\managers\ErrorHandler;
use JVBase\managers\FavouritesManager;
use JVBase\managers\InvitationsManager;
use JVBase\managers\LoginManager;
use JVBase\managers\MagicLinkManager;
use JVBase\managers\NotificationManager;
use JVBase\managers\queue\Queue;
use JVBase\managers\Dashboard\DashboardManager;
use JVBase\managers\DirectoryManager;
use JVBase\managers\ReferralManager;
use JVBase\managers\RoleManager;
use JVBase\managers\SEO\render\SchemaOutput;
use JVBase\admin\SEOAdmin;
use JVBase\managers\AdminPages;
use JVBase\managers\NotificationManager;
use JVBase\managers\TaxonomyRelationships;
use JVBase\managers\UserTermsManager;
use JVBase\registrar\Registrar;
use JVBase\rest\routes\FeedRoutes;
use JVBase\rest\routes\FavouritesRoutes;
use JVBase\rest\routes\IntegrationsSquareRoutes;
use JVBase\rest\routes\IntegrationsHelcimRoutes;
use JVBase\rest\routes\NotificationsRoutes;
use JVBase\rest\routes\AdminRoutes;
use JVBase\rest\routes\ApprovalRoutes;
use JVBase\rest\routes\ContentRoutes;
use JVBase\rest\routes\TermRoutes;
use JVBase\rest\routes\UploadRoutes;
use JVBase\rest\routes\SettingsRoutes;
use JVBase\rest\routes\ContentTermsRoutes;
use JVBase\rest\routes\SEORoutes;
use JVBase\rest\routes\QueueRoutes;
use JVBase\rest\routes\ErrorRoutes;
use JVBase\rest\routes\FavouritesRoutes;
use JVBase\rest\routes\FeedRoutes;
use JVBase\rest\routes\FormRoutes;
use JVBase\rest\routes\IntegrationsHelcimRoutes;
use JVBase\rest\routes\IntegrationsRoutes;
use JVBase\rest\routes\IntegrationsSquareRoutes;
use JVBase\rest\routes\Invitations;
use JVBase\rest\routes\LoginRoutes;
use JVBase\rest\routes\NewsRoutes;
use JVBase\rest\routes\NotificationsRoutes;
use JVBase\rest\routes\OptionsRoutes;
use JVBase\rest\routes\QueueRoutes;
use JVBase\rest\routes\ReferralRoutes;
use JVBase\rest\routes\ResponseRoutes;
use JVBase\rest\routes\OptionsRoutes;
use JVBase\rest\routes\SEORoutes;
use JVBase\rest\routes\SettingsRoutes;
use JVBase\rest\routes\TermRoutes;
use JVBase\rest\routes\UploadRoutes;
use JVBase\rest\routes\VoteRoutes;
use JVBase\rest\routes\Invitations;
use JVBase\rest\routes\ApprovalRoutes;
use JVBase\rest\routes\AdminRoutes;
use JVBase\rest\routes\IntegrationsRoutes;
use JVBase\base\SchemaHelper;
use UserRoutes;
if (!defined('ABSPATH')) {
@@ -65,16 +65,16 @@
    protected CustomBlocks $customBlocks;
    protected array $serviceMap = [
        'maps' => 'JVBase\integrations\GoogleMaps',
        'square' => 'JVBase\integrations\Square',
        'facebook' => 'JVBase\integrations\Facebook',
        'helcim' => 'JVBase\integrations\Helcim',
        'instagram' => 'JVBase\integrations\Instagram',
        'bluesky' => 'JVBase\integrations\BlueSky',
        'gmb' => 'JVBase\integrations\GoogleMyBusiness',
        'cloudflare' => 'JVBase\integrations\Cloudflare',
        'umami' => 'JVBase\integrations\Umami',
        'postmark' => 'JVBase\integrations\PostMark',
        'maps' => 'JVBase\integrations\services\GoogleMaps',
        'square' => 'JVBase\integrations\services\Square',
        'facebook' => 'JVBase\integrations\services\Facebook',
        'helcim' => 'JVBase\integrations\services\Helcim',
        'instagram' => 'JVBase\integrations\services\Instagram',
        'bluesky' => 'JVBase\integrations\services\BlueSky',
        'gmb' => 'JVBase\integrations\services\GoogleMyBusiness',
        'cloudflare' => 'JVBase\integrations\services\Cloudflare',
        'umami' => 'JVBase\integrations\services\Umami',
        'postmark' => 'JVBase\integrations\services\PostMark',
    ];
    public static function getInstance(): JVB
@@ -307,9 +307,11 @@
//
//          return $this->serviceMap[$service]::getInstance($userID);
//      }
        return is_null($userID) ?
            $this->integrations[$service]??null :
            (array_key_exists($service, $this->integrations) ? $this->serviceMap[$service]::getInstance($userID) : null);
        $instance = $this->integrations[$service]??null;
        if ($instance) {
            $instance->switchUser($userID);
        }
        return $instance;
    }
    public function userCanConnect(string $service, int $userID): bool
build/menu/style-index-rtl.css
@@ -1 +1 @@
.info h4{font-size:var(--txt-medium);font-weight:var(--fw-h);margin:0}.field.quantity>label{right:var(--offScreen);position:absolute}nav.menu{max-width:100%}nav.menu ul{--justify:flex-start}nav.menu ul li{padding:0 .5rem}main>header{grid-column:full}main>header h1,main>section *{max-width:var(--wide)}.menu-item.menu-item{margin:var(--sp2);max-width:none}.menu-item.menu-item+.menu-item{border-top:1px solid rgb(var(--base-200));padding-top:var(--sp4)}.menu-item.menu-item .header{flex-wrap:nowrap}.menu-item.menu-item .header h3,.menu-item.menu-item .header p{margin:0;width:-moz-max-content;width:max-content}
.info h4{font-size:var(--txt-medium);font-weight:var(--fw-h);margin:0}.field.quantity.quantity>label{right:var(--offScreen);position:absolute}nav.menu{max-width:100%}nav.menu ul{--justify:flex-start}nav.menu ul li{padding:0 .5rem}main>header{grid-column:full}main>header h1{max-width:var(--wide)}main>section *{max-width:var(--wide)!important}.menu-item.menu-item{margin:var(--sp2);max-width:none}.menu-item.menu-item+.menu-item{border-top:1px solid rgb(var(--base-200));padding-top:var(--sp4)}.menu-item.menu-item .header{flex-wrap:nowrap}.menu-item.menu-item .header h3,.menu-item.menu-item .header p{margin:0;width:-moz-max-content;width:max-content}
build/menu/style-index.css
@@ -1 +1 @@
.info h4{font-size:var(--txt-medium);font-weight:var(--fw-h);margin:0}.field.quantity>label{left:var(--offScreen);position:absolute}nav.menu{max-width:100%}nav.menu ul{--justify:flex-start}nav.menu ul li{padding:0 .5rem}main>header{grid-column:full}main>header h1,main>section *{max-width:var(--wide)}.menu-item.menu-item{margin:var(--sp2);max-width:none}.menu-item.menu-item+.menu-item{border-top:1px solid rgb(var(--base-200));padding-top:var(--sp4)}.menu-item.menu-item .header{flex-wrap:nowrap}.menu-item.menu-item .header h3,.menu-item.menu-item .header p{margin:0;width:-moz-max-content;width:max-content}
.info h4{font-size:var(--txt-medium);font-weight:var(--fw-h);margin:0}.field.quantity.quantity>label{left:var(--offScreen);position:absolute}nav.menu{max-width:100%}nav.menu ul{--justify:flex-start}nav.menu ul li{padding:0 .5rem}main>header{grid-column:full}main>header h1{max-width:var(--wide)}main>section *{max-width:var(--wide)!important}.menu-item.menu-item{margin:var(--sp2);max-width:none}.menu-item.menu-item+.menu-item{border-top:1px solid rgb(var(--base-200));padding-top:var(--sp4)}.menu-item.menu-item .header{flex-wrap:nowrap}.menu-item.menu-item .header h3,.menu-item.menu-item .header p{margin:0;width:-moz-max-content;width:max-content}
inc/integrations/Admin.php
New file
@@ -0,0 +1,233 @@
<?php
namespace JVBase\integrations;
use JVBase\meta\Form;
if (!defined('ABSPATH')) {
    exit;
}
trait Admin {
    use _Base;
    //An array of AdminAction objects
    protected array $actions = [];
    protected array $instructions = [];
    protected array $fields = [];
    protected function initializeActions():void
    {
        $actions = [
            [
                'name'      => 'Save Credentials',
                'label'     => 'Save Changes',
                'icon'      => 'floppy-disk',
                'action'    => [$this, 'saveCredentials']
            ],
            [
                'name'      => 'Clear Credentials',
                'icon'      => 'broom',
                'action'    => [$this, 'clearCredentials']
            ],
        ];
        foreach ($actions as $a) {
            $this->addAction($a['name'], $a['action'], $a['slug']??null, $a['icon']??null, $a['label']??null);
        }
    }
    public function addAction(string $name, callable $callback, ?string $slug = null, ?string $icon = null, ?string $label = null):void
    {
        $action = new AdminActions($name, $callback);
        if (!is_null($slug)) {
            $action->setSlug($slug);
        }
        if (!is_null($icon)) {
            $action->setIcon($icon);
        }
        if (!is_null($label)) {
            $action->setLabel($label);
        }
        $this->actions[$action->getSlug()] = $action;
    }
    /**
     * @param string $action
     * @param mixed|null $data
     * @return array [
     *      'success'   => {bool},
     *      'message'   => {string} optional message,
     *      'data'      => {mixed} optional data to return
     * ]
     */
    public function processAction(string $action, mixed $data = null):array
    {
        if (!array_key_exists($action, $this->actions)) {
            $this->logError('processAction', 'Attempted action not setup: '.print_r(['action' => $action, 'service' => $this->service_name], true));
            return $this->response(false, 'Attempted action not set up');
        }
        $theAction = $this->actions[$action];
        if (!$theAction->checkPermission()) {
            $this->logError('processAction', 'User has no capabilities to perform this action: '.print_r(['action' => $action, 'service' => $this->service_name], true));
            return  $this->response(false, 'Insufficient permissions');
        }
        $result = $theAction->handleCallback($data);
        if (is_wp_error($result)) {
            $this->logError('processAction', $result->get_error_message());
            return $this->response(false, $result->get_error_message());
        }
        return $result;
    }
    public function response(bool $success, string $message):array
    {
        return [
            'success'   => $success,
            'message'   => $message
        ];
    }
    public function renderConnection():string
    {
        if ($this->userID && !JVB()->userCanConnect($this->service_name, $this->userID)) {
            return '';
        }
        $credentials = $this->loadCredentials();
        $isConnected = !empty($credentials);
        $meta = '';
        if ($isConnected && array_key_exists('updated_at', $credentials) && $credentials['updated_at'] > 0) {
            $meta = sprintf(
            '<div class="meta">
                    <small>Last updated: %s ago</small>
                </div>',
                human_time_diff($credentials['updated_at'])
            );
        }
        $form = sprintf(
        '<form id="%s" class="integration %s" data-service="%s" data-save="integrations">
            <div class="header row x-btw">
                <h3>%s</h3>
                <div class="setup">
                    <span class="indicator">%s</span>
                    <span class="text">%sSet Up</span>
                </div>
            </div>
            %s',
            $this->service_name,
            $isConnected ? 'connected' : 'disconnected',
            esc_attr($this->service_name),
            esc_html($this->title),
            $isConnected ? '●' : '○',
            $isConnected ? '' : 'Not ',
            $meta,
        );
        if (!empty($this->instructions)) {
            $form .= sprintf(
                '<details%s>
                    <summary>Instructions</summary>
                    <ol>%s</ol>
                </details>',
                $isConnected ? '' : ' open',
                implode('',array_map(function($el) {
                    return sprintf('<li>%s</li>', $el);
                }, $this->instructions))
            );
        }
        if (current_user_can('manage_options')) {
            $form .= sprintf(
                '<details class="initial-setup"%s>
                <summary>Setup</summary>
                %s
                %s
            </details>',
                $isConnected ? '' : ' open',
                $this->outputFields($this->credentialFields),
                method_exists($this, 'renderWebhookUrl') ? $this->renderWebhookUrl() : ''
            );
        }
        if (method_exists($this, 'renderOAuthConnectionForm')) {
            $form .= $this->renderOAuthConnectionForm();
        }
        if (!empty($this->optionalFields) || $this->hasExtraOptions) {
            $form .= '<div class="integration-content">';
            if (!empty($this->optionalFields)) {
                $form .= sprintf(
                '<details class="advanced">
                        <summary>Advanced Settings</summary>%s</details>',
                    $this->outputFields($this->optionalFields)
                );
            }
            if ($this->hasExtraOptions) {
                $form .= sprintf(
                    '<a href="%s" class="btn">More Settings</a>',
                    $this->determineOptionPage()
                );
            }
            $form .= '</div>';
        }
        $form .= $this->outputActionButtons();
        $form .= '</form>';
        return $form;
    }
        protected function outputActionButtons():string
        {
            return sprintf('<div class="actions row x-btw wrap">%s</div>',implode('', array_map(function($action) {
                $label = '';
                if (!empty ($action->getIcon())) {
                    $label .= $this->outputIcon($action->getIcon());
                }
                $label .= $action->getLabel();
                return sprintf(
                    '<button type="button" data-action="%s"%s%s>%s</button>',
                    $action->getSlug,
                    $action->getName(),
                    empty($action->getConfirm()) ? '' : ' data-confirm="'.$action->getConfirm().'"',
                    $label
                );
            }, array_filter($this->actions, function ($action) {
                return $action->checkPermission();
            }))));
        }
    protected function outputFields(array $fields):string
    {
        $credentials = $this->loadCredentials();
        $fields = array_filter($fields, function ($field) {
            return $field->checkPermission();
        });
        return implode('', array_map(function($f) use ($credentials) {
            $value = array_key_exists($f->name, $credentials) ? $credentials[$f->name] : '';
            $config = $f->getConfig();
            if (!empty($value)) {
                $config['readonly'] = true;
            }
            $config['base'] = $this->service_name.'_';
            return Form::render($f->name, $value, $f->getConfig());
        }, $fields));
    }
    public function addField(string $name, string $label, string $type, callable|string|bool $permission):Field
    {
        $field = new Field($name, $label, $type, $permission);
        $this->fields[$name] = $field;
        return $field;
    }
        public function setFieldRequired(string $name):bool
        {
            if (!array_key_exists($name, $this->fields)) {
                return false;
            }
            $this->fields[$name]->setRequired();
            return true;
        }
}
inc/integrations/AdminActionResult.php
New file
@@ -0,0 +1,11 @@
<?php
namespace JVBase\integrations;
if (!defined('ABSPATH')) {
    exit;
}
trait AdminActionResult {
    public bool $success;
    public array $result;
}
inc/integrations/AdminActions.php
New file
@@ -0,0 +1,105 @@
<?php
namespace JVBase\integrations;
use Closure;
if (!defined('ABSPATH')) {
    exit;
}
class AdminActions
{
    public string $slug;
    public string $name;
    public string $label;
    public ?string $icon = null;
    public string|Closure $permission = 'manage_options';
    public Closure $callback;
    public string $confirm = '';
    public function __construct(string $name, callable $callback)
    {
        $this->name = $name;
        $this->setSlug();
    }
    public function setSlug(?string $slug = null):void
    {
        if (is_null($slug)) {
            $slug = $this->name;
        }
        $this->slug = $this->sanitizeSlug($slug);
    }
        protected function sanitizeSlug(string $slug):string
        {
            return str_replace('-','_',sanitize_title($slug));
        }
    public function getSlug():string
    {
        return $this->slug;
    }
    public function setIcon(string $icon):void
    {
        $this->icon = $icon;
    }
    public function getIcon():string|null
    {
        return $this->icon;
    }
    public function setLabel(string $label):void
    {
        $this->label = $label;
    }
    public function getLabel():string
    {
        if (isset($this->label)) {
            return $this->label;
        }
        return $this->name;
    }
    public function setCallback(callable $callback):void
    {
        $this->callback = Closure::fromCallable($callback);
    }
    public function handleCallback(?array $data = null):mixed
    {
        if (is_callable($this->callback)) {
            return ($this->callback)($data);
        }
        error_log('[AdminActions]::handleCallback No callback set for '.$this->name. ' action');
        return false;
    }
    public function setPermission(string $permission):void
    {
        $this->permission = $permission;
    }
    public function checkPermission():bool
    {
        if (is_string($this->permission)) {
            return current_user_can($this->permission);
        } else if (is_callable($this->permission)) {
            return ($this->permission)();
        }
        error_log('Check Permission failed as no permission set. '.print_r([
            'permission'    => $this->permission,
            ], true));
        return false;
    }
    public function setConfirm(string $confirm):void
    {
        $this->confirm = $confirm;
    }
    public function getConfirm():string
    {
        return $this->confirm;
    }
    public function getName():string
    {
        return $this->name;
    }
}
inc/integrations/Ajax.php
New file
@@ -0,0 +1,10 @@
<?php
namespace JVBase\integrations;
if (!defined('ABSPATH')) {
    exit;
}
trait Ajax {
}
inc/integrations/Auth.php
File was renamed from inc/integrations/CredentialsManager.php
@@ -1,15 +1,14 @@
<?php
namespace JVBase\integrations;
use JVBase\managers\CustomTable;
if (!defined('ABSPATH')) {
    exit;
}
class CredentialsManager
class Auth
{
    private static ?CredentialsManager $instance = null;
    private static ?Auth $instance = null;
    private string $encryption_key;
    private CustomTable $table;
@@ -24,11 +23,16 @@
        $table = CustomTable::for('integrations');
        $table->setColumns([
            'id'            => 'bigint(20) unsigned NOT NULL AUTO_INCREMENT',
            'user_id'       => $table->getUserIDType().' NOT NULL',
            'user_id'       => $table->getUserIDType().' DEFAULT NULL',
            'integration'   => "ENUM('bluesky','cloudflare','facebook','google-maps','gmb','helcim','instagram','postmark','square','umami') NOT NULL",
            'credentials'   => 'varchar(255) DEFAULT NULL',
            'credentials'   => 'varchar(1000) DEFAULT NULL',
            'created_at'    => 'datetime DEFAULT CURRENT_TIMESTAMP',
            'updated_at'    => 'datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'
            'updated_at'    => 'datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP',
            'last_tested'   => 'datetime DEFAULT NULL',
            'is_healthy'    => 'tinyint(1) DEFAULT 1',
            'webhook_healthy'   => 'tinyint(1) DEFAULT 1',
            'request_healthy'   => 'tinyint(1) DEFAULT 1',
            'oauth_healthy'     => 'tinyint(1) DEFAULT 1',
        ]);
        $table->setKeys([
@@ -46,7 +50,7 @@
        $this->table = $table;
    }
    public static function getInstance(): CredentialsManager
    public static function getInstance(): Auth
    {
        if (self::$instance === null) {
            self::$instance = new self();
@@ -60,10 +64,19 @@
    public function storeCredentials(string $service, array $credentials, ?int $userID = null): bool
    {
        $find = $this->findBy($service, $userID);
        error_log('Storing Credentials: '.print_r($credentials, true));
        $update = [
            'credentials'   => $this->encrypt(json_encode($credentials))
        ];
        return (bool)$this->table->findOrCreate($find, $update);
        error_log('Find: '.print_r($find, true));
        error_log('Update: '.print_r($update, true));
        $updated = $this->table->findOrCreate($find, $update);
        error_log('Last error: '.print_r($this->table->getLastError(), true));
        error_log('Updated: '.print_r($updated, true));
        return (bool)$updated;
    }
    /**
@@ -92,7 +105,7 @@
    protected function findBy(string $service, ?int $userID = null):array
    {
        return [
            'user_id'   => is_null($userID) ? 0 : $userID,
            'user_id'   => $userID,
            'integration'=> $service
        ];
    }
@@ -142,4 +155,33 @@
    {
        return $this->table->pluck('user_id', ['integration' => $service]);
    }
    public function updateTested(string $service, ?int $userID = null):void
    {
        $find = $this->findBy($service, $userID);
        $this->table->update([
            'last_tested'   => time(),
        ], $find);
    }
    public function markUnhealthy(string $service, string $type = '', ?int $userID = null):void
    {
        $update = [];
        switch ($type) {
            case 'oauth':
                $update['oauth_healthy'] = 0;
                break;
            case 'request':
                $update['request_healthy'] = 0;
                break;
            case 'webhook':
                $update['webhook_healthy'] = 0;
                break;
            default:
                $update['is_healthy'] = 0;
                break;
        }
        $find = $this->findBy($service, $userID);
        $this->table->update($update, $find);
    }
}
inc/integrations/Field.php
New file
@@ -0,0 +1,68 @@
<?php
namespace JVBase\integrations;
use Closure;
if (!defined('ABSPATH')) {
    exit;
}
class Field {
    public string $name;
    public string $label;
    public string $type;
    public bool $required;
    public string|Closure|bool $permission;
    protected array $options;
    public function __construct(string $name, string $label, string $type, callable|string|bool $permission)
    {
        $this->name = $name;
        $this->label = $label;
        $this->type = $type;
        $this->permission = $permission;
    }
    public function checkPermission():bool
    {
        if (is_bool($this->permission)) {
            return $this->permission;
        }
        if (is_string($this->permission)) {
            return current_user_can($this->permission);
        }
        if (is_callable($this->permission)) {
            return ($this->permission)();
        }
        error_log('Check Permission failed as no permission set. '.print_r([
            'permission' => $this->permission,
                'field' => $this->name,
            ], true));
        return false;
    }
    public function setRequired():void
    {
        $this->required = true;
    }
    public function setOptions(array $options):void
    {
        $this->options = $options;
    }
    public function getConfig():array
    {
        $conf = [
            'name'  => $this->name,
            'label' => $this->label,
            'type'  => $this->type,
        ];
        if ($this->required) {
            $conf['required'] = true;
        }
        if ($this->options) {
            $conf['options'] = $this->options;
        }
        return $conf;
    }
}
inc/integrations/Integrations.php
@@ -3,21 +3,17 @@
use Exception;
use JVBase\managers\Cache;
use JVBase\managers\ErrorHandler;
use JVBase\managers\queue\executors\IntegrationExecutor;
use JVBase\managers\queue\mergers\DefaultMerger;
use JVBase\managers\queue\TypeConfig;
use JVBase\managers\UploadManager;
use JVBase\meta\Form;
use JVBase\meta\Meta;
use JVBase\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;
@@ -34,85 +30,30 @@
 */
abstract class Integrations
{
    use _Base;
    //Flag to allow for custom settings (defaults, etc) for an integration in the dashboard
    public static bool $hasExtraOptions = false;
    public 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
     */
    protected string $service_name;      // Unique identifier for this service (e.g., 'square', 'facebook')
    protected string|array $apiBase = ''; // Base URL(s) for API endpoints. Array format: ['base' => '', 'auth' => '']
    protected array $apiEndpoints = [];   // Valid endpoint paths for this service
    protected string $apiVersion = '';     // API version string (e.g., 'v2', '2024-01-01')
    protected int $refresh_interval = 0; //seconds before expiry to refresh tokens. 0 to disable
    /**
     * OAuth Configuration
     * Set these properties if your service uses OAuth authentication
     */
    protected bool $isOAuthService = false;
    protected array $oauth = [
        'authorize' => '',    // OAuth authorization endpoint
        'token' => '',        // Token exchange endpoint
        'revoke' => '',       // Token revocation endpoint (optional)
        'scopes' => [],       // Required OAuth scopes
        'redirect_uri' => '', // OAuth callback URL
    ];
    /**
     * Display Properties
     * 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
    /**
     * 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
    protected array $defaults = [];     // Default overrides
    protected string $defaultContent = 'post'; //Default integration content type, is none is set. MUST EXIST as array key in $this->>getContentTypes
    protected array $instructions = []; // Each item in array becomes an item in the list of instructions
    protected bool $supportsWebp = true;
    /**
     * Client Management
     * For services that benefit from persistent connections
     */
    protected ?object $client = null;               // Persistent client instance
    protected array $actions = [
        'save_credentials'  => 'saveCredentials',
        'clear_credentials' => 'deleteCredentials',
        'clear_cache'       => 'clearCache',
        'test_connection'   => 'testConnection',
    ];
    protected array $buttons = [
        'save_credentials' => 'Save Changes',
        'clear_credentials' => 'Clear Credentials',
        'clear_cache'=> 'Clear Cache',
        'test_connection' => 'Test Connection',
    ];
    protected array $allowedContent = [];
@@ -127,24 +68,6 @@
        'minimal' => 60,       // 1 minute for frequently changing data
        'none' => 0           // No caching for real-time data
    ];
    protected int $ttl = 3600; // Default cache TTL in seconds
    /**
     * Rate Limiting Configuration
     * Override these values based on service-specific limits
     */
    protected array $rate_limits = [
        'per_second' => 2,
        'per_minute' => 30,
        'per_hour' => 1000
    ];
    protected array $oauth_rate_limits = [
            'per_second' => 1,    // Max 1 OAuth request per second
            'per_minute' => 10,   // Max 10 OAuth requests per minute
            'per_hour' => 100,    // Max 100 OAuth requests per hour
        ];
    protected array $request_history = [];
    protected int $max_history_size = 100;
    /**
     * Post Syncing Capabilities
@@ -163,74 +86,51 @@
     * Error Handling Configuration
     */
    protected array $lastError = [];
    protected int $maxRetries = 3;
    protected array $retryDelays = [1, 2, 5]; // Exponential backoff in seconds
    /**
     * Validation Rules
     * Define validation rules for credentials and data
     */
    protected array $validationRules = [];
    /**
     * Error tracking properties
     */
    protected array $error_stats = [
        'total_errors' => 0,
        'consecutive_errors' => 0,
        'last_success' => null,
        'error_types' => []
    ];
    protected ?ErrorHandler $errorHandler = null;
    protected int $error_threshold = 5; // Consecutive errors before marking unhealthy
    protected bool $is_healthy = true;
    protected bool $handleWebhooks = false;
    protected function __construct(?int $userID = null)
    {
        $this->cacheName = $this->cacheName ?: $this->service_name;
        $this->userID = $userID;
        $this->cache = Cache::for('integrations_' . $this->cacheName, $this->ttl);
        // Load error stats from cache
        $this->loadErrorStats();
        $this->cache = Cache::for('integrations_' . $this->cacheName);
        $this->getPostTypes();
        $this->getTaxonomies();
        $this->setContentTypes();
        $this->registerHooks();
        if ($this->isOAuthService) {
            $this->actions['get_oauth_url'] = 'getOAuthUrlAction';
            $this->actions['connect_oauth'] = 'handleOAuthConnect';
            $this->actions['disconnect_oauth'] = 'handleOAuthDisconnect';
            $this->actions['refresh_token'] = 'refreshOAuthToken';
            $this->buttons['refresh_token'] = 'Refresh OAuth Connection';
            $this->buttons['disconnect_oauth'] = 'Disconnect OAuth';
        }
        if ($this->handleWebhooks) {
            $this->advanced['webhook_signature_key'] = [
                'label' => 'Webhook Signature Key',
                'type' => 'text',
                'subtype' => 'password',
                'placeholder' => 'Enter webhook signature key',
                'hint' => 'Used to verify webhook authenticity (recommended)'
            ];
        if (method_exists($this, 'setQueueTypes')) {
            $this->setQueueTypes();
        }
        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';
        $this->initializeRateLimiters();
        if (method_exists($this, 'initializeActions')) {
            $this->initializeActions();
        }
        if (method_exists($this, 'addOAuthActions')) {
            $this->addOAuthActions();
        }
        add_filter('jvbShouldRenderMeta', [$this, 'checkRenderField'], 10, 4);
    }
        protected function initializeRateLimiters():void
        {
            $key = $this->service_name;
            if (!is_null($this->userID)) {
                $key .= '_'.$this->userID;
            }
    protected function addFilters():bool
    {
        return is_null($this->userID);
    }
            if ($this->hasRequests && !isset($this->requestLimiter) && !is_null($this->oauthLimiter)) {
                $this->requestLimiter = new RateLimits($key);
            }
            if ($this->hasOAuth && !isset($this->oauthLimiter) && !is_null($this->oauthLimiter)) {
                $key .= '_oauth';
                $this->oauthLimiter = new RateLimits($key, ['s' => 1,'m' => 10, 'h' => 100]);
            }
        }
    protected function setContentTypes():void
    {
@@ -238,7 +138,6 @@
    }
    public function getContentTypes(string $type):array
    {
        return (array_key_exists($type, $this->contentTypes)) ? $this->contentTypes[$type] : $this->contentTypes;
    }
@@ -256,7 +155,7 @@
    public function handleOAuthConnect(): array
    {
        if (!$this->isOAuthService) {
        if (!$this->hasOAuth) {
            return ['success' => false, 'message' => 'Not an OAuth service'];
        }
@@ -355,7 +254,7 @@
//      if (!empty($this->oauth['redirect_uri'])) {
//          return $this->oauth['redirect_uri'];
//      }
        if ($this->isOAuthService) {
        if ($this->hasOAuth) {
            return admin_url('admin-ajax.php?action=' . BASE . $this->service_name . '_oauth_callback');
        }
        // Changed from admin-ajax.php to REST endpoint
@@ -393,71 +292,6 @@
        }
    }
    /**
     * @param string $action
     * @param mixed|null $data
     * @return array [
     *      'success'   => {bool},
     *      'message'   => {string} optional message,
     *      'data'      => {mixed} optional data to return
     * ]
     */
    public function processAction(string $action, mixed $data = null):array
    {
        if (!$this->checkCapabilities($action, $this->userID)) {
            $this->logError('User cannot perform this action');
            return [
                'success'   => false,
                'message'   => 'Insufficient permissions'
            ];
        }
        if (!array_key_exists($action, $this->actions)) {
            $this->logError('Attempted action not set up', [
                'action'    => $action,
                'stored'    => $this->actions,
                'service'   => $this->service_name,
                'method'    => 'processAction'
            ]);
            return [
                'success'   => false,
                'message'   => 'Invalid action'
            ];
        }
        if (!method_exists($this, $this->actions[$action])) {
            $this->logError('Attempted method not set up', [
                'attempted' => $this->actions['action'],
                'service'   => $this->service_name,
                'method'    => 'processAction'
            ]);
            return [
                'success'   => false,
                'message'   => 'Action not set up'
            ];
        }
        $method = $this->actions[$action];
        // Log the action for debugging
        $this->logDebug("Processing action", [
            'action' => $action,
            'method' => $method,
            'data' => $data
        ]);
        if ($data !== null) {
            $result = $this->$method($data);
        } else {
            $result =  $this->$method();
        }
        if (is_wp_error($result)) {
            return [
                'success' => false,
                'message' => $result->get_error_message()
            ];
        }
        return is_array($result) ? $result : ['success' => true, 'data' => $result];
    }
    /*********************************************************************
     * ABSTRACT METHODS - MUST BE IMPLEMENTED BY CHILD CLASSES
@@ -475,22 +309,6 @@
     */
    abstract protected function initialize(): void;
    /**
     * Get request headers for API calls
     *
     * Return an associative array of headers required for API requests.
     * Common headers include Authorization, Content-Type, Accept, etc.
     *
     * Example:
     * return [
     *     'Authorization' => 'Bearer ' . $this->credentials['access_token'],
     *     'Content-Type' => 'application/json',
     *     'Accept' => 'application/json',
     * ];
     *
     * @return array Associative array of header names and values
     */
    abstract protected function getRequestHeaders(): array;
    /**
     * Render the connection settings form
@@ -513,7 +331,7 @@
     * OPTIONAL OVERRIDE METHODS - IMPLEMENT AS NEEDED
     *********************************************************************/
    /**
     * Save credentials using CredentialsManager
     * Save credentials using Auth
     */
    public function saveCredentials(array $credentials, bool $test = true):array
    {
@@ -552,7 +370,7 @@
            // Connection successful, save credentials
            $stored = CredentialsManager::getInstance()->storeCredentials(
            $stored = Auth::getInstance()->storeCredentials(
                $this->service_name,
                $credentials,
                $this->userID
@@ -576,7 +394,7 @@
        } catch (\Exception $e) {
            // Revert to old credentials on any error
            $old = CredentialsManager::getInstance()->getCredentials(
            $old = Auth::getInstance()->getCredentials(
                $this->service_name,
                $this->userID
            );
@@ -595,7 +413,7 @@
     */
    public function deleteCredentials():array
    {
        $success = CredentialsManager::getInstance()->deleteCredentials($this->service_name, $this->userID);
        $success = Auth::getInstance()->deleteCredentials($this->service_name, $this->userID);
        return [
            'success'   => $success
        ];
@@ -759,285 +577,18 @@
     * API REQUEST METHODS
     *********************************************************************/
    /**
     * Make an API request with automatic retry and error handling
     * @param string $method
     * @param string $endpoint
     * @param array $data
     * @param string|null $baseKey array key for the $this->apiEndpoints
     * @param array $options
     * @return WP_Error|array
     */
    protected function makeRequest(
        string $method,
        string $endpoint,
        array  $data = [],
        ?string $baseKey = null,
        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, [
                'method' => 'makeRequest',
            ]);
            return new WP_Error('Error', 'Connection is not set up to make request');
        }
        // Rate limiting check
        if (!$this->checkRateLimit()) {
            return new WP_Error('rate_limit', 'Rate limit exceeded. Please try again later.');
        }
        $attempt = 0;
        $lastError = null;
        while ($attempt < $this->maxRetries) {
            try {
//              $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
//              ]);
                // Reset error stats on success
                $this->resetErrorStats();
                return $result;
            } catch (Exception $e) {
                $lastError = $e;
                $this->recordRequest($method, $endpoint);
                $attempt++;
                // Retry with backoff for server errors
                if ($attempt < $this->maxRetries && !$this->isClientError($e)) {
                    $delay = pow(2, $attempt) * 1000000; // 2^attempt seconds in microseconds
                    $jitter = rand(0, $delay * 0.3); // Add 0-30% jitter
                    usleep($delay + $jitter);
                } else {
                    break;
                }
            }
        }
        $this->logError('API request failed after retries', [
            'method' => $method,
            'endpoint' => $endpoint,
            'attempts' => $attempt,
            'error' => $lastError ? $lastError->getMessage() : 'Unknown error'
        ]);
        return new WP_Error('api_error', $lastError ? $lastError->getMessage() : 'API request failed');
    }
    /**
     * Execute the actual request
     */
    protected function executeRequest(
        string $method,
        string $endpoint,
        array  $data = [],
        ?string $baseKey = null,
        array  $options = []
    ): ?array
    {
        $args = $this->buildRequestArgs($method, $data, $options);
        $url = $this->getApiUrl($endpoint, $baseKey);
        if (!$url) {
            return null;
        }
//      $this->logDebug("$method request to: $url: ".print_r($args, true));
        // Make the request
        $response = match($method) {
            'GET' => wp_remote_get($url, $args),
            'POST' => wp_remote_post($url, $args),
            'PUT', 'PATCH', 'DELETE' => wp_remote_request($url, array_merge($args, ['method' => $method])),
            default => null
        };
        if (!$response) {
            $this->logError("Unsupported HTTP method $method for $this->service_name");
            return null;
        }
        if (is_wp_error($response)) {
            $this->logError("Error for: $this->service_name in executeRequest", ['message' => $response->get_error_message()]);
            return null;
        }
        $response_code = wp_remote_retrieve_response_code($response);
        $body = wp_remote_retrieve_body($response);
        // Handle 401 - try to refresh token and retry once
        if ($response_code === 401 && $this->isOAuthService && !empty($this->credentials['refresh_token'])) {
            // Avoid infinite retry loop - only retry once
            static $retry_count = 0;
            if ($retry_count === 0) {
                $retry_count++;
//              $this->logDebug('Got 401, attempting token refresh...');
                if ($this->refreshOAuthToken()) {
//                  $this->logDebug('Token refreshed successfully, retrying request...');
                    // Rebuild request args with new token
                    $args = $this->buildRequestArgs($method, $data, $options);
                    // Retry the request
                    $response = match($method) {
                        'GET' => wp_remote_get($url, $args),
                        'POST' => wp_remote_post($url, $args),
                        'PUT', 'PATCH', 'DELETE' => wp_remote_request($url, array_merge($args, ['method' => $method])),
                        default => null
                    };
                    if ($response && !is_wp_error($response)) {
                        $response_code = wp_remote_retrieve_response_code($response);
                        $body = wp_remote_retrieve_body($response);
                    }
                }
                $retry_count = 0; // Reset for next request
            }
        }
        if ($response_code >= 400) {
            $this->handleApiError($response_code, $body, $endpoint);
        }
        $decoded = json_decode($body, true);
        if (json_last_error() !== JSON_ERROR_NONE) {
            return ['raw_response' => $body];
        }
        $this->updateLastTestedTime();
        return $decoded;
    }
    /**
     * GET request with caching
     */
    protected function getRequest(
        string $endpoint,
        array  $params = [],
        ?string $baseKey = null,
        string $cacheStrategy = 'moderate',
        bool   $force = false
    ): ?array
    {
        $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");
                return $cached;
            }
        }
        $result = $this->makeRequest('GET', $endpoint, $params, $baseKey);
        // Only cache successful responses (not WP_Error and not error objects)
        if ($result && !is_wp_error($result) && !$this->isErrorResponse($result) && $ttl > 0) {
            $this->cache->set($cacheKey, $result, $ttl);
        }
        return $result;
    }
    /**
     * Check if response contains an error
     * Override in child classes for service-specific error detection
     */
    protected function isErrorResponse(array $response): bool
    {
        // Common error patterns across APIs
        return isset($response['error'])
            || isset($response['errors'])
            || isset($response['error_description']);
    }
    /**
     * POST request
     */
    protected function postRequest(string $endpoint, array $data = [], ?string $baseKey = null): ?array
    {
        return $this->makeRequest('POST', $endpoint, $data, $baseKey);
    }
    /**
     * PUT request
     */
    protected function putRequest(string $endpoint, array $data = [], ?string $baseKey = null): ?array
    {
        return $this->makeRequest('PUT', $endpoint, $data, $baseKey);
    }
    /**
     * DELETE request
     */
    protected function deleteRequest(string $endpoint, array $data = [], ?string $baseKey = null): ?array
    {
        return $this->makeRequest('DELETE', $endpoint, $data, $baseKey);
    }
    /**
     * PATCH request
     */
    protected function patchRequest(string $endpoint, array $data = [], ?string $baseKey = null): WP_Error|array
    {
        return $this->makeRequest('PATCH', $endpoint, $data, $baseKey);
    }
    /**
     * Build request arguments
     */
    protected function buildRequestArgs(string $method, array $data, array $options): array
    {
        $args = [
            'method' => $method,
            'headers' => $this->getRequestHeaders(),
            'timeout' => $options['timeout'] ?? 30,
            'sslverify' => $options['sslverify'] ?? true,
            'redirection' => $options['redirection'] ?? 5,
            'blocking' => $options['blocking'] ?? true,
            'compress' => $options['compress'] ?? true,
            'decompress' => $options['decompress'] ?? true,
        ];
        if ($method === 'GET' && !empty($data)) {
            // Add query parameters for GET requests
            $args['body'] = $data;
        } elseif (!empty($data)) {
            $content_type = $args['headers']['Content-Type'] ?? 'application/json';
            if (str_contains($content_type, 'application/json')) {
                $args['body'] = json_encode($data);
            } elseif (str_contains($content_type, 'application/x-www-form-urlencoded')) {
                $args['body'] = http_build_query($data);
            } else {
                $args['body'] = $data;
            }
        }
        return $args;
    }
    /*********************************************************************
     * SYNC METHODS
@@ -1101,114 +652,17 @@
     *********************************************************************/
    /**
     * Get cache duration based on endpoint
     *
     * @param string $endpoint The API endpoint
     * @return int Cache duration in seconds
     */
    protected function getCacheDuration(string $endpoint): int
    {
        // Override in child classes for endpoint-specific caching
        // Example: user profiles = aggressive, posts = moderate, analytics = minimal
        return $this->ttl;
    }
    /**
     * Check if we're within rate limits
     *
     * @return bool True if within limits
     */
    protected function checkRateLimit(): bool
    {
        $now = time();
        // Clean old history
        $this->request_history = array_filter($this->request_history, function($timestamp) use ($now) {
            return ($now - $timestamp) < 3600; // Keep last hour
        });
        // Trim to max size
        if (count($this->request_history) > $this->max_history_size) {
            $this->request_history = array_slice($this->request_history, -$this->max_history_size);
        }
        // Check limits
        $recent_counts = [
            'second' => 0,
            'minute' => 0,
            'hour' => count($this->request_history)
        ];
        foreach ($this->request_history as $timestamp) {
            if ($now - $timestamp <= 1) $recent_counts['second']++;
            if ($now - $timestamp <= 60) $recent_counts['minute']++;
        }
        if ($recent_counts['second'] >= $this->rate_limits['per_second']) return false;
        if ($recent_counts['minute'] >= $this->rate_limits['per_minute']) return false;
        if ($recent_counts['hour'] >= $this->rate_limits['per_hour']) return false;
        return true;
    }
    protected function checkOAuthRateLimit(): bool
    {
        $cache_key = 'oauth_rate_limit_' . $this->service_name . '_' . $this->userID;
        $attempts = get_transient($cache_key) ?: [];
        $now = time();
        // Clean old attempts
        $attempts = array_filter($attempts, function($timestamp) use ($now) {
            return ($now - $timestamp) < 3600; // Keep last hour
        });
        // Check per-minute limit
        $recent = array_filter($attempts, function($timestamp) use ($now) {
            return ($now - $timestamp) < 60;
        });
        if (count($recent) >= 5) { // Max 5 OAuth attempts per minute
            return false;
        }
        // Add current attempt
        $attempts[] = $now;
        // Save updated attempts
        set_transient($cache_key, $attempts, 3600);
        return true;
    }
    /**
     * Record a request for rate limiting
     *
     * @param string $method HTTP method
     * @param string $endpoint API endpoint
     * @return void
     */
    protected function recordRequest(string $method, string $endpoint): void
    {
        $this->request_history[] = time();
    }
    /**
     * Register WordPress hooks based on capabilities
     */
    protected function registerHooks(): void
    {
        //Handled by IntegrationExecutor now
//      add_filter(BASE . 'handle_bulk_operation', [$this, 'processOperation'], 10, 3);
        if (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)){
        if (method_exists($this, 'registerOAuthCallbacks')) {
            $this->registerOAuthCallbacks();
        }
        if (method_exists($this, 'registerWebhookEndpoint')) {
            $this->registerWebhookEndpoint();
        }
        // Let child classes register additional hooks if needed
@@ -1280,62 +734,7 @@
            {
                //Empty. Integration extensions can register additional operation types from here.
            }
    public function handleAjaxResponse()
    {
        $code = $_GET['code'];
        $state =  $_GET['state'];
        $state_parts = explode('|', $state);
        $state_key = $state_parts[0] ?? '';
        $user_id = intval($state_parts[1] ?? 0);
        $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');
        $state_data = get_transient('oauth_state_' . $state_key);
        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);
        // Handle error from OAuth provider
        if (array_key_exists('error', $_GET)) {
            $error_description = $_GET['error_description'] ?? 'Authorization denied';
            wp_redirect(add_query_arg([
                'error' => 'OAuth authorization denied: ' . $error_description
            ], $return_url));
            exit;
        }
        // Get integration instance
        $integration = JVB()->connect($this->service_name, $user_id);
        if (!$integration) {
            wp_die('Invalid service: ' . esc_html($this->service_name), 'OAuth Error');
        }
        // Exchange code for tokens
        $result = $integration->handleOAuthCode($code, $state);
        // Redirect back with result
        if ($result['success']) {
            wp_redirect(add_query_arg([
                'success' => 'Successfully connected to ' . $integration->title
            ], $return_url));
        } else {
            // Handle failure
            $error_message = $result['message'] ?? 'Failed to complete OAuth authorization';
            wp_redirect(add_query_arg([
                'error' => $error_message
            ], $return_url));
        }
        exit;
    }
    /**
     * Handle connection setup and credential storage
     *
@@ -1393,45 +792,19 @@
        }
    }
    /**
     * Load credentials from secure storage
     */
    protected function loadCredentials(): void
    {
        $this->credentials = CredentialsManager::getInstance()->getCredentials($this->service_name, $this->userID);
    }
    public function getCredentials(): array
    {
        $this->ensureInitialized();
        return $this->credentials;
        return $this->loadCredentials();
    }
    /**
     * Check if integration is properly configured
     */
    public function isSetUp(): bool
    public function hasOAuthCredentials(?int $userID = null): bool
    {
        if (empty($this->credentials)) {
            $this->loadCredentials();
        if ($userID !== $this->userID) {
            $this->switchUser($userID);
        }
        return !empty($this->credentials);
    }
    public function hasOAuthCredentials(): bool
    {
        if (!$this->isOAuthService){
            return false;
        }
        $required = ['client_id', 'client_secret', 'access_token'];
        foreach ($required as $field) {
            if (empty($this->credentials[$field]) || !is_string($this->credentials[$field]) || $this->credentials[$field] === '') {
                return false;
            }
        }
        return true;
        return $this->hasOAuth && $this->hasValidOAuth();
    }
@@ -1443,42 +816,7 @@
        if (!$this->isSetUp()){
            return;
        }
        if (empty($this->credentials)) {
            $this->loadCredentials();
        }
        if (!empty($this->credentials)) {
            if ($this->isOAuthService && $this->hasOAuthCredentials()) {
                // Check if token is expired first
                if (!$this->isOAuthValid()) {
                    // Only attempt refresh once per request
                    if (!$this->token_refresh_attempted) {
                        $this->token_refresh_attempted = true;
//                      $this->logDebug('OAuth token expired, attempting refresh');
                        if (!$this->refreshOAuthToken()) {
                            $this->logError('Failed to refresh expired OAuth token - stopping execution');
                            // Token refresh failed - DO NOT continue making API requests
                            return;
                        }
                    } else {
                        // Already attempted refresh in this request
//                      $this->logDebug('Token refresh already attempted, skipping');
                        return;
                    }
                }
                // Check if we should proactively refresh (before expiry)
                elseif ($this->shouldRefreshToken() && !$this->token_refresh_attempted) {
                    $this->token_refresh_attempted = true;
//                  $this->logDebug('OAuth token should be refreshed proactively');
                    if (!$this->refreshOAuthToken()) {
                        $this->logError('Failed to proactively refresh OAuth token');
                        // Not critical - token is still valid, so continue
                    }
                }
            }
            $this->initialize();
        }
        $this->initialize();
    }
@@ -1526,9 +864,6 @@
        // Update error statistics
        $this->updateErrorStats($code, $endpoint);
        // Check if integration should be marked unhealthy
        $this->checkIntegrationHealth();
        // Store last error for debugging
        $this->lastError = [
            'code' => $code,
@@ -1539,57 +874,10 @@
        ];
    }
    /**
     * Check if error is a client error (4xx)
     */
    protected function isClientError(Exception $e): bool
    {
        $code = $e->getCode();
        return $code >= 400 && $code < 500;
    }
    /**
     * Get last error details
     */
    public function getLastError(): array
    {
        return $this->lastError;
    }
    /*****************************************************************
        OAUTH
     *****************************************************************/
    /**
     * Check user capabilities for specific actions
     * Can be overridden by child classes for custom logic
     */
    protected function checkCapabilities(string $action, ?int $userID = null): bool
    {
        // Default: require manage_options for admin actions
        $admin_actions = ['disconnect', 'update_credentials', 'clear_cache'];
        if (in_array($action, $admin_actions)) {
            return current_user_can('manage_options');
        }
        // User-specific actions
        return is_user_logged_in();
    }
    /*****************************************************************
        OAUTH
     *****************************************************************/
    protected function initializeOAuth():void
    {
        $this->oauth = array_merge([
            'authorize' => '',
            'token'     => '',
            'revoke'    => '',
            'scopes'    => [],
            'redirect_uri' => admin_url('admin-ajax.php?action=' . $this->service_name . '_oauth_callback')
        ], $this->oauth);
    }
    /**
     * Get OAuth authorization URL
@@ -1597,7 +885,7 @@
    public function getOAuthUrl(?string $return_url = null): string
    {
        if (!$this->isOAuthService) {
        if (!$this->hasOAuth) {
            return '';
        }
@@ -1663,119 +951,7 @@
        return $params;
    }
    protected function makeOAuthRequest(string $method, string $url, array $data = []): array|WP_Error
    {
        // Check rate limits before making request
        if (!$this->checkOAuthRateLimit()) {
            // For OAuth, we might want to be more lenient with rate limits
            // since these are critical authentication flows
            $this->logError('OAuth request rate limited, waiting before retry');
            sleep(2); // Brief pause before critical OAuth request
            // Check again after wait
            if (!$this->checkRateLimit()) {
                return new WP_Error(
                    'rate_limit',
                    'OAuth rate limit exceeded. Please try again in a few moments.'
                );
            }
        }
        // Record the request for rate limiting
        $this->recordRequest($method, $url);
        // Build OAuth-specific headers
        $headers = [
            'Content-Type' => 'application/json',
            'Accept' => 'application/json',
        ];
        // Add API version for services that require it (like Square)
        if (!empty($this->apiVersion)) {
            $headers['Square-Version'] = $this->apiVersion;
        }
        // Build request arguments
        $args = [
            'method' => $method,
            'headers' => $headers,
            'timeout' => 30,
            'sslverify' => true,
            'body' => json_encode($data)
        ];
        // Make the request with retry logic
        $attempt = 0;
        $max_attempts = 2; // Fewer retries for OAuth to avoid code expiry
        while ($attempt < $max_attempts) {
            $attempt++;
            // Make the actual request
            $response = wp_remote_request($url, $args);
            // Check for WordPress errors
            if (is_wp_error($response)) {
                if ($attempt >= $max_attempts) {
                    return $response;
                }
                // Wait before retry
                sleep($this->retryDelays[$attempt - 1] ?? 1);
                continue;
            }
            // Get response code and body
            $code = wp_remote_retrieve_response_code($response);
            $body = wp_remote_retrieve_body($response);
            // Parse JSON response
            $data = json_decode($body, true);
            // Check for successful response
            if ($code >= 200 && $code < 300) {
                // Reset error stats on success
                $this->resetErrorStats();
                // Return parsed data
                return $data ?: [];
            }
            // Handle specific OAuth error codes
            if ($code === 401) {
                // Invalid credentials - don't retry
                $this->handleApiError($code, $body, $url);
                return new WP_Error('invalid_credentials', 'Invalid OAuth credentials');
            }
            if ($code === 429) {
                // Rate limited - wait longer before retry
                if ($attempt < $max_attempts) {
                    $retry_after = $this->extractRetryAfter($response);
                    sleep($retry_after ?: 5);
                    continue;
                }
            }
            // Check if we should retry
            if ($attempt >= $max_attempts || $code >= 400 && $code < 500) {
                // Client errors (4xx) typically shouldn't be retried
                $this->handleApiError($code, $body, $url);
                $error_message = $data['error_description'] ??
                    $data['error'] ??
                    "OAuth request failed with status {$code}";
                return new WP_Error('oauth_error', $error_message, ['code' => $code]);
            }
            // Server error - retry after delay
            sleep($this->retryDelays[$attempt - 1] ?? 1);
        }
        // Shouldn't reach here, but handle it
        return new WP_Error('oauth_error', 'OAuth request failed after retries');
    }
    /**
     * Extract retry-after header from response
@@ -1870,7 +1046,7 @@
     */
    protected function shouldRefreshToken(): bool
    {
        if (!$this->isOAuthService || $this->refresh_interval === 0) {
        if (!$this->hasOAuth || $this->refresh_interval === 0) {
            return false;
        }
@@ -1908,7 +1084,7 @@
     */
    public function getTokenStatus(): string
    {
        if (!$this->isOAuthService) {
        if (!$this->hasOAuth) {
            return 'not_oauth';
        }
@@ -1938,7 +1114,7 @@
     */
    protected function refreshOAuthToken(): bool
    {
        if (!$this->isOAuthService || empty($this->credentials['refresh_token'])) {
        if (!$this->hasOAuth || empty($this->credentials['refresh_token'])) {
            return false;
        }
@@ -1992,7 +1168,7 @@
     */
    public function revokeOAuthAccess(): bool
    {
        if (!$this->isOAuthService || empty($this->oauth['revoke'])) {
        if (!$this->hasOAuth || empty($this->oauth['revoke'])) {
            return false;
        }
@@ -2002,7 +1178,7 @@
            ]);
        }
        return CredentialsManager::getInstance()->deleteCredentials($this->service_name, $this->userID);
        return Auth::getInstance()->deleteCredentials($this->service_name, $this->userID);
    }
@@ -2322,7 +1498,7 @@
     */
    protected function getApiUrl(string $endpoint, ?string $baseKey = null): string|false
    {
        if ($this->isOAuthService && in_array($endpoint, $this->oauth)) {
        if ($this->hasOAuth && in_array($endpoint, $this->oauth)) {
            return $endpoint;
        }
        if (is_array($this->apiBase)) {
@@ -2390,65 +1566,7 @@
        return false;
    }
    /**
     * Build cache key
     */
    protected function buildCacheKey(string $method, string $endpoint, array $params = []): string
    {
        $key_parts = [
            $this->service_name,
            $method,
            $endpoint
        ];
        if ($this->userID) {
            $key_parts[] = "user_{$this->userID}";
        }
        if (!empty($params)) {
            $key_parts[] = md5(serialize($params));
        }
        return implode('_', $key_parts);
    }
    /**
     * Log error message
     */
    protected function logError(string $message, array $context = [], ?string $severity = null): void
    {
        if (!$this->errorHandler) {
            $this->errorHandler = JVB()->error();
        }
        // Determine severity if not provided
        if ($severity === null) {
            $severity = 'error';
        }
        // Add integration-specific context
        $context = array_merge($context, [
            'component' => 'Integration:' . $this->service_name,
            'user_id' => $this->userID,
            'service' => $this->service_name,
            'timestamp' => current_time('mysql'),
            'memory_usage' => memory_get_usage(true),
            'peak_memory' => memory_get_peak_usage(true),
            'integration_health' => [
                'is_healthy' => $this->is_healthy,
                'consecutive_errors' => $this->error_stats['consecutive_errors'],
                'total_errors' => $this->error_stats['total_errors'],
                'last_success' => $this->error_stats['last_success']
            ]
        ]);
        // Log through ErrorHandler
        $this->errorHandler->log(
            'Integration:' . $this->service_name,
            $message,
            $context,
            $severity
        );
    }
    /**
     * Categorize API errors for better tracking
@@ -2549,100 +1667,8 @@
        $this->saveErrorStats();
    }
    /**
     * Reset error statistics on successful request
     */
    protected function resetErrorStats(): void
    {
        $this->error_stats['consecutive_errors'] = 0;
        $this->error_stats['last_success'] = time();
        $this->is_healthy = true;
        $this->saveErrorStats();
    }
    /**
     * Get integration health status
     */
    public function getHealthStatus(): array
    {
        return [
            'is_healthy' => $this->is_healthy,
            'consecutive_errors' => $this->error_stats['consecutive_errors'],
            'total_errors' => $this->error_stats['total_errors'],
            'last_success' => $this->error_stats['last_success'],
            'error_types' => $this->error_stats['error_types'],
            'threshold' => $this->error_threshold
        ];
    }
    /**
     * Manually reset integration health
     */
    public function resetHealth(): void
    {
        $this->error_stats = [
            'total_errors' => 0,
            'consecutive_errors' => 0,
            'last_success' => time(),
            'error_types' => []
        ];
        $this->is_healthy = true;
        $this->saveErrorStats();
    }
    /**
     * Check integration health based on error patterns
     */
    protected function checkIntegrationHealth(): void
    {
        // Mark unhealthy if too many consecutive errors
        if ($this->error_stats['consecutive_errors'] >= $this->error_threshold) {
            $this->is_healthy = false;
            // Log critical health issue
            $this->logError(
                "Integration marked unhealthy after {$this->error_stats['consecutive_errors']} consecutive errors",
                [
                    'error_stats' => $this->error_stats,
                    'threshold' => $this->error_threshold
                ],
                'critical'
            );
        }
    }
    /**
     * Load error statistics from cache
     */
    protected function loadErrorStats(): void
    {
        $cache_key = "error_stats_{$this->service_name}_{$this->userID}";
        $cached_stats = $this->cache->get($cache_key);
        if ($cached_stats) {
            $this->error_stats = array_merge($this->error_stats, $cached_stats);
            // Check if integration was marked unhealthy
            if ($this->error_stats['consecutive_errors'] >= $this->error_threshold) {
                $this->is_healthy = false;
            }
        }
    }
    /**
     * Save error statistics to cache
     */
    protected function saveErrorStats(): void
    {
        $cache_key = "error_stats_{$this->service_name}_{$this->userID}";
        $this->cache->set($cache_key, $this->error_stats, 86400); // 24 hours
    }
    protected function logDebug(string $message, array $context = []): void
    {
        error_log('[Integration: '.$this->service_name.']'.$message.': '.print_r($context, true));
    }
    /**
     * Get service name
@@ -2698,69 +1724,6 @@
        return "Manage your {$this->getServiceName()} integration settings.";
    }
    /**
     * Get service key for consistency
     */
    public function getServiceKey(): string
    {
        return strtolower($this->service_name);
    }
    /**
     * Enhanced webhook handling with common patterns
     */
    public function handleWebhook(array $payload): bool
    {
        // Log incoming webhook for debugging
        $this->logDebug('Webhook received', [
            'payload_keys' => array_keys($payload),
            'timestamp' => time()
        ]);
        // Validate webhook signature/authenticity
        if (!$this->validateWebhook($payload)) {
            $this->logError('Webhook validation failed', [
                'webhook_type' => 'validation_error',
                'payload_sample' => array_slice($payload, 0, 5) // Log partial payload for debugging
            ], 'warning');
            return false;
        }
        // Check for duplicate processing (idempotency)
        if ($this->isWebhookProcessed($payload)) {
            return true; // Return true to prevent retries
        }
        try {
            // Process the webhook
            $result = $this->processWebhook($payload);
            // Mark as processed if successful
            if ($result) {
                $this->markWebhookProcessed($payload);
                $this->resetErrorStats();
            } else {
                $this->logError('Webhook processing returned false', [
                    'webhook_id' => $this->extractWebhookId($payload)
                ], 'warning');
            }
            return $result;
        } catch (Exception $e) {
            $this->logError('Webhook processing failed', [
                'error' => $e->getMessage(),
                'webhook_id' => $this->extractWebhookId($payload),
                'trace' => $e->getTraceAsString()
            ], 'critical');
            // Update error stats
            $this->updateErrorStats($e->getCode() ?: 500, 'webhook');
            $this->checkIntegrationHealth();
            return false;
        }
    }
    /**
     * Validate webhook signature
@@ -2782,110 +1745,14 @@
        return hash_equals($expected, $signature);
    }
    protected function validateWebhook(array $payload):bool
    {
        //Handled by child classes
        return false;
    }
    protected function processWebhook(array $payload):bool
    {
        //Handled by child classes
        return false;
    }
    /**
     * Check if webhook was already processed (prevent duplicates)
     */
    protected function isWebhookProcessed(array $payload): bool
    {
        $webhook_id = $this->extractWebhookId($payload);
        if (!$webhook_id) {
            return false;
        }
        $cache_key = "webhook_processed_{$this->service_name}_{$webhook_id}";
        return $this->cache->get($cache_key) !== null;
    }
    /**
     * Mark webhook as processed
     */
    protected function markWebhookProcessed(array $payload): void
    {
        $webhook_id = $this->extractWebhookId($payload);
        if ($webhook_id) {
            $cache_key = "webhook_processed_{$this->service_name}_{$webhook_id}";
            $this->cache->set($cache_key, time(), 86400); // Store for 24 hours
        }
    }
    /**
     * Extract unique webhook ID (override in child classes)
     */
    protected function extractWebhookId(array $payload): ?string
    {
        // Common patterns - child classes can override
        return $payload['id'] ??
            $payload['event_id'] ??
            $payload['webhook_id'] ??
            md5(serialize($payload));
    }
    /**
     * Register webhook endpoint
     */
    protected function registerWebhookEndpoint(): void
    {
        // Register REST API endpoint for webhooks
        add_action('rest_api_init', function() {
            register_rest_route('jvb/v1', '/webhooks/' . $this->service_name, [
                'methods' => 'POST',
                'callback' => [$this, 'handleWebhookRequest'],
                'permission_callback' => '__return_true', // Webhooks come from external services
            ]);
        });
    }
    /**
     * Handle webhook REST API request
     */
    public function handleWebhookRequest(WP_REST_Request $request): WP_REST_Response
    {
        $payload = $request->get_params();
        $headers = $request->get_headers();
        // Include headers in payload for signature validation
        $payload['_headers'] = $headers;
        $success = $this->handleWebhook($payload);
        return new WP_REST_Response([
            'success' => $success
        ], $success ? 200 : 400);
    }
    protected function renderWebhookUrl(): string
    {
        if (!$this->handleWebhooks || !$this->isSetUp()) {
            return '';
        }
        $webhook_url = rest_url('jvb/v1/webhooks/' . $this->service_name);
        return sprintf(
            '<div class="webhook-info">
            <h4>Webhook URL</h4>
            <code id="webhook-url-%s">%s</code>
            <p class="hint">Add this URL to your %s webhook settings</p>
        </div>',
            esc_attr($this->service_name),
            esc_html($webhook_url),
            esc_html($this->title)
        );
    }
    public function renderConnection(bool $return = false):string
    {
        if ($this->userID && !JVB()->userCanConnect($this->service_name, $this->userID)) {
            return '';
        }
@@ -2970,7 +1837,7 @@
                        echo Form::render($name, '', $config);
                    }
                }
                if ($this->handleWebhooks) {
                if ($this->hasWebhooks) {
                    echo $this->renderWebhookUrl();
                }
                ?>
@@ -3108,26 +1975,14 @@
    }
    /**
     * Get last tested timestamp - extends your existing pattern
     */
    protected function getLastTestedTime(): ?int
    {
        $key = $this->userID
            ? "jvb_integration_{$this->service_name}_last_test_user_{$this->userID}"
            : "jvb_integration_{$this->service_name}_last_test";
        return get_option($key, null);
    }
    /**
     * Update last tested time
     */
    public function updateLastTestedTime(): void
    {
        $key = $this->userID
            ? "jvb_integration_{$this->service_name}_last_test_user_{$this->userID}"
            : "jvb_integration_{$this->service_name}_last_test";
        update_option($key, time());
        $cred = Auth::getInstance();
        $cred->updateTested($this->service_name, $this->userID);
    }
    public function handleAdminPost(): void
@@ -3321,155 +2176,16 @@
        if ($jpegVersion !== '' && is_int($jpegVersion)) {
            return $jpegVersion;
        }
        $converted = $this->convertWebpToJpeg($imgID);
        if ($converted) {
            update_post_meta($imgID, BASE . 'jpeg_version', $converted);
            return $converted;
        $uploader = new UploadManager();
        $converted = $uploader->convertImageTo($imgID, 'jpeg', 80, false);
        if ($converted && !is_wp_error($converted)) {
            update_post_meta($imgID, BASE . 'jpeg_version', $converted['attachment_id']);
            return $converted['attachment_id'];
        }
        return $imgID;
    }
    /**
     * Convert WebP image to JPEG
     *
     * @param int $webpAttachmentId
     * @return int|false Image ID on success, false on failure.
     */
    protected function convertWebpToJpeg(int $webpAttachmentId):int|false
    {
        try {
            $webpPath = get_attached_file($webpAttachmentId);
            if (!file_exists($webpPath)) {
                throw new Exception('WebP file not found');
            }
            // Generate JPEG filename
            $pathInfo = pathinfo($webpPath);
            $jpegPath = $pathInfo['dirname'] . '/' . $pathInfo['filename'] . '_jpeg.jpg';
            // Check if already exists (shouldn't happen due to meta check, but safety first)
            if (file_exists($jpegPath)) {
                // Try to find existing attachment
                global $wpdb;
                $existingId = $wpdb->get_var($wpdb->prepare(
                    "SELECT ID FROM {$wpdb->posts} WHERE guid = %s",
                    str_replace(ABSPATH, site_url('/'), $jpegPath)
                ));
                if ($existingId) {
                    return (int)$existingId;
                }
            }
            // Perform conversion based on available libraries
            if (extension_loaded('imagick')) {
                $this->convertWebpToJpegImagick($webpPath, $jpegPath);
            } else {
                $this->convertWebpToJpegGd($webpPath, $jpegPath);
            }
            // Create attachment for the JPEG
            $attachmentData = [
                'post_mime_type' => 'image/jpeg',
                'post_title' => get_the_title($webpAttachmentId) . ' (JPEG)',
                'post_content' => '',
                'post_status' => 'inherit'
            ];
            $jpegAttachmentId = wp_insert_attachment($attachmentData, $jpegPath);
            if (is_wp_error($jpegAttachmentId)) {
                throw new Exception('Failed to create JPEG attachment: ' . $jpegAttachmentId->get_error_message());
            }
            // Generate metadata
            require_once(ABSPATH . 'wp-admin/includes/image.php');
            $attachData = wp_generate_attachment_metadata($jpegAttachmentId, $jpegPath);
            wp_update_attachment_metadata($jpegAttachmentId, $attachData);
            // Copy alt text and other meta
            $altText = get_post_meta($webpAttachmentId, '_wp_attachment_image_alt', true);
            if ($altText) {
                update_post_meta($jpegAttachmentId, '_wp_attachment_image_alt', $altText);
            }
            return $jpegAttachmentId;
        } catch (Exception $e) {
            $this->logError('Image conversion failed...', [
                'method'    => 'convertWebpToJpeg'
            ]);
            return false;
        }
    }
    /**
    * Convert WebP to JPEG using Imagick
    */
    protected function convertWebpToJpegImagick(string $source, string $destination): void
    {
        $image = new \Imagick($source);
        $image->setImageFormat('jpeg');
        // Set quality (using 85 for good balance of quality/size)
        $image->setImageCompressionQuality(85);
        // Remove alpha channel and set white background
        $image->setImageBackgroundColor('white');
        $image->setImageAlphaChannel(\Imagick::ALPHACHANNEL_REMOVE);
        $image->mergeImageLayers(\Imagick::LAYERMETHOD_FLATTEN);
        $image->writeImage($destination);
        $image->clear();
    }
    /**
     * Convert WebP to JPEG using GD
     */
    protected function convertWebpToJpegGd(string $source, string $destination): void
    {
        $image = imagecreatefromwebp($source);
        if ($image === false) {
            throw new Exception('Failed to create image from WebP source');
        }
        // Get dimensions
        $width = imagesx($image);
        $height = imagesy($image);
        // Create new true color image
        $jpegImage = imagecreatetruecolor($width, $height);
        // Set white background (for transparency)
        $white = imagecolorallocate($jpegImage, 255, 255, 255);
        imagefill($jpegImage, 0, 0, $white);
        // Copy WebP image to JPEG
        imagecopy($jpegImage, $image, 0, 0, 0, 0, $width, $height);
        // Save as JPEG (quality 85)
        $result = imagejpeg($jpegImage, $destination, 85);
        // Clean up
        imagedestroy($image);
        imagedestroy($jpegImage);
        if (!$result) {
            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
    {
@@ -3490,15 +2206,7 @@
        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
    {
@@ -3512,468 +2220,4 @@
    {
        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();
    }
}
inc/integrations/OAuth.php
New file
@@ -0,0 +1,351 @@
<?php
namespace JVBase\integrations;
use JetBrains\PhpStorm\NoReturn;
use WP_Error;
if (!defined('ABSPATH')) {
    exit;
}
trait OAuth
{
    use _Base, Admin;
    protected bool $hasOAuth = true;
    /**
     * Child classes must set this (e.g. 'https://api.example.com/v1/')
     */
    protected ?string $baseOAuthUrl = null;
    protected ?string $oauthRedirectUrl = null;
    protected int $oauthTTL = 300;
    protected array $oathScopes = [];
    abstract protected function getOAuthRequestHeaders(): array;
    abstract protected function handleOAuthResponse(array|WP_Error $response, string $method, string $endpoint): array;
    abstract protected function refreshAccessToken(OAuthCredentials $credentials): array;
    function addOAuthActions():void
    {
        $actions = [
            [
                'name'      => 'Connect to Service',
                'label'     => 'Connect',
                'icon'      => 'plugs-connected',
                'action'    => [$this, 'handleOAuthConnect']
            ],
            [
                'name'      => 'Disconnect from Service',
                'icon'      => 'plugs-disconnected',
                'action'    => [$this, 'handleOAuthDisconnect']
            ],
            [
                'name'      => 'Refresh Token',
                'icon'      => 'arrows-clockwise',
                'action'    => [$this, 'handleOAuthRefreshToken']
            ]
        ];
        foreach ($actions as $a) {
            $this->addAction($a['name'], $a['action'], $a['slug']??null, $a['icon']??null, $a['label']??null);
        }
    }
    protected function registerOAuthCallbacks():void
    {
        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'] );
    }
    protected function getOAuthRequest(string $endpoint, array $data = [], bool $force = false, ?int $ttl = null): array
    {
        $key = [$this->service_name, $this->userID, $endpoint, $data];
        if (!$force) {
            $cached = $this->cache->get($key);
            if ($cached) {
                return $cached;
            }
        }
        $response = $this->makeOAuthRequest('GET', $endpoint, $data);
        if ($response && !is_wp_error($response)) {
            $ttl = is_int($ttl) ? $ttl : $this->oauthTTL;
            $this->cache->set($key, $response, $ttl);
        }
        return $response;
    }
    protected function postOAuthRequest(string $endpoint, array $data = []): array
    {
        return $this->makeOAuthRequest('POST', $endpoint, $data);
    }
    protected function putOAuthRequest(string $endpoint, array $data = []): array
    {
        return $this->makeOAuthRequest('PUT', $endpoint, $data);
    }
    protected function deleteOAuthRequest(string $endpoint, array $data = []): array
    {
        return $this->makeOAuthRequest('DELETE', $endpoint, $data);
    }
    protected function makeOAuthRequest(string $method, string $endpoint, array $data = []): array
    {
        if (is_null($this->baseOAuthUrl)) {
            error_log('Can not make request. No $baseOAuthUrl set for '.$this->service_name);
            return [];
        }
        if ($this->oauthLimiter && !$this->awaitOAuthRateLimit($endpoint)) {
            $error = new WP_Error('rate_limit_exceeded', "Rate limit exceeded for {$endpoint}");
            return $this->handleOAuthResponse($error, $method, $endpoint);
        }
        $credentials = $this->ensureFreshToken();
        if (!$credentials->isValid()) {
            $error = new WP_Error('invalid_credentials', "Missing or invalid OAuth credentials for {$this->service_name}");
            return $this->handleOAuthResponse($error, $method, $endpoint);
        }
        if (!empty($this->requiredScopes) && !$credentials->hasScopes($this->requiredScopes)) {
            $error = new WP_Error('insufficient_scope', "Missing required scopes for {$this->service_name}");
            return $this->handleOAuthResponse($error, $method, $endpoint);
        }
        $url = rtrim($this->baseOAuthUrl, '/') . '/' . ltrim($endpoint, '/');
        $args = [
            'method'    => $method,
            'headers'   => $this->getOAuthRequestHeaders(),
            'timeout'   => 15,
        ];
        if (!empty($data)) {
            if (in_array($method, ['GET', 'DELETE'], true)) {
                $url = add_query_arg($data, $url);
            } else {
                $args['body'] = wp_json_encode($data);
            }
        }
        $response = wp_remote_request($url, $args);
        $this->oauthLimiter?->recordRequest($endpoint);
        return $this->handleOAuthResponse($response, $method, $endpoint);
    }
    /**
     * Checks stored credentials, refreshes via refreshAccessToken() if expired,
     * and persists the refreshed credentials back to Auth
     */
    protected function ensureFreshToken(): OAuthCredentials
    {
        $auth = Auth::getInstance();
        $credentials = new OAuthCredentials($auth->getCredentials($this->service_name, $this->userID));
        if (!$credentials->isValid() || !$credentials->isExpired()) {
            return $credentials;
        }
        $refreshed = new OAuthCredentials($this->refreshAccessToken($credentials));
        if ($refreshed->isValid()) {
            $auth->storeCredentials($this->service_name, $refreshed->toArray(), $this->userID);
            return $refreshed;
        }
        return $credentials;
    }
    public function hasValidOAuth():bool
    {
        $auth = Auth::getInstance();
        $credentials = new OAuthCredentials($auth->getCredentials($this->service_name, $this->userID));
        return $credentials->isValid();
    }
    protected function awaitOAuthRateLimit(string $endpoint, int $maxAttempts = 3): bool
    {
        $attempts = 0;
        if (is_null($this->oauthLimiter)) {
            return false;
        }
        while (!$this->oauthLimiter->checkRateLimit($endpoint)) {
            $attempts++;
            if ($attempts >= $maxAttempts) {
                return false;
            }
            usleep(random_int(250_000, 750_000));
        }
        return true;
    }
    /***************************************************************
     * ACTIONS CALLBACKS
    ***************************************************************/
    #[NoReturn] public function handleAjaxResponse():void
    {
        $code = $_GET['code'];
        $state =  $_GET['state'];
        $state_parts = explode('|', $state);
        $state_key = $state_parts[0] ?? '';
        $user_id = intval($state_parts[1] ?? 0);
        $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');
        $state_data = get_transient('oauth_state_' . $state_key);
        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);
        // Handle error from OAuth provider
        if (array_key_exists('error', $_GET)) {
            $error_description = $_GET['error_description'] ?? 'Authorization denied';
            wp_redirect(add_query_arg([
                'error' => 'OAuth authorization denied: ' . $error_description
            ], $return_url));
            exit;
        }
        // Get integration instance
        $integration = JVB()->connect($this->service_name, $user_id);
        if (!$integration) {
            wp_die('Invalid service: ' . esc_html($this->service_name), 'OAuth Error');
        }
        // Exchange code for tokens
        $result = $integration->handleOAuthCode($code, $state);
        // Redirect back with result
        if ($result['success']) {
            wp_redirect(add_query_arg([
                'success' => 'Successfully connected to ' . $integration->title
            ], $return_url));
        } else {
            // Handle failure
            $error_message = $result['message'] ?? 'Failed to complete OAuth authorization';
            wp_redirect(add_query_arg([
                'error' => $error_message
            ], $return_url));
        }
        exit;
    }
    protected function renderOAuthConnectionForm():string
    {
        $credentials = $this->loadCredentials();
        $hasCredentials = $this->hasValidOAuth();
        $returnURL = $this->getReturnURL();
        if ($hasCredentials) {
            return sprintf(
            '<div class="oauth-connect">
                <h3>Connect to your %s account</h3>
                <a href="%s" class="btn jvb-oauth-connect" data-service="%s">%s Authorize Connection</a>',
                $this->title,
                $this->getOAuthURL($returnURL),
                esc_attr($this->service_name),
                $this->outputIcon($this->icon)
            );
        }
        $form = sprintf(
            '<details open>
            <summary>Connected Account
                <div class="connection-status connected">
                    <span class="status-indicator">●</span>
                    <span>Connected</span>
                </div>
            </summary>
            <span class="label">OAuth Redirect URL:</span>
            <code>%s</code>',
            $this->getRedirectUri()
        );
        if (!empty($credentials['updated_at'])) {
            $form .= sprintf(
                '<div class="oauth-meta"><small>Token expires: %s</small></div>',
                array_key_exists('expires_at', $credentials) ?
                        human_time_diff($credentials['expires_at']) :
                        'Never'
            );
        }
        //
        if (method_exists($this, 'renderOAuthConnectedOptions')) {
            $form .= $this->renderOAuthConnectedOptions();
        }
        $form .= '</details';
        return $form;
    }
    /**
     * @return string Outputs additional information after the basic OAuth connection
     */
    protected function renderOAuthConnectedOptions():string
    {
        return '';
    }
    public function getOAuthURL(?string $return_url = null):string
    {
        if (!$this->hasOAuth) {
            return '';
        }
        $credentials = $this->loadCredentials();
        if (empty($credentials['authorize'])) {
            $this->logError('getOAuthUrl', 'OAuth authorize URL not configured');
            return '';
        }
        $params = [
            'client_id'     => $credentials['client_id']??$credentials['application_id']??$credentials['app_id']??'',
            'redirect_url'  => $this->getRedirectUri(),
            'response_type' => 'code',
            'scope'         => implode(' ', $credentials['scopes']??[])
        ];
        $state_key = wp_generate_password(32, false);
        $user_id = $this->userID??0;
        //Store state data in transient, expiring in 10 minutes
        set_transient(
            BASE.'oauth_state_'.$state_key,
            [
                'service'   => $this->service_name,
                'user_id'   => $user_id,
                'created'   => time()
            ],
            600
        );
        $state_parts = [
            $state_key,
            $user_id
        ];
        $state_parts[] = base64_encode(is_null($return_url) ? $this->getReturnURL() : $return_url);
        $params['state'] = implode('|', $state_parts);
        //Allow extensions to modify params
        if (method_exists($this, 'addOAuthParams')) {
            $params = $this->addOAuthParams($params);
        }
        return $credentials['authorize'] . '?' . http_build_query($params);
    }
    protected function getRedirectUri():string
    {
        if ($this->hasOAuth) {
            return admin_url('admin-ajax.php?action='.BASE . $this->service_name . '_oauth_callback');
        }
        return rest_url('jvb/v1/oauth/callback?service=' . $this->service_name);
    }
}
inc/integrations/OAuthCredentials.php
New file
@@ -0,0 +1,47 @@
<?php
namespace JVBase\integrations;
if (!defined('ABSPATH')) {
    exit;
}
class OAuthCredentials
{
    public string $access_token;
    public int $expires_at;
    public ?string $refresh_token;
    public array $scopes;
    public function __construct(array $data)
    {
        $this->access_token = $data['access_token'] ?? '';
        $this->expires_at = (int)($data['expires_at'] ?? 0);
        $this->refresh_token = $data['refresh_token'] ?? null;
        $this->scopes = $data['scopes'] ?? [];
    }
    public function isValid(): bool
    {
        return !empty($this->access_token) && $this->expires_at > 0;
    }
    public function isExpired(int $buffer = 60): bool
    {
        return $this->expires_at <= (time() + $buffer);
    }
    public function hasScopes(array $required): bool
    {
        return empty(array_diff($required, $this->scopes));
    }
    public function toArray(): array
    {
        return [
            'access_token'  => $this->access_token,
            'expires_at'    => $this->expires_at,
            'refresh_token' => $this->refresh_token,
            'scopes'        => $this->scopes,
        ];
    }
}
inc/integrations/RateLimits.php
New file
@@ -0,0 +1,103 @@
<?php
namespace JVBase\integrations;
if (!defined('ABSPATH')) {
    exit;
}
class RateLimits {
    protected array $history = [];
    protected int $maxHistory = 100;
    protected array $limits = [
        'per_second'    => 2,
        'per_minute'    => 30,
        'per_hour'      => 1000
    ];
    protected string $key;
    public function __construct(string $key, array $limits = []) {
        $this->key = sanitize_title($key);
        //Allow custom limits
        foreach ($limits as $interval => $limit) {
            $key = match($interval) {
                'second', 'per_second','s'  => 'per_second',
                'minute', 'per_minute', 'm' => 'per_minute',
                'hour', 'per_hour', 'h'     => 'per_hour',
                default => false
            };
            if (!$key) {
                error_log('Attempted to add custom limit: '.$interval.' for key '.$key);
                continue;
            }
            $this->limits[$key] = (int)$limit;
        }
        $this->getHistory();
        add_action('shutdown', [$this, 'saveHistory']);
    }
        protected function getHistory():void
        {
            $stored = get_transient(BASE.$this->key.'_limits');
            if (!$stored) {
                $stored = [];
            }
            $this->history = $stored;
        }
        public function recordRequest(string $endpoint = 'base'):void
        {
            if (!array_key_exists($endpoint, $this->history)) {
                $this->history[$endpoint] = [];
            }
            $this->history[$endpoint][] = time();
            $this->checkHistorySize();
        }
        public function saveHistory():void
        {
            set_transient(BASE.$this->key.'_limits', $this->history);
        }
        protected function checkHistorySize():void
        {
            foreach ($this->history as $endpoint => $history) {
                if (count($history) > $this->maxHistory) {
                    $this->history[$endpoint] = array_slice($this->history[$endpoint], -$this->maxHistory);
                }
            }
        }
    public function checkRateLimit(string $endpoint = 'base'):bool
    {
        if (!array_key_exists($endpoint, $this->history)) {
            return true;
        }
        $now = time();
        $this->cleanHistory($now);
        $counts = [
            'per_second'    => 0,
            'per_minute'    => 0,
            'per_hour'      => count($this->history)
        ];
        foreach ($this->history[$endpoint]??[] as $timestamp) {
            if ($now - $timestamp <=1) $counts['per_second']++;
            if ($now - $timestamp <=60) $counts['per_minute']++;
        }
        foreach ($this->limits as $interval => $limit) {
            if ($counts[$interval] >= $limit) return false;
        }
        return true;
    }
    protected function cleanHistory($now):void
    {
        $this->history = array_map(function ($endpointHistory) use ($now) {
            return array_filter($endpointHistory, function($timestamp) use ($now) {
                return ($now - $timestamp) < 3600; //Keep last hour
            });
        }, $this->history);
    }
}
inc/integrations/Requests.php
New file
@@ -0,0 +1,114 @@
<?php
namespace JVBase\integrations;
if (!defined('ABSPATH')) {
    exit;
}
trait Requests
{
    protected bool $hasRequests = true;
    /**
     * Child classes must set this (e.g. 'https://api.example.com/v1/')
     */
    protected ?string $baseUrl = null;
    protected int $ttl = 300; //5 minutes default
    /**
     * Child classes define their auth headers (Bearer token, API key, etc.)
     */
    abstract protected function getRequestHeaders(): array;
    /**
     * Child classes inspect the wp_remote_request response,
     * decode it, and handle/log errors as needed
     */
    abstract protected function handleResponse(array|\WP_Error $response, string $method, string $endpoint): array;
    public function getRequest(string $endpoint, array $data = [], bool $force = false, ?int $ttl = null): array
    {
        $key = [$this->service_name, $this->userID, $endpoint, $data];
        if (!$force) {
            $cached = $this->cache->get($key);
            if ($cached) {
                return $cached;
            }
        }
        $response = $this->makeRequest('GET', $endpoint, $data);
        if ($response && !is_wp_error($response)) {
            $ttl = is_int($ttl) ? $ttl : $this->ttl;
            $this->cache->set($key, $response, $ttl);
        }
        return $response;
    }
    public function postRequest(string $endpoint, array $data = []): array
    {
        return $this->makeRequest('POST', $endpoint, $data);
    }
    public function putRequest(string $endpoint, array $data = []): array
    {
        return $this->makeRequest('PUT', $endpoint, $data);
    }
    public function deleteRequest(string $endpoint, array $data = []): array
    {
        return $this->makeRequest('DELETE', $endpoint, $data);
    }
    protected function makeRequest(string $method, string $endpoint, array $data = []): array
    {
        if (is_null($this->baseUrl)) {
            error_log('Can not make request. No $baseUrl set for '.$this->service_name);
            return [];
        }
        if ($this->requestLimiter && !$this->awaitRateLimit($endpoint)) {
            $error = new \WP_Error('rate_limit_exceeded', "Rate limit exceeded for {$endpoint}");
            return $this->handleResponse($error, $method, $endpoint);
        }
        $url = rtrim($this->baseUrl, '/') . '/' . ltrim($endpoint, '/');
        $args = [
            'method'    => $method,
            'headers'   => $this->getRequestHeaders(),
            'timeout'   => 15,
        ];
        if (!empty($data)) {
            if (in_array($method, ['GET', 'DELETE'], true)) {
                $url = add_query_arg($data, $url);
            } else {
                $args['body'] = wp_json_encode($data);
            }
        }
        $response = wp_remote_request($url, $args);
        $this->requestLimiter?->recordRequest($endpoint);
        return $this->handleResponse($response, $method, $endpoint);
    }
    /**
     * Waits (with jitter) for the rate limit to clear, up to a few attempts.
     * Returns false if still limited after max attempts.
     */
    protected function awaitRateLimit(string $endpoint, int $maxAttempts = 3): bool
    {
        $attempts = 0;
        while (!$this->requestLimiter->checkRateLimit($endpoint)) {
            $attempts++;
            if ($attempts >= $maxAttempts) {
                return false;
            }
            usleep(random_int(250_000, 750_000)); // 250-750ms jitter
        }
        return true;
    }
}
inc/integrations/SyncDefaults.php
New file
@@ -0,0 +1,13 @@
<?php
namespace JVBase\integrations;
if (!defined('ABSPATH')) {
    exit;
}
/**
 * Handles the getting and setting of default values for service fields.
 */
trait SyncDefaults {
}
inc/integrations/SyncFrom.php
New file
@@ -0,0 +1,41 @@
<?php
namespace JVBase\integrations;
use Exception;
if (!defined('ABSPATH')) {
    exit;
}
trait SyncFrom {
    use SyncHelpers;
    /**
     * @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);
    }
}
inc/integrations/SyncHelpers.php
New file
@@ -0,0 +1,144 @@
<?php
namespace JVBase\integrations;
use JVBase\meta\Meta;
use WP_Query;
if (!defined('ABSPATH')) {
    exit;
}
trait SyncHelpers {
    use _Base;
    /**
     * 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;
    protected function setQueueTypes():void
    {
        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';
    }
    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 => ''
        };
    }
    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 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'];
    }
}
inc/integrations/SyncTo.php
New file
@@ -0,0 +1,310 @@
<?php
namespace JVBase\integrations;
use Exception;
use WP_Error;
if (!defined('ABSPATH')) {
    exit;
}
trait SyncTo {
    use SyncHelpers;
    /**
     * 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');
    }
    /**
     * 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;
    }
    /***************************************************************
     * Item creation
    ***************************************************************/
    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');
    }
    /**
     * 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 processBatchCreateResponse(array $data, array $response):array
    {
        return [
            'outcome'   => 'failed',
            'result'    => [
                'message'   => $this->service_name.' should implement processBatchCreateResponse.'
            ]
        ];
    }
    /*****************************************************************
     * ITEM UPDATING
    *****************************************************************/
    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');
    }
    /**
     * 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.'
            ]
        ];
    }
    /*****************************************************************
     * UTILITY
    *****************************************************************/
    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;
    }
}
inc/integrations/UserConnection.php
New file
@@ -0,0 +1,62 @@
<?php
namespace JVBase\integrations;
use JVBase\meta\Meta;
use JVBase\registrar\Registrar;
if (!defined('ABSPATH')) {
    exit;
}
trait UserConnection {
    protected function determineUser():void
    {
        if (current_user_can('manage_options')) {
            $this->switchUser();
        }
        $this->switchUser(get_current_user_id());
    }
    public function switchUser(?int $userID = null):bool
    {
        if (!$this->userCanConnect($userID)) {
            return false;
        }
        $this->clearUser();
        $this->userID = $userID;
        $this->initializeRateLimiters();
        return true;
    }
    protected function clearUser():void
    {
        $this->userID = false;
        $this->credentials = [];
        if (property_exists($this, 'rateLimiter')) {
            $this->requestLimiter?->saveHistory();
            $this->requestLimiter = null;
        }
        if (property_exists($this, 'oathLimiter')) {
            $this->oauthLimiter?->saveHistory();
            $this->oauthLimiter = null;
        }
    }
    public function userCanConnect(?int $userID):bool
    {
        if (current_user_can('manage_options')) {
            return true;
        }
        $user = get_userdata($userID);
        if (!$user || is_wp_error($user)) {
            return false;
        }
        $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');
    }
}
inc/integrations/Webhooks.php
New file
@@ -0,0 +1,125 @@
<?php
namespace JVBase\integrations;
use Exception;
use WP_REST_Request;
use WP_REST_Response;
if (!defined('ABSPATH')) {
    exit;
}
trait Webhooks {
    use _Base;
    protected bool $hasWebhooks = true;
    abstract protected function validateWebhook(array $payload):bool;
    abstract protected function processWebhook(array $payload):bool;
    abstract protected function extractWebhookId(array $payload):string;
    /**
     * Register webhook endpoint
     */
    protected function registerWebhookEndpoint(): void
    {
        // Register REST API endpoint for webhooks
        add_action('rest_api_init', function() {
            register_rest_route('jvb/v1', '/webhooks/' . $this->service_name, [
                'methods' => 'POST',
                'callback' => [$this, 'handleWebhookRequest'],
                'permission_callback' => '__return_true', // Webhooks come from external services
            ]);
        });
    }
    /**
     * Handle webhook REST API request
     */
    public function handleWebhookRequest(WP_REST_Request $request): WP_REST_Response
    {
        $payload = $request->get_params();
        $headers = $request->get_headers();
        // Include headers in payload for signature validation
        $payload['_headers'] = $headers;
        $success = $this->handleWebhook($payload);
        return new WP_REST_Response([
            'success' => $success
        ], $success ? 200 : 400);
    }
    public function handleWebhook(array $payload):bool
    {
        error_log('[Integrations]::handleWebhook for '.$this->service_name.': '.print_r($payload, true));
        if (!$this->validateWebhook($payload)) {
            $this->logError('handleWebhook', 'Webhook validation failed.');
            return false;
        }
        //Check for duplicate processing
        if ($this->isWebhookProcessed($payload)) {
            return true;
        }
        try {
            $result = $this->processWebhook($payload);
            if ($result) {
                $this->markWebhookProcessed($payload);
            } else {
                $this->logError('handleWebhook', 'Webhook processing returned false.');
            }
            return $result;
        } catch (Exception $e) {
            $this->logError('handleWebhook', 'Webhook processing failed', [
                'error' => $e->getMessage(),
                'webhook_id'    => $this->extractWebhookId($payload),
                'trace' => $e->getTraceAsString()
            ]);
            Auth::getInstance()->markUnhealthy($this->service_name, 'oauth', $this->userID);
            return false;
        }
    }
    protected function renderWebhookUrl(): string
    {
        if (!$this->hasWebhooks || !$this->isSetUp()) {
            return '';
        }
        $webhook_url = rest_url('jvb/v1/webhooks/' . $this->service_name);
        return sprintf(
            '<div class="webhook-info">
            <h4>Webhook URL</h4>
            <code id="webhook-url-%s">%s</code>
            <p class="hint">Add this URL to your %s webhook settings</p>
        </div>',
            esc_attr($this->service_name),
            esc_html($webhook_url),
            esc_html($this->title)
        );
    }
    protected function isWebhookProcessed(array $payload):bool
    {
        $id = $this->extractWebhookId($payload);
        $key = BASE."webhook_processed_{$this->service_name}_{$id}";
        return get_transient($key)!==false;
    }
    protected function markWebhookProcessed(array $payload):void
    {
        $id = $this->extractWebhookId($payload);
        if (!empty($id)) {
            $key = BASE."webhook_processed_{$this->service_name}_{$id}";
            set_transient($key, true, DAY_IN_SECONDS);
        }
    }
}
inc/integrations/_Base.php
New file
@@ -0,0 +1,110 @@
<?php
namespace JVBase\integrations;
use JVBase\managers\Cache;
use JVBase\managers\IconsManager;
if (!defined('ABSPATH')) {
    exit;
}
trait _Base {
    /**
     * @var string Unique identifier for this service (ex: square, facebook)
     */
    protected string $service_name;
    /**
     * @var int|null The user context. If it is an OAuth integration, users may have their own connections, accessible via their user id
     */
    protected ?int $userID = null;
    /**
     * @var string Human-readable service name (ex: 'Google My Business', 'Square', etc)
     */
    public string $title;
    /**
     * @var string Phosphoricons icon slug
     */
    public string $icon = '';
    /**
     * @var string API version string (ex: 'v2', '2024-01-01')
     */
    protected string $apiVersion;
    protected Cache $cache;
    //Use Trait "Requests" to set this
    protected bool $hasRequests = false;
    protected ?RateLimits $requestLimiter = null;
    //Use Trait "OAuth" to set this
    protected ?RateLimits $oauthLimiter = null;
    protected bool $hasOAuth = false;
    //Use trait "Webhooks" to set this true
    protected bool $hasWebhooks = false;
    /**
     * @var array Each item in the array becomes a li element in the ordered list of instructions
     */
    protected array $instructions = [];
    /**
     * @var array The required fields for the credentials. Typically a site_key/secret_key or equivalent.
     */
    protected array $credentialFields = [];
    /**
     * @var array The required fields for OAuth connection
     */
    protected array $oauthFields = [];
    /**
     * @var array Optional fields that just tweak functionality
     */
    protected array $optionalFields = [];
    protected bool $supportsWebp = true;
    /**
     * @var bool Flag to indicate there are extra options that warrant a dedicated options page for this service
     * Usually used for default field values for syncable services
     */
    public bool $hasExtraOptions = false;
    /**
     * Check if integration is properly configured
     */
    public function isSetUp(): bool
    {
        return !empty($this->loadCredentials());
    }
    public function loadCredentials():array
    {
        return Auth::getInstance()->getCredentials($this->service_name, $this->userID);
    }
    protected function logError(string $method, string $message, array $context = []):void
    {
        $context['service'] = $this->service_name;
        JVB()->error()->log('['.self::class.']'.$method, $message, $context);
    }
    protected function getReturnURL():string
    {
        //If we are on the main admin page, we want to go back to our Integrations page on admin
        //If we are on the custom dashboard, we want to go back to that dashboard page
        return is_admin() ?
            admin_url('admin.php?page=jvb-integrations') :
            get_home_url(null, '/dash/integrations/');
    }
    protected function determineOptionPage():string
    {
        return is_admin() ?
            admin_url('admin.php?page=jvb-integrations-'.$this->service_name) :
            get_home_url(null, '/dash/integrations/'.$this->service_name.'/');
    }
    protected function outputIcon(string $icon):string
    {
        if (is_admin()) {
            $icons = IconsManager::for('admin');
            return $icons->getRawSvg($icon);
        }
        return jvbDashIcon($icon);
    }
}
inc/integrations/_setup.php
@@ -1,47 +1,52 @@
<?php
use JVBase\base\Site;
require(JVB_DIR . '/inc/integrations/Admin.php');
require(JVB_DIR . '/inc/integrations/Ajax.php');
require(JVB_DIR . '/inc/integrations/Auth.php');
require(JVB_DIR . '/inc/integrations/OAuth.php');
require(JVB_DIR . '/inc/integrations/RateLimits.php');
require(JVB_DIR . '/inc/integrations/Requests.php');
require(JVB_DIR . '/inc/integrations/Integrations.php');
require(JVB_DIR . '/inc/integrations/CredentialsManager.php');
if (Site::hasIntegration('maps')) {
    require(JVB_DIR . '/inc/integrations/GoogleMaps.php');
    require(JVB_DIR . '/inc/integrations/services/GoogleMaps.php');
}
if (Site::hasIntegration('umami')) {
    require(JVB_DIR . '/inc/integrations/Umami.php');
    require(JVB_DIR . '/inc/integrations/services/Umami.php');
}
if (Site::hasIntegration('cloudflare')) {
    require(JVB_DIR . '/inc/integrations/Cloudflare.php');
    require(JVB_DIR . '/inc/integrations/services/Cloudflare.php');
}
if (Site::hasIntegration('square')) {
    require(JVB_DIR . '/inc/integrations/Square.php');
    require(JVB_DIR . '/inc/integrations/services/Square.php');
}
if (Site::hasIntegration('facebook')) {
    require(JVB_DIR . '/inc/integrations/Facebook.php');
    require(JVB_DIR . '/inc/integrations/services/Facebook.php');
}
if (Site::hasIntegration('instagram')) {
    if (!Site::hasIntegration('facebook')) {
        require(JVB_DIR . '/inc/integrations/Facebook.php');
        require(JVB_DIR . '/inc/integrations/services/Facebook.php');
    }
    require(JVB_DIR . '/inc/integrations/Instagram.php');
    require(JVB_DIR . '/inc/integrations/services/Instagram.php');
}
if (Site::hasIntegration('bluesky')) {
    require(JVB_DIR . '/inc/integrations/BlueSky.php');
    require(JVB_DIR . '/inc/integrations/services/BlueSky.php');
}
if (Site::hasIntegration('gmb')) {
    require(JVB_DIR . '/inc/integrations/GoogleMyBusiness.php');
    require(JVB_DIR . '/inc/integrations/services/GoogleMyBusiness.php');
}
if (Site::hasIntegration('postmark')) {
    require(JVB_DIR . '/inc/integrations/PostMark.php');
    require(JVB_DIR . '/inc/integrations/services/PostMark.php');
}
if (Site::hasIntegration('helcim')) {
    require(JVB_DIR . '/inc/integrations/Helcim.php');
    require(JVB_DIR . '/inc/integrations/services/Helcim.php');
}
inc/integrations/services/BlueSky.php
File was renamed from inc/integrations/BlueSky.php
@@ -1,9 +1,7 @@
<?php
namespace JVBase\integrations;
namespace JVBase\integrations\services;
use Exception;
use WP_Error;
use JVBase\integrations\Integrations;
if (!defined('ABSPATH')) {
    exit;
}
@@ -361,7 +359,7 @@
    public function disconnectUser(int $user_id): bool
    {
        $key = "user_{$user_id}_{$this->service_name}";
        $credentials_manager = CredentialsManager::getInstance();
        $credentials_manager = Auth::getInstance();
        return $credentials_manager->deleteCredentials($key);
    }
inc/integrations/services/Cloudflare.php
File was renamed from inc/integrations/Cloudflare.php
@@ -1,7 +1,7 @@
<?php
namespace JVBase\integrations;
use WP_Error;
namespace JVBase\integrations\services;
use JVBase\integrations\Integrations;
if (!defined('ABSPATH')) {
    exit;
}
inc/integrations/services/Facebook.php
File was renamed from inc/integrations/Facebook.php
@@ -3,12 +3,12 @@
 * Facebook Integration
 * Handles Facebook posts, page information, events, and OAuth authentication
 */
namespace JVBase\integrations;
use Exception;
namespace JVBase\integrations\services;
use JVBase\integrations\Integrations;
use JVBase\meta\Meta;
use WP_Error;
use WP_Post;
use WP_Error;
if (!defined('ABSPATH')) {
    exit;
@@ -127,8 +127,9 @@
        $this->defaults = [
        ];
        $this->handleWebhooks = true;
        parent::__construct($userID);
        $this->hasWebhooks = true;
        parent::__construct();
        $this->actions = array_merge($this->actions, [
            'sync_pages'    => 'getUserPages'
inc/integrations/services/GoogleMaps.php
File was renamed from inc/integrations/GoogleMaps.php
@@ -3,10 +3,10 @@
 * Google Maps Integration
 * File: /inc/integrations/GoogleMapsIntegration.php
 */
namespace JVBase\integrations;
namespace JVBase\integrations\services;
use JVBase\integrations\Integrations;
use JVBase\registrar\Registrar;
use WP_Error;
if (!defined('ABSPATH')) {
    exit;
inc/integrations/services/GoogleMyBusiness.php
File was renamed from inc/integrations/GoogleMyBusiness.php
@@ -1,8 +1,8 @@
<?php
namespace JVBase\integrations;
namespace JVBase\integrations\services;
use JVBase\integrations\Integrations;
use JVBase\meta\Meta;
use WP_Error;
if (!defined('ABSPATH')) {
    exit;
}
@@ -125,7 +125,7 @@
            'Use the "Authorize" button below to complete OAuth flow'
        ];
        parent::__construct($userID);
        parent::__construct();
        $this->actions = array_merge(
            $this->actions,
inc/integrations/services/Helcim.php
File was renamed from inc/integrations/Helcim.php
@@ -1,13 +1,11 @@
<?php
namespace JVBase\integrations;
use Exception;
use JVBase\meta\Meta;
use WP_Error;
use WP_Post;
use JVBase\ui\Checkout;
use JVBase\managers\queue\TypeConfig;
namespace JVBase\integrations\services;
use JVBase\integrations\Integrations;
use JVBase\managers\queue\executors\IntegrationExecutor;
use JVBase\managers\queue\TypeConfig;
use JVBase\meta\Meta;
use JVBase\ui\Checkout;
if (!defined('ABSPATH')) {
    exit;
@@ -103,9 +101,9 @@
            'delete' => false,
        ];
        $this->handleWebhooks = false;
        $this->hasWebhooks = false;
        parent::__construct($userID);
        parent::__construct();
        // Helcim-specific actions (processAction dispatches these)
        $this->actions = array_merge($this->actions, [
inc/integrations/services/Instagram.php
File was renamed from inc/integrations/Instagram.php
@@ -3,14 +3,10 @@
 * Instagram Integration
 * File: /inc/integrations/Instagram.php
 */
namespace JVBase\integrations;
namespace JVBase\integrations\services;
use JVBase\integrations\Integrations;
use JVBase\meta\Meta;
use WP_Error;
use WP_REST_Request;
use WP_REST_Response;
use Exception;
if (!defined('ABSPATH')) {
    exit;
@@ -99,7 +95,7 @@
        $this->defaults = [
        ];
        $this->handleWebhooks = true;
        $this->hasWebhooks = true;
        parent::__construct($userID);
        $this->actions = array_merge(
@@ -192,8 +188,8 @@
    }
    public function getCredentials():array {
        parent::getCredentials();
        if (empty($this->credentials)) {
        $credentials = parent::getCredentials();
        if (empty($credentials)) {
            $facebook = JVB()->connect('facebook');
            if (!$facebook || !$facebook->isSetUp()) {
@@ -202,12 +198,12 @@
            $fbCredentials = $facebook->getCredentials();
            $this->credentials = [
            return [
                'client_id' => $fbCredentials['client_id'],
                'client_secret' => $fbCredentials['client_secret'],
            ];
        }
        return $this->credentials;
        return $credentials;
    }
    private function switchInstagramAccount(array $data): WP_Error|array
inc/integrations/services/PostMark.php
File was renamed from inc/integrations/PostMark.php
@@ -1,8 +1,7 @@
<?php
namespace JVBase\integrations;
use WP_Error;
namespace JVBase\integrations\services;
use JVBase\integrations\Integrations;
if (!defined('ABSPATH')) {
    exit;
}
@@ -117,7 +116,7 @@
            ]
        ];
        parent::__construct($userID);
        parent::__construct();
        // Hook into WordPress mail system if integration is healthy
        if ($this->is_healthy && $this->isSetUp()) {
inc/integrations/services/Square.php
File was renamed from inc/integrations/Square.php
@@ -1,18 +1,19 @@
<?php
namespace JVBase\integrations;
namespace JVBase\integrations\services;
use Exception;
use JVBase\integrations\Integrations;
use JVBase\managers\queue\executors\IntegrationExecutor;
use JVBase\managers\queue\TypeConfig;
use JVBase\meta\Form;
use JVBase\meta\Meta;
use Exception;
use JVBase\registrar\Fields;
use JVBase\registrar\Posts;
use JVBase\registrar\Registrar;
use JVBase\ui\Checkout;
use JVBase\ui\CRUDSkeleton;
use WP_Error;
use JVBase\ui\Checkout;
use JVBase\managers\queue\TypeConfig;
use JVBase\managers\queue\executors\IntegrationExecutor;
use WP_Post;
use WP_Query;
use WP_User;
if (!defined('ABSPATH')) {
    exit;
@@ -86,16 +87,14 @@
    protected string $locationId = '';
    protected array $locations = [];
    protected static array $instances = [];
    protected static Square $instance;
    public static function getInstance(?int $userID = null):self
    public static function getInstance():self
    {
        $key = is_null($userID) ? 'base' : $userID;
        if (!array_key_exists($key, self::$instances)) {
            self::$instances[$key] = new self($userID);
        if (!isset(self::$instance)) {
            self::$instance = new self();
        }
        return self::$instances[$key];
        return self::$instance;
    }
    protected function __construct(?int $userID = null)
@@ -190,9 +189,9 @@
            'delete' => true
        ];
        $this->supportsWebp = false;
        $this->handleWebhooks = true;
        $this->hasWebhooks = true;
        parent::__construct($userID);
        parent::__construct();
        // Add Square-specific actions
        $this->actions = array_merge(
@@ -858,6 +857,7 @@
    protected function registerAdditionalHooks(): void
    {
        if (!$this->isSetUp()) {
            error_log('Square is not setup');
            return;
        }
@@ -932,7 +932,7 @@
    /**
     * Handle post save for Square sync
     */
    protected function handleTheSavePost(int $postID, \WP_Post $post, bool $update, array $settings): void
    protected function handleTheSavePost(int $postID, WP_Post $post, bool $update, array $settings): void
    {
        error_log('==== [Square]::handleTheSavePost ====');
        $this->queueOperation(self::$syncTo, [
@@ -1113,7 +1113,7 @@
            if (!in_array($post_id, $success)) {
                $found_in_errors = false;
                foreach ($errors as $error) {
                    if (strpos($error, "Post $post_id") !== false) {
                    if (str_contains($error, "Post $post_id")) {
                        $found_in_errors = true;
                        break;
                    }
@@ -1542,7 +1542,7 @@
        }
        // Set user role (assuming you have a customer role defined)
        $user = new \WP_User($user_id);
        $user = new WP_User($user_id);
        $user->set_role(BASE.'foodie');
        // Generate password reset key
@@ -1576,7 +1576,7 @@
    /**
     * Send welcome email with password setup
     */
    private function sendWelcomeEmail(\WP_User $user, string $reset_key): void
    private function sendWelcomeEmail(WP_User $user, string $reset_key): void
    {
        $site_name = get_bloginfo('name');
        $reset_url = get_home_url(null, "wp-login.php?action=rp&key=$reset_key&login=" . rawurlencode($user->user_login), 'login');
@@ -2450,7 +2450,7 @@
                $sanitized[$key] = sanitize_key($value);
            }
            // Handle boolean fields
            elseif ($key === 'use_sandbox' || strpos($key, 'enable_') === 0) {
            elseif ($key === 'use_sandbox' || str_starts_with($key, 'enable_')) {
                $sanitized[$key] = (bool) $value;
            }
            // Handle numeric fields
@@ -3346,8 +3346,7 @@
        // Fetch from Square
        $response = $this->getRequest('orders/' . $order_id);
        if (!is_wp_error($response)) {
            $state = $response['order']['state'] ?? null;
            return $state;
            return $response['order']['state'] ?? null;
        }
        return null;
@@ -3430,7 +3429,7 @@
    }
    public function renderDashPage():void
    {
        $instance = $this->determineInstance();
        $this->determineUser();
        $crud = new CRUDSkeleton();
        $crud->icon('receipt');
@@ -3452,13 +3451,7 @@
        $crud->render();
    }
    protected function determineInstance():self
    {
        if (current_user_can('manage_options')) {
            return self::$instances['base'];
        }
        return self::getInstance(get_current_user_id());
    }
    public function getOrderPost(string $order_id):int|false
    {
inc/integrations/services/Umami.php
File was renamed from inc/integrations/Umami.php
@@ -1,10 +1,6 @@
<?php
namespace JVBase\integrations;
use JVBase\managers\Cache;
use WP_Error;
use WP_Post;
use Exception;
namespace JVBase\integrations\services;
use JVBase\integrations\Integrations;
if (!defined('ABSPATH')) {
    exit;
@@ -88,7 +84,7 @@
            'Login to your <a href="https://cloud.umami.is/settings/websites" target="_blank">umami.is</a> account, select your website, and copy the website ID'
        ];
        parent::__construct($userID);
        parent::__construct();
        $this->actions = array_merge(
            $this->actions,
@@ -705,7 +701,7 @@
    /**
     * Process queued operations
     */
    public function processOperation(WP_Error|array $result, object $operation, array $data): WP_Error|array
    public function processOperation(array|\WP_Error $result, object $operation, array $data): array|\WP_Error
    {
        $prefix = strtolower($this->service_name) . '_';
inc/managers/Dashboard/DashboardManager.php
@@ -1,19 +1,7 @@
<?php
namespace JVBase\managers\Dashboard;
use JetBrains\PhpStorm\NoReturn;
use JVBase\forms\TaxonomySelector;
use JVBase\base\Site;
use JVBase\managers\Cache;
use JVBase\managers\IconsManager;
use JVBase\managers\LoginManager;
use JVBase\managers\RoleManager;
use JVBase\meta\Form;
use JVBase\meta\Meta;
use JVBase\registrar\Fields;
use JVBase\registrar\Registrar;
use JVBase\ui\Navigation;
use JVBase\ui\Tabs;
use JetBrains\PhpStorm\NoReturn;use JVBase\base\Site;use JVBase\forms\TaxonomySelector;use JVBase\managers\Cache;use JVBase\managers\IconsManager;use JVBase\managers\LoginManager;use JVBase\managers\RoleManager;use JVBase\meta\Form;use JVBase\meta\Meta;use JVBase\registrar\Fields;use JVBase\registrar\Registrar;use JVBase\ui\Navigation;use JVBase\ui\Tabs;
if (!defined('ABSPATH')) {
    exit;
@@ -524,16 +512,16 @@
                foreach (array_keys(Site::getIntegrations()) as $integration) {
                    $integration = match($integration) {
                        'maps' => 'JVBase\integrations\GoogleMaps',
                        'square' => 'JVBase\integrations\Square',
                        'facebook' => 'JVBase\integrations\Facebook',
                        'helcim' => 'JVBase\integrations\Helcim',
                        'instagram' => 'JVBase\integrations\Instagram',
                        'bluesky' => 'JVBase\integrations\BlueSky',
                        'gmb' => 'JVBase\integrations\GoogleMyBusiness',
                        'cloudflare' => 'JVBase\integrations\Cloudflare',
                        'umami' => 'JVBase\integrations\Umami',
                        'postmark' => 'JVBase\integrations\PostMark',
                        'maps' => 'JVBase\integrations\services\GoogleMaps',
                        'square' => 'JVBase\integrations\services\Square',
                        'facebook' => 'JVBase\integrations\services\Facebook',
                        'helcim' => 'JVBase\integrations\services\Helcim',
                        'instagram' => 'JVBase\integrations\services\Instagram',
                        'bluesky' => 'JVBase\integrations\services\BlueSky',
                        'gmb' => 'JVBase\integrations\services\GoogleMyBusiness',
                        'cloudflare' => 'JVBase\integrations\services\Cloudflare',
                        'umami' => 'JVBase\integrations\services\Umami',
                        'postmark' => 'JVBase\integrations\services\PostMark',
                    };
                    $title = $integration::title();
                    $icon = $integration::icon();
inc/rest/routes/FeedRoutes.php
@@ -1,17 +1,17 @@
<?php
namespace JVBase\rest\routes;
use JVBase\base\Site;
use JVBase\meta\Meta;
use JVBase\registrar\Registrar;
use JVBase\rest\Rest;
use JVBase\integrations\Umami;
use JVBase\rest\Route;
use JVBase\base\Site;
use WP_Query;
use Umami;
use WP_Post;
use WP_Term;
use WP_Query;
use WP_REST_Request;
use WP_REST_Response;
use WP_Term;
if (!defined('ABSPATH')) {
    exit; // Exit if accessed directly
inc/rest/routes/IntegrationsSquareRoutes.php
@@ -2,10 +2,10 @@
namespace JVBase\rest\routes;
use JVBase\integrations\Square;
use Exception;
use JVBase\integrations\services\Square;
use JVBase\meta\Meta;
use JVBase\rest\Rest;
use Exception;
use JVBase\rest\Route;
use WP_REST_Request;
use WP_REST_Response;
inc/ui/Checkout.php
@@ -31,11 +31,13 @@
{
    public static function getProvider():Integrations|false
    {
        foreach (Site::getIntegrations() as $integration => $active) {
            if (in_array($integration, ['square', 'helcim']) && $active) {
                return JVB()->connect($integration);
            }
        }
        $int = ['square','helcim'];
        foreach ($int as $i) {
            if (Site::hasIntegration($i)) {
                error_log('Site has '.$i);
                return JVB()->connect($i);
            }
        }
        return false;
    }
    /**
src/menu/style.scss
@@ -4,7 +4,7 @@
    font-size: var(--txt-medium);
}
.field.quantity > label {
.field.quantity.quantity > label {
    position: absolute;
    left: var(--offScreen);
}
@@ -27,7 +27,7 @@
    }
    > section * {
        max-width: var(--wide);
        max-width: var(--wide)!important;
    }
}