Jake Vanderwerf
2025-11-23 d7dbe7fee362d587dfc334135d9581b6216a4295
inc/rest/routes/FeedRoutes.php
@@ -1,10 +1,13 @@
<?php
namespace JVBase\rest\routes;
use JVBase\managers\CacheManager;
use JVBase\rest\RestRouteManager;
use JVBase\integrations\Umami;
use JVBase\meta\MetaManager;
use JVBase\managers\TaxonomyRelationships;
use JVBase\utility\Checker;
use JVBase\utility\Features;
use WP_Query;
use WP_Post;
use WP_Term;
@@ -14,25 +17,54 @@
if (!defined('ABSPATH')) {
    exit; // Exit if accessed directly
}
/**
 * FeedRoutes - Optimized API endpoints for the feed block
 * TODO: Look at Content Routes' setup and make this more like that one; it's a bit more organized
 * Or NewsRoutes
 */
class FeedRoutes extends RestRouteManager
{
    protected int $per_page = 36;
    protected Umami $tracker;
   protected ?Umami $tracker = null;
   protected ?Checker $checker = null;
   protected ?array $fields = null;
   protected ?array $timelineSharedFields = null;
   protected ?array $timelineUniqueFields = null;
    public function __construct()
    {
        $this->cache_name = 'feed';
        $this->cache_ttl = 86400;
      parent::__construct();
   }
   public function init():void
   {
      $this->checker = Checker::getInstance();
        if (jvbSiteUsesUmami()) {
            $this->tracker = JVB()->connect('umami');
        }
        parent::__construct();
      $this->cache->clear();
      $this->setupCacheConnections();
   }
   /**
    * Set up cache connections for automatic invalidation
    */
   protected function setupCacheConnections(): void
   {
      // Connect to all content types with show_feed
      $contentTypes = Features::getTypesWithFeature('show_feed', 'content');
      foreach ($contentTypes as $type) {
         CacheManager::for('feed_item_'.$type)->connectTo('post');
         $this->cache->connectTo('post', $type);
      }
      // Connect to all taxonomies with show_feed
      $taxonomies = Features::getTypesWithFeature('show_feed', 'taxonomy');
      foreach ($taxonomies as $tax) {
         CacheManager::for('feed_item_'.$tax)->connectTo('taxonomy');
         $this->cache->connectTo('taxonomy', $tax);
      }
    }
    /**
@@ -46,6 +78,12 @@
            'callback' => [$this, 'handleFeedRequest'],
            'permission_callback' => [$this, 'checkPermission'],
        ]);
      register_rest_route($this->namespace, 'feed/types', [
         'permission_callback' => [$this, 'checkPermission'],
         'methods' => 'GET',
         'callback' => [$this, 'getFeedTypes']
      ]);
    }
    /**
@@ -54,172 +92,188 @@
     *
     * @return array
     */
    protected function formatItem(int $postID, string $type = 'post'):array
   protected function formatItem(int $postID, string $type = 'post', $skip = false): array
    {
        switch ($type) {
            case 'post':
                $post = get_post($postID);
                $type = jvbNoBase($post->post_type);
                $metaType = 'post';
            $cache = CacheManager::for('feed_item_'.$type);
                break;
            default:
                $post = get_term($postID, jvbCheckBase($type));
                $type = jvbNoBase($type);
                $metaType = 'term';
            $cache = CacheManager::for('feed_item_'.$type);
                break;
        }
        if (!$post || is_wp_error($post)) {
            return [];
        }
        $formatted = $this->cache->get($postID, $type);
//        $formatted = false;
        if ($formatted) {
            return $formatted;
      return $cache->remember($postID,
         function() use ($postID, $type, $metaType, $post, $skip) {
            $config = null;
            switch ($metaType) {
               case 'post':
                  $config = JVB_CONTENT[$type];
                  if (!$skip && array_key_exists('is_timeline', $config) && $config['is_timeline']) {
                     return $this->formatTimeline($postID, $post);
        }
                  break;
               case 'term':
                  $config = JVB_TAXONOMY[$type];
                  break;
            }
            if (!$config) {
               return [];
            }
            $fields = $config['fields'];
        $fields = apply_filters(
            'jvbFeedFields',
            [],
            $type
        );
            //Allow custom filtering for public fields
            if (array_key_exists('feed', $config) && array_key_exists('fields', $config['feed'])) {
               $fields = array_filter($fields, function($field) use ($config) {
                  return in_array($field, $config['feed']['fields']);
               }, ARRAY_FILTER_USE_KEY);
            }
        $meta = new MetaManager($postID, $metaType);
        $formatted = [
            'id'    => $postID,
            'icon'  => $type,
            $values = $meta->getAll(array_keys($fields));
            $out = [
               'fields' => $values,
        ];
        if (jvbSiteUsesUmami()) {
            $args = ($metaType === 'post') ? [ 'owner_id' => $post->post_author] : [];
            $formatted['umami_view'] = $this->tracker->trackFeedView($postID, $type, $args);
            $formatted['umami_fav'] = $this->tracker->trackFavouriteToggle($postID, $type, false);
            //Format Taxonomies
            $temp = array_filter($fields, function($field) {
               return $field['type'] === 'taxonomy';
            });
            foreach ($temp as $key => $config) {
               if (array_key_exists($key, $out) && $out[$key] !== '') {
                  $IDs = array_map('absint', explode(',', $out[$key]));
                  $data = [];
                  $icon = JVB_TAXONOMY[$config['taxonomy']]['icon']??'triangle';
                  foreach ($IDs as $ID) {
                     $term = get_term($ID, jvbCheckBase($config['taxonomy']));
                     if ($term && !is_wp_error($term)) {
                        $data[$ID] = [
                           'id'  => $ID,
                           'icon'   => $icon,
                           'name'   => $term->name,
                           'url' => get_term_link($ID, jvbCheckBase($config['taxonomy'])),
                        ];
                        if ($this->tracker) {
                           $data[$ID]['umami_click'] = $this->tracker->trackClick($ID, $config['taxonomy'], ['from' => $type.'_'.$postID]);
        }
                     }
                  }
                  if (!empty($data)) {
                     $out['fields'][$key] = $data;
                  }
               }
            }
            //Add images
            $imgIDs = [];
            $temp = array_filter($fields, function($field) {
               return in_array($field['type'], [ 'upload', 'image', 'gallery']);
            });
            foreach ($temp as $key => $config) {
               if (array_key_exists($key, $out) && $out[$key] !== '') {
                  $IDs = array_map('absint', explode(',',$out[$key]));
                  foreach ($IDs as $ID) {
                     $imgIDs[$ID] = jvbImageData($ID);
                  }
               }
            }
            $out['images'] = $imgIDs;
            $out['id'] = $postID;
            $out['content'] = $type;
            $out['icon'] = $config['icon']??'triangle';
            if ($this->tracker) {
               $args = ($metaType === 'post') ? ['owner_id' => $post->post_author] : [];
               $out['umami_view'] = $this->tracker->trackFeedView($postID, $type, $args);
               $out['umami_fav'] = $this->tracker->trackFavouriteToggle($postID, $type, false);
               $out['umami_click'] = $this->tracker->trackClick($postID, $type);
            }
        switch ($metaType) {
            case 'term':
                if (jvbSiteUsesUmami()) {
                    $formatted['umami_click'] = $this->tracker->trackTaxonomyClick($postID, $type);
                }
                $owner = (in_array($type, jvbContentTaxonomies()) ? $meta->getValue('owner') : null);
                if (!is_null($owner)) {
                    $formatted['user_id'] = $owner;
                     $out['user_id'] = $owner;
                }
                $formatted['url'] = get_term_link($postID, $type);
                  $out['url'] = get_term_link($postID, $type);
                break;
            default:
                $formatted = array_merge($formatted, [
                    'date'      => $post->post_date,
                    'user_id'   => (int)$post->post_author,
                    'url'       => get_the_permalink($postID),
                ]);
               case 'post':
                  $out['date'] = $post->post_date;
                  $out['user_id'] = (int)$post->post_author;
                  $out['url'] = get_the_permalink($postID);
                break;
        }
        $order = array_keys($fields);
        foreach ($fields as $field => $config) {
            $value = [];
            if ($field === 'umami_click') {
                if ($config === 'profile') {
                    $formatted['umami_click'] = $this->tracker->trackProfileClick($postID, $type);
            return $out;
                }
                if ($config === 'contentTaxonomy') {
                    $formatted['umami_click'] = $this->tracker->trackContentTaxonomyClick($postID, $type);
                }
            } else {
                if (array_key_exists('field', $config)) {
                    if ($field === 'image' && array_key_exists('gallery', $config)) {
                        //Array === post types
                        if (is_array($config['gallery'])) {
                            $posts = get_posts([
                                'post_type' => array_map(function ($item) { return BASE.$item; }, $config['gallery']),
                                'author'    => $post->post_author,
                                'posts_per_page'    => 5,
                                'orderby'   => 'date',
                                'order'     => 'DESC',
                            ]);
                            $formatted['content'] = array_map(function ($content) {
                                return [
                                    'url'   => get_permalink($content->ID),
                                    'title' => $content->post_title,
                                    'image' => jvbImageData((int)get_post_thumbnail_id($content->ID)),
                                ];
                            }, $posts);
                        } else {
                            //String === $meta
                            $ids = explode(',', $meta->getValue($config['gallery']));
                            $formatted['content'] = array_map(function ($id) {
                                return [
                                    'image' => jvbImageData((int)$id)
                                ];
                            }, $ids);
                        }
                    }
                    switch ($config['field']) {
                        case 'post_author':
                            $author = $this->getAuthorData($post);
                            $value = [
                                'value' => $author['value'],
                                'url'   => $author['url']
                            ];
                            break;
                        case 'name':
                            $value = $post->name;
                            break;
                        case 'post_title':
                            $value = $post->post_title;
                            break;
                        case 'image':
                        case 'image_portrait':
                        case 'featured_image':
                            $value = $meta->getValue($config['field']);
                            $value =  jvbImageData((int)$value);
                            break;
                        case 'top_style':
                        case 'city':
                        case 'top_theme':
                            $terms = explode(',', $meta->getValue($field));
                            $terms = array_filter(array_map(function ($termID) use ($config, $postID, $type) {
                                $term = get_term($termID, jvbCheckBase($config['icon']));
                                if ($term && !is_wp_error($term)) {
                                    return $this->formatTaxonomy($term, $postID, $type);
                                }
                                return [];
                            }, $terms));
                            $value = [
                                'terms' => $terms
                            ];
                            break;
                        default:
                            $value = [
                                'value' => $meta->getValue($field)
                            ];
      );
                    }
                } elseif (array_key_exists('taxonomy', $config)) {
                    $terms = get_the_terms($postID, BASE.$config['taxonomy']);
                    if ($terms && !is_wp_error($terms)) {
                        $terms = array_map(function ($term) use ($postID, $type) {
                            return $this->formatTaxonomy($term, $postID, $type);
                        }, $terms);
                        $value = [
                            'terms'     => $terms
                        ];
   protected function initTimelineFields(string $content):void
   {
      $content = jvbNoBase($content);
      if (!Features::forContent($content)->has('is_timeline')){
         return;
                    }
      $config = Features::getConfig($content);
      $this->fields = $config['fields'];
      $this->timelineSharedFields = array_keys(array_filter($this->fields, function ($field) {
         if (!array_key_exists('for_all', $field) || $field['for_all'] === false){
            return true;
         }
         return false;
      }));
      array_unshift($this->timelineSharedFields, 'post_thumbnail');
      array_unshift($this->timelineSharedFields, 'post_title');
      $this->timelineUniqueFields = array_keys(array_filter($this->fields, function ($field) {
         if (array_key_exists('for_all', $field) && $field['for_all'] === true) {
            return true;
         }
         return false;
      }));
                }
                if (array_key_exists('label', $config)) {
                    $value['label'] = $config['label'];
   protected function formatTimeline(int $postID, WP_Post $post):array
   {
      if (!$this->timelineSharedFields || !$this->timelineUniqueFields){
         $this->initTimelineFields($post->post_type);
                }
                if (array_key_exists('icon', $config)) {
                    $value['icon'] = $config['icon'];
                }
                $formatted[$field] = $value;
            }
        }
      $item = $this->formatItem($postID, 'post', true);
      //Step 1: Get the fields that apply to all posts
      $mainMeta = new MetaManager($post->ID, 'post');
      $item['fields'] = $mainMeta->getAll($this->timelineSharedFields);
        $formatted['order'] = $order;
        $this->cache->set($postID, $formatted, $type);
      //Step 2: Get the fields for each individual posts
      $children = get_children(['post_parent' => $post->ID, 'orderby' => 'date', 'order' => 'ASC', 'post_status' => ['publish'], 'fields'=> 'ids']);
      array_unshift($children, $post->ID);
        return $formatted;
      $subFields = [];
      $images = [];
      foreach ($children as $child) {
         $meta = new MetaManager($child, 'post');
         $f = $meta->getAll($this->timelineUniqueFields);
         $f =  ['id' => $child] + $f;
         $subFields[] = $f;
         $images[$f['post_thumbnail']] = jvbImageData((int) $f['post_thumbnail']);
      }
      $item['fields']['order'] = $subFields;
      $item['images'] = $item['images'] + $images;
      return $item;
    }
    protected function formatTaxonomy(WP_Term $term, int $postID, string $type)
@@ -233,6 +287,7 @@
            ])
        ];
    }
    protected function getAuthorData(WP_Post $post)
    {
        $author = $this->cache->get($post->post_author, 'author_data');
@@ -280,11 +335,10 @@
    protected function buildRequestArgs(WP_REST_Request $request):array
    {
        global $jvb_everything;
        $data = $request->get_params();
        error_log('Feed Request: '.print_r($data, true));
        $args = [
            'post_type'         => (array_key_exists($data['content'], $jvb_everything)) ?
         'post_type' => (array_key_exists($data['content'], $this->buildFeedTypesConfig())) ?
                BASE.$data['content'] :
                BASE.array_key_first(JVB_CONTENT),
            'paged'             => intval($data['page'] ?? 1),
@@ -294,7 +348,7 @@
            $args = $this->applyContextFilters(
                $args,
                [
                    'id' => $data['source'],
               'id' => $data['source']??'0',
                    'type'=>$data['context']
                ]
            );
@@ -305,9 +359,48 @@
        $args = $this->applyDateFilters($args, $data);
        $args = $this->applyFavouritesFilter($args, $data);
      error_log('Final Args: '.print_r($args, true));
      return $args;
   }
   protected function applyTaxonomyFilters(array $args, array $data): array
   {
      if (!isset($data['taxonomy']) || 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
     *
@@ -316,24 +409,18 @@
    public function handleFeedRequest(WP_REST_Request $request):WP_REST_Response
    {
        $args = $this->buildRequestArgs($request);
      $cacheContext = $this->buildCacheContext($args, $request);
        error_log('Final Args: '.print_r($args, true));
      // Determine content type(s) for cache checking
      $content_types = [];
      if (!empty($data['content'])) {
         $content_types[] = $data['content'];
      }
      if (!empty($data['type'])) {
         $types = is_array($data['type']) ? $data['type'] : [$data['type']];
         $content_types = array_merge($content_types, $types);
      }
      // Check HTTP cache headers first
      $cache_check = $this->checkHeaders($request, $content_types ?: ['feed']);
      $cache_check = $this->checkHeaders(
         $request,
         $cacheContext['content_types'],
         $cacheContext['additional_params']
      );
      if ($cache_check) {
         return $cache_check; // Returns 304 Not Modified
      }
      error_log('Feed Request Args: '.print_r($args, true));
        $key = $this->cache->generateKey($args);
        $cached = $this->cache->get($key);
        if ($cached) {
@@ -348,6 +435,8 @@
        // Fetch and format items
        $items = $this->fetchFeedItems($args);
      error_log('Feed Got items: ' .print_r($items, true));
        $ttl = (str_contains($args['orderby'], 'RAND')) ? 1800 : $this->cache_ttl;
        $this->cache->set($key, $items, $ttl);
@@ -363,6 +452,80 @@
    }
    /**
    * 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 $data parsed Request Data
     *
@@ -404,9 +567,6 @@
     */
    protected function applyContextFilters(array $args, array $context):array
    {
        error_log('Args at Context Filters: '.print_r($args, true));
        error_log('Request at Context Filters: '.print_r($context, true));
        if (!isset($context['type'])) {
            return $args;
        }
@@ -416,8 +576,16 @@
                $args['author'] = (int)get_post_meta($context['id'], BASE.'link', true);
                break;
            case taxIsJVBContentTax($context['type']):
                $args['post_type'] = (is_array($args['post_type'])) ? $args['post_type'] : explode(',',$args['post_type']);
                if (array_intersect($args['post_type'], array_map(function ($type) { return jvbCheckBase($type); },array_keys(jvbGlobalFeedContent())))) {
            $args['post_type'] = is_array($args['post_type'])
               ? $args['post_type']
               : explode(',', $args['post_type']);
            // Check if filtering global feed content
            $globalFeedTypes = array_map('jvbCheckBase',
               array_keys(Features::getTypesWithFeature('show_feed', 'content'))
            );
            if (array_intersect($args['post_type'], $globalFeedTypes)) {
                    $artists = jvbGetContentUsers($context['id']);
                    if (!empty($artists)) {
                        $args['author__in'] = $artists;
@@ -460,7 +628,9 @@
        global $wpdb;
        // Get post types for the current filter
        $post_types = explode(',', $args['post_type']);
      $post_types = is_array($args['post_type'])
         ? $args['post_type']
         : [$args['post_type']];
        $favourites_table = $wpdb->prefix . BASE . 'favourites';
        $placeholders = implode(',', array_fill(0, count($post_types), '%s'));
@@ -494,7 +664,13 @@
     */
    protected function fetchFeedItems(array $args):array
    {
        if (in_array($args['post_type'], jvbContentTaxonomies())) {
      $postType = is_array($args['post_type']) ? $args['post_type'][0] : $args['post_type'];
      $slug = jvbNoBase($postType);
      if (Features::forContent($slug)->has('is_timeline')) {
         $args['post_parent'] = 0;
      }
      if (in_array($slug, Features::getTypesWithFeature('is_content', 'taxonomy'))) {
            return $this->handleContentTaxonomies($args);
        }
        $args['fields'] = 'ids';
@@ -506,9 +682,7 @@
        update_object_term_cache($query->posts, $args['post_type']);
        // Format regular items
        $items = array_map(function ($post) {
            return $this->formatItem($post);
        }, $query->posts);
      $items = array_map(fn($post) => $this->formatItem($post), $query->posts);
        wp_reset_postdata();
        return [
@@ -544,9 +718,10 @@
        $items = [];
        if ($total > 0) {
            $results = $wpdb->get_results($queryBuilder['main_query'],ARRAY_A);
            $items = array_map(function ($ID) use ($taxonomy) {
                return $this->formatItem($ID['term_id'], $taxonomy);
            }, $results);
         $items = array_map(
            fn($ID) => $this->formatItem($ID['term_id'], $taxonomy),
            $results
         );
        }
        $page = $args['paged'] ?? 1;
@@ -559,6 +734,7 @@
            'total' => $total
        ];
    }
    /**
     * Build SQL query components for custom table
     * @param array $args WP_Query style arguments
@@ -1030,4 +1206,76 @@
    {
        return jvbContentTaxonomiesTableFields($taxonomy)['fields'] ?? [];
    }
   /**
    * Get available feed types (for block editor)
    * Returns structured data about content types that can be shown in feed
    */
   public function getFeedTypes(WP_REST_Request $request): WP_REST_Response
   {
      // Check HTTP cache
      $cache_check = $this->checkHeaders($request, ['feed_types']);
      if ($cache_check) {
         return $cache_check;
      }
      $feedTypes = $this->buildFeedTypesConfig();
      $response = new WP_REST_Response($feedTypes);
      return $this->addCacheHeaders($response);
   }
   public function getFeedTypesConfig():array
   {
      return $this->buildFeedTypesConfig();
   }
   /**
    * Build feed types configuration from Features
    */
   protected function buildFeedTypesConfig(): array
   {
      if (!$this->checker) {
         $this->checker = Checker::getInstance();
      }
      return $this->cache->remember(
         'contentTypes',
         function () {
            $config = [];
            // Get content types with show_feed
            $contentTypes = Features::getTypesWithFeature('show_feed', 'content');
            foreach ($contentTypes as $slug) {
               $contentConfig = JVB_CONTENT[$slug] ?? null;
               if (!$contentConfig) continue;
               $config[$slug] = [
                  'type' => 'content',
                  'singular' => $contentConfig['singular'] ?? ucfirst($slug),
                  'plural' => $contentConfig['plural'] ?? ucfirst($slug) . 's',
                  'icon' => $slug,
                  'taxonomies' => $this->checker->getTaxonomiesForContent($slug),
               ];
            }
            // Get taxonomies with show_feed (content taxonomies)
            $taxonomies = Features::getTypesWithFeature('show_feed', 'taxonomy');
            foreach ($taxonomies as $slug) {
               $taxConfig = JVB_TAXONOMY[$slug] ?? null;
               if (!$taxConfig || !($taxConfig['is_content'] ?? false)) {
                  continue;
               }
               $config[$slug] = [
                  'type' => 'taxonomy',
                  'singular' => $taxConfig['singular'] ?? ucfirst($slug),
                  'plural' => $taxConfig['plural'] ?? ucfirst($slug) . 's',
                  'icon' => $slug,
                  'taxonomies' => [], // Content taxonomies don't have sub-taxonomies
                  'for_content' => $taxConfig['for_content'] ?? [],
               ];
            }
            return $config;
         });
   }
}