From 0816566d03141cc61250e8a37883f48cd46c0604 Mon Sep 17 00:00:00 2001 From: Maulana Sodiqin Date: Sun, 30 Mar 2025 02:46:16 +0700 Subject: [PATCH] feat: update logic --- src/apps/v1/auth/auth_controller_test.rs | 103 +++++++++++--- src/apps/v1/auth/auth_dto.rs | 23 ++- src/apps/v1/auth/auth_middleware.rs | 22 +-- src/apps/v1/auth/auth_repository.rs | 19 +-- src/apps/v1/auth/auth_repository_test.rs | 78 ++++++++--- src/apps/v1/auth/auth_service.rs | 43 +++--- src/apps/v1/docs/docs_controller.rs | 12 +- src/apps/v1/permissions/permissions_dto.rs | 9 ++ src/apps/v1/roles/roles_controller_test.rs | 8 -- src/apps/v1/roles/roles_dto.rs | 32 ++++- src/apps/v1/roles/roles_repository.rs | 83 +++++------ src/apps/v1/users/mod.rs | 2 + src/apps/v1/users/users_controller.rs | 49 ++++++- src/apps/v1/users/users_controller_test.rs | 8 +- src/apps/v1/users/users_dto.rs | 102 +++++++++----- src/apps/v1/users/users_repository.rs | 155 ++++++++++++++++++--- src/apps/v1/users/users_repository_test.rs | 89 +++++++----- src/apps/v1/users/users_schema.rs | 10 +- src/apps/v1/users/users_service.rs | 130 +++++++++++++---- src/bin/seed_users.rs | 60 ++++++++ src/utils/get_id.rs | 5 + src/utils/mock_test.rs | 13 +- src/utils/query_list.rs | 32 +---- 23 files changed, 782 insertions(+), 305 deletions(-) create mode 100644 src/bin/seed_users.rs diff --git a/src/apps/v1/auth/auth_controller_test.rs b/src/apps/v1/auth/auth_controller_test.rs index 6b7b16a..613a1e3 100644 --- a/src/apps/v1/auth/auth_controller_test.rs +++ b/src/apps/v1/auth/auth_controller_test.rs @@ -1,10 +1,11 @@ use crate::{ apps::v1::auth::auth_router, create_mock_app_state, create_test_user, - encode_refresh_token, AuthOtpSchema, OtpManager, ResourceEnum, + encode_refresh_token, AuthOtpSchema, OtpManager, ResourceEnum, RolesRepository, }; use axum::{http::StatusCode, Extension}; use axum_test::TestServer; use serde_json::json; +use surrealdb::Uuid; #[tokio::test] async fn test_login_should_fail_with_invalid_user() { @@ -31,7 +32,13 @@ async fn test_login_should_fail_with_wrong_password() { .layer(Extension(state.clone())); let server = TestServer::new(app).unwrap(); let repo = crate::apps::v1::users::UsersRepository::new(&state); - let mut user = create_test_user("user@example.com", "User Satu", true); + let role_repo = RolesRepository::new(&state); + let role_id = role_repo + .query_role_by_name("Student".to_string()) + .await + .unwrap() + .id; + let mut user = create_test_user("user@example.com", "User Satu", true, &role_id); user.password = crate::hash_password("correctpassword").unwrap(); repo.query_create_user(user).await.unwrap(); let payload = json!({ @@ -52,14 +59,26 @@ async fn test_login_should_fail_if_user_not_active() { .layer(Extension(state.clone())); let server = TestServer::new(app).unwrap(); let repo = crate::apps::v1::users::UsersRepository::new(&state); - let mut user = create_test_user("inactive@example.com", "Inactive User", false); + let role_repo = RolesRepository::new(&state); + let role_id = role_repo + .query_role_by_name("Student".to_string()) + .await + .unwrap() + .id; + let mut user = create_test_user( + "inactive-again@example.com", + "Inactive User", + false, + &role_id, + ); user.password = crate::hash_password("secret").unwrap(); repo.query_create_user(user).await.unwrap(); let payload = json!({ - "email": "inactive@example.com", + "email": "inactive-again@example.com", "password": "secret" }); let res = server.post("/v1/auth/login").json(&payload).await; + dbg!(res.text()); assert_eq!(res.status_code(), StatusCode::BAD_REQUEST); let body = res.text(); println!("🧪 Not active user: {}", body); @@ -72,9 +91,15 @@ async fn test_login_should_succeed() { .nest("/v1/auth", auth_router()) .layer(Extension(state.clone())); let server = TestServer::new(app).unwrap(); - let repo = crate::apps::v1::users::UsersRepository::new(&state); - let mut user = create_test_user("active@example.com", "Active User", true); + let role_repo = RolesRepository::new(&state); + let role_id = role_repo + .query_role_by_name("Student".to_string()) + .await + .unwrap() + .id; + let mut user = + create_test_user("active@example.com", "Active User", true, &role_id); user.password = crate::hash_password("secret").unwrap(); repo.query_create_user(user).await.unwrap(); let payload = json!({ @@ -137,14 +162,15 @@ async fn test_register_should_succeed() { .nest("/v1/auth", auth_router()) .layer(Extension(state.clone())); let server = TestServer::new(app).unwrap(); + let random_email = format!("validuser+{}@example.com", Uuid::new_v4()); let payload = json!({ - "email": "validuser@example.com", - "password": "Validpass1!", - "fullname": "Valid User", - "student_type": "regular", - "phone_number": "0812345678", - "reffered_by": "Facebook", - "refferal_code": "KFNB" + "email": random_email, + "password": "Validpass1!", + "fullname": "Valid User", + "student_type": "regular", + "phone_number": "0812345678", + "reffered_by": "Facebook", + "refferal_code": "KFNB" }); let res = server.post("/v1/auth/register").json(&payload).await; assert_eq!(res.status_code(), StatusCode::CREATED); @@ -158,7 +184,14 @@ async fn test_register_should_fail_if_email_already_taken() { .layer(Extension(state.clone())); let server = TestServer::new(app).unwrap(); let repo = crate::apps::v1::users::UsersRepository::new(&state); - let mut user = create_test_user("duplicate@example.com", "User Exists", false); + let role_repo = RolesRepository::new(&state); + let role_id = role_repo + .query_role_by_name("Student".to_string()) + .await + .unwrap() + .id; + let mut user = + create_test_user("duplicate@example.com", "User Exists", false, &role_id); user.password = crate::hash_password("secret").unwrap(); repo.query_create_user(user).await.unwrap(); let payload = json!({ @@ -311,12 +344,20 @@ async fn test_forgot_password_should_succeed() { .nest("/v1/auth", auth_router()) .layer(Extension(state.clone())); let server = TestServer::new(app).unwrap(); - let mut user = create_test_user("forgot@example.com", "Forgot User", true); + let role_repo = RolesRepository::new(&state); + let role_id = role_repo + .query_role_by_name("Student".to_string()) + .await + .unwrap() + .id; + let mut user = + create_test_user("forgot@example.com", "Forgot User", true, &role_id); user.password = crate::hash_password("secret").unwrap(); let user_repo = crate::apps::v1::users::UsersRepository::new(&state); user_repo.query_create_user(user).await.unwrap(); let payload = json!({ "email": "forgot@example.com" }); let res = server.post("/v1/auth/forgot").json(&payload).await; + dbg!(res.text()); assert_eq!(res.status_code(), StatusCode::OK); let body = res.text(); assert!( @@ -414,7 +455,13 @@ async fn test_verify_email_should_succeed() { .layer(Extension(state.clone())), ) .unwrap(); - let user = create_test_user("verify@example.com", "Verify User", false); + let role_repo = RolesRepository::new(&state); + let role_id = role_repo + .query_role_by_name("Student".to_string()) + .await + .unwrap() + .id; + let user = create_test_user("verify@example.com", "Verify User", false, &role_id); let otp = OtpManager::generate_otp(); let user_repo = crate::apps::v1::users::UsersRepository::new(&state); let auth_repo = crate::apps::v1::auth::AuthRepository::new(&state); @@ -442,7 +489,13 @@ async fn test_verify_email_should_fail_with_wrong_otp() { .layer(Extension(state.clone())), ) .unwrap(); - let user = create_test_user("wrongotp@example.com", "Wrong OTP", false); + let role_repo = RolesRepository::new(&state); + let role_id = role_repo + .query_role_by_name("Student".to_string()) + .await + .unwrap() + .id; + let user = create_test_user("wrongotp@example.com", "Wrong OTP", false, &role_id); let otp = OtpManager::generate_otp(); let user_repo = crate::apps::v1::users::UsersRepository::new(&state); let auth_repo = crate::apps::v1::auth::AuthRepository::new(&state); @@ -470,7 +523,13 @@ async fn test_verify_email_should_fail_if_otp_expired() { .layer(Extension(state.clone())), ) .unwrap(); - let user = create_test_user("expired@example.com", "Expired OTP", false); + let role_repo = RolesRepository::new(&state); + let role_id = role_repo + .query_role_by_name("Student".to_string()) + .await + .unwrap() + .id; + let user = create_test_user("expired@example.com", "Expired OTP", false, &role_id); let user_repo = crate::apps::v1::users::UsersRepository::new(&state); user_repo.query_create_user(user.clone()).await.unwrap(); use chrono::{Duration, Utc}; @@ -518,7 +577,13 @@ async fn test_new_password_should_succeed() { .unwrap(); let email = "resetme@example.com"; let repo = crate::apps::v1::users::UsersRepository::new(&state); - let mut user = create_test_user(email, "Reset User", true); + let role_repo = RolesRepository::new(&state); + let role_id = role_repo + .query_role_by_name("Student".to_string()) + .await + .unwrap() + .id; + let mut user = create_test_user(email, "Reset User", true, &role_id); user.password = crate::hash_password("oldpass123!").unwrap(); repo.query_create_user(user).await.unwrap(); let token = crate::encode_reset_password_token(email.to_string()).unwrap(); diff --git a/src/apps/v1/auth/auth_dto.rs b/src/apps/v1/auth/auth_dto.rs index 5712dc9..14e6124 100644 --- a/src/apps/v1/auth/auth_dto.rs +++ b/src/apps/v1/auth/auth_dto.rs @@ -1,4 +1,4 @@ -use crate::UsersItemDto; +use crate::RolesItemDto; use lazy_static::lazy_static; use regex::Regex; use serde::{Deserialize, Serialize}; @@ -20,10 +20,29 @@ pub struct AuthLoginRequestDto { pub password: String, } +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] +pub struct AuthUserItemDto { + pub id: String, + pub role: RolesItemDto, + pub fullname: String, + pub email: String, + pub avatar: Option, + pub phone_number: String, + pub referred_by: Option, + pub referral_code: Option, + pub student_type: String, + pub is_active: bool, + pub is_profile_completed: bool, + pub identity_number: Option, + pub religion: Option, + pub gender: Option, + pub birthdate: Option, +} + #[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] pub struct AuthLoginResponsetDto { pub token: TokenDto, - pub user: UsersItemDto, + pub user: AuthUserItemDto, } #[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] diff --git a/src/apps/v1/auth/auth_middleware.rs b/src/apps/v1/auth/auth_middleware.rs index 4d59b86..2ede395 100644 --- a/src/apps/v1/auth/auth_middleware.rs +++ b/src/apps/v1/auth/auth_middleware.rs @@ -1,6 +1,5 @@ use crate::{ - common_response, extract_email, v1::users_schema::UsersSchema, AppState, - UsersRepository, + common_response, extract_email, AppState, UsersItemDtoRaw, UsersRepository, }; use axum::{ extract::Request, http::StatusCode, middleware::Next, response::Response, @@ -27,15 +26,16 @@ pub async fn auth_middleware( let repository = UsersRepository::new(&state); - let user: Option = match repository.query_user_by_email(email).await { - Ok(user) => Some(user), - Err(err) => { - return Ok(common_response( - StatusCode::INTERNAL_SERVER_ERROR, - &err.to_string(), - )) - } - }; + let user: Option = + match repository.query_user_by_email(email).await { + Ok(user) => Some(user), + Err(err) => { + return Ok(common_response( + StatusCode::INTERNAL_SERVER_ERROR, + &err.to_string(), + )) + } + }; if user.is_none() { return Ok(common_response( diff --git a/src/apps/v1/auth/auth_repository.rs b/src/apps/v1/auth/auth_repository.rs index 83b038f..3bd3deb 100644 --- a/src/apps/v1/auth/auth_repository.rs +++ b/src/apps/v1/auth/auth_repository.rs @@ -1,5 +1,5 @@ use super::AuthOtpSchema; -use crate::{make_thing, AppState, ResourceEnum, UsersSchema}; +use crate::{make_thing, AppState, ResourceEnum, UsersItemDtoRaw}; use anyhow::{anyhow, bail, Result}; use chrono::{Duration, Utc}; @@ -12,7 +12,7 @@ impl<'a> AuthRepository<'a> { Self { state } } - pub async fn query_store_user(&self, user: UsersSchema) -> Result { + pub async fn query_store_user(&self, user: UsersItemDtoRaw) -> Result { if user.email.trim().is_empty() { bail!("Email is required"); } @@ -22,12 +22,11 @@ impl<'a> AuthRepository<'a> { let _ = self .state .surrealdb_mem - .delete::>((table.clone(), user_id.clone())) + .delete::>((table.clone(), user_id.clone())) .await?; - let mut user_to_store = user.clone(); user_to_store.id = id.clone(); - let record: Option = self + let record: Option = self .state .surrealdb_mem .create((table, user_id)) @@ -39,8 +38,11 @@ impl<'a> AuthRepository<'a> { } } - pub async fn query_get_stored_user(&self, email: String) -> Result { - let user: Option = self + pub async fn query_get_stored_user( + &self, + email: String, + ) -> Result { + let user: Option = self .state .surrealdb_mem .select((ResourceEnum::UsersCache.to_string(), email)) @@ -52,11 +54,12 @@ impl<'a> AuthRepository<'a> { } pub async fn query_delete_stored_user(&self, email: String) -> Result { - let record: Option = self + let record: Option = self .state .surrealdb_mem .delete((ResourceEnum::UsersCache.to_string(), email)) .await?; + dbg!(record.clone()); match record { Some(_) => Ok("Success delete stored user".to_string()), None => bail!("Failed delete stored user"), diff --git a/src/apps/v1/auth/auth_repository_test.rs b/src/apps/v1/auth/auth_repository_test.rs index b988364..7b1ebc5 100644 --- a/src/apps/v1/auth/auth_repository_test.rs +++ b/src/apps/v1/auth/auth_repository_test.rs @@ -1,8 +1,8 @@ #[cfg(test)] mod auth_repository_test { use crate::{ - create_mock_app_state, make_thing, AuthOtpSchema, AuthRepository, ResourceEnum, - UsersSchema, + create_mock_app_state, get_iso_date, make_thing, AuthOtpSchema, AuthRepository, + ResourceEnum, RolesItemDtoRaw, UsersItemDtoRaw, UsersRepository, UsersSchema, }; use chrono::{Duration, Utc}; use surrealdb::Uuid; @@ -26,8 +26,8 @@ mod auth_repository_test { birthdate: None, is_profile_completed: false, role: make_thing("roles", "user"), - created_at: None, - updated_at: None, + created_at: get_iso_date(), + updated_at: get_iso_date(), } } @@ -35,8 +35,13 @@ mod auth_repository_test { async fn test_store_and_get_user() { let app_state = create_mock_app_state().await; let repo = AuthRepository::new(&app_state); - let user = create_mock_user("store_user@example.com"); - let store = repo.query_store_user(user.clone()).await; + let user = create_mock_user("forgot@example.com"); + let user_repo = UsersRepository::new(&app_state); + let user_data = user_repo + .query_user_by_email("forgot@example.com".to_string()) + .await + .unwrap(); + let store = repo.query_store_user(user_data.clone()).await; assert!(store.is_ok()); let fetched = repo.query_get_stored_user(user.email.clone()).await; assert!(fetched.is_ok()); @@ -45,14 +50,50 @@ mod auth_repository_test { #[tokio::test] async fn test_delete_stored_user() { - let app_state = create_mock_app_state().await; - let repo = AuthRepository::new(&app_state); - let user = create_mock_user("delete_user@example.com"); - assert!(repo.query_store_user(user.clone()).await.is_ok()); - let deleted = repo.query_delete_stored_user(user.email.clone()).await; - assert!(deleted.is_ok()); - let result = repo.query_get_stored_user(user.email.clone()).await; - assert!(result.is_err()); + let state = create_mock_app_state().await; + let auth_repo = AuthRepository::new(&state); + let email = "delete_me@example.com".to_string(); + let mock_user = UsersItemDtoRaw { + id: make_thing(&ResourceEnum::UsersCache.to_string(), &email), + fullname: "Test User".into(), + email: email.clone(), + avatar: None, + phone_number: "08123456789".into(), + referred_by: None, + referral_code: None, + student_type: "TNI".into(), + is_active: true, + is_profile_completed: false, + identity_number: None, + religion: None, + gender: None, + birthdate: None, + role: RolesItemDtoRaw { + id: make_thing("app_roles", &Uuid::new_v4().to_string()), + name: "Dummy Role".into(), + permissions: vec![], + is_deleted: false, + created_at: Some(get_iso_date()), + updated_at: Some(get_iso_date()), + }, + is_deleted: false, + password: "".into(), + created_at: get_iso_date(), + updated_at: get_iso_date(), + }; + let _: Option = state + .surrealdb_mem + .create((ResourceEnum::UsersCache.to_string(), email.clone())) + .content(mock_user) + .await + .unwrap(); + let result = auth_repo.query_delete_stored_user(email.clone()).await; + assert!( + result.is_ok(), + "Delete operation failed: {:?}", + result.err() + ); + assert_eq!(result.unwrap(), "Success delete stored user"); } #[tokio::test] @@ -100,15 +141,6 @@ mod auth_repository_test { assert!(result.is_err()); } - #[tokio::test] - async fn test_store_user_with_empty_email_should_fail() { - let app_state = create_mock_app_state().await; - let repo = AuthRepository::new(&app_state); - let user = create_mock_user(""); - let result = repo.query_store_user(user).await; - assert!(result.is_err()); - } - #[tokio::test] async fn test_get_non_existent_stored_user_should_fail() { let app_state = create_mock_app_state().await; diff --git a/src/apps/v1/auth/auth_service.rs b/src/apps/v1/auth/auth_service.rs index 9e0222a..e8cef81 100644 --- a/src/apps/v1/auth/auth_service.rs +++ b/src/apps/v1/auth/auth_service.rs @@ -1,15 +1,15 @@ use super::{ AuthLoginRequestDto, AuthLoginResponsetDto, AuthNewPasswordRequestDto, AuthRefreshTokenRequestDto, AuthRegisterRequestDto, AuthRepository, - AuthResendOtpRequestDto, AuthVerifyEmailRequestDto, TokenDto, + AuthResendOtpRequestDto, AuthUserItemDto, AuthVerifyEmailRequestDto, TokenDto, }; use crate::{ common_response, decode_refresh_token, encode_access_token, encode_refresh_token, encode_reset_password_token, extract_email_token, generate_otp, get_iso_date, hash_password, make_thing, send_email, success_response, validate_request, verify_password, AppState, Env, ResourceEnum, ResponseSuccessDto, RolesEnum, - RolesItemDto, RolesRepository, UsersActiveInactiveSchema, UsersItemDto, - UsersRepository, UsersSchema, UsersSetNewPasswordSchema, + RolesItemDto, RolesRepository, UsersActiveInactiveSchema, UsersRepository, + UsersSchema, UsersSetNewPasswordSchema, }; use axum::{http::StatusCode, response::Response}; use surrealdb::Uuid; @@ -69,13 +69,13 @@ impl AuthService { }; let role_response = role_repo - .query_role_by_id(user.role.id.to_raw()) + .query_role_by_id(user.role.id.id.to_raw()) .await .unwrap(); let response = ResponseSuccessDto { data: AuthLoginResponsetDto { - user: UsersItemDto { + user: AuthUserItemDto { id: user.id.id.to_raw(), fullname: user.fullname.clone(), email: user.email.clone(), @@ -93,8 +93,7 @@ impl AuthService { role: RolesItemDto { id: role_response.id, name: role_response.name, - is_deleted: role_response.is_deleted, - permissions: vec![], + permissions: role_response.permissions, created_at: role_response.created_at, updated_at: role_response.updated_at, }, @@ -191,8 +190,8 @@ impl AuthService { referral_code: new_user.referral_code.clone(), referred_by: new_user.referred_by.clone(), student_type: new_user.student_type.clone(), - created_at: Some(get_iso_date()), - updated_at: Some(get_iso_date()), + created_at: get_iso_date(), + updated_at: get_iso_date(), role: role_thing, ..Default::default() }) @@ -271,14 +270,17 @@ impl AuthService { return common_response(status, &message); } let user_repo = UsersRepository::new(state); - if user_repo - .query_user_by_email(payload.email.clone()) - .await - .is_err() - { - return common_response(StatusCode::BAD_REQUEST, "User not found"); - } - let token = match encode_reset_password_token(payload.email.clone()) { + let user_result = user_repo.query_user_by_email(payload.email.clone()).await; + let user = match user_result { + Ok(user) => user, + Err(err) if err.to_string().contains("User not found") => { + return common_response(StatusCode::BAD_REQUEST, "User not found") + } + Err(err) => { + return common_response(StatusCode::INTERNAL_SERVER_ERROR, &err.to_string()) + } + }; + let token = match encode_reset_password_token(user.email) { Ok(token) => token, Err(_) => { return common_response( @@ -290,10 +292,9 @@ impl AuthService { let env = Env::new(); let fe_url = env.fe_url; let message = format!( - "You have requested a password reset. Please click the link below to continue: {}/auth/reset-password?token={}", - fe_url, token - ); - + "You have requested a password reset. Please click the link below to continue: {}/auth/reset-password?token={}", + fe_url, token + ); match send_email(&payload.email, "Reset Password Request", &message) { Ok(_) => common_response(StatusCode::OK, "Reset Password request send"), Err(err) => common_response(StatusCode::BAD_REQUEST, &err.to_string()), diff --git a/src/apps/v1/docs/docs_controller.rs b/src/apps/v1/docs/docs_controller.rs index 30a0589..5e5fd72 100644 --- a/src/apps/v1/docs/docs_controller.rs +++ b/src/apps/v1/docs/docs_controller.rs @@ -1,7 +1,7 @@ use crate::{ v1::{ auth, permissions, roles, users, AuthLoginRequestDto, AuthLoginResponsetDto, AuthResendOtpRequestDto, AuthVerifyEmailRequestDto - }, AuthNewPasswordRequestDto, AuthRefreshTokenRequestDto, MessageResponseDto, MetaRequestDto, MetaResponseDto, PermissionsItemDto, PermissionsRequestDto, ResponseListSuccessDto, ResponseSuccessDto, RolesItemDto, RolesRequestCreateDto, RolesRequestUpdateDto, TokenDto, UsersItemDto + }, AuthNewPasswordRequestDto, AuthRefreshTokenRequestDto, MessageResponseDto, MetaRequestDto, MetaResponseDto, PermissionsItemDto, PermissionsRequestDto, ResponseListSuccessDto, ResponseSuccessDto, RolesItemDto, RolesRequestCreateDto, RolesRequestUpdateDto, TokenDto, UsersCreateRequestDto, UsersDetailItemDto, UsersItemDto, UsersListItemDto, UsersUpdateRequestDto }; use utoipa::{ @@ -21,9 +21,11 @@ use utoipa::{ auth::auth_controller::post_new_password, users::users_controller::post_create_user, users::users_controller::put_update_user, + users::users_controller::put_update_user_me, users::users_controller::patch_user_active_status, users::users_controller::delete_user, users::users_controller::get_user_by_id, + users::users_controller::get_user_me, users::users_controller::get_user_list, roles::roles_controller::get_role_list, roles::roles_controller::get_role_by_id, @@ -53,11 +55,15 @@ use utoipa::{ RolesRequestUpdateDto, PermissionsRequestDto, PermissionsItemDto, + UsersItemDto, + UsersListItemDto, + UsersUpdateRequestDto, + UsersCreateRequestDto, ResponseSuccessDto, ResponseListSuccessDto>, ResponseSuccessDto, - ResponseListSuccessDto>, - ResponseSuccessDto, + ResponseListSuccessDto>, + ResponseSuccessDto, ResponseListSuccessDto>, ResponseSuccessDto ) diff --git a/src/apps/v1/permissions/permissions_dto.rs b/src/apps/v1/permissions/permissions_dto.rs index 9b229c5..f0e7df6 100644 --- a/src/apps/v1/permissions/permissions_dto.rs +++ b/src/apps/v1/permissions/permissions_dto.rs @@ -1,4 +1,5 @@ use serde::{Deserialize, Serialize}; +use surrealdb::sql::Thing; use utoipa::ToSchema; use validator::Validate; @@ -15,3 +16,11 @@ pub struct PermissionsItemDto { pub created_at: Option, pub updated_at: Option, } + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct PermissionsItemDtoRaw { + pub id: Thing, + pub name: String, + pub created_at: Option, + pub updated_at: Option, +} diff --git a/src/apps/v1/roles/roles_controller_test.rs b/src/apps/v1/roles/roles_controller_test.rs index 11ca255..937e177 100644 --- a/src/apps/v1/roles/roles_controller_test.rs +++ b/src/apps/v1/roles/roles_controller_test.rs @@ -161,14 +161,6 @@ async fn test_get_role_list_with_invalid_page_params_should_return_400() { #[tokio::test] async fn test_create_duplicate_role_should_return_409() { let state = create_mock_app_state().await; - let repo = RolesRepository::new(&state); - let _ = repo - .query_create_role(RolesRequestCreateDto { - name: "Admin".into(), - permissions: vec![], - }) - .await - .unwrap(); let server = create_test_app(state); let payload = RolesRequestCreateDto { name: "Admin".into(), diff --git a/src/apps/v1/roles/roles_dto.rs b/src/apps/v1/roles/roles_dto.rs index 69f4ab0..b53af14 100644 --- a/src/apps/v1/roles/roles_dto.rs +++ b/src/apps/v1/roles/roles_dto.rs @@ -1,5 +1,6 @@ -use crate::PermissionsItemDto; +use crate::{PermissionsItemDto, PermissionsItemDtoRaw}; use serde::{Deserialize, Serialize}; +use surrealdb::sql::Thing; use utoipa::ToSchema; use validator::Validate; @@ -27,6 +28,15 @@ pub struct RolesItemListDto { #[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] pub struct RolesItemDto { + pub id: String, + pub name: String, + pub permissions: Vec, + pub created_at: Option, + pub updated_at: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] +pub struct RolesItemByIdDto { pub id: String, pub name: String, pub is_deleted: bool, @@ -34,3 +44,23 @@ pub struct RolesItemDto { pub created_at: Option, pub updated_at: Option, } + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct RolesItemByIdDtoRaw { + pub id: Thing, + pub name: String, + pub permissions: Vec, + pub is_deleted: bool, + pub created_at: Option, + pub updated_at: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct RolesItemDtoRaw { + pub id: Thing, + pub name: String, + pub permissions: Vec, + pub is_deleted: bool, + pub created_at: Option, + pub updated_at: Option, +} diff --git a/src/apps/v1/roles/roles_repository.rs b/src/apps/v1/roles/roles_repository.rs index 90547f2..353eb32 100644 --- a/src/apps/v1/roles/roles_repository.rs +++ b/src/apps/v1/roles/roles_repository.rs @@ -1,8 +1,9 @@ use super::{ - RolesItemDto, RolesRequestCreateDto, RolesRequestUpdateDto, RolesSchema, + RolesItemByIdDto, RolesItemByIdDtoRaw, RolesRequestCreateDto, + RolesRequestUpdateDto, RolesSchema, }; use crate::{ - get_id, make_thing, query_list_with_meta, AppState, MetaRequestDto, + extract_id, get_id, make_thing, query_list_with_meta, AppState, MetaRequestDto, PermissionsItemDto, ResourceEnum, ResponseListSuccessDto, }; use anyhow::{bail, Result}; @@ -50,30 +51,30 @@ impl<'a> RolesRepository<'a> { .await } - pub async fn query_role_by_name(&self, name: String) -> Result { + pub async fn query_role_by_name(&self, name: String) -> Result { let db = &self.state.surrealdb_ws; let sql = format!( - "SELECT * FROM {} WHERE name = $name AND is_deleted = false", + "SELECT *, permissions FROM {} WHERE name = $name AND is_deleted = false LIMIT 1 FETCH permissions", ResourceEnum::Roles.to_string() ); - let mut response: Vec = - db.query(sql).bind(("name", name.clone())).await?.take(0)?; - - let role = match response.pop() { - Some(r) => r, - None => bail!("Role not found"), + let mut result = db.query(sql).bind(("name", name.clone())).await?; + let role: Option = result.take(0)?; + let role = match role { + Some(r) if !r.is_deleted => r, + _ => bail!("Role not found"), }; - let mut permissions: Vec = Vec::new(); - for permission_thing in &role.permissions { - let result: Option = db - .select((permission_thing.tb.clone(), permission_thing.id.to_string())) - .await?; - if let Some(p) = result { - permissions.push(p); - } - } - Ok(RolesItemDto { - id: role.id.id.to_raw(), + let permissions = role + .permissions + .into_iter() + .map(|perm| PermissionsItemDto { + id: extract_id(&perm.id), + name: perm.name, + created_at: perm.created_at, + updated_at: perm.updated_at, + }) + .collect::>(); + Ok(RolesItemByIdDto { + id: extract_id(&role.id), name: role.name, is_deleted: role.is_deleted, permissions, @@ -82,31 +83,31 @@ impl<'a> RolesRepository<'a> { }) } - pub async fn query_role_by_id(&self, id: String) -> Result { + pub async fn query_role_by_id(&self, id: String) -> Result { let db = &self.state.surrealdb_ws; - - let role: Option = db - .select((ResourceEnum::Roles.to_string(), id.clone())) - .await?; - + let query = format!( + "SELECT *, permissions.* AS permissions + FROM app_roles:⟨{}⟩ WHERE is_deleted = false FETCH permissions", + id + ); + let mut result = db.query(query).await?; + let role: Option = result.take(0)?; let role = match role { Some(r) if !r.is_deleted => r, _ => bail!("Role not found"), }; - - let mut permissions: Vec = Vec::new(); - - for permission_thing in &role.permissions { - let result: Option = db - .select((permission_thing.tb.clone(), permission_thing.id.to_string())) - .await?; - if let Some(p) = result { - permissions.push(p); - } - } - - Ok(RolesItemDto { - id: role.id.id.to_raw(), + let permissions = role + .permissions + .into_iter() + .map(|perm| PermissionsItemDto { + id: extract_id(&perm.id), + name: perm.name, + created_at: perm.created_at, + updated_at: perm.updated_at, + }) + .collect::>(); + Ok(RolesItemByIdDto { + id: extract_id(&role.id), name: role.name, is_deleted: role.is_deleted, permissions, diff --git a/src/apps/v1/users/mod.rs b/src/apps/v1/users/mod.rs index 6a4dd63..aa622a8 100644 --- a/src/apps/v1/users/mod.rs +++ b/src/apps/v1/users/mod.rs @@ -25,7 +25,9 @@ pub fn users_router() -> Router { .route("/", get(get_user_list)) .route("/activate/{id}", put(patch_user_active_status)) .route("/create", post(post_create_user)) + .route("/me", get(get_user_me)) .route("/delete/{id}", delete(delete_user)) .route("/detail/{id}", get(get_user_by_id)) .route("/update/{id}", put(put_update_user)) + .route("/update/me", put(put_update_user_me)) } diff --git a/src/apps/v1/users/users_controller.rs b/src/apps/v1/users/users_controller.rs index a1ed51d..4b2ec71 100644 --- a/src/apps/v1/users/users_controller.rs +++ b/src/apps/v1/users/users_controller.rs @@ -1,14 +1,15 @@ use axum::extract::{Path, Query}; +use axum::http::HeaderMap; use axum::response::IntoResponse; use axum::{Extension, Json}; use crate::{v1::users_service::UsersService, AppState, MetaRequestDto}; use crate::{ MessageResponseDto, ResponseListSuccessDto, ResponseSuccessDto, - UsersActiveInactiveRequestDto, UsersCreateRequestDto, UsersItemDto, + UsersActiveInactiveRequestDto, UsersCreateRequestDto, UsersDetailItemDto, }; -use super::UsersUpdateRequestDto; +use super::{UsersListItemDto, UsersUpdateRequestDto}; #[utoipa::path( get, @@ -26,7 +27,7 @@ use super::UsersUpdateRequestDto; ("filter_by" = Option, Query, description = "Field to filter by"), ), responses( - (status = 200, description = "Get user list", body = ResponseListSuccessDto>) + (status = 200, description = "Get user list", body = ResponseListSuccessDto>) ), tag = "Users" )] @@ -47,7 +48,7 @@ pub async fn get_user_list( ("id" = String, Path, description = "User ID") ), responses( - (status = 200, description = "Get user by ID", body = ResponseSuccessDto) + (status = 200, description = "Get user by ID", body = ResponseSuccessDto) ), tag = "Users" )] @@ -58,6 +59,24 @@ pub async fn get_user_by_id( UsersService::get_user_by_id(&state, id).await } +#[utoipa::path( + get, + security( + ("Bearer" = []) + ), + path = "/v1/users/me", + responses( + (status = 200, description = "Get user by ID", body = ResponseSuccessDto) + ), + tag = "Users" +)] +pub async fn get_user_me( + Extension(state): Extension, + header: HeaderMap, +) -> impl IntoResponse { + UsersService::get_user_me(header, &state).await +} + #[utoipa::path( post, security( @@ -83,7 +102,7 @@ pub async fn post_create_user( ("Bearer" = []) ), path = "/v1/users/update/{id}", - request_body = UsersCreateRequestDto, + request_body = UsersUpdateRequestDto, responses( (status = 200, description = "Update user", body = MessageResponseDto) ), @@ -97,6 +116,26 @@ pub async fn put_update_user( UsersService::update_user(&state, id, payload).await } +#[utoipa::path( + put, + security( + ("Bearer" = []) + ), + path = "/v1/users/update/me", + request_body = UsersUpdateRequestDto, + responses( + (status = 200, description = "Update user me", body = MessageResponseDto) + ), + tag = "Users" +)] +pub async fn put_update_user_me( + Extension(state): Extension, + header: HeaderMap, + Json(payload): Json, +) -> impl IntoResponse { + UsersService::update_user_me(&state, header, payload).await +} + #[utoipa::path( put, security( diff --git a/src/apps/v1/users/users_controller_test.rs b/src/apps/v1/users/users_controller_test.rs index 975fd85..0cb125d 100644 --- a/src/apps/v1/users/users_controller_test.rs +++ b/src/apps/v1/users/users_controller_test.rs @@ -5,6 +5,7 @@ use crate::{ }; use axum::{http::StatusCode, Extension}; use axum_test::TestServer; +use surrealdb::Uuid; #[tokio::test] async fn test_get_user_list_should_return_200() { @@ -210,7 +211,7 @@ async fn test_activate_user_should_return_200() { let server = TestServer::new(app).unwrap(); let payload = UsersCreateRequestDto { fullname: "Inactive User".into(), - email: "inactive@test.com".into(), + email: format!("inactive-{}@test.com", Uuid::new_v4()).into(), password: "Password1!".into(), role_id, student_type: "general".into(), @@ -219,13 +220,16 @@ async fn test_activate_user_should_return_200() { referral_code: None, referred_by: None, }; - server.post("/v1/users/create").json(&payload).await; + let res_create = server.post("/v1/users/create").json(&payload).await; + dbg!(res_create.text()); let user = repo.query_user_by_email(payload.email).await.unwrap(); + dbg!(user.fullname); let user_id = user.id.id.to_raw(); let res = server .put(&format!("/v1/users/activate/{}", user_id)) .json(&UsersActiveInactiveRequestDto { is_active: true }) .await; + dbg!(res.text()); assert_eq!(res.status_code(), StatusCode::OK); } diff --git a/src/apps/v1/users/users_dto.rs b/src/apps/v1/users/users_dto.rs index 8ab6539..1e4338a 100644 --- a/src/apps/v1/users/users_dto.rs +++ b/src/apps/v1/users/users_dto.rs @@ -5,12 +5,17 @@ use surrealdb::sql::Thing; use utoipa::ToSchema; use validator::Validate; -use crate::RolesItemDto; +use crate::{RolesItemDto, RolesItemDtoRaw}; lazy_static! { static ref PASSWORD_REGEX: Regex = Regex::new(r"^[A-Za-z\d@$!%*?&]{8,}$").unwrap(); } +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] +pub struct UsersActiveInactiveRequestDto { + pub is_active: bool, +} + #[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)] pub struct UsersCreateRequestDto { #[validate( @@ -73,11 +78,15 @@ pub struct UsersUpdateRequestDto { pub referral_code: Option, pub referred_by: Option, pub is_active: bool, - #[validate(length(min = 16, message = "NIK at must have 16 character"))] + #[validate(length(min = 16, message = "NIK at least have 16 character"))] pub identity_number: Option, + #[validate(length(min = 1, message = "Religion is required"))] pub religion: Option, + #[validate(length(min = 1, message = "Gender is required"))] pub gender: Option, + #[validate(length(min = 1, message = "Birthdate is required"))] pub birthdate: Option, + #[validate(length(min = 1, message = "Avatar is required"))] pub avatar: Option, pub role_id: String, } @@ -95,16 +104,20 @@ pub struct UsersItemDto { pub student_type: String, pub is_active: bool, pub is_profile_completed: bool, + pub is_deleted: bool, pub identity_number: Option, pub religion: Option, pub gender: Option, pub birthdate: Option, + pub password: String, + pub created_at: String, + pub updated_at: String, } -#[derive(Debug, Serialize, Deserialize)] -pub struct UsersItemDtoRaw { - pub id: Thing, - pub role: Thing, +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] +pub struct UsersDetailItemDto { + pub id: String, + pub role: RolesItemDto, pub fullname: String, pub email: String, pub avatar: Option, @@ -120,36 +133,53 @@ pub struct UsersItemDtoRaw { pub birthdate: Option, } -impl From for UsersItemDto { - fn from(raw: UsersItemDtoRaw) -> Self { - Self { - id: raw.id.id.to_string(), - role: RolesItemDto { - id: raw.role.id.to_string(), - name: "".into(), - is_deleted: false, - permissions: vec![], - created_at: None, - updated_at: None, - }, - fullname: raw.fullname, - email: raw.email, - avatar: raw.avatar, - phone_number: raw.phone_number, - referred_by: raw.referred_by, - referral_code: raw.referral_code, - student_type: raw.student_type, - is_active: raw.is_active, - is_profile_completed: raw.is_profile_completed, - identity_number: raw.identity_number, - religion: raw.religion, - gender: raw.gender, - birthdate: raw.birthdate, - } - } -} - #[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] -pub struct UsersActiveInactiveRequestDto { +pub struct UsersListItemDto { + pub id: String, + pub role: String, + pub fullname: String, + pub email: String, + pub avatar: Option, + pub phone_number: String, + pub referred_by: Option, + pub referral_code: Option, + pub student_type: String, pub is_active: bool, } + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct UsersListItemDtoRaw { + pub id: Thing, + pub role: Option, + pub fullname: String, + pub email: String, + pub avatar: Option, + pub phone_number: String, + pub referred_by: Option, + pub referral_code: Option, + pub student_type: String, + pub is_active: bool, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct UsersItemDtoRaw { + pub id: Thing, + pub fullname: String, + pub email: String, + pub avatar: Option, + pub phone_number: String, + pub referred_by: Option, + pub referral_code: Option, + pub student_type: String, + pub is_active: bool, + pub is_profile_completed: bool, + pub is_deleted: bool, + pub identity_number: Option, + pub religion: Option, + pub gender: Option, + pub birthdate: Option, + pub role: RolesItemDtoRaw, + pub password: String, + pub created_at: String, + pub updated_at: String, +} diff --git a/src/apps/v1/users/users_repository.rs b/src/apps/v1/users/users_repository.rs index ffb72cd..fb508a8 100644 --- a/src/apps/v1/users/users_repository.rs +++ b/src/apps/v1/users/users_repository.rs @@ -1,9 +1,11 @@ use super::{ - UsersActiveInactiveSchema, UsersItemDtoRaw, UsersSchema, UsersSetNewPasswordSchema, + UsersActiveInactiveSchema, UsersItemDto, UsersItemDtoRaw, UsersListItemDto, + UsersListItemDtoRaw, UsersSchema, UsersSetNewPasswordSchema, }; use crate::{ - get_id, make_thing, query_list_with_meta, AppState, MetaRequestDto, ResourceEnum, - ResponseListSuccessDto, + extract_id, get_id, make_thing, query_list_with_meta, AppState, MetaRequestDto, + PermissionsItemDto, PermissionsItemDtoRaw, ResourceEnum, ResponseListSuccessDto, + RolesItemDto, RolesItemDtoRaw, }; use anyhow::{bail, Result}; @@ -19,7 +21,7 @@ impl<'a> UsersRepository<'a> { pub async fn query_user_list( &self, meta: MetaRequestDto, - ) -> Result>> { + ) -> Result>> { let db = &self.state.surrealdb_ws; let mut conditions = vec!["is_deleted = false".to_string()]; @@ -49,7 +51,7 @@ impl<'a> UsersRepository<'a> { " SELECT id, - role, + role.name AS role, fullname, email, avatar, @@ -66,7 +68,7 @@ impl<'a> UsersRepository<'a> { FROM {} {} LIMIT {} START {} - FETCH role, role.permissions + FETCH role ", ResourceEnum::Users.to_string(), where_clause, @@ -74,7 +76,7 @@ impl<'a> UsersRepository<'a> { start ); - let raw_result = query_list_with_meta::( + let raw_result = query_list_with_meta::( db, &ResourceEnum::Users.to_string(), &meta, @@ -83,36 +85,142 @@ impl<'a> UsersRepository<'a> { ) .await?; + let transformed_data = raw_result + .data + .into_iter() + .map(|user| UsersListItemDto { + id: extract_id(&user.id), + fullname: user.fullname, + email: user.email, + avatar: user.avatar, + phone_number: user.phone_number, + referred_by: user.referred_by, + referral_code: user.referral_code, + student_type: user.student_type, + is_active: user.is_active, + role: user.role.unwrap_or_else(|| "-".into()), // Handle role safely + }) + .collect::>(); + Ok(ResponseListSuccessDto { - data: raw_result.data, + data: transformed_data, meta: raw_result.meta, }) } - pub async fn query_user_by_email(&self, email: String) -> Result { + pub async fn query_user_by_email(&self, email: String) -> Result { let db = &self.state.surrealdb_ws; let sql = format!( - "SELECT * FROM {} WHERE email = $email AND is_deleted = false", + "SELECT *, role AS role FROM {} WHERE email = $email AND is_deleted = false LIMIT 1 FETCH role, role.permissions", ResourceEnum::Users.to_string() ); - let mut response: Vec = db + + let response: Option = db .query(sql) .bind(("email", email.clone())) .await? .take(0)?; - match response.pop() { - Some(user) => Ok(user), - None => bail!("User not found"), + + match response { + Some(user) if !user.role.is_deleted => { + let permissions = user + .role + .permissions + .into_iter() + .map(|perm| PermissionsItemDtoRaw { + id: perm.id, + name: perm.name, + created_at: perm.created_at, + updated_at: perm.updated_at, + }) + .collect::>(); + Ok(UsersItemDtoRaw { + id: user.id, + fullname: user.fullname, + email: user.email, + avatar: user.avatar, + phone_number: user.phone_number, + referred_by: user.referred_by, + referral_code: user.referral_code, + student_type: user.student_type, + is_active: user.is_active, + is_deleted: user.is_deleted, + is_profile_completed: user.is_profile_completed, + identity_number: user.identity_number, + religion: user.religion, + gender: user.gender, + birthdate: user.birthdate, + password: user.password, + created_at: user.created_at, + updated_at: user.updated_at, + role: RolesItemDtoRaw { + id: user.role.id, + name: user.role.name, + permissions, + created_at: user.role.created_at, + updated_at: user.role.updated_at, + is_deleted: user.role.is_deleted, + }, + }) + } + _ => bail!("User not found"), } } - pub async fn query_user_by_id(&self, id: String) -> Result { + pub async fn query_user_by_id(&self, id: String) -> Result { let db = &self.state.surrealdb_ws; - let result: Option = db - .select((ResourceEnum::Users.to_string(), id.clone())) - .await?; - match result { - Some(response) if !response.is_deleted => Ok(response), + + let query = format!( + "SELECT *, role AS role FROM app_users:⟨{}⟩ FETCH role, role.permissions", + id + ); + + let mut result = db.query(query).await?; + + let response: Option = result.take(0)?; + + match response { + Some(user) if !user.role.is_deleted => { + let permissions = user + .role + .permissions + .into_iter() + .map(|perm| PermissionsItemDto { + id: extract_id(&perm.id), + name: perm.name, + created_at: perm.created_at, + updated_at: perm.updated_at, + }) + .collect::>(); + + Ok(UsersItemDto { + id: extract_id(&user.id), + fullname: user.fullname, + email: user.email, + avatar: user.avatar, + phone_number: user.phone_number, + referred_by: user.referred_by, + referral_code: user.referral_code, + student_type: user.student_type, + is_active: user.is_active, + is_deleted: user.is_deleted, + is_profile_completed: user.is_profile_completed, + identity_number: user.identity_number, + religion: user.religion, + gender: user.gender, + birthdate: user.birthdate, + password: user.password, + role: RolesItemDto { + id: extract_id(&user.role.id), + name: user.role.name, + permissions, + created_at: user.role.created_at, + updated_at: user.role.updated_at, + }, + created_at: user.created_at, + updated_at: user.updated_at, + }) + } _ => bail!("User not found"), } } @@ -196,13 +304,13 @@ impl<'a> UsersRepository<'a> { ) -> Result { let db = &self.state.surrealdb_ws; let user = self.query_user_by_email(email).await?; - let record_key = get_id(&user.id)?; let record: Option = db - .update(record_key) + .update((ResourceEnum::Users.to_string(), user.id.id.to_raw())) .merge(UsersSetNewPasswordSchema { password: data.password.clone(), }) .await?; + dbg!(record.clone()); match record { Some(_) => Ok("Success update password user".into()), None => bail!("Failed to update password user"), @@ -216,7 +324,8 @@ impl<'a> UsersRepository<'a> { if user.is_deleted { bail!("User already deleted"); } - let record_key = get_id(&user.id)?; + let id = make_thing(&ResourceEnum::Users.to_string(), &user.id); + let record_key = get_id(&id)?; let record: Option = db .update(record_key) .merge(serde_json::json!({ "is_deleted": true })) diff --git a/src/apps/v1/users/users_repository_test.rs b/src/apps/v1/users/users_repository_test.rs index ba4dd31..00c0725 100644 --- a/src/apps/v1/users/users_repository_test.rs +++ b/src/apps/v1/users/users_repository_test.rs @@ -1,39 +1,27 @@ -use crate::{create_mock_app_state, ResourceEnum}; +use crate::{create_mock_app_state, create_test_user, RolesRepository}; use crate::{ - make_thing, MetaRequestDto, UsersActiveInactiveSchema, UsersRepository, - UsersSchema, UsersSetNewPasswordSchema, + MetaRequestDto, UsersActiveInactiveSchema, UsersRepository, + UsersSetNewPasswordSchema, }; -use surrealdb::Uuid; -fn create_test_user(email: &str, fullname: &str, is_active: bool) -> UsersSchema { - UsersSchema { - id: make_thing("app_users", &Uuid::new_v4().to_string()), - email: email.to_string(), - fullname: fullname.to_string(), - password: "password".to_string(), - is_deleted: false, - avatar: None, - phone_number: "081234567890".to_string(), - referral_code: None, - referred_by: None, - identity_number: None, - is_active, - student_type: "general".to_string(), - religion: None, - gender: None, - birthdate: None, - is_profile_completed: false, - role: make_thing(&ResourceEnum::Roles.to_string(), "user"), - created_at: None, - updated_at: None, - } +async fn get_role_id(state: &crate::AppState) -> String { + RolesRepository::new(state) + .query_role_by_name("Student".into()) + .await + .expect("Role not found") + .id } #[tokio::test] async fn test_create_and_get_user() { let app_state = create_mock_app_state().await; let repo = UsersRepository::new(&app_state); - let user = create_test_user("testuser@example.com", "Test User", true); + let user = create_test_user( + "testuser@example.com", + "Test User", + true, + &get_role_id(&app_state).await, + ); let create_result = repo.query_create_user(user.clone()).await; assert!(create_result.is_ok()); let fetched = repo @@ -47,18 +35,29 @@ async fn test_create_and_get_user() { async fn test_update_password_user() { let app_state = create_mock_app_state().await; let repo = UsersRepository::new(&app_state); + let role_repo = RolesRepository::new(&app_state); + let role_id = role_repo + .query_role_by_name("Student".into()) + .await + .expect("Role not found") + .id; let email = "changepass@example.com"; - let user = create_test_user(email, "Change Password", true); + let user = create_test_user(email, "Change Password", true, &role_id); repo.query_create_user(user).await.unwrap(); let result = repo .query_update_password_user( - email.into(), + email.to_string(), UsersSetNewPasswordSchema { password: "newpass".into(), }, ) .await; - assert!(result.is_ok()); + assert!( + result.is_ok(), + "Update password failed with error: {:?}", + result.err() + ); + dbg!(result.unwrap()); } #[tokio::test] @@ -69,7 +68,8 @@ async fn test_query_user_list_with_pagination_and_filter() { let email = format!("user{}@example.com", i); let fullname = format!("User {}", i); let is_active = i % 2 == 0; - let user = create_test_user(&email, &fullname, is_active); + let user = + create_test_user(&email, &fullname, is_active, &get_role_id(&app_state).await); repo.query_create_user(user).await.unwrap(); } let meta = MetaRequestDto { @@ -93,7 +93,12 @@ async fn test_query_user_list_basic() { let repo = UsersRepository::new(&app_state); for i in 0..10 { let email = format!("basic{}@example.com", i); - let user = create_test_user(&email, &format!("Basic User {}", i), true); + let user = create_test_user( + &email, + &format!("Basic User {}", i), + true, + &get_role_id(&app_state).await, + ); repo.query_create_user(user).await.unwrap(); } let meta = MetaRequestDto { @@ -116,7 +121,12 @@ async fn test_query_active_inactive_user() { let app_state = create_mock_app_state().await; let repo = UsersRepository::new(&app_state); let email = "inactive@example.com"; - let user = create_test_user(email, "Inactive User", false); + let user = create_test_user( + email, + "Inactive User", + false, + &get_role_id(&app_state).await, + ); repo.query_create_user(user).await.unwrap(); let result = repo .query_active_inactive_user( @@ -162,7 +172,8 @@ async fn test_query_delete_user() { let app_state = create_mock_app_state().await; let repo = UsersRepository::new(&app_state); let email = "deleteuser@example.com"; - let user = create_test_user(email, "Delete User", true); + let user = + create_test_user(email, "Delete User", true, &get_role_id(&app_state).await); repo.query_create_user(user.clone()).await.unwrap(); let user_detail = repo .query_user_by_email(email.to_string().clone()) @@ -192,7 +203,8 @@ async fn test_delete_user_twice_should_fail_on_second_attempt() { let app_state = create_mock_app_state().await; let repo = UsersRepository::new(&app_state); let email = "twice@example.com"; - let user = create_test_user(email, "Delete Twice", true); + let user = + create_test_user(email, "Delete Twice", true, &get_role_id(&app_state).await); repo.query_create_user(user.clone()).await.unwrap(); let first = repo.query_delete_user(user.id.id.to_raw()).await; assert!(first.is_ok()); @@ -205,7 +217,12 @@ async fn test_delete_user_twice_should_fail_on_second_attempt() { async fn test_query_update_user_should_succeed() { let state = create_mock_app_state().await; let repo = UsersRepository::new(&state); - let mut user = create_test_user("update@example.com", "Old Name", true); + let mut user = create_test_user( + "update@example.com", + "Old Name", + true, + &get_role_id(&state).await, + ); repo.query_create_user(user.clone()).await.unwrap(); user.fullname = "Updated Name".into(); user.phone_number = "089876543210".into(); diff --git a/src/apps/v1/users/users_schema.rs b/src/apps/v1/users/users_schema.rs index 1565d28..c005675 100644 --- a/src/apps/v1/users/users_schema.rs +++ b/src/apps/v1/users/users_schema.rs @@ -1,4 +1,4 @@ -use crate::ResourceEnum; +use crate::{get_iso_date, ResourceEnum}; use serde::{Deserialize, Serialize}; use surrealdb::{ sql::{Id, Thing}, @@ -24,8 +24,8 @@ pub struct UsersSchema { pub birthdate: Option, pub is_profile_completed: bool, pub role: Thing, - pub created_at: Option, - pub updated_at: Option, + pub created_at: String, + pub updated_at: String, } impl Default for UsersSchema { @@ -54,8 +54,8 @@ impl Default for UsersSchema { ResourceEnum::Roles.to_string(), Id::String(Uuid::new_v4().to_string()), )), - created_at: None, - updated_at: None, + created_at: get_iso_date(), + updated_at: get_iso_date(), } } } diff --git a/src/apps/v1/users/users_service.rs b/src/apps/v1/users/users_service.rs index c533975..fc0f8fd 100644 --- a/src/apps/v1/users/users_service.rs +++ b/src/apps/v1/users/users_service.rs @@ -1,15 +1,18 @@ use crate::{ - common_response, get_iso_date, hash_password, make_thing, success_list_response, - success_response, validate_request, ResourceEnum, ResponseSuccessDto, + common_response, extract_email, get_iso_date, hash_password, make_thing, + success_list_response, success_response, validate_request, ResourceEnum, + ResponseSuccessDto, }; use crate::{ AppState, MetaRequestDto, ResponseListSuccessDto, UsersActiveInactiveSchema, UsersRepository, UsersSchema, UsersSetNewPasswordSchema, }; +use axum::http::HeaderMap; use axum::{http::StatusCode, response::Response}; use super::{ - UsersActiveInactiveRequestDto, UsersCreateRequestDto, UsersUpdateRequestDto, + UsersActiveInactiveRequestDto, UsersCreateRequestDto, UsersDetailItemDto, + UsersUpdateRequestDto, }; pub struct UsersService; @@ -29,18 +32,56 @@ impl UsersService { } } - pub async fn get_user_by_email(state: &AppState, email: String) -> Response { + pub async fn get_user_by_id(state: &AppState, id: String) -> Response { let repo = UsersRepository::new(state); - match repo.query_user_by_email(email).await { - Ok(user) => success_response(ResponseSuccessDto { data: user }), + match repo.query_user_by_id(id).await { + Ok(user) => success_response(ResponseSuccessDto { + data: UsersDetailItemDto { + id: user.id, + role: user.role, + fullname: user.fullname, + email: user.email, + avatar: user.avatar, + phone_number: user.phone_number, + referred_by: user.referred_by, + referral_code: user.referral_code, + student_type: user.student_type, + is_active: user.is_active, + is_profile_completed: user.is_profile_completed, + identity_number: user.identity_number, + religion: user.religion, + gender: user.gender, + birthdate: user.birthdate, + }, + }), Err(e) => common_response(StatusCode::NOT_FOUND, &e.to_string()), } } - pub async fn get_user_by_id(state: &AppState, id: String) -> Response { + pub async fn get_user_me(headers: HeaderMap, state: &AppState) -> Response { let repo = UsersRepository::new(state); - match repo.query_user_by_id(id).await { - Ok(user) => success_response(ResponseSuccessDto { data: user }), + let email = extract_email(&headers).unwrap(); + let user = repo.query_user_by_email(email).await.unwrap(); + match repo.query_user_by_id(user.id.id.to_raw()).await { + Ok(user) => success_response(ResponseSuccessDto { + data: UsersDetailItemDto { + id: user.id, + role: user.role, + fullname: user.fullname, + email: user.email, + avatar: user.avatar, + phone_number: user.phone_number, + referred_by: user.referred_by, + referral_code: user.referral_code, + student_type: user.student_type, + is_active: user.is_active, + is_profile_completed: user.is_profile_completed, + identity_number: user.identity_number, + religion: user.religion, + gender: user.gender, + birthdate: user.birthdate, + }, + }), Err(e) => common_response(StatusCode::NOT_FOUND, &e.to_string()), } } @@ -114,7 +155,7 @@ impl UsersService { avatar: user.avatar, is_profile_completed: false, role: role_id, - updated_at: Some(get_iso_date()), + updated_at: get_iso_date(), ..Default::default() }; @@ -124,27 +165,66 @@ impl UsersService { } } + pub async fn update_user_me( + state: &AppState, + headers: HeaderMap, + user: UsersUpdateRequestDto, + ) -> Response { + let repo = UsersRepository::new(state); + let email = extract_email(&headers).unwrap(); + let user_data = repo.query_user_by_email(email).await.unwrap(); + if let Err((status, message)) = validate_request(&user) { + return common_response(status, &message); + } + let user_id = + make_thing(&ResourceEnum::Users.to_string(), &user_data.id.id.to_raw()); + let role_id = make_thing(&ResourceEnum::Roles.to_string(), ""); + let updated_user = UsersSchema { + id: user_id, + fullname: user.fullname, + email: user.email, + phone_number: user.phone_number, + referral_code: user.referral_code, + referred_by: user.referred_by, + identity_number: user.identity_number, + is_active: user.is_active, + student_type: user.student_type, + religion: user.religion, + gender: user.gender, + birthdate: user.birthdate, + avatar: user.avatar, + is_profile_completed: true, + role: role_id, + updated_at: get_iso_date(), + ..Default::default() + }; + match repo.query_update_user(updated_user).await { + Ok(msg) => common_response(StatusCode::OK, &msg), + Err(e) => common_response(StatusCode::BAD_REQUEST, &e.to_string()), + } + } + pub async fn set_user_active_status( state: &AppState, id: String, status: UsersActiveInactiveRequestDto, ) -> Response { let repo = UsersRepository::new(state); - - if repo.query_user_by_id(id.clone()).await.is_err() { - return common_response(StatusCode::BAD_REQUEST, "User not found"); - } - match repo - .query_active_inactive_user_by_id( - id, - UsersActiveInactiveSchema { - is_active: status.is_active, - }, - ) - .await - { - Ok(msg) => common_response(StatusCode::OK, &msg), - Err(e) => common_response(StatusCode::BAD_REQUEST, &e.to_string()), + let thing_id = make_thing(&ResourceEnum::Users.to_string(), &id); + match repo.query_user_by_id(thing_id.id.to_raw()).await { + Ok(_) => match repo + .query_active_inactive_user_by_id( + id, + UsersActiveInactiveSchema { + is_active: status.is_active, + }, + ) + .await + { + Ok(msg) => common_response(StatusCode::OK, &msg), + Err(e) => common_response(StatusCode::BAD_REQUEST, &e.to_string()), + }, + Err(err) => common_response(StatusCode::BAD_REQUEST, &err.to_string()), } } diff --git a/src/bin/seed_users.rs b/src/bin/seed_users.rs new file mode 100644 index 0000000..6ca9c8f --- /dev/null +++ b/src/bin/seed_users.rs @@ -0,0 +1,60 @@ +use najm_course_api::{get_iso_date, make_thing, Env}; +use serde_json::json; +use std::error::Error; +use surrealdb::{engine::remote::ws::Ws, opt::auth::Root, Surreal}; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let env = Env::new(); + let db = Surreal::new::(env.surrealdb_url).await?; + db.signin(Root { + username: &env.surrealdb_username, + password: &env.surrealdb_password, + }) + .await?; + db.use_ns(env.surrealdb_namespace) + .use_db(env.surrealdb_dbname) + .await?; + let users = vec![ + ( + "c3b1d6a8-8d4f-4b36-b789-2e532ec7a7b2", + "admin@example.com", + "Admin", + "f6b03f25-e416-4893-ac88-caaa690afb07", + ), + ( + "a4d23fb5-9e31-423c-9842-fbd6e75a5298", + "staff@example.com", + "Staff", + "50133429-f4b1-4249-9f97-7b86e6ee9d86", + ), + ( + "d5e89c12-72af-4b1a-abc3-ff1234567890", + "student@example.com", + "Student", + "5713cb37-dc02-4e87-8048-d7a41d352059", + ), + ]; + for (id, email, name, role_id) in users { + db.query("CREATE type::thing('app_users', $id) CONTENT $data") + .bind(("id", id)) + .bind(( + "data", + json!({ + "email": email, + "name": name, + "phone_number": "081234567890", + "student_type": "TNI", + "role": make_thing("app_roles", role_id), + "is_deleted": false, + "created_at": get_iso_date(), + "updated_at": get_iso_date(), + }), + )) + .await?; + + println!("✅ Inserted user: {} ({})", name, email); + } + println!("✅ Semua users berhasil disimpan ke SurrealDB!"); + Ok(()) +} diff --git a/src/utils/get_id.rs b/src/utils/get_id.rs index 65018d8..3505ea8 100644 --- a/src/utils/get_id.rs +++ b/src/utils/get_id.rs @@ -9,3 +9,8 @@ pub fn get_id(thing: &Thing) -> Result<(&str, &str)> { }; Ok((table, id)) } + +pub fn extract_id(thing: &Thing) -> String { + let id = thing.id.to_raw(); + id +} diff --git a/src/utils/mock_test.rs b/src/utils/mock_test.rs index 2373d4f..678faa8 100644 --- a/src/utils/mock_test.rs +++ b/src/utils/mock_test.rs @@ -1,5 +1,4 @@ -use crate::{hash_password, AppState, ResourceEnum, UsersSchema}; -use rand::Rng; +use crate::{hash_password, AppState, UsersSchema}; use surrealdb::{ engine::{local::Mem, remote::ws::Ws}, opt::auth::Root, @@ -57,11 +56,12 @@ pub fn create_test_user( email: &str, fullname: &str, is_active: bool, + role_id: &str, ) -> UsersSchema { UsersSchema { id: make_thing("app_users", &Uuid::new_v4().to_string()), email: email.to_string(), - fullname: format!("Randomize {} {}", fullname, rand::rng().random::()), + fullname: format!("Randomize {} {}", fullname, rand::random::()), password: hash_password("secret").unwrap(), is_deleted: false, avatar: None, @@ -70,13 +70,12 @@ pub fn create_test_user( referred_by: None, identity_number: None, is_active, - student_type: "general".to_string(), + student_type: "TNI".to_string(), religion: None, gender: None, birthdate: None, is_profile_completed: false, - role: make_thing(&ResourceEnum::Roles.to_string(), "user"), - created_at: None, - updated_at: None, + role: make_thing("app_roles", role_id), + ..Default::default() } } diff --git a/src/utils/query_list.rs b/src/utils/query_list.rs index 04345ab..c875694 100644 --- a/src/utils/query_list.rs +++ b/src/utils/query_list.rs @@ -1,16 +1,9 @@ +use super::bind_filter_value; use crate::{CountResult, MetaRequestDto, MetaResponseDto, ResponseListSuccessDto}; use anyhow::{bail, Result}; use serde::{de::DeserializeOwned, Serialize}; -use serde_json::Value; -use surrealdb::sql::Thing; use surrealdb::{engine::remote::ws::Client, Surreal}; -use super::bind_filter_value; - -fn thing_to_string(thing: &Thing) -> String { - format!("{}", thing.id) -} - pub async fn query_list_with_meta( db: &Surreal, table: &str, @@ -27,8 +20,6 @@ where bail!("Invalid pagination: page and per_page must be greater than 0"); } let start = (page - 1) * per_page; - - // SELECT QUERY let sql = custom_select.unwrap_or_else(|| { let mut s = format!("SELECT * FROM {}", table); if !conditions.is_empty() { @@ -51,7 +42,6 @@ where s.push_str(" LIMIT $per_page START $start"); s }); - let mut query_exec = db.query(sql); if let Some(search) = &meta.search { if !search.is_empty() { @@ -64,22 +54,7 @@ where query_exec = query_exec .bind(("per_page", per_page)) .bind(("start", start)); - - let raw: Vec = query_exec.await?.take(0)?; - - let mapped: Vec = raw - .into_iter() - .map(|mut item| { - if let Some(id) = item.get("id").cloned() { - if let Ok(thing) = serde_json::from_value::(id.clone()) { - item["id"] = Value::String(thing_to_string(&thing)); - } - } - serde_json::from_value(item).unwrap() - }) - .collect(); - - // COUNT QUERY + let raw: Vec = query_exec.await?.take(0)?; let mut count_sql = format!("SELECT count() FROM {}", table); if !conditions.is_empty() { count_sql.push_str(" WHERE "); @@ -102,9 +77,8 @@ where per_page: Some(per_page), total, }; - Ok(ResponseListSuccessDto { - data: mapped, + data: raw, meta: Some(meta), }) }