Skip to main content

stateset_core/models/
review.rs

1//! Product review and ratings domain models
2//!
3//! Handles customer reviews, moderation workflow, and aggregate rating summaries.
4
5use chrono::{DateTime, Utc};
6use serde::{Deserialize, Serialize};
7use stateset_primitives::{CustomerId, ProductId, ReviewId};
8use strum::{Display, EnumString};
9
10/// Review moderation status
11#[derive(
12    Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, Display, EnumString,
13)]
14#[serde(rename_all = "snake_case")]
15#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
16#[non_exhaustive]
17pub enum ReviewStatus {
18    /// Awaiting moderation
19    #[default]
20    Pending,
21    /// Approved and visible
22    Approved,
23    /// Rejected by moderator
24    Rejected,
25    /// Flagged for further review
26    Flagged,
27}
28
29/// A product review
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct Review {
32    /// Unique review ID
33    pub id: ReviewId,
34    /// Product being reviewed
35    pub product_id: ProductId,
36    /// Customer who wrote the review
37    pub customer_id: CustomerId,
38    /// Rating (1-5 stars)
39    pub rating: u8,
40    /// Review title/headline
41    pub title: Option<String>,
42    /// Review body text
43    pub body: Option<String>,
44    /// Moderation status
45    pub status: ReviewStatus,
46    /// Whether the reviewer purchased the product
47    pub verified_purchase: bool,
48    /// Number of "helpful" votes
49    pub helpful_count: u32,
50    /// Number of times reported as inappropriate
51    pub reported_count: u32,
52    /// When the review was created
53    pub created_at: DateTime<Utc>,
54    /// When the review was last updated
55    pub updated_at: DateTime<Utc>,
56}
57
58/// Aggregate rating summary for a product
59#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct ReviewSummary {
61    /// Product ID
62    pub product_id: ProductId,
63    /// Average rating (1.0-5.0)
64    pub average_rating: f64,
65    /// Total number of reviews
66    pub total_reviews: u64,
67    /// Distribution of ratings: index 0 = 1-star count, index 4 = 5-star count
68    pub rating_distribution: [u32; 5],
69}
70
71/// Input for creating a review
72#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct CreateReview {
74    /// Product being reviewed
75    pub product_id: ProductId,
76    /// Customer writing the review
77    pub customer_id: CustomerId,
78    /// Rating (1-5)
79    pub rating: u8,
80    /// Review title
81    pub title: Option<String>,
82    /// Review body
83    pub body: Option<String>,
84    /// Whether purchase was verified
85    pub verified_purchase: bool,
86}
87
88/// Input for updating a review
89#[derive(Debug, Clone, Serialize, Deserialize, Default)]
90pub struct UpdateReview {
91    /// Updated rating
92    pub rating: Option<u8>,
93    /// Updated title
94    pub title: Option<Option<String>>,
95    /// Updated body
96    pub body: Option<Option<String>>,
97    /// Updated status
98    pub status: Option<ReviewStatus>,
99}
100
101/// Filter for listing reviews
102#[derive(Debug, Clone, Serialize, Deserialize, Default)]
103pub struct ReviewFilter {
104    /// Filter by product
105    pub product_id: Option<ProductId>,
106    /// Filter by customer
107    pub customer_id: Option<CustomerId>,
108    /// Filter by status
109    pub status: Option<ReviewStatus>,
110    /// Filter by minimum rating
111    pub min_rating: Option<u8>,
112    /// Only verified purchases
113    pub verified_only: Option<bool>,
114    /// Maximum results
115    pub limit: Option<u32>,
116    /// Offset for pagination
117    pub offset: Option<u32>,
118}
119
120impl Review {
121    /// Whether this review is publicly visible
122    #[must_use]
123    pub fn is_visible(&self) -> bool {
124        self.status == ReviewStatus::Approved
125    }
126
127    /// Validate that the rating is in the valid range (1-5)
128    #[must_use]
129    pub fn is_valid_rating(rating: u8) -> bool {
130        (1..=5).contains(&rating)
131    }
132}
133
134impl ReviewSummary {
135    /// Create an empty summary for a product with no reviews
136    #[must_use]
137    pub const fn empty(product_id: ProductId) -> Self {
138        Self { product_id, average_rating: 0.0, total_reviews: 0, rating_distribution: [0; 5] }
139    }
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145    use chrono::Utc;
146    use stateset_primitives::{CustomerId, ProductId, ReviewId};
147
148    fn make_review(status: ReviewStatus, rating: u8) -> Review {
149        Review {
150            id: ReviewId::new(),
151            product_id: ProductId::new(),
152            customer_id: CustomerId::new(),
153            rating,
154            title: Some("Great product".to_string()),
155            body: Some("Really happy with this.".to_string()),
156            status,
157            verified_purchase: true,
158            helpful_count: 0,
159            reported_count: 0,
160            created_at: Utc::now(),
161            updated_at: Utc::now(),
162        }
163    }
164
165    // ---- is_visible ----
166
167    #[test]
168    fn is_visible_returns_true_when_approved() {
169        let review = make_review(ReviewStatus::Approved, 5);
170        assert!(review.is_visible());
171    }
172
173    #[test]
174    fn is_visible_returns_false_when_pending() {
175        let review = make_review(ReviewStatus::Pending, 5);
176        assert!(!review.is_visible());
177    }
178
179    #[test]
180    fn is_visible_returns_false_when_rejected() {
181        let review = make_review(ReviewStatus::Rejected, 5);
182        assert!(!review.is_visible());
183    }
184
185    #[test]
186    fn is_visible_returns_false_when_flagged() {
187        let review = make_review(ReviewStatus::Flagged, 5);
188        assert!(!review.is_visible());
189    }
190
191    // ---- is_valid_rating ----
192
193    #[test]
194    fn is_valid_rating_accepts_one_through_five() {
195        for r in 1u8..=5 {
196            assert!(Review::is_valid_rating(r), "rating {r} should be valid");
197        }
198    }
199
200    #[test]
201    fn is_valid_rating_rejects_zero() {
202        assert!(!Review::is_valid_rating(0));
203    }
204
205    #[test]
206    fn is_valid_rating_rejects_six() {
207        assert!(!Review::is_valid_rating(6));
208    }
209
210    // ---- ReviewSummary::empty ----
211
212    #[test]
213    fn review_summary_empty_has_zero_totals() {
214        let product_id = ProductId::new();
215        let summary = ReviewSummary::empty(product_id);
216        assert_eq!(summary.total_reviews, 0);
217        assert_eq!(summary.average_rating, 0.0);
218        assert_eq!(summary.rating_distribution, [0u32; 5]);
219    }
220
221    // ---- enum Display / FromStr round-trips ----
222
223    #[test]
224    fn review_status_display_fromstr_roundtrip() {
225        for status in [
226            ReviewStatus::Pending,
227            ReviewStatus::Approved,
228            ReviewStatus::Rejected,
229            ReviewStatus::Flagged,
230        ] {
231            let s = status.to_string();
232            let parsed: ReviewStatus = s.parse().unwrap();
233            assert_eq!(parsed, status, "round-trip failed for {s}");
234        }
235    }
236
237    // ---- Default ----
238
239    #[test]
240    fn review_status_default_is_pending() {
241        assert_eq!(ReviewStatus::default(), ReviewStatus::Pending);
242    }
243}