cacheName = 'favourites'; $this->cacheTtl = HOUR_IN_SECONDS; parent::__construct(); // 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->valid_types = array_keys(array_merge(JVB_CONTENT, JVB_TAXONOMY)); // 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 { // Favourites endpoints Route::for('favourites') ->get([$this, 'getFavourites']) ->args([ 'user' => 'integer|required', 'type' => 'string', 'include_all' => 'boolean', ]) ->auth(PermissionHandler::combine(['user', ['actionNonce' => 'favourites-']])) ->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', 'items' => 'array', 'notes' => 'string', ]) ->auth(PermissionHandler::combine(['user', ['actionNonce' => 'favourites-']])) ->rateLimit(30) ->register(); // Lists endpoints Route::for('favourites/lists') ->get([$this, 'getLists']) ->args(['user' => 'integer|required']) ->auth(PermissionHandler::combine(['user', ['actionNonce' => 'favourites-']])) ->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', 'name' => 'string', 'items' => 'array', ]) ->auth(PermissionHandler::combine(['user', ['actionNonce' => 'favourites-']])) ->rateLimit(20) ->register(); // Favourite counts Route::for('favourites/counts') ->get([$this, 'getFavouriteCounts']) ->args(['user' => 'integer|required']) ->auth(PermissionHandler::combine(['user', ['actionNonce' => 'favourites-']])) ->register(); } /** * Get user's favourites with optional filtering */ public function getFavourites(WP_REST_Request $request): WP_REST_Response { $user_id = absint($request->get_param('user')); if (!$this->userCheck($user_id)) { return $this->unauthorized(); } $args = $this->buildParams($request); $key = $this->cache->generateKey($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)); } /** * 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')); if (!$this->userCheck($user_id)) { return $this->unauthorized(); } $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') ?? ''), ]; JVB()->queue()->queueOperation( 'favourite_' . $action, $user_id, $data, [ 'operation_id' => $operation_id, 'priority' => 'high', ] ); return $this->queued($operation_id); } /** * Get user's favourite lists */ public function getLists(WP_REST_Request $request): WP_REST_Response { $user_id = absint($request->get_param('user')); if (!$this->userCheck($user_id)) { return $this->unauthorized(); } $params = ['user' => $user_id]; if ($request->get_param('id')) { $params['list'] = sanitize_text_field($request->get_param('id')); } $key = $this->listsCache->generateKey($params); // 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)); } /** * 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 []; } }); } /** * 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(); } $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); } }); 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 ($result['created']) { $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 { $items = $data['items'] ?? []; if (empty($items)) { return ['success' => false, 'result' => 'No items provided']; } return $this->favourites->transaction(function($table) use ($user_id, $items) { $results = [ 'added' => 0, 'removed' => 0, 'errors' => [] ]; foreach ($items as $item) { try { $type = BASE . ($item['type'] ?? ''); $target_id = absint($item['target_id'] ?? 0); $action = $item['action'] ?? 'add'; if ($action === 'add') { $result = $table->findOrCreate([ 'user_id' => $user_id, 'type' => $type, 'target_id' => $target_id ]); if ($result['created']) $results['added']++; } else { $deleted = $table->where([ 'user_id' => $user_id, 'type' => $type, 'target_id' => $target_id ])->deleteResults(); if ($deleted) $results['removed']++; } $this->updateFavouriteCount($type, $target_id); } catch (Exception $e) { $results['errors'][] = $item['target_id'] ?? 'unknown'; } } return [ 'success' => true, 'result' => $results ]; }); } /** * Create a list using transaction */ protected function createList(int $user_id, array $data): array { $name = sanitize_text_field($data['name'] ?? 'Untitled List'); $description = sanitize_textarea_field($data['description'] ?? ''); $items = $data['items'] ?? []; return $this->lists->transaction(function($table) use ($user_id, $name, $description, $items) { $list_id = $table->create([ 'user_id' => $user_id, 'name' => $name, 'description' => $description, ]); if (!$list_id) { throw new Exception('Failed to create list'); } $added_count = 0; if (!empty($items)) { $result = $this->addItemsToList($list_id, $items, $user_id); $added_count = $result['added']; } return [ 'success' => true, 'result' => [ 'list_id' => $list_id, 'name' => $name, 'added_items' => $added_count, ] ]; }); } /** * Add items to list with bulk insert */ protected function addItemsToList(int $list_id, array $items, int $user_id): array { if (empty($items)) { return ['added' => 0, 'errors' => []]; } $added = 0; $errors = []; try { // Group items by type $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 = str_starts_with($item['type'], BASE) ? $item['type'] : BASE . $item['type']; if (!isset($items_by_type[$type])) { $items_by_type[$type] = []; } $items_by_type[$type][] = absint($item['target_id']); } foreach ($items_by_type as $type => $target_ids) { // Get existing items to avoid duplicates $existing = $this->listItems ->where(['list_id' => $list_id, 'item_type' => $type]) ->getResults(); $existing_map = []; foreach ($existing as $item) { $existing_map[$item->item_id] = true; } // Get favourite IDs in bulk $favs = $this->favourites ->where(['user_id' => $user_id, 'type' => $type]) ->getResults(); $fav_map = []; foreach ($favs as $fav) { $fav_map[$fav->target_id] = $fav->id; } // Prepare items for bulk insert $to_insert = []; foreach ($target_ids as $target_id) { if (isset($existing_map[$target_id])) { continue; } $to_insert[] = [ 'list_id' => $list_id, 'item_type' => $type, 'item_id' => $target_id, 'favourite_id' => $fav_map[$target_id] ?? null ]; } // Bulk insert if (!empty($to_insert)) { $columns = ['list_id', 'item_type', 'item_id', 'favourite_id']; $result = $this->listItems->bulkInsert($to_insert, $columns); $added += $result; } } // Update list timestamp $this->lists->where(['id' => $list_id])->updateResults([]); return ['added' => $added, 'errors' => $errors]; } catch (Exception $e) { $this->logError('addItemsToList', [ 'error' => $e->getMessage(), 'user_id' => $user_id, 'list_id' => $list_id, ]); $errors[] = ['message' => $e->getMessage()]; return ['added' => 0, 'errors' => $errors]; } } /** * 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; } $args = array_merge($args, [ 'page' => max(1, absint($data['page'] ?? 1)), 'content' => $this->checkContent($data['content'] ?? 'all') ]); return $this->applyOrderFilters($args, $data); } /** * 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; } protected function getFavouriteCount(string $type, int $target_id): int { return $this->favourites->where([ 'type' => $type, 'target_id' => $target_id ])->countResults(); } protected function updateFavouriteCount(string $type, int $target_id): void { $count = $this->getFavouriteCount($type, $target_id); if (str_contains($type, 'post') || in_array($type, array_keys($this->valid_types))) { update_post_meta($target_id, BASE.'favourite_count', $count); } else { update_term_meta($target_id, BASE.'favourite_count', $count); } } /** * 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); } } /** * Get detailed information for a single list */ protected function getListDetails(int $list_id, int $user_id): array { $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') ->getResults(); // Format items - convert to favourite-like objects $formatted_items = []; foreach ($items as $item) { // Try to get the actual favourite record if it exists if ($item->favourite_id) { $fav = $this->favourites->where(['id' => $item->favourite_id])->first(); if ($fav) { $formatted_items[] = $fav; continue; } } // Create dummy favourite object for formatting $formatted_items[] = (object)[ 'type' => $item->item_type, 'target_id' => $item->item_id, 'date_added' => $item->added_at ]; } // Get shared users if owner $shared_users = []; if ($is_owner) { $shares = $this->listShares ->where(['list_id' => $list_id]) ->orderBy('created_at', 'DESC') ->getResults(); foreach ($shares as $share_item) { $shared_user = [ 'email' => $share_item->email, 'status' => $share_item->status, 'date_added' => $share_item->created_at, ]; if ($share_item->status === 'accepted' && $share_item->user_id) { $user = get_userdata($share_item->user_id); $shared_user['name'] = $user ? $user->display_name : 'Unknown'; $shared_user['permission_type'] = $share_item->permission_type; } $shared_users[] = $shared_user; } } return [ 'success' => true, 'list' => [ 'id' => (int)$list['id'], 'name' => $list['name'], 'description' => $list['description'] ?? '', 'created_at' => $list['created_at'], 'is_owner' => $is_owner, 'is_shared' => !$is_owner, 'items' => $this->formatItems($formatted_items), 'shared_users' => $shared_users ] ]; } catch (Exception $e) { $this->logError('getListDetails', [ 'error' => $e->getMessage(), 'user_id' => $user_id, 'list_id' => $list_id ]); return [ 'success' => false, 'message' => 'Error retrieving list details' ]; } }); } /** * Process favourite notes update */ protected function processNote(int $user_id, array $data): array { $target_ids = isset($data['target_id']) ? array_map('absint', explode(',', $data['target_id'])) : []; $notes = sanitize_textarea_field($data['notes'] ?? ''); if (empty($target_ids)) { return ['success' => false, 'result' => 'No target IDs provided']; } return $this->favourites->transaction(function ($table) use ($user_id, $target_ids, $notes) { $results = []; foreach ($target_ids as $target_id) { if ($target_id <= 0) { $results[] = [ 'success' => false, 'target_id' => $target_id, 'message' => 'Invalid target ID' ]; continue; } $favourite = $table->where([ 'user_id' => $user_id, 'target_id' => $target_id ])->first(); if (!$favourite) { $results[] = [ 'success' => false, 'target_id' => $target_id, 'message' => 'Favourite not found' ]; continue; } $table->where(['id' => $favourite->id])->updateResults([ 'notes' => $notes ]); $results[] = [ 'success' => true, 'action' => 'updated_notes', 'favourite_id' => $favourite->id, 'target_id' => $target_id ]; } return [ 'success' => true, 'result' => $results ]; }); } /** * Update list */ protected function updateList(int $user_id, array $data): array { $list_id = absint($data['list_id'] ?? 0); if (!$list_id) { return ['success' => false, 'result' => 'List ID is required']; } try { // Verify ownership $is_owner = $this->lists->where([ 'id' => $list_id, 'user_id' => $user_id ])->existsInQuery(); if (!$is_owner) { return [ 'success' => false, 'result' => 'You do not have permission to update this list' ]; } // Build update data $update_data = []; if (isset($data['name'])) { $update_data['name'] = sanitize_text_field($data['name']); } if (isset($data['description'])) { $update_data['description'] = sanitize_textarea_field($data['description']); } if (empty($update_data)) { return [ 'success' => true, 'result' => 'No changes to update' ]; } // Update the list $this->lists->where(['id' => $list_id])->updateResults($update_data); return [ 'success' => true, 'result' => [ 'message' => 'List updated successfully', 'list_id' => $list_id, 'updates' => array_keys($update_data) ] ]; } catch (Exception $e) { $this->logError('updateList', [ 'error' => $e->getMessage(), 'user_id' => $user_id, 'list_id' => $list_id ]); return [ 'success' => false, 'result' => $e->getMessage() ]; } } /** * Delete list */ protected function deleteList(int $user_id, array $data): array { $list_id = absint($data['list_id'] ?? 0); if (!$list_id) { return ['success' => false, 'result' => 'List ID is required']; } return $this->lists->transaction(function ($table) use ($user_id, $list_id) { // Verify ownership $is_owner = $table->where([ 'id' => $list_id, 'user_id' => $user_id ])->existsInQuery(); if (!$is_owner) { throw new Exception('You do not have permission to delete this list'); } // Delete related data (foreign keys should handle this, but being explicit) $this->listItems->where(['list_id' => $list_id])->deleteResults(); $this->listShares->where(['list_id' => $list_id])->deleteResults(); // Delete the list $table->where(['id' => $list_id])->deleteResults(); return [ 'success' => true, 'result' => [ 'message' => 'List deleted successfully', 'list_id' => $list_id ] ]; }); } /** * Add items to list */ protected function addToList(int $user_id, array $data): array { $list_ids = isset($data['list_id']) ? array_map('absint', explode(',', $data['list_id'])) : []; $items = $data['items'] ?? []; if (empty($list_ids) || empty($items)) { return ['success' => false, 'result' => 'List ID and items are required']; } return $this->listItems->transaction(function ($table) use ($user_id, $list_ids, $items) { $results = []; $total_added = 0; $all_errors = []; foreach ($list_ids as $list_id) { if (!$list_id) { $all_errors[] = ['message' => 'Invalid list ID', 'list_id' => $list_id]; continue; } // Check permission - either owner or has edit permission $is_owner = $this->lists->where([ 'id' => $list_id, 'user_id' => $user_id ])->existsInQuery(); $has_edit = $this->listShares->where([ 'list_id' => $list_id, 'user_id' => $user_id, 'permission_type' => 'edit', 'status' => 'accepted' ])->existsInQuery(); if (!$is_owner && !$has_edit) { $all_errors[] = [ 'message' => 'You do not have permission to add items to this list', 'list_id' => $list_id ]; continue; } // Add items to this list $add_result = $this->addItemsToList($list_id, $items, $user_id); $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 ($total_added == 0 && !empty($all_errors)) { throw new Exception('Failed to add any items'); } return [ 'success' => true, 'result' => [ 'lists' => $results, 'total_added' => $total_added, 'errors' => $all_errors ] ]; }); } /** * Remove items from list */ protected function removeFromList(int $user_id, array $data): array { $list_id = absint($data['list_id'] ?? 0); $items = $data['items'] ?? []; if (!$list_id || empty($items)) { return ['success' => false, 'result' => 'List ID and items are required']; } try { // Check permission $is_owner = $this->lists->where([ 'id' => $list_id, 'user_id' => $user_id ])->existsInQuery(); $has_edit = $this->listShares->where([ 'list_id' => $list_id, 'user_id' => $user_id, 'permission_type' => 'edit', 'status' => 'accepted' ])->existsInQuery(); if (!$is_owner && !$has_edit) { 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 = str_starts_with($item['type'], BASE) ? $item['type'] : BASE . $item['type']; $deleted = $this->listItems->where([ 'list_id' => $list_id, 'item_type' => $type, 'item_id' => absint($item['target_id']) ])->deleteResults(); if ($deleted) { $removed += $deleted; } } return [ 'success' => true, 'result' => [ 'message' => "{$removed} items removed from list", 'list_id' => $list_id, 'removed_count' => $removed ] ]; } catch (Exception $e) { $this->logError('removeFromList', [ 'error' => $e->getMessage(), 'user_id' => $user_id, 'list_id' => $list_id ]); return [ 'success' => false, 'result' => $e->getMessage() ]; } } /** * Share list with another user */ protected function shareList(int $user_id, array $data): array { $list_id = absint($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) { return ['success' => false, 'result' => 'List ID and email are required']; } return $this->listShares->transaction(function ($table) use ($user_id, $list_id, $email, $permission_type) { // Verify ownership $list = $this->lists->where([ 'id' => $list_id, 'user_id' => $user_id ])->first(); if (!$list) { throw new Exception('You do not have permission to share this list'); } // Get owner details $owner = get_userdata($user_id); $owner_name = $owner ? $owner->display_name : 'Someone'; // Look up user by email $share_user = get_user_by('email', $email); if ($share_user) { // User exists - check for existing share $existing = $table->where([ 'list_id' => $list_id, 'user_id' => $share_user->ID ])->first(); if ($existing) { // Update if different if ($existing->permission_type !== $permission_type || $existing->status !== 'accepted') { $table->where(['id' => $existing->id])->updateResults([ 'permission_type' => $permission_type, 'status' => 'accepted', ]); return [ 'success' => true, 'result' => [ 'action' => 'updated', 'message' => "Updated sharing permissions for {$email}", ] ]; } return [ 'success' => true, 'result' => [ 'action' => 'already_shared', 'message' => "List is already shared with {$email}", ] ]; } // Create new share $table->create([ 'list_id' => $list_id, 'user_id' => $share_user->ID, 'email' => $email, 'permission_type' => $permission_type, 'status' => 'accepted' ]); // 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 ] ); return [ 'success' => true, 'result' => [ 'action' => 'shared', 'message' => "List shared with {$email}", ] ]; } // User doesn't exist - create pending invitation $existing_pending = $table->where([ 'list_id' => $list_id, 'email' => $email, 'status' => 'pending' ])->first(); if ($existing_pending) { return [ 'success' => true, 'result' => [ 'action' => 'already_pending', 'message' => "Invitation already sent to {$email}", ] ]; } // Create pending share with invitation token $token = wp_generate_password(32, false); $table->create([ 'list_id' => $list_id, 'email' => $email, 'permission_type' => $permission_type, 'status' => 'pending', 'invitation_token' => $token, 'user_id' => 0 // Will be set when they accept ]); // Send invitation email $this->sendListInviteEmail($email, [ 'list_id' => $list_id, 'list_name' => $list->name, 'token' => $token, 'owner_name' => $owner_name, 'user_id' => $user_id ]); return [ 'success' => true, 'result' => [ 'action' => 'invitation_sent', 'message' => "Invitation sent to {$email}", ] ]; }); } /** * Unshare list */ protected function unshareList(int $user_id, array $data): array { $list_id = absint($data['list_id'] ?? 0); $email = sanitize_email($data['email'] ?? ''); if (!$list_id || !$email) { return ['success' => false, 'result' => 'List ID and email are required']; } try { // Verify ownership $is_owner = $this->lists->where([ 'id' => $list_id, 'user_id' => $user_id ])->existsInQuery(); if (!$is_owner) { return [ 'success' => false, 'result' => 'You do not have permission to manage shares for this list' ]; } // Find share by email $existing_share = $this->listShares->where([ 'list_id' => $list_id, 'email' => $email ])->first(); if (!$existing_share) { return [ 'success' => false, 'result' => "No active share or invitation found for {$email}" ]; } // Update status to revoked (or just delete) $this->listShares->where(['id' => $existing_share->id])->updateResults([ 'status' => 'revoked' ]); // Notify user if registered and was accepted if ($existing_share->status === 'accepted' && $existing_share->user_id) { $list = $this->lists->where(['id' => $list_id])->first(); if ($list) { JVB()->notification()->addNotification( $existing_share->user_id, 'list_share_revoked', $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 ] ); } } $action = $existing_share->status === 'accepted' ? 'unshared' : 'invitation_cancelled'; $message = $existing_share->status === 'accepted' ? "Removed {$email}'s access to list" : "Cancelled invitation to {$email}"; return [ 'success' => true, 'result' => [ 'action' => $action, 'message' => $message, ] ]; } catch (Exception $e) { $this->logError('unshareList', [ 'error' => $e->getMessage(), 'user_id' => $user_id, 'list_id' => $list_id, 'email' => $email ]); return [ 'success' => false, 'result' => $e->getMessage() ]; } } /** * Send list invitation email */ 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('/')); $subject = sprintf('%s shared a favourites list with you', $owner_name); $message = sprintf( '
Hi there,
%s has shared their list "%s" with you.
To view this list, click the link below:
Or copy and paste this link: %s
If you don\'t have an account, you\'ll be guided through creating one.
', $owner_name, $list_name, $invite_url, $invite_url ); return JVB()->email()->sendEmail($email, $subject, $message); } /** * Accept a list share invitation */ protected function acceptListInvitation(string $token, string $email, ?int $user_id = null): array { if (!$token || !$email) { return [ 'success' => false, 'message' => 'Invalid invitation parameters' ]; } try { // Find the pending invitation $invitation = $this->listShares->where([ 'invitation_token' => $token, 'email' => $email, 'status' => 'pending' ])->first(); if (!$invitation) { return [ 'success' => false, 'message' => 'Invalid or expired invitation' ]; } // If no user_id provided, check if user exists if (!$user_id) { $existing_user = get_user_by('email', $email); if ($existing_user) { $user_id = $existing_user->ID; } else { // No account - need to register $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 list details $list = $this->lists->where(['id' => $invitation->list_id])->first(); if (!$list) { return [ 'success' => false, 'message' => 'The shared list no longer exists' ]; } // Update invitation to accepted $this->listShares->where(['id' => $invitation->id])->updateResults([ 'status' => 'accepted', 'user_id' => $user_id, ]); // Notify list owner $user = get_userdata($user_id); $display_name = $user ? $user->display_name : $email; JVB()->notification()->addNotification( $list->user_id, 'list_share_accepted', $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, ] ); 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) { $this->logError('acceptListInvitation', [ 'error' => $e->getMessage(), 'token' => $token, 'email' => $email ]); return [ 'success' => false, 'message' => $e->getMessage() ]; } } /** * Get the owner ID for a content item */ protected function getContentOwner(string $type, int $target_id): int|array|null { // 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; } // For terms (shops, etc.) $owners = get_term_meta($target_id, BASE . 'owner', true); if ($owners) { $owner_array = array_map('absint', explode(',', $owners)); return count($owner_array) === 1 ? $owner_array[0] : $owner_array; } return null; } /** * Batch format post-type favourites to reduce queries */ protected function formatPostFavourites(array $items): array { if (empty($items)) { return []; } $formatted = []; $post_ids = array_map(fn($item) => (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 lookup map $posts_by_id = []; foreach ($posts as $post) { $posts_by_id[$post->ID] = $post; } // Get thumbnails for artists $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; $placeholders = implode(',', array_fill(0, count($artist_ids), '%d')); $results = $wpdb->get_results($wpdb->prepare( "SELECT post_id, meta_value FROM {$wpdb->postmeta} WHERE meta_key = %s AND post_id IN ($placeholders)", array_merge([BASE . 'image'], $artist_ids) )); foreach ($results as $result) { $artist_images[$result->post_id] = $result->meta_value; } } // Get regular thumbnails $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; 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 */ 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 shop images $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; $placeholders = implode(',', array_fill(0, count($shop_ids), '%d')); $results = $wpdb->get_results($wpdb->prepare( "SELECT term_id, meta_value FROM {$wpdb->termmeta} WHERE meta_key = %s AND term_id IN ($placeholders)", array_merge([BASE . 'image'], $shop_ids) )); 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; 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; } /** * Handle list operations - routes to appropriate method */ public function handleList(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')); if (!$this->userCheck($user_id)) { return $this->unauthorized(); } $data = [ 'action' => $action, 'list_id' => absint($request->get_param('list_id') ?? 0), 'name' => sanitize_text_field($request->get_param('name') ?? ''), 'description' => sanitize_textarea_field($request->get_param('description') ?? ''), 'items' => $request->get_param('items') ?? [], 'email' => sanitize_email($request->get_param('email') ?? ''), 'permission_type' => sanitize_text_field($request->get_param('permission_type') ?? 'view'), ]; // Map action to operation type $operation_type = match ($action) { 'create' => 'favourite_list_create', 'update' => 'favourite_list_update', 'delete' => 'favourite_list_delete', 'add_items' => 'favourite_list_add_items', 'remove_items' => 'favourite_list_remove_items', 'share' => 'favourite_list_share', 'unshare' => 'favourite_list_unshare', default => null }; if (!$operation_type) { return $this->error('Invalid action', 'invalid_action', 400); } JVB()->queue()->queueOperation( $operation_type, $user_id, $data, [ 'operation_id' => $operation_id, 'priority' => 'normal', ] ); return $this->queued($operation_id); } }