exercises

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs | README

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 }