<?php
|
namespace JVBase\rest\routes;
|
|
use JVBase\managers\Cache;
|
use JVBase\managers\CustomTable;
|
use JVBase\rest\PermissionHandler;
|
use JVBase\rest\Response;
|
use JVBase\rest\Rest;
|
use JVBase\rest\Route;
|
use WP_REST_Request;
|
use WP_REST_Response;
|
use Exception;
|
|
if (!defined('ABSPATH')) {
|
exit;
|
}
|
|
/**
|
* TODO: Extract business logic into a Favourites.php manager class
|
*/
|
class FavouritesRoutes extends Rest
|
{
|
protected array $valid_types;
|
protected Cache $listsCache;
|
protected Cache $sharedListsCache;
|
protected Cache $favouritesCache;
|
protected CustomTable $favourites;
|
protected CustomTable $lists;
|
protected CustomTable $listItems;
|
protected CustomTable $listShares;
|
|
public function __construct()
|
{
|
$this->cacheName = 'favourites';
|
$this->cacheTtl = HOUR_IN_SECONDS;
|
parent::__construct();
|
|
// Set up cache connections
|
$this->cache->connect('post')->connect('user')->connect('taxonomy');
|
$this->listsCache = Cache::for('lists')->connect('favourites', true);
|
$this->sharedListsCache = Cache::for('sharedLists')->connect('favourites', true);
|
$this->favouritesCache = Cache::for('allFavourites')->connect('favourites', true);
|
|
$this->valid_types = array_keys(array_merge(JVB_CONTENT, JVB_TAXONOMY));
|
|
// Initialize CustomTable instances
|
$this->favourites = CustomTable::for('favourites');
|
$this->lists = CustomTable::for('favourites_lists');
|
$this->listItems = CustomTable::for('favourites_list_items');
|
$this->listShares = CustomTable::for('favourites_list_shares');
|
|
// Register hooks
|
add_filter(BASE.'handle_bulk_operation', [$this, 'processOperation'], 10, 3);
|
add_action('before_delete_post', [$this, 'cleanupPostFavourites']);
|
add_action('delete_term', [$this, 'cleanupTermFavourites'], 10, 3);
|
add_action('jvbUserRegistered', [$this, 'maybeAcceptListInvite'], 10, 3);
|
add_action('jvb_cleanupOrphanedFavourites', [$this, 'cleanupOrphanedFavourites']);
|
}
|
|
public function registerRoutes(): void
|
{
|
// Favourites endpoints
|
Route::for('favourites')
|
->get([$this, 'getFavourites'])
|
->args([
|
'user' => 'integer|required',
|
'type' => 'string',
|
'include_all' => 'boolean',
|
])
|
->auth(PermissionHandler::combine(['user', ['actionNonce' => 'favourites-']]))
|
->rateLimit(30)
|
->post([$this, 'handleFavourite'])
|
->args([
|
'user' => 'integer|required',
|
'id' => 'string|required',
|
'action' => 'string|required|enum:add,remove,toggle,batch,note',
|
'type' => 'string',
|
'target_id' => 'integer',
|
'items' => 'array',
|
'notes' => 'string',
|
])
|
->auth(PermissionHandler::combine(['user', ['actionNonce' => 'favourites-']]))
|
->rateLimit(30)
|
->register();
|
|
// Lists endpoints
|
Route::for('favourites/lists')
|
->get([$this, 'getLists'])
|
->args(['user' => 'integer|required'])
|
->auth(PermissionHandler::combine(['user', ['actionNonce' => 'favourites-']]))
|
->rateLimit(30)
|
->post([$this, 'handleList'])
|
->args([
|
'user' => 'integer|required',
|
'id' => 'string|required',
|
'action' => 'string|required|enum:create,update,delete,share,unshare,add_items,remove_items',
|
'list_id' => 'integer',
|
'name' => 'string',
|
'items' => 'array',
|
])
|
->auth(PermissionHandler::combine(['user', ['actionNonce' => 'favourites-']]))
|
->rateLimit(20)
|
->register();
|
|
// Favourite counts
|
Route::for('favourites/counts')
|
->get([$this, 'getFavouriteCounts'])
|
->args(['user' => 'integer|required'])
|
->auth(PermissionHandler::combine(['user', ['actionNonce' => 'favourites-']]))
|
->register();
|
}
|
|
/**
|
* Get user's favourites with optional filtering
|
*/
|
public function getFavourites(WP_REST_Request $request): WP_REST_Response
|
{
|
$user_id = absint($request->get_param('user'));
|
|
if (!$this->userCheck($user_id)) {
|
return $this->unauthorized();
|
}
|
|
$args = $this->buildParams($request);
|
$key = $this->cache->generateKey($args);
|
|
// Check cache headers
|
$cache_check = $this->checkHeaders($request, $key);
|
if ($cache_check) {
|
return $cache_check;
|
}
|
|
if (count($args) === 1 || ($request->get_param('include_all') === true)) {
|
$result = $this->getAllFavourites($user_id);
|
} else {
|
$result = $this->cache->remember($key, function() use ($args) {
|
return $this->getFilteredFavourites($args);
|
});
|
}
|
|
return $this->addCacheHeaders(Response::success($result));
|
}
|
|
/**
|
* Get filtered favourites using CustomTable fluent interface
|
*/
|
protected function getFilteredFavourites(array $args): array
|
{
|
try {
|
// Build base query
|
$query = $this->favourites->where(['user_id' => $args['user']]);
|
|
// Add type filter if specified
|
if (!empty($args['content']) && $args['content'] !== 'all') {
|
$query = $this->favourites->where([
|
'user_id' => $args['user'],
|
'type' => BASE . $args['content']
|
]);
|
}
|
|
// Apply ordering and pagination
|
$orderby = in_array($args['orderby'] ?? 'date_added', ['date_added', 'type'])
|
? $args['orderby']
|
: 'date_added';
|
$order = in_array(strtoupper($args['order'] ?? 'DESC'), ['ASC', 'DESC'])
|
? strtoupper($args['order'])
|
: 'DESC';
|
|
$favourites = $query
|
->orderBy($orderby, $order)
|
->limit(100, ($args['page'] - 1) * 100)
|
->getResults();
|
|
// Get total count
|
$count_query = $this->favourites->where(['user_id' => $args['user']]);
|
if (!empty($args['content']) && $args['content'] !== 'all') {
|
$count_query->where(['type' => BASE . $args['content']]);
|
}
|
$total_items = $count_query->countResults();
|
|
return [
|
'items' => $this->formatItems($favourites),
|
'has_more' => ($args['page'] * 100) < $total_items,
|
'total' => $total_items,
|
'success' => true,
|
];
|
|
} catch (Exception $e) {
|
$this->logError('getFilteredFavourites', [
|
'error' => $e->getMessage(),
|
'args' => $args
|
]);
|
|
return [
|
'success' => false,
|
'items' => [],
|
'total' => 0,
|
'has_more' => false
|
];
|
}
|
}
|
|
/**
|
* Get all user's favourites organized by content type
|
*/
|
protected function getAllFavourites(int $user_id): array
|
{
|
return $this->cache->remember($user_id, function() use ($user_id) {
|
try {
|
$favourites = $this->favourites
|
->where(['user_id' => $user_id])
|
->getResults();
|
|
$by_type = [];
|
foreach ($favourites as $fav) {
|
$type = str_replace(BASE, '', $fav->type);
|
if (!isset($by_type[$type])) {
|
$by_type[$type] = [];
|
}
|
$by_type[$type][] = (int)$fav->target_id;
|
}
|
|
return [
|
'success' => true,
|
'items' => $by_type,
|
'has_more' => false,
|
];
|
|
} catch (Exception $e) {
|
$this->logError('getAllFavourites', [
|
'error' => $e->getMessage(),
|
'user_id' => $user_id
|
]);
|
|
return [
|
'success' => false,
|
'items' => [],
|
];
|
}
|
});
|
}
|
|
/**
|
* Handle favourite operations
|
*/
|
public function handleFavourite(WP_REST_Request $request): WP_REST_Response
|
{
|
$user_id = absint($request->get_param('user'));
|
$operation_id = sanitize_text_field($request->get_param('id'));
|
$action = sanitize_text_field($request->get_param('action'));
|
|
if (!$this->userCheck($user_id)) {
|
return $this->unauthorized();
|
}
|
|
$data = [
|
'action' => $action,
|
'type' => sanitize_text_field($request->get_param('type') ?? ''),
|
'target_id' => absint($request->get_param('target_id') ?? 0),
|
'items' => $request->get_param('items') ?? [],
|
'notes' => sanitize_textarea_field($request->get_param('notes') ?? ''),
|
];
|
|
JVB()->queue()->queueOperation(
|
'favourite_' . $action,
|
$user_id,
|
$data,
|
[
|
'operation_id' => $operation_id,
|
'priority' => 'high',
|
]
|
);
|
|
return $this->queued($operation_id);
|
}
|
|
/**
|
* Get user's favourite lists
|
*/
|
public function getLists(WP_REST_Request $request): WP_REST_Response
|
{
|
$user_id = absint($request->get_param('user'));
|
|
if (!$this->userCheck($user_id)) {
|
return $this->unauthorized();
|
}
|
|
$params = ['user' => $user_id];
|
if ($request->get_param('id')) {
|
$params['list'] = sanitize_text_field($request->get_param('id'));
|
}
|
|
$key = $this->listsCache->generateKey($params);
|
|
// Check cache headers
|
$cache_check = $this->checkHeaders($request, $key);
|
if ($cache_check) {
|
return $cache_check;
|
}
|
|
$list_id = $request->get_param('id');
|
$response = $list_id
|
? $this->getListDetails($list_id, $user_id)
|
: $this->getAvailableLists($user_id);
|
|
return $this->addCacheHeaders(Response::success($response));
|
}
|
|
/**
|
* Get lists available to a user using CustomTable
|
*/
|
protected function getAvailableLists(int $user_id, bool $include_shared = true): array
|
{
|
if (!$this->checkUser($user_id)) {
|
return [];
|
}
|
|
$cache = $include_shared ? $this->sharedListsCache : $this->listsCache;
|
|
return $cache->remember($user_id, function() use ($user_id, $include_shared) {
|
try {
|
// Get owned lists
|
$owned = $this->lists
|
->where(['user_id' => $user_id])
|
->orderBy('created_at', 'DESC')
|
->getResults(ARRAY_A);
|
|
// Add item counts
|
foreach ($owned as &$list) {
|
$list['item_count'] = $this->listItems
|
->where(['list_id' => $list['id']])
|
->countResults();
|
$list['is_owner'] = true;
|
$list['is_shared'] = false;
|
}
|
|
if (!$include_shared) {
|
return [
|
'success' => true,
|
'lists' => $owned
|
];
|
}
|
|
// Get shared lists
|
$shares = $this->listShares
|
->where(['user_id' => $user_id, 'status' => 'accepted'])
|
->getResults();
|
|
$shared_lists = [];
|
foreach ($shares as $share) {
|
$list = $this->lists
|
->where(['id' => $share->list_id])
|
->first(ARRAY_A);
|
|
if ($list) {
|
$owner = get_userdata($list['user_id']);
|
$list['owner_name'] = $owner ? $owner->display_name : 'Unknown';
|
$list['item_count'] = $this->listItems
|
->where(['list_id' => $list['id']])
|
->countResults();
|
$list['permission_type'] = $share->permission_type;
|
$list['is_owner'] = false;
|
$list['is_shared'] = true;
|
|
$shared_lists[] = $list;
|
}
|
}
|
|
return [
|
'success' => true,
|
'lists' => [
|
'owned' => $owned,
|
'shared' => $shared_lists
|
]
|
];
|
|
} catch (Exception $e) {
|
$this->logError('getAvailableLists', [
|
'error' => $e->getMessage(),
|
'user_id' => $user_id
|
]);
|
|
return [];
|
}
|
});
|
}
|
|
/**
|
* Get favourite counts by type
|
*/
|
public function getFavouriteCounts(WP_REST_Request $request): WP_REST_Response
|
{
|
$user_id = absint($request->get_param('user'));
|
|
if (!$this->userCheck($user_id)) {
|
return $this->unauthorized();
|
}
|
|
$key = "counts_{$user_id}";
|
|
$counts = $this->cache->remember($key, function() use ($user_id) {
|
try {
|
// Get counts grouped by type using raw query
|
$results = $this->favourites->queryResults(
|
"SELECT type, COUNT(*) as count FROM {table} WHERE user_id = %d GROUP BY type",
|
[$user_id],
|
OBJECT_K
|
);
|
|
$all_counts = array_fill_keys(
|
array_map(fn($type) => str_replace(BASE, '', $type), array_keys($this->valid_types)),
|
0
|
);
|
|
foreach ($results as $type => $data) {
|
$type_key = str_replace(BASE, '', $type);
|
$all_counts[$type_key] = (int)$data->count;
|
}
|
|
return $all_counts;
|
|
} catch (Exception $e) {
|
$this->logError('getFavouriteCounts', [
|
'error' => $e->getMessage(),
|
'user_id' => $user_id
|
]);
|
|
return array_fill_keys(array_keys($this->valid_types), 0);
|
}
|
});
|
|
return Response::success(['counts' => $counts]);
|
}
|
|
/**
|
* Process favourite operations using transactions
|
*/
|
public function processOperation($result, object $operation, array $data)
|
{
|
$action_map = [
|
'favourite_add' => 'addFavourite',
|
'favourite_remove' => 'removeFavourite',
|
'favourite_batch' => 'batchFavourites',
|
'favourite_note' => 'processNote',
|
'favourite_list_create' => 'createList',
|
'favourite_list_update' => 'updateList',
|
'favourite_list_delete' => 'deleteList',
|
'favourite_list_add_items' => 'addToList',
|
'favourite_list_remove_items' => 'removeFromList',
|
'favourite_list_share' => 'shareList',
|
'favourite_list_unshare' => 'unshareList',
|
];
|
|
if (!isset($action_map[$operation->type])) {
|
return $result;
|
}
|
|
try {
|
$method = $action_map[$operation->type];
|
$response = $this->$method($operation->user_id, $data);
|
|
// Clear cache on success
|
if ($response['success'] ?? false) {
|
Cache::invalidateItem('favourites', $operation->user_id);
|
$this->listsCache->flush();
|
$this->sharedListsCache->flush();
|
}
|
|
return $response;
|
} catch (Exception $e) {
|
$this->logError('processOperation', [
|
'error' => $e->getMessage(),
|
'operation_id' => $operation->id,
|
'type' => $operation->type
|
]);
|
|
return [
|
'success' => false,
|
'result' => $e->getMessage()
|
];
|
}
|
}
|
|
/**
|
* Add a favourite using findOrCreate pattern
|
*/
|
protected function addFavourite(int $user_id, array $data): array
|
{
|
$type = $data['type'];
|
$target_id = $data['target_id'];
|
|
if (!str_starts_with($type, BASE)) {
|
$type = BASE . $type;
|
}
|
|
if (!isset($this->valid_types[$type])) {
|
return ['success' => false, 'result' => 'Invalid type'];
|
}
|
|
// Use findOrCreate pattern
|
$result = $this->favourites->findOrCreate([
|
'user_id' => $user_id,
|
'type' => $type,
|
'target_id' => $target_id
|
]);
|
|
if ($result['created']) {
|
$this->updateFavouriteCount($type, $target_id);
|
$this->maybeNotifyOwner($type, $target_id, $user_id);
|
}
|
|
return [
|
'success' => true,
|
'result' => [
|
'action' => $result['created'] ? 'added' : 'already_exists',
|
'favourite_id' => $result['id'],
|
'count' => $this->getFavouriteCount($type, $target_id)
|
]
|
];
|
}
|
|
/**
|
* Remove a favourite
|
*/
|
protected function removeFavourite(int $user_id, array $data): array
|
{
|
$type = $data['type'];
|
$target_id = $data['target_id'];
|
|
if (!str_starts_with($type, BASE)) {
|
$type = BASE . $type;
|
}
|
|
$deleted = $this->favourites->where([
|
'user_id' => $user_id,
|
'type' => $type,
|
'target_id' => $target_id
|
])->deleteResults();
|
|
if ($deleted) {
|
$this->updateFavouriteCount($type, $target_id);
|
$this->removeRelatedNotifications($type, $target_id, $user_id);
|
}
|
|
return [
|
'success' => true,
|
'result' => [
|
'action' => $deleted ? 'removed' : 'not_found',
|
'count' => $this->getFavouriteCount($type, $target_id)
|
]
|
];
|
}
|
|
/**
|
* Batch favourites using transaction
|
*/
|
protected function batchFavourites(int $user_id, array $data): array
|
{
|
$items = $data['items'] ?? [];
|
|
if (empty($items)) {
|
return ['success' => false, 'result' => 'No items provided'];
|
}
|
|
return $this->favourites->transaction(function($table) use ($user_id, $items) {
|
$results = [
|
'added' => 0,
|
'removed' => 0,
|
'errors' => []
|
];
|
|
foreach ($items as $item) {
|
try {
|
$type = BASE . ($item['type'] ?? '');
|
$target_id = absint($item['target_id'] ?? 0);
|
$action = $item['action'] ?? 'add';
|
|
if ($action === 'add') {
|
$result = $table->findOrCreate([
|
'user_id' => $user_id,
|
'type' => $type,
|
'target_id' => $target_id
|
]);
|
if ($result['created']) $results['added']++;
|
} else {
|
$deleted = $table->where([
|
'user_id' => $user_id,
|
'type' => $type,
|
'target_id' => $target_id
|
])->deleteResults();
|
if ($deleted) $results['removed']++;
|
}
|
|
$this->updateFavouriteCount($type, $target_id);
|
} catch (Exception $e) {
|
$results['errors'][] = $item['target_id'] ?? 'unknown';
|
}
|
}
|
|
return [
|
'success' => true,
|
'result' => $results
|
];
|
});
|
}
|
|
/**
|
* Create a list using transaction
|
*/
|
protected function createList(int $user_id, array $data): array
|
{
|
$name = sanitize_text_field($data['name'] ?? 'Untitled List');
|
$description = sanitize_textarea_field($data['description'] ?? '');
|
$items = $data['items'] ?? [];
|
|
return $this->lists->transaction(function($table) use ($user_id, $name, $description, $items) {
|
$list_id = $table->create([
|
'user_id' => $user_id,
|
'name' => $name,
|
'description' => $description,
|
]);
|
|
if (!$list_id) {
|
throw new Exception('Failed to create list');
|
}
|
|
$added_count = 0;
|
if (!empty($items)) {
|
$result = $this->addItemsToList($list_id, $items, $user_id);
|
$added_count = $result['added'];
|
}
|
|
return [
|
'success' => true,
|
'result' => [
|
'list_id' => $list_id,
|
'name' => $name,
|
'added_items' => $added_count,
|
]
|
];
|
});
|
}
|
|
/**
|
* Add items to list with bulk insert
|
*/
|
protected function addItemsToList(int $list_id, array $items, int $user_id): array
|
{
|
if (empty($items)) {
|
return ['added' => 0, 'errors' => []];
|
}
|
|
$added = 0;
|
$errors = [];
|
|
try {
|
// Group items by type
|
$items_by_type = [];
|
foreach ($items as $item) {
|
if (empty($item['type']) || !isset($item['target_id'])) {
|
$errors[] = ['message' => 'Item missing type or target_id', 'item' => $item];
|
continue;
|
}
|
|
$type = str_starts_with($item['type'], BASE)
|
? $item['type']
|
: BASE . $item['type'];
|
|
if (!isset($items_by_type[$type])) {
|
$items_by_type[$type] = [];
|
}
|
|
$items_by_type[$type][] = absint($item['target_id']);
|
}
|
|
foreach ($items_by_type as $type => $target_ids) {
|
// Get existing items to avoid duplicates
|
$existing = $this->listItems
|
->where(['list_id' => $list_id, 'item_type' => $type])
|
->getResults();
|
|
$existing_map = [];
|
foreach ($existing as $item) {
|
$existing_map[$item->item_id] = true;
|
}
|
|
// Get favourite IDs in bulk
|
$favs = $this->favourites
|
->where(['user_id' => $user_id, 'type' => $type])
|
->getResults();
|
|
$fav_map = [];
|
foreach ($favs as $fav) {
|
$fav_map[$fav->target_id] = $fav->id;
|
}
|
|
// Prepare items for bulk insert
|
$to_insert = [];
|
foreach ($target_ids as $target_id) {
|
if (isset($existing_map[$target_id])) {
|
continue;
|
}
|
|
$to_insert[] = [
|
'list_id' => $list_id,
|
'item_type' => $type,
|
'item_id' => $target_id,
|
'favourite_id' => $fav_map[$target_id] ?? null
|
];
|
}
|
|
// Bulk insert
|
if (!empty($to_insert)) {
|
$columns = ['list_id', 'item_type', 'item_id', 'favourite_id'];
|
$result = $this->listItems->bulkInsert($to_insert, $columns);
|
$added += $result;
|
}
|
}
|
|
// Update list timestamp
|
$this->lists->where(['id' => $list_id])->updateResults([]);
|
|
return ['added' => $added, 'errors' => $errors];
|
|
} catch (Exception $e) {
|
$this->logError('addItemsToList', [
|
'error' => $e->getMessage(),
|
'user_id' => $user_id,
|
'list_id' => $list_id,
|
]);
|
|
$errors[] = ['message' => $e->getMessage()];
|
return ['added' => 0, 'errors' => $errors];
|
}
|
}
|
|
/**
|
* Clean up favourites when a post is deleted
|
*/
|
public function cleanupPostFavourites(int $post_id): void
|
{
|
try {
|
$type = get_post_type($post_id);
|
if (!$type) return;
|
|
$type = BASE . $type;
|
|
// Delete using fluent interface
|
$this->favourites->where([
|
'type' => $type,
|
'target_id' => $post_id
|
])->deleteResults();
|
|
$this->listItems->where([
|
'item_type' => $type,
|
'item_id' => $post_id
|
])->deleteResults();
|
|
} catch (Exception $e) {
|
$this->logError('cleanupPostFavourites', [
|
'error' => $e->getMessage(),
|
'post_id' => $post_id
|
]);
|
}
|
}
|
|
/**
|
* Clean up favourites when a term is deleted
|
*/
|
public function cleanupTermFavourites(int $term_id, int $tt_id, string $taxonomy): void
|
{
|
try {
|
if (!isset($this->valid_types[$taxonomy])) {
|
return;
|
}
|
|
// Delete using fluent interface
|
$this->favourites->where([
|
'type' => $taxonomy,
|
'target_id' => $term_id
|
])->deleteResults();
|
|
$this->listItems->where([
|
'item_type' => $taxonomy,
|
'item_id' => $term_id
|
])->deleteResults();
|
|
} catch (Exception $e) {
|
$this->logError('cleanupTermFavourites', [
|
'error' => $e->getMessage(),
|
'term_id' => $term_id,
|
'taxonomy' => $taxonomy
|
]);
|
}
|
}
|
|
/**
|
* Cleanup orphaned favourites using CustomTable query method
|
*/
|
public function cleanupOrphanedFavourites(): bool
|
{
|
try {
|
// Delete favourites for non-existent users
|
$this->favourites->query(
|
"DELETE f FROM {table} f
|
LEFT JOIN {$GLOBALS['wpdb']->users} u ON f.user_id = u.ID
|
WHERE u.ID IS NULL"
|
);
|
|
// Delete favourites for non-existent posts
|
$post_types = array_filter(
|
array_keys($this->valid_types),
|
fn($type) => $this->valid_types[$type]['table'] === 'post'
|
);
|
|
foreach ($post_types as $type) {
|
$this->favourites->query(
|
"DELETE f FROM {table} f
|
LEFT JOIN {$GLOBALS['wpdb']->posts} p ON f.target_id = p.ID
|
WHERE f.type = %s AND p.ID IS NULL",
|
[$type]
|
);
|
}
|
|
return true;
|
|
} catch (Exception $e) {
|
$this->logError('cleanupOrphanedFavourites', [
|
'error' => $e->getMessage()
|
]);
|
|
return false;
|
}
|
}
|
|
/**
|
* Helper methods
|
*/
|
protected function buildParams(WP_REST_Request $request): array
|
{
|
$data = $request->get_params();
|
$args = ['user' => absint($data['user'])];
|
|
if (!array_key_exists('page', $data)) {
|
return $args;
|
}
|
|
$args = array_merge($args, [
|
'page' => max(1, absint($data['page'] ?? 1)),
|
'content' => $this->checkContent($data['content'] ?? 'all')
|
]);
|
|
return $this->applyOrderFilters($args, $data);
|
}
|
|
|
/**
|
* Batch format a collection of favourite items
|
*
|
* @param array $items Collection of favourite items
|
* @return array Formatted items
|
*/
|
protected function formatItems(array $items):array
|
{
|
if (empty($items)) {
|
return [];
|
}
|
|
// Group items by type to reduce queries
|
$items_by_type = [];
|
foreach ($items as $item) {
|
if (!isset($item->type) || !isset($item->target_id)) {
|
continue;
|
}
|
|
// Verify item type is valid
|
if (!isset($this->valid_types[$item->type])) {
|
continue;
|
}
|
|
$type = $item->type;
|
if (!isset($items_by_type[$type])) {
|
$items_by_type[$type] = [];
|
}
|
$items_by_type[$type][] = $item;
|
}
|
|
$formatted = [];
|
|
// Process post-type items in batches
|
foreach ($items_by_type as $type => $type_items) {
|
$config = $this->valid_types[$type];
|
|
if ($config['table'] === 'post') {
|
$formatted = array_merge($formatted, $this->formatPostFavourites($type_items));
|
} else {
|
$formatted = array_merge($formatted, $this->formatTermFavourites($type_items));
|
}
|
}
|
|
return $formatted;
|
}
|
protected function getFavouriteCount(string $type, int $target_id): int
|
{
|
return $this->favourites->where([
|
'type' => $type,
|
'target_id' => $target_id
|
])->countResults();
|
}
|
|
protected function updateFavouriteCount(string $type, int $target_id): void
|
{
|
$count = $this->getFavouriteCount($type, $target_id);
|
|
if (str_contains($type, 'post') || in_array($type, array_keys($this->valid_types))) {
|
update_post_meta($target_id, BASE.'favourite_count', $count);
|
} else {
|
update_term_meta($target_id, BASE.'favourite_count', $count);
|
}
|
}
|
|
/**
|
* Notify content owner of new favourite if configured
|
*
|
* @param string $type Content type
|
* @param int $target_id Content ID
|
* @param int $user_id User who favourited
|
* @return void
|
*/
|
protected function maybeNotifyOwner(string $type, int $target_id, int $user_id): void
|
{
|
try {
|
$owner_id = $this->getContentOwner($type, $target_id);
|
|
if ($owner_id && $owner_id !== $user_id) {
|
JVB()->notification()->addNotification(
|
$owner_id,
|
'new_favourite',
|
[
|
'user_id' => $user_id,
|
'type' => $type,
|
'target_id' => $target_id
|
]
|
);
|
}
|
} catch (Exception $e) {
|
// Silent fail - notifications are non-critical
|
}
|
}
|
|
/**
|
* Remove any existing notifications about a favorite action
|
*
|
* @param int $user_id User who removed the favorite
|
* @param string $type Content type
|
* @param int $target_id Content ID
|
* @return void
|
*/
|
protected function removeRelatedNotifications(int $user_id, string $type, int $target_id):void
|
{
|
try {
|
// Get the content owner(s)
|
$owner_ids = $this->getContentOwner($type, $target_id);
|
if (!$owner_ids) {
|
return;
|
}
|
|
$owner_ids = (is_array($owner_ids)) ? $owner_ids : [$owner_ids];
|
|
foreach ($owner_ids as $owner_id) {
|
// Skip if owner is the same as the user who unfavorited
|
if ($owner_id === $user_id) {
|
continue;
|
}
|
|
global $wpdb;
|
$notifications_table = $wpdb->prefix . BASE . 'notifications';
|
|
// Find recent (within last 30 days) new_favourite notifications from this user for this content
|
$notifications = $wpdb->get_results($wpdb->prepare(
|
"SELECT id FROM {$notifications_table}
|
WHERE owner_id = %d
|
AND action_user_id = %d
|
AND type = 'new_favourite'
|
AND target_id = %d
|
AND target_type = %s
|
AND created_at > DATE_SUB(%s, INTERVAL 30 DAY)",
|
$owner_id,
|
$user_id,
|
$target_id,
|
$type,
|
current_time('mysql')
|
));
|
|
if (empty($notifications)) {
|
continue;
|
}
|
|
// Delete the notifications
|
foreach ($notifications as $notification) {
|
$wpdb->delete(
|
$notifications_table,
|
['id' => $notification->id],
|
['%d']
|
);
|
}
|
|
// Invalidate notification cache for this user
|
// if (method_exists(JVB()->notification(), 'clearNotificationCache')) {
|
// JVB()->notification()->clearNotificationCache($owner_id);
|
// }
|
}
|
} catch (Exception $e) {
|
// Log but continue
|
JVB()->error()->log(
|
'favourites',
|
'Error removing related notifications: ' . $e->getMessage(),
|
['type' => $type, 'target_id' => $target_id, 'user_id' => $user_id],
|
'warning'
|
);
|
}
|
}
|
|
public function maybeAcceptListInvite(int $user_id, string $email, array $data):void
|
{
|
if (array_key_exists('list_token', $data) && !empty($data['list_token'])) {
|
$this->acceptListInvitation($data['list_token'], $email);
|
}
|
}
|
|
/**
|
* Get detailed information for a single list
|
*/
|
protected function getListDetails(int $list_id, int $user_id): array
|
{
|
$key = "list_{$list_id}_user_{$user_id}";
|
|
return $this->listsCache->remember($key, function () use ($list_id, $user_id) {
|
try {
|
// Check access - either owner or has share
|
$is_owner = $this->lists->where([
|
'id' => $list_id,
|
'user_id' => $user_id
|
])->existsInQuery();
|
|
$share = null;
|
if (!$is_owner) {
|
$share = $this->listShares->where([
|
'list_id' => $list_id,
|
'user_id' => $user_id,
|
'status' => 'accepted'
|
])->first();
|
}
|
|
if (!$is_owner && !$share) {
|
return [
|
'success' => false,
|
'message' => 'You do not have access to this list'
|
];
|
}
|
|
// Get list details
|
$list = $this->lists->where(['id' => $list_id])->first(ARRAY_A);
|
if (!$list) {
|
return [
|
'success' => false,
|
'message' => 'List not found'
|
];
|
}
|
|
// Get list items
|
$items = $this->listItems
|
->where(['list_id' => $list_id])
|
->orderBy('added_at', 'DESC')
|
->getResults();
|
|
// Format items - convert to favourite-like objects
|
$formatted_items = [];
|
foreach ($items as $item) {
|
// Try to get the actual favourite record if it exists
|
if ($item->favourite_id) {
|
$fav = $this->favourites->where(['id' => $item->favourite_id])->first();
|
if ($fav) {
|
$formatted_items[] = $fav;
|
continue;
|
}
|
}
|
|
// Create dummy favourite object for formatting
|
$formatted_items[] = (object)[
|
'type' => $item->item_type,
|
'target_id' => $item->item_id,
|
'date_added' => $item->added_at
|
];
|
}
|
|
// Get shared users if owner
|
$shared_users = [];
|
if ($is_owner) {
|
$shares = $this->listShares
|
->where(['list_id' => $list_id])
|
->orderBy('created_at', 'DESC')
|
->getResults();
|
|
foreach ($shares as $share_item) {
|
$shared_user = [
|
'email' => $share_item->email,
|
'status' => $share_item->status,
|
'date_added' => $share_item->created_at,
|
];
|
|
if ($share_item->status === 'accepted' && $share_item->user_id) {
|
$user = get_userdata($share_item->user_id);
|
$shared_user['name'] = $user ? $user->display_name : 'Unknown';
|
$shared_user['permission_type'] = $share_item->permission_type;
|
}
|
|
$shared_users[] = $shared_user;
|
}
|
}
|
|
return [
|
'success' => true,
|
'list' => [
|
'id' => (int)$list['id'],
|
'name' => $list['name'],
|
'description' => $list['description'] ?? '',
|
'created_at' => $list['created_at'],
|
'is_owner' => $is_owner,
|
'is_shared' => !$is_owner,
|
'items' => $this->formatItems($formatted_items),
|
'shared_users' => $shared_users
|
]
|
];
|
|
} catch (Exception $e) {
|
$this->logError('getListDetails', [
|
'error' => $e->getMessage(),
|
'user_id' => $user_id,
|
'list_id' => $list_id
|
]);
|
|
return [
|
'success' => false,
|
'message' => 'Error retrieving list details'
|
];
|
}
|
});
|
}
|
|
/**
|
* Process favourite notes update
|
*/
|
protected function processNote(int $user_id, array $data): array
|
{
|
$target_ids = isset($data['target_id'])
|
? array_map('absint', explode(',', $data['target_id']))
|
: [];
|
$notes = sanitize_textarea_field($data['notes'] ?? '');
|
|
if (empty($target_ids)) {
|
return ['success' => false, 'result' => 'No target IDs provided'];
|
}
|
|
return $this->favourites->transaction(function ($table) use ($user_id, $target_ids, $notes) {
|
$results = [];
|
|
foreach ($target_ids as $target_id) {
|
if ($target_id <= 0) {
|
$results[] = [
|
'success' => false,
|
'target_id' => $target_id,
|
'message' => 'Invalid target ID'
|
];
|
continue;
|
}
|
|
$favourite = $table->where([
|
'user_id' => $user_id,
|
'target_id' => $target_id
|
])->first();
|
|
if (!$favourite) {
|
$results[] = [
|
'success' => false,
|
'target_id' => $target_id,
|
'message' => 'Favourite not found'
|
];
|
continue;
|
}
|
|
$table->where(['id' => $favourite->id])->updateResults([
|
'notes' => $notes
|
]);
|
|
$results[] = [
|
'success' => true,
|
'action' => 'updated_notes',
|
'favourite_id' => $favourite->id,
|
'target_id' => $target_id
|
];
|
}
|
|
return [
|
'success' => true,
|
'result' => $results
|
];
|
});
|
}
|
|
/**
|
* Update list
|
*/
|
protected function updateList(int $user_id, array $data): array
|
{
|
$list_id = absint($data['list_id'] ?? 0);
|
|
if (!$list_id) {
|
return ['success' => false, 'result' => 'List ID is required'];
|
}
|
|
try {
|
// Verify ownership
|
$is_owner = $this->lists->where([
|
'id' => $list_id,
|
'user_id' => $user_id
|
])->existsInQuery();
|
|
if (!$is_owner) {
|
return [
|
'success' => false,
|
'result' => 'You do not have permission to update this list'
|
];
|
}
|
|
// Build update data
|
$update_data = [];
|
|
if (isset($data['name'])) {
|
$update_data['name'] = sanitize_text_field($data['name']);
|
}
|
|
if (isset($data['description'])) {
|
$update_data['description'] = sanitize_textarea_field($data['description']);
|
}
|
|
if (empty($update_data)) {
|
return [
|
'success' => true,
|
'result' => 'No changes to update'
|
];
|
}
|
|
// Update the list
|
$this->lists->where(['id' => $list_id])->updateResults($update_data);
|
|
return [
|
'success' => true,
|
'result' => [
|
'message' => 'List updated successfully',
|
'list_id' => $list_id,
|
'updates' => array_keys($update_data)
|
]
|
];
|
|
} catch (Exception $e) {
|
$this->logError('updateList', [
|
'error' => $e->getMessage(),
|
'user_id' => $user_id,
|
'list_id' => $list_id
|
]);
|
|
return [
|
'success' => false,
|
'result' => $e->getMessage()
|
];
|
}
|
}
|
|
/**
|
* Delete list
|
*/
|
protected function deleteList(int $user_id, array $data): array
|
{
|
$list_id = absint($data['list_id'] ?? 0);
|
|
if (!$list_id) {
|
return ['success' => false, 'result' => 'List ID is required'];
|
}
|
|
return $this->lists->transaction(function ($table) use ($user_id, $list_id) {
|
// Verify ownership
|
$is_owner = $table->where([
|
'id' => $list_id,
|
'user_id' => $user_id
|
])->existsInQuery();
|
|
if (!$is_owner) {
|
throw new Exception('You do not have permission to delete this list');
|
}
|
|
// Delete related data (foreign keys should handle this, but being explicit)
|
$this->listItems->where(['list_id' => $list_id])->deleteResults();
|
$this->listShares->where(['list_id' => $list_id])->deleteResults();
|
|
// Delete the list
|
$table->where(['id' => $list_id])->deleteResults();
|
|
return [
|
'success' => true,
|
'result' => [
|
'message' => 'List deleted successfully',
|
'list_id' => $list_id
|
]
|
];
|
});
|
}
|
|
/**
|
* Add items to list
|
*/
|
protected function addToList(int $user_id, array $data): array
|
{
|
$list_ids = isset($data['list_id'])
|
? array_map('absint', explode(',', $data['list_id']))
|
: [];
|
$items = $data['items'] ?? [];
|
|
if (empty($list_ids) || empty($items)) {
|
return ['success' => false, 'result' => 'List ID and items are required'];
|
}
|
|
return $this->listItems->transaction(function ($table) use ($user_id, $list_ids, $items) {
|
$results = [];
|
$total_added = 0;
|
$all_errors = [];
|
|
foreach ($list_ids as $list_id) {
|
if (!$list_id) {
|
$all_errors[] = ['message' => 'Invalid list ID', 'list_id' => $list_id];
|
continue;
|
}
|
|
// Check permission - either owner or has edit permission
|
$is_owner = $this->lists->where([
|
'id' => $list_id,
|
'user_id' => $user_id
|
])->existsInQuery();
|
|
$has_edit = $this->listShares->where([
|
'list_id' => $list_id,
|
'user_id' => $user_id,
|
'permission_type' => 'edit',
|
'status' => 'accepted'
|
])->existsInQuery();
|
|
if (!$is_owner && !$has_edit) {
|
$all_errors[] = [
|
'message' => 'You do not have permission to add items to this list',
|
'list_id' => $list_id
|
];
|
continue;
|
}
|
|
// Add items to this list
|
$add_result = $this->addItemsToList($list_id, $items, $user_id);
|
$total_added += $add_result['added'];
|
|
if (!empty($add_result['errors'])) {
|
$all_errors = array_merge($all_errors, $add_result['errors']);
|
}
|
|
$results[] = [
|
'list_id' => $list_id,
|
'added_count' => $add_result['added']
|
];
|
}
|
|
if ($total_added == 0 && !empty($all_errors)) {
|
throw new Exception('Failed to add any items');
|
}
|
|
return [
|
'success' => true,
|
'result' => [
|
'lists' => $results,
|
'total_added' => $total_added,
|
'errors' => $all_errors
|
]
|
];
|
});
|
}
|
|
/**
|
* Remove items from list
|
*/
|
protected function removeFromList(int $user_id, array $data): array
|
{
|
$list_id = absint($data['list_id'] ?? 0);
|
$items = $data['items'] ?? [];
|
|
if (!$list_id || empty($items)) {
|
return ['success' => false, 'result' => 'List ID and items are required'];
|
}
|
|
try {
|
// Check permission
|
$is_owner = $this->lists->where([
|
'id' => $list_id,
|
'user_id' => $user_id
|
])->existsInQuery();
|
|
$has_edit = $this->listShares->where([
|
'list_id' => $list_id,
|
'user_id' => $user_id,
|
'permission_type' => 'edit',
|
'status' => 'accepted'
|
])->existsInQuery();
|
|
if (!$is_owner && !$has_edit) {
|
return [
|
'success' => false,
|
'result' => 'You do not have permission to remove items from this list'
|
];
|
}
|
|
// Remove items
|
$removed = 0;
|
foreach ($items as $item) {
|
if (empty($item['type']) || !isset($item['target_id'])) {
|
continue;
|
}
|
|
$type = str_starts_with($item['type'], BASE)
|
? $item['type']
|
: BASE . $item['type'];
|
|
$deleted = $this->listItems->where([
|
'list_id' => $list_id,
|
'item_type' => $type,
|
'item_id' => absint($item['target_id'])
|
])->deleteResults();
|
|
if ($deleted) {
|
$removed += $deleted;
|
}
|
}
|
|
return [
|
'success' => true,
|
'result' => [
|
'message' => "{$removed} items removed from list",
|
'list_id' => $list_id,
|
'removed_count' => $removed
|
]
|
];
|
|
} catch (Exception $e) {
|
$this->logError('removeFromList', [
|
'error' => $e->getMessage(),
|
'user_id' => $user_id,
|
'list_id' => $list_id
|
]);
|
|
return [
|
'success' => false,
|
'result' => $e->getMessage()
|
];
|
}
|
}
|
|
/**
|
* Share list with another user
|
*/
|
protected function shareList(int $user_id, array $data): array
|
{
|
$list_id = absint($data['list_id'] ?? 0);
|
$email = sanitize_email($data['email'] ?? '');
|
$permission_type = in_array($data['permission_type'] ?? '', ['view', 'edit'])
|
? $data['permission_type']
|
: 'view';
|
|
if (!$list_id || !$email) {
|
return ['success' => false, 'result' => 'List ID and email are required'];
|
}
|
|
return $this->listShares->transaction(function ($table) use ($user_id, $list_id, $email, $permission_type) {
|
// Verify ownership
|
$list = $this->lists->where([
|
'id' => $list_id,
|
'user_id' => $user_id
|
])->first();
|
|
if (!$list) {
|
throw new Exception('You do not have permission to share this list');
|
}
|
|
// Get owner details
|
$owner = get_userdata($user_id);
|
$owner_name = $owner ? $owner->display_name : 'Someone';
|
|
// Look up user by email
|
$share_user = get_user_by('email', $email);
|
|
if ($share_user) {
|
// User exists - check for existing share
|
$existing = $table->where([
|
'list_id' => $list_id,
|
'user_id' => $share_user->ID
|
])->first();
|
|
if ($existing) {
|
// Update if different
|
if ($existing->permission_type !== $permission_type || $existing->status !== 'accepted') {
|
$table->where(['id' => $existing->id])->updateResults([
|
'permission_type' => $permission_type,
|
'status' => 'accepted',
|
]);
|
|
return [
|
'success' => true,
|
'result' => [
|
'action' => 'updated',
|
'message' => "Updated sharing permissions for {$email}",
|
]
|
];
|
}
|
|
return [
|
'success' => true,
|
'result' => [
|
'action' => 'already_shared',
|
'message' => "List is already shared with {$email}",
|
]
|
];
|
}
|
|
// Create new share
|
$table->create([
|
'list_id' => $list_id,
|
'user_id' => $share_user->ID,
|
'email' => $email,
|
'permission_type' => $permission_type,
|
'status' => 'accepted'
|
]);
|
|
// Send notification
|
JVB()->notification()->addNotification(
|
$share_user->ID,
|
'list_shared',
|
$user_id,
|
sprintf('%s shared a favorites list with you: "%s"', $owner_name, $list->name),
|
$list_id,
|
'favourites_list',
|
[
|
'list_id' => $list_id,
|
'list_name' => $list->name,
|
'permission_type' => $permission_type
|
]
|
);
|
|
return [
|
'success' => true,
|
'result' => [
|
'action' => 'shared',
|
'message' => "List shared with {$email}",
|
]
|
];
|
}
|
|
// User doesn't exist - create pending invitation
|
$existing_pending = $table->where([
|
'list_id' => $list_id,
|
'email' => $email,
|
'status' => 'pending'
|
])->first();
|
|
if ($existing_pending) {
|
return [
|
'success' => true,
|
'result' => [
|
'action' => 'already_pending',
|
'message' => "Invitation already sent to {$email}",
|
]
|
];
|
}
|
|
// Create pending share with invitation token
|
$token = wp_generate_password(32, false);
|
|
$table->create([
|
'list_id' => $list_id,
|
'email' => $email,
|
'permission_type' => $permission_type,
|
'status' => 'pending',
|
'invitation_token' => $token,
|
'user_id' => 0 // Will be set when they accept
|
]);
|
|
// Send invitation email
|
$this->sendListInviteEmail($email, [
|
'list_id' => $list_id,
|
'list_name' => $list->name,
|
'token' => $token,
|
'owner_name' => $owner_name,
|
'user_id' => $user_id
|
]);
|
|
return [
|
'success' => true,
|
'result' => [
|
'action' => 'invitation_sent',
|
'message' => "Invitation sent to {$email}",
|
]
|
];
|
});
|
}
|
|
/**
|
* Unshare list
|
*/
|
protected function unshareList(int $user_id, array $data): array
|
{
|
$list_id = absint($data['list_id'] ?? 0);
|
$email = sanitize_email($data['email'] ?? '');
|
|
if (!$list_id || !$email) {
|
return ['success' => false, 'result' => 'List ID and email are required'];
|
}
|
|
try {
|
// Verify ownership
|
$is_owner = $this->lists->where([
|
'id' => $list_id,
|
'user_id' => $user_id
|
])->existsInQuery();
|
|
if (!$is_owner) {
|
return [
|
'success' => false,
|
'result' => 'You do not have permission to manage shares for this list'
|
];
|
}
|
|
// Find share by email
|
$existing_share = $this->listShares->where([
|
'list_id' => $list_id,
|
'email' => $email
|
])->first();
|
|
if (!$existing_share) {
|
return [
|
'success' => false,
|
'result' => "No active share or invitation found for {$email}"
|
];
|
}
|
|
// Update status to revoked (or just delete)
|
$this->listShares->where(['id' => $existing_share->id])->updateResults([
|
'status' => 'revoked'
|
]);
|
|
// Notify user if registered and was accepted
|
if ($existing_share->status === 'accepted' && $existing_share->user_id) {
|
$list = $this->lists->where(['id' => $list_id])->first();
|
|
if ($list) {
|
JVB()->notification()->addNotification(
|
$existing_share->user_id,
|
'list_share_revoked',
|
$user_id,
|
sprintf('Your access to the list "%s" has been revoked', $list->name),
|
$list_id,
|
'favourites_list',
|
[
|
'list_id' => $list_id,
|
'list_name' => $list->name
|
]
|
);
|
}
|
}
|
|
$action = $existing_share->status === 'accepted' ? 'unshared' : 'invitation_cancelled';
|
$message = $existing_share->status === 'accepted'
|
? "Removed {$email}'s access to list"
|
: "Cancelled invitation to {$email}";
|
|
return [
|
'success' => true,
|
'result' => [
|
'action' => $action,
|
'message' => $message,
|
]
|
];
|
|
} catch (Exception $e) {
|
$this->logError('unshareList', [
|
'error' => $e->getMessage(),
|
'user_id' => $user_id,
|
'list_id' => $list_id,
|
'email' => $email
|
]);
|
|
return [
|
'success' => false,
|
'result' => $e->getMessage()
|
];
|
}
|
}
|
|
/**
|
* Send list invitation email
|
*/
|
protected function sendListInviteEmail(string $email, array $data): bool
|
{
|
$list_name = $data['list_name'];
|
$token = $data['token'];
|
$owner_name = $data['owner_name'];
|
|
// Generate invitation URL
|
$invite_url = add_query_arg([
|
'action' => 'accept_list_invite',
|
'token' => $token,
|
'list' => $data['list_id']
|
], home_url('/'));
|
|
$subject = sprintf('%s shared a favourites list with you', $owner_name);
|
|
$message = sprintf(
|
'<p>Hi there,</p>
|
<p><strong>%s</strong> has shared their list "<strong>%s</strong>" with you.</p>
|
<p>To view this list, click the link below:</p>
|
<p><a href="%s">Accept List Invitation</a></p>
|
<p>Or copy and paste this link: %s</p>
|
<p>If you don\'t have an account, you\'ll be guided through creating one.</p>',
|
$owner_name,
|
$list_name,
|
$invite_url,
|
$invite_url
|
);
|
|
return JVB()->email()->sendEmail($email, $subject, $message);
|
}
|
|
/**
|
* Accept a list share invitation
|
*/
|
protected function acceptListInvitation(string $token, string $email, ?int $user_id = null): array
|
{
|
if (!$token || !$email) {
|
return [
|
'success' => false,
|
'message' => 'Invalid invitation parameters'
|
];
|
}
|
|
try {
|
// Find the pending invitation
|
$invitation = $this->listShares->where([
|
'invitation_token' => $token,
|
'email' => $email,
|
'status' => 'pending'
|
])->first();
|
|
if (!$invitation) {
|
return [
|
'success' => false,
|
'message' => 'Invalid or expired invitation'
|
];
|
}
|
|
// If no user_id provided, check if user exists
|
if (!$user_id) {
|
$existing_user = get_user_by('email', $email);
|
|
if ($existing_user) {
|
$user_id = $existing_user->ID;
|
} else {
|
// No account - need to register
|
$registration_url = add_query_arg([
|
'action' => 'register',
|
'type' => 'favourites',
|
'list_token' => $token,
|
'email' => urlencode($email)
|
], wp_login_url());
|
|
return [
|
'success' => false,
|
'message' => 'You need to create an account to access this shared list',
|
'needs_registration' => true,
|
'registration_url' => $registration_url
|
];
|
}
|
}
|
|
// Get list details
|
$list = $this->lists->where(['id' => $invitation->list_id])->first();
|
|
if (!$list) {
|
return [
|
'success' => false,
|
'message' => 'The shared list no longer exists'
|
];
|
}
|
|
// Update invitation to accepted
|
$this->listShares->where(['id' => $invitation->id])->updateResults([
|
'status' => 'accepted',
|
'user_id' => $user_id,
|
]);
|
|
// Notify list owner
|
$user = get_userdata($user_id);
|
$display_name = $user ? $user->display_name : $email;
|
|
JVB()->notification()->addNotification(
|
$list->user_id,
|
'list_share_accepted',
|
$user_id,
|
sprintf('%s accepted your invitation to the list "%s"', $display_name, $list->name),
|
$invitation->list_id,
|
'favourites_list',
|
[
|
'list_id' => $invitation->list_id,
|
'list_name' => $list->name,
|
]
|
);
|
|
return [
|
'success' => true,
|
'message' => 'List successfully shared with you',
|
'list_id' => $invitation->list_id,
|
'list_name' => $list->name,
|
'permission_type' => $invitation->permission_type
|
];
|
|
} catch (Exception $e) {
|
$this->logError('acceptListInvitation', [
|
'error' => $e->getMessage(),
|
'token' => $token,
|
'email' => $email
|
]);
|
|
return [
|
'success' => false,
|
'message' => $e->getMessage()
|
];
|
}
|
}
|
|
/**
|
* Get the owner ID for a content item
|
*/
|
protected function getContentOwner(string $type, int $target_id): int|array|null
|
{
|
// For posts
|
if (str_contains($type, 'post') || in_array($type, array_keys($this->valid_types))) {
|
$post = get_post($target_id);
|
return $post ? $post->post_author : null;
|
}
|
|
// For terms (shops, etc.)
|
$owners = get_term_meta($target_id, BASE . 'owner', true);
|
if ($owners) {
|
$owner_array = array_map('absint', explode(',', $owners));
|
return count($owner_array) === 1 ? $owner_array[0] : $owner_array;
|
}
|
|
return null;
|
}
|
|
/**
|
* Batch format post-type favourites to reduce queries
|
*/
|
protected function formatPostFavourites(array $items): array
|
{
|
if (empty($items)) {
|
return [];
|
}
|
|
$formatted = [];
|
$post_ids = array_map(fn($item) => (int)$item->target_id, $items);
|
|
// Get all posts in one query
|
$posts = get_posts([
|
'post__in' => $post_ids,
|
'post_type' => 'any',
|
'posts_per_page' => -1,
|
'post_status' => 'any',
|
]);
|
|
// Create lookup map
|
$posts_by_id = [];
|
foreach ($posts as $post) {
|
$posts_by_id[$post->ID] = $post;
|
}
|
|
// Get thumbnails for artists
|
$artist_ids = [];
|
foreach ($items as $item) {
|
if ($item->type === BASE . 'artist') {
|
$artist_ids[] = (int)$item->target_id;
|
}
|
}
|
|
$artist_images = [];
|
if (!empty($artist_ids)) {
|
global $wpdb;
|
$placeholders = implode(',', array_fill(0, count($artist_ids), '%d'));
|
$results = $wpdb->get_results($wpdb->prepare(
|
"SELECT post_id, meta_value FROM {$wpdb->postmeta}
|
WHERE meta_key = %s AND post_id IN ($placeholders)",
|
array_merge([BASE . 'image'], $artist_ids)
|
));
|
|
foreach ($results as $result) {
|
$artist_images[$result->post_id] = $result->meta_value;
|
}
|
}
|
|
// Get regular thumbnails
|
$thumbnail_ids = [];
|
foreach ($items as $item) {
|
if ($item->type !== BASE . 'artist' && isset($posts_by_id[$item->target_id])) {
|
$thumb_id = get_post_thumbnail_id($item->target_id);
|
if ($thumb_id) {
|
$thumbnail_ids[$item->target_id] = $thumb_id;
|
}
|
}
|
}
|
|
// Format each item
|
foreach ($items as $item) {
|
$post_id = (int)$item->target_id;
|
|
if (!isset($posts_by_id[$post_id])) {
|
continue;
|
}
|
|
$post = $posts_by_id[$post_id];
|
|
$formatted_item = [
|
'id' => $item->id ?? null,
|
'type' => str_replace(BASE, '', $item->type),
|
'target_id' => $post_id,
|
'date_added' => $item->date_added ?? current_time('mysql'),
|
'notes' => $item->notes ?? '',
|
'url' => get_permalink($post),
|
'title' => $post->post_title,
|
'author' => [
|
'id' => $post->post_author,
|
'name' => get_the_author_meta('display_name', $post->post_author)
|
]
|
];
|
|
// Add thumbnail
|
if ($item->type === BASE . 'artist') {
|
$meta_value = $artist_images[$post_id] ?? null;
|
$formatted_item['thumbnail'] = $meta_value ? jvbFormatImage($meta_value, 'medium', 'medium') : null;
|
} else {
|
$thumb_id = $thumbnail_ids[$post_id] ?? null;
|
$formatted_item['thumbnail'] = $thumb_id ? jvbFormatImage($thumb_id, 'medium', 'medium') : null;
|
}
|
|
$formatted[] = $formatted_item;
|
}
|
|
return $formatted;
|
}
|
|
/**
|
* Batch format term-type favourites to reduce queries
|
*/
|
protected function formatTermFavourites(array $items): array
|
{
|
if (empty($items)) {
|
return [];
|
}
|
|
$formatted = [];
|
|
// Group by taxonomy
|
$terms_by_taxonomy = [];
|
foreach ($items as $item) {
|
$tax = $item->type;
|
if (!isset($terms_by_taxonomy[$tax])) {
|
$terms_by_taxonomy[$tax] = [];
|
}
|
$terms_by_taxonomy[$tax][] = (int)$item->target_id;
|
}
|
|
// Get all terms by taxonomy
|
$terms_by_id = [];
|
foreach ($terms_by_taxonomy as $taxonomy => $term_ids) {
|
$terms = get_terms([
|
'taxonomy' => $taxonomy,
|
'include' => $term_ids,
|
'hide_empty' => false,
|
]);
|
|
if (!is_wp_error($terms)) {
|
foreach ($terms as $term) {
|
$terms_by_id[$taxonomy . '_' . $term->term_id] = $term;
|
}
|
}
|
}
|
|
// Get shop images
|
$shop_ids = [];
|
foreach ($items as $item) {
|
if ($item->type === BASE . 'shop') {
|
$shop_ids[] = (int)$item->target_id;
|
}
|
}
|
|
$shop_images = [];
|
if (!empty($shop_ids)) {
|
global $wpdb;
|
$placeholders = implode(',', array_fill(0, count($shop_ids), '%d'));
|
$results = $wpdb->get_results($wpdb->prepare(
|
"SELECT term_id, meta_value FROM {$wpdb->termmeta}
|
WHERE meta_key = %s AND term_id IN ($placeholders)",
|
array_merge([BASE . 'image'], $shop_ids)
|
));
|
|
foreach ($results as $result) {
|
$shop_images[$result->term_id] = $result->meta_value;
|
}
|
}
|
|
// Format each item
|
foreach ($items as $item) {
|
$term_id = (int)$item->target_id;
|
$key = $item->type . '_' . $term_id;
|
|
if (!isset($terms_by_id[$key])) {
|
continue;
|
}
|
|
$term = $terms_by_id[$key];
|
|
$formatted_item = [
|
'id' => $item->id ?? null,
|
'type' => str_replace(BASE, '', $item->type),
|
'target_id' => $term_id,
|
'date_added' => $item->date_added ?? current_time('mysql'),
|
'notes' => $item->notes ?? '',
|
'title' => html_entity_decode($term->name),
|
'url' => get_term_link($term)
|
];
|
|
// Add thumbnail for shops
|
if ($item->type === BASE . 'shop') {
|
$meta_value = $shop_images[$term_id] ?? null;
|
$formatted_item['thumbnail'] = $meta_value ? jvbFormatImage($meta_value, 'medium', 'medium') : null;
|
}
|
|
$formatted[] = $formatted_item;
|
}
|
|
return $formatted;
|
}
|
|
/**
|
* Handle list operations - routes to appropriate method
|
*/
|
public function handleList(WP_REST_Request $request): WP_REST_Response
|
{
|
$user_id = absint($request->get_param('user'));
|
$operation_id = sanitize_text_field($request->get_param('id'));
|
$action = sanitize_text_field($request->get_param('action'));
|
|
if (!$this->userCheck($user_id)) {
|
return $this->unauthorized();
|
}
|
|
$data = [
|
'action' => $action,
|
'list_id' => absint($request->get_param('list_id') ?? 0),
|
'name' => sanitize_text_field($request->get_param('name') ?? ''),
|
'description' => sanitize_textarea_field($request->get_param('description') ?? ''),
|
'items' => $request->get_param('items') ?? [],
|
'email' => sanitize_email($request->get_param('email') ?? ''),
|
'permission_type' => sanitize_text_field($request->get_param('permission_type') ?? 'view'),
|
];
|
|
// Map action to operation type
|
$operation_type = match ($action) {
|
'create' => 'favourite_list_create',
|
'update' => 'favourite_list_update',
|
'delete' => 'favourite_list_delete',
|
'add_items' => 'favourite_list_add_items',
|
'remove_items' => 'favourite_list_remove_items',
|
'share' => 'favourite_list_share',
|
'unshare' => 'favourite_list_unshare',
|
default => null
|
};
|
|
if (!$operation_type) {
|
return $this->error('Invalid action', 'invalid_action', 400);
|
}
|
|
JVB()->queue()->queueOperation(
|
$operation_type,
|
$user_id,
|
$data,
|
[
|
'operation_id' => $operation_id,
|
'priority' => 'normal',
|
]
|
);
|
|
return $this->queued($operation_id);
|
}
|
}
|