PluginProbe
Yatra – Travel Booking & Tour Operator Software / trunk
Yatra – Travel Booking & Tour Operator Software vtrunk
3.0.14 3.0.14.1 3.0.14.2 3.0.12 3.0.13 3.0.11 3.0.10 3.0.9 3.0.8 3.0.7 3.0.6 3.0.5 3.0.5.1 3.0.4 3.0.3 3.0.2.9 3.0.2.7 3.0.2.8 3.0.2.6 trunk 1.0.0 2.0.0 2.0.1 2.0.10 2.0.11 All 82 releases
yatra / app / Controllers / ReviewController.php

ReviewController.php in Yatra – Travel Booking & Tour Operator Software trunk, at app/Controllers/ReviewController.php

584 lines 19.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 declare(strict_types=1);
4
5 namespace Yatra\Controllers;
6
7 use WP_REST_Request;
8 use WP_REST_Response;
9 use WP_Error;
10 use Yatra\Services\ReviewService;
11
12 /**
13 * Review REST API Controller
14 *
15 * Handles HTTP requests only - delegates business logic to ReviewService.
16 *
17 * NO DATABASE QUERIES OR BUSINESS LOGIC IN THIS FILE.
18 *
19 * @package Yatra\Controllers
20 */
21 class ReviewController extends BaseController
22 {
23 /**
24 * Review service instance
25 */
26 private ReviewService $reviewService;
27
28 /**
29 * Constructor
30 */
31 public function __construct()
32 {
33 $this->reviewService = new ReviewService();
34 }
35
36 /**
37 * Register routes
38 */
39 public function register_routes(): void
40 {
41 $namespace = 'yatra/v1';
42 $base = 'reviews';
43
44 // =====================
45 // ADMIN ROUTES
46 // =====================
47
48 // List reviews + create review (admin). View cap for list,
49 // edit cap for create.
50 register_rest_route($namespace, '/' . $base, [
51 [
52 'methods' => \WP_REST_Server::READABLE,
53 'callback' => [$this, 'getReviews'],
54 'permission_callback' => [$this, 'checkCanView'],
55 ],
56 [
57 // The admin "Add New Review" form (resources/js/pages/ReviewForm.tsx)
58 // POSTs here. Distinct from the public-facing
59 // `POST /trips/{trip_id}/reviews` route below: the admin path
60 // bypasses the "already reviewed this trip" gate, accepts
61 // any DB-valid status (incl. spam/trash post-3.0.5 migration),
62 // and stamps `created_by` with the current admin user id.
63 'methods' => \WP_REST_Server::CREATABLE,
64 'callback' => [$this, 'createReview'],
65 'permission_callback' => [$this, 'checkCanEdit'],
66 ],
67 ]);
68
69 // Bulk actions — moderation operations. Manage cap covers
70 // approval workflows; delete actions inside the bulk handler
71 // should re-check the delete cap.
72 register_rest_route($namespace, '/' . $base . '/bulk', [
73 [
74 'methods' => \WP_REST_Server::EDITABLE,
75 'callback' => [$this, 'bulkAction'],
76 'permission_callback' => [$this, 'checkCanManage'],
77 ],
78 ]);
79
80 // Single review — read / update / delete with distinct caps.
81 register_rest_route($namespace, '/' . $base . '/(?P<id>[\d]+)', [
82 [
83 'methods' => \WP_REST_Server::READABLE,
84 'callback' => [$this, 'getReview'],
85 'permission_callback' => [$this, 'checkCanView'],
86 ],
87 [
88 'methods' => \WP_REST_Server::EDITABLE,
89 'callback' => [$this, 'updateReview'],
90 'permission_callback' => [$this, 'checkCanEdit'],
91 ],
92 [
93 'methods' => \WP_REST_Server::DELETABLE,
94 'callback' => [$this, 'deleteReview'],
95 'permission_callback' => [$this, 'checkCanDelete'],
96 ],
97 ]);
98
99 // Update review status — moderation action. Manage cap (held
100 // by Owner, Manager, Marketing).
101 register_rest_route($namespace, '/' . $base . '/(?P<id>[\d]+)/status', [
102 [
103 'methods' => \WP_REST_Server::EDITABLE,
104 'callback' => [$this, 'updateStatus'],
105 'permission_callback' => [$this, 'checkCanManage'],
106 ],
107 ]);
108
109 // Stats — view cap.
110 register_rest_route($namespace, '/' . $base . '/stats', [
111 [
112 'methods' => \WP_REST_Server::READABLE,
113 'callback' => [$this, 'getStats'],
114 'permission_callback' => [$this, 'checkCanView'],
115 ],
116 ]);
117
118 // =====================
119 // PUBLIC ROUTES
120 // =====================
121
122 // Get trip reviews (public)
123 register_rest_route($namespace, '/trips/(?P<trip_id>[\d]+)/reviews', [
124 [
125 'methods' => \WP_REST_Server::READABLE,
126 'callback' => [$this, 'getTripReviews'],
127 'permission_callback' => '__return_true',
128 ],
129 ]);
130
131 // Submit review (authenticated)
132 register_rest_route($namespace, '/trips/(?P<trip_id>[\d]+)/reviews', [
133 [
134 'methods' => \WP_REST_Server::CREATABLE,
135 'callback' => [$this, 'submitReview'],
136 'permission_callback' => [$this, 'checkReviewPermission'],
137 ],
138 ]);
139
140 // Check if user can review
141 register_rest_route($namespace, '/trips/(?P<trip_id>[\d]+)/can-review', [
142 [
143 'methods' => \WP_REST_Server::READABLE,
144 'callback' => [$this, 'canReview'],
145 'permission_callback' => '__return_true',
146 ],
147 ]);
148
149 // User's own review operations
150 register_rest_route($namespace, '/my-reviews/(?P<id>[\d]+)', [
151 [
152 'methods' => \WP_REST_Server::EDITABLE,
153 'callback' => [$this, 'updateMyReview'],
154 'permission_callback' => [$this, 'checkUserPermission'],
155 ],
156 ]);
157 }
158
159 /**
160 * Granular admin-side permission checks. WP administrators pass
161 * every cap via the Team module's admin-fallback filter, so an
162 * explicit `manage_options` check isn't needed here.
163 */
164 public function checkCanView(): bool
165 {
166 return current_user_can('yatra_view_reviews');
167 }
168
169 public function checkCanEdit(): bool
170 {
171 return current_user_can('yatra_edit_reviews');
172 }
173
174 public function checkCanManage(): bool
175 {
176 // Moderation cap — approve / spam / trash. Distinct from
177 // edit so a Marketing role can moderate without being able
178 // to edit the review body itself.
179 return current_user_can('yatra_manage_reviews');
180 }
181
182 public function checkCanDelete(): bool
183 {
184 return current_user_can('yatra_delete_reviews');
185 }
186
187 /**
188 * @deprecated Kept for any external code referencing the old
189 * method name. Old implementation only checked `manage_options`,
190 * so non-admin Yatra-role users were locked out of every
191 * endpoint. New behaviour routes to the view cap which is more
192 * permissive for legitimate team members; admin users still pass
193 * via the admin-fallback layer.
194 */
195 public function checkAdminPermission(): bool
196 {
197 return $this->checkCanView();
198 }
199
200 /**
201 * Check if user is logged in
202 */
203 public function checkUserPermission(): bool
204 {
205 return is_user_logged_in();
206 }
207
208 /**
209 * Check review submission permission
210 */
211 public function checkReviewPermission(): bool
212 {
213 $settings = \Yatra\Services\SettingsService::getSettings();
214 $requireLogin = $settings['reviews']['require_login'] ?? true;
215
216 if ($requireLogin) {
217 return is_user_logged_in();
218 }
219
220 return true;
221 }
222
223 // =========================================================================
224 // ADMIN ENDPOINTS
225 // =========================================================================
226
227 /**
228 * GET /reviews - List all reviews (admin)
229 */
230 public function getReviews(WP_REST_Request $request): WP_REST_Response
231 {
232 $filters = [
233 'page' => (int) ($request->get_param('page') ?: 1),
234 'per_page' => (int) ($request->get_param('per_page') ?: 20),
235 'status' => $request->get_param('status') ?: '',
236 'trip_id' => (int) $request->get_param('trip_id'),
237 'rating' => (int) $request->get_param('rating'),
238 'search' => $request->get_param('search') ?: '',
239 ];
240
241 $result = $this->reviewService->getReviews($filters);
242
243
244
245 // Map database fields to frontend expected fields
246 $mappedData = array_map(function($review) {
247 // ReviewService returns arrays, not objects
248 return (object) [
249 'id' => $review['id'] ?? null,
250 'trip_id' => $review['trip_id'] ?? null,
251 'trip_title' => $review['trip_title'] ?? '',
252 'trip_slug' => $review['trip_slug'] ?? '',
253 'customer_name' => $review['author_name'] ?? '',
254 'customer_email' => $review['author_email'] ?? '',
255 'rating' => $review['rating'] ?? 0,
256 'title' => $review['title'] ?? '',
257 'content' => $review['content'] ?? '',
258 'status' => $review['status'] ?? '',
259 'verified' => false, // TODO: Add verified field to database
260 'created_at' => $review['created_at'] ?? null,
261 ];
262 }, $result['data']);
263
264 return new WP_REST_Response([
265 'success' => true,
266 'data' => $mappedData,
267 'meta' => [
268 'total' => $result['total'],
269 'page' => $result['page'],
270 'per_page' => $result['per_page'],
271 'total_pages' => $result['total_pages'],
272 ],
273 ]);
274 }
275
276 /**
277 * GET /reviews/{id} - Get single review
278 */
279 public function getReview(WP_REST_Request $request): WP_REST_Response
280 {
281 $id = (int) $request->get_param('id');
282
283 $review = $this->reviewService->getReview($id);
284
285 if (!$review) {
286 return new WP_REST_Response([
287 'success' => false,
288 'message' => __('Review not found.', 'yatra'),
289 ], 404);
290 }
291
292 return new WP_REST_Response([
293 'success' => true,
294 'data' => $review,
295 ]);
296 }
297
298 /**
299 * PUT /reviews/{id} - Update review
300 */
301 public function updateReview(WP_REST_Request $request): WP_REST_Response
302 {
303 $id = (int) $request->get_param('id');
304 $data = $this->mapAdminReviewPayload($request->get_json_params() ?? []);
305
306 $result = $this->reviewService->updateReview($id, $data);
307
308 if (!$result['success']) {
309 return new WP_REST_Response($result, 400);
310 }
311
312 return new WP_REST_Response($result);
313 }
314
315 /**
316 * POST /reviews — Admin "Add New Review" endpoint.
317 *
318 * The admin React form posts the operator-curated fields here. This
319 * path differs from `submitReview` (which the public review form uses)
320 * in three ways:
321 *
322 * 1. No "already reviewed this trip" gate — admins should be able to
323 * enter reviews on behalf of customers without tripping the
324 * duplicate-prevention guard.
325 * 2. Honours the operator-supplied `status` rather than deriving it
326 * from the `reviews.auto_approve` setting — the admin is the
327 * authority for whether the row is pending / approved / spam etc.
328 * 3. Stamps `created_by` with the current admin user id so audits
329 * can attribute who added the review.
330 */
331 public function createReview(WP_REST_Request $request): WP_REST_Response
332 {
333 $data = $this->mapAdminReviewPayload($request->get_json_params() ?? []);
334
335 // created_by is set here (controller) rather than in the service
336 // so the service stays input-agnostic — service methods may also
337 // be called from CLI / cron / tests where there's no current user.
338 $data['created_by'] = get_current_user_id() ?: null;
339
340 $result = $this->reviewService->createReviewAsAdmin($data);
341
342 if (!$result['success']) {
343 return new WP_REST_Response($result, 400);
344 }
345
346 return new WP_REST_Response($result, 201);
347 }
348
349 /**
350 * Translate the admin form's payload shape into the field names the
351 * service + repository expect.
352 *
353 * The admin React form (resources/js/pages/ReviewForm.tsx) ships:
354 * - customer_name, customer_email, comment, verified, status
355 *
356 * The DB columns (and {@see ReviewRepository::prepareReviewData()})
357 * speak:
358 * - author_name, author_email, content, status
359 * (no `verified` column exists yet — silently dropped)
360 *
361 * Doing this map at the controller layer keeps the service free of
362 * UI-specific aliases, and means future UIs can either send the
363 * legacy alias names or the canonical names with no double-mapping.
364 *
365 * @param array<string, mixed> $payload Raw JSON from the request.
366 * @return array<string, mixed> Canonical, service-ready payload.
367 */
368 private function mapAdminReviewPayload(array $payload): array
369 {
370 // Field aliases: admin-side name → canonical DB-column name.
371 $aliases = [
372 'customer_name' => 'author_name',
373 'customer_email' => 'author_email',
374 'comment' => 'content',
375 ];
376
377 foreach ($aliases as $from => $to) {
378 if (array_key_exists($from, $payload) && !array_key_exists($to, $payload)) {
379 $payload[$to] = $payload[$from];
380 }
381 // Don't unset the alias — leaving both is harmless because
382 // prepareReviewData ignores unknown keys, and it keeps the
383 // payload introspectable in logs.
384 }
385
386 // `verified` has no column in wp_yatra_reviews yet. Drop it
387 // explicitly so a future log of the payload doesn't suggest the
388 // value was honoured.
389 if (array_key_exists('verified', $payload)) {
390 unset($payload['verified']);
391 }
392
393 // Clamp status to the actual enum. Anything else gets coerced to
394 // 'pending' so we never write '' (the silent-truncation pit that
395 // motivated the Upgrade_3_0_5 migration in the first place).
396 if (array_key_exists('status', $payload)) {
397 $allowed = ['pending', 'approved', 'rejected', 'spam', 'trash'];
398 $status = is_string($payload['status']) ? $payload['status'] : '';
399 $payload['status'] = in_array($status, $allowed, true) ? $status : 'pending';
400 }
401
402 return $payload;
403 }
404
405 /**
406 * PUT /reviews/bulk - Bulk actions
407 */
408 public function bulkAction(WP_REST_Request $request): WP_REST_Response
409 {
410 $data = $request->get_json_params();
411 $action = $data['action'] ?? '';
412 $ids = $data['ids'] ?? [];
413
414 if (empty($ids) || !is_array($ids)) {
415 return new WP_REST_Response([
416 'success' => false,
417 'message' => __('No items selected.', 'yatra'),
418 ], 400);
419 }
420
421 switch ($action) {
422 case 'delete':
423 $result = $this->reviewService->bulkDelete($ids);
424 break;
425 case 'mark_approved':
426 $result = $this->reviewService->bulkUpdateStatus($ids, 'approved');
427 break;
428 case 'mark_pending':
429 $result = $this->reviewService->bulkUpdateStatus($ids, 'pending');
430 break;
431 case 'mark_spam':
432 $result = $this->reviewService->bulkUpdateStatus($ids, 'spam');
433 break;
434 case 'mark_trash':
435 $result = $this->reviewService->bulkUpdateStatus($ids, 'trash');
436 break;
437 default:
438 return new WP_REST_Response([
439 'success' => false,
440 'message' => __('Invalid action.', 'yatra'),
441 ], 400);
442 }
443
444 return new WP_REST_Response($result);
445 }
446
447 /**
448 * DELETE /reviews/{id} - Delete review
449 */
450 public function deleteReview(WP_REST_Request $request): WP_REST_Response
451 {
452 $id = (int) $request->get_param('id');
453
454 $result = $this->reviewService->deleteReview($id);
455
456 if (!$result['success']) {
457 return new WP_REST_Response($result, 400);
458 }
459
460 return new WP_REST_Response($result);
461 }
462
463 /**
464 * PUT /reviews/{id}/status - Update status
465 */
466 public function updateStatus(WP_REST_Request $request): WP_REST_Response
467 {
468 $id = (int) $request->get_param('id');
469 $data = $request->get_json_params();
470 $status = $data['status'] ?? '';
471
472 if (empty($status)) {
473 return new WP_REST_Response([
474 'success' => false,
475 'message' => __('Status is required.', 'yatra'),
476 ], 400);
477 }
478
479 $result = $this->reviewService->updateStatus($id, $status);
480
481 if (!$result['success']) {
482 return new WP_REST_Response($result, 400);
483 }
484
485 return new WP_REST_Response($result);
486 }
487
488 /**
489 * GET /reviews/stats - Get statistics
490 */
491 public function getStats(WP_REST_Request $request): WP_REST_Response
492 {
493 $stats = $this->reviewService->getStats();
494
495 return new WP_REST_Response([
496 'success' => true,
497 'data' => $stats,
498 ]);
499 }
500
501 // =========================================================================
502 // PUBLIC ENDPOINTS
503 // =========================================================================
504
505 /**
506 * GET /trips/{trip_id}/reviews - Get trip reviews (public)
507 */
508 public function getTripReviews(WP_REST_Request $request): WP_REST_Response
509 {
510 $tripId = (int) $request->get_param('trip_id');
511 $limit = (int) ($request->get_param('limit') ?: 10);
512
513 $reviews = $this->reviewService->getTripReviews($tripId, $limit);
514 $summary = $this->reviewService->getTripRatingSummary($tripId);
515
516 return new WP_REST_Response([
517 'success' => true,
518 'data' => [
519 'reviews' => $reviews,
520 'summary' => $summary,
521 ],
522 ]);
523 }
524
525 /**
526 * POST /trips/{trip_id}/reviews - Submit review
527 */
528 public function submitReview(WP_REST_Request $request): WP_REST_Response
529 {
530 $tripId = (int) $request->get_param('trip_id');
531 $data = $request->get_json_params();
532
533 // Handle both JSON and form data
534 if (empty($data)) {
535 $data = $request->get_params();
536 }
537
538 $data['trip_id'] = $tripId;
539
540 $result = $this->reviewService->submitReview($data);
541
542 if (!$result['success']) {
543 return new WP_REST_Response($result, 400);
544 }
545
546 return new WP_REST_Response($result, 201);
547 }
548
549 /**
550 * GET /trips/{trip_id}/can-review - Check if user can review
551 */
552 public function canReview(WP_REST_Request $request): WP_REST_Response
553 {
554 $tripId = (int) $request->get_param('trip_id');
555
556 $result = $this->reviewService->canUserReview($tripId);
557
558 return new WP_REST_Response([
559 'success' => true,
560 'data' => $result,
561 ]);
562 }
563
564 /**
565 * PUT /my-reviews/{id} - Update own review
566 */
567 public function updateMyReview(WP_REST_Request $request): WP_REST_Response
568 {
569 $id = (int) $request->get_param('id');
570 $data = $request->get_json_params();
571
572 // Set user_id to enforce ownership check in service
573 $data['user_id'] = get_current_user_id();
574
575 $result = $this->reviewService->updateReview($id, $data);
576
577 if (!$result['success']) {
578 return new WP_REST_Response($result, 400);
579 }
580
581 return new WP_REST_Response($result);
582 }
583 }
584