| 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 |
|