| 1 |
<?php |
| 2 |
|
| 3 |
namespace FluentCart\Api\Resource; |
| 4 |
|
| 5 |
use FluentCart\App\Events\ReviewApproved; |
| 6 |
use FluentCart\App\Events\ReviewReplied; |
| 7 |
use FluentCart\App\Helpers\Status; |
| 8 |
use FluentCart\App\Models\Product; |
| 9 |
use FluentCart\App\Models\ProductDetail; |
| 10 |
use FluentCart\App\Models\ProductReview; |
| 11 |
use FluentCart\App\Services\ProductReviewService; |
| 12 |
use FluentCart\Framework\Database\Orm\Builder; |
| 13 |
use FluentCart\Framework\Support\Arr; |
| 14 |
|
| 15 |
class ProductReviewResource extends BaseResourceApi |
| 16 |
{ |
| 17 |
public static function getQuery(): Builder |
| 18 |
{ |
| 19 |
return ProductReview::query(); |
| 20 |
} |
| 21 |
|
| 22 |
public static function get(array $params = []) |
| 23 |
{ |
| 24 |
$query = static::getQuery(); |
| 25 |
|
| 26 |
$status = Arr::get($params, 'status', 'all'); |
| 27 |
$postId = Arr::get($params, 'post_id'); |
| 28 |
$rating = Arr::get($params, 'rating'); |
| 29 |
$search = Arr::get($params, 'search'); |
| 30 |
$sortBy = Arr::get($params, 'sort_by', 'id'); |
| 31 |
$sortOrder = Arr::get($params, 'sort_order', 'DESC'); |
| 32 |
$perPage = min(100, max(1, (int) Arr::get($params, 'per_page', 15))); |
| 33 |
$with = Arr::get($params, 'with', []); |
| 34 |
|
| 35 |
// Only show top-level reviews (not replies) in listings |
| 36 |
$query->topLevel(); |
| 37 |
|
| 38 |
$query->ofStatus($status) |
| 39 |
->ofProduct($postId) |
| 40 |
->ofRating($rating) |
| 41 |
// The list's own floor, set on the Review Item block. Sits beside |
| 42 |
// ofRating() rather than instead of it: the chips still pick a |
| 43 |
// single star, and a chip below the floor simply finds nothing — |
| 44 |
// the two narrow the same query from different directions. |
| 45 |
->minRating(Arr::get($params, 'min_rating')); |
| 46 |
|
| 47 |
if (Arr::get($params, 'has_media')) { |
| 48 |
$query->withMedia(); |
| 49 |
} |
| 50 |
|
| 51 |
if (Arr::get($params, 'verified_only')) { |
| 52 |
$query->where('is_verified', 1); |
| 53 |
} |
| 54 |
|
| 55 |
// Allow extensions to apply advanced filters (e.g. saved views from Pro) |
| 56 |
$filterType = Arr::get($params, 'filter_type', 'simple'); |
| 57 |
if ($filterType === 'advanced') { |
| 58 |
$advancedFilters = Arr::get($params, 'advanced_filters', []); |
| 59 |
if (is_string($advancedFilters)) { |
| 60 |
$advancedFilters = json_decode($advancedFilters, true) ?: []; |
| 61 |
} |
| 62 |
$query = apply_filters('fluent_cart/review/advanced_filters', $query, $advancedFilters, $params); |
| 63 |
} |
| 64 |
|
| 65 |
if ($search) { |
| 66 |
global $wpdb; |
| 67 |
// esc_like escapes the LIKE wildcards; search() adds the |
| 68 |
// surrounding % via its like_all operators. |
| 69 |
$searchValue = $wpdb->esc_like(trim($search)); |
| 70 |
|
| 71 |
$query->where(function ($reviewQuery) use ($searchValue) { |
| 72 |
$reviewQuery->search([ |
| 73 |
'reviewer_name' => ['column' => 'reviewer_name', 'operator' => 'like_all', 'value' => $searchValue], |
| 74 |
'reviewer_email' => ['column' => 'reviewer_email', 'operator' => 'or_like_all', 'value' => $searchValue], |
| 75 |
'title' => ['column' => 'title', 'operator' => 'or_like_all', 'value' => $searchValue], |
| 76 |
'review' => ['column' => 'review', 'operator' => 'or_like_all', 'value' => $searchValue], |
| 77 |
])->orWhereHas('product', function ($productQuery) use ($searchValue) { |
| 78 |
$productQuery->search([ |
| 79 |
'post_title' => ['column' => 'post_title', 'operator' => 'like_all', 'value' => $searchValue], |
| 80 |
]); |
| 81 |
}); |
| 82 |
}); |
| 83 |
} |
| 84 |
|
| 85 |
if (!empty($with)) { |
| 86 |
$query->with($with); |
| 87 |
} |
| 88 |
|
| 89 |
$sortOrder = in_array(strtoupper($sortOrder), ['ASC', 'DESC']) ? strtoupper($sortOrder) : 'DESC'; |
| 90 |
|
| 91 |
$defaultSortColumns = ['id', 'rating', 'created_at', 'reviewer_name']; |
| 92 |
$query = apply_filters('fluent_cart/review/sort_query', $query, $sortBy, $sortOrder); |
| 93 |
|
| 94 |
if (in_array($sortBy, $defaultSortColumns)) { |
| 95 |
$query->orderBy($sortBy, $sortOrder); |
| 96 |
|
| 97 |
// Tie-breaker on the primary key. Ratings and dates collide constantly, and |
| 98 |
// without a deterministic second key MySQL is free to order ties differently |
| 99 |
// per page — rows then repeat or vanish while paginating. |
| 100 |
if ($sortBy !== 'id') { |
| 101 |
$query->orderBy('id', $sortOrder); |
| 102 |
} |
| 103 |
} elseif (!has_filter('fluent_cart/review/sort_query')) { |
| 104 |
$query->orderBy('id', $sortOrder); |
| 105 |
} |
| 106 |
|
| 107 |
// Null page falls through to the paginator's own request resolution, |
| 108 |
// the same shape every sibling resource uses. |
| 109 |
return $query->paginate($perPage, ['*'], 'page', Arr::get($params, 'page')); |
| 110 |
} |
| 111 |
|
| 112 |
public static function find($id, $params = []) |
| 113 |
{ |
| 114 |
$with = Arr::get($params, 'with', ['product', 'customer', 'order', 'replies']); |
| 115 |
|
| 116 |
$review = static::getQuery()->with($with)->find($id); |
| 117 |
|
| 118 |
if (!$review) { |
| 119 |
return static::makeErrorResponse([ |
| 120 |
['code' => 404, 'message' => __('Review not found', 'fluent-cart')] |
| 121 |
]); |
| 122 |
} |
| 123 |
|
| 124 |
return $review; |
| 125 |
} |
| 126 |
|
| 127 |
public static function create($data, $params = []) |
| 128 |
{ |
| 129 |
// Verify the product actually exists — exists() matches the codebase |
| 130 |
// convention for boolean checks (ProductReviewService, TaxClass, |
| 131 |
// stock checks): no model hydration, nothing to throw, so a review |
| 132 |
// can never be attached to a nonexistent product ID. |
| 133 |
$postId = (int) ($data['post_id'] ?? 0); |
| 134 |
if (!$postId || !Product::query()->where('ID', $postId)->exists()) { |
| 135 |
return static::makeErrorResponse([ |
| 136 |
['code' => 400, 'message' => __('A valid product is required', 'fluent-cart')] |
| 137 |
], 400); |
| 138 |
} |
| 139 |
|
| 140 |
// Validate parent_id belongs to the same product if provided |
| 141 |
$parentId = (int) ($data['parent_id'] ?? 0); |
| 142 |
if ($parentId) { |
| 143 |
$parent = static::getQuery()->find($parentId); |
| 144 |
if (!$parent || $parent->post_id !== $postId) { |
| 145 |
$parentId = 0; |
| 146 |
} |
| 147 |
} |
| 148 |
|
| 149 |
// Any child row is a reply — admin or customer follow-up; the |
| 150 |
// is_admin_reply flag records which. |
| 151 |
$isReply = (bool) $parentId; |
| 152 |
|
| 153 |
// A reply belongs to the same item as its review, so the thread can |
| 154 |
// be read per item without a join. A top-level row takes what the |
| 155 |
// caller resolved — the controller has already checked the item |
| 156 |
// belongs to the product and that a grant covers it. |
| 157 |
$itemId = $isReply ? (int) $parent->item_id : (int) ($data['item_id'] ?? 0); |
| 158 |
|
| 159 |
$review = new ProductReview([ |
| 160 |
'post_id' => $postId, |
| 161 |
'item_id' => $itemId ?: null, |
| 162 |
'parent_id' => $parentId ?: null, |
| 163 |
'reviewer_name' => $data['reviewer_name'] ?? '', |
| 164 |
'reviewer_email' => $data['reviewer_email'] ?? '', |
| 165 |
'title' => $data['title'] ?? null, |
| 166 |
'review' => $data['content'] ?? '', |
| 167 |
'rating' => $isReply ? null : ProductReviewService::clampRating($data['rating'] ?? 0), |
| 168 |
'ip_address' => !empty($_SERVER['REMOTE_ADDR']) ? sanitize_text_field($_SERVER['REMOTE_ADDR']) : '', |
| 169 |
'other_info' => static::buildOtherInfo([], $data), |
| 170 |
]); |
| 171 |
|
| 172 |
// Trust fields — server-derived by the caller, never mass-assigned |
| 173 |
$review->user_id = (int) ($data['user_id'] ?? 0) ?: null; |
| 174 |
$review->customer_id = (int) ($data['customer_id'] ?? 0) ?: null; |
| 175 |
$review->order_id = (int) ($data['order_id'] ?? 0) ?: null; |
| 176 |
$review->status = $data['status'] ?? Status::REVIEW_PENDING; |
| 177 |
$review->is_verified = (int) !empty($data['is_verified']); |
| 178 |
$review->is_admin_reply = (int) !empty($data['is_admin_reply']); |
| 179 |
|
| 180 |
if (!$review->save()) { |
| 181 |
return static::makeErrorResponse([ |
| 182 |
['code' => 400, 'message' => __('Failed to create review', 'fluent-cart')] |
| 183 |
]); |
| 184 |
} |
| 185 |
|
| 186 |
// Admin replies (rating NULL) don't affect rating aggregates — skip recalculation. |
| 187 |
// This also prevents N redundant recalculations during bulkReply. |
| 188 |
if ($review->status === Status::REVIEW_APPROVED && !$isReply) { |
| 189 |
static::recalculateProductRatings($review->post_id); |
| 190 |
} |
| 191 |
|
| 192 |
// A review born approved — the store auto-approves, or a moderator |
| 193 |
// wrote it — is an approval too, but it is NOT dispatched from here. |
| 194 |
// Both callers that create top-level reviews run their after_submit |
| 195 |
// and media hooks after this returns, and those hooks write other_info |
| 196 |
// from a model and save() it. The approval notice claims a key in that |
| 197 |
// same blob the moment the event fires; fired here, the claim would be |
| 198 |
// erased by the media save that follows. The controllers dispatch once |
| 199 |
// their hooks are done, on a re-read row — see |
| 200 |
// ReviewApproved::dispatchIfApproved() and its callers. |
| 201 |
|
| 202 |
// The creation hook is fluent_cart/review_created, fired by the |
| 203 |
// ReviewCreated event with the standard array payload — matching the |
| 204 |
// fluent_cart/{entity}_{verb} event convention (order_created etc.). |
| 205 |
|
| 206 |
return $review; |
| 207 |
} |
| 208 |
|
| 209 |
public static function update($data, $id, $params = []) |
| 210 |
{ |
| 211 |
$review = static::getQuery()->find($id); |
| 212 |
|
| 213 |
if (!$review) { |
| 214 |
return static::makeErrorResponse([ |
| 215 |
['code' => 404, 'message' => __('Review not found', 'fluent-cart')] |
| 216 |
]); |
| 217 |
} |
| 218 |
|
| 219 |
$oldStatus = $review->status; |
| 220 |
$oldRating = $review->rating; |
| 221 |
|
| 222 |
foreach (['reviewer_name', 'reviewer_email', 'title'] as $column) { |
| 223 |
if (array_key_exists($column, $data)) { |
| 224 |
$review->{$column} = $data[$column]; |
| 225 |
} |
| 226 |
} |
| 227 |
|
| 228 |
if (array_key_exists('post_id', $data)) { |
| 229 |
$review->post_id = (int) $data['post_id']; |
| 230 |
} |
| 231 |
|
| 232 |
if (array_key_exists('content', $data)) { |
| 233 |
$review->review = $data['content']; |
| 234 |
} |
| 235 |
|
| 236 |
if (array_key_exists('rating', $data) && !$review->parent_id) { |
| 237 |
$review->rating = ProductReviewService::clampRating($data['rating']); |
| 238 |
} |
| 239 |
|
| 240 |
if (array_key_exists('status', $data)) { |
| 241 |
$review->status = $data['status']; |
| 242 |
} |
| 243 |
|
| 244 |
// Guarded column — assigned directly, never mass-assigned. Only a |
| 245 |
// top-level review carries the badge; a reply has no purchase to |
| 246 |
// verify. |
| 247 |
if (array_key_exists('is_verified', $data) && !$review->parent_id) { |
| 248 |
$review->is_verified = (int) !empty($data['is_verified']); |
| 249 |
} |
| 250 |
|
| 251 |
if (array_key_exists('meta', $data)) { |
| 252 |
$review->other_info = static::buildOtherInfo($review->other_info ?: [], $data); |
| 253 |
} |
| 254 |
|
| 255 |
// A failed write must not be reported as success — callers would act on |
| 256 |
// the in-memory model while the stored row still holds the old values, |
| 257 |
// and the status-change hooks would fire for a change that never landed. |
| 258 |
if (!$review->save()) { |
| 259 |
return static::makeErrorResponse([ |
| 260 |
['code' => 500, 'message' => __('Failed to update review', 'fluent-cart')] |
| 261 |
]); |
| 262 |
} |
| 263 |
|
| 264 |
if ($oldStatus !== $review->status || $oldRating !== $review->rating) { |
| 265 |
static::recalculateProductRatings($review->post_id); |
| 266 |
} |
| 267 |
|
| 268 |
ReviewApproved::dispatchIfApproved($review, $oldStatus); |
| 269 |
|
| 270 |
return static::makeSuccessResponse( |
| 271 |
$review, |
| 272 |
__('Review updated successfully', 'fluent-cart') |
| 273 |
); |
| 274 |
} |
| 275 |
|
| 276 |
public static function bulkReply(array $ids, array $replyTemplate) |
| 277 |
{ |
| 278 |
// Cap batch size to prevent long-running requests |
| 279 |
$ids = array_slice($ids, 0, 50); |
| 280 |
|
| 281 |
$reviews = static::getQuery() |
| 282 |
->whereIn('id', $ids) |
| 283 |
->topLevel() |
| 284 |
->get(); |
| 285 |
|
| 286 |
if ($reviews->isEmpty()) { |
| 287 |
return static::makeErrorResponse([ |
| 288 |
['code' => 404, 'message' => __('No valid reviews found', 'fluent-cart')] |
| 289 |
]); |
| 290 |
} |
| 291 |
|
| 292 |
$connection = static::getQuery()->getConnection(); |
| 293 |
$connection->beginTransaction(); |
| 294 |
|
| 295 |
try { |
| 296 |
// One store reply per review unless an extension allows multiple |
| 297 |
// replies: skip reviews that already have a reply. The lookup |
| 298 |
// runs inside the transaction with the parent rows locked, so a |
| 299 |
// concurrent single or bulk reply to the same reviews serializes |
| 300 |
// on the locks instead of racing the check. |
| 301 |
$skipped = 0; |
| 302 |
if (!\FluentCart\App\Services\ProductReviewService::isMultipleRepliesAllowed()) { |
| 303 |
$reviewIds = []; |
| 304 |
foreach ($reviews as $review) { |
| 305 |
$reviewIds[] = (int) $review->id; |
| 306 |
} |
| 307 |
|
| 308 |
// Row locks on every selected parent; released on commit/rollback. |
| 309 |
static::getQuery() |
| 310 |
->whereIn('id', $reviewIds) |
| 311 |
->lockForUpdate() |
| 312 |
->get(); |
| 313 |
|
| 314 |
$repliedParentIds = []; |
| 315 |
$existingReplies = static::getQuery()->whereIn('parent_id', $reviewIds)->get(); |
| 316 |
foreach ($existingReplies as $existingReply) { |
| 317 |
$repliedParentIds[(int) $existingReply->parent_id] = true; |
| 318 |
} |
| 319 |
|
| 320 |
$reviews = $reviews->filter(function ($review) use ($repliedParentIds) { |
| 321 |
return empty($repliedParentIds[(int) $review->id]); |
| 322 |
}); |
| 323 |
|
| 324 |
$skipped = count($repliedParentIds); |
| 325 |
|
| 326 |
if ($reviews->isEmpty()) { |
| 327 |
$connection->rollBack(); |
| 328 |
|
| 329 |
return static::makeErrorResponse([ |
| 330 |
['code' => 422, 'message' => __('All selected reviews already have a reply.', 'fluent-cart')] |
| 331 |
], 422); |
| 332 |
} |
| 333 |
} |
| 334 |
|
| 335 |
$replied = 0; |
| 336 |
$createdReplies = []; |
| 337 |
foreach ($reviews as $review) { |
| 338 |
$replyData = array_merge($replyTemplate, [ |
| 339 |
'parent_id' => $review->id, |
| 340 |
'post_id' => $review->post_id, |
| 341 |
]); |
| 342 |
|
| 343 |
$reply = static::create($replyData); |
| 344 |
|
| 345 |
if (is_wp_error($reply)) { |
| 346 |
throw new \Exception($reply->get_error_message()); |
| 347 |
} |
| 348 |
|
| 349 |
// Kept with the review it answers, which this loop already |
| 350 |
// holds — the event is handed both rather than looking the |
| 351 |
// review up again for every reply in the batch. |
| 352 |
$createdReplies[] = ['reply' => $reply, 'review' => $review]; |
| 353 |
$replied++; |
| 354 |
} |
| 355 |
|
| 356 |
$connection->commit(); |
| 357 |
} catch (\Exception $e) { |
| 358 |
$connection->rollBack(); |
| 359 |
|
| 360 |
return static::makeErrorResponse([ |
| 361 |
['code' => 500, 'message' => __('Failed to create replies', 'fluent-cart')] |
| 362 |
]); |
| 363 |
} |
| 364 |
|
| 365 |
// Announced only now, after the commit: a listener that queued an |
| 366 |
// email for a reply the rollback then erased would tell a reviewer |
| 367 |
// about a reply that does not exist. The products for the whole |
| 368 |
// batch come in one query, so a batch of fifty announces itself |
| 369 |
// without fifty lookups of what was just written. |
| 370 |
$productIds = []; |
| 371 |
foreach ($createdReplies as $pair) { |
| 372 |
$productIds[(int) $pair['review']->post_id] = true; |
| 373 |
} |
| 374 |
$products = Product::query()->whereIn('ID', array_keys($productIds))->get()->keyBy('ID'); |
| 375 |
|
| 376 |
foreach ($createdReplies as $pair) { |
| 377 |
ReviewReplied::dispatchIfStoreReply( |
| 378 |
$pair['reply'], |
| 379 |
$pair['review'], |
| 380 |
$products->get((int) $pair['review']->post_id) |
| 381 |
); |
| 382 |
} |
| 383 |
|
| 384 |
$message = sprintf( |
| 385 |
/* translators: %d - number of reviews replied to */ |
| 386 |
__('Successfully replied to %d review(s).', 'fluent-cart'), |
| 387 |
$replied |
| 388 |
); |
| 389 |
|
| 390 |
if ($skipped > 0) { |
| 391 |
$message .= ' ' . sprintf( |
| 392 |
/* translators: %d - number of reviews skipped because they already have a reply */ |
| 393 |
__('%d review(s) skipped — they already have a reply.', 'fluent-cart'), |
| 394 |
$skipped |
| 395 |
); |
| 396 |
} |
| 397 |
|
| 398 |
return static::makeSuccessResponse( |
| 399 |
['replied' => $replied, 'skipped' => $skipped], |
| 400 |
$message |
| 401 |
); |
| 402 |
} |
| 403 |
|
| 404 |
public static function delete($id, $params = []) |
| 405 |
{ |
| 406 |
$review = static::getQuery()->find($id); |
| 407 |
|
| 408 |
if (!$review) { |
| 409 |
return static::makeErrorResponse([ |
| 410 |
['code' => 404, 'message' => __('Review not found', 'fluent-cart')] |
| 411 |
]); |
| 412 |
} |
| 413 |
|
| 414 |
$postId = $review->post_id; |
| 415 |
$wasApproved = $review->status === Status::REVIEW_APPROVED; |
| 416 |
|
| 417 |
$connection = static::getQuery()->getConnection(); |
| 418 |
$connection->beginTransaction(); |
| 419 |
|
| 420 |
try { |
| 421 |
do_action('fluent_cart/review/before_delete', $review); |
| 422 |
|
| 423 |
// Replies are deleted together with the review in one batch |
| 424 |
static::getQuery() |
| 425 |
->where(function ($q) use ($id) { |
| 426 |
$q->where('id', $id)->orWhere('parent_id', $id); |
| 427 |
}) |
| 428 |
->delete(); |
| 429 |
|
| 430 |
$connection->commit(); |
| 431 |
} catch (\Exception $e) { |
| 432 |
$connection->rollBack(); |
| 433 |
|
| 434 |
return static::makeErrorResponse([ |
| 435 |
['code' => 500, 'message' => __('Failed to delete review', 'fluent-cart')] |
| 436 |
]); |
| 437 |
} |
| 438 |
|
| 439 |
do_action('fluent_cart/review/after_delete', ['reviews' => [$review]]); |
| 440 |
|
| 441 |
if ($wasApproved) { |
| 442 |
static::recalculateProductRatings($postId); |
| 443 |
} |
| 444 |
|
| 445 |
return static::makeSuccessResponse( |
| 446 |
[], |
| 447 |
__('Review deleted successfully', 'fluent-cart') |
| 448 |
); |
| 449 |
} |
| 450 |
|
| 451 |
public static function bulkAction($action, $ids) |
| 452 |
{ |
| 453 |
if (empty($ids)) { |
| 454 |
return static::makeErrorResponse([ |
| 455 |
['code' => 400, 'message' => __('No reviews selected', 'fluent-cart')] |
| 456 |
]); |
| 457 |
} |
| 458 |
|
| 459 |
$validActions = ['approve', 'pending', 'spam', 'trash', 'delete']; |
| 460 |
if (!in_array($action, $validActions)) { |
| 461 |
return static::makeErrorResponse([ |
| 462 |
['code' => 400, 'message' => __('Invalid action', 'fluent-cart')] |
| 463 |
]); |
| 464 |
} |
| 465 |
|
| 466 |
// Cap batch size to prevent long-running requests |
| 467 |
$ids = array_slice(array_map('intval', $ids), 0, 50); |
| 468 |
|
| 469 |
// Resolve the supplied IDs through the model and replace the list |
| 470 |
// with what actually matched. |
| 471 |
$reviewRows = static::getQuery()->whereIn('id', $ids)->get(); |
| 472 |
|
| 473 |
if ($reviewRows->isEmpty()) { |
| 474 |
return static::makeErrorResponse([ |
| 475 |
['code' => 404, 'message' => __('Reviews not found', 'fluent-cart')] |
| 476 |
]); |
| 477 |
} |
| 478 |
|
| 479 |
$ids = array_map('intval', $reviewRows->pluck('id')->toArray()); |
| 480 |
$affectedProductIds = array_values(array_unique( |
| 481 |
array_map('intval', $reviewRows->pluck('post_id')->toArray()) |
| 482 |
)); |
| 483 |
|
| 484 |
$connection = static::getQuery()->getConnection(); |
| 485 |
$connection->beginTransaction(); |
| 486 |
|
| 487 |
try { |
| 488 |
if ($action === 'delete') { |
| 489 |
foreach ($reviewRows as $review) { |
| 490 |
do_action('fluent_cart/review/before_delete', $review); |
| 491 |
} |
| 492 |
|
| 493 |
// Replies and parents deleted together in one batch |
| 494 |
$affectedCount = $reviewRows->count(); |
| 495 |
static::getQuery() |
| 496 |
->where(function ($q) use ($ids) { |
| 497 |
$q->whereIn('id', $ids)->orWhereIn('parent_id', $ids); |
| 498 |
}) |
| 499 |
->delete(); |
| 500 |
} else { |
| 501 |
$statusMap = [ |
| 502 |
'approve' => Status::REVIEW_APPROVED, |
| 503 |
'pending' => Status::REVIEW_PENDING, |
| 504 |
'spam' => Status::REVIEW_SPAM, |
| 505 |
'trash' => Status::REVIEW_TRASH, |
| 506 |
]; |
| 507 |
|
| 508 |
$affectedCount = static::getQuery() |
| 509 |
->whereIn('id', $ids) |
| 510 |
->update(['status' => $statusMap[$action]]); |
| 511 |
} |
| 512 |
|
| 513 |
$connection->commit(); |
| 514 |
} catch (\Exception $e) { |
| 515 |
$connection->rollBack(); |
| 516 |
|
| 517 |
return static::makeErrorResponse([ |
| 518 |
['code' => 500, 'message' => __('Bulk action failed', 'fluent-cart')] |
| 519 |
]); |
| 520 |
} |
| 521 |
|
| 522 |
if ($action === 'delete') { |
| 523 |
do_action('fluent_cart/review/after_delete', ['reviews' => $reviewRows->all()]); |
| 524 |
} |
| 525 |
|
| 526 |
if ($action === 'approve') { |
| 527 |
static::dispatchApprovedEventsForBulk($reviewRows); |
| 528 |
} |
| 529 |
|
| 530 |
// One batched recalculation for everything this operation touched |
| 531 |
static::recalculateProductRatings($affectedProductIds); |
| 532 |
|
| 533 |
return static::makeSuccessResponse( |
| 534 |
['affected' => $affectedCount], |
| 535 |
__('Bulk action completed successfully', 'fluent-cart') |
| 536 |
); |
| 537 |
} |
| 538 |
|
| 539 |
/** |
| 540 |
* The approved event, for the rows a bulk approve actually changed. |
| 541 |
* |
| 542 |
* The bulk write is one UPDATE, so no model saw its own transition. The |
| 543 |
* rows fetched before it still carry the old status, which is exactly the |
| 544 |
* question: a row already approved was not approved by this action and |
| 545 |
* its author is not told twice. The rows that did move are re-read so the |
| 546 |
* event carries what is stored, not a stale copy. |
| 547 |
* |
| 548 |
* @param \FluentCart\Framework\Support\Collection $reviewsBeforeUpdate the selected rows, as read before the update |
| 549 |
*/ |
| 550 |
protected static function dispatchApprovedEventsForBulk($reviewsBeforeUpdate): void |
| 551 |
{ |
| 552 |
$newlyApprovedIds = []; |
| 553 |
foreach ($reviewsBeforeUpdate as $row) { |
| 554 |
if (!$row->parent_id && $row->status !== Status::REVIEW_APPROVED) { |
| 555 |
$newlyApprovedIds[] = (int) $row->id; |
| 556 |
} |
| 557 |
} |
| 558 |
|
| 559 |
if (!$newlyApprovedIds) { |
| 560 |
return; |
| 561 |
} |
| 562 |
|
| 563 |
// The product rides along in the same query; the event reads it off |
| 564 |
// each review rather than fetching it once per dispatch. |
| 565 |
$newlyApprovedReviews = static::getQuery()->with('product')->whereIn('id', $newlyApprovedIds)->get(); |
| 566 |
foreach ($newlyApprovedReviews as $review) { |
| 567 |
ReviewApproved::dispatchIfApproved($review, Status::REVIEW_PENDING); |
| 568 |
} |
| 569 |
} |
| 570 |
|
| 571 |
/** |
| 572 |
* The other_info keys the system owns, which request data can neither |
| 573 |
* set nor erase: the media list (the Pro pipeline's, kept with |
| 574 |
* media_count) and the notice leases (ProductReviewService's — the |
| 575 |
* approval and reply notices' state, timestamp, token and delivered |
| 576 |
* names). A submission that could write approval_notice as "sent" would |
| 577 |
* silence its own approval email; an edit that could erase a real "sent" |
| 578 |
* would have the next re-approval send again, and one that erased a live |
| 579 |
* lease would let a second worker send alongside the first. |
| 580 |
*/ |
| 581 |
const SYSTEM_OTHER_INFO_PREFIXES = ['media', 'approval_notice', 'reply_notice']; |
| 582 |
|
| 583 |
/** |
| 584 |
* Whether an other_info key is the system's, by name or prefix — the |
| 585 |
* notice keys come as a family (approval_notice, approval_notice_at, …). |
| 586 |
*/ |
| 587 |
protected static function isSystemOtherInfoKey($key): bool |
| 588 |
{ |
| 589 |
$key = (string) $key; |
| 590 |
|
| 591 |
foreach (static::SYSTEM_OTHER_INFO_PREFIXES as $prefix) { |
| 592 |
if ($key === $prefix || strpos($key, $prefix . '_') === 0) { |
| 593 |
return true; |
| 594 |
} |
| 595 |
} |
| 596 |
|
| 597 |
return false; |
| 598 |
} |
| 599 |
|
| 600 |
/** |
| 601 |
* Merge request-shaped extra data into other_info, preserving every |
| 602 |
* system-owned key — see SYSTEM_OTHER_INFO_PREFIXES — exactly as stored. |
| 603 |
* |
| 604 |
* @param array $current Existing other_info |
| 605 |
* @param array $data Request data (extra data under the 'meta' key) |
| 606 |
* @return array|null |
| 607 |
*/ |
| 608 |
protected static function buildOtherInfo(array $current, array $data) |
| 609 |
{ |
| 610 |
if (!array_key_exists('meta', $data)) { |
| 611 |
return $current ?: null; |
| 612 |
} |
| 613 |
|
| 614 |
$extra = $data['meta']; |
| 615 |
if (is_object($extra)) { |
| 616 |
$extra = (array) $extra; |
| 617 |
} |
| 618 |
if (!is_array($extra)) { |
| 619 |
$extra = []; |
| 620 |
} |
| 621 |
|
| 622 |
// System-owned keys are never accepted from request data … |
| 623 |
foreach (array_keys($extra) as $key) { |
| 624 |
if (static::isSystemOtherInfoKey($key)) { |
| 625 |
unset($extra[$key]); |
| 626 |
} |
| 627 |
} |
| 628 |
|
| 629 |
// … and what is stored under them survives the replacement of the |
| 630 |
// rest, untouched. |
| 631 |
$systemOwned = []; |
| 632 |
foreach ($current as $key => $value) { |
| 633 |
if (static::isSystemOtherInfoKey($key)) { |
| 634 |
$systemOwned[$key] = $value; |
| 635 |
} |
| 636 |
} |
| 637 |
|
| 638 |
return array_merge($extra, $systemOwned) ?: null; |
| 639 |
} |
| 640 |
|
| 641 |
public static function getStatusCounts($postId = null) |
| 642 |
{ |
| 643 |
$query = static::getQuery()->topLevel()->ofProduct($postId); |
| 644 |
|
| 645 |
$counts = $query->selectRaw('status, COUNT(*) as count') |
| 646 |
->groupBy('status')->get(); |
| 647 |
|
| 648 |
$result = [ |
| 649 |
'all' => 0, |
| 650 |
'approved' => 0, |
| 651 |
'pending' => 0, |
| 652 |
'spam' => 0, |
| 653 |
'trash' => 0, |
| 654 |
]; |
| 655 |
|
| 656 |
foreach ($counts as $row) { |
| 657 |
$status = $row->status; |
| 658 |
if (isset($result[$status])) { |
| 659 |
$result[$status] = (int) $row->count; |
| 660 |
} |
| 661 |
$result['all'] += (int) $row->count; |
| 662 |
} |
| 663 |
|
| 664 |
return $result; |
| 665 |
} |
| 666 |
|
| 667 |
/** |
| 668 |
* @param int|array $postIds Single post ID or array of post IDs |
| 669 |
*/ |
| 670 |
public static function recalculateProductRatings($postIds) |
| 671 |
{ |
| 672 |
$postIds = array_unique(array_filter((array) $postIds)); |
| 673 |
if (empty($postIds)) { |
| 674 |
return; |
| 675 |
} |
| 676 |
|
| 677 |
global $wpdb; |
| 678 |
|
| 679 |
// Aggregate rating stats from fct_product_reviews. |
| 680 |
// No transaction wrapper: the updates are idempotent (re-triggerable |
| 681 |
// via any status change). |
| 682 |
$postIds = array_map('intval', $postIds); |
| 683 |
|
| 684 |
// 1. Per-star breakdown for average + breakdown (only reviews with valid rating 1-5) |
| 685 |
$ratingRows = ProductReview::query() |
| 686 |
->topLevel() |
| 687 |
->approved() |
| 688 |
->whereIn('post_id', $postIds) |
| 689 |
->whereBetween('rating', [1, 5]) |
| 690 |
->groupBy('post_id', 'rating') |
| 691 |
->selectRaw('post_id, rating, COUNT(*) as review_count') |
| 692 |
->get(); |
| 693 |
|
| 694 |
// 2. Total review count — ALL approved top-level reviews regardless of rating. |
| 695 |
// Matches getStatusCounts().approved so product card and reviews page show same number. |
| 696 |
$countRows = ProductReview::query() |
| 697 |
->topLevel() |
| 698 |
->approved() |
| 699 |
->whereIn('post_id', $postIds) |
| 700 |
->groupBy('post_id') |
| 701 |
->selectRaw('post_id, COUNT(*) as review_count') |
| 702 |
->get(); |
| 703 |
|
| 704 |
$totalCountMap = []; |
| 705 |
foreach ($countRows as $row) { |
| 706 |
$totalCountMap[(int) $row->post_id] = (int) $row->review_count; |
| 707 |
} |
| 708 |
|
| 709 |
// Build per-product rating stats from the breakdown rows |
| 710 |
$defaultBreakdown = [5 => 0, 4 => 0, 3 => 0, 2 => 0, 1 => 0]; |
| 711 |
$ratingStatsMap = []; |
| 712 |
foreach ($ratingRows as $row) { |
| 713 |
$productId = (int) $row->post_id; |
| 714 |
if (!isset($ratingStatsMap[$productId])) { |
| 715 |
$ratingStatsMap[$productId] = [ |
| 716 |
'breakdown' => $defaultBreakdown, |
| 717 |
'rated_count' => 0, |
| 718 |
'weighted_sum' => 0, |
| 719 |
]; |
| 720 |
} |
| 721 |
$starValue = (int) $row->rating; |
| 722 |
$reviewCount = (int) $row->review_count; |
| 723 |
$ratingStatsMap[$productId]['breakdown'][$starValue] = $reviewCount; |
| 724 |
$ratingStatsMap[$productId]['rated_count'] += $reviewCount; |
| 725 |
$ratingStatsMap[$productId]['weighted_sum'] += $starValue * $reviewCount; |
| 726 |
} |
| 727 |
|
| 728 |
$details = ProductDetail::query() |
| 729 |
->whereIn('post_id', $postIds) |
| 730 |
->get() |
| 731 |
->keyBy('post_id'); |
| 732 |
|
| 733 |
foreach ($postIds as $postId) { |
| 734 |
$detail = $details->get($postId); |
| 735 |
if (!$detail) { |
| 736 |
continue; |
| 737 |
} |
| 738 |
|
| 739 |
$ratingStat = $ratingStatsMap[$postId] ?? null; |
| 740 |
$ratedCount = $ratingStat ? $ratingStat['rated_count'] : 0; |
| 741 |
$avgRating = $ratedCount > 0 ? round($ratingStat['weighted_sum'] / $ratedCount, 2) : 0.00; |
| 742 |
$breakdown = $ratingStat ? $ratingStat['breakdown'] : $defaultBreakdown; |
| 743 |
$totalCount = $totalCountMap[$postId] ?? 0; |
| 744 |
|
| 745 |
// Partial JSON_MERGE_PATCH update in a single atomic statement — |
| 746 |
// only the three keys we own are rewritten (each replaced |
| 747 |
// wholesale, matching PATCH semantics), so a concurrent writer of |
| 748 |
// a different other_info key (e.g. reviews_enabled from |
| 749 |
// ProductUpdateRequest) can never be clobbered by a PHP-side |
| 750 |
// read-then-merge-then-save race on the same JSON blob. |
| 751 |
// JSON_MERGE_PATCH (not JSON_SET + CAST(... AS JSON)) because |
| 752 |
// MariaDB has no native JSON type and rejects CAST(x AS JSON). |
| 753 |
// |
| 754 |
// The patch document is built with JSON_OBJECT() rather than a |
| 755 |
// bound json_encode() string: WPFluent's WPDBConnection converts |
| 756 |
// every double quote in a compiled query to a backtick (its |
| 757 |
// ANSI-identifier normalization is a blind str_replace), which |
| 758 |
// corrupts JSON text embedded in a raw fragment and makes the |
| 759 |
// statement fail with "Invalid JSON text". JSON_OBJECT() needs |
| 760 |
// no double quotes at all — keys are single-quoted SQL strings, |
| 761 |
// values are bound numbers — and exists on MySQL 5.7+ and |
| 762 |
// MariaDB 10.2.3+. |
| 763 |
$query = ProductDetail::query(); |
| 764 |
$query->where('id', $detail->id)->update([ |
| 765 |
'other_info' => $query->raw($wpdb->prepare( |
| 766 |
"JSON_MERGE_PATCH( |
| 767 |
IF(other_info IS NULL OR other_info = '', '{}', other_info), |
| 768 |
JSON_OBJECT( |
| 769 |
'average_rating', %f, |
| 770 |
'review_count', %d, |
| 771 |
'rating_breakdown', JSON_OBJECT('5', %d, '4', %d, '3', %d, '2', %d, '1', %d) |
| 772 |
) |
| 773 |
)", |
| 774 |
$avgRating, |
| 775 |
$totalCount, |
| 776 |
$breakdown[5], |
| 777 |
$breakdown[4], |
| 778 |
$breakdown[3], |
| 779 |
$breakdown[2], |
| 780 |
$breakdown[1] |
| 781 |
)), |
| 782 |
]); |
| 783 |
} |
| 784 |
} |
| 785 |
} |
| 786 |
|