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

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

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