ratings.rs (5327B)
1 //! Product rating API handlers 2 //! 3 //! This module handles creating and updating product ratings. 4 //! Enforces one rating per user per product using database upsert logic. 5 6 use axum::{ 7 extract::{Path, State}, 8 http::StatusCode, 9 response::{IntoResponse, Json}, 10 }; 11 use sqlx::PgPool; 12 use tracing::instrument; 13 use uuid::Uuid; 14 15 use crate::models::{Rating, RatingResponse, UpsertRatingRequest}; 16 17 /// Create or update a user's rating for a product 18 /// 19 /// # Endpoint 20 /// `PUT /products/{id}/ratings` 21 /// 22 /// # Path Parameters 23 /// - `id` - The product ID to rate 24 /// 25 /// # Request Body 26 /// ```json 27 /// { 28 /// "user_id": "123e4567-e89b-12d3-a456-426614174000", 29 /// "rating": 5, 30 /// "review": "Great product!" 31 /// } 32 /// ``` 33 /// 34 /// # Behavior 35 /// - If the user has already rated this product, the existing rating is **updated** 36 /// - If not, a new rating is **created** 37 /// - This is enforced by a unique constraint on (product_id, user_id) 38 /// 39 /// # Validation 40 /// - Rating must be between 1-5 41 /// - Product must exist and not be deleted 42 /// - User must exist in the users table 43 /// 44 /// # Response 45 /// Returns the created/updated rating with a message indicating the action taken. 46 /// 47 /// # Errors 48 /// - `400 BAD REQUEST` - Invalid rating value (not 1-5) or user doesn't exist 49 /// - `404 NOT FOUND` - Product doesn't exist 50 /// - `500 INTERNAL SERVER ERROR` - Database error 51 #[instrument( 52 name = "upsert_rating", 53 skip(pool, payload), 54 fields( 55 product.uuid = %product_id, 56 rating.value = payload.rating 57 ) 58 )] 59 pub async fn upsert_rating( 60 State(pool): State<PgPool>, 61 Path(product_id): Path<Uuid>, 62 Json(payload): Json<UpsertRatingRequest>, 63 ) -> impl IntoResponse { 64 // Validate rating is within acceptable range (1-5 stars) 65 if !(1..=5).contains(&payload.rating) { 66 return ( 67 StatusCode::BAD_REQUEST, 68 Json(serde_json::json!({ 69 "error": "Invalid rating value", 70 "message": "Rating must be between 1 and 5" 71 })), 72 ) 73 .into_response(); 74 } 75 76 // Verify the product exists and is not deleted 77 // Uses EXISTS for efficiency - only checks presence, doesn't fetch data 78 let product_exists: Option<(bool,)> = sqlx::query_as( 79 "SELECT EXISTS(SELECT 1 FROM products WHERE uuid = $1 AND deleted_at IS NULL)", 80 ) 81 .bind(product_id) 82 .fetch_optional(&pool) 83 .await 84 .ok() 85 .flatten(); 86 87 if !product_exists.map(|x| x.0).unwrap_or(false) { 88 return ( 89 StatusCode::NOT_FOUND, 90 Json(serde_json::json!({ 91 "error": "Product not found", 92 "product_id": product_id 93 })), 94 ) 95 .into_response(); 96 } 97 98 // Upsert the rating using PostgreSQL's ON CONFLICT clause 99 // This atomically either: 100 // 1. Inserts a new rating if none exists for this user-product pair 101 // 2. Updates the existing rating if one already exists 102 // 103 // The unique constraint on (product_id, user_id) ensures one rating per user per product 104 let result = sqlx::query_as::<_, Rating>( 105 r#" 106 INSERT INTO ratings (product_id, user_id, rating, review) 107 VALUES ($1, $2, $3, $4) 108 ON CONFLICT (product_id, user_id) 109 DO UPDATE SET 110 rating = EXCLUDED.rating, 111 review = EXCLUDED.review, 112 updated_at = NOW() 113 RETURNING id, uuid, product_id, user_id, rating, review, created_at, updated_at 114 "#, 115 ) 116 .bind(product_id) 117 .bind(payload.user_id) 118 .bind(payload.rating) 119 .bind(&payload.review) 120 .fetch_one(&pool) 121 .await; 122 123 match result { 124 Ok(rating) => { 125 // Determine if this was an update or new insert 126 // If created_at != updated_at, the record was updated 127 let was_updated = rating.created_at != rating.updated_at; 128 let message = if was_updated { 129 "Rating updated successfully" 130 } else { 131 "Rating created successfully" 132 }; 133 134 ( 135 StatusCode::OK, 136 Json(RatingResponse { 137 rating, 138 message: message.to_string(), 139 }), 140 ) 141 .into_response() 142 } 143 Err(e) => { 144 eprintln!("Database error upserting rating: {}", e); 145 146 // Check for foreign key constraint violations 147 // This happens when user_id doesn't exist in the users table 148 let error_message = e.to_string(); 149 if error_message.contains("foreign key") || error_message.contains("violates") { 150 return ( 151 StatusCode::BAD_REQUEST, 152 Json(serde_json::json!({ 153 "error": "Invalid user_id", 154 "message": "User must be registered before rating products" 155 })), 156 ) 157 .into_response(); 158 } 159 160 // Generic database error 161 ( 162 StatusCode::INTERNAL_SERVER_ERROR, 163 Json(serde_json::json!({ 164 "error": "Failed to save rating", 165 "details": e.to_string() 166 })), 167 ) 168 .into_response() 169 } 170 } 171 }