Jake Vanderwerf
2026-04-26 86c6cd3cc099d2480932ede03c12cea01e625c94
inc/rest/RestRouteManager.php
@@ -1,12 +1,13 @@
<?php
namespace JVBase\rest;
use JVBase\JVB;
use JVBase\rest\RateLimiter;
use DateTime;
use DateTimeZone;
use JVBase\registrar\Registrar;
use JVBase\managers\OperationQueue;
use JVBase\managers\CacheManager;
use JVBase\managers\Cache;
use JVBase\managers\NotificationManager;
use JVBase\utility\Features;
use JVBase\base\Site;
use WP_REST_Request;
use WP_Error;
use Exception;
@@ -17,6 +18,7 @@
}
/**
 * @deprecated use Rest.php
 * Handles route registration and high-level coordination
 */
abstract class RestRouteManager
@@ -28,12 +30,12 @@
    protected string $route;
    protected string $base;
    protected string $content_type; //the registered post type
    protected string $type; //post, user, term, for MetaManager
    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 CacheManager $cache;
    protected Cache $cache;
    protected NotificationManager $notifications;
    protected string $cache_name ='';
    protected int $cache_ttl = 3600; //1 hour default
@@ -51,7 +53,7 @@
        $this->base = BASE;
        $this->rate_limiter = new RateLimiter();
        if ($this->cache_name !== '') {
            $this->cache = CacheManager::for($this->cache_name, $this->cache_ttl);
            $this->cache = Cache::for($this->cache_name, $this->cache_ttl);
        }
        add_action('rest_api_init', [$this, 'registerRoutes']);
    }
@@ -128,6 +130,32 @@
      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
   {
      $result = JVB_CONTENT[$content]??JVB_TAXONOMY[$content]??JVB_USER[$content]??'';
@@ -201,11 +229,11 @@
    */
   protected function checkUser(int $userID): bool
   {
      $cache = CacheManager::for('users');
      $cache = Cache::for('checkUser', DAY_IN_SECONDS)->connect('user');
      return $cache->remember("user_exists_{$userID}", function() use ($userID) {
      return $cache->remember($userID, function() use ($userID) {
         return (bool)get_userdata($userID);
      }, DAY_IN_SECONDS);
      });
   }
   /**
@@ -213,11 +241,11 @@
    */
   protected function checkShop(int $shopID): bool
   {
      $cache = CacheManager::for('shop');
      $cache = Cache::for('checkShop',DAY_IN_SECONDS)->connect('taxonomy');
      return $cache->remember("shop_exists_{$shopID}", function() use ($shopID) {
      return $cache->remember($shopID, function() use ($shopID) {
         return (bool)term_exists($shopID, BASE . 'shop');
      }, DAY_IN_SECONDS);
      });
   }
   /**
@@ -236,11 +264,11 @@
      }
      $taxonomy = jvbCheckBase($taxonomy);
      $cache = CacheManager::for($taxonomy);
      $cache = Cache::for('checkTerm', DAY_IN_SECONDS)->connect('taxonomy');
      return $cache->remember("term_exists_{$termID}", function() use ($termID, $taxonomy) {
      return $cache->remember($termID, function() use ($termID, $taxonomy) {
         return (bool)term_exists($termID, $taxonomy);
      }, DAY_IN_SECONDS);
      });
   }
   /**
@@ -248,63 +276,101 @@
    */
   public function isVerifiedUser(int $user_id): bool
   {
      $cache = CacheManager::forUser($user_id);
      $cache = Cache::for('verifiedUsers', DAY_IN_SECONDS)->connect('user');
      return $cache->remember('is_verified', function() use ($user_id) {
      return $cache->remember($user_id, function() use ($user_id) {
         return user_can($user_id, 'skip_moderation');
      }, DAY_IN_SECONDS);
      });
   }
    protected function applyTaxonomyFilters(array $args, array $data):array
    {
        $taxQuery = [];
      foreach($data['taxonomies']??[] as $taxonomy => $terms) {
         if (array_key_exists(jvbNoBase($taxonomy), JVB_TAXONOMY)) {
            $taxQuery[] = [
               'taxonomy'  => jvbCheckBase($taxonomy),
               'terms'     => array_map(
                  'absint',
                  is_array($terms) ? $terms : explode(',', $terms)
               )
            ];
         }
   /**
    * @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);
      }
        if (!empty($taxQuery)) {
            $args['tax_query'] = array_merge([
                'relation'  => (array_key_exists('match', $data)) ? 'AND' : 'OR',
            ], $taxQuery);
        }
      $taxonomies = $data['taxonomies'] ?? $data['taxonomy'] ?? [];
      $taxQuery = [];
        $authorQuery = [];
        foreach (jvbAuthorUsers() 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);
        }
      foreach($taxonomies as $taxonomy => $terms) {
         // Better validation: check if taxonomy actually exists
         if (!taxonomy_exists(jvbCheckBase($taxonomy))) {
            continue;
         }
        return $args;
    }
         $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::getFeatured('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 = jvbGetRandomSeed();
            $current_seed = floor(time() / 1800);
            $args['orderby'] = 'RAND(' . $current_seed . ')';
            unset($args['order']);
            return $args;
        }
        if (in_array($data['orderby'], ['date', 'title', 'alphabetical'])) {
            $args['orderby'] = ($data['orderby'] === 'alphabetical') ? 'title' : $data['orderby'];
        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':
@@ -315,8 +381,18 @@
                    $args['meta_key'] = BASE.'karma';
                    $args['orderby'] = 'meta_value_num';
                    break;
                default:
                    $args['orderby'] = 'date';
            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';
@@ -325,6 +401,92 @@
        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::getFeatured('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)) {
@@ -416,55 +578,52 @@
    */
   protected function checkHeaders(
      WP_REST_Request $request,
      string|array $content_types,
      array $additional_params = []
   ): WP_REST_Response|null {
      // Get latest timestamp for the content type(s)
      $last_modified = CacheManager::getTimestamp($content_types);
      // Generate ETag from request params + timestamp
      $etag = $this->generateETag($request->get_params(), $additional_params, $last_modified);
      // Check If-None-Match (ETag) header
      $if_none_match = $request->get_header('If-None-Match');
      if ($if_none_match === $etag) {
         return $this->createNotModifiedResponse($etag, $last_modified);
      }
      // Check If-Modified-Since header
      $if_modified_since = $request->get_header('If-Modified-Since');
      if ($if_modified_since) {
         $if_modified_timestamp = strtotime($if_modified_since);
         if ($last_modified <= $if_modified_timestamp) {
            return $this->createNotModifiedResponse($etag, $last_modified);
         }
      }
      // Content has changed - store headers to add to successful response
      $this->response_headers = $this->buildCacheHeaders($etag, $last_modified);
      return null; // Continue processing
   }
   /**
    * Generate ETag from request parameters and timestamp
    *
    * @param array $params Request parameters
    * @param array $additional Additional parameters for uniqueness
    * @param int $timestamp Last modified timestamp
    * @return string ETag value with quotes
    */
   private function generateETag(array $params, array $additional, int $timestamp): string
      int|string|array $key,
      string|array $group = ''
   ): WP_REST_Response|false
   {
      // Combine all data that makes this response unique
      $etag_data = array_merge(
         $params,
         $additional,
         ['t' => $timestamp]
      );
      $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);
      }
      return '"' . md5(serialize($etag_data)) . '"';
      // Prefer tag freshness if available
      $tags = $cache->getTags();
      $lastModified = $tags
         ? $cache->getLastModifiedForTags($tags)
         : $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;
   }
   /**
@@ -507,40 +666,22 @@
   protected function addCacheHeaders(WP_REST_Response $response): WP_REST_Response
   {
      if (!empty($this->response_headers)) {
         $response->set_headers($this->response_headers);
         $this->response_headers = []; // Clear after use
         foreach ($this->response_headers as $name => $value) {
            $response->header($name, $value);
         }
         $this->response_headers = [];
      }
      return $response;
   }
   /**
    * Helper: Check headers for user-specific endpoints
    * Automatically includes user_id in ETag
    *
    * @param WP_REST_Request $request The REST request
    * @param int $user_id User ID
    * @param string|array $content_types Content type(s)
    * @return WP_REST_Response|null
    */
   protected function checkUserHeaders(
      WP_REST_Request $request,
      int $user_id,
      string|array $content_types = 'user'
   ): WP_REST_Response|null {
      // Include user-specific timestamp
      $types = is_array($content_types) ? $content_types : [$content_types];
      $types[] = "user_{$user_id}";
      return $this->checkHeaders($request, $types, ['user_id' => $user_id]);
   }
   /**
    * 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
      ];
@@ -556,6 +697,7 @@
    */
   protected function success(array $data, int $status = 200): WP_REST_Response
   {
      $data['success'] = true;
      return new WP_REST_Response($data, $status);
   }
@@ -910,7 +1052,7 @@
   protected function verifyTurnstile(string $token): bool
   {
      if (!Features::hasIntegration('cloudflare') || !JVB()->connect('cloudflare')->isSetUp()) {
      if (!Site::hasIntegration('cloudflare') || !JVB()->connect('cloudflare')->isSetUp()) {
         return true;
      }
@@ -921,121 +1063,3 @@
      return JVB()->connect('cloudflare')->verifyTurnstile($token);
   }
}
//
//Simple example:
//public function getTattoos(WP_REST_Request $request): WP_REST_Response
//{
// // Check HTTP cache headers first
// $cache_check = $this->checkHeaders($request, 'tattoo');
// if ($cache_check) {
//    return $cache_check; // Returns 304 Not Modified
// }
//
// // Get data (use CacheManager for data caching too!)
// $filters = $request->get_params();
// $cache = CacheManager::for('tattoo');
//
// $tattoos = $cache->remember($filters, function() use ($filters) {
//    return $this->queryTattoos($filters);
// }, 300);
//
// $response = new WP_REST_Response(['items' => $tattoos]);
// return $this->addCacheHeaders($response); // Add ETag and Last-Modified
//}
//
//Multiple Content Types:
//public function getTermsWithContent(WP_REST_Request $request): WP_REST_Response
//{
// $taxonomy = $request->get_param('taxonomy');
//
// // Check both taxonomy and its content types
// $cache_check = $this->checkHeaders($request, [$taxonomy, 'tattoo', 'artwork']);
// if ($cache_check) {
//    return $cache_check;
// }
//
// // ... fetch data ...
//
// $response = new WP_REST_Response($data);
// return $this->addCacheHeaders($response);
//}
//
//User-specific:
//public function getUserFavorites(WP_REST_Request $request): WP_REST_Response
//{
// $user_id = $request->get_param('user');
//
// // Automatically checks user_{$user_id} timestamp + includes user_id in ETag
// $cache_check = $this->checkUserHeaders($request, $user_id);
// if ($cache_check) {
//    return $cache_check;
// }
//
// // Get user's favorites (cached per user)
// $favorites = CacheManager::forUser($user_id)->remember('favorites', function() use ($user_id) {
//    return $this->getUserFavorites($user_id);
// }, 1800);
//
// $response = new WP_REST_Response(['items' => $favorites]);
// return $this->addCacheHeaders($response);
//}
//
//Complex with additional params:
//public function getFilteredContent(WP_REST_Request $request): WP_REST_Response
//{
// $user_id = get_current_user_id();
// $filters = $request->get_params();
//
// // Include custom params in ETag for uniqueness
// $cache_check = $this->checkHeaders(
//    $request,
//    'tattoo',
//    [
//       'user_id' => $user_id,
//       'is_verified' => $this->isVerifiedUser($user_id)
//    ]
// );
//
// if ($cache_check) {
//    return $cache_check;
// }
//
// // ... fetch filtered data ...
//
// $response = new WP_REST_Response($data);
// return $this->addCacheHeaders($response);
//}
/**
 * Use operation lock in your methods like this:
 *
 * public function updateContent(WP_REST_Request $request): WP_REST_Response
 * {
 *     $user_id = get_current_user_id();
 *     $content_id = $request->get_param('content_id');
 *
 *     // Prevent concurrent updates
 *     $lock_key = "update_{$user_id}_{$content_id}";
 *     if (!$this->acquireOperationLock($lock_key)) {
 *         return $this->error(
 *             'An update is already in progress. Please wait.',
 *             'concurrent_operation',
 *             409
 *         );
 *     }
 *
 *     try {
 *         // Do your operation
 *         $result = $this->doUpdate($content_id);
 *
 *         $this->releaseOperationLock($lock_key);
 *         return $this->success($result);
 *
 *     } catch (\Exception $e) {
 *         $this->releaseOperationLock($lock_key);
 *         return $this->error($e->getMessage(), 'operation_failed');
 *     }
 * }
 */