PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.7.1
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.7.1
1.7.1 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 All 51 releases
fluent-cart / api / Resource / ProductReviewResource.php

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

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