PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.7.1
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.7.1
1.7.1 1.7.0 1.6.6 1.6.5 1.6.4 1.6.3 1.6.2 1.6.1 1.6.0 1.5.4 1.5.5 1.5.3 1.5.2 1.5.1 1.5.0 1.4.2 1.4.1 1.4.0 1.3.28 1.3.27 1.3.26 1.3.25 1.3.23 1.3.22 1.3.21 All 51 releases
fluent-cart / app / Http / Controllers / FrontendControllers / ProductReviewFrontendController.php

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

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