Jake Vanderwerf
2026-08-28 a9654f00c8fbc9934289d761843ddd071025013f
inc/rest/routes/FavouritesRoutes.php
@@ -1,8 +1,13 @@
<?php
namespace JVBase\rest\routes;
use JVBase\Instance;
use JVBase\managers\Cache;
use JVBase\managers\CustomTable;
use JVBase\managers\Emails;
use JVBase\managers\Favourites;
use JVBase\managers\NotificationManager;
use JVBase\managers\queue\Queue;
use JVBase\registrar\Registrar;
use JVBase\rest\PermissionHandler;
use JVBase\rest\Response;
@@ -16,11 +21,9 @@
   exit;
}
/**
 * TODO: Extract business logic into a Favourites.php manager class
 */
class FavouritesRoutes extends Rest
{
   use Instance;
   protected array $valid_types;
   protected Cache $listsCache;
   protected Cache $sharedListsCache;
@@ -35,27 +38,16 @@
      $this->cacheName = 'favourites';
      $this->cacheTtl = HOUR_IN_SECONDS;
      parent::__construct();
      $this->cache = Cache::for($this->cacheName)->user();
      // Set up cache connections
      $this->cache->connect('post')->connect('user')->connect('taxonomy');
      $this->listsCache = Cache::for('lists')->connect('favourites', true);
      $this->sharedListsCache = Cache::for('sharedLists')->connect('favourites', true);
      $this->favouritesCache = Cache::for('allFavourites')->connect('favourites', true);
      $this->listsCache = Cache::for('fav_lists')->user();
      $this->sharedListsCache = Cache::for('fav_shared_list')->user();
      $this->favouritesCache = Cache::for('allFavourites')->user();
      $this->valid_types = array_merge(Registrar::getRegistered('post'), Registrar::getRegistered('term'));
      // Initialize CustomTable instances
      $this->favourites = CustomTable::for('favourites');
      $this->lists = CustomTable::for('favourites_lists');
      $this->listItems = CustomTable::for('favourites_list_items');
      $this->listShares = CustomTable::for('favourites_list_shares');
      // Register hooks
      add_filter(BASE.'handle_bulk_operation', [$this, 'processOperation'], 10, 3);
      add_action('before_delete_post', [$this, 'cleanupPostFavourites']);
      add_action('delete_term', [$this, 'cleanupTermFavourites'], 10, 3);
      add_action('jvbUserRegistered', [$this, 'maybeAcceptListInvite'], 10, 3);
      add_action('jvb_cleanupOrphanedFavourites', [$this, 'cleanupOrphanedFavourites']);
   }
   public function registerRoutes(): void
@@ -64,7 +56,6 @@
      Route::for('favourites')
         ->get([$this, 'getFavourites'])
         ->args([
            'user' => 'integer|required',
            'type' => 'string',
            'include_all' => 'boolean',
         ])
@@ -72,8 +63,6 @@
         ->rateLimit(30)
         ->post([$this, 'handleFavourite'])
         ->args([
            'user' => 'integer|required',
            'id' => 'string|required',
            'action' => 'string|required|enum:add,remove,toggle,batch,note',
            'type' => 'string',
            'target_id' => 'integer',
@@ -92,7 +81,6 @@
         ->rateLimit(30)
         ->post([$this, 'handleList'])
         ->args([
            'user' => 'integer|required',
            'id' => 'string|required',
            'action' => 'string|required|enum:create,update,delete,share,unshare,add_items,remove_items',
            'list_id' => 'integer',
@@ -106,7 +94,6 @@
      // Favourite counts
      Route::for('favourites/counts')
         ->get([$this, 'getFavouriteCounts'])
         ->args(['user' => 'integer|required'])
         ->auth(PermissionHandler::combine(['user', ['actionNonce' => 'favourites-']]))
         ->register();
   }
@@ -116,163 +103,97 @@
    */
   public function getFavourites(WP_REST_Request $request): WP_REST_Response
   {
      $user_id = absint($request->get_param('user'));
      $user_id = get_current_user_id();
      if (!$this->userCheck($user_id)) {
      if (!$user_id) {
         return $this->unauthorized();
      }
      $args = $this->buildParams($request);
      $key = $this->cache->generateKey($args);
      $result = $this->cache->remember(
         $args,
         function() use ($user_id, $args) {
            return Favourites::getInstance()->getFavourites($args);
         }
      );
      // Check cache headers
      $cache_check = $this->checkHeaders($request, $key);
      if ($cache_check) {
         return $cache_check;
      }
      if (count($args) === 1 || ($request->get_param('include_all') === true)) {
         $result = $this->getAllFavourites($user_id);
      } else {
         $result = $this->cache->remember($key, function() use ($args) {
            return $this->getFilteredFavourites($args);
         });
      }
      return $this->addCacheHeaders(Response::success($result));
      $response = [
         'items' => $this->formatItems($result['items'])
      ];
      return Response::success($response);
   }
   /**
    * Get filtered favourites using CustomTable fluent interface
    */
   protected function getFilteredFavourites(array $args): array
   {
      try {
         // Build base query
         $query = $this->favourites->where(['user_id' => $args['user']]);
         // Add type filter if specified
         if (!empty($args['content']) && $args['content'] !== 'all') {
            $query = $this->favourites->where([
               'user_id' => $args['user'],
               'type' => BASE . $args['content']
            ]);
         }
         // Apply ordering and pagination
         $orderby = in_array($args['orderby'] ?? 'date_added', ['date_added', 'type'])
            ? $args['orderby']
            : 'date_added';
         $order = in_array(strtoupper($args['order'] ?? 'DESC'), ['ASC', 'DESC'])
            ? strtoupper($args['order'])
            : 'DESC';
         $favourites = $query
            ->orderBy($orderby, $order)
            ->limit(100, ($args['page'] - 1) * 100)
            ->getResults();
         // Get total count
         $count_query = $this->favourites->where(['user_id' => $args['user']]);
         if (!empty($args['content']) && $args['content'] !== 'all') {
            $count_query->where(['type' => BASE . $args['content']]);
         }
         $total_items = $count_query->countResults();
         return [
            'items' => $this->formatItems($favourites),
            'has_more' => ($args['page'] * 100) < $total_items,
            'total' => $total_items,
            'success' => true,
         ];
      } catch (Exception $e) {
         $this->logError('getFilteredFavourites', [
            'error' => $e->getMessage(),
            'args' => $args
         ]);
         return [
            'success' => false,
            'items' => [],
            'total' => 0,
            'has_more' => false
         ];
      }
   }
   /**
    * Get all user's favourites organized by content type
    */
   protected function getAllFavourites(int $user_id): array
   {
      return $this->cache->remember($user_id, function() use ($user_id) {
         try {
            $favourites = $this->favourites
               ->where(['user_id' => $user_id])
               ->getResults();
            $by_type = [];
            foreach ($favourites as $fav) {
               $type = str_replace(BASE, '', $fav->type);
               if (!isset($by_type[$type])) {
                  $by_type[$type] = [];
               }
               $by_type[$type][] = (int)$fav->target_id;
            }
            return [
               'success' => true,
               'items' => $by_type,
               'has_more' => false,
            ];
         } catch (Exception $e) {
            $this->logError('getAllFavourites', [
               'error' => $e->getMessage(),
               'user_id' => $user_id
            ]);
            return [
               'success' => false,
               'items' => [],
            ];
         }
      });
   }
   /**
    * Handle favourite operations
    */
   public function handleFavourite(WP_REST_Request $request): WP_REST_Response
   {
      $user_id = absint($request->get_param('user'));
      $operation_id = sanitize_text_field($request->get_param('id'));
      $action = sanitize_text_field($request->get_param('action'));
      $params = $request->get_params();
      $user_id = get_current_user_id();
      if (!$this->userCheck($user_id)) {
         return $this->unauthorized();
      $action = strtolower(sanitize_text_field($params['action']));
      $action = in_array($action, ['add', 'remove', 'toggle', 'batch', 'notes']) ? $action : false;
      if (!$action) {
         return $this->error('Invalid favourite action');
      }
      $data = [
         'action' => $action,
         'type' => sanitize_text_field($request->get_param('type') ?? ''),
         'target_id' => absint($request->get_param('target_id') ?? 0),
         'items' => $request->get_param('items') ?? [],
         'notes' => sanitize_textarea_field($request->get_param('notes') ?? ''),
      ];
      $type = sanitize_text_field($params['type']??'');
      if (empty($type)) {
         return $this->error('No type provided');
      }
      JVB()->queue()->queueOperation(
         'favourite_' . $action,
         $user_id,
         $data,
         [
            'operation_id' => $operation_id,
            'priority' => 'high',
         ]
      );
      $target_id = absint($params['target_id']??0);
      if ($action !== 'batch' && $target_id === 0) {
         return $this->error('Invalid target id');
      }
      return $this->queued($operation_id);
      $favourites = Favourites::getInstance();
      $result = false;
      switch ($action) {
         case 'notes':
            $note = sanitize_text_field($params['note']??'');
            $result = $favourites->addNoteFromTarget($user_id, $target_id, $type, $note);
            break;
         case 'toggle':
            $isFavourited = $favourites->hasFavourited($user_id, $target_id, $type);
            $result = $favourites->toggleFavourite(!$isFavourited, $user_id, $target_id, $type);
            break;
         case 'add':
            $result = $favourites->favourite($user_id, $target_id, $type);
            break;
         case 'remove':
            $result = $favourites->unfavourite($user_id, $target_id, $type);
            break;
         case 'batch':
            $items = $params['items']??[];
            $items = array_filter($items, function ($item) {
               $keys = ['target_id', 'type', 'action'];
               foreach ($keys as $key) {
                  if (!array_key_exists($key, $item)) {
                     return false;
                  }
                  if (empty($item[$key])) {
                     return false;
                  }
               }
               if (!in_array($item['action'], ['add', 'remove'])) {
                  return false;
               }
               return true;
            });
            if (empty($items)) {
               $result = $this->success(['message' => 'No items to update']);
            } else {
               $result = $favourites->batchToggleFavourites($user_id, $items);
            }
            break;
      }
      if ($result) {
         return $this->success(is_bool($result) ? [] : ['result' => $result]);
      }
      return $this->error('Something went wrong');
   }
   /**
@@ -280,279 +201,41 @@
    */
   public function getLists(WP_REST_Request $request): WP_REST_Response
   {
      $user_id = absint($request->get_param('user'));
      $user_id = get_current_user_id();
      if (!$this->userCheck($user_id)) {
         return $this->unauthorized();
      $args = $this->buildParams($request);
      $args['per_page'] = 20;
      $listId = $request->get_param('id');
      if (!empty($listId)) {
         $args['where']['id'] = sanitize_text_field($listId);
      }
      $params = ['user' => $user_id];
      if ($request->get_param('id')) {
         $params['list'] = sanitize_text_field($request->get_param('id'));
      }
      $includeShares = !empty($request->get_param('include_shares'));
      $key = $this->listsCache->generateKey($params);
      $response = !empty($listId)
         ? Favourites::getInstance()->getListDetails($listId, $user_id)
         : Favourites::getInstance()->getAvailableLists($args, $includeShares);
      // Check cache headers
      $cache_check = $this->checkHeaders($request, $key);
      if ($cache_check) {
         return $cache_check;
      }
      $list_id = $request->get_param('id');
      $response = $list_id
         ? $this->getListDetails($list_id, $user_id)
         : $this->getAvailableLists($user_id);
      return $this->addCacheHeaders(Response::success($response));
      return Response::success($response);
   }
   /**
    * Get lists available to a user using CustomTable
    */
   protected function getAvailableLists(int $user_id, bool $include_shared = true): array
   {
      if (!$this->checkUser($user_id)) {
         return [];
      }
      $cache = $include_shared ? $this->sharedListsCache : $this->listsCache;
      return $cache->remember($user_id, function() use ($user_id, $include_shared) {
         try {
            // Get owned lists
            $owned = $this->lists
               ->where(['user_id' => $user_id])
               ->orderBy('created_at', 'DESC')
               ->getResults(ARRAY_A);
            // Add item counts
            foreach ($owned as &$list) {
               $list['item_count'] = $this->listItems
                  ->where(['list_id' => $list['id']])
                  ->countResults();
               $list['is_owner'] = true;
               $list['is_shared'] = false;
            }
            if (!$include_shared) {
               return [
                  'success' => true,
                  'lists' => $owned
               ];
            }
            // Get shared lists
            $shares = $this->listShares
               ->where(['user_id' => $user_id, 'status' => 'accepted'])
               ->getResults();
            $shared_lists = [];
            foreach ($shares as $share) {
               $list = $this->lists
                  ->where(['id' => $share->list_id])
                  ->first(ARRAY_A);
               if ($list) {
                  $owner = get_userdata($list['user_id']);
                  $list['owner_name'] = $owner ? $owner->display_name : 'Unknown';
                  $list['item_count'] = $this->listItems
                     ->where(['list_id' => $list['id']])
                     ->countResults();
                  $list['permission_type'] = $share->permission_type;
                  $list['is_owner'] = false;
                  $list['is_shared'] = true;
                  $shared_lists[] = $list;
               }
            }
            return [
               'success' => true,
               'lists' => [
                  'owned' => $owned,
                  'shared' => $shared_lists
               ]
            ];
         } catch (Exception $e) {
            $this->logError('getAvailableLists', [
               'error' => $e->getMessage(),
               'user_id' => $user_id
            ]);
            return [];
         }
      });
   }
   /**
    * TODO: Done until here
    * Get favourite counts by type
    */
   public function getFavouriteCounts(WP_REST_Request $request): WP_REST_Response
   {
      $user_id = absint($request->get_param('user'));
      if (!$this->userCheck($user_id)) {
         return $this->unauthorized();
      }
      $user_id = get_current_user_id();
      $key = "counts_{$user_id}";
      $counts = $this->cache->remember($key, function() use ($user_id) {
         try {
            // Get counts grouped by type using raw query
            $results = $this->favourites->queryResults(
               "SELECT type, COUNT(*) as count FROM {table} WHERE user_id = %d GROUP BY type",
               [$user_id],
               OBJECT_K
            );
            $all_counts = array_fill_keys(
               array_map(fn($type) => str_replace(BASE, '', $type), array_keys($this->valid_types)),
               0
            );
            foreach ($results as $type => $data) {
               $type_key = str_replace(BASE, '', $type);
               $all_counts[$type_key] = (int)$data->count;
            }
            return $all_counts;
         } catch (Exception $e) {
            $this->logError('getFavouriteCounts', [
               'error' => $e->getMessage(),
               'user_id' => $user_id
            ]);
            return array_fill_keys(array_keys($this->valid_types), 0);
         }
      });
      $counts = Favourites::getInstance()->getFavouriteCounts($user_id);
      return Response::success(['counts' => $counts]);
   }
   /**
    * Process favourite operations using transactions
    */
   public function processOperation($result, object $operation, array $data)
   {
      $action_map = [
         'favourite_add' => 'addFavourite',
         'favourite_remove' => 'removeFavourite',
         'favourite_batch' => 'batchFavourites',
         'favourite_note' => 'processNote',
         'favourite_list_create' => 'createList',
         'favourite_list_update' => 'updateList',
         'favourite_list_delete' => 'deleteList',
         'favourite_list_add_items' => 'addToList',
         'favourite_list_remove_items' => 'removeFromList',
         'favourite_list_share' => 'shareList',
         'favourite_list_unshare' => 'unshareList',
      ];
      if (!isset($action_map[$operation->type])) {
         return $result;
      }
      try {
         $method = $action_map[$operation->type];
         $response = $this->$method($operation->user_id, $data);
         // Clear cache on success
         if ($response['success'] ?? false) {
            Cache::invalidateItem('favourites', $operation->user_id);
            $this->listsCache->flush();
            $this->sharedListsCache->flush();
         }
         return $response;
      } catch (Exception $e) {
         $this->logError('processOperation', [
            'error' => $e->getMessage(),
            'operation_id' => $operation->id,
            'type' => $operation->type
         ]);
         return [
            'success' => false,
            'result' => $e->getMessage()
         ];
      }
   }
   /**
    * Add a favourite using findOrCreate pattern
    */
   protected function addFavourite(int $user_id, array $data): array
   {
      $type = $data['type'];
      $target_id = $data['target_id'];
      if (!str_starts_with($type, BASE)) {
         $type = BASE . $type;
      }
      if (!isset($this->valid_types[$type])) {
         return ['success' => false, 'result' => 'Invalid type'];
      }
      // Use findOrCreate pattern
      $result = $this->favourites->findOrCreate([
         'user_id' => $user_id,
         'type' => $type,
         'target_id' => $target_id
      ]);
      if ((bool)$result) {
         $this->updateFavouriteCount($type, $target_id);
         $this->maybeNotifyOwner($type, $target_id, $user_id);
      }
      return [
         'success' => true,
         'result' => [
            'action' => $result['created'] ? 'added' : 'already_exists',
            'favourite_id' => $result['id'],
            'count' => $this->getFavouriteCount($type, $target_id)
         ]
      ];
   }
   /**
    * Remove a favourite
    */
   protected function removeFavourite(int $user_id, array $data): array
   {
      $type = $data['type'];
      $target_id = $data['target_id'];
      if (!str_starts_with($type, BASE)) {
         $type = BASE . $type;
      }
      $deleted = $this->favourites->where([
         'user_id' => $user_id,
         'type' => $type,
         'target_id' => $target_id
      ])->deleteResults();
      if ($deleted) {
         $this->updateFavouriteCount($type, $target_id);
         $this->removeRelatedNotifications($type, $target_id, $user_id);
      }
      return [
         'success' => true,
         'result' => [
            'action' => $deleted ? 'removed' : 'not_found',
            'count' => $this->getFavouriteCount($type, $target_id)
         ]
      ];
   }
   /**
    * Batch favourites using transaction
    */
   protected function batchFavourites(int $user_id, array $data): array
@@ -560,7 +243,7 @@
      $items = $data['items'] ?? [];
      if (empty($items)) {
         return ['success' => false, 'result' => 'No items provided'];
         return $this->success(['result' => 'No items provided']);
      }
      return $this->favourites->transaction(function($table) use ($user_id, $items) {
@@ -736,122 +419,30 @@
   }
   /**
    * Clean up favourites when a post is deleted
    */
   public function cleanupPostFavourites(int $post_id): void
   {
      try {
         $type = get_post_type($post_id);
         if (!$type) return;
         $type = BASE . $type;
         // Delete using fluent interface
         $this->favourites->where([
            'type' => $type,
            'target_id' => $post_id
         ])->deleteResults();
         $this->listItems->where([
            'item_type' => $type,
            'item_id' => $post_id
         ])->deleteResults();
      } catch (Exception $e) {
         $this->logError('cleanupPostFavourites', [
            'error' => $e->getMessage(),
            'post_id' => $post_id
         ]);
      }
   }
   /**
    * Clean up favourites when a term is deleted
    */
   public function cleanupTermFavourites(int $term_id, int $tt_id, string $taxonomy): void
   {
      try {
         if (!isset($this->valid_types[$taxonomy])) {
            return;
         }
         // Delete using fluent interface
         $this->favourites->where([
            'type' => $taxonomy,
            'target_id' => $term_id
         ])->deleteResults();
         $this->listItems->where([
            'item_type' => $taxonomy,
            'item_id' => $term_id
         ])->deleteResults();
      } catch (Exception $e) {
         $this->logError('cleanupTermFavourites', [
            'error' => $e->getMessage(),
            'term_id' => $term_id,
            'taxonomy' => $taxonomy
         ]);
      }
   }
   /**
    * Cleanup orphaned favourites using CustomTable query method
    */
   public function cleanupOrphanedFavourites(): bool
   {
      try {
         // Delete favourites for non-existent users
         $this->favourites->query(
            "DELETE f FROM {table} f
                 LEFT JOIN {$GLOBALS['wpdb']->users} u ON f.user_id = u.ID
                 WHERE u.ID IS NULL"
         );
         // Delete favourites for non-existent posts
         $post_types = array_filter(
            array_keys($this->valid_types),
            fn($type) => $this->valid_types[$type]['table'] === 'post'
         );
         foreach ($post_types as $type) {
            $this->favourites->query(
               "DELETE f FROM {table} f
                     LEFT JOIN {$GLOBALS['wpdb']->posts} p ON f.target_id = p.ID
                     WHERE f.type = %s AND p.ID IS NULL",
               [$type]
            );
         }
         return true;
      } catch (Exception $e) {
         $this->logError('cleanupOrphanedFavourites', [
            'error' => $e->getMessage()
         ]);
         return false;
      }
   }
   /**
    * Helper methods
    */
   protected function buildParams(WP_REST_Request $request): array
   {
      $data = $request->get_params();
      $args = ['user' => absint($data['user'])];
      if (!array_key_exists('page', $data)) {
         return $args;
      $userID = get_current_user_id();
      if(!$userID) {
         return [];
      }
      $where = ['user_id' => $userID];
      if (!empty($data['content']) && $data['content'] !== 'all') {
         $where['type'] = BASE . $data['content'];
      }
      $args = array_merge($args, [
         'page' => max(1, absint($data['page'] ?? 1)),
         'content' => Registrar::getInstance($data['content']) ? $data['content'] : 'all',
      ]);
      $page    = max(1, absint($data['page'] ?? 1));
      $perPage = 250;
      return $this->applyOrderFilters($args, $data);
      return [
         'where'     => $where,
         'orderby'   => sanitize_text_field($data['orderby'] ?? 'created_at'),
         'order'     => sanitize_text_field($data['order'] ?? 'DESC'),
         'per_page'  => $perPage,
         'page'      => $page
      ];
   }
@@ -920,112 +511,10 @@
      }
   }
   /**
    * Notify content owner of new favourite if configured
    *
    * @param string $type Content type
    * @param int $target_id Content ID
    * @param int $user_id User who favourited
    * @return void
    */
   protected function maybeNotifyOwner(string $type, int $target_id, int $user_id): void
   {
      try {
         $owner_id = $this->getContentOwner($type, $target_id);
         if ($owner_id && $owner_id !== $user_id) {
            JVB()->notification()->addNotification(
               $owner_id,
               'new_favourite',
               [
                  'user_id' => $user_id,
                  'type' => $type,
                  'target_id' => $target_id
               ]
            );
         }
      } catch (Exception $e) {
         // Silent fail - notifications are non-critical
      }
   }
   /**
    * Remove any existing notifications about a favorite action
    *
    * @param int $user_id User who removed the favorite
    * @param string $type Content type
    * @param int $target_id Content ID
    * @return void
    */
   protected function removeRelatedNotifications(int $user_id, string $type, int $target_id):void
   {
      try {
         // Get the content owner(s)
         $owner_ids = $this->getContentOwner($type, $target_id);
         if (!$owner_ids) {
            return;
         }
         $owner_ids = (is_array($owner_ids)) ? $owner_ids : [$owner_ids];
         foreach ($owner_ids as $owner_id) {
            // Skip if owner is the same as the user who unfavorited
            if ($owner_id === $user_id) {
               continue;
            }
            global $wpdb;
            $notifications_table = $wpdb->prefix . BASE . 'notifications';
            // Find recent (within last 30 days) new_favourite notifications from this user for this content
            $notifications = $wpdb->get_results($wpdb->prepare(
               "SELECT id FROM {$notifications_table}
                WHERE owner_id = %d
                AND action_user_id = %d
                AND type = 'new_favourite'
                AND target_id = %d
                AND target_type = %s
                AND created_at > DATE_SUB(%s, INTERVAL 30 DAY)",
               $owner_id,
               $user_id,
               $target_id,
               $type,
               current_time('mysql')
            ));
            if (empty($notifications)) {
               continue;
            }
            // Delete the notifications
            foreach ($notifications as $notification) {
               $wpdb->delete(
                  $notifications_table,
                  ['id' => $notification->id],
                  ['%d']
               );
            }
            // Invalidate notification cache for this user
//                if (method_exists(JVB()->notification(), 'clearNotificationCache')) {
//                    JVB()->notification()->clearNotificationCache($owner_id);
//                }
         }
      } catch (Exception $e) {
         // Log but continue
         JVB()->error()->log(
            'favourites',
            'Error removing related notifications: ' . $e->getMessage(),
            ['type' => $type, 'target_id' => $target_id, 'user_id' => $user_id],
            'warning'
         );
      }
   }
   public function maybeAcceptListInvite(int $user_id, string $email, array $data):void
   {
      if (array_key_exists('list_token', $data) && !empty($data['list_token'])) {
         $this->acceptListInvitation($data['list_token'], $email);
         Favourites::getInstance()->acceptListShare($data['list_token'], $user_id);
      }
   }
@@ -1034,45 +523,40 @@
    */
   protected function getListDetails(int $list_id, int $user_id): array
   {
      // Check access - either owner or has share
      $is_owner = Favourites::getInstance()->userOwnsList($list_id, $user_id);
      $is_shared = Favourites::getInstance()->userCanViewList($list_id, $user_id);
      if (!$is_owner && !$is_shared) {
         return [
            'success'   => false,
            'message'   => 'You do not have access to this list.'
         ];
      }
      $list = Favourites::getInstance()->getListDetails($list_id, $user_id);
      if (empty($list)) {
         return [
            'success' => false,
            'message' => 'List not found'
         ];
      }
      $key = "list_{$list_id}_user_{$user_id}";
      return $this->listsCache->remember($key, function () use ($list_id, $user_id) {
         try {
            // Check access - either owner or has share
            $is_owner = $this->lists->where([
               'id' => $list_id,
               'user_id' => $user_id
            ])->existsInQuery();
            $share = null;
            if (!$is_owner) {
               $share = $this->listShares->where([
                  'list_id' => $list_id,
                  'user_id' => $user_id,
                  'status' => 'accepted'
               ])->first();
            }
            if (!$is_owner && !$share) {
               return [
                  'success' => false,
                  'message' => 'You do not have access to this list'
               ];
            }
            // Get list details
            $list = $this->lists->where(['id' => $list_id])->first(ARRAY_A);
            if (!$list) {
               return [
                  'success' => false,
                  'message' => 'List not found'
               ];
            }
            // Get list items
            $items = $this->listItems
               ->where(['list_id' => $list_id])
               ->orderBy('added_at', 'DESC')
               ->orderBy('added_at')
               ->getResults();
            // Format items - convert to favourite-like objects
@@ -1097,10 +581,11 @@
            // Get shared users if owner
            $shared_users = [];
            $is_owner = false;//TODO
            if ($is_owner) {
               $shares = $this->listShares
                  ->where(['list_id' => $list_id])
                  ->orderBy('created_at', 'DESC')
                  ->orderBy('created_at')
                  ->getResults();
               foreach ($shares as $share_item) {
@@ -1119,7 +604,7 @@
                  $shared_users[] = $shared_user;
               }
            }
            $list = []; //TODO: check this
            return [
               'success' => true,
               'list' => [
@@ -1546,19 +1031,20 @@
            ]);
            // Send notification
            JVB()->notification()->addNotification(
               $share_user->ID,
               'list_shared',
               $user_id,
               sprintf('%s shared a favorites list with you: "%s"', $owner_name, $list->name),
               $list_id,
               'favourites_list',
               [
                  'list_id' => $list_id,
                  'list_name' => $list->name,
                  'permission_type' => $permission_type
               ]
            );
            // TODO This should be handled by the Favourites:: manager
//          NotificationManager::getInstance()->addNotification(
//             $share_user->ID,
//             'list_shared',
//             $user_id,
//             sprintf('%s shared a favorites list with you: "%s"', $owner_name, $list->name),
//             $list_id,
//             'favourites_list',
//             [
//                'list_id' => $list_id,
//                'list_name' => $list->name,
//                'permission_type' => $permission_type
//             ]
//          );
            return [
               'success' => true,
@@ -1666,7 +1152,7 @@
            $list = $this->lists->where(['id' => $list_id])->first();
            if ($list) {
               JVB()->notification()->addNotification(
               NotificationManager::getInstance()->addNotification(
                  $existing_share->user_id,
                  'list_share_revoked',
                  $user_id,
@@ -1740,7 +1226,7 @@
         $invite_url
      );
      return JVB()->email()->sendEmail($email, $subject, $message);
      return Emails::getInstance()->sendEmail($email, $subject, $message);
   }
   /**
@@ -1814,7 +1300,7 @@
         $user = get_userdata($user_id);
         $display_name = $user ? $user->display_name : $email;
         JVB()->notification()->addNotification(
         NotificationManager::getInstance()->addNotification(
            $list->user_id,
            'list_share_accepted',
            $user_id,
@@ -1857,7 +1343,7 @@
      // For posts
      if (str_contains($type, 'post') || in_array($type, array_keys($this->valid_types))) {
         $post = get_post($target_id);
         return $post ? $post->post_author : null;
         return $post?->post_author;
      }
      // For terms (shops, etc.)
@@ -2067,7 +1553,7 @@
    */
   public function handleList(WP_REST_Request $request): WP_REST_Response
   {
      $user_id = absint($request->get_param('user'));
      $user_id = get_current_user_id();
      $operation_id = sanitize_text_field($request->get_param('id'));
      $action = sanitize_text_field($request->get_param('action'));
@@ -2098,10 +1584,10 @@
      };
      if (!$operation_type) {
         return $this->error('Invalid action', 'invalid_action', 400);
         return $this->error('Invalid action', 'invalid_action');
      }
      JVB()->queue()->queueOperation(
      Queue::getInstance()->queueOperation(
         $operation_type,
         $user_id,
         $data,