mostro_core/user.rs
1//! Persistent user representation and reputation helpers.
2//!
3//! The [`User`] struct is the database-backed record Mostro keeps for every
4//! identity that has interacted with the system. It tracks rating aggregates,
5//! administrative flags and the last trade index used by the user, which is
6//! required so that new orders always carry a strictly increasing trade index.
7//!
8//! [`UserInfo`] is a lightweight view of the same data that can safely be
9//! shared with a counterpart during a trade without leaking internals.
10
11use chrono::Utc;
12use serde::{Deserialize, Serialize};
13#[cfg(feature = "sqlx")]
14use sqlx::FromRow;
15
16/// Public snapshot of a user's reputation shared with peers during a trade.
17///
18/// Unlike [`User`], `UserInfo` contains only the values a counterpart needs
19/// to decide whether to trade: aggregated rating, number of reviews and how
20/// many days the user has been operating on Mostro.
21#[derive(Debug, Default, Deserialize, Serialize, Clone)]
22
23pub struct UserInfo {
24 /// Aggregated rating value for the user (see [`crate::rating::Rating`]).
25 pub rating: f64,
26 /// Total number of ratings received.
27 pub reviews: i64,
28 /// Number of days since the user account was created.
29 pub operating_days: u64,
30}
31
32/// Database representation of a Mostro user.
33///
34/// This is the canonical row stored on the Mostro node. It tracks identity
35/// data (`pubkey`), administrative role flags, the last trade index used by
36/// the user and rating aggregates used to compute reputation.
37#[cfg_attr(feature = "sqlx", derive(FromRow))]
38#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
39pub struct User {
40 /// Master public key of the user, hex encoded.
41 pub pubkey: String,
42 /// `1` when the user has admin privileges, `0` otherwise. Stored as
43 /// `i64` to match the underlying SQLite representation.
44 pub is_admin: i64,
45 /// Optional password used to authenticate privileged admin actions.
46 pub admin_password: Option<String>,
47 /// `1` when the user is a dispute solver, `0` otherwise.
48 pub is_solver: i64,
49 /// `1` when the user is banned from the platform, `0` otherwise.
50 pub is_banned: i64,
51 /// Free-form category bucket. Reserved for future segmentation.
52 pub category: i64,
53 /// Last trade index used by this user. When a user creates a new order
54 /// (or takes one) the incoming trade index must be strictly greater than
55 /// this value, or the request is rejected.
56 pub last_trade_index: i64,
57 /// Total number of ratings the user has received.
58 pub total_reviews: i64,
59 /// Weighted rating average computed from all received ratings.
60 pub total_rating: f64,
61 /// Most recent rating received, in the `MIN_RATING..=MAX_RATING` range.
62 pub last_rating: i64,
63 /// Highest rating ever received.
64 pub max_rating: i64,
65 /// Lowest rating ever received.
66 pub min_rating: i64,
67 /// Unix timestamp (seconds) when the user record was created.
68 pub created_at: i64,
69}
70
71impl User {
72 /// Create a new [`User`] with fresh rating aggregates.
73 ///
74 /// `trade_index` becomes the user's `last_trade_index`. The `created_at`
75 /// timestamp is set to the current system time.
76 pub fn new(
77 pubkey: String,
78 is_admin: i64,
79 is_solver: i64,
80 is_banned: i64,
81 category: i64,
82 trade_index: i64,
83 ) -> Self {
84 Self {
85 pubkey,
86 is_admin,
87 admin_password: None,
88 is_solver,
89 is_banned,
90 category,
91 last_trade_index: trade_index,
92 total_reviews: 0,
93 total_rating: 0.0,
94 last_rating: 0,
95 max_rating: 0,
96 min_rating: 0,
97 created_at: Utc::now().timestamp(),
98 }
99 }
100
101 /// Record a new rating for the user and refresh the aggregates.
102 ///
103 /// The first vote is weighted by `1/2` so that a single review cannot
104 /// anchor a perfect or disastrous reputation. Subsequent votes update
105 /// `total_rating` with an incremental running-average formula.
106 /// `min_rating` and `max_rating` are tightened as new extremes arrive.
107 ///
108 /// # Example
109 ///
110 /// ```
111 /// use mostro_core::user::User;
112 ///
113 /// let mut user = User::new("pubkey".into(), 0, 0, 0, 0, 0);
114 /// user.update_rating(5);
115 /// assert_eq!(user.total_reviews, 1);
116 /// assert_eq!(user.max_rating, 5);
117 /// ```
118 pub fn update_rating(&mut self, rating: u8) {
119 // Update user reputation
120 // increment first
121 self.total_reviews += 1;
122 let old_rating = self.total_rating;
123 // recompute new rating
124 if self.total_reviews <= 1 {
125 // New logic with weight 1/2 for first vote.
126 let first_rating = rating as f64;
127 self.total_rating = first_rating / 2.0;
128 self.max_rating = rating.into();
129 self.min_rating = rating.into();
130 } else {
131 self.total_rating =
132 old_rating + ((rating as f64) - old_rating) / (self.total_reviews as f64);
133 if self.max_rating < rating.into() {
134 self.max_rating = rating.into();
135 }
136 if self.min_rating > rating.into() {
137 self.min_rating = rating.into();
138 }
139 }
140 // Store last rating
141 self.last_rating = rating.into();
142 }
143}
144
145#[cfg(test)]
146mod tests {
147 use super::*;
148
149 #[test]
150 fn first_vote_is_weighted_by_half() {
151 let mut user = User::default();
152
153 user.update_rating(5);
154
155 assert_eq!(user.total_reviews, 1);
156 assert_eq!(user.total_rating, 2.5);
157 assert_eq!(user.last_rating, 5);
158 assert_eq!(user.max_rating, 5);
159 assert_eq!(user.min_rating, 5);
160 }
161
162 #[test]
163 fn second_vote_is_folded_into_the_average() {
164 let mut user = User::default();
165 user.update_rating(5);
166
167 user.update_rating(1);
168
169 // First vote weighted 1/2 -> 2.5, then incremental average with the
170 // new vote: 2.5 + (1 - 2.5) / 2 = 1.75
171 assert_eq!(user.total_reviews, 2);
172 assert!((user.total_rating - 1.75).abs() < 1e-9);
173 assert_eq!(user.last_rating, 1);
174 }
175
176 #[test]
177 fn low_vote_lowers_a_high_average() {
178 let mut user = User::default();
179 for _ in 0..10 {
180 user.update_rating(5);
181 }
182 let farmed_average = user.total_rating;
183 assert!((farmed_average - 4.75).abs() < 1e-9);
184
185 user.update_rating(1);
186
187 // Correct running average: (2.5 + 9 * 5 + 1) / 11 = 48.5 / 11
188 let expected = 48.5 / 11.0;
189 assert!(
190 user.total_rating < farmed_average,
191 "a 1-star review must lower the average, got {} (was {})",
192 user.total_rating,
193 farmed_average
194 );
195 assert!((user.total_rating - expected).abs() < 1e-9);
196 assert_eq!(user.last_rating, 1);
197 assert_eq!(user.min_rating, 1);
198 assert_eq!(user.max_rating, 5);
199 }
200
201 #[test]
202 fn high_vote_raises_a_low_average() {
203 let mut user = User::default();
204 user.update_rating(1);
205 user.update_rating(1);
206 let low_average = user.total_rating;
207
208 user.update_rating(5);
209
210 assert!(
211 user.total_rating > low_average,
212 "a 5-star review must raise the average, got {} (was {})",
213 user.total_rating,
214 low_average
215 );
216 // (0.5 + 1 + 5) / 3
217 assert!((user.total_rating - 6.5 / 3.0).abs() < 1e-9);
218 }
219
220 #[test]
221 fn min_and_max_track_extremes() {
222 let mut user = User::default();
223 user.update_rating(3);
224 user.update_rating(5);
225 user.update_rating(1);
226
227 assert_eq!(user.max_rating, 5);
228 assert_eq!(user.min_rating, 1);
229 }
230}