From c204185ae86a98994f80010abf35a190c9406739 Mon Sep 17 00:00:00 2001
From: Jake Vanderwerf <get@jakevanderwerf.ca>
Date: Sun, 12 Jul 2026 18:08:19 +0000
Subject: [PATCH] =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.
---
inc/integrations/RateLimits.php | 103 +
inc/integrations/Requests.php | 114 +
build/menu/style-index-rtl.css | 2
inc/integrations/SyncFrom.php | 41
inc/integrations/services/GoogleMyBusiness.php | 8
inc/integrations/services/Cloudflare.php | 4
inc/integrations/services/PostMark.php | 7
inc/integrations/_Base.php | 110 +
inc/managers/Dashboard/DashboardManager.php | 34
src/menu/style.scss | 4
inc/rest/routes/FeedRoutes.php | 8
inc/integrations/SyncTo.php | 310 ++++
inc/ui/Checkout.php | 12
build/menu/style-index.css | 2
inc/integrations/_setup.php | 29
inc/integrations/services/Helcim.php | 16
inc/integrations/Admin.php | 233 +++
inc/integrations/OAuth.php | 351 +++++
inc/integrations/SyncDefaults.php | 13
JVBase.php | 72
inc/rest/routes/IntegrationsSquareRoutes.php | 4
inc/integrations/Field.php | 68
inc/integrations/services/BlueSky.php | 8
inc/integrations/services/Square.php | 55
inc/integrations/Integrations.php | 1902 +--------------------------
inc/integrations/AdminActionResult.php | 11
inc/integrations/services/Facebook.php | 11
inc/integrations/AdminActions.php | 105 +
inc/integrations/Webhooks.php | 125 +
inc/integrations/Ajax.php | 10
inc/integrations/SyncHelpers.php | 144 ++
inc/integrations/services/GoogleMaps.php | 4
inc/integrations/OAuthCredentials.php | 47
inc/integrations/UserConnection.php | 62
inc/integrations/services/Umami.php | 12
inc/integrations/services/Instagram.php | 16
inc/integrations/Auth.php | 60
37 files changed, 2,114 insertions(+), 2,003 deletions(-)
diff --git a/JVBase.php b/JVBase.php
index e771147..36f94ca 100644
--- a/JVBase.php
+++ b/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
diff --git a/build/menu/style-index-rtl.css b/build/menu/style-index-rtl.css
index 899b4f8..8007299 100644
--- a/build/menu/style-index-rtl.css
+++ b/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}
diff --git a/build/menu/style-index.css b/build/menu/style-index.css
index 59fbfe6..531238d 100644
--- a/build/menu/style-index.css
+++ b/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}
diff --git a/inc/integrations/Admin.php b/inc/integrations/Admin.php
new file mode 100644
index 0000000..122a459
--- /dev/null
+++ b/inc/integrations/Admin.php
@@ -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;
+ }
+
+}
diff --git a/inc/integrations/AdminActionResult.php b/inc/integrations/AdminActionResult.php
new file mode 100644
index 0000000..87d2d93
--- /dev/null
+++ b/inc/integrations/AdminActionResult.php
@@ -0,0 +1,11 @@
+<?php
+namespace JVBase\integrations;
+
+if (!defined('ABSPATH')) {
+ exit;
+}
+
+trait AdminActionResult {
+ public bool $success;
+ public array $result;
+}
diff --git a/inc/integrations/AdminActions.php b/inc/integrations/AdminActions.php
new file mode 100644
index 0000000..93e4e7f
--- /dev/null
+++ b/inc/integrations/AdminActions.php
@@ -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;
+ }
+}
diff --git a/inc/integrations/Ajax.php b/inc/integrations/Ajax.php
new file mode 100644
index 0000000..2cd8565
--- /dev/null
+++ b/inc/integrations/Ajax.php
@@ -0,0 +1,10 @@
+<?php
+namespace JVBase\integrations;
+
+if (!defined('ABSPATH')) {
+ exit;
+}
+
+trait Ajax {
+
+}
diff --git a/inc/integrations/CredentialsManager.php b/inc/integrations/Auth.php
similarity index 68%
rename from inc/integrations/CredentialsManager.php
rename to inc/integrations/Auth.php
index a3967a2..85dc232 100644
--- a/inc/integrations/CredentialsManager.php
+++ b/inc/integrations/Auth.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);
+ }
}
diff --git a/inc/integrations/Field.php b/inc/integrations/Field.php
new file mode 100644
index 0000000..9217387
--- /dev/null
+++ b/inc/integrations/Field.php
@@ -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;
+ }
+}
diff --git a/inc/integrations/Integrations.php b/inc/integrations/Integrations.php
index 6f6232c..dd8ede2 100644
--- a/inc/integrations/Integrations.php
+++ b/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();
- }
}
diff --git a/inc/integrations/OAuth.php b/inc/integrations/OAuth.php
new file mode 100644
index 0000000..5f8085f
--- /dev/null
+++ b/inc/integrations/OAuth.php
@@ -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);
+ }
+}
diff --git a/inc/integrations/OAuthCredentials.php b/inc/integrations/OAuthCredentials.php
new file mode 100644
index 0000000..4664ab8
--- /dev/null
+++ b/inc/integrations/OAuthCredentials.php
@@ -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,
+ ];
+ }
+}
diff --git a/inc/integrations/RateLimits.php b/inc/integrations/RateLimits.php
new file mode 100644
index 0000000..9f470e5
--- /dev/null
+++ b/inc/integrations/RateLimits.php
@@ -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);
+ }
+}
diff --git a/inc/integrations/Requests.php b/inc/integrations/Requests.php
new file mode 100644
index 0000000..ad9ebfc
--- /dev/null
+++ b/inc/integrations/Requests.php
@@ -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;
+ }
+}
diff --git a/inc/integrations/SyncDefaults.php b/inc/integrations/SyncDefaults.php
new file mode 100644
index 0000000..383aa7b
--- /dev/null
+++ b/inc/integrations/SyncDefaults.php
@@ -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 {
+
+}
diff --git a/inc/integrations/SyncFrom.php b/inc/integrations/SyncFrom.php
new file mode 100644
index 0000000..a06bf5d
--- /dev/null
+++ b/inc/integrations/SyncFrom.php
@@ -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);
+ }
+}
diff --git a/inc/integrations/SyncHelpers.php b/inc/integrations/SyncHelpers.php
new file mode 100644
index 0000000..f2d0ca0
--- /dev/null
+++ b/inc/integrations/SyncHelpers.php
@@ -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'];
+ }
+}
diff --git a/inc/integrations/SyncTo.php b/inc/integrations/SyncTo.php
new file mode 100644
index 0000000..dd1e9ca
--- /dev/null
+++ b/inc/integrations/SyncTo.php
@@ -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;
+ }
+}
diff --git a/inc/integrations/UserConnection.php b/inc/integrations/UserConnection.php
new file mode 100644
index 0000000..f1f61d1
--- /dev/null
+++ b/inc/integrations/UserConnection.php
@@ -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');
+ }
+
+}
diff --git a/inc/integrations/Webhooks.php b/inc/integrations/Webhooks.php
new file mode 100644
index 0000000..0b8c56a
--- /dev/null
+++ b/inc/integrations/Webhooks.php
@@ -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);
+ }
+
+
+ }
+}
diff --git a/inc/integrations/_Base.php b/inc/integrations/_Base.php
new file mode 100644
index 0000000..2d62970
--- /dev/null
+++ b/inc/integrations/_Base.php
@@ -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);
+ }
+}
diff --git a/inc/integrations/_setup.php b/inc/integrations/_setup.php
index b9ed54e..9739293 100644
--- a/inc/integrations/_setup.php
+++ b/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');
}
diff --git a/inc/integrations/BlueSky.php b/inc/integrations/services/BlueSky.php
similarity index 98%
rename from inc/integrations/BlueSky.php
rename to inc/integrations/services/BlueSky.php
index 2691efe..dadabb8 100644
--- a/inc/integrations/BlueSky.php
+++ b/inc/integrations/services/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);
}
diff --git a/inc/integrations/Cloudflare.php b/inc/integrations/services/Cloudflare.php
similarity index 98%
rename from inc/integrations/Cloudflare.php
rename to inc/integrations/services/Cloudflare.php
index ed37f69..1425725 100644
--- a/inc/integrations/Cloudflare.php
+++ b/inc/integrations/services/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;
}
diff --git a/inc/integrations/Facebook.php b/inc/integrations/services/Facebook.php
similarity index 98%
rename from inc/integrations/Facebook.php
rename to inc/integrations/services/Facebook.php
index 2812607..842cc96 100644
--- a/inc/integrations/Facebook.php
+++ b/inc/integrations/services/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'
diff --git a/inc/integrations/GoogleMaps.php b/inc/integrations/services/GoogleMaps.php
similarity index 99%
rename from inc/integrations/GoogleMaps.php
rename to inc/integrations/services/GoogleMaps.php
index 545e323..6477a0d 100644
--- a/inc/integrations/GoogleMaps.php
+++ b/inc/integrations/services/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;
diff --git a/inc/integrations/GoogleMyBusiness.php b/inc/integrations/services/GoogleMyBusiness.php
similarity index 99%
rename from inc/integrations/GoogleMyBusiness.php
rename to inc/integrations/services/GoogleMyBusiness.php
index a66a81a..cd80f51 100644
--- a/inc/integrations/GoogleMyBusiness.php
+++ b/inc/integrations/services/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,
diff --git a/inc/integrations/Helcim.php b/inc/integrations/services/Helcim.php
similarity index 99%
rename from inc/integrations/Helcim.php
rename to inc/integrations/services/Helcim.php
index f5dc8a5..47d97fd 100644
--- a/inc/integrations/Helcim.php
+++ b/inc/integrations/services/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, [
diff --git a/inc/integrations/Instagram.php b/inc/integrations/services/Instagram.php
similarity index 98%
rename from inc/integrations/Instagram.php
rename to inc/integrations/services/Instagram.php
index 3968e45..838246f 100644
--- a/inc/integrations/Instagram.php
+++ b/inc/integrations/services/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
diff --git a/inc/integrations/PostMark.php b/inc/integrations/services/PostMark.php
similarity index 99%
rename from inc/integrations/PostMark.php
rename to inc/integrations/services/PostMark.php
index a06f65f..4f6f2ee 100644
--- a/inc/integrations/PostMark.php
+++ b/inc/integrations/services/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()) {
diff --git a/inc/integrations/Square.php b/inc/integrations/services/Square.php
similarity index 98%
rename from inc/integrations/Square.php
rename to inc/integrations/services/Square.php
index c0191a3..0d18f51 100644
--- a/inc/integrations/Square.php
+++ b/inc/integrations/services/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
{
diff --git a/inc/integrations/Umami.php b/inc/integrations/services/Umami.php
similarity index 98%
rename from inc/integrations/Umami.php
rename to inc/integrations/services/Umami.php
index 4afb1d5..807ec3b 100644
--- a/inc/integrations/Umami.php
+++ b/inc/integrations/services/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) . '_';
diff --git a/inc/managers/Dashboard/DashboardManager.php b/inc/managers/Dashboard/DashboardManager.php
index 2a09547..2f7c43e 100644
--- a/inc/managers/Dashboard/DashboardManager.php
+++ b/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();
diff --git a/inc/rest/routes/FeedRoutes.php b/inc/rest/routes/FeedRoutes.php
index b0fcbe2..814b821 100644
--- a/inc/rest/routes/FeedRoutes.php
+++ b/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
diff --git a/inc/rest/routes/IntegrationsSquareRoutes.php b/inc/rest/routes/IntegrationsSquareRoutes.php
index f60dc34..79cbd0d 100644
--- a/inc/rest/routes/IntegrationsSquareRoutes.php
+++ b/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;
diff --git a/inc/ui/Checkout.php b/inc/ui/Checkout.php
index ad21146..82c96a6 100644
--- a/inc/ui/Checkout.php
+++ b/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;
}
/**
diff --git a/src/menu/style.scss b/src/menu/style.scss
index cea5a9f..f2858ff 100644
--- a/src/menu/style.scss
+++ b/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;
}
}
--
Gitblit v1.10.0