From 413a28c089172ceecb82f3a0301b391ec4216a35 Mon Sep 17 00:00:00 2001 From: Maulana Sodiqin Date: Fri, 21 Mar 2025 05:36:00 +0700 Subject: [PATCH] feat: setup data --- Cargo.lock | 99 ++++++++++++++++++++++++++- Cargo.toml | 3 + src/apps/v1/auth/auth_controller.rs | 19 ++++- src/apps/v1/auth/auth_dto.rs | 95 +++++++++++++++++++++---- src/apps/v1/auth/auth_service.rs | 56 +++++++++++++-- src/apps/v1/auth/mod.rs | 1 + src/apps/v1/docs/docs_controller.rs | 11 +-- src/apps/v1/users/users_repository.rs | 26 +++++-- src/apps/v1/users/users_schema.rs | 20 +++++- src/utils/extract_email.rs | 5 ++ src/utils/generate_date.rs | 6 ++ src/utils/mod.rs | 4 ++ src/utils/validator.rs | 28 ++++++++ 13 files changed, 341 insertions(+), 32 deletions(-) create mode 100644 src/utils/generate_date.rs create mode 100644 src/utils/validator.rs diff --git a/Cargo.lock b/Cargo.lock index dc76ac5..910ddca 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1733,6 +1733,17 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" +[[package]] +name = "idna" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "418a0a6fab821475f634efe3ccc45c013f742efe03d853e8d3355d5cb850ecf8" +dependencies = [ + "matches", + "unicode-bidi", + "unicode-normalization", +] + [[package]] name = "idna" version = "1.0.3" @@ -1754,6 +1765,12 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "if_chain" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb56e1aa765b4b4f3aadfab769793b7087bb03a4ea4920644a6d238e2df5b9ed" + [[package]] name = "indexmap" version = "1.9.3" @@ -1902,7 +1919,7 @@ dependencies = [ "futures-util", "hostname", "httpdate", - "idna", + "idna 1.0.3", "mime", "native-tls", "nom", @@ -2017,6 +2034,12 @@ dependencies = [ "tendril", ] +[[package]] +name = "matches" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" + [[package]] name = "matchit" version = "0.8.4" @@ -2134,10 +2157,12 @@ dependencies = [ "axum", "chrono", "jsonwebtoken", + "lazy_static", "lettre", "log", "rand 0.9.0", "redis", + "regex", "serde", "serde_json", "surrealdb", @@ -2146,6 +2171,7 @@ dependencies = [ "tower-http", "utoipa", "utoipa-swagger-ui", + "validator", ] [[package]] @@ -2598,6 +2624,30 @@ dependencies = [ "toml_edit", ] +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + [[package]] name = "proc-macro2" version = "1.0.94" @@ -4259,6 +4309,12 @@ version = "2.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + [[package]] name = "unicode-ident" version = "1.0.18" @@ -4315,7 +4371,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60" dependencies = [ "form_urlencoded", - "idna", + "idna 1.0.3", "percent-encoding", ] @@ -4397,6 +4453,45 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "validator" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d6937c33ec6039d8071bcf72933146b5bbe378d645d8fa59bdadabfc2a249" +dependencies = [ + "idna 0.2.3", + "lazy_static", + "regex", + "serde", + "serde_derive", + "serde_json", + "url", + "validator_derive", + "validator_types", +] + +[[package]] +name = "validator_derive" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4286b4497f270f59276a89ae0ad109d5f8f18c69b613e3fb22b61201aadb0c4d" +dependencies = [ + "if_chain", + "lazy_static", + "proc-macro-error", + "proc-macro2", + "quote", + "regex", + "syn 1.0.109", + "validator_types", +] + +[[package]] +name = "validator_types" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad9680608df133af2c1ddd5eaf1ddce91d60d61b6bc51494ef326458365a470a" + [[package]] name = "vart" version = "0.8.1" diff --git a/Cargo.toml b/Cargo.toml index d185800..4fc295c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,9 @@ thiserror = "2.0.11" anyhow = "1.0.97" rand = "0.9.0" tower-http = { version = "0.6.2", features = ["cors"] } +validator = { version = "0.12", features = ["derive"] } +lazy_static = "1.4.0" +regex = "1.11.1" [profile.release] lto = "fat" diff --git a/src/apps/v1/auth/auth_controller.rs b/src/apps/v1/auth/auth_controller.rs index cc1308f..0c16988 100644 --- a/src/apps/v1/auth/auth_controller.rs +++ b/src/apps/v1/auth/auth_controller.rs @@ -3,7 +3,7 @@ use super::{ AuthVerifyEmailRequestDto, }; use crate::{v1::AuthLoginResponsetDto, AppState}; -use crate::{MessageResponseDto, ResponseSuccessDto}; +use crate::{AuthNewPasswordRequestDto, MessageResponseDto, ResponseSuccessDto}; use axum::{response::IntoResponse, Extension, Json}; #[utoipa::path( @@ -90,3 +90,20 @@ pub async fn post_forgot_password( ) -> impl IntoResponse { AuthService::mutation_forgot_password(payload, &state).await } + +#[utoipa::path( + post, + path = "/v1/auth/new-password", + request_body = AuthNewPasswordRequestDto, + responses( + (status = 200, description = "New password request successful", body = MessageResponseDto), + (status = 401, description = "New password request failed", body = MessageResponseDto) + ), + tag = "Authentication" +)] +pub async fn post_new_password( + Extension(state): Extension, + Json(payload): Json, +) -> impl IntoResponse { + AuthService::mutation_new_password(payload, &state).await +} diff --git a/src/apps/v1/auth/auth_dto.rs b/src/apps/v1/auth/auth_dto.rs index 3a50ac9..d87234a 100644 --- a/src/apps/v1/auth/auth_dto.rs +++ b/src/apps/v1/auth/auth_dto.rs @@ -1,10 +1,25 @@ use crate::UsersItemDto; +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-Z])(?=.*[a-z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$" + ) + .unwrap(); +} + +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)] pub struct AuthLoginRequestDto { + #[validate( + length(min = 1, message = "Email cannot be empty"), + email(message = "Email not valid") + )] pub email: String, + #[validate(length(min = 1, message = "Password cannot be empty"))] pub password: String, } @@ -20,46 +35,96 @@ pub struct TokenDto { pub refresh_token: String, } -#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)] pub struct AuthRegisterRequestDto { + #[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(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, } -#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)] pub struct AuthActiveInactiveRequestDto { pub is_active: bool, + #[validate( + length(min = 1, message = "Email cannot be empty"), + email(message = "Email not valid") + )] pub email: String, } -#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)] pub struct AuthVerifyEmailRequestDto { + #[validate( + length(min = 1, message = "Email cannot be empty"), + email(message = "Email not valid") + )] pub email: String, pub otp: u32, } -#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)] pub struct AuthResendOtpRequestDto { + #[validate( + length(min = 1, message = "Email cannot be empty"), + email(message = "Email not valid") + )] pub email: String, } -#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)] pub struct AuthNewPasswordRequestDto { pub token: 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, } -#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)] pub struct AuthSetNewPasswordRequestDto { + #[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(regex( + path = "PASSWORD_REGEX", + message = "Password must include uppercase, lowercase, number, and special character" + ))] pub password: String, } - -#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] -pub struct AuthQueryByEmailResponseDto { - pub email: String, - pub fullname: String, - pub password: String, - pub is_active: bool, -} diff --git a/src/apps/v1/auth/auth_service.rs b/src/apps/v1/auth/auth_service.rs index 5a22c48..cc5f887 100644 --- a/src/apps/v1/auth/auth_service.rs +++ b/src/apps/v1/auth/auth_service.rs @@ -1,14 +1,17 @@ use super::{ - AuthLoginRequestDto, AuthLoginResponsetDto, AuthRegisterRequestDto, - AuthRepository, AuthResendOtpRequestDto, AuthVerifyEmailRequestDto, TokenDto, + AuthLoginRequestDto, AuthLoginResponsetDto, AuthNewPasswordRequestDto, + AuthRegisterRequestDto, AuthRepository, AuthResendOtpRequestDto, + AuthVerifyEmailRequestDto, TokenDto, }; use crate::{ - common_response, encode_access_token, encode_refresh_token, generate_otp, - hash_password, send_email, success_response, verify_password, AppState, Env, + common_response, encode_access_token, encode_refresh_token, extract_email_token, + generate_otp, get_iso_date, hash_password, send_email, success_response, + validate_request, verify_password, AppState, Env, ResourceEnum, ResponseSuccessDto, UsersActiveInactiveSchema, UsersItemDto, UsersRepository, UsersSchema, }; use axum::{http::StatusCode, response::Response}; +use surrealdb::sql::{Id, Thing}; pub struct AuthService; @@ -17,6 +20,10 @@ impl AuthService { payload: AuthLoginRequestDto, state: &AppState, ) -> Response { + if let Err((status, message)) = validate_request(&payload) { + return common_response(status, &message); + } + let user_repo = UsersRepository::new(state); let auth_repo = AuthRepository::new(state); @@ -112,6 +119,10 @@ impl AuthService { email: payload.email, password: hashed_password, fullname: payload.fullname, + student_type: payload.student_type, + phone_number: payload.phone_number, + referral_code: payload.referral_code, + referred_by: payload.referred_by, }; let otp = generate_otp::OtpManager::generate_otp(); @@ -124,12 +135,30 @@ impl AuthService { send_email(&new_user.email.clone(), "OTP Verification", &message).unwrap(); + let role_thing = + Thing::from((ResourceEnum::Roles.to_string(), Id::String("".to_string()))); + match user_repo .query_create_user(UsersSchema { + id: Some("".to_string()), email: new_user.email.clone(), fullname: new_user.fullname.clone(), password: new_user.password.clone(), is_active: false, + role_id: "".to_string(), + avatar: Some("".to_string()), + phone_number: new_user.phone_number.clone(), + referral_code: new_user.referral_code.clone(), + referred_by: new_user.referred_by.clone(), + identity_number: Some("".to_string()), + student_type: new_user.student_type.clone(), + religion: Some("".to_string()), + gender: Some("".to_string()), + birthdate: Some("".to_string()), + is_profile_completed: Some(false), + created_at: Some(get_iso_date()), + updated_at: Some(get_iso_date()), + role: role_thing, }) .await { @@ -235,4 +264,23 @@ impl AuthService { Err(err) => common_response(StatusCode::BAD_REQUEST, &err.to_string()), } } + + pub async fn mutation_new_password( + payload: AuthNewPasswordRequestDto, + state: &AppState, + ) -> Response { + let user_repo = UsersRepository::new(state); + let email = extract_email_token(payload.token).unwrap(); + + match user_repo + .query_update_password_user(crate::UsersSetNewPasswordSchema { + email: email.clone(), + password: payload.password.clone(), + }) + .await + { + Ok(msg) => common_response(StatusCode::OK, &msg), + Err(err) => common_response(StatusCode::BAD_REQUEST, &err.to_string()), + } + } } diff --git a/src/apps/v1/auth/mod.rs b/src/apps/v1/auth/mod.rs index c8a538a..4645812 100644 --- a/src/apps/v1/auth/mod.rs +++ b/src/apps/v1/auth/mod.rs @@ -17,4 +17,5 @@ pub fn auth_router() -> Router { .route("/verify", post(auth_controller::post_verify_email)) .route("/resend", post(auth_controller::post_resend_otp)) .route("/forgot", post(auth_controller::post_forgot_password)) + .route("/new-password", post(auth_controller::post_new_password)) } diff --git a/src/apps/v1/docs/docs_controller.rs b/src/apps/v1/docs/docs_controller.rs index 03ed350..25336f4 100644 --- a/src/apps/v1/docs/docs_controller.rs +++ b/src/apps/v1/docs/docs_controller.rs @@ -3,7 +3,8 @@ use crate::{ auth, AuthLoginRequestDto, AuthLoginResponsetDto, AuthResendOtpRequestDto, AuthVerifyEmailRequestDto, }, - MessageResponseDto, MetaRequestDto, MetaResponseDto, ResponseSuccessDto, + AuthNewPasswordRequestDto, MessageResponseDto, MetaRequestDto, MetaResponseDto, + ResponseSuccessDto, }; use utoipa::{ @@ -17,7 +18,8 @@ use utoipa::{ auth::auth_controller::post_login, auth::auth_controller::post_register, auth::auth_controller::post_verify_email, - auth::auth_controller::post_resend_otp + auth::auth_controller::post_resend_otp, + auth::auth_controller::post_new_password ), components( schemas( @@ -28,12 +30,13 @@ use utoipa::{ AuthLoginResponsetDto, AuthVerifyEmailRequestDto, AuthResendOtpRequestDto, + AuthNewPasswordRequestDto, ResponseSuccessDto, ) ), info( - title = "Axum SurrealDB Boilerplate", - description = "Axum SurrealDB Documentation", + title = "NAJM Course API", + description = "NAJM Course API", version = "0.1.0", contact( name = "Maulana Sodiqin", diff --git a/src/apps/v1/users/users_repository.rs b/src/apps/v1/users/users_repository.rs index 73f37ff..a5a7632 100644 --- a/src/apps/v1/users/users_repository.rs +++ b/src/apps/v1/users/users_repository.rs @@ -1,6 +1,7 @@ -use super::{UsersActiveInactiveSchema, UsersSetNewPasswordSchema}; -use crate::{v1::users_schema::UsersSchema, AppState, ResourceEnum}; +use super::{UsersActiveInactiveSchema, UsersSchema, UsersSetNewPasswordSchema}; +use crate::{get_iso_date, AppState, ResourceEnum}; use anyhow::{bail, Result}; +use surrealdb::Uuid; pub struct UsersRepository<'a> { state: &'a AppState, @@ -25,14 +26,31 @@ impl<'a> UsersRepository<'a> { } pub async fn query_create_user(&self, data: UsersSchema) -> Result { + let id = Uuid::new_v4().to_string(); let db = &self.state.surrealdb; + let record: Option = db - .create((ResourceEnum::Users.to_string(), &data.email)) + .create((ResourceEnum::Users.to_string(), &id)) .content(UsersSchema { + id: Some(id.clone()), + role_id: data.role_id.clone(), fullname: data.fullname.clone(), email: data.email.clone(), password: data.password.clone(), - is_active: false, + avatar: data.avatar.clone(), + phone_number: data.phone_number.clone(), + referral_code: data.referral_code.clone(), + referred_by: data.referred_by.clone(), + identity_number: data.identity_number.clone(), + student_type: data.student_type.clone(), + religion: data.religion.clone(), + gender: data.gender.clone(), + birthdate: data.birthdate.clone(), + is_active: data.is_active.clone(), + is_profile_completed: data.is_profile_completed.clone(), + role: data.role.clone(), + created_at: Some(get_iso_date()), + updated_at: Some(get_iso_date()), }) .await?; match record { diff --git a/src/apps/v1/users/users_schema.rs b/src/apps/v1/users/users_schema.rs index eff6635..55612e7 100644 --- a/src/apps/v1/users/users_schema.rs +++ b/src/apps/v1/users/users_schema.rs @@ -1,17 +1,33 @@ use serde::{Deserialize, Serialize}; +use surrealdb::sql::Thing; #[derive(Clone, Debug, Serialize, Deserialize)] pub struct UsersSchema { - pub email: String, + pub id: Option, + pub role_id: String, pub fullname: String, + pub email: String, pub password: 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 student_type: String, + pub religion: Option, + pub gender: Option, + pub birthdate: Option, + pub is_profile_completed: Option, + pub role: Thing, + pub created_at: Option, + pub updated_at: Option, } #[derive(Clone, Debug, Serialize, Deserialize)] pub struct UsersSetNewPasswordSchema { pub email: String, - pub password: bool, + pub password: String, } #[derive(Clone, Debug, Serialize, Deserialize)] diff --git a/src/utils/extract_email.rs b/src/utils/extract_email.rs index d30f074..b84ff15 100644 --- a/src/utils/extract_email.rs +++ b/src/utils/extract_email.rs @@ -7,3 +7,8 @@ pub fn extract_email(headers: &HeaderMap) -> Option { let token_data = decode_access_token(token).ok()?; Some(token_data.claims.sub) } + +pub fn extract_email_token(token: String) -> Option { + let token_data = decode_access_token(&token).ok()?; + Some(token_data.claims.sub) +} diff --git a/src/utils/generate_date.rs b/src/utils/generate_date.rs new file mode 100644 index 0000000..7970532 --- /dev/null +++ b/src/utils/generate_date.rs @@ -0,0 +1,6 @@ +use chrono::{DateTime, Utc}; + +pub fn get_iso_date() -> String { + let now: DateTime = Utc::now(); + now.to_rfc3339() +} diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 3aa445a..4099c5f 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -1,7 +1,11 @@ pub mod extract_email; +pub mod generate_date; pub mod generate_otp; pub mod response_format; +pub mod validator; pub use extract_email::*; +pub use generate_date::*; pub use generate_otp::*; pub use response_format::*; +pub use validator::*; diff --git a/src/utils/validator.rs b/src/utils/validator.rs new file mode 100644 index 0000000..0989b46 --- /dev/null +++ b/src/utils/validator.rs @@ -0,0 +1,28 @@ +use axum::http::StatusCode; +use validator::Validate; + +pub fn validate_request( + payload: &T, +) -> Result<(), (StatusCode, String)> { + if let Err(validation_errors) = payload.validate() { + let error_messages: Vec = validation_errors + .field_errors() + .iter() + .flat_map(|(_, errors)| { + errors.iter().map(move |error| { + format!( + "{}", + error + .message + .clone() + .unwrap_or_else(|| "Invalid value".into()) + ) + }) + }) + .collect(); + + return Err((StatusCode::BAD_REQUEST, error_messages.join(", "))); + } + + Ok(()) +}