reviewService = new ReviewService(); } /** * Register routes */ public function register_routes(): void { $namespace = 'yatra/v1'; $base = 'reviews'; // ===================== // ADMIN ROUTES // ===================== // List reviews + create review (admin). View cap for list, // edit cap for create. register_rest_route($namespace, '/' . $base, [ [ 'methods' => \WP_REST_Server::READABLE, 'callback' => [$this, 'getReviews'], 'permission_callback' => [$this, 'checkCanView'], ], [ // The admin "Add New Review" form (resources/js/pages/ReviewForm.tsx) // POSTs here. Distinct from the public-facing // `POST /trips/{trip_id}/reviews` route below: the admin path // bypasses the "already reviewed this trip" gate, accepts // any DB-valid status (incl. spam/trash post-3.0.5 migration), // and stamps `created_by` with the current admin user id. 'methods' => \WP_REST_Server::CREATABLE, 'callback' => [$this, 'createReview'], 'permission_callback' => [$this, 'checkCanEdit'], ], ]); // Bulk actions — moderation operations. Manage cap covers // approval workflows; delete actions inside the bulk handler // should re-check the delete cap. register_rest_route($namespace, '/' . $base . '/bulk', [ [ 'methods' => \WP_REST_Server::EDITABLE, 'callback' => [$this, 'bulkAction'], 'permission_callback' => [$this, 'checkCanManage'], ], ]); // Single review — read / update / delete with distinct caps. register_rest_route($namespace, '/' . $base . '/(?P[\d]+)', [ [ 'methods' => \WP_REST_Server::READABLE, 'callback' => [$this, 'getReview'], 'permission_callback' => [$this, 'checkCanView'], ], [ 'methods' => \WP_REST_Server::EDITABLE, 'callback' => [$this, 'updateReview'], 'permission_callback' => [$this, 'checkCanEdit'], ], [ 'methods' => \WP_REST_Server::DELETABLE, 'callback' => [$this, 'deleteReview'], 'permission_callback' => [$this, 'checkCanDelete'], ], ]); // Update review status — moderation action. Manage cap (held // by Owner, Manager, Marketing). register_rest_route($namespace, '/' . $base . '/(?P[\d]+)/status', [ [ 'methods' => \WP_REST_Server::EDITABLE, 'callback' => [$this, 'updateStatus'], 'permission_callback' => [$this, 'checkCanManage'], ], ]); // Stats — view cap. register_rest_route($namespace, '/' . $base . '/stats', [ [ 'methods' => \WP_REST_Server::READABLE, 'callback' => [$this, 'getStats'], 'permission_callback' => [$this, 'checkCanView'], ], ]); // ===================== // PUBLIC ROUTES // ===================== // Get trip reviews (public) register_rest_route($namespace, '/trips/(?P[\d]+)/reviews', [ [ 'methods' => \WP_REST_Server::READABLE, 'callback' => [$this, 'getTripReviews'], 'permission_callback' => '__return_true', ], ]); // Submit review (authenticated) register_rest_route($namespace, '/trips/(?P[\d]+)/reviews', [ [ 'methods' => \WP_REST_Server::CREATABLE, 'callback' => [$this, 'submitReview'], 'permission_callback' => [$this, 'checkReviewPermission'], ], ]); // Check if user can review register_rest_route($namespace, '/trips/(?P[\d]+)/can-review', [ [ 'methods' => \WP_REST_Server::READABLE, 'callback' => [$this, 'canReview'], 'permission_callback' => '__return_true', ], ]); // User's own review operations register_rest_route($namespace, '/my-reviews/(?P[\d]+)', [ [ 'methods' => \WP_REST_Server::EDITABLE, 'callback' => [$this, 'updateMyReview'], 'permission_callback' => [$this, 'checkUserPermission'], ], ]); } /** * Granular admin-side permission checks. WP administrators pass * every cap via the Team module's admin-fallback filter, so an * explicit `manage_options` check isn't needed here. */ public function checkCanView(): bool { return current_user_can('yatra_view_reviews'); } public function checkCanEdit(): bool { return current_user_can('yatra_edit_reviews'); } public function checkCanManage(): bool { // Moderation cap — approve / spam / trash. Distinct from // edit so a Marketing role can moderate without being able // to edit the review body itself. return current_user_can('yatra_manage_reviews'); } public function checkCanDelete(): bool { return current_user_can('yatra_delete_reviews'); } /** * @deprecated Kept for any external code referencing the old * method name. Old implementation only checked `manage_options`, * so non-admin Yatra-role users were locked out of every * endpoint. New behaviour routes to the view cap which is more * permissive for legitimate team members; admin users still pass * via the admin-fallback layer. */ public function checkAdminPermission(): bool { return $this->checkCanView(); } /** * Check if user is logged in */ public function checkUserPermission(): bool { return is_user_logged_in(); } /** * Check review submission permission */ public function checkReviewPermission(): bool { $settings = \Yatra\Services\SettingsService::getSettings(); $requireLogin = $settings['reviews']['require_login'] ?? true; if ($requireLogin) { return is_user_logged_in(); } return true; } // ========================================================================= // ADMIN ENDPOINTS // ========================================================================= /** * GET /reviews - List all reviews (admin) */ public function getReviews(WP_REST_Request $request): WP_REST_Response { $filters = [ 'page' => (int) ($request->get_param('page') ?: 1), 'per_page' => (int) ($request->get_param('per_page') ?: 20), 'status' => $request->get_param('status') ?: '', 'trip_id' => (int) $request->get_param('trip_id'), 'rating' => (int) $request->get_param('rating'), 'search' => $request->get_param('search') ?: '', ]; $result = $this->reviewService->getReviews($filters); // Map database fields to frontend expected fields $mappedData = array_map(function($review) { // ReviewService returns arrays, not objects return (object) [ 'id' => $review['id'] ?? null, 'trip_id' => $review['trip_id'] ?? null, 'trip_title' => $review['trip_title'] ?? '', 'trip_slug' => $review['trip_slug'] ?? '', 'customer_name' => $review['author_name'] ?? '', 'customer_email' => $review['author_email'] ?? '', 'rating' => $review['rating'] ?? 0, 'title' => $review['title'] ?? '', 'content' => $review['content'] ?? '', 'status' => $review['status'] ?? '', 'verified' => false, // TODO: Add verified field to database 'created_at' => $review['created_at'] ?? null, ]; }, $result['data']); return new WP_REST_Response([ 'success' => true, 'data' => $mappedData, 'meta' => [ 'total' => $result['total'], 'page' => $result['page'], 'per_page' => $result['per_page'], 'total_pages' => $result['total_pages'], ], ]); } /** * GET /reviews/{id} - Get single review */ public function getReview(WP_REST_Request $request): WP_REST_Response { $id = (int) $request->get_param('id'); $review = $this->reviewService->getReview($id); if (!$review) { return new WP_REST_Response([ 'success' => false, 'message' => __('Review not found.', 'yatra'), ], 404); } return new WP_REST_Response([ 'success' => true, 'data' => $review, ]); } /** * PUT /reviews/{id} - Update review */ public function updateReview(WP_REST_Request $request): WP_REST_Response { $id = (int) $request->get_param('id'); $data = $this->mapAdminReviewPayload($request->get_json_params() ?? []); $result = $this->reviewService->updateReview($id, $data); if (!$result['success']) { return new WP_REST_Response($result, 400); } return new WP_REST_Response($result); } /** * POST /reviews — Admin "Add New Review" endpoint. * * The admin React form posts the operator-curated fields here. This * path differs from `submitReview` (which the public review form uses) * in three ways: * * 1. No "already reviewed this trip" gate — admins should be able to * enter reviews on behalf of customers without tripping the * duplicate-prevention guard. * 2. Honours the operator-supplied `status` rather than deriving it * from the `reviews.auto_approve` setting — the admin is the * authority for whether the row is pending / approved / spam etc. * 3. Stamps `created_by` with the current admin user id so audits * can attribute who added the review. */ public function createReview(WP_REST_Request $request): WP_REST_Response { $data = $this->mapAdminReviewPayload($request->get_json_params() ?? []); // created_by is set here (controller) rather than in the service // so the service stays input-agnostic — service methods may also // be called from CLI / cron / tests where there's no current user. $data['created_by'] = get_current_user_id() ?: null; $result = $this->reviewService->createReviewAsAdmin($data); if (!$result['success']) { return new WP_REST_Response($result, 400); } return new WP_REST_Response($result, 201); } /** * Translate the admin form's payload shape into the field names the * service + repository expect. * * The admin React form (resources/js/pages/ReviewForm.tsx) ships: * - customer_name, customer_email, comment, verified, status * * The DB columns (and {@see ReviewRepository::prepareReviewData()}) * speak: * - author_name, author_email, content, status * (no `verified` column exists yet — silently dropped) * * Doing this map at the controller layer keeps the service free of * UI-specific aliases, and means future UIs can either send the * legacy alias names or the canonical names with no double-mapping. * * @param array $payload Raw JSON from the request. * @return array Canonical, service-ready payload. */ private function mapAdminReviewPayload(array $payload): array { // Field aliases: admin-side name → canonical DB-column name. $aliases = [ 'customer_name' => 'author_name', 'customer_email' => 'author_email', 'comment' => 'content', ]; foreach ($aliases as $from => $to) { if (array_key_exists($from, $payload) && !array_key_exists($to, $payload)) { $payload[$to] = $payload[$from]; } // Don't unset the alias — leaving both is harmless because // prepareReviewData ignores unknown keys, and it keeps the // payload introspectable in logs. } // `verified` has no column in wp_yatra_reviews yet. Drop it // explicitly so a future log of the payload doesn't suggest the // value was honoured. if (array_key_exists('verified', $payload)) { unset($payload['verified']); } // Clamp status to the actual enum. Anything else gets coerced to // 'pending' so we never write '' (the silent-truncation pit that // motivated the Upgrade_3_0_5 migration in the first place). if (array_key_exists('status', $payload)) { $allowed = ['pending', 'approved', 'rejected', 'spam', 'trash']; $status = is_string($payload['status']) ? $payload['status'] : ''; $payload['status'] = in_array($status, $allowed, true) ? $status : 'pending'; } return $payload; } /** * PUT /reviews/bulk - Bulk actions */ public function bulkAction(WP_REST_Request $request): WP_REST_Response { $data = $request->get_json_params(); $action = $data['action'] ?? ''; $ids = $data['ids'] ?? []; if (empty($ids) || !is_array($ids)) { return new WP_REST_Response([ 'success' => false, 'message' => __('No items selected.', 'yatra'), ], 400); } switch ($action) { case 'delete': $result = $this->reviewService->bulkDelete($ids); break; case 'mark_approved': $result = $this->reviewService->bulkUpdateStatus($ids, 'approved'); break; case 'mark_pending': $result = $this->reviewService->bulkUpdateStatus($ids, 'pending'); break; case 'mark_spam': $result = $this->reviewService->bulkUpdateStatus($ids, 'spam'); break; case 'mark_trash': $result = $this->reviewService->bulkUpdateStatus($ids, 'trash'); break; default: return new WP_REST_Response([ 'success' => false, 'message' => __('Invalid action.', 'yatra'), ], 400); } return new WP_REST_Response($result); } /** * DELETE /reviews/{id} - Delete review */ public function deleteReview(WP_REST_Request $request): WP_REST_Response { $id = (int) $request->get_param('id'); $result = $this->reviewService->deleteReview($id); if (!$result['success']) { return new WP_REST_Response($result, 400); } return new WP_REST_Response($result); } /** * PUT /reviews/{id}/status - Update status */ public function updateStatus(WP_REST_Request $request): WP_REST_Response { $id = (int) $request->get_param('id'); $data = $request->get_json_params(); $status = $data['status'] ?? ''; if (empty($status)) { return new WP_REST_Response([ 'success' => false, 'message' => __('Status is required.', 'yatra'), ], 400); } $result = $this->reviewService->updateStatus($id, $status); if (!$result['success']) { return new WP_REST_Response($result, 400); } return new WP_REST_Response($result); } /** * GET /reviews/stats - Get statistics */ public function getStats(WP_REST_Request $request): WP_REST_Response { $stats = $this->reviewService->getStats(); return new WP_REST_Response([ 'success' => true, 'data' => $stats, ]); } // ========================================================================= // PUBLIC ENDPOINTS // ========================================================================= /** * GET /trips/{trip_id}/reviews - Get trip reviews (public) */ public function getTripReviews(WP_REST_Request $request): WP_REST_Response { $tripId = (int) $request->get_param('trip_id'); $limit = (int) ($request->get_param('limit') ?: 10); $reviews = $this->reviewService->getTripReviews($tripId, $limit); $summary = $this->reviewService->getTripRatingSummary($tripId); return new WP_REST_Response([ 'success' => true, 'data' => [ 'reviews' => $reviews, 'summary' => $summary, ], ]); } /** * POST /trips/{trip_id}/reviews - Submit review */ public function submitReview(WP_REST_Request $request): WP_REST_Response { $tripId = (int) $request->get_param('trip_id'); $data = $request->get_json_params(); // Handle both JSON and form data if (empty($data)) { $data = $request->get_params(); } $data['trip_id'] = $tripId; $result = $this->reviewService->submitReview($data); if (!$result['success']) { return new WP_REST_Response($result, 400); } return new WP_REST_Response($result, 201); } /** * GET /trips/{trip_id}/can-review - Check if user can review */ public function canReview(WP_REST_Request $request): WP_REST_Response { $tripId = (int) $request->get_param('trip_id'); $result = $this->reviewService->canUserReview($tripId); return new WP_REST_Response([ 'success' => true, 'data' => $result, ]); } /** * PUT /my-reviews/{id} - Update own review */ public function updateMyReview(WP_REST_Request $request): WP_REST_Response { $id = (int) $request->get_param('id'); $data = $request->get_json_params(); // Set user_id to enforce ownership check in service $data['user_id'] = get_current_user_id(); $result = $this->reviewService->updateReview($id, $data); if (!$result['success']) { return new WP_REST_Response($result, 400); } return new WP_REST_Response($result); } }