Skip to main content

mockforge_collab/
user.rs

1//! User management service
2
3use crate::auth::AuthService;
4use crate::error::{CollabError, Result};
5use crate::models::User;
6use sqlx::{Pool, Sqlite};
7use std::sync::Arc;
8use uuid::Uuid;
9
10/// User service for managing user accounts
11pub struct UserService {
12    db: Pool<Sqlite>,
13    auth: Arc<AuthService>,
14}
15
16impl UserService {
17    /// Create a new user service
18    #[must_use]
19    pub const fn new(db: Pool<Sqlite>, auth: Arc<AuthService>) -> Self {
20        Self { db, auth }
21    }
22
23    /// Create a new user account
24    ///
25    /// # Errors
26    ///
27    /// Returns an error if input is invalid, user already exists, or database fails.
28    pub async fn create_user(
29        &self,
30        username: String,
31        email: String,
32        password: String,
33    ) -> Result<User> {
34        // Validate input
35        if username.is_empty() || email.is_empty() || password.is_empty() {
36            return Err(CollabError::InvalidInput(
37                "Username, email, and password are required".to_string(),
38            ));
39        }
40
41        // Check if username already exists
42        let existing = sqlx::query!(
43            r#"SELECT COUNT(*) as count FROM users WHERE username = ? OR email = ?"#,
44            username,
45            email
46        )
47        .fetch_one(&self.db)
48        .await?;
49
50        if existing.count > 0 {
51            return Err(CollabError::AlreadyExists("Username or email already exists".to_string()));
52        }
53
54        // Hash password
55        let password_hash = self.auth.hash_password(&password)?;
56
57        // Create user
58        let user = User::new(username, email, password_hash);
59
60        // Insert into database
61        sqlx::query!(
62            r#"
63            INSERT INTO users (id, username, email, password_hash, display_name, avatar_url, created_at, updated_at, is_active)
64            VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
65            "#,
66            user.id,
67            user.username,
68            user.email,
69            user.password_hash,
70            user.display_name,
71            user.avatar_url,
72            user.created_at,
73            user.updated_at,
74            user.is_active
75        )
76        .execute(&self.db)
77        .await?;
78
79        Ok(user)
80    }
81
82    /// Authenticate a user and return user if valid
83    ///
84    /// # Errors
85    ///
86    /// Returns an error if credentials are invalid.
87    pub async fn authenticate(&self, username: &str, password: &str) -> Result<User> {
88        // Fetch user by username or email
89        let user = sqlx::query_as!(
90            User,
91            r#"
92            SELECT id as "id: Uuid", username, email, password_hash, display_name, avatar_url,
93                   created_at as "created_at: chrono::DateTime<chrono::Utc>",
94                   updated_at as "updated_at: chrono::DateTime<chrono::Utc>",
95                   is_active as "is_active: bool"
96            FROM users
97            WHERE (username = ? OR email = ?) AND is_active = TRUE
98            "#,
99            username,
100            username
101        )
102        .fetch_optional(&self.db)
103        .await?
104        .ok_or_else(|| CollabError::AuthenticationFailed("Invalid credentials".to_string()))?;
105
106        // Verify password
107        if !self.auth.verify_password(password, &user.password_hash)? {
108            return Err(CollabError::AuthenticationFailed("Invalid credentials".to_string()));
109        }
110
111        Ok(user)
112    }
113
114    /// Get user by ID
115    ///
116    /// # Errors
117    ///
118    /// Returns an error if the user is not found.
119    pub async fn get_user(&self, user_id: Uuid) -> Result<User> {
120        let user = sqlx::query_as!(
121            User,
122            r#"
123            SELECT id as "id: Uuid", username, email, password_hash, display_name, avatar_url,
124                   created_at as "created_at: chrono::DateTime<chrono::Utc>",
125                   updated_at as "updated_at: chrono::DateTime<chrono::Utc>",
126                   is_active as "is_active: bool"
127            FROM users
128            WHERE id = ?
129            "#,
130            user_id
131        )
132        .fetch_optional(&self.db)
133        .await?
134        .ok_or_else(|| CollabError::UserNotFound(user_id.to_string()))?;
135
136        Ok(user)
137    }
138
139    /// Get user by username
140    ///
141    /// # Errors
142    ///
143    /// Returns an error if the user is not found.
144    pub async fn get_user_by_username(&self, username: &str) -> Result<User> {
145        let user = sqlx::query_as!(
146            User,
147            r#"
148            SELECT id as "id: Uuid", username, email, password_hash, display_name, avatar_url,
149                   created_at as "created_at: chrono::DateTime<chrono::Utc>",
150                   updated_at as "updated_at: chrono::DateTime<chrono::Utc>",
151                   is_active as "is_active: bool"
152            FROM users
153            WHERE username = ?
154            "#,
155            username
156        )
157        .fetch_optional(&self.db)
158        .await?
159        .ok_or_else(|| CollabError::UserNotFound(username.to_string()))?;
160
161        Ok(user)
162    }
163
164    /// Update user profile
165    ///
166    /// # Errors
167    ///
168    /// Returns an error if the update fails.
169    pub async fn update_user(
170        &self,
171        user_id: Uuid,
172        display_name: Option<String>,
173        avatar_url: Option<String>,
174    ) -> Result<User> {
175        let now = chrono::Utc::now();
176
177        sqlx::query!(
178            r#"
179            UPDATE users
180            SET display_name = COALESCE(?, display_name),
181                avatar_url = COALESCE(?, avatar_url),
182                updated_at = ?
183            WHERE id = ?
184            "#,
185            display_name,
186            avatar_url,
187            now,
188            user_id
189        )
190        .execute(&self.db)
191        .await?;
192
193        self.get_user(user_id).await
194    }
195
196    /// Change user password
197    ///
198    /// # Errors
199    ///
200    /// Returns an error if the old password is invalid or the update fails.
201    pub async fn change_password(
202        &self,
203        user_id: Uuid,
204        old_password: &str,
205        new_password: &str,
206    ) -> Result<()> {
207        // Get user
208        let user = self.get_user(user_id).await?;
209
210        // Verify old password
211        if !self.auth.verify_password(old_password, &user.password_hash)? {
212            return Err(CollabError::AuthenticationFailed("Invalid old password".to_string()));
213        }
214
215        // Hash new password
216        let new_hash = self.auth.hash_password(new_password)?;
217
218        // Update password
219        let now = chrono::Utc::now();
220        sqlx::query!(
221            r#"UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?"#,
222            new_hash,
223            now,
224            user_id
225        )
226        .execute(&self.db)
227        .await?;
228
229        Ok(())
230    }
231
232    /// Deactivate user account
233    ///
234    /// # Errors
235    ///
236    /// Returns an error if the deactivation fails.
237    pub async fn deactivate_user(&self, user_id: Uuid) -> Result<()> {
238        let now = chrono::Utc::now();
239        sqlx::query!(
240            r#"UPDATE users SET is_active = FALSE, updated_at = ? WHERE id = ?"#,
241            now,
242            user_id
243        )
244        .execute(&self.db)
245        .await?;
246
247        Ok(())
248    }
249}
250
251#[cfg(test)]
252mod tests {
253    // Note: These tests require a database setup
254    // They serve as documentation of the API
255}