PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.7.0
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.7.0
1.7.0 1.6.6 1.6.5 1.6.4 1.6.3 1.6.2 1.6.1 1.6.0 1.5.4 1.5.5 1.5.3 1.5.2 1.5.1 1.5.0 1.4.2 1.4.1 1.4.0 1.3.28 1.3.27 1.3.26 1.3.25 1.3.23 1.3.22 1.3.21 1.3.20 All 50 releases
fluent-cart / api / Resource / ProductReviewResource.php

ProductReviewResource.php in FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler 1.7.0, at api/Resource/ProductReviewResource.php

786 lines 30.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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