From a6413da67a4579866f0fd0323ddb23a7b6b9654a Mon Sep 17 00:00:00 2001
From: Jake Vanderwerf <get@jakevanderwerf.ca>
Date: Fri, 18 Sep 2026 17:00:53 +0000
Subject: [PATCH] =Cleaning up the Integrations setup a bit, started the Helcim integration work too
---
inc/managers/CustomTable.php | 31
jvb.php | 9
inc/rest/routes/NotificationsRoutes.php | 6
inc/rest/routes/TermRoutes.php | 4
inc/integrations/_Base.php | 25
inc/managers/_setup.php | 1
inc/rest/routes/ImporterRoutes.php | 4
inc/rest/routes/IntegrationsSquareRoutes.php | 2
inc/integrations/Field.php | 41
inc/managers/queue/executors/ContentExecutor.php | 16
inc/registrar/helpers/AddCustomFields.php | 18
inc/integrations/Integrations.php | 18
inc/integrations/services/Facebook.php | 1
inc/managers/queue/executors/IntegrationExecutor.php | 17
inc/integrations/services/SquareOld.php | 12
inc/integrations/ServiceMap.php | 6
inc/managers/Responses.php | 12
inc/integrations/services/GoogleMaps.php | 1
inc/managers/DirectoryManager.php | 2
inc/integrations/services/Umami.php | 21
inc/rest/routes/IntegrationsHelcimRoutes.php | 2
inc/rest/routes/FormRoutes.php | 2
inc/integrations/Auth.php | 12
inc/managers/UserTermsManager.php | 11
inc/helpers/renderFields.php | 6
inc/meta/Storage.php | 11
inc/blocks/_setup.php | 3
inc/integrations/services/GoogleMyBusiness.php | 35
inc/registrar/Posts.php | 4
inc/integrations/services/Cloudflare.php | 9
inc/rest/routes/ErrorRoutes.php | 4
inc/rest/routes/FavouritesRoutes.php | 18
inc/rest/routes/ContentTermsRoutes.php | 9
inc/integrations/services/PostMark.php | 13
inc/managers/Errors.php | 513 -----------
inc/rest/routes/UploadRoutes.php | 31
inc/managers/queue/Queue.php | 6
inc/managers/ErrorHelper.php | 27
inc/managers/UploadManager.php | 17
inc/integrations/services/Helcim.php | 109 ++
inc/integrations/Admin.php | 119 ++
inc/integrations/OAuth.php | 83 +
inc/rest/routes/IntegrationsRoutes.php | 10
inc/integrations/SyncDefaults.php | 1
inc/managers/queue/executors/UploadExecutor.php | 16
inc/managers/Dashboard/Dashboard.php | 7
inc/managers/MagicLinks.php | 20
inc/admin/Integrations.php | 29
/dev/null | 1058 -------------------------
inc/rest/Rest.php | 16
inc/integrations/SyncHelpers.php | 2
inc/rest/routes/LoginRoutes.php | 12
inc/managers/InvitationsManager.php | 10
inc/managers/Favourites.php | 7
inc/rest/routes/UserRoutes.php | 1
55 files changed, 612 insertions(+), 1,868 deletions(-)
diff --git a/inc/admin/Integrations.php b/inc/admin/Integrations.php
index f61289c..3462c18 100644
--- a/inc/admin/Integrations.php
+++ b/inc/admin/Integrations.php
@@ -39,7 +39,8 @@
// Add submenu pages for configured integrations
foreach ($this->getAvailableServices() as $service) {
$integration = $this->getService($service);
- if ($integration->isSetUp() && method_exists($integration, 'getAllowedContent') && !empty($integration->getAllowedContent())) {
+
+ if (!empty(Registrar::withIntegration($service)) && $integration->isSetUp() && method_exists($integration, 'getAllowedContent') && !empty($integration->getAllowedContent())) {
add_submenu_page(
'jvb-integrations',
$integration->getTitle() . ' Defaults',
@@ -65,6 +66,9 @@
wp_enqueue_style(
'jvb-dash',
);
+ wp_enqueue_style(
+ 'jvb-forms'
+ );
wp_enqueue_script(
'jvb-admin-scripts',
@@ -107,6 +111,22 @@
public function renderPage(): void
{
?>
+ <style>
+ form.integration .actions.x-btw.x-btw {
+ justify-content: flex-start;
+ }
+ form.integration button {
+ display: flex;
+ gap: .5rem;
+ padding: .25rem .5rem;
+ align-items: center;
+ justify-content: space-between;
+ }
+ form.integration button svg {
+ width: 1.5em;
+ height: 1.5em;
+ }
+ </style>
<div class="wrap jvb-admin-wrap">
<h1>Integrations</h1>
@@ -124,7 +144,7 @@
<?php
foreach ($this->getAvailableServices() as $service) {
$integration = $this->getService($service);
- if ($integration->isSetUp() && method_exists($integration, 'renderConnection')) {
+ if (method_exists($integration, 'renderConnection')) {
$integration->renderConnection();
}
}
@@ -527,7 +547,7 @@
.item-grid.integrations {
display: grid;
- grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
+ grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 2rem;
margin-top: 2rem;
}
@@ -1045,6 +1065,9 @@
height: auto;
}
+ #wpfooter#wpfooter {
+ position: relative;
+ }
</style>
<?php
}
diff --git a/inc/blocks/_setup.php b/inc/blocks/_setup.php
index 091429f..761ad88 100644
--- a/inc/blocks/_setup.php
+++ b/inc/blocks/_setup.php
@@ -1,11 +1,12 @@
<?php
+use JVBase\blocks\CustomBlocks;
use JVBase\registrar\Registrar;
use JVBase\base\Site;
//require(JVB_DIR . '/inc/blocks/RegisterBlocks.php');
require(JVB_DIR . '/inc/blocks/CustomBlocks.php');
-
+CustomBlocks::getInstance();
add_action('init', 'jvb_conditionally_load_blocks', 1);
function jvb_conditionally_load_blocks(): void
diff --git a/inc/helpers/renderFields.php b/inc/helpers/renderFields.php
index 44af18d..4a36140 100644
--- a/inc/helpers/renderFields.php
+++ b/inc/helpers/renderFields.php
@@ -352,8 +352,14 @@
return '';
}
}
+ if (is_null($term)) {
+ return '';
+ }
$cache = Cache::for($term->taxonomy.'_link', 'term');
$key = $term->term_id;
+ if (is_null($key)) {
+ return '';
+ }
return $cache->remember(
$key,
function() use ($term) {
diff --git a/inc/integrations/Admin.php b/inc/integrations/Admin.php
index a243bb3..cb9efdf 100644
--- a/inc/integrations/Admin.php
+++ b/inc/integrations/Admin.php
@@ -9,6 +9,7 @@
trait Admin {
use _Base;
+ protected bool $canTest = false;
//An array of AdminAction objects
protected array $actions = [];
protected array $instructions = [];
@@ -22,18 +23,33 @@
'label' => 'Save Changes',
'icon' => 'floppy-disk',
'action' => [$this, 'saveCredentials']
- ],
- [
+ ]
+ ];
+ if ($this->isSetUp()) {
+ $actions[] = [
'name' => 'Clear Credentials',
+ 'label' => 'Clear Credentials',
'icon' => 'broom',
'action' => [$this, 'deleteCredentials']
- ],
- ];
+ ];
+ }
+ if ($this->canTest) {
+ $actions[] = [
+ 'name' => 'Test Connection',
+ 'label' => 'Test Connection',
+ 'icon' => 'plugs-connected',
+ 'action' => [$this, 'testConnectionAction']
+ ];
+ }
foreach ($actions as $a) {
$this->addAction($a['name'], $a['action'], $a['slug']??null, $a['icon']??null, $a['label']??null);
}
+
+ $this->defineActions();
}
+ abstract protected function defineActions():void;
+
public function addAction(string $name, callable $callback, ?string $slug = null, ?string $icon = null, ?string $label = null):void
{
$action = new AdminActions($name, $callback);
@@ -89,13 +105,28 @@
$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'])
- );
+ if ($isConnected) {
+ $updated = Auth::getInstance()->getLastUpdated($this->service_name, $this->userID);
+ $meta = [];
+ if ($updated) {
+ $meta[] = sprintf(
+ '<small>Last updated: %s ago</small>',
+ human_time_diff(strtotime($updated))
+ );
+ }
+ $tested = Auth::getInstance()->getLastTested($this->service_name, $this->userID);
+ if ($tested && $tested !== '0000-00-00 00:00:00') {
+ $meta[] = sprintf(
+ '<small>Last tested: %s ago</small>',
+ human_time_diff(strtotime($tested))
+ );
+ }
+ if (!empty($meta)) {
+ $meta = sprintf(
+ '<div class="meta">%s</div>',
+ implode('<br>', $meta)
+ );
+ }
}
$form = sprintf(
@@ -117,7 +148,7 @@
$meta,
);
- if (!empty($this->instructions)) {
+ if (!$this->isSetUp() && !empty($this->instructions)) {
$form .= sprintf(
'<details%s>
<summary>Instructions</summary>
@@ -125,13 +156,22 @@
</details>',
$isConnected ? '' : ' open',
implode('',array_map(function($el) {
+ if (is_array($el)) {
+ $bits = implode('', array_map(function ($b) {
+ return sprintf('<li>%s</li>', $b);
+ }, $el));
+ return sprintf('<ol>%s</ol>', $bits);
+ }
return sprintf('<li>%s</li>', $el);
}, $this->instructions))
);
}
+ $setupFields = array_filter($this->fields, function($field) {
+ return $field->isSetupField();
+ });
- if (current_user_can('manage_options')) {
+ if ((method_exists($this, 'userCanConnect') && $this->userCanConnect(get_current_user_id())) || current_user_can('manage_options')) {
$form .= sprintf(
'<details class="initial-setup"%s>
<summary>Setup</summary>
@@ -139,7 +179,7 @@
%s
</details>',
$isConnected ? '' : ' open',
- $this->outputFields($this->credentialFields),
+ $this->outputFields($setupFields),
method_exists($this, 'renderWebhookUrl') ? $this->renderWebhookUrl() : ''
);
}
@@ -148,13 +188,32 @@
$form .= $this->renderOAuthConnectionForm();
}
- if (!empty($this->optionalFields) || $this->hasExtraOptions()) {
- $form .= '<div class="integration-content">';
- if (!empty($this->optionalFields)) {
- $form .= sprintf(
+ $advancedFields = array_filter($this->fields, function($field) {
+ return $field->isAdvancedField();
+ });
+
+ if (!empty($advancedFields) && $this->isSetUp()) {
+ $form .= sprintf(
'<details class="advanced">
- <summary>Advanced Settings</summary>%s</details>',
- $this->outputFields($this->optionalFields)
+ <summary>Advanced Options</summary>
+ %s
+ </details>',
+ $this->outputFields($advancedFields),
+ );
+ }
+
+ $optionalFields = array_filter($this->fields, function($field) {
+ return $field->isAdditionalField();
+ });
+
+ if ($this->isSetUp() && (!empty($optionalFields) || $this->hasExtraOptions())) {
+ $form .= '<div class="integration-content">';
+ if (!empty($optionalFields)) {
+ $form .= sprintf(
+ '<details class="advanced"%s>
+ <summary>Optional Settings</summary>%s</details>',
+ $this->isSetUp() ? ' open' : '',
+ $this->outputFields($optionalFields)
);
}
if ($this->hasExtraOptions()) {
@@ -185,7 +244,7 @@
$label .= $action->getLabel();
return sprintf(
'<button type="button" data-action="%s"%s%s>%s</button>',
- $action->getSlug,
+ $action->getSlug(),
$action->getName(),
empty($action->getConfirm()) ? '' : ' data-confirm="'.$action->getConfirm().'"',
$label
@@ -207,6 +266,13 @@
if (!empty($value)) {
$config['readonly'] = true;
}
+ if (!empty($value) && $f->isHideStored()) {
+ return sprintf(
+ '<span class="label">%s</span><code>%s</code>',
+ $config['label'],
+ preg_replace('/.(?=.{4})/', '*', $value)
+ );
+ }
$config['base'] = $this->service_name.'_';
return Form::render($f->name, $value, $f->getConfig());
}, $fields));
@@ -226,7 +292,16 @@
$this->fields[$name]->setRequired();
return true;
}
+ public function updateField(string $name, Field $field):void
+ {
+ $this->fields[$name] = $field;
+ }
-
+ public function testConnectionAction(?array $data = null):array
+ {
+ $success = $this->testConnection();
+ $message = $success ? 'Successfully tested connection' : 'Could not test connection';
+ return $this->response($success, $message);
+ }
}
diff --git a/inc/integrations/Auth.php b/inc/integrations/Auth.php
index 85dc232..231c07b 100644
--- a/inc/integrations/Auth.php
+++ b/inc/integrations/Auth.php
@@ -79,6 +79,12 @@
return (bool)$updated;
}
+ public function getLastUpdated(string $service, ?int $userID = null):?string
+ {
+ $find = $this->findBy($service, $userID);
+ return $this->table->pluck('updated_at', $find)[0]??null;
+ }
+
/**
* Retrieve and decrypt credentials
*/
@@ -164,6 +170,12 @@
], $find);
}
+ public function getLastTested(string $service, ?int $userID = null):?string
+ {
+ $find = $this->findBy($service, $userID);
+ return $this->table->pluck('last_tested', $find)[0]??null;
+ }
+
public function markUnhealthy(string $service, string $type = '', ?int $userID = null):void
{
$update = [];
diff --git a/inc/integrations/Field.php b/inc/integrations/Field.php
index 61cfc55..1687d9f 100644
--- a/inc/integrations/Field.php
+++ b/inc/integrations/Field.php
@@ -21,6 +21,10 @@
protected bool $hidden = false;
protected int $min;
protected int $max;
+ protected bool $isAdditionalField = false;
+ protected bool $isSetupField = false;
+ protected bool $isAdvancedField = false;
+ protected bool $hideStored = false;
public function __construct(string $name, string $label, string $type, callable|string|bool $permission)
{
@@ -52,6 +56,14 @@
{
$this->required = true;
}
+ public function setHideStored():void
+ {
+ $this->hideStored = true;
+ }
+ public function isHideStored():bool
+ {
+ return $this->hideStored;
+ }
public function setOptions(array $options):void
{
$this->options = $options;
@@ -61,6 +73,30 @@
$this->default = $default;
}
+ public function makeSetupField():void
+ {
+ $this->isSetupField = true;
+ }
+ public function isSetupField():bool
+ {
+ return $this->isSetupField;
+ }
+ public function makeAdvancedField():void
+ {
+ $this->isAdvancedField = true;
+ }
+ public function isAdvancedField():bool{
+ return $this->isAdvancedField;
+ }
+ public function makeAdditionalField():void
+ {
+ $this->isAdditionalField = true;
+ }
+ public function isAdditionalField():bool
+ {
+ return $this->isAdditionalField;
+ }
+
public function setHint(string $hint):void
{
$this->hint = $hint;
@@ -108,8 +144,9 @@
if (isset($this->hint)) {
$conf['hint'] = $this->hint;
}
- if (isset($this->subType)) {
- $conf['subtype'] = $this->subType;
+
+ if (isset($this->subtype)) {
+ $conf['subtype'] = $this->subtype;
}
if ($this->hidden) {
$conf['hidden'] = true;
diff --git a/inc/integrations/Integrations.php b/inc/integrations/Integrations.php
index f2e8147..d6ff18f 100644
--- a/inc/integrations/Integrations.php
+++ b/inc/integrations/Integrations.php
@@ -247,7 +247,7 @@
public function testConnection(bool $force = false): bool
{
if (!$this->isSetUp()) {
- return false;
+ return true;
}
$this->ensureInitialized();
@@ -255,13 +255,6 @@
return false;
}
- // Cache test results to avoid excessive API calls
- $cacheKey = "connection_test_$this->service_name" . ($this->userID ? "_$this->userID" : '');
- $cached = $this->cache->get($cacheKey);
-
- if ($cached !== false && !$force) {
- return (bool)$cached;
- }
try {
if (!$this->hasOAuthCredentials()){
@@ -272,11 +265,9 @@
}
$this->updateLastTestedTime();
- $this->cache->set($cacheKey, $result, 300); // Cache for 5 minutes
return $result;
} catch (Exception $e) {
- $this->logError('Connection test failed', ['error' => $e->getMessage()]);
- $this->cache->set($cacheKey, false, 60); // Cache failure for 1 minute
+ $this->logError('testConnection', $e->getMessage());
return false;
}
}
@@ -634,4 +625,9 @@
return $this->icon;
}
+ public function getUserID():?int
+ {
+ return $this->userID;
+ }
+
}
diff --git a/inc/integrations/OAuth.php b/inc/integrations/OAuth.php
index df742fa..5750df4 100644
--- a/inc/integrations/OAuth.php
+++ b/inc/integrations/OAuth.php
@@ -58,26 +58,29 @@
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']
- ],
+ if ($this->isSetUp()) {
+ $actions = [
+ [
+ 'name' => 'Connect to Service',
+ 'label' => 'Connect',
+ 'icon' => 'plugs-connected',
+ 'action' => [$this, 'handleOAuthConnect']
+ ],
+ [
+ 'name' => 'Disconnect from Service',
+ 'label' => 'Disconnect',
+ '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);
+ ];
+ foreach ($actions as $a) {
+ $this->addAction($a['name'], $a['action'], $a['slug']??null, $a['icon']??null, $a['label']??null);
+ }
}
}
public function hasOAuthCredentials(?int $userID = null):bool
@@ -109,7 +112,7 @@
public function revokeOAuth():bool
{
- if (empty($this->oauth['revoke'])) {
+ if (empty($this->oauth->getRevoke())) {
error_log('No revoke url set for '.$this->service_name);
return false;
}
@@ -117,7 +120,7 @@
if (empty($credentials['access_token'])) {
error_log('Could not send revoke request, no access_token for '.$this->service_name);
} else {
- $response = $this->postOAuthRequest($this->oauth['revoke'], [
+ $response = $this->postOAuthRequest($this->oauth->getRevoke(), [
'body' => [
'token' => $credentials['access_token']
]
@@ -328,20 +331,23 @@
$credentials = $this->loadCredentials();
$hasCredentials = $this->hasValidOAuth();
$returnURL = $this->getReturnURL();
-
- if ($hasCredentials) {
+ if (!$this->isSetUp()) {
+ return '';
+ }
+ 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>',
+ <a href="%s" class="btn jvb-oauth-connect" data-service="%s">%s Authorize Connection</a>
+ </div>',
$this->title,
$this->getOAuthURL($returnURL),
esc_attr($this->service_name),
$this->outputIcon($this->icon)
);
- }
- $form = sprintf(
- '<details open>
+ } else {
+ $form = sprintf(
+ '<details open>
<summary>Connected Account
<div class="connection-status connected">
<span class="status-indicator">●</span>
@@ -350,22 +356,23 @@
</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) ?
+ $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;
}
- //
- if (method_exists($this, 'renderOAuthConnectedOptions')) {
- $form .= $this->renderOAuthConnectedOptions();
- }
- $form .= '</details';
- return $form;
}
/**
@@ -380,7 +387,7 @@
{
$credentials = $this->loadCredentials();
- if (empty($this->oauth['authorize'])) {
+ if (empty($this->oauth->getAuthorize())) {
$this->logError('getOAuthUrl', 'OAuth authorize URL not configured');
return '';
}
@@ -418,7 +425,7 @@
$params = $this->addOAuthParams($params);
}
- return $this->oauth['authorize'] . '?' . http_build_query($params);
+ return $this->oauth->getAuthorize() . '?' . http_build_query($params);
}
protected function getRedirectUri():string
diff --git a/inc/integrations/ServiceMap.php b/inc/integrations/ServiceMap.php
index 7cc2069..7fa64c2 100644
--- a/inc/integrations/ServiceMap.php
+++ b/inc/integrations/ServiceMap.php
@@ -29,7 +29,7 @@
public function getServiceUser(string $service, ?int $userID = null):Integrations|false
{
$service = $this->getService($service);
- if ($service->userID !== $userID && method_exists($service, 'switchUser')) {
+ if ($service->getUserID() !== $userID && method_exists($service, 'switchUser')) {
$service->switchUser($userID);
}
return $service;
@@ -37,8 +37,6 @@
public function getAvailableServices():array
{
- return array_filter(array_keys($this->serviceMap), function ($service) {
- return Site::hasIntegration($service);
- }, ARRAY_FILTER_USE_KEY);
+ return array_keys(Site::getIntegrations());
}
}
diff --git a/inc/integrations/SyncDefaults.php b/inc/integrations/SyncDefaults.php
index 4c9ad2b..271d4b0 100644
--- a/inc/integrations/SyncDefaults.php
+++ b/inc/integrations/SyncDefaults.php
@@ -128,6 +128,7 @@
->description($options['description'])
->content($this->getContentTypeContent($allowed));
}
+
$tabs->render(false);
}
public function getContentTypeContent(array $content):string
diff --git a/inc/integrations/SyncHelpers.php b/inc/integrations/SyncHelpers.php
index 84f5303..db97940 100644
--- a/inc/integrations/SyncHelpers.php
+++ b/inc/integrations/SyncHelpers.php
@@ -13,7 +13,7 @@
}
trait SyncHelpers {
- use _Base;
+ use _Base, SyncDefaults;
/**
* Queue types
* These types match with IntegrationExecutor
diff --git a/inc/integrations/_Base.php b/inc/integrations/_Base.php
index 3c4f266..d381877 100644
--- a/inc/integrations/_Base.php
+++ b/inc/integrations/_Base.php
@@ -1,6 +1,7 @@
<?php
namespace JVBase\integrations;
+use JVBase\inc\managers\ErrorHelper;
use JVBase\managers\Cache;
use JVBase\managers\Errors;
use JVBase\managers\IconsManager;
@@ -10,6 +11,7 @@
}
trait _Base {
+ use ErrorHelper;
/**
* @var string Unique identifier for this service (ex: square, facebook)
*/
@@ -95,7 +97,16 @@
*/
public function isSetUp(): bool
{
- return !empty($this->loadCredentials());
+ $tmp = null;
+ if ($this->userID) {
+ $tmp = $this->userID;
+ $this->switchUser();
+ }
+ $isSetUp = !empty($this->loadCredentials()??[]);
+ if ($tmp) {
+ $this->switchUser($tmp);
+ }
+ return $isSetUp;
}
public function loadCredentials():array
@@ -108,8 +119,16 @@
protected function logError(string $method, string $message, array $context = []):void
{
+
$context['service'] = $this->service_name;
- Errors::getInstance()->log('['.self::class.']'.$method, $message, $context);
+ Errors::log($message, array_merge(
+ [
+ 'service' => $this->service_name,
+ 'method' => $method,
+ 'class' => static::class,
+ ],
+ $context
+ ));
}
protected function getReturnURL():string
@@ -131,7 +150,7 @@
{
if (is_admin()) {
$icons = IconsManager::for('admin');
- return $icons->getRawSvg($icon);
+ return $icons->getRawSvg($icon)??'';
}
return jvbDashIcon($icon);
}
diff --git a/inc/integrations/services/Cloudflare.php b/inc/integrations/services/Cloudflare.php
index b8b9278..fb4d405 100644
--- a/inc/integrations/services/Cloudflare.php
+++ b/inc/integrations/services/Cloudflare.php
@@ -31,10 +31,15 @@
$site_key = $this->addField('site_key', 'Site Key', 'text', 'manage_options');
$site_key->setRequired();
$site_key->setHint('Public key for Turnstile Widget');
+ $site_key->makeSetUpField();
+ $this->updateField('site_key', $site_key);
+
$secret_key = $this->addField('secret_key', 'Secret Key', 'text', 'manage_options');
$secret_key->setRequired();
$secret_key->setSubType('password');
$secret_key->setHint('Secret key for server-side verification of Turnstile Widget');
+ $secret_key->makeSetUpField();
+ $this->updateField('secret_key', $secret_key);
}
protected function defineInstructions():void
@@ -210,4 +215,8 @@
return;
}
}
+
+ protected function defineActions(): void
+ {
+ }
}
diff --git a/inc/integrations/services/Facebook.php b/inc/integrations/services/Facebook.php
index 3c54f91..8725303 100644
--- a/inc/integrations/services/Facebook.php
+++ b/inc/integrations/services/Facebook.php
@@ -52,7 +52,6 @@
$this->defineFields();
$this->defineInstructions();
$this->defineOAuth();
- $this->defineActions();
$this->defineContentTypes();
$this->baseUrl = 'https://graph.facebook.com/'.$this->apiVersion;
diff --git a/inc/integrations/services/GoogleMaps.php b/inc/integrations/services/GoogleMaps.php
index adfa358..2910bac 100644
--- a/inc/integrations/services/GoogleMaps.php
+++ b/inc/integrations/services/GoogleMaps.php
@@ -26,7 +26,6 @@
$this->apiBase = 'https://maps.googleapis.com/maps/api/';
$this->defineFields();
$this->defineInstructions();
- $this->defineActions();
parent::__construct();
// Auto-enqueue maps script when needed
diff --git a/inc/integrations/services/GoogleMyBusiness.php b/inc/integrations/services/GoogleMyBusiness.php
index 694285e..44002c7 100644
--- a/inc/integrations/services/GoogleMyBusiness.php
+++ b/inc/integrations/services/GoogleMyBusiness.php
@@ -56,6 +56,8 @@
);
$client_id->setRequired();
$client_id->setHint('From your Google Cloud Console OAuth credentials.');
+ $client_id->makeSetupField();
+ $this->updateField('client_id', $client_id);
$secret = $this->addField(
'client_secret',
@@ -65,6 +67,8 @@
);
$secret->setSubType('password');
$secret->setRequired();
+ $secret->makeSetupField();
+ $this->updateField('client_secret', $client_id);
if ($this->getAccessToken()) {
$accs = $this->addField('accounts', 'Accounts', 'select', [$this, 'testUserCanConnect']);
@@ -101,6 +105,25 @@
'Add redirect URI: <code>'.esc_html($this->getRedirectUri()).'</code>',
'Use the "Authorize" button below to complete OAuth flow'
];
+
+ $this->instructions = [
+ 'Go to <a href="https://console.cloud.google.com/apis/credentials" target="_blank">Google Cloud Console</a>',
+ 'Create OAuth 2.0 Client ID credentials',
+ 'Enable the following APIs in your project:',
+ [
+ '<a href="https://console.cloud.google.com/apis/library/mybusinessaccountmanagement.googleapis.com" target="_blank">My Business Account Management API</a>',
+ '<a href="https://console.cloud.google.com/apis/library/mybusinessbusinessinformation.googleapis.com" target="_blank">My Business Business Information API</a>',
+ '<a href="https://console.cloud.google.com/apis/library/mybusiness.googleapis.com" target="_blank">Google My Business API</a> (legacy v4, required for reviews)',
+ ],
+ 'On the OAuth consent screen, add these scopes:',
+ [
+ '<code>https://www.googleapis.com/auth/business.manage</code>',
+ '<code>https://www.googleapis.com/auth/userinfo.email</code>',
+ '<code>https://www.googleapis.com/auth/userinfo.profile</code>',
+ ],
+ 'Add redirect URI: <code>'.esc_html($this->getRedirectUri()).'</code>',
+ 'Use the "Authorize" button below to complete OAuth flow'
+ ];
}
protected function defineOAuth():void
{
@@ -119,11 +142,13 @@
}
protected function defineActions():void
{
- $this->addAction(
- 'Update Location',
- [$this, 'handleUpdateLocation'],
- 'update_location',
- );
+ if ($this->hasOAuthCredentials($this->userID)) {
+ $this->addAction(
+ 'Update Location',
+ [$this, 'handleUpdateLocation'],
+ 'update_location',
+ );
+ }
}
public function handleUpdateLocation(array $data):array
{
diff --git a/inc/integrations/services/Helcim.php b/inc/integrations/services/Helcim.php
index f388c96..a54f0ef 100644
--- a/inc/integrations/services/Helcim.php
+++ b/inc/integrations/services/Helcim.php
@@ -28,10 +28,11 @@
*/
class Helcim extends Integrations
{
- use Admin, Instance, OAuth, Requests, UserConnection, SyncToPost;
+ use Admin, Instance, Requests, UserConnection, SyncToPost;
protected function __construct()
{
+ $this->canTest = true;
$this->service_name = 'helcim';
$this->title = 'Helcim';
$this->icon = 'currency-dollar';
@@ -42,11 +43,11 @@
'delete' => true,
];
- $this->baseUrl = 'https://api.helcim.com/v2';
+ $this->apiVersion = 'v2';
+ $this->baseUrl = sprintf('https://api.helcim.com/%s/', $this->apiVersion);
$this->defineFields();
$this->defineInstructions();
- $this->defineActions();
parent::__construct();
}
@@ -61,6 +62,9 @@
$api_token->setSubType('password');
$api_token->setRequired();
$api_token->setHint('Found in Helcim Dashboard → Settings → API Access');
+ $api_token->makeSetupField();
+ $api_token->setHideStored();
+ $this->updateField('api_token', $api_token);
$currency = $this->addField(
'currency',
@@ -73,6 +77,7 @@
'USD' => 'USD'
]);
$currency->setDefault('CAD');
+ $currency->makeAdditionalField();
$feeSaver = $this->addField(
'fee_saver',
@@ -81,6 +86,7 @@
[$this, 'testUserSetup']
);
$feeSaver->setHint('Pass processing fees to customers? (Not compatible with Google Pay)');
+ $feeSaver->makeAdditionalField();
$allowACH = $this->addField(
'allow_ach',
@@ -89,6 +95,7 @@
[$this, 'testUserSetup']
);
$allowACH->setHint('Enable bank account payments alongside credit card');
+ $allowACH->makeAdditionalField();
}
public function testUserCanConnect():bool
{
@@ -113,11 +120,21 @@
protected function defineActions():void
{
- $this->addAction(
- 'Initialize Checkout',
- [$this, 'initializeCheckout'],
- 'initialize_checkout'
- );
+ if ($this->isSetUp()) {
+ $this->addAction(
+ 'Sync to Helcim',
+ [$this, 'syncToHelcim'],
+ 'sync_to_helcim',
+ 'upload-simple'
+ );
+ $this->addAction(
+ 'Sync to Site',
+ [$this, 'syncToSite'],
+ 'sync_to_site',
+ 'download-simple'
+ );
+ }
+
}
protected function initialize(): void
@@ -125,30 +142,84 @@
// TODO: Implement initialize() method.
}
- protected function refreshAccessToken(OAuthCredentials $credentials): array
- {
- // TODO: Implement refreshAccessToken() method.
- }
protected function getRequestHeaders(): array
{
- // TODO: Implement getRequestHeaders() method.
- }
-
- protected function handleResponse(WP_Error|array $response, string $method, string $endpoint): array
- {
- // TODO: Implement handleResponse() method.
+ return empty($this->getApiToken()) ? [] : [
+ 'api-token' => $this->getApiToken(),
+ 'Accept' => 'application/json',
+ ];
}
protected function setContentTypes(): void
{
- // TODO: Implement setContentTypes() method.
+ $this->contentTypes = [
+ 'recurring' => $this->buildRecurringPaymentFields(),
+ 'product' => $this->buildPaymentFields(),
+ 'customer' => $this->buildCustomerFields(),
+ 'invoice' => $this->buildInvoiceFields(),
+ ];
}
+ protected function buildRecurringPaymentFields(): array
+ {
+ return [];
+ }
+ protected function buildPaymentFields(): array
+ {
+ return [];
+ }
+
+ protected function buildCustomerFields():array
+ {
+ return [];
+ }
+
+ protected function buildInvoiceFields():array
+ {
+ return [];
+ }
protected function formatForService(int $itemID, string $type = 'post'): array
{
+ return [];
// TODO: Implement formatForService() method.
}
+
+ public function initializeCheckout():void
+ {
+
+ }
+
+ public function syncToHelcim():void
+ {
+
+ }
+ public function syncToSite():void
+ {
+
+ }
+ /*************************************************************************
+ * REQUESTS
+ *************************************************************************/
+
+ protected function handleResponse(WP_Error|array $response, string $method, string $endpoint): array
+ {
+ if (is_wp_error($response) && $response->get_error_message() === 'rate_limit_exceeded') {
+ return $this->response(false, 'Rate limit exceeded');
+ }
+ return match ($endpoint) {
+ 'connection-test' => $this->response(true,'Connection successful', $response),
+ default => $this->response(false, 'Unhandled response for '.$endpoint),
+ };
+ }
+ protected function performConnectionTest(): bool
+ {
+ $response = $this->getRequest(
+ 'connection-test'
+ );
+ error_log('Helcim connection test: '.print_r($response, true));
+ return $response['success']??false;
+ }
/*************************************************************************
* UTILITY
*************************************************************************/
diff --git a/inc/integrations/services/PostMark.php b/inc/integrations/services/PostMark.php
index 5dd9ea8..ac996a1 100644
--- a/inc/integrations/services/PostMark.php
+++ b/inc/integrations/services/PostMark.php
@@ -34,25 +34,32 @@
$this->defineInstructions();
parent::__construct();
- $this->defineActions();
}
protected function defineFields():void
{
$token = $this->addField('service_token', 'Server API Token', 'text', 'manage_options');
$token->setSubType('password');
+ $token->makeSetupField();
+ $this->updateField('service_token', $token);
$stream = $this->addField('message_stream', 'Message Stream ID', 'text', 'manage_options');
$stream->setDefault('outbound');
$stream->setHint('Message stream to use (default: outbound)');
+ $stream->makeAdditionalField();
+ $this->updateField('message_stream', $stream);
$from = $this->addField('from_email', 'From Email', 'email', 'manage_options');
$from->setPlaceholder('noreply@yourdomain.com');
$from->setDefault(get_option('admin_email'));
+ $from->makeAdditionalField();
+ $this->updateField('from_email', $from);
$name = $this->addField('from_name', 'From Name', 'text', 'manage_options');
$name->setDefault(get_bloginfo('name'));
$name->setHint('Default sender name');
+ $name->makeAdditionalField();
+ $this->updateField('from_name', $from);
$opens = $this->addField('track_opens', 'Track Opens', 'select', 'manage_options');
$opens->setOptions([
@@ -61,6 +68,8 @@
]);
$opens->setDefault('false');
$opens->setHint('Track when emails are opened');
+ $opens->makeAdvancedField();
+ $this->updateField('track_opens', $opens);
$links = $this->addField('track_links', 'Track Links', 'select', 'manage_options');
$links->setOptions([
@@ -70,6 +79,8 @@
]);
$links->setDefault('None');
$links->setHint('Track link clicks in emails');
+ $links->makeAdvancedField();
+ $this->updateField('track_links', $links);
}
protected function defineInstructions():void
{
diff --git a/inc/integrations/services/SquareOld.php b/inc/integrations/services/SquareOld.php
index d2c4611..b5fd010 100644
--- a/inc/integrations/services/SquareOld.php
+++ b/inc/integrations/services/SquareOld.php
@@ -237,9 +237,9 @@
? 'https://connect.squareup.com'
: 'https://connect.squareupsandbox.com';
- $this->oauth['authorize'] = $baseUrl . '/oauth2/authorize';
- $this->oauth['token'] = $baseUrl . '/oauth2/token';
- $this->oauth['revoke'] = $baseUrl . '/oauth2/revoke';
+ $this->oauth->getAuthorize() = $baseUrl . '/oauth2/authorize';
+ $this->oauth->getToken() = $baseUrl . '/oauth2/token';
+ $this->oauth->getRevoke() = $baseUrl . '/oauth2/revoke';
// Set up API endpoints with version
$this->apiEndpoints = [
@@ -408,7 +408,7 @@
'redirect_uri' => $this->getRedirectUri()
];
- $response = wp_remote_post($this->oauth['token'], [
+ $response = wp_remote_post($this->oauth->getToken(), [
'body' => json_encode($body),
'headers' => [
'Content-Type' => 'application/json',
@@ -464,7 +464,7 @@
return false;
}
- $response = wp_remote_post($this->oauth['token'], [
+ $response = wp_remote_post($this->oauth->getToken(), [
'body' => [
'client_id' => $this->credentials['client_id'],
'client_secret' => $this->credentials['client_secret'],
@@ -823,7 +823,7 @@
try {
// Revoke the token with Square
if (!empty($this->credentials['access_token'])) {
- wp_remote_post($this->oauth['revoke'], [
+ wp_remote_post($this->oauth->getRevoke(), [
'body' => [
'client_id' => $this->credentials['client_id'] ?? '',
'access_token' => $this->credentials['access_token']
diff --git a/inc/integrations/services/Umami.php b/inc/integrations/services/Umami.php
index b67bc45..bd83cc2 100644
--- a/inc/integrations/services/Umami.php
+++ b/inc/integrations/services/Umami.php
@@ -29,15 +29,21 @@
protected function defineFields(): void
{
$id = $this->addField('website_id', 'Website ID', 'text', 'manage_options');
- $id->setHint('Found in your Umami dashboard under Settings->Websites');
- $id->setRequired();
+ $id->setHint('Found in your Umami dashboard under Settings->Websites');
+ $id->setRequired();
+ $id->makeSetupField();
+ $this->updateField('website_id', $id);
$api_url = $this->addField('api_url', 'API URL', 'text', 'manage_options');
- $api_url->setHint('Your Umami server URL. Leave blank if using umami.is cloud service.');
+ $api_url->setHint('Your Umami server URL. Leave blank if using umami.is cloud service.');
+ $api_url->makeAdvancedField();
+ $this->updateField('api_url', $api_url);
$api_token = $this->addField('api_token', 'API Token (Optional)', 'text', 'manage_options');
- $api_token->setSubType('password');
- $api_token->setHint('Required only if you want to retrieve analytics data for dashboards.');
+ $api_token->setSubType('password');
+ $api_token->setHint('Required only if you want to retrieve analytics data for dashboards.');
+ $api_token->makeAdvancedField();
+ $this->updateField('api_token', $api_token);
}
protected function defineInstructions():void
{
@@ -220,4 +226,9 @@
return implode(' ', $strings);
}
+
+ protected function defineActions(): void
+ {
+ // TODO: Implement defineActions() method.
+ }
}
diff --git a/inc/managers/CustomTable.php b/inc/managers/CustomTable.php
index d86a631..3bb0114 100644
--- a/inc/managers/CustomTable.php
+++ b/inc/managers/CustomTable.php
@@ -2,6 +2,7 @@
namespace JVBase\managers;
use Exception;
+use JVBase\inc\managers\ErrorHelper;
use wpdb;
if (!defined('ABSPATH')) {
@@ -20,7 +21,7 @@
*/
class CustomTable
{
-
+ use ErrorHelper;
private static ?wpdb $wpdb = null;
private static string $prefix;
protected string $tableName;
@@ -507,7 +508,7 @@
);
if ($result === false) {
- $this->logError('insert', $data);
+ $this->logError(self::$wpdb->last_error, 'insert', $data);
return false;
}
@@ -608,7 +609,7 @@
$result = self::$wpdb->query(self::$wpdb->prepare($query, $values));
if ($result === false) {
- $this->logError('bulkInsert', ['rows' => count($rows)]);
+ $this->logError(self::$wpdb->last_error, 'bulkInsert', ['rows' => count($rows)]);
return false;
}
@@ -755,7 +756,7 @@
$key,
function() use ($column, $where, $orderby, $order, $limit) {
if (!empty($this->columns) && !array_key_exists($column, $this->columns)) {
- $this->logError('pluck', ['reason' => "Column '{$column}' not in definition"]);
+ $this->logError("Column '{$column}' not in definition", 'pluck');
return [];
}
$args = ['where' => $where];
@@ -851,7 +852,7 @@
);
if ($result === false) {
- $this->logError('update', ['data' => $data, 'where' => $where]);
+ $this->logError(self::$wpdb->last_error,'update', ['data' => $data, 'where' => $where]);
}
$this->cache->flush();
@@ -881,7 +882,7 @@
);
if ($result === false) {
- $this->logError('delete', ['where' => $where]);
+ $this->logError(self::$wpdb->last_error, 'delete', ['where' => $where]);
}
$this->cache->flush();
@@ -1005,7 +1006,7 @@
return $result;
} catch (Exception $e) {
$this->rollback();
- $this->logError('transaction', ['error' => $e->getMessage()]);
+ $this->logError(self::$wpdb->last_error,'transaction', ['error' => $e->getMessage()]);
return false;
}
}
@@ -1192,22 +1193,6 @@
return isset($cache[$this->tableName][$column]);
}
- /**
- * Log database errors
- */
- private function logError(string $operation, array $context = []): void
- {
- if (function_exists('JVB')) {
- Errors::getInstance()->log(
- $this->tableName,
- "CustomTable {$operation} failed: " . self::$wpdb->last_error,
- $context
- );
- } else {
- error_log("[CustomTable:{$this->tableName}] {$operation} failed: " . self::$wpdb->last_error);
- }
- }
-
public function sum(string $column, array $where):float
{
$rows = $this->pluck($column, $where);
diff --git a/inc/managers/Dashboard/Dashboard.php b/inc/managers/Dashboard/Dashboard.php
index 8a1e1d8..3482b02 100644
--- a/inc/managers/Dashboard/Dashboard.php
+++ b/inc/managers/Dashboard/Dashboard.php
@@ -378,7 +378,14 @@
$registrar = Registrar::getInstance($c);
$page = $this->addPage($registrar->getPlural(), $c, $registrar->getIcon());
$page->setPermission(Roles::getPermissionName('edit', $c));
+ $this->sections[$c] = new Section($registrar->getPlural(), $c, $registrar->getIcon(), 'content');
$page->setSection($c);
+
+
+ if ($registrar->hasFeature('verify_entry')) {
+
+ }
+
//TODO: add artist management and invitations, if applicable
}
}
diff --git a/inc/managers/DirectoryManager.php b/inc/managers/DirectoryManager.php
index 4f4b009..c3e2010 100644
--- a/inc/managers/DirectoryManager.php
+++ b/inc/managers/DirectoryManager.php
@@ -72,7 +72,7 @@
'public' => true,
'menu_icon' => jvbCSSIcon('list-dashes'),
'publicly_queryable' => true,
- 'show_in_menu' => true,
+ 'show_in_menu' => false,
'show_in_admin_bar' => false,
'has_archive' => true,
'hierarchical' => true,
diff --git a/inc/managers/ErrorHelper.php b/inc/managers/ErrorHelper.php
new file mode 100644
index 0000000..498a616
--- /dev/null
+++ b/inc/managers/ErrorHelper.php
@@ -0,0 +1,27 @@
+<?php
+
+namespace JVBase\inc\managers;
+
+use JVBase\managers\Errors;
+
+trait ErrorHelper
+{
+ /**
+ * Log errors with proper context
+ */
+ protected function logError(string $message, string $caller, array $context = []): void
+ {
+ Errors::log($message, array_merge([
+ 'class' => static::class,
+ 'method' => $caller,
+ ], $context));
+ }
+
+ protected static function staticLogError(string $message, string $caller, array $context = []): void
+ {
+ Errors::log($message, array_merge([
+ 'class' => static::class,
+ 'method' => $caller,
+ ], $context));
+ }
+}
diff --git a/inc/managers/Errors.php b/inc/managers/Errors.php
index e45050c..40d0252 100644
--- a/inc/managers/Errors.php
+++ b/inc/managers/Errors.php
@@ -13,503 +13,30 @@
class Errors
{
use Instance;
- protected int $notification_threshold = 5; // Critical errors within 1 hour
- protected array $error_levels = [
- 'debug' => 0,
- 'info' => 1,
- 'warning' => 2,
- 'error' => 3,
- 'critical' => 4
- ];
-
- protected CustomTable $table;
-
- private function __construct()
- {
- $this->defineTables();
-// global $wpdb;
-// $this->wpdb = $wpdb;
-// $this->tableName = $wpdb->prefix . BASE . 'error_log';
-
- add_filter(BASE.'handle_bulk_operation', [$this, 'processOperation'], 10, 3);
-
- // Hook our send function to the cron event
- add_action('jvb_daily_error_summary', [$this, 'sendErrorSummary']);
-
- jvb_register_do_once('error_admin_action_registered', [$this, 'registerAdminAction']);
-// add_filter(BASE.'admin_action_filter', [$this, 'adminActionFilter'], 10, 3);
- }
-
- public function defineTables():void
+ /**
+ * @param string $message
+ * @param array $context
+ * @return void
+ */
+ public static function log(string $message, array $context = []):void
{
- $table = CustomTable::for('error_log');
- $table->setColumns([
- 'id' => 'bigint(20) unsigned NOT NULL AUTO_INCREMENT',
- 'error_type' => 'varchar(50) NOT NULL',
- 'component' => 'varchar(100) NOT NULL',
- 'method' => 'varchar(100) DEFAULT NULL',
- 'page_url' => 'varchar(255) DEFAULT NULL',
- 'message' => 'text NOT NULL',
- 'context' => 'JSON',
- 'severity' => 'ENUM(\'high\',\'normal\',\'low\') DEFAULT \'normal\'',
- 'user_id' => $table->getUserIDType().' DEFAULT NULL',
- 'user_was_logged_in' => 'tinyint(1) NOT NULL',
- 'source' => 'ENUM(\'frontend\', \'backend\') NOT NULL',
- 'created_at' => 'timestamp DEFAULT CURRENT_TIMESTAMP',
- ]);
-
- $table->setKeys([
- ['key' => 'PRIMARY', 'value' => 'id'],
- '`created_at` (`created_at`)',
- '`component_severity_date` (`component`, `severity`, `created_at`)',
- '`error_type_date` (`error_type`, `created_at`)',
- '`severity_date` (`severity`, `created_at`)'
- ]);
-
- $table->defineTable();
- $this->table = $table;
- }
- public function registerAdminAction():void
- {
- $admin = AdminPages::getInstance();
-
- $admin->registerSubpage(
- 'Error Log',
- 'Errors',
- 'manage_options',
- 'jvb-error-log',
- [$this, 'renderAdminPage'],
- );
- }
-
- /**
- * Process error log operations queued through OperationQueue
- *
- * @param WP_Error|array $result The result we are filtering
- * @param object $operation The operation information
- * @param array $data The current chunk of data to process
- *
- * @return WP_Error|array Either the unfiltered WP_Error, or the result of the operation
- */
- public function processOperation(WP_Error|array $result, object $operation, array $data):WP_Error|array
- {
- if ($operation->type !== 'error_log') {
- return $result;
- }
-
- try {
-
- // Extract error data
- $component = sanitize_text_field($data['component'] ?? '');
- $message = sanitize_textarea_field($data['message'] ?? '');
- $context = $data['context'] ?? [];
- $severity = sanitize_text_field($data['severity'] ?? 'error');
-
- // Validate severity level
- if (!array_key_exists($severity, $this->error_levels)) {
- $severity = 'error'; // Default to error if invalid severity provided
- }
-
- // Determine error type from context or use component as fallback
- $error_type = sanitize_text_field($context['error_type'] ?? $component);
-
- // Store context as JSON
- $context_json = json_encode($context);
- if (json_last_error() !== JSON_ERROR_NONE) {
- // If JSON encoding fails, store only basic info
- $context_json = json_encode([
- 'error' => 'Failed to encode original context',
- 'json_error' => json_last_error_msg()
- ]);
- }
-
- // Insert into database
- $result = $this->table->insert(
- [
- 'error_type' => $error_type,
- 'component' => $component,
- 'message' => $message,
- 'context' => $context_json,
- 'severity' => $severity,
- 'user_id' => get_current_user_id(),
- 'created_at' => current_time('mysql')
- ]
- );
-
- if (!$result) {
- // If insert fails, log to PHP error log as fallback
- error_log("[ErrorHandler] Database insert failed: " . $this->table->getLastError());
- return [
- 'success' => false,
- 'message' => "[ErrorHandler] Database insert failed: " . $this->table->getLastError()
- ];
- }
-
- // Potentially track error metrics or trigger additional actions for critical errors
- if ($severity === 'critical') {
- $this->checkErrorThreshold($error_type, $component);
- }
-
- return [
- 'success' => true,
- 'result' => $result
- ];
-
- } catch (Exception $e) {
- // Fallback to PHP error log if our error handler itself fails
- error_log("[ErrorHandler Exception] " . $e->getMessage());
- return [
- 'success' => false,
- 'message' => $e->getMessage()
- ];
- }
- }
-
- /**
- * Check if error threshold has been reached for a specific error type/component
- * and take additional actions if needed
- *
- * @param string $error_type The type of error
- * @param string $component The component where the error occurred
- */
- protected function checkErrorThreshold(string $error_type, string $component)
- {
- // Get count of similar critical errors in the last hour
- $count = $this->table->count([
- 'error_type' => $error_type,
- 'component' => $component,
- 'severity' => $this->error_levels[$error_type],
- 'created_at' => ['>','DATE_SUB(NOW(), INTERVAL 1 HOUR)']
- ]);
-
- // If threshold reached, take additional actions (e.g., notify developers)
- if ($count >= $this->notification_threshold) {
- // You could send an urgent notification, Slack message, etc.
- $admin_email = get_option('admin_email');
- $subject = "[URGENT] Error Threshold Exceeded for {$component}";
- $message = "The error '{$error_type}' in component '{$component}' has occurred {$count} times in the last hour.";
-
- wp_mail($admin_email, $subject, $message);
- }
- }
-
-
- /**
- * @param string $component Where the error is being logged from
- * @param string $message The error message, usually extracted from $e->getMessage())
- * @param array $context Additional context (i.e: data that was attempted to be acted on)
- * @param string $severity
- *
- * @return array
- */
- public function log(string $component, string $message, array $context = [], string $severity = 'error'): array
- {
- try {
- // Validate severity
- if (!array_key_exists($severity, $this->error_levels)) {
- $severity = 'error';
- }
-
- // Extract info
- $error_type = sanitize_text_field($context['error_type'] ?? $component);
- $method = isset($context['method']) ? sanitize_text_field($context['method']) : null;
- $page_url = isset($context['url']) ? esc_url_raw($context['url']) : null;
- $user_id = get_current_user_id();
- $user_was_logged_in = $user_id > 0 || (!empty($context['isLoggedIn']));
-
- // Determine source from context
- $source = $context['source'] ?? (isset($context['url']) ? 'frontend' : 'backend');
-
- $result = $this->table->insert(
- [
- 'error_type' => $error_type,
- 'component' => $component,
- 'method' => $method,
- 'page_url' => $page_url,
- 'message' => sanitize_textarea_field($message),
- 'context' => json_encode($context),
- 'severity' => $severity,
- 'user_id' => $user_id ?: null,
- 'user_was_logged_in' => $user_was_logged_in ? 1 : 0,
- 'source' => $source,
- 'created_at' => current_time('mysql')
- ]);
-
- if (!$result) {
- error_log("[ErrorHandler] Database insert failed: " . $this->table->getLastError());
- return ['success' => false, 'message' => $this->table->getLastError()];
- }
-
- if ($severity === 'critical') {
- $this->checkErrorThreshold($error_type, $component);
- }
-
- return ['success' => true, 'id' => $result];
-
- } catch (Exception $e) {
- error_log("[ErrorHandler Exception] " . $e->getMessage());
- return ['success' => false, 'message' => $e->getMessage()];
+ $msg = '{'.BASE.'}';
+ if (array_key_exists('class', $context)) {
+ $msg .= '['.$context['class'].']';
+ unset($context['class']);
}
- }
-
- /**
- * @param string $component What class or function logs the error
- * @param string $message Details on the error
- * @param array $context additional context of the data that was being processed
- *
- * @return bool Whether the notification is sent successfully
- */
- protected function notifyAdmin(string $component, string $message, array $context):bool
- {
- $admin_email = get_option('admin_email');
- $subject = "[" . get_bloginfo('name') . " Critical Error] {$component}";
-
- $body = Emails::getInstance()->alert(
- 'A critical error has occurred and requires immediate attention',
- 'error'
- );
-
- $body .= Emails::getInstance()->h2('Error Details');
- $body .= Emails::getInstance()->card(
- '<p><strong>Component:</strong> ' . esc_html($component) . '</p>' .
- '<p><strong>Message:</strong></p>' .
- Emails::getInstance()->codeBlock($message),
- 'Error Information'
- );
-
+ if (array_key_exists('method', $context)) {
+ $msg .= '::'.$context['method'];
+ unset($context['method']);
+ }
+ if (array_key_exists('function', $context)) {
+ $msg .= $context['function'];
+ unset($context['function']);
+ }
+ error_log($msg.' '.$message);
if (!empty($context)) {
- $body .= Emails::getInstance()->h3('Additional Context');
- $body .= Emails::getInstance()->codeBlock(json_encode($context, JSON_PRETTY_PRINT));
+ error_log('Context: '.print_r($context, true));
}
-
- return Emails::getInstance()->sendEmail($admin_email, $subject, $body, 'CRITICAL ERROR');
}
-
- /**
- * Gather summary of the most important errors
- * @param ?string $start_date Defaults to today
- * @param ?string $end_date Defaults to today
- * @return array
- */
- public function gatherErrorSummary(?string $start_date = null, ?string $end_date = null): array
- {
- if (!$start_date) {
- $start_date = gmdate('Y-m-d 00:00:00', strtotime('-1 day'));
- }
- if (!$end_date) {
- $end_date = gmdate('Y-m-d 23:59:59');
- }
-
- // Most frequent error patterns (deduplicated by component/method/message)
- $frequent = $this->table->getMany([
- 'where' => [
- 'created_at' => ['BETWEEN', "{$start_date} AND {$end_date}"]
- ]
- ]);
-
- return [
- 'errors' => $frequent,
- 'date_range' => ['start' => $start_date, 'end' => $end_date]
- ];
- }
-
- /**
- * Send daily error summary email to administrator
- * @return bool Whether email is sent
- */
- public function sendErrorSummary():bool
- {
- $summary = $this->gatherErrorSummary();
-
- if (empty($summary['frequent']) && empty($summary['critical'])) {
- return false;
- }
-
- $admin_email = get_option('admin_email');
- $site_name = get_bloginfo('name');
- $yesterday = date('Y-m-d', strtotime('-1 day'));
- $subject = "[{$site_name}] Daily Error Summary - " . date('Y-m-d');
-
- // Header with alert
- $body = Emails::getInstance()->h1('Daily Error Summary');
- $body .= sprintf('<p>Error summary for <strong>%s</strong></p>', $yesterday);
-
- // Summary stats in a grid
- if (!empty($summary['stats'])) {
- $stats = [
- Emails::getInstance()->stat($summary['stats']->total_errors, 'Total Errors'),
- Emails::getInstance()->stat($summary['stats']->critical_count, 'Critical', 'Requires attention'),
- Emails::getInstance()->stat($summary['stats']->error_count, 'Errors'),
- Emails::getInstance()->stat($summary['stats']->warning_count, 'Warnings')
- ];
- $body .= Emails::getInstance()->grid($stats, 4);
- }
-
- // Alert if critical errors exist
- if (!empty($summary['critical'])) {
- $body .= Emails::getInstance()->alert(
- sprintf('Found %d critical errors that need immediate attention', count($summary['critical'])),
- 'error'
- );
- }
-
- $body .= Emails::getInstance()->spacer();
-
- // Frequent errors section
- if (!empty($summary['frequent'])) {
- $body .= Emails::getInstance()->h2('Most Frequent Errors');
-
- foreach ($summary['frequent'] as $error) {
- $cardContent = Emails::getInstance()->badge($error->count . 'x', 'warning') . ' ';
- $cardContent .= '<strong>' . esc_html($error->error_type) . '</strong>';
- $cardContent .= '<p style="margin:10px 0 5px 0;font-size:13px;">' . esc_html(wp_trim_words($error->message, 15)) . '</p>';
- $cardContent .= '<p style="margin:0;font-size:12px;color:' . Emails::getInstance()->colours['dark-200'] . ';">
- Source: ' . esc_html($error->source) . ' |
- Logged in: ' . $error->logged_in_count . ' |
- Logged out: ' . $error->logged_out_count . '
- </p>';
-
- $body .= Emails::getInstance()->card($cardContent, $error->component);
- }
- }
-
- // Critical errors section
- if (!empty($summary['critical'])) {
- $body .= Emails::getInstance()->spacer(30);
- $body .= Emails::getInstance()->h2('Recent Critical Errors');
-
- foreach ($summary['critical'] as $error) {
- $cardContent = '<p><strong>Time:</strong> ' . esc_html($error->created_at) . '</p>';
- $cardContent .= '<p><strong>Message:</strong></p>';
- $cardContent .= Emails::getInstance()->codeBlock($error->message);
-
- // Include context if available
- if (!empty($error->context)) {
- $context = json_decode($error->context, true);
- if (is_array($context)) {
- $cardContent .= '<p><strong>Context:</strong></p>';
- $cardContent .= Emails::getInstance()->codeBlock(json_encode($context, JSON_PRETTY_PRINT));
- }
- }
-
- $body .= Emails::getInstance()->card($cardContent, $error->component . ': ' . $error->error_type);
- }
- }
-
- // Dashboard link
- $admin_url = admin_url('admin.php?page=jvb-error-logs');
- $body .= Emails::getInstance()->spacer(30);
- $body .= Emails::getInstance()->button($admin_url, 'View Detailed Logs');
-
- return Emails::getInstance()->sendEmail($admin_email, $subject, $body, 'ERROR SUMMARY');
- }
-
- /**
- * Get HTML version of the error summary for nicer emails
- *
- * @param array $summary The error summary data
- * @return string HTML formatted error summary
- */
- protected function getHtmlErrorSummary(array $summary):string
- {
- $html = '<div style="font-family: sans-serif; max-width: 800px; margin: 0 auto;">';
- $html .= '<h1 style="color:#FF0080;border-bottom:2px solid #1B1B1B;padding-bottom:10px;">Error Summary</h1>';
-
- // Frequent errors table
- if (!empty($summary['frequent'])) {
- $html .= '<h2>Most Frequent Errors</h2>';
- $html .= '<table style="width: 100%; border-collapse: collapse; margin-bottom: 20px;">';
- $html .= '<tr style="background-color: #1B1B1B; color: #EFEFEF;">';
- $html .= '<th style="padding: 8px; text-align: left; border: 1px solid #ddd;">Component</th>';
- $html .= '<th style="padding: 8px; text-align: left; border: 1px solid #ddd;">Error Type</th>';
- $html .= '<th style="padding: 8px; text-align: left; border: 1px solid #ddd;">Message</th>';
- $html .= '<th style="padding: 8px; text-align: center; border: 1px solid #ddd;">Count</th>';
- $html .= '</tr>';
-
- foreach ($summary['frequent'] as $error) {
- $html .= '<tr>';
- $html .= '<td style="padding:8px;border:1px solid #ddd;">' . esc_html($error->component) . '</td>';
- $html .= '<td style="padding:8px;border:1px solid #ddd;">' . esc_html($error->error_type) . '</td>';
- $html .= '<td style="padding:8px;border:1px solid #ddd;">' . esc_html(wp_trim_words($error->message, 10, '...')) . '</td>';
- $html .= '<td style="padding:8px;border:1px solid #ddd;text-align:center;"><strong>' . esc_html($error->count) . '</strong></td>';
- $html .= '</tr>';
- }
-
- $html .= '</table>';
- }
-
- // Critical errors section
- if (!empty($summary['critical'])) {
- $html .= '<h2>Recent Critical Errors</h2>';
-
- foreach ($summary['critical'] as $error) {
- $html .= '<div style="margin-bottom: 20px; padding: 15px; border-left: 4px solid #FF0080; background-color: #fff9fb;">';
- $html .= '<h3 style="margin-top: 0; color: #1B1B1B;">' . esc_html($error->component) . ': ' . esc_html($error->error_type) . '</h3>';
- $html .= '<p><strong>Time:</strong> ' . esc_html($error->created_at) . '</p>';
- $html .= '<p><strong>Message:</strong> ' . esc_html($error->message) . '</p>';
-
- // Context display
- if (!empty($error->context)) {
- $context = json_decode($error->context, true);
- if (is_array($context)) {
- $html .= '<div style="margin-top: 10px;">';
- $html .= '<strong>Context:</strong>';
- $html .= '<pre style="background-color: #f5f5f5; padding: 10px; border-radius: 4px; overflow: auto; max-height: 200px;">';
- $html .= esc_html(json_encode($context, JSON_PRETTY_PRINT));
- $html .= '</pre>';
- $html .= '</div>';
- }
- }
-
- $html .= '</div>';
- }
- }
-
- // Dashboard link
- $admin_url = admin_url('admin.php?page=jvb-error-logs');
- $html .= '<p style="margin-top: 30px; padding-top: 15px; border-top: 1px solid #ddd;">';
- $html .= '<a href="' . esc_url($admin_url) . '" style="display: inline-block; background-color: #FF0080; color: white; padding: 10px 15px; text-decoration: none; border-radius: 4px;">View All Errors in Dashboard</a>';
- $html .= '</p>';
-
- $html .= '</div>';
-
- return $html;
- }
-
- public function renderAdminPage()
- {
- ?>
- <h1>Error Logs</h1>
- <?php
- }
-
- public function getErrors(WP_REST_Request $request) {
- $params = $this->buildParams($request);
-
- }
-
-
-
- protected function buildParams(WP_REST_Request $request):array {
- $allowedSeverity = [
- 'all',
- 'notice',
- 'warning',
- 'error',
- 'critical',
- ];
- $allowedComponents = [
- 'all',
- //Get all components
- ];
- $severity = $request->get_param('severity')??'all';
- $component = $request->get_param('component')??'all';
-
- return [
- 'page' => (int)$request->get_param('page'),
- 'severity' => (in_array($severity, $allowedSeverity)) ? $severity : 'all',
- 'component' => (in_array($component, $allowedComponents)) ? $component : 'all',
- ];
- }
}
diff --git a/inc/managers/Favourites.php b/inc/managers/Favourites.php
index 23ce3be..06e8a42 100644
--- a/inc/managers/Favourites.php
+++ b/inc/managers/Favourites.php
@@ -2,6 +2,7 @@
namespace JVBase\managers;
use Exception;
+use JVBase\inc\managers\ErrorHelper;
use JVBase\Instance;
use JVBase\registrar\Registrar;
use JVBase\rest\Rest;
@@ -13,7 +14,7 @@
}
class Favourites
{
- use Instance;
+ use Instance, ErrorHelper;
protected CustomTable $favourites;
protected CustomTable $lists;
@@ -813,7 +814,7 @@
'item_id' => $post_id
])->deleteResults();
} catch (Exception $e) {
- Errors::getInstance()->log('cleanupPostFavourites', $e->getMessage(), [
+ $this->logError($e->getMessage(), 'cleanupPostFavourites',[
'post_id' => $post_id
]);
}
@@ -836,7 +837,7 @@
'item_id' => $term_id
])->deleteResults();
} catch (Exception $e) {
- Errors::getInstance()->log('cleanupTermFavourites', $e->getMessage(), [
+ $this->logError($e->getMessage(), 'cleanupTermFavourites',[
'term_id' => $term_id,
'taxonomy'=> $taxonomy
]);
diff --git a/inc/managers/InvitationsManager.php b/inc/managers/InvitationsManager.php
index cc3078d..de21889 100644
--- a/inc/managers/InvitationsManager.php
+++ b/inc/managers/InvitationsManager.php
@@ -2,6 +2,7 @@
namespace JVBase\managers;
use Exception;
+use JVBase\inc\managers\ErrorHelper;
use JVBase\Instance;
use JVBase\managers\queue\executors\InvitationExecutor;
use JVBase\managers\queue\Queue;
@@ -18,7 +19,7 @@
class InvitationsManager
{
- use Instance;
+ use Instance, ErrorHelper;
protected array $inviteConfig;
protected CustomTable $table;
protected int $expiryDays = 14;
@@ -391,12 +392,7 @@
} catch (Exception $e) {
$this->table->rollback();
-
- Errors::getInstance()->log(
- 'invitation_create',
- 'Error processing invitations: ' . $e->getMessage(),
- ['user_id' => $userID],
- );
+ $this->logError($e->getMessage(), 'processInvitations', ['user_id' => $userID]);
return [
'success' => false,
diff --git a/inc/managers/MagicLinks.php b/inc/managers/MagicLinks.php
index 7ec979b..ec8efd8 100644
--- a/inc/managers/MagicLinks.php
+++ b/inc/managers/MagicLinks.php
@@ -3,6 +3,7 @@
use JetBrains\PhpStorm\NoReturn;
use JVBase\base\Site;
+use JVBase\inc\managers\ErrorHelper;
use JVBase\Instance;
use JVBase\integrations\services\Cloudflare;
use JVBase\meta\Form;
@@ -23,7 +24,7 @@
*/
class MagicLinks
{
- use Instance;
+ use Instance, ErrorHelper;
protected Cache $cache;
protected Cache $referral_cache;
protected Cache $rateLimitCache;
@@ -382,7 +383,8 @@
#[NoReturn] protected function processSignup(array $token_data): void
{
if (!array_key_exists('email', $token_data) || !array_key_exists('name', $token_data)) {
- Errors::getInstance()->log('MagicLinkManager', 'Could not process Signup');
+ $this->logError('Could not process signup', 'processSignup', ['token_data' => $token_data]);
+
$this->cleanURL();
}
$user_id = wp_create_user(
@@ -429,7 +431,7 @@
protected function processReferralSignup(array $token_data): void
{
if (!array_key_exists('email', $token_data) || !array_key_exists('name', $token_data)) {
- Errors::getInstance()->log('[MagicLinkManager]Could not process Referral Signup');
+ $this->logError('Could not process referral signup', 'processReferralSignup', ['token_data' => $token_data]);
return;
}
@@ -455,11 +457,8 @@
exit;
}
} else {
- Errors::getInstance()->log(
- '[MagicLinkManager]',
- $user_id->get_error_message(),
- $token_data
- );
+ $this->logError($user_id->get_error_message(), 'processReferralSignup');
+
$this->cleanURL();
}
}
@@ -473,10 +472,7 @@
$user = get_user_by('ID', $token_data['user_id']);
if (!$user) {
- Errors::getInstance()->log(
- 'MagicLinkManager',
- 'Invalid user, could not reset password',
- );
+ $this->logError('Invalid user - could not reset password', 'processPasswordReset', ['token_data' => $token_data]);
$this->cleanURL();
}
diff --git a/inc/managers/OperationQueue.php b/inc/managers/OperationQueue.php
deleted file mode 100644
index 8dba76a..0000000
--- a/inc/managers/OperationQueue.php
+++ /dev/null
@@ -1,2183 +0,0 @@
-<?php
-namespace JVBase\managers;
-
-use Exception;
-use WP_Error;
-use WP_REST_Response;
-use WP_REST_Request;
-use wpdb;
-
-if (!defined('ABSPATH')) {
- exit; // Exit if accessed directly
-}
-
-//# Every minute - main queue processing
-//* * * * * cd /path/to/wordpress && wp cron event run jvb_process_queue
-//
-//# Every hour - maintenance tasks
-//0 * * * * cd /path/to/wordpress && wp cron event run jvb_queue_maintenance
-//
-//# Daily at 2 AM - metrics report
-//0 2 * * * cd /path/to/wordpress && wp cron event run jvbEmailDailyMetricsReport
-//
-//
-//
-//Click "Create" to save the cron job
-class OperationQueue
-{
- protected string $table = BASE.'_operation_queue';
- protected string $metricsTable = BASE.'stats__operation_queue';
- protected int $max_attempts = 3;
- protected wpdb $wpdb;
-
- // System load thresholds
- protected array $load_thresholds = [
- 'cpu' => 80, // Max CPU usage percentage
- 'memory' => 85, // Max memory usage percentage
- 'load_avg' => 1.6 // Max load average (80% of 2 cores)
- ];
-
- protected int $maxPerBatch = 25;
-
- // Queue configuration
- protected array $config = [
- 'max_operations_per_batch' => 25, // Reduced from 100 for better control
- 'max_concurrent_operations' => 100, // Adjusted for 2 CPU cores
-
- ];
-
- protected ?Cache $cache = null;
- protected int $ttl = 300;
- // Cache keys for different data types
- private const CACHE_QUEUE_STATUS = 'status';
- private const CACHE_HAS_ITEMS = 'has_items';
- private const CACHE_OPERATION_PREFIX = 'op_';
- private const CACHE_USER_QUEUE_PREFIX = 'user_queue_';
- private const CACHE_QUEUE_SIZE = 'queue_size';
-
-
- public function __construct()
- {
- global $wpdb;
- $this->wpdb = $wpdb;
- $this->cache = Cache::for('queue', 'user',DAY_IN_SECONDS)->user();
- add_action('jvb_process_queue', [ $this, 'checkQueue' ]);
- add_action('jvb_queue_maintenance', [$this, 'hourlyMaintenance']);
- add_action('jvbEmailDailyMetricsReport', [$this, 'emailDailyMetricsReport']);
-
- jvb_register_do_once('queue_admin_action_registered', [$this, 'registerAdminAction']);
- add_filter(BASE.'admin_action_filter', [$this, 'adminActionFilter'], 10, 3);
-// add_filter('jvbAdminSubpages', [$this, 'addSubpage'], 10, 1);
-
- if ( ! wp_next_scheduled( 'jvb_process_queue' ) ) {
- wp_schedule_event( time(), 'every-minute', 'jvb_process_queue' );
- }
- if ( ! wp_next_scheduled( 'jvb_queue_maintenance' ) ) {
- wp_schedule_event( time(), 'hourly', 'jvb_queue_maintenance' );
- }
- }
-
- public function addSubpage(array $subpages):array
- {
- $subpages[] = [
- 'page_title' => 'Operation Queue', // page_title
- 'menu_title' => 'Queue', // menu_title
- 'capability' => 'manage_options', // capability
- 'menu_slug' => 'operation-queue', // menu_slug (will become BASE.'integrations')
- 'callback' => [$this, 'renderAdminPage'], // callback
- ];
-
- return $subpages;
- }
- public function registerAdminAction():void
- {
- $admin = AdminPages::getInstance();
- $admin->registerAction(
- 'Restart Stuck Operations',
- 'restart-stuck-operations',
- 'manage_options',
- 'all'
- );
- $admin->registerAction(
- 'Unlock Queue',
- 'unlock-operation-queue',
- 'manage_options',
- 'all'
- );
- }
-
- /**
- * @param WP_REST_Response $response
- * @param string $action
- *
- * @return bool|WP_REST_Response
- */
- public function adminActionFilter(WP_REST_Response $response, WP_REST_Request $request, string $action):WP_REST_Response|bool
- {
- switch ($action) {
- case 'unlock-operation-queue':
- $this->unlockQueue();
- return new WP_REST_Response([
- 'success' => true,
- 'message' => 'Unlocked Queue'
- ]);
- case 'restart-stuck-operations':
- $this->restartStuckOperations();
- return new WP_REST_Response([
- 'success' => true,
- 'message' => 'Restarted Stuck Operations'
- ]);
- default:
- return $response;
- }
- }
-
- protected function getQueueInfo(): array
- {
- $cacheKey = 'queue_info';
- $cached = $this->cache->get($cacheKey);
- if ($cached !== false) {
- return $cached;
- }
-
- $table = $this->wpdb->prefix . $this->table;
- $current_time = current_time('mysql');
-
- // Get both count and existence in one query
- $result = $this->wpdb->get_row($this->wpdb->prepare("
- SELECT
- COUNT(*) as total,
- SUM(IF(status IN ('pending', 'processing'), 1, 0)) as active,
- SUM(IF(status = 'scheduled' AND scheduled_at <= %s, 1, 0)) as ready_scheduled
- FROM $table
- WHERE status IN ('pending', 'processing', 'scheduled')
- ", $current_time));
-
- $info = [
- 'total' => (int)$result->total,
- 'active' => (int)$result->active,
- 'ready' => (int)$result->active + (int)$result->ready_scheduled,
- 'has_items' => ((int)$result->active + (int)$result->ready_scheduled) > 0
- ];
-
- $this->cache->set($cacheKey, $info, 30);
- return $info;
- }
- protected function hasItemsInQueue(): bool
- {
- return $this->getQueueInfo()['has_items'];
- }
-
- /**
- * Cron job that checks if the queue needs to be processed
- * @return void
- */
- public function checkQueue(): void
- {
- if (!$this->hasItemsInQueue()) {
- return;
- }
-
- if ($this->isQueueLocked()) {
- return;
- }
-
-
- // Peek at what operations we might process
- $batch_size = $this->getAdaptiveBatchSize();
- $operations = $this->getOperations($batch_size);
-
- // Only check server load for heavy operations
- if ($this->shouldCheckServerLoad($operations) && !$this->isServerIdle()) {
- error_log('Server not idle for heavy operations');
- return;
- }
-
- $this->lockQueue();
- $this->applyServerSettings();
- $this->processQueue($operations);
- $this->unlockQueue();
- }
-
- public function hourlyMaintenance(): void
- {
- if ($this->isQueueLocked()) {
- return; // Don't run maintenance while processing
- }
-
- $this->cleanupStuckOperations();
- $this->restartStuckOperations();
-// $this->pruneOldMetrics(); // Optional: clean old metrics
- }
-
- protected function isHeavyOperation(string $type): bool
- {
- $heavyOperations = [
- 'file_upload' => true,
- 'image_processing' => true,
- 'rebuild_user_term_index' => true,
- 'sync_content_taxonomy_tables' => true,
- 'email_notification_digest' => true,
- 'taxonomy_relationships' => true,
- ];
-
- return isset($heavyOperations[$type]);
- }
-
- protected function shouldCheckServerLoad(array $operations): bool
- {
- // Check if any operations are heavy
- foreach ($operations as $op) {
- if ($this->isHeavyOperation($op->type)) {
- return true;
- }
- }
- return false;
- }
-
- /**
- * Check if the queue is currently locked for processing
- * @return bool
- */
- protected function isQueueLocked(): bool
- {
- $lock_name = BASE . '_queue_lock';
-
- // Check if lock exists and is recent
- $result = $this->wpdb->get_var($this->wpdb->prepare(
- "SELECT IS_USED_LOCK(%s)",
- $lock_name
- ));
-
- return $result !== null;
- }
-
- /**
- * Check if server is relatively idle (very low load)
- * @return bool
- */
- private function isServerIdle():bool
- {
- // Consider server idle if load is below 50% of our threshold
- if (function_exists('sys_getloadavg')) {
- $load = sys_getloadavg();
- if ($load[0] > ($this->load_thresholds['load_avg'] * 0.5)) {
- return false;
- }
- }
-
- // Consider memory idle if usage is below 60% of our threshold
- $memory_usage = $this->getMemoryUsage();
- if ($memory_usage > ($this->load_thresholds['memory'] * 0.6)) {
- return false;
- }
- return true;
- }
-
- /**
- * Acquire a lock on the queue
- * @return bool
- */
- protected function lockQueue(): bool
- {
- $lock_name = BASE . '_queue_lock';
-
- // Try to acquire lock with 0 timeout (non-blocking)
- $result = $this->wpdb->get_var($this->wpdb->prepare(
- "SELECT GET_LOCK(%s, 0)",
- $lock_name
- ));
-
- return $result === '1';
- }
-
- /**
- * Release the queue lock
- * @return void
- */
- protected function unlockQueue(): void
- {
- $lock_name = BASE . '_queue_lock';
-
- $this->wpdb->query($this->wpdb->prepare(
- "SELECT RELEASE_LOCK(%s)",
- $lock_name
- ));
- }
-
-
- /**
- * @return void
- */
- protected function applyServerSettings():void
- {
- // Set max execution time for background processes
- set_time_limit(300); // 5 minutes
-
- // Optimize garbage collection
- gc_enable();
-
- // Set process priority if possible
- if (function_exists('proc_nice')) {
- proc_nice(10); // Lower priority for background tasks
- }
- }
-
- /**
- * @param object $operation
- *
- * @return bool
- */
- protected function areDependenciesMet(object $operation): bool
- {
- if (empty($operation->dependencies)) {
- return true;
- }
-
- // Add timeout check for stuck dependencies
-// $created_time = strtotime($operation->created_at);
-// $max_wait = 3600; // 1 hour max wait
-//
-// if (time() - $created_time > $max_wait) {
-// error_log("Operation {$operation->id} timed out waiting for dependencies");
-// $this->markAsPermanentlyFailed($operation->id, 'Dependencies timeout after 1 hour');
-// return false;
-// }
-
- $dependencies = is_string($operation->dependencies)
- ? json_decode($operation->dependencies, true)
- : $operation->dependencies;
-
- if (is_string($dependencies)) {
- $dependencies = explode(',',$dependencies);
- }
-
- // Batch check all dependencies at once
- $statuses = $this->getOperationStatuses($dependencies);
-
- foreach ($dependencies as $dep_id) {
- if (!isset($statuses[$dep_id]) || $statuses[$dep_id]->status !== 'completed') {
- return false;
- }
- }
-
- return true;
- }
-
- public function getOperationStatuses(array $operation_ids): array
- {
- if (empty($operation_ids)) {
- return [];
- }
-
- $placeholders = implode(',', array_fill(0, count($operation_ids), '%s'));
- $table = $this->wpdb->prefix . $this->table;
-
- $results = $this->wpdb->get_results($this->wpdb->prepare(
- "SELECT id, status FROM $table WHERE id IN ($placeholders)",
- ...$operation_ids
- ), OBJECT_K);
-
- return $results ?: [];
- }
-
- /**
- * Queue or update an operation with intelligent batching
- *
- * @param string $type Operation type
- * @param int $user_id User ID
- * @param array $data Operation data
- * @param array $options Operation options
- * - operation_id: string,
- * - priority: string, (high, normal, low) Operation priority
- * - delay: int Time window in seconds before processing
- * - scheduled: string, as formatted by sanitizeDateTime in MetaSanitizer.php
- * - merge: string, //replace, append, merge
- * - chunk_key: string The array key to split large operations by
- * - chunk_size: int The batch size to process at once
- * @return array|WP_Error
- */
- public function queueOperation(string $type, int $user_id, array $data, array $options = []): array|WP_Error
- {
- try {
- $table = $this->wpdb->prefix . $this->table;
-
- // Extract options
- $priority = (array_key_exists('priority', $options) && in_array($options['priority'], ['low', 'normal', 'high'])) ? $options['priority'] : 'normal';
- $delay = (array_key_exists('delay', $options)) ? (int) $options['delay'] : 0;
- $scheduledFor = (array_key_exists('scheduled', $options)) ? $options['scheduled'] : '';
- $merge = (array_key_exists('merge', $options) && in_array($options['merge'], ['merge', 'append', 'replace'])) ? $options['merge'] : 'merge';
- $operation_id = $options['operation_id'] ?? false;
- $chunk_key = $options['chunk_key'] ?? null;
- $chunk_size = $options['chunk_size'] ?? null;
-
- // Calculate operation count based on chunk_key if provided
- $count = 1; // Default
- if ($chunk_key && isset($data[$chunk_key])) {
- if (is_array($chunk_key)) {
- // Support multiple keys (like favourites_batch with 'adds' and 'removes')
- $total = 0;
- foreach ($chunk_key as $key) {
- if (isset($data[$key]) && is_array($data[$key])) {
- $total += count($data[$key]);
- }
- }
- $count = max(1, $total);
- } else {
- // Single key
- $count = max(1, count($data[$chunk_key]));
- }
- }
-
- // Generate operation ID
- $operation_id = $this->checkOperationId($operation_id, $type, $merge, $user_id);
-
- // Check for existing operation
- $existing = $this->getOperation($operation_id);
-
- $dependencies = $options['depends_on'] ?? [];
- if (is_string($dependencies)) {
- $dependencies = explode(',',$dependencies);
- }
-
- $scheduled_at = $this->calculateScheduledTime($delay === 0 ? $scheduledFor : $delay);
-
- // Prepare chunk config to store
- $chunk_config = null;
- if ($chunk_key && $chunk_size) {
- $chunk_config = [
- 'key' => $chunk_key,
- 'size' => $chunk_size
- ];
- }
-
- if ($existing) {
-
- if ($merge !== 'append' && ($existing->status === 'pending' || $existing->status === 'scheduled')) {
-
- if ($merge === 'merge') {
- $existing_data = json_decode($existing->request_data ?? '{}', true);
- $data = $this->deepMerge($existing_data, $data);
-
- $existing_dependencies = json_decode($existing->dependencies ?? '[]', true);
- if (!is_array($existing_dependencies)) {
- $existing_dependencies = [];
- }
- $dependencies = array_unique(array_merge($existing_dependencies, $dependencies));
- }
-
- // Keep earliest scheduled time
- if ($delay > 0 || $scheduledFor !== '') {
- $scheduled_at = $this->getEarliestScheduledTime(
- $existing->scheduled_at ?? $existing->created_at,
- $scheduled_at
- );
- }
-
- // Store chunking config in metadata
- $metadata = json_decode($existing->metadata ?? '{}', true);
- if ($chunk_config) {
- $metadata['chunk_config'] = $chunk_config;
- }
-
- $result = $this->wpdb->update(
- $table,
- [
- 'request_data' => json_encode($data),
- 'dependencies' => json_encode($dependencies),
- 'merge' => $merge,
- 'count' => $count,
- 'priority' => $priority,
- 'scheduled_at' => $scheduled_at,
- 'metadata' => json_encode($metadata),
- 'updated_at' => current_time('mysql'),
- ],
- ['id' => $operation_id],
- ['%s', '%s', '%s', '%d', '%s', '%s', '%s', '%s'],
- ['%s']
- );
-
- if ($result === false) {
- throw new Exception("Failed to update operation: " . $this->wpdb->last_error);
- }
-
- } else if ($merge === 'append') {
- $operation_id = $operation_id . '_' . time() . '_' . substr(uniqid(), -4);
- $existing = null;
- }
- }
-
- if (!$existing) {
- // Prepare metadata with chunk config
- $metadata = $chunk_config ? ['chunk_config' => $chunk_config] : [];
-
- $result = $this->wpdb->insert(
- $table,
- [
- 'id' => $operation_id,
- 'type' => $type,
- 'user_id' => $user_id,
- 'request_data' => json_encode($data),
- 'count' => $count,
- 'priority' => $priority,
- 'status' => $delay > 0 ? 'scheduled' : 'pending',
- 'dependencies' => json_encode($dependencies),
- 'metadata' => json_encode($metadata),
- 'merge' => $merge,
- 'scheduled_at' => $scheduled_at,
- 'created_at' => current_time('mysql'),
- 'updated_at' => current_time('mysql'),
- ],
- ['%s', '%s', '%d', '%s', '%d', '%s', '%s', '%s', '%s', '%s', '%s', '%s']
- );
-
- if ($result === false) {
- throw new Exception("Failed to insert operation: " . $this->wpdb->last_error);
- }
- }
-
- $this->invalidateUserQueue($user_id);
- $this->runQueueOnShutdown();
-
- return [
- 'success' => true,
- 'operation_id' => $operation_id,
- 'updated_existing' => !empty($existing),
- 'scheduled_at' => $scheduled_at,
- 'queue_status' => $this->getQueueStatus()
- ];
-
- } catch (Exception $e) {
- Errors::getInstance()->log('queue', $e->getMessage(), $data, 'high');
- return new WP_Error('queue_failed', $e->getMessage());
- }
- }
-
- protected function deepMerge(array $existing, array $new): array
- {
- $merged = $existing;
-
- if (!$this->isAssociativeArray($existing) && !$this->isAssociativeArray($new)) {
- return array_merge($existing, $new);
- }
- foreach ($new as $key => $newValue) {
- if (!array_key_exists($key, $existing)) {
- $merged[$key] = $newValue;
- } else {
- $existingValue = $existing[$key];
- if (is_array($existingValue) && is_array($newValue)) {
- if ($this->isAssociativeArray($existingValue) || $this->isAssociativeArray($newValue)) {
- // Recursive merge going deeper, if any of them are associative arrays
- $merged[$key] = $this->deepMerge($existingValue, $newValue);
- } else {
- $containsComplex = $this->containsComplexData($existingValue) || $this->containsComplexData($newValue);
-
- if ($containsComplex) {
- // Just merge and re-index - preserves all items from chunks
- $merged[$key] = array_values(array_merge($existingValue, $newValue));
- } else {
- // Simple scalar arrays - use unique merge
- $merged[$key] = array_unique(array_merge($existingValue, $newValue), SORT_REGULAR);
- }
- }
- } elseif (is_array($existingValue) && !is_array($newValue)) {
- // The existing value is an array, but the new one isn't
- // Check if it's safe to use in_array (only if existing doesn't contain arrays)
- $containsArrays = false;
- foreach ($existingValue as $item) {
- if (is_array($item) || is_object($item)) {
- $containsArrays = true;
- break;
- }
- }
-
- if (!$containsArrays) {
- // Safe to use in_array with strict comparison
- if (!in_array($newValue, $existingValue, true)) {
- $merged[$key][] = $newValue;
- }
- } else {
- // For arrays containing complex types, just append
- // (avoiding comparison that might cause issues)
- $merged[$key][] = $newValue;
- }
- } elseif (!is_array($existingValue) && is_array($newValue)) {
- // The opposite check as above
- $merged[$key] = array_unique(array_merge([$existingValue], $newValue), SORT_REGULAR);
- } else {
- // Override the existing with the new, both are scalars
- $merged[$key] = $newValue;
- }
- }
- }
- return $merged;
- }
-
- /**
- * Check if an array is associative
- * @param array $arr
- * @return bool
- */
- protected function isAssociativeArray(array $arr): bool
- {
- if (empty($arr)) {
- return false;
- }
- return array_keys($arr) !== range(0, count($arr) - 1);
- }
-
- /**
- * Check if an array contains complex data (arrays or objects)
- * @param array $arr
- * @return bool
- */
- protected function containsComplexData(array $arr): bool
- {
- foreach ($arr as $item) {
- if (is_array($item) || is_object($item)) {
- return true;
- }
- }
- return false;
- }
-
- protected function getEarliestScheduledTime(string $existing, string $new): string
- {
- $existing_time = strtotime($existing);
- $new_time = strtotime($new);
- return $existing_time < $new_time ? $existing : $new;
- }
-
- protected function calculateScheduledTime(int|string $delay): string
- {
- if (is_int($delay) && $delay > 0) {
- return date('Y-m-d H:i:s', current_time('timestamp') + $delay);
- }
- if ($delay !== '') {
- return strtotime($delay);
- }
- return current_time('mysql');
- }
-
- protected function checkOperationId(?string $operation_id, string $type, string $merge, int $user_id): string
- {
- if ($merge === 'merge') {
- $existing = $this->getUserOperations(
- $user_id,
- [
- 'status' => 'pending',
- 'type' => $type,
- 'merge' => $merge,
- 'limit' => 1
- ]);
- if ($existing) {
- $operation_id = $existing[0]->id;
- }
- }
-
- if (!$operation_id) {
- $operation_id = uniqid('op_');
- }
-
- if (!str_starts_with($operation_id, 'u')) {
- $operation_id = 'u' . $user_id . '_' . $operation_id;
- }
-
- return $operation_id;
- }
-
-
- protected function runQueueOnShutdown(): void
- {
- // Only add if not already scheduled AND if operation is high priority or small
- if (!has_action('shutdown', [$this, 'processQueueOnShutdown'])) {
- add_action('shutdown', [$this, 'processQueueOnShutdown'], 100);
- }
- }
-
- /**
- * Process queue after request is complete
- */
- public function processQueueOnShutdown(): void
- {
- // Remove so it only runs once per request
- remove_action('shutdown', [$this, 'processQueueOnShutdown']);
-
- // Send response to user first if using PHP-FPM
- if (function_exists('fastcgi_finish_request')) {
- fastcgi_finish_request();
- }
-
- // Now process the queue
- $this->checkQueue();
- }
-
- /**
- * @param string $operation_id
- * @return object|false
- */
- public function getOperation(string $operation_id, bool $skipCache = false): object|false
- {
- try {
- $table = $this->wpdb->prefix . $this->table;
-
- $operation = $this->wpdb->get_row($this->wpdb->prepare(
- "SELECT * FROM $table WHERE id = %s",
- $operation_id
- ));
-
- return $operation ?: false;
- } catch (Exception) {
- return false;
- }
- }
-
- /**
- * Process queue with adaptive batch sizing
- * @return void
- */
- public function processQueue(array $operations = []): void
- {
- try {
- // If no operations passed, fetch them
- if (empty($operations)) {
- $batch_size = $this->getAdaptiveBatchSize();
- $operations = $this->getOperations($batch_size);
- }
-
- // Pre-warm cache for dependencies check
- $this->prewarmDependencyCache($operations);
-
- foreach ($operations as $operation) {
- if (!$this->canProcessOperation($operation)) {
- continue;
- }
-
- if (!$this->areDependenciesMet($operation)) {
- continue;
- }
-
- $this->processOperation($operation);
-
- // Invalidate operation cache after processing
- $this->cache->forget(self::CACHE_OPERATION_PREFIX . $operation->id);
- $this->cache->forget(self::CACHE_USER_QUEUE_PREFIX . $operation->user_id);
- }
-
- // Batch invalidate caches at the end
- $this->invalidateQueueCache();
- $this->runQueueOnShutdown();
-
- } catch (Exception $e) {
- error_log('Error processing queue: ' . $e->getMessage());
- }
- }
-
- public function cancelOperation(string $operation_id): bool
- {
- $table = $this->wpdb->prefix . $this->table;
-
- // Only cancel if pending or scheduled
- $result = $this->wpdb->update(
- $table,
- [
- 'status' => 'cancelled',
- 'completed_at' => current_time('mysql'),
- 'updated_at' => current_time('mysql'),
- 'error_message' => 'Cancelled by user'
- ],
- [
- 'id' => $operation_id,
- 'status' => ['pending', 'scheduled']
- ]
- );
-
- if ($result) {
- $this->invalidateQueueCache('status');
- return true;
- }
-
- return false;
- }
-
- /**
- * Pre-warm cache for dependency checks
- */
- protected function prewarmDependencyCache(array $operations): void
- {
- $allDependencyIds = [];
- foreach ($operations as $op) {
- $deps = json_decode($op->dependencies ?? '[]', true);
- if (!is_array($deps)) {
- $deps = [$deps];
- }
- $allDependencyIds = array_merge($allDependencyIds, $deps);
- }
-
- if (empty($allDependencyIds)) {
- return;
- }
-
- // Get all statuses at once
- $statuses = $this->getOperationStatuses(array_unique($allDependencyIds));
-
- // Cache them individually
- foreach ($statuses as $id => $status) {
- $this->cache->set(self::CACHE_OPERATION_PREFIX . $id, $status, 300);
- }
- }
-
- /**
- * Get operations with optimized query and caching
- */
- protected function getOperations(int $batch_size): array
- {
- $table = $this->wpdb->prefix . $this->table;
-
-
- $current_time = current_time('mysql');
- $operations = $this->wpdb->get_results($this->wpdb->prepare("
- SELECT * FROM $table
- WHERE (
- status = 'pending' OR
- (status = 'scheduled' AND scheduled_at <= %s)
- )
- AND retries < %d
- ORDER BY
- FIELD(priority, 'high', 'normal', 'low'),
- COALESCE(scheduled_at, created_at)
- LIMIT %d
- ", $current_time, $this->max_attempts, $batch_size));
-
- return $operations ?: [];
- }
-
- protected function canProcessOperation(object $operation): bool
- {
- if ($operation->retries >= $this->max_attempts) {
- $this->markAsPermanentlyFailed($operation->id, 'Exceeded maximum retry attempts');
- return false;
- }
- return true;
- }
-
- protected function markAsPermanentlyFailed(string $operation_id, string $reason): void
- {
-
- $table = $this->wpdb->prefix . $this->table;
-
- // Get the current operation to preserve original error
- $operation = $this->wpdb->get_row($this->wpdb->prepare(
- "SELECT error_message FROM $table WHERE id = %s",
- $operation_id
- ));
-
- // Preserve original error message, append cleanup reason
- $final_error_message = $reason;
- if (!empty($operation->error_message)) {
- $final_error_message = $operation->error_message . " | " . $reason;
- }
-
- $result = $this->wpdb->update($table, [
- 'status' => 'failed',
- 'error_message' => $final_error_message, // ✅ Preserves original error
- 'completed_at' => current_time('mysql'),
- 'updated_at' => current_time('mysql')
- ], ['id' => $operation_id]);
-
- if ($result) {
- $this->invalidateQueueCache('status');
- }
- }
-
- /**
- * Get operations for a specific user with caching
- */
- public function getUserOperations(int $user_id, array $filters = []): array
- {
- $cacheKey = self::CACHE_USER_QUEUE_PREFIX . $user_id . '_' . md5(serialize($filters));
-//
-// // Check cache first
-// $cached = $this->cache->get($cacheKey);
-// if ($cached !== false) {
-// return $cached;
-// }
-
-
- $table = $this->wpdb->prefix . $this->table;
-
- $where = ['user_id = %d'];
- $params = [$user_id];
-
- if (!empty($filters['status'])) {
- $where[] = 'status = %s';
- $params[] = $filters['status'];
- }
-
- if (!empty($filters['type'])) {
- $where[] = 'type = %s';
- $params[] = $filters['type'];
- }
-
- if (!empty($filters['merge'])) {
- $where[] = 'merge = %s';
- $params[] = $filters['merge'];
- }
-
- $limit = $filters['limit'] ?? 50;
-
- $query = "SELECT * FROM $table WHERE " . implode(' AND ', $where) .
- " ORDER BY created_at DESC LIMIT %d";
- $params[] = $limit;
-
- $operations = $this->wpdb->get_results($this->wpdb->prepare($query, ...$params));
-
- // Cache user operations
- $this->cache->set($cacheKey, $operations);
-
- return $operations ?: [];
- }
-
- public function getUserOperationStatuses(int $user_id): array
- {
- // Get user's operation IDs
- $table = $this->wpdb->prefix . $this->table;
- $operation_ids = $this->wpdb->get_col($this->wpdb->prepare(
- "SELECT id FROM $table WHERE user_id = %d ORDER BY created_at DESC LIMIT 20",
- $user_id
- ));
-
- return $this->getOperationStatuses($operation_ids);
- }
-
- /**
- * Check if user's queue has been modified since given timestamp
- * Returns true if modified, false if not (can send 304)
- */
- public function isUserQueueModified(int $user_id, int $since_timestamp): bool
- {
- return $this->cache::lastModified("user_{$user_id}") > $since_timestamp;
- }
- protected function invalidateUserQueue(int $user_id): void
- {
- // This automatically:
- // 1. Updates HTTP timestamp for user_{$user_id}
- // 2. Flushes user-specific caches
- // 3. Triggers connected cache invalidation
- Cache::for($user_id)->flush();
- }
-
- /**
- * Invalidate all queue-related caches
- */
- protected function invalidateQueueCache(string $scope = 'all'): void
- {
- $cacheKeys = [
- 'status' => [self::CACHE_QUEUE_STATUS, self::CACHE_QUEUE_SIZE],
- 'items' => [self::CACHE_HAS_ITEMS, 'queue_info'],
- 'all' => [
- self::CACHE_QUEUE_STATUS,
- self::CACHE_HAS_ITEMS,
- self::CACHE_QUEUE_SIZE,
- 'queue_info'
- ]
- ];
-
- $keys = $cacheKeys[$scope] ?? $cacheKeys['all'];
-
- foreach ($keys as $key) {
- $this->cache->forget($key);
- }
-
- $this->cache->touch();
-
- if ($scope === 'all') {
- delete_transient('jvb_queue_status_counts');
- }
- }
-
- /**
- * @return void
- */
- protected function restartStuckOperations(): void
- {
-
- $table = $this->wpdb->prefix . $this->table;
-
- // Find operations that have been processing for too long
- $stuck_operations = $this->wpdb->get_results($this->wpdb->prepare("
- SELECT * FROM $table
- WHERE status = 'processing'
- AND started_at < %s
- LIMIT 5",
- date('Y-m-d H:i:s', strtotime('-30 minutes'))
- ));
-
- foreach ($stuck_operations as $operation) {
- $retries = (int) $operation->retries + 1 ?? 1;
-
- if ($retries >= $this->max_attempts) {
- // Too many retries, mark as permanently failed
- $this->wpdb->update(
- $table,
- [
- 'status' => 'failed_permanent',
- 'error_message' => 'Operation exceeded maximum retry attempts',
- 'updated_at' => current_time('mysql'),
- 'completed_at' => current_time('mysql')
- ],
- [ 'id' => $operation->id ]
- );
-
- // Log this as a critical error
- Errors::getInstance()->log(
- '[OperationQueue]:restartStuckOperations',
- "Operation failed after maximum retries",
- [
- 'operation_id' => $operation->id,
- 'type' => $operation->type,
- 'user_id' => $operation->user_id,
- 'retries' => $retries,
- 'data' => $operation->request_data
- ],
- 'critical'
- );
-
- $this->notifyAdmin($operation);
- } else {
- // Reset to pending for another attempt with backoff
- $this->wpdb->update(
- $table,
- [
- 'status' => 'pending',
- 'updated_at' => current_time('mysql'),
- 'started_at' => null,
- 'retries' => $retries,
- ],
- [ 'id' => $operation->id ]
- );
-
- // Log the backoff
- Errors::getInstance()->log(
- '[OperationQueue]:restartStuckOperations',
- "Operation requeued with exponential backoff",
- [
- 'operation_id' => $operation->id,
- 'retry_number' => $retries,
- ],
- 'warning'
- );
- }
- }
- }
-
- /**
- * Implement intelligent retry delays with exponential backoff
- */
- public function scheduleRetry(string $operation_id, int $attempt): void
- {
- // Calculate delay with exponential backoff and jitter
- $base_delay = 5; // 5 seconds
- $max_delay = 3600; // 1 hour max
-
- $delay = min($base_delay * pow(2, $attempt), $max_delay);
-
- // Add jitter to prevent thundering herd
- $jitter = rand(0, $delay * 0.1);
- $delay += $jitter;
-
- $scheduled_at = date('Y-m-d H:i:s', time() + $delay);
-
-
- $table = $this->wpdb->prefix . $this->table;
-
- $this->wpdb->update(
- $table,
- [
- 'status' => 'scheduled',
- 'scheduled_at' => $scheduled_at,
- 'retries' => $attempt
- ],
- ['id' => $operation_id],
- ['%s', '%s', '%d'],
- ['%s']
- );
-
- }
-
- /**
- * Notify admin about repeatedly failed operation
- *
- * @param object $operation The failed operation
- *
- * @return bool
- */
- protected function notifyAdmin(object $operation):bool
- {
- $admin_email = get_option('admin_email');
- $site_name = get_bloginfo('name');
-
- $subject = "[$site_name] Queue Operation Failed After Multiple Attempts";
-
- $message = "A queue operation has failed after multiple retry attempts:\n\n";
- $message .= "Operation ID: $operation->id\n";
- $message .= "Operation Type: $operation->type\n";
- $message .= "User ID: $operation->user_id\n";
- $message .= "Created At: $operation->created_at\n";
- $message .= "Retries: $operation->retries\n\n";
-
- if (!empty($operation->error_message)) {
- $message .= "Error Message: $operation->error_message\n\n";
- }
-
- $message .= "Please check the error logs for more details.";
-
- return Emails::getInstance()->sendEmail($admin_email, $subject, $message);
- }
-
- /**
- * @param object $operation
- *
- * @return array
- */
- protected function processOperation(object $operation): array
- {
- try {
- $table = $this->wpdb->prefix . $this->table;
-
- // Mark as processing...
- $updated = $this->wpdb->query($this->wpdb->prepare(
- "UPDATE $table
- SET status = 'processing',
- started_at = %s,
- updated_at = %s
- WHERE id = %s
- AND status IN ('pending', 'scheduled')
- AND (scheduled_at IS NULL OR scheduled_at <= %s)",
- current_time('mysql'),
- current_time('mysql'),
- $operation->id,
- current_time('mysql')
- ));
-
- if (!$updated) {
- throw new Exception('Operation no longer available for processing');
- }
- $this->invalidateUserQueue($operation->user_id);
-
- $data = json_decode($operation->request_data, true);
- $progress_count = (int) $operation->progress_count;
- $count = (int) $operation->count;
- $failed_items = json_decode($operation->failed_items ?: '[]', true);
-
- // Process chunk
- $chunk = $this->getNextChunk($operation);
-
- // Apply filter - will return ['success' => bool, 'result' => mixed]
- $filterResult = apply_filters(
- BASE.'handle_bulk_operation',
- ['success' => false, 'result' => 'No handler for operation item'], // Default if no filter
- $operation,
- $chunk
- );
-
- // Ensure we have the expected format
- if (!is_array($filterResult) || !isset($filterResult['success'])) {
- // Log invalid filter response and quit early
- $invalid_response = is_object($filterResult) ? get_class($filterResult) : gettype($filterResult);
- Errors::getInstance()->log(
- '[OperationQueue]:processOperation',
- "Invalid filter response format - expected array with 'success' key",
- [
- 'operation_id' => $operation->id,
- 'operation_type' => $operation->type,
- 'filter_response_type' => $invalid_response,
- 'filter_response' => $filterResult
- ],
- 'error'
- );
-
- // Mark operation as failed
- $this->wpdb->update(
- $table,
- [
- 'status' => 'failed',
- 'error_message' => "Invalid filter response: expected array with 'success' key, got {$invalid_response}",
- 'result' => json_encode([
- 'success' => false,
- 'error' => "Invalid filter response format"
- ]),
- 'updated_at' => current_time('mysql'),
- 'completed_at' => current_time('mysql')
- ],
- ['id' => $operation->id]
- );
-
- $this->invalidateQueueCache('status');
-
- return [
- 'success' => false,
- 'result' => "Invalid filter response format"
- ];
- }
-
- // Additional validation for WP_Error handling
- if (is_wp_error($filterResult)) {
- $filterResult = [
- 'success' => false,
- 'result' => $filterResult->get_error_message()
- ];
- }
-
- $newCount = $progress_count + $chunk['progress'];
-
- if ($filterResult['success']) {
- // Success path
- $oldResult = json_decode($operation->result ?: '{}', true);
- if (!is_array($oldResult)) {
- $oldResult = [];
- }
-
- if (!is_array($filterResult['result'])) {
- $filterResult['result'] = [$filterResult['result']];
- }
- // Store the result data
- $resultToStore = $this->deepMerge($oldResult, $filterResult['result']);
- $resultToStore['processed_at'] = current_time('mysql');
-
- // Check if operation is complete
- if ($newCount >= $count) {
- $status = empty($failed_items) ? 'completed' : 'completed_with_errors';
-
- $result = $this->wpdb->update(
- $table,
- [
- 'progress_count' => $newCount,
- 'result' => json_encode($resultToStore),
- 'status' => $status,
- 'completed_at' => current_time('mysql'),
- 'updated_at' => current_time('mysql')
- ],
- ['id' => $operation->id],
- ['%d', '%s', '%s', '%s', '%s'],
- ['%s']
- );
-
- // Now do post-completion tasks
- $this->invalidateUserQueue($operation->user_id);
-
- $this->trackOperationMetrics($operation->id);
-
- } else {
- // More work to do - just update progress and result
- $this->wpdb->update(
- $table,
- [
- 'progress_count' => $newCount,
- 'result' => json_encode($resultToStore),
- 'status' => 'pending', // Back to pending for next chunk
- 'updated_at' => current_time('mysql')
- ],
- ['id' => $operation->id],
- ['%d', '%s', '%s', '%s'],
- ['%s']
- );
-
- $this->invalidateUserQueue($operation->user_id);
- }
-
- } else {
- // Error path
- $error_message = is_string($filterResult['result'])
- ? $filterResult['result']
- : json_encode($filterResult['result']);
-
- $error_context = [
- 'index' => $progress_count,
- 'error' => $error_message,
- 'timestamp' => current_time('mysql'),
- 'retry_count' => $operation->retries,
- 'type' => $operation->type
- ];
-
- $failed_items[] = $error_context;
-
- if ($newCount >= $count) {
- // Failed but complete
- $this->wpdb->update(
- $table,
- [
- 'failed_items' => json_encode($failed_items),
- 'progress_count' => $newCount,
- 'result' => json_encode([
- 'success' => false,
- 'error' => $error_message,
- 'failed_items' => $failed_items
- ]),
- 'status' => 'failed',
- 'error_message' => $error_message,
- 'completed_at' => current_time('mysql'),
- 'updated_at' => current_time('mysql')
- ],
- ['id' => $operation->id],
- ['%s', '%d', '%s', '%s', '%s', '%s', '%s'],
- ['%s']
- );
-
- $this->invalidateUserQueue($operation->user_id);
- } else {
- // Failed but more to process - continue with next chunk
- $this->wpdb->update(
- $table,
- [
- 'failed_items' => json_encode($failed_items),
- 'progress_count' => $newCount,
- 'status' => 'pending',
- 'error_message' => $error_message,
- 'updated_at' => current_time('mysql')
- ],
- ['id' => $operation->id],
- ['%s', '%d', '%s', '%s', '%s'],
- ['%s']
- );
-
- }
- }
- // Clear operation cache after any update
- $this->invalidateUserQueue($operation->user_id);
- return $filterResult;
-
- } catch (Exception $e) {
- // Handle exception...
- Errors::getInstance()->log(
- '[OperationQueue]:processOperation',
- "Exception during operation processing: " . $e->getMessage(),
- [
- 'operation_id' => $operation->id,
- 'type' => $operation->type,
- 'progress_count' => $progress_count ?? 0,
- 'user_id' => $operation->user_id,
- 'file' => $e->getFile(),
- 'line' => $e->getLine(),
- 'trace' => $e->getTraceAsString()
- ],
- 'error'
- );
-
- // Mark operation as failed
- $this->wpdb->update(
- $table,
- [
- 'status' => 'failed',
- 'error_message' => $e->getMessage(),
- 'result' => json_encode([
- 'success' => false,
- 'error' => $e->getMessage()
- ]),
- 'updated_at' => current_time('mysql'),
- 'completed_at' => current_time('mysql')
- ],
- ['id' => $operation->id]
- );
-
- $this->invalidateQueueCache('status');
-
-
- return [
- 'success' => false,
- 'result' => $e->getMessage()
- ];
- }
- }
-
- /**
- * Extract X amount of items from the operation data to process, based on complexity
- *
- * @param object $operation The current operation
- *
- * @return array false if not batched, the extracted data to process if batchable
- */
- protected function getNextChunk(object $operation): array
- {
- $data = json_decode($operation->request_data, true);
-
- // Get chunk config from metadata
- $metadata = json_decode($operation->metadata ?? '{}', true);
- $chunk_config = $metadata['chunk_config'] ?? null;
-
- // If no chunk config, return all data with progress of 1
- if (!$chunk_config) {
- return array_merge($data, ['progress' => 1]);
- }
-
- $chunk_key = $chunk_config['key'];
- $chunk_size = $chunk_config['size'];
- $current_progress = (int)$operation->progress_count;
-
- // Handle single or multiple keys uniformly
- $keys = (array)$chunk_key;
- $batch = $this->extractBatch($data, $keys, $current_progress, $chunk_size);
-
- // Merge non-chunked data with chunked batch
- $result = array_filter($data, function ($k) use ($keys) {
- return !in_array($k, $keys);
- }, ARRAY_FILTER_USE_KEY);
-
- // Add the batched data
- foreach ($batch as $k => $v) {
- $result[$k] = $v;
- }
-
- return $result;
- }
-
- protected function extractBatch(array $data, array $keys, int $currentProgress, int $batchSize): array
- {
- $result = ['progress' => 0];
- $itemsExtracted = 0;
-
- // Calculate total items across all keys
- $totalItems = 0;
- $keyData = [];
-
- foreach ($keys as $key) {
- if (isset($data[$key]) && is_array($data[$key])) {
- $keyData[$key] = $data[$key];
- $totalItems += count($data[$key]);
- }
- }
-
- // If current progress >= total, we're done
- if ($currentProgress >= $totalItems) {
- return ['progress' => 0];
- }
-
- // Extract items starting from currentProgress
- $globalIndex = 0;
-
- foreach ($keyData as $key => $items) {
- $keyItemCount = count($items);
-
- // Skip this key if we haven't reached it yet
- if ($globalIndex + $keyItemCount <= $currentProgress) {
- $globalIndex += $keyItemCount;
- continue;
- }
-
- // Calculate start position within this key
- $startInKey = max(0, $currentProgress - $globalIndex);
- $remainingInBatch = $batchSize - $itemsExtracted;
-
- // Extract what we can from this key
- $extracted = array_slice($items, $startInKey, $remainingInBatch, true);
-
- if (!empty($extracted)) {
- $result[$key] = $extracted;
- $itemsExtracted += count($extracted);
- $result['progress'] += count($extracted);
- }
-
- // Stop if we've filled the batch
- if ($itemsExtracted >= $batchSize) {
- break;
- }
-
- $globalIndex += $keyItemCount;
- }
-
- return $result;
- }
-
-
- protected function updateUserQueueTimestamp(int $user_id)
- {
-
- Cache::touch("user_{$user_id}");
- }
-
- /**
- * Track operation metrics with proper success checking
- * @param string $operationId
- * @return void
- */
- protected function trackOperationMetrics(string $operationId): void
- {
- $metrics_table = $this->wpdb->prefix . $this->metricsTable;
- $operation = $this->getOperation($operationId, true);
- $today = date('Y-m-d');
-
- // Calculate duration if available
- $duration = null;
- if (!empty($operation->started_at) && !empty($operation->completed_at)) {
- $started = strtotime($operation->started_at);
- $completed = strtotime($operation->completed_at);
- $duration = $completed - $started;
- }
-
- // Determine success status from the result JSON
- $success = false;
- if (!empty($operation->result)) {
- $result = json_decode($operation->result, true);
- if (is_array($result) && isset($result['success'])) {
- $success = (bool) $result['success'];
- } else {
- // Fallback: check status field
- $success = ($operation->status === 'completed' || $operation->status === 'completed_with_errors');
- }
- }
-
- // Get or create today's record for this operation type
- $existing = $this->wpdb->get_row($this->wpdb->prepare(
- "SELECT * FROM $metrics_table
- WHERE date = %s AND type = %s",
- $today,
- $operation->type
- ));
-
- if ($existing) {
- // Update existing record
- $this->wpdb->update(
- $metrics_table,
- [
- 'total_operations' => $existing->total_operations + 1,
- 'successful_operations' => $existing->successful_operations + ($success ? 1 : 0),
- 'failed_operations' => $existing->failed_operations + ($success ? 0 : 1),
- 'average_duration' => $duration ?
- (($existing->average_duration * $existing->total_operations) + $duration) /
- ($existing->total_operations + 1) :
- $existing->average_duration,
- 'total_items_processed' => $existing->total_items_processed + $operation->count,
- 'peak_queue_size' => max($existing->peak_queue_size, $this->getCurrentQueueSize()),
- 'peak_memory_usage' => max($existing->peak_memory_usage, memory_get_peak_usage(true)),
- 'peak_cpu_usage' => function_exists('sys_getloadavg') ?
- max($existing->peak_cpu_usage, sys_getloadavg()[0]) :
- $existing->peak_cpu_usage
- ],
- ['id' => $existing->id]
- );
- } else {
- // Create new record
- $this->wpdb->insert(
- $metrics_table,
- [
- 'date' => $today,
- 'type' => $operation->type,
- 'total_operations' => 1,
- 'successful_operations' => $success ? 1 : 0,
- 'failed_operations' => $success ? 0 : 1,
- 'average_duration' => $duration,
- 'total_items_processed' => $operation->count,
- 'peak_queue_size' => $this->getCurrentQueueSize(),
- 'peak_memory_usage' => memory_get_peak_usage(true),
- 'peak_cpu_usage' => function_exists('sys_getloadavg') ? sys_getloadavg()[0] : null
- ]
- );
- }
- }
-
- /**
- * Helper method to check if an operation was successful
- * @param object|array $operation Operation object or decoded result array
- * @return bool
- */
- public function isOperationSuccessful($operation): bool
- {
- if (is_object($operation)) {
- // Check the result field
- if (!empty($operation->result)) {
- $result = json_decode($operation->result, true);
- if (is_array($result) && isset($result['success'])) {
- return (bool) $result['success'];
- }
- }
- // Fallback to status field
- return in_array($operation->status, ['completed', 'completed_with_errors']);
- }
-
- if (is_array($operation)) {
- // Direct result array
- return isset($operation['success']) && $operation['success'];
- }
-
- return false;
- }
-
- /**
- * Get current size of queue
- * @return int
- */
- /**
- * Get current queue size with caching
- */
- protected function getCurrentQueueSize(): int
- {
- return $this->getQueueInfo()['total'];
- }
-
- /**
- * Send daily metrics report to admin
- * @return void
- */
- public function emailDailyMetricsReport():void
- {
- $metrics_table = $this->wpdb->prefix . $this->metricsTable;
- $yesterday = date('Y-m-d', strtotime('-1 day'));
-
- $metrics = $this->wpdb->get_results($this->wpdb->prepare(
- "SELECT * FROM $metrics_table WHERE date = %s",
- $yesterday
- ));
-
- if (empty($metrics)) {
- return;
- }
-
- $admin_email = get_option('admin_email');
- $site_name = get_bloginfo('name');
- $subject = "[$site_name] Daily Queue Performance - " . $yesterday;
-
- // Calculate totals
- $total_ops = 0;
- $total_success = 0;
- $total_failed = 0;
- $total_items = 0;
-
- foreach ($metrics as $metric) {
- $total_ops += $metric->total_operations;
- $total_success += $metric->successful_operations;
- $total_failed += $metric->failed_operations;
- $total_items += $metric->total_items_processed;
- }
-
- $success_rate = round(($total_success / max(1, $total_ops)) * 100, 1);
-
- $message = Emails::getInstance()->h1('Daily Queue Performance Report');
- $message .= sprintf('<p>Report for <strong>%s</strong></p>', $yesterday);
-
- // Summary stats in grid
- $stats = [
- Emails::getInstance()->stat($total_ops, 'Operations'),
- Emails::getInstance()->stat($total_success, 'Successful', '✓'),
- Emails::getInstance()->stat($total_failed, 'Failed', $total_failed > 0 ? '⚠' : ''),
- Emails::getInstance()->stat($success_rate . '%', 'Success Rate')
- ];
- $message .= Emails::getInstance()->grid($stats, 4);
-
- $message .= Emails::getInstance()->spacer(20);
-
- // Alert if success rate is low
- if ($success_rate < 90) {
- $message .= Emails::getInstance()->alert(
- sprintf('Success rate of %s%% is below the 90%% threshold', $success_rate),
- 'warning'
- );
- }
-
- $message .= Emails::getInstance()->h2('Details by Operation Type');
-
- // Details for each operation type
- foreach ($metrics as $metric) {
- $details = [];
- $details[] = ['label' => 'Total', 'value' => $metric->total_operations];
- $details[] = ['label' => 'Success', 'value' => Emails::getInstance()->badge($metric->successful_operations, 'success')];
- $details[] = ['label' => 'Failed', 'value' => $metric->failed_operations > 0 ? Emails::getInstance()->badge($metric->failed_operations, 'error') : '0'];
-
- if ($metric->average_duration) {
- $details[] = ['label' => 'Avg Duration', 'value' => round($metric->average_duration, 2) . 's'];
- }
-
- $details[] = ['label' => 'Items Processed', 'value' => number_format($metric->total_items_processed)];
-
- if ($metric->peak_memory_usage) {
- $memory_mb = round($metric->peak_memory_usage / 1024 / 1024, 2);
- $details[] = ['label' => 'Peak Memory', 'value' => $memory_mb . ' MB'];
- }
-
- $message .= Emails::getInstance()->card(
- Emails::getInstance()->table($details),
- esc_html($metric->type)
- );
- }
-
- // Current queue status
- $pending_count = $this->getCurrentQueueSize();
- if ($pending_count > 0) {
- $message .= Emails::getInstance()->spacer(20);
- $message .= Emails::getInstance()->notice(
- sprintf('<strong>Current Queue:</strong> %d operations pending', $pending_count)
- );
- }
-
- $message .= Emails::getInstance()->spacer(20);
- $message .= Emails::getInstance()->button(admin_url('admin.php?page=jvb-queue'), 'View Queue Dashboard');
-
- Emails::getInstance()->sendEmail($admin_email, $subject, $message, 'QUEUE REPORT');
- }
-
- /**
- * @return int
- */
- protected function getCPUUsage():int
- {
- $cpu = @sys_getloadavg();
- return $cpu ? ( $cpu[0] * 100 / 2 ) : 0; // Normalize for 2 cores
- }
-
- /**
- * @return int
- */
- protected function getMemoryUsage():int
- {
- $memory_usage = memory_get_usage(true);
- $memory_limit = $this->getMemoryLimitBytes();
-
- return (int)( $memory_usage / $memory_limit ) * 100;
- }
-
- /**
- * @return int
- */
- protected function getMemoryLimitBytes():int
- {
- $memory_limit = ini_get('memory_limit');
- if (preg_match('/^(\d+)(.)$/', $memory_limit, $matches)) {
- $limit = $matches[1];
- $unit = strtolower($matches[2]);
- switch ($unit) {
- case 'g':
- $limit *= 1024;
- case 'm':
- $limit *= 1024;
- case 'k':
- $limit *= 1024;
- }
-
- return $limit;
- }
-
- return 134217728; // 128MB default
- }
-
- /**
- * @return array
- */
- /**
- * Get queue status with scheduled operations
- */
- /**
- * Get queue status with caching
- */
- public function getQueueStatus(): array
- {
- // Try cache first
- $cached = $this->cache->get(self::CACHE_QUEUE_STATUS);
- if ($cached !== false) {
- return $cached;
- }
-
- // If not cached, fetch from database
-
- $table = $this->wpdb->prefix . $this->table;
- $current_time = current_time('mysql');
-
- // Get counts by status with single optimized query
- $status_counts = $this->wpdb->get_results("
- SELECT
- status,
- COUNT(*) as count,
- SUM(IF(status = 'scheduled' AND scheduled_at <= '$current_time', 1, 0)) as scheduled_ready
- FROM $table
- GROUP BY status
- ", OBJECT_K);
-
- $result = [
- 'pending' => $status_counts['pending']->count ?? 0,
- 'scheduled' => $status_counts['scheduled']->count ?? 0,
- 'scheduled_ready' => $status_counts['scheduled']->scheduled_ready ?? 0,
- 'processing' => $status_counts['processing']->count ?? 0,
- 'completed' => $status_counts['completed']->count ?? 0,
- 'failed' => $status_counts['failed']->count ?? 0,
- 'total' => array_sum(array_column($status_counts, 'count'))
- ];
-
- // Cache the result
- $this->cache->set(self::CACHE_QUEUE_STATUS, $result);
-
- return $result;
- }
-
- /**
- * Calculate server load factor
- *
- * @return float Load factor (1.0 = normal, >1.0 = high load)
- * @return float
- */
- private function calculateServerLoadFactor():float
- {
- // Get memory usage percentage
- $memory_usage = memory_get_usage(true) / $this->getMemoryLimitBytes();
-
- // Get CPU load if available
- if (function_exists('sys_getloadavg')) {
- $load = sys_getloadavg();
- $cpu_cores = 2; // Adjust for your server
- $load_factor = $load[0] / $cpu_cores;
- } else {
- $load_factor = 1.0; // Default if not available
- }
-
- // Calculate combined factor
- return ( $load_factor * 0.7 ) + ( $memory_usage * 0.3 );
- }
-
- /**
- * Dynamically adjust batch size based on server load
- * @return float
- */
- protected function getAdaptiveBatchSize():float
- {
- $load_factor = $this->calculateServerLoadFactor();
-
- if ($load_factor > 1.5) {
- // Server is under heavy load
- return max(5, floor($this->maxPerBatch / 5));
- } elseif ($load_factor > 1.0) {
- // Moderate load
- return max(10, floor($this->maxPerBatch / 2));
- } else {
- // Normal load
- return $this->maxPerBatch;
- }
- }
-
- /**
- * Get a specific column value from an operation by ID
- *
- * @param string $operation_id The ID of the operation
- * @param string $column The column name to retrieve (default: 'result')
- * @param bool $decode_json Whether to JSON decode the retrieved value (default: true)
- * @param string|null $status Filter by operation status (default: null - any status)
- *
- * @return mixed|false The requested value or false if not found
- */
- public function getOperationValue(string $operation_id, string $column, bool $decode_json = true, string|null $status = null):mixed
- {
- try {
-
- $table = $this->wpdb->prefix . $this->table;
-
- // Prepare the query with status filter if provided
- $sql = "SELECT {$this->wpdb->_escape($column)} FROM $table WHERE id = %s";
- $params = [ $operation_id ];
-
- // Get the column value
- $value = $this->wpdb->get_var($this->wpdb->prepare($sql, $params));
-
- if ($value === null) {
- return false;
- }
-
- // Decode JSON if requested and column appears to be JSON
- if ($decode_json && $value && (str_starts_with(trim($value), '{') || str_starts_with(trim($value), '['))) {
- $decoded = json_decode($value, true);
- // Return the decoded value only if decode was successful
- if (json_last_error() === JSON_ERROR_NONE) {
- return $decoded;
- }
- }
-
- return $value;
- } catch (Exception $e) {
- Errors::getInstance()->log(
- '[OperationQueue]:getOperationValue',
- 'Error retrieving operation value: ' . $e->getMessage(),
- [
- 'operation_id' => $operation_id,
- 'column' => $column
- ],
- 'error'
- );
-
- return false;
- }
- }
-
- /**
- * Get average processing time for an operation type
- *
- * @param string $type The type of operation
- *
- * @return float Average seconds per operation
- */
- private function getAverageProcessingTime(string $type):float
- {
- $stats = get_option(BASE . 'average_operation_time', []);
- //Return stored stat, if available
- if (!empty($stats) && array_key_exists($type, $stats)) {
- return (float) $stats[$type];
- }
- //Build statistics if not available
- $stats[$type] = $this->buildAverageProcessingTime($type);
-
- return $stats[$type];
- }
-
- /**
- * @return array
- */
- private function getAllAverageProcessingTimes():array
- {
- return get_option(BASE . 'average_operation_time', []);
- }
-
- /**
- * @param string $type
- *
- * @return float
- */
- private function buildAverageProcessingTime(string $type): float
- {
- // Get existing stats from options table
- $stats = get_option(BASE . 'average_operation_time', []);
-
- // If there's already a saved value for this operation type, return it
- if (isset($stats[$type]) && is_numeric($stats[$type])) {
- return (float)$stats[$type];
- }
-
- // Query the database for average processing time
-
- $table = $this->wpdb->prefix . $this->table;
-
- $avgTime = $this->wpdb->get_var(
- $this->wpdb->prepare(
- "SELECT AVG(TIMESTAMPDIFF(SECOND, started_at, completed_at) / count)
- FROM {$table}
- WHERE type = %s
- AND status = 'completed'",
- $type
- )
- );
-
- // If query returns null or 0, use default values based on operation type
- if ($avgTime === null || $avgTime === false || (float)$avgTime <= 0) {
- // Default times for different operation types
- $defaultTimes = [
- 'email_notification_digest' => 20.0,
- 'image_upload' => 10.0,
- 'image_processing' => 5.0,
- 'batch_upload' => 10.0,
- 'artist_bulk_approval' => 15.0,
- 'taxonomy_relationships' => 60.0,
- 'rebuild_user_term_index' => 10.0,
- 'bio_update' => 12.0,
- 'featured_image' => 15.0,
- 'content_update' => 8.0,
- 'batch_creation' => 20.0,
- 'favourites_batch' => 10.0,
- 'favourite_list_add' => 5.0,
- 'favourite_list_remove' => 5.0,
- 'notification_mark_all_as_read' => 15.0,
- 'sync_content_taxonomy_tables' => 20.0,
- 'temporary_cleanup' => 10.0,
- ];
-
- // Use type-specific default or general default
- $avgTime = (array_key_exists($type, $defaultTimes)) ? $defaultTimes[$type] : 3.0;
- }
-
- // Ensure it's a float and save it
- $avgTime = (float)$avgTime;
- $stats[$type] = $avgTime;
- update_option(BASE . 'average_operation_time', $stats);
-
- return $avgTime;
- }
-
- public function cleanupStuckOperations(): void
- {
-
- $table = $this->wpdb->prefix . $this->table;
-
- // Find operations that exceeded retry limits but weren't marked as failed
- $stuck_operations = $this->wpdb->get_results($this->wpdb->prepare("
- SELECT id, type, retries FROM $table
- WHERE retries >= %d
- AND status NOT IN ('failed', 'completed')
- ", $this->max_attempts));
-
- if (!empty($stuck_operations)) {
- foreach ($stuck_operations as $operation) {
- $this->markAsPermanentlyFailed(
- $operation->id,
- "Cleanup: Operation exceeded {$this->max_attempts} attempts (had {$operation->retries})"
- );
- }
- }
-
- // Also find operations stuck in processing for too long
- $long_processing = $this->wpdb->get_results($this->wpdb->prepare("
- SELECT id, type, started_at FROM $table
- WHERE status = 'processing'
- AND started_at < %s
- ", date('Y-m-d H:i:s', strtotime('-1 hour'))));
-
- if (!empty($long_processing)) {
- foreach ($long_processing as $operation) {
- $this->wpdb->update($table, [
- 'status' => 'pending',
- 'started_at' => null,
- 'retries' => $this->wpdb->get_var($this->wpdb->prepare(
- "SELECT retries FROM $table WHERE id = %s",
- $operation->id
- )) + 1,
- 'updated_at' => current_time('mysql')
- ], ['id' => $operation->id]);
- }
- }
- }
-
- public function renderAdminPage(): void
- {
- $status = $this->getQueueStatus();
- $health = $this->getQueueHealthReport();
- ?>
- <div class="wrap">
- <h1>Operation Queue Status</h1>
-
- <div class="card">
- <h2>Current Status</h2>
- <ul>
- <li>Queue is: <?= ($this->isQueueLocked()) ? 'locked' : 'unlocked'?>.</li>
- <li>Queue: <?= ($this->hasItemsInQueue()) ? 'has items in queue' : 'has nothing in queue'?>.</li>
- <li>Pending: <?php echo $status['pending']; ?></li>
- <li>Processing: <?php echo $status['processing']; ?></li>
- <li>Scheduled: <?php echo $status['scheduled']; ?>
- (<?php echo $status['scheduled_ready']; ?> ready)</li>
- <li>Completed: <?php echo $status['completed']; ?></li>
- <li>Failed: <?php echo $status['failed']; ?></li>
- </ul>
- </div>
-
- <?php if (!empty($health['stuck_operations'])): ?>
- <div class="card">
- <h2>⚠️ Stuck Operations</h2>
- <table class="wp-list-table widefat fixed striped">
- <thead>
- <tr>
- <th>ID</th>
- <th>Type</th>
- <th>Status</th>
- <th>Age (minutes)</th>
- <th>Error</th>
- </tr>
- </thead>
- <tbody>
- <?php foreach ($health['stuck_operations'] as $op): ?>
- <tr>
- <td><?php echo esc_html($op['id']); ?></td>
- <td><?php echo esc_html($op['type']); ?></td>
- <td><?php echo esc_html($op['status']); ?></td>
- <td><?php echo esc_html($op['age_minutes']); ?></td>
- <td><?php echo esc_html(substr($op['error_message'] ?? '', 0, 50)); ?></td>
- </tr>
- <?php endforeach; ?>
- </tbody>
- </table>
- </div>
- <?php endif; ?>
- </div>
- <?php
- }
-
- function getQueueHealthReport(): array
- {
-
- $table = $this->wpdb->prefix . BASE . '_operation_queue';
-
- $report = [
- 'status_counts' => [],
- 'stuck_operations' => [],
- 'error_patterns' => [],
- 'performance_stats' => []
- ];
-
- // Status counts
- $status_counts = $this->wpdb->get_results("
- SELECT status, COUNT(*) as count, AVG(retries) as avg_retries
- FROM $table
- GROUP BY status
- ", OBJECT_K);
-
- foreach ($status_counts as $status => $data) {
- $report['status_counts'][$status] = [
- 'count' => intval($data->count),
- 'avg_retries' => round(floatval($data->avg_retries), 2)
- ];
- }
-
- // Stuck operations
- $report['stuck_operations'] = $this->wpdb->get_results("
- SELECT id, type, status, retries,
- TIMESTAMPDIFF(MINUTE, created_at, NOW()) as age_minutes,
- error_message
- FROM $table
- WHERE (
- retries >= 3 OR
- (status = 'processing' AND started_at < DATE_SUB(NOW(), INTERVAL 30 MINUTE)) OR
- (status = 'pending' AND created_at < DATE_SUB(NOW(), INTERVAL 2 HOUR))
- )
- ORDER BY created_at DESC
- LIMIT 10
- ", ARRAY_A);
-
- // Error patterns
- $report['error_patterns'] = $this->wpdb->get_results("
- SELECT
- SUBSTRING(error_message, 1, 100) as error_pattern,
- COUNT(*) as frequency,
- type
- FROM $table
- WHERE error_message IS NOT NULL
- AND updated_at > DATE_SUB(NOW(), INTERVAL 24 HOUR)
- GROUP BY type, SUBSTRING(error_message, 1, 100)
- HAVING frequency > 1
- ORDER BY frequency DESC
- LIMIT 5
- ", ARRAY_A);
-
- return $report;
- }
-
- protected function archiveToMetrics(int $days_to_keep): void
- {
-
- $table = $this->wpdb->prefix . $this->table;
- $metricsTable = $this->wpdb->prefix . $this->metricsTable;
-
- // Aggregate and insert metrics
- $this->wpdb->query($this->wpdb->prepare("
- INSERT INTO $metricsTable
- (date, type, total_operations, successful_operations,
- failed_operations, average_duration, total_items_processed)
- SELECT
- DATE(completed_at) as date,
- type,
- COUNT(*) as total_operations,
- SUM(IF(status = 'completed', 1, 0)) as successful,
- SUM(IF(status LIKE 'failed%', 1, 0)) as failed,
- AVG(TIMESTAMPDIFF(SECOND, created_at, completed_at)) as avg_duration,
- SUM(count) as items_processed
- FROM $table
- WHERE completed_at < DATE_SUB(NOW(), INTERVAL %d DAY)
- AND completed_at >= DATE_SUB(NOW(), INTERVAL %d DAY)
- GROUP BY DATE(completed_at), type
- ON DUPLICATE KEY UPDATE
- total_operations = VALUES(total_operations),
- successful_operations = VALUES(successful_operations),
- failed_operations = VALUES(failed_operations),
- average_duration = VALUES(average_duration),
- total_items_processed = VALUES(total_items_processed)
- ", $days_to_keep, $days_to_keep + 1));
- }
-}
diff --git a/inc/managers/Responses.php b/inc/managers/Responses.php
index 3609d1e..c3051af 100644
--- a/inc/managers/Responses.php
+++ b/inc/managers/Responses.php
@@ -1,6 +1,7 @@
<?php
namespace JVBase\managers;
+use JVBase\inc\managers\ErrorHelper;
use JVBase\Instance;
use JVBase\registrar\Registrar;
use JVBase\rest\routes\ResponseRoutes;
@@ -16,7 +17,7 @@
*/
class Responses
{
- use Instance;
+ use Instance, ErrorHelper;
protected CustomTable $responses;
protected KarmaManager $karma;
protected ResponseRoutes $route;
@@ -378,16 +379,11 @@
], [
'user_id' => $user_id,
]);
-
- Errors::getInstance()->log(
- 'Responses',
- 'Anonymized responses for deleted user',
+ $this->logError('Anonymized responses for deleted user','handleUserDeletion',
[
'user_id' => $user_id,
'count' => $updated,
- ],
- 'info'
- );
+ ]);
}
protected function getNotifyableUsers(int $id):array
diff --git a/inc/managers/UploadManager.php b/inc/managers/UploadManager.php
index 4889dbc..bb39c39 100644
--- a/inc/managers/UploadManager.php
+++ b/inc/managers/UploadManager.php
@@ -2,6 +2,7 @@
namespace JVBase\managers;
use JVBase\base\Site;
+use JVBase\inc\managers\ErrorHelper;
use JVBase\Instance;
use Exception;
use Imagick;
@@ -41,7 +42,7 @@
*/
class UploadManager
{
- use Instance;
+ use Instance, ErrorHelper;
protected array $allowedTypes = [
// Images
'image/jpeg' => 'image',
@@ -310,7 +311,7 @@
'stored_at' => current_time('mysql')
];
} catch (Exception $e) {
- Errors::getInstance()->log('UploadManager','Failed to store uploaded file: ' . print_r($e->getMessage(), true));
+ $this->logError('Failed to store uploaded file', 'secureUploadedFile', ['error' => $e->getMessage()]);
return [];
}
}
@@ -350,11 +351,7 @@
};
} catch (Exception $e) {
- Errors::getInstance()->log(
- '[UploadManager]:processUpload',
- $e->getMessage(),
- ['temp_path' => $temp_path, 'context' => $context]
- );
+ $this->logError($e->getMessage(), 'processUpload');
return new WP_Error('upload_failed', $e->getMessage());
}
@@ -548,7 +545,7 @@
);
} catch (Exception $e) {
- Errors::getInstance()->log('Uploader', 'Image conversion failed: ' . print_r($e->getMessage(), true));
+ $this->logError($e->getMessage(), 'processImage');
// Fallback to original
$ext = pathinfo($original_path, PATHINFO_EXTENSION);
$final_path = "{$full_dir}/{$filename}.{$ext}";
@@ -604,7 +601,7 @@
try {
$attachment_id = $this->createAttachment($final_path, $filename, $context, $post_id);
} catch (Exception $e) {
- Errors::getInstance()->log('Uploader', 'Attachment creation failed: '.print_r($e->getMessage(), true));
+ $this->logError($e->getMessage(), 'processVideo');
return ['success' => false];
}
@@ -642,7 +639,7 @@
try {
$attachment_id = $this->createAttachment($final_path, $filename, $context, $post_id);
} catch (Exception $e) {
- Errors::getInstance()->log('Uploader','Conversion failed: '.print_r($e->getMessage(), true));
+ $this->logError($e->getMessage(), 'processDocument');
return ['success' => false];
}
diff --git a/inc/managers/UserTermsManager.php b/inc/managers/UserTermsManager.php
index c3a02e8..f2b0b15 100644
--- a/inc/managers/UserTermsManager.php
+++ b/inc/managers/UserTermsManager.php
@@ -1,6 +1,7 @@
<?php
namespace JVBase\managers;
+use JVBase\inc\managers\ErrorHelper;
use JVBase\Instance;
use JVBase\managers\queue\Queue;
use JVBase\registrar\Registrar;
@@ -13,7 +14,7 @@
}
class UserTermsManager
{
- use Instance;
+ use Instance, ErrorHelper;
protected CustomTable $index;
private function __construct()
@@ -317,13 +318,7 @@
'result' => $results
];
} catch (Exception $e) {
- Errors::getInstance()->log(
- '[UserTermsManager]:processOperation',
- "Exception during operation processing: " . $e->getMessage(),
- [
- 'operation' => $operation->id,
- ]
- );
+ $this->logError($e->getMessage(), 'processOperation');
return [
'success' => false,
'result' => $e->getMessage()
diff --git a/inc/managers/_setup.php b/inc/managers/_setup.php
index baeb21d..ed9aa30 100644
--- a/inc/managers/_setup.php
+++ b/inc/managers/_setup.php
@@ -6,7 +6,6 @@
use JVBase\registrar\Registrar;
use JVBase\base\Site;
require(JVB_DIR . '/inc/managers/ScriptLoader.php');
-require(JVB_DIR . '/inc/managers/Errors.php');
require(JVB_DIR . '/inc/managers/CustomTable.php');
//require(JVB_DIR . '/inc/managers/CacheManager.php');
require(JVB_DIR . '/inc/managers/Cache.php');
diff --git a/inc/managers/queue/Queue.php b/inc/managers/queue/Queue.php
index 01bcb31..86ba4f9 100644
--- a/inc/managers/queue/Queue.php
+++ b/inc/managers/queue/Queue.php
@@ -5,6 +5,7 @@
}
use Exception;
+use JVBase\inc\managers\ErrorHelper;
use JVBase\Instance;
use JVBase\managers\AdminPages;
use JVBase\managers\Errors;
@@ -16,7 +17,7 @@
class Queue
{
- use Instance;
+ use Instance, ErrorHelper;
private Storage $storage;
private Processor $processor;
private TypeRegistry $registry;
@@ -109,7 +110,8 @@
];
} catch (Exception $e) {
- Errors::getInstance()->log('queue', $e->getMessage(), $data, 'high');
+ $this->logError($e->getMessage(), 'add', $data);
+
return new WP_Error('queue_failed', $e->getMessage());
}
}
diff --git a/inc/managers/queue/executors/ContentExecutor.php b/inc/managers/queue/executors/ContentExecutor.php
index 562a004..5d07be0 100644
--- a/inc/managers/queue/executors/ContentExecutor.php
+++ b/inc/managers/queue/executors/ContentExecutor.php
@@ -3,6 +3,7 @@
use DateTime;
use JVBase\managers\queue\{Executor, Operation, Progress, Result};
+use JVBase\inc\managers\ErrorHelper;
use JVBase\managers\Errors;
use JVBase\managers\Roles;
use JVBase\meta\Meta;
@@ -20,6 +21,7 @@
*/
final class ContentExecutor implements Executor
{
+ use ErrorHelper;
private const HANDLED_TYPES = [
'content_update',
// 'batch_creation',
@@ -45,15 +47,11 @@
};
} catch (Exception $e) {
- Errors::getInstance()->log(
- '[ContentExecutor]:execute',
- $e->getMessage(),
- [
- 'operation_id' => $operation->id,
- 'operation_type' => $operation->type,
- 'user_id' => $operation->userId,
- ]
- );
+ $this->logError($e->getMessage(), 'execute', [
+ 'operation_id' => $operation->id,
+ 'operation_type' => $operation->type,
+ 'user_id' => $operation->userId,
+ ]);
return new Result(
outcome: 'failed',
diff --git a/inc/managers/queue/executors/IntegrationExecutor.php b/inc/managers/queue/executors/IntegrationExecutor.php
index 6a85296..a44a4d0 100644
--- a/inc/managers/queue/executors/IntegrationExecutor.php
+++ b/inc/managers/queue/executors/IntegrationExecutor.php
@@ -3,6 +3,7 @@
use JVBase\managers\queue\{Executor, Operation, Progress, Result};
use Exception;
+use JVBase\inc\managers\ErrorHelper;
use JVBase\integrations\ServiceMap;
use JVBase\managers\Errors;
@@ -30,7 +31,7 @@
*/
final class IntegrationExecutor implements Executor
{
- use ServiceMap;
+ use ServiceMap, ErrorHelper;
public function execute(Operation $operation, Progress $progress): Result
@@ -59,15 +60,11 @@
};
} catch (Exception $e) {
- Errors::getInstance()->log(
- '[IntegrationExecutor]:execute',
- $e->getMessage(),
- [
- 'operation_id' => $operation->id,
- 'operation_type' => $operation->type,
- 'user_id' => $operation->userId,
- ]
- );
+ $this->logError($e->getMessage(),'execute', [
+ 'operation_id' => $operation->id,
+ 'operation_type' => $operation->type,
+ 'user_id' => $operation->userId,
+ ]);
return new Result(
outcome: 'failed',
diff --git a/inc/managers/queue/executors/UploadExecutor.php b/inc/managers/queue/executors/UploadExecutor.php
index 138d022..8a9b677 100644
--- a/inc/managers/queue/executors/UploadExecutor.php
+++ b/inc/managers/queue/executors/UploadExecutor.php
@@ -2,6 +2,7 @@
namespace JVBase\managers\queue\executors;
use JVBase\managers\queue\{Executor, Operation, Progress, Queue, Result};
+use JVBase\inc\managers\ErrorHelper;
use JVBase\managers\Cache;
use JVBase\managers\Errors;
use JVBase\managers\UploadManager;
@@ -21,6 +22,7 @@
*/
final class UploadExecutor implements Executor
{
+ use ErrorHelper;
private const HANDLED_TYPES = [
'image_upload',
'video_upload',
@@ -58,15 +60,11 @@
};
} catch (Exception $e) {
- Errors::getInstance()->log(
- '[UploadExecutor]:execute',
- $e->getMessage(),
- [
- 'operation_id' => $operation->id,
- 'operation_type' => $operation->type,
- 'user_id' => $operation->userId,
- ]
- );
+ $this->logError($e->getMessage(), 'execute', [
+ 'operation_id' => $operation->id,
+ 'operation_type' => $operation->type,
+ 'user_id' => $operation->userId,
+ ]);
return new Result(
outcome: 'failed',
diff --git a/inc/meta/Storage.php b/inc/meta/Storage.php
index 433b478..732a9bb 100644
--- a/inc/meta/Storage.php
+++ b/inc/meta/Storage.php
@@ -2,6 +2,7 @@
namespace JVBase\meta;
use Exception;
+use JVBase\inc\managers\ErrorHelper;
use JVBase\managers\Cache;
use JVBase\managers\Errors;
use wpdb;
@@ -15,6 +16,7 @@
*/
class Storage
{
+ use ErrorHelper;
protected wpdb $wpdb;
public function __construct()
@@ -171,11 +173,8 @@
} catch (Exception $e) {
$this->wpdb->query('ROLLBACK');
- Errors::getInstance()->log('meta_storage', $e->getMessage(), [
- 'item_id' => $item->id,
- 'object_type' => $item->objectType,
- 'fields' => array_keys($dirty)
- ]);
+ $this->logError($e->getMessage(), 'save');
+
return false;
}
}
@@ -327,7 +326,7 @@
$results[$meta->item()->id ?? 'options'] = false;
}
- Errors::getInstance()->log('meta_storage', 'Bulk save failed: ' . $e->getMessage());
+ self::staticLogError($e->getMessage(), 'save');
return $results;
}
diff --git a/inc/registrar/Posts.php b/inc/registrar/Posts.php
index bedacaa..ef9e3f3 100644
--- a/inc/registrar/Posts.php
+++ b/inc/registrar/Posts.php
@@ -2,6 +2,7 @@
namespace JVBase\registrar;
use JVBase\forms\TaxonomySelector;
+use JVBase\inc\managers\ErrorHelper;
use JVBase\managers\Errors;
use WP_Post;
@@ -9,6 +10,7 @@
exit;
}
final class Posts {
+ use ErrorHelper;
public string $postType;
public string $singular;
public string $plural;
@@ -358,7 +360,7 @@
}
if (is_wp_error($registered)) {
- Errors::getInstance()->log('JVBase\registrar\Posts', 'Could not register post type', $registered->get_error_messages());
+ $this->logError('Could not register post type', 'register', $registered->get_error_messages());
}
}
diff --git a/inc/registrar/helpers/AddCustomFields.php b/inc/registrar/helpers/AddCustomFields.php
index 2be7fd2..1cccca9 100644
--- a/inc/registrar/helpers/AddCustomFields.php
+++ b/inc/registrar/helpers/AddCustomFields.php
@@ -99,10 +99,14 @@
$schema = is_array($rawSchema) ? $this->schema($rawSchema) : $rawSchema;
+ $default = $config['type'] === 'repeater' ?
+ $this->determineDefault($schema['schema']['properties']??[]) :
+ $config['default'] ?? MetaTypeManager::getType($config['type'])['default'] ?? '';
+
register_post_meta($this->registrar->getBased(), jvbCheckBase($name), [
'single' => true,
'type' => $metaType,
- 'default' => $config['default'] ?? MetaTypeManager::getType($config['type'])['default'] ?? '',
+ 'default' => $default,
'sanitize_callback' => fn($value) => (Sanitizer::sanitize($value, $config)),
'auth_callback' => fn($allowed, $key, $postID) => current_user_can('edit_post', $postID),
'show_in_rest' => $schema,
@@ -285,4 +289,16 @@
{
}
+
+ protected function determineDefault(array $properties):array
+ {
+ $out = [];
+ foreach ($properties as $prop => $value) {
+ $out[$prop] = match ($value) {
+ 'object' => [],
+ default => ''
+ };
+ }
+ return $out;
+ }
}
diff --git a/inc/rest/Rest.php b/inc/rest/Rest.php
index cc2caef..81c6fcb 100644
--- a/inc/rest/Rest.php
+++ b/inc/rest/Rest.php
@@ -1,6 +1,7 @@
<?php
namespace JVBase\rest;
+use JVBase\inc\managers\ErrorHelper;
use JVBase\integrations\services\Cloudflare;
use JVBase\managers\Cache;
use JVBase\managers\Errors;
@@ -30,6 +31,7 @@
*/
abstract class Rest
{
+ use ErrorHelper;
protected string $namespace = 'jvb/v1';
protected ?Cache $cache = null;
protected string $cacheName = '';
@@ -645,24 +647,12 @@
]);
try {
- Errors::getInstance()->log('security_audit', $event, $context, 'info');
+ $this->logError($event,'auditLog', $context);
} catch (Exception $e) {
error_log("Security Audit: {$event} - " . json_encode($context));
}
}
- /**
- * Log errors with proper context
- */
- protected function logError(string $message, array $context = [], string $severity = 'error'): void
- {
- try {
- Errors::getInstance()->log(static::class, $message, $context, $severity);
- } catch (Exception $e) {
- error_log(static::class . " Error: {$message} - " . json_encode($context));
- }
- }
-
/************************************************************
SESSION FINGERPRINT
************************************************************/
diff --git a/inc/rest/RestRouteManager.php b/inc/rest/RestRouteManager.php
deleted file mode 100644
index b714389..0000000
--- a/inc/rest/RestRouteManager.php
+++ /dev/null
@@ -1,1058 +0,0 @@
-<?php
-namespace JVBase\rest;
-
-use DateTime;
-use DateTimeZone;
-use JVBase\integrations\services\Cloudflare;
-use JVBase\managers\Errors;
-use JVBase\registrar\Registrar;
-use JVBase\managers\OperationQueue;
-use JVBase\managers\Cache;
-use JVBase\managers\NotificationManager;
-use JVBase\base\Site;
-use WP_REST_Request;
-use WP_Error;
-use Exception;
-use WP_REST_Response;
-
-if (!defined('ABSPATH')) {
- exit; // Exit if accessed directly
-}
-
-/**
- * @deprecated use Rest.php
- * Handles route registration and high-level coordination
- */
-abstract class RestRouteManager
-{
- protected string $namespace = 'jvb/v1';
-
- protected RateLimiter|null $rate_limiter;
- protected array $types;
- protected string $route;
- protected string $base;
- protected string $content_type; //the registered post type
- protected string $type; //post, user, term, for Meta
- protected string $action = ''; //optional additional nonce to check
- protected array $callback; //route->callback array
- protected string $operation_type; // from QueueManager.js and OperationQueue.php
- protected OperationQueue $queue;
- protected Cache $cache;
- protected NotificationManager $notifications;
- protected string $cache_name ='';
- protected int $cache_ttl = 3600; //1 hour default
- protected array $response_headers = [];
-
- // Error code constants for consistency
- const ERROR_MISSING_PARAMS = 'missing_parameters';
- const ERROR_ACCESS_DENIED = 'access_denied';
- const ERROR_NOT_FOUND = 'not_found';
- const ERROR_INVALID_OPERATION = 'invalid_operation';
- const ERROR_PROCESSING = 'processing_error';
-
- public function __construct()
- {
- $this->base = BASE;
- $this->rate_limiter = new RateLimiter();
- if ($this->cache_name !== '') {
- $this->cache = Cache::for($this->cache_name, $this->cache_ttl);
- }
- add_action('rest_api_init', [$this, 'registerRoutes']);
- }
-
- public function init()
- {
- //Replace in child classes, if necessary
- }
-
-
- /**
- * @param WP_REST_Request $request Rest Request object
- * @param string $nonceName nonce name to check
- * @param string|null $nonce optional additional nonce to check
- *
- * @return bool|WP_Error
- */
- public function verifyNonce(WP_REST_Request $request, string $nonceName = 'wp_rest', string|null $nonce = null): bool|WP_Error
- {
- $nonce = (!is_null($nonce)) ? $nonce : $request->get_header('X-WP-Nonce');
-
- if (!wp_verify_nonce($nonce, $nonceName)) {
- return new WP_Error(
- 'invalid_nonce',
- 'Invalid nonce',
- ['status' => 403]
- );
- }
- return true;
- }
-
- abstract public function registerRoutes();
-
-
- /**
- * @param WP_REST_Request $request the Request Object
- *
- * @return bool|WP_Error Whether or not we can proceed
- */
- public function checkPermission(WP_REST_Request $request):bool|WP_Error
- {
- // Check rate limits first
- if (!$this->rate_limiter->checkLimit($request)) {
- error_log('Rate Limit Reached');
- return new WP_Error(
- 'rate_limit_reached',
- 'Rate limit reached',
- ['status' => 403]
- );
- }
- $user_id = $request->get_param('user');
- if (!empty($user_id) && !$this->userCheck($user_id)) {
- error_log('Usercheck failed');
- return false;
- }
- // Verify nonces
- $this->verifyNonce($request);
- if ($this->action!=='') {
- $this->verifyNonce($request, $this->action . $user_id, $request->get_header('action_nonce'));
- }
- return true;
- }
-
- public function checkRateLimit(WP_REST_Request $request):bool|WP_Error
- {
- if (!$this->rate_limiter->checkLimit($request)) {
- error_log('Rate Limit Reached');
- return new WP_Error(
- 'rate_limit',
- 'Too many attempts. Please wait a moment before trying again.',
- ['status' => 429]
- );
- }
- return true;
- }
-
- /**
- * Convert MySQL datetime to ISO 8601 timestamp with proper timezone
- */
- public function formatTimestamp(?string $mysql_datetime): ?string
- {
- if (empty($mysql_datetime)) {
- return null;
- }
-
- try {
- // Get WordPress timezone - dates are stored in this timezone
- $wp_timezone = wp_timezone();
-
- // Parse the datetime in WordPress timezone
- $date = new DateTime($mysql_datetime, $wp_timezone);
-
- // Convert to UTC for API consistency
- $date->setTimezone(new DateTimeZone('UTC'));
-
- // Return ISO 8601 format
- return $date->format('c');
- } catch (Exception $e) {
- return null;
- }
- }
-
- protected function checkContent(string $content, bool $bool = false):string|bool
- {
- return (bool)Registrar::getInstance($content);
- }
-
-
- public function formatError($code, $message, $status = 400)
- {
- return new WP_Error($code, $message, ['status' => $status]);
- }
-
- /**
- * @param int $userID the userID to check
- *
- * @return bool whether or not the $userID matches the currently logged in user
- */
- protected function userCheck(int $userID)
- {
- return $userID === get_current_user_id();
- }
-
- /**
- * Makes sure the value is in the format it needs to be
- * @param mixed $value
- *
- * @return mixed
- */
- protected function getMetaValues(mixed $value):mixed
- {
- //get the repeater out of there
- $temp = json_decode($value);
- if (is_array($temp)) {
- $value = [];
- foreach ($temp as $t) {
- if (is_object($t)) {
- $t = (array)$t;
- }
- $value[] = $t;
- }
- }
- return $value;
- }
- /**
- * Log errors with proper context
- * @param string $message The error message
- * @param array $context Additional context
- * @param string $severity
- * @return void
- */
- protected function logError(string $message, array $context = [], string $severity = 'error')
- {
- try {
- Errors::getInstance()->log(
- 'artist_invitations', // component
- $message,
- $context,
- $severity
- );
- } catch (Exception $e) {
- // Fallback if error handler fails
- error_log("Invitations Error: {$message} - " . json_encode($context));
- }
- }
-
- /**
- * Check if user exists (cached)
- */
- protected function checkUser(int $userID): bool
- {
- $cache = Cache::for('checkUser', 'user',DAY_IN_SECONDS);
-
- return $cache->remember($userID, function() use ($userID) {
- return (bool)get_userdata($userID);
- });
- }
-
- /**
- * Check if shop exists (cached)
- */
- protected function checkShop(int $shopID): bool
- {
- $cache = Cache::for('checkShop', 'term',DAY_IN_SECONDS);
-
- return $cache->remember($shopID, function() use ($shopID) {
- return (bool)term_exists($shopID, BASE . 'shop');
- });
- }
-
- /**
- * Check if term exists (cached)
- */
- protected function checkTerm(array $args): bool
- {
- $termID = $args['to_term'] ?? $args['term_id'] ?? false;
- if (!$termID) {
- return false;
- }
-
- $taxonomy = $args['taxonomy'] ?? false;
- if (!$taxonomy) {
- return false;
- }
-
- $taxonomy = jvbCheckBase($taxonomy);
- $cache = Cache::for('checkTerm', 'term', DAY_IN_SECONDS);
-
- return $cache->remember($termID, function() use ($termID, $taxonomy) {
- return (bool)term_exists($termID, $taxonomy);
- });
- }
-
- /**
- * Check if an artist is verified
- */
- public function isVerifiedUser(int $user_id): bool
- {
- $cache = Cache::for('verifiedUsers', 'user',DAY_IN_SECONDS);
-
- return $cache->remember($user_id, function() use ($user_id) {
- return user_can($user_id, 'skip_moderation');
- });
- }
-
- /**
- * @deprecated
- * @param array $args
- * @param array $data
- * @return array
- */
- protected function applyTaxonomyFilters(array $args, array $data):array
- {
- // Handle JSON-encoded taxonomy data
- if (array_key_exists('taxonomy', $data) && is_string($data['taxonomy'])) {
- $data['taxonomy'] = json_decode($data['taxonomy'], true);
- }
-
- $taxonomies = $data['taxonomies'] ?? $data['taxonomy'] ?? [];
- $taxQuery = [];
-
- foreach($taxonomies as $taxonomy => $terms) {
- // Better validation: check if taxonomy actually exists
- if (!taxonomy_exists(jvbCheckBase($taxonomy))) {
- continue;
- }
-
- $taxQuery[] = [
- 'taxonomy' => jvbCheckBase($taxonomy),
- 'field' => 'term_id',
- 'terms' => array_map(
- 'absint',
- is_array($terms) ? $terms : explode(',', $terms)
- ),
- 'operator' => 'IN'
- ];
- }
-
- if (!empty($taxQuery)) {
- // Match 'all' = AND, anything else = OR
- $relation = ($data['match'] ?? 'all') === 'all' ? 'AND' : 'OR';
-
- $args['tax_query'] = array_merge([
- 'relation' => $relation,
- ], $taxQuery);
- }
-
- // Keep existing author filtering logic
- $authorQuery = [];
- foreach (Registrar::withFeature('can_create', 'user') as $type) {
- if (array_key_exists($type, $data)) {
- $artist_ids = array_map(
- 'absint',
- is_array($data[$type]) ?
- $data[$type] :
- explode(',', $data[$type])
- );
- $authorQuery = array_merge($authorQuery, $artist_ids);
- }
- }
- if (!empty($authorQuery)) {
- $args['author__in'] = array_unique($authorQuery);
- }
-
- return $args;
- }
-
- protected function applyOrderFilters(array $args, array $data):array
- {
- // Check for custom order first
- $customArgs = $this->applyCustomOrder($args, $data);
- if ($customArgs !== null) {
- $order = (array_key_exists('order', $data)) ? strtoupper($data['order']) : 'DESC';
- $customArgs['order'] = (in_array($order, ['ASC', 'DESC'])) ? $order : 'DESC';
- return $customArgs;
- }
-
- //Handle random
- if (array_key_exists('orderby', $data) && $data['orderby'] === 'random') {
- $current_seed = floor(time() / 1800);
- $args['orderby'] = 'RAND(' . $current_seed . ')';
- unset($args['order']);
- return $args;
- }
-
- if (in_array($data['orderby'], ['date', 'modified', 'title', 'alphabetical'])) {
- if ($data['orderby'] === 'date' && $this->isTimeline($args, $data)) {
- $args['meta_key'] = BASE . 'latest_date';
- $args['orderby'] = 'meta_value_num';
- } else {
- $args['orderby'] = ($data['orderby'] === 'alphabetical') ? 'title' : $data['orderby'];
- }
-
- } else {
- switch ($data['orderby']) {
- case 'popularity':
- $args['meta_key'] = BASE.'upvotes';
- $args['orderby'] = 'meta_value_num';
- break;
- case 'karma':
- $args['meta_key'] = BASE.'karma';
- $args['orderby'] = 'meta_value_num';
- break;
- case 'unpopularity':
- $args['meta_key'] = BASE.'downvotes';
- $args['orderby'] = 'meta_value_num';
- break;
- case 'favourites':
- $args['meta_key'] = BASE.'total_favourites';
- $args['orderby'] = 'meta_value_num';
- break;
- case 'date':
- default:
- $args['orderby'] = 'date';
- break;
- }
- }
- $order = (array_key_exists('order', $data)) ? strtoupper($data['order']) : 'DESC';
- $args['order'] = (in_array($order, ['ASC', 'DESC'])) ? $order : 'DESC';
-
- return $args;
- }
-
- /**
- * Apply custom order if defined in content/taxonomy/user config
- *
- * @param array $args WP_Query args
- * @param array $data Request data
- * @return array|null Modified args if custom order found, null otherwise
- */
- protected function applyCustomOrder(array $args, array $data): ?array
- {
- $orderby = $data['orderby'] ?? '';
-
- // Skip if no orderby or it's a standard order
- if (empty($orderby) || in_array($orderby, ['date', 'modified', 'title', 'alphabetical', 'random', 'popularity', 'karma', 'unpopularity', 'favourites'])) {
- return null;
- }
-
- // Determine content type
- $post_type = is_array($args['post_type']) ? $args['post_type'][0] : $args['post_type'];
- $content = jvbNoBase($post_type);
-
- // Get config for this content type
- $config = Registrar::getInstance($content);
- if (!$config) {
- return null;
- }
-
- // Check if this orderby is a custom order
- $customOrders = $config->custom_order ?? [];
- if (empty($customOrders) || !isset($customOrders[$orderby])) {
- return null;
- }
-
- // Get field definition
- $fields = $config->getFields()??[];
- if (!isset($fields[$orderby])) {
- return null;
- }
-
- $field = $fields[$orderby];
-
- // Set meta_key
- $args['meta_key'] = BASE . $orderby;
-
- // Determine orderby and meta_type based on field type
- $fieldType = $field['type'] ?? 'text';
- $subtype = $field['subtype'] ?? '';
-
- switch ($fieldType) {
- case 'number':
- $args['orderby'] = 'meta_value_num';
- break;
-
- case 'text':
- $args['orderby'] = ($subtype === 'number') ? 'meta_value_num' : 'meta_value';
- break;
-
- case 'date':
- $args['orderby'] = 'meta_value';
- $args['meta_type'] = 'DATE';
- break;
-
- case 'datetime':
- $args['orderby'] = 'meta_value';
- $args['meta_type'] = 'DATETIME';
- break;
-
- case 'true_false':
- case 'checkbox':
- $args['orderby'] = 'meta_value';
- $args['meta_type'] = 'BINARY';
- break;
-
- default:
- $args['orderby'] = 'meta_value';
- }
-
- return $args;
- }
-
- protected function isTimeline($args, $data):bool
- {
- $post_types = is_array($args['post_type']) ? $args['post_type'] : [$args['post_type']];
- $areTimeline = array_map(function($type) { return BASE.$type; },Registrar::withFeature('is_timeline', 'post'));
- return !empty(array_intersect($post_types, $areTimeline));
- }
-
- protected function applyDateFilters(array $args, array $data):array
- {
- if (!array_key_exists('date-filter', $data) && !array_key_exists('dateFrom', $data)) {
- return $args;
- }
- if (array_key_exists('dateFrom', $data)) {
- $dateFrom = strtotime(sanitize_text_field($data['dateFrom']));
- $dateTo = strtotime(sanitize_text_field($data['dateTo']));
- if ($dateFrom && $dateTo) {
- $args['date_query'] = [
- [
- 'after' => date('c', $dateFrom),
- 'before' => date('c', $dateTo),
- 'inclusive' => true,
- ]
- ];
- }
- } else {
- switch ($data['date-filter']) {
- case 'today':
- $args['date_query'] = [['after' => '1 day ago']];
- break;
- case 'week':
- $args['date_query'] = [['after' => '1 week ago']];
- break;
- case 'month':
- $args['date_query'] = [['after' => '1 month ago']];
- break;
- case 'year':
- $args['date_query'] = [['after' => '1 year ago']];
- break;
- }
- }
- return $args;
- }
-
- protected function applyCalendarFilters(array $args, array $data):array
- {
- $meta_query = [];
- $today = date('Y-m-d');
- if (in_array('future', $args['post_status'])) {
- $meta_query[] = [
- 'key' => 'jvb_start_date',
- 'value' => $today,
- 'compare' => '>=',
- 'type' => 'DATE'
- ];
- }
- if (in_array('past', $args['post_status'])) {
- $meta_query[] = [
- 'key' => 'jvb_end_date',
- 'value' => $today,
- 'compare' => '<',
- 'type' => 'DATE'
- ];
- }
- if (in_array('recurring', $args['post_status'])) {
- $meta_query[] = [
- 'key' => 'jvb_is_recurring',
- 'value' => true,
- 'compare' => '='
- ];
- }
- if (!empty($meta_query)) {
- $args['meta_query'] = (array_key_exists('meta_query', $args)) ? array_merge($args['meta_query'], $meta_query) : $meta_query;
- }
- return $args;
-
- }
-
- protected function table_exists($tableName, $wpdb = null)
- {
- if (!$wpdb) {
- global $wpdb;
- }
- return $wpdb->get_var("SHOW TABLES LIKE '{$tableName}'") !== $tableName;
- }
-
- // ========== HTTP CACHING METHODS ==========
- /**
- * Check HTTP caching headers (ETag and If-Modified-Since)
- * Returns 304 Not Modified if content hasn't changed
- * @param WP_REST_Request $request
- * @param int|string|array $key Content type(s) to check timestamps for
- * @param string|array $group Additional params for ETag uniqueness (e.g., user_id, filters)
- * @return WP_REST_Response|false Returns 304 response if not modified, null to continue processing
- */
- protected function checkHeaders(
- WP_REST_Request $request,
- int|string|array $key,
- string|array $group = ''
- ): WP_REST_Response|false
- {
- $group = ($group!=='') ? $group : $this->cache_name;
- $cache = $this->cache_name !== $group ? Cache::for($group) : $this->cache;
- if (!$cache) {
- return false;
- }
- if (is_array($key)) {
- $key = $cache->generateKey($key);
- }
-
-
-
- $lastModified = $cache::lastModified($group);
-
- if (!$lastModified) {
- return false;
- }
-
-
- $etag = '"' . sha1($group . ':' . $key . ':' . $lastModified) . '"';
-
- // ETag check
- $ifNoneMatch = $request->get_header('if-none-match');
- if ($ifNoneMatch && trim($ifNoneMatch) === $etag) {
- return new WP_REST_Response(null, 304);
- }
-
- // Last-Modified check
- $ifModifiedSince = $request->get_header('if-modified-since');
- if ($ifModifiedSince && strtotime($ifModifiedSince) >= $lastModified) {
- return new WP_REST_Response(null, 304);
- }
-
- // Store headers for response phase
- $this->response_headers = [
- 'ETag' => $etag,
- 'Last-Modified' => gmdate('D, d M Y H:i:s', $lastModified) . ' GMT',
- ];
-
- return false;
- }
-
- /**
- * Create 304 Not Modified response with proper headers
- *
- * @param string $etag ETag value
- * @param int $last_modified Last modified timestamp
- * @return WP_REST_Response 304 response
- */
- private function createNotModifiedResponse(string $etag, int $last_modified): WP_REST_Response
- {
- $response = new WP_REST_Response(null, 304);
- $response->set_headers($this->buildCacheHeaders($etag, $last_modified));
- return $response;
- }
-
- /**
- * Build cache headers array
- *
- * @param string $etag ETag value
- * @param int $last_modified Last modified timestamp
- * @return array Headers array
- */
- private function buildCacheHeaders(string $etag, int $last_modified): array
- {
- return [
- 'ETag' => $etag,
- 'Last-Modified' => gmdate('D, d M Y H:i:s', $last_modified) . ' GMT',
- 'Cache-Control' => 'private, max-age=60, must-revalidate'
- ];
- }
-
- /**
- * Add stored cache headers to a response
- * Call this on your final WP_REST_Response before returning
- *
- * @param WP_REST_Response $response The response to add headers to
- * @return WP_REST_Response The response with headers added
- */
- protected function addCacheHeaders(WP_REST_Response $response): WP_REST_Response
- {
- if (!empty($this->response_headers)) {
- foreach ($this->response_headers as $name => $value) {
- $response->header($name, $value);
- }
- $this->response_headers = [];
- }
-
- return $response;
- }
-
- /**
- * Helper to return error response
- */
- protected function error(string $message, string $code, int $status = 400, ?string $field = null): WP_REST_Response
- {
- $data = [
- 'success' => false,
- 'message' => $message,
- 'code' => $code
- ];
-
- if ($field) {
- $data['field'] = $field;
- }
-
- return new WP_REST_Response($data, $status);
- }
- /**
- * Helper to return success response
- */
- protected function success(array $data, int $status = 200): WP_REST_Response
- {
- $data['success'] = true;
- return new WP_REST_Response($data, $status);
- }
-
- /************************************************************
- SESSION FINGERPRINT
- ************************************************************/
- /**
- * Store session fingerprint for hijacking detection
- */
- protected function storeSessionFingerprint(int $user_id, WP_REST_Request $request): void
- {
- if (!defined('JVB_SESSION_FINGERPRINT') || !JVB_SESSION_FINGERPRINT) {
- return;
- }
-
- $fingerprint = $this->generateSessionFingerprint($request);
- update_user_meta($user_id, BASE . 'session_fingerprint', $fingerprint);
- update_user_meta($user_id, BASE . 'session_timestamp', time());
- }
- /**
- * Generate session fingerprint for hijacking detection
- *
- * @param WP_REST_Request $request The REST request
- * @return string Hashed fingerprint
- */
- protected function generateSessionFingerprint(WP_REST_Request $request): string
- {
- return hash('sha256', implode('|', [
- $request->get_header('User-Agent') ?? '',
- // Use IP class instead of full IP to allow for mobile network changes
- $this->getIPClass(
- $request->get_header('X-Forwarded-For')
- ?: $request->get_header('X-Real-IP')
- ?: $_SERVER['REMOTE_ADDR'] ?? ''
- )
- ]));
- }
-
- /**
- * Get IP class (first 3 octets) for session validation
- * This allows for minor IP changes (common with mobile networks)
- *
- * @param string $ip IP address
- * @return string First 3 octets
- */
- protected function getIPClass(string $ip): string
- {
- $parts = explode('.', $ip);
- return implode('.', array_slice($parts, 0, 3));
- }
-
- /**
- * Validate session fingerprint against stored value
- *
- * @param int $user_id User ID to validate
- * @param WP_REST_Request $request Current request
- * @return bool True if valid, false if potential hijacking
- */
- protected function validateSessionFingerprint(int $user_id, WP_REST_Request $request): bool
- {
- // Only enforce if enabled in config
- if (!defined('JVB_SESSION_FINGERPRINT') || !JVB_SESSION_FINGERPRINT) {
- return true;
- }
-
- $stored = get_user_meta($user_id, BASE . 'session_fingerprint', true);
- $current = $this->generateSessionFingerprint($request);
-
- if (empty($stored)) {
- // First request - store fingerprint
- update_user_meta($user_id, BASE . 'session_fingerprint', $current);
- update_user_meta($user_id, BASE . 'session_timestamp', time());
- return true;
- }
-
- // Compare using timing-safe comparison
- return hash_equals($stored, $current);
- }
-
- /**
- * Clear session fingerprint (call on logout)
- *
- * @param int $user_id User ID
- * @return void
- */
- protected function clearSessionFingerprint(int $user_id): void
- {
- delete_user_meta($user_id, BASE . 'session_fingerprint');
- delete_user_meta($user_id, BASE . 'session_timestamp');
- }
-
- /******************************************************************
- * CSRF PROTECTION
- ******************************************************************/
- /**
- * Generate CSRF token for a user
- *
- * @param int $user_id User ID
- * @return string CSRF token
- */
- protected function generateCSRFToken(int $user_id): string
- {
- $token = wp_generate_password(32, false);
- set_transient(BASE . 'csrf_' . $user_id, $token, HOUR_IN_SECONDS);
- return $token;
- }
-
- /**
- * Validate CSRF token from request header
- *
- * @param WP_REST_Request $request The REST request
- * @return bool True if valid or not required
- */
- protected function validateCSRFToken(WP_REST_Request $request): bool
- {
- // Only for authenticated requests
- if (!is_user_logged_in()) {
- return true;
- }
-
- // Only for state-changing operations
- if (in_array($request->get_method(), ['GET', 'HEAD', 'OPTIONS'])) {
- return true;
- }
-
- $user_id = get_current_user_id();
- $token = $request->get_header('X-CSRF-Token');
- $stored = get_transient(BASE . 'csrf_' . $user_id);
-
- if (empty($stored) || empty($token)) {
- return false;
- }
-
- return hash_equals($stored, $token);
- }
-
- /**
- * Get current CSRF token for user (for frontend to use)
- *
- * @param int $user_id User ID
- * @return string|null CSRF token or null
- */
- protected function getCSRFToken(int $user_id): ?string
- {
- $token = get_transient(BASE . 'csrf_' . $user_id);
-
- if (!$token) {
- $token = $this->generateCSRFToken($user_id);
- }
-
- return $token;
- }
-
- /***********************************************************
- * REQUEST SIGNATURE VERIFICATION
- ***********************************************************/
- /**
- * Verify request signature for sensitive operations
- * Adds an additional layer of security for critical endpoints
- *
- * @param WP_REST_Request $request The REST request
- * @param array $sensitive_params Params to include in signature
- * @return bool True if valid signature
- */
- protected function verifyRequestSignature(WP_REST_Request $request, array $sensitive_params): bool
- {
- $signature = $request->get_header('X-Request-Signature');
- if (empty($signature)) {
- return false;
- }
-
- // Get user's signing key (rotated on each login)
- $user_id = get_current_user_id();
- if (!$user_id) {
- return false;
- }
-
- $signing_key = get_user_meta($user_id, BASE . 'signing_key', true);
- if (empty($signing_key)) {
- // Generate new key if missing
- $signing_key = wp_generate_password(64, false);
- update_user_meta($user_id, BASE . 'signing_key', $signing_key);
- }
-
- // Build signature from sensitive params
- ksort($sensitive_params);
- $message = json_encode($sensitive_params);
- $expected = hash_hmac('sha256', $message, $signing_key);
-
- return hash_equals($expected, $signature);
- }
-
- /**
- * Rotate signing key (call on login)
- *
- * @param int $user_id User ID
- * @return string New signing key
- */
- protected function rotateSigningKey(int $user_id): string
- {
- $new_key = wp_generate_password(64, false);
- update_user_meta($user_id, BASE . 'signing_key', $new_key);
- return $new_key;
- }
- /**********************************************************
- * AUDIT LOGGING
- **********************************************************/
- /**
- * Log security-relevant events
- *
- * @param string $event Event name
- * @param array $data Additional event data
- * @return void
- */
- protected function auditLog(string $event, array $data = []): void
- {
- // Add standard context
- $context = array_merge($data, [
- 'timestamp' => current_time('mysql'),
- 'user_id' => get_current_user_id() ?: 0,
- 'ip' => $_SERVER['REMOTE_ADDR'] ?? '',
- 'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? '',
- ]);
-
- // Use existing error handler
- try {
- Errors::getInstance()->log(
- 'security_audit',
- $event,
- $context,
- 'info'
- );
- } catch (Exception $e) {
- // Fallback to error_log if handler fails
- error_log("Security Audit: {$event} - " . json_encode($context));
- }
- }
-
- /***************************************************************
- * SANITIZATION HELPERS
- ***************************************************************/
- /**
- * Sanitize array of IDs
- *
- * @param array $ids Array of IDs
- * @return array Sanitized array of integers
- */
- protected function sanitizeIDs(array $ids): array
- {
- return array_map('absint', array_filter($ids, function($id) {
- return is_numeric($id) && $id > 0;
- }));
- }
-
- /***************************************************************
- * RESPONSE HELPERS
- ***************************************************************/
- /**
- * Return validation error response
- *
- * @param array $errors Validation errors (field => message)
- * @return WP_REST_Response 422 response with errors
- */
- protected function validationError(array $errors): WP_REST_Response
- {
- return new WP_REST_Response([
- 'success' => false,
- 'message' => 'Validation failed',
- 'errors' => $errors
- ], 422);
- }
-
- /**
- * Return unauthorized error response
- *
- * @param string $message Error message
- * @return WP_REST_Response 401 response
- */
- protected function unauthorized(string $message = 'Unauthorized'): WP_REST_Response
- {
- return new WP_REST_Response([
- 'success' => false,
- 'message' => $message
- ], 401);
- }
-
- /**
- * Return forbidden error response
- *
- * @param string $message Error message
- * @return WP_REST_Response 403 response
- */
- protected function forbidden(string $message = 'Forbidden'): WP_REST_Response
- {
- return new WP_REST_Response([
- 'success' => false,
- 'message' => $message
- ], 403);
- }
-
- /**
- * Return not found error response
- *
- * @param string $message Error message
- * @return WP_REST_Response 404 response
- */
- protected function notFound(string $message = 'Not found'): WP_REST_Response
- {
- return new WP_REST_Response([
- 'success' => false,
- 'message' => $message
- ], 404);
- }
-
- /*****************************************************************
- * CONCURRENT CHECKS
- *****************************************************************/
- /**
- * Prevent concurrent requests for the same operation
- * Useful for preventing double-submissions
- *
- * @param string $operation_key Unique key for this operation
- * @param int $lock_duration Lock duration in seconds
- * @return bool True if lock acquired, false if already locked
- */
- protected function acquireOperationLock(string $operation_key, int $lock_duration = 5): bool
- {
- $lock_key = 'op_lock_' . md5($operation_key);
-
- // Try to acquire lock
- $locked = get_transient($lock_key);
- if ($locked) {
- return false; // Already locked
- }
-
- // Set lock
- set_transient($lock_key, true, $lock_duration);
- return true;
- }
-
- /**
- * Release operation lock
- *
- * @param string $operation_key Unique key for this operation
- * @return void
- */
- protected function releaseOperationLock(string $operation_key): void
- {
- $lock_key = 'op_lock_' . md5($operation_key);
- delete_transient($lock_key);
- }
-
- protected function verifyTurnstile(string $token): bool
- {
- if (!Site::hasIntegration('cloudflare') || !Cloudflare::getInstance()->isSetUp()) {
- return true;
- }
-
- if (empty($token)) {
- return false;
- }
-
- return Cloudflare::getInstance()->verifyTurnstile($token);
- }
-}
diff --git a/inc/rest/routes/ContentTermsRoutes.php b/inc/rest/routes/ContentTermsRoutes.php
index dbc191c..c42060c 100644
--- a/inc/rest/routes/ContentTermsRoutes.php
+++ b/inc/rest/routes/ContentTermsRoutes.php
@@ -411,8 +411,7 @@
return $this->error('Failed to create request');
} catch (Exception $e) {
- $this->logError('createRequest', [
- 'error' => $e->getMessage(),
+ $this->logError($e->getMessage(),'createRequest', [
'user_id' => $userID,
'term_id' => $termID
]);
@@ -478,8 +477,7 @@
return $this->success(['message' => 'Request ' . $status]);
} catch (Exception $e) {
- $this->logError('processRequest', [
- 'error' => $e->getMessage(),
+ $this->logError($e->getMessage(), 'processRequest', [
'request_id' => $requestID,
'action' => $action
]);
@@ -526,8 +524,7 @@
return $this->error('Failed to update permissions');
} catch (Exception $e) {
- $this->logError('updatePermissions', [
- 'error' => $e->getMessage(),
+ $this->logError($e->getMessage(),'updatePermissions', [
'term_id' => $termID,
'target_user' => $targetUserID,
'role' => $role
diff --git a/inc/rest/routes/ErrorRoutes.php b/inc/rest/routes/ErrorRoutes.php
index 824f4bd..6fc7c06 100644
--- a/inc/rest/routes/ErrorRoutes.php
+++ b/inc/rest/routes/ErrorRoutes.php
@@ -54,11 +54,9 @@
// Determine severity based on error type
$severity = $this->getSeverityFromType($error_type);
- Errors::getInstance()->log(
- $contextData['component'] ?? 'client-js',
+ Errors::log(
$message,
$contextData,
- $severity
);
return Response::success(['message'=>'Error logged']);
diff --git a/inc/rest/routes/FavouritesRoutes.php b/inc/rest/routes/FavouritesRoutes.php
index 78cd3e0..795ddeb 100644
--- a/inc/rest/routes/FavouritesRoutes.php
+++ b/inc/rest/routes/FavouritesRoutes.php
@@ -407,8 +407,7 @@
return ['added' => $added, 'errors' => $errors];
} catch (Exception $e) {
- $this->logError('addItemsToList', [
- 'error' => $e->getMessage(),
+ $this->logError($e->getMessage(),'addItemsToList', [
'user_id' => $user_id,
'list_id' => $list_id,
]);
@@ -620,8 +619,7 @@
];
} catch (Exception $e) {
- $this->logError('getListDetails', [
- 'error' => $e->getMessage(),
+ $this->logError($e->getMessage(), 'getListDetails', [
'user_id' => $user_id,
'list_id' => $list_id
]);
@@ -750,8 +748,7 @@
];
} catch (Exception $e) {
- $this->logError('updateList', [
- 'error' => $e->getMessage(),
+ $this->logError($e->getMessage(),'updateList', [
'user_id' => $user_id,
'list_id' => $list_id
]);
@@ -942,8 +939,7 @@
];
} catch (Exception $e) {
- $this->logError('removeFromList', [
- 'error' => $e->getMessage(),
+ $this->logError($e->getMessage(), 'removeFromList', [
'user_id' => $user_id,
'list_id' => $list_id
]);
@@ -1181,8 +1177,7 @@
];
} catch (Exception $e) {
- $this->logError('unshareList', [
- 'error' => $e->getMessage(),
+ $this->logError($e->getMessage(),'unshareList', [
'user_id' => $user_id,
'list_id' => $list_id,
'email' => $email
@@ -1322,8 +1317,7 @@
];
} catch (Exception $e) {
- $this->logError('acceptListInvitation', [
- 'error' => $e->getMessage(),
+ $this->logError($e->getMessage(),'acceptListInvitation', [
'token' => $token,
'email' => $email
]);
diff --git a/inc/rest/routes/FormRoutes.php b/inc/rest/routes/FormRoutes.php
index baf5b9f..8cf61b9 100644
--- a/inc/rest/routes/FormRoutes.php
+++ b/inc/rest/routes/FormRoutes.php
@@ -108,7 +108,7 @@
return $this->success($result);
} catch (Exception $e) {
- $this->logError('Form submission error', [
+ $this->logError('Form submission error', 'submitForm', [
'message' => $e->getMessage(),
'trace' => $e->getTraceAsString()
]);
diff --git a/inc/rest/routes/ImporterRoutes.php b/inc/rest/routes/ImporterRoutes.php
index 682fef7..3753a4e 100644
--- a/inc/rest/routes/ImporterRoutes.php
+++ b/inc/rest/routes/ImporterRoutes.php
@@ -101,7 +101,7 @@
$results = $importer->importFromCSV($file['tmp_name'], $options);
if (is_wp_error($results)) {
- $this->logError('Client import failed', [
+ $this->logError('Client import failed','importClients', [
'error' => $results->get_error_message(),
'file' => $file['name']
]);
@@ -163,7 +163,7 @@
$results = $importer->importFromCSV($file['tmp_name'], $options);
if (is_wp_error($results)) {
- $this->logError('Sales import failed', [
+ $this->logError('Sales import failed', 'importSales', [
'error' => $results->get_error_message(),
'file' => $file['name']
]);
diff --git a/inc/rest/routes/IntegrationsHelcimRoutes.php b/inc/rest/routes/IntegrationsHelcimRoutes.php
index f735618..98b662b 100644
--- a/inc/rest/routes/IntegrationsHelcimRoutes.php
+++ b/inc/rest/routes/IntegrationsHelcimRoutes.php
@@ -93,7 +93,7 @@
return $this->success($result);
} catch (Exception $e) {
- $this->logError('Helcim checkout init failed', ['error' => $e->getMessage()]);
+ $this->logError('Helcim checkout init failed','handleInitializeCheckout', ['error' => $e->getMessage()]);
return $this->error($e->getMessage());
}
}
diff --git a/inc/rest/routes/IntegrationsRoutes.php b/inc/rest/routes/IntegrationsRoutes.php
index 8df07a2..838cdd5 100644
--- a/inc/rest/routes/IntegrationsRoutes.php
+++ b/inc/rest/routes/IntegrationsRoutes.php
@@ -58,19 +58,16 @@
$theUserID = current_user_can('manage_options') ? null : get_current_user_id();
if ($theUserID === false) {
+ error_log('User not logged in');
return $this->validationError(['message' => 'Not logged in']);
}
- $integration = $this->getService($service);
- if (!method_exists($integration, 'switchUser')) {
- return $this->validationError(['message' => 'Cannot get user integration']);
- }
+ $integration = $this->getServiceUser($service, $theUserID);
if (!$integration || !method_exists($integration, 'processAction')) {
+ error_log('No processAction method');
return $this->validationError(['message'=>'Invalid service']);
}
- $integration->switchUser($theUserID);
-
$integration->getCredentials();
// Handle the action
@@ -86,6 +83,7 @@
return $this->success($result);
} catch (Exception $e) {
+ error_log(print_r($e->getMessage(), true));
return $this->error($e->getMessage());
}
}
diff --git a/inc/rest/routes/IntegrationsSquareRoutes.php b/inc/rest/routes/IntegrationsSquareRoutes.php
index 6a5cbc9..2653fa7 100644
--- a/inc/rest/routes/IntegrationsSquareRoutes.php
+++ b/inc/rest/routes/IntegrationsSquareRoutes.php
@@ -145,7 +145,7 @@
return $this->success($result);
} catch (Exception $e) {
- $this->logError('Payment processing failed', [
+ $this->logError('Payment processing failed', 'handlePaymentProcessing',[
'error' => $e->getMessage(),
'idempotency_key' => $data['idempotency_key']
]);
diff --git a/inc/rest/routes/LoginRoutes.php b/inc/rest/routes/LoginRoutes.php
index 030cda6..9ff7f51 100644
--- a/inc/rest/routes/LoginRoutes.php
+++ b/inc/rest/routes/LoginRoutes.php
@@ -263,7 +263,7 @@
);
if (is_wp_error($user_id)) {
- $this->logError('Registration failed', [
+ $this->logError('Registration failed', 'handleRegister', [
'email' => $email,
'error' => $user_id->get_error_message(),
]);
@@ -351,7 +351,7 @@
$result = MagicLinks::getInstance()->sendMagicLink($email, 'reset');
if (is_wp_error($result)) {
- $this->logError('Magic link send failed', [
+ $this->logError('Magic link send failed', 'handleLostPassword',[
'email' => $email,
'error' => $result->get_error_message(),
]);
@@ -360,7 +360,7 @@
// Standard WordPress password reset
$key = get_password_reset_key($user);
if (is_wp_error($key)) {
- $this->logError('Reset key generation failed', [
+ $this->logError('Reset key generation failed', 'handleLostPassword', [
'email' => $email,
'error' => $key->get_error_message(),
]);
@@ -468,7 +468,7 @@
$result = MagicLinks::getInstance()->sendMagicLink($email, $type, $context);
if (is_wp_error($result)) {
- $this->logError('Magic link send failed', [
+ $this->logError('Magic link send failed', 'handleMagicLink', [
'email' => $email,
'type' => $type,
'error' => $result->get_error_message(),
@@ -597,11 +597,11 @@
try {
Referrals::getInstance()->processReferralCode($user_id, $referral_code);
} catch (Exception $e) {
- $this->logError('Referral processing failed', [
+ $this->logError('Referral processing failed', 'processReferralCode', [
'user_id' => $user_id,
'code' => $referral_code,
'error' => $e->getMessage(),
- ], 'warning');
+ ]);
}
}
diff --git a/inc/rest/routes/NotificationsRoutes.php b/inc/rest/routes/NotificationsRoutes.php
index ac1ea1a..3d6d1bb 100644
--- a/inc/rest/routes/NotificationsRoutes.php
+++ b/inc/rest/routes/NotificationsRoutes.php
@@ -610,10 +610,9 @@
} catch (Exception $e) {
global $wpdb;
$wpdb->query('ROLLBACK');
- $this->logError('markContentNotificationRead exception', [
+ $this->logError($e->getMessage(),'markContentNotificationRead', [
'seen_id' => $seen_id,
'user_id' => $user_id,
- 'error' => $e->getMessage()
]);
return [
'success' => false,
@@ -1154,8 +1153,7 @@
]);
} catch (Exception $e) {
// Don't fail the request if metrics tracking fails
- $this->logError('trackMetrics failed', [
- 'error' => $e->getMessage(),
+ $this->logError($e->getMessage(),'trackMetrics', [
'user_id' => $user_id,
'action' => $action
]);
diff --git a/inc/rest/routes/TermRoutes.php b/inc/rest/routes/TermRoutes.php
index ce0f578..aa18144 100644
--- a/inc/rest/routes/TermRoutes.php
+++ b/inc/rest/routes/TermRoutes.php
@@ -601,9 +601,9 @@
], 201); // 201 Created
} catch (Exception $e) {
- Errors::getInstance()->log(
- 'terms',
+ $this->logError(
'Term creation failed: ' . $e->getMessage(),
+ 'createTermRequest',
['user_id' => $user_id, 'taxonomy' => $taxonomy, 'name' => $name]
);
diff --git a/inc/rest/routes/UploadRoutes.php b/inc/rest/routes/UploadRoutes.php
index 4b3128e..6d0e1bf 100644
--- a/inc/rest/routes/UploadRoutes.php
+++ b/inc/rest/routes/UploadRoutes.php
@@ -239,7 +239,7 @@
$secured_files = $this->secureFiles($files, $args);
if (empty($secured_files)) {
- $this->logError('No valid files to upload');
+ $this->logError('No valid files to upload', 'handleUpload');
return $this->error('No valid files to upload');
}
@@ -250,15 +250,7 @@
} catch (Exception $e) {
// Error handling...
- Errors::getInstance()->log(
- '[UploadRoutes]:handleUploadRequest',
- $e->getMessage(),
- [
- 'request_data' => $request->get_params(),
- 'files_info' => $this->getFilesInfo($_FILES),
- 'trace' => $e->getTraceAsString()
- ]
- );
+ $this->logError($e->getMessage(), 'handleUpload');
return $this->error($e->getMessage());
}
}
@@ -485,14 +477,7 @@
);
} catch (Exception $e) {
- Errors::getInstance()->log(
- '[UploadRoutes]:handleMetadataUpdate',
- $e->getMessage(),
- [
- 'request_data' => $request->get_params(),
- 'trace' => $e->getTraceAsString()
- ]
- );
+ $this->logError($e->getMessage(), 'handleMetadataUpdate');
return $this->sendResponse(
false,
@@ -710,14 +695,8 @@
return $this->queued($ID['operation_id'], 'Files uploaded and posts queued for creation');
} catch (Exception $e) {
- Errors::getInstance()->log(
- '[UploadRoutes]:handleGroupingRequest',
- $e->getMessage(),
- [
- 'request_data' => $request->get_params(),
- 'trace' => $e->getTraceAsString()
- ]
- );
+ $this->logError($e->getMessage(), 'handleGroupingRequest');
+
return $this->error('Grouping operation failed: '.$e->getMessage());
}
}
diff --git a/inc/rest/routes/UserRoutes.php b/inc/rest/routes/UserRoutes.php
index f517a62..41bdbcf 100644
--- a/inc/rest/routes/UserRoutes.php
+++ b/inc/rest/routes/UserRoutes.php
@@ -1,4 +1,5 @@
<?php
+namespace JVBase\rest\routes;
use JVBase\Instance;
use JVBase\managers\Cache;
diff --git a/jvb.php b/jvb.php
index e74a588..e87b324 100644
--- a/jvb.php
+++ b/jvb.php
@@ -137,6 +137,9 @@
require(JVB_DIR.'/inc/Instance.php');
require(JVB_DIR.'/base/_setup.php');
+require(JVB_DIR . '/inc/managers/Errors.php');
+require(JVB_DIR . '/inc/managers/ErrorHelper.php');
+
require(JVB_DIR.'/inc/utility/setup.php');
require(JVB_DIR.'/checks.php');
@@ -404,12 +407,14 @@
use JVBase\rest\routes\ErrorRoutes;
use JVBase\rest\routes\FeedRoutes;
use JVBase\rest\routes\FormRoutes;
+use JVBase\rest\routes\IntegrationsRoutes;
use JVBase\rest\routes\NewsRoutes;
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\UserRoutes;
function jvbLoadRoutes():void
{
@@ -421,6 +426,10 @@
}
UserRoutes::getInstance();
+ if (Site::hasAnyIntegration()) {
+ IntegrationsRoutes::getInstance();
+ }
+
if (Site::has('feed_block')) {
FeedRoutes::getInstance();
}
--
Gitblit v1.10.0