PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.7.0
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.7.0
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 / Http / Controllers / FrontendControllers / ProductReviewFrontendController.php

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

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