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

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

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