| | |
| | | <?php |
| | | namespace JVBase\rest\routes; |
| | | |
| | | use JVBase\JVB; |
| | | use JVBase\rest\RestRouteManager; |
| | | use JVBase\managers\CacheManager; |
| | | use JVBase\managers\Cache; |
| | | use JVBase\managers\CustomTable; |
| | | use JVBase\registrar\Registrar; |
| | | use JVBase\rest\PermissionHandler; |
| | | use JVBase\rest\Response; |
| | | use JVBase\rest\Rest; |
| | | use JVBase\rest\Route; |
| | | use WP_REST_Request; |
| | | use WP_REST_Response; |
| | | use WP_Error; |
| | | use Exception; |
| | | |
| | | if (!defined('ABSPATH')) { |
| | | exit; // Exit if accessed directly |
| | | exit; |
| | | } |
| | | |
| | | class FavouritesRoutes extends RestRouteManager |
| | | /** |
| | | * TODO: Extract business logic into a Favourites.php manager class |
| | | */ |
| | | class FavouritesRoutes extends Rest |
| | | { |
| | | protected array $valid_types; |
| | | protected int $user_id; |
| | | protected array $valid_types; |
| | | protected Cache $listsCache; |
| | | protected Cache $sharedListsCache; |
| | | protected Cache $favouritesCache; |
| | | protected CustomTable $favourites; |
| | | protected CustomTable $lists; |
| | | protected CustomTable $listItems; |
| | | protected CustomTable $listShares; |
| | | |
| | | public function __construct() |
| | | { |
| | | $this->cache_name = 'favourites'; |
| | | parent::__construct(); |
| | | |
| | | $this->valid_types = array_keys(array_merge(JVB_CONTENT, JVB_TAXONOMY)); |
| | | |
| | | $this->user_id = get_current_user_id(); |
| | | $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 |
| | | public function __construct() |
| | | { |
| | | $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); |
| | | $this->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_merge(Registrar::getRegistered('post'), Registrar::getRegistered('term')); |
| | | |
| | | // Initialize CustomTable instances |
| | | $this->favourites = CustomTable::for('favourites'); |
| | | $this->lists = CustomTable::for('favourites_lists'); |
| | | $this->listItems = CustomTable::for('favourites_list_items'); |
| | | $this->listShares = CustomTable::for('favourites_list_shares'); |
| | | |
| | | // Register hooks |
| | | add_filter(BASE.'handle_bulk_operation', [$this, 'processOperation'], 10, 3); |
| | | add_action('before_delete_post', [$this, 'cleanupPostFavourites']); |
| | | add_action('delete_term', [$this, 'cleanupTermFavourites'], 10, 3); |
| | | add_action('jvbUserRegistered', [$this, 'maybeAcceptListInvite'], 10, 3); |
| | | add_action('jvb_cleanupOrphanedFavourites', [$this, 'cleanupOrphanedFavourites']); |
| | | } |
| | | /** |
| | | * 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'] === ''){ |
| | | $result = [ |
| | | 'success' => false, |
| | | 'message' => 'No user set' |
| | | ]; |
| | | |
| | | 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(); |
| | | } |
| | | // Check HTTP cache headers for user-specific data |
| | | $cache_check = $this->checkUserHeaders($request, $args['user'], 'favourites'); |
| | | |
| | | $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 || (array_key_exists('all', $args) && $args['all'] === true)) { |
| | | $result = $this->getAllFavourites($args['user']); |
| | | if (count($args) === 1 || ($request->get_param('include_all') === true)) { |
| | | $result = $this->getAllFavourites($user_id); |
| | | } else { |
| | | $result = $this->cache->remember( |
| | | $args, |
| | | function() use ($args) { |
| | | $response = new WP_REST_Response($this->getFilteredFavourites($args)); |
| | | return $this->addCacheHeaders($response); |
| | | } |
| | | ); |
| | | $result = $this->cache->remember($key, function() use ($args) { |
| | | return $this->getFilteredFavourites($args); |
| | | }); |
| | | } |
| | | $response = new WP_REST_Response($result); |
| | | return $this->addCacheHeaders($response); |
| | | } |
| | | |
| | | protected function getFilteredFavourites(array $args):array |
| | | return $this->addCacheHeaders(Response::success($result)); |
| | | } |
| | | |
| | | /** |
| | | * Get filtered favourites using CustomTable fluent interface |
| | | */ |
| | | 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']]; |
| | | // Build base query |
| | | $query = $this->favourites->where(['user_id' => $args['user']]); |
| | | |
| | | // Add type filter if specified |
| | | if ($args['content'] && $args['content']!=='all') { |
| | | $query_parts[] = "AND type = %s"; |
| | | $params[] = BASE . $args['content']; |
| | | if (!empty($args['content']) && $args['content'] !== 'all') { |
| | | $query = $this->favourites->where([ |
| | | 'user_id' => $args['user'], |
| | | 'type' => BASE . $args['content'] |
| | | ]); |
| | | } |
| | | |
| | | // Add ordering - make sure to use whitelist for column names |
| | | if ($args['orderby'] === 'date') { |
| | | $args['orderby'] = 'date_added'; |
| | | // 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']]); |
| | | } |
| | | $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}"; |
| | | $total_items = $count_query->countResults(); |
| | | |
| | | // 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, |
| | | 'items' => $this->formatItems($favourites), |
| | | 'has_more' => ($args['page'] * 100) < $total_items, |
| | | 'total' => $total_items, |
| | | 'success' => true, |
| | | ]; |
| | | |
| | | } catch (Exception $e) { |
| | | $this->logError( |
| | | $e->getMessage(), |
| | | [ |
| | | 'method' => 'getFilteredFavourites', |
| | | 'args' => $args |
| | | ] |
| | | ); |
| | | $this->logError('getFilteredFavourites', [ |
| | | 'error' => $e->getMessage(), |
| | | 'args' => $args |
| | | ]); |
| | | |
| | | return [ |
| | | 'success' => false, |
| | | 'favourites' => [], |
| | | 'counts' => 0, |
| | | 'pagination' => [] |
| | | 'success' => false, |
| | | 'items' => [], |
| | | 'total' => 0, |
| | | 'has_more' => false |
| | | ]; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 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_'.$user_id.'_all_favourites', |
| | | function() use ($user_id) { |
| | | return $this->fetchAllFavourites($user_id); |
| | | } |
| | | ); |
| | | |
| | | return new WP_REST_Response($result); |
| | | } |
| | | |
| | | protected function fetchAllFavourites(int $user_id):array |
| | | /** |
| | | * Get all user's favourites organized by content type |
| | | */ |
| | | protected function getAllFavourites(int $user_id): array |
| | | { |
| | | try { |
| | | global $wpdb; |
| | | $table = $wpdb->prefix . BASE . 'favourites'; |
| | | return $this->cache->remember($user_id, function() use ($user_id) { |
| | | try { |
| | | $favourites = $this->favourites |
| | | ->where(['user_id' => $user_id]) |
| | | ->getResults(); |
| | | |
| | | // 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 = []; |
| | | 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; |
| | | } |
| | | |
| | | $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 ((bool)$result) { |
| | | $this->updateFavouriteCount($type, $target_id); |
| | | $this->maybeNotifyOwner($type, $target_id, $user_id); |
| | | } |
| | | |
| | | return [ |
| | | 'success' => true, |
| | | 'result' => [ |
| | | 'action' => $result['created'] ? 'added' : 'already_exists', |
| | | 'favourite_id' => $result['id'], |
| | | 'count' => $this->getFavouriteCount($type, $target_id) |
| | | ] |
| | | ]; |
| | | } |
| | | |
| | | /** |
| | | * Remove a favourite |
| | | */ |
| | | protected function removeFavourite(int $user_id, array $data): array |
| | | { |
| | | $type = $data['type']; |
| | | $target_id = $data['target_id']; |
| | | |
| | | if (!str_starts_with($type, BASE)) { |
| | | $type = BASE . $type; |
| | | } |
| | | |
| | | $deleted = $this->favourites->where([ |
| | | 'user_id' => $user_id, |
| | | 'type' => $type, |
| | | 'target_id' => $target_id |
| | | ])->deleteResults(); |
| | | |
| | | if ($deleted) { |
| | | $this->updateFavouriteCount($type, $target_id); |
| | | $this->removeRelatedNotifications($type, $target_id, $user_id); |
| | | } |
| | | |
| | | return [ |
| | | 'success' => true, |
| | | 'result' => [ |
| | | 'action' => $deleted ? 'removed' : 'not_found', |
| | | 'count' => $this->getFavouriteCount($type, $target_id) |
| | | ] |
| | | ]; |
| | | } |
| | | |
| | | /** |
| | | * Batch favourites using transaction |
| | | */ |
| | | protected function batchFavourites(int $user_id, array $data): array |
| | | { |
| | | $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 ((bool) $result) $results['added']++; |
| | | } else { |
| | | $deleted = $table->delete([ |
| | | 'user_id' => $user_id, |
| | | 'type' => $type, |
| | | 'target_id' => $target_id |
| | | ]); |
| | | if ($deleted) $results['removed']++; |
| | | } |
| | | |
| | | $this->updateFavouriteCount($type, $target_id); |
| | | } catch (Exception $e) { |
| | | $results['errors'][] = $item['target_id'] ?? 'unknown'; |
| | | } |
| | | } |
| | | |
| | | return [ |
| | | 'success' => true, |
| | | 'items' => $by_type, |
| | | 'has_more' => false, |
| | | '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( |
| | | $e->getMessage(), |
| | | [ |
| | | 'context' => 'fetchAllFavourites', |
| | | 'user' => $user_id |
| | | ] |
| | | ); |
| | | return [ |
| | | 'success' => false, |
| | | 'favourites' => [], |
| | | ]; |
| | | $this->logError('addItemsToList', [ |
| | | 'error' => $e->getMessage(), |
| | | 'user_id' => $user_id, |
| | | 'list_id' => $list_id, |
| | | ]); |
| | | |
| | | $errors[] = ['message' => $e->getMessage()]; |
| | | return ['added' => 0, 'errors' => $errors]; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 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(); |
| | | /** |
| | | * 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; |
| | | |
| | | $queue = JVB()->queue(); |
| | | $operation_id = $data['id'] ?? uniqid('fav_'); |
| | | $type = BASE . $type; |
| | | |
| | | error_log('Favourite Request: '.print_r($data, true)); |
| | | // Delete using fluent interface |
| | | $this->favourites->where([ |
| | | 'type' => $type, |
| | | 'target_id' => $post_id |
| | | ])->deleteResults(); |
| | | |
| | | switch ($operation) { |
| | | case 'toggle': |
| | | $adds = $request->get_param('adds') ?? []; |
| | | $removes = $request->get_param('removes') ?? []; |
| | | $this->listItems->where([ |
| | | 'item_type' => $type, |
| | | 'item_id' => $post_id |
| | | ])->deleteResults(); |
| | | |
| | | $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' |
| | | } catch (Exception $e) { |
| | | $this->logError('cleanupPostFavourites', [ |
| | | 'error' => $e->getMessage(), |
| | | 'post_id' => $post_id |
| | | ]); |
| | | } |
| | | } |
| | | |
| | | // Check HTTP cache headers |
| | | $cache_check = $this->checkUserHeaders($request, $user_id, 'favourites_lists'); |
| | | if ($cache_check) { |
| | | return $cache_check; |
| | | } |
| | | /** |
| | | * 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; |
| | | } |
| | | |
| | | $list_id = $request->get_param('id'); |
| | | // Delete using fluent interface |
| | | $this->favourites->where([ |
| | | 'type' => $taxonomy, |
| | | 'target_id' => $term_id |
| | | ])->deleteResults(); |
| | | |
| | | if ($list_id) { |
| | | $response = $this->getListDetails($list_id, $user_id); |
| | | } else { |
| | | $response = $this->getAvailableLists($user_id); |
| | | } |
| | | $this->listItems->where([ |
| | | 'item_type' => $taxonomy, |
| | | 'item_id' => $term_id |
| | | ])->deleteResults(); |
| | | |
| | | $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 []; |
| | | } |
| | | $key = sprintf( |
| | | 'user_%d_lists', |
| | | $user_id |
| | | ); |
| | | if ($include_shared) { |
| | | $key = $key.'_shared'; |
| | | } |
| | | $cache = $this->cache->get($key, 'favourites_lists'); |
| | | if ($cache) { |
| | | return $cache; |
| | | } |
| | | |
| | | 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, |
| | | ]; |
| | | } |
| | | |
| | | // Cache result |
| | | $this->cache->set($key, ['success' => true, 'lists'=>$lists], 'favourites_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->cache->get($key, 'favourites_lists'); |
| | | 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->cache->set($key, $response_data, 'favourites_lists'); |
| | | 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' |
| | | } catch (Exception $e) { |
| | | $this->logError('cleanupTermFavourites', [ |
| | | 'error' => $e->getMessage(), |
| | | 'term_id' => $term_id, |
| | | 'taxonomy' => $taxonomy |
| | | ]); |
| | | } |
| | | } |
| | | |
| | | // Check HTTP cache headers |
| | | $cache_check = $this->checkUserHeaders($request, $user_id, 'favourites_shares'); |
| | | if ($cache_check) { |
| | | return $cache_check; |
| | | /** |
| | | * 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; |
| | | } |
| | | $list_id = $request->get_param('list_id'); |
| | | } |
| | | |
| | | if (!$list_id) { |
| | | return $this->createErrorResponse( |
| | | self::ERROR_MISSING_PARAMS, |
| | | 'List ID is required', |
| | | 400 |
| | | ); |
| | | } |
| | | /** |
| | | * Helper methods |
| | | */ |
| | | protected function buildParams(WP_REST_Request $request): array |
| | | { |
| | | $data = $request->get_params(); |
| | | $args = ['user' => absint($data['user'])]; |
| | | |
| | | $key = sprintf( |
| | | 'user_%d_shares_for_list_%d', |
| | | $user_id, |
| | | $list_id |
| | | ); |
| | | $cache = $this->cache->get($key, 'favourites_list_shares'); |
| | | if ($cache) { |
| | | return new WP_REST_Response($cache); |
| | | } |
| | | if (!array_key_exists('page', $data)) { |
| | | return $args; |
| | | } |
| | | |
| | | try { |
| | | global $wpdb; |
| | | $lists_table = $wpdb->prefix . BASE . 'favourites_lists'; |
| | | $shares_table = $wpdb->prefix . BASE . 'favourites_list_shares'; |
| | | $args = array_merge($args, [ |
| | | 'page' => max(1, absint($data['page'] ?? 1)), |
| | | 'content' => Registrar::getInstance($data['content']) ? $data['content'] : 'all', |
| | | ]); |
| | | |
| | | // 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 |
| | | )); |
| | | return $this->applyOrderFilters($args, $data); |
| | | } |
| | | |
| | | 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 |
| | | ); |
| | | /** |
| | | * 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 []; |
| | | } |
| | | |
| | | $all_shares = $wpdb->get_results($query); |
| | | // Group items by type to reduce queries |
| | | $items_by_type = []; |
| | | foreach ($items as $item) { |
| | | if (!isset($item->type) || !isset($item->target_id)) { |
| | | continue; |
| | | } |
| | | |
| | | // 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, |
| | | ]; |
| | | // Verify item type is valid |
| | | if (!isset($this->valid_types[$item->type])) { |
| | | continue; |
| | | } |
| | | |
| | | // 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; |
| | | } |
| | | $type = $item->type; |
| | | if (!isset($items_by_type[$type])) { |
| | | $items_by_type[$type] = []; |
| | | } |
| | | $items_by_type[$type][] = $item; |
| | | } |
| | | |
| | | $shares[] = $formatted_share; |
| | | } |
| | | $formatted = []; |
| | | |
| | | $response_data = [ |
| | | 'success' => true, |
| | | 'list_id' => $list_id, |
| | | 'shares' => $shares |
| | | ]; |
| | | // Process post-type items in batches |
| | | foreach ($items_by_type as $type => $type_items) { |
| | | $config = $this->valid_types[$type]; |
| | | |
| | | // Cache the results |
| | | $this->cache->set($key, $response_data, 'favourites_list_shares'); |
| | | if ($config['table'] === 'post') { |
| | | $formatted = array_merge($formatted, $this->formatPostFavourites($type_items)); |
| | | } else { |
| | | $formatted = array_merge($formatted, $this->formatTermFavourites($type_items)); |
| | | } |
| | | } |
| | | |
| | | $response = new WP_REST_Response($response_data); |
| | | return $this->addCacheHeaders($response); |
| | | return $formatted; |
| | | } |
| | | protected function getFavouriteCount(string $type, int $target_id): int |
| | | { |
| | | return $this->favourites->where([ |
| | | 'type' => $type, |
| | | 'target_id' => $target_id |
| | | ])->countResults(); |
| | | } |
| | | |
| | | } catch (Exception $e) { |
| | | return $this->createErrorResponse( |
| | | self::ERROR_PROCESSING, |
| | | 'Error retrieving shares: ' . $e->getMessage(), |
| | | 500, |
| | | ['user_id' => $user_id, 'list_id' => $list_id] |
| | | ); |
| | | } |
| | | } |
| | | protected function updateFavouriteCount(string $type, int $target_id): void |
| | | { |
| | | $count = $this->getFavouriteCount($type, $target_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(); |
| | | 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); |
| | | } |
| | | } |
| | | |
| | | $queue = JVB()->queue(); |
| | | $operation_id = $data['id'] ?? uniqid('share_'); |
| | | /** |
| | | * 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 (empty($data['list_id']) || empty($data['email'])) { |
| | | return $this->createErrorResponse( |
| | | self::ERROR_MISSING_PARAMS, |
| | | 'List ID and email are required', |
| | | 400 |
| | | ); |
| | | } |
| | | 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 |
| | | } |
| | | } |
| | | |
| | | switch ($operation) { |
| | | case 'add': |
| | | // Share list with email |
| | | if (!is_email($data['email'])) { |
| | | return $this->createErrorResponse( |
| | | self::ERROR_MISSING_PARAMS, |
| | | 'Invalid email address', |
| | | 400 |
| | | ); |
| | | } |
| | | /** |
| | | * 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; |
| | | } |
| | | |
| | | $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; |
| | | $owner_ids = (is_array($owner_ids)) ? $owner_ids : [$owner_ids]; |
| | | |
| | | 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; |
| | | foreach ($owner_ids as $owner_id) { |
| | | // Skip if owner is the same as the user who unfavorited |
| | | if ($owner_id === $user_id) { |
| | | continue; |
| | | } |
| | | |
| | | 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 |
| | | global $wpdb; |
| | | $notifications_table = $wpdb->prefix . BASE . 'notifications'; |
| | | |
| | | $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' => $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->invalidate('favourite_counts_by_type_' . $user_id.'_all'); |
| | | $this->cache->invalidate('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 |
| | | CacheManager::invalidateGroup($this->cache_name); |
| | | CacheManager::invalidateGroup('favourites_lists'); |
| | | |
| | | 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} |
| | | // 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') |
| | | )); |
| | | $owner_id, |
| | | $user_id, |
| | | $target_id, |
| | | $type, |
| | | current_time('mysql') |
| | | )); |
| | | |
| | | if (empty($notifications)) { |
| | | continue; |
| | | } |
| | | if (empty($notifications)) { |
| | | continue; |
| | | } |
| | | |
| | | // Delete the notifications |
| | | foreach ($notifications as $notification) { |
| | | $wpdb->delete( |
| | | $notifications_table, |
| | | ['id' => $notification->id], |
| | | ['%d'] |
| | | ); |
| | | } |
| | | // 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 |
| | | CacheManager::invalidateGroup('favourites_lists'); |
| | | |
| | | 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( |
| | | '<p style="text-align: center;"><a href="%s" class="button">Set Your Password</a></p>', |
| | | $invite_url |
| | | ); |
| | | $inviteUrl = sprintf( |
| | | '<p style="user-select:all;">%s</p>', |
| | | $invite_url |
| | | ); |
| | | |
| | | $subject = sprintf('%s shared a favourites list with you on edmonton.ink', $owner_name); |
| | | |
| | | $message = sprintf( |
| | | '<p>Hi there,</p> |
| | | <p><strong>%s</strong> has shared their list \"<strong>%s</strong>\" with you on edmonton.ink.</p> |
| | | <p>To view this list, click the button below:</p> |
| | | %s |
| | | <p>Or copy and paste this link into your browser:</p> |
| | | %s |
| | | <p>If you don\'t already have an account, you\'ll be guided through creating one.</p> |
| | | %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' |
| | | ); |
| | | } |
| | | } |
| | | // 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 |
| | | { |
| | |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 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; |
| | | } |
| | | /** |
| | | * Get detailed information for a single list |
| | | */ |
| | | protected function getListDetails(int $list_id, int $user_id): array |
| | | { |
| | | $key = "list_{$list_id}_user_{$user_id}"; |
| | | |
| | | 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 $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(); |
| | | |
| | | return null; |
| | | } |
| | | $share = null; |
| | | if (!$is_owner) { |
| | | $share = $this->listShares->where([ |
| | | 'list_id' => $list_id, |
| | | 'user_id' => $user_id, |
| | | 'status' => 'accepted' |
| | | ])->first(); |
| | | } |
| | | |
| | | /** |
| | | * 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); |
| | | if (!$is_owner && !$share) { |
| | | return [ |
| | | 'success' => false, |
| | | 'message' => 'You do not have access to this list' |
| | | ]; |
| | | } |
| | | |
| | | return array_merge($owners, $managers); |
| | | } |
| | | // Get list details |
| | | $list = $this->lists->where(['id' => $list_id])->first(ARRAY_A); |
| | | if (!$list) { |
| | | return [ |
| | | 'success' => false, |
| | | 'message' => 'List not found' |
| | | ]; |
| | | } |
| | | |
| | | /** |
| | | * 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'; |
| | | // Get list items |
| | | $items = $this->listItems |
| | | ->where(['list_id' => $list_id]) |
| | | ->orderBy('added_at', 'DESC') |
| | | ->getResults(); |
| | | |
| | | // 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 |
| | | "); |
| | | // 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; |
| | | } |
| | | } |
| | | |
| | | // 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'; |
| | | })); |
| | | // Create dummy favourite object for formatting |
| | | $formatted_items[] = (object)[ |
| | | 'type' => $item->item_type, |
| | | 'target_id' => $item->item_id, |
| | | 'date_added' => $item->added_at |
| | | ]; |
| | | } |
| | | |
| | | if (!empty($post_types)) { |
| | | $post_types_list = implode(',', $post_types); |
| | | // Get shared users if owner |
| | | $shared_users = []; |
| | | if ($is_owner) { |
| | | $shares = $this->listShares |
| | | ->where(['list_id' => $list_id]) |
| | | ->orderBy('created_at', 'DESC') |
| | | ->getResults(); |
| | | |
| | | $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 |
| | | "); |
| | | } |
| | | foreach ($shares as $share_item) { |
| | | $shared_user = [ |
| | | 'email' => $share_item->email, |
| | | 'status' => $share_item->status, |
| | | 'date_added' => $share_item->created_at, |
| | | ]; |
| | | |
| | | // 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 ($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; |
| | | } |
| | | |
| | | if (!empty($term_types)) { |
| | | $term_types_list = implode(',', $term_types); |
| | | $shared_users[] = $shared_user; |
| | | } |
| | | } |
| | | |
| | | $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 [ |
| | | '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 |
| | | ] |
| | | ]; |
| | | |
| | | return true; |
| | | } catch (Exception $e) { |
| | | JVB()->error()->log( |
| | | 'favourites', |
| | | 'Error during cleanup: ' . $e->getMessage(), |
| | | [], |
| | | 'error' |
| | | ); |
| | | } catch (Exception $e) { |
| | | $this->logError('getListDetails', [ |
| | | 'error' => $e->getMessage(), |
| | | 'user_id' => $user_id, |
| | | 'list_id' => $list_id |
| | | ]); |
| | | |
| | | return false; |
| | | } |
| | | } |
| | | 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'] ?? ''); |
| | | |
| | | /** |
| | | * 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($target_ids)) { |
| | | return ['success' => false, 'result' => 'No target IDs provided']; |
| | | } |
| | | |
| | | if (!empty($additional_data)) { |
| | | $error->add_data($additional_data, 'additional_data'); |
| | | } |
| | | return $this->favourites->transaction(function ($table) use ($user_id, $target_ids, $notes) { |
| | | $results = []; |
| | | |
| | | // 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 |
| | | ]); |
| | | } |
| | | 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(); |
| | | |
| | | /** |
| | | * 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 (!$favourite) { |
| | | $results[] = [ |
| | | 'success' => false, |
| | | 'target_id' => $target_id, |
| | | 'message' => 'Favourite not found' |
| | | ]; |
| | | continue; |
| | | } |
| | | |
| | | if (!in_array($operation->type, $favourites_operations)) { |
| | | return $result; // Not our operation, pass through |
| | | } |
| | | $table->where(['id' => $favourite->id])->updateResults([ |
| | | 'notes' => $notes |
| | | ]); |
| | | |
| | | try { |
| | | $user_id = $operation->user_id; |
| | | $results[] = [ |
| | | 'success' => true, |
| | | 'action' => 'updated_notes', |
| | | 'favourite_id' => $favourite->id, |
| | | 'target_id' => $target_id |
| | | ]; |
| | | } |
| | | |
| | | switch ($operation->type) { |
| | | case 'favourites_batch': |
| | | $response = $this->processBatches($user_id, $data); |
| | | CacheManager::invalidateGroup($this->cache_name); |
| | | return $response; |
| | | return [ |
| | | 'success' => true, |
| | | 'result' => $results |
| | | ]; |
| | | }); |
| | | } |
| | | |
| | | case 'favourite_notes': |
| | | $response = $this->processNote($user_id, $data); |
| | | CacheManager::invalidateGroup($this->cache_name); |
| | | return $response; |
| | | /** |
| | | * Update list |
| | | */ |
| | | protected function updateList(int $user_id, array $data): array |
| | | { |
| | | $list_id = absint($data['list_id'] ?? 0); |
| | | |
| | | case 'favourite_list_create': |
| | | $response = $this->processListCreate($user_id, $data); |
| | | CacheManager::invalidateGroup('favourites_lists'); |
| | | return $response; |
| | | if (!$list_id) { |
| | | return ['success' => false, 'result' => 'List ID is required']; |
| | | } |
| | | |
| | | case 'favourite_list_update': |
| | | $response = $this->processUpdateList($user_id, $data); |
| | | CacheManager::invalidateGroup('favourites_lists'); |
| | | return $response; |
| | | try { |
| | | // Verify ownership |
| | | $is_owner = $this->lists->where([ |
| | | 'id' => $list_id, |
| | | 'user_id' => $user_id |
| | | ])->existsInQuery(); |
| | | |
| | | case 'favourite_list_delete': |
| | | $response = $this->processListDeletion($user_id, $data); |
| | | CacheManager::invalidateGroup('favourites_lists'); |
| | | return $response; |
| | | if (!$is_owner) { |
| | | return [ |
| | | 'success' => false, |
| | | 'result' => 'You do not have permission to update this list' |
| | | ]; |
| | | } |
| | | |
| | | case 'favourite_list_add': |
| | | $response = $this->processAddToList($user_id, $data); |
| | | CacheManager::invalidateGroup('favourites_lists'); |
| | | return $response; |
| | | // Build update data |
| | | $update_data = []; |
| | | |
| | | case 'favourite_list_remove': |
| | | $response = $this->removeFromList($user_id, $data); |
| | | CacheManager::invalidateGroup('favourites_lists'); |
| | | return $response; |
| | | if (isset($data['name'])) { |
| | | $update_data['name'] = sanitize_text_field($data['name']); |
| | | } |
| | | |
| | | case 'favourite_list_share': |
| | | $response = $this->shareList($user_id, $data); |
| | | CacheManager::invalidateGroup('favourites_lists_shares'); |
| | | return $response; |
| | | if (isset($data['description'])) { |
| | | $update_data['description'] = sanitize_textarea_field($data['description']); |
| | | } |
| | | |
| | | case 'favourite_list_unshare': |
| | | $response = $this->unshareList($user_id, $data); |
| | | CacheManager::invalidateGroup('favourites_lists_shares'); |
| | | return $response; |
| | | if (empty($update_data)) { |
| | | return [ |
| | | 'success' => true, |
| | | 'result' => 'No changes to update' |
| | | ]; |
| | | } |
| | | |
| | | 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' |
| | | ); |
| | | // Update the list |
| | | $this->lists->where(['id' => $list_id])->updateResults($update_data); |
| | | |
| | | return $result; |
| | | } |
| | | } |
| | | return [ |
| | | 'success' => true, |
| | | 'result' => [ |
| | | 'message' => 'List updated successfully', |
| | | 'list_id' => $list_id, |
| | | 'updates' => array_keys($update_data) |
| | | ] |
| | | ]; |
| | | |
| | | /** |
| | | * 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); |
| | | } catch (Exception $e) { |
| | | $this->logError('updateList', [ |
| | | 'error' => $e->getMessage(), |
| | | 'user_id' => $user_id, |
| | | 'list_id' => $list_id |
| | | ]); |
| | | |
| | | if (!$type) { |
| | | return; |
| | | } |
| | | return [ |
| | | 'success' => false, |
| | | 'result' => $e->getMessage() |
| | | ]; |
| | | } |
| | | } |
| | | |
| | | $type = BASE . $type; |
| | | /** |
| | | * Delete list |
| | | */ |
| | | protected function deleteList(int $user_id, array $data): array |
| | | { |
| | | $list_id = absint($data['list_id'] ?? 0); |
| | | |
| | | if (!isset($this->valid_types[$type])) { |
| | | return; |
| | | } |
| | | if (!$list_id) { |
| | | return ['success' => false, 'result' => 'List ID is required']; |
| | | } |
| | | |
| | | // Delete favourites for this post |
| | | $wpdb->delete( |
| | | $table, |
| | | [ |
| | | 'type' => $type, |
| | | 'target_id' => $post_id |
| | | ], |
| | | ['%s', '%d'] |
| | | ); |
| | | 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(); |
| | | |
| | | // 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'] |
| | | ); |
| | | if (!$is_owner) { |
| | | throw new Exception('You do not have permission to delete this list'); |
| | | } |
| | | |
| | | } catch (Exception $e) { |
| | | JVB()->error()->log( |
| | | 'favourites', |
| | | 'Error cleaning up favourites for deleted post: ' . $e->getMessage(), |
| | | ['post_id' => $post_id], |
| | | 'error' |
| | | ); |
| | | } |
| | | } |
| | | // 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(); |
| | | |
| | | /** |
| | | * 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; |
| | | } |
| | | // Delete the list |
| | | $table->where(['id' => $list_id])->deleteResults(); |
| | | |
| | | global $wpdb; |
| | | $table = $wpdb->prefix . BASE . 'favourites'; |
| | | return [ |
| | | 'success' => true, |
| | | 'result' => [ |
| | | 'message' => 'List deleted successfully', |
| | | 'list_id' => $list_id |
| | | ] |
| | | ]; |
| | | }); |
| | | } |
| | | |
| | | // Delete favourites for this term |
| | | $wpdb->delete( |
| | | $table, |
| | | [ |
| | | 'type' => $taxonomy, |
| | | 'target_id' => $term_id |
| | | ], |
| | | ['%s', '%d'] |
| | | ); |
| | | /** |
| | | * 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'] ?? []; |
| | | |
| | | // 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'] |
| | | ); |
| | | if (empty($list_ids) || empty($items)) { |
| | | return ['success' => false, 'result' => 'List ID and items are required']; |
| | | } |
| | | |
| | | // 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'] |
| | | ); |
| | | return $this->listItems->transaction(function ($table) use ($user_id, $list_ids, $items) { |
| | | $results = []; |
| | | $total_added = 0; |
| | | $all_errors = []; |
| | | |
| | | } catch (Exception $e) { |
| | | JVB()->error()->log( |
| | | 'favourites', |
| | | 'Error cleaning up favourites for deleted term: ' . $e->getMessage(), |
| | | ['term_id' => $term_id, 'taxonomy' => $taxonomy], |
| | | 'error' |
| | | ); |
| | | } |
| | | } |
| | | 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( |
| | | '<p>Hi there,</p> |
| | | <p><strong>%s</strong> has shared their list "<strong>%s</strong>" with you.</p> |
| | | <p>To view this list, click the link below:</p> |
| | | <p><a href="%s">Accept List Invitation</a></p> |
| | | <p>Or copy and paste this link: %s</p> |
| | | <p>If you don\'t have an account, you\'ll be guided through creating one.</p>', |
| | | $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); |
| | | } |
| | | } |