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 / Http / Controllers / FrontendControllers / ProductReviewFrontendController.php

ProductReviewFrontendController.php in FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler 1.8.0, at app/Http/Controllers/FrontendControllers/ProductReviewFrontendController.php

1,005 lines 46.1 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\Http\Controllers\FrontendControllers;
4
5 use FluentCart\App\Helpers\Status;
6 use FluentCart\Api\Resource\CustomerResource;
7 use FluentCart\Api\Resource\ProductReviewResource;
8 use FluentCart\App\Events\ReviewApproved;
9 use FluentCart\App\Events\ReviewCreated;
10 use FluentCart\App\Http\Requests\FrontendRequests\ReviewRequest;
11 use FluentCart\App\Models\Customer;
12 use FluentCart\App\Models\Product;
13 use FluentCart\App\Models\ProductReview;
14 use FluentCart\App\Services\ProductReviewService;
15 use FluentCart\App\Services\ReviewSubmissionLimiter;
16 use FluentCart\App\Services\Renderer\ProductReviewRenderer;
17 use FluentCart\App\Hooks\Handlers\BlockEditors\ProductReviewList\ProductReviewListBlockEditor;
18 use FluentCart\App\Services\Renderer\ReviewListRenderer;
19 use FluentCart\App\Services\Renderer\ReviewModalRenderer;
20 use FluentCart\App\Services\Renderer\ReviewThreadMarkup;
21 use FluentCart\Framework\Http\Request\Request;
22 use FluentCart\Framework\Support\Arr;
23
24 class ProductReviewFrontendController extends BaseFrontendController
25 {
26 /**
27 * The most reviews one page may ask for.
28 *
29 * per_page comes off the query string, so this is the guard against a
30 * request asking for every review at once. Well above anything the block's
31 * own control offers, so it never gets in an editor's way.
32 */
33 const MAX_REVIEWS_PER_PAGE = 100;
34
35 /**
36 * The longest trim a caller may ask for. Past this the limit stops being
37 * a trim: a review that long was going to print in full anyway.
38 */
39 const MAX_REVIEW_WORDS = 500;
40
41 const MAX_ALL_PRODUCTS_REVIEWS_PER_PAGE = ProductReviewService::MAX_ALL_PRODUCTS_REVIEWS_PER_PAGE;
42
43 public function getReviews(ReviewRequest $request, $postId)
44 {
45 $postId = intval($postId);
46
47 // Only allow reading reviews for published products
48 if (!ProductReviewService::isReviewEnabledForProduct($postId)
49 || !Product::query()->where('post_status', 'publish')->find($postId)) {
50 return $this->sendError([
51 'message' => __('Product not found', 'fluent-cart'),
52 ], 404);
53 }
54
55 $data = [
56 // 0, not 10: absent means "the store's setting", resolved below.
57 'per_page' => intval($request->get('per_page', 0)),
58 'sort_by' => sanitize_text_field($request->get('sort_by', 'created_at')),
59 'sort_order' => sanitize_text_field($request->get('sort_order', 'DESC')),
60 'rating' => intval($request->get('rating', 0)),
61 'has_media' => intval($request->get('has_media', 0)),
62 'verified_only' => intval($request->get('verified_only', 0)),
63 ];
64
65 $settings = ProductReviewService::getReviewSettings();
66
67 // The store's setting is the default, not a ceiling. Capping at it made
68 // a block's own Reviews Per Page a lie: the first render honoured the
69 // block and every page after it silently dropped to the store's number,
70 // so the rows-per-page changed under the reader — and in grid view the
71 // columns went ragged on the first click.
72 $perPage = $data['per_page'] > 0
73 ? $data['per_page']
74 : (int) $settings['reviews_per_page'];
75 $perPage = max(1, min($perPage, static::MAX_REVIEWS_PER_PAGE));
76
77 $params = [
78 'post_id' => $postId,
79 'status' => 'approved',
80 'page' => max(1, intval($request->get('page', 1))),
81 'sort_by' => $data['sort_by'] ?? 'created_at',
82 'sort_order' => $data['sort_order'] ?? 'DESC',
83 'per_page' => $perPage,
84 // A signed per-instance floor takes precedence over the legacy
85 // composition default. Neither changes public-review permissions.
86 'min_rating' => ProductReviewListBlockEditor::minRatingOfComposition(
87 $request->get('client_id', ''),
88 (int) $postId,
89 (string) $request->get('rating_token', '')
90 ),
91 ];
92
93 // ratings=5,4 from the star chips; the single rating param still works.
94 $ratings = ProductReviewService::ratingList($request->get('ratings', ''));
95 $rating = !empty($data['rating']) ? (int) $data['rating'] : null;
96
97 if ($ratings) {
98 $params['ratings'] = $ratings;
99 } elseif ($rating && $rating >= 1 && $rating <= 5) {
100 $params['rating'] = $rating;
101 }
102
103 if (!empty($data['has_media'])) {
104 $params['has_media'] = true;
105 }
106
107 if (!empty($data['verified_only'])) {
108 $params['verified_only'] = true;
109 }
110
111 $responseData = ProductReviewService::getPublicReviewsPayload($params, $postId);
112
113 // The rows render on the server, after the filter so PRO's media and
114 // vote fields are part of them; the JS swaps this markup in. Display
115 // flags travel with the request since they are block attributes the
116 // server cannot otherwise know.
117 $listRenderer = $this->buildListRendererFromRequest($request, [
118 // The composed row again, rebuilt from the post the block is saved
119 // in. Without this the first sort, filter or page swaps the
120 // editor's row for the fixed one and never gives it back — the
121 // saved composition lasting exactly one page load.
122 'rows_renderer' => ProductReviewListBlockEditor::savedRowRenderer(
123 $request->get('client_id', '')
124 ),
125 ]);
126
127 $responseData['reviews_html'] = $listRenderer->renderReviewItems(Arr::get($responseData, 'reviews.data', []));
128 $responseData['pagination_html'] = $listRenderer->paginationHtml(Arr::get($responseData, 'reviews', []));
129
130 return $this->sendSuccess($responseData);
131 }
132
133 /**
134 * Rows and pager for a list request. Display flags are block attributes the server
135 * cannot otherwise know, so they travel with the request and are bounded here.
136 *
137 * @param Request $request
138 * @param array $extraOptions Options the caller adds or overrides.
139 * @return ReviewListRenderer
140 */
141 protected function buildListRendererFromRequest($request, array $extraOptions = []): ReviewListRenderer
142 {
143 return new ReviewListRenderer(array_merge([
144 'show_reviewer' => $request->get('show_reviewer', '1') !== '0',
145 'show_date' => $request->get('show_date', '1') !== '0',
146 'show_verified' => $request->get('show_verified', '1') !== '0',
147 'show_view_reply' => $request->get('show_view_reply', '1') !== '0',
148 'show_avatar' => (int) $request->get('show_avatar', 1) !== 0,
149 'show_title' => (int) $request->get('show_title', 1) !== 0,
150 'show_content' => (int) $request->get('show_content', 1) !== 0,
151 'show_photos' => (int) $request->get('show_photos', 1) !== 0,
152 'show_footer' => (int) $request->get('show_footer', 1) !== 0,
153 'show_variation' => (int) $request->get('show_variation', 1) !== 0,
154 'can_thread' => ProductReviewService::isMultipleRepliesAllowed() && is_user_logged_in(),
155 // Whitelisted, not passed through: it selects a render path, and
156 // an unknown value would silently fall back to the numbered pager
157 // on every page change while the first render kept the chosen one.
158 'max_words' => max(0, min(static::MAX_REVIEW_WORDS, (int) $request->get('max_words', 0))),
159 // Bounded the same way the first render bounds them — this is the
160 // same editor input arriving by a different door.
161 'media_visible' => ProductReviewRenderer::mediaVisibleCount($request->get('media_visible', 0)),
162 'media_width' => ProductReviewRenderer::mediaTileSize($request->get('media_width', 0)),
163 'media_height' => ProductReviewRenderer::mediaTileSize($request->get('media_height', 0)),
164 'media_full_width' => $request->get('media_full_width', '0') === '1',
165 'media_flush' => $request->get('media_flush', '0') === '1',
166 'media_backdrop' => $request->get('media_backdrop', '0') === '1',
167 'photos_first' => $request->get('photos_first', '0') === '1',
168 'rating_first' => $request->get('rating_first', '0') === '1',
169 'badge_last' => $request->get('badge_last', '0') === '1',
170 'show_meta' => $request->get('show_meta', '1') !== '0',
171 // Whitelisted, not taken as given -- this lands in a class
172 // attribute and arrives from a public request.
173 'item_class' => ReviewListRenderer::itemClass($request->get('item_class', '')),
174 'media_more' => ReviewThreadMarkup::moreTilePlacement($request->get('media_more', 'overlay')),
175 'pagination_type' => in_array(
176 $request->get('pagination_type', 'numbers'),
177 ReviewListRenderer::paginationTypes(),
178 true
179 ) ? $request->get('pagination_type', 'numbers') : 'numbers',
180 ], $extraOptions));
181 }
182
183 /**
184 * Approved reviews across the store, for [fluent_cart_product_reviews scope="all"].
185 * Same list contract as getReviews(), narrowed by product and category ids.
186 */
187 public function getAllProductsReviews(Request $request)
188 {
189 $settings = ProductReviewService::getReviewSettings();
190
191 if (Arr::get($settings, 'reviews_enabled') !== 'yes') {
192 return $this->sendError([
193 'message' => __('Reviews are not available', 'fluent-cart'),
194 ], 404);
195 }
196
197 $perPage = (int) $request->get('per_page', 0);
198 $perPage = $perPage > 0 ? $perPage : (int) Arr::get($settings, 'reviews_per_page', 10);
199 $perPage = max(1, min($perPage, static::MAX_ALL_PRODUCTS_REVIEWS_PER_PAGE));
200
201 $params = array_merge(ProductReviewService::sanitizeProductFilters([
202 'product_ids' => $request->get('product_ids', ''),
203 'category_ids' => $request->get('category_ids', ''),
204 ]), [
205 'page' => max(1, intval($request->get('page', 1))),
206 'sort_by' => sanitize_text_field($request->get('sort_by', 'created_at')),
207 'sort_order' => sanitize_text_field($request->get('sort_order', 'DESC')),
208 'per_page' => $perPage,
209 // A floor the renderer signed for post id 0. Without a valid token there is
210 // no floor; every approved review is public, so that only widens the list.
211 'min_rating' => (int) ProductReviewService::minRatingFromToken(0, (string) $request->get('rating_token', '')),
212 ]);
213
214 $ratings = ProductReviewService::ratingList($request->get('ratings', ''));
215 $rating = intval($request->get('rating', 0));
216
217 if ($ratings) {
218 $params['ratings'] = $ratings;
219 } elseif ($rating >= 1 && $rating <= 5) {
220 $params['rating'] = $rating;
221 }
222
223 if (intval($request->get('has_media', 0))) {
224 $params['has_media'] = true;
225 }
226
227 if (intval($request->get('verified_only', 0))) {
228 $params['verified_only'] = true;
229 }
230
231 $responseData = ProductReviewService::getAllProductsReviewsPayload($params);
232
233 if ($request->get('include_summary', '0') === '1') {
234 $summary = ProductReviewService::getProductsRatingSummary($params);
235 $responseData['summary'] = $summary;
236 $responseData['stars_html'] = (new ProductReviewRenderer(0))->starsHtml(Arr::get($summary, 'average', 0));
237 /* translators: 1: the average star rating, e.g. "Rated 4.5 out of 5" */
238 $responseData['stars_label'] = sprintf(__('Rated %1$s out of 5', 'fluent-cart'), Arr::get($summary, 'average', 0));
239 }
240
241 $listRenderer = $this->buildListRendererFromRequest($request, [
242 // A Review List block's custom row; an unknown token keeps the fixed row.
243 'rows_renderer' => ProductReviewListBlockEditor::savedRowRenderer($request->get('client_id', '')),
244 ]);
245
246 $responseData['reviews_html'] = $listRenderer->renderReviewItems(Arr::get($responseData, 'reviews.data', []));
247 $responseData['pagination_html'] = $listRenderer->paginationHtml(Arr::get($responseData, 'reviews', []));
248
249 return $this->sendSuccess($responseData);
250 }
251
252 public function getRatingSummary(Request $request, $postId)
253 {
254 $postId = intval($postId);
255
256 if (!ProductReviewService::isReviewEnabledForProduct($postId)
257 || !Product::query()->where('post_status', 'publish')->find($postId)) {
258 return $this->sendError([
259 'message' => __('Product not found', 'fluent-cart'),
260 ], 404);
261 }
262
263 $summary = ProductReviewService::getProductRatingSummary($postId);
264
265 // Check if current user can submit a review
266 $canSubmit = ProductReviewService::canSubmitReview($postId, $request);
267
268 /* translators: 1: the average star rating, e.g. "Rated 4.5 out of 5" */
269 $starsLabel = sprintf(__('Rated %1$s out of 5', 'fluent-cart'), Arr::get($summary, 'average', 0));
270
271 return $this->sendSuccess([
272 'summary' => $summary,
273 'can_submit' => $canSubmit,
274 // The average-star row rendered server-side, so the script swaps
275 // markup instead of assembling it.
276 'stars_html' => (new ProductReviewRenderer($postId))->starsHtml(Arr::get($summary, 'average', 0)),
277 'stars_label' => $starsLabel,
278 ]);
279 }
280
281 public function submitReview(ReviewRequest $request, $postId)
282 {
283 // No explicit nonce check here, deliberately. This endpoint accepts guest
284 // submissions in 'anyone' permission mode, and the nonce is rendered into the
285 // page HTML — under full-page caching it outlives its 12-24h validity and a
286 // hard check would reject legitimate guest reviews.
287 //
288 // CSRF is already neutralised upstream: these are register_rest_route() routes,
289 // so WP's rest_cookie_check_errors() calls wp_set_current_user(0) on a nonce-less
290 // request. A forged cross-site POST therefore arrives with no identity to abuse
291 // and lands on the guest path, which requires a name and email and is rate
292 // limited and moderated. updateReview() does check the nonce —
293 // both require an authenticated user, where a stale nonce is not a concern
294 // because caches bypass logged-in requests.
295 $postId = intval($postId);
296
297 $ip = !empty($_SERVER['REMOTE_ADDR']) ? sanitize_text_field($_SERVER['REMOTE_ADDR']) : '';
298 $userId = get_current_user_id();
299
300 // The variation the review is about, from the request body and
301 // nowhere else. Only the order-review page sends one, and only a
302 // grant covering that exact item can honour it — checked below, once
303 // the product is known to exist. Read here because the grant lookup
304 // that decides the rate-limit exemption is already keyed on it.
305 $itemId = max(0, (int) $request->get('item_id', 0));
306
307 // A submission carrying a valid order grant is measured against its
308 // own bucket, not the per-IP one. That limit exists to stop anonymous
309 // bulk review spam and is the wrong instrument here: the order-review
310 // page invites a customer to review every product they bought in one
311 // sitting, so a six-item order would trip a five-per-hour cap on a
312 // legitimate last review. The grant's own cap is the order's size
313 // plus a little slack for a retry — enough for every line, not
314 // enough to flood moderation from one purchase, however many times
315 // an earlier review from that link is trashed and the slot reopens.
316 $orderHash = (string) $request->get('order_hash', '');
317 $grant = ProductReviewService::resolveOrderGrant($postId, $orderHash, $itemId);
318
319 // Rate limit: max 5 review submissions per identity per hour.
320 $limit = 5;
321 if ($grant) {
322 $identity = 'g' . (int) $grant->id;
323 $limit = max($limit, ProductReviewService::grantProductCount($orderHash) + 2);
324 } elseif ($userId) {
325 $identity = 'u' . $userId;
326 } elseif ($ip && filter_var($ip, FILTER_VALIDATE_IP)) {
327 $identity = 'ip_' . md5($ip);
328 } else {
329 // No usable identity means the limit cannot be enforced. Deny rather than
330 // fall through unlimited — an unattributable submission is the one case
331 // where skipping the limit would be most costly.
332 return $this->sendError([
333 'message' => __('Unable to verify identity.', 'fluent-cart'),
334 ], 400);
335 }
336
337 $count = ReviewSubmissionLimiter::increment($identity);
338
339 if ($count > $limit) {
340 return $this->sendError([
341 'message' => __('Too many submissions. Please try again later.', 'fluent-cart'),
342 ], 429);
343 }
344
345 // Verify product exists and is published
346 $product = Product::query()->where('post_status', 'publish')->find($postId);
347 if (!$product) {
348 return $this->sendError([
349 'message' => __('Product not found', 'fluent-cart'),
350 ], 404);
351 }
352
353 // The item must be one of this product's, and a simple product files
354 // at product level whatever was sent. A stray id is refused rather
355 // than quietly dropped, so the response never claims a review it did
356 // not write.
357 $grant = ProductReviewService::resolveOrderGrant($postId, $request->get('order_hash'), $itemId);
358 $resolvedItemId = ProductReviewService::resolveReviewItem($postId, $itemId);
359 if ($resolvedItemId === null) {
360 return $this->sendError([
361 'message' => __('That item is not available for review.', 'fluent-cart'),
362 ], 422);
363 }
364
365 // resolveReviewItem() can downgrade a simple product's item to 0, in
366 // which case the grant has to be re-read for the product-level slot.
367 if ($resolvedItemId !== $itemId) {
368 $itemId = $resolvedItemId;
369 $grant = ProductReviewService::resolveOrderGrant($postId, $request->get('order_hash'), $itemId);
370 }
371
372 // An item-level review is only ever written from the order-review
373 // page, where the order line names the item. Without a grant for that
374 // item — no hash, a hash for another order, or an order that holds a
375 // different variant — the request is refused, never downgraded to a
376 // product-level review the visitor did not ask for.
377 if ($itemId && !$grant) {
378 return $this->sendError([
379 'message' => __('Reviews of a specific item are only accepted through the order link that includes it.', 'fluent-cart'),
380 ], 403);
381 }
382
383 // Check if user can submit review. Listeners read item_id off the
384 // request; the filter keeps its three-argument shape.
385 $canSubmit = ProductReviewService::canSubmitReview($postId, $request, $itemId);
386 $canSubmit = apply_filters('fluent_cart/review/can_submit', $canSubmit, $postId, $request);
387 if (!is_array($canSubmit) || !$canSubmit['can_submit']) {
388 return $this->sendError([
389 'message' => $canSubmit['message'],
390 ], 403);
391 }
392
393 $data = $request->getSafe($request->sanitize());
394 $settings = ProductReviewService::getReviewSettings();
395
396 if (empty(trim($data['content'] ?? ''))) {
397 return $this->sendError([
398 'message' => __('Review content is required.', 'fluent-cart'),
399 ], 422);
400 }
401
402 // Clamp rating to valid range, then enforce requirement based on settings
403 $rating = isset($data['rating']) ? ProductReviewService::clampRating($data['rating']) : 0;
404 if (ProductReviewService::isStarRatingRequired() && $rating < 1) {
405 return $this->sendError([
406 'message' => __('Star rating is required', 'fluent-cart'),
407 ], 422);
408 }
409
410 $reviewData = [
411 'post_id' => $postId,
412 'item_id' => $itemId,
413 'rating' => $rating,
414 'title' => isset($data['title']) ? $data['title'] : '',
415 'content' => $data['content'],
416 'status' => $settings['auto_approve_reviews'] === 'yes' ? 'approved' : 'pending',
417 'is_verified' => 0,
418 'user_id' => $userId ?: 0,
419 'customer_id' => null,
420 'order_id' => null,
421 'reviewer_name' => '',
422 'reviewer_email' => '',
423 ];
424
425 // A submission the order hash authorised posts as the order's customer,
426 // whether or not anyone is signed in. Deciding this before the
427 // logged-in branch is the point: otherwise a forwarded review link
428 // opened by someone signed in to their own account would publish a
429 // review under THEIR name for a product they never bought — the exact
430 // thing verified_buyers mode exists to prevent.
431 $grantOwns = ProductReviewService::grantOwnsSubmission($grant);
432 $grantIdentity = $grantOwns ? ProductReviewService::orderGrantIdentity($grant) : null;
433 $grantEmail = $grantIdentity ? trim((string) Arr::get($grantIdentity, 'email', '')) : '';
434
435 // Set reviewer info based on the grant, the logged in user, or guest.
436 // A grant that owns the submission but has no customer left to name
437 // (the row is gone) takes the typed fields even from a signed-in
438 // visitor: the review is the buyer's, filed against their order, and
439 // must not land under whoever happened to be logged in when the link
440 // was opened — their account would otherwise own, and could edit, a
441 // review of something they never bought.
442 if ($grantEmail !== '') {
443 $reviewData['reviewer_name'] = Arr::get($grantIdentity, 'name', '');
444 $reviewData['reviewer_email'] = $grantEmail;
445 $reviewData['customer_id'] = Arr::get($grantIdentity, 'customer_id');
446 $reviewData['order_id'] = $grant->id;
447
448 // The review belongs to the buyer, so it carries the buyer's user
449 // id when they have one — not the id of whoever opened the link.
450 // It also keeps the duplicate guard working if that buyer later
451 // signs in and tries to review the same product again, since that
452 // check is keyed on user_id.
453 $buyer = $grant->customer;
454 $reviewData['user_id'] = ($buyer && $buyer->user_id) ? (int) $buyer->user_id : 0;
455
456 // Same rule as the logged-in path: the badge tracks a successful
457 // purchase, which is stricter than the grant.
458 if ($reviewData['customer_id'] && ProductReviewService::isVerifiedPurchase($postId, $reviewData['customer_id'])) {
459 $reviewData['is_verified'] = 1;
460 }
461 } elseif ($userId && !$grantOwns) {
462 $user = get_userdata($userId);
463 $reviewData['reviewer_name'] = $user ? $user->display_name : '';
464 $reviewData['reviewer_email'] = $user ? $user->user_email : '';
465
466 $customer = ProductReviewService::visitorCustomer($userId);
467 if ($customer) {
468 $reviewData['customer_id'] = $customer->id;
469
470 // Check if verified purchase
471 if (ProductReviewService::isVerifiedPurchase($postId, $customer->id)) {
472 $reviewData['is_verified'] = 1;
473 }
474 }
475 } else {
476 $reviewerName = isset($data['reviewer_name']) ? trim($data['reviewer_name']) : '';
477 $reviewerEmail = isset($data['reviewer_email']) ? trim($data['reviewer_email']) : '';
478
479 // Reached only when no grant supplied an identity — including the
480 // case of a grant whose customer row is gone, where the form shows
481 // the visitor real name and email fields to fill in.
482 if (empty($reviewerName)) {
483 return $this->sendError([
484 'message' => __('Name is required', 'fluent-cart'),
485 ], 422);
486 }
487 if (empty($reviewerEmail) || !is_email($reviewerEmail)) {
488 return $this->sendError([
489 'message' => __('A valid email address is required', 'fluent-cart'),
490 ], 422);
491 }
492
493 $reviewData['reviewer_name'] = $reviewerName;
494 $reviewData['reviewer_email'] = $reviewerEmail;
495 // Typed identity is nobody's account, whoever is signed in.
496 $reviewData['user_id'] = 0;
497 $reviewData['customer_id'] = null;
498 }
499
500 // Whatever identity the row ends up under, a grant-authorised review
501 // records the order that authorised it — the buyer signed in as
502 // themselves, and a grant whose customer row is gone, included.
503 if ($grant) {
504 $reviewData['order_id'] = (int) $grant->id;
505 }
506
507 // Whether this submission needs moderating, settled before the trust
508 // fields are captured below — the store's own rule, plus anything that
509 // owns a fact the rule depends on. PRO holds photo reviews back here
510 // when the store auto-approves reviews but not photo reviews: free
511 // cannot see that a submission carries photos.
512 $reviewData['status'] = ProductReviewService::applySubmissionStatusFilter(
513 $reviewData['status'],
514 $reviewData,
515 $request
516 );
517
518 // Trust fields must never come from user input or a filter — the
519 // service captures them before the filter and re-imposes them after.
520 $reviewData = ProductReviewService::applySubmitDataFilter($reviewData, $request, $postId);
521
522 // Claim the (product, identity) slot atomically so a second concurrent
523 // request for the same product + identity cannot slip past the
524 // duplicate check above before this one finishes inserting. The slot
525 // belongs to the identity the row is filed under — the buyer when a
526 // grant owns the submission — never to whoever opened the link.
527 $identity = ProductReviewService::effectiveReviewerIdentity($grant, $reviewData['reviewer_email']);
528 $duplicateMessage = $identity['user_id'] || $identity['via_grant']
529 ? __('You have already submitted a review for this product', 'fluent-cart')
530 : __('A review with this email already exists for this product', 'fluent-cart');
531
532 $lock = ProductReviewService::claimReviewSlot(
533 $postId,
534 $identity['user_id'],
535 ProductReviewService::slotLockEmail($identity, $reviewData['reviewer_email']),
536 $itemId,
537 $identity['customer_id']
538 );
539 if (!$lock) {
540 return $this->sendError(['message' => $duplicateMessage], 409);
541 }
542
543 try {
544 // The duplicate guard ran before the lock was held. A request that
545 // passed it while another holder of this same slot was still
546 // inserting would otherwise insert a second row the moment that
547 // holder released — so the guard runs once more, now serialised.
548 //
549 // Keyed to the same slot as the lock and the first check. Without
550 // $itemId it asks about the product-level slot instead: a buyer
551 // who already reviewed the product as a whole was refused when
552 // reviewing one of its variations from the order link, and two
553 // concurrent submissions for the same variation both passed,
554 // which is the race this recheck exists to close.
555 $recheck = ProductReviewService::canSubmitReview($postId, $request, $itemId);
556 $recheck = apply_filters('fluent_cart/review/can_submit', $recheck, $postId, $request);
557 if (!is_array($recheck) || !$recheck['can_submit']) {
558 // The guard names its own reason — reviews switched off for
559 // the product mid-flight, an add-on refusing — and only
560 // falls back to the duplicate wording when it gives none.
561 $message = is_array($recheck) && !empty($recheck['message']) ? $recheck['message'] : $duplicateMessage;
562
563 return $this->sendError(['message' => $message], 409);
564 }
565
566 $review = ProductReviewResource::create($reviewData);
567 } finally {
568 ProductReviewService::releaseReviewSlot($lock);
569 }
570
571 if (is_wp_error($review)) {
572 return $review;
573 }
574
575 // Run after_submit hooks first (media attachment, etc.) so they complete
576 // before the event dispatch which may trigger email notifications
577 do_action('fluent_cart/review/after_submit', $review, $request);
578
579 // Born approved, if the store auto-approves. Decided on the row as it
580 // is now, not as it was written: a hook above can hold a photo review
581 // back, and the hooks save other_info from a model, so the approval
582 // notice's claim on that blob has to land after them, not under them.
583 $savedReview = ProductReviewResource::find($review->id, ['with' => []]);
584 if ($savedReview && !is_wp_error($savedReview)) {
585 ReviewApproved::dispatchIfApproved($savedReview);
586 }
587
588 // Dispatch event (triggers email notifications)
589 (new ReviewCreated($review))->dispatch();
590
591 $message = $reviewData['status'] === 'approved'
592 ? __('Thank you for your review!', 'fluent-cart')
593 : __('Thank you! Your review has been submitted and is pending approval.', 'fluent-cart');
594
595 $message = apply_filters('fluent_cart/review/submit_success_message', $message, $review);
596
597 // The hooks wrote onto the row (photos, counts); re-read so the form
598 // gets the review as saved and can turn itself into the edit form.
599 $saved = ProductReviewResource::find($review->id, ['with' => []]);
600 if ($saved && !is_wp_error($saved)) {
601 $review = $saved;
602 }
603
604 // Match the hiding applied by getReviews — the create response must not be the
605 // one path that serialises reviewer_email and the internal id columns.
606 if ($review && method_exists($review, 'makeHidden')) {
607 $review->makeHidden(['reviewer_email', 'user_id', 'customer_id', 'order_id', 'meta']);
608 }
609
610 /**
611 * The answer to a submission, before it is sent. An extension that
612 * did work in after_submit — storing the photos that came with the
613 * request — reports on it here, so the form can tell the reviewer
614 * what happened to each part of what they sent.
615 *
616 * @param array $payload message and review
617 * @param object $review the saved review
618 * @param object $request
619 * @param bool $isUpdate false: a new review
620 */
621 $payload = apply_filters('fluent_cart/review/submit_response', [
622 'message' => $message,
623 'review' => $review,
624 'media' => static::mediaForResponse($review),
625 'can_edit' => $userId > 0 && (int) $review->user_id === $userId,
626 ], $review, $request, false);
627
628 return $this->sendSuccess($payload);
629 }
630
631 /**
632 * The photos on a review, as the form's uploader shows them: id and url,
633 * from the media refs the row carries. An empty list without photos.
634 *
635 * @param mixed $review
636 * @return array
637 */
638 protected static function mediaForResponse($review): array
639 {
640 $items = is_object($review) && isset($review->media) && is_array($review->media) ? $review->media : [];
641
642 $media = [];
643 foreach ($items as $item) {
644 $id = (int) Arr::get($item, 'attachment_id', 0);
645 $url = (string) Arr::get($item, 'url', '');
646 if ($id && $url) {
647 $media[] = ['id' => $id, 'url' => esc_url($url), 'name' => sanitize_text_field((string) Arr::get($item, 'name', ''))];
648 }
649 }
650
651 return $media;
652 }
653
654 public function updateReview(ReviewRequest $request, $postId, $reviewId)
655 {
656 // CSRF protection — verify WordPress REST nonce (see submitReview).
657 if (!wp_verify_nonce($request->get_header('X-WP-Nonce'), 'wp_rest')) {
658 return $this->sendError([
659 'message' => __('Session expired. Please refresh and try again.', 'fluent-cart'),
660 ], 403);
661 }
662
663 $postId = intval($postId);
664 $reviewId = intval($reviewId);
665 $userId = get_current_user_id();
666
667 if (!$userId) {
668 return $this->sendError([
669 'message' => __('You must be logged in to update a review', 'fluent-cart'),
670 ], 403);
671 }
672
673 if (!ProductReviewService::isReviewEnabledForProduct($postId)
674 || !Product::query()->where('post_status', 'publish')->find($postId)) {
675 return $this->sendError(['message' => __('Reviews are currently disabled', 'fluent-cart')], 403);
676 }
677
678 // Verify the review exists and belongs to the current user.
679 //
680 // topLevel() matters as much as the ownership columns: a reply is a
681 // row on the same product with the same user_id, so without it this
682 // endpoint edits replies too — accepting a title and a rating for a
683 // row that has neither, and re-moderating an approved reply back to
684 // pending, which drops it out of the thread it belongs to.
685 //
686 // Spelled out rather than the topLevel() scope: model scopes resolve
687 // through Builder::__call(), which static analysis cannot see. Keep in
688 // step with that scope's predicate.
689 $review = ProductReview::query()
690 ->whereNull('parent_id')
691 ->where('id', $reviewId)
692 ->where('post_id', $postId)
693 ->where('user_id', $userId)
694 ->first();
695
696 if (!$review) {
697 return $this->sendError([
698 'message' => __('Review not found or you do not have permission to edit it', 'fluent-cart'),
699 ], 404);
700 }
701
702 $data = $request->getSafe($request->sanitize());
703 $settings = ProductReviewService::getReviewSettings();
704
705 // Fall back to the stored rating when the client omits it, so a content-only
706 // edit is not rejected by the "star rating is required" gate below and is not
707 // silently downgraded to zero stars.
708 $rating = array_key_exists('rating', $data)
709 ? ProductReviewService::clampRating($data['rating'])
710 : (int) $review->rating;
711
712 if (ProductReviewService::isStarRatingRequired() && $rating < 1) {
713 return $this->sendError([
714 'message' => __('Star rating is required', 'fluent-cart'),
715 ], 422);
716 }
717
718 // Build from what the client actually sent. Assigning content unconditionally
719 // would null out the stored body whenever a partial edit omits it.
720 $updateData = [];
721
722 if (array_key_exists('content', $data)) {
723 $content = trim((string) $data['content']);
724 if ($content === '') {
725 return $this->sendError([
726 'message' => __('Review content is required.', 'fluent-cart'),
727 ], 422);
728 }
729 $updateData['content'] = $content;
730 }
731 if (array_key_exists('rating', $data)) {
732 $updateData['rating'] = $rating;
733 }
734 if (array_key_exists('title', $data)) {
735 $updateData['title'] = $data['title'];
736 }
737
738 if (empty($updateData)) {
739 return $this->sendError([
740 'message' => __('Nothing to update.', 'fluent-cart'),
741 ], 422);
742 }
743
744 $updateData = apply_filters('fluent_cart/review/update_data', $updateData, $request, $postId, $reviewId);
745
746 // Whitelist: only allow these fields through — everything else is dropped.
747 // Filters must not change ownership, status, or product association.
748 $allowedUpdateFields = ['content', 'rating', 'title'];
749 $updateData = array_intersect_key($updateData, array_flip($allowedUpdateFields));
750
751 // Re-moderate after the whitelist so neither the client nor a filter can choose
752 // the status. Without this, an approved review can be edited to arbitrary content
753 // and stay published — one approval would grant ongoing unmoderated publishing.
754 if ($settings['auto_approve_reviews'] !== 'yes' && $review->status === 'approved') {
755 $updateData['status'] = 'pending';
756 }
757
758 $updateData['status'] = ProductReviewService::applyUpdateStatusFilter(
759 $updateData['status'] ?? $review->status,
760 $updateData,
761 $request,
762 $review
763 );
764
765 $result = ProductReviewResource::update($updateData, $reviewId);
766
767 if (is_wp_error($result)) {
768 return $result;
769 }
770
771 do_action('fluent_cart/review/after_update', $result, $request);
772
773 // The hooks may have changed the row's photos; answer with it as saved.
774 $saved = ProductReviewResource::find($reviewId, ['with' => []]);
775
776 // Tell the reviewer their edit is queued again — otherwise the review silently
777 // disappears from the public list after a successful save.
778 $message = isset($updateData['status']) && $updateData['status'] === 'pending'
779 ? __('Your review has been updated and is pending approval.', 'fluent-cart')
780 : __('Your review has been updated!', 'fluent-cart');
781
782 // See submitReview() — the same report, for an edit.
783 $payload = apply_filters('fluent_cart/review/submit_response', [
784 'message' => $message,
785 'review' => $result,
786 'media' => static::mediaForResponse($saved && !is_wp_error($saved) ? $saved : null),
787 ], $review, $request, true);
788
789 return $this->sendSuccess($payload);
790 }
791
792 /**
793 * Server-rendered markup for the review thread modal.
794 *
795 * The storefront paints an overlay shell with a loader and swaps in this
796 * view, the same split the product modal uses — so the review, its
797 * replies and any extension footer arrive together instead of the modal
798 * opening empty and fetching replies afterwards.
799 */
800 public function getModalView(Request $request, $postId, $reviewId)
801 {
802 $postId = intval($postId);
803 $reviewId = intval($reviewId);
804
805 // Same gate as the other public read endpoints.
806 if (!ProductReviewService::isReviewEnabledForProduct($postId)
807 || !Product::query()->where('post_status', 'publish')->find($postId)) {
808 return $this->sendError([
809 'message' => __('Product not found', 'fluent-cart'),
810 ], 404);
811 }
812
813 $review = ProductReview::query()
814 ->where('id', $reviewId)
815 ->where('post_id', $postId)
816 // Spelled out rather than the topLevel() scope: model scopes
817 // resolve through Builder::__call(), which static analysis
818 // cannot see. Keep in step with that scope's predicate.
819 ->whereNull('parent_id')
820 ->where('status', Status::REVIEW_APPROVED)
821 ->first();
822
823 if (!$review) {
824 return $this->sendError([
825 'message' => __('Review not found', 'fluent-cart'),
826 ], 404);
827 }
828
829 ob_start();
830 (new ReviewModalRenderer($review, get_post_field('post_title', $postId, 'raw')))->render();
831 $view = ob_get_clean();
832
833 return $this->sendSuccess([
834 'view' => $view,
835 ]);
836 }
837
838 /**
839 * Get paginated replies for a single review.
840 * Called when opening the thread modal — keeps the list endpoint lightweight.
841 */
842 public function getReplies(Request $request, $postId, $reviewId)
843 {
844 $postId = intval($postId);
845 $reviewId = intval($reviewId);
846 $perPage = min(100, max(1, intval($request->get('per_page', 50))));
847 $page = max(1, intval($request->get('page', 1)));
848
849 // Only allow reading replies for published products (matches getReviews gate)
850 if (!ProductReviewService::isReviewEnabledForProduct($postId)
851 || !Product::query()->where('post_status', 'publish')->find($postId)) {
852 return $this->sendError([
853 'message' => __('Product not found', 'fluent-cart'),
854 ], 404);
855 }
856
857 // Verify parent review exists, belongs to product, is approved + top-level
858 $review = ProductReview::query()
859 ->where('id', $reviewId)
860 ->where('post_id', $postId)
861 ->whereNull('parent_id')
862 ->where('status', Status::REVIEW_APPROVED)
863 ->first();
864
865 if (!$review) {
866 return $this->sendError([
867 'message' => __('Review not found', 'fluent-cart'),
868 ], 404);
869 }
870
871 $replies = ProductReview::query()
872 ->where('parent_id', $reviewId)
873 ->where('status', Status::REVIEW_APPROVED)
874 ->orderBy('created_at', 'ASC')
875 ->orderBy('id', 'ASC')
876 ->paginate($perPage, ['*'], 'page', $page);
877
878 // photo is appended by the model itself.
879 foreach ($replies->items() as $reply) {
880 $reply->makeHidden(['reviewer_email', 'user_id', 'customer_id', 'order_id', 'meta']);
881 }
882
883 return $this->sendSuccess([
884 'replies' => $replies->toArray(),
885 ]);
886 }
887
888 /**
889 * The review form for one product, for the My Reviews page.
890 *
891 * The dashboard is a Vue app with no product page under it, so it asks
892 * for the same form the order-review page renders in its rows: the
893 * storefront renderer with a modal around it, every field at once. The
894 * page mounts the markup and ReviewForm.js takes it from there, so a
895 * customer writes the review where they are instead of leaving for the
896 * product page. Product level only — the order-review page is the one
897 * place a review is filed under a specific item.
898 *
899 * Rendered, not the block: the block prints its own trigger button too,
900 * and the page has the row's button for that. The customer's own
901 * identity carries the submission, as it would on the product page.
902 *
903 * item_id names the slot when there is one. The History tab edits an
904 * existing review through this same endpoint, and an item-level review
905 * must open ITS form: the renderer decides between "write" and "edit" by
906 * looking up the visitor's review in the (product, item) slot, so a
907 * variation's review asked for at product level would come back as a
908 * blank create form and a save would collide with the duplicate guard.
909 */
910 public function getReviewSubmissionForm(Request $request): \WP_REST_Response
911 {
912 if (ProductReviewService::getReviewSettings()['reviews_enabled'] !== 'yes') {
913 return $this->sendError([
914 'message' => __('Reviews are currently disabled', 'fluent-cart'),
915 ], 404);
916 }
917
918 $postId = max(0, (int) $request->get('post_id', 0));
919 $product = $postId
920 ? Product::query()->where('post_status', 'publish')->find($postId)
921 : null;
922
923 if (!$product) {
924 return $this->sendError([
925 'message' => __('Product not found', 'fluent-cart'),
926 ], 404);
927 }
928
929 // Refused rather than quietly downgraded, the same rule submitReview()
930 // applies: null is a variation that is not this product's, and 0 is a
931 // product with no items worth telling apart.
932 $itemId = ProductReviewService::resolveReviewItem($product->ID, $request->get('item_id', 0));
933 if ($itemId === null) {
934 return $this->sendError([
935 'message' => __('That item is not available for review.', 'fluent-cart'),
936 ], 422);
937 }
938
939 ob_start();
940 (new ProductReviewRenderer($product->ID, [
941 'container' => 'modal',
942 'layout' => 'inline',
943 'itemId' => $itemId,
944 ]))->renderForm();
945 $html = trim((string) ob_get_clean());
946
947 // Nothing rendered means reviews are off for this product.
948 if ($html === '') {
949 return $this->sendError([
950 'message' => __('Reviews are currently disabled for this product', 'fluent-cart'),
951 ], 404);
952 }
953
954 return $this->sendSuccess([
955 'html' => $html,
956 ]);
957 }
958
959 /**
960 * The dashboard's My Reviews page, one endpoint for both tabs: the
961 * default returns the logged-in customer's review history, type=pending
962 * returns the purchased-but-not-reviewed products. Sits behind
963 * CustomerFrontendPolicy and only ever reads rows scoped to the
964 * current customer.
965 */
966 public function getReviewsByCustomer(Request $request): \WP_REST_Response
967 {
968 // Module switch is enforced here, not just in the menu: with reviews
969 // off, the page URL and the endpoint must both go dark.
970 if (ProductReviewService::getReviewSettings()['reviews_enabled'] !== 'yes') {
971 return $this->sendError([
972 'message' => __('Reviews are currently disabled', 'fluent-cart'),
973 ], 404);
974 }
975
976 $customer = CustomerResource::getCurrentCustomer();
977
978 if ($request->get('type') === 'pending') {
979 // history_total rides along so the History tab shows its count
980 // before it is ever opened — the rows themselves load lazily.
981 return $this->sendSuccess([
982 'products' => $customer ? ProductReviewService::getPendingReviewProducts($customer) : [],
983 'history_total' => $customer ? ProductReviewService::countCustomerReviews($customer) : 0,
984 ]);
985 }
986
987 if (!$customer) {
988 return $this->sendSuccess([
989 'reviews' => [
990 'data' => [],
991 'total' => 0,
992 'per_page' => 10,
993 'current_page' => 1,
994 'last_page' => 1,
995 ],
996 ]);
997 }
998
999 return $this->sendSuccess(ProductReviewService::getCustomerReviewsPayload($customer, [
1000 'per_page' => (int) $request->get('per_page', 10),
1001 'page' => (int) $request->get('page', 1),
1002 ]));
1003 }
1004 }
1005