Jake Vanderwerf
2026-02-04 2127b1bdd73ecd2423e443992da4b442f5a3c1a3
inc/rest/routes/FeedRoutes.php
@@ -1,11 +1,10 @@
<?php
namespace JVBase\rest\routes;
use JVBase\managers\Cache;
use JVBase\rest\RestRouteManager;
use JVBase\meta\Meta;
use JVBase\rest\Rest;
use JVBase\integrations\Umami;
use JVBase\meta\MetaManager;
use JVBase\managers\TaxonomyRelationships;
use JVBase\rest\Route;
use JVBase\utility\Checker;
use JVBase\utility\Features;
use WP_Query;
@@ -18,7 +17,7 @@
    exit; // Exit if accessed directly
}
class FeedRoutes extends RestRouteManager
class FeedRoutes extends Rest
{
   protected int $per_page = 36;
   protected ?Umami $tracker = null;
@@ -29,8 +28,8 @@
   public function __construct()
   {
      $this->cache_name = 'feed';
      $this->cache_ttl = 86400;
      $this->cacheName = 'feed';
      $this->cacheTtl = 86400;
      parent::__construct();
      $this->cache
         ->connect('post', true)
@@ -58,17 +57,51 @@
    */
   public function registerRoutes(): void
   {
      register_rest_route($this->namespace, '/feed', [
         'methods' => ['GET', 'POST'],
         'callback' => [$this, 'handleFeedRequest'],
         'permission_callback' => [$this, 'checkPermission'],
      ]);
      Route::for('feed')
         ->get([$this, 'handleFeedRequest'])
         ->args([
            'content' => 'string',
            'page' => 'integer|default:1|min:1',
            'taxonomy' => 'string',
            'match' => 'string|enum:all,any|default:all',
            'orderby' => 'string',
            'order' => 'string|enum:ASC,DESC',
            'date-filter' => 'string',
            'dateFrom' => 'string',
            'dateTo' => 'string',
            'context' => 'string',
            'source' => 'string',
            'favourites' => 'boolean',
            'user' => 'integer',
            'highlight' => 'string',
         ])
         ->auth('public')
         ->rateLimit(30, 60)
         ->post([$this, 'handleFeedRequest'])
         ->args([
            'content' => 'string',
            'page' => 'integer|default:1|min:1',
            'taxonomy' => 'string',
            'match' => 'string|enum:all,any|default:all',
            'orderby' => 'string',
            'order' => 'string|enum:ASC,DESC',
            'date-filter' => 'string',
            'dateFrom' => 'string',
            'dateTo' => 'string',
            'context' => 'string',
            'source' => 'string',
            'favourites' => 'boolean',
            'user' => 'integer',
            'highlight' => 'string',
         ])
         ->auth('public')
         ->rateLimit(30, 60);
      register_rest_route($this->namespace, 'feed/types', [
         'permission_callback' => [$this, 'checkPermission'],
         'methods' => 'GET',
         'callback' => [$this, 'getFeedTypes']
      ]);
      // Feed types endpoint
      Route::for('feed/types')
         ->get([$this, 'getFeedTypes'])
         ->auth('public')
         ->rateLimit(60, 60);
   }
   /**
@@ -102,11 +135,15 @@
            switch ($metaType) {
               case 'post':
                  $config = JVB_CONTENT[$type];
                  $meta = Meta::forPost($postID);
                  if (!$skip && array_key_exists('is_timeline', $config) && $config['is_timeline']) {
                     return $this->formatTimeline($postID, $post);
                  }
                  break;
               case 'term':
                  $meta = Meta::forTerm($postID);
                  $config = JVB_TAXONOMY[$type];
                  break;
            }
@@ -122,7 +159,6 @@
               }, ARRAY_FILTER_USE_KEY);
            }
            $meta = new MetaManager($postID, $metaType);
            $values = $meta->getAll(array_keys($fields));
            $out = [
@@ -216,7 +252,7 @@
      }
      $item = $this->formatItem($postID, 'post', true);
      //Step 1: Get the fields that apply to all posts
      $mainMeta = new MetaManager($post->ID, 'post');
      $mainMeta = Meta::forPost($post->ID);
      $item['fields'] = $mainMeta->getAll($this->timelineSharedFields);
      //Step 2: Get the fields for each individual posts
@@ -228,7 +264,7 @@
      $subFields = [];
      $images = [];
      foreach ($children as $child) {
         $meta = new MetaManager($child, 'post');
         $meta = Meta::forPost($child);
         $f = $meta->getAll($this->timelineUniqueFields);
         $f =  ['id' => $child] + $f;
         $subFields[] = $f;
@@ -385,44 +421,6 @@
      return $this->applyFavouritesFilter($args, $data);
   }
// protected function applyTaxonomyFilters(array $args, array $data): array
// {
//    if (!array_key_exists('taxonomy', $data) || empty($data['taxonomy'])) {
//       return $args;
//    }
//
//    $taxonomyFilters = $data['taxonomy'];
//
//    // Validate taxonomies exist and sanitize
//    $validFilters = [];
//    foreach ($taxonomyFilters as $taxonomy => $terms) {
//       if (!taxonomy_exists(jvbCheckBase($taxonomy))) {
//          continue;
//       }
//
//       $validFilters[] = [
//          'taxonomy' => jvbCheckBase($taxonomy),
//          'field' => 'term_id',
//          'terms' => array_map('absint', (array)$terms),
//          'operator' => 'IN'
//       ];
//    }
//
//    if (empty($validFilters)) {
//       return $args;
//    }
//
//    // Determine relation based on match filter
//    $relation = ($data['match'] ?? 'all') === 'all' ? 'AND' : 'OR';
//
//    $args['tax_query'] = array_merge(
//       ['relation' => $relation],
//       $validFilters
//    );
//
//    return $args;
// }
   /**
    * @param WP_REST_Request $request
    *
@@ -449,13 +447,13 @@
            $args['highlight'] = $highlight;
         }
         $cached['items'] = $this->processHighlightedItem($cached['items'], $args);
         $response = new WP_REST_Response($cached);
         $response = $this->success($cached);
         return $this->addCacheHeaders($response);
      }
      // Fetch and format items
      $items = $this->fetchFeedItems($args);
      $ttl = (str_contains($args['orderby'], 'RAND')) ? 300 : $this->cache_ttl;
      $ttl = (str_contains($args['orderby'], 'RAND')) ? 300 : $this->cacheTtl;
      $this->cache->set($key, $items, $ttl);
      if ($request->get_param('highlight')) {
@@ -464,86 +462,12 @@
      }
      $items['items'] = $this->processHighlightedItem($items['items'], $args);
      $response = new WP_REST_Response($items);
      $response = $this->success($items);
      return $this->addCacheHeaders($response);
   }
   /**
    * Build cache context from query args
    * Extracts content types and parameters needed for proper cache checking
    *
    * @param array $args Built WP_Query arguments
    * @param WP_REST_Request $request Original request
    * @return array Cache context with content_types and additional_params
    */
   protected function buildCacheContext(array $args, WP_REST_Request $request): array
   {
      // Extract content types from post_type in args
      $post_types = is_array($args['post_type'])
         ? $args['post_type']
         : [$args['post_type']];
      $content_types = array_map('jvbNoBase', $post_types);
      $content_types[] = 'feed'; // Always include base feed type
      // Build additional params for ETag uniqueness
      $additional_params = [
         'order' => $args['orderby'] ?? 'date',
         'direction' => $args['order'] ?? 'DESC',
         'page' => $args['paged'] ?? 1,
      ];
      if ($request->get_param('favourites')) {
         $additional_params['user'] = (int)$request->get_param('user');
      }
      // Include author filter if present (from context or favourites)
      if (!empty($args['author'])) {
         $additional_params['author'] = $args['author'];
      }
      if (!empty($args['author__in'])) {
         $additional_params['author__in'] = $args['author__in'];
      }
      // Include taxonomy filters if present
      if (!empty($args['tax_query'])) {
         $tax_filters = [];
         foreach ($args['tax_query'] as $key => $query) {
            if ($key === 'relation' || !is_array($query)) {
               continue;
            }
            $taxonomy = jvbNoBase($query['taxonomy'] ?? '');
            if ($taxonomy) {
               $tax_filters[$taxonomy] = $query['terms'] ?? [];
               // Also add taxonomy to content_types for timestamp checking
               $content_types[] = $taxonomy;
            }
         }
         if (!empty($tax_filters)) {
            $additional_params['taxonomies'] = $tax_filters;
         }
      }
      // Include date filters if present
      if (!empty($args['date_query'])) {
         $additional_params['date_filter'] = md5(serialize($args['date_query']));
      }
      // Include meta queries if present
      if (!empty($args['meta_query'])) {
         $additional_params['meta_filter'] = md5(serialize($args['meta_query']));
      }
      return [
         'content_types' => array_unique($content_types),
         'additional_params' => $additional_params
      ];
   }
   /**
    * @param array $args Formatted Args for WP_Query
    * @param array $items Formatted Args for WP_Query
    * @param array $data parsed Request Data
    *
    * @return array|null
@@ -597,35 +521,39 @@
               : explode(',', $args['post_type']);
            // Check if filtering global feed content
            $globalFeedTypes = array_map('jvbCheckBase',
               array_keys(Features::getTypesWithFeature('show_feed', 'content'))
            if (in_array($context['type'], jvbGlobalFeedContentTaxonomies())) {
               // Global: show posts from any content type with this taxonomy
               $for_content = JVB_TAXONOMY[$context['type']]['for_content'] ?? [];
               if (empty($for_content)) {
                  // Fall back to any content that has this taxonomy registered
                  $for_content = array_keys(
                     array_filter(
                        JVB_CONTENT,
                        fn($c) => in_array($context['type'], $c['taxonomies'] ?? [])
                     )
            );
               }
            if (array_intersect($args['post_type'], $globalFeedTypes)) {
               $artists = jvbGetContentUsers($context['id']);
               if (!empty($artists)) {
                  $args['author__in'] = $artists;
               // Convert to full post types with BASE prefix
               $post_types = array_map(fn($type) => BASE . $type, $for_content);
               // Filter to only show_feed content types
               $show_feed_types = Features::getTypesWithFeature('show_feed', 'content');
               $args['post_type'] = array_intersect(
                  $post_types,
                  array_map(fn($type) => BASE . $type, $show_feed_types)
               );
               }
            } else {
               $args['tax_query'] = [
                  'relation' => 'AND',
                  [
                     'taxonomy' => BASE . $context['type'],
                     'terms' => $context['id'],
                  ]
               ];
            }
            break;
         case taxonomy_exists(jvbCheckBase($context['type'])):
            $args['tax_query'] = [
               'relation' => 'AND',
               [
                  'taxonomy' => BASE . $context['type'],
                  'terms' => $context['id'],
               ]
            // Add term to tax query
            $args['tax_query'][] = [
               'taxonomy' => jvbCheckBase($context['type']),
               'field' => 'term_id',
               'terms' => [(int)$context['id']],
            ];
            break;
      }
      return $args;
   }
@@ -635,38 +563,34 @@
    *
    * @return array
    */
   protected function applyFavouritesFilter(array $args, array $filters): array
   protected function applyFavouritesFilter(array $args, array $data): array
   {
      if (!array_key_exists('favourites', $filters)) {
      if (empty($data['favourites']) || empty($data['user'])) {
         return $args;
      }
      global $wpdb;
      // Get post types for the current filter
      $post_types = is_array($args['post_type'])
         ? $args['post_type']
         : [$args['post_type']];
      $user_id = (int)$data['user'];
      $content = jvbNoBase($args['post_type']);
      $favourites_table = $wpdb->prefix . BASE . 'favourites';
      $placeholders = implode(',', array_fill(0, count($post_types), '%s'));
      $favourited_ids = $wpdb->get_col($wpdb->prepare(
         "SELECT target_id FROM {$favourites_table}
            WHERE user_id = %d AND type IN ($placeholders)",
         array_merge(
            [get_current_user_id()],
            $post_types
         )
      ));
      // Get user's favourites for this content type
      $fav_key = BASE . 'favourites_' . $content;
      $favourites = get_user_meta($user_id, $fav_key, true);
      if (empty($favourited_ids)) {
         // Force empty results
      if (empty($favourites)) {
         // No favourites - return empty result
         $args['post__in'] = [0]; // Will return no results
         return $args;
      }
      $fav_ids = array_filter(array_map('intval', explode(',', $favourites)));
      if (empty($fav_ids)) {
         $args['post__in'] = [0];
         return $args;
      }
      $args['post__in'] = isset($args['post__in'])
         ? array_intersect($args['post__in'], $favourited_ids)
         : $favourited_ids;
      $args['post__in'] = $fav_ids;
      $args['orderby'] = 'post__in'; // Preserve favourite order
      return $args;
   }
@@ -1235,7 +1159,7 @@
      $feedTypes = $this->buildFeedTypesConfig();
      $response = new WP_REST_Response($feedTypes);
      $response = $this->success($feedTypes);
      return $this->addCacheHeaders($response);
   }