cacheName = 'favourites'; $this->cacheTtl = HOUR_IN_SECONDS; parent::__construct(); // Set up cache connections $this->cache->connect('post')->connect('user')->connect('taxonomy')->user(); $this->listsCache = Cache::for('lists')->connect('favourites', true)->user(); $this->sharedListsCache = Cache::for('sharedLists')->connect('favourites', true)->user(); $this->favouritesCache = Cache::for('allFavourites')->connect('favourites', true)->user(); $this->valid_types = array_merge(Registrar::getRegistered('post'), Registrar::getRegistered('term')); // Initialize CustomTable instances $this->favourites = CustomTable::for('favourites'); $this->lists = CustomTable::for('favourites_lists'); $this->listItems = CustomTable::for('favourites_list_items'); $this->listShares = CustomTable::for('favourites_list_shares'); } 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', 'action' => 'string|required|enum:add,remove,toggle,batch,note', 'type' => 'string', 'target_id' => 'integer', 'items' => 'array', 'notes' => 'string', ]) ->auth(PermissionHandler::combine(['user', ['actionNonce' => 'favourites-']])) ->rateLimit(30) ->register(); // Lists endpoints Route::for('favourites/lists') ->get([$this, 'getLists']) ->args(['user' => 'integer|required']) ->auth(PermissionHandler::combine(['user', ['actionNonce' => 'favourites-']])) ->rateLimit(30) ->post([$this, 'handleList']) ->args([ 'user' => 'integer|required', 'id' => 'string|required', 'action' => 'string|required|enum:create,update,delete,share,unshare,add_items,remove_items', 'list_id' => 'integer', 'name' => 'string', 'items' => 'array', ]) ->auth(PermissionHandler::combine(['user', ['actionNonce' => 'favourites-']])) ->rateLimit(20) ->register(); // Favourite counts Route::for('favourites/counts') ->get([$this, 'getFavouriteCounts']) ->args(['user' => 'integer|required']) ->auth(PermissionHandler::combine(['user', ['actionNonce' => 'favourites-']])) ->register(); } /** * Get user's favourites with optional filtering */ public function getFavourites(WP_REST_Request $request): WP_REST_Response { $user_id = absint($request->get_param('user')); if (!$this->userCheck($user_id)) { return $this->unauthorized(); } $args = $this->buildParams($request); $key = $this->cache->generateKey($args); // Check cache headers $cache_check = $this->checkHeaders($request, $key); if ($cache_check) { return $cache_check; } $result = JVB()->favourites()->getFavourites($args); $result['items'] = $this->formatItems($result['items']); return $this->addCacheHeaders(Response::success($result)); } /** * Handle favourite operations */ public function handleFavourite(WP_REST_Request $request): WP_REST_Response { $params = $request->get_params(); $user_id = absint($params['user']??0); if (!$this->userCheck($user_id)) { return $this->unauthorized(); } $action = strtolower(sanitize_text_field($params['action'])); $action = in_array($action, ['add', 'remove']) ? $action : false; if (!$action) { return $this->error('Invalid favourite action'); } $target_id = absint($params['target_id']??0); if ($target_id === 0) { return $this->error('Invalid target id'); } $type = sanitize_text_field($params['type']??''); if (empty($type)) { return $this->error('No type provided'); } $result = JVB()->favourites()->toggleFavourite( $action === 'add', $user_id, $target_id, $type ); if ($result) { return $this->success(); } return $this->error('Something went wrong'); } /** * Get user's favourite lists */ public function getLists(WP_REST_Request $request): WP_REST_Response { $params = $request->get_params(); $user_id = absint($params['user']); if (!$this->userCheck($user_id)) { return $this->unauthorized(); } $args = $this->buildParams($request); $args['per_page'] = 20; $listId = $request->get_param('id'); if (!empty($listId)) { $args['where']['id'] = sanitize_text_field($listId); } $key = $this->listsCache->generateKey($args); // Check cache headers $cache_check = $this->checkHeaders($request, $key); if ($cache_check) { return $cache_check; } $includeShares = !empty($request->get_param('include_shares')); $response = !empty($listId) ? JVB()->favourites()->getListDetails($listId, $user_id) : JVB()->favourites()->getAvailableLists($args, $includeShares); return $this->addCacheHeaders(Response::success($response)); } /** * TODO: Done until here * Get favourite counts by type */ public function getFavouriteCounts(WP_REST_Request $request): WP_REST_Response { $user_id = absint($request->get_param('user')); if (!$this->userCheck($user_id)) { return $this->unauthorized(); } $counts = JVB()->favourites()->getFavouriteCounts($user_id); return Response::success(['counts' => $counts]); } /** * Process favourite operations using transactions */ public function processOperation($result, object $operation, array $data) { $action_map = [ 'favourite_add' => 'addFavourite', 'favourite_remove' => 'removeFavourite', 'favourite_batch' => 'batchFavourites', 'favourite_note' => 'processNote', 'favourite_list_create' => 'createList', 'favourite_list_update' => 'updateList', 'favourite_list_delete' => 'deleteList', 'favourite_list_add_items' => 'addToList', 'favourite_list_remove_items' => 'removeFromList', 'favourite_list_share' => 'shareList', 'favourite_list_unshare' => 'unshareList', ]; if (!isset($action_map[$operation->type])) { return $result; } try { $method = $action_map[$operation->type]; $response = $this->$method($operation->user_id, $data); // Clear cache on success if ($response['success'] ?? false) { Cache::invalidateItem('favourites', $operation->user_id); $this->listsCache->flush(); $this->sharedListsCache->flush(); } return $response; } catch (Exception $e) { $this->logError('processOperation', [ 'error' => $e->getMessage(), 'operation_id' => $operation->id, 'type' => $operation->type ]); return [ 'success' => false, 'result' => $e->getMessage() ]; } } /** * Add a favourite using findOrCreate pattern */ protected function addFavourite(int $user_id, array $data): array { $type = $data['type']; $target_id = $data['target_id']; if (!str_starts_with($type, BASE)) { $type = BASE . $type; } if (!isset($this->valid_types[$type])) { return ['success' => false, 'result' => 'Invalid type']; } // Use findOrCreate pattern $result = $this->favourites->findOrCreate([ 'user_id' => $user_id, 'type' => $type, 'target_id' => $target_id ]); if ((bool)$result) { $this->updateFavouriteCount($type, $target_id); $this->maybeNotifyOwner($type, $target_id, $user_id); } return [ 'success' => true, 'result' => [ 'action' => $result['created'] ? 'added' : 'already_exists', 'favourite_id' => $result['id'], 'count' => $this->getFavouriteCount($type, $target_id) ] ]; } /** * Remove a favourite */ protected function removeFavourite(int $user_id, array $data): array { $type = $data['type']; $target_id = $data['target_id']; if (!str_starts_with($type, BASE)) { $type = BASE . $type; } $deleted = $this->favourites->where([ 'user_id' => $user_id, 'type' => $type, 'target_id' => $target_id ])->deleteResults(); if ($deleted) { $this->updateFavouriteCount($type, $target_id); $this->removeRelatedNotifications($type, $target_id, $user_id); } return [ 'success' => true, 'result' => [ 'action' => $deleted ? 'removed' : 'not_found', 'count' => $this->getFavouriteCount($type, $target_id) ] ]; } /** * Batch favourites using transaction */ protected function batchFavourites(int $user_id, array $data): array { $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, 'result' => $results ]; }); } /** * Create a list using transaction */ protected function createList(int $user_id, array $data): array { $name = sanitize_text_field($data['name'] ?? 'Untitled List'); $description = sanitize_textarea_field($data['description'] ?? ''); $items = $data['items'] ?? []; return $this->lists->transaction(function($table) use ($user_id, $name, $description, $items) { $list_id = $table->create([ 'user_id' => $user_id, 'name' => $name, 'description' => $description, ]); if (!$list_id) { throw new Exception('Failed to create list'); } $added_count = 0; if (!empty($items)) { $result = $this->addItemsToList($list_id, $items, $user_id); $added_count = $result['added']; } return [ 'success' => true, 'result' => [ 'list_id' => $list_id, 'name' => $name, 'added_items' => $added_count, ] ]; }); } /** * Add items to list with bulk insert */ protected function addItemsToList(int $list_id, array $items, int $user_id): array { if (empty($items)) { return ['added' => 0, 'errors' => []]; } $added = 0; $errors = []; try { // Group items by type $items_by_type = []; foreach ($items as $item) { if (empty($item['type']) || !isset($item['target_id'])) { $errors[] = ['message' => 'Item missing type or target_id', 'item' => $item]; continue; } $type = str_starts_with($item['type'], BASE) ? $item['type'] : BASE . $item['type']; if (!isset($items_by_type[$type])) { $items_by_type[$type] = []; } $items_by_type[$type][] = absint($item['target_id']); } foreach ($items_by_type as $type => $target_ids) { // Get existing items to avoid duplicates $existing = $this->listItems ->where(['list_id' => $list_id, 'item_type' => $type]) ->getResults(); $existing_map = []; foreach ($existing as $item) { $existing_map[$item->item_id] = true; } // Get favourite IDs in bulk $favs = $this->favourites ->where(['user_id' => $user_id, 'type' => $type]) ->getResults(); $fav_map = []; foreach ($favs as $fav) { $fav_map[$fav->target_id] = $fav->id; } // Prepare items for bulk insert $to_insert = []; foreach ($target_ids as $target_id) { if (isset($existing_map[$target_id])) { continue; } $to_insert[] = [ 'list_id' => $list_id, 'item_type' => $type, 'item_id' => $target_id, 'favourite_id' => $fav_map[$target_id] ?? null ]; } // Bulk insert if (!empty($to_insert)) { $columns = ['list_id', 'item_type', 'item_id', 'favourite_id']; $result = $this->listItems->bulkInsert($to_insert, $columns); $added += $result; } } // Update list timestamp $this->lists->where(['id' => $list_id])->updateResults([]); return ['added' => $added, 'errors' => $errors]; } catch (Exception $e) { $this->logError('addItemsToList', [ 'error' => $e->getMessage(), 'user_id' => $user_id, 'list_id' => $list_id, ]); $errors[] = ['message' => $e->getMessage()]; return ['added' => 0, 'errors' => $errors]; } } /** * Helper methods */ protected function buildParams(WP_REST_Request $request): array { $data = $request->get_params(); $where = ['user_id' => absint($data['user'])]; if (!empty($data['content']) && $data['content'] !== 'all') { $where['type'] = BASE . $data['content']; } $page = max(1, absint($data['page'] ?? 1)); $perPage = 250; return [ 'where' => $where, 'orderby' => sanitize_text_field($data['orderby'] ?? 'created_at'), 'order' => sanitize_text_field($data['order'] ?? 'DESC'), 'per_page' => $perPage, 'page' => $page ]; } /** * Batch format a collection of favourite items * * @param array $items Collection of favourite items * @return array Formatted items */ protected function formatItems(array $items):array { if (empty($items)) { return []; } // Group items by type to reduce queries $items_by_type = []; foreach ($items as $item) { if (!isset($item->type) || !isset($item->target_id)) { continue; } // Verify item type is valid if (!isset($this->valid_types[$item->type])) { continue; } $type = $item->type; if (!isset($items_by_type[$type])) { $items_by_type[$type] = []; } $items_by_type[$type][] = $item; } $formatted = []; // Process post-type items in batches foreach ($items_by_type as $type => $type_items) { $config = $this->valid_types[$type]; if ($config['table'] === 'post') { $formatted = array_merge($formatted, $this->formatPostFavourites($type_items)); } else { $formatted = array_merge($formatted, $this->formatTermFavourites($type_items)); } } return $formatted; } protected function getFavouriteCount(string $type, int $target_id): int { return $this->favourites->where([ 'type' => $type, 'target_id' => $target_id ])->countResults(); } protected function updateFavouriteCount(string $type, int $target_id): void { $count = $this->getFavouriteCount($type, $target_id); if (str_contains($type, 'post') || in_array($type, array_keys($this->valid_types))) { update_post_meta($target_id, BASE.'favourite_count', $count); } else { update_term_meta($target_id, BASE.'favourite_count', $count); } } public function maybeAcceptListInvite(int $user_id, string $email, array $data):void { if (array_key_exists('list_token', $data) && !empty($data['list_token'])) { JVB()->favourites()->acceptListShare($data['list_token'], $user_id); } } /** * Get detailed information for a single list */ protected function getListDetails(int $list_id, int $user_id): array { // Check access - either owner or has share $is_owner = JVB()->favourites()->userOwnsList($list_id, $user_id); $is_shared = JVB()->favourites()->userCanViewList($list_id, $user_id); if (!$is_owner && !$is_shared) { return [ 'success' => false, 'message' => 'You do not have access to this list.' ]; } $list = JVB()->favourites()->getListDetails($list_id, $user_id); if (empty($list)) { return [ 'success' => false, 'message' => 'List not found' ]; } $key = "list_{$list_id}_user_{$user_id}"; return $this->listsCache->remember($key, function () use ($list_id, $user_id) { try { // Get list items $items = $this->listItems ->where(['list_id' => $list_id]) ->orderBy('added_at', 'DESC') ->getResults(); // Format items - convert to favourite-like objects $formatted_items = []; foreach ($items as $item) { // Try to get the actual favourite record if it exists if ($item->favourite_id) { $fav = $this->favourites->where(['id' => $item->favourite_id])->first(); if ($fav) { $formatted_items[] = $fav; continue; } } // Create dummy favourite object for formatting $formatted_items[] = (object)[ 'type' => $item->item_type, 'target_id' => $item->item_id, 'date_added' => $item->added_at ]; } // Get shared users if owner $shared_users = []; if ($is_owner) { $shares = $this->listShares ->where(['list_id' => $list_id]) ->orderBy('created_at', 'DESC') ->getResults(); foreach ($shares as $share_item) { $shared_user = [ 'email' => $share_item->email, 'status' => $share_item->status, 'date_added' => $share_item->created_at, ]; if ($share_item->status === 'accepted' && $share_item->user_id) { $user = get_userdata($share_item->user_id); $shared_user['name'] = $user ? $user->display_name : 'Unknown'; $shared_user['permission_type'] = $share_item->permission_type; } $shared_users[] = $shared_user; } } return [ 'success' => true, 'list' => [ 'id' => (int)$list['id'], 'name' => $list['name'], 'description' => $list['description'] ?? '', 'created_at' => $list['created_at'], 'is_owner' => $is_owner, 'is_shared' => !$is_owner, 'items' => $this->formatItems($formatted_items), 'shared_users' => $shared_users ] ]; } catch (Exception $e) { $this->logError('getListDetails', [ 'error' => $e->getMessage(), 'user_id' => $user_id, 'list_id' => $list_id ]); return [ 'success' => false, 'message' => 'Error retrieving list details' ]; } }); } /** * Process favourite notes update */ protected function processNote(int $user_id, array $data): array { $target_ids = isset($data['target_id']) ? array_map('absint', explode(',', $data['target_id'])) : []; $notes = sanitize_textarea_field($data['notes'] ?? ''); if (empty($target_ids)) { return ['success' => false, 'result' => 'No target IDs provided']; } return $this->favourites->transaction(function ($table) use ($user_id, $target_ids, $notes) { $results = []; foreach ($target_ids as $target_id) { if ($target_id <= 0) { $results[] = [ 'success' => false, 'target_id' => $target_id, 'message' => 'Invalid target ID' ]; continue; } $favourite = $table->where([ 'user_id' => $user_id, 'target_id' => $target_id ])->first(); if (!$favourite) { $results[] = [ 'success' => false, 'target_id' => $target_id, 'message' => 'Favourite not found' ]; continue; } $table->where(['id' => $favourite->id])->updateResults([ 'notes' => $notes ]); $results[] = [ 'success' => true, 'action' => 'updated_notes', 'favourite_id' => $favourite->id, 'target_id' => $target_id ]; } return [ 'success' => true, 'result' => $results ]; }); } /** * Update list */ protected function updateList(int $user_id, array $data): array { $list_id = absint($data['list_id'] ?? 0); if (!$list_id) { return ['success' => false, 'result' => 'List ID is required']; } try { // Verify ownership $is_owner = $this->lists->where([ 'id' => $list_id, 'user_id' => $user_id ])->existsInQuery(); if (!$is_owner) { return [ 'success' => false, 'result' => 'You do not have permission to update this list' ]; } // Build update data $update_data = []; if (isset($data['name'])) { $update_data['name'] = sanitize_text_field($data['name']); } if (isset($data['description'])) { $update_data['description'] = sanitize_textarea_field($data['description']); } if (empty($update_data)) { return [ 'success' => true, 'result' => 'No changes to update' ]; } // Update the list $this->lists->where(['id' => $list_id])->updateResults($update_data); return [ 'success' => true, 'result' => [ 'message' => 'List updated successfully', 'list_id' => $list_id, 'updates' => array_keys($update_data) ] ]; } catch (Exception $e) { $this->logError('updateList', [ 'error' => $e->getMessage(), 'user_id' => $user_id, 'list_id' => $list_id ]); return [ 'success' => false, 'result' => $e->getMessage() ]; } } /** * Delete list */ protected function deleteList(int $user_id, array $data): array { $list_id = absint($data['list_id'] ?? 0); if (!$list_id) { return ['success' => false, 'result' => 'List ID is required']; } return $this->lists->transaction(function ($table) use ($user_id, $list_id) { // Verify ownership $is_owner = $table->where([ 'id' => $list_id, 'user_id' => $user_id ])->existsInQuery(); if (!$is_owner) { throw new Exception('You do not have permission to delete this list'); } // Delete related data (foreign keys should handle this, but being explicit) $this->listItems->where(['list_id' => $list_id])->deleteResults(); $this->listShares->where(['list_id' => $list_id])->deleteResults(); // Delete the list $table->where(['id' => $list_id])->deleteResults(); return [ 'success' => true, 'result' => [ 'message' => 'List deleted successfully', 'list_id' => $list_id ] ]; }); } /** * Add items to list */ protected function addToList(int $user_id, array $data): array { $list_ids = isset($data['list_id']) ? array_map('absint', explode(',', $data['list_id'])) : []; $items = $data['items'] ?? []; if (empty($list_ids) || empty($items)) { return ['success' => false, 'result' => 'List ID and items are required']; } return $this->listItems->transaction(function ($table) use ($user_id, $list_ids, $items) { $results = []; $total_added = 0; $all_errors = []; foreach ($list_ids as $list_id) { if (!$list_id) { $all_errors[] = ['message' => 'Invalid list ID', 'list_id' => $list_id]; continue; } // Check permission - either owner or has edit permission $is_owner = $this->lists->where([ 'id' => $list_id, 'user_id' => $user_id ])->existsInQuery(); $has_edit = $this->listShares->where([ 'list_id' => $list_id, 'user_id' => $user_id, 'permission_type' => 'edit', 'status' => 'accepted' ])->existsInQuery(); if (!$is_owner && !$has_edit) { $all_errors[] = [ 'message' => 'You do not have permission to add items to this list', 'list_id' => $list_id ]; continue; } // Add items to this list $add_result = $this->addItemsToList($list_id, $items, $user_id); $total_added += $add_result['added']; if (!empty($add_result['errors'])) { $all_errors = array_merge($all_errors, $add_result['errors']); } $results[] = [ 'list_id' => $list_id, 'added_count' => $add_result['added'] ]; } if ($total_added == 0 && !empty($all_errors)) { throw new Exception('Failed to add any items'); } return [ 'success' => true, 'result' => [ 'lists' => $results, 'total_added' => $total_added, 'errors' => $all_errors ] ]; }); } /** * Remove items from list */ protected function removeFromList(int $user_id, array $data): array { $list_id = absint($data['list_id'] ?? 0); $items = $data['items'] ?? []; if (!$list_id || empty($items)) { return ['success' => false, 'result' => 'List ID and items are required']; } try { // Check permission $is_owner = $this->lists->where([ 'id' => $list_id, 'user_id' => $user_id ])->existsInQuery(); $has_edit = $this->listShares->where([ 'list_id' => $list_id, 'user_id' => $user_id, 'permission_type' => 'edit', 'status' => 'accepted' ])->existsInQuery(); if (!$is_owner && !$has_edit) { return [ 'success' => false, 'result' => 'You do not have permission to remove items from this list' ]; } // Remove items $removed = 0; foreach ($items as $item) { if (empty($item['type']) || !isset($item['target_id'])) { continue; } $type = str_starts_with($item['type'], BASE) ? $item['type'] : BASE . $item['type']; $deleted = $this->listItems->where([ 'list_id' => $list_id, 'item_type' => $type, 'item_id' => absint($item['target_id']) ])->deleteResults(); if ($deleted) { $removed += $deleted; } } return [ 'success' => true, 'result' => [ 'message' => "{$removed} items removed from list", 'list_id' => $list_id, 'removed_count' => $removed ] ]; } catch (Exception $e) { $this->logError('removeFromList', [ 'error' => $e->getMessage(), 'user_id' => $user_id, 'list_id' => $list_id ]); return [ 'success' => false, 'result' => $e->getMessage() ]; } } /** * Share list with another user */ protected function shareList(int $user_id, array $data): array { $list_id = absint($data['list_id'] ?? 0); $email = sanitize_email($data['email'] ?? ''); $permission_type = in_array($data['permission_type'] ?? '', ['view', 'edit']) ? $data['permission_type'] : 'view'; if (!$list_id || !$email) { return ['success' => false, 'result' => 'List ID and email are required']; } return $this->listShares->transaction(function ($table) use ($user_id, $list_id, $email, $permission_type) { // Verify ownership $list = $this->lists->where([ 'id' => $list_id, 'user_id' => $user_id ])->first(); if (!$list) { throw new Exception('You do not have permission to share this list'); } // Get owner details $owner = get_userdata($user_id); $owner_name = $owner ? $owner->display_name : 'Someone'; // Look up user by email $share_user = get_user_by('email', $email); if ($share_user) { // User exists - check for existing share $existing = $table->where([ 'list_id' => $list_id, 'user_id' => $share_user->ID ])->first(); if ($existing) { // Update if different if ($existing->permission_type !== $permission_type || $existing->status !== 'accepted') { $table->where(['id' => $existing->id])->updateResults([ 'permission_type' => $permission_type, 'status' => 'accepted', ]); return [ 'success' => true, 'result' => [ 'action' => 'updated', 'message' => "Updated sharing permissions for {$email}", ] ]; } return [ 'success' => true, 'result' => [ 'action' => 'already_shared', 'message' => "List is already shared with {$email}", ] ]; } // Create new share $table->create([ 'list_id' => $list_id, 'user_id' => $share_user->ID, 'email' => $email, 'permission_type' => $permission_type, 'status' => 'accepted' ]); // Send notification JVB()->notification()->addNotification( $share_user->ID, 'list_shared', $user_id, sprintf('%s shared a favorites list with you: "%s"', $owner_name, $list->name), $list_id, 'favourites_list', [ 'list_id' => $list_id, 'list_name' => $list->name, 'permission_type' => $permission_type ] ); return [ 'success' => true, 'result' => [ 'action' => 'shared', 'message' => "List shared with {$email}", ] ]; } // User doesn't exist - create pending invitation $existing_pending = $table->where([ 'list_id' => $list_id, 'email' => $email, 'status' => 'pending' ])->first(); if ($existing_pending) { return [ 'success' => true, 'result' => [ 'action' => 'already_pending', 'message' => "Invitation already sent to {$email}", ] ]; } // Create pending share with invitation token $token = wp_generate_password(32, false); $table->create([ 'list_id' => $list_id, 'email' => $email, 'permission_type' => $permission_type, 'status' => 'pending', 'invitation_token' => $token, 'user_id' => 0 // Will be set when they accept ]); // Send invitation email $this->sendListInviteEmail($email, [ 'list_id' => $list_id, 'list_name' => $list->name, 'token' => $token, 'owner_name' => $owner_name, 'user_id' => $user_id ]); return [ 'success' => true, 'result' => [ 'action' => 'invitation_sent', 'message' => "Invitation sent to {$email}", ] ]; }); } /** * Unshare list */ protected function unshareList(int $user_id, array $data): array { $list_id = absint($data['list_id'] ?? 0); $email = sanitize_email($data['email'] ?? ''); if (!$list_id || !$email) { return ['success' => false, 'result' => 'List ID and email are required']; } try { // Verify ownership $is_owner = $this->lists->where([ 'id' => $list_id, 'user_id' => $user_id ])->existsInQuery(); if (!$is_owner) { return [ 'success' => false, 'result' => 'You do not have permission to manage shares for this list' ]; } // Find share by email $existing_share = $this->listShares->where([ 'list_id' => $list_id, 'email' => $email ])->first(); if (!$existing_share) { return [ 'success' => false, 'result' => "No active share or invitation found for {$email}" ]; } // Update status to revoked (or just delete) $this->listShares->where(['id' => $existing_share->id])->updateResults([ 'status' => 'revoked' ]); // Notify user if registered and was accepted if ($existing_share->status === 'accepted' && $existing_share->user_id) { $list = $this->lists->where(['id' => $list_id])->first(); if ($list) { JVB()->notification()->addNotification( $existing_share->user_id, 'list_share_revoked', $user_id, sprintf('Your access to the list "%s" has been revoked', $list->name), $list_id, 'favourites_list', [ 'list_id' => $list_id, 'list_name' => $list->name ] ); } } $action = $existing_share->status === 'accepted' ? 'unshared' : 'invitation_cancelled'; $message = $existing_share->status === 'accepted' ? "Removed {$email}'s access to list" : "Cancelled invitation to {$email}"; return [ 'success' => true, 'result' => [ 'action' => $action, 'message' => $message, ] ]; } catch (Exception $e) { $this->logError('unshareList', [ 'error' => $e->getMessage(), 'user_id' => $user_id, 'list_id' => $list_id, 'email' => $email ]); return [ 'success' => false, 'result' => $e->getMessage() ]; } } /** * Send list invitation email */ protected function sendListInviteEmail(string $email, array $data): bool { $list_name = $data['list_name']; $token = $data['token']; $owner_name = $data['owner_name']; // Generate invitation URL $invite_url = add_query_arg([ 'action' => 'accept_list_invite', 'token' => $token, 'list' => $data['list_id'] ], home_url('/')); $subject = sprintf('%s shared a favourites list with you', $owner_name); $message = sprintf( '
Hi there,
%s has shared their list "%s" with you.
To view this list, click the link below:
Or copy and paste this link: %s
If you don\'t have an account, you\'ll be guided through creating one.
', $owner_name, $list_name, $invite_url, $invite_url ); return JVB()->email()->sendEmail($email, $subject, $message); } /** * Accept a list share invitation */ protected function acceptListInvitation(string $token, string $email, ?int $user_id = null): array { if (!$token || !$email) { return [ 'success' => false, 'message' => 'Invalid invitation parameters' ]; } try { // Find the pending invitation $invitation = $this->listShares->where([ 'invitation_token' => $token, 'email' => $email, 'status' => 'pending' ])->first(); if (!$invitation) { return [ 'success' => false, 'message' => 'Invalid or expired invitation' ]; } // If no user_id provided, check if user exists if (!$user_id) { $existing_user = get_user_by('email', $email); if ($existing_user) { $user_id = $existing_user->ID; } else { // No account - need to register $registration_url = add_query_arg([ 'action' => 'register', 'type' => 'favourites', 'list_token' => $token, 'email' => urlencode($email) ], wp_login_url()); return [ 'success' => false, 'message' => 'You need to create an account to access this shared list', 'needs_registration' => true, 'registration_url' => $registration_url ]; } } // Get list details $list = $this->lists->where(['id' => $invitation->list_id])->first(); if (!$list) { return [ 'success' => false, 'message' => 'The shared list no longer exists' ]; } // Update invitation to accepted $this->listShares->where(['id' => $invitation->id])->updateResults([ 'status' => 'accepted', 'user_id' => $user_id, ]); // Notify list owner $user = get_userdata($user_id); $display_name = $user ? $user->display_name : $email; JVB()->notification()->addNotification( $list->user_id, 'list_share_accepted', $user_id, sprintf('%s accepted your invitation to the list "%s"', $display_name, $list->name), $invitation->list_id, 'favourites_list', [ 'list_id' => $invitation->list_id, 'list_name' => $list->name, ] ); return [ 'success' => true, 'message' => 'List successfully shared with you', 'list_id' => $invitation->list_id, 'list_name' => $list->name, 'permission_type' => $invitation->permission_type ]; } catch (Exception $e) { $this->logError('acceptListInvitation', [ 'error' => $e->getMessage(), 'token' => $token, 'email' => $email ]); return [ 'success' => false, 'message' => $e->getMessage() ]; } } /** * Get the owner ID for a content item */ protected function getContentOwner(string $type, int $target_id): int|array|null { // For posts if (str_contains($type, 'post') || in_array($type, array_keys($this->valid_types))) { $post = get_post($target_id); return $post ? $post->post_author : null; } // For terms (shops, etc.) $owners = get_term_meta($target_id, BASE . 'owner', true); if ($owners) { $owner_array = array_map('absint', explode(',', $owners)); return count($owner_array) === 1 ? $owner_array[0] : $owner_array; } return null; } /** * Batch format post-type favourites to reduce queries */ protected function formatPostFavourites(array $items): array { if (empty($items)) { return []; } $formatted = []; $post_ids = array_map(fn($item) => (int)$item->target_id, $items); // Get all posts in one query $posts = get_posts([ 'post__in' => $post_ids, 'post_type' => 'any', 'posts_per_page' => -1, 'post_status' => 'any', ]); // Create lookup map $posts_by_id = []; foreach ($posts as $post) { $posts_by_id[$post->ID] = $post; } // Get thumbnails for artists $artist_ids = []; foreach ($items as $item) { if ($item->type === BASE . 'artist') { $artist_ids[] = (int)$item->target_id; } } $artist_images = []; if (!empty($artist_ids)) { global $wpdb; $placeholders = implode(',', array_fill(0, count($artist_ids), '%d')); $results = $wpdb->get_results($wpdb->prepare( "SELECT post_id, meta_value FROM {$wpdb->postmeta} WHERE meta_key = %s AND post_id IN ($placeholders)", array_merge([BASE . 'image'], $artist_ids) )); foreach ($results as $result) { $artist_images[$result->post_id] = $result->meta_value; } } // Get regular thumbnails $thumbnail_ids = []; foreach ($items as $item) { if ($item->type !== BASE . 'artist' && isset($posts_by_id[$item->target_id])) { $thumb_id = get_post_thumbnail_id($item->target_id); if ($thumb_id) { $thumbnail_ids[$item->target_id] = $thumb_id; } } } // Format each item foreach ($items as $item) { $post_id = (int)$item->target_id; if (!isset($posts_by_id[$post_id])) { continue; } $post = $posts_by_id[$post_id]; $formatted_item = [ 'id' => $item->id ?? null, 'type' => str_replace(BASE, '', $item->type), 'target_id' => $post_id, 'date_added' => $item->date_added ?? current_time('mysql'), 'notes' => $item->notes ?? '', 'url' => get_permalink($post), 'title' => $post->post_title, 'author' => [ 'id' => $post->post_author, 'name' => get_the_author_meta('display_name', $post->post_author) ] ]; // Add thumbnail if ($item->type === BASE . 'artist') { $meta_value = $artist_images[$post_id] ?? null; $formatted_item['thumbnail'] = $meta_value ? jvbFormatImage($meta_value, 'medium', 'medium') : null; } else { $thumb_id = $thumbnail_ids[$post_id] ?? null; $formatted_item['thumbnail'] = $thumb_id ? jvbFormatImage($thumb_id, 'medium', 'medium') : null; } $formatted[] = $formatted_item; } return $formatted; } /** * Batch format term-type favourites to reduce queries */ protected function formatTermFavourites(array $items): array { if (empty($items)) { return []; } $formatted = []; // Group by taxonomy $terms_by_taxonomy = []; foreach ($items as $item) { $tax = $item->type; if (!isset($terms_by_taxonomy[$tax])) { $terms_by_taxonomy[$tax] = []; } $terms_by_taxonomy[$tax][] = (int)$item->target_id; } // Get all terms by taxonomy $terms_by_id = []; foreach ($terms_by_taxonomy as $taxonomy => $term_ids) { $terms = get_terms([ 'taxonomy' => $taxonomy, 'include' => $term_ids, 'hide_empty' => false, ]); if (!is_wp_error($terms)) { foreach ($terms as $term) { $terms_by_id[$taxonomy . '_' . $term->term_id] = $term; } } } // Get shop images $shop_ids = []; foreach ($items as $item) { if ($item->type === BASE . 'shop') { $shop_ids[] = (int)$item->target_id; } } $shop_images = []; if (!empty($shop_ids)) { global $wpdb; $placeholders = implode(',', array_fill(0, count($shop_ids), '%d')); $results = $wpdb->get_results($wpdb->prepare( "SELECT term_id, meta_value FROM {$wpdb->termmeta} WHERE meta_key = %s AND term_id IN ($placeholders)", array_merge([BASE . 'image'], $shop_ids) )); foreach ($results as $result) { $shop_images[$result->term_id] = $result->meta_value; } } // Format each item foreach ($items as $item) { $term_id = (int)$item->target_id; $key = $item->type . '_' . $term_id; if (!isset($terms_by_id[$key])) { continue; } $term = $terms_by_id[$key]; $formatted_item = [ 'id' => $item->id ?? null, 'type' => str_replace(BASE, '', $item->type), 'target_id' => $term_id, 'date_added' => $item->date_added ?? current_time('mysql'), 'notes' => $item->notes ?? '', 'title' => html_entity_decode($term->name), 'url' => get_term_link($term) ]; // Add thumbnail for shops if ($item->type === BASE . 'shop') { $meta_value = $shop_images[$term_id] ?? null; $formatted_item['thumbnail'] = $meta_value ? jvbFormatImage($meta_value, 'medium', 'medium') : null; } $formatted[] = $formatted_item; } return $formatted; } /** * Handle list operations - routes to appropriate method */ public function handleList(WP_REST_Request $request): WP_REST_Response { $user_id = absint($request->get_param('user')); $operation_id = sanitize_text_field($request->get_param('id')); $action = sanitize_text_field($request->get_param('action')); if (!$this->userCheck($user_id)) { return $this->unauthorized(); } $data = [ 'action' => $action, 'list_id' => absint($request->get_param('list_id') ?? 0), 'name' => sanitize_text_field($request->get_param('name') ?? ''), 'description' => sanitize_textarea_field($request->get_param('description') ?? ''), 'items' => $request->get_param('items') ?? [], 'email' => sanitize_email($request->get_param('email') ?? ''), 'permission_type' => sanitize_text_field($request->get_param('permission_type') ?? 'view'), ]; // Map action to operation type $operation_type = match ($action) { 'create' => 'favourite_list_create', 'update' => 'favourite_list_update', 'delete' => 'favourite_list_delete', 'add_items' => 'favourite_list_add_items', 'remove_items' => 'favourite_list_remove_items', 'share' => 'favourite_list_share', 'unshare' => 'favourite_list_unshare', default => null }; if (!$operation_type) { return $this->error('Invalid action', 'invalid_action', 400); } JVB()->queue()->queueOperation( $operation_type, $user_id, $data, [ 'operation_id' => $operation_id, 'priority' => 'normal', ] ); return $this->queued($operation_id); } }