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 / ProductReviewController.php

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

448 lines 17.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;
4
5 use FluentCart\Api\Resource\ProductReviewResource;
6 use FluentCart\App\Events\ReviewApproved;
7 use FluentCart\App\Events\ReviewReplied;
8 use FluentCart\App\Helpers\Status;
9 use FluentCart\App\Http\Requests\ReviewCreateRequest;
10 use FluentCart\App\Http\Requests\ReviewRequest;
11 use FluentCart\App\Models\ProductDetail;
12 use FluentCart\App\Services\Filter\ReviewFilter;
13 use FluentCart\App\Services\ProductReviewService;
14
15 class ProductReviewController extends Controller
16 {
17 public function index(ReviewRequest $request)
18 {
19 $data = $request->getSafe($request->sanitize());
20
21 // Backward compat: map legacy 'status' param to 'active_view' when active_view is absent
22 $activeView = $data['active_view'] ?? '';
23 if (empty($activeView) && !empty($data['status']) && $data['status'] !== 'all') {
24 $activeView = $data['status'];
25 }
26
27 $args = [
28 'search' => $data['search'] ?? '',
29 'per_page' => !empty($data['per_page']) ? (int) $data['per_page'] : 15,
30 'sort_by' => $data['sort_by'] ?? 'id',
31 'sort_type' => $data['sort_type'] ?? ($data['sort_order'] ?? 'DESC'),
32 'filter_type' => $data['filter_type'] ?? 'simple',
33 'advanced_filters' => $data['advanced_filters'] ?? '',
34 'active_view' => $activeView,
35 'user_tz' => $data['user_tz'] ?? '',
36 'page' => !empty($data['page']) ? (int) $data['page'] : 1,
37 'post_id' => !empty($data['post_id']) ? (int) $data['post_id'] : null,
38 'rating' => !empty($data['rating']) ? (int) $data['rating'] : null,
39 'with' => ['product', 'customer'],
40 ];
41
42 $reviews = ReviewFilter::make($args)->paginate();
43
44 // The item each review names, one query for the page.
45 ProductReviewService::attachItemLabels($reviews->items());
46
47 return $this->sendSuccess([
48 'reviews' => $reviews,
49 ]);
50 }
51
52 /**
53 * Add a review by hand, attributed to a named reviewer rather than to the
54 * admin who typed it.
55 *
56 * The storefront gates (permission mode, one-review-per-identity, rate
57 * limit, submission lock) are intentionally absent — see
58 * ProductReviewService::buildAdminReviewData(). No ReviewCreated event is
59 * dispatched either: that event's only listener mails the store owner
60 * "New Review Submitted", which would notify them about a review they
61 * just wrote.
62 */
63 public function create(ReviewCreateRequest $request)
64 {
65 // Required fields, the email format, the rating rule and the status
66 // list are enforced by ReviewCreateRequest before this runs.
67 $data = $request->getSafe($request->sanitize());
68
69 $postId = (int) $data['post_id'];
70 $content = trim((string) $data['content']);
71 $reviewerName = trim((string) $data['reviewer_name']);
72 $reviewerEmail = trim((string) ($data['reviewer_email'] ?? ''));
73 $rating = isset($data['rating']) ? ProductReviewService::clampRating($data['rating']) : 0;
74 $status = !empty($data['status']) ? $data['status'] : Status::REVIEW_APPROVED;
75
76 // The same resolution the storefront submit uses, so a moderator and a
77 // buyer can never disagree about which slot a review occupies: null is
78 // a variation that is not this product's (refused, never quietly
79 // dropped), and 0 is a product with no items worth telling apart, which
80 // files at product level whatever was sent.
81 //
82 // No grant is required here, unlike the storefront path: the order link
83 // exists to prove a guest bought that item, and a moderator holding
84 // reviews/manage is already trusted to write the review by hand.
85 $itemId = ProductReviewService::resolveReviewItem($postId, $data['item_id'] ?? 0);
86 if ($itemId === null) {
87 // The field-map shape the Add Review form prints under each field,
88 // so the message lands under the variation picker that caused it.
89 return $this->sendError([
90 'item_id' => [
91 'invalid' => __('Please select a variation that belongs to this product.', 'fluent-cart'),
92 ],
93 ], 422);
94 }
95
96 $reviewData = ProductReviewService::buildAdminReviewData([
97 'post_id' => $postId,
98 'item_id' => $itemId,
99 'rating' => $rating,
100 'title' => $data['title'] ?? '',
101 'content' => $content,
102 'status' => $status,
103 'is_verified' => !empty($data['is_verified']),
104 'reviewer_name' => $reviewerName,
105 'reviewer_email' => $reviewerEmail,
106 ]);
107
108 // The admin's explicit status and verified choices survive a filter
109 // that would rewrite them — see applySubmitDataFilter().
110 $reviewData = ProductReviewService::applySubmitDataFilter($reviewData, $request, $postId);
111
112 // Resolves the product and rejects an unknown post_id, so no separate
113 // existence check is needed here.
114 $review = ProductReviewResource::create($reviewData);
115
116 if (is_wp_error($review)) {
117 return $review;
118 }
119
120 // The storefront's own post-create hook, not an admin-specific one:
121 // this path creates the same kind of row from the same kind of request,
122 // and the extensions that care (the Pro media pipeline) already listen
123 // here. A separate admin hook would have to be wired a second time and
124 // would silently drop attachments until it was.
125 do_action('fluent_cart/review/after_submit', $review, $request);
126
127 // Attachments ride along on the same request. The review row already
128 // exists by this point, so the files can be stored against a real id
129 // — no upload-first endpoint, and no claim token to bridge the gap
130 // between an upload and the review that will eventually own it.
131 //
132 // Free ships no media pipeline; whoever implements this stores the
133 // bytes and attaches them (the Pro reviews module in practice).
134 $files = $request->files('files');
135 if (!empty($files)) {
136 do_action('fluent_cart/review/admin_store_media', [
137 'review' => $review,
138 'files' => is_array($files) ? $files : [$files],
139 ]);
140
141 // The listener writes media refs and media_count straight onto the
142 // row, so re-read before echoing it back — otherwise the response
143 // reports the review as having no attachments.
144 $review = ProductReviewResource::find($review->id, ['with' => []]);
145 }
146
147 // Saved approved by the moderator's own hand: an approval, told the
148 // same way as one from the moderation queue. After the media hooks,
149 // on the row as it now is — they save other_info from a model, and
150 // the approval notice's claim lives in that blob.
151 $savedReview = ProductReviewResource::find($review->id, ['with' => []]);
152 if ($savedReview && !is_wp_error($savedReview)) {
153 ReviewApproved::dispatchIfApproved($savedReview);
154 }
155
156 $payload = apply_filters('fluent_cart/review/admin_submit_response', [
157 'message' => __('Review added successfully', 'fluent-cart'),
158 'review' => $review,
159 ]);
160
161 return $this->sendSuccess($payload);
162 }
163
164 public function find(ReviewRequest $request, $id)
165 {
166 $review = ProductReviewResource::find($id);
167
168 if (is_wp_error($review)) {
169 return $this->entityNotFoundError(
170 __('Review not found', 'fluent-cart'),
171 __('Back to Reviews', 'fluent-cart'),
172 '/reviews'
173 );
174 }
175
176 // The permalink is an accessor, so it only reaches the JSON when it
177 // is appended — the sidebar's storefront link depends on it. append()
178 // rather than setAppends(), which would drop the model's own thumbnail.
179 if ($review->relationLoaded('product') && $review->product) {
180 $review->product->append('view_url');
181 }
182
183 ProductReviewService::attachItemLabels([$review]);
184
185 $responseData = [
186 'review' => $review,
187 // One store reply per review unless threaded replies (Pro) are
188 // enabled — the reply form hides once a reply exists.
189 'allow_multiple_replies' => ProductReviewService::isMultipleRepliesAllowed(),
190 ];
191
192 $responseData = apply_filters('fluent_cart/review/admin_single_response', $responseData, $review);
193
194 return $this->sendSuccess($responseData);
195 }
196
197 public function update(ReviewRequest $request, $id)
198 {
199 $data = $request->getSafe($request->sanitize());
200
201 // Only allow specific fields to be updated — prevent mass assignment of protected fields
202 // Only include fields that were actually sent in the request to avoid overwriting with defaults
203 $allowedFields = ['status', 'title', 'content', 'rating', 'is_verified'];
204 $data = array_intersect_key($data, array_flip($allowedFields));
205 $data = array_filter($data, function ($value, $key) use ($request) {
206 return $request->exists($key);
207 }, ARRAY_FILTER_USE_BOTH);
208
209 if (isset($data['status']) && !in_array($data['status'], Status::getReviewStatuses(), true)) {
210 return $this->sendError([
211 'message' => __('Invalid status', 'fluent-cart'),
212 ], 400);
213 }
214
215 $oldStatus = null;
216 if (isset($data['status'])) {
217 // Only the status is needed here — skip find()'s default eager loads.
218 $existingReview = ProductReviewResource::find($id, ['with' => []]);
219 if (!is_wp_error($existingReview)) {
220 $oldStatus = $existingReview->status;
221 }
222 }
223
224 $result = ProductReviewResource::update($data, $id);
225
226 if (is_wp_error($result)) {
227 return $result;
228 }
229
230 if ($oldStatus && $oldStatus !== $data['status']) {
231 // update() reloads internally — use the review from its response
232 $updatedReview = $result['data'] ?? null;
233 if ($updatedReview) {
234 do_action('fluent_cart/review/admin_status_changed', $updatedReview, $oldStatus, $data['status']);
235 }
236 }
237
238 return $this->sendSuccess($result);
239 }
240
241 public function delete(ReviewRequest $request, $id)
242 {
243 $result = ProductReviewResource::delete($id);
244
245 if (is_wp_error($result)) {
246 return $result;
247 }
248
249 return $this->sendSuccess($result);
250 }
251
252 public function bulkAction(ReviewRequest $request)
253 {
254 $data = $request->getSafe($request->sanitize());
255
256 $action = $data['action_type'] ?? '';
257 $ids = $data['review_ids'] ?? [];
258
259 if (!is_array($ids)) {
260 $ids = [];
261 }
262
263 $ids = array_map('intval', array_filter($ids));
264
265 $result = ProductReviewResource::bulkAction($action, $ids);
266
267 if (is_wp_error($result)) {
268 return $result;
269 }
270
271 return $this->sendSuccess($result);
272 }
273
274 public function stats(ReviewRequest $request)
275 {
276 $data = $request->getSafe($request->sanitize());
277 $postId = !empty($data['post_id']) ? (int) $data['post_id'] : null;
278 $counts = ProductReviewResource::getStatusCounts($postId);
279
280 // Include avg_rating from canonical cache so the UI stays fresh after actions
281 $avgRating = 0;
282 if ($postId) {
283 $detail = ProductDetail::query()->where('post_id', $postId)->first();
284 if ($detail && $detail->other_info) {
285 $avgRating = array_key_exists('average_rating', $detail->other_info)
286 ? round((float) $detail->other_info['average_rating'], 2)
287 : 0;
288 }
289 }
290
291 $counts['avg_rating'] = $avgRating;
292
293 return $this->sendSuccess([
294 'stats' => $counts,
295 ]);
296 }
297
298 public function reply(ReviewRequest $request, $id)
299 {
300 $parentReview = ProductReviewResource::find($id);
301
302 if (is_wp_error($parentReview)) {
303 return $parentReview;
304 }
305
306 // Replies hang off a review, never off another reply: free's contract
307 // is one review and one store reply, and the thread modal only renders
308 // a top-level review's direct children, so a nested row would be
309 // written but never shown.
310 if ($parentReview->parent_id) {
311 return $this->sendError([
312 'message' => __('You can only reply to a review.', 'fluent-cart'),
313 ], 422);
314 }
315
316 $data = $request->getSafe($request->sanitize());
317 $content = $data['content'] ?? '';
318
319 if (empty(trim($content))) {
320 return $this->sendError([
321 'message' => __('Please write a reply message before sending.', 'fluent-cart'),
322 ], 422);
323 }
324
325 $replyData = ProductReviewService::buildAdminReplyData($content, $parentReview);
326
327 // One store reply per review unless an extension allows multiple
328 // replies. A unique index cannot express it (Pro lifts the limit to
329 // many replies per review), so the check runs inside a transaction
330 // holding a row lock on the parent review — concurrent replies to the
331 // same review serialize on the lock and the loser sees the winner's row.
332 $connection = ProductReviewResource::getQuery()->getConnection();
333 $connection->beginTransaction();
334
335 try {
336 // Row lock on the parent review; released on commit/rollback.
337 ProductReviewResource::getQuery()
338 ->where('id', (int) $parentReview->id)
339 ->lockForUpdate()
340 ->get();
341
342 if (!ProductReviewService::isMultipleRepliesAllowed()) {
343 $existingReplies = ProductReviewResource::getQuery()
344 ->where('parent_id', $parentReview->id)
345 ->count();
346 if ($existingReplies > 0) {
347 $connection->rollBack();
348
349 return $this->sendError([
350 'message' => __('This review already has a reply. Delete the existing reply to write a new one.', 'fluent-cart'),
351 ], 422);
352 }
353 }
354
355 $reply = ProductReviewResource::create($replyData);
356
357 if (is_wp_error($reply)) {
358 $connection->rollBack();
359
360 return $reply;
361 }
362
363 $connection->commit();
364 } catch (\Throwable $e) {
365 $connection->rollBack();
366
367 return $this->sendError([
368 'message' => __('Could not save the reply. Please try again.', 'fluent-cart'),
369 ], 500);
370 }
371
372 // After the commit, never inside it — see ReviewReplied. The parent
373 // and its product were loaded to answer it; handed over rather than
374 // looked up again.
375 ReviewReplied::dispatchIfStoreReply($reply, $parentReview, $parentReview->product);
376
377 return $this->sendSuccess([
378 'message' => __('Your reply has been submitted successfully.', 'fluent-cart'),
379 'reply' => $reply,
380 ]);
381 }
382
383 public function deleteReply(ReviewRequest $request, $reviewId, $replyId)
384 {
385 $reply = ProductReviewResource::getQuery()
386 ->where('id', (int) $replyId)
387 ->where('parent_id', (int) $reviewId)
388 ->first();
389
390 if (!$reply) {
391 return $this->sendError([
392 'message' => __('Reply not found', 'fluent-cart'),
393 ], 404);
394 }
395
396 // Share the transactional deletion and post-commit media cleanup.
397 $deleted = ProductReviewResource::delete($reply->id);
398 if (is_wp_error($deleted)) {
399 return $deleted;
400 }
401
402 return $this->sendSuccess([
403 'message' => __('Reply deleted successfully', 'fluent-cart'),
404 ]);
405 }
406
407 public function bulkReply(ReviewRequest $request)
408 {
409 $data = $request->getSafe($request->sanitize());
410
411 $content = $data['content'] ?? '';
412 $ids = $data['review_ids'] ?? [];
413
414 if (empty(trim($content))) {
415 return $this->sendError([
416 'message' => __('Please write a reply message before sending.', 'fluent-cart'),
417 ], 422);
418 }
419
420 if (!is_array($ids)) {
421 $ids = [];
422 }
423
424 $ids = array_map('intval', array_filter($ids));
425
426 if (empty($ids)) {
427 return $this->sendError([
428 'message' => __('No reviews selected', 'fluent-cart'),
429 ], 400);
430 }
431
432 // Cap batch size to prevent long-running requests
433 $ids = array_slice($ids, 0, 50);
434
435 $result = ProductReviewResource::bulkReply(
436 $ids,
437 ProductReviewService::buildAdminReplyData($content)
438 );
439
440 if (is_wp_error($result)) {
441 return $result;
442 }
443
444 return $this->sendSuccess($result);
445 }
446
447 }
448