<?php
|
namespace JVBase\rest\routes;
|
|
use JVBase\JVB;
|
use JVBase\rest\RestRouteManager;
|
use JVBase\managers\CacheManager;
|
use WP_REST_Request;
|
use WP_REST_Response;
|
use WP_Error;
|
use Exception;
|
|
if (!defined('ABSPATH')) {
|
exit; // Exit if accessed directly
|
}
|
|
class FavouritesRoutes extends RestRouteManager
|
{
|
protected array $valid_types;
|
protected int $user_id;
|
|
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
|
{
|
$data = $request->get_params();
|
error_log('Favourites Request Data: '.print_r($data, true));
|
$args = [];
|
if (!array_key_exists('user', $data)) {
|
return $args;
|
}
|
$args['user'] = absint($data['user']);
|
if (!array_key_exists('page', $data)) {
|
//No filters set, just get a list of favourites
|
return $args;
|
}
|
$args = array_merge($args, [
|
'page' => max(1, absint($data['page'] ?? 1)),
|
'content' => $this->checkContent($data['content'])
|
]);
|
return $this->applyOrderFilters($args, $data);
|
}
|
/**
|
* Get user's favourites with optional filtering
|
*
|
* @param WP_REST_Request $request Request object
|
* @return WP_REST_Response Response with favourites data
|
*/
|
public function getFavourites(WP_REST_Request $request):WP_REST_Response
|
{
|
$args = $this->buildParams($request);
|
if (!$args['user'] || $args['user'] === ''){
|
$result = [
|
'success' => false,
|
'message' => 'No user set'
|
];
|
}
|
// Check HTTP cache headers for user-specific data
|
$cache_check = $this->checkUserHeaders($request, $args['user'], 'favourites');
|
if ($cache_check) {
|
return $cache_check;
|
}
|
|
if (count($args) === 1 || (array_key_exists('all', $args) && $args['all'] === true)) {
|
$result = $this->getAllFavourites($args['user']);
|
} else {
|
$result = $this->cache->remember(
|
$args,
|
function() use ($args) {
|
$response = new WP_REST_Response($this->getFilteredFavourites($args));
|
return $this->addCacheHeaders($response);
|
}
|
);
|
}
|
$response = new WP_REST_Response($result);
|
return $this->addCacheHeaders($response);
|
}
|
|
protected function getFilteredFavourites(array $args):array
|
{
|
try {
|
global $wpdb;
|
$table = $wpdb->prefix . BASE . 'favourites';
|
|
// Build query with proper escaping
|
$query_parts = ["SELECT f.* FROM {$table} f WHERE user_id = %d"];
|
$params = [$args['user']];
|
|
// Add type filter if specified
|
if ($args['content'] && $args['content']!=='all') {
|
$query_parts[] = "AND type = %s";
|
$params[] = BASE . $args['content'];
|
}
|
|
// Add ordering - make sure to use whitelist for column names
|
if ($args['orderby'] === 'date') {
|
$args['orderby'] = 'date_added';
|
}
|
$valid_orderby_columns = ['date_added', 'type'];
|
$valid_orders = ['ASC', 'DESC'];
|
$orderby = in_array($args['orderby'], $valid_orderby_columns) ? $args['orderby'] : 'date_added';
|
$order = in_array(strtoupper($args['order']), $valid_orders) ? strtoupper($args['order']) : 'DESC';
|
$query_parts[] = "ORDER BY {$orderby} {$order}";
|
|
// Add pagination
|
$query_parts[] = "LIMIT %d OFFSET %d";
|
$params[] = 100;
|
$params[] = ($args['page'] - 1) * 100;
|
|
// Execute query
|
$query = implode(' ', $query_parts);
|
$favourites = $wpdb->get_results($wpdb->prepare($query, $params));
|
|
// Get total count for pagination
|
$count_query = "SELECT COUNT(*) FROM {$table} WHERE user_id = %d";
|
$count_params = [$args['user']];
|
|
if ($args['content'] && $args['content'] !== 'all') {
|
$count_query .= " AND type = %s";
|
$count_params[] = BASE . $args['content'];
|
}
|
|
$total_items = (int)$wpdb->get_var($wpdb->prepare($count_query, $count_params));
|
|
// Format the favourites using batch processing to reduce queries
|
$formatted = $this->formatItems($favourites);
|
|
// Get counts by type for filters
|
$counts = $this->getFavouriteCounts($args['user']);
|
|
// Prepare response data
|
return [
|
'items' => $formatted,
|
'has_more' => ($args['page'] * 100) < $total_items,
|
'total' => $total_items,
|
'success' => true,
|
];
|
|
} catch (Exception $e) {
|
$this->logError(
|
$e->getMessage(),
|
[
|
'method' => 'getFilteredFavourites',
|
'args' => $args
|
]
|
);
|
return [
|
'success' => false,
|
'favourites' => [],
|
'counts' => 0,
|
'pagination' => []
|
];
|
}
|
}
|
|
/**
|
* Get all user's favourites organized by content type
|
*
|
* @param int $user_id User ID
|
* @return WP_REST_Response Response with favourites by content type
|
*/
|
protected function getAllFavourites(int $user_id):WP_REST_Response
|
{
|
if (!$this->checkUser($user_id)) {
|
return new WP_REST_Response([
|
'success' => false,
|
'message' => 'User ID doesn\'t match... are you a bot?'
|
]);
|
}
|
|
$result = $this->cache->remember(
|
'user_'.$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
|
{
|
try {
|
global $wpdb;
|
$table = $wpdb->prefix . BASE . 'favourites';
|
|
// Get all favourites for this user
|
$query = $wpdb->prepare(
|
"SELECT type, target_id FROM {$table} WHERE user_id = %d",
|
$user_id
|
);
|
|
$favourites = $wpdb->get_results($query);
|
|
// Organize by content type
|
$by_type = [];
|
|
foreach ($favourites as $fav) {
|
$type = str_replace(BASE, '', $fav->type);
|
|
if (!isset($by_type[$type])) {
|
$by_type[$type] = [];
|
}
|
|
$by_type[$type][] = (int)$fav->target_id;
|
}
|
|
$response_data = [
|
'success' => true,
|
'items' => $by_type,
|
'has_more' => false,
|
];
|
|
return $response_data;
|
|
} catch (Exception $e) {
|
$this->logError(
|
$e->getMessage(),
|
[
|
'context' => 'fetchAllFavourites',
|
'user' => $user_id
|
]
|
);
|
return [
|
'success' => false,
|
'favourites' => [],
|
];
|
}
|
}
|
|
/**
|
* Handle favourite operations (toggle, notes update)
|
*
|
* @param WP_REST_Request $request Request object
|
* @return WP_REST_Response Response with operation result
|
*/
|
public function handleFavouriteOperation(WP_REST_Request $request): WP_REST_Response
|
{
|
$data = $request->get_json_params();
|
$operation = $data['operation'] ?? 'toggle';
|
$user_id = get_current_user_id();
|
|
$queue = JVB()->queue();
|
$operation_id = $data['id'] ?? uniqid('fav_');
|
|
error_log('Favourite Request: '.print_r($data, true));
|
|
switch ($operation) {
|
case 'toggle':
|
$adds = $request->get_param('adds') ?? [];
|
$removes = $request->get_param('removes') ?? [];
|
|
$queue->queueOperation(
|
'favourites_batch',
|
$request->get_param('user'),
|
[
|
'adds' => $adds,
|
'removes' => $removes
|
],
|
[
|
'count' => count($adds) + count($removes),
|
'chunk_key' => ['adds', 'removes'],
|
'chunk_size' => 20,
|
'priority' => 'normal',
|
'operation_id' => $request->get_param('id')
|
]
|
);
|
|
break;
|
|
case 'update_notes':
|
// Handle notes update
|
if (!array_key_exists('target_id', $data)) {
|
return $this->createErrorResponse(
|
self::ERROR_MISSING_PARAMS,
|
'Type and target ID are required',
|
400
|
);
|
}
|
|
$ids = explode(',', $data['target_id']);
|
foreach ($ids as $key => $id) {
|
$ids[$key] = (int)$id;
|
}
|
$ids = implode(',', $ids);
|
|
$queue->queueOperation(
|
'favourite_notes',
|
$user_id,
|
[
|
'target_id' => $ids,
|
'notes' => sanitize_textarea_field($data['notes'] ?? '')
|
],
|
[
|
'count' => 1,
|
'operation_id' => $operation_id
|
]
|
);
|
|
break;
|
|
default:
|
return $this->createErrorResponse(
|
self::ERROR_INVALID_OPERATION,
|
'Invalid operation',
|
400
|
);
|
}
|
|
return new WP_REST_Response([
|
'success' => true,
|
'message' => __('Operation queued', 'jvb'),
|
'operation_id' => $operation_id,
|
'queue_status' => $queue->getQueueStatus()
|
]);
|
}
|
|
/**
|
* Get user's favourite lists
|
*
|
* @param WP_REST_Request $request Request object
|
* @return WP_REST_Response Response with lists data
|
*/
|
public function getLists(WP_REST_Request $request):WP_REST_Response
|
{
|
$user_id = get_current_user_id();
|
|
if (!$user_id || !$this->userCheck($user_id)) {
|
return new WP_REST_Response([
|
'success' => false,
|
'message' => 'Invalid user'
|
]);
|
}
|
|
// Check HTTP cache headers
|
$cache_check = $this->checkUserHeaders($request, $user_id, 'favourites_lists');
|
if ($cache_check) {
|
return $cache_check;
|
}
|
|
$list_id = $request->get_param('id');
|
|
if ($list_id) {
|
$response = $this->getListDetails($list_id, $user_id);
|
} else {
|
$response = $this->getAvailableLists($user_id);
|
}
|
|
$response = new WP_REST_Response($response);
|
return $this->addCacheHeaders($response);
|
}
|
/**
|
* Get lists available to a user (owned and shared)
|
*
|
* @param int $user_id User ID
|
* @param bool $include_shared Include lists shared with user
|
* @return array Lists data
|
*/
|
public function getAvailableLists(int $user_id, bool $include_shared = true):array
|
{
|
if (!$this->checkUser($user_id)) {
|
return [];
|
}
|
$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'
|
]);
|
}
|
|
// Check HTTP cache headers
|
$cache_check = $this->checkUserHeaders($request, $user_id, 'favourites_shares');
|
if ($cache_check) {
|
return $cache_check;
|
}
|
$list_id = $request->get_param('list_id');
|
|
if (!$list_id) {
|
return $this->createErrorResponse(
|
self::ERROR_MISSING_PARAMS,
|
'List ID is required',
|
400
|
);
|
}
|
|
$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);
|
}
|
|
try {
|
global $wpdb;
|
$lists_table = $wpdb->prefix . BASE . 'favourites_lists';
|
$shares_table = $wpdb->prefix . BASE . 'favourites_list_shares';
|
|
// Verify ownership
|
$is_owner = $wpdb->get_var($wpdb->prepare(
|
"SELECT 1 FROM {$lists_table} WHERE id = %d AND user_id = %d",
|
$list_id,
|
$user_id
|
));
|
|
if (!$is_owner) {
|
return $this->createErrorResponse(
|
self::ERROR_ACCESS_DENIED,
|
'You do not own this list',
|
403
|
);
|
}
|
|
// Get all shares (both active and pending) in one query
|
$query = $wpdb->prepare(
|
"SELECT s.*,
|
CASE
|
WHEN s.user_id IS NOT NULL THEN u.display_name
|
ELSE NULL
|
END as display_name,
|
CASE
|
WHEN s.user_id IS NOT NULL THEN u.user_email
|
ELSE s.email
|
END as email
|
FROM {$shares_table} s
|
LEFT JOIN {$wpdb->users} u ON s.user_id = u.ID
|
WHERE s.list_id = %d
|
ORDER BY s.status, s.created_at DESC",
|
$list_id
|
);
|
|
$all_shares = $wpdb->get_results($query);
|
|
// Format shares for response
|
$shares = [];
|
foreach ($all_shares as $share) {
|
$formatted_share = [
|
'id' => $share->id,
|
'email' => $share->email,
|
'status' => $share->status,
|
'date_added' => $share->created_at,
|
];
|
|
// Add attributes specific to the status
|
if ($share->status === 'accepted') {
|
$formatted_share['name'] = $share->display_name;
|
$formatted_share['user_id'] = $share->user_id;
|
$formatted_share['permission_type'] = $share->permission_type;
|
} else if ($share->status === 'pending') {
|
// Include invitation token if needed for managing invitations
|
$formatted_share['invitation_token'] = $share->invitation_token;
|
}
|
|
$shares[] = $formatted_share;
|
}
|
|
$response_data = [
|
'success' => true,
|
'list_id' => $list_id,
|
'shares' => $shares
|
];
|
|
// Cache the results
|
$this->cache->set($key, $response_data, 'favourites_list_shares');
|
|
$response = new WP_REST_Response($response_data);
|
return $this->addCacheHeaders($response);
|
|
} catch (Exception $e) {
|
return $this->createErrorResponse(
|
self::ERROR_PROCESSING,
|
'Error retrieving shares: ' . $e->getMessage(),
|
500,
|
['user_id' => $user_id, 'list_id' => $list_id]
|
);
|
}
|
}
|
|
/**
|
* Handle share operations (add/remove)
|
*
|
* @param WP_REST_Request $request Request object
|
* @return WP_REST_Response Response with operation result
|
*/
|
public function handleShare(WP_REST_Request $request):WP_REST_Response
|
{
|
$data = $request->get_json_params();
|
$operation = $data['operation'] ?? '';
|
$user_id = get_current_user_id();
|
|
$queue = JVB()->queue();
|
$operation_id = $data['id'] ?? uniqid('share_');
|
|
if (empty($data['list_id']) || empty($data['email'])) {
|
return $this->createErrorResponse(
|
self::ERROR_MISSING_PARAMS,
|
'List ID and email are required',
|
400
|
);
|
}
|
|
switch ($operation) {
|
case 'add':
|
// Share list with email
|
if (!is_email($data['email'])) {
|
return $this->createErrorResponse(
|
self::ERROR_MISSING_PARAMS,
|
'Invalid email address',
|
400
|
);
|
}
|
|
$queue->queueOperation(
|
'favourite_list_share',
|
$user_id,
|
[
|
'list_id' => (int)$data['list_id'],
|
'email' => sanitize_email($data['email']),
|
'permission_type' => in_array($data['permission_type'] ?? '', ['view', 'edit'])
|
? $data['permission_type']
|
: 'view'
|
],
|
[
|
'count' => 1,
|
'operation_id' => $operation_id
|
]
|
);
|
break;
|
|
case 'remove':
|
// Remove share
|
$queue->queueOperation(
|
'favourite_list_unshare',
|
$user_id,
|
[
|
'list_id' => (int)$data['list_id'],
|
'email' => sanitize_email($data['email'])
|
],
|
[
|
'count' => 1,
|
'operation_id' => $operation_id
|
]
|
);
|
break;
|
|
case 'accept':
|
$data = $request->get_json_params();
|
$token = $data['token'] ?? '';
|
$email = sanitize_email($data['email'] ?? '');
|
$user_id = get_current_user_id(); // Get current user if logged in
|
|
$result = $this->acceptListInvitation($token, $email, $user_id > 0 ? $user_id : null);
|
break;
|
default:
|
return $this->createErrorResponse(
|
self::ERROR_INVALID_OPERATION,
|
'Invalid share operation',
|
400
|
);
|
}
|
|
|
return new WP_REST_Response([
|
'success' => true,
|
'message' => __('Share operation queued', 'jvb'),
|
'operation_id' => $operation_id,
|
'queue_status' => $queue->getQueueStatus()
|
]);
|
}
|
|
/**
|
* Batch format a collection of favourite items
|
*
|
* @param array $items Collection of favourite items
|
* @return array Formatted items
|
*/
|
protected function formatItems(array $items):array
|
{
|
if (empty($items)) {
|
return [];
|
}
|
|
// Group items by type to reduce queries
|
$items_by_type = [];
|
foreach ($items as $item) {
|
if (!isset($item->type) || !isset($item->target_id)) {
|
continue;
|
}
|
|
// Verify item type is valid
|
if (!isset($this->valid_types[$item->type])) {
|
continue;
|
}
|
|
$type = $item->type;
|
if (!isset($items_by_type[$type])) {
|
$items_by_type[$type] = [];
|
}
|
$items_by_type[$type][] = $item;
|
}
|
|
$formatted = [];
|
|
// Process post-type items in batches
|
foreach ($items_by_type as $type => $type_items) {
|
$config = $this->valid_types[$type];
|
|
if ($config['table'] === 'post') {
|
$formatted = array_merge($formatted, $this->formatPostFavourites($type_items));
|
} else {
|
$formatted = array_merge($formatted, $this->formatTermFavourites($type_items));
|
}
|
}
|
|
return $formatted;
|
}
|
|
/**
|
* Batch format post-type favourites to reduce queries
|
*
|
* @param array $items Collection of favourite items of post type
|
* @return array Formatted items
|
*/
|
protected function formatPostFavourites(array $items):array
|
{
|
if (empty($items)) {
|
return [];
|
}
|
|
$formatted = [];
|
$post_ids = array_map(function ($item) {
|
return (int)$item->target_id;
|
}, $items);
|
|
// Get all posts in one query
|
$posts = get_posts([
|
'post__in' => $post_ids,
|
'post_type' => 'any',
|
'posts_per_page' => -1,
|
'post_status' => 'any',
|
]);
|
|
// Create a lookup map
|
$posts_by_id = [];
|
foreach ($posts as $post) {
|
$posts_by_id[$post->ID] = $post;
|
}
|
|
// Get all thumbnails for artists in one query if needed
|
$artist_ids = [];
|
foreach ($items as $item) {
|
if ($item->type === BASE.'artist') {
|
$artist_ids[] = (int)$item->target_id;
|
}
|
}
|
|
$artist_images = [];
|
if (!empty($artist_ids)) {
|
global $wpdb;
|
$meta_query = $wpdb->prepare(
|
"SELECT post_id, meta_value FROM {$wpdb->postmeta}
|
WHERE meta_key = %s AND post_id IN (" . implode(',', array_fill(0, count($artist_ids), '%d')) . ")",
|
array_merge([BASE.'image'], $artist_ids)
|
);
|
$results = $wpdb->get_results($meta_query);
|
|
foreach ($results as $result) {
|
$artist_images[$result->post_id] = $result->meta_value;
|
}
|
}
|
|
// Get all thumbnails in one query
|
$thumbnail_ids = [];
|
foreach ($items as $item) {
|
if ($item->type !== BASE.'artist' && isset($posts_by_id[$item->target_id])) {
|
$thumb_id = get_post_thumbnail_id($item->target_id);
|
if ($thumb_id) {
|
$thumbnail_ids[$item->target_id] = $thumb_id;
|
}
|
}
|
}
|
|
// Format each item
|
foreach ($items as $item) {
|
$post_id = (int)$item->target_id;
|
|
// Skip if post doesn't exist
|
if (!isset($posts_by_id[$post_id])) {
|
continue;
|
}
|
|
$post = $posts_by_id[$post_id];
|
|
$formatted_item = [
|
'id' => $item->id ?? null,
|
'type' => str_replace(BASE, '', $item->type),
|
'target_id' => $post_id,
|
'date_added' => $item->date_added ?? current_time('mysql'),
|
'notes' => $item->notes ?? '',
|
'url' => get_permalink($post),
|
'title' => $post->post_title,
|
'author' => [
|
'id' => $post->post_author,
|
'name' => get_the_author_meta('display_name', $post->post_author)
|
]
|
];
|
|
// Add thumbnail
|
if ($item->type === BASE.'artist') {
|
$meta_value = $artist_images[$post_id] ?? null;
|
$formatted_item['thumbnail'] = $meta_value ? jvbFormatImage($meta_value, 'medium', 'medium') : null;
|
} else {
|
$thumb_id = $thumbnail_ids[$post_id] ?? null;
|
$formatted_item['thumbnail'] = $thumb_id ? jvbFormatImage($thumb_id, 'medium', 'medium') : null;
|
}
|
|
$formatted[] = $formatted_item;
|
}
|
|
return $formatted;
|
}
|
|
/**
|
* Batch format term-type favourites to reduce queries
|
*
|
* @param array $items Collection of favourite items of term type
|
* @return array Formatted items
|
*/
|
protected function formatTermFavourites(array $items):array
|
{
|
if (empty($items)) {
|
return [];
|
}
|
|
$formatted = [];
|
|
// Group by taxonomy
|
$terms_by_taxonomy = [];
|
foreach ($items as $item) {
|
$tax = $item->type;
|
if (!isset($terms_by_taxonomy[$tax])) {
|
$terms_by_taxonomy[$tax] = [];
|
}
|
$terms_by_taxonomy[$tax][] = (int)$item->target_id;
|
}
|
|
// Get all terms by taxonomy
|
$terms_by_id = [];
|
foreach ($terms_by_taxonomy as $taxonomy => $term_ids) {
|
$terms = get_terms([
|
'taxonomy' => $taxonomy,
|
'include' => $term_ids,
|
'hide_empty' => false,
|
]);
|
|
if (!is_wp_error($terms)) {
|
foreach ($terms as $term) {
|
$terms_by_id[$taxonomy . '_' . $term->term_id] = $term;
|
}
|
}
|
}
|
|
// Get all shop images in one query if needed
|
$shop_ids = [];
|
foreach ($items as $item) {
|
if ($item->type === BASE.'shop') {
|
$shop_ids[] = (int)$item->target_id;
|
}
|
}
|
|
$shop_images = [];
|
if (!empty($shop_ids)) {
|
global $wpdb;
|
$meta_query = $wpdb->prepare(
|
"SELECT term_id, meta_value FROM {$wpdb->termmeta}
|
WHERE meta_key = %s AND term_id IN (" . implode(',', array_fill(0, count($shop_ids), '%d')) . ")",
|
array_merge([BASE.'image'], $shop_ids)
|
);
|
$results = $wpdb->get_results($meta_query);
|
|
foreach ($results as $result) {
|
$shop_images[$result->term_id] = $result->meta_value;
|
}
|
}
|
|
// Format each item
|
foreach ($items as $item) {
|
$term_id = (int)$item->target_id;
|
$key = $item->type . '_' . $term_id;
|
|
// Skip if term doesn't exist
|
if (!isset($terms_by_id[$key])) {
|
continue;
|
}
|
|
$term = $terms_by_id[$key];
|
|
$formatted_item = [
|
'id' => $item->id ?? null,
|
'type' => str_replace(BASE, '', $item->type),
|
'target_id' => $term_id,
|
'date_added' => $item->date_added ?? current_time('mysql'),
|
'notes' => $item->notes ?? '',
|
'title' => $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}
|
WHERE owner_id = %d
|
AND action_user_id = %d
|
AND type = 'new_favourite'
|
AND target_id = %d
|
AND target_type = %s
|
AND created_at > DATE_SUB(%s, INTERVAL 30 DAY)",
|
$owner_id,
|
$user_id,
|
$target_id,
|
$type,
|
current_time('mysql')
|
));
|
|
if (empty($notifications)) {
|
continue;
|
}
|
|
// Delete the notifications
|
foreach ($notifications as $notification) {
|
$wpdb->delete(
|
$notifications_table,
|
['id' => $notification->id],
|
['%d']
|
);
|
}
|
|
// Invalidate notification cache for this user
|
if (method_exists(JVB()->notification(), 'clearNotificationCache')) {
|
JVB()->notification()->clearNotificationCache($owner_id);
|
}
|
}
|
} catch (Exception $e) {
|
// Log but continue
|
JVB()->error()->log(
|
'favourites',
|
'Error removing related notifications: ' . $e->getMessage(),
|
['type' => $type, 'target_id' => $target_id, 'user_id' => $user_id],
|
'warning'
|
);
|
}
|
}
|
|
/**
|
* Process favourite notes update with improved transaction handling
|
*
|
* @param int $user_id User ID
|
* @param array $data Operation data
|
* @return array Operation result
|
*/
|
protected function processNote(int $user_id, array $data):array
|
{
|
global $wpdb;
|
$result = [];
|
|
// Start transaction
|
$wpdb->query('START TRANSACTION');
|
|
try {
|
$IDs = explode(',', $data['target_id']);
|
$results = [];
|
foreach ($IDs as $ID) {
|
$target_id = absint($ID);
|
if ($target_id <= 0) {
|
throw new Exception('Invalid target ID');
|
}
|
$notes = sanitize_textarea_field($data['notes'] ?? '');
|
|
$table = $wpdb->prefix . BASE . 'favourites';
|
|
// Check if favourite exists
|
$favourite_id = $wpdb->get_var($wpdb->prepare(
|
"SELECT id FROM {$table}
|
WHERE user_id = %d AND target_id = %d",
|
$user_id, $target_id
|
));
|
|
if (!$favourite_id) {
|
$result[] = [
|
'success' => false,
|
'target_id' => $target_id,
|
'message' => __('No favourite id found...', 'jvb')
|
];
|
} else {
|
// Update existing favourite
|
$updated = $wpdb->update(
|
$table,
|
['notes' => $notes],
|
['id' => $favourite_id],
|
['%s'],
|
['%d']
|
);
|
|
if ($updated === false) {
|
throw new Exception($wpdb->last_error);
|
}
|
|
$result[] = [
|
'success' => true,
|
'action' => 'updated_notes',
|
'favourite_id' => $favourite_id,
|
'target_id' => $target_id
|
];
|
}
|
}
|
|
// If we got here, everything worked - commit the transaction
|
$wpdb->query('COMMIT');
|
|
return [
|
'success' => true,
|
'results' => $result
|
];
|
} catch (Exception $e) {
|
// Something went wrong, roll back changes
|
$wpdb->query('ROLLBACK');
|
|
JVB()->error()->log(
|
'favourites',
|
'Notes update failed: ' . $e->getMessage(),
|
[
|
'user_id' => $user_id,
|
'target_id' => $data['target_id'] ?? null
|
],
|
'error'
|
);
|
|
return [
|
'success' => false,
|
'result' => $e->getMessage(),
|
];
|
}
|
}
|
|
/**
|
* Process list creation
|
*
|
* @param int $user_id User ID
|
* @param array $data Operation data
|
* @return array Operation result
|
*/
|
protected function processListCreate(int $user_id, array $data):array
|
{
|
global $wpdb;
|
|
// Start transaction
|
$wpdb->query('START TRANSACTION');
|
|
try {
|
$name = sanitize_text_field($data['name']);
|
$description = sanitize_textarea_field($data['description'] ?? '');
|
$items = $data['items'] ?? [];
|
|
// Fire pre-create action
|
do_action('jvb_before_list_create', $user_id, $name, $description);
|
|
$lists_table = $wpdb->prefix . BASE . 'favourites_lists';
|
|
// Create the list
|
$inserted = $wpdb->insert(
|
$lists_table,
|
[
|
'user_id' => $user_id,
|
'name' => $name,
|
'description' => $description,
|
'created_at' => current_time('mysql'),
|
'updated_at' => current_time('mysql')
|
],
|
['%d', '%s', '%s', '%s', '%s']
|
);
|
|
if (!$inserted) {
|
throw new Exception($wpdb->last_error);
|
}
|
|
$list_id = $wpdb->insert_id;
|
|
// Add items if any, but limit batch size
|
$added_count = 0;
|
$batch_size = 50; // Process in batches of 50
|
|
if (!empty($items)) {
|
$item_batches = array_chunk($items, $batch_size);
|
|
foreach ($item_batches as $batch) {
|
$result = $this->addItemsToList($list_id, $batch, $user_id);
|
$batch_added = $result['added'];
|
$added_count += $batch_added;
|
|
// Give the server a small break between large batches
|
if (count($items) > $batch_size) {
|
usleep(50000); // 50ms pause
|
}
|
}
|
}
|
|
// Commit transaction
|
$wpdb->query('COMMIT');
|
|
|
// Fire post-create action
|
do_action('jvb_after_list_create', $user_id, $list_id, $name, $added_count);
|
|
return [
|
'success' => true,
|
'result' => [
|
'list_id' => $list_id,
|
'name' => $name,
|
'added_items' => $added_count,
|
'message' => sprintf('Created list "%s" with %d items', $name, $added_count)
|
]
|
];
|
} catch (Exception $e) {
|
// Rollback on error
|
$wpdb->query('ROLLBACK');
|
|
JVB()->error()->log(
|
'favourites',
|
'Error creating list: ' . $e->getMessage(),
|
['user_id' => $user_id, 'name' => $data['name'] ?? ''],
|
'error'
|
);
|
|
return [
|
'success' => false,
|
'result' => $e->getMessage()
|
];
|
}
|
}
|
|
|
|
/**
|
* Process adding items to a list
|
*
|
* @param int $user_id User ID
|
* @param array $data Operation data
|
* @return array Operation result
|
*/
|
protected function processAddToList(int $user_id, array $data): array
|
{
|
global $wpdb;
|
|
// Start transaction
|
$wpdb->query('START TRANSACTION');
|
|
try {
|
$list_ids = explode(',', $data['list_id']);
|
$items = $data['items'] ?? [];
|
|
if (empty($items)) {
|
throw new Exception('Items array is required');
|
}
|
|
$lists_table = $wpdb->prefix . BASE . 'favourites_lists';
|
$shares_table = $wpdb->prefix . BASE . 'favourites_list_shares';
|
|
$results = [];
|
$total_added = 0;
|
$all_errors = [];
|
|
// Process each list
|
foreach ($list_ids as $list_id) {
|
$list_id = (int) $list_id;
|
|
if (!$list_id) {
|
$all_errors[] = ['message' => 'Invalid list ID', 'list_id' => $list_id];
|
continue;
|
}
|
|
// Check permission - either owner or has edit permission
|
$has_permission = $wpdb->get_var($wpdb->prepare(
|
"SELECT 1 FROM {$lists_table} WHERE id = %d AND user_id = %d
|
UNION
|
SELECT 1 FROM {$shares_table}
|
WHERE list_id = %d AND user_id = %d AND permission_type = 'edit'
|
LIMIT 1",
|
$list_id,
|
$user_id,
|
$list_id,
|
$user_id
|
));
|
|
if (!$has_permission) {
|
$all_errors[] = [
|
'message' => 'You do not have permission to add items to this list',
|
'list_id' => $list_id
|
];
|
continue;
|
}
|
|
// Call the optimized helper method to add items
|
$add_result = $this->addItemsToList($list_id, $items, $user_id);
|
|
// Track results
|
$total_added += $add_result['added'];
|
|
if (!empty($add_result['errors'])) {
|
$all_errors = array_merge($all_errors, $add_result['errors']);
|
}
|
|
$results[] = [
|
'list_id' => $list_id,
|
'added_count' => $add_result['added']
|
];
|
|
}
|
|
// If we've had no successful additions and only errors, throw an exception
|
if ($total_added == 0 && !empty($all_errors)) {
|
throw new Exception('Failed to add any items: ' . json_encode($all_errors));
|
}
|
|
// Commit the transaction
|
$wpdb->query('COMMIT');
|
|
// Invalidate relevant caches
|
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,
|
jvbSignature()
|
);
|
|
return jvbMail($email, $subject, $message);
|
}
|
|
/**
|
* Accept a list share invitation, handling both registered and non-registered users
|
*
|
* @param string $token Invitation token
|
* @param string $email Email address of the invited user
|
* @param int|null $user_id Optional user ID if already registered
|
* @return array Result with success status and messages
|
*/
|
public function acceptListInvitation(string $token, string $email, ?int $user_id = null):array
|
{
|
if (!$token || !$email) {
|
return [
|
'success' => false,
|
'message' => 'Invalid invitation parameters'
|
];
|
}
|
|
try {
|
global $wpdb;
|
$shares_table = $wpdb->prefix . BASE . 'favourites_list_shares';
|
|
// Find the pending invitation
|
$invitation = $wpdb->get_row($wpdb->prepare(
|
"SELECT * FROM {$shares_table}
|
WHERE invitation_token = %s
|
AND email = %s
|
AND status = 'pending'",
|
$token,
|
$email
|
));
|
|
if (!$invitation) {
|
return [
|
'success' => false,
|
'message' => 'Invalid or expired invitation'
|
];
|
}
|
|
// If no user_id provided, check if a user with this email exists
|
if (!$user_id) {
|
$existing_user = get_user_by('email', $email);
|
|
if ($existing_user) {
|
$user_id = $existing_user->ID;
|
} else {
|
// No user account exists - create a registration URL with the token embedded
|
$registration_url = add_query_arg([
|
'action' => 'register',
|
'type' => 'favourites',
|
'list_token' => $token,
|
'email' => urlencode($email)
|
], wp_login_url());
|
|
return [
|
'success' => false,
|
'message' => 'You need to create an account to access this shared list',
|
'needs_registration' => true,
|
'registration_url' => $registration_url
|
];
|
}
|
}
|
|
// Get the list details for the response
|
$lists_table = $wpdb->prefix . BASE . 'favourites_lists';
|
$list = $wpdb->get_row($wpdb->prepare(
|
"SELECT l.*, u.display_name as owner_name
|
FROM {$lists_table} l
|
JOIN {$wpdb->users} u ON l.user_id = u.ID
|
WHERE l.id = %d",
|
$invitation->list_id
|
));
|
|
if (!$list) {
|
return [
|
'success' => false,
|
'message' => 'The shared list no longer exists'
|
];
|
}
|
|
// Update the invitation to accepted status
|
$updated = $wpdb->update(
|
$shares_table,
|
[
|
'status' => 'accepted',
|
'user_id' => $user_id,
|
'updated_at' => current_time('mysql')
|
],
|
['id' => $invitation->id],
|
['%s', '%d', '%s'],
|
['%d']
|
);
|
|
if ($updated === false) {
|
throw new Exception($wpdb->last_error);
|
}
|
|
// Notify the list owner
|
$user = get_userdata($user_id);
|
$display_name = $user ? $user->display_name : $email;
|
JVB()->notification()->addNotification(
|
$list->user_id, // Owner ID
|
'list_share_accepted',
|
$user_id, // Action user ID
|
sprintf('%s accepted your invitation to the list "%s"', $display_name, $list->name),
|
$invitation->list_id,
|
'favourites_list',
|
[
|
'list_id' => $invitation->list_id,
|
'list_name' => $list->name,
|
'user_id' => $user_id,
|
'email' => $email
|
]
|
);
|
|
return [
|
'success' => true,
|
'message' => 'List successfully shared with you',
|
'list_id' => $invitation->list_id,
|
'list_name' => $list->name,
|
'permission_type' => $invitation->permission_type
|
];
|
|
} catch (Exception $e) {
|
JVB()->error()->log(
|
'favourites',
|
'Error accepting list invitation: ' . $e->getMessage(),
|
['token' => $token, 'email' => $email],
|
'error'
|
);
|
|
return [
|
'success' => false,
|
'message' => $e->getMessage()
|
];
|
}
|
}
|
|
/**
|
* Get count of favourites for an item
|
*
|
* @param string $type Item type
|
* @param int $target_id Item ID
|
* @return int Favourite count
|
*/
|
protected function getFavouriteCount(string $type, int $target_id):int
|
{
|
try {
|
global $wpdb;
|
$table = $wpdb->prefix . BASE . 'favourites';
|
|
return (int)$wpdb->get_var($wpdb->prepare(
|
"SELECT COUNT(*) FROM {$table}
|
WHERE type = %s AND target_id = %d",
|
$type,
|
$target_id
|
));
|
} catch (Exception $e) {
|
return 0;
|
}
|
}
|
|
/**
|
* Update favourite count meta for an item
|
*
|
* @param string $type Item type
|
* @param int $target_id Item ID
|
*/
|
protected function updateFavouriteCount(string $type, int $target_id):void
|
{
|
if (!isset($this->valid_types[$type])) {
|
return;
|
}
|
try {
|
$config = $this->valid_types[$type];
|
$count = $this->getFavouriteCount($type, $target_id);
|
|
if ($config['table'] === 'post') {
|
update_post_meta($target_id, $config['count_meta_key'], $count);
|
} else {
|
update_term_meta($target_id, $config['count_meta_key'], $count);
|
}
|
} catch (Exception $e) {
|
// Log but continue
|
JVB()->error()->log(
|
'favourites',
|
'Error updating favourite count: ' . $e->getMessage(),
|
['type' => $type, 'target_id' => $target_id],
|
'warning'
|
);
|
}
|
}
|
|
/**
|
* Notify content owner of new favourite if configured
|
*
|
* @param string $type Content type
|
* @param int $target_id Content ID
|
* @param int $user_id User who favourited
|
* @return void
|
*/
|
protected function maybeNotifyOwner(string $type, int $target_id, int $user_id):void
|
{
|
// Skip if this type doesn't need owner notification
|
if (!array_key_exists($type, $this->valid_types) || !$this->valid_types[$type]['notify_owner']) {
|
return;
|
}
|
|
try {
|
$owner_ids = $this->getContentOwner($type, $target_id);
|
if ($owner_ids) {
|
$owner_ids = (is_array($owner_ids)) ? $owner_ids : [$owner_ids];
|
foreach ($owner_ids as $owner_id) {
|
if ($owner_id !== $user_id) {
|
$type_label = str_replace(BASE, '', $type);
|
JVB()->notification()->addNotification(
|
$owner_id,
|
'new_favourite',
|
$user_id, // Action user ID
|
sprintf('%s favourited your %s', jvbShareName($user_id), $type_label),
|
$target_id,
|
$type,
|
[
|
'favourite_user_id' => $user_id,
|
'content_type' => $type,
|
'content_id' => $target_id
|
]
|
);
|
}
|
}
|
}
|
} catch (Exception $e) {
|
// Log but continue
|
JVB()->error()->log(
|
'favourites',
|
'Error notifying owner: ' . $e->getMessage(),
|
['type' => $type, 'target_id' => $target_id, 'user_id' => $user_id],
|
'warning'
|
);
|
}
|
}
|
|
public function maybeAcceptListInvite(int $user_id, string $email, array $data):void
|
{
|
if (array_key_exists('list_token', $data) && !empty($data['list_token'])) {
|
$this->acceptListInvitation($data['list_token'], $email);
|
}
|
}
|
|
/**
|
* Get the owner ID for a content item
|
*
|
* @param string $type Content type
|
* @param int $target_id Content ID
|
* @return int|null Owner ID
|
*/
|
protected function getContentOwner(string $type, int $target_id):int|null
|
{
|
if (!array_key_exists($type, $this->valid_types)) {
|
return null;
|
}
|
|
try {
|
if ($this->valid_types[$type]['table'] === 'post') {
|
$post = get_post($target_id);
|
return $post ? $post->post_author : false;
|
} elseif ($type === BASE.'shop') {
|
return $this->getShopOwner($target_id);
|
}
|
} catch (Exception $e) {
|
return null;
|
}
|
|
return null;
|
}
|
|
/**
|
* Get the owner ID for a shop
|
*
|
* @param int $shop_id Shop term ID
|
* @return int|null Owner ID
|
*/
|
protected function getShopOwner(int $shop_id):array
|
{
|
// Get shop manager users
|
$owners = get_term_meta($shop_id, BASE.'owner', true);
|
$owners = explode(',', $owners);
|
$managers = get_term_meta($shop_id, BASE.'managers', true);
|
$managers = explode(',', $managers);
|
|
return array_merge($owners, $managers);
|
}
|
|
/**
|
* Maintenance method to clean up orphaned favourites
|
* Called by scheduled task
|
*/
|
/**
|
* Maintenance method to clean up orphaned favourites
|
* Scheduled action
|
* @return bool
|
*/
|
public function cleanupOrphanedFavourites():bool
|
{
|
try {
|
global $wpdb;
|
$table = $wpdb->prefix . BASE.'favourites';
|
|
// Delete favourites for non-existent users
|
$wpdb->query("
|
DELETE f FROM $table f
|
LEFT JOIN {$wpdb->users} u ON f.user_id = u.ID
|
WHERE u.ID IS NULL
|
");
|
|
// Delete favourites for non-existent posts
|
$post_types = array_map(function ($type) use ($wpdb) {
|
return $wpdb->prepare('%s', $type);
|
}, array_filter(array_keys($this->valid_types), function ($type) {
|
return $type === 'content';
|
}));
|
|
if (!empty($post_types)) {
|
$post_types_list = implode(',', $post_types);
|
|
$wpdb->query("
|
DELETE f FROM $table f
|
LEFT JOIN {$wpdb->posts} p ON f.target_id = p.ID
|
WHERE f.type IN ($post_types_list)
|
AND p.ID IS NULL
|
");
|
}
|
|
// Delete favourites for non-existent terms
|
$term_types = array_map(function ($type) use ($wpdb) {
|
return $wpdb->prepare('%s', $type);
|
}, array_filter(array_keys($this->valid_types), function ($type) {
|
return $type === 'tax';
|
}));
|
|
if (!empty($term_types)) {
|
$term_types_list = implode(',', $term_types);
|
|
$wpdb->query("
|
DELETE f FROM $table f
|
LEFT JOIN {$wpdb->terms} t ON f.target_id = t.term_id
|
WHERE f.type IN ($term_types_list)
|
AND t.term_id IS NULL
|
");
|
}
|
|
return true;
|
} catch (Exception $e) {
|
JVB()->error()->log(
|
'favourites',
|
'Error during cleanup: ' . $e->getMessage(),
|
[],
|
'error'
|
);
|
|
return false;
|
}
|
}
|
|
|
/**
|
* Create a standardized error response
|
*
|
* @param string $code Error code
|
* @param string $message Error message
|
* @param int $status HTTP status code
|
* @param array $additional_data Additional context data
|
* @return WP_REST_Response Formatted error
|
*/
|
protected function createErrorResponse(string $code, string $message, int $status = 400, array $additional_data = []):WP_REST_Response
|
{
|
$error = new WP_Error(
|
$code,
|
__($message, 'jvb'),
|
['status' => $status]
|
);
|
|
if (!empty($additional_data)) {
|
$error->add_data($additional_data, 'additional_data');
|
}
|
|
// Log error using central error handling system
|
JVB()->error()->log(
|
'favourites',
|
$message,
|
$additional_data,
|
'error'
|
);
|
return new WP_REST_Response([
|
'success' => false,
|
'code' => $code,
|
'message' => __($message, 'jvb'),
|
'status' => $status
|
]);
|
}
|
|
|
/**
|
* Handle queued operations for favourites
|
*
|
* @param WP_Error|array $result Current result
|
* @param object $operation Operation from queue
|
* @param array $data Current Data
|
* @return array|WP_Error Processing result
|
*/
|
public function processOperation(WP_Error|array $result, object $operation, array $data):array|WP_Error
|
{
|
// Check if this is a favourites-related operation type
|
$favourites_operations = [
|
'favourites_batch',
|
'favourite_notes',
|
'favourite_list_create',
|
'favourite_list_update',
|
'favourite_list_delete',
|
'favourite_list_add',
|
'favourite_list_remove',
|
'favourite_list_share',
|
'favourite_list_unshare'
|
];
|
|
if (!in_array($operation->type, $favourites_operations)) {
|
return $result; // Not our operation, pass through
|
}
|
|
try {
|
$user_id = $operation->user_id;
|
|
switch ($operation->type) {
|
case 'favourites_batch':
|
$response = $this->processBatches($user_id, $data);
|
CacheManager::invalidateGroup($this->cache_name);
|
return $response;
|
|
case 'favourite_notes':
|
$response = $this->processNote($user_id, $data);
|
CacheManager::invalidateGroup($this->cache_name);
|
return $response;
|
|
case 'favourite_list_create':
|
$response = $this->processListCreate($user_id, $data);
|
CacheManager::invalidateGroup('favourites_lists');
|
return $response;
|
|
case 'favourite_list_update':
|
$response = $this->processUpdateList($user_id, $data);
|
CacheManager::invalidateGroup('favourites_lists');
|
return $response;
|
|
case 'favourite_list_delete':
|
$response = $this->processListDeletion($user_id, $data);
|
CacheManager::invalidateGroup('favourites_lists');
|
return $response;
|
|
case 'favourite_list_add':
|
$response = $this->processAddToList($user_id, $data);
|
CacheManager::invalidateGroup('favourites_lists');
|
return $response;
|
|
case 'favourite_list_remove':
|
$response = $this->removeFromList($user_id, $data);
|
CacheManager::invalidateGroup('favourites_lists');
|
return $response;
|
|
case 'favourite_list_share':
|
$response = $this->shareList($user_id, $data);
|
CacheManager::invalidateGroup('favourites_lists_shares');
|
return $response;
|
|
case 'favourite_list_unshare':
|
$response = $this->unshareList($user_id, $data);
|
CacheManager::invalidateGroup('favourites_lists_shares');
|
return $response;
|
|
default:
|
return $result;
|
}
|
} catch (Exception $e) {
|
JVB()->error()->log(
|
'[FavouritesRoutes]:processOperation',
|
'Failed to process queued operation: ' . $e->getMessage(),
|
[
|
'operation_id' => $operation->id,
|
'type' => $operation->type,
|
'user_id' => $operation->user_id
|
],
|
'error'
|
);
|
|
return $result;
|
}
|
}
|
|
/**
|
* Clean up favourites when a post is deleted
|
*
|
* @param int $post_id The ID of the post being deleted
|
*/
|
public function cleanupPostFavourites(int $post_id)
|
{
|
try {
|
global $wpdb;
|
$table = $wpdb->prefix . BASE . 'favourites';
|
$type = get_post_type($post_id);
|
|
if (!$type) {
|
return;
|
}
|
|
$type = BASE . $type;
|
|
if (!isset($this->valid_types[$type])) {
|
return;
|
}
|
|
// Delete favourites for this post
|
$wpdb->delete(
|
$table,
|
[
|
'type' => $type,
|
'target_id' => $post_id
|
],
|
['%s', '%d']
|
);
|
|
// Also remove from list items
|
$items_table = $wpdb->prefix . BASE . 'favourites_list_items';
|
$wpdb->delete(
|
$items_table,
|
[
|
'item_type' => $type,
|
'item_id' => $post_id
|
],
|
['%s', '%d']
|
);
|
|
} catch (Exception $e) {
|
JVB()->error()->log(
|
'favourites',
|
'Error cleaning up favourites for deleted post: ' . $e->getMessage(),
|
['post_id' => $post_id],
|
'error'
|
);
|
}
|
}
|
|
/**
|
* Clean up favourites when a term is deleted
|
*
|
* @param int $term_id The ID of the deleted term
|
* @param int $tt_id Term taxonomy ID
|
* @param string $taxonomy The taxonomy slug
|
*/
|
public function cleanupTermFavourites(int $term_id, int $tt_id, string $taxonomy)
|
{
|
try {
|
if (!isset($this->valid_types[$taxonomy])) {
|
return;
|
}
|
|
global $wpdb;
|
$table = $wpdb->prefix . BASE . 'favourites';
|
|
// Delete favourites for this term
|
$wpdb->delete(
|
$table,
|
[
|
'type' => $taxonomy,
|
'target_id' => $term_id
|
],
|
['%s', '%d']
|
);
|
|
// Also remove from list items
|
$items_table = $wpdb->prefix . BASE . 'favourites_list_items';
|
$wpdb->delete(
|
$items_table,
|
[
|
'item_type' => $taxonomy,
|
'item_id' => $term_id
|
],
|
['%s', '%d']
|
);
|
|
// Clean up list stats
|
$stats_table = $wpdb->prefix . BASE . 'favourites_list_stats';
|
$wpdb->delete(
|
$stats_table,
|
[
|
'item_type' => $taxonomy,
|
'item_id' => $term_id
|
],
|
['%s', '%d']
|
);
|
|
} catch (Exception $e) {
|
JVB()->error()->log(
|
'favourites',
|
'Error cleaning up favourites for deleted term: ' . $e->getMessage(),
|
['term_id' => $term_id, 'taxonomy' => $taxonomy],
|
'error'
|
);
|
}
|
}
|
}
|