cache_name = 'favourites'; parent::__construct(); $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->valid_types = array_keys(array_merge(JVB_CONTENT, JVB_TAXONOMY)); $this->action = 'favourites-'; 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); // Register cleanup scheduler add_action('jvb_cleanupOrphanedFavourites', [$this, 'cleanupOrphanedFavourites']); } /** * Registers favourites routes * @return void */ public function registerRoutes():void { // Main favourites endpoint - GET for retrieval, POST for toggling/notes register_rest_route($this->namespace, '/favourites', [ [ 'methods' => 'GET', 'callback' => [$this, 'getFavourites'], 'permission_callback' => [$this, 'checkPermission'] ], [ 'methods' => 'POST', 'callback' => [$this, 'handleFavouriteOperation'], 'permission_callback' => [$this, 'checkPermission'] ] ]); // Lists endpoint - GET for retrieval, POST for creation/modifications register_rest_route($this->namespace, '/favourites/lists', [ [ 'methods' => 'GET', 'callback' => [$this, 'getLists'], 'permission_callback' => [$this, 'checkPermission'] ], //Adding and removing list items is handled by the body dta [ 'methods' => 'POST', 'callback' => [$this, 'handleListOperation'], 'permission_callback' => [$this, 'checkPermission'] ] ]); // List shares operations register_rest_route($this->namespace, '/favourites/lists/shares', [ [ 'methods' => 'GET', 'callback' => [$this, 'getShares'], 'permission_callback' => [$this, 'checkPermission'] ], //Adds and removes are handled in the body data [ 'methods' => 'POST', 'callback' => [$this, 'handleShare'], 'permission_callback' => [$this, 'checkPermission'] ] ]); } protected function buildParams(WP_REST_Request $request):array { $data = $request->get_params(); error_log('Favourites Request Data: '.print_r($data, true)); $args = []; if (!array_key_exists('user', $data)) { return $args; } $args['user'] = absint($data['user']); if (!array_key_exists('page', $data)) { //No filters set, just get a list of favourites return $args; } $args = array_merge($args, [ 'page' => max(1, absint($data['page'] ?? 1)), 'content' => $this->checkContent($data['content']) ]); return $this->applyOrderFilters($args, $data); } /** * Get user's favourites with optional filtering * * @param WP_REST_Request $request Request object * @return WP_REST_Response Response with favourites data */ public function getFavourites(WP_REST_Request $request):WP_REST_Response { $args = $this->buildParams($request); if (!$args['user'] || $args['user'] === ''){ return $this->addCacheHeaders(new WP_REST_Response([ 'success' => false, 'message' => 'No user set' ])); } $key = $this->cache->generateKey($args); // Check HTTP cache headers for user-specific data $cache_check = $this->checkHeaders($request, $key); if ($cache_check) { return $cache_check; } if (count($args) === 1 || (array_key_exists('all', $args) && $args['all'] === true)) { $result = $this->getAllFavourites($args['user']); } else { $result = $this->cache->remember( $this->cache->generateKey($args), function() use ($args) { return $this->getFilteredFavourites($args); } ); } $response = new WP_REST_Response($result); return $this->addCacheHeaders($response); } protected function getFilteredFavourites(array $args):array { try { global $wpdb; $table = $wpdb->prefix . BASE . 'favourites'; // Build query with proper escaping $query_parts = ["SELECT f.* FROM {$table} f WHERE user_id = %d"]; $params = [$args['user']]; // Add type filter if specified if ($args['content'] && $args['content']!=='all') { $query_parts[] = "AND type = %s"; $params[] = BASE . $args['content']; } // Add ordering - make sure to use whitelist for column names if ($args['orderby'] === 'date') { $args['orderby'] = 'date_added'; } $valid_orderby_columns = ['date_added', 'type']; $valid_orders = ['ASC', 'DESC']; $orderby = in_array($args['orderby'], $valid_orderby_columns) ? $args['orderby'] : 'date_added'; $order = in_array(strtoupper($args['order']), $valid_orders) ? strtoupper($args['order']) : 'DESC'; $query_parts[] = "ORDER BY {$orderby} {$order}"; // Add pagination $query_parts[] = "LIMIT %d OFFSET %d"; $params[] = 100; $params[] = ($args['page'] - 1) * 100; // Execute query $query = implode(' ', $query_parts); $favourites = $wpdb->get_results($wpdb->prepare($query, $params)); // Get total count for pagination $count_query = "SELECT COUNT(*) FROM {$table} WHERE user_id = %d"; $count_params = [$args['user']]; if ($args['content'] && $args['content'] !== 'all') { $count_query .= " AND type = %s"; $count_params[] = BASE . $args['content']; } $total_items = (int)$wpdb->get_var($wpdb->prepare($count_query, $count_params)); // Format the favourites using batch processing to reduce queries $formatted = $this->formatItems($favourites); // Get counts by type for filters $counts = $this->getFavouriteCounts($args['user']); // Prepare response data return [ 'items' => $formatted, 'has_more' => ($args['page'] * 100) < $total_items, 'total' => $total_items, 'success' => true, ]; } catch (Exception $e) { $this->logError( $e->getMessage(), [ 'method' => 'getFilteredFavourites', 'args' => $args ] ); return [ 'success' => false, 'favourites' => [], 'counts' => 0, 'pagination' => [] ]; } } /** * Get all user's favourites organized by content type * * @param int $user_id User ID * @return WP_REST_Response Response with favourites by content type */ protected function getAllFavourites(int $user_id):WP_REST_Response { if (!$this->checkUser($user_id)) { return new WP_REST_Response([ 'success' => false, 'message' => 'User ID doesn\'t match... are you a bot?' ]); } $result = $this->cache->remember( $user_id, function() use ($user_id) { return $this->fetchAllFavourites($user_id); } ); return new WP_REST_Response($result); } protected function fetchAllFavourites(int $user_id):array { try { global $wpdb; $table = $wpdb->prefix . BASE . 'favourites'; // Get all favourites for this user $query = $wpdb->prepare( "SELECT type, target_id FROM {$table} WHERE user_id = %d", $user_id ); $favourites = $wpdb->get_results($query); // Organize by content type $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( $e->getMessage(), [ 'context' => 'fetchAllFavourites', 'user' => $user_id ] ); return [ 'success' => false, 'favourites' => [], ]; } } /** * Handle favourite operations (toggle, notes update) * * @param WP_REST_Request $request Request object * @return WP_REST_Response Response with operation result */ public function handleFavouriteOperation(WP_REST_Request $request): WP_REST_Response { $data = $request->get_json_params(); $operation = $data['operation'] ?? 'toggle'; $user_id = get_current_user_id(); $queue = JVB()->queue(); $operation_id = $data['id'] ?? uniqid('fav_'); error_log('Favourite Request: '.print_r($data, true)); switch ($operation) { case 'toggle': $adds = $request->get_param('adds') ?? []; $removes = $request->get_param('removes') ?? []; $queue->queueOperation( 'favourites_batch', $request->get_param('user'), [ 'adds' => $adds, 'removes' => $removes ], [ 'count' => count($adds) + count($removes), 'chunk_key' => ['adds', 'removes'], 'chunk_size' => 20, 'priority' => 'normal', 'operation_id' => $request->get_param('id') ] ); break; case 'update_notes': // Handle notes update if (!array_key_exists('target_id', $data)) { return $this->createErrorResponse( self::ERROR_MISSING_PARAMS, 'Type and target ID are required', 400 ); } $ids = explode(',', $data['target_id']); foreach ($ids as $key => $id) { $ids[$key] = (int)$id; } $ids = implode(',', $ids); $queue->queueOperation( 'favourite_notes', $user_id, [ 'target_id' => $ids, 'notes' => sanitize_textarea_field($data['notes'] ?? '') ], [ 'count' => 1, 'operation_id' => $operation_id ] ); break; default: return $this->createErrorResponse( self::ERROR_INVALID_OPERATION, 'Invalid operation', 400 ); } return new WP_REST_Response([ 'success' => true, 'message' => __('Operation queued', 'jvb'), 'operation_id' => $operation_id, 'queue_status' => $queue->getQueueStatus() ]); } /** * Get user's favourite lists * * @param WP_REST_Request $request Request object * @return WP_REST_Response Response with lists data */ public function getLists(WP_REST_Request $request):WP_REST_Response { $user_id = get_current_user_id(); if (!$user_id || !$this->userCheck($user_id)) { return new WP_REST_Response([ 'success' => false, 'message' => 'Invalid user' ]); } $params = [ 'user' => $user_id, ]; if ($request->get_param('id')) { $params['list'] = sanitize_text_field($request->get_param('id')); } $key = $this->listsCache->generateKey($params); // Check HTTP cache headers $cache_check = $this->checkHeaders($request, $key); if ($cache_check) { return $cache_check; } $list_id = $request->get_param('id'); if ($list_id) { $response = $this->getListDetails($list_id, $user_id); } else { $response = $this->getAvailableLists($user_id); } $response = new WP_REST_Response($response); return $this->addCacheHeaders($response); } /** * Get lists available to a user (owned and shared) * * @param int $user_id User ID * @param bool $include_shared Include lists shared with user * @return array Lists data */ public 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) { global $wpdb; error_log('Attempting to get available lists..'); $lists_table = $wpdb->prefix . BASE . 'favourites_lists'; $items_table = $wpdb->prefix . BASE . 'favourites_list_items'; $shares_table = $wpdb->prefix . BASE . 'favourites_list_shares'; try { // Get owned lists $owned_query = $wpdb->prepare( "SELECT l.*, COUNT(DISTINCT i.id) as item_count, TRUE as is_owner, FALSE as is_shared FROM {$lists_table} l LEFT JOIN {$items_table} i ON l.id = i.list_id WHERE l.user_id = %d GROUP BY l.id ORDER BY l.created_at DESC", $user_id ); $lists = $wpdb->get_results($owned_query); error_log('Lists result: '.print_r($lists, true)); // Add shared lists if requested if ($include_shared) { $shared_query = $wpdb->prepare( "SELECT l.*, u.display_name as owner_name, COUNT(DISTINCT i.id) as item_count, s.permission_type, FALSE as is_owner, TRUE as is_shared FROM {$lists_table} l JOIN {$shares_table} s ON l.id = s.list_id JOIN {$wpdb->users} u ON l.user_id = u.ID LEFT JOIN {$items_table} i ON l.id = i.list_id WHERE s.user_id = %d GROUP BY l.id ORDER BY l.created_at DESC", $user_id ); $shared_lists = $wpdb->get_results($shared_query); error_log('Shared lists: '.print_r($shared_lists, true)); $lists = [ 'owned' => $lists, 'shared'=> $shared_lists, ]; } error_log('Lists: '.print_r($lists, true)); return [ 'success' => true, 'lists' => $lists ]; } catch (Exception $e) { JVB()->error()->log( 'favourites', 'Error getting available lists: ' . $e->getMessage(), ['user_id' => $user_id], 'error' ); return []; } } ); } /** * Get detailed information for a single list * * @param int $list_id List ID * @param int $user_id User ID * @return WP_REST_Response|WP_Error Response with list details or error */ protected function getListDetails(int $list_id, int $user_id):WP_REST_Response { $key = sprintf( 'user_%d_list_%d', $user_id, $list_id ); $cache = $this->listsCache->get($key); if ($cache) { return new WP_REST_Response($cache); } //No cache, build it again global $wpdb; $lists_table = $wpdb->prefix . BASE . 'favourites_lists'; $items_table = $wpdb->prefix . BASE . 'favourites_list_items'; $shares_table = $wpdb->prefix . BASE . 'favourites_list_shares'; $pending_shares_table = $wpdb->prefix . BASE . 'favourites_pending_shares'; // Check if user has access to this list $list_access = $wpdb->get_row($wpdb->prepare(" SELECT l.*, CASE WHEN l.user_id = %d THEN 1 ELSE 0 END as is_owner, CASE WHEN s.id IS NOT NULL THEN 1 ELSE 0 END as is_shared FROM {$lists_table} l LEFT JOIN {$shares_table} s ON l.id = s.list_id AND s.user_id = %d WHERE l.id = %d ", $user_id, $user_id, $list_id)); if (!$list_access || (!$list_access->is_owner && !$list_access->is_shared)) { return $this->createErrorResponse( self::ERROR_ACCESS_DENIED, 'You do not have access to this list', 403 ); } // Get list items $items = $wpdb->get_results($wpdb->prepare(" SELECT i.*, f.user_id, f.type, f.target_id, f.notes FROM {$items_table} i LEFT JOIN {$wpdb->prefix}" . BASE . "favourites f ON i.favourite_id = f.id WHERE i.list_id = %d ORDER BY i.added_at DESC ", $list_id)); // Format items using batch processing to reduce queries $formatted_items = []; $dummy_items = []; $favourite_items = []; foreach ($items as $item) { if ($item->favourite_id === null) { // Create a dummy favourite object $dummy_items[] = (object)[ 'type' => $item->item_type, 'target_id' => $item->item_id, 'date_added' => $item->added_at ]; } else { // Use the joined favourite data $favourite_items[] = (object)[ 'id' => $item->favourite_id, 'user_id' => $item->user_id, 'type' => $item->type, 'target_id' => $item->target_id, 'notes' => $item->notes, 'date_added' => $item->added_at ]; } } // Process items in batches to reduce DB queries $formatted_dummy_items = $this->formatItems($dummy_items); $formatted_favourite_items = $this->formatItems($favourite_items); // Combine formatted items $formatted_items = array_merge($formatted_dummy_items, $formatted_favourite_items); // Get shared users if owner $shared_users = []; if ($list_access->is_owner) { // Get active shares $active_shares = $wpdb->get_results($wpdb->prepare(" SELECT s.*, u.user_email as email, u.display_name FROM {$shares_table} s JOIN {$wpdb->users} u ON s.user_id = u.ID WHERE s.list_id = %d ", $list_id)); // Get pending shares $pending_shares = $wpdb->get_results($wpdb->prepare(" SELECT * FROM {$pending_shares_table} WHERE list_id = %d ", $list_id)); // Format shared users foreach ($active_shares as $share) { $shared_users[] = [ 'email' => $share->email, 'name' => $share->display_name, 'permission_type' => $share->permission_type, 'date_added' => $share->created_at, 'status' => 'active' ]; } foreach ($pending_shares as $share) { $shared_users[] = [ 'email' => $share->email, 'invitation_token' => $share->invitation_token, 'date_added' => $share->created_at, 'status' => 'pending' ]; } } // Prepare response data $response_data = [ 'success' => true, 'list' => [ 'id' => (int)$list_access->id, 'name' => $list_access->name, 'description' => $list_access->description, 'created_at' => $list_access->created_at, 'is_owner' => (bool)$list_access->is_owner, 'is_shared' => (bool)$list_access->is_shared, 'items' => $formatted_items, 'shared_users' => $shared_users ] ]; $this->listsCache->set($key, $response_data); return new WP_REST_Response($response_data); } /** * Handle list operations (create, update, delete, add/remove items) * * @param WP_REST_Request $request Request object * @return WP_REST_Response Response with operation result */ public function handleListOperation(WP_REST_Request $request):WP_REST_Response { $data = $request->get_json_params(); $operation = $data['operation'] ?? ''; $user_id = get_current_user_id(); // Get queue system $queue = JVB()->queue(); $operation_id = (array_key_exists('id', $data)) ? $data['id'] : uniqid('list_'); // Process based on operation type switch ($operation) { case 'create': // Create new list if (!array_key_exists('name', $data)) { return $this->createErrorResponse( self::ERROR_MISSING_PARAMS, 'List name is required', 400 ); } $queue->queueOperation( 'favourite_list_create', $user_id, [ 'name' => sanitize_text_field($data['name']), 'description' => sanitize_textarea_field($data['description'] ?? ''), 'items' => $data['items'] ?? [] ], [ 'count' => 1, 'operation_id' => $operation_id ] ); break; case 'update': // Update list if (!array_key_exists('list_id', $data)) { return $this->createErrorResponse( self::ERROR_MISSING_PARAMS, 'List ID is required', 400 ); } $queue->queueOperation( 'favourite_list_update', $user_id, [ 'list_id' => (int)$data['list_id'], 'name' => $data['name'] ?? null, 'description' => $data['description'] ?? null ], [ 'count' => 1, 'operation_id' => $operation_id ] ); break; case 'delete': // Delete list if (!array_key_exists('list_id', $data)) { return $this->createErrorResponse( self::ERROR_MISSING_PARAMS, 'List ID is required', 400 ); } $queue->queueOperation( 'favourite_list_delete', $user_id, [ 'list_id' => (int)$data['list_id'] ], [ 'count' => 1, 'operation_id' => $operation_id ] ); break; case 'add_items': // Add items to list if (!array_key_exists('list_id', $data) || !array_key_exists('items', $data)) { return $this->createErrorResponse( self::ERROR_MISSING_PARAMS, 'List ID and items are required', 400 ); } $queue->queueOperation( 'favourite_list_add', $user_id, [ 'list_id' => (int)$data['list_id'], 'items' => $data['items'] ], [ 'count' => 1, 'chunk_key' => 'items', 'chunk_size' => 20, 'operation_id' => $operation_id ] ); break; case 'remove_items': // Remove items from list if (!array_key_exists('list_id', $data) || !array_key_exists('items', $data)) { return $this->createErrorResponse( self::ERROR_MISSING_PARAMS, 'List ID and items are required', 400 ); } $queue->queueOperation( 'favourite_list_remove', $user_id, [ 'list_id' => (int)$data['list_id'], 'items' => $data['items'] ], [ 'count' => 1, 'chunk_key' => 'items', 'chunk_size' => 20, 'operation_id' => $operation_id ] ); break; default: return $this->createErrorResponse( self::ERROR_INVALID_OPERATION, 'Invalid list operation', 400 ); } return new WP_REST_Response([ 'success' => true, 'message' => __('List operation queued', 'jvb'), 'operation_id' => $operation_id, 'queue_status' => $queue->getQueueStatus() ]); } /** * Get shares for a list * * @param WP_REST_Request $request Request object * @return WP_REST_Response Response with shares data */ public function getShares(WP_REST_Request $request):WP_REST_Response { $user_id = $request->get_param('user'); if (!$user_id || !$this->userCheck($user_id)) { return new WP_REST_Response([ 'success' => false, 'message' => 'Invalid user' ]); } $list_id = $request->get_param('list_id'); if (!$list_id) { return $this->createErrorResponse( self::ERROR_MISSING_PARAMS, 'List ID is required', 400 ); } $args = [ 'user' => $user_id, 'list' => sanitize_text_field($list_id), ]; $key = $this->sharedListsCache->generateKey($args); // Check HTTP cache headers $cache_check = $this->checkHeaders($request, $key); if ($cache_check) { return $cache_check; } $cache = $this->sharedListsCache->get($key); if ($cache) { return new WP_REST_Response($cache); } try { global $wpdb; $lists_table = $wpdb->prefix . BASE . 'favourites_lists'; $shares_table = $wpdb->prefix . BASE . 'favourites_list_shares'; // Verify ownership $is_owner = $wpdb->get_var($wpdb->prepare( "SELECT 1 FROM {$lists_table} WHERE id = %d AND user_id = %d", $list_id, $user_id )); if (!$is_owner) { return $this->createErrorResponse( self::ERROR_ACCESS_DENIED, 'You do not own this list', 403 ); } // Get all shares (both active and pending) in one query $query = $wpdb->prepare( "SELECT s.*, CASE WHEN s.user_id IS NOT NULL THEN u.display_name ELSE NULL END as display_name, CASE WHEN s.user_id IS NOT NULL THEN u.user_email ELSE s.email END as email FROM {$shares_table} s LEFT JOIN {$wpdb->users} u ON s.user_id = u.ID WHERE s.list_id = %d ORDER BY s.status, s.created_at DESC", $list_id ); $all_shares = $wpdb->get_results($query); // Format shares for response $shares = []; foreach ($all_shares as $share) { $formatted_share = [ 'id' => $share->id, 'email' => $share->email, 'status' => $share->status, 'date_added' => $share->created_at, ]; // Add attributes specific to the status if ($share->status === 'accepted') { $formatted_share['name'] = $share->display_name; $formatted_share['user_id'] = $share->user_id; $formatted_share['permission_type'] = $share->permission_type; } else if ($share->status === 'pending') { // Include invitation token if needed for managing invitations $formatted_share['invitation_token'] = $share->invitation_token; } $shares[] = $formatted_share; } $response_data = [ 'success' => true, 'list_id' => $list_id, 'shares' => $shares ]; // Cache the results $this->sharedListsCache->set($key, $response_data); $response = new WP_REST_Response($response_data); return $this->addCacheHeaders($response); } catch (Exception $e) { return $this->createErrorResponse( self::ERROR_PROCESSING, 'Error retrieving shares: ' . $e->getMessage(), 500, ['user_id' => $user_id, 'list_id' => $list_id] ); } } /** * Handle share operations (add/remove) * * @param WP_REST_Request $request Request object * @return WP_REST_Response Response with operation result */ public function handleShare(WP_REST_Request $request):WP_REST_Response { $data = $request->get_json_params(); $operation = $data['operation'] ?? ''; $user_id = get_current_user_id(); $queue = JVB()->queue(); $operation_id = $data['id'] ?? uniqid('share_'); if (empty($data['list_id']) || empty($data['email'])) { return $this->createErrorResponse( self::ERROR_MISSING_PARAMS, 'List ID and email are required', 400 ); } switch ($operation) { case 'add': // Share list with email if (!is_email($data['email'])) { return $this->createErrorResponse( self::ERROR_MISSING_PARAMS, 'Invalid email address', 400 ); } $queue->queueOperation( 'favourite_list_share', $user_id, [ 'list_id' => (int)$data['list_id'], 'email' => sanitize_email($data['email']), 'permission_type' => in_array($data['permission_type'] ?? '', ['view', 'edit']) ? $data['permission_type'] : 'view' ], [ 'count' => 1, 'operation_id' => $operation_id ] ); break; case 'remove': // Remove share $queue->queueOperation( 'favourite_list_unshare', $user_id, [ 'list_id' => (int)$data['list_id'], 'email' => sanitize_email($data['email']) ], [ 'count' => 1, 'operation_id' => $operation_id ] ); break; case 'accept': $data = $request->get_json_params(); $token = $data['token'] ?? ''; $email = sanitize_email($data['email'] ?? ''); $user_id = get_current_user_id(); // Get current user if logged in $result = $this->acceptListInvitation($token, $email, $user_id > 0 ? $user_id : null); break; default: return $this->createErrorResponse( self::ERROR_INVALID_OPERATION, 'Invalid share operation', 400 ); } return new WP_REST_Response([ 'success' => true, 'message' => __('Share operation queued', 'jvb'), 'operation_id' => $operation_id, 'queue_status' => $queue->getQueueStatus() ]); } /** * Batch format a collection of favourite items * * @param array $items Collection of favourite items * @return array Formatted items */ protected function formatItems(array $items):array { if (empty($items)) { return []; } // Group items by type to reduce queries $items_by_type = []; foreach ($items as $item) { if (!isset($item->type) || !isset($item->target_id)) { continue; } // Verify item type is valid if (!isset($this->valid_types[$item->type])) { continue; } $type = $item->type; if (!isset($items_by_type[$type])) { $items_by_type[$type] = []; } $items_by_type[$type][] = $item; } $formatted = []; // Process post-type items in batches foreach ($items_by_type as $type => $type_items) { $config = $this->valid_types[$type]; if ($config['table'] === 'post') { $formatted = array_merge($formatted, $this->formatPostFavourites($type_items)); } else { $formatted = array_merge($formatted, $this->formatTermFavourites($type_items)); } } return $formatted; } /** * Batch format post-type favourites to reduce queries * * @param array $items Collection of favourite items of post type * @return array Formatted items */ protected function formatPostFavourites(array $items):array { if (empty($items)) { return []; } $formatted = []; $post_ids = array_map(function ($item) { return (int)$item->target_id; }, $items); // Get all posts in one query $posts = get_posts([ 'post__in' => $post_ids, 'post_type' => 'any', 'posts_per_page' => -1, 'post_status' => 'any', ]); // Create a lookup map $posts_by_id = []; foreach ($posts as $post) { $posts_by_id[$post->ID] = $post; } // Get all thumbnails for artists in one query if needed $artist_ids = []; foreach ($items as $item) { if ($item->type === BASE.'artist') { $artist_ids[] = (int)$item->target_id; } } $artist_images = []; if (!empty($artist_ids)) { global $wpdb; $meta_query = $wpdb->prepare( "SELECT post_id, meta_value FROM {$wpdb->postmeta} WHERE meta_key = %s AND post_id IN (" . implode(',', array_fill(0, count($artist_ids), '%d')) . ")", array_merge([BASE.'image'], $artist_ids) ); $results = $wpdb->get_results($meta_query); foreach ($results as $result) { $artist_images[$result->post_id] = $result->meta_value; } } // Get all thumbnails in one query $thumbnail_ids = []; foreach ($items as $item) { if ($item->type !== BASE.'artist' && isset($posts_by_id[$item->target_id])) { $thumb_id = get_post_thumbnail_id($item->target_id); if ($thumb_id) { $thumbnail_ids[$item->target_id] = $thumb_id; } } } // Format each item foreach ($items as $item) { $post_id = (int)$item->target_id; // Skip if post doesn't exist if (!isset($posts_by_id[$post_id])) { continue; } $post = $posts_by_id[$post_id]; $formatted_item = [ 'id' => $item->id ?? null, 'type' => str_replace(BASE, '', $item->type), 'target_id' => $post_id, 'date_added' => $item->date_added ?? current_time('mysql'), 'notes' => $item->notes ?? '', 'url' => get_permalink($post), 'title' => $post->post_title, 'author' => [ 'id' => $post->post_author, 'name' => get_the_author_meta('display_name', $post->post_author) ] ]; // Add thumbnail if ($item->type === BASE.'artist') { $meta_value = $artist_images[$post_id] ?? null; $formatted_item['thumbnail'] = $meta_value ? jvbFormatImage($meta_value, 'medium', 'medium') : null; } else { $thumb_id = $thumbnail_ids[$post_id] ?? null; $formatted_item['thumbnail'] = $thumb_id ? jvbFormatImage($thumb_id, 'medium', 'medium') : null; } $formatted[] = $formatted_item; } return $formatted; } /** * Batch format term-type favourites to reduce queries * * @param array $items Collection of favourite items of term type * @return array Formatted items */ protected function formatTermFavourites(array $items):array { if (empty($items)) { return []; } $formatted = []; // Group by taxonomy $terms_by_taxonomy = []; foreach ($items as $item) { $tax = $item->type; if (!isset($terms_by_taxonomy[$tax])) { $terms_by_taxonomy[$tax] = []; } $terms_by_taxonomy[$tax][] = (int)$item->target_id; } // Get all terms by taxonomy $terms_by_id = []; foreach ($terms_by_taxonomy as $taxonomy => $term_ids) { $terms = get_terms([ 'taxonomy' => $taxonomy, 'include' => $term_ids, 'hide_empty' => false, ]); if (!is_wp_error($terms)) { foreach ($terms as $term) { $terms_by_id[$taxonomy . '_' . $term->term_id] = $term; } } } // Get all shop images in one query if needed $shop_ids = []; foreach ($items as $item) { if ($item->type === BASE.'shop') { $shop_ids[] = (int)$item->target_id; } } $shop_images = []; if (!empty($shop_ids)) { global $wpdb; $meta_query = $wpdb->prepare( "SELECT term_id, meta_value FROM {$wpdb->termmeta} WHERE meta_key = %s AND term_id IN (" . implode(',', array_fill(0, count($shop_ids), '%d')) . ")", array_merge([BASE.'image'], $shop_ids) ); $results = $wpdb->get_results($meta_query); foreach ($results as $result) { $shop_images[$result->term_id] = $result->meta_value; } } // Format each item foreach ($items as $item) { $term_id = (int)$item->target_id; $key = $item->type . '_' . $term_id; // Skip if term doesn't exist if (!isset($terms_by_id[$key])) { continue; } $term = $terms_by_id[$key]; $formatted_item = [ 'id' => $item->id ?? null, 'type' => str_replace(BASE, '', $item->type), 'target_id' => $term_id, 'date_added' => $item->date_added ?? current_time('mysql'), 'notes' => $item->notes ?? '', 'title' => html_entity_decode($term->name), 'url' => get_term_link($term) ]; // Add thumbnail for shops if ($item->type === BASE.'shop') { $meta_value = $shop_images[$term_id] ?? null; $formatted_item['thumbnail'] = $meta_value ? jvbFormatImage($meta_value, 'medium', 'medium') : null; } $formatted[] = $formatted_item; } return $formatted; } /** * Get counts of favourites by type for a user * * @param int $user_id User ID * @return array Counts by type */ public function getFavouriteCounts(int $user_id, bool $show_all = true):array { $key = 'favourite_counts_by_type_' . $user_id; $key .= ($show_all) ? '_all' : '_not_all'; $cache = $this->cache->get($key); if ($cache) { return $cache; } try { global $wpdb; $table = $wpdb->prefix . BASE.'favourites'; $counts = $wpdb->get_results($wpdb->prepare(" SELECT type, COUNT(*) as count FROM {$table} WHERE user_id = %d GROUP BY type ", $user_id), OBJECT_K); $all_counts = []; if ($show_all) { // Fill in zeros for types with no favourites $all_counts = array_fill_keys(array_keys($this->valid_types), 0); $temp = []; foreach ($all_counts as $type => $count) { $type_key = str_replace(BASE, '', $type); $temp[$type_key] = $count; } $all_counts = $temp; } foreach ($counts as $type => $data) { $type_key = str_replace(BASE, '', $type); $all_counts[$type_key] = (int)$data->count; } $this->cache->set($key, $all_counts); return $all_counts; } catch (Exception $e) { JVB()->error()->log( 'favourites', 'Error getting counts by type: ' . $e->getMessage(), ['user_id' => $user_id], 'warning' ); return array_fill_keys(array_keys($this->valid_types), 0); } } /** * Process batch favourites operation * * @param int $user_id User ID * @param array $data Operation data * @return array Operation result */ protected function processBatches(int $user_id, array $data):array { error_log('Processing Batch Operation'); if (empty($data['adds']) && empty($data['removes'])) { return [ 'success' => true, 'result' => array() ]; } error_log('Proceeding to TRANSACTION'); global $wpdb; // Start transaction $wpdb->query('START TRANSACTION'); try { // Collect notifications to send after transaction $notifications = []; // Process adds foreach ($data['adds'] as $item) { $result = $this->addFavourite($user_id, $item['type'], $item['target_id']); if (is_wp_error($result)) { $results[] = [ 'success' => false, 'error' => $result->get_error_message(), 'type' => $item['type'], 'target_id' => $item['target_id'] ]; } else { $results[] = array_merge($item, $result); // If notification needed, add to collection instead of sending immediately if (isset($this->valid_types[$item['type']]) && $this->valid_types[$item['type']]['notify_owner']) { $owner_ids = $this->getContentOwner($item['type'], $item['target_id']); if ($owner_ids) { $owner_ids = (is_array($owner_ids)) ? $owner_ids : [$owner_ids]; $type_label = str_replace(BASE, '', $item['type']); foreach ($owner_ids as $owner_id) { if ($owner_id !== $user_id) { $notifications[] = [ 'owner_id' => $owner_id, 'type' => 'new_favourite', 'action_user_id' => $user_id, 'message' => sprintf( '%s favorited your %s', jvbShareName($user_id), $type_label ), 'target_id' => $item['target_id'], 'target_type' => $item['type'], 'context' => [ 'favourite_user_id' => $user_id, 'content_type' => $item['type'], 'content_id' => $item['target_id'], ] ]; } } } } } } // Process removes foreach ($data['removes'] as $item) { $result = $this->removeFavourite($user_id, $item['type'], $item['target_id']); if (is_wp_error($result)) { $results[] = [ 'success' => false, 'error' => $result->get_error_message(), 'type' => $item['type'], 'target_id' => $item['target_id'] ]; } else { $results[] = array_merge($item, $result); } } $wpdb->query('COMMIT'); // Send all notifications at once after successful commit $manager = JVB()->notification(); if (!empty($notifications)) { foreach ($notifications as $notification) { $manager->addNotification( $notification['owner_id'], $notification['type'], $notification['action_user_id'], $notification['message'], $notification['target_id'], $notification['target_type'], $notification['context'] ); } } error_log('Results: '.print_r($results, true)); $this->cache->forget('favourite_counts_by_type_' . $user_id.'_all'); $this->cache->forget('favourite_counts_by_type_' . $user_id.'_not_all'); return [ 'success' => true, 'result' => $results ]; } catch (Exception $e) { // Something went wrong, roll back changes $wpdb->query('ROLLBACK'); JVB()->error()->log( 'favourites', 'Batch operation failed: ' . $e->getMessage(), ['user_id' => $user_id, 'item_count' => count($items ?? [])], 'error' ); error_log('Error: '.print_r($e->getMessage(), true)); return [ 'success' => false, 'result' => $e->getMessage() ]; } } /** * Add a favourite * * @param int $user_id User ID * @param string $type Content type * @param int $target_id Target content ID * @return array Operation result */ protected function addFavourite(int $user_id, string $type, int $target_id):array { if (!str_starts_with($type, BASE)) { $type = BASE.$type; } // Validate type if (!isset($this->valid_types[$type])) { return [ 'success' => false, 'message' => 'Invalid type', 'type' => $type, 'target_id' => $target_id ]; } try { global $wpdb; $table = $wpdb->prefix . BASE . 'favourites'; // Check if already favourited $exists = $wpdb->get_var($wpdb->prepare( "SELECT 1 FROM {$table} WHERE user_id = %d AND type = %s AND target_id = %d LIMIT 1", $user_id, $type, $target_id )); if ($exists) { $result = [ 'success' => true, 'action' => 'already_exists', 'type' => str_replace(BASE, '', $type), 'target_id' => $target_id, 'count' => $this->getFavouriteCount($type, $target_id) ]; // Fire after action even for already existing do_action('jvb_after_favourite_add', $user_id, $type, $target_id, $result); return $result; } // Insert new favourite $inserted = $wpdb->insert( $table, [ 'user_id' => $user_id, 'type' => $type, 'target_id' => $target_id, 'date_added' => current_time('mysql') ], ['%d', '%s', '%d', '%s'] ); if ($inserted === false) { throw new Exception($wpdb->last_error); } // Get new favourite ID $favourite_id = $wpdb->insert_id; // Update favourite count $this->updateFavouriteCount($type, $target_id); // Notify owner if needed $this->maybeNotifyOwner($type, $target_id, $user_id); return [ 'success' => true, 'action' => 'added', 'favourite_id' => $favourite_id, 'type' => str_replace(BASE, '', $type), 'target_id' => $target_id, 'count' => $this->getFavouriteCount($type, $target_id) ]; } catch (Exception $e) { JVB()->error()->log( 'favourites', 'Error adding favourite: ' . $e->getMessage(), ['user_id' => $user_id, 'type' => $type, 'target_id' => $target_id], 'error' ); $result = [ 'success' => false, 'message' => $e->getMessage(), 'type' => str_replace(BASE, '', $type), 'target_id' => $target_id ]; // Fire error action do_action('jvb_favourite_add_error', $user_id, $type, $target_id, $e->getMessage()); return $result; } } protected function removeFavourite(int $user_id, string $type, int $target_id) { try { if (!str_starts_with($type, BASE)) { $type = BASE.$type; } // Validate type if (!isset($this->valid_types[$type])) { return [ 'success' => false, 'message' => 'Invalid type', 'type' => $type, 'target_id' => $target_id ]; } global $wpdb; $table = $wpdb->prefix . BASE . 'favourites'; // Check if favorite exists before deleting $exists = $wpdb->get_var($wpdb->prepare( "SELECT 1 FROM {$table} WHERE user_id = %d AND type = %s AND target_id = %d LIMIT 1", $user_id, $type, $target_id )); if (!$exists) { return [ 'success' => true, 'action' => 'already_removed', 'type' => str_replace(BASE, '', $type), 'target_id' => $target_id, 'count' => $this->getFavouriteCount($type, $target_id) ]; } $deleted = $wpdb->delete( $table, [ 'user_id' => $user_id, 'type' => $type, 'target_id' => $target_id ], ['%d', '%s', '%d'] ); if ($deleted === false) { throw new Exception($wpdb->last_error); } // Update favourite count $this->updateFavouriteCount($type, $target_id); // Remove any related notifications $this->removeRelatedNotifications($user_id, $type, $target_id); // Invalidate cache $this->cache->flush(); $this->listsCache->flush(); $this->sharedListsCache->flush(); $this->favouritesCache->flush(); return [ 'success' => true, 'action' => 'removed', 'type' => str_replace(BASE, '', $type), 'target_id' => $target_id, 'count' => $this->getFavouriteCount($type, $target_id) ]; } catch (Exception $e) { JVB()->error()->log( 'favourites', 'Error removing favourite: ' . $e->getMessage(), ['user_id' => $user_id, 'type' => $type, 'target_id' => $target_id], 'error' ); return [ 'success' => false, 'message' => $e->getMessage(), 'type' => str_replace(BASE, '', $type), 'target_id' => $target_id ]; } } /** * 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' ); } } /** * Process favourite notes update with improved transaction handling * * @param int $user_id User ID * @param array $data Operation data * @return array Operation result */ protected function processNote(int $user_id, array $data):array { global $wpdb; $result = []; // Start transaction $wpdb->query('START TRANSACTION'); try { $IDs = explode(',', $data['target_id']); $results = []; foreach ($IDs as $ID) { $target_id = absint($ID); if ($target_id <= 0) { throw new Exception('Invalid target ID'); } $notes = sanitize_textarea_field($data['notes'] ?? ''); $table = $wpdb->prefix . BASE . 'favourites'; // Check if favourite exists $favourite_id = $wpdb->get_var($wpdb->prepare( "SELECT id FROM {$table} WHERE user_id = %d AND target_id = %d", $user_id, $target_id )); if (!$favourite_id) { $result[] = [ 'success' => false, 'target_id' => $target_id, 'message' => __('No favourite id found...', 'jvb') ]; } else { // Update existing favourite $updated = $wpdb->update( $table, ['notes' => $notes], ['id' => $favourite_id], ['%s'], ['%d'] ); if ($updated === false) { throw new Exception($wpdb->last_error); } $result[] = [ 'success' => true, 'action' => 'updated_notes', 'favourite_id' => $favourite_id, 'target_id' => $target_id ]; } } // If we got here, everything worked - commit the transaction $wpdb->query('COMMIT'); return [ 'success' => true, 'results' => $result ]; } catch (Exception $e) { // Something went wrong, roll back changes $wpdb->query('ROLLBACK'); JVB()->error()->log( 'favourites', 'Notes update failed: ' . $e->getMessage(), [ 'user_id' => $user_id, 'target_id' => $data['target_id'] ?? null ], 'error' ); return [ 'success' => false, 'result' => $e->getMessage(), ]; } } /** * Process list creation * * @param int $user_id User ID * @param array $data Operation data * @return array Operation result */ protected function processListCreate(int $user_id, array $data):array { global $wpdb; // Start transaction $wpdb->query('START TRANSACTION'); try { $name = sanitize_text_field($data['name']); $description = sanitize_textarea_field($data['description'] ?? ''); $items = $data['items'] ?? []; // Fire pre-create action do_action('jvb_before_list_create', $user_id, $name, $description); $lists_table = $wpdb->prefix . BASE . 'favourites_lists'; // Create the list $inserted = $wpdb->insert( $lists_table, [ 'user_id' => $user_id, 'name' => $name, 'description' => $description, 'created_at' => current_time('mysql'), 'updated_at' => current_time('mysql') ], ['%d', '%s', '%s', '%s', '%s'] ); if (!$inserted) { throw new Exception($wpdb->last_error); } $list_id = $wpdb->insert_id; // Add items if any, but limit batch size $added_count = 0; $batch_size = 50; // Process in batches of 50 if (!empty($items)) { $item_batches = array_chunk($items, $batch_size); foreach ($item_batches as $batch) { $result = $this->addItemsToList($list_id, $batch, $user_id); $batch_added = $result['added']; $added_count += $batch_added; // Give the server a small break between large batches if (count($items) > $batch_size) { usleep(50000); // 50ms pause } } } // Commit transaction $wpdb->query('COMMIT'); // Fire post-create action do_action('jvb_after_list_create', $user_id, $list_id, $name, $added_count); return [ 'success' => true, 'result' => [ 'list_id' => $list_id, 'name' => $name, 'added_items' => $added_count, 'message' => sprintf('Created list "%s" with %d items', $name, $added_count) ] ]; } catch (Exception $e) { // Rollback on error $wpdb->query('ROLLBACK'); JVB()->error()->log( 'favourites', 'Error creating list: ' . $e->getMessage(), ['user_id' => $user_id, 'name' => $data['name'] ?? ''], 'error' ); return [ 'success' => false, 'result' => $e->getMessage() ]; } } /** * Process adding items to a list * * @param int $user_id User ID * @param array $data Operation data * @return array Operation result */ protected function processAddToList(int $user_id, array $data): array { global $wpdb; // Start transaction $wpdb->query('START TRANSACTION'); try { $list_ids = explode(',', $data['list_id']); $items = $data['items'] ?? []; if (empty($items)) { throw new Exception('Items array is required'); } $lists_table = $wpdb->prefix . BASE . 'favourites_lists'; $shares_table = $wpdb->prefix . BASE . 'favourites_list_shares'; $results = []; $total_added = 0; $all_errors = []; // Process each list foreach ($list_ids as $list_id) { $list_id = (int) $list_id; if (!$list_id) { $all_errors[] = ['message' => 'Invalid list ID', 'list_id' => $list_id]; continue; } // Check permission - either owner or has edit permission $has_permission = $wpdb->get_var($wpdb->prepare( "SELECT 1 FROM {$lists_table} WHERE id = %d AND user_id = %d UNION SELECT 1 FROM {$shares_table} WHERE list_id = %d AND user_id = %d AND permission_type = 'edit' LIMIT 1", $list_id, $user_id, $list_id, $user_id )); if (!$has_permission) { $all_errors[] = [ 'message' => 'You do not have permission to add items to this list', 'list_id' => $list_id ]; continue; } // Call the optimized helper method to add items $add_result = $this->addItemsToList($list_id, $items, $user_id); // Track results $total_added += $add_result['added']; if (!empty($add_result['errors'])) { $all_errors = array_merge($all_errors, $add_result['errors']); } $results[] = [ 'list_id' => $list_id, 'added_count' => $add_result['added'] ]; } // If we've had no successful additions and only errors, throw an exception if ($total_added == 0 && !empty($all_errors)) { throw new Exception('Failed to add any items: ' . json_encode($all_errors)); } // Commit the transaction $wpdb->query('COMMIT'); // Invalidate relevant caches $this->listsCache->flush(); return [ 'success' => true, 'results' => [ 'success' => $results, 'added_count' => $total_added, 'errors' => $all_errors ] ]; } catch (Exception $e) { // Something went wrong, roll back changes $wpdb->query('ROLLBACK'); JVB()->error()->log( 'favourites', 'Error adding items to list: ' . $e->getMessage(), ['user_id' => $user_id, 'list_ids' => $data['list_id'] ?? 0], 'error' ); return [ 'success' => false, 'result' => $e->getMessage() ]; } } /** * Helper method to add items to a list with improved performance and error handling * * @param int $list_id List ID * @param array $items Items to add * @param int $user_id User ID * @return array Success details with counts and errors */ protected function addItemsToList(int $list_id, array $items, int $user_id) { if (empty($items)) { return ['added' => 0, 'errors' => []]; } global $wpdb; $items_table = $wpdb->prefix . BASE . 'favourites_list_items'; $added = 0; $errors = []; try { // Group items by type for more efficient processing $items_by_type = []; foreach ($items as $item) { if (empty($item['type']) || !isset($item['target_id'])) { $errors[] = ['message' => 'Item missing type or target_id', 'item' => $item]; continue; } $type = isset($item['type']) && strpos($item['type'], BASE) !== 0 ? BASE . $item['type'] : $item['type']; if (!isset($items_by_type[$type])) { $items_by_type[$type] = []; } $items_by_type[$type][] = (int)$item['target_id']; } // Process each type in bulk foreach ($items_by_type as $type => $target_ids) { // Find existing items to avoid duplicates $placeholders = implode(',', array_fill(0, count($target_ids), '%d')); $query_params = array_merge([$list_id], $target_ids); array_unshift($query_params, $type); $existing_query = $wpdb->prepare( "SELECT item_id FROM {$items_table} WHERE list_id = %d AND item_type = %s AND item_id IN ({$placeholders})", $query_params ); $existing_ids = $wpdb->get_col($existing_query); $existing_ids_map = array_flip($existing_ids); // Get favourite IDs in bulk $favourites_table = $wpdb->prefix . BASE . "favourites"; $fav_query = $wpdb->prepare( "SELECT id, target_id FROM {$favourites_table} WHERE user_id = %d AND type = %s AND target_id IN ({$placeholders})", array_merge([$user_id, $type], $target_ids) ); $fav_results = $wpdb->get_results($fav_query); $fav_id_map = []; foreach ($fav_results as $fav) { $fav_id_map[$fav->target_id] = $fav->id; } // Prepare bulk insert $now = current_time('mysql'); $insert_values = []; $insert_placeholders = []; foreach ($target_ids as $target_id) { // Skip if already in list if (isset($existing_ids_map[$target_id])) { continue; } $fav_id = $fav_id_map[$target_id] ?? null; $insert_values[] = $list_id; $insert_values[] = $type; $insert_values[] = $target_id; $insert_values[] = $fav_id; $insert_values[] = $now; $insert_placeholders[] = "(%d, %s, %d, %d, %s)"; } // Perform bulk insert if there are items to add if (!empty($insert_placeholders)) { $insert_query = "INSERT INTO {$items_table} (list_id, item_type, item_id, favourite_id, added_at) VALUES " . implode(',', $insert_placeholders); $result = $wpdb->query($wpdb->prepare($insert_query, $insert_values)); if ($result === false) { throw new Exception($wpdb->last_error); } $added += $result; } } // Update list modified timestamp $lists_table = $wpdb->prefix . BASE . 'favourites_lists'; $wpdb->update( $lists_table, ['updated_at' => current_time('mysql')], ['id' => $list_id], ['%s'], ['%d'] ); return ['added' => $added, 'errors' => $errors]; } catch (Exception $e) { // Rollback transaction on error $wpdb->query('ROLLBACK'); JVB()->error()->log( 'favourites', 'Error adding items to list: ' . $e->getMessage(), ['user_id' => $user_id, 'list_id' => $list_id, 'items_count' => count($items)], 'error' ); $errors[] = ['message' => $e->getMessage()]; return ['added' => 0, 'errors' => $errors]; } } /** * Process list update * * @param int $user_id User ID * @param array $data Operation data * @return array Operation result */ protected function processUpdateList(int $user_id, array $data):array { $list_id = intval($data['list_id'] ?? 0); if (!$list_id) { return [ 'success' => false, 'result' => 'List ID is required' ]; } try { global $wpdb; $lists_table = $wpdb->prefix . BASE . 'favourites_lists'; // Verify ownership $is_owner = $wpdb->get_var($wpdb->prepare( "SELECT 1 FROM {$lists_table} WHERE id = %d AND user_id = %d", $list_id, $user_id )); if (!$is_owner) { return [ 'success' => false, 'result' => 'You do not have permission to update this list' ]; } // Build update data $update_data = []; $update_format = []; if (isset($data['name'])) { $update_data['name'] = sanitize_text_field($data['name']); $update_format[] = '%s'; } if (isset($data['description'])) { $update_data['description'] = sanitize_textarea_field($data['description']); $update_format[] = '%s'; } if (empty($update_data)) { return [ 'success' => true, 'result' => 'No changes to update' ]; } // Add updated timestamp $update_data['updated_at'] = current_time('mysql'); $update_format[] = '%s'; $updated = $wpdb->update( $lists_table, $update_data, ['id' => $list_id, 'user_id' => $user_id], $update_format, ['%d', '%d'] ); if ($updated === false) { throw new Exception($wpdb->last_error); } return [ 'success' => true, 'result' => [ 'message' => 'List updated successfully', 'list_id' => $list_id, 'updates' => array_keys($update_data) ] ]; } catch (Exception $e) { JVB()->error()->log( 'favourites', 'Error updating list: ' . $e->getMessage(), ['user_id' => $user_id, 'list_id' => $list_id], 'error' ); return [ 'success' => false, 'result' => $e->getMessage() ]; } } /** * Process list deletion * * @param int $user_id User ID * @param array $data Operation data * @return array Operation result */ protected function processListDeletion(int $user_id, array $data):array { $list_id = intval($data['list_id'] ?? 0); if (!$list_id) { return [ 'success' => false, 'result' => 'List ID is required' ]; } try { global $wpdb; $lists_table = $wpdb->prefix . BASE . 'favourites_lists'; // Verify ownership $is_owner = $wpdb->get_var($wpdb->prepare( "SELECT 1 FROM {$lists_table} WHERE id = %d AND user_id = %d", $list_id, $user_id )); if (!$is_owner) { return [ 'success' => false, 'result' => 'You do not have permission to delete this list' ]; } // Start transaction for cascading deletes $wpdb->query('START TRANSACTION'); // Delete from all related tables $items_table = $wpdb->prefix . BASE . 'favourites_list_items'; $shares_table = $wpdb->prefix . BASE . 'favourites_list_shares'; $pending_shares_table = $wpdb->prefix . BASE . 'favourites_pending_shares'; $wpdb->delete($items_table, ['list_id' => $list_id], ['%d']); $wpdb->delete($shares_table, ['list_id' => $list_id], ['%d']); $wpdb->delete($pending_shares_table, ['list_id' => $list_id], ['%d']); // Delete the list itself $deleted = $wpdb->delete( $lists_table, ['id' => $list_id, 'user_id' => $user_id], ['%d', '%d'] ); if ($deleted === false) { throw new Exception($wpdb->last_error); } $wpdb->query('COMMIT'); return [ 'success' => true, 'result' => [ 'message' => 'List deleted successfully', 'list_id' => $list_id ] ]; } catch (Exception $e) { $wpdb->query('ROLLBACK'); JVB()->error()->log( 'favourites', 'Error deleting list: ' . $e->getMessage(), ['user_id' => $user_id, 'list_id' => $list_id], 'error' ); return [ 'success' => false, 'result' => $e->getMessage() ]; } } /** * Process removing items from a list * * @param int $user_id User ID * @param array $data Operation data * @return array Operation result */ protected function removeFromList(int $user_id, array $data):array { $list_id = intval($data['list_id'] ?? 0); $items = $data['items'] ?? []; if (!$list_id || empty($items)) { return [ 'success' => false, 'result' => 'List ID and items are required' ]; } try { global $wpdb; $lists_table = $wpdb->prefix . BASE . 'favourites_lists'; $items_table = $wpdb->prefix . BASE . 'favourites_list_items'; $shares_table = $wpdb->prefix . BASE . 'favourites_list_shares'; // Check permission - either owner or has edit permission $has_permission = $wpdb->get_var($wpdb->prepare( "SELECT 1 FROM {$lists_table} WHERE id = %d AND user_id = %d UNION SELECT 1 FROM {$shares_table} WHERE list_id = %d AND user_id = %d AND permission_type = 'edit' LIMIT 1", $list_id, $user_id, $list_id, $user_id )); if (!$has_permission) { return [ 'success' => false, 'result' => 'You do not have permission to remove items from this list' ]; } // Remove items $removed = 0; foreach ($items as $item) { if (empty($item['type']) || !isset($item['target_id'])) { continue; } $type = isset($item['type']) && strpos($item['type'], BASE) !== 0 ? BASE . $item['type'] : $item['type']; $deleted = $wpdb->delete( $items_table, [ 'list_id' => $list_id, 'item_type' => $type, 'item_id' => intval($item['target_id']) ], ['%d', '%s', '%d'] ); if ($deleted) { $removed += $deleted; } } return [ 'success' => true, 'result' => [ 'message' => "{$removed} items removed from list", 'list_id' => $list_id, 'removed_count' => $removed ] ]; } catch (Exception $e) { JVB()->error()->log( 'favourites', 'Error removing items from list: ' . $e->getMessage(), ['user_id' => $user_id, 'list_id' => $list_id], 'error' ); return [ 'success' => false, 'result' => $e->getMessage() ]; } } /** * Process sharing a list * * @param int $user_id User ID * @param array $data Operation data * @return array Operation result */ protected function shareList(int $user_id, array $data):array { global $wpdb; $result = null; // Start transaction $wpdb->query('START TRANSACTION'); try { $list_id = intval($data['list_id'] ?? 0); $email = sanitize_email($data['email'] ?? ''); $permission_type = in_array($data['permission_type'] ?? '', ['view', 'edit']) ? $data['permission_type'] : 'view'; if (!$list_id || !$email) { throw new Exception('List ID and email are required'); } $lists_table = $wpdb->prefix . BASE . 'favourites_lists'; $shares_table = $wpdb->prefix . BASE . 'favourites_list_shares'; // Verify ownership $list = $wpdb->get_row($wpdb->prepare( "SELECT l.*, u.display_name as owner_name FROM {$lists_table} l JOIN {$wpdb->users} u ON l.user_id = u.ID WHERE l.id = %d AND l.user_id = %d", $list_id, $user_id )); if (!$list) { throw new Exception('You do not have permission to share this list'); } // Look up user by email $share_user = get_user_by('email', $email); if ($share_user) { // User exists - check if they already have access $existing_share = $wpdb->get_row($wpdb->prepare( "SELECT * FROM {$shares_table} WHERE list_id = %d AND (user_id = %d OR email = %s)", $list_id, $share_user->ID, $email )); if ($existing_share) { // Update permission if it's different if ($existing_share->permission_type !== $permission_type || $existing_share->status !== 'accepted') { $wpdb->update( $shares_table, [ 'permission_type' => $permission_type, 'status' => 'accepted', 'user_id' => $share_user->ID, 'updated_at' => current_time('mysql') ], ['id' => $existing_share->id] ); if ($wpdb->last_error) { throw new Exception($wpdb->last_error); } $result = [ 'success' => true, 'action' => 'updated', 'message' => "Updated sharing permissions for {$email}", 'user_id' => $share_user->ID, 'email' => $email, 'permission_type' => $permission_type ]; } else { $result = [ 'success' => true, 'action' => 'already_shared', 'message' => "List is already shared with {$email}", 'user_id' => $share_user->ID, 'email' => $email, 'permission_type' => $permission_type ]; } } else { // Add new share for existing user $wpdb->insert( $shares_table, [ 'list_id' => $list_id, 'user_id' => $share_user->ID, 'email' => $email, 'permission_type' => $permission_type, 'status' => 'accepted', 'created_at' => current_time('mysql'), 'updated_at' => current_time('mysql') ], ['%d', '%d', '%s', '%s', '%s', '%s', '%s'] ); if ($wpdb->last_error) { throw new Exception($wpdb->last_error); } // Send notification to user JVB()->notification()->addNotification( $share_user->ID, // Recipient 'list_shared', $user_id, // Action user ID sprintf('%s shared a favorites list with you: "%s"', jvbShareName($user_id), $list->name), $list_id, 'favourites_list', [ 'list_id' => $list_id, 'list_name' => $list->name, 'permission_type' => $permission_type ] ); $result = [ 'success' => true, 'result' => [ 'message' => "List shared with {$email}", 'user_id' => $share_user->ID, 'email' => $email, 'permission_type' => $permission_type ] ]; } } else { // User doesn't exist - check for existing pending invitation $existing_pending = $wpdb->get_var($wpdb->prepare( "SELECT id FROM {$shares_table} WHERE list_id = %d AND email = %s AND status = 'pending'", $list_id, $email )); if ($existing_pending) { $result = [ 'success' => true, 'result' => [ 'action' => 'already_pending', 'message' => "Invitation already sent to {$email}", 'email' => $email ] ]; } else { // Generate invitation token $token = wp_generate_password(32, false); // Create pending share $wpdb->insert( $shares_table, [ 'list_id' => $list_id, 'email' => $email, 'permission_type' => $permission_type, 'status' => 'pending', 'invitation_token' => $token, 'created_at' => current_time('mysql'), 'updated_at' => current_time('mysql') ], ['%d', '%s', '%s', '%s', '%s', '%s', '%s'] ); if ($wpdb->last_error) { throw new Exception($wpdb->last_error); } // Send invitation email $this->sendListInviteEmail($email, [ 'list_id' => $list_id, 'list_name' => $list->name, 'token' => $token, 'owner_name' => $list->owner_name, 'user_id' => $user_id ]); $result = [ 'success' => true, 'result' => [ 'action' => 'invitation_sent', 'message' => "Invitation sent to {$email}", 'email' => $email ] ]; } } // All operations successful, commit the transaction $wpdb->query('COMMIT'); // Return the result that was set earlier return $result; } catch (Exception $e) { // Something went wrong, roll back changes $wpdb->query('ROLLBACK'); JVB()->error()->log( 'favourites', 'Error sharing list: ' . $e->getMessage(), ['user_id' => $user_id, 'list_id' => $data['list_id'] ?? 0, 'email' => $data['email'] ?? ''], 'error' ); return [ 'success' => false, 'result' => $e->getMessage() ]; } } /** * Process unsharing a list * * @param int $user_id User ID * @param array $data Operation data * @return array Operation result */ protected function unshareList(int $user_id, array $data):array { $list_id = intval($data['list_id'] ?? 0); $email = sanitize_email($data['email'] ?? ''); if (!$list_id || !$email) { return [ 'success' => false, 'result' => 'List ID and email are required' ]; } try { global $wpdb; $lists_table = $wpdb->prefix . BASE . 'favourites_lists'; $shares_table = $wpdb->prefix . BASE . 'favourites_list_shares'; // Verify ownership $is_owner = $wpdb->get_var($wpdb->prepare( "SELECT 1 FROM {$lists_table} WHERE id = %d AND user_id = %d", $list_id, $user_id )); if (!$is_owner) { return [ 'success' => false, 'result' => 'You do not have permission to manage shares for this list' ]; } // Look for any share with this email, regardless of status $existing_share = $wpdb->get_row($wpdb->prepare( "SELECT * FROM {$shares_table} WHERE list_id = %d AND email = %s", $list_id, $email )); if (!$existing_share) { // Also check by user_id if it's a registered user's email $share_user = get_user_by('email', $email); if ($share_user) { $existing_share = $wpdb->get_row($wpdb->prepare( "SELECT * FROM {$shares_table} WHERE list_id = %d AND user_id = %d", $list_id, $share_user->ID )); } } if (!$existing_share) { return [ 'success' => false, 'result' => "No active share or invitation found for {$email}" ]; } // For active shares, update status to 'revoked' // For pending invitations, also update status to 'revoked' $updated = $wpdb->update( $shares_table, [ 'status' => 'revoked', 'updated_at' => current_time('mysql') ], ['id' => $existing_share->id], ['%s', '%s'], ['%d'] ); if ($updated === false) { throw new Exception($wpdb->last_error); } // Determine the appropriate message based on previous status if ($existing_share->status === 'accepted') { $action = 'unshared'; $message = "Removed {$email}'s access to list"; // Send notification to user if they're registered if ($existing_share->user_id) { // Get list details $list = $wpdb->get_row($wpdb->prepare( "SELECT * FROM {$lists_table} WHERE id = %d", $list_id )); if ($list) { JVB()->notification()->addNotification( $existing_share->user_id, 'list_share_revoked', $user_id, // Action user ID sprintf('Your access to the list "%s" has been revoked', $list->name), $list_id, 'favourites_list', [ 'list_id' => $list_id, 'list_name' => $list->name ] ); } } } else { $action = 'invitation_cancelled'; $message = "Cancelled invitation to {$email}"; } return [ 'success' => true, 'result' => [ 'action' => $action, 'message' => $message, 'email' => $email ] ]; } catch (Exception $e) { JVB()->error()->log( 'favourites', 'Error unsharing list: ' . $e->getMessage(), ['user_id' => $user_id, 'list_id' => $list_id, 'email' => $email], 'error' ); return [ 'success' => false, 'result' => $e->getMessage() ]; } } /** * Send list invitation email * * @param string $email Recipient email * @param array $data Invitation data * @return bool Success status */ protected function sendListInviteEmail(string $email, array $data):bool { $list_name = $data['list_name']; $token = $data['token']; $owner_name = $data['owner_name']; // Generate invitation URL $invite_url = add_query_arg([ 'action' => 'accept_list_invite', 'token' => $token, 'list' => $data['list_id'] ], home_url('/')); $inviteButton = sprintf( '

Set Your Password

', $invite_url ); $inviteUrl = sprintf( '

%s

', $invite_url ); $subject = sprintf('%s shared a favourites list with you on edmonton.ink', $owner_name); $message = sprintf( '

Hi there,

%s has shared their list \"%s\" with you on edmonton.ink.

To view this list, click the button below:

%s

Or copy and paste this link into your browser:

%s

If you don\'t already have an account, you\'ll be guided through creating one.

%s', $owner_name, $list_name, $inviteButton, $inviteUrl, JVB()->email()->signature() ); return JVB()->email()->sendEmail($email, $subject, $message); } /** * Accept a list share invitation, handling both registered and non-registered users * * @param string $token Invitation token * @param string $email Email address of the invited user * @param int|null $user_id Optional user ID if already registered * @return array Result with success status and messages */ public function acceptListInvitation(string $token, string $email, ?int $user_id = null):array { if (!$token || !$email) { return [ 'success' => false, 'message' => 'Invalid invitation parameters' ]; } try { global $wpdb; $shares_table = $wpdb->prefix . BASE . 'favourites_list_shares'; // Find the pending invitation $invitation = $wpdb->get_row($wpdb->prepare( "SELECT * FROM {$shares_table} WHERE invitation_token = %s AND email = %s AND status = 'pending'", $token, $email )); if (!$invitation) { return [ 'success' => false, 'message' => 'Invalid or expired invitation' ]; } // If no user_id provided, check if a user with this email exists if (!$user_id) { $existing_user = get_user_by('email', $email); if ($existing_user) { $user_id = $existing_user->ID; } else { // No user account exists - create a registration URL with the token embedded $registration_url = add_query_arg([ 'action' => 'register', 'type' => 'favourites', 'list_token' => $token, 'email' => urlencode($email) ], wp_login_url()); return [ 'success' => false, 'message' => 'You need to create an account to access this shared list', 'needs_registration' => true, 'registration_url' => $registration_url ]; } } // Get the list details for the response $lists_table = $wpdb->prefix . BASE . 'favourites_lists'; $list = $wpdb->get_row($wpdb->prepare( "SELECT l.*, u.display_name as owner_name FROM {$lists_table} l JOIN {$wpdb->users} u ON l.user_id = u.ID WHERE l.id = %d", $invitation->list_id )); if (!$list) { return [ 'success' => false, 'message' => 'The shared list no longer exists' ]; } // Update the invitation to accepted status $updated = $wpdb->update( $shares_table, [ 'status' => 'accepted', 'user_id' => $user_id, 'updated_at' => current_time('mysql') ], ['id' => $invitation->id], ['%s', '%d', '%s'], ['%d'] ); if ($updated === false) { throw new Exception($wpdb->last_error); } // Notify the list owner $user = get_userdata($user_id); $display_name = $user ? $user->display_name : $email; JVB()->notification()->addNotification( $list->user_id, // Owner ID 'list_share_accepted', $user_id, // Action user ID sprintf('%s accepted your invitation to the list "%s"', $display_name, $list->name), $invitation->list_id, 'favourites_list', [ 'list_id' => $invitation->list_id, 'list_name' => $list->name, 'user_id' => $user_id, 'email' => $email ] ); return [ 'success' => true, 'message' => 'List successfully shared with you', 'list_id' => $invitation->list_id, 'list_name' => $list->name, 'permission_type' => $invitation->permission_type ]; } catch (Exception $e) { JVB()->error()->log( 'favourites', 'Error accepting list invitation: ' . $e->getMessage(), ['token' => $token, 'email' => $email], 'error' ); return [ 'success' => false, 'message' => $e->getMessage() ]; } } /** * Get count of favourites for an item * * @param string $type Item type * @param int $target_id Item ID * @return int Favourite count */ protected function getFavouriteCount(string $type, int $target_id):int { try { global $wpdb; $table = $wpdb->prefix . BASE . 'favourites'; return (int)$wpdb->get_var($wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE type = %s AND target_id = %d", $type, $target_id )); } catch (Exception $e) { return 0; } } /** * Update favourite count meta for an item * * @param string $type Item type * @param int $target_id Item ID */ protected function updateFavouriteCount(string $type, int $target_id):void { if (!isset($this->valid_types[$type])) { return; } try { $config = $this->valid_types[$type]; $count = $this->getFavouriteCount($type, $target_id); if ($config['table'] === 'post') { update_post_meta($target_id, $config['count_meta_key'], $count); } else { update_term_meta($target_id, $config['count_meta_key'], $count); } } catch (Exception $e) { // Log but continue JVB()->error()->log( 'favourites', 'Error updating favourite count: ' . $e->getMessage(), ['type' => $type, 'target_id' => $target_id], 'warning' ); } } /** * 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 { // Skip if this type doesn't need owner notification if (!array_key_exists($type, $this->valid_types) || !$this->valid_types[$type]['notify_owner']) { return; } try { $owner_ids = $this->getContentOwner($type, $target_id); if ($owner_ids) { $owner_ids = (is_array($owner_ids)) ? $owner_ids : [$owner_ids]; foreach ($owner_ids as $owner_id) { if ($owner_id !== $user_id) { $type_label = str_replace(BASE, '', $type); JVB()->notification()->addNotification( $owner_id, 'new_favourite', $user_id, // Action user ID sprintf('%s favourited your %s', jvbShareName($user_id), $type_label), $target_id, $type, [ 'favourite_user_id' => $user_id, 'content_type' => $type, 'content_id' => $target_id ] ); } } } } catch (Exception $e) { // Log but continue JVB()->error()->log( 'favourites', 'Error notifying owner: ' . $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); } } /** * Get the owner ID for a content item * * @param string $type Content type * @param int $target_id Content ID * @return int|null Owner ID */ protected function getContentOwner(string $type, int $target_id):int|null { if (!array_key_exists($type, $this->valid_types)) { return null; } try { if ($this->valid_types[$type]['table'] === 'post') { $post = get_post($target_id); return $post ? $post->post_author : false; } elseif ($type === BASE.'shop') { return $this->getShopOwner($target_id); } } catch (Exception $e) { return null; } return null; } /** * Get the owner ID for a shop * * @param int $shop_id Shop term ID * @return int|null Owner ID */ protected function getShopOwner(int $shop_id):array { // Get shop manager users $owners = get_term_meta($shop_id, BASE.'owner', true); $owners = explode(',', $owners); $managers = get_term_meta($shop_id, BASE.'managers', true); $managers = explode(',', $managers); return array_merge($owners, $managers); } /** * Maintenance method to clean up orphaned favourites * Called by scheduled task */ /** * Maintenance method to clean up orphaned favourites * Scheduled action * @return bool */ public function cleanupOrphanedFavourites():bool { try { global $wpdb; $table = $wpdb->prefix . BASE.'favourites'; // Delete favourites for non-existent users $wpdb->query(" DELETE f FROM $table f LEFT JOIN {$wpdb->users} u ON f.user_id = u.ID WHERE u.ID IS NULL "); // Delete favourites for non-existent posts $post_types = array_map(function ($type) use ($wpdb) { return $wpdb->prepare('%s', $type); }, array_filter(array_keys($this->valid_types), function ($type) { return $type === 'content'; })); if (!empty($post_types)) { $post_types_list = implode(',', $post_types); $wpdb->query(" DELETE f FROM $table f LEFT JOIN {$wpdb->posts} p ON f.target_id = p.ID WHERE f.type IN ($post_types_list) AND p.ID IS NULL "); } // Delete favourites for non-existent terms $term_types = array_map(function ($type) use ($wpdb) { return $wpdb->prepare('%s', $type); }, array_filter(array_keys($this->valid_types), function ($type) { return $type === 'tax'; })); if (!empty($term_types)) { $term_types_list = implode(',', $term_types); $wpdb->query(" DELETE f FROM $table f LEFT JOIN {$wpdb->terms} t ON f.target_id = t.term_id WHERE f.type IN ($term_types_list) AND t.term_id IS NULL "); } return true; } catch (Exception $e) { JVB()->error()->log( 'favourites', 'Error during cleanup: ' . $e->getMessage(), [], 'error' ); return false; } } /** * Create a standardized error response * * @param string $code Error code * @param string $message Error message * @param int $status HTTP status code * @param array $additional_data Additional context data * @return WP_REST_Response Formatted error */ protected function createErrorResponse(string $code, string $message, int $status = 400, array $additional_data = []):WP_REST_Response { $error = new WP_Error( $code, __($message, 'jvb'), ['status' => $status] ); if (!empty($additional_data)) { $error->add_data($additional_data, 'additional_data'); } // Log error using central error handling system JVB()->error()->log( 'favourites', $message, $additional_data, 'error' ); return new WP_REST_Response([ 'success' => false, 'code' => $code, 'message' => __($message, 'jvb'), 'status' => $status ]); } /** * Handle queued operations for favourites * * @param WP_Error|array $result Current result * @param object $operation Operation from queue * @param array $data Current Data * @return array|WP_Error Processing result */ public function processOperation(WP_Error|array $result, object $operation, array $data):array|WP_Error { // Check if this is a favourites-related operation type $favourites_operations = [ 'favourites_batch', 'favourite_notes', 'favourite_list_create', 'favourite_list_update', 'favourite_list_delete', 'favourite_list_add', 'favourite_list_remove', 'favourite_list_share', 'favourite_list_unshare' ]; if (!in_array($operation->type, $favourites_operations)) { return $result; // Not our operation, pass through } try { $user_id = $operation->user_id; switch ($operation->type) { case 'favourites_batch': $response = $this->processBatches($user_id, $data); $this->cache->flush(); return $response; case 'favourite_notes': $response = $this->processNote($user_id, $data); $this->cache->flush(); return $response; case 'favourite_list_create': $response = $this->processListCreate($user_id, $data); $this->listsCache->flush(); return $response; case 'favourite_list_update': $response = $this->processUpdateList($user_id, $data); $this->listsCache->flush(); return $response; case 'favourite_list_delete': $response = $this->processListDeletion($user_id, $data); $this->listsCache->flush(); return $response; case 'favourite_list_add': $response = $this->processAddToList($user_id, $data); $this->listsCache->flush(); return $response; case 'favourite_list_remove': $response = $this->removeFromList($user_id, $data); $this->listsCache->flush(); return $response; case 'favourite_list_share': $response = $this->shareList($user_id, $data); $this->sharedListsCache->flush(); return $response; case 'favourite_list_unshare': $response = $this->unshareList($user_id, $data); $this->sharedListsCache->flush(); return $response; default: return $result; } } catch (Exception $e) { JVB()->error()->log( '[FavouritesRoutes]:processOperation', 'Failed to process queued operation: ' . $e->getMessage(), [ 'operation_id' => $operation->id, 'type' => $operation->type, 'user_id' => $operation->user_id ], 'error' ); return $result; } } /** * Clean up favourites when a post is deleted * * @param int $post_id The ID of the post being deleted */ public function cleanupPostFavourites(int $post_id) { try { global $wpdb; $table = $wpdb->prefix . BASE . 'favourites'; $type = get_post_type($post_id); if (!$type) { return; } $type = BASE . $type; if (!isset($this->valid_types[$type])) { return; } // Delete favourites for this post $wpdb->delete( $table, [ 'type' => $type, 'target_id' => $post_id ], ['%s', '%d'] ); // Also remove from list items $items_table = $wpdb->prefix . BASE . 'favourites_list_items'; $wpdb->delete( $items_table, [ 'item_type' => $type, 'item_id' => $post_id ], ['%s', '%d'] ); } catch (Exception $e) { JVB()->error()->log( 'favourites', 'Error cleaning up favourites for deleted post: ' . $e->getMessage(), ['post_id' => $post_id], 'error' ); } } /** * Clean up favourites when a term is deleted * * @param int $term_id The ID of the deleted term * @param int $tt_id Term taxonomy ID * @param string $taxonomy The taxonomy slug */ public function cleanupTermFavourites(int $term_id, int $tt_id, string $taxonomy) { try { if (!isset($this->valid_types[$taxonomy])) { return; } global $wpdb; $table = $wpdb->prefix . BASE . 'favourites'; // Delete favourites for this term $wpdb->delete( $table, [ 'type' => $taxonomy, 'target_id' => $term_id ], ['%s', '%d'] ); // Also remove from list items $items_table = $wpdb->prefix . BASE . 'favourites_list_items'; $wpdb->delete( $items_table, [ 'item_type' => $taxonomy, 'item_id' => $term_id ], ['%s', '%d'] ); // Clean up list stats $stats_table = $wpdb->prefix . BASE . 'favourites_list_stats'; $wpdb->delete( $stats_table, [ 'item_type' => $taxonomy, 'item_id' => $term_id ], ['%s', '%d'] ); } catch (Exception $e) { JVB()->error()->log( 'favourites', 'Error cleaning up favourites for deleted term: ' . $e->getMessage(), ['term_id' => $term_id, 'taxonomy' => $taxonomy], 'error' ); } } }