diff --git a/src/apps/v1/docs/docs_controller.rs b/src/apps/v1/docs/docs_controller.rs index ea97f96..9b436f3 100644 --- a/src/apps/v1/docs/docs_controller.rs +++ b/src/apps/v1/docs/docs_controller.rs @@ -1,10 +1,11 @@ use crate::{ v1::{ - auth, AuthLoginRequestDto, AuthLoginResponsetDto, AuthResendOtpRequestDto, - AuthVerifyEmailRequestDto, + auth, users, AuthLoginRequestDto, AuthLoginResponsetDto, + AuthResendOtpRequestDto, AuthVerifyEmailRequestDto, }, AuthNewPasswordRequestDto, AuthRefreshTokenRequestDto, MessageResponseDto, - MetaRequestDto, MetaResponseDto, ResponseSuccessDto, TokenDto, + MetaRequestDto, MetaResponseDto, ResponseListSuccessDto, ResponseSuccessDto, + TokenDto, UsersItemDto, }; use utoipa::{ @@ -21,7 +22,13 @@ use utoipa::{ auth::auth_controller::post_resend_otp, auth::auth_controller::post_refresh_token, auth::auth_controller::post_forgot_password, - auth::auth_controller::post_new_password + auth::auth_controller::post_new_password, + users::users_controller::post_create_user, + users::users_controller::put_update_user, + users::users_controller::patch_user_active_status, + users::users_controller::delete_user, + users::users_controller::get_user_by_id, + users::users_controller::get_user_list ), components( schemas( @@ -36,6 +43,7 @@ use utoipa::{ AuthRefreshTokenRequestDto, ResponseSuccessDto, ResponseSuccessDto, + ResponseListSuccessDto> ) ), info( diff --git a/src/apps/v1/mod.rs b/src/apps/v1/mod.rs index ba45ebd..ed70c65 100644 --- a/src/apps/v1/mod.rs +++ b/src/apps/v1/mod.rs @@ -10,8 +10,9 @@ pub use users::*; pub async fn routes() -> Router { let public_routes = Router::new().nest("/auth", auth_router()); - let protected_routes = - Router::new().layer(from_fn(auth::auth_middleware::auth_middleware)); + let protected_routes = Router::new() + .nest("/users", users_router()) + .layer(from_fn(auth::auth_middleware::auth_middleware)); Router::new().merge(public_routes).merge(protected_routes) } diff --git a/src/apps/v1/roles/mod.rs b/src/apps/v1/roles/mod.rs new file mode 100644 index 0000000..e69de29 diff --git a/src/apps/v1/users/mod.rs b/src/apps/v1/users/mod.rs index 6434227..6a4dd63 100644 --- a/src/apps/v1/users/mod.rs +++ b/src/apps/v1/users/mod.rs @@ -1,10 +1,31 @@ +use axum::{ + routing::{delete, get, post, put}, + Router, +}; + +pub mod users_controller; pub mod users_dto; pub mod users_repository; pub mod users_schema; +pub mod users_service; +#[cfg(test)] +pub mod users_controller_test; #[cfg(test)] pub mod users_repository_test; +pub use users_controller::*; pub use users_dto::*; pub use users_repository::*; pub use users_schema::*; +pub use users_service::*; + +pub fn users_router() -> Router { + Router::new() + .route("/", get(get_user_list)) + .route("/activate/{id}", put(patch_user_active_status)) + .route("/create", post(post_create_user)) + .route("/delete/{id}", delete(delete_user)) + .route("/detail/{id}", get(get_user_by_id)) + .route("/update/{id}", put(put_update_user)) +} diff --git a/src/apps/v1/users/users_controller.rs b/src/apps/v1/users/users_controller.rs new file mode 100644 index 0000000..49e4e85 --- /dev/null +++ b/src/apps/v1/users/users_controller.rs @@ -0,0 +1,118 @@ +use axum::extract::{Path, Query}; +use axum::response::IntoResponse; +use axum::{Extension, Json}; + +use crate::{v1::users_service::UsersService, AppState, MetaRequestDto}; +use crate::{ + MessageResponseDto, ResponseListSuccessDto, ResponseSuccessDto, + UsersActiveInactiveRequestDto, UsersCreateRequestDto, UsersItemDto, +}; + +use super::UsersUpdateRequestDto; + +#[utoipa::path( + get, + path = "/v1/users", + params( + ("page" = Option, Query, description = "Page number"), + ("per_page" = Option, Query, description = "Items per page"), + ("search" = Option, Query, description = "Search keyword"), + ("sort_by" = Option, Query, description = "Sort by field"), + ("order" = Option, Query, description = "Order ASC or DESC"), + ("filter" = Option, Query, description = "Filter value"), + ("filter_by" = Option, Query, description = "Field to filter by"), + ), + responses( + (status = 200, description = "Get user list", body = ResponseListSuccessDto>) + ), + tag = "Users" +)] +pub async fn get_user_list( + Extension(state): Extension, + Query(meta): Query, +) -> impl IntoResponse { + UsersService::get_user_list(&state, meta).await +} + +#[utoipa::path( + get, + path = "/v1/users/detail/{id}", + params( + ("id" = String, Path, description = "User ID") + ), + responses( + (status = 200, description = "Get user by ID", body = ResponseSuccessDto) + ), + tag = "Users" +)] +pub async fn get_user_by_id( + Extension(state): Extension, + Path(id): Path, +) -> impl IntoResponse { + UsersService::get_user_by_id(&state, id).await +} + +#[utoipa::path( + post, + path = "/v1/users/create", + request_body = UsersCreateRequestDto, + responses( + (status = 201, description = "Create new user", body = MessageResponseDto) + ), + tag = "Users" +)] +pub async fn post_create_user( + Extension(state): Extension, + Json(payload): Json, +) -> impl IntoResponse { + UsersService::create_user(&state, payload).await +} + +#[utoipa::path( + put, + path = "/v1/users/update/{id}", + request_body = UsersCreateRequestDto, + responses( + (status = 200, description = "Update user", body = MessageResponseDto) + ), + tag = "Users" +)] +pub async fn put_update_user( + Extension(state): Extension, + Path(id): Path, + Json(payload): Json, +) -> impl IntoResponse { + UsersService::update_user(&state, id, payload).await +} + +#[utoipa::path( + put, + path = "/v1/users/activate/{id}", + request_body = UsersActiveInactiveRequestDto, + responses( + (status = 200, description = "Set user active/inactive", body = MessageResponseDto) + ), + tag = "Users" +)] +pub async fn patch_user_active_status( + Extension(state): Extension, + axum::extract::Path(id): axum::extract::Path, + Json(payload): Json, +) -> impl IntoResponse { + UsersService::set_user_active_status(&state, id, payload).await +} + +#[utoipa::path( + delete, + path = "/v1/users/delete/{id}", + responses( + (status = 200, description = "Soft delete user", body = MessageResponseDto) + ), + tag = "Users" +)] +pub async fn delete_user( + Extension(state): Extension, + Path(id): Path, +) -> impl IntoResponse { + UsersService::delete_user(&state, id).await +} diff --git a/src/apps/v1/users/users_controller_test.rs b/src/apps/v1/users/users_controller_test.rs new file mode 100644 index 0000000..d3b11cb --- /dev/null +++ b/src/apps/v1/users/users_controller_test.rs @@ -0,0 +1,507 @@ +use crate::{ + apps::v1::users_router, create_mock_app_state, UsersActiveInactiveRequestDto, + UsersCreateRequestDto, UsersRepository, UsersUpdateRequestDto, +}; +use axum::{http::StatusCode, Extension}; +use axum_test::TestServer; + +#[tokio::test] +async fn test_get_user_list_should_return_200() { + let state = create_mock_app_state().await; + let app = axum::Router::new() + .nest("/v1/users", users_router()) + .layer(Extension(state)); + let server = TestServer::new(app).unwrap(); + let res = server.get("/v1/users?page=1&per_page=10").await; + let status = res.status_code(); + assert_eq!(status, StatusCode::OK); +} + +#[tokio::test] +async fn test_create_user_should_return_201() { + let state = create_mock_app_state().await; + let app = axum::Router::new() + .nest("/v1/users", users_router()) + .layer(Extension(state)); + let server = TestServer::new(app).unwrap(); + let payload = UsersCreateRequestDto { + fullname: "Create Data #1".into(), + email: "test@create.com".into(), + password: "Password1!".into(), + student_type: "general".into(), + phone_number: "081234567890".into(), + is_active: true, + referral_code: None, + referred_by: None, + }; + let res = server.post("/v1/users/create").json(&payload).await; + assert_eq!(res.status_code(), StatusCode::CREATED); +} + +#[tokio::test] +async fn test_delete_user_should_return_200() { + let state = create_mock_app_state().await; + let repo = UsersRepository::new(&state); + let app = axum::Router::new() + .nest("/v1/users", users_router()) + .layer(Extension(state.clone())); + let server = TestServer::new(app).unwrap(); + let payload = UsersCreateRequestDto { + fullname: "Create Data #1".into(), + email: "test@create.com".into(), + password: "Password1!".into(), + student_type: "general".into(), + phone_number: "081234567890".into(), + is_active: true, + referral_code: None, + referred_by: None, + }; + let create_res = server.post("/v1/users/create").json(&payload).await; + assert_eq!(create_res.status_code(), StatusCode::CREATED); + let detail_res = repo.query_user_by_email(payload.email); + let user_id = detail_res.await.unwrap().id.id.to_raw(); + let delete_res = server + .delete(&format!("/v1/users/delete/{}", user_id)) + .await; + assert_eq!(delete_res.status_code(), StatusCode::OK); +} + +#[tokio::test] +async fn test_get_user_detail_should_return_200() { + let state = create_mock_app_state().await; + let repo = UsersRepository::new(&state); + let app = axum::Router::new() + .nest("/v1/users", users_router()) + .layer(Extension(state.clone())); + let server = TestServer::new(app).unwrap(); + let payload = UsersCreateRequestDto { + fullname: "Detail User".into(), + email: "detail@test.com".into(), + password: "Password1!".into(), + student_type: "general".into(), + phone_number: "081234567890".into(), + is_active: true, + referral_code: None, + referred_by: None, + }; + server.post("/v1/users/create").json(&payload).await; + let user = repo.query_user_by_email(payload.email).await.unwrap(); + let user_id = user.id.id.to_raw(); + let res = server.get(&format!("/v1/users/detail/{}", user_id)).await; + assert_eq!(res.status_code(), StatusCode::OK); +} + +#[tokio::test] +async fn test_activate_user_should_return_200() { + let state = create_mock_app_state().await; + let repo = UsersRepository::new(&state); + let app = axum::Router::new() + .nest("/v1/users", users_router()) + .layer(Extension(state.clone())); + let server = TestServer::new(app).unwrap(); + let payload = UsersCreateRequestDto { + fullname: "Inactive User".into(), + email: "inactive@test.com".into(), + password: "Password1!".into(), + student_type: "general".into(), + phone_number: "081234567890".into(), + is_active: false, + referral_code: None, + referred_by: None, + }; + server.post("/v1/users/create").json(&payload).await; + let user = repo.query_user_by_email(payload.email).await.unwrap(); + let user_id = user.id.id.to_raw(); + let res = server + .put(&format!("/v1/users/activate/{}", user_id)) + .json(&UsersActiveInactiveRequestDto { is_active: true }) + .await; + assert_eq!(res.status_code(), StatusCode::OK); +} + +#[tokio::test] +async fn test_update_user_should_return_200() { + let state = create_mock_app_state().await; + let repo = UsersRepository::new(&state); + let app = axum::Router::new() + .nest("/v1/users", users_router()) + .layer(Extension(state.clone())); + let server = TestServer::new(app).unwrap(); + let payload = UsersCreateRequestDto { + fullname: "Old Name".into(), + email: "update@test.com".into(), + password: "Password1!".into(), + student_type: "general".into(), + phone_number: "081234567890".into(), + is_active: true, + referral_code: None, + referred_by: None, + }; + server.post("/v1/users/create").json(&payload).await; + let user = repo.query_user_by_email(payload.email).await.unwrap(); + let user_id = user.id.id.to_raw(); + let update_payload = UsersUpdateRequestDto { + fullname: "Updated Name".into(), + email: "update@test.com".into(), + student_type: "general".into(), + phone_number: "081234567890".into(), + is_active: true, + referral_code: None, + referred_by: None, + identity_number: Some("1234567890123456".into()), + religion: Some("Islam".into()), + gender: Some("Laki-laki".into()), + birthdate: Some("2000-01-01".into()), + avatar: None, + }; + let res = server + .put(&format!("/v1/users/update/{}", user_id)) + .json(&update_payload) + .await; + let status = res.status_code(); + assert_eq!(status, StatusCode::OK); +} + +#[tokio::test] +async fn test_create_user_should_fail_if_email_taken() { + let state = create_mock_app_state().await; + let app = axum::Router::new() + .nest("/v1/users", users_router()) + .layer(Extension(state.clone())); + let server = TestServer::new(app).unwrap(); + let payload = UsersCreateRequestDto { + fullname: "User Satu".into(), + email: "taken@example.com".into(), + password: "Password1!".into(), + student_type: "general".into(), + phone_number: "081234567890".into(), + is_active: true, + referral_code: None, + referred_by: None, + }; + let res1 = server.post("/v1/users/create").json(&payload).await; + assert_eq!(res1.status_code(), StatusCode::CREATED); + let res2 = server.post("/v1/users/create").json(&payload).await; + let body2 = res2.text(); + assert_eq!(res2.status_code(), StatusCode::BAD_REQUEST); + assert!(body2.contains("User already exists")); +} + +#[tokio::test] +async fn test_get_user_detail_should_fail_if_not_found() { + let state = create_mock_app_state().await; + let app = axum::Router::new() + .nest("/v1/users", users_router()) + .layer(Extension(state)); + let server = TestServer::new(app).unwrap(); + let res = server.get("/v1/users/detail/non-existent-id").await; + assert_eq!(res.status_code(), StatusCode::NOT_FOUND); + let body = res.text(); + assert!(body.contains("User not found")); +} + +#[tokio::test] +async fn test_delete_user_should_fail_if_already_deleted() { + let state = create_mock_app_state().await; + let repo = UsersRepository::new(&state); + let app = axum::Router::new() + .nest("/v1/users", users_router()) + .layer(Extension(state.clone())); + let server = TestServer::new(app).unwrap(); + let payload = UsersCreateRequestDto { + fullname: "To Be Deleted".into(), + email: "delete-me@example.com".into(), + password: "Password1!".into(), + student_type: "general".into(), + phone_number: "081234567890".into(), + is_active: true, + referral_code: None, + referred_by: None, + }; + server.post("/v1/users/create").json(&payload).await; + let user = repo.query_user_by_email(payload.email).await.unwrap(); + let id = user.id.id.to_raw(); + let res1 = server.delete(&format!("/v1/users/delete/{}", id)).await; + assert_eq!(res1.status_code(), StatusCode::OK); + let res2 = server.delete(&format!("/v1/users/delete/{}", id)).await; + assert_eq!(res2.status_code(), StatusCode::BAD_REQUEST); + let body = res2.text(); + assert!(body.contains("User not found")); +} + +#[tokio::test] +async fn test_update_user_should_fail_if_user_not_found() { + let state = create_mock_app_state().await; + let app = axum::Router::new() + .nest("/v1/users", users_router()) + .layer(Extension(state)); + let server = TestServer::new(app).unwrap(); + let update_payload = UsersUpdateRequestDto { + fullname: "Does Not Exist".into(), + email: "nonexistent@test.com".into(), + student_type: "general".into(), + phone_number: "081234567890".into(), + is_active: true, + referral_code: None, + referred_by: None, + identity_number: Some("1234567890123456".into()), + religion: Some("Islam".into()), + gender: Some("Laki-laki".into()), + birthdate: Some("2000-01-01".into()), + avatar: None, + }; + let res = server + .put("/v1/users/update/non-existent-id") + .json(&update_payload) + .await; + assert_eq!(res.status_code(), StatusCode::BAD_REQUEST); + let body = res.text(); + assert!(body.contains("User not found")); +} + +#[tokio::test] +async fn test_activate_user_should_fail_if_user_not_found() { + let state = create_mock_app_state().await; + let app = axum::Router::new() + .nest("/v1/users", users_router()) + .layer(Extension(state)); + let server = TestServer::new(app).unwrap(); + let payload = UsersActiveInactiveRequestDto { is_active: true }; + let res = server + .put("/v1/users/activate/non-existent-id") + .json(&payload) + .await; + assert_eq!(res.status_code(), StatusCode::BAD_REQUEST); + let body = res.text(); + assert!(body.contains("User not found")); +} + +#[tokio::test] +async fn test_create_user_should_fail_if_payload_invalid() { + let state = create_mock_app_state().await; + let app = axum::Router::new() + .nest("/v1/users", users_router()) + .layer(Extension(state)); + let server = TestServer::new(app).unwrap(); + let invalid_payload = serde_json::json!({}); + let res = server.post("/v1/users/create").json(&invalid_payload).await; + assert_eq!(res.status_code(), StatusCode::UNPROCESSABLE_ENTITY); +} + +#[tokio::test] +async fn test_list_users_should_fail_with_invalid_pagination() { + let state = create_mock_app_state().await; + let app = axum::Router::new() + .nest("/v1/users", users_router()) + .layer(Extension(state)); + let server = TestServer::new(app).unwrap(); + let res = server.get("/v1/users?page=0&per_page=0").await; + assert_eq!(res.status_code(), StatusCode::BAD_REQUEST); + let body = res.text(); + assert!( + body.contains("Invalid pagination") || body.contains("per_page"), + "Expected pagination error, got: {body}" + ); +} + +#[tokio::test] +async fn test_list_users_should_ignore_invalid_sort_field() { + let state = create_mock_app_state().await; + let app = axum::Router::new() + .nest("/v1/users", users_router()) + .layer(Extension(state)); + let server = TestServer::new(app).unwrap(); + let res = server + .get("/v1/users?page=1&per_page=10&sort_by=nonexistent_field") + .await; + assert_eq!(res.status_code(), StatusCode::OK); +} + +#[tokio::test] +async fn test_list_users_should_fallback_on_invalid_order() { + let state = create_mock_app_state().await; + let app = axum::Router::new() + .nest("/v1/users", users_router()) + .layer(Extension(state)); + let server = TestServer::new(app).unwrap(); + let res = server + .get("/v1/users?page=1&per_page=10&sort_by=email&order=invalid") + .await; + assert_eq!(res.status_code(), StatusCode::OK); +} + +#[tokio::test] +async fn test_list_users_with_search_should_return_empty_if_no_match() { + let state = create_mock_app_state().await; + let app = axum::Router::new() + .nest("/v1/users", users_router()) + .layer(Extension(state)); + let server = TestServer::new(app).unwrap(); + let res = server + .get("/v1/users?page=1&per_page=10&search=no-user-should-match-this") + .await; + assert_eq!(res.status_code(), StatusCode::OK); + let body: serde_json::Value = res.json(); + assert_eq!(body["data"].as_array().unwrap().len(), 0); +} + +#[tokio::test] +async fn test_list_users_with_invalid_filter_by_should_return_empty() { + let state = create_mock_app_state().await; + let app = axum::Router::new() + .nest("/v1/users", users_router()) + .layer(Extension(state)); + let server = TestServer::new(app).unwrap(); + let res = server + .get("/v1/users?page=1&per_page=10&filter_by=unknown_field&filter=value") + .await; + assert_eq!(res.status_code(), StatusCode::OK); + let body: serde_json::Value = res.json(); + assert_eq!(body["data"].as_array().unwrap().len(), 0); +} + +#[tokio::test] +async fn test_user_detail_should_fail_with_invalid_id_format() { + let state = create_mock_app_state().await; + let app = axum::Router::new() + .nest("/v1/users", users_router()) + .layer(Extension(state)); + let server = TestServer::new(app).unwrap(); + let res = server.get("/v1/users/detail/!@#invalid-id").await; + assert_eq!(res.status_code(), StatusCode::NOT_FOUND); + let body = res.text(); + assert!(body.contains("User not found") || body.contains("not found")); +} + +#[tokio::test] +async fn test_user_detail_should_fail_if_user_not_found() { + let state = create_mock_app_state().await; + let app = axum::Router::new() + .nest("/v1/users", users_router()) + .layer(Extension(state)); + let server = TestServer::new(app).unwrap(); + let res = server.get("/v1/users/detail/random-id-123456").await; + assert_eq!(res.status_code(), StatusCode::NOT_FOUND); + let body = res.text(); + assert!(body.contains("User not found")); +} + +#[tokio::test] +async fn test_user_detail_should_fail_if_user_is_soft_deleted() { + let state = create_mock_app_state().await; + let repo = UsersRepository::new(&state); + let app = axum::Router::new() + .nest("/v1/users", users_router()) + .layer(Extension(state.clone())); + let server = TestServer::new(app).unwrap(); + let payload = UsersCreateRequestDto { + fullname: "Soft Deleted".into(), + email: "softdeleted@test.com".into(), + password: "Password1!".into(), + student_type: "general".into(), + phone_number: "081234567890".into(), + is_active: true, + referral_code: None, + referred_by: None, + }; + server.post("/v1/users/create").json(&payload).await; + let user = repo.query_user_by_email(payload.email).await.unwrap(); + let user_id = user.id.id.to_raw(); + let _ = repo.query_delete_user(user_id.clone()).await.unwrap(); + let res = server.get(&format!("/v1/users/detail/{}", user_id)).await; + assert_eq!(res.status_code(), StatusCode::NOT_FOUND); + let body = res.text(); + assert!(body.contains("User not found")); +} + +#[tokio::test] +async fn test_update_user_should_fail_if_user_is_deleted() { + let state = create_mock_app_state().await; + let repo = UsersRepository::new(&state); + let app = axum::Router::new() + .nest("/v1/users", users_router()) + .layer(Extension(state.clone())); + let server = TestServer::new(app).unwrap(); + let payload = UsersCreateRequestDto { + fullname: "To Be Deleted".into(), + email: "deleteduser@test.com".into(), + password: "Password1!".into(), + student_type: "general".into(), + phone_number: "081234567890".into(), + is_active: true, + referral_code: None, + referred_by: None, + }; + server.post("/v1/users/create").json(&payload).await; + let user = repo.query_user_by_email(payload.email).await.unwrap(); + let user_id = user.id.id.to_raw(); + let _ = repo.query_delete_user(user_id.clone()).await.unwrap(); + let update_payload = UsersUpdateRequestDto { + fullname: "Should Fail".into(), + email: "deleteduser@test.com".into(), + student_type: "general".into(), + phone_number: "081234567890".into(), + is_active: true, + referral_code: None, + referred_by: None, + identity_number: Some("1234567890123456".into()), + religion: Some("Islam".into()), + gender: Some("Laki-laki".into()), + birthdate: Some("2000-01-01".into()), + avatar: None, + }; + let res = server + .put(&format!("/v1/users/update/{}", user_id)) + .json(&update_payload) + .await; + assert_eq!(res.status_code(), StatusCode::BAD_REQUEST); + let body = res.text(); + assert!(body.contains("User not found")); +} + +#[tokio::test] +async fn test_update_user_should_fail_if_payload_invalid() { + let state = create_mock_app_state().await; + let repo = UsersRepository::new(&state); + let app = axum::Router::new() + .nest("/v1/users", users_router()) + .layer(Extension(state.clone())); + let server = TestServer::new(app).unwrap(); + let payload = UsersCreateRequestDto { + fullname: "Invalid Payload".into(), + email: "invalid@test.com".into(), + password: "Password1!".into(), + student_type: "general".into(), + phone_number: "081234567890".into(), + is_active: true, + referral_code: None, + referred_by: None, + }; + server.post("/v1/users/create").json(&payload).await; + let user = repo.query_user_by_email(payload.email).await.unwrap(); + let user_id = user.id.id.to_raw(); + let update_payload = UsersUpdateRequestDto { + fullname: "".into(), + email: "invalid@test.com".into(), + student_type: "general".into(), + phone_number: "081234567890".into(), + is_active: true, + referral_code: None, + referred_by: None, + identity_number: Some("1234567890123456".into()), + religion: Some("Islam".into()), + gender: Some("Laki-laki".into()), + birthdate: Some("2000-01-01".into()), + avatar: None, + }; + let res = server + .put(&format!("/v1/users/update/{}", user_id)) + .json(&update_payload) + .await; + assert_eq!(res.status_code(), StatusCode::BAD_REQUEST); + let body = res.text(); + assert!( + body.contains("Fullname") || body.contains("at least have 2 character"), + "Gagal validasi fullname: {body}" + ); +} diff --git a/src/apps/v1/users/users_dto.rs b/src/apps/v1/users/users_dto.rs index c00751e..02ae30a 100644 --- a/src/apps/v1/users/users_dto.rs +++ b/src/apps/v1/users/users_dto.rs @@ -1,11 +1,80 @@ +use lazy_static::lazy_static; +use regex::Regex; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; +use validator::Validate; -#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] +lazy_static! { + static ref PASSWORD_REGEX: Regex = Regex::new(r"^[A-Za-z\d@$!%*?&]{8,}$").unwrap(); +} + +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)] pub struct UsersCreateRequestDto { + #[validate( + length(min = 1, message = "Email cannot be empty"), + email(message = "Email not valid") + )] pub email: String, - pub fullname: String, + #[validate(length( + min = 8, + message = "Password must have at least 8 characters" + ))] + #[validate(regex( + path = "PASSWORD_REGEX", + message = "Password must include uppercase, lowercase, number, and special character" + ))] pub password: String, + #[validate(length(min = 2, message = "Fullname at least have 2 character"))] + pub fullname: String, + #[validate(length(min = 1, message = "Student type is required"))] + pub student_type: String, + #[validate(length( + min = 10, + message = "Phone number at least have 10 character" + ))] + pub phone_number: String, + #[validate(length( + max = 4, + message = "Referal code cannot be more than 4 character" + ))] + pub referral_code: Option, + pub referred_by: Option, + pub is_active: bool, +} + +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)] +pub struct UsersUpdateRequestDto { + #[validate( + length(min = 1, message = "Email cannot be empty"), + email(message = "Email not valid") + )] + pub email: String, + #[validate(length( + min = 8, + message = "Password must have at least 8 characters" + ))] + #[validate(length(min = 2, message = "Fullname at least have 2 character"))] + pub fullname: String, + #[validate(length(min = 1, message = "Student type is required"))] + pub student_type: String, + #[validate(length( + min = 10, + message = "Phone number at least have 10 character" + ))] + pub phone_number: String, + #[validate(length( + max = 4, + message = "Referal code cannot be more than 4 character" + ))] + pub referral_code: Option, + pub referred_by: Option, + pub is_active: bool, + #[validate(length(min = 16, message = "NIK at must have 16 character"))] + pub identity_number: Option, + pub religion: Option, + pub gender: Option, + pub birthdate: Option, + pub avatar: Option, } #[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] @@ -15,13 +84,18 @@ pub struct UsersItemDto { pub email: String, pub avatar: Option, pub phone_number: String, - pub referral_code: Option, pub referred_by: Option, - pub identity_number: Option, - pub is_active: bool, + 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, - pub is_profile_completed: bool, +} + +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] +pub struct UsersActiveInactiveRequestDto { + pub is_active: bool, } diff --git a/src/apps/v1/users/users_repository.rs b/src/apps/v1/users/users_repository.rs index 42c857a..555ef83 100644 --- a/src/apps/v1/users/users_repository.rs +++ b/src/apps/v1/users/users_repository.rs @@ -1,7 +1,7 @@ use super::{UsersActiveInactiveSchema, UsersSchema, UsersSetNewPasswordSchema}; use crate::{ - get_id, AppState, CountResult, MetaRequestDto, MetaResponseDto, ResourceEnum, - ResponseListSuccessDto, + get_id, make_thing, AppState, CountResult, MetaRequestDto, MetaResponseDto, + ResourceEnum, ResponseListSuccessDto, }; use anyhow::{bail, Result}; @@ -23,6 +23,11 @@ impl<'a> UsersRepository<'a> { let page = meta.page.unwrap_or(1); let per_page = meta.per_page.unwrap_or(10); + + if page < 1 || per_page < 1 { + bail!("Invalid pagination: page and per_page must be greater than 0"); + } + let start = (page - 1) * per_page; let mut sql = format!("SELECT * FROM {}", table); @@ -43,7 +48,17 @@ impl<'a> UsersRepository<'a> { } if let Some(sort_by) = &meta.sort_by { - let order = meta.order.clone().unwrap_or_else(|| "ASC".to_string()); + let order = match meta + .order + .clone() + .unwrap_or_else(|| "ASC".to_string()) + .to_uppercase() + .as_str() + { + "ASC" => "ASC", + "DESC" => "DESC", + _ => "ASC", // fallback kalau order invalid + }; sql.push_str(&format!(" ORDER BY {} {}", sort_by, order)); } @@ -146,7 +161,16 @@ impl<'a> UsersRepository<'a> { pub async fn query_update_user(&self, data: UsersSchema) -> Result { let db = &self.state.surrealdb_ws; let record_key = get_id(&data.id)?; - let record: Option = db.update(record_key).merge(data).await?; + let existing = self.query_user_by_id(data.id.id.to_raw()).await?; + if existing.is_deleted { + bail!("User already deleted"); + } + let merged = UsersSchema { + password: existing.password, + created_at: existing.created_at, + ..data.clone() + }; + let record: Option = db.update(record_key).merge(merged).await?; match record { Some(_) => Ok("Success update user".into()), None => bail!("Failed to update user"), @@ -160,6 +184,9 @@ impl<'a> UsersRepository<'a> { ) -> Result { let db = &self.state.surrealdb_ws; let user = self.query_user_by_email(email.clone()).await?; + if user.is_deleted { + bail!("User not found"); + } let record_key = get_id(&user.id)?; let record: Option = db .update(record_key) @@ -173,6 +200,24 @@ impl<'a> UsersRepository<'a> { } } + pub async fn query_active_inactive_user_by_id( + &self, + id: String, + data: UsersActiveInactiveSchema, + ) -> Result { + let db = &self.state.surrealdb_ws; + let record: Option = db + .update((ResourceEnum::Users.to_string(), id)) + .merge(UsersActiveInactiveSchema { + is_active: data.is_active, + }) + .await?; + match record { + Some(_) => Ok("Success update user".into()), + None => bail!("Failed to update user"), + } + } + pub async fn query_update_password_user( &self, email: String, @@ -193,9 +238,13 @@ impl<'a> UsersRepository<'a> { } } - pub async fn query_delete_user(&self, email: String) -> Result { + pub async fn query_delete_user(&self, id: String) -> Result { let db = &self.state.surrealdb_ws; - let user = self.query_user_by_email(email.clone()).await?; + let user_id = make_thing(&ResourceEnum::Users.to_string(), &id); + let user = self.query_user_by_id(user_id.id.to_raw()).await?; + if user.is_deleted { + bail!("User already deleted"); + } let record_key = get_id(&user.id)?; let record: Option = db .update(record_key) diff --git a/src/apps/v1/users/users_repository_test.rs b/src/apps/v1/users/users_repository_test.rs index aaab1f3..6321d52 100644 --- a/src/apps/v1/users/users_repository_test.rs +++ b/src/apps/v1/users/users_repository_test.rs @@ -164,9 +164,17 @@ async fn test_query_delete_user() { let email = "deleteuser@example.com"; let user = create_test_user(email, "Delete User", true); repo.query_create_user(user.clone()).await.unwrap(); - let delete_result = repo.query_delete_user(email.to_string()).await; + let user_detail = repo + .query_user_by_email(email.to_string().clone()) + .await + .unwrap(); + let delete_result = repo + .query_delete_user(user_detail.id.id.to_raw().clone()) + .await; assert!(delete_result.is_ok()); - let fetch_result = repo.query_user_by_email(email.to_string()).await; + let fetch_result = repo + .query_user_by_email(user_detail.id.id.to_raw().clone()) + .await; assert!(fetch_result.is_err()); } @@ -174,9 +182,7 @@ async fn test_query_delete_user() { async fn test_delete_non_existent_user_should_fail() { let app_state = create_mock_app_state().await; let repo = UsersRepository::new(&app_state); - let result = repo - .query_delete_user("nonexistent@example.com".to_string()) - .await; + let result = repo.query_delete_user("lklklklk".to_string()).await; assert!(result.is_err()); assert_eq!(result.unwrap_err().to_string(), "User not found"); } @@ -188,9 +194,24 @@ async fn test_delete_user_twice_should_fail_on_second_attempt() { let email = "twice@example.com"; let user = create_test_user(email, "Delete Twice", true); repo.query_create_user(user.clone()).await.unwrap(); - let first = repo.query_delete_user(email.to_string()).await; + let first = repo.query_delete_user(user.id.id.to_raw()).await; assert!(first.is_ok()); - let second = repo.query_delete_user(email.to_string()).await; + let second = repo.query_delete_user(user.id.id.to_raw()).await; assert!(second.is_err()); assert_eq!(second.unwrap_err().to_string(), "User not found"); } + +#[tokio::test] +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); + repo.query_create_user(user.clone()).await.unwrap(); + user.fullname = "Updated Name".into(); + user.phone_number = "089876543210".into(); + let result = repo.query_update_user(user.clone()).await; + assert!(result.is_ok(), "Update failed: {:?}", result.err()); + let updated = repo.query_user_by_id(user.id.id.to_raw()).await.unwrap(); + assert_eq!(updated.fullname, "Updated Name"); + assert_eq!(updated.phone_number, "089876543210"); +} diff --git a/src/apps/v1/users/users_service.rs b/src/apps/v1/users/users_service.rs new file mode 100644 index 0000000..d17e3fb --- /dev/null +++ b/src/apps/v1/users/users_service.rs @@ -0,0 +1,172 @@ +use crate::{ + common_response, 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::StatusCode, response::Response}; + +use super::{ + UsersActiveInactiveRequestDto, UsersCreateRequestDto, UsersUpdateRequestDto, +}; + +pub struct UsersService; + +impl UsersService { + pub async fn get_user_list(state: &AppState, meta: MetaRequestDto) -> Response { + let repo = UsersRepository::new(state); + match repo.query_user_list(meta).await { + Ok(data) => { + let response = ResponseListSuccessDto { + data: data.data, + meta: data.meta, + }; + success_list_response(response) + } + Err(e) => common_response(StatusCode::BAD_REQUEST, &e.to_string()), + } + } + + pub async fn get_user_by_email(state: &AppState, email: String) -> Response { + let repo = UsersRepository::new(state); + match repo.query_user_by_email(email).await { + Ok(user) => success_response(ResponseSuccessDto { data: user }), + Err(e) => common_response(StatusCode::NOT_FOUND, &e.to_string()), + } + } + + pub async fn get_user_by_id(state: &AppState, id: String) -> Response { + let repo = UsersRepository::new(state); + match repo.query_user_by_id(id).await { + Ok(user) => success_response(ResponseSuccessDto { data: user }), + Err(e) => common_response(StatusCode::NOT_FOUND, &e.to_string()), + } + } + + pub async fn create_user( + state: &AppState, + new_user: UsersCreateRequestDto, + ) -> Response { + if let Err((status, message)) = validate_request(&new_user) { + return common_response(status, &message); + } + let repo = UsersRepository::new(state); + + if repo + .query_user_by_email(new_user.email.clone()) + .await + .is_ok() + { + return common_response(StatusCode::BAD_REQUEST, "User already exists"); + } + match repo + .query_create_user(UsersSchema { + email: new_user.email.clone(), + fullname: new_user.fullname.clone(), + password: hash_password(&new_user.password).unwrap(), + phone_number: new_user.phone_number.clone(), + referral_code: new_user.referral_code.clone(), + referred_by: new_user.referred_by.clone(), + student_type: new_user.student_type.clone(), + is_active: new_user.is_active.clone(), + is_profile_completed: false, + ..Default::default() + }) + .await + { + Ok(msg) => common_response(StatusCode::CREATED, &msg), + Err(err) => { + common_response(StatusCode::INTERNAL_SERVER_ERROR, &err.to_string()) + } + } + } + + pub async fn update_user( + state: &AppState, + id: String, + user: UsersUpdateRequestDto, + ) -> Response { + let repo = UsersRepository::new(state); + + if let Err((status, message)) = validate_request(&user) { + return common_response(status, &message); + } + + let user_id = make_thing(&ResourceEnum::Users.to_string(), &id); + 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: false, + role: role_id, + updated_at: Some(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()), + } + } + + pub async fn update_user_password( + state: &AppState, + email: String, + new_password: UsersSetNewPasswordSchema, + ) -> Response { + let repo = UsersRepository::new(state); + match repo.query_update_password_user(email, new_password).await { + Ok(msg) => common_response(StatusCode::OK, &msg), + Err(e) => common_response(StatusCode::BAD_REQUEST, &e.to_string()), + } + } + + pub async fn delete_user(state: &AppState, id: String) -> 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_delete_user(id).await { + Ok(msg) => common_response(StatusCode::OK, &msg), + Err(e) => common_response(StatusCode::BAD_REQUEST, &e.to_string()), + } + } +}