db/models/
user.rs

1use argon2::{
2    Argon2,
3    password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
4};
5use chrono::{DateTime, Utc};
6use rand::rngs::OsRng;
7use sea_orm::entity::prelude::*;
8use sea_orm::{
9    ActiveModelTrait, ColumnTrait, DatabaseConnection, DbErr, EntityTrait, QueryFilter, Set,
10};
11
12use crate::models::user_module_role::Role;
13use crate::models::{
14    module::Entity as ModuleEntity,
15    user::{self, ActiveModel as UserActiveModel, Entity as UserEntity},
16    user_module_role::{Column as RoleColumn, Entity as RoleEntity},
17};
18use std::str::FromStr;
19
20/// Represents a user in the `users` table.
21#[derive(Clone, Debug, PartialEq, DeriveEntityModel, serde::Serialize)]
22#[sea_orm(table_name = "users")]
23pub struct Model {
24    /// Primary key ID (auto-incremented).
25    #[sea_orm(primary_key)]
26    pub id: i64,
27    /// Unique student number.
28    pub username: String,
29    /// User's unique email address.
30    pub email: String,
31    /// Securely hashed password string.
32    pub password_hash: String,
33    /// Whether the user has admin privileges.
34    pub admin: bool,
35    /// Timestamp when the user was created.
36    pub created_at: DateTime<Utc>,
37    /// Timestamp when the user was last updated.
38    pub updated_at: DateTime<Utc>,
39    //User profile picture
40    pub profile_picture_path: Option<String>,
41}
42
43/// This enum would define relations if any exist. Currently unused.
44#[derive(Copy, Clone, Debug, EnumIter)]
45pub enum Relation {}
46
47impl RelationTrait for Relation {
48    fn def(&self) -> RelationDef {
49        panic!("No RelationDef implemented")
50    }
51}
52
53/// SeaORM hook point for customizing model behavior.
54impl ActiveModelBehavior for ActiveModel {}
55
56/// Struct returned by `get_module_roles`, summarizing a user's role in a module.
57#[derive(Debug, Clone)]
58pub struct UserModuleRole {
59    pub module_id: i64,
60    pub module_code: String,
61    pub module_year: i32,
62    pub module_description: Option<String>,
63    pub module_credits: i32,
64    pub module_created_at: String,
65    pub module_updated_at: String,
66    pub role: String,
67}
68
69impl Model {
70    /// Creates a new user with hashed password and returns the inserted model.
71    ///
72    /// # Arguments
73    /// * `db` - Database connection reference.
74    /// * `username` - Unique student number.
75    /// * `email` - Email address.
76    /// * `password` - Plaintext password to hash.
77    /// * `admin` - Whether the user is an admin.
78    pub async fn create(
79        db: &DatabaseConnection,
80        username: &str,
81        email: &str,
82        password: &str,
83        admin: bool,
84    ) -> Result<Model, DbErr> {
85        let hash = Self::hash_password(password);
86        let active = UserActiveModel {
87            username: Set(username.to_owned()),
88            email: Set(email.to_owned()),
89            password_hash: Set(hash),
90            admin: Set(admin),
91            ..Default::default()
92        };
93        active.insert(db).await
94    }
95
96    /// Creates a new user with no hashed password and returns the inserted model.
97    /// NOTE: FOR SEEDING PURPOSES ONLY
98    /// DO NOT USE
99    ///
100    /// # Arguments
101    /// * `db` - Database connection reference.
102    /// * `username` - Unique student number.
103    /// * `email` - Email address.
104    /// * `password` - Plaintext password to hash.
105    /// * `admin` - Whether the user is an admin.
106    pub async fn create_fake_user_with_no_hashed_password_do_not_use(
107        db: &DatabaseConnection,
108        username: &str,
109        email: &str,
110        password: &str,
111        admin: bool,
112    ) -> Result<Model, DbErr> {
113        let hash = password;
114        let active = UserActiveModel {
115            username: Set(username.to_owned()),
116            email: Set(email.to_owned()),
117            password_hash: Set(hash.to_string()),
118            admin: Set(admin),
119            ..Default::default()
120        };
121        active.insert(db).await
122    }
123
124    /// Fetches a user by student number.
125    ///
126    /// # Arguments
127    /// * `db` - Database connection.
128    /// * `username` - The student number to look up.
129    ///
130    /// # Returns
131    /// An optional user model if found.
132    pub async fn get_by_username(
133        db: &DatabaseConnection,
134        username: &str,
135    ) -> Result<Option<Model>, DbErr> {
136        UserEntity::find()
137            .filter(user::Column::Username.eq(username))
138            .one(db)
139            .await
140    }
141
142    /// Verifies user credentials by checking password against stored hash.
143    ///
144    /// # Arguments
145    /// * `db` - Database connection.
146    /// * `username` - The student number of the user.
147    /// * `password` - The plaintext password to verify.
148    ///
149    /// # Returns
150    /// The user model if credentials are valid.
151    pub async fn verify_credentials(
152        db: &DatabaseConnection,
153        username: &str,
154        password: &str,
155    ) -> Result<Option<Model>, DbErr> {
156        let username = username.trim();
157
158        if let Some(user) = Self::get_by_username(db, username).await? {
159            let parsed = PasswordHash::new(&user.password_hash)
160                .map_err(|e| DbErr::Custom(format!("Invalid hash: {}", e)))?;
161
162            if Argon2::default()
163                .verify_password(password.as_bytes(), &parsed)
164                .is_ok()
165            {
166                return Ok(Some(user));
167            }
168        }
169
170        Ok(None)
171    }
172
173    /// Retrieves all module roles associated with the user.
174    ///
175    /// # Arguments
176    /// * `db` - Database connection.
177    /// * `user_id` - The user ID to query.
178    ///
179    /// # Returns
180    /// A list of module roles with related metadata.
181    pub async fn get_module_roles(
182        db: &DatabaseConnection,
183        user_id: i64,
184    ) -> Result<Vec<UserModuleRole>, DbErr> {
185        let roles = RoleEntity::find()
186            .filter(RoleColumn::UserId.eq(user_id))
187            .find_also_related(ModuleEntity)
188            .all(db)
189            .await?;
190
191        Ok(roles
192            .into_iter()
193            .filter_map(|(role, maybe_module)| {
194                maybe_module.map(|module| UserModuleRole {
195                    module_id: module.id,
196                    module_code: module.code,
197                    module_year: module.year,
198                    module_description: module.description,
199                    module_credits: module.credits,
200                    module_created_at: module.created_at.to_string(),
201                    module_updated_at: module.updated_at.to_string(),
202                    role: role.role.to_string(),
203                })
204            })
205            .collect())
206    }
207
208    /// Checks whether a user is assigned a specific role in a given module.
209    ///
210    /// # Arguments
211    /// * `db` - Database connection.
212    /// * `user_id` - User ID to check.
213    /// * `module_id` - Module ID to check against.
214    /// * `role` - Role name (e.g. "lecturer", "tutor", "student").
215    ///
216    /// # Returns
217    /// `true` if the role exists, otherwise `false`.
218    pub async fn is_in_role(
219        db: &DatabaseConnection,
220        user_id: i64,
221        module_id: i64,
222        role: &str,
223    ) -> Result<bool, DbErr> {
224        let parsed_role = Role::from_str(role)
225            .map_err(|_| DbErr::Custom(format!("Invalid role string: '{}'", role)))?;
226
227        let exists = RoleEntity::find()
228            .filter(RoleColumn::UserId.eq(user_id))
229            .filter(RoleColumn::ModuleId.eq(module_id))
230            .filter(RoleColumn::Role.eq(parsed_role))
231            .one(db)
232            .await?;
233
234        Ok(exists.is_some())
235    }
236
237    /// Hashes a plaintext password using Argon2 with a generated salt.
238    ///
239    /// # Arguments
240    /// * `password` - The plaintext password.
241    ///
242    /// # Returns
243    /// A hashed password string.
244    pub fn hash_password(password: &str) -> String {
245        let salt = SaltString::generate(&mut OsRng);
246        Argon2::default()
247            .hash_password(password.as_bytes(), &salt)
248            .expect("Failed to hash password")
249            .to_string()
250    }
251
252    /// Verifies a plaintext password against the stored hash
253    pub fn verify_password(&self, password: &str) -> bool {
254        let parsed = match PasswordHash::new(&self.password_hash) {
255            Ok(parsed) => parsed,
256            Err(_) => return false,
257        };
258
259        Argon2::default()
260            .verify_password(password.as_bytes(), &parsed)
261            .is_ok()
262    }
263}
264
265#[cfg(test)]
266mod tests {
267    use super::*;
268    use crate::models::user_module_role::Role as UserRole;
269    use crate::test_utils::setup_test_db;
270
271    #[tokio::test]
272    async fn test_create_and_get_user() {
273        let db = setup_test_db().await;
274        let username = "u12345678";
275        let email = "[email protected]";
276        let password = "secret123";
277
278        let _user = Model::create(&db, username, email, password, false)
279            .await
280            .expect("Failed to create user");
281
282        let found = Model::get_by_username(&db, username)
283            .await
284            .expect("Failed to query user");
285
286        assert!(found.is_some());
287        let found = found.unwrap();
288        assert_eq!(found.email, email);
289        assert_eq!(found.username, username);
290        assert_eq!(found.admin, false);
291    }
292
293    #[tokio::test]
294    async fn test_verify_credentials_success_and_failure() {
295        let db = setup_test_db().await;
296        let username = "u87654321";
297        let email = "[email protected]";
298        let password = "correct_pw";
299
300        Model::create(&db, username, email, password, false)
301            .await
302            .expect("Failed to create user");
303
304        let ok = Model::verify_credentials(&db, username, password)
305            .await
306            .expect("Failed to verify credentials");
307        assert!(ok.is_some());
308
309        let bad = Model::verify_credentials(&db, username, "wrong_pw")
310            .await
311            .expect("Failed to verify wrong credentials");
312        assert!(bad.is_none());
313    }
314
315    #[tokio::test]
316    async fn test_is_in_role_and_get_module_roles() {
317        use crate::models::{
318            module::ActiveModel as ModuleActiveModel,
319            user_module_role::ActiveModel as RoleActiveModel,
320        };
321
322        let db = setup_test_db().await;
323
324        // Create user
325        let user = Model::create(&db, "u00001111", "[email protected]", "pw", false)
326            .await
327            .expect("create user");
328
329        // Create module
330        let module = ModuleActiveModel {
331            code: Set("COS999".into()),
332            year: Set(2025),
333            description: Set(Some("Test Module".into())),
334            credits: Set(15),
335            ..Default::default()
336        }
337        .insert(&db)
338        .await
339        .expect("create module");
340
341        // Assign user to module with role
342        RoleActiveModel {
343            user_id: Set(user.id),
344            module_id: Set(module.id),
345            role: Set(UserRole::Lecturer),
346            ..Default::default()
347        }
348        .insert(&db)
349        .await
350        .expect("assign role");
351
352        // Check `is_in_role`
353        let is_lecturer = Model::is_in_role(&db, user.id, module.id, "lecturer")
354            .await
355            .expect("check is_in_role");
356        assert!(is_lecturer);
357
358        let is_tutor = Model::is_in_role(&db, user.id, module.id, "tutor")
359            .await
360            .expect("check is_in_role");
361        assert!(!is_tutor);
362
363        // Check `get_module_roles`
364        let roles = Model::get_module_roles(&db, user.id)
365            .await
366            .expect("get_module_roles");
367
368        assert_eq!(roles.len(), 1);
369        let r = &roles[0];
370        assert_eq!(r.module_id, module.id);
371        assert_eq!(r.module_code, "COS999");
372        assert_eq!(r.role, "lecturer");
373    }
374}