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 / app / Services / ProductReviewService.php

ProductReviewService.php in FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler 1.8.0, at app/Services/ProductReviewService.php

2,432 lines 99.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\App\Services;
4
5 use FluentCart\Api\ModuleSettings;
6 use FluentCart\Api\Resource\ProductReviewResource;
7 use FluentCart\App\Helpers\Helper;
8 use FluentCart\App\Helpers\Status;
9 use FluentCart\App\Models\Customer;
10 use FluentCart\App\Models\Order;
11 use FluentCart\App\Models\OrderItem;
12 use FluentCart\App\Models\ProductDetail;
13 use FluentCart\App\Models\ProductReview;
14 use FluentCart\App\Models\ProductVariation;
15 use FluentCart\App\Services\DateTime\DateTime;
16 use FluentCart\Framework\Support\Arr;
17
18 class ProductReviewService
19 {
20 /**
21 * Per-request memo for resolveOrderGrant(), keyed "<hash>:<postId>".
22 *
23 * @var array<string, \FluentCart\App\Models\Order|null>
24 */
25 protected static $grantCache = [];
26
27 /**
28 * Per-request memo of the order an order-hash names, with the product and
29 * variation of each of its lines, keyed by hash. Loaded once however many
30 * products the page asks about; the order-review page seeds it from the
31 * order it already loaded via primeOrderGrant(), so rendering N rows costs
32 * no grant queries at all.
33 *
34 * `products` is the same information keyed by product — post id to the
35 * set of variation ids bought — so asking whether a hash covers a product
36 * is a lookup, not a scan of every line for every row on the page.
37 *
38 * @var array<string, array{order: \FluentCart\App\Models\Order|null, lines: array<int, array{post_id: int, object_id: int}>, products: array<int, array<int, true>>}>
39 */
40 protected static $orderCache = [];
41
42 /**
43 * Per-request memo of isReviewEnabledForProduct(), keyed by post id. The
44 * order-review page asks it once per row and the block it renders asks
45 * again for its CTA and its form; primeReviewsEnabled() fills it for a
46 * whole page in one query.
47 *
48 * @var array<int, bool>
49 */
50 protected static $reviewsEnabledCache = [];
51
52 /**
53 * Per-request memo of the customer row behind a signed-in visitor, keyed
54 * by user id. grantOwnsSubmission() and effectiveReviewerIdentity() both
55 * ask, and the order-review page asks for every row it renders.
56 *
57 * @var array<int, \FluentCart\App\Models\Customer|null>
58 */
59 protected static $visitorCustomerCache = [];
60
61 /**
62 * How long a notice lease ("sending") stands before it is treated as
63 * abandoned and may be taken over. A send takes seconds; a worker killed
64 * mid-send leaves a lease nobody settles.
65 */
66 const NOTICE_LEASE_SECONDS = 15 * MINUTE_IN_SECONDS;
67
68 /** Most ids a store-wide list (scope="all") accepts per narrowing list. */
69 const MAX_PRODUCT_FILTER_IDS = 100;
70
71 /** A store-wide list's page ceiling, for the first render and the endpoint alike. */
72 const MAX_ALL_PRODUCTS_REVIEWS_PER_PAGE = 50;
73
74 /** Most product caches one combined-summary request repairs; later requests do the rest. */
75 const MAX_SUMMARY_CACHE_REPAIRS = 50;
76
77 /**
78 * Drop the per-request memos: the grant cache above and the settings
79 * cache getReviewSettings() keeps.
80 *
81 * Both are correct for one request and wrong across two. Tests run many
82 * "requests" in one process — changing the store's permission mode or an
83 * order's items between cases — so they need a way to clear them. Same
84 * seam as TaxCalculator::resetCache(); production never calls it.
85 *
86 * @return void
87 */
88 public static function resetRuntimeCache(): void
89 {
90 static::$grantCache = [];
91 static::$orderCache = [];
92 static::$reviewsEnabledCache = [];
93 static::$visitorCustomerCache = [];
94 static::resetReviewSettingsCache();
95 }
96
97 /**
98 * Free ships exactly one store reply per review and no customer
99 * replies — a hard contract: without Pro active the switch is dead,
100 * so no hook can lift the single-reply rule. With Pro, its
101 * ReviewService bridges the public
102 * fluent_cart/review/allow_threaded_replies filter onto this
103 * internal switch; free itself ships no threaded-reply code.
104 */
105 public static function isMultipleRepliesAllowed(): bool
106 {
107 if (!\FluentCart\App\App::isProActive()) {
108 return false;
109 }
110
111 return (bool) apply_filters('fluent_cart/review/allow_multiple_replies', false);
112 }
113
114 /**
115 * The arrangement the list may actually be drawn in.
116 *
117 * Free draws one review under the last. Grid, slider and masonry are Pro,
118 * and the check lives here rather than at each of the four places a view
119 * mode is read — the renderer, the composed row, the list block and the
120 * shortcode — because a gate repeated four times is a gate that will
121 * disagree with itself. The editor asks the same question through its own
122 * localized flag; this is what actually decides, since a block attribute
123 * is hand-editable in the code editor and a shortcode is typed by hand.
124 *
125 * Anything unrecognised also lands on 'list', which is the existing
126 * behaviour and keeps a typo from becoming a layout.
127 */
128 public static function resolveViewMode($viewMode): string
129 {
130 $viewMode = strtolower(trim((string) $viewMode));
131
132 if (!in_array($viewMode, ['grid', 'slider', 'masonry'], true)) {
133 return 'list';
134 }
135
136 return \FluentCart\App\App::isProActive() ? $viewMode : 'list';
137 }
138
139 /**
140 * Whether a photograph may be styled as the card rather than sit inside it.
141 *
142 * Flush and backdrop are Pro. They are the two settings that change what a
143 * review card looks like rather than how many fit a row, so they are the
144 * one part of the photograph handling that follows the layout modes behind
145 * the licence; how many photographs show, how wide and how tall they are,
146 * and filtering to reviews that carry one all stay free.
147 *
148 * Asked here rather than at each emitter for the same reason as
149 * resolveViewMode(): the rendered row and the composed row each write the
150 * modifier themselves, and a gate written twice is a gate that will
151 * disagree with itself.
152 */
153 public static function isPhotoStylingAllowed(): bool
154 {
155 return \FluentCart\App\App::isProActive();
156 }
157
158 public static function isVerifiedPurchase($postId, $customerIdOrEmail): bool
159 {
160 if (!is_numeric($customerIdOrEmail)) {
161 $customer = Customer::query()->where('email', $customerIdOrEmail)->first();
162 if (!$customer) {
163 return false;
164 }
165 $customerId = $customer->id;
166 } else {
167 $customerId = $customerIdOrEmail;
168 }
169
170 return OrderItem::query()
171 ->where('post_id', $postId)
172 ->whereHas('order', function ($q) use ($customerId) {
173 $q->whereIn('status', Status::getOrderSuccessStatuses())
174 ->where('customer_id', $customerId);
175 })
176 ->exists();
177 }
178
179 /**
180 * Whether the customer has ANY order containing this product, in any
181 * status. Review eligibility in verified_buyers mode uses this — placing
182 * an order at all unlocks the review form. The stricter
183 * isVerifiedPurchase() (successful orders only) keeps governing the
184 * Verified Purchase badge.
185 */
186 public static function hasOrderedProduct($postId, $customerId): bool
187 {
188 return OrderItem::query()
189 ->where('post_id', $postId)
190 ->whereHas('order', function ($q) use ($customerId) {
191 $q->where('customer_id', $customerId);
192 })
193 ->exists();
194 }
195
196 public static function isReviewEnabledForProduct($postId): bool
197 {
198 $globalSettings = static::getReviewSettings();
199 if ($globalSettings['reviews_enabled'] !== 'yes') {
200 return false;
201 }
202
203 $postId = (int) $postId;
204
205 if (!array_key_exists($postId, static::$reviewsEnabledCache)) {
206 static::primeReviewsEnabled([$postId]);
207 }
208
209 return static::$reviewsEnabledCache[$postId];
210 }
211
212 /**
213 * Load the per-product reviews toggle for a set of products in one query,
214 * so a page that asks about each of them — and the review block each row
215 * renders, which asks again — never repeats the lookup.
216 *
217 * @param int[] $postIds
218 * @return void
219 */
220 public static function primeReviewsEnabled(array $postIds): void
221 {
222 $wanted = [];
223 foreach ($postIds as $postId) {
224 $postId = (int) $postId;
225 if ($postId && !array_key_exists($postId, static::$reviewsEnabledCache)) {
226 $wanted[$postId] = true;
227 }
228 }
229
230 if (!$wanted) {
231 return;
232 }
233
234 // A product with no detail row has no toggle, so it stays enabled.
235 foreach (array_keys($wanted) as $postId) {
236 static::$reviewsEnabledCache[$postId] = true;
237 }
238
239 $details = ProductDetail::query()
240 ->select(['post_id', 'other_info'])
241 ->whereIn('post_id', array_keys($wanted))
242 ->get();
243
244 foreach ($details as $detail) {
245 $otherInfo = $detail->other_info;
246 if (is_array($otherInfo) && isset($otherInfo['reviews_enabled']) && $otherInfo['reviews_enabled'] === 'no') {
247 static::$reviewsEnabledCache[(int) $detail->post_id] = false;
248 }
249 }
250 }
251
252 /**
253 * The order an order-hash names, but only if that order actually contains
254 * the product being reviewed.
255 *
256 * This is the trust boundary for the public order-review page. The hash is
257 * an unguessable per-order uuid that the store emails to the buyer, so
258 * holding one is evidence of the purchase in the same way that being
259 * logged in as the customer is — which is what lets a guest, who by
260 * definition has no user account to check, review what they bought.
261 *
262 * Gated on the order having actually succeeded, which the logged-in path
263 * does not need: hasOrderedProduct() accepts any status because it also
264 * requires the visitor to be authenticated AS that customer. A hash has no
265 * such anchor — it is bearer-transferable and anonymous — and an order row
266 * and its uuid both exist BEFORE payment. Without this check anyone could
267 * start a checkout, abandon it, and hold a working grant for everything in
268 * the basket, which is the exact bypass verified_buyers mode forbids.
269 *
270 * With an item, the grant narrows further: the order must hold a line
271 * for that exact variation (fct_order_items.object_id). A buyer of Red
272 * holds no grant for Blue. Item 0 keeps the product-level rule, so every
273 * caller that never learned about items behaves exactly as before.
274 *
275 * @param int $postId
276 * @param string $orderHash
277 * @param int $itemId 0 for the product as a whole
278 * @return Order|null
279 */
280 public static function resolveOrderGrant($postId, $orderHash, $itemId = 0)
281 {
282 $postId = (int) $postId;
283 $itemId = max(0, (int) $itemId);
284 $orderHash = sanitize_text_field((string) $orderHash);
285
286 if (!$postId || $orderHash === '') {
287 return null;
288 }
289
290 // One page renders a CTA and a form per line item, each asking the
291 // same question about the same hash, so the lookup is memoized per
292 // request. Keyed on all three, because one hash grants some products
293 // (and some items) and not others. A class-static rather than a
294 // function-static so resetRuntimeCache() can clear it between tests.
295 $cacheKey = $orderHash . ':' . $postId . ':' . $itemId;
296 if (array_key_exists($cacheKey, static::$grantCache)) {
297 return static::$grantCache[$cacheKey];
298 }
299
300 $loaded = static::loadOrderForGrant($orderHash);
301 $order = $loaded['order'];
302
303 // Same statuses that govern the Verified Purchase badge, so a link can
304 // never authorise a review the badge would call unverified.
305 if ($order && !in_array($order->status, Status::getOrderSuccessStatuses(), true)) {
306 $order = null;
307 }
308
309 if ($order) {
310 $bought = $loaded['products'][$postId] ?? null;
311 if ($bought === null || ($itemId && !isset($bought[$itemId]))) {
312 $order = null;
313 }
314 }
315
316 static::$grantCache[$cacheKey] = $order;
317
318 return $order;
319 }
320
321 /**
322 * The order a hash names and the (product, variation) of each of its
323 * lines — one order query and one line query per hash per request,
324 * however many products then ask about it.
325 *
326 * @param string $orderHash
327 * @return array{order: Order|null, lines: array<int, array{post_id: int, object_id: int}>, products: array<int, array<int, true>>}
328 */
329 protected static function loadOrderForGrant($orderHash): array
330 {
331 if (array_key_exists($orderHash, static::$orderCache)) {
332 return static::$orderCache[$orderHash];
333 }
334
335 $order = Order::query()->where('uuid', $orderHash)->first();
336 $lines = [];
337
338 if ($order) {
339 $rows = OrderItem::query()
340 ->select(['post_id', 'object_id'])
341 ->where('order_id', $order->id)
342 ->get();
343
344 foreach ($rows as $row) {
345 $lines[] = ['post_id' => (int) $row->post_id, 'object_id' => (int) $row->object_id];
346 }
347 }
348
349 static::$orderCache[$orderHash] = static::orderMemoEntry($order ?: null, $lines);
350
351 return static::$orderCache[$orderHash];
352 }
353
354 /**
355 * One memo entry: the order, its lines, and the lines keyed by product.
356 *
357 * @param Order|null $order
358 * @param array<int, array{post_id: int, object_id: int}> $lines
359 * @return array{order: Order|null, lines: array<int, array{post_id: int, object_id: int}>, products: array<int, array<int, true>>}
360 */
361 protected static function orderMemoEntry($order, array $lines): array
362 {
363 $products = [];
364 foreach ($lines as $line) {
365 if ($line['post_id']) {
366 $products[$line['post_id']][$line['object_id']] = true;
367 }
368 }
369
370 return ['order' => $order, 'lines' => $lines, 'products' => $products];
371 }
372
373 /**
374 * Seed the grant memo from an order a caller has already loaded, so the
375 * per-product grant checks that follow issue no queries of their own.
376 * The order-review page loads the order with its lines to render them;
377 * without this every row would reload the same order to answer the same
378 * question.
379 *
380 * @param Order|null $order loaded with its order_items
381 * @return void
382 */
383 public static function primeOrderGrant($order): void
384 {
385 if (!$order || !$order->uuid) {
386 return;
387 }
388
389 $lines = [];
390 foreach ($order->order_items as $line) {
391 $lines[] = ['post_id' => (int) $line->post_id, 'object_id' => (int) $line->object_id];
392 }
393
394 static::$orderCache[(string) $order->uuid] = static::orderMemoEntry($order, $lines);
395 }
396
397 /**
398 * The variation a submission may be filed under, checked against the
399 * product it claims to belong to.
400 *
401 * One place for the rule, so the public submit path and any later admin
402 * path cannot drift: the id must name a variation of THIS product, and a
403 * simple product has no items to speak of — its lone default variation is
404 * the product, so the review is filed at product level (item 0) whatever
405 * the client sent.
406 *
407 * Only the id is resolved. The item's name is never copied onto the
408 * review; it is read from the variation row wherever the review is shown,
409 * the way the product's name is read from the post.
410 *
411 * @param int $postId
412 * @param int $itemId
413 * @return int|null the item to file under (0 for the product as a whole),
414 * or null when the id does not belong to the product
415 */
416 public static function resolveReviewItem($postId, $itemId)
417 {
418 $postId = (int) $postId;
419 $itemId = max(0, (int) $itemId);
420
421 if (!$itemId) {
422 return 0;
423 }
424
425 $belongsToProduct = ProductVariation::query()
426 ->where('id', $itemId)
427 ->where('post_id', $postId)
428 ->exists();
429
430 if (!$belongsToProduct) {
431 return null;
432 }
433
434 return static::productHasItems($postId) ? $itemId : 0;
435 }
436
437 /**
438 * Narrow a review query to one (product, item) slot.
439 *
440 * Item 0 means the product-level slot, which is item_id NULL — never
441 * `= 0`, which no row carries. Every duplicate check and reviewed-state
442 * lookup must go through this so the two cannot disagree about which
443 * rows occupy a slot.
444 *
445 * @param \FluentCart\Framework\Database\Orm\Builder $query
446 * @param int $itemId
447 * @return \FluentCart\Framework\Database\Orm\Builder
448 */
449 public static function scopeToItem($query, $itemId)
450 {
451 $itemId = max(0, (int) $itemId);
452
453 return $itemId
454 ? $query->where('item_id', $itemId)
455 : $query->whereNull('item_id');
456 }
457
458 /**
459 * Whether a product's variations are items worth telling apart.
460 *
461 * Positive signal only: an empty variation_type (a drifted or incomplete
462 * detail row) reads as simple, so a lone default variation named
463 * "Simple" never surfaces as an item.
464 *
465 * @param int $postId
466 * @return bool
467 */
468 public static function productHasItems($postId): bool
469 {
470 return (bool) static::itemTypedProductIds([$postId]);
471 }
472
473 /**
474 * Which of these products have items, in one query.
475 *
476 * The batch form of productHasItems(), and the place the rule actually
477 * lives — that method asks this one. A caller with a page of products (the
478 * add-review picker) must not ask per row, and must not carry its own copy
479 * of the predicate either: two copies drift, and a picker that disagrees
480 * with resolveReviewItem() offers a variation the write path then refuses.
481 *
482 * A missing detail row, an empty variation_type and a NULL one all read as
483 * simple — whereNotIn excludes NULL, the same answer the single-row form
484 * gave by casting it to ''.
485 *
486 * @param array $postIds
487 * @return array<int, int> the ids that have items
488 */
489 public static function itemTypedProductIds(array $postIds): array
490 {
491 $postIds = array_values(array_unique(array_filter(array_map('intval', $postIds))));
492
493 if (!$postIds) {
494 return [];
495 }
496
497 $ids = ProductDetail::query()
498 ->whereIn('post_id', $postIds)
499 ->whereNotIn('variation_type', ['', Helper::PRODUCT_TYPE_SIMPLE])
500 ->pluck('post_id')
501 ->all();
502
503 return array_map('intval', $ids);
504 }
505
506 /**
507 * Attach the name a review's item is shown under, in one query for the
508 * whole page.
509 *
510 * Read from the variation row the way the product name is read from the
511 * post: a rename shows the new name, a deleted variation shows nothing.
512 * Rows with no item get an empty string so templates test one key.
513 *
514 * @param iterable<ProductReview> $reviews
515 * @return void
516 */
517 public static function attachItemLabels($reviews): void
518 {
519 $itemIds = [];
520 foreach ($reviews as $review) {
521 if ((int) $review->item_id) {
522 $itemIds[(int) $review->item_id] = true;
523 }
524 }
525
526 $names = [];
527 if ($itemIds) {
528 $names = ProductVariation::query()
529 ->whereIn('id', array_keys($itemIds))
530 ->pluck('variation_title', 'id')
531 ->all();
532 }
533
534 foreach ($reviews as $review) {
535 $itemId = (int) $review->item_id;
536 $review->setAttribute('item_label', $itemId ? trim((string) ($names[$itemId] ?? '')) : '');
537 }
538 }
539
540 /**
541 * The URL of the request being served, for links that come back to the
542 * page the visitor is on. Built the way WordPress builds its own
543 * canonical redirect target — host plus the request path — rather than
544 * home_url() plus the path, which on a site installed in a subdirectory
545 * would name that directory twice.
546 *
547 * @return string empty when the request carries no path
548 */
549 public static function currentRequestUrl(): string
550 {
551 $requestUri = isset($_SERVER['REQUEST_URI']) ? (string) wp_unslash($_SERVER['REQUEST_URI']) : '';
552 $host = isset($_SERVER['HTTP_HOST']) ? (string) wp_unslash($_SERVER['HTTP_HOST']) : '';
553
554 if ($requestUri === '' || $host === '') {
555 return '';
556 }
557
558 return esc_url_raw(set_url_scheme('http://' . $host . $requestUri));
559 }
560
561 /**
562 * How many distinct products an order hash unlocks — the size of the
563 * grant's own submission allowance. Answered from the order memo, so a
564 * hash already resolved costs nothing; an unknown hash counts as none.
565 *
566 * @param string $orderHash
567 * @return int
568 */
569 public static function grantProductCount($orderHash): int
570 {
571 $orderHash = trim((string) $orderHash);
572
573 if ($orderHash === '') {
574 return 0;
575 }
576
577 return count(static::loadOrderForGrant($orderHash)['products']);
578 }
579
580 /**
581 * Whether a grant, rather than the visitor's own account, owns this
582 * submission.
583 *
584 * An order hash is a bearer credential: whoever holds the link may review
585 * what the order contains, and the review belongs to the buyer. That is
586 * settled. What must not happen is a forwarded link crediting the review
587 * to whoever opened it — a logged-in visitor who never bought the product
588 * would otherwise get a review published under their own account in
589 * verified_buyers mode, which is exactly what that mode forbids.
590 *
591 * So a grant owns the submission unless the visitor is signed in AS the
592 * order's customer, in which case their own account is the better identity
593 * (they keep the ability to edit it) and they would have passed the
594 * permission check without any hash at all.
595 *
596 * @param \FluentCart\App\Models\Order|null $grant
597 * @return bool
598 */
599 public static function grantOwnsSubmission($grant): bool
600 {
601 if (!$grant) {
602 return false;
603 }
604
605 $userId = get_current_user_id();
606
607 if (!$userId) {
608 return true;
609 }
610
611 $customer = static::visitorCustomer($userId);
612
613 if (!$customer) {
614 return true;
615 }
616
617 return (int) $customer->id !== (int) $grant->customer_id;
618 }
619
620 /**
621 * The customer row a signed-in visitor's account is linked to, looked up
622 * once per request.
623 *
624 * @param int $userId
625 * @return Customer|null
626 */
627 public static function visitorCustomer($userId)
628 {
629 $userId = (int) $userId;
630
631 if (!$userId) {
632 return null;
633 }
634
635 if (!array_key_exists($userId, static::$visitorCustomerCache)) {
636 static::$visitorCustomerCache[$userId] = Customer::query()->where('user_id', $userId)->first();
637 }
638
639 return static::$visitorCustomerCache[$userId];
640 }
641
642 /**
643 * The identity an order grant posts under: the order's customer, never
644 * anything the client typed.
645 *
646 * @param Order $order
647 * @return array{name: string, email: string, customer_id: int|null}
648 */
649 public static function orderGrantIdentity($order): array
650 {
651 $customer = $order ? $order->customer : null;
652
653 if ($customer) {
654 $name = trim((string) $customer->full_name);
655 $email = (string) $customer->email;
656 $customerId = (int) $customer->id;
657 } else {
658 $name = '';
659 $email = '';
660 $customerId = null;
661 }
662
663 // A customer row with no name still needs a byline: the reviewer name
664 // is required, and it is published on the product page. Not the
665 // email's local part — that is half the address, shown to everyone —
666 // but a neutral label that is still true of anyone holding a grant.
667 if ($name === '' && $email !== '') {
668 $name = __('Verified Buyer', 'fluent-cart');
669 }
670
671 return [
672 'name' => $name,
673 'email' => $email,
674 'customer_id' => $customerId,
675 ];
676 }
677
678 /**
679 * Who a submission is filed under, resolved once so the permission check,
680 * the duplicate guard, the slot lock and the order-review page's reviewed
681 * state all agree on the same person.
682 *
683 * A grant that owns the submission (see grantOwnsSubmission()) files it
684 * under the order's buyer, so the buyer is the identity here too — a
685 * forwarded link opened by a signed-in stranger must find the buyer's
686 * earlier review, not search for the stranger's own, or every reopen
687 * would insert one more review under the buyer's name. Otherwise it is
688 * the visitor: their account and its emails when signed in, the typed
689 * email when not.
690 *
691 * @param Order|null $grant
692 * @param string $typedEmail what a guest typed, used only with no grant
693 * The customer id rides along with the user id and the emails: a buyer
694 * without a WordPress account is only ever their customer row, and that
695 * row's email can change between two orders — a review filed under the
696 * old address must still count as theirs.
697 *
698 * @return array{user_id: int, customer_id: int, emails: string[], order_id: int, order_keyed: bool, via_grant: bool}
699 */
700 public static function effectiveReviewerIdentity($grant, $typedEmail = ''): array
701 {
702 if (static::grantOwnsSubmission($grant)) {
703 $grantEmail = trim((string) Arr::get(static::orderGrantIdentity($grant), 'email', ''));
704
705 // A customer row that is gone yields no email. The submit path
706 // then takes the typed name and email for the byline, but the
707 // review slot cannot be keyed on what was typed — a fresh address
708 // per submission would reopen it every time — so it is keyed on
709 // the order the grant names instead: one review per product per
710 // order, whichever address the visitor supplies.
711 if ($grantEmail === '') {
712 $typed = sanitize_email((string) $typedEmail);
713
714 return [
715 'user_id' => 0,
716 'customer_id' => 0,
717 'emails' => $typed !== '' ? [$typed] : [],
718 'order_id' => (int) $grant->id,
719 // The typed address is the byline, not the slot: the
720 // duplicate guard and the lock key on the order.
721 'order_keyed' => true,
722 'via_grant' => true,
723 ];
724 }
725
726 if ($grantEmail !== '') {
727 $buyer = $grant->customer;
728 $buyerUserId = ($buyer && $buyer->user_id) ? (int) $buyer->user_id : 0;
729
730 $emails = [$grantEmail];
731 if ($buyerUserId) {
732 $buyerUser = get_userdata($buyerUserId);
733 if ($buyerUser && $buyerUser->user_email) {
734 $emails[] = (string) $buyerUser->user_email;
735 }
736 }
737
738 return [
739 'user_id' => $buyerUserId,
740 'customer_id' => $buyer ? (int) $buyer->id : 0,
741 'emails' => array_values(array_unique(array_filter($emails))),
742 'order_id' => (int) $grant->id,
743 'order_keyed' => false,
744 'via_grant' => true,
745 ];
746 }
747 }
748
749 $userId = get_current_user_id();
750
751 if ($userId) {
752 // Same identity rule as the My Reviews dashboard queries: the
753 // account matches by user id OR by its emails — a review left as
754 // a guest with either address belongs to this customer. Both the
755 // WP account email and the customer record's email count: the two
756 // can diverge, and the dashboard matches on the customer one.
757 $customer = static::visitorCustomer($userId);
758
759 return [
760 'user_id' => (int) $userId,
761 'customer_id' => $customer ? (int) $customer->id : 0,
762 'emails' => array_values(array_unique(array_filter([
763 (string) wp_get_current_user()->user_email,
764 $customer ? (string) $customer->email : '',
765 ]))),
766 'order_id' => 0,
767 'order_keyed' => false,
768 'via_grant' => false,
769 ];
770 }
771
772 $typed = sanitize_email((string) $typedEmail);
773
774 return [
775 'user_id' => 0,
776 'customer_id' => 0,
777 'emails' => $typed !== '' ? [$typed] : [],
778 'order_id' => 0,
779 'order_keyed' => false,
780 'via_grant' => false,
781 ];
782 }
783
784 /**
785 * Narrow a review query to the rows one identity owns: its user id, or
786 * any of its emails. Plain equality on the email — the column's ci
787 * collation already ignores case, and unlike LOWER() it keeps the
788 * reviewer_email index usable.
789 *
790 * @param \FluentCart\Framework\Database\Orm\Builder $query
791 * @param array{user_id: int, customer_id?: int, emails: string[]} $identity
792 * @return \FluentCart\Framework\Database\Orm\Builder
793 */
794 public static function scopeToIdentity($query, array $identity)
795 {
796 $userId = (int) Arr::get($identity, 'user_id', 0);
797 $customerId = (int) Arr::get($identity, 'customer_id', 0);
798 $emails = array_values(array_filter((array) Arr::get($identity, 'emails', [])));
799 // Flagged only for a grant whose customer is gone: the slot is then
800 // the order itself, so a review already filed from this order counts
801 // whatever address was typed alongside it.
802 $orderId = (int) Arr::get($identity, 'order_id', 0);
803 $orderKeyed = $orderId && Arr::get($identity, 'order_keyed');
804
805 return $query->where(function ($identityQuery) use ($userId, $customerId, $emails, $orderId, $orderKeyed) {
806 $first = true;
807 if ($userId) {
808 $identityQuery->where('user_id', $userId);
809 $first = false;
810 }
811 // The customer row outlives its email: a guest buyer's earlier
812 // review, filed under the address they had then, is still theirs.
813 if ($customerId) {
814 $first ? $identityQuery->where('customer_id', $customerId) : $identityQuery->orWhere('customer_id', $customerId);
815 $first = false;
816 }
817 if ($emails) {
818 $first ? $identityQuery->whereIn('reviewer_email', $emails) : $identityQuery->orWhereIn('reviewer_email', $emails);
819 $first = false;
820 }
821 if ($orderKeyed) {
822 $first ? $identityQuery->where('order_id', $orderId) : $identityQuery->orWhere('order_id', $orderId);
823 }
824 });
825 }
826
827 /**
828 * The email the slot lock is claimed under for an identity. Normally the
829 * identity's own address. For a grant whose customer is gone there is no
830 * stable address — the visitor types one — so the lock is claimed under a
831 * synthetic, well-formed address derived from the order id: two
832 * submissions from one order then contend on one lock whatever was typed.
833 * The value only ever feeds the lock name; it is never stored.
834 *
835 * @param array{user_id: int, emails: string[], order_id: int, order_keyed: bool} $identity
836 * @param string $fallbackEmail
837 * @return string
838 */
839 public static function slotLockEmail(array $identity, $fallbackEmail = ''): string
840 {
841 $emails = array_values(array_filter((array) Arr::get($identity, 'emails', [])));
842 $orderId = (int) Arr::get($identity, 'order_id', 0);
843 $userId = (int) Arr::get($identity, 'user_id', 0);
844
845 // Only when the grant supplies NO stable address — a customer row
846 // that is gone. A guest buyer whose customer row still exists has an
847 // email, and two of their orders for one product must contend on
848 // that email, not on two different order keys.
849 if ($orderId && !$userId && Arr::get($identity, 'order_keyed')) {
850 return 'order-' . $orderId . '@review-slot.invalid';
851 }
852
853 return (string) ($emails[0] ?? $fallbackEmail);
854 }
855
856 /**
857 * @param int $postId
858 * @param mixed $request the request the review came from
859 * @param int $itemId the variation the review targets, 0 for the
860 * product; only ever non-zero behind an order grant
861 */
862 public static function canSubmitReview($postId, $request, $itemId = 0): array
863 {
864 $itemId = max(0, (int) $itemId);
865
866 if (!static::isReviewEnabledForProduct($postId)) {
867 return [
868 'can_submit' => false,
869 'message' => __('Reviews are currently disabled for this product', 'fluent-cart'),
870 ];
871 }
872
873 $settings = static::getReviewSettings();
874
875 $permissionMode = $settings['review_permission_mode'];
876 $userId = get_current_user_id();
877
878 // An order hash that names an order containing this product stands in
879 // for the permission-mode check: the holder demonstrably bought the
880 // thing, which is exactly what verified_buyers asks, and asking a
881 // guest-checkout buyer to log in first would break the emailed link
882 // that brought them here. See resolveOrderGrant() for why this is safe.
883 $grant = static::resolveOrderGrant($postId, $request->get('order_hash'), $itemId);
884
885 if (!$grant && $permissionMode === Status::REVIEW_PERMISSION_VERIFIED_BUYERS) {
886 if (!$userId) {
887 return [
888 'can_submit' => false,
889 'message' => __('Please log in to leave a review', 'fluent-cart'),
890 ];
891 }
892
893 $customer = Customer::query()->where('user_id', $userId)->first();
894 if (!$customer || !static::hasOrderedProduct($postId, $customer->id)) {
895 return [
896 'can_submit' => false,
897 'message' => __('Only verified buyers can leave a review', 'fluent-cart'),
898 ];
899 }
900 } elseif (!$grant && $permissionMode === Status::REVIEW_PERMISSION_LOGGED_IN) {
901 if (!$userId) {
902 return [
903 'can_submit' => false,
904 'message' => __('Please log in to leave a review', 'fluent-cart'),
905 ];
906 }
907 }
908 // 'anyone' mode allows all
909
910 // Check for duplicate review. Spam/trash are excluded (see
911 // Status::getReviewDuplicateStatuses()): a rejected review does not
912 // block a fresh submission.
913 //
914 // The slot is (product, item): a buyer who ordered Red and Blue may
915 // review each once, and the product-level slot (item NULL) is its own.
916 //
917 // Keyed on the identity the review will actually be filed under —
918 // the order's buyer when a grant owns the submission, else the
919 // visitor. Reading the request here instead would let a guest walk
920 // past the guard by typing a different address each time, and
921 // searching for a signed-in stranger's own reviews would let a
922 // forwarded link insert one more review under the buyer per reopen.
923 $identity = static::effectiveReviewerIdentity($grant, (string) $request->get('reviewer_email'));
924
925 // A guest with no grant and nothing typed has no identity to check
926 // against; the submit path refuses that request for the missing
927 // email before anything is written.
928 if ($identity['user_id'] || $identity['emails'] || $identity['order_id']) {
929 $existingReview = static::scopeToIdentity(static::scopeToItem(ProductReview::query(), $itemId), $identity)
930 ->where('post_id', $postId)
931 ->whereIn('status', Status::getReviewDuplicateStatuses())
932 ->first();
933
934 if ($existingReview) {
935 return [
936 'can_submit' => false,
937 'message' => $identity['user_id'] || $identity['via_grant']
938 ? __('You have already submitted a review for this product', 'fluent-cart')
939 : __('A review with this email already exists for this product', 'fluent-cart'),
940 ];
941 }
942 }
943
944 return [
945 'can_submit' => true,
946 'message' => '',
947 ];
948 }
949
950 // Generous upper bound on a review submission request (insert + meta save
951 // + event dispatch + email). A lock older than this almost certainly
952 // means the request that claimed it died before reaching its finally
953 // block (fatal error, timeout, OOM kill) rather than genuine contention.
954 const REVIEW_LOCK_TTL = 120;
955
956 /**
957 * Atomically claim the (product, identity) slot for the duration of a review
958 * submission, closing the check-then-insert race that canSubmitReview()'s
959 * duplicate lookup cannot close on its own — two concurrent requests for the
960 * same product + identity can both pass that lookup before either insert
961 * lands. Uses INSERT IGNORE against wp_options' UNIQUE(option_name), the
962 * same primitive WP core's own locking helpers rely on, so only one caller
963 * ever wins the row regardless of object-cache availability.
964 *
965 * The lock is short-lived and released via releaseReviewSlot() right after
966 * the create attempt in the same request — it is a submission-time mutex,
967 * not a permanent uniqueness record, so no cleanup on delete/trash/untrash
968 * is needed. The real duplicate-prevention for subsequent requests stays in
969 * canSubmitReview()'s query against the now-inserted row.
970 *
971 * If the slot is already held, its embedded timestamp is checked against
972 * REVIEW_LOCK_TTL: a lock older than that is reclaimed via a
973 * compare-and-swap UPDATE (only succeeds if option_value still equals the
974 * stale value just read), so a lock a concurrent request is legitimately
975 * still holding, or has already renewed/reclaimed itself, can never be
976 * stolen out from under it — only a truly abandoned lock is ever reused.
977 *
978 * The stored value is "$timestamp:$ownerToken", not just a timestamp —
979 * the random token is what makes an acquisition individually
980 * identifiable, so releaseReviewSlot() can delete-if-still-mine instead
981 * of delete-by-name. Without it, a request that outlives the TTL (slow,
982 * not dead) would delete a successor's freshly-reclaimed lock the moment
983 * it finally reaches its finally block, reopening the duplicate-review
984 * race this exists to close.
985 *
986 * @param int $postId
987 * @param int $userId the account the row is filed under, 0 for none
988 * @param string $email the address the row is filed under, or the
989 * synthetic order address from slotLockEmail()
990 * @param int $itemId the variation slot, 0 for the product — a
991 * separate lock per item, matching the duplicate
992 * guard's (product, item) slot
993 * @param int $customerId the customer row, when the identity has one
994 * and no account — the stable key for a guest buyer
995 * @return array{name: string, value: string}|false lock claim on success,
996 * false if genuinely held
997 */
998 public static function claimReviewSlot($postId, $userId, $email, $itemId = 0, $customerId = 0)
999 {
1000 global $wpdb;
1001
1002 $itemId = max(0, (int) $itemId);
1003
1004 // Every alias of one reviewer must contend on ONE lock name, or two
1005 // aliases race past the duplicate check together and both insert.
1006 // Canonical identity: the user account when any path resolves one —
1007 // directly, or from the submitted email via the WP account or a
1008 // customer record — failing that the customer row itself, which a
1009 // guest buyer keeps across a change of address — otherwise the
1010 // normalized email. A guest submitting with a customer's address and
1011 // that customer submitting logged in therefore claim the same lock.
1012 $normalizedEmail = strtolower(sanitize_email((string) $email));
1013 $resolvedUserId = (int) $userId;
1014 $resolvedCustomerId = (int) $customerId;
1015
1016 if (!$resolvedUserId && $normalizedEmail) {
1017 $accountOwner = get_user_by('email', $normalizedEmail);
1018 $resolvedUserId = $accountOwner ? (int) $accountOwner->ID : 0;
1019
1020 if (!$resolvedUserId) {
1021 $customerRow = Customer::query()
1022 ->where('email', $normalizedEmail)
1023 ->first(['id', 'user_id']);
1024 if ($customerRow) {
1025 $resolvedUserId = (int) $customerRow->user_id;
1026 $resolvedCustomerId = $resolvedCustomerId ?: (int) $customerRow->id;
1027 }
1028 }
1029 }
1030
1031 if ($resolvedUserId) {
1032 $identity = 'u' . $resolvedUserId;
1033 } elseif ($resolvedCustomerId) {
1034 $identity = 'c' . $resolvedCustomerId;
1035 } else {
1036 $identity = 'e' . md5($normalizedEmail);
1037 }
1038
1039 // The product-level name is unchanged so a lock claimed before items
1040 // existed still contends with one claimed after; an item slot gets
1041 // its own name, as it gets its own duplicate slot.
1042 $lockName = 'fct_review_lock_' . (int) $postId . ($itemId ? '_i' . $itemId : '') . '_' . $identity;
1043 $now = time();
1044 $ownerValue = $now . ':' . wp_generate_uuid4();
1045
1046 $claimed = $wpdb->query($wpdb->prepare(
1047 "INSERT IGNORE INTO {$wpdb->options} (option_name, option_value, autoload) VALUES (%s, %s, 'no')",
1048 $lockName,
1049 $ownerValue
1050 ));
1051
1052 if ($claimed) {
1053 return ['name' => $lockName, 'value' => $ownerValue];
1054 }
1055
1056 // Slot already held (or the INSERT hit a real DB error — either way
1057 // $claimed is falsy here). Read the current holder's value and
1058 // reclaim only if its embedded timestamp proves stale. A null read
1059 // here (the row was deleted between our INSERT and this SELECT —
1060 // e.g. the original holder's releaseReviewSlot() landed in that
1061 // exact window, or a genuine query error) is treated the same as
1062 // live contention: we deny this attempt rather than retry the
1063 // INSERT. The narrow race this misses — the slot was actually free
1064 // by the time we checked — self-heals on the caller's next submit
1065 // attempt; we never reclaim without positive proof the existing
1066 // lock is old.
1067 $existingValue = $wpdb->get_var($wpdb->prepare(
1068 "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s",
1069 $lockName
1070 ));
1071
1072 if ($existingValue === null || (int) strtok($existingValue, ':') > $now - self::REVIEW_LOCK_TTL) {
1073 return false;
1074 }
1075
1076 $reclaimed = $wpdb->update(
1077 $wpdb->options,
1078 ['option_value' => $ownerValue],
1079 ['option_name' => $lockName, 'option_value' => $existingValue]
1080 );
1081
1082 return $reclaimed ? ['name' => $lockName, 'value' => $ownerValue] : false;
1083 }
1084
1085 public static function releaseReviewSlot($lock): void
1086 {
1087 if (!$lock) {
1088 return;
1089 }
1090
1091 global $wpdb;
1092
1093 // Compare-and-delete on the exact owner value this call claimed —
1094 // never delete by option_name alone. If a later request's TTL
1095 // reclaim has since overwritten the value, this release no longer
1096 // matches and is a safe no-op, so a request that outlives the TTL
1097 // (slow, not dead) can never tear down a successor's live lock.
1098 $wpdb->delete($wpdb->options, [
1099 'option_name' => $lock['name'],
1100 'option_value' => $lock['value'],
1101 ]);
1102 }
1103
1104 /**
1105 * Build the payload for an admin reply, so the single-reply and bulk-reply
1106 * paths cannot drift apart.
1107 *
1108 * Trust fields (status, is_admin_reply, the replying user) are set here
1109 * rather than taken from the request — an admin reply is always authored
1110 * by the current user and always published.
1111 *
1112 * @param string $content Reply body
1113 * @param ProductReview|null $parentReview Parent review; omitted for bulk,
1114 * where the resource fills it per row
1115 * @return array
1116 */
1117 public static function buildAdminReplyData($content, $parentReview = null): array
1118 {
1119 $user = wp_get_current_user();
1120
1121 $data = [
1122 'content' => $content,
1123 'title' => '',
1124 'status' => Status::REVIEW_APPROVED,
1125 'is_verified' => 0,
1126 'is_admin_reply' => 1,
1127 'user_id' => $user->ID,
1128 'reviewer_name' => $user->display_name,
1129 'reviewer_email' => $user->user_email,
1130 ];
1131
1132 if ($parentReview) {
1133 $data['parent_id'] = $parentReview->id;
1134 $data['post_id'] = $parentReview->post_id;
1135 }
1136
1137 return $data;
1138 }
1139
1140 /**
1141 * Clamp a submitted rating into the range the column accepts.
1142 *
1143 * The rating is a TINYINT capped at 5 and every write path has to bound
1144 * it, so the bound lives here rather than being re-typed at each one.
1145 */
1146 public static function clampRating($value): int
1147 {
1148 return max(0, min(5, (int) $value));
1149 }
1150
1151 /**
1152 * The star ratings a review list is filtered to, from "5,4" or [5, 4].
1153 *
1154 * Only whole stars 1-5 survive, each once, so the list is never longer than five.
1155 *
1156 * @param string|array $value
1157 * @return int[]
1158 */
1159 public static function ratingList($value): array
1160 {
1161 $parts = is_array($value) ? $value : explode(',', (string) $value);
1162 $ratings = [];
1163
1164 foreach (array_slice($parts, 0, 5) as $part) {
1165 $part = is_scalar($part) ? trim((string) $part) : '';
1166
1167 if (!ctype_digit($part)) {
1168 continue;
1169 }
1170
1171 $rating = (int) $part;
1172
1173 if ($rating >= 1 && $rating <= 5) {
1174 $ratings[$rating] = $rating;
1175 }
1176 }
1177
1178 return array_values($ratings);
1179 }
1180
1181 /**
1182 * Whether the store collects star ratings at all.
1183 *
1184 * Defaults to on, so a store that has never touched the setting shows the
1185 * rating control.
1186 */
1187 public static function isStarRatingEnabled(): bool
1188 {
1189 $settings = static::getReviewSettings();
1190
1191 return !isset($settings['enable_star_rating']) || $settings['enable_star_rating'] === 'yes';
1192 }
1193
1194 /**
1195 * Whether a review must carry a star rating on this store.
1196 *
1197 * Two settings decide it and BOTH have to be consulted: ratings can be
1198 * switched off entirely, or left on but optional. Both default to on, so
1199 * a store that has never touched them requires a rating.
1200 *
1201 * One place, because the call sites had drifted once already — an earlier
1202 * version of the admin path checked only the first of the two and was
1203 * therefore stricter than the customer form on the same store, and the
1204 * storefront renderer treated an absent `star_rating_required` as "not
1205 * required" while every server path treated it as "required".
1206 */
1207 public static function isStarRatingRequired(): bool
1208 {
1209 if (!static::isStarRatingEnabled()) {
1210 return false;
1211 }
1212
1213 $settings = static::getReviewSettings();
1214
1215 return !isset($settings['star_rating_required']) || $settings['star_rating_required'] === 'yes';
1216 }
1217
1218 /**
1219 * Extensions may hold an approved edit for moderation, never publish a
1220 * pending, rejected or trashed review. Runs before the row is saved.
1221 */
1222 public static function applyUpdateStatusFilter($status, array $reviewData, $request, $review): string
1223 {
1224 if ($status !== Status::REVIEW_APPROVED) {
1225 return (string) $status;
1226 }
1227
1228 $filtered = apply_filters('fluent_cart/review/update_status', $status, [
1229 'data' => $reviewData,
1230 'request' => $request,
1231 'review' => $review,
1232 ]);
1233
1234 return $filtered === Status::REVIEW_PENDING ? Status::REVIEW_PENDING : $status;
1235 }
1236
1237 /**
1238 * The status a submission is stored with, after anything that owns part
1239 * of the submission has had its say.
1240 *
1241 * Runs BEFORE applySubmitDataFilter(), which is the whole point: that one
1242 * captures status as a trust field and re-imposes it afterwards, so a
1243 * listener there cannot change it. Moderation is a decision the store
1244 * makes, not one an add-on may quietly take — but an add-on does own facts
1245 * the store's rule depends on. PRO knows a submission carries photos; free
1246 * does not, and the store's "auto approve photo reviews" switch cannot be
1247 * honoured without that.
1248 *
1249 * One direction only: a listener may hold a review back, never publish one
1250 * the store would have moderated. So a store that moderates everything
1251 * still moderates everything, whatever is installed, and the worst a buggy
1252 * or hostile listener can do is ask for more moderation.
1253 *
1254 * @param string $status what the store's own rule decided
1255 * @param array $reviewData the payload as assembled so far
1256 * @param mixed $request the request the review came from
1257 * @return string
1258 */
1259 public static function applySubmissionStatusFilter($status, array $reviewData, $request): string
1260 {
1261 $status = (string) $status;
1262
1263 $filtered = apply_filters('fluent_cart/review/submission_status', $status, $reviewData, $request);
1264 $filtered = is_string($filtered) ? $filtered : '';
1265
1266 // A status this install does not have means nothing; keep the store's.
1267 if (!in_array($filtered, Status::getReviewStatuses(), true)) {
1268 return $status;
1269 }
1270
1271 // Already held back: nothing may release it.
1272 if ($status !== Status::REVIEW_APPROVED) {
1273 return $status;
1274 }
1275
1276 // Approved may only become pending — never spam or trash, which are a
1277 // moderator's judgement and not a submission's starting point.
1278 return $filtered === Status::REVIEW_PENDING ? Status::REVIEW_PENDING : $status;
1279 }
1280
1281 /**
1282 * Let extensions shape a review payload, then re-impose the fields the
1283 * server decided.
1284 *
1285 * The ordering is the whole point: the filter runs on the full payload so
1286 * a listener can add its own keys (the Pro media pipeline stashes pending
1287 * attachment ids in `meta` here), but ownership, moderation state and the
1288 * product cannot be rewritten by it — those are merged back on top
1289 * afterwards from values captured before the filter ran.
1290 *
1291 * @param array $reviewData ProductReviewResource::create() payload
1292 * @param mixed $request the request the review came from
1293 */
1294 public static function applySubmitDataFilter(array $reviewData, $request, int $postId): array
1295 {
1296 $trustFields = [
1297 'post_id' => $postId,
1298 // The item is settled by the order grant before this runs; a
1299 // filter cannot move the review to another variation.
1300 'item_id' => max(0, (int) Arr::get($reviewData, 'item_id', 0)),
1301 'status' => $reviewData['status'],
1302 'is_verified' => $reviewData['is_verified'],
1303 // A submitted review is never a store reply, on either path.
1304 'is_admin_reply' => 0,
1305 'user_id' => $reviewData['user_id'],
1306 'customer_id' => $reviewData['customer_id'],
1307 'order_id' => $reviewData['order_id'],
1308 ];
1309
1310 $reviewData = apply_filters('fluent_cart/review/submit_data', $reviewData, $request, $postId);
1311
1312 return array_merge($reviewData, $trustFields);
1313 }
1314
1315 /**
1316 * Assemble the row an admin-authored review writes, the counterpart of
1317 * buildAdminReplyData() for top-level reviews.
1318 *
1319 * The storefront's submission gates deliberately do NOT run on this path:
1320 * no permission mode, no one-review-per-identity duplicate check, no rate
1321 * limit and no slot lock. Those rules exist to police anonymous visitors;
1322 * a store owner transcribing a review by hand (a phone call, an email, a
1323 * migration from another platform) is an authorization decision already
1324 * made by the reviews/manage capability. A moderator entering a second
1325 * review from the same buyer must not be refused by the guest rules.
1326 *
1327 * The row is guest-shaped — user_id stays null even when the email belongs
1328 * to a WP account — so an admin-written review never grants that account
1329 * storefront edit rights over words it did not write. A matching customer
1330 * IS linked, so the review shows its customer in the admin sidebar; the
1331 * customer's own My Reviews page already matches on email either way.
1332 *
1333 * Trust fields the caller must have decided server-side (status,
1334 * is_verified) are passed in rather than derived here, because the admin
1335 * chooses both explicitly in the add-review modal.
1336 *
1337 * @param array $input post_id, item_id, rating, title, content, status,
1338 * is_verified, reviewer_name, reviewer_email
1339 * @return array ProductReviewResource::create() payload
1340 */
1341 public static function buildAdminReviewData(array $input): array
1342 {
1343 $email = trim((string) Arr::get($input, 'reviewer_email', ''));
1344
1345 $customerId = null;
1346 if ($email) {
1347 // Plain equality: the column's ci collation already ignores case,
1348 // and unlike LOWER() it keeps the email index usable — the same
1349 // rule canSubmitReview()'s logged-in branch follows.
1350 $customerId = Customer::query()->where('email', $email)->value('id');
1351 }
1352
1353 return [
1354 'post_id' => (int) Arr::get($input, 'post_id'),
1355 // Already resolved against the product by the caller; 0 is the
1356 // product-level slot, which the resource stores as NULL.
1357 'item_id' => max(0, (int) Arr::get($input, 'item_id', 0)),
1358 'rating' => (int) Arr::get($input, 'rating', 0),
1359 'title' => (string) Arr::get($input, 'title', ''),
1360 'content' => (string) Arr::get($input, 'content', ''),
1361 'status' => Arr::get($input, 'status', Status::REVIEW_APPROVED),
1362 'is_verified' => !empty($input['is_verified']) ? 1 : 0,
1363 'is_admin_reply' => 0,
1364 'user_id' => 0,
1365 'customer_id' => $customerId ? (int) $customerId : null,
1366 'order_id' => null,
1367 'reviewer_name' => trim((string) Arr::get($input, 'reviewer_name', '')),
1368 'reviewer_email' => $email,
1369 ];
1370 }
1371
1372 /**
1373 * One page of public reviews, shaped for the storefront: fetched,
1374 * decorated with ownership and reply counts, serialized, and passed
1375 * through the public_response filter so PRO attaches its fields. The
1376 * REST endpoint and the server-rendered first page both read from here,
1377 * so the two cannot drift.
1378 *
1379 * @param array $params ProductReviewResource::get params.
1380 * @param int $postId
1381 * @return array ['reviews' => paginator array, ...filter additions]
1382 */
1383 public static function getPublicReviewsPayload(array $params, $postId): array
1384 {
1385 $reviews = ProductReviewResource::get($params);
1386
1387 // Replies are NOT eager-loaded — fetched on demand via the replies
1388 // endpoint. photo is appended by the model itself.
1389 if ($reviews && method_exists($reviews, 'items')) {
1390 $currentUserId = get_current_user_id();
1391 $items = $reviews->items();
1392
1393 // Batch reply counts in a single query, keyed by review id
1394 $reviewIds = array_map(fn($r) => (int) $r->id, $items);
1395 $replyCounts = [];
1396 if (!empty($reviewIds)) {
1397 $reviewsWithReplyCounts = ProductReview::query()
1398 ->whereIn('id', $reviewIds)
1399 ->withCount([
1400 'replies as approved_reply_count' => function ($replyQuery) {
1401 $replyQuery->where('status', Status::REVIEW_APPROVED);
1402 },
1403 ])
1404 ->get();
1405 foreach ($reviewsWithReplyCounts as $countedReview) {
1406 $replyCounts[(int) $countedReview->id] = (int) $countedReview->approved_reply_count;
1407 }
1408 }
1409
1410 // One query for the page's item labels, before the rows are
1411 // serialised for the list renderer.
1412 static::attachItemLabels($items);
1413
1414 foreach ($items as $review) {
1415 $review->is_owner = $currentUserId && (int) $review->user_id === $currentUserId;
1416 $review->reply_count = $replyCounts[(int) $review->id] ?? 0;
1417 $review->makeHidden(['reviewer_email', 'user_id', 'customer_id', 'order_id', 'meta']);
1418 }
1419 }
1420
1421 $responseData = [
1422 'reviews' => $reviews->toArray(),
1423 ];
1424
1425 return apply_filters('fluent_cart/review/public_response', $responseData, $postId);
1426 }
1427
1428 /**
1429 * The product and category filters of an all-products list, as unique positive
1430 * ints capped at MAX_PRODUCT_FILTER_IDS.
1431 *
1432 * @param array $filters product_ids and category_ids, as arrays or comma-separated strings
1433 * @return array{product_ids: int[], category_ids: int[]}
1434 */
1435 public static function sanitizeProductFilters(array $filters): array
1436 {
1437 $sanitizedFilters = [];
1438
1439 foreach (['product_ids', 'category_ids'] as $filterKey) {
1440 $rawIds = Arr::get($filters, $filterKey, []);
1441 $rawIds = is_array($rawIds) ? $rawIds : explode(',', (string) $rawIds);
1442 $rawIds = array_slice($rawIds, 0, static::MAX_PRODUCT_FILTER_IDS);
1443
1444 $sanitizedFilters[$filterKey] = array_values(array_unique(array_filter(array_map('intval', $rawIds), function ($id) {
1445 return $id > 0;
1446 })));
1447 }
1448
1449 return $sanitizedFilters;
1450 }
1451
1452 /**
1453 * One rating summary across several products, added up from each product's
1454 * cached figures (see getProductRatingSummary()). Same products as the
1455 * all-products list: published, reviews not switched off, optionally only
1456 * product_ids and category_ids. The average is weighted by review, from the
1457 * summed breakdown.
1458 *
1459 * @param array $productFilters product_ids, category_ids; empty means every product
1460 * @return array{breakdown: array<int, int>, total: int, average: float}
1461 */
1462 public static function getProductsRatingSummary(array $productFilters): array
1463 {
1464 $filters = static::sanitizeProductFilters($productFilters);
1465 $productIds = $filters['product_ids'];
1466 $categoryIds = $filters['category_ids'];
1467
1468 $sums = "SUM(COALESCE(CAST(JSON_UNQUOTE(JSON_EXTRACT(other_info, '$.review_count')) AS UNSIGNED), 0)) AS total";
1469 foreach ([5, 4, 3, 2, 1] as $star) {
1470 // Breakdown keys are numeric, so the path quotes them; CHAR(34) because the
1471 // query builder rewrites a literal double quote into a backtick.
1472 $starPath = "CONCAT('$.rating_breakdown.', CHAR(34 USING utf8mb4), '{$star}', CHAR(34 USING utf8mb4))";
1473 $sums .= ", SUM(COALESCE(CAST(JSON_UNQUOTE(JSON_EXTRACT(other_info, {$starPath})) AS UNSIGNED), 0)) AS star_{$star}";
1474 }
1475
1476 $query = ProductDetail::query()
1477 // JSON_VALID first: one malformed other_info would otherwise fail the whole query.
1478 ->whereRaw('JSON_VALID(other_info)')
1479 // The same products the list shows (ProductReviewResource::scopeToReviewableProducts()).
1480 // Product's global scope limits this to fluent-products.
1481 ->whereHas('product', function ($productQuery) use ($categoryIds) {
1482 $productQuery->published()
1483 ->whereDoesntHave('detail', function ($detailQuery) {
1484 $detailQuery->reviewsDisabled();
1485 });
1486
1487 if ($categoryIds) {
1488 $productQuery->whereHas('wpTerms', function ($termQuery) use ($categoryIds) {
1489 $termQuery->search([
1490 'taxonomy' => ['operator' => '=', 'value' => 'product-categories'],
1491 'term_id' => ['operator' => 'in', 'value' => $categoryIds],
1492 ]);
1493 });
1494 }
1495 });
1496
1497 // search() skips an empty value, so no product_ids means every product.
1498 $query->search([
1499 'post_id' => ['operator' => 'in', 'value' => $productIds],
1500 ]);
1501
1502 // Repair reviewed products whose cache has no breakdown, a bounded batch per
1503 // request so a page view never rewrites the catalog. A product never
1504 // reviewed counts as zero anyway, so it is left alone rather than rewritten.
1505 $staleProductIds = (clone $query)
1506 ->whereRaw("JSON_EXTRACT(other_info, '$.rating_breakdown') IS NULL")
1507 ->whereHas('product.reviews')
1508 ->orderBy('id')
1509 ->limit(static::MAX_SUMMARY_CACHE_REPAIRS)
1510 ->pluck('post_id')
1511 ->all();
1512
1513 if ($staleProductIds) {
1514 ProductReviewResource::recalculateProductRatings($staleProductIds);
1515 }
1516
1517 $totals = $query->selectRaw($sums)->first();
1518 $totals = $totals ? $totals->getAttributes() : [];
1519
1520 $breakdown = [];
1521 $ratedCount = 0;
1522 $weightedSum = 0;
1523 foreach ([5, 4, 3, 2, 1] as $star) {
1524 $breakdown[$star] = (int) Arr::get($totals, 'star_' . $star, 0);
1525 $ratedCount += $breakdown[$star];
1526 $weightedSum += $star * $breakdown[$star];
1527 }
1528
1529 return [
1530 'breakdown' => $breakdown,
1531 'total' => (int) Arr::get($totals, 'total', 0),
1532 'average' => $ratedCount > 0 ? round($weightedSum / $ratedCount, 2) : 0,
1533 ];
1534 }
1535
1536 /**
1537 * Whether a review block's query_type lists more than one product.
1538 *
1539 * @param mixed $queryType
1540 * @return bool
1541 */
1542 public static function isMultiProductQuery($queryType): bool
1543 {
1544 return in_array($queryType, ['all', 'multiple'], true);
1545 }
1546
1547 /**
1548 * Product filters for a review block in All products or Multiple products mode.
1549 *
1550 * Null means render nothing: Multiple products with no valid selection must not
1551 * fall through to the unfiltered list of every review.
1552 *
1553 * @param mixed $queryType
1554 * @param mixed $productIds the block's product_ids, ignored in All products mode
1555 * @return array{product_ids: int[], category_ids: int[]}|null
1556 */
1557 public static function blockProductFilters($queryType, $productIds): ?array
1558 {
1559 if ($queryType === 'all') {
1560 return static::sanitizeProductFilters([]);
1561 }
1562
1563 if ($queryType !== 'multiple') {
1564 return null;
1565 }
1566
1567 $productFilters = static::sanitizeProductFilters([
1568 'product_ids' => is_array($productIds) ? $productIds : [],
1569 ]);
1570
1571 return $productFilters['product_ids'] ? $productFilters : null;
1572 }
1573
1574 /**
1575 * One page of approved reviews across the store. Extensions see post id 0 on the
1576 * public_response filter.
1577 *
1578 * @param array $params ProductReviewResource::get() params plus the narrowing id lists,
1579 * which are cleaned and capped here.
1580 * @return array
1581 */
1582 public static function getAllProductsReviewsPayload(array $params): array
1583 {
1584 $params = array_merge($params, static::sanitizeProductFilters($params));
1585 $params['status'] = 'approved';
1586 $params['all_products'] = true;
1587 unset($params['post_id']);
1588
1589 return static::getPublicReviewsPayload($params, 0);
1590 }
1591
1592 /**
1593 * Resolve an editorial floor before rendering, or for legacy refreshes.
1594 * A client id is context only; integrations authenticating their own tokens
1595 * must verify them before overriding the trusted default.
1596 */
1597 public static function resolveListMinRating(string $clientId, int $default = 0, int $postId = 0): int
1598 {
1599 $rating = apply_filters('fluent_cart/review/list_min_rating', $default, [
1600 'client_id' => $clientId,
1601 'post_id' => $postId,
1602 ]);
1603
1604 return is_numeric($rating) && (int) $rating >= 1 && (int) $rating <= 5
1605 ? (int) $rating : 0;
1606 }
1607
1608 /** Product-bound, cache-safe list setting; does not grant access to reviews. */
1609 public static function listRatingToken(int $postId, int $minimum): string
1610 {
1611 $minimum = max(0, min(5, $minimum));
1612 $payload = 'fcr1-' . $postId . '-' . $minimum;
1613 $signature = rtrim(strtr(base64_encode(hash_hmac('sha256', $payload, wp_salt('auth'), true)), '+/', '-_'), '=');
1614 return 'fcr1-' . $minimum . '-' . $signature;
1615 }
1616
1617 /** Null means absent, modified, or issued for a different product. */
1618 public static function minRatingFromToken(int $postId, string $token): ?int
1619 {
1620 if (!preg_match('/^fcr1-([0-5])-[A-Za-z0-9_-]{43}$/D', $token, $matches)) {
1621 return null;
1622 }
1623 $minimum = (int) $matches[1];
1624 return hash_equals(static::listRatingToken($postId, $minimum), $token) ? $minimum : null;
1625 }
1626
1627 /** Shared rating visibility for blocks, shortcodes and addon renderers. */
1628 public static function ratingMeetsThresholds($product, array $attributes = [], $block = null): bool
1629 {
1630 $context = [
1631 'product' => $product,
1632 'post_id' => (int) $product->ID,
1633 'attributes' => $attributes,
1634 'block' => $block,
1635 ];
1636 $minimumCount = max(0, (int) apply_filters(
1637 'fluent_cart/review/min_review_count',
1638 max(0, (int) Arr::get($attributes, 'minReviewCount', 0)),
1639 $context
1640 ));
1641 $minimumAverage = min(5.0, max(0.0, (float) apply_filters(
1642 'fluent_cart/review/min_average_rating',
1643 min(5.0, max(0.0, (float) Arr::get($attributes, 'minAverageRating', 0))),
1644 $context
1645 )));
1646 $info = $product->detail ? ($product->detail->other_info ?: []) : [];
1647 return (int) Arr::get($info, 'review_count', 0) >= $minimumCount
1648 && (float) Arr::get($info, 'average_rating', 0) >= $minimumAverage;
1649 }
1650
1651 /**
1652 * Per-request settings cache, shared by every caller of
1653 * getReviewSettings(). Held on the class rather than as a function static
1654 * so it can be cleared: a long-lived process (the test runner, WP-CLI)
1655 * outlives the single request the cache assumes, and a settings write
1656 * there would otherwise never be seen.
1657 *
1658 * @var array|null
1659 */
1660 protected static $reviewSettingsCache = null;
1661
1662 /**
1663 * Drop the cached settings so the next read hits the option again.
1664 *
1665 * A no-op in a normal web request, which builds the cache once and dies.
1666 * It exists for processes that outlive one request and change the setting
1667 * mid-flight — the test suite above all.
1668 */
1669 public static function resetReviewSettingsCache(): void
1670 {
1671 static::$reviewSettingsCache = null;
1672
1673 // ModuleSettings keeps its own per-request static one layer down, so
1674 // clearing only ours would still serve the stale module payload. The
1675 // uncached read reassigns that static as a side effect.
1676 ModuleSettings::getAllSettings(false);
1677 }
1678
1679 public static function getReviewSettings(): array
1680 {
1681 if (static::$reviewSettingsCache !== null) {
1682 return static::$reviewSettingsCache;
1683 }
1684
1685 $moduleSettings = ModuleSettings::getSettings('reviews');
1686
1687 if (!$moduleSettings || !\is_array($moduleSettings)) {
1688 $moduleSettings = [];
1689 }
1690
1691 $settings = [
1692 'reviews_enabled' => Arr::get($moduleSettings, 'active', 'yes'),
1693 'review_permission_mode' => Arr::get($moduleSettings, 'review_permission_mode', Status::REVIEW_PERMISSION_VERIFIED_BUYERS),
1694 'auto_approve_reviews' => Arr::get($moduleSettings, 'auto_approve_reviews', 'no'),
1695 'reviews_per_page' => (int) Arr::get($moduleSettings, 'reviews_per_page', 10),
1696 'enable_star_rating' => Arr::get($moduleSettings, 'enable_star_rating', 'yes'),
1697 'star_rating_required' => Arr::get($moduleSettings, 'star_rating_required', 'yes'),
1698 'show_verified_badge' => Arr::get($moduleSettings, 'show_verified_badge', 'yes'),
1699 ];
1700
1701 // Merge any additional keys (e.g. PRO settings) from module settings
1702 unset($moduleSettings['active']);
1703 static::$reviewSettingsCache = array_merge($moduleSettings, $settings);
1704
1705 return static::$reviewSettingsCache;
1706 }
1707
1708 public static function getProductRatingSummary($postId): array
1709 {
1710 // Single source of truth: detail.other_info, maintained by recalculateProductRatings().
1711 // All consumers (product cards, admin list, reviews page) read from the same cache.
1712 $productDetail = ProductDetail::query()->where('post_id', $postId)->first();
1713 $productOtherInfo = ($productDetail && $productDetail->other_info) ? $productDetail->other_info : [];
1714
1715 // If rating_breakdown is missing (product cached before this field was added),
1716 // backfill it once, then re-read.
1717 //
1718 // Guarded on $productDetail: a product with no fct_product_details row reaches
1719 // here with $productOtherInfo = [], which passes the array_key_exists check
1720 // below — calling refresh() on the null would fatal on a public request.
1721 //
1722 // The backfill is a write inside a read path, so it is throttled with a short
1723 // lock: without it every concurrent visitor to an unbackfilled product runs the
1724 // same two aggregate queries and the same save.
1725 if ($productDetail && !array_key_exists('rating_breakdown', $productOtherInfo)) {
1726 $lockKey = 'fct_rating_backfill_' . (int) $postId;
1727
1728 if (!get_transient($lockKey)) {
1729 set_transient($lockKey, 1, MINUTE_IN_SECONDS);
1730 ProductReviewResource::recalculateProductRatings($postId);
1731 $productDetail->refresh();
1732 $productOtherInfo = $productDetail->other_info ?: [];
1733 }
1734 }
1735
1736 $defaultBreakdown = [5 => 0, 4 => 0, 3 => 0, 2 => 0, 1 => 0];
1737
1738 $starBreakdown = $defaultBreakdown;
1739 if (array_key_exists('rating_breakdown', $productOtherInfo) && is_array($productOtherInfo['rating_breakdown'])) {
1740 foreach ($productOtherInfo['rating_breakdown'] as $starValue => $reviewCount) {
1741 $starValue = (int) $starValue;
1742 if ($starValue >= 1 && $starValue <= 5) {
1743 $starBreakdown[$starValue] = (int) $reviewCount;
1744 }
1745 }
1746 }
1747
1748 $totalReviews = array_key_exists('review_count', $productOtherInfo) ? (int) $productOtherInfo['review_count'] : 0;
1749 $averageRating = array_key_exists('average_rating', $productOtherInfo) ? round((float) $productOtherInfo['average_rating'], 2) : 0;
1750
1751 return [
1752 'breakdown' => $starBreakdown,
1753 'total' => $totalReviews,
1754 'average' => $averageRating,
1755 ];
1756 }
1757
1758 /**
1759 * A product title fit for a JSON payload: the one stored on the product.
1760 *
1761 * Not get_the_title(), which runs the_title — wptexturize curls its
1762 * quotes and dashes and hands them back as HTML entities. That is right
1763 * for markup and wrong for JSON, where the dashboard renders titles as
1764 * text and a product came out reading Men&#8217;s Hoodie.
1765 *
1766 * Reading the column instead of decoding the filtered version is what
1767 * the rest of the plugin already does: the products table and the admin
1768 * reviews table both print post_title straight off the model, so a title
1769 * now reads the same wherever it appears.
1770 */
1771 protected static function productTitle($postId): string
1772 {
1773 return (string) get_post_field('post_title', $postId, 'raw');
1774 }
1775
1776 /**
1777 * A review row belongs to this customer when its user id matches, or —
1778 * for reviews left before the account existed, or as a guest — when the
1779 * reviewer email matches. Every "my reviews" query and its inverse (the
1780 * to-be-reviewed exclusion) must share this predicate or the two lists
1781 * drift: a product could show as reviewed in one and pending in the other.
1782 */
1783 protected static function scopeReviewsOfCustomer($query, Customer $customer)
1784 {
1785 $identityEmails = static::customerIdentityEmails($customer);
1786
1787 return $query->where(function ($identityQuery) use ($customer, $identityEmails) {
1788 $identityQuery->whereIn('reviewer_email', $identityEmails);
1789 if ($customer->user_id) {
1790 $identityQuery->orWhere('user_id', $customer->user_id);
1791 }
1792 });
1793 }
1794
1795 /**
1796 * Every email that identifies this customer: the customer record's own
1797 * address plus the linked WP account's, which can diverge. One set,
1798 * used by the dashboard queries and mirrored by canSubmitReview()'s
1799 * duplicate guard — if the two ever disagree, a product can sit in
1800 * To Be Reviewed while submission reports a duplicate, or the reverse.
1801 */
1802 protected static function customerIdentityEmails(Customer $customer): array
1803 {
1804 $emails = [(string) $customer->email];
1805
1806 if ($customer->user_id) {
1807 $user = get_userdata((int) $customer->user_id);
1808 if ($user && $user->user_email) {
1809 $emails[] = (string) $user->user_email;
1810 }
1811 }
1812
1813 return array_values(array_unique(array_filter($emails)));
1814 }
1815
1816 /**
1817 * The base query every My Reviews surface builds on: the customer's own
1818 * top-level reviews in the statuses that count as "written" — the same
1819 * set canSubmitReview() treats as blocking a duplicate. Spam and trash
1820 * are out on BOTH sides: the customer neither sees them in history nor
1821 * has them block the to-be-reviewed list, matching the write path where
1822 * a rejected review permits a fresh submission.
1823 */
1824 protected static function activeReviewsOfCustomerQuery(Customer $customer)
1825 {
1826 return static::scopeReviewsOfCustomer(
1827 ProductReview::query()
1828 ->whereNull('parent_id')
1829 // Everything the author may see — approved and pending alike,
1830 // never spam or trash. Not the duplicate set: see
1831 // Status::getReviewAuthorVisibleStatuses().
1832 ->whereIn('status', Status::getReviewAuthorVisibleStatuses()),
1833 $customer
1834 );
1835 }
1836
1837 /**
1838 * How many reviews the customer has written — the History tab's count,
1839 * cheap enough to ride along with the pending payload so the tab label
1840 * is right before the tab is ever opened.
1841 */
1842 public static function countCustomerReviews(Customer $customer): int
1843 {
1844 return (int) static::activeReviewsOfCustomerQuery($customer)->count();
1845 }
1846
1847 /**
1848 * The customer's own reviews (their whole history, pending included —
1849 * the customer may always see what they wrote), newest first, with the
1850 * reviewed product's title, link and thumbnail attached to each row.
1851 */
1852 public static function getCustomerReviewsPayload(Customer $customer, array $params): array
1853 {
1854 $perPage = min(50, max(1, (int) Arr::get($params, 'per_page', 10)));
1855 $page = max(1, (int) Arr::get($params, 'page', 1));
1856
1857 $reviews = static::activeReviewsOfCustomerQuery($customer)
1858 ->orderBy('created_at', 'DESC')
1859 ->orderBy('id', 'DESC')
1860 ->paginate($perPage, ['*'], 'page', $page);
1861
1862 $postIds = array_map('intval', $reviews->getCollection()->pluck('post_id')->all());
1863 if ($postIds) {
1864 _prime_post_caches($postIds, false, true);
1865 }
1866
1867 // The item each review names, under the product title — one query
1868 // for the page, like every other surface that shows a review.
1869 static::attachItemLabels($reviews->getCollection()->all());
1870
1871 // Who may edit, decided here rather than in the browser: user_id is
1872 // hidden from the payload precisely so the client cannot reason about
1873 // ownership, and updateReview() pins id + post_id + user_id. A row the
1874 // customer reached by email alone (a guest review, no account behind
1875 // it) is theirs to read and not theirs to change — offering an Edit
1876 // button there would only produce a 404 they cannot act on.
1877 $viewerUserId = get_current_user_id();
1878
1879 $reviews->getCollection()->transform(function ($review) use ($viewerUserId) {
1880 $review->setAttribute(
1881 'can_edit',
1882 $viewerUserId && (int) $review->user_id === (int) $viewerUserId
1883 );
1884 $review->makeHidden(['reviewer_email', 'user_id', 'customer_id', 'order_id', 'meta']);
1885 $review->setAttribute('product_title', static::productTitle($review->post_id));
1886 $review->setAttribute('product_url', (string) get_permalink($review->post_id));
1887 $review->setAttribute('product_thumbnail', (string) get_the_post_thumbnail_url($review->post_id, 'thumbnail'));
1888 return $review;
1889 });
1890
1891 $responseData = [
1892 'reviews' => $reviews->toArray(),
1893 ];
1894
1895 // PRO attaches its extras (media, vote counts) here. Context travels
1896 // as a read-only array, the repository's hook contract.
1897 return apply_filters('fluent_cart/review/customer_reviews_response', $responseData, [
1898 'customer' => $customer,
1899 ]);
1900 }
1901
1902 /**
1903 * Products the customer ordered and has not reviewed yet — the "to be
1904 * reviewed" list. Order eligibility matches hasOrderedProduct(), the
1905 * predicate canSubmitReview() uses: any order unlocks the review form,
1906 * so any order surfaces the product here.
1907 *
1908 * Every phase is bounded so cost cannot grow with the customer's whole
1909 * history: candidates come from their most recent order items only (one
1910 * indexed, LIMIT-ed scan — no grouping, no correlated subquery), then a
1911 * single whereIn query against the same identity-and-status predicate
1912 * the history list uses marks the reviewed ones, so the two tabs stay
1913 * complementary. Products older than the candidate window are
1914 * deliberately outside this nudge list.
1915 */
1916 public static function getPendingReviewProducts(Customer $customer, int $limit = 50): array
1917 {
1918 if (static::getReviewSettings()['reviews_enabled'] !== 'yes') {
1919 return [];
1920 }
1921
1922 $limit = min(50, max(1, $limit));
1923
1924 // Bounded candidate phases — bounded in rows SCANNED, not just rows
1925 // returned. Phase one reads the customer's most recent orders
1926 // straight off the customer_id index in reverse-id order (EXPLAIN:
1927 // Backward index scan, no filesort), so its cost is fixed by the
1928 // LIMIT however long the purchase history grows.
1929 $recentOrderIds = Order::query()
1930 ->where('customer_id', $customer->id)
1931 ->orderBy('id', 'DESC')
1932 ->limit(50)
1933 ->pluck('id')
1934 ->all();
1935
1936 if (!$recentOrderIds) {
1937 return [];
1938 }
1939
1940 // Phase two: those orders' items under a hard examined-row budget.
1941 // Each chunk query carries a LIMIT and NO ORDER BY, so the range
1942 // scan over the order_id index stops at the limit — one enormous
1943 // order can contribute at most a chunk's budget instead of forcing
1944 // a filesort of its entire contents. Chunks run newest orders
1945 // first, and each chunk's rows are re-ranked by order recency in
1946 // PHP (a bounded set), so candidate priority stays purchase
1947 // recency; within one order the items share their creation moment,
1948 // so intra-order order carries no information. Worst case examined:
1949 // 5 chunks x 200 rows.
1950 $orderRecency = array_flip($recentOrderIds);
1951 $candidates = [];
1952
1953 foreach (array_chunk($recentOrderIds, 10) as $orderIdChunk) {
1954 if (count($candidates) >= 200) {
1955 break;
1956 }
1957
1958 $chunkItems = OrderItem::query()
1959 ->select(['id', 'post_id', 'title', 'created_at', 'order_id'])
1960 ->whereIn('order_id', $orderIdChunk)
1961 ->limit(200)
1962 ->get()
1963 ->all();
1964
1965 usort($chunkItems, function ($a, $b) use ($orderRecency) {
1966 $aRank = isset($orderRecency[$a->order_id]) ? $orderRecency[$a->order_id] : PHP_INT_MAX;
1967 $bRank = isset($orderRecency[$b->order_id]) ? $orderRecency[$b->order_id] : PHP_INT_MAX;
1968
1969 if ($aRank !== $bRank) {
1970 return $aRank <=> $bRank;
1971 }
1972
1973 // Within one order, latest line item first — the tie-break
1974 // the previous created_at/id sort applied.
1975 return $b->id <=> $a->id;
1976 });
1977
1978 // Newest order's items land first, so the first sighting of a
1979 // product is its latest purchase — variation title included.
1980 foreach ($chunkItems as $item) {
1981 $itemPostId = (int) $item->post_id;
1982 if (!$itemPostId || isset($candidates[$itemPostId])) {
1983 continue;
1984 }
1985 $candidates[$itemPostId] = [
1986 'variation_title' => (string) $item->title,
1987 'last_purchased_at' => (string) $item->created_at,
1988 ];
1989 }
1990 }
1991
1992 if (!$candidates) {
1993 return [];
1994 }
1995
1996 $postIds = array_keys($candidates);
1997
1998 // Which candidates the customer already reviewed — one bounded
1999 // whereIn query on the shared identity/status predicate.
2000 $reviewedPostIds = array_fill_keys(
2001 array_map('intval', static::activeReviewsOfCustomerQuery($customer)
2002 ->whereIn('post_id', $postIds)
2003 ->pluck('post_id')
2004 ->all()),
2005 true
2006 );
2007
2008 // One query for every product's own reviews toggle and variation
2009 // type — never per row.
2010 $reviewsDisabledFor = [];
2011 $isVariableProduct = [];
2012 $details = ProductDetail::query()->whereIn('post_id', $postIds)->get();
2013 foreach ($details as $detail) {
2014 $otherInfo = $detail->other_info;
2015 if (isset($otherInfo['reviews_enabled']) && $otherInfo['reviews_enabled'] === 'no') {
2016 $reviewsDisabledFor[(int) $detail->post_id] = true;
2017 }
2018 // Positive signal only: an empty variation_type (drifted or
2019 // incomplete detail row) must read as simple, not variable —
2020 // otherwise a lone default variation named "Simple" leaks in.
2021 if ($detail->variation_type && $detail->variation_type !== 'simple') {
2022 $isVariableProduct[(int) $detail->post_id] = true;
2023 }
2024 }
2025
2026 _prime_post_caches($postIds, false, true);
2027
2028 $products = [];
2029 foreach ($candidates as $postId => $candidate) {
2030 if (count($products) >= $limit) {
2031 break;
2032 }
2033 if (
2034 isset($reviewedPostIds[$postId])
2035 || isset($reviewsDisabledFor[$postId])
2036 || get_post_status($postId) !== 'publish'
2037 ) {
2038 continue;
2039 }
2040
2041 $products[] = [
2042 'post_id' => $postId,
2043 'title' => static::productTitle($postId),
2044 'variation_title' => isset($isVariableProduct[$postId])
2045 ? $candidate['variation_title']
2046 : '',
2047 'url' => (string) get_permalink($postId),
2048 'thumbnail' => (string) get_the_post_thumbnail_url($postId, 'thumbnail'),
2049 'last_purchased_at' => $candidate['last_purchased_at'],
2050 ];
2051 }
2052
2053 return $products;
2054 }
2055
2056 /**
2057 * Where a notification about this review goes.
2058 *
2059 * The address the review was filed under, first: the storefront requires
2060 * it from a guest and copies it from the account or the order grant for
2061 * everyone else, so on that path it is always there. The fallbacks exist
2062 * for a review a moderator typed in by hand, which can be saved without
2063 * one — the account it names, then the customer it names. Never the
2064 * order: a review can be written before an order, after it, or from a
2065 * customer with several, and the order's address is a guess about a
2066 * different thing.
2067 *
2068 * Every step is checked, not just the first. A malformed address a
2069 * moderator typed fails as quietly as an empty one, and the fallback
2070 * behind it may be perfectly good.
2071 *
2072 * One resolver for every review notification, so no two emails about the
2073 * same review can be sent to different people. A reply notifies the
2074 * author of the review it answers, so callers pass the parent.
2075 *
2076 * @param ProductReview $review
2077 * @return string a valid address, or '' when there is none to send to
2078 */
2079 public static function resolveNotificationRecipient(ProductReview $review): string
2080 {
2081 $candidateEmails = [(string) $review->reviewer_email];
2082
2083 if ($review->user_id) {
2084 $user = get_userdata((int) $review->user_id);
2085 $candidateEmails[] = $user ? (string) $user->user_email : '';
2086 }
2087
2088 if ($review->customer_id) {
2089 $customer = Customer::query()->find((int) $review->customer_id);
2090 $candidateEmails[] = $customer ? (string) $customer->email : '';
2091 }
2092
2093 foreach ($candidateEmails as $candidateEmail) {
2094 $candidateEmail = trim($candidateEmail);
2095 if ($candidateEmail !== '' && is_email($candidateEmail)) {
2096 return $candidateEmail;
2097 }
2098 }
2099
2100 return '';
2101 }
2102
2103 /**
2104 * Take the lease to send this review's approval notice.
2105 *
2106 * @param ProductReview $review
2107 * @return string|null the lease token when this caller holds the lease; null when it does not
2108 */
2109 public static function claimApprovalNotice(ProductReview $review)
2110 {
2111 return static::claimNoticeOnce((int) $review->id, 'approval_notice');
2112 }
2113
2114 /**
2115 * The notifications already delivered for this review's approval under
2116 * an earlier, released lease — so a retry sends only what has not.
2117 *
2118 * @param int $reviewId
2119 * @return string[] notification names
2120 */
2121 public static function deliveredApprovalNotices(int $reviewId): array
2122 {
2123 return static::deliveredNotices($reviewId, 'approval_notice');
2124 }
2125
2126 /**
2127 * Record, under the held lease, the approval notifications delivered so
2128 * far — see recordNoticeDeliveries().
2129 *
2130 * @param int $reviewId
2131 * @param string $token
2132 * @param string[] $deliveredNames
2133 */
2134 public static function recordApprovalNoticeDeliveries(int $reviewId, string $token, array $deliveredNames): void
2135 {
2136 static::recordNoticeDeliveries($reviewId, 'approval_notice', $token, $deliveredNames);
2137 }
2138
2139 /**
2140 * Settle the approval notice's lease: delivered, or released. Only by
2141 * its holder — see settleNotice().
2142 *
2143 * @param int $reviewId
2144 * @param string $token
2145 * @param bool $delivered every notification went out
2146 * @param string[] $deliveredNames the notifications that did go out, delivered or not
2147 */
2148 public static function settleApprovalNotice(int $reviewId, string $token, bool $delivered, array $deliveredNames = []): void
2149 {
2150 static::settleNotice($reviewId, 'approval_notice', $token, $delivered, $deliveredNames);
2151 }
2152
2153 /**
2154 * Take the lease to tell the reviewer about this store reply. The lease
2155 * sits on the reply row, so each reply is announced once and a second
2156 * reply to the same review is announced on its own.
2157 *
2158 * @param ProductReview $reply
2159 * @return string|null the lease token when this caller holds the lease; null when it does not
2160 */
2161 public static function claimReplyNotice(ProductReview $reply)
2162 {
2163 return static::claimNoticeOnce((int) $reply->id, 'reply_notice');
2164 }
2165
2166 /**
2167 * The notifications already delivered for this reply under an earlier,
2168 * released lease.
2169 *
2170 * @param int $replyId
2171 * @return string[] notification names
2172 */
2173 public static function deliveredReplyNotices(int $replyId): array
2174 {
2175 return static::deliveredNotices($replyId, 'reply_notice');
2176 }
2177
2178 /**
2179 * Record, under the held lease, the reply notifications delivered so far
2180 * — see recordNoticeDeliveries().
2181 *
2182 * @param int $replyId
2183 * @param string $token
2184 * @param string[] $deliveredNames
2185 */
2186 public static function recordReplyNoticeDeliveries(int $replyId, string $token, array $deliveredNames): void
2187 {
2188 static::recordNoticeDeliveries($replyId, 'reply_notice', $token, $deliveredNames);
2189 }
2190
2191 /**
2192 * Settle the reply notice's lease: delivered, or released. Only by its
2193 * holder — see settleNotice().
2194 *
2195 * @param int $replyId
2196 * @param string $token
2197 * @param bool $delivered every notification went out
2198 * @param string[] $deliveredNames the notifications that did go out, delivered or not
2199 */
2200 public static function settleReplyNotice(int $replyId, string $token, bool $delivered, array $deliveredNames = []): void
2201 {
2202 static::settleNotice($replyId, 'reply_notice', $token, $delivered, $deliveredNames);
2203 }
2204
2205 /**
2206 * Take a once-only lease on a notice for a review row.
2207 *
2208 * A notice must go out once, however many jobs run for the same row at
2209 * once. A flag read then written from PHP cannot promise that — two jobs
2210 * read "unsent" together and both send. The lease is one conditional
2211 * UPDATE, so the row is handed to exactly one caller and the other sees
2212 * zero affected rows.
2213 *
2214 * A lease, not a receipt: it is taken before the send and settled after,
2215 * by settleNotice(). The key in other_info reads
2216 * (absent) never sent — claimable
2217 * sending leased, delivery in progress — not claimable until the
2218 * lease is older than NOTICE_LEASE_SECONDS
2219 * sent delivered — never claimable again
2220 * A send that fails or throws releases the lease (back to absent), so a
2221 * retry or a later event can send; a claim written as "sent" up front
2222 * would make every transport failure permanent. A lease nobody settled —
2223 * the process killed between the claim and its finally — would otherwise
2224 * hold the row forever, so a "sending" older than the timeout is treated
2225 * as abandoned and may be taken over.
2226 *
2227 * The lease names its holder: a token, returned to the caller and written
2228 * beside the state. Settling requires it, so a worker that stalled past
2229 * the timeout and wakes after another has taken the lease over cannot
2230 * close or release what is no longer its own.
2231 *
2232 * JSON_MERGE_PATCH rather than a PHP-side merge and save, for the same
2233 * reason recalculateProductRatings() uses it: other_info also carries the
2234 * review's photos, and a read-merge-write here would race the media
2235 * pipeline for the same blob. Only these keys are written.
2236 *
2237 * No double quotes anywhere in the statement — WPFluent rewrites every
2238 * double quote in a compiled query to a backtick. The key is one of two
2239 * literals from this class, never input, and is checked as such before it
2240 * is put into the JSON path.
2241 *
2242 * @param int $rowId
2243 * @param string $noticeKey 'approval_notice' or 'reply_notice'
2244 * @return string|null the lease token when this caller holds the lease; null when it does not
2245 */
2246 protected static function claimNoticeOnce(int $rowId, string $noticeKey)
2247 {
2248 global $wpdb;
2249
2250 if (!static::isNoticeKey($noticeKey)) {
2251 return null;
2252 }
2253
2254 $query = ProductReview::query();
2255 $token = wp_generate_password(16, false);
2256
2257 $state = static::noticeField($noticeKey);
2258 $leasedAt = static::noticeField($noticeKey . '_at');
2259
2260 // A lease older than this with nobody to settle it is abandoned.
2261 // Well past any send, which is seconds; short enough that a killed
2262 // worker does not silence a row for long.
2263 $abandonedBefore = DateTime::gmtNow()
2264 ->subSeconds(static::NOTICE_LEASE_SECONDS)
2265 ->format('Y-m-d H:i:s');
2266
2267 $claimedRowCount = $query
2268 ->where('id', $rowId)
2269 ->whereRaw($wpdb->prepare(
2270 "(" . $state . " IS NULL OR (" . $state . " = 'sending' AND " . $leasedAt . " < %s))",
2271 $abandonedBefore
2272 ))
2273 ->update([
2274 'other_info' => $query->raw($wpdb->prepare(
2275 "JSON_MERGE_PATCH(
2276 IF(other_info IS NULL OR other_info = '', '{}', other_info),
2277 JSON_OBJECT('" . $noticeKey . "', 'sending', '" . $noticeKey . "_at', %s, '" . $noticeKey . "_token', %s)
2278 )",
2279 DateTime::gmtNow()->format('Y-m-d H:i:s'),
2280 $token
2281 )),
2282 ]);
2283
2284 return (int) $claimedRowCount === 1 ? $token : null;
2285 }
2286
2287 /**
2288 * The notifications a released lease left as delivered, so the retry
2289 * sends only the rest.
2290 *
2291 * @param int $rowId
2292 * @param string $noticeKey
2293 * @return string[]
2294 */
2295 protected static function deliveredNotices(int $rowId, string $noticeKey): array
2296 {
2297 if (!static::isNoticeKey($noticeKey)) {
2298 return [];
2299 }
2300
2301 $row = ProductReview::query()->find($rowId);
2302 $names = $row && is_array($row->other_info)
2303 ? Arr::get($row->other_info, $noticeKey . '_delivered', [])
2304 : [];
2305
2306 return array_values(array_filter(array_map('strval', (array) $names)));
2307 }
2308
2309 /**
2310 * Record, under a held lease, the notifications delivered so far — before
2311 * the next one is attempted and before the lease is settled.
2312 *
2313 * Written the moment the transport accepts a message rather than at the
2314 * end of the loop, so the window in which a killed worker leaves a
2315 * delivery unrecorded — and a later holder repeats it — is the one UPDATE
2316 * after the send, not the whole send. Only by the holder, like settle.
2317 *
2318 * @param int $rowId
2319 * @param string $noticeKey
2320 * @param string $token
2321 * @param string[] $deliveredNames every notification delivered under this and earlier leases
2322 */
2323 protected static function recordNoticeDeliveries(int $rowId, string $noticeKey, string $token, array $deliveredNames): void
2324 {
2325 global $wpdb;
2326
2327 if (!static::isNoticeKey($noticeKey)) {
2328 return;
2329 }
2330
2331 $deliveredNames = array_values(array_unique(array_filter(array_map('strval', $deliveredNames))));
2332 if (!$deliveredNames) {
2333 return;
2334 }
2335
2336 $query = ProductReview::query();
2337 $deliveredJson = $wpdb->prepare('JSON_ARRAY(' . implode(', ', array_fill(0, count($deliveredNames), '%s')) . ')', $deliveredNames);
2338
2339 $query
2340 ->where('id', $rowId)
2341 ->whereRaw($wpdb->prepare(
2342 static::noticeField($noticeKey) . " = 'sending' AND " . static::noticeField($noticeKey . '_token') . " = %s",
2343 $token
2344 ))
2345 ->update([
2346 'other_info' => $query->raw(
2347 "JSON_MERGE_PATCH(IF(other_info IS NULL OR other_info = '', '{}', other_info), JSON_OBJECT('" . $noticeKey . "_delivered', " . $deliveredJson . "))"
2348 ),
2349 ]);
2350 }
2351
2352 /**
2353 * Settle a lease taken by claimNoticeOnce(): delivered, or not.
2354 *
2355 * Only by its holder: the row is touched only while it still reads
2356 * "sending" under this token. A lease taken over after the timeout
2357 * carries a new token, and the stalled worker's late settle finds
2358 * nothing to settle.
2359 *
2360 * Delivered closes the notice for good. Not delivered releases the lease —
2361 * the keys are removed, so the row reads as never sent and the next job or
2362 * event can try again — but keeps the names of the notifications that did
2363 * go out under it, so that retry does not send them a second time. One
2364 * lease covers every notification switched on for the event, and a
2365 * failure on the second must not repeat the first. (In a JSON merge patch,
2366 * null removes a key.)
2367 *
2368 * @param int $rowId
2369 * @param string $noticeKey
2370 * @param string $token the token claimNoticeOnce() handed this caller
2371 * @param bool $delivered every notification went out
2372 * @param string[] $deliveredNames the notifications that did go out, delivered or not
2373 */
2374 protected static function settleNotice(int $rowId, string $noticeKey, string $token, bool $delivered, array $deliveredNames = []): void
2375 {
2376 global $wpdb;
2377
2378 if (!static::isNoticeKey($noticeKey)) {
2379 return;
2380 }
2381
2382 $query = ProductReview::query();
2383
2384 if ($delivered) {
2385 $patch = $wpdb->prepare(
2386 "JSON_OBJECT('" . $noticeKey . "', 'sent', '" . $noticeKey . "_at', %s, '" . $noticeKey . "_token', NULL, '" . $noticeKey . "_delivered', NULL)",
2387 DateTime::gmtNow()->format('Y-m-d H:i:s')
2388 );
2389 } else {
2390 $deliveredNames = array_values(array_unique(array_filter(array_map('strval', $deliveredNames))));
2391 // JSON_ARRAY() of bound strings, the way the rest of the patch is
2392 // built — names are notification registry keys, never typed by
2393 // a user, but they are bound rather than interpolated all the same.
2394 $deliveredJson = $deliveredNames
2395 ? $wpdb->prepare('JSON_ARRAY(' . implode(', ', array_fill(0, count($deliveredNames), '%s')) . ')', $deliveredNames)
2396 : 'NULL';
2397 $patch = "JSON_OBJECT('" . $noticeKey . "', NULL, '" . $noticeKey . "_at', NULL, '" . $noticeKey . "_token', NULL, '" . $noticeKey . "_delivered', " . $deliveredJson . ")";
2398 }
2399
2400 $query
2401 ->where('id', $rowId)
2402 ->whereRaw($wpdb->prepare(
2403 static::noticeField($noticeKey) . " = 'sending' AND " . static::noticeField($noticeKey . '_token') . " = %s",
2404 $token
2405 ))
2406 ->update([
2407 'other_info' => $query->raw(
2408 "JSON_MERGE_PATCH(IF(other_info IS NULL OR other_info = '', '{}', other_info), " . $patch . ")"
2409 ),
2410 ]);
2411 }
2412
2413 /**
2414 * The SQL that reads one notice field out of other_info, empty or NULL
2415 * blobs included. The key is one of this class's own literals — see
2416 * isNoticeKey() — never input.
2417 */
2418 protected static function noticeField(string $key): string
2419 {
2420 return "JSON_UNQUOTE(JSON_EXTRACT(IF(other_info IS NULL OR other_info = '', '{}', other_info), '$." . $key . "'))";
2421 }
2422
2423 /**
2424 * The two notice keys this class writes. Anything else never reaches a
2425 * JSON path.
2426 */
2427 protected static function isNoticeKey(string $noticeKey): bool
2428 {
2429 return in_array($noticeKey, ['approval_notice', 'reply_notice'], true);
2430 }
2431 }
2432