Merge pull request #44 from IMPHNEN/feat/mentor

Add comprehensive tests for mentor repository and authentication
This commit is contained in:
Maulana Sodiqin
2025-07-21 22:18:14 +07:00
committed by GitHub
103 changed files with 7851 additions and 1509 deletions
+24 -8
View File
@@ -1,8 +1,24 @@
PORT= RUST_ENV=development
SURREALDB_URL= PORT=4099
SURREALDB_USERNAME= SURREALDB_URL=ws://localhost:8000/rpc
SURREALDB_PASSWORD= SURREALDB_USERNAME=root
SURREALDB_NAMESPACE= SURREALDB_PASSWORD=root
SURREALDB_DBNAME= SURREALDB_NAMESPACE=test
ACCESS_TOKEN_SECRET= SURREALDB_DBNAME=test
REFRESH_TOKEN_SECRET= ACCESS_TOKEN_SECRET=your-access-token-secret-key-here
REFRESH_TOKEN_SECRET=your-refresh-token-secret-key-here
SMTP_EMAIL=your-email@example.com
SMTP_PASSWORD=your-smtp-password
SMTP_NAME="Your App Name"
SMTP_HOST=smtp.gmail.com
REDISDB_URL=localhost
FE_URL=http://localhost
MINIO_ENDPOINT=http://localhost:9000
MINIO_BUCKET_NAME=default_bucket
MINIO_ACCESS_KEY=your-minio-access-key
MINIO_SECRET_KEY=your-minio-secret-key
MAIL_USER=your-email@example.com
MAIL_PASSWORD=your-smtp-password
MAIL_HOST=smtp.gmail.com
MAIL_PORT=465
MAIL_SECURE=true
Generated
+727 -578
View File
File diff suppressed because it is too large Load Diff
+32 -5
View File
@@ -20,28 +20,55 @@ axum = { version = "0.8.4", features = ["multipart"] }
log = "0.4.25" log = "0.4.25"
serde = { version = "1.0.217", features = ["derive"] } serde = { version = "1.0.217", features = ["derive"] }
serde_json = "1.0.138" serde_json = "1.0.138"
tokio = { version = "1.45.0" } tokio = { version = "1.45.0", features = ["full"] }
argon2 = { version = "0.5.3", features = ["password-hash"] } argon2 = { version = "0.5.3", features = ["password-hash"] }
jsonwebtoken = "9.3.1" jsonwebtoken = "9.3.1"
chrono = "0.4.41" chrono = "0.4.41"
utoipa = { version = "5.3.1", features = ["axum_extras"] } utoipa = { version = "5.3.1", features = ["axum_extras"] }
utoipa-swagger-ui = { version = "9.0.0", features = ["axum"] } utoipa-swagger-ui = { version = "9.0.0", features = ["axum"] }
lettre = { version = "0.11.16", features = ["tokio1-native-tls"] } lettre = { version = "0.11.16", features = ["tokio1-native-tls"] }
surrealdb = { version = "2.3.2", features = ["kv-mem"] } surrealdb = { version = "2.3.7", features = ["kv-mem", "kv-fdb"] }
thiserror = "2.0.12" thiserror = "2.0.12"
anyhow = "1.0.98" anyhow = "1.0.98"
rand = { version = "0.9.1", features = ["std", "alloc"] } rand = { version = "0.9.1", features = ["std", "alloc"] }
rand_distr = "0.5.1" rand_distr = "0.5.1"
tower-http = { version = "0.6.4", features = ["cors"] } tower-http = { version = "0.6.4", features = ["cors", "trace"] }
validator = { version = "0.12", features = ["derive"] } http-body-util = "0.1.1"
validator = { version = "0.20.0", features = ["derive"] }
lazy_static = "1.4.0" lazy_static = "1.4.0"
regex = "1.11.1" regex = "1.11.1"
axum-test = "17.2.0" axum-test = "17.2.0"
fancy-regex = "0.14.0" fancy-regex = "0.15.0"
futures = "0.3.31" futures = "0.3.31"
tower = "0.5.2" tower = "0.5.2"
env_logger = "0.11.8" env_logger = "0.11.8"
dotenvy = "0.15.7" dotenvy = "0.15.7"
tracing = "0.1.40"
tracing-subscriber = { version = "0.3.18", features = ["fmt"] }
once_cell = "1.21.3"
uuid = { version = "1.8.0", features = ["v4", "fast-rng", "serde"] }
strum = { version = "0.27.1", features = ["derive"] }
strum_macros = "0.27.1"
hyper = "1.6.0"
hyper-util = "0.1.0"
async-trait = "0.1.75"
tokio-tungstenite = "0.23"
url = "2.5"
futures-util = "0.3"
tests = { path = "./tests" }
imphnen-iam = { path = "./imphnen-iam" }
imphnen-cms = { path = "./imphnen-cms" }
imphnen-libs = { path = "./imphnen-libs" }
imphnen-utils = { path = "./imphnen-utils" }
imphnen-gacha = { path = "./imphnen-gacha" }
imphnen-gateway = { path = "./imphnen-gateway" }
imphnen-backend = { path = "./imphnen-backend" }
imphnen-entities = { path = "./imphnen-entities" }
imphnen-dimentorin = { path = "./imphnen-dimentorin" }
imphnen-middleware = { path = "./imphnen-middleware" }
[profile.release] [profile.release]
lto = "fat" lto = "fat"
codegen-units = 1 codegen-units = 1
+1 -1
View File
@@ -1,4 +1,4 @@
FROM rust:1.85-alpine AS builder FROM rust:1.86-alpine AS builder
RUN apk add --no-cache \ RUN apk add --no-cache \
curl \ curl \
+13 -6
View File
@@ -4,12 +4,13 @@ version = "0.1.0"
edition = "2021" edition = "2021"
[dependencies] [dependencies]
imphnen-libs = { version = "0.1.0", path = "../imphnen-libs" } imphnen-libs.workspace = true
imphnen-utils = { version = "0.1.0", path = "../imphnen-utils" } imphnen-utils.workspace = true
imphnen-gateway = { version = "0.1.0", path = "../imphnen-gateway" } imphnen-gateway.workspace = true
imphnen-entities = { version = "0.1.0", path = "../imphnen-entities" } imphnen-entities.workspace = true
imphnen-iam = { version = "0.1.0", path = "../imphnen-iam" } imphnen-iam.workspace = true
imphnen-cms = { version = "0.1.0", path = "../imphnen-cms" } imphnen-cms.workspace = true
imphnen-dimentorin.workspace = true
axum.workspace = true axum.workspace = true
serde.workspace = true serde.workspace = true
serde_json.workspace = true serde_json.workspace = true
@@ -26,3 +27,9 @@ anyhow.workspace = true
tower-http.workspace = true tower-http.workspace = true
utoipa-swagger-ui.workspace = true utoipa-swagger-ui.workspace = true
env_logger.workspace = true env_logger.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
uuid.workspace=true
tokio-tungstenite.workspace = true
url.workspace = true
futures-util.workspace = true
-1
View File
@@ -1,4 +1,3 @@
use env_logger;
use imphnen_gateway::gateway_service; use imphnen_gateway::gateway_service;
use imphnen_libs::axum_init; use imphnen_libs::axum_init;
+169
View File
@@ -0,0 +1,169 @@
use tokio_tungstenite::{connect_async, tungstenite::protocol::Message};
use url::Url;
use futures_util::{StreamExt, SinkExt};
use serde_json::json;
// Menentukan kredensial dan detail koneksi secara langsung sebagai string statis
static SURREALDB_URL_WS: &str = "ws://localhost:8000/rpc";
static SURREALDB_USERNAME: &str = "root";
static SURREALDB_PASSWORD: &str = "root";
static SURREALDB_NAMESPACE: &str = "test";
static SURREALDB_DBNAME: &str = "test";
// Daftar tabel sebagai variabel static yang tidak dapat diubah
static TABLES_TO_CLEAR: &[&str] = &[
"app_events", "users", "roles", "permissions", "gacha_rolls",
"mentor_users", "gacha_claims", "gacha_credits", "gacha_items",
"mentor_profiles", "roles_permissions", "testimonials",
];
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Tidak perlu memuat env lagi, karena kita menggunakan nilai hardcoded
// imphnen_libs::enviroment::load_env(); // Baris ini tidak lagi dibutuhkan
// let env = Env::new(); // Baris ini tidak lagi dibutuhkan
println!("DEBUG: URL WS: {}", SURREALDB_URL_WS);
println!("DEBUG: Username: {}", SURREALDB_USERNAME);
println!("DEBUG: Namespace: {}", SURREALDB_NAMESPACE);
println!("DEBUG: Database: {}", SURREALDB_DBNAME);
let url = Url::parse(SURREALDB_URL_WS)?; // Menggunakan SURREALDB_URL_WS statis
let (ws_stream, _) = connect_async(url).await?;
let (mut write, mut read) = ws_stream.split();
// Authenticate (signin)
let signin_query = json!({
"method": "signin",
"params": [{
"user": SURREALDB_USERNAME, // Menggunakan SURREALDB_USERNAME statis
"pass": SURREALDB_PASSWORD, // Menggunakan SURREALDB_PASSWORD statis
}],
"id": 1
}).to_string();
println!("DEBUG: Sending signin query: {}", signin_query);
write.send(Message::Text(signin_query)).await?;
let signin_response = read.next().await.ok_or("Failed to read signin response")?;
let signin_response_msg = signin_response?;
let signin_response_str = signin_response_msg.to_text()?;
println!("DEBUG: Signin response: {}", signin_response_str);
if signin_response_str.contains("\"error\":") {
return Err(format!("Signin failed: {}", signin_response_str).into());
}
// Use namespace and database
let use_query = json!({
"method": "use",
"params": [SURREALDB_NAMESPACE, SURREALDB_DBNAME], // Menggunakan NS & DB statis
"id": 2
}).to_string();
println!("DEBUG: Sending use query: {}", use_query);
write.send(Message::Text(use_query)).await?;
let use_response = read.next().await.ok_or("Failed to read use response")?;
let use_response_msg = use_response?;
let use_response_str = use_response_msg.to_text()?;
println!("DEBUG: Use response: {}", use_response_str);
if use_response_str.contains("\"error\":") {
return Err(format!("USE command failed: {}", use_response_str).into());
}
println!("INFO: Attempting to clear database tables via WebSocket...");
let mut all_clear = true;
for (i, table) in TABLES_TO_CLEAR.iter().enumerate() {
let remove_query = format!("REMOVE TABLE {};", table);
let query_json = json!({
"method": "query",
"params": [remove_query],
"id": i + 3
}).to_string();
println!("DEBUG: Attempting REMOVE TABLE {}: {}", table, query_json);
write.send(Message::Text(query_json)).await?;
let response_result = read.next().await.ok_or("Stream ended unexpectedly")?;
match response_result {
Ok(msg) => {
let response_str = msg.to_text()?;
if response_str.contains("\"error\":") {
println!("WARN: Failed to REMOVE TABLE {}: {}. Attempting DELETE type::{}.", table, response_str, table);
let delete_all_query = format!("DELETE FROM {};", table);
let delete_all_json = json!({
"method": "query",
"params": [delete_all_query],
"id": i + 300
}).to_string();
println!("DEBUG: Attempting DELETE {}: {}", table, delete_all_json);
write.send(Message::Text(delete_all_json)).await?;
let delete_response_result = read.next().await.ok_or("Stream ended unexpectedly during DELETE type::")?;
match delete_response_result {
Ok(delete_msg) => {
let delete_response_str = delete_msg.to_text()?;
if delete_response_str.contains("\"error\":") {
println!("ERROR: Failed to DELETE type::{} : {}", table, delete_response_str);
all_clear = false;
} else {
println!("INFO: Successfully DELETED type:: table: {}", table);
}
},
Err(delete_e) => {
println!("ERROR: Error receiving response for DELETE type:: table {}: {}", table, delete_e);
all_clear = false;
}
}
} else {
println!("INFO: Successfully REMOVED TABLE: {}", table);
}
},
Err(e) => {
println!("ERROR: Error receiving response for REMOVE TABLE {}: {}", table, e);
all_clear = false;
}
}
// Check if table is empty after deletion attempt
let select_query = format!("SELECT * FROM {} LIMIT 1;", table);
let select_json = json!({
"method": "query",
"params": [select_query],
"id": i + 1000
}).to_string();
write.send(Message::Text(select_json)).await?;
let select_response_result = read.next().await.ok_or("Stream ended unexpectedly during SELECT check")?;
match select_response_result {
Ok(select_msg) => {
let select_response_str = select_msg.to_text()?;
if select_response_str.contains("does not exist") {
println!("CHECK: Table '{}' does not exist after clear attempt (success).", table);
} else if select_response_str.contains("\"result\":[]") || select_response_str.contains("\"result\":[[]]") {
println!("CHECK: Table '{}' is empty after clear attempt.", table);
} else {
println!("WARNING: Table '{}' is NOT empty after clear attempt! Response: {}", table, select_response_str);
all_clear = false;
}
},
Err(e) => {
println!("ERROR: Error receiving response for SELECT check on table {}: {}", table, e);
all_clear = false;
}
}
}
println!("INFO: Database clearing complete.");
if !all_clear {
eprintln!("ERROR: One or more tables could not be cleared. Check logs for details.");
return Err("Database clearing failed for one or more tables.".into());
}
Ok(())
}
+21 -10
View File
@@ -1,9 +1,9 @@
use imphnen_cms::v1::landing::events::events_schema::EventsSchema; use imphnen_cms::v1::landing::events::events_schema::EventsSchema;
use imphnen_libs::enviroment::load_env;
use imphnen_utils::{get_iso_date, Env}; use imphnen_utils::{get_iso_date, Env};
use std::error::Error; use std::error::Error;
use surrealdb::engine::any; use surrealdb::engine::any;
use surrealdb::{opt::auth::Root, sql::Thing}; use surrealdb::{opt::auth::Root, sql::Thing, Uuid}; // Added Uuid
use imphnen_libs::enviroment::load_env;
#[tokio::main] #[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> { async fn main() -> Result<(), Box<dyn Error>> {
load_env(); load_env();
@@ -20,7 +20,6 @@ async fn main() -> Result<(), Box<dyn Error>> {
let events = vec![ let events = vec![
( (
"e1a2b3c4-5d6e-7f8g-9h0i-1j2k3l4m5n6o",
"Tech Conference 2025", "Tech Conference 2025",
"Annual technology conference featuring the latest innovations in software development, AI, and cloud computing.", "Annual technology conference featuring the latest innovations in software development, AI, and cloud computing.",
"https://techconf2025.example.com", "https://techconf2025.example.com",
@@ -31,7 +30,6 @@ async fn main() -> Result<(), Box<dyn Error>> {
"2025-06-17T18:00:00Z", "2025-06-17T18:00:00Z",
), ),
( (
"f2b3c4d5-6e7f-8g9h-0i1j-2k3l4m5n6o7p",
"Online Web Development Workshop", "Online Web Development Workshop",
"Comprehensive workshop covering modern web development frameworks including React, Vue, and Angular.", "Comprehensive workshop covering modern web development frameworks including React, Vue, and Angular.",
"https://webdev-workshop.example.com", "https://webdev-workshop.example.com",
@@ -42,7 +40,6 @@ async fn main() -> Result<(), Box<dyn Error>> {
"2025-07-10T17:00:00Z", "2025-07-10T17:00:00Z",
), ),
( (
"g3c4d5e6-7f8g-9h0i-1j2k-3l4m5n6o7p8q",
"Startup Pitch Competition", "Startup Pitch Competition",
"Exciting competition where emerging startups present their innovative ideas to a panel of expert judges and investors.", "Exciting competition where emerging startups present their innovative ideas to a panel of expert judges and investors.",
"https://startup-pitch.example.com", "https://startup-pitch.example.com",
@@ -53,7 +50,6 @@ async fn main() -> Result<(), Box<dyn Error>> {
"2025-08-05T16:00:00Z", "2025-08-05T16:00:00Z",
), ),
( (
"h4d5e6f7-8g9h-0i1j-2k3l-4m5n6o7p8q9r",
"Digital Marketing Masterclass", "Digital Marketing Masterclass",
"Learn advanced digital marketing strategies, social media optimization, and data-driven marketing techniques.", "Learn advanced digital marketing strategies, social media optimization, and data-driven marketing techniques.",
"https://digital-marketing.example.com", "https://digital-marketing.example.com",
@@ -65,9 +61,20 @@ async fn main() -> Result<(), Box<dyn Error>> {
), ),
]; ];
for (id, name, description, detail_link, price, location, is_online, start_date, end_date) in events { for (
name,
description,
detail_link,
price,
location,
is_online,
start_date,
end_date,
) in events
{
let uuid = Uuid::new_v4().to_string(); // Generate new UUID
let event = EventsSchema { let event = EventsSchema {
id: Thing::from(("app_events", id)), id: Thing::from(("app_events", uuid.as_str())), // Use generated UUID
name: name.into(), name: name.into(),
description: description.into(), description: description.into(),
detail_link: detail_link.into(), detail_link: detail_link.into(),
@@ -81,11 +88,15 @@ async fn main() -> Result<(), Box<dyn Error>> {
updated_at: get_iso_date(), updated_at: get_iso_date(),
}; };
db.create::<Option<EventsSchema>>(("app_events", id)) db.create::<Option<EventsSchema>>(("app_events", uuid.as_str())) // Use generated UUID
.content(event) .content(event)
.await?; .await?;
println!("✅ Inserted event: {} ({})", name, if is_online { "Online" } else { "In-person" }); println!(
"✅ Inserted event: {} ({})",
name,
if is_online { "Online" } else { "In-person" }
);
} }
println!("✅ All Events seeded"); println!("✅ All Events seeded");
@@ -0,0 +1,55 @@
use imphnen_libs::enviroment::load_env;
use imphnen_utils::{get_iso_date, Env};
use std::error::Error;
use surrealdb::opt::auth::Root;
use surrealdb::sql::Thing;
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
load_env();
let env = Env::new();
use surrealdb::engine::any;
let db = any::connect(&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?;
db.query("DELETE type::thing('app_gacha_items', $id)")
.bind(("id", "gacha_item_test_id"))
.await?;
db.query("DELETE type::thing('app_gacha_rolls', $id)")
.bind(("id", "gacha_roll_test_id"))
.await?;
let gacha_item_id = "gacha_item_test_id";
db.query("CREATE type::thing('app_gacha_items', $id) SET name = $name, image_url = $image_url, is_deleted = $is_deleted, created_at = $created_at, updated_at = $updated_at")
.bind(("id", gacha_item_id))
.bind(("name", "Test Gacha Item"))
.bind(("image_url", "https://example.com/gacha_item.png"))
.bind(("is_deleted", false))
.bind(("created_at", get_iso_date()))
.bind(("updated_at", get_iso_date()))
.await?;
println!("Gacha Item seeded successfully!");
let gacha_roll_id = "gacha_roll_test_id";
db.query("CREATE type::thing('app_gacha_rolls', $id) SET item = $item, quantity = $quantity, weight = $weight, is_deleted = $is_deleted, created_at = $created_at, updated_at = $updated_at")
.bind(("id", gacha_roll_id))
.bind(("item", Thing::from(("app_gacha_items", gacha_item_id))))
.bind(("quantity", 10))
.bind(("weight", 1.0))
.bind(("is_deleted", false))
.bind(("created_at", get_iso_date()))
.bind(("updated_at", get_iso_date()))
.await?;
println!("Gacha Roll seeded successfully!");
println!("✅ Gacha items and rolls seeded.");
Ok(())
}
@@ -0,0 +1,94 @@
use imphnen_libs::enviroment::load_env;
use imphnen_utils::{get_iso_date, hash_password, Env};
use serde_json::json;
use std::error::Error;
use surrealdb::opt::auth::Root;
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
load_env();
let env = Env::new();
use surrealdb::engine::any;
let db = any::connect(&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?;
db.query("DELETE type::thing('app_mentors', $id)")
.bind(("id", "e6f78d23-83bf-5c2b-bcd4-001345678901"))
.await?;
db.query("DELETE type::thing('app_users', $id)")
.bind(("id", "e6f78d23-83bf-5c2b-bcd4-001345678901"))
.await?;
use surrealdb::sql::Thing;
db.query("CREATE type::thing('app_users', $id) SET fullname = $fullname, email = $email, password = $password, avatar = $avatar, phone_number = $phone_number, is_active = $is_active, is_deleted = $is_deleted, mentor_id = $mentor_id, gender = $gender, birthdate = $birthdate, role = $role, created_at = $created_at, updated_at = $updated_at")
.bind(("id", "e6f78d23-83bf-5c2b-bcd4-001345678901"))
.bind(("fullname", "Mentor User"))
.bind(("email", "mentor@example.com"))
.bind(("password", hash_password("password").unwrap()))
.bind(("avatar", Option::<String>::None))
.bind(("phone_number", "081234567890"))
.bind(("is_active", true))
.bind(("is_deleted", false))
.bind(("mentor_id", Option::<Thing>::None))
.bind(("gender", "male"))
.bind(("birthdate", "1990-05-15"))
.bind(("role", Thing::from(("app_roles", "3b9f8c4e-6a2d-4f8a-9a12-2d6f8b3c4e5a"))))
.bind(("created_at", get_iso_date()))
.bind(("updated_at", get_iso_date()))
.await?;
db.query("CREATE type::thing('app_mentors', $id) SET user_id = $user_id, legal_name = $legal_name, identity_document_url = $identity_document_url, phone_for_verification = $phone_for_verification, bio = $bio, linkedin_url = $linkedin_url, github_url = $github_url, cv_url = $cv_url, industries = $industries, expertise = $expertise, languages = $languages, current_company = $current_company, current_role = $current_role, years_of_experience = $years_of_experience, topics_of_interest = $topics_of_interest, preferred_mentee_level = $preferred_mentee_level, preferred_mentoring_formats = $preferred_mentoring_formats, availability_commitment = $availability_commitment, mentoring_rate = $mentoring_rate, status = $status, is_deleted = $is_deleted, created_at = $created_at, updated_at = $updated_at, email = $email")
.bind(("id", "e6f78d23-83bf-5c2b-bcd4-001345678901"))
.bind(("user_id", Thing::from(("app_users", "e6f78d23-83bf-5c2b-bcd4-001345678901"))))
.bind(("legal_name", "Mentor User"))
.bind(("identity_document_url", "https://example.com/ktp.jpg"))
.bind(("phone_for_verification", "081234567890"))
.bind(("bio", "Saya adalah mentor backend Rust dengan pengalaman 5 tahun dalam pengembangan aplikasi backend yang scalable dan performant."))
.bind(("linkedin_url", "https://linkedin.com/in/mentor"))
.bind(("github_url", "https://github.com/mentor"))
.bind(("cv_url", Option::<String>::None))
.bind(("industries", vec!["Software", "Education"]))
.bind(("expertise", vec!["Rust", "Microservices"]))
.bind(("languages", vec!["Indonesian", "English"]))
.bind(("current_company", "PT Contoh"))
.bind(("current_role", "Senior Backend Engineer"))
.bind(("years_of_experience", 5))
.bind(("topics_of_interest", vec!["Rust Programming", "Backend Development"]))
.bind(("preferred_mentee_level", vec!["beginner", "intermediate"]))
.bind(("preferred_mentoring_formats", vec!["online", "offline"]))
.bind(("availability_commitment", "2 jam per minggu untuk mentoring online dan offline"))
.bind(("mentoring_rate", json!({
"amount": 100000,
"currency": "IDR",
"per_duration": "hour"
})))
.bind(("status", "verified"))
.bind(("is_deleted", false))
.bind(("created_at", get_iso_date()))
.bind(("updated_at", get_iso_date()))
.bind(("email", "mentor@example.com"))
.await?;
println!("Mentor created successfully!");
println!("Updating user with mentor_id...");
db.query("UPDATE type::thing('app_users', $id) SET mentor_id = $mentor_id")
.bind(("id", "e6f78d23-83bf-5c2b-bcd4-001345678901"))
.bind((
"mentor_id",
Thing::from(("app_mentors", "e6f78d23-83bf-5c2b-bcd4-001345678901")),
))
.await?;
println!("User updated with mentor_id successfully!");
println!("✅ Inserted mentor user: mentor@example.com");
println!("✅ Mentor user seeded");
Ok(())
}
+10 -1
View File
@@ -48,6 +48,14 @@ async fn main() -> Result<(), Box<dyn Error>> {
PermissionsEnum::ReadDetailGachaRolls, PermissionsEnum::ReadDetailGachaRolls,
PermissionsEnum::CreateGachaRolls, PermissionsEnum::CreateGachaRolls,
PermissionsEnum::ExecuteGachaRolls, PermissionsEnum::ExecuteGachaRolls,
PermissionsEnum::ReadListMentors,
PermissionsEnum::ReadDetailMentors,
PermissionsEnum::RegisterMentors,
PermissionsEnum::ReadOwnMentorProfile,
PermissionsEnum::UpdateOwnMentorProfile,
PermissionsEnum::ReadOwnMentorStatus,
PermissionsEnum::VerifyMentors,
PermissionsEnum::DeleteMentors,
] { ] {
db.query("CREATE type::thing('app_permissions', $id) CONTENT $data") db.query("CREATE type::thing('app_permissions', $id) CONTENT $data")
.bind(("id", permission.id())) .bind(("id", permission.id()))
@@ -61,9 +69,10 @@ async fn main() -> Result<(), Box<dyn Error>> {
}), }),
)) ))
.await?; .await?;
println!("✅ Inserted: {}", permission.to_string()); println!("✅ Inserted: {permission}");
} }
println!("✅ All Permissions seeded"); println!("✅ All Permissions seeded");
Ok(()) Ok(())
} }
+12 -3
View File
@@ -1,9 +1,9 @@
use imphnen_libs::enviroment::load_env;
use imphnen_utils::{get_iso_date, Env}; use imphnen_utils::{get_iso_date, Env};
use serde_json::json; use serde_json::json;
use std::error::Error; use std::error::Error;
use surrealdb::opt::auth::Root;
use imphnen_libs::enviroment::load_env;
use surrealdb::engine::any; use surrealdb::engine::any;
use surrealdb::opt::auth::Root;
#[tokio::main] #[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> { async fn main() -> Result<(), Box<dyn Error>> {
load_env(); load_env();
@@ -49,9 +49,18 @@ async fn main() -> Result<(), Box<dyn Error>> {
None, None,
Some("2025-02-22T15:38:39.868306+00"), Some("2025-02-22T15:38:39.868306+00"),
), ),
(
"3b9f8c4e-6a2d-4f8a-9a12-2d6f8b3c4e5a",
"Mentor",
None,
Some("2025-07-06T10:00:00.000000+00"),
),
]; ];
for (id, name, _created_at, _updated_at) in roles { for (id, name, _created_at, _updated_at) in roles {
db.query("DELETE type::thing('app_roles', $id)")
.bind(("id", id))
.await?;
db.query("CREATE type::thing('app_roles', $id) CONTENT $data") db.query("CREATE type::thing('app_roles', $id) CONTENT $data")
.bind(("id", id)) .bind(("id", id))
.bind(( .bind((
@@ -65,7 +74,7 @@ async fn main() -> Result<(), Box<dyn Error>> {
}), }),
)) ))
.await?; .await?;
println!("✅ Inserted role: {}", name); println!("✅ Inserted role: {name}");
} }
println!("✅ All Roles seeded"); println!("✅ All Roles seeded");
Ok(()) Ok(())
+117 -41
View File
@@ -1,8 +1,9 @@
use imphnen_iam::{get_iso_date, make_thing, Env, PermissionsEnum}; use imphnen_iam::{get_iso_date, make_thing, Env, PermissionsEnum};
use std::error::Error;
use surrealdb::opt::auth::Root;
use surrealdb::engine::any;
use imphnen_libs::enviroment::load_env; use imphnen_libs::enviroment::load_env;
use std::error::Error;
use surrealdb::engine::any;
use surrealdb::opt::auth::Root;
#[tokio::main] #[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> { async fn main() -> Result<(), Box<dyn Error>> {
load_env(); load_env();
@@ -16,43 +17,118 @@ async fn main() -> Result<(), Box<dyn Error>> {
db.use_ns(env.surrealdb_namespace) db.use_ns(env.surrealdb_namespace)
.use_db(env.surrealdb_dbname) .use_db(env.surrealdb_dbname)
.await?; .await?;
let permission_refs_admin: Vec<_> = [
PermissionsEnum::ReadListUsers, let roles_permissions = vec![
PermissionsEnum::ReadDetailUsers, (
PermissionsEnum::CreateUsers, "f6b03f25-e416-4893-ac88-caaa690afb07",
PermissionsEnum::DeleteUsers, vec![
PermissionsEnum::UpdateUsers, PermissionsEnum::ReadListUsers,
PermissionsEnum::ActivateUsers, PermissionsEnum::ReadDetailUsers,
PermissionsEnum::ReadListRoles, PermissionsEnum::CreateUsers,
PermissionsEnum::ReadDetailRoles, PermissionsEnum::DeleteUsers,
PermissionsEnum::CreateRoles, PermissionsEnum::UpdateUsers,
PermissionsEnum::DeleteRoles, PermissionsEnum::ActivateUsers,
PermissionsEnum::UpdateRoles, PermissionsEnum::ReadListRoles,
PermissionsEnum::ReadListPermissions, PermissionsEnum::ReadDetailRoles,
PermissionsEnum::ReadDetailPermissions, PermissionsEnum::CreateRoles,
PermissionsEnum::CreatePermissions, PermissionsEnum::DeleteRoles,
PermissionsEnum::DeletePermissions, PermissionsEnum::UpdateRoles,
PermissionsEnum::UpdatePermissions, PermissionsEnum::ReadListPermissions,
PermissionsEnum::CreateGachaClaims, PermissionsEnum::ReadDetailPermissions,
PermissionsEnum::ReadDetailGachaClaims, PermissionsEnum::CreatePermissions,
PermissionsEnum::ReadListGachaItems, PermissionsEnum::DeletePermissions,
PermissionsEnum::ReadDetailGachaItems, PermissionsEnum::UpdatePermissions,
PermissionsEnum::CreateGachaItems, PermissionsEnum::CreateGachaClaims,
PermissionsEnum::DeleteGachaItems, PermissionsEnum::ReadDetailGachaClaims,
PermissionsEnum::UpdateGachaItems, PermissionsEnum::ReadListGachaItems,
PermissionsEnum::ReadDetailGachaRolls, PermissionsEnum::ReadDetailGachaItems,
PermissionsEnum::CreateGachaRolls, PermissionsEnum::CreateGachaItems,
PermissionsEnum::ExecuteGachaRolls, PermissionsEnum::DeleteGachaItems,
] PermissionsEnum::UpdateGachaItems,
.iter() PermissionsEnum::ReadDetailGachaRolls,
.map(|perm| make_thing("app_permissions", perm.id())) PermissionsEnum::CreateGachaRolls,
.collect(); PermissionsEnum::ExecuteGachaRolls,
let admin_role_id = "f6b03f25-e416-4893-ac88-caaa690afb07"; PermissionsEnum::ReadListMentors,
db.query("UPDATE type::thing('app_roles', $role_id) SET permissions = $permissions, updated_at = $updated_at WHERE is_deleted = false") PermissionsEnum::ReadDetailMentors,
.bind(("role_id", admin_role_id)) PermissionsEnum::RegisterMentors,
.bind(("permissions", permission_refs_admin)) PermissionsEnum::UpdateMentors,
.bind(("updated_at", get_iso_date())) PermissionsEnum::VerifyMentors,
.await?; PermissionsEnum::DeleteMentors,
println!("✅ All permissions successfully added to Admin role"); ],
),
(
"3b9f8c4e-6a2d-4f8a-9a12-2d6f8b3c4e5a",
vec![
PermissionsEnum::ReadOwnMentorProfile,
PermissionsEnum::UpdateOwnMentorProfile,
PermissionsEnum::ReadOwnMentorStatus,
PermissionsEnum::ReadListMentors,
PermissionsEnum::ReadDetailMentors,
PermissionsEnum::ReadListGachaItems,
PermissionsEnum::ReadDetailGachaItems,
PermissionsEnum::ReadDetailGachaRolls,
PermissionsEnum::CreateGachaRolls,
PermissionsEnum::ExecuteGachaRolls,
],
),
(
"5713cb37-dc02-4e87-8048-d7a41d352059",
vec![
PermissionsEnum::ReadListGachaItems,
PermissionsEnum::ReadDetailGachaItems,
PermissionsEnum::CreateGachaClaims,
PermissionsEnum::ReadDetailGachaClaims,
PermissionsEnum::ReadDetailGachaRolls,
PermissionsEnum::CreateGachaRolls,
PermissionsEnum::ExecuteGachaRolls,
PermissionsEnum::RegisterMentors,
PermissionsEnum::ReadListMentors,
PermissionsEnum::ReadDetailMentors,
PermissionsEnum::ReadOwnMentorProfile,
PermissionsEnum::ReadOwnMentorStatus,
],
),
(
"50133429-f4b1-4249-9f97-7b86e6ee9d86",
vec![
PermissionsEnum::ReadListUsers,
PermissionsEnum::ReadListMentors,
PermissionsEnum::ReadDetailUsers,
PermissionsEnum::ActivateUsers,
PermissionsEnum::ReadListRoles,
PermissionsEnum::ReadDetailRoles,
PermissionsEnum::ReadListPermissions,
PermissionsEnum::ReadDetailPermissions,
PermissionsEnum::ReadListGachaItems,
PermissionsEnum::ReadDetailGachaItems,
PermissionsEnum::ReadListMentors,
PermissionsEnum::ReadDetailMentors,
PermissionsEnum::ReadDetailGachaRolls,
PermissionsEnum::CreateGachaRolls,
PermissionsEnum::ExecuteGachaRolls,
],
),
(
"60f1aeb7-dad2-4e06-bcb5-be1ba510c906",
vec![PermissionsEnum::ActivateUsers],
),
("6d4fea5d-4a08-4b8a-9782-f2ab2183dcf0", vec![]),
];
for (role_id, permissions) in roles_permissions {
let permission_refs: Vec<_> = permissions
.iter()
.map(|perm| make_thing("app_permissions", perm.id()))
.collect();
db.query("UPDATE type::thing('app_roles', $role_id) SET permissions = $permissions, updated_at = $updated_at WHERE is_deleted = false")
.bind(("role_id", role_id))
.bind(("permissions", permission_refs))
.bind(("updated_at", get_iso_date()))
.await?;
println!("✅ Permissions updated for role: {role_id}");
}
println!("✅ All roles permissions updated!");
Ok(()) Ok(())
} }
+7 -1
View File
@@ -2,6 +2,7 @@ use imphnen_iam::UsersSchema;
use imphnen_libs::enviroment::load_env; use imphnen_libs::enviroment::load_env;
use imphnen_utils::{get_iso_date, hash_password, Env}; use imphnen_utils::{get_iso_date, hash_password, Env};
use std::error::Error; use std::error::Error;
use surrealdb::{opt::auth::Root, sql::Thing}; use surrealdb::{opt::auth::Root, sql::Thing};
#[tokio::main] #[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> { async fn main() -> Result<(), Box<dyn Error>> {
@@ -40,6 +41,10 @@ async fn main() -> Result<(), Box<dyn Error>> {
]; ];
for (id, email, fullname, role_id) in users { for (id, email, fullname, role_id) in users {
db.query("DELETE type::thing('app_users', $id)")
.bind(("id", id))
.await?;
let user = UsersSchema { let user = UsersSchema {
id: Thing::from(("app_users", id)), id: Thing::from(("app_users", id)),
fullname: fullname.into(), fullname: fullname.into(),
@@ -49,6 +54,7 @@ async fn main() -> Result<(), Box<dyn Error>> {
phone_number: "081234567890".into(), phone_number: "081234567890".into(),
is_active: true, is_active: true,
is_deleted: false, is_deleted: false,
mentor_id: None,
gender: None, gender: None,
birthdate: None, birthdate: None,
role: Thing::from(("app_roles", role_id)), role: Thing::from(("app_roles", role_id)),
@@ -60,7 +66,7 @@ async fn main() -> Result<(), Box<dyn Error>> {
.content(user) .content(user)
.await?; .await?;
println!("✅ Inserted user: {} ({})", fullname, email); println!("✅ Inserted user: {fullname} ({email})");
} }
println!("✅ All Users seeded"); println!("✅ All Users seeded");
+2 -1
View File
@@ -20,7 +20,8 @@ fn main() -> Result<(), Box<dyn Error>> {
run_seed("seed_roles_permissions")?; run_seed("seed_roles_permissions")?;
run_seed("seed_users")?; run_seed("seed_users")?;
run_seed("seed_events")?; run_seed("seed_events")?;
run_seed("seed_gacha_rolls")?;
run_seed("seed_mentor_user")?;
println!("\n✅ All seeding completed successfully."); println!("\n✅ All seeding completed successfully.");
Ok(()) Ok(())
} }
+2
View File
@@ -3,6 +3,8 @@ use imphnen_libs::axum_init;
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
tracing_subscriber::fmt::init();
axum_init(|surrealdb_ws, surrealdb_mem| async { axum_init(|surrealdb_ws, surrealdb_mem| async {
gateway_service(surrealdb_ws, surrealdb_mem).await gateway_service(surrealdb_ws, surrealdb_mem).await
}) })
+9 -4
View File
@@ -4,10 +4,10 @@ version = "0.1.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
imphnen-iam = { version = "0.1.0", path = "../imphnen-iam" } imphnen-iam.workspace = true
imphnen-libs = { version = "0.1.0", path = "../imphnen-libs" } imphnen-libs.workspace = true
imphnen-utils = { version = "0.1.0", path = "../imphnen-utils" } imphnen-utils.workspace = true
imphnen-entities = { version = "0.1.0", path = "../imphnen-entities" } imphnen-entities.workspace = true
axum.workspace = true axum.workspace = true
serde.workspace = true serde.workspace = true
serde_json.workspace = true serde_json.workspace = true
@@ -23,3 +23,8 @@ chrono.workspace = true
anyhow.workspace = true anyhow.workspace = true
tower-http.workspace = true tower-http.workspace = true
utoipa-swagger-ui.workspace = true utoipa-swagger-ui.workspace = true
log.workspace = true
tracing.workspace = true
[package.metadata.validator.regex]
VALID_URL_REGEX = "^https?://"
+11 -14
View File
@@ -1,16 +1,14 @@
use chrono::{DateTime, Utc};
use lazy_static::lazy_static; use lazy_static::lazy_static;
use regex::Regex; lazy_static! {
pub static ref VALID_URL_REGEX: regex::Regex =
regex::Regex::new(r"^https?://").unwrap();
}
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use surrealdb::sql::Thing; use surrealdb::sql::Thing;
use utoipa::ToSchema; use utoipa::ToSchema;
use validator::Validate; use validator::Validate;
// Lazy static regex for URL validation
lazy_static! {
static ref VALID_URL_REGEX: Regex = Regex::new(r"^https?://").unwrap();
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)] #[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)]
pub struct EventsCreateRequestDto { pub struct EventsCreateRequestDto {
#[validate(length(min = 1, message = "Name is required"))] #[validate(length(min = 1, message = "Name is required"))]
@@ -19,13 +17,10 @@ pub struct EventsCreateRequestDto {
#[validate(length(min = 1, message = "Description is required"))] #[validate(length(min = 1, message = "Description is required"))]
pub description: String, pub description: String,
#[validate(regex( #[validate(url(message = "Detail link must be a valid URL"))]
path = "VALID_URL_REGEX",
message = "Detail link must be a valid URL"
))]
pub detail_link: String, pub detail_link: String,
#[validate(range(min = 0, message = "Price cannot be negative"))] #[validate(range(min = 0.0, message = "Price cannot be negative"))]
pub price: f64, pub price: f64,
#[schema(example = "2025-09-20T13:00:00Z", value_type = String)] #[schema(example = "2025-09-20T13:00:00Z", value_type = String)]
@@ -42,16 +37,18 @@ pub struct EventsCreateRequestDto {
pub struct EventsUpdateRequestDto { pub struct EventsUpdateRequestDto {
#[validate(length(min = 1, message = "Name is required"))] #[validate(length(min = 1, message = "Name is required"))]
pub name: String, pub name: String,
#[schema(example = "2025-09-20T13:00:00Z", value_type = String)] #[schema(example = "2025-09-20T13:00:00Z", value_type = String)]
pub end_date: DateTime<Utc>, pub end_date: DateTime<Utc>,
#[schema(example = "2025-09-20T13:00:00Z", value_type = String)] #[schema(example = "2025-09-20T13:00:00Z", value_type = String)]
pub start_date: DateTime<Utc>, pub start_date: DateTime<Utc>,
#[validate(range(min = 0.0, message = "Price cannot be negative"))]
pub price: f64, pub price: f64,
pub is_online: bool, pub is_online: bool,
pub description: String, pub description: String,
#[validate(url(message = "Detail link must be a valid URL"))]
pub detail_link: String, pub detail_link: String,
pub location: Option<String>, pub location: Option<String>,
} }
@@ -2,6 +2,8 @@ use super::{events_dto::EventsQueryDto, events_schema::EventsSchema};
use anyhow::{Result, bail}; use anyhow::{Result, bail};
use imphnen_libs::{AppState, MetaRequestDto, ResourceEnum, ResponseListSuccessDto}; use imphnen_libs::{AppState, MetaRequestDto, ResourceEnum, ResponseListSuccessDto};
use imphnen_utils::{DetailQueryBuilder, ListQueryBuilder, get_id, get_iso_date}; use imphnen_utils::{DetailQueryBuilder, ListQueryBuilder, get_id, get_iso_date};
use std::time::Instant;
use tracing::instrument;
pub struct EventsRepository<'a> { pub struct EventsRepository<'a> {
state: &'a AppState, state: &'a AppState,
@@ -12,17 +14,25 @@ impl<'a> EventsRepository<'a> {
Self { state } Self { state }
} }
#[instrument(skip(self, meta), err)]
pub async fn query_event_list( pub async fn query_event_list(
&self, &self,
meta: MetaRequestDto, meta: MetaRequestDto,
) -> Result<ResponseListSuccessDto<Vec<EventsQueryDto>>> { ) -> Result<ResponseListSuccessDto<Vec<EventsQueryDto>>> {
let query = ListQueryBuilder::new(&ResourceEnum::Events.to_string()) let now = Instant::now();
let query = ListQueryBuilder::new(ResourceEnum::Events.to_string())
.with_select_fields(vec!["*"]) .with_select_fields(vec!["*"])
.with_pagination(meta.page, Some(10)) .with_pagination(meta.page, Some(10))
.with_sorting(meta.sort_by.as_deref(), meta.order.as_deref()) .with_sorting(meta.sort_by.as_deref(), meta.order.as_deref())
.build(); .build();
let res: Vec<EventsQueryDto> = let res: Vec<EventsQueryDto> =
self.state.surrealdb_ws.query(query).await?.take(0)?; self.state.surrealdb_ws.query(query).await?.take(0)?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_event_list' took: {elapsed:.2?}");
}
let data = ResponseListSuccessDto { let data = ResponseListSuccessDto {
data: res, data: res,
meta: None, meta: None,
@@ -30,8 +40,9 @@ impl<'a> EventsRepository<'a> {
Ok(data) Ok(data)
} }
// Get event by ID #[instrument(skip(self, id), err)]
pub async fn query_event_by_id(&self, id: String) -> Result<EventsQueryDto> { pub async fn query_event_by_id(&self, id: String) -> Result<EventsQueryDto> {
let now = Instant::now();
let db = &self.state.surrealdb_ws; let db = &self.state.surrealdb_ws;
let builder = DetailQueryBuilder::new(ResourceEnum::Events.to_string()) let builder = DetailQueryBuilder::new(ResourceEnum::Events.to_string())
.with_id(&id) .with_id(&id)
@@ -39,6 +50,12 @@ impl<'a> EventsRepository<'a> {
let sql = builder.build(); let sql = builder.build();
let result: Option<EventsQueryDto> = let result: Option<EventsQueryDto> =
builder.apply_bindings(db.query(sql)).await?.take(0)?; builder.apply_bindings(db.query(sql)).await?.take(0)?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_event_by_id' took: {elapsed:.2?}");
}
match result { match result {
Some(event) => { Some(event) => {
@@ -51,13 +68,20 @@ impl<'a> EventsRepository<'a> {
} }
} }
// Create new event #[instrument(skip(self, data), err)]
pub async fn query_create_event(&self, data: EventsSchema) -> Result<String> { pub async fn query_create_event(&self, data: EventsSchema) -> Result<String> {
let now = Instant::now();
let db = &self.state.surrealdb_ws; let db = &self.state.surrealdb_ws;
let record: Option<EventsSchema> = db let record: Option<EventsSchema> = db
.create(ResourceEnum::Events.to_string()) .create(ResourceEnum::Events.to_string())
.content(data) .content(data)
.await?; .await?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_create_event' took: {elapsed:.2?}");
}
match record { match record {
Some(_) => Ok("Success create event".into()), Some(_) => Ok("Success create event".into()),
@@ -65,17 +89,16 @@ impl<'a> EventsRepository<'a> {
} }
} }
// Update existing event #[instrument(skip(self, data), err)]
pub async fn query_update_event(&self, data: EventsSchema) -> Result<String> { pub async fn query_update_event(&self, data: EventsSchema) -> Result<String> {
let now = Instant::now();
let db = &self.state.surrealdb_ws; let db = &self.state.surrealdb_ws;
// Cek apakah event ada
let existing = self.query_event_by_id(data.id.id.to_raw()).await?; let existing = self.query_event_by_id(data.id.id.to_raw()).await?;
if existing.is_deleted { if existing.is_deleted {
bail!("Event already deleted"); bail!("Event already deleted");
} }
// Merge field tertentu jika diperlukan
let merged = EventsSchema { let merged = EventsSchema {
created_at: existing.created_at, created_at: existing.created_at,
updated_at: get_iso_date(), updated_at: get_iso_date(),
@@ -84,6 +107,12 @@ impl<'a> EventsRepository<'a> {
let record_key = get_id(&merged.id)?; let record_key = get_id(&merged.id)?;
let record: Option<EventsSchema> = db.update(record_key).merge(merged).await?; let record: Option<EventsSchema> = db.update(record_key).merge(merged).await?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_update_event' took: {elapsed:.2?}");
}
match record { match record {
Some(_) => Ok("Success update event".into()), Some(_) => Ok("Success update event".into()),
@@ -91,8 +120,9 @@ impl<'a> EventsRepository<'a> {
} }
} }
// Soft delete event (mark is_deleted = true) #[instrument(skip(self, id), err)]
pub async fn query_delete_event(&self, id: String) -> Result<String> { pub async fn query_delete_event(&self, id: String) -> Result<String> {
let now = Instant::now();
let db = &self.state.surrealdb_ws; let db = &self.state.surrealdb_ws;
let event = self.query_event_by_id(id).await?; let event = self.query_event_by_id(id).await?;
if event.is_deleted { if event.is_deleted {
@@ -104,6 +134,12 @@ impl<'a> EventsRepository<'a> {
.update(record_key) .update(record_key)
.merge(serde_json::json!({ "is_deleted": true })) .merge(serde_json::json!({ "is_deleted": true }))
.await?; .await?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_delete_event' took: {elapsed:.2?}");
}
match record { match record {
Some(_) => Ok("Success delete event".into()), Some(_) => Ok("Success delete event".into()),
@@ -1,10 +1,12 @@
use imphnen_libs::ResourceEnum; use imphnen_libs::ResourceEnum;
use imphnen_utils::{get_iso_date, make_thing};
use serde::{Deserialize, Serialize};
use surrealdb::Uuid; use surrealdb::Uuid;
use surrealdb::sql::Thing; use surrealdb::sql::Thing;
use serde::{Deserialize, Serialize};
use imphnen_utils::{get_iso_date, make_thing};
use super::events_dto::{EventsCreateRequestDto, EventsQueryDto, EventsUpdateRequestDto}; use super::events_dto::{
EventsCreateRequestDto, EventsQueryDto, EventsUpdateRequestDto,
};
#[derive(Clone, Debug, Serialize, Deserialize)] #[derive(Clone, Debug, Serialize, Deserialize)]
pub struct EventsSchema { pub struct EventsSchema {
@@ -1,86 +1,101 @@
use super::{ use super::{
events_dto::{EventsCreateRequestDto, EventsDetailItemDto, EventsListItemDto, EventsQueryDto, EventsUpdateRequestDto}, events_dto::{
events_repository::EventsRepository, EventsCreateRequestDto, EventsDetailItemDto, EventsListItemDto, EventsQueryDto,
events_schema::EventsSchema, EventsUpdateRequestDto,
},
events_repository::EventsRepository,
events_schema::EventsSchema,
}; };
use imphnen_libs::{AppState, MetaRequestDto, ResponseListSuccessDto, ResponseSuccessDto};
use imphnen_utils::{common_response, success_list_response, success_response, validate_request};
use axum::{http::StatusCode, response::Response}; use axum::{http::StatusCode, response::Response};
use imphnen_libs::{
AppState, MetaRequestDto, ResponseListSuccessDto, ResponseSuccessDto,
};
use imphnen_utils::{
common_response, success_list_response, success_response, validate_request,
};
pub struct EventsService; pub struct EventsService;
impl EventsService { impl EventsService {
pub async fn get_event_list(state: &AppState, meta: MetaRequestDto) -> Response { pub async fn get_event_list(state: &AppState, meta: MetaRequestDto) -> Response {
let repo = EventsRepository::new(state); let repo = EventsRepository::new(state);
match repo.query_event_list(meta).await { match repo.query_event_list(meta).await {
Ok(data) => { Ok(data) => {
let items: Vec<EventsListItemDto> = data.data let items: Vec<EventsListItemDto> = data
.into_iter() .data
.filter(|e| !e.is_deleted) .into_iter()
.map(EventsQueryDto::from) .filter(|e| !e.is_deleted)
.collect(); .map(EventsQueryDto::from)
let response = ResponseListSuccessDto { .collect();
data: items, let response = ResponseListSuccessDto {
meta: data.meta, data: items,
}; meta: data.meta,
success_list_response(response) };
} success_list_response(response)
Err(e) => common_response(StatusCode::BAD_REQUEST, &e.to_string()), }
} Err(e) => common_response(StatusCode::BAD_REQUEST, &e.to_string()),
} }
}
pub async fn get_event_by_id(state: &AppState, id: String) -> Response { pub async fn get_event_by_id(state: &AppState, id: String) -> Response {
let repo = EventsRepository::new(state); let repo = EventsRepository::new(state);
match repo.query_event_by_id(id).await { match repo.query_event_by_id(id).await {
Ok(event) if !event.is_deleted => success_response(ResponseSuccessDto { Ok(event) if !event.is_deleted => success_response(ResponseSuccessDto {
data: EventsDetailItemDto { data: EventsDetailItemDto {
id: event.id.id.to_raw(), id: event.id.id.to_raw(),
name: event.name, name: event.name,
description: event.description, description: event.description,
detail_link: event.detail_link, detail_link: event.detail_link,
price: event.price, price: event.price,
is_online: event.is_online, is_online: event.is_online,
start_date: event.start_date, start_date: event.start_date,
end_date: event.end_date, end_date: event.end_date,
created_at: event.created_at, created_at: event.created_at,
updated_at: event.updated_at, updated_at: event.updated_at,
location: event.location, location: event.location,
}, },
}), }),
Ok(_) => common_response(StatusCode::NOT_FOUND, "Event not found"), Ok(_) => common_response(StatusCode::NOT_FOUND, "Event not found"),
Err(e) => common_response(StatusCode::NOT_FOUND, &e.to_string()), Err(e) => common_response(StatusCode::NOT_FOUND, &e.to_string()),
} }
} }
pub async fn create_event(state: &AppState, payload: EventsCreateRequestDto) -> Response { pub async fn create_event(
if let Err((status, message)) = validate_request(&payload) { state: &AppState,
return common_response(status, &message); payload: EventsCreateRequestDto,
} ) -> Response {
let repo = EventsRepository::new(state); if let Err((status, message)) = validate_request(&payload) {
let schema = EventsSchema::create(payload); return common_response(status, &message);
match repo.query_create_event(schema).await { }
Ok(msg) => common_response(StatusCode::CREATED, &msg), let repo = EventsRepository::new(state);
Err(e) => common_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()), let schema = EventsSchema::create(payload);
} match repo.query_create_event(schema).await {
} Ok(msg) => common_response(StatusCode::CREATED, &msg),
Err(e) => common_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
}
}
pub async fn update_event(state: &AppState, id: String, payload: EventsUpdateRequestDto) -> Response { pub async fn update_event(
if let Err((status, message)) = validate_request(&payload) { state: &AppState,
return common_response(status, &message); id: String,
} payload: EventsUpdateRequestDto,
let repo = EventsRepository::new(state); ) -> Response {
let schema = EventsSchema::update(payload, id); if let Err((status, message)) = validate_request(&payload) {
match repo.query_update_event(schema).await { return common_response(status, &message);
Ok(msg) => common_response(StatusCode::OK, &msg), }
Err(e) => common_response(StatusCode::BAD_REQUEST, &e.to_string()), let repo = EventsRepository::new(state);
} let schema = EventsSchema::update(payload, id);
} match repo.query_update_event(schema).await {
Ok(msg) => common_response(StatusCode::OK, &msg),
Err(e) => common_response(StatusCode::BAD_REQUEST, &e.to_string()),
}
}
pub async fn delete_event(state: &AppState, id: String) -> Response { pub async fn delete_event(state: &AppState, id: String) -> Response {
let repo = EventsRepository::new(state); let repo = EventsRepository::new(state);
match repo.query_delete_event(id).await { match repo.query_delete_event(id).await {
Ok(msg) => common_response(StatusCode::OK, &msg), Ok(msg) => common_response(StatusCode::OK, &msg),
Err(e) => common_response(StatusCode::BAD_REQUEST, &e.to_string()), Err(e) => common_response(StatusCode::BAD_REQUEST, &e.to_string()),
} }
} }
} }
@@ -73,7 +73,6 @@ pub async fn post_create_testimonial(
Extension(authenticated_user): Extension<UsersDetailQueryDto>, Extension(authenticated_user): Extension<UsersDetailQueryDto>,
Json(payload): Json<TestimonialsCreateRequestDto>, Json(payload): Json<TestimonialsCreateRequestDto>,
) -> impl IntoResponse { ) -> impl IntoResponse {
println!("Authenticated User Now: {:?}", authenticated_user);
TestimonialsService::create_testimonial(&state, payload, &authenticated_user).await TestimonialsService::create_testimonial(&state, payload, &authenticated_user).await
} }
@@ -34,7 +34,7 @@ pub struct TestimonialsUpdateRequestDto {
pub struct TestimonialsListItemDto { pub struct TestimonialsListItemDto {
pub id: String, pub id: String,
pub user_id: String, pub user_id: String,
pub user_fullname: String, // Assuming we'll fetch user's full name pub user_fullname: String,
pub role: String, pub role: String,
pub content: String, pub content: String,
pub created_at: String, pub created_at: String,
@@ -45,7 +45,7 @@ pub struct TestimonialsListItemDto {
pub struct TestimonialsDetailItemDto { pub struct TestimonialsDetailItemDto {
pub id: String, pub id: String,
pub user_id: String, pub user_id: String,
pub user_fullname: String, // Assuming we'll fetch user's full name pub user_fullname: String,
pub role: String, pub role: String,
pub content: String, pub content: String,
pub created_at: String, pub created_at: String,
@@ -55,7 +55,7 @@ pub struct TestimonialsDetailItemDto {
#[derive(Clone, Debug, Serialize, Deserialize)] #[derive(Clone, Debug, Serialize, Deserialize)]
pub struct TestimonialsQueryDto { pub struct TestimonialsQueryDto {
pub id: Thing, pub id: Thing,
pub user: UsersSchema, // Change from Thing to UsersSchema pub user: UsersSchema,
pub role: String, pub role: String,
pub content: String, pub content: String,
pub is_deleted: bool, pub is_deleted: bool,
@@ -68,7 +68,7 @@ impl TestimonialsQueryDto {
TestimonialsListItemDto { TestimonialsListItemDto {
id: self.id.id.to_raw(), id: self.id.id.to_raw(),
user_id: self.user.id.id.to_raw(), user_id: self.user.id.id.to_raw(),
user_fullname: self.user.fullname, // Extract fullname from UsersSchema user_fullname: self.user.fullname,
role: self.role, role: self.role,
content: self.content, content: self.content,
created_at: self.created_at, created_at: self.created_at,
@@ -4,6 +4,9 @@ use super::{
use anyhow::{Result, bail}; use anyhow::{Result, bail};
use imphnen_libs::{AppState, MetaRequestDto, ResourceEnum, ResponseListSuccessDto}; use imphnen_libs::{AppState, MetaRequestDto, ResourceEnum, ResponseListSuccessDto};
use imphnen_utils::{DetailQueryBuilder, ListQueryBuilder, get_id, get_iso_date}; use imphnen_utils::{DetailQueryBuilder, ListQueryBuilder, get_id, get_iso_date};
use serde_json;
use std::time::Instant;
use tracing::instrument;
pub struct TestimonialsRepository<'a> { pub struct TestimonialsRepository<'a> {
state: &'a AppState, state: &'a AppState,
@@ -14,17 +17,25 @@ impl<'a> TestimonialsRepository<'a> {
Self { state } Self { state }
} }
#[instrument(skip(self, meta), err)]
pub async fn query_testimonial_list( pub async fn query_testimonial_list(
&self, &self,
meta: MetaRequestDto, meta: MetaRequestDto,
) -> Result<ResponseListSuccessDto<Vec<TestimonialsQueryDto>>> { ) -> Result<ResponseListSuccessDto<Vec<TestimonialsQueryDto>>> {
let query = ListQueryBuilder::new(&ResourceEnum::Testimonials.to_string()) let now = Instant::now();
.with_select_fields(vec!["*", "user.* as user"]) // Select user details let query = ListQueryBuilder::new(ResourceEnum::Testimonials.to_string())
.with_select_fields(vec!["*", "user.* as user"])
.with_pagination(meta.page, Some(10)) .with_pagination(meta.page, Some(10))
.with_sorting(meta.sort_by.as_deref(), meta.order.as_deref()) .with_sorting(meta.sort_by.as_deref(), meta.order.as_deref())
.build(); .build();
let res: Vec<TestimonialsQueryDto> = let res: Vec<TestimonialsQueryDto> =
self.state.surrealdb_ws.query(query).await?.take(0)?; self.state.surrealdb_ws.query(query).await?.take(0)?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_testimonial_list' took: {elapsed:.2?}");
}
let data = ResponseListSuccessDto { let data = ResponseListSuccessDto {
data: res, data: res,
meta: None, meta: None,
@@ -32,17 +43,26 @@ impl<'a> TestimonialsRepository<'a> {
Ok(data) Ok(data)
} }
#[instrument(skip(self, id), err)]
pub async fn query_testimonial_by_id( pub async fn query_testimonial_by_id(
&self, &self,
id: String, id: String,
) -> Result<TestimonialsQueryDto> { ) -> Result<TestimonialsQueryDto> {
let now = Instant::now();
let db = &self.state.surrealdb_ws; let db = &self.state.surrealdb_ws;
let builder = DetailQueryBuilder::new(ResourceEnum::Testimonials.to_string()) let builder = DetailQueryBuilder::new(ResourceEnum::Testimonials.to_string())
.with_id(&id) .with_id(&id)
.with_select_fields(vec!["*", "user.* as user"]); // Select user details .with_condition("is_deleted = false")
.with_select_fields(vec!["*", "user.* as user"]);
let sql = builder.build(); let sql = builder.build();
let result: Option<TestimonialsQueryDto> = let result: Option<TestimonialsQueryDto> =
builder.apply_bindings(db.query(sql)).await?.take(0)?; builder.apply_bindings(db.query(sql)).await?.take(0)?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_testimonial_by_id' took: {elapsed:.2?}");
}
match result { match result {
Some(testimonial) => { Some(testimonial) => {
@@ -55,15 +75,23 @@ impl<'a> TestimonialsRepository<'a> {
} }
} }
#[instrument(skip(self, data), err)]
pub async fn query_create_testimonial( pub async fn query_create_testimonial(
&self, &self,
data: TestimonialsSchema, data: TestimonialsSchema,
) -> Result<String> { ) -> Result<String> {
let now = Instant::now();
let db = &self.state.surrealdb_ws; let db = &self.state.surrealdb_ws;
let record: Option<TestimonialsSchema> = db let record: Option<TestimonialsSchema> = db
.create(ResourceEnum::Testimonials.to_string()) .create(ResourceEnum::Testimonials.to_string())
.content(data) .content(data)
.await?; .await?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_create_testimonial' took: {elapsed:.2?}");
}
match record { match record {
Some(_) => Ok("Success create testimonial".into()), Some(_) => Ok("Success create testimonial".into()),
@@ -71,10 +99,12 @@ impl<'a> TestimonialsRepository<'a> {
} }
} }
#[instrument(skip(self, data), err)]
pub async fn query_update_testimonial( pub async fn query_update_testimonial(
&self, &self,
data: TestimonialsSchema, data: TestimonialsSchema,
) -> Result<String> { ) -> Result<String> {
let now = Instant::now();
let db = &self.state.surrealdb_ws; let db = &self.state.surrealdb_ws;
let existing = self.query_testimonial_by_id(data.id.id.to_raw()).await?; let existing = self.query_testimonial_by_id(data.id.id.to_raw()).await?;
@@ -85,13 +115,19 @@ impl<'a> TestimonialsRepository<'a> {
let merged = TestimonialsSchema { let merged = TestimonialsSchema {
created_at: existing.created_at, created_at: existing.created_at,
updated_at: get_iso_date(), updated_at: get_iso_date(),
user: existing.user.id, // Preserve user ID user: existing.user.id,
..data ..data
}; };
let record_key = get_id(&merged.id)?; let record_key = get_id(&merged.id)?;
let record: Option<TestimonialsSchema> = let record: Option<TestimonialsSchema> =
db.update(record_key).merge(merged).await?; db.update(record_key).merge(merged).await?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_update_testimonial' took: {elapsed:.2?}");
}
match record { match record {
Some(_) => Ok("Success update testimonial".into()), Some(_) => Ok("Success update testimonial".into()),
@@ -99,7 +135,9 @@ impl<'a> TestimonialsRepository<'a> {
} }
} }
#[instrument(skip(self, id), err)]
pub async fn query_delete_testimonial(&self, id: String) -> Result<String> { pub async fn query_delete_testimonial(&self, id: String) -> Result<String> {
let now = Instant::now();
let db = &self.state.surrealdb_ws; let db = &self.state.surrealdb_ws;
let testimonial = self.query_testimonial_by_id(id).await?; let testimonial = self.query_testimonial_by_id(id).await?;
if testimonial.is_deleted { if testimonial.is_deleted {
@@ -111,6 +149,12 @@ impl<'a> TestimonialsRepository<'a> {
.update(record_key) .update(record_key)
.merge(serde_json::json!({ "is_deleted": true })) .merge(serde_json::json!({ "is_deleted": true }))
.await?; .await?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_delete_testimonial' took: {elapsed:.2?}");
}
match record { match record {
Some(_) => Ok("Success delete testimonial".into()), Some(_) => Ok("Success delete testimonial".into()),
@@ -11,7 +11,7 @@ use super::testimonials_dto::{
#[derive(Clone, Debug, Serialize, Deserialize)] #[derive(Clone, Debug, Serialize, Deserialize)]
pub struct TestimonialsSchema { pub struct TestimonialsSchema {
pub id: Thing, pub id: Thing,
pub user: Thing, // Link to app_users table pub user: Thing,
pub role: String, pub role: String,
pub content: String, pub content: String,
pub is_deleted: bool, pub is_deleted: bool,
@@ -28,7 +28,7 @@ impl Default for TestimonialsSchema {
), ),
user: make_thing( user: make_thing(
&ResourceEnum::Users.to_string(), &ResourceEnum::Users.to_string(),
&Uuid::new_v4().to_string(), // Placeholder, will be replaced by actual user ID &Uuid::new_v4().to_string(),
), ),
role: String::new(), role: String::new(),
content: String::new(), content: String::new(),
+9 -3
View File
@@ -4,9 +4,10 @@ version = "0.1.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
imphnen-libs = { version = "0.1.0", path = "../imphnen-libs" } imphnen-libs.workspace = true
imphnen-utils = { version = "0.1.0", path = "../imphnen-utils" } imphnen-utils.workspace = true
imphnen-entities = { version = "0.1.0", path = "../imphnen-entities" } imphnen-entities.workspace = true
imphnen-iam.workspace = true
axum.workspace = true axum.workspace = true
serde.workspace = true serde.workspace = true
serde_json.workspace = true serde_json.workspace = true
@@ -22,3 +23,8 @@ chrono.workspace = true
anyhow.workspace = true anyhow.workspace = true
tower-http.workspace = true tower-http.workspace = true
utoipa-swagger-ui.workspace = true utoipa-swagger-ui.workspace = true
tracing.workspace = true
[dev-dependencies]
dotenvy.workspace = true
http-body-util.workspace = true
+2 -14
View File
@@ -1,14 +1,2 @@
pub fn add(left: u64, right: u64) -> u64 { pub mod v1;
left + right pub use v1::*;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn it_works() {
let result = add(2, 2);
assert_eq!(result, 4);
}
}
@@ -0,0 +1,356 @@
use super::{
MentorDetailResponseDto, MentorListResponseDto, MentorUpdateRequestDto,
MentorUserRegisterRequestDto, MentorVerifyRequestDto, MentorsService,
};
use crate::v1::mentors::mentors_dto::MentorRegisterResponseDto;
use ::axum::{
extract::{Extension, Json, Path, Query},
http::HeaderMap,
response::{IntoResponse, Response},
};
use imphnen_entities::*;
use imphnen_iam::{PermissionsEnum, permissions_guard};
use imphnen_utils::extract_email;
use serde_json::json;
#[utoipa::path(
post,
path = "/v1/mentors/register",
request_body = MentorUserRegisterRequestDto,
responses(
(status = 200, description = "Mentor registered successfully", body = MentorRegisterResponseDto),
(status = 400, description = "Bad request - validation error"),
(status = 409, description = "Conflict - user already has mentor profile"),
(status = 500, description = "Internal server error")
),
tag = "Mentors"
)]
pub async fn post_register_mentor(
Extension(app_state): Extension<AppState>,
Json(dto): Json<MentorUserRegisterRequestDto>,
) -> Response {
MentorsService::register_mentor(&app_state, dto).await
}
#[utoipa::path(
get,
path = "/v1/mentors",
params(
("page" = Option<u64>, Query, description = "Page number"),
("per_page" = Option<u64>, Query, description = "Items per page"),
("search" = Option<String>, Query, description = "Search query"),
("sort_by" = Option<String>, Query, description = "Sort by field"),
("order" = Option<String>, Query, description = "Sort order (ASC/DESC)"),
),
responses(
(status = 200, description = "Get list of mentors", body = Vec<MentorListResponseDto>),
(status = 500, description = "Internal server error")
),
tag = "Mentors",
security(
("Bearer" = [])
)
)]
pub async fn get_mentor_list(
headers: HeaderMap,
Extension(app_state): Extension<AppState>,
Query(meta): Query<MetaRequestDto>,
) -> Response {
match permissions_guard(
&headers,
app_state.clone(),
vec![PermissionsEnum::ReadListMentors],
)
.await
{
Ok(_) => MentorsService::get_mentor_list(&app_state, meta).await,
Err(response) => response,
}
}
#[utoipa::path(
get,
path = "/v1/mentors/detail/{id}",
params(
("id" = String, Path, description = "Mentor ID")
),
responses(
(status = 200, description = "Get mentor by ID", body = MentorDetailResponseDto),
(status = 404, description = "Mentor not found"),
(status = 500, description = "Internal server error")
),
tag = "Mentors",
security(
("Bearer" = [])
)
)]
pub async fn get_mentor_by_id(
headers: HeaderMap,
Extension(app_state): Extension<AppState>,
Path(id): Path<String>,
) -> Response {
match permissions_guard(
&headers,
app_state.clone(),
vec![PermissionsEnum::ReadDetailMentors],
)
.await
{
Ok(_) => MentorsService::get_mentor_by_id(&app_state, &id).await,
Err(response) => response,
}
}
#[utoipa::path(
put,
path = "/v1/mentors/update/{id}",
params(
("id" = String, Path, description = "Mentor ID")
),
request_body = MentorUpdateRequestDto,
responses(
(status = 200, description = "Mentor updated successfully", body = MentorDetailResponseDto),
(status = 400, description = "Bad request - validation error"),
(status = 404, description = "Mentor not found"),
(status = 500, description = "Internal server error")
),
tag = "Mentors - Admin",
security(
("Bearer" = [])
)
)]
pub async fn put_update_mentor(
headers: HeaderMap,
Extension(app_state): Extension<AppState>,
Path(id): Path<String>,
Json(dto): Json<MentorUpdateRequestDto>,
) -> Response {
match permissions_guard(
&headers,
app_state.clone(),
vec![PermissionsEnum::UpdateMentors],
)
.await
{
Ok(_) => MentorsService::update_mentor(&app_state, &id, dto).await,
Err(response) => response,
}
}
#[utoipa::path(
delete,
path = "/v1/mentors/delete/{id}",
params(
("id" = String, Path, description = "Mentor ID")
),
responses(
(status = 200, description = "Mentor deleted successfully"),
(status = 404, description = "Mentor not found"),
(status = 500, description = "Internal server error")
),
tag = "Mentors - Admin",
security(
("Bearer" = [])
)
)]
pub async fn delete_mentor(
headers: HeaderMap,
Extension(app_state): Extension<AppState>,
Path(id): Path<String>,
) -> Response {
match permissions_guard(
&headers,
app_state.clone(),
vec![PermissionsEnum::DeleteMentors],
)
.await
{
Ok(_) => MentorsService::delete_mentor(&app_state, &id).await,
Err(response) => response,
}
}
#[utoipa::path(
put,
path = "/v1/mentors/verify/{id}",
params(
("id" = String, Path, description = "Mentor ID")
),
request_body = MentorVerifyRequestDto,
responses(
(status = 200, description = "Mentor verified successfully", body = MentorDetailResponseDto),
(status = 400, description = "Bad request - validation error"),
(status = 404, description = "Mentor not found"),
(status = 500, description = "Internal server error")
),
tag = "Mentors - Admin",
security(
("Bearer" = [])
)
)]
pub async fn put_verify_mentor(
headers: HeaderMap,
Extension(app_state): Extension<AppState>,
Path(id): Path<String>,
Json(dto): Json<MentorVerifyRequestDto>,
) -> Response {
match permissions_guard(
&headers,
app_state.clone(),
vec![PermissionsEnum::VerifyMentors],
)
.await
{
Ok(_) => MentorsService::verify_mentor(&app_state, &id, dto).await,
Err(response) => response,
}
}
#[utoipa::path(
get,
path = "/v1/mentors/me",
responses(
(status = 200, description = "Current user's mentor profile", body = MentorDetailResponseDto),
(status = 401, description = "Unauthorized - invalid token"),
(status = 403, description = "Mentor profile not found for current user"),
(status = 500, description = "Internal server error")
),
tag = "Mentors",
security(
("Bearer" = [])
)
)]
pub async fn get_mentor_me(
Extension(app_state): Extension<AppState>,
headers: HeaderMap,
) -> Response {
match permissions_guard(
&headers,
app_state.clone(),
vec![PermissionsEnum::ReadOwnMentorProfile],
)
.await
{
Ok(_) => {
let email = match extract_email(&headers) {
Some(email) => email,
None => {
return (
axum::http::StatusCode::UNAUTHORIZED,
Json(json!({
"error": "Unauthorized",
"message": "Token tidak valid"
})),
)
.into_response();
}
};
MentorsService::get_mentor_me(&app_state, &email).await
}
Err(response) => response,
}
}
#[utoipa::path(
put,
path = "/v1/mentors/update/me",
request_body = MentorUpdateRequestDto,
responses(
(status = 200, description = "Mentor profile updated successfully", body = MentorDetailResponseDto),
(status = 400, description = "Bad request - validation error"),
(status = 401, description = "Unauthorized - invalid token"),
(status = 404, description = "Mentor profile not found"),
(status = 500, description = "Internal server error")
),
tag = "Mentors",
security(
("Bearer" = [])
)
)]
pub async fn put_update_mentor_me(
Extension(app_state): Extension<AppState>,
headers: HeaderMap,
Json(dto): Json<MentorUpdateRequestDto>,
) -> Response {
match permissions_guard(
&headers,
app_state.clone(),
vec![PermissionsEnum::UpdateOwnMentorProfile],
)
.await
{
Ok(_) => {
let email = match extract_email(&headers) {
Some(email) => email,
None => {
return imphnen_utils::common_response(
axum::http::StatusCode::UNAUTHORIZED,
"Token tidak valid",
);
}
};
MentorsService::update_mentor_me(&app_state, &email, dto).await
}
Err(response) => response,
}
}
#[utoipa::path(
put,
path = "/v1/mentors/update",
request_body = MentorUpdateRequestDto,
responses(
(status = 400, description = "Bad request - Mentor ID is required for update"),
),
tag = "Mentors - Admin"
)]
pub async fn put_update_mentor_no_id() -> Response {
imphnen_utils::common_response(
axum::http::StatusCode::BAD_REQUEST,
"Mentor ID is required for update",
)
}
#[utoipa::path(
get,
path = "/v1/mentors/status",
responses(
(status = 200, description = "Mentor application status", body = String),
(status = 401, description = "Unauthorized - invalid token"),
(status = 403, description = "No mentor application found for current user"),
(status = 500, description = "Internal server error")
),
tag = "Mentors",
security(
("Bearer" = [])
)
)]
pub async fn get_mentor_status(
Extension(app_state): Extension<AppState>,
headers: HeaderMap,
) -> Response {
match permissions_guard(
&headers,
app_state.clone(),
vec![PermissionsEnum::ReadOwnMentorStatus],
)
.await
{
Ok(_) => {
let email = match extract_email(&headers) {
Some(email) => email,
None => {
return (
axum::http::StatusCode::UNAUTHORIZED,
Json(json!({
"error": "Unauthorized",
"message": "Token tidak valid"
})),
)
.into_response();
}
};
MentorsService::get_mentor_status(&app_state, &email).await
}
Err(response) => response,
}
}
@@ -0,0 +1,479 @@
use crate::v1::mentors::MentorSchema;
use imphnen_utils::extract_id;
use serde::{Deserialize, Serialize};
use surrealdb::sql::Thing;
use utoipa::ToSchema;
use validator::Validate;
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct MentorListResponseDto {
pub id: String,
pub fullname: Option<String>,
pub email: Option<String>,
pub status: String,
pub created_at: String,
pub updated_at: String,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct MentorDetailWithUserDto {
pub id: Thing,
pub user_id: Thing,
pub fullname: Option<String>,
pub email: Option<String>,
pub legal_name: String,
pub identity_document_url: String,
pub phone_for_verification: String,
pub bio: String,
pub linkedin_url: Option<String>,
pub github_url: Option<String>,
pub cv_url: Option<String>,
pub industries: Vec<String>,
pub expertise: Vec<String>,
pub languages: Vec<String>,
pub current_company: String,
pub current_role: String,
pub years_of_experience: i32,
pub topics_of_interest: Vec<String>,
pub preferred_mentee_level: Vec<String>,
pub preferred_mentoring_formats: Vec<String>,
pub availability_commitment: String,
pub mentoring_rate: MentoringRate,
pub status: String,
pub is_deleted: bool,
pub created_at: String,
pub updated_at: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct MentorDetailResponseDto {
pub id: String,
pub user_id: String,
pub fullname: Option<String>,
pub email: Option<String>,
pub legal_name: String,
pub identity_document_url: String,
pub phone_for_verification: String,
pub bio: String,
pub linkedin_url: Option<String>,
pub github_url: Option<String>,
pub cv_url: Option<String>,
pub industries: Vec<String>,
pub expertise: Vec<String>,
pub languages: Vec<String>,
pub current_company: String,
pub current_role: String,
pub years_of_experience: i32,
pub topics_of_interest: Vec<String>,
pub preferred_mentee_level: Vec<String>,
pub preferred_mentoring_formats: Vec<String>,
pub availability_commitment: String,
pub mentoring_rate: MentoringRate,
pub status: String,
pub created_at: String,
pub updated_at: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)]
pub struct MentorRegisterResponseDto {
pub id: String,
pub user_id: String,
pub email: Option<String>,
pub status: String,
pub created_at: String,
pub updated_at: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)]
pub struct MentorUpdateRequestDto {
#[validate(length(
min = 3,
message = "Legal name must be at least 3 characters"
))]
#[serde(skip_serializing_if = "Option::is_none")]
pub legal_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub gender: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub domicile: Option<String>,
#[validate(url(message = "Invalid identity document URL"))]
#[serde(skip_serializing_if = "Option::is_none")]
pub identity_document_url: Option<String>,
#[validate(length(
min = 10,
max = 15,
message = "Phone must be 10-15 characters"
))]
#[serde(skip_serializing_if = "Option::is_none")]
pub phone_for_verification: Option<String>,
#[validate(length(min = 50, message = "Bio must be at least 50 characters"))]
#[serde(skip_serializing_if = "Option::is_none")]
pub bio: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_education: Option<String>,
#[validate(url(message = "Invalid LinkedIn URL"))]
#[serde(skip_serializing_if = "Option::is_none")]
pub linkedin_url: Option<String>,
#[validate(url(message = "Invalid GitHub URL"))]
#[serde(skip_serializing_if = "Option::is_none")]
pub github_url: Option<String>,
#[validate(url(message = "Invalid CV URL"))]
#[serde(skip_serializing_if = "Option::is_none")]
pub cv_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub portfolio_url: Option<String>,
#[validate(length(min = 1, message = "At least 1 industry required"))]
#[serde(skip_serializing_if = "Option::is_none")]
pub industries: Option<Vec<String>>,
#[validate(length(min = 1, message = "At least 1 expertise required"))]
#[serde(skip_serializing_if = "Option::is_none")]
pub expertise: Option<Vec<String>>,
#[validate(length(min = 1, message = "At least 1 language required"))]
#[serde(skip_serializing_if = "Option::is_none")]
pub languages: Option<Vec<String>>,
#[validate(length(min = 1, message = "Current company required"))]
#[serde(skip_serializing_if = "Option::is_none")]
pub current_company: Option<String>,
#[validate(length(min = 1, message = "Current role required"))]
#[serde(skip_serializing_if = "Option::is_none")]
pub current_role: Option<String>,
#[validate(range(min = 2, message = "At least 2 years of experience required"))]
#[serde(skip_serializing_if = "Option::is_none")]
pub years_of_experience: Option<i32>,
#[validate(length(min = 1, message = "At least 1 topic required"))]
#[serde(skip_serializing_if = "Option::is_none")]
pub topics_of_interest: Option<Vec<String>>,
#[validate(length(min = 1, message = "At least 1 mentee level required"))]
#[serde(skip_serializing_if = "Option::is_none")]
pub preferred_mentee_level: Option<Vec<String>>,
#[validate(length(min = 1, message = "At least 1 mentoring format required"))]
#[serde(skip_serializing_if = "Option::is_none")]
pub preferred_mentoring_formats: Option<Vec<String>>,
#[validate(length(
min = 5,
message = "Availability commitment must be at least 5 characters"
))]
#[serde(skip_serializing_if = "Option::is_none")]
pub availability_commitment: Option<String>,
#[validate(range(min = 1, message = "Amount must be at least 1"))]
#[serde(skip_serializing_if = "Option::is_none")]
pub mentoring_rate_amount: Option<u64>,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)]
pub struct MentorUserRegisterRequestDto {
#[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(custom(
function = "imphnen_iam::auth_dto::validate_password_complexity",
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 = "Phone number is required"))]
pub phone_number: String,
#[validate(nested)]
pub identity_and_verification: IdentityAndVerification,
#[validate(nested)]
pub professional_profile: ProfessionalProfile,
#[validate(nested)]
pub mentoring_logistics: MentoringLogistics,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)]
pub struct MentorRegisterFromTokenRequestDto {
#[validate(nested)]
pub identity_and_verification: IdentityAndVerification,
#[validate(nested)]
pub professional_profile: ProfessionalProfile,
#[validate(nested)]
pub mentoring_logistics: MentoringLogistics,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)]
pub struct IdentityAndVerification {
#[validate(length(
min = 3,
message = "Legal name must be at least 3 characters"
))]
pub legal_name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub gender: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub domicile: Option<String>,
#[validate(url(message = "Invalid identity document URL"))]
pub identity_document_url: String,
#[validate(length(
min = 10,
max = 15,
message = "Phone must be 10-15 characters"
))]
pub phone_for_verification: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)]
pub struct ProfessionalProfile {
#[validate(length(min = 50, message = "Bio must be at least 50 characters"))]
pub bio: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_education: Option<String>,
#[validate(url(message = "Invalid LinkedIn URL"))]
#[serde(skip_serializing_if = "Option::is_none")]
pub linkedin_url: Option<String>,
#[validate(url(message = "Invalid GitHub URL"))]
#[serde(skip_serializing_if = "Option::is_none")]
pub github_url: Option<String>,
#[validate(url(message = "Invalid CV URL"))]
#[serde(skip_serializing_if = "Option::is_none")]
pub cv_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub portfolio_url: Option<String>,
#[validate(length(min = 1, message = "At least 1 industry required"))]
pub industries: Vec<String>,
#[validate(length(min = 1, message = "At least 1 expertise required"))]
pub expertise: Vec<String>,
#[validate(length(min = 1, message = "At least 1 language required"))]
pub languages: Vec<String>,
#[validate(length(min = 1, message = "Current company required"))]
pub current_company: String,
#[validate(length(min = 1, message = "Current role required"))]
pub current_role: String,
#[validate(range(min = 2, message = "At least 2 years of experience required"))]
pub years_of_experience: i32,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)]
pub struct MentoringLogistics {
#[validate(length(min = 1, message = "At least 1 topic required"))]
pub topics_of_interest: Vec<String>,
#[validate(length(min = 1, message = "At least 1 mentee level required"))]
pub preferred_mentee_level: Vec<String>,
#[validate(length(min = 1, message = "At least 1 mentoring format required"))]
pub preferred_mentoring_formats: Vec<String>,
#[validate(length(
min = 5,
message = "Availability commitment must be at least 5 characters"
))]
pub availability_commitment: String,
#[validate(range(min = 1, message = "Amount must be at least 1"))]
pub mentoring_rate_amount: u64,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate, Default)]
pub struct MentoringRate {
#[validate(range(min = 1, message = "Amount must be at least 1"))]
pub amount: u64,
#[validate(length(min = 1, message = "Currency is required"))]
pub currency: String,
#[validate(length(min = 1, message = "Per duration is required"))]
pub per_duration: String,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct MentorInsertDto {
pub id: Thing,
pub user_id: Option<Thing>,
pub email: Option<String>,
pub legal_name: String,
pub gender: Option<String>,
pub domicile: Option<String>,
pub identity_document_url: String,
pub phone_for_verification: String,
pub bio: String,
pub last_education: Option<String>,
pub linkedin_url: Option<String>,
pub github_url: Option<String>,
pub cv_url: Option<String>,
pub portfolio_url: Option<String>,
pub industries: Vec<String>,
pub expertise: Vec<String>,
pub languages: Vec<String>,
pub current_company: String,
pub current_role: String,
pub years_of_experience: i32,
pub topics_of_interest: Vec<String>,
pub preferred_mentee_level: Vec<String>,
pub preferred_mentoring_formats: Vec<String>,
pub availability_commitment: String,
pub mentoring_rate: MentoringRate,
pub status: String,
pub is_deleted: bool,
pub created_at: String,
pub updated_at: String,
}
impl From<MentorSchema> for MentorInsertDto {
fn from(schema: MentorSchema) -> Self {
MentorInsertDto {
id: schema.id,
user_id: schema.user_id,
email: schema.email,
legal_name: schema.legal_name,
gender: schema.gender,
domicile: schema.domicile,
identity_document_url: schema.identity_document_url,
phone_for_verification: schema.phone_for_verification,
bio: schema.bio,
last_education: schema.last_education,
linkedin_url: schema.linkedin_url,
github_url: schema.github_url,
cv_url: schema.cv_url,
portfolio_url: schema.portfolio_url,
industries: schema.industries,
expertise: schema.expertise,
languages: schema.languages,
current_company: schema.current_company,
current_role: schema.current_role,
years_of_experience: schema.years_of_experience,
topics_of_interest: schema.topics_of_interest,
preferred_mentee_level: schema.preferred_mentee_level,
preferred_mentoring_formats: schema.preferred_mentoring_formats,
availability_commitment: schema.availability_commitment,
mentoring_rate: schema.mentoring_rate,
status: schema.status,
is_deleted: schema.is_deleted,
created_at: schema.created_at,
updated_at: schema.updated_at,
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)]
pub struct MentorVerifyRequestDto {
#[validate(length(min = 1, message = "Status is required"))]
pub status: String,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct MentorDetailQueryDto {
pub id: Thing,
pub user_id: Thing,
pub fullname: Option<String>,
pub email: Option<String>,
pub legal_name: String,
pub gender: Option<String>,
pub domicile: Option<String>,
pub identity_document_url: String,
pub phone_for_verification: String,
pub bio: String,
pub last_education: Option<String>,
pub linkedin_url: Option<String>,
pub github_url: Option<String>,
pub cv_url: Option<String>,
pub portfolio_url: Option<String>,
pub industries: Vec<String>,
pub expertise: Vec<String>,
pub languages: Vec<String>,
pub current_company: String,
pub current_role: String,
pub years_of_experience: i32,
pub topics_of_interest: Vec<String>,
pub preferred_mentee_level: Vec<String>,
pub preferred_mentoring_formats: Vec<String>,
pub availability_commitment: String,
pub mentoring_rate: MentoringRate,
pub status: String,
pub is_deleted: bool,
pub created_at: String,
pub updated_at: String,
}
impl From<MentorDetailQueryDto> for MentorListResponseDto {
fn from(dto: MentorDetailQueryDto) -> Self {
Self {
id: extract_id(&dto.id),
fullname: dto.fullname,
email: dto.email,
status: dto.status,
created_at: dto.created_at,
updated_at: dto.updated_at,
}
}
}
impl From<MentorDetailQueryDto> for MentorDetailResponseDto {
fn from(dto: MentorDetailQueryDto) -> Self {
Self {
id: extract_id(&dto.id),
user_id: extract_id(&dto.user_id),
fullname: dto.fullname,
email: dto.email,
legal_name: dto.legal_name,
identity_document_url: dto.identity_document_url,
phone_for_verification: dto.phone_for_verification,
bio: dto.bio,
linkedin_url: dto.linkedin_url,
github_url: dto.github_url,
cv_url: dto.cv_url,
industries: dto.industries,
expertise: dto.expertise,
languages: dto.languages,
current_company: dto.current_company,
current_role: dto.current_role,
years_of_experience: dto.years_of_experience,
topics_of_interest: dto.topics_of_interest,
preferred_mentee_level: dto.preferred_mentee_level,
preferred_mentoring_formats: dto.preferred_mentoring_formats,
availability_commitment: dto.availability_commitment,
mentoring_rate: dto.mentoring_rate,
status: dto.status,
created_at: dto.created_at,
updated_at: dto.updated_at,
}
}
}
impl From<MentorSchema> for MentorRegisterResponseDto {
fn from(schema: MentorSchema) -> Self {
Self {
id: schema.id.to_string(),
user_id: schema.user_id.map(|id| extract_id(&id)).unwrap_or_default(),
email: schema.email,
status: schema.status,
created_at: schema.created_at,
updated_at: schema.updated_at,
}
}
}
impl From<MentorDetailWithUserDto> for MentorDetailQueryDto {
fn from(dto: MentorDetailWithUserDto) -> Self {
MentorDetailQueryDto {
id: dto.id,
user_id: dto.user_id,
fullname: dto.fullname,
email: dto.email,
legal_name: dto.legal_name,
gender: None, // Frontend form implies these are optional, not present in original MentorDetailWithUserDto
domicile: None, // Frontend form implies these are optional, not present in original MentorDetailWithUserDto
identity_document_url: dto.identity_document_url,
phone_for_verification: dto.phone_for_verification,
bio: dto.bio,
last_education: None, // Frontend form implies these are optional, not present in original MentorDetailWithUserDto
linkedin_url: dto.linkedin_url,
github_url: dto.github_url,
cv_url: dto.cv_url,
portfolio_url: None, // Frontend form implies these are optional, not present in original MentorDetailWithUserDto
industries: dto.industries,
expertise: dto.expertise,
languages: dto.languages,
current_company: dto.current_company,
current_role: dto.current_role,
years_of_experience: dto.years_of_experience,
topics_of_interest: dto.topics_of_interest,
preferred_mentee_level: dto.preferred_mentee_level,
preferred_mentoring_formats: dto.preferred_mentoring_formats,
availability_commitment: dto.availability_commitment,
mentoring_rate: dto.mentoring_rate,
status: dto.status,
is_deleted: dto.is_deleted,
created_at: dto.created_at,
updated_at: dto.updated_at,
}
}
}
@@ -0,0 +1,299 @@
use anyhow::{Result, bail};
use imphnen_iam::{get_id, make_thing};
use surrealdb::sql::Thing;
use crate::v1::mentors::{MentorDetailWithUserDto, MentorInsertDto, MentorSchema};
use imphnen_libs::{AppState, MetaRequestDto, ResourceEnum, ResponseListSuccessDto};
use imphnen_utils::{DetailQueryBuilder, QueryListBuilder, get_iso_date};
use serde_json::{Map, Value};
use std::time::Instant;
use tracing::instrument;
pub struct MentorsRepository<'a> {
pub state: &'a AppState,
}
impl<'a> MentorsRepository<'a> {
pub fn new(state: &'a AppState) -> Self {
Self { state }
}
#[instrument(skip(self, meta), err)]
pub async fn query_mentor_list(
&self,
meta: MetaRequestDto,
) -> Result<ResponseListSuccessDto<Vec<MentorDetailWithUserDto>>> {
let now = Instant::now();
let db = &self.state.surrealdb_ws;
let mentors_table = ResourceEnum::Mentors.to_string();
let builder = QueryListBuilder::new(db, &mentors_table, &meta)
.search_field("legal_name")
.select_fields(vec![
"id",
"user_id",
"user_id.fullname as fullname",
"email",
"legal_name",
"identity_document_url",
"phone_for_verification",
"bio",
"linkedin_url",
"github_url",
"cv_url",
"industries",
"expertise",
"languages",
"current_company",
"current_role",
"years_of_experience",
"topics_of_interest",
"preferred_mentee_level",
"preferred_mentoring_formats",
"availability_commitment",
"mentoring_rate",
"status",
"is_deleted",
"created_at",
"updated_at",
]);
let result = builder.build().await?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_mentor_list' took: {elapsed:.2?}");
}
let data = result.data.into_iter().collect();
Ok(ResponseListSuccessDto {
data,
meta: result.meta,
})
}
#[instrument(skip(self, email, include_deleted), err)]
pub async fn query_mentor_by_email(
&self,
email: String,
include_deleted: bool,
) -> Result<MentorDetailWithUserDto> {
let now = Instant::now();
let db = &self.state.surrealdb_ws;
let mut builder = DetailQueryBuilder::new(ResourceEnum::Mentors.to_string())
.with_where("email", Some(email.clone()))
.with_select_fields(vec![
"id",
"user_id",
"user_id.fullname as fullname",
"email",
"legal_name",
"identity_document_url",
"phone_for_verification",
"bio",
"linkedin_url",
"github_url",
"cv_url",
"industries",
"expertise",
"languages",
"current_company",
"current_role",
"years_of_experience",
"topics_of_interest",
"preferred_mentee_level",
"preferred_mentoring_formats",
"availability_commitment",
"mentoring_rate",
"status",
"is_deleted",
"created_at",
"updated_at",
]);
if !include_deleted {
builder = builder.with_condition("is_deleted = false");
}
let sql = builder.build();
let mentor_opt: Option<MentorDetailWithUserDto> =
builder.apply_bindings(db.query(sql)).await?.take(0)?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_mentor_by_email' took: {elapsed:.2?}");
}
let Some(mentor) = mentor_opt else {
bail!("Mentor not found");
};
Ok(mentor)
}
#[instrument(skip(self, id, include_deleted), err)]
pub async fn query_mentor_by_id(
&self,
id: &Thing,
include_deleted: bool,
) -> Result<MentorDetailWithUserDto> {
let now = Instant::now();
let db = &self.state.surrealdb_ws;
let mut builder = DetailQueryBuilder::new(ResourceEnum::Mentors.to_string())
.with_id(get_id(id)?.1)
.with_select_fields(vec![
"id",
"user_id",
"user_id.fullname as fullname",
"email",
"legal_name",
"identity_document_url",
"phone_for_verification",
"bio",
"linkedin_url",
"github_url",
"cv_url",
"industries",
"expertise",
"languages",
"current_company",
"current_role",
"years_of_experience",
"topics_of_interest",
"preferred_mentee_level",
"preferred_mentoring_formats",
"availability_commitment",
"mentoring_rate",
"status",
"is_deleted",
"created_at",
"updated_at",
]);
if !include_deleted {
builder = builder.with_condition("is_deleted = false");
}
let sql = builder.build();
let mentor_opt: Option<MentorDetailWithUserDto> =
builder.apply_bindings(db.query(sql)).await?.take(0)?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_mentor_by_id' took: {elapsed:.2?}");
}
let Some(mentor) = mentor_opt else {
bail!("Mentor not found in database");
};
if mentor.is_deleted && !include_deleted {
bail!("Mentor has been deleted");
}
Ok(mentor)
}
#[instrument(skip(self, data), err)]
pub async fn query_create_mentor(&self, data: MentorSchema) -> Result<String> {
let now = Instant::now();
let db = &self.state.surrealdb_ws;
let dto: MentorInsertDto = data.into();
let record: Option<MentorSchema> = db
.create(ResourceEnum::Mentors.to_string())
.content(dto.clone())
.await?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_create_mentor' took: {elapsed:.2?}");
}
match record {
Some(mentor) => {
let id_str = mentor.id.id.to_raw();
let _user = format!("{:?}", mentor.user_id);
Ok(id_str)
}
None => {
bail!("Failed to create mentor")
}
}
}
#[instrument(skip(self, data), err)]
pub async fn query_update_mentor(&self, data: MentorSchema) -> Result<String> {
let now = Instant::now();
let db = &self.state.surrealdb_ws;
let id_ref = &data.id;
let record_key = get_id(id_ref)?;
let _existing = self.query_mentor_by_id(id_ref, false).await?;
let mut merged_data_json: Map<String, Value> =
serde_json::to_value(data.clone())
.map_err(|e| anyhow::anyhow!("Failed to serialize MentorSchema: {}", e))?
.as_object()
.cloned()
.unwrap_or_default();
merged_data_json.remove("id");
merged_data_json.remove("user_id");
merged_data_json.remove("created_at");
merged_data_json.insert("updated_at".to_string(), Value::String(get_iso_date()));
let record: Option<MentorSchema> =
db.update(record_key).merge(merged_data_json).await?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_update_mentor' took: {elapsed:.2?}");
}
match record {
Some(_) => Ok("Success update mentor".into()),
None => {
bail!("Failed to update mentor")
}
}
}
#[instrument(skip(self, id), err)]
pub async fn query_delete_mentor(&self, id: String) -> Result<String> {
let now = Instant::now();
let db = &self.state.surrealdb_ws;
let thing = make_thing(ResourceEnum::Mentors.to_string().as_str(), &id);
let record_key = get_id(&thing)?;
let mentor_to_delete_res = self.query_mentor_by_id(&thing, true).await;
let _mentor_to_delete = match mentor_to_delete_res {
Ok(mentor) => {
if mentor.is_deleted {
bail!("Mentor is already soft deleted");
}
mentor
}
Err(e) => {
if e.to_string().contains("Mentor has been deleted") {
bail!("Mentor is already soft deleted");
} else {
return Err(e);
}
}
};
let mut patch = Map::new();
patch.insert("is_deleted".to_string(), Value::Bool(true));
patch.insert("updated_at".to_string(), Value::String(get_iso_date()));
let record: Option<MentorSchema> = db.update(record_key).merge(patch).await?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_delete_mentor' took: {elapsed:.2?}");
}
match record {
Some(_) => Ok("Success soft delete mentor".into()),
None => {
bail!("Failed to soft delete mentor")
}
}
}
}
@@ -0,0 +1,250 @@
use super::{
IdentityAndVerification, MentorDetailQueryDto, MentorUpdateRequestDto,
MentoringLogistics, MentoringRate, ProfessionalProfile,
};
use imphnen_libs::ResourceEnum;
use imphnen_utils::{get_iso_date, make_thing};
use serde::{Deserialize, Serialize};
use surrealdb::{Uuid, sql::Thing};
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct MentorSchema {
pub id: Thing,
#[serde(skip_serializing_if = "Option::is_none")]
pub user_id: Option<Thing>,
pub email: Option<String>,
pub legal_name: String,
pub gender: Option<String>,
pub domicile: Option<String>,
pub identity_document_url: String,
pub phone_for_verification: String,
pub bio: String,
pub last_education: Option<String>,
pub linkedin_url: Option<String>,
pub github_url: Option<String>,
pub cv_url: Option<String>,
pub portfolio_url: Option<String>,
pub industries: Vec<String>,
pub expertise: Vec<String>,
pub languages: Vec<String>,
pub current_company: String,
pub current_role: String,
pub years_of_experience: i32,
pub topics_of_interest: Vec<String>,
pub preferred_mentee_level: Vec<String>,
pub preferred_mentoring_formats: Vec<String>,
pub availability_commitment: String,
pub mentoring_rate: MentoringRate,
pub status: String,
pub is_deleted: bool,
pub created_at: String,
pub updated_at: String,
}
impl Default for MentorSchema {
fn default() -> Self {
Self {
id: make_thing(
ResourceEnum::Mentors.to_string().as_str(),
&Uuid::new_v4().to_string(),
),
user_id: Some(make_thing(
ResourceEnum::Users.to_string().as_str(),
&Uuid::new_v4().to_string(),
)),
email: None,
legal_name: String::new(),
gender: None,
domicile: None,
identity_document_url: String::new(),
phone_for_verification: String::new(),
bio: String::new(),
last_education: None,
linkedin_url: None,
github_url: None,
cv_url: None,
portfolio_url: None,
industries: Vec::new(),
expertise: Vec::new(),
languages: Vec::new(),
current_company: String::new(),
current_role: String::new(),
years_of_experience: 0,
topics_of_interest: Vec::new(),
preferred_mentee_level: Vec::new(),
preferred_mentoring_formats: Vec::new(),
availability_commitment: String::new(),
mentoring_rate: MentoringRate {
amount: 0,
currency: "IDR".to_string(),
per_duration: "hour".to_string(),
},
status: "pending".to_string(),
is_deleted: false,
created_at: get_iso_date(),
updated_at: get_iso_date(),
}
}
}
impl MentorSchema {
pub fn create(
identity_and_verification: IdentityAndVerification,
professional_profile: ProfessionalProfile,
mentoring_logistics: MentoringLogistics,
user_id_raw: String,
email_str: String,
) -> Self {
Self {
id: make_thing(
&ResourceEnum::Mentors.to_string(),
&Uuid::new_v4().to_string(),
),
user_id: Some(make_thing(&ResourceEnum::Users.to_string(), &user_id_raw)),
email: Some(email_str),
legal_name: identity_and_verification.legal_name,
gender: identity_and_verification.gender,
domicile: identity_and_verification.domicile,
identity_document_url: identity_and_verification.identity_document_url,
phone_for_verification: identity_and_verification.phone_for_verification,
bio: professional_profile.bio,
last_education: professional_profile.last_education,
linkedin_url: professional_profile.linkedin_url,
github_url: professional_profile.github_url,
cv_url: professional_profile.cv_url,
portfolio_url: professional_profile.portfolio_url,
industries: professional_profile.industries,
expertise: professional_profile.expertise,
languages: professional_profile.languages,
current_company: professional_profile.current_company,
current_role: professional_profile.current_role,
years_of_experience: professional_profile.years_of_experience,
topics_of_interest: mentoring_logistics.topics_of_interest,
preferred_mentee_level: mentoring_logistics.preferred_mentee_level,
preferred_mentoring_formats: mentoring_logistics.preferred_mentoring_formats,
availability_commitment: mentoring_logistics.availability_commitment,
mentoring_rate: MentoringRate {
amount: mentoring_logistics.mentoring_rate_amount,
currency: "IDR".to_string(),
per_duration: "hour".to_string(),
},
status: "pending".to_string(),
is_deleted: false,
created_at: get_iso_date(),
updated_at: get_iso_date(),
}
}
pub fn from(dto: MentorDetailQueryDto) -> Self {
Self {
id: dto.id,
user_id: Some(dto.user_id),
email: dto.email,
legal_name: dto.legal_name,
gender: dto.gender,
domicile: dto.domicile,
identity_document_url: dto.identity_document_url,
phone_for_verification: dto.phone_for_verification,
bio: dto.bio,
last_education: dto.last_education,
linkedin_url: dto.linkedin_url,
github_url: dto.github_url,
cv_url: dto.cv_url,
portfolio_url: dto.portfolio_url,
industries: dto.industries,
expertise: dto.expertise,
languages: dto.languages,
current_company: dto.current_company,
current_role: dto.current_role,
years_of_experience: dto.years_of_experience,
topics_of_interest: dto.topics_of_interest,
preferred_mentee_level: dto.preferred_mentee_level,
preferred_mentoring_formats: dto.preferred_mentoring_formats,
availability_commitment: dto.availability_commitment,
mentoring_rate: dto.mentoring_rate,
status: dto.status,
is_deleted: dto.is_deleted,
created_at: dto.created_at,
updated_at: dto.updated_at,
}
}
pub fn update(mut self, dto: MentorUpdateRequestDto) -> Self {
// Update fields only if they are Some(value), otherwise preserve current value
if let Some(val) = dto.legal_name {
self.legal_name = val;
}
if let Some(val) = dto.gender {
self.gender = Some(val);
}
if let Some(val) = dto.domicile {
self.domicile = Some(val);
}
if let Some(val) = dto.identity_document_url {
self.identity_document_url = val;
}
if let Some(val) = dto.phone_for_verification {
self.phone_for_verification = val;
}
if let Some(val) = dto.bio {
self.bio = val;
}
if let Some(val) = dto.last_education {
self.last_education = Some(val);
}
if let Some(val) = dto.linkedin_url {
self.linkedin_url = Some(val);
}
if let Some(val) = dto.github_url {
self.github_url = Some(val);
}
if let Some(val) = dto.cv_url {
self.cv_url = Some(val);
}
if let Some(val) = dto.portfolio_url {
self.portfolio_url = Some(val);
}
if let Some(val) = dto.industries {
self.industries = val;
}
if let Some(val) = dto.expertise {
self.expertise = val;
}
if let Some(val) = dto.languages {
self.languages = val;
}
if let Some(val) = dto.current_company {
self.current_company = val;
}
if let Some(val) = dto.current_role {
self.current_role = val;
}
if let Some(val) = dto.years_of_experience {
self.years_of_experience = val;
}
if let Some(val) = dto.topics_of_interest {
self.topics_of_interest = val;
}
if let Some(val) = dto.preferred_mentee_level {
self.preferred_mentee_level = val;
}
if let Some(val) = dto.preferred_mentoring_formats {
self.preferred_mentoring_formats = val;
}
if let Some(val) = dto.availability_commitment {
self.availability_commitment = val;
}
if let Some(val) = dto.mentoring_rate_amount {
self.mentoring_rate.amount = val;
}
self.updated_at = get_iso_date();
self
}
pub fn update_status(mut self, status: String) -> Self {
self.status = status;
self.updated_at = get_iso_date();
self
}
}
@@ -0,0 +1,386 @@
use crate::v1::mentors::{
MentorDetailQueryDto, MentorDetailResponseDto, MentorListResponseDto,
MentorRegisterResponseDto, MentorSchema, MentorUpdateRequestDto,
MentorUserRegisterRequestDto, MentorVerifyRequestDto, MentorsRepository,
};
use axum::http::StatusCode;
use axum::response::Response;
use imphnen_entities::{
AppState, MetaRequestDto, ResponseListSuccessDto, ResponseSuccessDto,
};
use imphnen_iam::{
AuthRepository, RolesEnum, RolesRepository, UsersRepository, UsersSchema,
};
use imphnen_libs::ResourceEnum;
use imphnen_utils::{
common_response, success_list_response, success_response, validate_request,
};
use surrealdb::Uuid;
use surrealdb::sql::Thing;
use tracing::error;
pub struct MentorsService;
impl MentorsService {
pub async fn register_mentor(
state: &AppState,
dto: MentorUserRegisterRequestDto,
) -> Response {
if let Err((status, message)) = validate_request(&dto) {
return common_response(status, &message);
}
let user_repo = UsersRepository::new(state);
let mentor_repo = MentorsRepository::new(state);
let role_repo = RolesRepository::new(state);
let auth_repo = AuthRepository::new(state);
let user_email = dto.email.clone();
let mut _user_to_update: Option<UsersSchema> = None;
let existing_user_result =
user_repo.query_user_by_email(user_email.clone()).await;
let user_id;
let final_user_email = user_email.clone();
if let Ok(user_detail_query_dto) = existing_user_result {
if mentor_repo
.query_mentor_by_email(user_email.clone(), false)
.await
.is_ok()
{
return common_response(
StatusCode::CONFLICT,
"Mentor profile already exists for this user",
);
}
let mut user_schema = UsersSchema::from(user_detail_query_dto.clone());
user_schema.fullname = dto.fullname.clone();
user_schema.phone_number = dto.phone_number.clone();
user_schema.updated_at = imphnen_utils::get_iso_date();
let hashed_password = match imphnen_utils::hash_password(&dto.password) {
Ok(hash) => hash,
Err(_e) => {
error!(
"Failed to hash password during update for {}: {}",
final_user_email, _e
);
return common_response(
StatusCode::INTERNAL_SERVER_ERROR,
"Failed to hash password",
);
}
};
user_schema.password = hashed_password;
let mentor_role = match role_repo
.query_role_by_name(RolesEnum::Mentor.to_string())
.await
{
Ok(role) => role,
Err(_e) => {
return common_response(StatusCode::BAD_REQUEST, "Mentor Role Not Found");
}
};
user_schema.role =
imphnen_utils::make_thing(&ResourceEnum::Roles.to_string(), &mentor_role.id);
user_schema.is_active = false;
if let Err(_err) = user_repo.query_update_user(user_schema.clone()).await {
error!(
"Failed to update existing user {} to mentor role: {}",
final_user_email, _err
);
return common_response(
StatusCode::INTERNAL_SERVER_ERROR,
&_err.to_string(),
);
}
user_id = user_schema.id.clone();
} else {
let mentor_role = match role_repo
.query_role_by_name(RolesEnum::Mentor.to_string())
.await
{
Ok(role) => role,
Err(_e) => {
return common_response(StatusCode::BAD_REQUEST, "Mentor Role Not Found");
}
};
let hashed_password = match imphnen_utils::hash_password(&dto.password) {
Ok(hash) => hash,
Err(_e) => {
error!(
"Failed to hash password for new user {}: {}",
final_user_email, _e
);
return common_response(
StatusCode::INTERNAL_SERVER_ERROR,
"Failed to hash password",
);
}
};
let new_user_schema = UsersSchema {
id: imphnen_utils::make_thing(
&ResourceEnum::Users.to_string(),
&Uuid::new_v4().to_string(),
),
email: dto.email.clone(),
fullname: dto.fullname.clone(),
password: hashed_password,
phone_number: dto.phone_number.clone(),
created_at: imphnen_utils::get_iso_date(),
updated_at: imphnen_utils::get_iso_date(),
role: imphnen_utils::make_thing(
&ResourceEnum::Roles.to_string(),
&mentor_role.id,
),
is_active: false,
..Default::default()
};
user_id = new_user_schema.id.clone();
match user_repo.query_create_user(new_user_schema).await {
Ok(_) => {}
Err(_err) => {
error!("Failed to create new user {}: {}", final_user_email, _err);
return common_response(
StatusCode::INTERNAL_SERVER_ERROR,
&_err.to_string(),
);
}
}
}
let otp = imphnen_utils::generate_otp::OtpManager::generate_otp();
match auth_repo
.query_store_otp(final_user_email.clone(), otp)
.await
{
Ok(_) => {
let message = format!("your otp code is {otp}");
if let Err(_err) =
imphnen_utils::send_email(&final_user_email, "OTP Verification", &message)
{
error!("Failed to send OTP email to {}: {}", final_user_email, _err);
return common_response(
StatusCode::INTERNAL_SERVER_ERROR,
&_err.to_string(),
);
}
}
Err(_err) => {
error!("Failed to store OTP for {}: {}", final_user_email, _err);
return common_response(
StatusCode::INTERNAL_SERVER_ERROR,
&_err.to_string(),
);
}
}
let mentor_schema = MentorSchema::create(
dto.identity_and_verification,
dto.professional_profile,
dto.mentoring_logistics,
user_id.to_raw(),
final_user_email.clone(),
);
match mentor_repo.query_create_mentor(mentor_schema.clone()).await {
Ok(mentor_profile_id) => {
let user_after_mentor_creation_dto = user_repo
.query_user_by_email(final_user_email.clone())
.await
.unwrap();
let mut user_after_mentor_creation_schema =
UsersSchema::from(user_after_mentor_creation_dto);
user_after_mentor_creation_schema = user_after_mentor_creation_schema
.update_mentor_id(Some(mentor_profile_id));
if let Err(_e) = user_repo
.query_update_user(user_after_mentor_creation_schema)
.await
{
error!(
"Failed to update user's mentor_id for {}: {}",
final_user_email, _e
);
return common_response(
StatusCode::INTERNAL_SERVER_ERROR,
&_e.to_string(),
);
}
let response_dto = MentorRegisterResponseDto::from(mentor_schema);
success_response(ResponseSuccessDto { data: response_dto })
}
Err(_e) => {
error!(
"Failed to create mentor profile for {}: {}",
final_user_email, _e
);
common_response(StatusCode::INTERNAL_SERVER_ERROR, &_e.to_string())
}
}
}
pub async fn get_mentor_list(state: &AppState, meta: MetaRequestDto) -> Response {
let repo = MentorsRepository::new(state);
match repo.query_mentor_list(meta).await {
Ok(result) => {
let data: Vec<MentorListResponseDto> = result
.data
.into_iter()
.map(MentorDetailQueryDto::from)
.map(MentorListResponseDto::from)
.collect();
success_list_response(ResponseListSuccessDto {
data,
meta: result.meta,
})
}
Err(_e) => common_response(StatusCode::INTERNAL_SERVER_ERROR, &_e.to_string()),
}
}
pub async fn get_mentor_by_id(state: &AppState, id: &str) -> Response {
let repo = MentorsRepository::new(state);
let thing_id = Thing::from((ResourceEnum::Mentors.to_string().as_str(), id));
match repo.query_mentor_by_id(&thing_id, false).await {
Ok(mentor) => {
let dto = MentorDetailResponseDto::from(MentorDetailQueryDto::from(mentor));
success_response(ResponseSuccessDto { data: dto })
}
Err(_e) => common_response(StatusCode::NOT_FOUND, &_e.to_string()),
}
}
pub async fn update_mentor(
state: &AppState,
id: &str,
dto: MentorUpdateRequestDto,
) -> Response {
if let Err((status, message)) = validate_request(&dto) {
return common_response(status, &message);
}
let repo = MentorsRepository::new(state);
let thing_id = Thing::from((ResourceEnum::Mentors.to_string().as_str(), id));
let existing_mentor = match repo.query_mentor_by_id(&thing_id, false).await {
Ok(mentor) => mentor,
Err(_e) => return common_response(StatusCode::NOT_FOUND, &_e.to_string()),
};
let mut schema = MentorSchema::from(MentorDetailQueryDto::from(existing_mentor));
schema = schema.update(dto);
match repo.query_update_mentor(schema).await {
Ok(_) => {
let updated_mentor =
repo.query_mentor_by_id(&thing_id, false).await.unwrap();
let response_dto =
MentorDetailResponseDto::from(MentorDetailQueryDto::from(updated_mentor));
success_response(ResponseSuccessDto { data: response_dto })
}
Err(_e) => common_response(StatusCode::INTERNAL_SERVER_ERROR, &_e.to_string()),
}
}
pub async fn delete_mentor(state: &AppState, id: &str) -> Response {
let repo = MentorsRepository::new(state);
match repo.query_delete_mentor(id.to_string()).await {
Ok(msg) => common_response(StatusCode::OK, &msg),
Err(_e) => common_response(StatusCode::NOT_FOUND, &_e.to_string()),
}
}
pub async fn get_mentor_me(state: &AppState, email: &str) -> Response {
let repo = MentorsRepository::new(state);
match repo.query_mentor_by_email(email.to_string(), false).await {
Ok(mentor) => {
let dto = MentorDetailResponseDto::from(MentorDetailQueryDto::from(mentor));
success_response(ResponseSuccessDto { data: dto })
}
Err(_e) => common_response(
StatusCode::FORBIDDEN,
"Mentor profile not found for current user",
),
}
}
pub async fn update_mentor_me(
state: &AppState,
email: &str,
dto: MentorUpdateRequestDto,
) -> Response {
if let Err((status, message)) = validate_request(&dto) {
return common_response(status, &message);
}
let repo = MentorsRepository::new(state);
let existing_mentor =
match repo.query_mentor_by_email(email.to_string(), false).await {
Ok(mentor) => mentor,
Err(_e) => return common_response(StatusCode::FORBIDDEN, &_e.to_string()),
};
let mut schema = MentorSchema::from(MentorDetailQueryDto::from(existing_mentor));
schema = schema.update(dto);
match repo.query_update_mentor(schema).await {
Ok(_) => {
let updated_mentor = repo
.query_mentor_by_email(email.to_string(), false)
.await
.unwrap();
let response_dto =
MentorDetailResponseDto::from(MentorDetailQueryDto::from(updated_mentor));
success_response(ResponseSuccessDto { data: response_dto })
}
Err(_e) => common_response(StatusCode::INTERNAL_SERVER_ERROR, &_e.to_string()),
}
}
pub async fn get_mentor_status(state: &AppState, email: &str) -> Response {
let repo = MentorsRepository::new(state);
match repo.query_mentor_by_email(email.to_string(), false).await {
Ok(mentor) => common_response(StatusCode::OK, &mentor.status),
Err(_e) => common_response(
StatusCode::FORBIDDEN,
"No mentor application found for current user",
),
}
}
pub async fn verify_mentor(
state: &AppState,
id: &str,
dto: MentorVerifyRequestDto,
) -> Response {
let repo = MentorsRepository::new(state);
let thing_id = Thing::from((ResourceEnum::Mentors.to_string().as_str(), id));
let existing_mentor = match repo.query_mentor_by_id(&thing_id, false).await {
Ok(mentor) => mentor,
Err(_e) => return common_response(StatusCode::NOT_FOUND, &_e.to_string()),
};
let mut schema = MentorSchema::from(MentorDetailQueryDto::from(existing_mentor));
schema = schema.update_status(dto.status);
match repo.query_update_mentor(schema).await {
Ok(_) => {
let updated_mentor =
repo.query_mentor_by_id(&thing_id, false).await.unwrap();
let response_dto =
MentorDetailResponseDto::from(MentorDetailQueryDto::from(updated_mentor));
success_response(ResponseSuccessDto { data: response_dto })
}
Err(_e) => common_response(StatusCode::INTERNAL_SERVER_ERROR, &_e.to_string()),
}
}
}
+30
View File
@@ -0,0 +1,30 @@
use axum::{
Router,
routing::{delete, get, post, put},
};
pub mod mentors_controller;
pub mod mentors_dto;
pub mod mentors_repository;
pub mod mentors_schema;
pub mod mentors_service;
pub use mentors_controller::*;
pub use mentors_dto::*;
pub use mentors_repository::*;
pub use mentors_schema::*;
pub use mentors_service::*;
pub fn mentors_router() -> Router {
Router::new()
.route("/", get(get_mentor_list))
.route("/register", post(post_register_mentor))
.route("/me", get(get_mentor_me))
.route("/update/me", put(put_update_mentor_me))
.route("/status", get(get_mentor_status))
.route("/detail/{id}", get(get_mentor_by_id))
.route("/update/{id}", put(put_update_mentor))
.route("/update", put(put_update_mentor_no_id))
.route("/delete/{id}", delete(delete_mentor))
.route("/verify/{id}", put(put(put_verify_mentor)))
}
+7
View File
@@ -0,0 +1,7 @@
use axum::Router;
pub mod mentors;
pub fn dimentorin_router() -> Router {
Router::new().nest("/mentors", mentors::mentors_router())
}
+1
View File
@@ -0,0 +1 @@
+1 -5
View File
@@ -1,9 +1,5 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use surrealdb::{ use surrealdb::{Surreal, engine::any::Any, engine::local::Db};
engine::local::Db,
engine::any::Any,
Surreal,
};
use utoipa::{IntoParams, ToSchema}; use utoipa::{IntoParams, ToSchema};
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
+11 -6
View File
@@ -1,26 +1,31 @@
pub mod error { pub mod error {
use axum::Json;
use axum::http::StatusCode; use axum::http::StatusCode;
use axum::response::IntoResponse; use axum::response::IntoResponse;
use axum::response::Response; use axum::response::Response;
use axum::Json;
use thiserror::Error; use thiserror::Error;
#[derive(Error, Debug)] #[derive(Error, Debug)]
pub enum Error { pub enum Error {
#[error("database error")] #[error("database error: {0}")]
Db, Db(String),
} }
impl IntoResponse for Error { impl IntoResponse for Error {
fn into_response(self) -> Response { fn into_response(self) -> Response {
(StatusCode::INTERNAL_SERVER_ERROR, Json(self.to_string())).into_response() let (status, error_message) = match self {
Error::Db(detail) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Database error: {detail}"),
),
};
(status, Json(error_message)).into_response()
} }
} }
impl From<surrealdb::Error> for Error { impl From<surrealdb::Error> for Error {
fn from(error: surrealdb::Error) -> Self { fn from(error: surrealdb::Error) -> Self {
eprintln!("{error}"); Self::Db(error.to_string())
Self::Db
} }
} }
} }
+7 -5
View File
@@ -4,10 +4,10 @@ version = "0.1.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
imphnen-iam ={ version = "0.1.0", path = "../imphnen-iam" } imphnen-iam.workspace = true
imphnen-libs = { version = "0.1.0", path = "../imphnen-libs" } imphnen-libs.workspace = true
imphnen-utils = { version = "0.1.0", path = "../imphnen-utils" } imphnen-utils.workspace = true
imphnen-entities = { version = "0.1.0", path = "../imphnen-entities" } imphnen-entities.workspace = true
axum.workspace = true axum.workspace = true
serde.workspace = true serde.workspace = true
serde_json.workspace = true serde_json.workspace = true
@@ -23,4 +23,6 @@ chrono.workspace = true
anyhow.workspace = true anyhow.workspace = true
tower-http.workspace = true tower-http.workspace = true
utoipa-swagger-ui.workspace = true utoipa-swagger-ui.workspace = true
rand_distr.workspace = true rand_distr.workspace = true
log.workspace = true
tracing.workspace = true
-4
View File
@@ -1,7 +1,3 @@
use imphnen_entities::*;
use imphnen_libs::*;
use imphnen_utils::*;
pub mod v1; pub mod v1;
pub use imphnen_entities::*; pub use imphnen_entities::*;
@@ -2,6 +2,8 @@ use super::{GachaClaimQueryDto, GachaClaimSchema};
use crate::{AppState, ResourceEnum}; use crate::{AppState, ResourceEnum};
use anyhow::{Result, bail}; use anyhow::{Result, bail};
use imphnen_iam::DetailQueryBuilder; use imphnen_iam::DetailQueryBuilder;
use std::time::Instant;
use tracing::instrument;
pub struct GachaClaimRepository<'a> { pub struct GachaClaimRepository<'a> {
state: &'a AppState, state: &'a AppState,
@@ -12,10 +14,12 @@ impl<'a> GachaClaimRepository<'a> {
Self { state } Self { state }
} }
#[instrument(skip(self, id), err)]
pub async fn query_gacha_claim_by_id( pub async fn query_gacha_claim_by_id(
&self, &self,
id: String, id: String,
) -> Result<GachaClaimQueryDto> { ) -> Result<GachaClaimQueryDto> {
let now = Instant::now();
let db = &self.state.surrealdb_ws; let db = &self.state.surrealdb_ws;
let builder = DetailQueryBuilder::new(ResourceEnum::GachaClaims.to_string()) let builder = DetailQueryBuilder::new(ResourceEnum::GachaClaims.to_string())
.with_id(id.clone()) .with_id(id.clone())
@@ -25,21 +29,35 @@ impl<'a> GachaClaimRepository<'a> {
let sql = builder.build(); let sql = builder.build();
let result: Option<GachaClaimQueryDto> = let result: Option<GachaClaimQueryDto> =
builder.apply_bindings(db.query(sql)).await?.take(0)?; builder.apply_bindings(db.query(sql)).await?.take(0)?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_gacha_claim_by_id' took: {elapsed:.2?}");
}
match result { match result {
Some(claim) if !claim.is_deleted => Ok(claim), Some(claim) if !claim.is_deleted => Ok(claim),
_ => bail!("Gacha Claim not found"), _ => bail!("Gacha Claim not found"),
} }
} }
#[instrument(skip(self, data), err)]
pub async fn query_create_gacha_claim( pub async fn query_create_gacha_claim(
&self, &self,
data: GachaClaimSchema, data: GachaClaimSchema,
) -> Result<String> { ) -> Result<String> {
let now = Instant::now();
let db = &self.state.surrealdb_ws; let db = &self.state.surrealdb_ws;
let record: Option<GachaClaimSchema> = db let record: Option<GachaClaimSchema> = db
.create(ResourceEnum::GachaClaims.to_string()) .create(ResourceEnum::GachaClaims.to_string())
.content(data) .content(data)
.await?; .await?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_create_gacha_claim' took: {elapsed:.2?}");
}
match record { match record {
Some(_) => Ok("Success create Gacha Claim".into()), Some(_) => Ok("Success create Gacha Claim".into()),
None => bail!("Failed to create Gacha Claim"), None => bail!("Failed to create Gacha Claim"),
@@ -2,7 +2,9 @@ use super::{GachaCreditRequestDto, GachaCreditSchema};
use crate::{AppState, ResourceEnum}; use crate::{AppState, ResourceEnum};
use anyhow::{Result, bail}; use anyhow::{Result, bail};
use imphnen_iam::make_thing; use imphnen_iam::make_thing;
use std::time::Instant;
use surrealdb::Uuid; use surrealdb::Uuid;
use tracing::instrument;
pub struct GachaCreditRepository<'a> { pub struct GachaCreditRepository<'a> {
state: &'a AppState, state: &'a AppState,
@@ -13,28 +15,54 @@ impl<'a> GachaCreditRepository<'a> {
Self { state } Self { state }
} }
#[instrument(skip(self, user_id), err)]
pub async fn query_by_user_id( pub async fn query_by_user_id(
&self, &self,
user_id: String, user_id: String,
) -> Result<Option<GachaCreditSchema>> { ) -> Result<Option<GachaCreditSchema>> {
let now = Instant::now();
let db = &self.state.surrealdb_ws; let db = &self.state.surrealdb_ws;
let sql = format!( let sql = format!(
"SELECT * FROM {} WHERE user = {}:⟨$user_id⟩ AND is_deleted = false LIMIT 1", "SELECT * FROM {} WHERE user = {}:⟨$user_id⟩ AND is_deleted = false LIMIT 1",
ResourceEnum::GachaCredits.to_string(), ResourceEnum::GachaCredits,
ResourceEnum::Users.to_string() ResourceEnum::Users
); );
let result: Vec<GachaCreditSchema> = let result: Vec<GachaCreditSchema> =
db.query(sql).bind(("user_id", user_id)).await?.take(0)?; db.query(sql).bind(("user_id", user_id)).await?.take(0)?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_by_user_id' took: {elapsed:.2?}");
}
Ok(result.into_iter().next()) Ok(result.into_iter().next())
} }
#[instrument(skip(self, user_id), err)]
pub async fn query_consume_credit(&self, user_id: String) -> Result<()> { pub async fn query_consume_credit(&self, user_id: String) -> Result<()> {
let now = Instant::now();
let db = &self.state.surrealdb_ws; let db = &self.state.surrealdb_ws;
let credit_opt = self.query_by_user_id(user_id).await?; let credit_opt = self.query_by_user_id(user_id).await?;
let Some(mut credit) = credit_opt else { let Some(mut credit) = credit_opt else {
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!(
"Query 'query_consume_credit' took: {elapsed:.2?} (no credit to consume)"
);
}
return Ok(()); return Ok(());
}; };
if credit.available_rolls <= 0 { if credit.available_rolls <= 0 {
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!(
"Query 'query_consume_credit' took: {elapsed:.2?} (no rolls remaining)"
);
}
bail!("No extra roll credits remaining"); bail!("No extra roll credits remaining");
} }
credit.available_rolls -= 1; credit.available_rolls -= 1;
@@ -45,13 +73,21 @@ impl<'a> GachaCreditRepository<'a> {
)) ))
.merge(credit) .merge(credit)
.await?; .await?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_consume_credit' took: {elapsed:.2?}");
}
Ok(()) Ok(())
} }
#[instrument(skip(self, payload), err)]
pub async fn query_add_credit( pub async fn query_add_credit(
&self, &self,
payload: GachaCreditRequestDto, payload: GachaCreditRequestDto,
) -> Result<()> { ) -> Result<()> {
let now = Instant::now();
let db = &self.state.surrealdb_ws; let db = &self.state.surrealdb_ws;
if let Some(mut credit) = self.query_by_user_id(payload.user_id.clone()).await? { if let Some(mut credit) = self.query_by_user_id(payload.user_id.clone()).await? {
credit.available_rolls += payload.amount; credit.available_rolls += payload.amount;
@@ -63,7 +99,7 @@ impl<'a> GachaCreditRepository<'a> {
.merge(credit) .merge(credit)
.await?; .await?;
} else { } else {
let data = GachaCreditSchema::from(&GachaCreditSchema { let data = GachaCreditSchema {
id: make_thing( id: make_thing(
&ResourceEnum::GachaCredits.to_string(), &ResourceEnum::GachaCredits.to_string(),
&Uuid::new_v4().to_string(), &Uuid::new_v4().to_string(),
@@ -71,12 +107,18 @@ impl<'a> GachaCreditRepository<'a> {
user: make_thing(&ResourceEnum::Users.to_string(), &payload.user_id), user: make_thing(&ResourceEnum::Users.to_string(), &payload.user_id),
available_rolls: payload.amount, available_rolls: payload.amount,
..Default::default() ..Default::default()
}); };
let _: Option<GachaCreditSchema> = db let _: Option<GachaCreditSchema> = db
.create(&ResourceEnum::GachaCredits.to_string()) .create(ResourceEnum::GachaCredits.to_string())
.content(data) .content(data)
.await?; .await?;
} }
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_add_credit' took: {elapsed:.2?}");
}
Ok(()) Ok(())
} }
} }
@@ -5,6 +5,10 @@ use crate::{
}; };
use anyhow::{Result, bail}; use anyhow::{Result, bail};
use imphnen_iam::QueryListBuilder; use imphnen_iam::QueryListBuilder;
use imphnen_utils::get_iso_date;
use serde_json::{Map, Value};
use std::time::Instant;
use tracing::instrument;
pub struct GachaItemRepository<'a> { pub struct GachaItemRepository<'a> {
state: &'a AppState, state: &'a AppState,
@@ -15,10 +19,12 @@ impl<'a> GachaItemRepository<'a> {
Self { state } Self { state }
} }
#[instrument(skip(self, meta), err)]
pub async fn query_gacha_item_list( pub async fn query_gacha_item_list(
&self, &self,
meta: MetaRequestDto, meta: MetaRequestDto,
) -> Result<ResponseListSuccessDto<Vec<GachaItemDto>>> { ) -> Result<ResponseListSuccessDto<Vec<GachaItemDto>>> {
let now = Instant::now();
let raw_result: ResponseListSuccessDto<Vec<GachaItemSchema>> = let raw_result: ResponseListSuccessDto<Vec<GachaItemSchema>> =
QueryListBuilder::new( QueryListBuilder::new(
&self.state.surrealdb_ws, &self.state.surrealdb_ws,
@@ -30,6 +36,12 @@ impl<'a> GachaItemRepository<'a> {
.select_fields(vec!["*"]) .select_fields(vec!["*"])
.build() .build()
.await?; .await?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_gacha_item_list' took: {elapsed:.2?}");
}
let data = raw_result let data = raw_result
.data .data
.into_iter() .into_iter()
@@ -41,36 +53,54 @@ impl<'a> GachaItemRepository<'a> {
}) })
} }
#[instrument(skip(self, id), err)]
pub async fn query_gacha_item_by_id(&self, id: String) -> Result<GachaItemSchema> { pub async fn query_gacha_item_by_id(&self, id: String) -> Result<GachaItemSchema> {
let now = Instant::now();
let db = &self.state.surrealdb_ws; let db = &self.state.surrealdb_ws;
let result: Option<GachaItemSchema> = db let result: Option<GachaItemSchema> = db
.select((ResourceEnum::GachaItems.to_string(), id.clone())) .select((ResourceEnum::GachaItems.to_string(), id.clone()))
.await?; .await?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_gacha_item_by_id' took: {elapsed:.2?}");
}
match result { match result {
Some(item) if !item.is_deleted => Ok(item), Some(item) if !item.is_deleted => Ok(item),
_ => bail!("Gacha Item not found"), _ => bail!("Gacha Item not found"),
} }
} }
#[instrument(skip(self, data), err)]
pub async fn query_create_gacha_item( pub async fn query_create_gacha_item(
&self, &self,
data: GachaItemSchema, data: GachaItemSchema,
) -> Result<String> { ) -> Result<String> {
let now = Instant::now();
let db = &self.state.surrealdb_ws; let db = &self.state.surrealdb_ws;
let record: Option<GachaItemSchema> = db let record: Option<GachaItemSchema> = db
.create(ResourceEnum::GachaItems.to_string()) .create(ResourceEnum::GachaItems.to_string())
.content(data) .content(data)
.await?; .await?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_create_gacha_item' took: {elapsed:.2?}");
}
match record { match record {
Some(_) => Ok("Success create Gacha Item".into()), Some(_) => Ok("Success create Gacha Item".into()),
None => bail!("Failed to create Gacha Item"), None => bail!("Failed to create Gacha Item"),
} }
} }
#[instrument(skip(self, data), err)]
pub async fn query_update_gacha_item( pub async fn query_update_gacha_item(
&self, &self,
data: GachaItemSchema, data: GachaItemSchema,
) -> Result<String> { ) -> Result<String> {
let now = Instant::now();
let db = &self.state.surrealdb_ws; let db = &self.state.surrealdb_ws;
let record_key = get_id(&data.id)?; let record_key = get_id(&data.id)?;
let existing = self.query_gacha_item_by_id(data.id.id.to_raw()).await?; let existing = self.query_gacha_item_by_id(data.id.id.to_raw()).await?;
@@ -83,13 +113,21 @@ impl<'a> GachaItemRepository<'a> {
}; };
let record: Option<GachaItemSchema> = let record: Option<GachaItemSchema> =
db.update(record_key).merge(merged).await?; db.update(record_key).merge(merged).await?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_update_gacha_item' took: {elapsed:.2?}");
}
match record { match record {
Some(_) => Ok("Success update Gacha Item".into()), Some(_) => Ok("Success update Gacha Item".into()),
None => bail!("Failed to update Gacha Item"), None => bail!("Failed to update Gacha Item"),
} }
} }
#[instrument(skip(self, id), err)]
pub async fn query_delete_gacha_item(&self, id: String) -> Result<String> { pub async fn query_delete_gacha_item(&self, id: String) -> Result<String> {
let now = Instant::now();
let db = &self.state.surrealdb_ws; let db = &self.state.surrealdb_ws;
let item_id = make_thing(&ResourceEnum::GachaItems.to_string(), &id); let item_id = make_thing(&ResourceEnum::GachaItems.to_string(), &id);
let item = self.query_gacha_item_by_id(item_id.id.to_raw()).await?; let item = self.query_gacha_item_by_id(item_id.id.to_raw()).await?;
@@ -97,13 +135,20 @@ impl<'a> GachaItemRepository<'a> {
bail!("Gacha Item already deleted"); bail!("Gacha Item already deleted");
} }
let record_key = get_id(&item.id)?; let record_key = get_id(&item.id)?;
let record: Option<GachaItemSchema> = db let mut patch = Map::new();
.update(record_key) patch.insert("is_deleted".to_string(), Value::Bool(true));
.merge(serde_json::json!({ "is_deleted": true })) patch.insert("updated_at".to_string(), Value::String(get_iso_date()));
.await?;
let record: Option<GachaItemSchema> = db.update(record_key).merge(patch).await?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_delete_gacha_item' took: {elapsed:.2?}");
}
match record { match record {
Some(_) => Ok("Success delete Gacha Item".into()), Some(_) => Ok("Success soft delete Gacha Item".into()),
None => bail!("Failed to delete Gacha Item"), None => bail!("Failed to soft delete Gacha Item"),
} }
} }
} }
@@ -45,7 +45,12 @@ impl GachaItemService {
return common_response(status, &message); return common_response(status, &message);
} }
let repo = GachaItemRepository::new(state); let repo = GachaItemRepository::new(state);
let schema = GachaItemSchema::from(payload); let schema = GachaItemSchema {
id: make_thing(&ResourceEnum::GachaItems.to_string(), &payload.name), // Fixed: Use payload.name or some other identifier
name: payload.name,
image_url: payload.image_url,
..Default::default()
};
match repo.query_create_gacha_item(schema).await { match repo.query_create_gacha_item(schema).await {
Ok(msg) => common_response(StatusCode::CREATED, &msg), Ok(msg) => common_response(StatusCode::CREATED, &msg),
Err(e) => common_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()), Err(e) => common_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
@@ -91,3 +91,32 @@ pub async fn post_execute_gacha_roll(
Err(response) => response, Err(response) => response,
} }
} }
#[utoipa::path(
delete,
path = "/v1/gacha/rolls/delete/{id}",
security(
("Bearer" = [])
),
params(("id" = String, Path, description = "Gacha Roll ID")),
responses(
(status = 200, description = "Delete Gacha Roll (soft delete)", body = MessageResponseDto)
),
tag = "Gacha"
)]
pub async fn delete_gacha_roll(
headers: HeaderMap,
Extension(state): Extension<AppState>,
Path(id): Path<String>,
) -> impl IntoResponse {
match permissions_guard(
&headers,
state.clone(),
vec![PermissionsEnum::DeleteGachaRolls],
)
.await
{
Ok(_) => GachaRollService::soft_delete_gacha_roll(&state, id).await,
Err(response) => response,
}
}
@@ -29,7 +29,7 @@ impl GachaRollItemDto {
Self { Self {
id: dto.id.id.to_raw(), id: dto.id.id.to_raw(),
item: GachaItemDto::from(dto.item.clone()), item: GachaItemDto::from(dto.item.clone()),
weight: dto.weight.clone(), weight: dto.weight,
quantity: dto.quantity, quantity: dto.quantity,
is_deleted: dto.is_deleted, is_deleted: dto.is_deleted,
created_at: dto.created_at.clone(), created_at: dto.created_at.clone(),
@@ -1,11 +1,15 @@
use super::GachaRollQueryDto; use super::GachaRollQueryDto;
use super::GachaRollSchema; use super::GachaRollSchema;
use crate::{AppState, DetailQueryBuilder, ResourceEnum}; use crate::{AppState, DetailQueryBuilder, ResourceEnum, get_id, make_thing};
use anyhow::{Result, bail}; use anyhow::{Result, bail};
use imphnen_iam::ListQueryBuilder;
use rand::prelude::*; use rand::prelude::*;
use rand::rng;
use imphnen_utils::get_iso_date;
use rand_distr::weighted::WeightedIndex; use rand_distr::weighted::WeightedIndex;
use serde_json::{Map, Value};
use std::time::Instant;
use tracing::instrument;
pub struct GachaRollRepository<'a> { pub struct GachaRollRepository<'a> {
state: &'a AppState, state: &'a AppState,
@@ -16,46 +20,70 @@ impl<'a> GachaRollRepository<'a> {
Self { state } Self { state }
} }
#[instrument(skip(self, id), err)]
pub async fn query_gacha_roll_by_id( pub async fn query_gacha_roll_by_id(
&self, &self,
id: String, id: String,
) -> Result<GachaRollQueryDto> { ) -> Result<GachaRollQueryDto> {
let now = Instant::now();
let db = &self.state.surrealdb_ws; let db = &self.state.surrealdb_ws;
let builder = DetailQueryBuilder::new(ResourceEnum::GachaRolls.to_string()) let builder = DetailQueryBuilder::new(ResourceEnum::GachaRolls.to_string())
.with_id(id.clone()) .with_id(id.clone())
.with_condition("is_deleted = false")
.with_select_fields(vec!["*"]) .with_select_fields(vec!["*"])
.with_fetch("item"); .with_fetch("item");
let sql = builder.build(); let sql = builder.build();
let result: Option<GachaRollQueryDto> = let result: Option<GachaRollQueryDto> =
builder.apply_bindings(db.query(sql)).await?.take(0)?; builder.apply_bindings(db.query(sql)).await?.take(0)?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_gacha_roll_by_id' took: {elapsed:.2?}");
}
match result { match result {
Some(roll) if !roll.is_deleted => Ok(roll), Some(roll) if !roll.is_deleted => Ok(roll),
_ => bail!("Gacha Roll not found"), _ => bail!("Gacha Roll not found"),
} }
} }
#[instrument(skip(self, data), err)]
pub async fn query_create_gacha_roll( pub async fn query_create_gacha_roll(
&self, &self,
data: GachaRollSchema, data: GachaRollSchema,
) -> Result<String> { ) -> Result<String> {
let now = Instant::now();
let db = &self.state.surrealdb_ws; let db = &self.state.surrealdb_ws;
let record: Option<GachaRollSchema> = db let record: Option<GachaRollSchema> = db
.create(ResourceEnum::GachaRolls.to_string()) .create(ResourceEnum::GachaRolls.to_string())
.content(data) .content(data)
.await?; .await?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_create_gacha_roll' took: {elapsed:.2?}");
}
match record { match record {
Some(_) => Ok("Success create Gacha Roll".into()), Some(_) => Ok("Success create Gacha Roll".into()),
None => bail!("Failed to create Gacha Roll"), None => bail!("Failed to create Gacha Roll"),
} }
} }
#[instrument(skip(self), err)]
pub async fn query_all_active_rolls(&self) -> Result<Vec<GachaRollQueryDto>> { pub async fn query_all_active_rolls(&self) -> Result<Vec<GachaRollQueryDto>> {
let now = Instant::now();
let db = &self.state.surrealdb_ws; let db = &self.state.surrealdb_ws;
let builder = ListQueryBuilder::new(ResourceEnum::GachaRolls.to_string()) let table_name = ResourceEnum::GachaRolls.to_string();
.with_select_fields(vec!["*"]) let sql =
.with_fetch(Some(vec!["item"])); format!("SELECT * FROM {table_name} WHERE is_deleted = false FETCH item");
let sql = builder.build();
let result: Vec<GachaRollQueryDto> = db.query(sql).await?.take(0)?; let result: Vec<GachaRollQueryDto> = db.query(sql).await?.take(0)?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_all_active_rolls' took: {elapsed:.2?}");
}
Ok(result) Ok(result)
} }
@@ -72,8 +100,36 @@ impl<'a> GachaRollRepository<'a> {
return None; return None;
} }
let dist = WeightedIndex::new(&weights).ok()?; let dist = WeightedIndex::new(&weights).ok()?;
let mut rng = rng(); let mut rng = rand::rngs::ThreadRng::default();
let index = dist.sample(&mut rng); let index = dist.sample(&mut rng);
Some(filtered[index].clone()) Some(filtered[index].clone())
} }
#[instrument(skip(self, id), err)]
pub async fn query_soft_delete_gacha_roll(&self, id: String) -> Result<String> {
let now = Instant::now();
let db = &self.state.surrealdb_ws;
let roll_id_thing = make_thing(&ResourceEnum::GachaRolls.to_string(), &id);
let roll = self.query_gacha_roll_by_id(id.clone()).await?;
if roll.is_deleted {
bail!("Gacha Roll already deleted");
}
let record_key = get_id(&roll_id_thing)?;
let mut patch = Map::new();
patch.insert("is_deleted".to_string(), Value::Bool(true));
patch.insert("updated_at".to_string(), Value::String(get_iso_date()));
let record: Option<GachaRollSchema> = db.update(record_key).merge(patch).await?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_soft_delete_gacha_roll' took: {elapsed:.2?}");
}
match record {
Some(_) => Ok("Success soft delete Gacha Roll".into()),
None => bail!("Failed to soft delete Gacha Roll"),
}
}
} }
@@ -63,4 +63,12 @@ impl GachaRollService {
Err(e) => common_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()), Err(e) => common_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
} }
} }
pub async fn soft_delete_gacha_roll(state: &AppState, id: String) -> Response {
let repo = GachaRollRepository::new(state);
match repo.query_soft_delete_gacha_roll(id).await {
Ok(msg) => common_response(StatusCode::OK, &msg),
Err(e) => common_response(StatusCode::NOT_FOUND, &e.to_string()),
}
}
} }
+8 -7
View File
@@ -4,13 +4,14 @@ version = "0.1.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
imphnen-iam = { version = "0.1.0", path = "../imphnen-iam" } imphnen-iam.workspace = true
imphnen-libs = { version = "0.1.0", path = "../imphnen-libs" } imphnen-libs.workspace = true
imphnen-utils = { version = "0.1.0", path = "../imphnen-utils" } imphnen-utils.workspace = true
imphnen-gacha = { version = "0.1.0", path = "../imphnen-gacha" } imphnen-gacha.workspace = true
imphnen-entities = { version = "0.1.0", path = "../imphnen-entities" } imphnen-entities.workspace = true
imphnen-middleware = { version = "0.1.0", path = "../imphnen-middleware" } imphnen-middleware.workspace = true
imphnen-cms = { version = "0.1.0", path = "../imphnen-cms" } imphnen-cms.workspace = true
imphnen-dimentorin.workspace = true
axum.workspace = true axum.workspace = true
serde.workspace = true serde.workspace = true
serde_json.workspace = true serde_json.workspace = true
+79 -23
View File
@@ -1,18 +1,44 @@
use imphnen_cms::{
events_controller,
events_dto::{EventsDetailItemDto, EventsListItemDto},
testimonials_controller,
testimonials_dto::{
TestimonialsCreateRequestDto, TestimonialsDetailItemDto,
TestimonialsListItemDto, TestimonialsUpdateRequestDto,
},
};
use imphnen_dimentorin::v1::mentors::{
mentors_controller,
mentors_dto::{
IdentityAndVerification, MentorDetailResponseDto, MentorListResponseDto,
MentorRegisterFromTokenRequestDto, MentorRegisterResponseDto,
MentorUpdateRequestDto, MentorUserRegisterRequestDto, MentorVerifyRequestDto,
MentoringLogistics, MentoringRate, ProfessionalProfile,
},
};
use imphnen_gacha::{
GachaClaimItemDto, GachaClaimRequestDto, GachaItemDto, GachaItemRequestDto,
GachaRollItemDto, GachaRollRequestDto, gacha_claims, gacha_items, gacha_rolls,
};
use imphnen_iam::{ use imphnen_iam::{
auth, permissions, roles, users, AuthLoginRequestDto, AuthLoginResponsetDto, AuthNewPasswordRequestDto, AuthRefreshTokenRequestDto, AuthResendOtpRequestDto, AuthVerifyEmailRequestDto, MessageResponseDto, MetaRequestDto, MetaResponseDto, PermissionsItemDto, PermissionsRequestDto, ResponseListSuccessDto, ResponseSuccessDto, RolesDetailItemDto, RolesListItemDto, RolesRequestCreateDto, RolesRequestUpdateDto, TokenDto, UsersCreateRequestDto, UsersDetailItemDto, UsersListItemDto, UsersUpdateRequestDto AuthLoginRequestDto, AuthLoginResponsetDto, AuthNewPasswordRequestDto,
AuthRefreshTokenRequestDto, AuthResendOtpRequestDto, AuthVerifyEmailRequestDto,
MessageResponseDto, MetaRequestDto, MetaResponseDto, PermissionsItemDto,
PermissionsRequestDto, ResponseListSuccessDto, ResponseSuccessDto,
RolesDetailItemDto, RolesListItemDto, RolesRequestCreateDto,
RolesRequestUpdateDto, TokenDto, UsersCreateRequestDto, UsersDetailItemDto,
UsersListItemDto, UsersUpdateRequestDto, auth, permissions, roles, users,
}; };
use utoipa::{ use utoipa::{
openapi::security::{Http, HttpAuthScheme, SecurityScheme},
Modify, OpenApi, Modify, OpenApi,
openapi::security::{Http, HttpAuthScheme, SecurityScheme},
}; };
use imphnen_gacha::{gacha_claims, gacha_items, gacha_rolls, GachaClaimItemDto, GachaClaimRequestDto, GachaItemDto, GachaItemRequestDto, GachaRollItemDto, GachaRollRequestDto};
use imphnen_cms::{events_controller, events_dto::{EventsDetailItemDto, EventsListItemDto}, testimonials_controller, testimonials_dto::{TestimonialsCreateRequestDto, TestimonialsDetailItemDto, TestimonialsListItemDto, TestimonialsUpdateRequestDto}};
#[derive(OpenApi)] #[derive(OpenApi)]
#[openapi( #[openapi(
paths( paths(
auth::auth_controller::post_login, auth::auth_controller::post_login,
auth::auth_controller::post_login_mentor,
auth::auth_controller::post_register, auth::auth_controller::post_register,
auth::auth_controller::post_verify_email, auth::auth_controller::post_verify_email,
auth::auth_controller::post_resend_otp, auth::auth_controller::post_resend_otp,
@@ -47,16 +73,25 @@ use imphnen_cms::{events_controller, events_dto::{EventsDetailItemDto, EventsLis
gacha_rolls::get_detail_gacha_roll, gacha_rolls::get_detail_gacha_roll,
gacha_rolls::post_create_gacha_roll, gacha_rolls::post_create_gacha_roll,
gacha_rolls::post_execute_gacha_roll, gacha_rolls::post_execute_gacha_roll,
events_controller::get_event_list, events_controller::get_event_list,
events_controller::get_event_by_id, events_controller::get_event_by_id,
events_controller::post_create_event, events_controller::post_create_event,
events_controller::patch_update_event, events_controller::patch_update_event,
events_controller::delete_event, events_controller::delete_event,
testimonials_controller::get_testimonial_list, testimonials_controller::get_testimonial_list,
testimonials_controller::get_testimonial_by_id, testimonials_controller::get_testimonial_by_id,
testimonials_controller::post_create_testimonial, testimonials_controller::post_create_testimonial,
testimonials_controller::patch_update_testimonial, testimonials_controller::patch_update_testimonial,
testimonials_controller::delete_testimonial, testimonials_controller::delete_testimonial,
mentors_controller::get_mentor_list,
mentors_controller::get_mentor_by_id,
mentors_controller::post_register_mentor,
mentors_controller::get_mentor_me,
mentors_controller::put_update_mentor_me,
mentors_controller::get_mentor_status,
mentors_controller::put_update_mentor,
mentors_controller::put_verify_mentor,
mentors_controller::delete_mentor,
), ),
components( components(
schemas( schemas(
@@ -71,7 +106,7 @@ use imphnen_cms::{events_controller, events_dto::{EventsDetailItemDto, EventsLis
AuthRefreshTokenRequestDto, AuthRefreshTokenRequestDto,
ResponseSuccessDto<TokenDto>, ResponseSuccessDto<TokenDto>,
RolesListItemDto, RolesListItemDto,
RolesRequestCreateDto, RolesRequestCreateDto,
RolesRequestUpdateDto, RolesRequestUpdateDto,
PermissionsRequestDto, PermissionsRequestDto,
PermissionsItemDto, PermissionsItemDto,
@@ -96,13 +131,26 @@ use imphnen_cms::{events_controller, events_dto::{EventsDetailItemDto, EventsLis
ResponseSuccessDto<UsersDetailItemDto>, ResponseSuccessDto<UsersDetailItemDto>,
ResponseListSuccessDto<Vec<PermissionsItemDto>>, ResponseListSuccessDto<Vec<PermissionsItemDto>>,
ResponseSuccessDto<PermissionsItemDto>, ResponseSuccessDto<PermissionsItemDto>,
ResponseListSuccessDto<Vec<EventsListItemDto>>, ResponseListSuccessDto<Vec<EventsListItemDto>>,
ResponseSuccessDto<EventsDetailItemDto>, ResponseSuccessDto<EventsDetailItemDto>,
ResponseListSuccessDto<Vec<TestimonialsListItemDto>>, ResponseListSuccessDto<Vec<TestimonialsListItemDto>>,
ResponseSuccessDto<TestimonialsDetailItemDto>, ResponseSuccessDto<TestimonialsDetailItemDto>,
TestimonialsCreateRequestDto, TestimonialsCreateRequestDto,
TestimonialsUpdateRequestDto, TestimonialsUpdateRequestDto,
MessageResponseDto, MentorUserRegisterRequestDto,
MentorRegisterFromTokenRequestDto,
MentorRegisterResponseDto,
MentorListResponseDto,
MentorDetailResponseDto,
MentorUpdateRequestDto,
MentorVerifyRequestDto,
IdentityAndVerification,
ProfessionalProfile,
MentoringLogistics,
MentoringRate,
ResponseListSuccessDto<Vec<MentorListResponseDto>>,
ResponseSuccessDto<MentorDetailResponseDto>,
ResponseSuccessDto<MentorRegisterResponseDto>,
) )
), ),
info( info(
@@ -121,6 +169,14 @@ use imphnen_cms::{events_controller, events_dto::{EventsDetailItemDto, EventsLis
modifiers(&SecurityAddon), modifiers(&SecurityAddon),
tags( tags(
(name = "Authentication", description = "List of Authentication Endpoints"), (name = "Authentication", description = "List of Authentication Endpoints"),
(name = "Users", description = "User Management Endpoints"),
(name = "Roles", description = "Role Management Endpoints"),
(name = "Permissions", description = "Permission Management Endpoints"),
(name = "Events", description = "Event Management Endpoints"),
(name = "Testimonials", description = "Testimonial Management Endpoints"),
(name = "Mentors", description = "Mentor Management Endpoints"),
(name = "Mentors - Admin", description = "Mentor Admin Management Endpoints (Admin Access Required)"),
(name = "Gacha", description = "Gacha System Endpoints"),
) )
)] )]
+2
View File
@@ -5,6 +5,7 @@ use imphnen_cms::{
events_protected_routes, events_public_routes, testimonials_protected_routes, events_protected_routes, events_public_routes, testimonials_protected_routes,
testimonials_public_routes, testimonials_public_routes,
}; };
use imphnen_dimentorin::dimentorin_router;
use imphnen_entities::{AppState, SurrealMemClient, SurrealWsClient}; use imphnen_entities::{AppState, SurrealMemClient, SurrealWsClient};
use imphnen_gacha::gacha_router; use imphnen_gacha::gacha_router;
use imphnen_iam::{iam_protected_routes, iam_public_routes}; use imphnen_iam::{iam_protected_routes, iam_public_routes};
@@ -32,6 +33,7 @@ pub async fn gateway_service(
.merge(iam_protected_routes()) .merge(iam_protected_routes())
.merge(events_protected_routes()) .merge(events_protected_routes())
.merge(testimonials_protected_routes()) .merge(testimonials_protected_routes())
.merge(dimentorin_router())
.merge(gacha_router()) .merge(gacha_router())
.layer(from_fn(auth_middleware)); .layer(from_fn(auth_middleware));
+13 -3
View File
@@ -4,9 +4,9 @@ version = "0.1.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
imphnen-libs = { version = "0.1.0", path = "../imphnen-libs" } imphnen-libs.workspace = true
imphnen-utils = { version = "0.1.0", path = "../imphnen-utils" } imphnen-utils.workspace = true
imphnen-entities = { version = "0.1.0", path = "../imphnen-entities" } imphnen-entities.workspace = true
axum.workspace = true axum.workspace = true
serde.workspace = true serde.workspace = true
serde_json.workspace = true serde_json.workspace = true
@@ -22,3 +22,13 @@ chrono.workspace = true
anyhow.workspace = true anyhow.workspace = true
tower-http.workspace = true tower-http.workspace = true
utoipa-swagger-ui.workspace = true utoipa-swagger-ui.workspace = true
strum.workspace = true
strum_macros.workspace = true
log.workspace = true
once_cell.workspace = true
tracing.workspace = true
uuid.workspace = true
[dev-dependencies]
dotenvy.workspace = true
http-body-util.workspace = true
-28
View File
@@ -1,34 +1,6 @@
use ::surrealdb::Uuid;
use imphnen_entities::*;
use imphnen_libs::*;
use imphnen_utils::*;
pub mod v1; pub mod v1;
pub use imphnen_entities::*; pub use imphnen_entities::*;
pub use imphnen_libs::*; pub use imphnen_libs::*;
pub use imphnen_utils::*; pub use imphnen_utils::*;
pub use v1::*; pub use v1::*;
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::random::<u32>()),
password: hash_password("secret").unwrap(),
is_deleted: false,
avatar: None,
phone_number: "081234567890".to_string(),
is_active,
gender: None,
birthdate: None,
role: make_thing("app_roles", role_id),
created_at: get_iso_date(),
updated_at: get_iso_date(),
}
}
+20 -2
View File
@@ -2,9 +2,9 @@ use super::{
AuthLoginRequestDto, AuthRefreshTokenRequestDto, AuthRegisterRequestDto, AuthLoginRequestDto, AuthRefreshTokenRequestDto, AuthRegisterRequestDto,
AuthResendOtpRequestDto, AuthService, AuthVerifyEmailRequestDto, AuthResendOtpRequestDto, AuthService, AuthVerifyEmailRequestDto,
}; };
use crate::{v1::AuthLoginResponsetDto, AppState}; use crate::{AppState, v1::AuthLoginResponsetDto};
use crate::{AuthNewPasswordRequestDto, MessageResponseDto, ResponseSuccessDto}; use crate::{AuthNewPasswordRequestDto, MessageResponseDto, ResponseSuccessDto};
use axum::{response::IntoResponse, Extension, Json}; use axum::{Extension, Json, response::IntoResponse};
#[utoipa::path( #[utoipa::path(
post, post,
@@ -23,6 +23,24 @@ pub async fn post_login(
AuthService::mutation_login(payload, &state).await AuthService::mutation_login(payload, &state).await
} }
#[utoipa::path(
post,
path = "/v1/auth/login-mentor",
request_body = AuthLoginRequestDto,
responses(
(status = 200, description = "Mentor login successful", body = ResponseSuccessDto<AuthLoginResponsetDto>),
(status = 401, description = "Mentor login failed", body = MessageResponseDto),
(status = 403, description = "Forbidden - Not a mentor", body = MessageResponseDto)
),
tag = "Authentication"
)]
pub async fn post_login_mentor(
Extension(state): Extension<AppState>,
Json(payload): Json<AuthLoginRequestDto>,
) -> impl IntoResponse {
AuthService::mutation_mentor_login(payload, &state).await
}
#[utoipa::path( #[utoipa::path(
post, post,
path = "/v1/auth/register", path = "/v1/auth/register",
+6 -1
View File
@@ -3,7 +3,7 @@ use serde::{Deserialize, Serialize};
use utoipa::ToSchema; use utoipa::ToSchema;
use validator::{Validate, ValidationError}; use validator::{Validate, ValidationError};
fn validate_password_complexity(password: &str) -> Result<(), ValidationError> { pub fn validate_password_complexity(password: &str) -> Result<(), ValidationError> {
let has_uppercase = password.chars().any(|c| c.is_ascii_uppercase()); let has_uppercase = password.chars().any(|c| c.is_ascii_uppercase());
let has_lowercase = password.chars().any(|c| c.is_ascii_lowercase()); let has_lowercase = password.chars().any(|c| c.is_ascii_lowercase());
let has_digit = password.chars().any(|c| c.is_ascii_digit()); let has_digit = password.chars().any(|c| c.is_ascii_digit());
@@ -117,3 +117,8 @@ pub struct AuthSetNewPasswordRequestDto {
))] ))]
pub password: String, pub password: String,
} }
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct UserCacheSchema {
pub email: String,
pub permissions: Vec<String>,
}
+64 -13
View File
@@ -1,7 +1,12 @@
use super::AuthOtpSchema; use super::AuthOtpSchema;
use crate::{AppState, ResourceEnum, UsersDetailQueryDto, make_thing}; use super::UserCacheSchema;
use crate::{
AppState, PermissionsQueryDto, ResourceEnum, RolesDetailQueryDto,
UsersDetailQueryDto,
};
use anyhow::{Result, anyhow, bail}; use anyhow::{Result, anyhow, bail};
use chrono::{Duration, Utc}; use chrono::{Duration, Utc};
use surrealdb::sql::Thing;
pub struct AuthRepository<'a> { pub struct AuthRepository<'a> {
pub state: &'a AppState, pub state: &'a AppState,
@@ -18,20 +23,26 @@ impl<'a> AuthRepository<'a> {
} }
let table = ResourceEnum::UsersCache.to_string(); let table = ResourceEnum::UsersCache.to_string();
let user_id = user.email.clone(); let user_id = user.email.clone();
let id = make_thing(&table, &user_id); let permissions: Vec<String> =
let _ = self user.role.permissions.into_iter().map(|p| p.name).collect();
let user_cache = UserCacheSchema {
email: user_id.clone(),
permissions,
};
let _record: Option<UserCacheSchema> = self
.state .state
.surrealdb_mem .surrealdb_mem
.delete::<Option<UsersDetailQueryDto>>((table.clone(), user_id.clone())) .delete::<Option<UserCacheSchema>>((table.clone(), user_id.clone()))
.await?; .await?;
let mut user_to_store = user.clone();
user_to_store.id = id.clone(); let record: Option<UserCacheSchema> = self
let record: Option<UsersDetailQueryDto> = self
.state .state
.surrealdb_mem .surrealdb_mem
.create((table, user_id)) .create((table, user_id))
.content(user_to_store) .content(user_cache)
.await?; .await?;
match record { match record {
Some(_) => Ok("Success store user data".to_string()), Some(_) => Ok("Success store user data".to_string()),
None => bail!("Failed store user data"), None => bail!("Failed store user data"),
@@ -42,13 +53,54 @@ impl<'a> AuthRepository<'a> {
&self, &self,
email: String, email: String,
) -> Result<UsersDetailQueryDto> { ) -> Result<UsersDetailQueryDto> {
let user: Option<UsersDetailQueryDto> = self let user_cache: Option<UserCacheSchema> = self
.state .state
.surrealdb_mem .surrealdb_mem
.select((ResourceEnum::UsersCache.to_string(), email)) .select((ResourceEnum::UsersCache.to_string(), email.clone()))
.await?; .await?;
match user {
Some(u) => Ok(u), match user_cache {
Some(cache) => {
let permissions_query_dto: Vec<PermissionsQueryDto> = cache
.permissions
.into_iter()
.map(|name| PermissionsQueryDto {
id: Thing::from((
"app_permissions".to_string(),
surrealdb::sql::Id::rand(),
)),
name,
created_at: None,
updated_at: None,
})
.collect();
let role_detail_query_dto = RolesDetailQueryDto {
id: Thing::from(("app_roles".to_string(), surrealdb::sql::Id::rand())),
name: "CachedRole".to_string(),
permissions: permissions_query_dto,
is_deleted: false,
created_at: None,
updated_at: None,
};
Ok(UsersDetailQueryDto {
id: Thing::from(("app_users".to_string(), email.clone())),
fullname: "Cached User".to_string(),
email: cache.email,
avatar: None,
phone_number: String::new(),
is_active: true,
is_deleted: false,
gender: None,
birthdate: None,
password: String::new(),
role: role_detail_query_dto,
created_at: String::new(),
updated_at: String::new(),
mentor_id: None,
})
}
None => bail!("No stored user data found"), None => bail!("No stored user data found"),
} }
} }
@@ -59,7 +111,6 @@ impl<'a> AuthRepository<'a> {
.surrealdb_mem .surrealdb_mem
.delete((ResourceEnum::UsersCache.to_string(), email)) .delete((ResourceEnum::UsersCache.to_string(), email))
.await?; .await?;
dbg!(record.clone());
match record { match record {
Some(_) => Ok("Success delete stored user".to_string()), Some(_) => Ok("Success delete stored user".to_string()),
None => bail!("Failed delete stored user"), None => bail!("Failed delete stored user"),
+211 -48
View File
@@ -13,6 +13,7 @@ use crate::{
}; };
use axum::{http::StatusCode, response::Response}; use axum::{http::StatusCode, response::Response};
use surrealdb::Uuid; use surrealdb::Uuid;
use tracing::error;
pub struct AuthService; pub struct AuthService;
@@ -49,7 +50,11 @@ impl AuthService {
let access_token = match encode_access_token(payload.email.clone()) { let access_token = match encode_access_token(payload.email.clone()) {
Ok(token) => token, Ok(token) => token,
Err(_) => { Err(_e) => {
error!(
"Failed to generate access token for {}: {}",
payload.email, _e
);
return common_response( return common_response(
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
"Failed to generate access token", "Failed to generate access token",
@@ -59,7 +64,11 @@ impl AuthService {
let refresh_token = match encode_refresh_token(payload.email.clone()) { let refresh_token = match encode_refresh_token(payload.email.clone()) {
Ok(token) => token, Ok(token) => token,
Err(_) => { Err(_e) => {
error!(
"Failed to generate refresh token for {}: {}",
payload.email, _e
);
return common_response( return common_response(
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
"Failed to generate refresh token", "Failed to generate refresh token",
@@ -77,13 +86,116 @@ impl AuthService {
}, },
}; };
if let Err(_err) = auth_repo.query_store_user(user).await { if let Err(err_store) = auth_repo.query_store_user(user.clone()).await {
return common_response(StatusCode::BAD_REQUEST, "User already login"); error!(
"Failed to store user cache for {}: {}",
user.email, err_store
);
return common_response(
StatusCode::BAD_REQUEST,
"User already login or failed to cache",
);
} }
success_response(response) success_response(response)
} }
Err(err) => common_response(StatusCode::UNAUTHORIZED, &err.to_string()), Err(err_find) => {
common_response(StatusCode::UNAUTHORIZED, &err_find.to_string())
}
}
}
pub async fn mutation_mentor_login(
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);
match user_repo.query_user_by_email(payload.email.clone()).await {
Ok(user) => {
let is_password_correct =
verify_password(&payload.password, &user.password).unwrap_or(false);
if !is_password_correct {
return common_response(
StatusCode::BAD_REQUEST,
"Email or password not correct",
);
}
if !user.is_active {
return common_response(
StatusCode::BAD_REQUEST,
"Account not active, please verify your email",
);
}
let user_detail = UsersDetailItemDto::from(&user);
if user_detail.role.name != RolesEnum::Mentor.to_string() {
return common_response(
StatusCode::FORBIDDEN,
"User does not have mentor privileges",
);
}
let access_token = match encode_access_token(payload.email.clone()) {
Ok(token) => token,
Err(_e) => {
error!(
"Failed to generate access token for {}: {}",
payload.email, _e
);
return common_response(
StatusCode::INTERNAL_SERVER_ERROR,
"Failed to generate access token",
);
}
};
let refresh_token = match encode_refresh_token(payload.email.clone()) {
Ok(token) => token,
Err(_e) => {
error!(
"Failed to generate refresh token for {}: {}",
payload.email, _e
);
return common_response(
StatusCode::INTERNAL_SERVER_ERROR,
"Failed to generate refresh token",
);
}
};
let response = ResponseSuccessDto {
data: AuthLoginResponsetDto {
user: UsersDetailItemDto::from(&user),
token: TokenDto {
access_token,
refresh_token,
},
},
};
if let Err(err_store) = auth_repo.query_store_user(user.clone()).await {
error!(
"Failed to store user cache for {}: {}",
user.email, err_store
);
return common_response(
StatusCode::BAD_REQUEST,
"User already login or failed to cache",
);
}
success_response(response)
}
Err(err_find) => {
common_response(StatusCode::UNAUTHORIZED, &err_find.to_string())
}
} }
} }
@@ -102,7 +214,10 @@ impl AuthService {
.await .await
{ {
Ok(role) => role, Ok(role) => role,
Err(_) => return common_response(StatusCode::BAD_REQUEST, "Role Not Found"), Err(_e) => {
error!("Failed to retrieve User role during registration: {}", _e);
return common_response(StatusCode::BAD_REQUEST, "Role Not Found");
}
}; };
if user_repo if user_repo
.query_user_by_email(payload.email.clone()) .query_user_by_email(payload.email.clone())
@@ -113,7 +228,11 @@ impl AuthService {
} }
let hashed_password = match hash_password(&payload.password) { let hashed_password = match hash_password(&payload.password) {
Ok(hash) => hash, Ok(hash) => hash,
Err(_) => { Err(_e) => {
error!(
"Failed to hash password during registration for {}: {}",
payload.email, _e
);
return common_response( return common_response(
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
"Failed to hash password", "Failed to hash password",
@@ -121,27 +240,34 @@ impl AuthService {
} }
}; };
let new_user = AuthRegisterRequestDto { let new_user = AuthRegisterRequestDto {
email: payload.email, email: payload.email.clone(),
password: hashed_password, password: hashed_password,
fullname: payload.fullname, fullname: payload.fullname,
phone_number: payload.phone_number, phone_number: payload.phone_number,
}; };
let otp = generate_otp::OtpManager::generate_otp(); let otp = generate_otp::OtpManager::generate_otp();
match auth_repo match auth_repo.query_store_otp(new_user.email.clone(), otp).await {
.query_store_otp(new_user.email.clone(), otp.clone())
.await
{
Ok(_) => { Ok(_) => {
let message = format!("your otp code is {}", otp); let message = format!("your otp code is {otp}");
if let Err(err) = send_email(&new_user.email, "OTP Verification", &message) { if let Err(err_send) =
send_email(&new_user.email, "OTP Verification", &message)
{
error!(
"Failed to send OTP email to {}: {}",
new_user.email, err_send
);
return common_response( return common_response(
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
&err.to_string(), &err_send.to_string(),
); );
} }
} }
Err(err) => { Err(err_store) => {
return common_response(StatusCode::INTERNAL_SERVER_ERROR, &err.to_string()); error!("Failed to store OTP for {}: {}", new_user.email, err_store);
return common_response(
StatusCode::INTERNAL_SERVER_ERROR,
&err_store.to_string(),
);
} }
} }
let role_thing = make_thing(&ResourceEnum::Roles.to_string(), &role.id); let role_thing = make_thing(&ResourceEnum::Roles.to_string(), &role.id);
@@ -159,13 +285,15 @@ impl AuthService {
created_at: get_iso_date(), created_at: get_iso_date(),
updated_at: get_iso_date(), updated_at: get_iso_date(),
role: role_thing, role: role_thing,
is_active: true,
..Default::default() ..Default::default()
}) })
.await .await
{ {
Ok(msg) => common_response(StatusCode::CREATED, &msg), Ok(msg) => common_response(StatusCode::CREATED, &msg),
Err(err) => { Err(err_create) => {
common_response(StatusCode::INTERNAL_SERVER_ERROR, &err.to_string()) error!("Failed to create user {}: {}", new_user.email, err_create);
common_response(StatusCode::INTERNAL_SERVER_ERROR, &err_create.to_string())
} }
} }
} }
@@ -188,13 +316,22 @@ impl AuthService {
let auth_repo = AuthRepository::new(state); let auth_repo = AuthRepository::new(state);
let _ = auth_repo.query_get_stored_otp(payload.email.clone()).await; let _ = auth_repo.query_get_stored_otp(payload.email.clone()).await;
let otp = generate_otp::OtpManager::generate_otp(); let otp = generate_otp::OtpManager::generate_otp();
let message = format!("Your OTP code is {}", otp); let message = format!("Your OTP code is {otp}");
match auth_repo.query_store_otp(payload.email.clone(), otp).await { match auth_repo.query_store_otp(payload.email.clone(), otp).await {
Ok(_) => match send_email(&payload.email, "OTP Verification", &message) { Ok(_) => match send_email(&payload.email, "OTP Verification", &message) {
Ok(_) => common_response(StatusCode::OK, "OTP resent successfully"), Ok(_) => common_response(StatusCode::OK, "OTP resent successfully"),
Err(err) => common_response(StatusCode::BAD_REQUEST, &err.to_string()), Err(err_send) => {
error!(
"Failed to send OTP email to {}: {}",
payload.email, err_send
);
common_response(StatusCode::BAD_REQUEST, &err_send.to_string())
}
}, },
Err(err) => common_response(StatusCode::BAD_REQUEST, &err.to_string()), Err(err_store) => {
error!("Failed to store OTP for {}: {}", payload.email, err_store);
common_response(StatusCode::BAD_REQUEST, &err_store.to_string())
}
} }
} }
@@ -206,13 +343,14 @@ impl AuthService {
} }
let email = match decode_refresh_token(&payload.refresh_token) { let email = match decode_refresh_token(&payload.refresh_token) {
Ok(token) => token.claims.sub, Ok(token) => token.claims.sub,
Err(_) => { Err(_e) => {
return common_response(StatusCode::UNAUTHORIZED, "Invalid refresh token"); return common_response(StatusCode::UNAUTHORIZED, "Invalid refresh token");
} }
}; };
let access_token = match encode_access_token(email.clone()) { let access_token = match encode_access_token(email.clone()) {
Ok(token) => token, Ok(token) => token,
Err(_) => { Err(_e) => {
error!("Failed to generate access token for {}: {}", email, _e);
return common_response( return common_response(
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
"Failed to generate access token", "Failed to generate access token",
@@ -221,7 +359,8 @@ impl AuthService {
}; };
let refresh_token = match encode_refresh_token(email.clone()) { let refresh_token = match encode_refresh_token(email.clone()) {
Ok(token) => token, Ok(token) => token,
Err(_) => { Err(_e) => {
error!("Failed to generate refresh token for {}: {}", email, _e);
return common_response( return common_response(
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
"Failed to generate refresh token", "Failed to generate refresh token",
@@ -248,16 +387,27 @@ impl AuthService {
let user_result = user_repo.query_user_by_email(payload.email.clone()).await; let user_result = user_repo.query_user_by_email(payload.email.clone()).await;
let user = match user_result { let user = match user_result {
Ok(user) => user, Ok(user) => user,
Err(err) if err.to_string().contains("User not found") => { Err(err_find) if err_find.to_string().contains("User not found") => {
return common_response(StatusCode::BAD_REQUEST, "User not found"); return common_response(StatusCode::BAD_REQUEST, "User not found");
} }
Err(err) => { Err(err_other) => {
return common_response(StatusCode::INTERNAL_SERVER_ERROR, &err.to_string()); error!(
"Error finding user for forgot password {}: {}",
payload.email, err_other
);
return common_response(
StatusCode::INTERNAL_SERVER_ERROR,
&err_other.to_string(),
);
} }
}; };
let token = match encode_reset_password_token(user.email) { let token = match encode_reset_password_token(user.email.clone()) {
Ok(token) => token, Ok(token) => token,
Err(_) => { Err(_e) => {
error!(
"Failed to generate reset password token for {}: {}",
user.email, _e
);
return common_response( return common_response(
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
"Failed to generate access token", "Failed to generate access token",
@@ -267,12 +417,17 @@ impl AuthService {
let env = Env::new(); let env = Env::new();
let fe_url = env.fe_url; let fe_url = env.fe_url;
let message = format!( let message = format!(
"You have requested a password reset. Please click the link below to continue: {}/auth/reset-password?token={}", "You have requested a password reset. Please click the link below to continue: {fe_url}/auth/reset-password?token={token}"
fe_url, token
); );
match send_email(&payload.email, "Reset Password Request", &message) { match send_email(&payload.email, "Reset Password Request", &message) {
Ok(_) => common_response(StatusCode::OK, "Reset Password request send"), Ok(_) => common_response(StatusCode::OK, "Reset Password request send"),
Err(err) => common_response(StatusCode::BAD_REQUEST, &err.to_string()), Err(err_send) => {
error!(
"Failed to send reset password email to {}: {}",
payload.email, err_send
);
common_response(StatusCode::BAD_REQUEST, &err_send.to_string())
}
} }
} }
@@ -288,33 +443,38 @@ impl AuthService {
let email = payload.email.clone(); let email = payload.email.clone();
let user = match user_repo.query_user_by_email(email.clone()).await { let user = match user_repo.query_user_by_email(email.clone()).await {
Ok(user) if !user.is_deleted => user, Ok(user) if !user.is_deleted => user,
_ => return common_response(StatusCode::NOT_FOUND, "User not found"), _ => {
return common_response(StatusCode::NOT_FOUND, "User not found");
}
}; };
let patch = UsersSchema { let patch = UsersSchema {
id: user.id.clone(), id: user.id.clone(),
is_active: true, is_active: true,
..UsersSchema::from(user) ..UsersSchema::from(user.clone())
}; };
match auth_repo.query_get_stored_otp(email.clone()).await { match auth_repo.query_get_stored_otp(email.clone()).await {
Ok(stored_otp) => match stored_otp == payload.otp { Ok(stored_otp) => match stored_otp == payload.otp {
true => match user_repo.query_update_user(patch).await { true => match user_repo.query_update_user(patch).await {
Ok(_) => match auth_repo.query_delete_stored_otp(email).await { Ok(_) => match auth_repo.query_delete_stored_otp(email.clone()).await {
Ok(_) => common_response(StatusCode::OK, "Email verified successfully"), Ok(_) => common_response(StatusCode::OK, "Email verified successfully"),
Err(e) => { Err(e_del) => {
common_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()) error!("Failed to delete OTP for {}: {}", email, e_del);
common_response(StatusCode::INTERNAL_SERVER_ERROR, &e_del.to_string())
} }
}, },
Err(err) => common_response(StatusCode::BAD_REQUEST, &err.to_string()), Err(err_update) => {
common_response(StatusCode::BAD_REQUEST, &err_update.to_string())
}
}, },
false => match auth_repo.query_delete_stored_otp(email).await { false => match auth_repo.query_delete_stored_otp(email.clone()).await {
Ok(_) => common_response(StatusCode::BAD_REQUEST, "Failed to verify OTP"), Ok(_) => common_response(StatusCode::BAD_REQUEST, "Failed to verify OTP"),
Err(e) => common_response( Err(e_del_mismatch) => common_response(
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
&format!("Failed to delete OTP: {}", e), &format!("Failed to delete OTP: {e_del_mismatch}"),
), ),
}, },
}, },
Err(err) => common_response(StatusCode::BAD_REQUEST, &err.to_string()), Err(err_get) => common_response(StatusCode::BAD_REQUEST, &err_get.to_string()),
} }
} }
@@ -327,14 +487,15 @@ impl AuthService {
} }
let repo = UsersRepository::new(state); let repo = UsersRepository::new(state);
let email = match extract_email_token(payload.token.clone()) { let email = match extract_email_token(payload.token.clone()) {
Some(email) => email, Some(token) => token,
None => { None => {
return common_response(StatusCode::BAD_REQUEST, "Invalid or missing token"); return common_response(StatusCode::BAD_REQUEST, "Invalid or missing token");
} }
}; };
let password = match hash_password(&payload.password) { let password = match hash_password(&payload.password) {
Ok(p) => p, Ok(p) => p,
Err(_) => { Err(_e) => {
error!("Failed to hash new password for {}: {}", email, _e);
return common_response( return common_response(
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
"Failed to hash password", "Failed to hash password",
@@ -343,7 +504,9 @@ impl AuthService {
}; };
let user = match repo.query_user_by_email(email.clone()).await { let user = match repo.query_user_by_email(email.clone()).await {
Ok(user) if !user.is_deleted => user, Ok(user) if !user.is_deleted => user,
_ => return common_response(StatusCode::NOT_FOUND, "User not found"), _ => {
return common_response(StatusCode::NOT_FOUND, "User not found");
}
}; };
let patch = UsersSchema { let patch = UsersSchema {
id: user.id.clone(), id: user.id.clone(),
@@ -352,7 +515,7 @@ impl AuthService {
}; };
match repo.query_update_user(patch).await { match repo.query_update_user(patch).await {
Ok(msg) => common_response(StatusCode::OK, &msg), Ok(msg) => common_response(StatusCode::OK, &msg),
Err(e) => common_response(StatusCode::BAD_REQUEST, &e.to_string()), Err(_e) => common_response(StatusCode::BAD_REQUEST, &_e.to_string()),
} }
} }
} }
+1
View File
@@ -15,6 +15,7 @@ pub fn auth_router() -> Router {
Router::new() Router::new()
.route("/forgot", post(auth_controller::post_forgot_password)) .route("/forgot", post(auth_controller::post_forgot_password))
.route("/login", post(auth_controller::post_login)) .route("/login", post(auth_controller::post_login))
.route("/login-mentor", post(auth_controller::post_login_mentor))
.route("/new-password", post(auth_controller::post_new_password)) .route("/new-password", post(auth_controller::post_new_password))
.route("/refresh", post(auth_controller::post_refresh_token)) .route("/refresh", post(auth_controller::post_refresh_token))
.route("/register", post(auth_controller::post_register)) .route("/register", post(auth_controller::post_register))
@@ -1,6 +1,8 @@
use std::fmt; use std::fmt;
#[derive(Debug, Clone, PartialEq, Eq)] use strum_macros::EnumIter;
#[derive(Debug, Clone, PartialEq, Eq, EnumIter)]
pub enum PermissionsEnum { pub enum PermissionsEnum {
ReadListUsers, ReadListUsers,
ReadDetailUsers, ReadDetailUsers,
@@ -28,6 +30,16 @@ pub enum PermissionsEnum {
ReadDetailGachaRolls, ReadDetailGachaRolls,
CreateGachaRolls, CreateGachaRolls,
ExecuteGachaRolls, ExecuteGachaRolls,
DeleteGachaRolls,
ReadListMentors,
ReadDetailMentors,
RegisterMentors,
ReadOwnMentorProfile,
UpdateOwnMentorProfile,
ReadOwnMentorStatus,
UpdateMentors,
VerifyMentors,
DeleteMentors,
} }
impl fmt::Display for PermissionsEnum { impl fmt::Display for PermissionsEnum {
@@ -59,8 +71,18 @@ impl fmt::Display for PermissionsEnum {
PermissionsEnum::ReadDetailGachaRolls => "Read Detail Gacha Rolls", PermissionsEnum::ReadDetailGachaRolls => "Read Detail Gacha Rolls",
PermissionsEnum::CreateGachaRolls => "Create Gacha Rolls", PermissionsEnum::CreateGachaRolls => "Create Gacha Rolls",
PermissionsEnum::ExecuteGachaRolls => "Execute Gacha Rolls", PermissionsEnum::ExecuteGachaRolls => "Execute Gacha Rolls",
PermissionsEnum::DeleteGachaRolls => "Delete Gacha Rolls",
PermissionsEnum::ReadListMentors => "Read List Mentors",
PermissionsEnum::ReadDetailMentors => "Read Detail Mentors",
PermissionsEnum::RegisterMentors => "Register Mentors",
PermissionsEnum::ReadOwnMentorProfile => "Read Own Mentor Profile",
PermissionsEnum::UpdateOwnMentorProfile => "Update Own Mentor Profile",
PermissionsEnum::ReadOwnMentorStatus => "Read Own Mentor Status",
PermissionsEnum::UpdateMentors => "Update Mentors",
PermissionsEnum::VerifyMentors => "Verify Mentors",
PermissionsEnum::DeleteMentors => "Delete Mentors",
}; };
write!(f, "{}", permission_str) write!(f, "{permission_str}")
} }
} }
@@ -101,6 +123,20 @@ impl PermissionsEnum {
} }
PermissionsEnum::CreateGachaRolls => "18e36c63-fcb7-4877-b911-c5aa611e878f", PermissionsEnum::CreateGachaRolls => "18e36c63-fcb7-4877-b911-c5aa611e878f",
PermissionsEnum::ExecuteGachaRolls => "14c6a1cd-5c63-4643-89b5-b1a5f9920cc0", PermissionsEnum::ExecuteGachaRolls => "14c6a1cd-5c63-4643-89b5-b1a5f9920cc0",
PermissionsEnum::DeleteGachaRolls => "12345678-ABCD-EFAB-CDEF-0123456789AB",
PermissionsEnum::ReadListMentors => "a1b2c3d4-5e6f-7890-abcd-ef1234567890",
PermissionsEnum::ReadDetailMentors => "b2c3d4e5-6f78-9012-bcde-f23456789012",
PermissionsEnum::RegisterMentors => "c3d4e5f6-7890-1234-cdef-345678901234",
PermissionsEnum::ReadOwnMentorProfile => {
"d4e5f6a7-8901-2345-def0-456789012345"
}
PermissionsEnum::UpdateOwnMentorProfile => {
"e5f6a7b8-9012-3456-ef01-567890123456"
}
PermissionsEnum::ReadOwnMentorStatus => "f6a7b8c9-0123-4567-f012-678901234567",
PermissionsEnum::UpdateMentors => "a7b8c9d0-1234-5678-0123-789012345678",
PermissionsEnum::VerifyMentors => "b8c9d0e1-2345-6789-1234-890123456789",
PermissionsEnum::DeleteMentors => "c9d0e1f2-3456-7890-2345-901234567890",
} }
} }
} }
@@ -1,5 +1,5 @@
use super::PermissionsEnum; use super::PermissionsEnum;
use crate::{common_response, extract_email, AppState, AuthRepository}; use crate::{AppState, AuthRepository, common_response, extract_email};
use axum::{ use axum::{
http::{HeaderMap, StatusCode}, http::{HeaderMap, StatusCode},
response::Response, response::Response,
@@ -29,14 +29,16 @@ pub async fn permissions_guard(
let role = raw_user.role; let role = raw_user.role;
let role_permissions: Vec<String> = let role_permissions: Vec<String> =
role.permissions.into_iter().map(|perm| perm.name).collect(); role.permissions.into_iter().map(|perm| perm.name).collect();
let has_all_permissions = required_permissions
.iter() for required in &required_permissions {
.all(|required| role_permissions.contains(&required.to_string())); let required_str = required.to_string();
if !has_all_permissions { if !role_permissions.contains(&required_str) {
return Err(common_response( eprintln!(" MISSING REQUIRED PERMISSION: {required_str}");
StatusCode::FORBIDDEN, return Err(common_response(
"You don't have the required permissions", StatusCode::FORBIDDEN,
)); "You don't have the required permissions",
));
}
} }
Ok(()) Ok(())
} }
@@ -4,6 +4,9 @@ use crate::{
}; };
use anyhow::{Result, bail}; use anyhow::{Result, bail};
use imphnen_utils::{DetailQueryBuilder, QueryListBuilder, extract_id}; use imphnen_utils::{DetailQueryBuilder, QueryListBuilder, extract_id};
use serde_json;
use std::time::Instant;
use tracing::instrument;
pub struct PermissionsRepository<'a> { pub struct PermissionsRepository<'a> {
state: &'a AppState, state: &'a AppState,
@@ -14,10 +17,12 @@ impl<'a> PermissionsRepository<'a> {
Self { state } Self { state }
} }
#[instrument(skip(self, meta), err)]
pub async fn query_permission_list( pub async fn query_permission_list(
&self, &self,
meta: MetaRequestDto, meta: MetaRequestDto,
) -> Result<ResponseListSuccessDto<Vec<PermissionsItemDto>>> { ) -> Result<ResponseListSuccessDto<Vec<PermissionsItemDto>>> {
let now = Instant::now();
let raw_result: ResponseListSuccessDto<Vec<PermissionsSchema>> = let raw_result: ResponseListSuccessDto<Vec<PermissionsSchema>> =
QueryListBuilder::new( QueryListBuilder::new(
&self.state.surrealdb_ws, &self.state.surrealdb_ws,
@@ -30,6 +35,13 @@ impl<'a> PermissionsRepository<'a> {
.build() .build()
.await?; .await?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_permission_list' took: {elapsed:.2?}");
}
let transformed_data = raw_result let transformed_data = raw_result
.data .data
.into_iter() .into_iter()
@@ -42,25 +54,41 @@ impl<'a> PermissionsRepository<'a> {
}) })
} }
#[instrument(skip(self, id), err)]
pub async fn query_permission_by_id( pub async fn query_permission_by_id(
&self, &self,
id: String, id: String,
) -> Result<PermissionsSchema> { ) -> Result<PermissionsSchema> {
let now = Instant::now();
let db = &self.state.surrealdb_ws; let db = &self.state.surrealdb_ws;
let result: Option<PermissionsSchema> = db let result: Option<PermissionsSchema> = db
.select((ResourceEnum::Permissions.to_string(), id.clone())) .select((ResourceEnum::Permissions.to_string(), id.clone()))
.await?; .await?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_permission_by_id' took: {elapsed:.2?}");
}
match result { match result {
Some(permission) if !permission.is_deleted => Ok(permission), Some(permission) if !permission.is_deleted => Ok(permission),
_ => bail!("Permission not found"), _ => bail!("Permission not found"),
} }
} }
#[instrument(skip(self, id), err)]
pub async fn transformed_query_permission_by_id( pub async fn transformed_query_permission_by_id(
&self, &self,
id: String, id: String,
) -> Result<PermissionsItemDto> { ) -> Result<PermissionsItemDto> {
let now = Instant::now();
let raw_result = self.query_permission_by_id(id.clone()).await?; let raw_result = self.query_permission_by_id(id.clone()).await?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'transformed_query_permission_by_id' took: {elapsed:.2?}");
}
let transformed_data = PermissionsItemDto { let transformed_data = PermissionsItemDto {
id: extract_id(&raw_result.id), id: extract_id(&raw_result.id),
name: raw_result.name, name: raw_result.name,
@@ -70,43 +98,60 @@ impl<'a> PermissionsRepository<'a> {
Ok(transformed_data) Ok(transformed_data)
} }
#[instrument(skip(self, name), err)]
pub async fn query_permission_by_name( pub async fn query_permission_by_name(
&self, &self,
name: String, name: String,
) -> Result<PermissionsSchema> { ) -> Result<PermissionsSchema> {
let now = Instant::now();
let db = &self.state.surrealdb_ws; let db = &self.state.surrealdb_ws;
let builder = DetailQueryBuilder::new(ResourceEnum::Permissions.to_string()) let builder = DetailQueryBuilder::new(ResourceEnum::Permissions.to_string())
.with_where("name") .with_where("name", Some(name.clone()))
.where_value(name.clone())
.with_select_fields(vec!["*"]); .with_select_fields(vec!["*"]);
let sql = builder.build(); let sql = builder.build();
let result: Option<PermissionsSchema> = let result: Option<PermissionsSchema> =
builder.apply_bindings(db.query(sql)).await?.take(0)?; builder.apply_bindings(db.query(sql)).await?.take(0)?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_permission_by_name' took: {elapsed:.2?}");
}
match result { match result {
Some(permission) => Ok(permission), Some(permission) => Ok(permission),
None => bail!("Permission not found"), None => bail!("Permission not found"),
} }
} }
#[instrument(skip(self, data), err)]
pub async fn query_create_permission( pub async fn query_create_permission(
&self, &self,
data: PermissionsSchema, data: PermissionsSchema,
) -> Result<String> { ) -> Result<String> {
let now = Instant::now();
let db = &self.state.surrealdb_ws; let db = &self.state.surrealdb_ws;
let record: Option<PermissionsSchema> = db let record: Option<PermissionsSchema> = db
.create(ResourceEnum::Permissions.to_string()) .create(ResourceEnum::Permissions.to_string())
.content(data) .content(data)
.await?; .await?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_create_permission' took: {elapsed:.2?}");
}
match record { match record {
Some(_) => Ok("Success create permission".into()), Some(_) => Ok("Success create permission".into()),
None => bail!("Failed to create permission"), None => bail!("Failed to create permission"),
} }
} }
#[instrument(skip(self, data), err)]
pub async fn query_update_permission( pub async fn query_update_permission(
&self, &self,
data: PermissionsSchema, data: PermissionsSchema,
) -> Result<String> { ) -> Result<String> {
let now = Instant::now();
let db = &self.state.surrealdb_ws; let db = &self.state.surrealdb_ws;
let record_key = get_id(&data.id)?; let record_key = get_id(&data.id)?;
let existing = self.query_permission_by_id(data.id.id.to_raw()).await?; let existing = self.query_permission_by_id(data.id.id.to_raw()).await?;
@@ -119,13 +164,21 @@ impl<'a> PermissionsRepository<'a> {
}; };
let record: Option<PermissionsSchema> = let record: Option<PermissionsSchema> =
db.update(record_key).merge(merged).await?; db.update(record_key).merge(merged).await?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_update_permission' took: {elapsed:.2?}");
}
match record { match record {
Some(_) => Ok("Success update permission".into()), Some(_) => Ok("Success update permission".into()),
None => bail!("Failed to update permission"), None => bail!("Failed to update permission"),
} }
} }
#[instrument(skip(self, id), err)]
pub async fn query_delete_permission(&self, id: String) -> Result<String> { pub async fn query_delete_permission(&self, id: String) -> Result<String> {
let now = Instant::now();
let db = &self.state.surrealdb_ws; let db = &self.state.surrealdb_ws;
let permission_id = make_thing(&ResourceEnum::Permissions.to_string(), &id); let permission_id = make_thing(&ResourceEnum::Permissions.to_string(), &id);
let permission = self let permission = self
@@ -139,6 +192,12 @@ impl<'a> PermissionsRepository<'a> {
.update(record_key) .update(record_key)
.merge(serde_json::json!({ "is_deleted": true })) .merge(serde_json::json!({ "is_deleted": true }))
.await?; .await?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_delete_permission' took: {elapsed:.2?}");
}
match record { match record {
Some(_) => Ok("Success delete permission".into()), Some(_) => Ok("Success delete permission".into()),
None => bail!("Failed to delete permission"), None => bail!("Failed to delete permission"),
@@ -1,7 +1,7 @@
use crate::{ use crate::{
common_response, make_thing, success_list_response, success_response, AppState, MetaRequestDto, PermissionsRepository, PermissionsSchema, ResourceEnum,
validate_request, AppState, MetaRequestDto, PermissionsRepository, ResponseListSuccessDto, ResponseSuccessDto, common_response, make_thing,
PermissionsSchema, ResourceEnum, ResponseListSuccessDto, ResponseSuccessDto, success_list_response, success_response, validate_request,
}; };
use axum::http::StatusCode; use axum::http::StatusCode;
use axum::response::Response; use axum::response::Response;
+13
View File
@@ -64,3 +64,16 @@ pub struct RolesDetailQueryDto {
pub created_at: Option<String>, pub created_at: Option<String>,
pub updated_at: Option<String>, pub updated_at: Option<String>,
} }
impl Default for RolesDetailQueryDto {
fn default() -> Self {
Self {
id: Thing::from(("".to_string(), surrealdb::sql::Id::Number(0))),
name: String::new(),
permissions: Vec::new(),
is_deleted: false,
created_at: None,
updated_at: None,
}
}
}
+3 -1
View File
@@ -5,6 +5,7 @@ pub enum RolesEnum {
Admin, Admin,
User, User,
Staff, Staff,
Mentor, // Added Mentor role
} }
impl fmt::Display for RolesEnum { impl fmt::Display for RolesEnum {
@@ -13,7 +14,8 @@ impl fmt::Display for RolesEnum {
RolesEnum::Admin => "Admin", RolesEnum::Admin => "Admin",
RolesEnum::User => "User", RolesEnum::User => "User",
RolesEnum::Staff => "Staff", RolesEnum::Staff => "Staff",
RolesEnum::Mentor => "Mentor", // Added Mentor role
}; };
write!(f, "{}", roles_str) write!(f, "{roles_str}")
} }
} }
+67 -11
View File
@@ -7,8 +7,11 @@ use crate::{
}; };
use anyhow::{Result, bail}; use anyhow::{Result, bail};
use imphnen_utils::{DetailQueryBuilder, QueryListBuilder}; use imphnen_utils::{DetailQueryBuilder, QueryListBuilder};
use serde_json;
use std::time::Instant;
use surrealdb::Uuid; use surrealdb::Uuid;
use surrealdb::sql::Thing; use surrealdb::sql::Thing;
use tracing::instrument;
pub struct RolesRepository<'a> { pub struct RolesRepository<'a> {
state: &'a AppState, state: &'a AppState,
@@ -19,10 +22,12 @@ impl<'a> RolesRepository<'a> {
Self { state } Self { state }
} }
#[instrument(skip(self, meta), err)]
pub async fn query_role_list( pub async fn query_role_list(
&self, &self,
meta: MetaRequestDto, meta: MetaRequestDto,
) -> Result<ResponseListSuccessDto<Vec<RolesListItemDto>>> { ) -> Result<ResponseListSuccessDto<Vec<RolesListItemDto>>> {
let now = Instant::now();
let result: ResponseListSuccessDto<Vec<RolesSchema>> = QueryListBuilder::new( let result: ResponseListSuccessDto<Vec<RolesSchema>> = QueryListBuilder::new(
&self.state.surrealdb_ws, &self.state.surrealdb_ws,
&ResourceEnum::Roles.to_string(), &ResourceEnum::Roles.to_string(),
@@ -31,6 +36,12 @@ impl<'a> RolesRepository<'a> {
.search_field("name") .search_field("name")
.build() .build()
.await?; .await?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_role_list' took: {elapsed:.2?}");
}
let data = result let data = result
.data .data
.into_iter() .into_iter()
@@ -42,48 +53,71 @@ impl<'a> RolesRepository<'a> {
}) })
} }
#[instrument(skip(self, name), err)]
pub async fn query_role_by_name( pub async fn query_role_by_name(
&self, &self,
name: String, name: String,
) -> Result<RolesDetailItemDto> { ) -> Result<RolesDetailItemDto> {
let now = Instant::now();
let db = &self.state.surrealdb_ws; let db = &self.state.surrealdb_ws;
let builder = DetailQueryBuilder::new(ResourceEnum::Roles.to_string()) let builder = DetailQueryBuilder::new(ResourceEnum::Roles.to_string())
.with_where("name") .with_where("name", Some(name.clone()))
.where_value(name.clone())
.with_select_fields(vec!["*"]) .with_select_fields(vec!["*"])
.with_fetch("permissions"); .with_fetch("permissions");
let sql = builder.build(); let sql = builder.build();
let result: Option<RolesDetailQueryDto> = builder let mut response = builder.apply_bindings(db.query(sql)).await?;
.apply_bindings(db.query(sql).bind(("name", name)))
.await? let result_vec: Vec<RolesDetailQueryDto> = response.take(0).map_err(|e| {
.take(0)?; anyhow::anyhow!("Failed to take result from response: {:?}", e)
let role = match result { })?;
Some(r) if !r.is_deleted => r,
_ => bail!("Role not found"), let role = result_vec
}; .into_iter()
.next()
.ok_or_else(|| anyhow::anyhow!("Role not found"))?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_role_by_name' took: {elapsed:.2?}");
}
Ok(RolesDetailItemDto::from(&role)) Ok(RolesDetailItemDto::from(&role))
} }
#[instrument(skip(self, id), err)]
pub async fn query_role_by_id(&self, id: String) -> Result<RolesDetailItemDto> { pub async fn query_role_by_id(&self, id: String) -> Result<RolesDetailItemDto> {
let now = Instant::now();
let db = &self.state.surrealdb_ws; let db = &self.state.surrealdb_ws;
let thing_id = make_thing(&ResourceEnum::Roles.to_string(), &id);
let builder = DetailQueryBuilder::new(ResourceEnum::Roles.to_string()) let builder = DetailQueryBuilder::new(ResourceEnum::Roles.to_string())
.with_id(&id) .with_id(&id)
.with_select_fields(vec!["*"]) .with_select_fields(vec!["*"])
.with_fetch("permissions"); .with_fetch("permissions");
let sql = builder.build(); let sql = builder.build();
let sql_debug = sql.to_string(); // Move this line here
let result: Option<RolesDetailQueryDto> = let result: Option<RolesDetailQueryDto> =
builder.apply_bindings(db.query(sql)).await?.take(0)?; builder.apply_bindings(db.query(sql)).await?.take(0)?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_role_by_id' took: {elapsed:.2?}");
}
let role = match result { let role = match result {
Some(r) if !r.is_deleted => r, Some(r) if !r.is_deleted => r,
_ => bail!("Role not found"), _ => bail!("Role not found sql: {} id: {}", sql_debug, thing_id),
}; };
Ok(RolesDetailItemDto::from(&role)) Ok(RolesDetailItemDto::from(&role))
} }
#[instrument(skip(self, payload), err)]
pub async fn query_create_role( pub async fn query_create_role(
&self, &self,
payload: RolesRequestCreateDto, payload: RolesRequestCreateDto,
) -> Result<String> { ) -> Result<String> {
let now = Instant::now();
let db = &self.state.surrealdb_ws; let db = &self.state.surrealdb_ws;
let role_id = Uuid::new_v4().to_string(); let role_id = Uuid::new_v4().to_string();
let permission_things: Vec<Thing> = payload let permission_things: Vec<Thing> = payload
@@ -103,14 +137,22 @@ impl<'a> RolesRepository<'a> {
.create((&ResourceEnum::Roles.to_string(), role_id)) .create((&ResourceEnum::Roles.to_string(), role_id))
.content(role) .content(role)
.await?; .await?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_create_role' took: {elapsed:.2?}");
}
Ok("Role with permissions created successfully".into()) Ok("Role with permissions created successfully".into())
} }
#[instrument(skip(self, id, data), err)]
pub async fn query_update_role( pub async fn query_update_role(
&self, &self,
id: String, id: String,
data: RolesRequestUpdateDto, data: RolesRequestUpdateDto,
) -> Result<String> { ) -> Result<String> {
let now = Instant::now();
let db = &self.state.surrealdb_ws; let db = &self.state.surrealdb_ws;
let existing = self.query_role_by_id(id.clone()).await?; let existing = self.query_role_by_id(id.clone()).await?;
if existing.is_deleted { if existing.is_deleted {
@@ -119,13 +161,21 @@ impl<'a> RolesRepository<'a> {
let merged = RolesSchema::update(data, id.clone(), existing); let merged = RolesSchema::update(data, id.clone(), existing);
let record: Option<RolesSchema> = let record: Option<RolesSchema> =
db.update(get_id(&merged.id)?).content(merged).await?; db.update(get_id(&merged.id)?).content(merged).await?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_update_role' took: {elapsed:.2?}");
}
match record { match record {
Some(_) => Ok("Success update role".into()), Some(_) => Ok("Success update role".into()),
None => bail!("Failed to update role"), None => bail!("Failed to update role"),
} }
} }
#[instrument(skip(self, id), err)]
pub async fn query_delete_role(&self, id: String) -> Result<String> { pub async fn query_delete_role(&self, id: String) -> Result<String> {
let now = Instant::now();
let db = &self.state.surrealdb_ws; let db = &self.state.surrealdb_ws;
let role_id = make_thing(&ResourceEnum::Roles.to_string(), &id); let role_id = make_thing(&ResourceEnum::Roles.to_string(), &id);
let role = self.query_role_by_id(role_id.id.to_raw()).await?; let role = self.query_role_by_id(role_id.id.to_raw()).await?;
@@ -137,6 +187,12 @@ impl<'a> RolesRepository<'a> {
.update(record_key) .update(record_key)
.merge(serde_json::json!({ "is_deleted": true })) .merge(serde_json::json!({ "is_deleted": true }))
.await?; .await?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_delete_role' took: {elapsed:.2?}");
}
match record { match record {
Some(_) => Ok("Success delete role".into()), Some(_) => Ok("Success delete role".into()),
None => bail!("Failed to delete role"), None => bail!("Failed to delete role"),
+29 -5
View File
@@ -1,13 +1,13 @@
use crate::{RolesDetailItemDto, RolesDetailQueryDto}; use crate::{RolesDetailItemDto, RolesDetailQueryDto};
use lazy_static::lazy_static; use lazy_static::lazy_static;
use regex::Regex;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use surrealdb::sql::Thing; use surrealdb::sql::Thing;
use utoipa::ToSchema; use utoipa::ToSchema;
use validator::Validate; use validator::Validate;
lazy_static! { lazy_static! {
static ref PASSWORD_REGEX: Regex = Regex::new(r"^[A-Za-z\d@$!%*?&]{8,}$").unwrap(); static ref PASSWORD_REGEX: regex::Regex =
regex::Regex::new(r"^[A-Za-z\d@$!%*?&]{8,}$").unwrap();
} }
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] #[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
@@ -34,7 +34,7 @@ pub struct UsersCreateRequestDto {
message = "Password must have at least 8 characters" message = "Password must have at least 8 characters"
))] ))]
#[validate(regex( #[validate(regex(
path = "PASSWORD_REGEX", path = "*PASSWORD_REGEX",
message = "Password must include uppercase, lowercase, number, and special character" message = "Password must include uppercase, lowercase, number, and special character"
))] ))]
pub password: String, pub password: String,
@@ -62,6 +62,7 @@ pub struct UsersUpdateRequestDto {
min = 8, min = 8,
message = "Password must have at least 8 characters" message = "Password must have at least 8 characters"
))] ))]
pub password: String,
#[validate(length(min = 2, message = "Fullname at least have 2 character"))] #[validate(length(min = 2, message = "Fullname at least have 2 character"))]
pub fullname: String, pub fullname: String,
#[validate(length( #[validate(length(
@@ -103,7 +104,7 @@ impl UsersDetailItemDto {
email: dto.email.clone(), email: dto.email.clone(),
avatar: dto.avatar.clone(), avatar: dto.avatar.clone(),
phone_number: dto.phone_number.clone(), phone_number: dto.phone_number.clone(),
is_active: dto.is_active.clone(), is_active: dto.is_active,
gender: dto.gender.clone(), gender: dto.gender.clone(),
birthdate: dto.birthdate.clone(), birthdate: dto.birthdate.clone(),
created_at: dto.created_at.clone(), created_at: dto.created_at.clone(),
@@ -169,18 +170,20 @@ pub struct UsersDetailQueryDto {
pub role: RolesDetailQueryDto, pub role: RolesDetailQueryDto,
pub created_at: String, pub created_at: String,
pub updated_at: String, pub updated_at: String,
pub mentor_id: Option<Thing>,
} }
impl UsersDetailQueryDto { impl UsersDetailQueryDto {
pub fn from(&self) -> Self { pub fn from(&self) -> Self {
Self { Self {
id: self.id.clone(), id: self.id.clone(),
role: RolesDetailQueryDto::from(self.role.clone()), role: self.role.clone(),
fullname: self.fullname.clone(), fullname: self.fullname.clone(),
email: self.email.clone(), email: self.email.clone(),
avatar: self.avatar.clone(), avatar: self.avatar.clone(),
phone_number: self.phone_number.clone(), phone_number: self.phone_number.clone(),
is_active: self.is_active, is_active: self.is_active,
mentor_id: self.mentor_id.clone(),
gender: self.gender.clone(), gender: self.gender.clone(),
is_deleted: self.is_deleted, is_deleted: self.is_deleted,
password: self.password.clone(), password: self.password.clone(),
@@ -190,3 +193,24 @@ impl UsersDetailQueryDto {
} }
} }
} }
impl From<&UsersDetailItemDto> for UsersDetailQueryDto {
fn from(dto: &UsersDetailItemDto) -> Self {
Self {
id: crate::make_thing(&imphnen_libs::ResourceEnum::Users.to_string(), &dto.id),
fullname: dto.fullname.clone(),
email: dto.email.clone(),
avatar: dto.avatar.clone(),
phone_number: dto.phone_number.clone(),
is_active: dto.is_active,
is_deleted: false,
gender: dto.gender.clone(),
birthdate: dto.birthdate.clone(),
password: String::new(),
role: RolesDetailQueryDto::default(),
created_at: dto.created_at.clone(),
updated_at: dto.updated_at.clone(),
mentor_id: None,
}
}
}
+77 -9
View File
@@ -2,10 +2,16 @@ use super::{UsersDetailQueryDto, UsersListItemDto, UsersListQueryDto, UsersSchem
use crate::{ use crate::{
AppState, MetaRequestDto, ResourceEnum, ResponseListSuccessDto, get_id, make_thing, AppState, MetaRequestDto, ResourceEnum, ResponseListSuccessDto, get_id, make_thing,
}; };
use surrealdb::sql::Thing;
use anyhow::{Result, bail}; use anyhow::{Result, bail};
use imphnen_utils::{DetailQueryBuilder, QueryListBuilder}; use imphnen_utils::{DetailQueryBuilder, QueryListBuilder};
use serde_json;
use std::time::Instant;
use surrealdb::{Surreal, engine::remote::ws::Client}; use surrealdb::{Surreal, engine::remote::ws::Client};
pub struct UsersRepository<'a> { pub struct UsersRepository<'a> {
state: &'a AppState, state: &'a AppState,
} }
@@ -30,10 +36,12 @@ impl<'a> UsersRepository<'a> {
Self { state } Self { state }
} }
pub async fn query_user_list( pub async fn query_user_list(
&self, &self,
meta: MetaRequestDto, meta: MetaRequestDto,
) -> Result<ResponseListSuccessDto<Vec<UsersListItemDto>>> { ) -> Result<ResponseListSuccessDto<Vec<UsersListItemDto>>> {
let now = Instant::now();
let result: ResponseListSuccessDto<Vec<UsersListQueryDto>> = let result: ResponseListSuccessDto<Vec<UsersListQueryDto>> =
QueryListBuilder::new( QueryListBuilder::new(
&self.state.surrealdb_ws, &self.state.surrealdb_ws,
@@ -46,6 +54,14 @@ impl<'a> UsersRepository<'a> {
.fetch_fields(vec!["role", "role.permissions"]) .fetch_fields(vec!["role", "role.permissions"])
.build() .build()
.await?; .await?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_user_list' took: {elapsed:.2?}");
}
let data = result let data = result
.data .data
.into_iter() .into_iter()
@@ -57,70 +73,103 @@ impl<'a> UsersRepository<'a> {
}) })
} }
pub async fn query_user_by_email( pub async fn query_user_by_email(
&self, &self,
email: String, email: String,
) -> Result<UsersDetailQueryDto> { ) -> Result<UsersDetailQueryDto> {
let now = Instant::now();
let db = &self.state.surrealdb_ws; let db = &self.state.surrealdb_ws;
let builder = DetailQueryBuilder::new(ResourceEnum::Users.to_string()) let builder = DetailQueryBuilder::new(ResourceEnum::Users.to_string())
.with_where("email") .with_where("email", Some(email.clone()))
.where_value(email.clone())
.with_select_fields(vec!["*"]) .with_select_fields(vec!["*"])
.with_fetch("role") .with_fetch("role")
.with_fetch("role.permissions"); .with_fetch("role.permissions");
let sql = builder.build(); let sql = builder.build();
let user_opt: Option<UsersDetailQueryDto> = let user_opt: Option<UsersDetailQueryDto> =
builder.apply_bindings(db.query(sql)).await?.take(0)?; builder.apply_bindings(db.query(sql)).await?.take(0)?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_user_by_email' took: {elapsed:.2?}");
}
let Some(user) = user_opt else { let Some(user) = user_opt else {
bail!("User not found"); bail!("User not found");
}; };
if user.is_deleted { if user.is_deleted {
bail!("User not found"); bail!("User not found");
} }
if user.role.is_deleted { if user.role.updated_at.is_none() || user.role.is_deleted {
bail!("User not found"); bail!("User not found");
} }
Ok(UsersDetailQueryDto::from(&user)) Ok(UsersDetailQueryDto::from(&user))
} }
pub async fn query_user_by_id(&self, id: String) -> Result<UsersDetailQueryDto> {
pub async fn query_user_by_id(&self, id: &Thing) -> Result<UsersDetailQueryDto> {
let now = Instant::now();
let db = &self.state.surrealdb_ws; let db = &self.state.surrealdb_ws;
let builder = DetailQueryBuilder::new(ResourceEnum::Users.to_string()) let builder = DetailQueryBuilder::new(ResourceEnum::Users.to_string())
.with_id(&id) .with_id(&id.id.to_raw())
.with_select_fields(vec!["*"]) .with_select_fields(vec!["*"])
.with_fetch("role") .with_fetch("role")
.with_fetch("role.permissions"); .with_fetch("role.permissions");
let sql = builder.build(); let sql = builder.build();
let result: Option<UsersDetailQueryDto> = let result: Option<UsersDetailQueryDto> =
builder.apply_bindings(db.query(sql)).await?.take(0)?; builder.apply_bindings(db.query(sql)).await?.take(0)?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_user_by_id' took: {elapsed:.2?}");
}
let Some(user) = result else { let Some(user) = result else {
bail!("User not found"); bail!("User not found in database");
}; };
if user.is_deleted { if user.is_deleted {
bail!("User not found"); bail!("User not found");
} }
if user.role.is_deleted { if user.role.is_deleted {
bail!("User not found"); bail!("User's role has been deleted");
} }
Ok(UsersDetailQueryDto::from(&user)) Ok(UsersDetailQueryDto::from(&user))
} }
pub async fn query_create_user(&self, data: UsersSchema) -> Result<String> { pub async fn query_create_user(&self, data: UsersSchema) -> Result<String> {
let now = Instant::now();
let db = &self.state.surrealdb_ws; let db = &self.state.surrealdb_ws;
let record: Option<UsersSchema> = db let record: Option<UsersSchema> = db
.create(ResourceEnum::Users.to_string()) .create(ResourceEnum::Users.to_string())
.content(data) .content(data)
.await?; .await?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_create_user' took: {elapsed:.2?}");
}
match record { match record {
Some(_) => Ok("Success create user".into()), Some(_) => Ok("Success create user".into()),
None => bail!("Failed to create user"), None => bail!("Failed to create user"),
} }
} }
pub async fn query_update_user(&self, data: UsersSchema) -> Result<String> { pub async fn query_update_user(&self, data: UsersSchema) -> Result<String> {
let now = Instant::now();
let db = &self.state.surrealdb_ws; let db = &self.state.surrealdb_ws;
let record_key = get_id(&data.id)?; let record_key = get_id(&data.id)?;
let existing = self.query_user_by_id(data.id.id.to_raw()).await?; let existing = self.query_user_by_id(&data.id).await?;
if existing.is_deleted { if existing.is_deleted {
bail!("User already deleted"); bail!("User already deleted");
} }
@@ -136,15 +185,25 @@ impl<'a> UsersRepository<'a> {
..data.clone() ..data.clone()
}; };
let record: Option<UsersSchema> = db.update(record_key).merge(merged).await?; let record: Option<UsersSchema> = db.update(record_key).merge(merged).await?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_update_user' took: {elapsed:.2?}");
}
match record { match record {
Some(_) => Ok("Success update user".into()), Some(_) => Ok("Success update user".into()),
None => bail!("Failed to update user"), None => bail!("Failed to update user"),
} }
} }
pub async fn query_delete_user(&self, id: String) -> Result<String> { pub async fn query_delete_user(&self, id: String) -> Result<String> {
let now = Instant::now();
let db = &self.state.surrealdb_ws; let db = &self.state.surrealdb_ws;
let user = self.query_user_by_id(id).await?; let user = self.query_user_by_id(&make_thing(&ResourceEnum::Users.to_string(), &id)).await?;
if user.is_deleted { if user.is_deleted {
bail!("User not found"); bail!("User not found");
} }
@@ -153,9 +212,18 @@ impl<'a> UsersRepository<'a> {
.update(record_key) .update(record_key)
.merge(serde_json::json!({ "is_deleted": true })) .merge(serde_json::json!({ "is_deleted": true }))
.await?; .await?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_delete_user' took: {elapsed:.2?}");
}
match record { match record {
Some(_) => Ok("Success delete user".into()), Some(_) => Ok("Success delete user".into()),
None => bail!("Failed to delete user"), None => bail!("Failed to delete user"),
} }
} }
} }
+33 -1
View File
@@ -1,5 +1,6 @@
use super::{UsersCreateRequestDto, UsersDetailQueryDto, UsersUpdateRequestDto}; use super::{UsersCreateRequestDto, UsersDetailQueryDto, UsersUpdateRequestDto};
use imphnen_libs::{ResourceEnum, hash_password}; use imphnen_libs::{ResourceEnum, hash_password};
use imphnen_utils::extract_id;
use imphnen_utils::{get_iso_date, make_thing}; use imphnen_utils::{get_iso_date, make_thing};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use surrealdb::{Uuid, sql::Thing}; use surrealdb::{Uuid, sql::Thing};
@@ -10,11 +11,16 @@ pub struct UsersSchema {
pub fullname: String, pub fullname: String,
pub email: String, pub email: String,
pub password: String, pub password: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub avatar: Option<String>, pub avatar: Option<String>,
pub phone_number: String, pub phone_number: String,
pub is_active: bool, pub is_active: bool,
pub is_deleted: bool, pub is_deleted: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub mentor_id: Option<Thing>,
#[serde(skip_serializing_if = "Option::is_none")]
pub gender: Option<String>, pub gender: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub birthdate: Option<String>, pub birthdate: Option<String>,
pub role: Thing, pub role: Thing,
pub created_at: String, pub created_at: String,
@@ -35,6 +41,10 @@ impl Default for UsersSchema {
phone_number: String::new(), phone_number: String::new(),
is_active: false, is_active: false,
is_deleted: false, is_deleted: false,
mentor_id: Some(make_thing(
&ResourceEnum::Users.to_string(),
&Uuid::new_v4().to_string(),
)),
gender: None, gender: None,
birthdate: None, birthdate: None,
role: make_thing( role: make_thing(
@@ -57,12 +67,18 @@ impl UsersSchema {
phone_number: dto.phone_number, phone_number: dto.phone_number,
is_active: dto.is_active, is_active: dto.is_active,
is_deleted: dto.is_deleted, is_deleted: dto.is_deleted,
mentor_id: Some(dto.mentor_id.unwrap_or_else(|| {
make_thing(
&ResourceEnum::Users.to_string(),
&Uuid::new_v4().to_string(),
)
})),
gender: dto.gender, gender: dto.gender,
birthdate: dto.birthdate, birthdate: dto.birthdate,
password: dto.password, password: dto.password,
created_at: dto.created_at, created_at: dto.created_at,
updated_at: dto.updated_at, updated_at: dto.updated_at,
role: make_thing(&ResourceEnum::Roles.to_string(), &dto.role.id.id.to_raw()), role: make_thing(&ResourceEnum::Roles.to_string(), &extract_id(&dto.role.id)),
} }
} }
@@ -95,6 +111,10 @@ impl UsersSchema {
password, password,
phone_number: user.phone_number, phone_number: user.phone_number,
is_active: false, is_active: false,
mentor_id: Some(make_thing(
&ResourceEnum::Users.to_string(),
&Uuid::new_v4().to_string(),
)),
gender: None, gender: None,
birthdate: None, birthdate: None,
avatar: None, avatar: None,
@@ -112,4 +132,16 @@ impl UsersSchema {
..Self::from(dto) ..Self::from(dto)
} }
} }
pub fn update_mentor_id(mut self, mentor_id: Option<String>) -> Self {
self.mentor_id = match mentor_id {
Some(id) => Some(make_thing(&ResourceEnum::Users.to_string(), &id)),
None => Some(make_thing(
&ResourceEnum::Users.to_string(),
&Uuid::new_v4().to_string(),
)),
};
self.updated_at = get_iso_date();
self
}
} }
+39 -7
View File
@@ -6,12 +6,15 @@ use crate::{
AppState, MetaRequestDto, ResponseListSuccessDto, UsersRepository, UsersSchema, AppState, MetaRequestDto, ResponseListSuccessDto, UsersRepository, UsersSchema,
}; };
use crate::{ use crate::{
ResourceEnum, ResponseSuccessDto, common_response, extract_email, make_thing, ResponseSuccessDto, common_response, extract_email, success_list_response,
success_list_response, success_response, validate_request, success_response, validate_request,
}; };
use axum::http::HeaderMap; use axum::http::HeaderMap;
use axum::{http::StatusCode, response::Response}; use axum::{http::StatusCode, response::Response};
use imphnen_libs::{hash_password, verify_password}; use imphnen_libs::{ResourceEnum, hash_password, verify_password};
use imphnen_utils::make_thing;
use uuid::Uuid;
pub struct UsersService; pub struct UsersService;
@@ -31,8 +34,12 @@ impl UsersService {
} }
pub async fn get_user_by_id(state: &AppState, id: String) -> Response { pub async fn get_user_by_id(state: &AppState, id: String) -> Response {
if Uuid::parse_str(&id).is_err() {
return common_response(StatusCode::BAD_REQUEST, "Invalid User ID format");
}
let repo = UsersRepository::new(state); let repo = UsersRepository::new(state);
match repo.query_user_by_id(id).await { let thing_id = make_thing(&ResourceEnum::Users.to_string(), &id);
match repo.query_user_by_id(&thing_id).await {
Ok(user) if !user.is_deleted => success_response(ResponseSuccessDto { Ok(user) if !user.is_deleted => success_response(ResponseSuccessDto {
data: UsersDetailItemDto::from(&user), data: UsersDetailItemDto::from(&user),
}), }),
@@ -84,6 +91,9 @@ impl UsersService {
id: String, id: String,
user: UsersUpdateRequestDto, user: UsersUpdateRequestDto,
) -> Response { ) -> Response {
if Uuid::parse_str(&id).is_err() {
return common_response(StatusCode::BAD_REQUEST, "Invalid User ID format");
}
let repo = UsersRepository::new(state); let repo = UsersRepository::new(state);
if let Err((status, message)) = validate_request(&user) { if let Err((status, message)) = validate_request(&user) {
return common_response(status, &message); return common_response(status, &message);
@@ -124,9 +134,12 @@ impl UsersService {
id: String, id: String,
payload: UsersActiveInactiveRequestDto, payload: UsersActiveInactiveRequestDto,
) -> Response { ) -> Response {
if Uuid::parse_str(&id).is_err() {
return common_response(StatusCode::BAD_REQUEST, "Invalid User ID format");
}
let repo = UsersRepository::new(state); let repo = UsersRepository::new(state);
let thing_id = make_thing(&ResourceEnum::Users.to_string(), &id); let thing_id = make_thing(&ResourceEnum::Users.to_string(), &id);
match repo.query_user_by_id(thing_id.id.to_raw()).await { match repo.query_user_by_id(&thing_id).await {
Ok(user) if !user.is_deleted => { Ok(user) if !user.is_deleted => {
let patch = UsersSchema { let patch = UsersSchema {
id: user.id.clone(), id: user.id.clone(),
@@ -186,9 +199,28 @@ impl UsersService {
} }
} }
pub async fn delete_user(state: &AppState, id: String) -> Response { pub async fn get_user_by_mentor_id(
state: &AppState,
mentor_id: String,
) -> Response {
let repo = UsersRepository::new(state); let repo = UsersRepository::new(state);
if repo.query_user_by_id(id.clone()).await.is_err() { let thing_id = make_thing(&ResourceEnum::Mentors.to_string(), &mentor_id);
match repo.query_user_by_id(&thing_id).await {
Ok(user) if !user.is_deleted => success_response(ResponseSuccessDto {
data: UsersDetailItemDto::from(&user),
}),
Ok(_) => common_response(StatusCode::NOT_FOUND, "User not found"),
Err(e) => common_response(StatusCode::NOT_FOUND, &e.to_string()),
}
}
pub async fn delete_user(state: &AppState, id: String) -> Response {
if Uuid::parse_str(&id).is_err() {
return common_response(StatusCode::BAD_REQUEST, "Invalid User ID format");
}
let repo = UsersRepository::new(state);
let thing_id = make_thing(&ResourceEnum::Users.to_string(), &id);
if repo.query_user_by_id(&thing_id).await.is_err() {
return common_response(StatusCode::BAD_REQUEST, "User not found"); return common_response(StatusCode::BAD_REQUEST, "User not found");
} }
match repo.query_delete_user(id).await { match repo.query_delete_user(id).await {
+1 -2
View File
@@ -4,7 +4,7 @@ version = "0.1.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
imphnen-entities = { path = "../imphnen-entities" } imphnen-entities.workspace = true
log.workspace = true log.workspace = true
axum.workspace = true axum.workspace = true
tokio.workspace = true tokio.workspace = true
@@ -15,4 +15,3 @@ chrono.workspace = true
surrealdb.workspace = true surrealdb.workspace = true
jsonwebtoken.workspace = true jsonwebtoken.workspace = true
dotenvy.workspace = true dotenvy.workspace = true
+4 -4
View File
@@ -1,9 +1,9 @@
use argon2::{ use argon2::{
password_hash::{
rand_core::OsRng, Error, PasswordHash, PasswordHasher, PasswordVerifier,
SaltString,
},
Argon2, Argon2,
password_hash::{
Error, PasswordHash, PasswordHasher, PasswordVerifier, SaltString,
rand_core::OsRng,
},
}; };
pub fn hash_password(password: &str) -> Result<String, Error> { pub fn hash_password(password: &str) -> Result<String, Error> {
+3 -10
View File
@@ -1,11 +1,9 @@
use crate::{load_env, surrealdb_init_mem, surrealdb_init_ws, Env}; use crate::{Env, load_env, surrealdb_init_mem, surrealdb_init_ws};
use axum::{Router, serve}; use axum::{Router, serve};
use imphnen_entities::{SurrealMemClient, SurrealWsClient}; use imphnen_entities::{SurrealMemClient, SurrealWsClient};
use log::{debug, error, info};
use std::{future::Future, net::SocketAddr}; use std::{future::Future, net::SocketAddr};
use tokio::net::TcpListener; use tokio::net::TcpListener;
pub async fn axum_init<F, Fut>(router_fn: F) pub async fn axum_init<F, Fut>(router_fn: F)
where where
F: FnOnce(SurrealWsClient, SurrealMemClient) -> Fut, F: FnOnce(SurrealWsClient, SurrealMemClient) -> Fut,
@@ -13,24 +11,19 @@ where
{ {
load_env(); load_env();
let env = Env::new(); let env = Env::new();
info!("Environment loaded with port: {}", env.port);
let surrealdb_ws = surrealdb_init_ws().await.expect("Failed surrealdb ws"); let surrealdb_ws = surrealdb_init_ws().await.expect("Failed surrealdb ws");
info!("SurrealDB WS client initialized");
let surrealdb_mem = surrealdb_init_mem().await.expect("Failed surrealdb mem"); let surrealdb_mem = surrealdb_init_mem().await.expect("Failed surrealdb mem");
info!("SurrealDB MEM client initialized");
let router = router_fn(surrealdb_ws, surrealdb_mem).await; let router = router_fn(surrealdb_ws, surrealdb_mem).await;
debug!("Router created successfully");
let port = env.port; let port = env.port;
let addr = SocketAddr::from(([0, 0, 0, 0], port)); let addr = SocketAddr::from(([0, 0, 0, 0], port));
let listener = TcpListener::bind(&addr).await.unwrap(); let listener = TcpListener::bind(&addr).await.unwrap();
info!("Listening on http://{}", addr);
match serve(listener, router).await { match serve(listener, router).await {
Ok(_) => info!("Server stopped gracefully."), Ok(_) => {}
Err(err) => error!("Server encountered an error: {}", err), Err(_err) => {}
} }
} }
+39 -87
View File
@@ -1,7 +1,7 @@
use std::env; use std::env;
pub fn load_env() { pub fn load_env() {
dotenvy::dotenv().ok(); if dotenvy::dotenv().is_ok() {}
} }
pub struct Env { pub struct Env {
@@ -13,6 +13,7 @@ pub struct Env {
pub surrealdb_password: String, pub surrealdb_password: String,
pub surrealdb_namespace: String, pub surrealdb_namespace: String,
pub surrealdb_dbname: String, pub surrealdb_dbname: String,
pub surrealdb_url_ws: String,
pub smtp_email: String, pub smtp_email: String,
pub smtp_password: String, pub smtp_password: String,
pub smtp_name: String, pub smtp_name: String,
@@ -31,112 +32,63 @@ impl Env {
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {
port: env::var("PORT") port: env::var("PORT")
.unwrap_or_else(|_| { .unwrap_or_else(|_| "3000".to_string())
println!("INFO: PORT is not set, using default '3000'.");
"3000".to_string()
})
.parse() .parse()
.unwrap_or(3000), .unwrap_or(3000),
rust_env: env::var("RUST_ENV").unwrap_or_else(|_| { rust_env: env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string()),
println!("INFO: RUST_ENV is not set, using default 'development'.");
"development".to_string()
}),
access_token_secret: env::var("ACCESS_TOKEN_SECRET").unwrap_or_else(|_| { access_token_secret: env::var("ACCESS_TOKEN_SECRET")
println!("WARNING: ACCESS_TOKEN_SECRET is not set, using fallback!"); .unwrap_or_else(|_| "default_access_secret".to_string()),
"default_access_secret".to_string()
}),
refresh_token_secret: env::var("REFRESH_TOKEN_SECRET").unwrap_or_else(|_| { refresh_token_secret: env::var("REFRESH_TOKEN_SECRET")
println!("WARNING: REFRESH_TOKEN_SECRET is not set, using fallback!"); .unwrap_or_else(|_| "default_refresh_secret".to_string()),
"default_refresh_secret".to_string()
}),
surrealdb_url: env::var("SURREALDB_URL").unwrap_or_else(|_| { surrealdb_url: env::var("SURREALDB_URL")
println!( .unwrap_or_else(|_| "http://localhost:8000".to_string()),
"WARNING: SURREALDB_URL is not set, using fallback 'http://localhost:8000'!"
);
"http://localhost:8000".to_string()
}),
surrealdb_username: env::var("SURREALDB_USERNAME").unwrap_or_else(|_| { surrealdb_username: env::var("SURREALDB_USERNAME")
println!("WARNING: SURREALDB_USERNAME is not set, using fallback 'root'!"); .unwrap_or_else(|_| "root".to_string()),
"root".to_string()
}),
surrealdb_password: env::var("SURREALDB_PASSWORD").unwrap_or_else(|_| { surrealdb_password: env::var("SURREALDB_PASSWORD")
println!("WARNING: SURREALDB_PASSWORD is not set, using fallback!"); .unwrap_or_else(|_| "password".to_string()),
"password".to_string()
}),
surrealdb_namespace: env::var("SURREALDB_NAMESPACE").unwrap_or_else(|_| { surrealdb_namespace: env::var("SURREALDB_NAMESPACE")
println!( .unwrap_or_else(|_| "namespace".to_string()),
"WARNING: SURREALDB_NAMESPACE is not set, using fallback 'namespace'!"
);
"namespace".to_string()
}),
surrealdb_dbname: env::var("SURREALDB_DBNAME").unwrap_or_else(|_| { surrealdb_dbname: env::var("SURREALDB_DBNAME")
println!("WARNING: SURREALDB_DBNAME is not set, using fallback 'database'!"); .unwrap_or_else(|_| "database".to_string()),
"database".to_string()
}),
smtp_email: env::var("SMTP_EMAIL").unwrap_or_else(|_| { surrealdb_url_ws: env::var("SURREALDB_URL_WS")
println!( .unwrap_or_else(|_| "ws://localhost:8000/rpc".to_string()),
"WARNING: SMTP_EMAIL is not set, using fallback 'no-reply@example.com'!"
);
"no-reply@example.com".to_string()
}),
smtp_password: env::var("SMTP_PASSWORD").unwrap_or_else(|_| { smtp_email: env::var("SMTP_EMAIL")
println!("WARNING: SMTP_PASSWORD is not set, using fallback!"); .unwrap_or_else(|_| "no-reply@example.com".to_string()),
"default_smtp_password".to_string()
}),
smtp_name: env::var("SMTP_NAME").unwrap_or_else(|_| { smtp_password: env::var("SMTP_PASSWORD")
println!("WARNING: SMTP_NAME is not set, using fallback 'MyApp SMTP'!"); .unwrap_or_else(|_| "default_smtp_password".to_string()),
"MyApp SMTP".to_string()
}),
smtp_host: env::var("SMTP_HOST").unwrap_or_else(|_| { smtp_name: env::var("SMTP_NAME").unwrap_or_else(|_| "MyApp SMTP".to_string()),
println!("WARNING: SMTP_HOST is not set, using fallback 'smtp.gmail.com'!");
"smtp.gmail.com".to_string()
}),
redisdb_url: env::var("REDISDB_URL").unwrap_or_else(|_| { smtp_host: env::var("SMTP_HOST")
println!("WARNING: REDISDB_URL is not set, using fallback 'localhost'!"); .unwrap_or_else(|_| "smtp.gmail.com".to_string()),
"localhost".to_string()
}),
fe_url: env::var("FE_URL").unwrap_or_else(|_| { redisdb_url: env::var("REDISDB_URL")
println!("WARNING: FE_URL is not set, using fallback 'http://localhost'!"); .unwrap_or_else(|_| "localhost".to_string()),
"http://localhost".to_string()
}),
minio_endpoint: env::var("MINIO_ENDPOINT").unwrap_or_else(|_| { fe_url: env::var("FE_URL").unwrap_or_else(|_| "http://localhost".to_string()),
println!(
"WARNING: MINIO_ENDPOINT is not set, using fallback 'http://localhost:9000'!"
);
"http://localhost:9000".to_string()
}),
minio_bucket_name: env::var("MINIO_BUCKET_NAME").unwrap_or_else(|_| { minio_endpoint: env::var("MINIO_ENDPOINT")
println!( .unwrap_or_else(|_| "http://localhost:9000".to_string()),
"WARNING: MINIO_BUCKET_NAME is not set, using fallback 'default_bucket'!"
);
"default_bucket".to_string()
}),
minio_access_key: env::var("MINIO_ACCESS_KEY").unwrap_or_else(|_| { minio_bucket_name: env::var("MINIO_BUCKET_NAME")
println!("WARNING: MINIO_ACCESS_KEY is not set, using fallback!"); .unwrap_or_else(|_| "default_bucket".to_string()),
"minio_access".to_string()
}),
minio_secret_key: env::var("MINIO_SECRET_KEY").unwrap_or_else(|_| { minio_access_key: env::var("MINIO_ACCESS_KEY")
println!("WARNING: MINIO_SECRET_KEY is not set, using fallback!"); .unwrap_or_else(|_| "minio_access".to_string()),
"minio_secret".to_string()
}), minio_secret_key: env::var("MINIO_SECRET_KEY")
.unwrap_or_else(|_| "minio_secret".to_string()),
} }
} }
} }
+3 -3
View File
@@ -2,7 +2,7 @@ use super::Env;
use axum::http::StatusCode; use axum::http::StatusCode;
use chrono::{Duration, TimeDelta, Utc}; use chrono::{Duration, TimeDelta, Utc};
use jsonwebtoken::{ use jsonwebtoken::{
decode, encode, DecodingKey, EncodingKey, Header, TokenData, Validation, DecodingKey, EncodingKey, Header, TokenData, Validation, decode, encode,
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -51,7 +51,7 @@ pub fn decode_access_token(
let env = Env::new(); let env = Env::new();
let secret: String = env.access_token_secret; let secret: String = env.access_token_secret;
let result: Result<TokenData<Claims>, StatusCode> = decode( let result: Result<TokenData<Claims>, StatusCode> = decode(
&jwt_token, jwt_token,
&DecodingKey::from_secret(secret.as_ref()), &DecodingKey::from_secret(secret.as_ref()),
&Validation::default(), &Validation::default(),
) )
@@ -81,7 +81,7 @@ pub fn decode_refresh_token(
let env = Env::new(); let env = Env::new();
let secret: String = env.refresh_token_secret; let secret: String = env.refresh_token_secret;
let result: Result<TokenData<Claims>, StatusCode> = decode( let result: Result<TokenData<Claims>, StatusCode> = decode(
&jwt_token, jwt_token,
&DecodingKey::from_secret(secret.as_ref()), &DecodingKey::from_secret(secret.as_ref()),
&Validation::default(), &Validation::default(),
) )
+2 -8
View File
@@ -29,13 +29,7 @@ pub fn send_email(
.credentials(smtp_credentials) .credentials(smtp_credentials)
.build(); .build();
match mailer.send(&email) { match mailer.send(&email) {
Ok(_) => { Ok(_) => Ok(()),
println!("Email sent successfully to {}", to); Err(e) => Err(Box::new(e)),
Ok(())
}
Err(e) => {
println!("Failed to send email: {}", e);
Err(Box::new(e))
}
} }
} }
+2 -3
View File
@@ -1,10 +1,10 @@
use super::Env; use super::Env;
use crate::SurrealMemClient; use crate::SurrealMemClient;
use surrealdb::engine::local::Mem; use crate::enviroment::load_env;
use surrealdb::engine::any; use surrealdb::engine::any;
use surrealdb::engine::local::Mem;
use surrealdb::opt::auth::Root; use surrealdb::opt::auth::Root;
use surrealdb::{Result, Surreal}; use surrealdb::{Result, Surreal};
use crate::enviroment::load_env;
pub mod resource; pub mod resource;
pub use resource::*; pub use resource::*;
@@ -13,7 +13,6 @@ pub async fn surrealdb_init_ws() -> Result<Surreal<any::Any>> {
load_env(); load_env();
let env = Env::new(); let env = Env::new();
let db = any::connect(&env.surrealdb_url).await?; let db = any::connect(&env.surrealdb_url).await?;
db.signin(Root { db.signin(Root {
username: &env.surrealdb_username, username: &env.surrealdb_username,
+3 -1
View File
@@ -14,6 +14,7 @@ pub enum ResourceEnum {
RolesPermissions, RolesPermissions,
Events, Events,
Testimonials, Testimonials,
Mentors,
} }
impl fmt::Display for ResourceEnum { impl fmt::Display for ResourceEnum {
@@ -31,7 +32,8 @@ impl fmt::Display for ResourceEnum {
ResourceEnum::GachaCredits => "app_gacha_credits", ResourceEnum::GachaCredits => "app_gacha_credits",
ResourceEnum::Events => "app_events", ResourceEnum::Events => "app_events",
ResourceEnum::Testimonials => "app_testimonials", ResourceEnum::Testimonials => "app_testimonials",
ResourceEnum::Mentors => "app_mentors",
}; };
write!(f, "{}", str) write!(f, "{str}")
} }
} }
+4 -4
View File
@@ -4,10 +4,10 @@ version = "0.1.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
imphnen-iam = { version = "0.1.0", path = "../imphnen-iam" } imphnen-iam.workspace = true
imphnen-libs = { version = "0.1.0", path = "../imphnen-libs" } imphnen-libs.workspace = true
imphnen-utils = { version = "0.1.0", path = "../imphnen-utils" } imphnen-utils.workspace = true
imphnen-entities = { version = "0.1.0", path = "../imphnen-entities" } imphnen-entities.workspace = true
axum.workspace = true axum.workspace = true
serde.workspace = true serde.workspace = true
serde_json.workspace = true serde_json.workspace = true
+6 -2
View File
@@ -4,8 +4,8 @@ version = "0.1.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
imphnen-libs = { path = "../imphnen-libs"} imphnen-libs.workspace = true
imphnen-entities = { path = "../imphnen-entities" } imphnen-entities.workspace = true
surrealdb.workspace = true surrealdb.workspace = true
axum.workspace = true axum.workspace = true
chrono.workspace = true chrono.workspace = true
@@ -15,3 +15,7 @@ axum-test.workspace = true
serde.workspace = true serde.workspace = true
serde_json.workspace = true serde_json.workspace = true
validator.workspace = true validator.workspace = true
strum.workspace = true
strum_macros.workspace = true
uuid.workspace = true
tracing.workspace = true
+2 -2
View File
@@ -1,9 +1,9 @@
use surrealdb::method::Query;
use surrealdb::engine::any; use surrealdb::engine::any;
use surrealdb::method::Query;
pub fn bind_filter_value( pub fn bind_filter_value(
query: Query<'_, any::Any>, query: Query<'_, any::Any>,
val: String, val: String,
) -> Query<'_, any::Any> { ) -> Query<'_, any::Any> {
query.bind(("filter", val)) // langsung string aja, udah cukup query.bind(("filter", val))
} }
+3 -14
View File
@@ -1,24 +1,13 @@
use crate::decode_access_token; use crate::decode_access_token;
use axum::http::{header::AUTHORIZATION, HeaderMap}; use axum::http::{HeaderMap, header::AUTHORIZATION};
pub fn extract_email(headers: &HeaderMap) -> Option<String> { pub fn extract_email(headers: &HeaderMap) -> Option<String> {
println!("📥 Received headers: {:?}", headers);
let auth_header = headers.get(AUTHORIZATION)?.to_str().ok()?; let auth_header = headers.get(AUTHORIZATION)?.to_str().ok()?;
println!("🔍 Authorization Header: {}", auth_header);
let token = auth_header.strip_prefix("Bearer ")?; let token = auth_header.strip_prefix("Bearer ")?;
println!("🧪 Token: {}", token);
match decode_access_token(token) { match decode_access_token(token) {
Ok(data) => { Ok(data) => Some(data.claims.sub),
println!("✅ Token claims: {:?}", data.claims); Err(_e) => None,
Some(data.claims.sub)
}
Err(e) => {
eprintln!("❌ Failed to decode token: {}", e);
None
}
} }
} }
+1 -1
View File
@@ -1,4 +1,4 @@
use rand::{rng, Rng}; use rand::{Rng, rng};
pub struct OtpManager; pub struct OtpManager;
+1 -2
View File
@@ -11,6 +11,5 @@ pub fn get_id(thing: &Thing) -> Result<(&str, &str)> {
} }
pub fn extract_id(thing: &Thing) -> String { pub fn extract_id(thing: &Thing) -> String {
let id = thing.id.to_raw(); thing.id.to_raw()
id
} }
+5 -5
View File
@@ -1,16 +1,13 @@
use imphnen_entities::*;
use imphnen_libs::*;
pub mod bind_filter; pub mod bind_filter;
pub mod extract_email; pub mod extract_email;
pub mod generate_date; pub mod generate_date;
pub mod generate_otp; pub mod generate_otp;
pub mod get_id; pub mod get_id;
pub mod make_thing; pub mod make_thing;
pub mod mock_test;
pub mod query_builder; pub mod query_builder;
pub mod query_list; pub mod query_list;
pub mod response_format; pub mod response_format;
pub mod serde_helpers;
pub mod validator; pub mod validator;
pub use bind_filter::*; pub use bind_filter::*;
@@ -21,8 +18,11 @@ pub use get_id::*;
pub use imphnen_entities::*; pub use imphnen_entities::*;
pub use imphnen_libs::*; pub use imphnen_libs::*;
pub use make_thing::*; pub use make_thing::*;
pub use mock_test::*;
pub use query_builder::*; pub use query_builder::*;
pub use query_list::*; pub use query_list::*;
pub use response_format::*; pub use response_format::*;
pub use serde_helpers::{
option_thing_or_string, serialize_option_thing, serialize_thing,
string_or_empty_string, thing_or_string,
};
pub use validator::*; pub use validator::*;
+1 -1
View File
@@ -5,5 +5,5 @@ pub fn make_thing(table: &str, id: &str) -> Thing {
} }
pub fn make_thing_str(table: &str, id: &str) -> String { pub fn make_thing_str(table: &str, id: &str) -> String {
format!("{}:⟨{}", table, id) format!("{table}:⟨{id}")
} }
-51
View File
@@ -1,52 +1 @@
use crate::AppState;
use imphnen_libs::enviroment::load_env;
use surrealdb::engine::any;
use surrealdb::{Surreal, engine::local::Mem, opt::auth::Root};
use super::Env;
pub async fn create_mock_app_state() -> AppState {
load_env();
let env = Env::new();
let db_mem = Surreal::new::<Mem>(()).await.unwrap();
let db_ws = any::connect(&env.surrealdb_url).await.unwrap();
db_mem.use_ns("test").use_db("test").await.unwrap();
db_ws
.signin(Root {
username: "root",
password: "root",
})
.await
.unwrap();
db_ws.use_ns("test").use_db("test").await.unwrap();
AppState {
surrealdb_mem: db_mem,
surrealdb_ws: db_ws,
}
}
pub async fn cleanup_db() {
let app_state = create_mock_app_state().await;
let _ = app_state
.surrealdb_mem
.query(
r#"
REMOVE TABLE app_users;
REMOVE TABLE app_roles;
REMOVE TABLE app_users_cache;
REMOVE TABLE app_otp_cache;
"#,
)
.await;
let _ = app_state
.surrealdb_ws
.query(
r#"
REMOVE TABLE app_users;
REMOVE TABLE app_roles;
REMOVE TABLE app_users_cache;
REMOVE TABLE app_otp_cache;
"#,
)
.await;
}
+75 -38
View File
@@ -1,7 +1,8 @@
use imphnen_libs::MetaRequestDto; use imphnen_libs::MetaRequestDto;
use serde_json::{Map, Value};
use surrealdb::engine::any;
use surrealdb::method::Query; use surrealdb::method::Query;
use surrealdb::sql::Thing; use surrealdb::sql::Thing;
use surrealdb::engine::any;
pub struct ListQueryBuilder { pub struct ListQueryBuilder {
resource: String, resource: String,
@@ -57,8 +58,7 @@ impl ListQueryBuilder {
if let Some(search) = search { if let Some(search) = search {
if !search.is_empty() { if !search.is_empty() {
self.conditions.push(format!( self.conditions.push(format!(
"string::contains(string::lowercase({} ?? ''), string::lowercase($search))", "string::contains(string::lowercase({field} ?? ''), string::lowercase($search))"
field
)); ));
} }
} }
@@ -69,8 +69,7 @@ impl ListQueryBuilder {
if let (Some(f), Some(v)) = (field, value) { if let (Some(f), Some(v)) = (field, value) {
if !v.is_empty() { if !v.is_empty() {
self.conditions.push(format!( self.conditions.push(format!(
"string::contains(string::join('', [{}]), $filter)", "string::contains(string::join('', [{f}]), $filter)"
f
)); ));
} }
} }
@@ -111,7 +110,7 @@ impl ListQueryBuilder {
let order_clause = if let Some(field) = self.order_by { let order_clause = if let Some(field) = self.order_by {
let ord = self.order.unwrap_or_else(|| "ASC".into()); let ord = self.order.unwrap_or_else(|| "ASC".into());
format!("ORDER BY {} {}", field, ord) format!("ORDER BY {field} {ord}")
} else { } else {
String::new() String::new()
}; };
@@ -145,16 +144,26 @@ impl ListQueryBuilder {
fetch_clause fetch_clause
) )
} }
pub fn build_count(self) -> String {
let where_clause = if !self.conditions.is_empty() {
format!("WHERE {}", self.conditions.join(" AND "))
} else {
String::new()
};
format!("SELECT count() FROM {} {}", self.resource, where_clause)
}
} }
pub struct DetailQueryBuilder { pub struct DetailQueryBuilder {
resource: String, resource: String,
id: Option<String>, id: Option<String>,
thing: Option<String>, thing: Option<String>,
where_field: Option<String>,
where_value: Option<String>,
select_fields: Vec<String>, select_fields: Vec<String>,
fetch_fields: Vec<String>, fetch_fields: Vec<String>,
conditions: Vec<String>,
bindings: Map<String, Value>,
} }
impl DetailQueryBuilder { impl DetailQueryBuilder {
@@ -163,40 +172,59 @@ impl DetailQueryBuilder {
resource: resource.into(), resource: resource.into(),
id: None, id: None,
thing: None, thing: None,
where_field: None,
where_value: None,
select_fields: vec![], select_fields: vec![],
fetch_fields: vec![], fetch_fields: vec![],
conditions: vec![],
bindings: Map::new(),
} }
} }
pub fn with_id(mut self, id: impl Into<String>) -> Self { pub fn with_id(mut self, id: impl Into<String>) -> Self {
if self.where_field.is_some() || self.thing.is_some() { if self.thing.is_some() || !self.conditions.is_empty() {
panic!("Cannot use with_id() after with_where() or with_thing()"); panic!(
"Cannot use with_id() after with_thing() or with_where()/with_condition()"
);
} }
self.id = Some(id.into()); self.id = Some(id.into());
self self
} }
pub fn with_thing(mut self, thing: &Thing) -> Self { pub fn with_thing(mut self, thing: &Thing) -> Self {
if self.id.is_some() || self.where_field.is_some() { if self.id.is_some() || !self.conditions.is_empty() {
panic!("Cannot use with_thing() after with_id() or with_where()"); panic!(
"Cannot use with_thing() after with_id() or with_where()/with_condition()"
);
} }
self.thing = Some(thing.to_string()); // app_users:uuid self.thing = Some(thing.to_string());
self.resource = thing.tb.clone(); // update resource dari thing self.resource = thing.tb.clone();
self self
} }
pub fn with_where(mut self, field: impl Into<String>) -> Self { // Modified with_where method
pub fn with_where(
mut self,
field: impl Into<String>,
value: Option<impl Into<String>>,
) -> Self {
if self.id.is_some() || self.thing.is_some() { if self.id.is_some() || self.thing.is_some() {
panic!("Cannot use with_where() after with_id() or with_thing()"); panic!("Cannot use with_where() after with_id() or with_thing()");
} }
self.where_field = Some(field.into()); let field_str = field.into();
if let Some(val) = value {
// Using a distinct binding key to avoid conflicts
self.conditions.push(format!("{field_str} = $value_where"));
self
.bindings
.insert("value_where".to_string(), Value::String(val.into()));
} else {
// If no value, assume it's a direct condition string (e.g., "is_active = true")
self.conditions.push(field_str);
}
self self
} }
pub fn where_value(mut self, value: impl Into<String>) -> Self { pub fn with_condition(mut self, condition: &str) -> Self {
self.where_value = Some(value.into()); self.conditions.push(condition.to_string());
self self
} }
@@ -220,32 +248,41 @@ impl DetailQueryBuilder {
let fetch_clause = if self.fetch_fields.is_empty() { let fetch_clause = if self.fetch_fields.is_empty() {
String::new() String::new()
} else { } else {
format!("FETCH {}", self.fetch_fields.join(", ")) format!("FETCH {}", self.fetch_fields.join(", ")) // Fixed: Changed self.fetch to self.fetch_fields
}; };
let from_clause = if let Some(thing) = &self.thing { // Determine the base FROM clause
let mut from_clause_base = if let Some(thing) = &self.thing {
thing.to_string() thing.to_string()
} else if let Some(id) = &self.id { } else if let Some(id_val) = &self.id {
format!("{}:⟨{}", self.resource, id) format!("{}:⟨{}", self.resource, id_val)
} else if let (Some(field), Some(_)) = (&self.where_field, &self.where_value) {
format!("{} WHERE {} = $value", self.resource, field)
} else { } else {
panic!( self.resource.clone() // Start with resource name for WHERE queries
"You must set one of with_id(), with_thing(), or with_where()+where_value()"
);
}; };
format!( // Add WHERE clause based on accumulated conditions
"SELECT {} FROM {} {}", if !self.conditions.is_empty() {
select_clause, from_clause, fetch_clause // This logic needs to be careful: if `from_clause_base` already contains `WHERE` (e.g. from `id` lookup),
) // then `conditions` should append with `AND`. But for `DetailQueryBuilder`, only one `WHERE` style is expected.
// The panic conditions in `with_id`, `with_thing`, `with_where` should prevent logical conflicts.
from_clause_base = format!(
"{} WHERE {}",
from_clause_base,
self.conditions.join(" AND ")
);
}
format!("SELECT {select_clause} FROM {from_clause_base} {fetch_clause}")
} }
pub fn apply_bindings<'q>(&self, query: Query<'q, any::Any>) -> Query<'q, any::Any> { // Modified apply_bindings to clone both key and value
if let (Some(_), Some(value)) = (&self.where_field, &self.where_value) { pub fn apply_bindings<'q>(
query.bind(("value", value.clone())) &self,
} else { mut query: Query<'q, any::Any>,
query ) -> Query<'q, any::Any> {
for (key, val) in &self.bindings {
query = query.bind((key.clone(), val.clone())); // Clone both key and value
} }
query
} }
} }
+122 -97
View File
@@ -1,117 +1,142 @@
use crate::{CountResult, MetaRequestDto, MetaResponseDto, ResponseListSuccessDto};
use anyhow::Result; use anyhow::Result;
use serde::{Serialize, de::DeserializeOwned}; use imphnen_entities::{
use surrealdb::Surreal; CountResult, MetaRequestDto, MetaResponseDto, ResponseListSuccessDto,
};
use serde::{de::DeserializeOwned, Serialize};
use surrealdb::engine::any; use surrealdb::engine::any;
use surrealdb::Surreal;
use tracing;
pub struct QueryListBuilder<'a> { pub struct QueryListBuilder<'a> {
db: &'a Surreal<any::Any>, db: &'a Surreal<any::Any>,
table: &'a str, table: &'a str,
meta: &'a MetaRequestDto, meta: &'a MetaRequestDto,
conditions: Vec<String>, conditions: Vec<String>,
search_field: String, search_field: String,
select_fields: Option<Vec<&'a str>>, select_fields: Option<Vec<&'a str>>,
fetch_fields: Option<Vec<&'a str>>, fetch_fields: Option<Vec<&'a str>>,
cast_thing_fields: bool,
} }
impl<'a> QueryListBuilder<'a> { impl<'a> QueryListBuilder<'a> {
pub fn new( pub fn new(
db: &'a Surreal<any::Any>, db: &'a Surreal<any::Any>,
table: &'a str, table: &'a str,
meta: &'a MetaRequestDto, meta: &'a MetaRequestDto,
) -> Self { ) -> Self {
Self { Self {
db, db,
table, table,
meta, meta,
conditions: vec![], conditions: vec![],
search_field: "name".to_string(), search_field: "name".to_string(),
select_fields: None, select_fields: None,
fetch_fields: None, fetch_fields: None,
} cast_thing_fields: false,
} }
}
pub fn search_field(mut self, field: &'a str) -> Self { pub fn search_field(mut self, field: &'a str) -> Self {
self.search_field = field.to_string(); self.search_field = field.to_string();
self self
} }
pub fn select_fields(mut self, fields: Vec<&'a str>) -> Self { pub fn select_fields(mut self, fields: Vec<&'a str>) -> Self {
self.select_fields = Some(fields); self.select_fields = Some(fields);
self self
} }
pub fn fetch_fields(mut self, fields: Vec<&'a str>) -> Self { pub fn fetch_fields(mut self, fields: Vec<&'a str>) -> Self {
self.fetch_fields = Some(fields); self.fetch_fields = Some(fields);
self self
} }
pub fn with_condition(mut self, condition: &str) -> Self { pub fn with_condition(mut self, condition: &str) -> Self {
self.conditions.push(condition.to_string()); self.conditions.push(condition.to_string());
self self
} }
pub async fn build<T>(self) -> Result<ResponseListSuccessDto<Vec<T>>> pub fn with_cast_thing_fields(mut self) -> Self {
where self.cast_thing_fields = true;
T: DeserializeOwned + Serialize, self
{ }
let page = self.meta.page.unwrap_or(1).max(1);
let per_page = self.meta.per_page.unwrap_or(10).max(1);
let start = (page - 1) * per_page;
let sql = crate::ListQueryBuilder::from_meta( pub async fn build<T>(self) -> Result<ResponseListSuccessDto<Vec<T>>>
self.table, where
self.meta, T: DeserializeOwned + Serialize,
&self.search_field, {
self.select_fields, let page = self.meta.page.unwrap_or(1).max(1);
self.fetch_fields, let per_page = self.meta.per_page.unwrap_or(10).max(1);
) let start = (page - 1) * per_page;
.build();
let mut query_exec = self.db.query(sql); // --- Data Query ---
if let Some(search) = &self.meta.search { let data_query_builder = crate::ListQueryBuilder::from_meta(
if !search.is_empty() { self.table,
query_exec = query_exec.bind(("search", search.to_lowercase())); self.meta,
} &self.search_field,
} self.select_fields,
if let Some(filter_val) = &self.meta.filter { self.fetch_fields,
query_exec = crate::bind_filter_value(query_exec, filter_val.clone()); );
} let data_sql = data_query_builder.build();
query_exec = query_exec
.bind(("per_page", per_page))
.bind(("start", start));
let raw: Vec<T> = query_exec.await?.take(0)?; // --- Count Query ---
let count_query_builder = crate::ListQueryBuilder::from_meta(
self.table,
self.meta,
&self.search_field,
None, // No select fields for count
None, // No fetch fields for count
);
let count_sql = count_query_builder.build_count();
let mut count_query = self.db.query(format!( // Combine both queries into a single query string within a transaction for a single database call
"SELECT count() FROM {} {}", let combined_sql = format!(
self.table, "BEGIN; {}; {}; COMMIT;",
if self.conditions.is_empty() { data_sql,
"".into() count_sql
} else { );
format!("WHERE {}", self.conditions.join(" AND "))
}
));
if let Some(search) = &self.meta.search { let mut query_exec = self.db.query(combined_sql.clone());
if !search.is_empty() {
count_query = count_query.bind(("search", search.clone()));
}
}
if let Some(filter_val) = &self.meta.filter {
count_query = crate::bind_filter_value(count_query, filter_val.clone());
}
let count_result: Vec<CountResult> = count_query.await?.take(0)?; // Bind parameters for both data and count queries.
let total = count_result.first().map(|c| c.count); // It's assumed that the parameters are named consistently and applied to both.
// The ListQueryBuilder already uses $search, $per_page, $start, $filter.
if let Some(search) = &self.meta.search {
if !search.is_empty() {
query_exec = query_exec.bind(("search", search.to_lowercase()));
}
}
if let Some(filter_val) = &self.meta.filter {
query_exec = crate::bind_filter_value(query_exec, filter_val.clone());
}
query_exec = query_exec
.bind(("per_page", per_page))
.bind(("start", start));
Ok(ResponseListSuccessDto { let query_debug_str = format!("{:?}", &query_exec);
data: raw,
meta: Some(MetaResponseDto { let mut response = query_exec.await.map_err(|e| {
page: Some(page), tracing::error!(
per_page: Some(per_page), query = %combined_sql, // `combined_sql` is cloned, so it can be borrowed here
total, full_query_object = %query_debug_str,
}), "Failed to execute combined query: {:?}", e
}) );
} e
})?;
// Extract results: first for the data, then for the count
let raw: Vec<T> = response.take(0)?; // First result is the data
let count_result: Vec<CountResult> = response.take(1)?; // Second result is the count
let total = count_result.first().map(|c| c.count);
Ok(ResponseListSuccessDto {
data: raw,
meta: Some(MetaResponseDto {
page: Some(page),
per_page: Some(per_page),
total,
}),
})
}
} }
+1 -1
View File
@@ -1,7 +1,7 @@
use axum::{ use axum::{
Json,
http::StatusCode, http::StatusCode,
response::{IntoResponse, Response}, response::{IntoResponse, Response},
Json,
}; };
use serde::Serialize; use serde::Serialize;
use serde_json::json; use serde_json::json;
+130
View File
@@ -0,0 +1,130 @@
use serde::{self, Deserialize, Deserializer, Serializer};
use surrealdb::sql::Thing;
use serde::de::{self};
use serde::ser::Serialize;
use serde_json::Value;
use std::str::FromStr;
pub fn thing_or_string<'de, D>(deserializer: D) -> Result<Thing, D::Error>
where
D: Deserializer<'de>,
{
let v = Value::deserialize(deserializer)?;
match &v {
Value::Object(map) => {
if let Some(id_val) = map.get("Id") {
if let Value::Object(id_map) = id_val {
if let Some(Value::String(s)) = id_map.get("String") {
return Thing::from_str(s).map_err(|e| {
de::Error::custom(format!("Thing::from_str error: {e:?}"))
});
}
}
}
serde_json::from_value(v).map_err(de::Error::custom)
}
Value::String(s) => {
if s.is_empty() {
Thing::from_str("unknown:empty")
.map_err(|e| de::Error::custom(format!("Thing::from_str error: {e:?}")))
} else {
Thing::from_str(s)
.map_err(|e| de::Error::custom(format!("Thing::from_str error: {e:?}")))
}
}
_ => Err(de::Error::custom(
"Expected SurrealDB Thing object, string, or enum Id::String",
)),
}
}
pub fn option_thing_or_string<'de, D>(
deserializer: D,
) -> Result<Option<Thing>, D::Error>
where
D: Deserializer<'de>,
{
let v = Value::deserialize(deserializer)?;
match &v {
Value::Null => Ok(None),
Value::Object(map) => {
if let Some(id_val) = map.get("Id") {
if let Value::Object(id_map) = id_val {
if let Some(Value::String(s)) = id_map.get("String") {
return Ok(Some(Thing::from_str(s).map_err(|e| {
de::Error::custom(format!("Thing::from_str error: {e:?}"))
})?));
}
}
}
Ok(Some(serde_json::from_value(v).map_err(de::Error::custom)?))
}
Value::String(s) => {
if s.is_empty() {
Ok(None)
} else {
Ok(Some(Thing::from_str(s).map_err(|e| {
de::Error::custom(format!("Thing::from_str error: {e:?}"))
})?))
}
}
_ => Err(de::Error::custom(
"Expected SurrealDB Thing object, string, enum Id::String, or null",
)),
}
}
pub fn string_or_empty_string<'de, D>(deserializer: D) -> Result<String, D::Error>
where
D: Deserializer<'de>,
{
let v = Value::deserialize(deserializer)?;
match v {
Value::String(s) => Ok(s),
Value::Null => Ok(String::new()),
_ => Err(de::Error::custom("Expected a string or null")),
}
}
pub fn serialize_thing<S>(thing: &Thing, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
thing.to_string().serialize(serializer)
}
pub fn serialize_option_thing<S>(
thing: &Option<Thing>,
serializer: S,
) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
match thing {
Some(t) => Some(t.to_string()).serialize(serializer),
None => None::<String>.serialize(serializer),
}
}
pub fn serialize_datetime<S>(
datetime: &chrono::DateTime<chrono::Utc>,
serializer: S,
) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(&datetime.to_rfc3339())
}
pub fn deserialize_datetime<'de, D>(
deserializer: D,
) -> Result<chrono::DateTime<chrono::Utc>, D::Error>
where
D: Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
chrono::DateTime::parse_from_rfc3339(&s)
.map_err(de::Error::custom)
.map(|dt| dt.with_timezone(&chrono::Utc))
}
+905
View File
@@ -0,0 +1,905 @@
#!/bin/bash
# ==============================================================================
# IMPHNEN API Comprehensive Test Suite (Bash Version)
# ==============================================================================
BASE_URL="http://127.0.0.1:4099"
TEST_EMAIL="admin@example.com"
TEST_PASSWORD="password"
declare -A ALL_USERS=(
["admin@example.com"]="Admin"
["staff@example.com"]="Staff"
["user@example.com"]="User"
["mentor@example.com"]="Mentor User"
)
START_SERVER=false
SKIP_BASIC=false
SKIP_COMPREHENSIVE=false
SKIP_CRUD=false
GENERATE_REPORT=false
VERBOSE=false
while getopts "sbcrgvh" opt; do
case ${opt} in
s ) START_SERVER=true ;;
b ) SKIP_BASIC=true ;;
c ) SKIP_COMPREHENSIVE=true ;;
r ) SKIP_CRUD=true ;;
g ) GENERATE_REPORT=true ;;
v ) VERBOSE=true ;;
h )
echo "IMPHNEN API Test Suite"
echo "Usage: $0 [OPTIONS]"
echo ""
echo "Options:"
echo " -s Start server automatically"
echo " -b Skip basic tests (auth, error handling)"
echo " -c Skip comprehensive tests (users, roles, mentors, etc.)"
echo " -r Skip CRUD and advanced tests"
echo " -g Generate JSON test report"
echo " -v Verbose output (show all INFO logs)"
echo " -h Show this help message"
echo ""
echo "Examples:"
echo " $0 # Run all tests"
echo " $0 -s # Start server and run all tests"
echo " $0 -g # Run tests and generate report"
echo " $0 -sv # Start server with verbose output"
echo " $0 -bc # Run only public endpoint tests"
exit 0
;;
\? ) echo "Invalid option: -$OPTARG" >&2; echo "Use -h for help" >&2; exit 1 ;;
esac
done
if ! command -v curl &> /dev/null; then
echo "Error: 'curl' tidak ditemukan. Mohon install terlebih dahulu." >&2
exit 1
fi
if ! command -v jq &> /dev/null; then
echo "Error: 'jq' tidak ditemukan. Mohon install terlebih dahulu." >&2
exit 1
fi
TEST_START_TIME=$(date +%s)
AUTH_TOKEN=""
SERVER_PID=""
TEST_RESULTS=()
FALED_TESTS_SUMMARY=()
PASS_COUNT=0
FAIL_COUNT=0
TEST_TESTIMONIAL_ID=""
CYAN='\033[0;36m'
YELLOW='\033[0;33m'
GREEN='\033[0;32m'
RED='\033[0;31m'
BLUE='\033[0;34m'
NC='\033[0m'
cleanup() {
if [ -n "$SERVER_PID" ]; then
printf "\n${YELLOW}Menghentikan proses server...${NC}\n"
kill "$SERVER_PID" &>/dev/null
fi
}
trap cleanup EXIT
write_test_log() {
local level=$1
local message=$2
local color=$NC
case $level in
"SUCCESS") color=$GREEN ;;
"ERROR") color=$RED ;;
"WARN") color=$YELLOW ;;
"INFO") color=$CYAN ;;
esac
if [[ "$VERBOSE" = true || "$level" != "INFO" ]]; then
printf "[$(date +'%H:%M:%S')] [${color}%-7s${NC}] %s\n" "$level" "$message" >&2
fi
}
test_api_endpoint() {
local test_name=$1
local method=$2
local endpoint=$3
local expected_status=$4
local body=$5
local require_auth=$6
local headers=(-H "Content-Type: application/json")
if [[ "$require_auth" = true && -n "$AUTH_TOKEN" ]]; then
headers+=(-H "Authorization: Bearer $AUTH_TOKEN")
elif [[ "$require_auth" = true && -z "$AUTH_TOKEN" ]]; then
write_test_log "WARN" "$test_name - Dilewati: token autentikasi tidak tersedia"
return
fi
local start_req_time=$(date +%s%3N)
response=$(curl -s -w "\n%{http_code}" -X "$method" "${headers[@]}" -d "$body" "$BASE_URL$endpoint")
http_status=$(echo "$response" | tail -n1)
response_body=$(echo "$response" | sed '$d')
local end_req_time=$(date +%s%3N)
local duration=$((end_req_time - start_req_time))
local status="FAIL"
local error_msg=""
if [ "$http_status" -eq "$expected_status" ]; then
status="PASS"
((PASS_COUNT++))
write_test_log "SUCCESS" "$test_name - Sukses (Status: $http_status, Waktu: ${duration}ms)"
else
status="FAIL"
((FAIL_COUNT++))
write_test_log "ERROR" " Request Body: $body"
write_test_log "ERROR" " Response Body: $response_body"
error_msg="Status yang diharapkan $expected_status, tetapi mendapat $http_status."
write_test_log "ERROR" "$test_name - Gagal: $error_msg"
FAILED_TESTS_SUMMARY+=("$test_name - $error_msg")
fi
result_json=$(jq -n --arg name "$test_name" --arg ep "$endpoint" --arg meth "$method" \
--arg stat "$status" --arg code "$http_status" --arg dur "$duration" \
--arg err "$error_msg" \
'{TestName: $name, Endpoint: $ep, Method: $meth, Status: $stat, StatusCode: $code, ResponseTimeMs: $dur, Error: $err}')
TEST_RESULTS+=("$result_json")
# Return response_body for further processing if needed by the caller
}
test_server_connection() {
curl -s --head "$BASE_URL/v1/cms/landing/events" > /dev/null
return $?
}
clear_database() {
write_test_log "INFO" "Membersihkan database via WebSocket..."
if ! cargo run --bin clear_db_test --release; then
write_test_log "ERROR" "Gagal membersihkan database."
exit 1
fi
write_test_log "SUCCESS" "Pembersihan database selesai."
}
get_auth_token() {
write_test_log "INFO" "Mengautentikasi test user..."
local login_data
login_data=$(jq -n --arg email "$TEST_EMAIL" --arg pass "$TEST_PASSWORD" '{email: $email, password: $pass}')
local headers=(-H "Content-Type: application/json")
local start_req_time=$(date +%s%3N)
response=$(curl -s -w "\n%{http_code}" -X "POST" "${headers[@]}" -d "$login_data" "$BASE_URL/v1/auth/login")
local http_status=$(echo "$response" | tail -n1)
local response_body=$(echo "$response" | sed '$d')
local end_req_time=$(date +%s%3N)
local duration=$((end_req_time - start_req_time))
if [ "$http_status" -eq 200 ]; then
if echo "$response_body" | jq . > /dev/null 2>&1; then
AUTH_TOKEN=$(echo "$response_body" | jq -r '.data.token.access_token // empty')
if [[ -n "$AUTH_TOKEN" && "$AUTH_TOKEN" != "null" ]]; then
write_test_log "SUCCESS" "✓ User Authentication - Sukses (Status: $http_status, Waktu: ${duration}ms)"
write_test_log "SUCCESS" "Autentikasi berhasil"
((PASS_COUNT++))
else
write_test_log "ERROR" "Autentikasi gagal - token tidak ditemukan dalam response"
AUTH_TOKEN=""
((FAIL_COUNT++))
fi
else
write_test_log "ERROR" "Autentikasi gagal - response bukan JSON valid"
AUTH_TOKEN=""
((FAIL_COUNT++))
fi
else
write_test_log "ERROR" "✗ User Authentication - Gagal (Status: $http_status, Waktu: ${duration}ms)"
AUTH_TOKEN=""
((FAIL_COUNT++))
fi
local status="PASS"
local error_msg=""
if [ "$http_status" -ne 200 ] || [[ -z "$AUTH_TOKEN" ]]; then
status="FAIL"
error_msg="Authentication failed"
fi
result_json=$(jq -n --arg name "User Authentication" --arg ep "/v1/auth/login" --arg meth "POST" \
--arg stat "$status" --arg code "$http_status" --arg dur "$duration" \
--arg err "$error_msg" \
'{TestName: $name, Endpoint: $ep, Method: $meth, Status: $stat, StatusCode: $code, ResponseTimeMs: $dur, Error: $err}')
TEST_RESULTS+=("$result_json")
}
test_all_users_login_performance() {
printf "\n${CYAN}=== Menguji Login Performance Semua User ===${NC}\n"
local total_login_time=0
local successful_logins=0
local failed_logins=0
local email="admin@example.com"
local fullname="${ALL_USERS[$email]}"
write_test_log "INFO" "Testing login for: $fullname ($email)"
local login_data
login_data=$(jq -n --arg email "$email" --arg pass "$TEST_PASSWORD" '{email: $email, password: $pass}')
local start_time=$(date +%s%3N)
response=$(curl -s -w "\n%{http_code}" -X "POST" \
-H "Content-Type: application/json" \
-d "$login_data" \
"$BASE_URL/v1/auth/login")
local http_status=$(echo "$response" | tail -n1)
local response_body=$(echo "$response" | sed '$d')
local end_time=$(date +%s%3N)
local duration=$((end_time - start_time))
total_login_time=$((total_login_time + duration))
if [ "$http_status" -eq 200 ]; then
if echo "$response_body" | jq -e '.data.token.access_token' > /dev/null 2>&1; then
((successful_logins++))
((PASS_COUNT++))
write_test_log "SUCCESS" "✓ Login $fullname - ${duration}ms"
result_json=$(jq -n --arg name "Login Performance - $fullname" --arg ep "/v1/auth/login" --arg meth "POST" \
--arg stat "PASS" --arg code "$http_status" --arg dur "$duration" \
--arg err "" \
'{TestName: $name, Endpoint: $ep, Method: $meth, Status: $stat, StatusCode: $code, ResponseTimeMs: $dur, Error: $err}')
TEST_RESULTS+=("$result_json")
else
((failed_logins++))
((FAIL_COUNT++))
write_test_log "ERROR" "✗ Login $fullname - No token (${duration}ms)"
FAILED_TESTS_SUMMARY+=("✗ Login $fullname - No token in response")
fi
else
((failed_logins++))
((FAIL_COUNT++))
write_test_log "ERROR" "✗ Login $fullname - HTTP $http_status (${duration}ms)"
FAILED_TESTS_SUMMARY+=("✗ Login $fullname - HTTP $http_status")
fi
local total_users=1
local avg_login_time=0
if [ "$total_users" -gt 0 ]; then
avg_login_time=$((total_login_time / total_users))
fi
printf "\n${BLUE}=== Login Performance Summary ===${NC}\n"
printf "Total Users Tested: %d\n" "$total_users"
printf "${GREEN}Successful Logins: %d${NC}\n" "$successful_logins"
printf "${RED}Failed Logins: %d${NC}\n" "$failed_logins"
printf "${BLUE}Average Login Time: %dms${NC}\n" "$avg_login_time"
printf "${BLUE}Total Login Time: %dms${NC}\n" "$total_login_time"
if [ "$avg_login_time" -lt 2000 ]; then
printf "${GREEN}✅ Performance Status: EXCELLENT (< 2s average)${NC}\n"
elif [ "$avg_login_time" -lt 5000 ]; then
printf "${YELLOW}⚠️ Performance Status: GOOD (2-5s average)${NC}\n"
else
printf "${RED}❌ Performance Status: POOR (> 5s average)${NC}\n"
fi
printf "\n"
}
test_with_user() {
local email=$1
local fullname=$2
local test_name=$3
write_test_log "INFO" "Testing $test_name dengan user: $fullname ($email)"
local login_data
login_data=$(jq -n --arg email "$email" --arg pass "$TEST_PASSWORD" '{email: $email, password: $pass}')
local start_time=$(date +%s%3N)
response=$(curl -s -w "\n%{http_code}" -X "POST" \
-H "Content-Type: application/json" \
-d "$login_data" \
"$BASE_URL/v1/auth/login")
local http_status=$(echo "$response" | tail -n1)
local response_body=$(echo "$response" | sed '$d')
local end_time=$(date +%s%3N)
local duration=$((end_time - start_time))
if [ "$http_status" -eq 200 ]; then
if echo "$response_body" | jq -e '.data.token.access_token' > /dev/null 2>&1; then
local user_auth_token=$(echo "$response_body" | jq -r '.data.token.access_token')
write_test_log "SUCCESS" "✓ Login $fullname berhasil - ${duration}ms"
local me_response
me_response=$(curl -s -w "\n%{http_code}" -X "GET" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $user_auth_token" \
"$BASE_URL/v1/users/me")
local me_status=$(echo "$me_response" | tail -n1)
local me_body=$(echo "$me_response" | sed '$d')
if [ "$me_status" -eq 200 ]; then
((PASS_COUNT++))
write_test_log "SUCCESS" "✓ Get profile $fullname berhasil"
local user_email=$(echo "$me_body" | jq -r '.data.email // empty')
local user_name=$(echo "$me_body" | jq -r '.data.fullname // empty')
if [ "$user_email" = "$email" ]; then
write_test_log "SUCCESS" "✓ User data verified: $user_name ($user_email)"
else
write_test_log "WARN" "⚠ User data mismatch: expected $email, got $user_email"
fi
else
((FAIL_COUNT++))
write_test_log "ERROR" "✗ Get profile $fullname gagal - HTTP $me_status"
FAILED_TESTS_SUMMARY+=("✗ Get profile $fullname - HTTP $me_status")
fi
else
((FAIL_COUNT++))
write_test_log "ERROR" "✗ Login $fullname - No token (${duration}ms)"
FAILED_TESTS_SUMMARY+=("✗ Login $fullname - No token in response")
fi
else
write_test_log "ERROR" "✗ Login $fullname gagal - HTTP $http_status (${duration}ms)"
FAILED_TESTS_SUMMARY+=("✗ Login $fullname - HTTP $http_status")
fi
}
test_all_users_individually() {
printf "\n${CYAN}=== Menguji Semua User Secara Individual ===${NC}\n"
for email in "${!ALL_USERS[@]}"; do
local fullname="${ALL_USERS[$email]}"
test_with_user "$email" "$fullname" "Individual User Test"
echo ""
done
}
test_comprehensive_with_user() {
printf "\n${CYAN}=== Comprehensive Test untuk $fullname ($email) ===${NC}\n"
local login_data
login_data=$(jq -n --arg email "$email" --arg pass "$TEST_PASSWORD" '{email: $email, password: $pass}')
local start_time=$(date +%s%3N)
response=$(curl -s -w "\n%{http_code}" -X "POST" \
-H "Content-Type: application/json" \
-d "$login_data" \
"$BASE_URL/v1/auth/login")
local http_status=$(echo "$response" | tail -n1)
local response_body=$(echo "$response" | sed '$d')
local end_time=$(date +%s%3N)
local duration=$((end_time - start_time))
if [ "$http_status" -eq 200 ]; then
if echo "$response_body" | jq -e '.data.token.access_token' > /dev/null 2>&1; then
user_token=$(echo "$response_body" | jq -r '.data.token.access_token')
write_test_log "SUCCESS" "✓ Login $fullname berhasil - ${duration}ms"
local original_auth_token="$AUTH_TOKEN"
AUTH_TOKEN="$user_token"
printf "\n${BLUE}--- Testing dengan $fullname (Expected results berdasarkan role) ---${NC}\n"
test_api_endpoint "Get Current User Profile - $fullname" "GET" "/v1/users/me" 200 "" true
case "$email" in
"admin@example.com")
test_api_endpoint "Get Users List - $fullname" "GET" "/v1/users" 200 "" true
test_api_endpoint "Get Roles List - $fullname" "GET" "/v1/roles" 200 "" true
test_api_endpoint "Get Permissions List - $fullname" "GET" "/v1/permissions" 200 "" true
test_api_endpoint "Get Mentors List - $fullname" "GET" "/v1/mentors" 200 "" true
test_api_endpoint "Get Mentor Me - $fullname" "GET" "/v1/mentors/me" 403 "" true # Admin is not a mentor
test_api_endpoint "Get Mentor Status - $fullname" "GET" "/v1/mentors/status" 403 "" true # Admin is not a mentor
test_api_endpoint "Get Gacha Items - $fullname" "GET" "/v1/gacha/items" 200 "" true
test_api_endpoint "Execute Gacha Roll - $fullname" "POST" "/v1/gacha/rolls/execute" 200 "" true
local testimonial_data
testimonial_data=$(jq -n --arg content "Test testimonial by $fullname $(date +%s)" '{role: "Student", content: $content}')
test_api_endpoint "Create Testimonial - $fullname" "POST" "/v1/cms/landing/testimonials/create" 201 "$testimonial_data" true
;;
"staff@example.com")
test_api_endpoint "Get Users List - $fullname" "GET" "/v1/users" 200 "" true
test_api_endpoint "Get Roles List - $fullname" "GET" "/v1/roles" 200 "" true
test_api_endpoint "Get Permissions List - $fullname" "GET" "/v1/permissions" 200 "" true
test_api_endpoint "Get Mentors List - $fullname" "GET" "/v1/mentors" 200 "" true
test_api_endpoint "Get Gacha Items - $fullname" "GET" "/v1/gacha/items" 200 "" true
test_api_endpoint "Execute Gacha Roll - $fullname" "POST" "/v1/gacha/rolls/execute" 200 "" true
local testimonial_data
testimonial_data=$(jq -n --arg content "Test testimonial by $fullname $(date +%s)" '{role: "Student", content: $content}')
test_api_endpoint "Create Testimonial - $fullname" "POST" "/v1/cms/landing/testimonials/create" 201 "$testimonial_data" true
;;
"mentor@example.com")
test_api_endpoint "Get Users List - $fullname" "GET" "/v1/users" 403 "" true
test_api_endpoint "Get Roles List - $fullname" "GET" "/v1/roles" 403 "" true
test_api_endpoint "Get Permissions List - $fullname" "GET" "/v1/permissions" 403 "" true
test_api_endpoint "Get Mentors List - $fullname" "GET" "/v1/mentors" 200 "" true
test_api_endpoint "Get Mentor Me - $fullname" "GET" "/v1/mentors/me" 200 "" true
test_api_endpoint "Get Mentor Status - $fullname" "GET" "/v1/mentors/status" 200 "" true
test_api_endpoint "Get Gacha Items - $fullname" "GET" "/v1/gacha/items" 200 "" true
test_api_endpoint "Execute Gacha Roll - $fullname" "POST" "/v1/gacha/rolls/execute" 200 "" true
local testimonial_data
testimonial_data=$(jq -n --arg content "Test testimonial by $fullname $(date +%s)" '{role: "Student", content: $content}')
test_api_endpoint "Create Testimonial - $fullname" "POST" "/v1/cms/landing/testimonials/create" 201 "$testimonial_data" true
;;
"user@example.com")
test_api_endpoint "Get Users List - $fullname" "GET" "/v1/users" 403 "" true
test_api_endpoint "Get Roles List - $fullname" "GET" "/v1/roles" 403 "" true
test_api_endpoint "Get Permissions List - $fullname" "GET" "/v1/permissions" 403 "" true
test_api_endpoint "Get Mentors List - $fullname" "GET" "/v1/mentors" 200 "" true
test_api_endpoint "Get Mentor Me - $fullname" "GET" "/v1/mentors/me" 403 "" true # User is not a mentor
test_api_endpoint "Get Mentor Status - $fullname" "GET" "/v1/mentors/status" 403 "" true # User is not a mentor
test_api_endpoint "Get Gacha Items - $fullname" "GET" "/v1/gacha/items" 200 "" true
test_api_endpoint "Execute Gacha Roll - $fullname" "POST" "/v1/gacha/rolls/execute" 200 "" true
local testimonial_data
testimonial_data=$(jq -n --arg content "Test testimonial by $fullname $(date +%s)" '{role: "Student", content: $content}')
test_api_endpoint "Create Testimonial - $fullname" "POST" "/v1/cms/landing/testimonials/create" 201 "$testimonial_data" true
;;
esac
test_api_endpoint "Events with Advanced Filter - $fullname" "GET" "/v1/cms/landing/events?filter=online&filter_by=is_online" 200 "" false
test_api_endpoint "Testimonials with Search - $fullname" "GET" "/v1/cms/landing/testimonials?search=test" 200 "" false
case "$email" in
"admin@example.com"|"staff@example.com")
test_api_endpoint "Users with Sort - $fullname" "GET" "/v1/users?sort_by=created_at&order=DESC" 200 "" true
;;
*)
test_api_endpoint "Users with Sort - $fullname" "GET" "/v1/users?sort_by=created_at&order=DESC" 403 "" true
;;
esac
AUTH_TOKEN="$original_auth_token"
write_test_log "SUCCESS" "✓ Comprehensive test untuk $fullname selesai"
else
write_test_log "ERROR" "✗ Login $fullname gagal - No token (${duration}ms)"
fi
else
write_test_log "ERROR" "✗ Login $fullname gagal - HTTP $http_status (${duration}ms)"
fi
}
test_all_endpoints_with_all_users() {
printf "\n${CYAN}=== Menjalankan Semua Test dengan Semua User ===${NC}\n"
for email in "${!ALL_USERS[@]}"; do
local fullname="${ALL_USERS[$email]}"
test_comprehensive_with_user "$email" "$fullname"
printf "\n${BLUE}--- Selesai testing dengan $fullname ---${NC}\n\n"
done
}
test_public_endpoints() {
printf "\n${CYAN}=== Menguji Public Endpoints ===${NC}\n"
test_api_endpoint "Get Events List" "GET" "/v1/cms/landing/events" 200
test_api_endpoint "Get Testimonials List" "GET" "/v1/cms/landing/testimonials" 200
}
test_authentication_endpoints() {
printf "\n${CYAN}=== Menguji Authentication Endpoints ===${NC}\n"
get_auth_token
local invalid_login
invalid_login=$(jq -n '{email: "invalid@example.com", password: "wrongpassword"}')
test_api_endpoint "Invalid Login Test" "POST" "/v1/auth/login" 401 "$invalid_login"
# User registration and verification tests currently rely on external email service or OTP logic
# that is not easily testable in a simple curl script without actual email sending/receiving.
# Skipping these tests for now.
# local register_email="test_user_$(date +%s%N)@example.com"
# local register_data=$(jq -n --arg email "$register_email" --arg pass "$TEST_PASSWORD" --arg fullname "Test Register" --arg phone "081234567899" '{email: $email, password: $pass, fullname: $fullname, phone_number: $phone}')
# test_api_endpoint "User Registration Test" "POST" "/v1/auth/register" 200 "$register_data"
# local verify_otp_data=$(jq -n --arg email "$register_email" --arg otp "123456" '{email: $email, otp: ($otp | tonumber)}')
# test_api_endpoint "Verify Email Test (Invalid OTP)" "POST" "/v1/auth/verify-email" 400 "$verify_otp_data"
local forgot_password_data
forgot_password_data=$(jq -n --arg email "$TEST_EMAIL" '{email: $email}')
test_api_endpoint "Forgot Password Test" "POST" "/v1/auth/forgot" 400 "$forgot_password_data"
local new_password_data
new_password_data=$(jq -n --arg token "some_reset_token" --arg pass "newpassword123!A" '{token: $token, password: $pass}')
test_api_endpoint "New Password Test (Invalid Token)" "POST" "/v1/auth/new-password" 400 "$new_password_data"
local refresh_token=$(curl -s -X POST -H "Content-Type: application/json" -d "$(jq -n --arg email "$TEST_EMAIL" --arg pass "$TEST_PASSWORD" '{email: $email, password: $pass}')" "$BASE_URL/v1/auth/login" | jq -r '.data.token.refresh_token // empty')
if [ -n "$refresh_token" ]; then
local refresh_data
refresh_data=$(jq -n --arg token "$refresh_token" '{refresh_token: $token}')
test_api_endpoint "Refresh Token Test" "POST" "/v1/auth/refresh" 200 "$refresh_data"
else
write_test_log "WARN" "✗ Refresh Token Test - Dilewati: Refresh token tidak tersedia dari login"
fi
}
test_error_handling() {
printf "\n${CYAN}=== Menguji Error Handling ===${NC}\n"
test_api_endpoint "Non-existent Endpoint" "GET" "/v1/nonexistent" 404
test_api_endpoint "Unauthorized Access" "GET" "/v1/users" 401 "" false
}
test_user_management_endpoints() {
printf "\n${CYAN}=== Menguji User Management Endpoints ===${NC}\n"
test_api_endpoint "Get Users List" "GET" "/v1/users" 200 "" true
local test_user_id="c3b1d6a8-8d4f-4b36-b789-2e532ec7a7b2"
test_api_endpoint "Get User By ID" "GET" "/v1/users/detail/$test_user_id" 200 "" true
local new_user_email="new_test_user_$(date +%s%N)@example.com"
local new_user_fullname="New Test User $(date +%s%N)"
local new_user_phone="089876543211"
local new_user_password="NewPassword123!"
local new_user_role_id="5713cb37-dc02-4e87-8048-d7a41d352059" # User role ID from seed_users.rs
local create_user_data=$(jq -n \
--arg email "$new_user_email" \
--arg pass "$new_user_password" \
--arg fullname "$new_user_fullname" \
--arg phone "$new_user_phone" \
--arg is_active true \
--arg role_id "$new_user_role_id" \
'{email: $email, password: $pass, fullname: $fullname, phone_number: $phone, is_active: $is_active | fromjson, role_id: $role_id}')
test_api_endpoint "Create New User" "POST" "/v1/users/create" 201 "$create_user_data" true
# Assuming the created user can be fetched by email for update/delete
local created_user_id=$(curl -s -X GET -H "Authorization: Bearer $AUTH_TOKEN" "$BASE_URL/v1/users?search=$new_user_email" | jq -r '.data[0].id // empty')
if [ -n "$created_user_id" ]; then
local updated_user_fullname="Updated Test User $(date +%s%N)"
local updated_user_data=$(jq -n \
--arg email "$new_user_email" \
--arg pass "$new_user_password" \
--arg fullname "$updated_user_fullname" \
--arg phone "$new_user_phone" \
--arg is_active true \
--arg gender "Male" \
--arg birthdate "1990-01-01" \
--arg avatar "https://example.com/avatar.jpg" \
--arg role_id "$new_user_role_id" \
'{email: $email, password: $pass, fullname: $fullname, phone_number: $phone, is_active: $is_active | fromjson, gender: $gender, birthdate: $birthdate, avatar: $avatar, role_id: $role_id}')
test_api_endpoint "Update User" "PUT" "/v1/users/update/$created_user_id" 200 "$updated_user_data" true
local set_active_data=$(jq -n --arg is_active false '{is_active: $is_active | fromjson}')
test_api_endpoint "Deactivate User" "PUT" "/v1/users/activate/$created_user_id" 200 "$set_active_data" true
local set_active_data=$(jq -n --arg is_active true '{is_active: $is_active | fromjson}')
test_api_endpoint "Reactivate User" "PUT" "/v1/users/activate/$created_user_id" 200 "$set_active_data" true
test_api_endpoint "Delete User" "DELETE" "/v1/users/delete/$created_user_id" 200 "" true
else
write_test_log "WARN" "✗ Skipping User Update/Delete tests: Failed to retrieve ID of newly created user."
fi
}
test_crud_operations() {
printf "\n${CYAN}=== Menguji CRUD Operations ===${NC}\n"
local testimonial_data
testimonial_data=$(jq -n --arg content "Test testimonial via Bash $(date +%s)" '{role: "Student", content: $content}')
local testimonial_response=$(test_api_endpoint "Create Testimonial" "POST" "/v1/cms/landing/testimonials/create" 201 "$testimonial_data" true)
TEST_TESTIMONIAL_ID=$(echo "$testimonial_response" | jq -r '.data.id // empty')
write_test_log "INFO" "TEST_TESTIMONIAL_ID: $TEST_TESTIMONIAL_ID"
write_test_log "INFO" "Captured Testimonial ID: $TEST_TESTIMONIAL_ID"
sleep 0.2
local permission_data
permission_data=$(jq -n --arg name "Test Permission $(date +%s)" '{name: $name}')
test_api_endpoint "Create Permission" "POST" "/v1/permissions/create" 201 "$permission_data" true
local gacha_item_data
gacha_item_data=$(jq -n --arg name "Test Item $(date +%s)" '{name: $name, image_url: "https://example.com/id.jpg"}')
test_api_endpoint "Create Gacha Item" "POST" "/v1/gacha/items/create" 201 "$gacha_item_data" true
local event_data
event_data=$(jq -n --arg name "Test Event $(date +%s)" '{
name: $name,
description: "Test event description",
detail_link: "https://example.com/event",
price: 50.0,
is_online: true,
start_date: "2025-12-01T10:00:00Z",
end_date: "2025-12-01T16:00:00Z",
location: null
}')
test_api_endpoint "Create Event" "POST" "/v1/cms/landing/events/create" 201 "$event_data" true
}
test_roles_and_permissions() {
printf "\n${CYAN}=== Menguji Roles & Permissions Endpoints ===${NC}\n"
test_api_endpoint "Get Roles List" "GET" "/v1/roles" 200 "" true
test_api_endpoint "Get Permissions List" "GET" "/v1/permissions" 200 "" true
local test_role_id="3b9f8c4e-6a2d-4f8a-9a12-2d6f8b3c4e5a"
test_api_endpoint "Get Role By ID" "GET" "/v1/roles/detail/$test_role_id" 200 "" true
}
test_mentor_endpoints() {
printf "\n${CYAN}=== Menguji Mentor Endpoints ===${NC}\n"
test_api_endpoint "Get Mentors List" "GET" "/v1/mentors" 200 "" true
# These tests are run with AUTH_TOKEN set to admin. Since admin is not a mentor, these should be 403.
test_api_endpoint "Get Mentor Me" "GET" "/v1/mentors/me" 403 "" true
test_api_endpoint "Get Mentor Status" "GET" "/v1/mentors/status" 403 "" true
local test_mentor_id="e6f78d23-83bf-5c2b-bcd4-001345678901"
test_api_endpoint "Get Mentor By ID" "GET" "/v1/mentors/detail/$test_mentor_id" 200 "" true
local mentor_register_data
mentor_register_data=$(jq -n --arg email "test.mentor.$(date +%s%N)@example.com" '{
identity_and_verification: {
legal_name: "Test Mentor Legal Name",
identity_document_url: "https://example.com/id.jpg",
phone_for_verification: "+1234567890"
},
professional_profile: {
bio: "Test mentor bio",
linkedin_url: "https://linkedin.com/in/testmentor",
industries: ["Technology", "Software"],
expertise: ["JavaScript", "Python"],
languages: ["English", "Indonesian"],
current_company: "Test Company",
current_role: "Senior Developer",
years_of_experience: 5
},
mentoring_logistics: {
topics_of_interest: ["Career Development", "Technical Skills"],
preferred_mentee_level: ["Junior", "Mid-level"],
preferred_mentoring_formats: ["1-on-1", "Group"],
availability_commitment: "2-3 hours per week",
mentoring_rate: {
amount: 100000,
currency: "IDR",
per_duration: "hour"
}
},
email: $email
}')
test_api_endpoint "Register as Mentor" "POST" "/v1/mentors/register" 422 "$mentor_register_data" true
}
test_events_endpoints() {
printf "\n${CYAN}=== Menguji Events Endpoints ===${NC}\n"
test_api_endpoint "Get Events with Pagination" "GET" "/v1/cms/landing/events?page=1&per_page=5" 200
test_api_endpoint "Get Events with Search" "GET" "/v1/cms/landing/events?search=tech" 200
local test_event_id="e1a2b3c4-5d6e-7f8g-9h0i-1j2k3l4m5n6o"
test_api_endpoint "Get Event By ID" "GET" "/v1/cms/landing/events/detail/$test_event_id" 200
}
test_testimonials_endpoints() {
printf "\n${CYAN}=== Menguji Testimonials Endpoints ===${NC}\n"
test_api_endpoint "Get Testimonials with Pagination" "GET" "/v1/cms/landing/testimonials?page=1&per_page=5" 200
# Ensure TEST_TESTIMONIAL_ID is not empty before testing
if [ -n "$TEST_TESTIMONIAL_ID" ]; then
test_api_endpoint "Get Testimonial By ID" "GET" "/v1/cms/landing/testimonials/detail/$TEST_TESTIMONIAL_ID" 200
else
write_test_log "WARN" "✗ Get Testimonial By ID - Dilewati: TEST_TESTIMONIAL_ID tidak tersedia"
fi
}
test_gacha_endpoints() {
printf "\n${CYAN}=== Menguji Gacha Endpoints ===${NC}\n"
test_api_endpoint "Get Gacha Items" "GET" "/v1/gacha/items" 200 "" true
test_api_endpoint "Execute Gacha Roll" "POST" "/v1/gacha/rolls/execute" 200 "" true
}
test_advanced_scenarios() {
printf "\n${CYAN}=== Menguji Advanced Scenarios ===${NC}\n"
test_api_endpoint "Events with Advanced Filter" "GET" "/v1/cms/landing/events?filter=online&filter_by=is_online" 200
test_api_endpoint "Users with Sort" "GET" "/v1/users?sort_by=created_at&order=DESC" 200 "" true
test_api_endpoint "Testimonials with Search" "GET" "/v1/cms/landing/testimonials?search=test" 200
local mentor_register_data
mentor_register_data=$(jq -n --arg email "test.mentor.$(date +%s%N)@example.com" '{
identity_and_verification: {
legal_name: "Test Mentor Legal Name",
identity_document_url: "https://example.com/id.jpg",
phone_for_verification: "+1234567890"
},
professional_profile: {
bio: "Test mentor bio",
linkedin_url: "https://linkedin.com/in/testmentor",
industries: ["Technology", "Software"],
expertise: ["JavaScript", "Python"],
languages: ["English", "Indonesian"],
current_company: "Test Company",
current_role: "Senior Developer",
years_of_experience: 5
},
mentoring_logistics: {
topics_of_interest: ["Career Development", "Technical Skills"],
preferred_mentee_level: ["Junior", "Mid-level"],
preferred_mentoring_formats: ["1-on-1", "Group"],
availability_commitment: "2-3 hours per week",
mentoring_rate: {
amount: 100000,
currency: "IDR",
per_duration: "hour"
}
},
email: $email
}')
test_api_endpoint "Register as Mentor" "POST" "/v1/mentors/register" 422 "$mentor_register_data" true
test_api_endpoint "Invalid POST to GET endpoint" "POST" "/v1/cms/landing/events" 405
test_api_endpoint "Invalid PUT with Invalid ID" "PUT" "/v1/users/update/some_invalid_id" 400 "" true
}
show_test_summary() {
printf "\n${YELLOW}=== Test Coverage Summary ===${NC}\n"
printf "📋 Authentication: Login, Forgot Password, OTP\n"
printf "👥 Users: List, Details, Profile Management\n"
printf "🔐 Roles & Permissions: RBAC System Testing\n"
printf "👨‍🏫 Mentors: Registration, Profile, Status\n"
printf "📅 Events: CRUD Operations, Filtering\n"
printf "💬 Testimonials: Management & Creation\n"
printf "🎲 Gacha: Items, Rolls, Claims\n"
printf "🔧 Advanced: Pagination, Search, Edge Cases\n"
printf "❌ Error Handling: 401, 404, Invalid Requests\n"
printf "\n"
}
printf "${CYAN}=== IMPHNEN API Comprehensive Test Suite ===${NC}\n"
printf "${YELLOW}Base URL: %s${NC}\n" "$BASE_URL"
show_test_summary
if [ "$START_SERVER" = true ]; then
if ! command -v cargo &> /dev/null; then
write_test_log "ERROR" "Perintah 'cargo' tidak ditemukan. Tidak bisa memulai server."
exit 1
fi
printf "${YELLOW}Memulai server backend...${NC}\n"
cargo run --bin api &
SERVER_PID=$!
printf "${YELLOW}Menunggu server siap...${NC}\n"
retries=0
max_retries=100 # Increased from 15 to 30
until test_server_connection; do
((retries++))
if [ $retries -ge $max_retries ]; then
write_test_log "ERROR" "Gagal memulai server dalam timeout\. Cek output terminal untuk detail\."
exit 1
fi
sleep 2
done
write_test_log "SUCCESS" "Server berjalan!"
else
if ! test_server_connection; then
write_test_log "ERROR" "Server tidak berjalan di $BASE_URL"
write_test_log "WARN" "Silakan jalankan server secara manual atau gunakan flag -s"
exit 1
fi
write_test_log "SUCCESS" "Server sudah berjalan di $BASE_URL"
fi
clear_database
printf "\n${CYAN}=== Menjalankan Seeders ===${NC}\n"
if ! cargo run --bin seeder; then
write_test_log "ERROR" "Gagal menjalankan seeder roles permissions."
exit 1
fi
write_test_log "SUCCESS" "Seeders selesai."
printf "\n${CYAN}=== Menampilkan User yang Tersedia ===${NC}\n"
for email in "${!ALL_USERS[@]}"; do
fullname="${ALL_USERS[$email]}"
printf "${BLUE}$fullname${NC} - ${email}\n"
done
printf "\n"
test_public_endpoints
test_all_users_login_performance
test_all_users_individually
if [ "$SKIP_BASIC" = false ]; then
test_authentication_endpoints
test_error_handling
fi
if [[ "$SKIP_CRUD" = false && -n "$AUTH_TOKEN" ]]; then
test_crud_operations # This will now set TEST_TESTIMONIAL_ID
fi
if [ "$SKIP_COMPREHENSIVE" = false ]; then
test_all_endpoints_with_all_users
fi
if [[ "$SKIP_COMPREHENSIVE" = false && -n "$AUTH_TOKEN" ]]; then
printf "\n${CYAN}=== Test Comprehensive dengan Admin Token ===${NC}\n"
test_user_management_endpoints
test_roles_and_permissions
test_mentor_endpoints
test_events_endpoints
test_testimonials_endpoints # This will now use TEST_TESTIMONIAL_ID
test_gacha_endpoints
fi
test_advanced_scenarios
TEST_END_TIME=$(date +%s)
TOTAL_DURATION=$((TEST_END_TIME - TEST_START_TIME))
TOTAL_TESTS=$((PASS_COUNT + FAIL_COUNT))
SUCCESS_RATE="0"
if [ "$TOTAL_TESTS" -gt 0 ]; then
SUCCESS_RATE=$(( (PASS_COUNT * 100) / TOTAL_TESTS ))
fi
if [ "$GENERATE_REPORT" = true ]; then
printf "\n${CYAN}=== Membuat Laporan Tes ===${NC}\n"
all_results_json=$(printf "%s," "${TEST_RESULTS[@]}")
all_results_json="[${all_results_json%,}]"
report_file="api-test-report-$(date +'%Y%m%d-%H%M%S').json"
jq -n --arg start "$(date -d @$TEST_START_TIME +'%Y-%m-%d %H:%M:%S')" \
--arg end "$(date -d @$TEST_END_TIME +'%Y-%m-%d %H:%M:%S')" \
--arg dur "$TOTAL_DURATION" \
--arg url "$BASE_URL" \
--arg total "$TOTAL_TESTS" \
--arg pass "$PASS_COUNT" \
--arg fail "$FAIL_COUNT" \
--arg rate "${SUCCESS_RATE}%" \
--argjson results "$all_results_json" \
'{
TestRun: {StartTime: $start, EndTime: $end, DurationSec: $dur, BaseUrl: $url},
Summary: {TotalTests: $total, PassedTests: $pass, FailedTests: $fail, SuccessRate: $rate},
Results: $results
}' > "$report_file"
printf "${BLUE}Laporan tes detail disimpan di: %s${NC}\n" "$report_file"
fi
printf "\n${CYAN}=== Ringkasan Test Suite ===${NC}\n"
printf "Total Durasi: %s detik\n" "$TOTAL_DURATION"
printf "Total Tes : %s\n" "$TOTAL_TESTS"
printf "${GREEN}Lolos : %s${NC}\n" "$PASS_COUNT"
printf "${RED}Gagal : %s${NC}\n" "$FAIL_COUNT"
printf "Tingkat Sukses: %s%%\n" "$SUCCESS_RATE"
if [ "$FAIL_COUNT" -gt 0 ]; then
printf "\n${RED}Tes yang Gagal:${NC}\n"
for summary in "${FAILED_TESTS_SUMMARY[@]}"; do
printf " %s\n" "$summary"
done
fi
if [ "$FAIL_COUNT" -eq 0 ]; then
printf "\n${GREEN}Test suite selesai dengan sukses.${NC}\n"
exit 0
else
printf "\n${RED}Test suite selesai dengan beberapa kegagalan.${NC}\n"
exit 1
fi
+23 -10
View File
@@ -1,16 +1,29 @@
[package] [package]
name = "tests" name = "tests"
version = "0.1.0" version = "0.1.0"
edition = "2024" edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies] [dependencies]
imphnen-iam = { version = "0.1.0", path = "../imphnen-iam" } tokio.workspace = true
imphnen-libs = { version = "0.1.0", path = "../imphnen-libs" } imphnen-iam.workspace = true
imphnen-utils = { version = "0.1.0", path = "../imphnen-utils" } imphnen-dimentorin.workspace = true
imphnen-entities = { version = "0.1.0", path = "../imphnen-entities" } imphnen-entities.workspace = true
axum.workspace = true imphnen-libs.workspace = true
axum-test.workspace = true imphnen-utils.workspace = true
chrono.workspace = true http-body-util.workspace = true
surrealdb.workspace = true hyper.workspace = true
tokio.workspace = true hyper-util.workspace = true
serde_json.workspace = true serde_json.workspace = true
dotenvy.workspace = true
tower.workspace = true
tracing.workspace = true
surrealdb.workspace = true
serde.workspace = true
strum.workspace = true
uuid.workspace = true
rand.workspace = true
async-trait.workspace = true
chrono.workspace = true
axum.workspace = true
@@ -0,0 +1,758 @@
use axum::{
body::Body,
http::{Method, Request, StatusCode},
response::Response,
routing::post,
Router,
};
use http_body_util::BodyExt; // for `buffer` method
use imphnen_dimentorin::{
mentors_controller,
mentors_dto::{MentorUserRegisterRequestDto, MentorRegisterResponseDto},
};
use imphnen_entities::{AppState, SurrealMemClient, SurrealWsClient};
use imphnen_iam::{RolesRepository, UsersRepository};
use imphnen_iam::{RolesEnum, UsersSchema, RolesSchema};
use imphnen_utils::{hash_password, make_thing, get_iso_date};
use surrealdb::{Uuid, sql::Thing};
use imphnen_libs::{ResourceEnum, surrealdb_init_ws, surrealdb_init_mem, Env};
use dotenvy::dotenv;
use tower::ServiceExt; // Added ServiceExt for .oneshot()
use crate::mock_test::setup_all_test_environment; // Import the new setup function
// Helper function to create a test AppState
async fn setup_app_state() -> AppState {
dotenv().ok();
// Use the surrealdb initialization functions from imphnen_libs
let surrealdb_ws = surrealdb_init_ws().await.expect("Failed to initialize SurrealDB WS client");
let surrealdb_mem = surrealdb_init_mem().await.expect("Failed to initialize SurrealDB MEM client");
let app_state = AppState {
surrealdb_ws,
surrealdb_mem,
};
// Manually seed roles since seed_roles is not directly callable as a repository method
let db = &app_state.surrealdb_ws;
let roles_to_seed = vec![
(RolesEnum::Admin.to_string(), Vec::new()),
(RolesEnum::User.to_string(), Vec::new()),
(RolesEnum::Staff.to_string(), Vec::new()),
(RolesEnum::Mentor.to_string(), Vec::new()),
];
for (name, permissions) in roles_to_seed {
// Check if role already exists to avoid errors on rerun
let existing_role: Option<RolesSchema> = db.query(format!("SELECT * FROM ONLY role WHERE name = '{}'", name)).await.unwrap().take(0).unwrap_or(None);
if existing_role.is_none() {
let role_id = Uuid::new_v4().to_string();
let role = RolesSchema {
id: make_thing(&ResourceEnum::Roles.to_string(), &role_id),
name: name.clone(),
is_deleted: false,
permissions,
created_at: Some(get_iso_date()),
updated_at: Some(get_iso_date()),
};
db.create::<RolesSchema>((ResourceEnum::Roles.to_string().as_str(), role_id)).content(role).await.unwrap(); // Corrected syntax
}
}
app_state
}
// Helper function to create a test application (router)
fn app(app_state: AppState) -> Router {
Router::new()
.route("/v1/mentors/register", post(mentors_controller::post_register_mentor))
.with_state(app_state)
}
#[tokio::test]
async fn test_register_new_user_as_mentor_success() {
let app_state = setup_all_test_environment().await; // Use the new setup function
let app = app(app_state.clone());
let test_email = "newmentor@example.com";
let test_password = "Password123!";
let test_fullname = "New Mentor User";
let test_phone = "1234567890";
// Clean up before test
let user_repo = UsersRepository::new(&app_state);
let _ = user_repo.query_delete_user(test_email.to_string()).await;
let dto = MentorUserRegisterRequestDto {
email: test_email.to_string(),
password: test_password.to_string(),
fullname: test_fullname.to_string(),
phone_number: test_phone.to_string(),
identity_and_verification: imphnen_dimentorin::mentors_dto::IdentityAndVerification {
legal_name: "Legal Name".to_string(),
gender: Some("Laki-laki".to_string()),
domicile: Some("Jakarta Selatan".to_string()),
identity_document_url: "http://example.com/id.pdf".to_string(),
phone_for_verification: "0987654321".to_string(),
},
professional_profile: imphnen_dimentorin::mentors_dto::ProfessionalProfile {
bio: "Experienced professional seeking to mentor others in software development.".to_string(),
last_education: Some("S1".to_string()),
linkedin_url: Some("http://linkedin.com/in/mentor".to_string()),
github_url: None,
cv_url: None,
portfolio_url: Some("http://example.com/portfolio".to_string()),
industries: vec!["Technology".to_string()],
expertise: vec!["Rust".to_string(), "Debugging".to_string()],
languages: vec!["English".to_string()],
current_company: "Acme Corp".to_string(),
current_role: "Senior Engineer".to_string(),
years_of_experience: 5,
},
mentoring_logistics: imphnen_dimentorin::mentors_dto::MentoringLogistics {
topics_of_interest: vec!["Career Development".to_string()],
preferred_mentee_level: vec!["Beginner".to_string()],
preferred_mentoring_formats: vec!["Online".to_string()],
availability_commitment: "5 hours/week".to_string(),
mentoring_rate_amount: 100,
},
};
let response = app
.oneshot(
Request::builder()
.method(Method::POST)
.uri("/v1/mentors/register")
.header("Content-Type", "application/json")
.body(Body::from(serde_json::to_string(&dto).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response.into_body().collect().await.unwrap().to_bytes();
let mentor_register_response: MentorRegisterResponseDto = serde_json::from_slice(&body).unwrap();
assert!(!mentor_register_response.id.is_empty());
assert!(!mentor_register_response.user_id.is_empty());
assert_eq!(mentor_register_response.email, Some(test_email.to_string()));
assert_eq!(mentor_register_response.status, "pending".to_string()); // New mentors should be pending verification
// Verify user created and has mentor role, and is inactive
let user = user_repo.query_user_by_email(test_email.to_string()).await.unwrap();
assert_eq!(user.email, test_email);
assert_eq!(user.is_active, false); // Should be inactive awaiting OTP
let role_repo = RolesRepository::new(&app_state); // Use RolesRepository to get role
let mentor_role = role_repo.query_role_by_name(RolesEnum::Mentor.to_string()).await.unwrap();
assert_eq!(user.role.to_raw(), mentor_role.id.to_raw()); // Compare raw IDs
// Clean up after test
let _ = user_repo.query_delete_user(test_email.to_string()).await;
}
#[tokio::test]
async fn test_register_existing_user_as_mentor_success() {
let app_state = setup_all_test_environment().await; // Use the new setup function
let app = app(app_state.clone());
let test_email = "existinguser_mentor@example.com";
let test_password = "Password123!";
let test_fullname = "Existing User Becoming Mentor";
let test_phone = "1122334455";
// Clean up and create an existing user
let user_repo = UsersRepository::new(&app_state);
let _ = user_repo.query_delete_user(test_email.to_string()).await;
// Manually create a user with 'User' role first
let role_repo = RolesRepository::new(&app_state); // Use RolesRepository to get role
let user_role_id_item = role_repo.query_role_by_name(RolesEnum::User.to_string()).await.unwrap(); // Get RolesDetailItemDto
let user_role_id_thing = make_thing(&ResourceEnum::Roles.to_string(), &user_role_id_item.id); // Convert to Thing
let hashed_password = hash_password(test_password).unwrap();
user_repo.query_create_user(imphnen_iam::UsersSchema {
id: make_thing(&ResourceEnum::Users.to_string(), &Uuid::new_v4().to_string()),
email: test_email.to_string(),
password: hashed_password,
fullname: "Original User Name".to_string(),
phone_number: "0000000000".to_string(),
is_active: true, // Initially active
role: user_role_id_thing,
created_at: get_iso_date(),
updated_at: get_iso_date(),
..Default::default()
}).await.unwrap();
let dto = MentorUserRegisterRequestDto {
email: test_email.to_string(),
password: test_password.to_string(), // Keep password same for simplicity in test
fullname: test_fullname.to_string(),
phone_number: test_phone.to_string(),
identity_and_verification: imphnen_dimentorin::mentors_dto::IdentityAndVerification {
legal_name: "Legal Name Updated".to_string(),
gender: Some("Perempuan".to_string()),
domicile: Some("Surabaya".to_string()),
identity_document_url: "http://example.com/id_updated.pdf".to_string(),
phone_for_verification: "0987654322".to_string(),
},
professional_profile: imphnen_dimentorin::mentors_dto::ProfessionalProfile {
bio: "Existing user now a mentor.".to_string(),
last_education: Some("S2".to_string()),
linkedin_url: Some("http://linkedin.com/in/mentor_existing".to_string()),
github_url: None,
cv_url: None,
portfolio_url: Some("http://example.com/portfolio_existing".to_string()),
industries: vec!["Education".to_string()],
expertise: vec!["Marketing".to_string()],
languages: vec!["Indonesian".to_string()],
current_company: "New Company".to_string(),
current_role: "Manager".to_string(),
years_of_experience: 10,
},
mentoring_logistics: imphnen_dimentorin::mentors_dto::MentoringLogistics {
topics_of_interest: vec!["Business Strategy".to_string()],
preferred_mentee_level: vec!["Experienced".to_string()],
preferred_mentoring_formats: vec!["Offline".to_string()],
availability_commitment: "10 hours/month".to_string(),
mentoring_rate_amount: 200,
},
};
let response = app
.oneshot(
Request::builder()
.method(Method::POST)
.uri("/v1/mentors/register")
.header("Content-Type", "application/json")
.body(Body::from(serde_json::to_string(&dto).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response.into_body().collect().await.unwrap().to_bytes();
let mentor_register_response: MentorRegisterResponseDto = serde_json::from_slice(&body).unwrap();
assert!(!mentor_register_response.id.is_empty());
assert!(!mentor_register_response.user_id.is_empty());
assert_eq!(mentor_register_response.email, Some(test_email.to_string()));
assert_eq!(mentor_register_response.status, "pending".to_string()); // Should be pending verification
// Verify user updated and has mentor role, and is inactive
let user = user_repo.query_user_by_email(test_email.to_string()).await.unwrap();
assert_eq!(user.email, test_email);
assert_eq!(user.fullname, test_fullname);
assert_eq!(user.phone_number, test_phone);
assert_eq!(user.is_active, false); // Should be inactive awaiting OTP
let role_repo = RolesRepository::new(&app_state); // Use RolesRepository to get role
let mentor_role = role_repo.query_role_by_name(RolesEnum::Mentor.to_string()).await.unwrap();
assert_eq!(user.role.to_raw(), mentor_role.id.to_raw()); // Compare raw IDs
// Clean up after test
let _ = user_repo.query_delete_user(test_email.to_string()).await;
}
#[tokio::test]
async fn test_register_mentor_already_has_profile() {
let app_state = setup_all_test_environment().await; // Use the new setup function
let app = app(app_state.clone());
let test_email = "existing_mentor_profile@example.com";
let test_password = "Password123!";
let test_fullname = "Existing Mentor Profile";
let test_phone = "5551234567";
// Clean up and create a user that is already a mentor
let user_repo = UsersRepository::new(&app_state);
let mentor_repo = imphnen_dimentorin::mentors_repository::MentorsRepository::new(&app_state);
let _ = user_repo.query_delete_user(test_email.to_string()).await;
// Manually create a user with 'Mentor' role
let role_repo = RolesRepository::new(&app_state); // Use RolesRepository to get role
let mentor_role_thing = role_repo.query_role_by_name(RolesEnum::Mentor.to_string()).await.unwrap().id;
let hashed_password = imphnen_utils::hash_password(test_password).unwrap();
let user_id = imphnen_utils::make_thing(&ResourceEnum::Users.to_string(), &Uuid::new_v4().to_string());
user_repo.query_create_user(imphnen_iam::UsersSchema {
id: user_id.clone(),
email: test_email.to_string(),
password: hashed_password,
fullname: test_fullname.to_string(),
phone_number: test_phone.to_string(),
is_active: true,
role: mentor_role_thing.clone(),
created_at: imphnen_utils::get_iso_date(),
updated_at: imphnen_utils::get_iso_date(),
// Removed mentor_user_id
}).await.unwrap();
// Manually create a mentor profile for this user
let existing_mentor_profile_id = imphnen_utils::make_thing(&ResourceEnum::Mentors.to_string(), &Uuid::new_v4().to_string());
mentor_repo.query_create_mentor(imphnen_dimentorin::mentors_schema::MentorSchema {
id: existing_mentor_profile_id.clone(),
user_id: Some(user_id.clone()),
email: Some(test_email.to_string()),
legal_name: "Existing Mentor Legal Name".to_string(),
gender: Some("Laki-laki".to_string()),
domicile: Some("Bandung".to_string()),
identity_document_url: "http://example.com/existing_id.pdf".to_string(),
phone_for_verification: "1234567890".to_string(),
bio: "Already an existing mentor in the system.".to_string(),
last_education: Some("S3".to_string()),
linkedin_url: None,
github_url: None,
cv_url: None,
portfolio_url: Some("http://example.com/existing_portfolio".to_string()),
industries: vec!["Finance".to_string()],
expertise: vec!["Investments".to_string()],
languages: vec!["English".to_string()],
current_company: "Finance Corp".to_string(),
current_role: "Analyst".to_string(),
years_of_experience: 7,
topics_of_interest: vec!["Stocks".to_string()],
preferred_mentee_level: vec!["All".to_string()],
preferred_mentoring_formats: vec!["Any".to_string()],
availability_commitment: "Flexible".to_string(),
mentoring_rate: imphnen_dimentorin::mentors_dto::MentoringRate::default(),
status: "Approved".to_string(),
is_deleted: false,
created_at: imphnen_utils::get_iso_date(),
updated_at: imphnen_utils::get_iso_date(),
}).await.unwrap();
// Update user to link mentor profile
let user_after_mentor_creation_dto = user_repo.query_user_by_email(test_email.to_string()).await.unwrap();
let mut user_after_mentor_creation_schema = UsersSchema::from(user_after_mentor_creation_dto);
user_after_mentor_creation_schema = user_after_mentor_creation_schema.update_mentor_id(Some(existing_mentor_profile_id.clone().to_raw()));
user_repo.query_update_user(user_after_mentor_creation_schema).await.unwrap();
let dto = MentorUserRegisterRequestDto {
email: test_email.to_string(),
password: test_password.to_string(),
fullname: test_fullname.to_string(),
phone_number: test_phone.to_string(),
identity_and_verification: imphnen_dimentorin::mentors_dto::IdentityAndVerification {
legal_name: "Legal Name".to_string(),
gender: Some("Perempuan".to_string()),
domicile: Some("Yogyakarta".to_string()),
identity_document_url: "http://example.com/id.pdf".to_string(),
phone_for_verification: "0987654321".to_string(),
},
professional_profile: imphnen_dimentorin::mentors_dto::ProfessionalProfile {
bio: "Experienced professional seeking to mentor others in software development.".to_string(),
last_education: Some("SMA".to_string()),
linkedin_url: Some("http://linkedin.com/in/mentor".to_string()),
github_url: None,
cv_url: None,
portfolio_url: Some("http://example.com/portfolio_new".to_string()),
industries: vec!["Technology".to_string()],
expertise: vec!["Rust".to_string(), "Debugging".to_string()],
languages: vec!["English".to_string()],
current_company: "Acme Corp".to_string(),
current_role: "Senior Engineer".to_string(),
years_of_experience: 5,
},
mentoring_logistics: imphnen_dimentorin::mentors_dto::MentoringLogistics {
topics_of_interest: vec!["Career Development".to_string()],
preferred_mentee_level: vec!["Beginner".to_string()],
preferred_mentoring_formats: vec!["Online".to_string()],
availability_commitment: "5 hours/week".to_string(),
mentoring_rate_amount: 100,
},
};
let response = app
.oneshot(
Request::builder()
.method(Method::POST)
.uri("/v1/mentors/register")
.header("Content-Type", "application/json")
.body(Body::from(serde_json::to_string(&dto).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::CONFLICT); // Expecting conflict
// Clean up after test
let _ = user_repo.query_delete_user(test_email.to_string()).await;
let _ = mentor_repo.query_delete_mentor(&existing_mentor_profile_id.to_raw()).await;
}
#[tokio::test]
async fn test_register_mentor_invalid_email_format() {
let app_state = setup_all_test_environment().await;
let app = app(app_state.clone());
let test_email = "invalid-email"; // Invalid email format
let test_password = "Password123!";
let test_fullname = "Test User";
let test_phone = "1234567890";
let dto = MentorUserRegisterRequestDto {
email: test_email.to_string(),
password: test_password.to_string(),
fullname: test_fullname.to_string(),
phone_number: test_phone.to_string(),
identity_and_verification: imphnen_dimentorin::mentors_dto::IdentityAndVerification {
legal_name: "Legal Name".to_string(),
gender: Some("Laki-laki".to_string()),
domicile: Some("Jakarta Selatan".to_string()),
identity_document_url: "http://example.com/id.pdf".to_string(),
phone_for_verification: "0987654321".to_string(),
},
professional_profile: imphnen_dimentorin::mentors_dto::ProfessionalProfile {
bio: "Experienced professional seeking to mentor others in software development.".to_string(),
last_education: Some("S1".to_string()),
linkedin_url: Some("http://linkedin.com/in/mentor".to_string()),
github_url: None,
cv_url: None,
portfolio_url: Some("http://example.com/portfolio".to_string()),
industries: vec!["Technology".to_string()],
expertise: vec!["Rust".to_string(), "Debugging".to_string()],
languages: vec!["English".to_string()],
current_company: "Acme Corp".to_string(),
current_role: "Senior Engineer".to_string(),
years_of_experience: 5,
},
mentoring_logistics: imphnen_dimentorin::mentors_dto::MentoringLogistics {
topics_of_interest: vec!["Career Development".to_string()],
preferred_mentee_level: vec!["Beginner".to_string()],
preferred_mentoring_formats: vec!["Online".to_string()],
availability_commitment: "5 hours/week".to_string(),
mentoring_rate_amount: 100,
},
};
let response = app
.oneshot(
Request::builder()
.method(Method::POST)
.uri("/v1/mentors/register")
.header("Content-Type", "application/json")
.body(Body::from(serde_json::to_string(&dto).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let body = response.into_body().collect().await.unwrap().to_bytes();
let error_response: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(error_response["message"].as_str().unwrap().contains("email"));
}
#[tokio::test]
async fn test_register_mentor_weak_password() {
let app_state = setup_all_test_environment().await;
let app = app(app_state.clone());
let test_email = "weakpass@example.com";
let test_password = "weak"; // Weak password
let test_fullname = "Test User";
let test_phone = "1234567890";
let dto = MentorUserRegisterRequestDto {
email: test_email.to_string(),
password: test_password.to_string(),
fullname: test_fullname.to_string(),
phone_number: test_phone.to_string(),
identity_and_verification: imphnen_dimentorin::mentors_dto::IdentityAndVerification {
legal_name: "Legal Name".to_string(),
gender: Some("Laki-laki".to_string()),
domicile: Some("Jakarta Selatan".to_string()),
identity_document_url: "http://example.com/id.pdf".to_string(),
phone_for_verification: "0987654321".to_string(),
},
professional_profile: imphnen_dimentorin::mentors_dto::ProfessionalProfile {
bio: "Experienced professional seeking to mentor others in software development.".to_string(),
last_education: Some("S1".to_string()),
linkedin_url: Some("http://linkedin.com/in/mentor".to_string()),
github_url: None,
cv_url: None,
portfolio_url: Some("http://example.com/portfolio".to_string()),
industries: vec!["Technology".to_string()],
expertise: vec!["Rust".to_string(), "Debugging".to_string()],
languages: vec!["English".to_string()],
current_company: "Acme Corp".to_string(),
current_role: "Senior Engineer".to_string(),
years_of_experience: 5,
},
mentoring_logistics: imphnen_dimentorin::mentors_dto::MentoringLogistics {
topics_of_interest: vec!["Career Development".to_string()],
preferred_mentee_level: vec!["Beginner".to_string()],
preferred_mentoring_formats: vec!["Online".to_string()],
availability_commitment: "5 hours/week".to_string(),
mentoring_rate_amount: 100,
},
};
let response = app
.oneshot(
Request::builder()
.method(Method::POST)
.uri("/v1/mentors/register")
.header("Content-Type", "application/json")
.body(Body::from(serde_json::to_string(&dto).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let body = response.into_body().collect().await.unwrap().to_bytes();
let error_response: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(error_response["message"].as_str().unwrap().contains("password"));
}
#[tokio::test]
async fn test_register_mentor_missing_fullname() {
let app_state = setup_all_test_environment().await;
let app = app(app_state.clone());
let test_email = "missingfullname@example.com";
let test_password = "Password123!";
let test_phone = "1234567890";
let dto = MentorUserRegisterRequestDto {
email: test_email.to_string(),
password: test_password.to_string(),
fullname: "".to_string(), // Missing fullname
phone_number: test_phone.to_string(),
identity_and_verification: imphnen_dimentorin::mentors_dto::IdentityAndVerification {
legal_name: "Legal Name".to_string(),
gender: Some("Laki-laki".to_string()),
domicile: Some("Jakarta Selatan".to_string()),
identity_document_url: "http://example.com/id.pdf".to_string(),
phone_for_verification: "0987654321".to_string(),
},
professional_profile: imphnen_dimentorin::mentors_dto::ProfessionalProfile {
bio: "Experienced professional seeking to mentor others in software development.".to_string(),
last_education: Some("S1".to_string()),
linkedin_url: Some("http://linkedin.com/in/mentor".to_string()),
github_url: None,
cv_url: None,
portfolio_url: Some("http://example.com/portfolio".to_string()),
industries: vec!["Technology".to_string()],
expertise: vec!["Rust".to_string(), "Debugging".to_string()],
languages: vec!["English".to_string()],
current_company: "Acme Corp".to_string(),
current_role: "Senior Engineer".to_string(),
years_of_experience: 5,
},
mentoring_logistics: imphnen_dimentorin::mentors_dto::MentoringLogistics {
topics_of_interest: vec!["Career Development".to_string()],
preferred_mentee_level: vec!["Beginner".to_string()],
preferred_mentoring_formats: vec!["Online".to_string()],
availability_commitment: "5 hours/week".to_string(),
mentoring_rate_amount: 100,
},
};
let response = app
.oneshot(
Request::builder()
.method(Method::POST)
.uri("/v1/mentors/register")
.header("Content-Type", "application/json")
.body(Body::from(serde_json::to_string(&dto).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let body = response.into_body().collect().await.unwrap().to_bytes();
let error_response: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(error_response["message"].as_str().unwrap().contains("fullname"));
}
#[tokio::test]
async fn test_register_mentor_missing_phone_number() {
let app_state = setup_all_test_environment().await;
let app = app(app_state.clone());
let test_email = "missingphone@example.com";
let test_password = "Password123!";
let test_fullname = "Test User";
let dto = MentorUserRegisterRequestDto {
email: test_email.to_string(),
password: test_password.to_string(),
fullname: test_fullname.to_string(),
phone_number: "".to_string(), // Missing phone number
identity_and_verification: imphnen_dimentorin::mentors_dto::IdentityAndVerification {
legal_name: "Legal Name".to_string(),
gender: Some("Laki-laki".to_string()),
domicile: Some("Jakarta Selatan".to_string()),
identity_document_url: "http://example.com/id.pdf".to_string(),
phone_for_verification: "0987654321".to_string(),
},
professional_profile: imphnen_dimentorin::mentors_dto::ProfessionalProfile {
bio: "Experienced professional seeking to mentor others in software development.".to_string(),
last_education: Some("S1".to_string()),
linkedin_url: Some("http://linkedin.com/in/mentor".to_string()),
github_url: None,
cv_url: None,
portfolio_url: Some("http://example.com/portfolio".to_string()),
industries: vec!["Technology".to_string()],
expertise: vec!["Rust".to_string(), "Debugging".to_string()],
languages: vec!["English".to_string()],
current_company: "Acme Corp".to_string(),
current_role: "Senior Engineer".to_string(),
years_of_experience: 5,
},
mentoring_logistics: imphnen_dimentorin::mentors_dto::MentoringLogistics {
topics_of_interest: vec!["Career Development".to_string()],
preferred_mentee_level: vec!["Beginner".to_string()],
preferred_mentoring_formats: vec!["Online".to_string()],
availability_commitment: "5 hours/week".to_string(),
mentoring_rate_amount: 100,
},
};
let response = app
.oneshot(
Request::builder()
.method(Method::POST)
.uri("/v1/mentors/register")
.header("Content-Type", "application/json")
.body(Body::from(serde_json::to_string(&dto).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let body = response.into_body().collect().await.unwrap().to_bytes();
let error_response: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(error_response["message"].as_str().unwrap().contains("phone_number"));
}
#[tokio::test]
async fn test_register_mentor_missing_identity_document_url() {
let app_state = setup_all_test_environment().await;
let app = app(app_state.clone());
let test_email = "missingdocurl@example.com";
let test_password = "Password123!";
let test_fullname = "Test User";
let test_phone = "1234567890";
let dto = MentorUserRegisterRequestDto {
email: test_email.to_string(),
password: test_password.to_string(),
fullname: test_fullname.to_string(),
phone_number: test_phone.to_string(),
identity_and_verification: imphnen_dimentorin::mentors_dto::IdentityAndVerification {
legal_name: "Legal Name".to_string(),
gender: Some("Laki-laki".to_string()),
domicile: Some("Jakarta Selatan".to_string()),
identity_document_url: "".to_string(), // Missing identity document url
phone_for_verification: "0987654321".to_string(),
},
professional_profile: imphnen_dimentorin::mentors_dto::ProfessionalProfile {
bio: "Experienced professional seeking to mentor others in software development.".to_string(),
last_education: Some("S1".to_string()),
linkedin_url: Some("http://linkedin.com/in/mentor".to_string()),
github_url: None,
cv_url: None,
portfolio_url: Some("http://example.com/portfolio".to_string()),
industries: vec!["Technology".to_string()],
expertise: vec!["Rust".to_string(), "Debugging".to_string()],
languages: vec!["English".to_string()],
current_company: "Acme Corp".to_string(),
current_role: "Senior Engineer".to_string(),
years_of_experience: 5,
},
mentoring_logistics: imphnen_dimentorin::mentors_dto::MentoringLogistics {
topics_of_interest: vec!["Career Development".to_string()],
preferred_mentee_level: vec!["Beginner".to_string()],
preferred_mentoring_formats: vec!["Online".to_string()],
availability_commitment: "5 hours/week".to_string(),
mentoring_rate_amount: 100,
},
};
let response = app
.oneshot(
Request::builder()
.method(Method::POST)
.uri("/v1/mentors/register")
.header("Content-Type", "application/json")
.body(Body::from(serde_json::to_string(&dto).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let body = response.into_body().collect().await.unwrap().to_bytes();
let error_response: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(error_response["message"].as_str().unwrap().contains("identity_document_url"));
}
#[tokio::test]
async fn test_register_mentor_invalid_phone_for_verification_format() {
let app_state = setup_all_test_environment().await;
let app = app(app_state.clone());
let test_email = "invalidphoneveri@example.com";
let test_password = "Password123!";
let test_fullname = "Test User";
let test_phone = "1234567890";
let dto = MentorUserRegisterRequestDto {
email: test_email.to_string(),
password: test_password.to_string(),
fullname: test_fullname.to_string(),
phone_number: test_phone.to_string(),
identity_and_verification: imphnen_dimentorin::mentors_dto::IdentityAndVerification {
legal_name: "Legal Name".to_string(),
gender: Some("Laki-laki".to_string()),
domicile: Some("Jakarta Selatan".to_string()),
identity_document_url: "http://example.com/id.pdf".to_string(),
phone_for_verification: "invalid".to_string(), // Invalid phone for verification
},
professional_profile: imphnen_dimentorin::mentors_dto::ProfessionalProfile {
bio: "Experienced professional seeking to mentor others in software development.".to_string(),
last_education: Some("S1".to_string()),
linkedin_url: Some("http://linkedin.com/in/mentor".to_string()),
github_url: None,
cv_url: None,
portfolio_url: Some("http://example.com/portfolio".to_string()),
industries: vec!["Technology".to_string()],
expertise: vec!["Rust".to_string(), "Debugging".to_string()],
languages: vec!["English".to_string()],
current_company: "Acme Corp".to_string(),
current_role: "Senior Engineer".to_string(),
years_of_experience: 5,
},
mentoring_logistics: imphnen_dimentorin::mentors_dto::MentoringLogistics {
topics_of_interest: vec!["Career Development".to_string()],
preferred_mentee_level: vec!["Beginner".to_string()],
preferred_mentoring_formats: vec!["Online".to_string()],
availability_commitment: "5 hours/week".to_string(),
mentoring_rate_amount: 100,
},
};
let response = app
.oneshot(
Request::builder()
.method(Method::POST)
.uri("/v1/mentors/register")
.header("Content-Type", "application/json")
.body(Body::from(serde_json::to_string(&dto).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let body = response.into_body().collect().await.unwrap().to_bytes();
let error_response: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(error_response["message"].as_str().unwrap().contains("phone_for_verification"));
}
@@ -0,0 +1,349 @@
use anyhow::Result;
use imphnen_dimentorin::v1::mentors::{MentorSchema, MentoringRate, MentorsRepository};
use surrealdb::sql::Thing;
use imphnen_iam::v1::users::UsersRepository;
use tests::{
create_test_user, get_role_id, ResourceEnum, cleanup_db,
generate_unique_email, setup_all_test_environment,
};
use surrealdb::Uuid;
/// Helper to create a full-featured MentorSchema for tests
fn create_full_mentor_schema(id: &str, user_id: &str, email: &str, legal_name: &str) -> MentorSchema {
MentorSchema {
id: Thing::from(("app_mentors", id)),
user_id: Some(Thing::from(("app_users", user_id))),
email: Some(email.to_string()),
legal_name: legal_name.to_string(),
gender: Some("Laki-laki".to_string()),
domicile: Some("Jakarta".to_string()),
identity_document_url: "https://example.com/ktp.jpg".to_string(),
phone_for_verification: "+6281234567890".to_string(),
bio: "Saya adalah mentor backend Rust dengan pengalaman 5 tahun dalam pengembangan aplikasi backend yang scalable dan performant.".to_string(),
last_education: Some("S1".to_string()),
linkedin_url: Some("https://linkedin.com/in/mentor".to_string()),
github_url: Some("http://github.com/test".to_string()),
cv_url: Some("http://example.com/cv.pdf".to_string()),
portfolio_url: Some("http://example.com/portfolio".to_string()),
industries: vec!["Software".to_string(), "Education".to_string()],
expertise: vec!["Rust".to_string(), "Microservices".to_string()],
languages: vec!["Indonesian".to_string(), "English".to_string()],
current_company: "PT Contoh".to_string(),
current_role: "Senior Backend Engineer".to_string(),
years_of_experience: 5,
topics_of_interest: vec!["Rust Programming".to_string(), "Backend Development".to_string()],
preferred_mentee_level: vec!["beginner".to_string(), "intermediate".to_string()],
preferred_mentoring_formats: vec!["Test Format".to_string()],
availability_commitment: "2 jam per minggu untuk mentoring online dan offline".to_string(),
mentoring_rate: MentoringRate {
amount: 100_000,
currency: "IDR".to_string(),
per_duration: "hour".to_string(),
},
status: "verified".to_string(),
is_deleted: false,
created_at: "2024-01-01T00:00:00Z".to_string(),
updated_at: "2024-01-01T00:00:00Z".to_string(),
..Default::default()
}
}
#[tokio::test]
async fn test_create_mentor() -> Result<()> {
cleanup_db().await; // Keep for now as setup_all_test_environment doesn't clean up
let app_state = setup_all_test_environment().await;
let repo = MentorsRepository::new(&app_state);
let id = Uuid::new_v4().to_string();
let email = generate_unique_email("test_create_mentor");
let user_repo = UsersRepository::new(&app_state);
let mut user = create_test_user(&email, "Mentor User", true, &get_role_id(&app_state).await);
user.id = Thing::from(("app_users", id.as_str()));
user.email = email.to_string();
user.mentor_id = Some(Thing::from(("app_mentors", id.as_str())));
let create_result = user_repo.query_create_user(user.clone()).await;
assert!(create_result.is_ok(), "Failed to create user: {:?}", create_result.err());
let mentor = create_full_mentor_schema(&id, &id, &email, "Mentor User");
let create_res = repo.query_create_mentor(mentor.clone()).await;
assert!(create_res.is_ok(), "Failed to create mentor: {:?}", create_res.err());
let thing_id = Thing::from((ResourceEnum::Mentors.to_string().as_str(), id.as_str()));
let mentor_fetched = repo.query_mentor_by_id(&thing_id, false).await;
assert!(mentor_fetched.is_ok(), "Failed to fetch mentor after create: {:?}", mentor_fetched.err());
let mentor_fetched = mentor_fetched.unwrap();
assert_eq!(mentor_fetched.legal_name, "Mentor User");
assert_eq!(mentor_fetched.status, "verified");
assert_eq!(mentor_fetched.user_id, Thing::from(("app_users", id.as_str())));
Ok(())
}
#[tokio::test]
async fn test_get_all_mentors() -> Result<()> {
cleanup_db().await;
let app_state = setup_all_test_environment().await;
let repo = MentorsRepository::new(&app_state);
let id = Uuid::new_v4().to_string();
let email = generate_unique_email("test_get_all_mentors");
let user_repo = UsersRepository::new(&app_state);
let mut user = create_test_user(&email, "Test Mentor Get All User", true, &get_role_id(&app_state).await);
user.id = Thing::from(("app_users", id.as_str()));
user.email = email.to_string();
user.mentor_id = Some(Thing::from(("app_mentors", id.as_str())));
let create_user_res = user_repo.query_create_user(user.clone()).await;
assert!(create_user_res.is_ok(), "Failed to create user for get_all test: {:?}", create_user_res.err());
let mentor = create_full_mentor_schema(&id, &id, &email, "Test Mentor Get All");
let create_res = repo.query_create_mentor(mentor.clone()).await;
assert!(create_res.is_ok(), "Failed to create mentor: {:?}", create_res.err());
let mut meta = imphnen_libs::MetaRequestDto::default();
meta.search = Some("Test Mentor Get All".to_string());
let mentors_res = repo.query_mentor_list(meta).await;
assert!(mentors_res.is_ok(), "Failed to get all mentors: {:?}", mentors_res.err());
let mentors = mentors_res.unwrap().data;
assert!(!mentors.is_empty(), "Mentors list should not be empty");
Ok(())
}
#[tokio::test]
async fn test_get_mentor_by_id() -> Result<()> {
cleanup_db().await;
let app_state = setup_all_test_environment().await;
let repo = MentorsRepository::new(&app_state);
let id = Uuid::new_v4().to_string();
let email = generate_unique_email("test_get_mentor_by_id");
let user_repo = UsersRepository::new(&app_state);
let mut user = create_test_user(&email, "Test Mentor Get By Id User", true, &get_role_id(&app_state).await);
user.id = Thing::from(("app_users", id.as_str()));
user.email = email.to_string();
user.mentor_id = Some(Thing::from(("app_mentors", id.as_str())));
let create_user_res = user_repo.query_create_user(user.clone()).await;
assert!(create_user_res.is_ok(), "Failed to create user for get_by_id test: {:?}", create_user_res.err());
let mentor = create_full_mentor_schema(&id, &id, &email, "Test Mentor Get By Id");
let create_res = repo.query_create_mentor(mentor.clone()).await;
assert!(create_res.is_ok(), "Failed to create mentor: {:?}", create_res.err());
let mentor_res = repo
.query_mentor_by_id(&Thing::from((ResourceEnum::Mentors.to_string().as_str(), id.as_str())), false)
.await;
assert!(mentor_res.is_ok(), "Failed to get mentor by id: {:?}", mentor_res.err());
let mentor = mentor_res.unwrap();
assert_eq!(mentor.legal_name, "Test Mentor Get By Id");
Ok(())
}
#[tokio::test]
async fn test_update_mentor() -> Result<()> {
cleanup_db().await;
let app_state = setup_all_test_environment().await;
let repo = MentorsRepository::new(&app_state);
let id = Uuid::new_v4().to_string();
let email = generate_unique_email("test_update_mentor");
let user_repo = UsersRepository::new(&app_state);
let mut user = create_test_user(&email, "Test Mentor Update User", true, &get_role_id(&app_state).await);
user.id = Thing::from(("app_users", id.as_str()));
user.email = email.to_string();
user.mentor_id = Some(Thing::from(("app_mentors", id.as_str())));
let create_user_res = user_repo.query_create_user(user.clone()).await;
assert!(create_user_res.is_ok(), "Failed to create user for update test: {:?}", create_user_res.err());
let mentor = create_full_mentor_schema(&id, &id, &email, "Test Mentor Update");
let create_res = repo.query_create_mentor(mentor.clone()).await;
assert!(create_res.is_ok(), "Failed to create mentor: {:?}", create_res.err());
let mut updated_mentor = mentor.clone();
updated_mentor.legal_name = "Test Mentor Updated".to_string();
updated_mentor.gender = Some("Perempuan".to_string());
updated_mentor.domicile = Some("Bandung".to_string());
updated_mentor.last_education = Some("S2".to_string());
updated_mentor.portfolio_url = Some("http://example.com/updated_portfolio".to_string());
updated_mentor.mentoring_rate.amount = 200_000;
let update_res = repo.query_update_mentor(updated_mentor.clone()).await;
assert!(update_res.is_ok(), "Failed to update mentor: {:?}", update_res.err());
let mentor_fetched = repo
.query_mentor_by_id(&Thing::from((ResourceEnum::Mentors.to_string().as_str(), id.as_str())), false)
.await;
assert!(mentor_fetched.is_ok(), "Failed to fetch mentor after update: {:?}", mentor_fetched.err());
let mentor_fetched = mentor_fetched.unwrap();
assert_eq!(mentor_fetched.legal_name, "Test Mentor Updated");
assert_eq!(mentor_fetched.gender, Some("Perempuan".to_string()));
assert_eq!(mentor_fetched.domicile, Some("Bandung".to_string()));
assert_eq!(mentor_fetched.last_education, Some("S2".to_string()));
assert_eq!(mentor_fetched.portfolio_url, Some("http://example.com/updated_portfolio".to_string()));
assert_eq!(mentor_fetched.mentoring_rate.amount, 200_000);
Ok(())
}
#[tokio::test]
async fn test_delete_mentor() -> Result<()> {
cleanup_db().await;
let app_state = setup_all_test_environment().await;
let repo = MentorsRepository::new(&app_state);
let id = Uuid::new_v4().to_string();
let email = generate_unique_email("test_delete_mentor");
let user_repo = UsersRepository::new(&app_state);
let mut user = create_test_user(&email, "Test Mentor Delete User", true, &get_role_id(&app_state).await);
user.id = Thing::from(("app_users", id.as_str()));
user.email = email.to_string();
user.mentor_id = Some(Thing::from(("app_mentors", id.as_str())));
let create_user_res = user_repo.query_create_user(user.clone()).await;
assert!(create_user_res.is_ok(), "Failed to create user for delete test: {:?}", create_user_res.err());
let mentor = create_full_mentor_schema(&id, &id, &email, "Test Mentor Delete");
let create_res = repo.query_create_mentor(mentor.clone()).await;
assert!(create_res.is_ok(), "Failed to create mentor: {:?}", create_res.err());
let delete_res = repo.query_delete_mentor(id.clone()).await;
assert!(delete_res.is_ok(), "Failed to delete mentor: {:?}", delete_res.err());
let thing_id = Thing::from((ResourceEnum::Mentors.to_string().as_str(), id.as_str()));
let mentor_fetched = repo.query_mentor_by_id(&thing_id, false).await;
assert!(mentor_fetched.is_err(), "Mentor should not be found after soft delete");
if let Some(err) = mentor_fetched.err() {
assert!(
err.to_string().contains("Mentor not found in database")
|| err.to_string().contains("Mentor has been deleted"),
"Expected 'Mentor not found in database' or 'Mentor has been deleted' error, got: {}",
err
);
}
let delete_again_res = repo.query_delete_mentor(id.clone()).await;
assert!(delete_again_res.is_err(), "Should not be able to soft delete an already deleted mentor");
if let Some(err) = delete_again_res.err() {
assert!(
err.to_string().contains("already soft deleted"),
"Expected 'already soft deleted' error, got: {}",
err
);
}
Ok(())
}
#[tokio::test]
async fn test_get_by_user_email() -> Result<()> {
cleanup_db().await;
let app_state = setup_all_test_environment().await;
let repo = MentorsRepository::new(&app_state);
let id = Uuid::new_v4().to_string();
let user_email = generate_unique_email("test_mentor_by_email");
let user_repo = UsersRepository::new(&app_state);
let mut user = create_test_user(&user_email, "Test User By Email", true, &get_role_id(&app_state).await);
user.id = Thing::from(("app_users", id.as_str()));
user.email = user_email.clone();
user.mentor_id = Some(Thing::from(("app_mentors", id.as_str())));
let create_user_res = user_repo.query_create_user(user.clone()).await;
assert!(create_user_res.is_ok(), "Failed to create user for get_by_user_email test: {:?}", create_user_res.err());
let mentor = create_full_mentor_schema(&id, &id, &user_email, "Test Mentor By Email");
let create_res = repo.query_create_mentor(mentor.clone()).await;
assert!(create_res.is_ok(), "Failed to create mentor: {:?}", create_res.err());
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
let mentor_detail = repo
.query_mentor_by_email(user_email.to_string(), false)
.await;
if mentor_detail.is_err() {
let mentor_by_id = repo
.query_mentor_by_id(&Thing::from(("app_mentors", id.as_str())), false)
.await;
assert!(
mentor_by_id.is_ok(),
"Failed to get mentor by user_id fallback: {:?}",
mentor_by_id.err()
);
let mentor_by_id = mentor_by_id.unwrap();
assert_eq!(mentor_by_id.legal_name, "Test Mentor By Email");
return Ok(());
}
assert!(
mentor_detail.is_ok(),
"Failed to get mentor by user email: {:?}",
mentor_detail.err()
);
let mentor_detail = mentor_detail.unwrap();
assert_eq!(mentor_detail.legal_name, "Test Mentor By Email");
Ok(())
}
#[tokio::test]
async fn test_create_mentor_with_duplicate_user_id() -> Result<()> {
cleanup_db().await;
let app_state = setup_all_test_environment().await;
let repo = MentorsRepository::new(&app_state);
let id = Uuid::new_v4().to_string();
let email = generate_unique_email("test_duplicate_user_id");
let user_repo = UsersRepository::new(&app_state);
let mut user = create_test_user(&email, "Duplicate User", true, &get_role_id(&app_state).await);
user.id = Thing::from(("app_users", id.as_str()));
user.email = email.to_string();
user.mentor_id = Some(Thing::from(("app_mentors", id.as_str())));
let _ = user_repo.query_create_user(user.clone()).await;
let mentor1 = create_full_mentor_schema(&id, &id, &email, "Mentor 1");
let create_res1 = repo.query_create_mentor(mentor1.clone()).await;
assert!(create_res1.is_ok(), "Failed to create first mentor: {:?}", create_res1.err());
// Attempt to create another mentor with the same user_id
let duplicate_mentor_id = Uuid::new_v4().to_string();
let mentor2 = create_full_mentor_schema(&duplicate_mentor_id, &id, &generate_unique_email("test_duplicate_user_id_2"), "Mentor 2");
let create_res2 = repo.query_create_mentor(mentor2.clone()).await;
assert!(create_res2.is_err(), "Should not be able to create mentor with duplicate user_id");
if let Some(err) = create_res2.err() {
assert!(err.to_string().contains("already has a mentor profile"), "Expected 'already has a mentor profile' error, got: {}", err);
}
Ok(())
}
#[tokio::test]
async fn test_update_non_existent_mentor() -> Result<()> {
cleanup_db().await;
let app_state = setup_all_test_environment().await;
let repo = MentorsRepository::new(&app_state);
let non_existent_id = Uuid::new_v4().to_string();
let non_existent_email = generate_unique_email("test_non_existent_mentor");
let non_existent_user_id = Uuid::new_v4().to_string();
let mentor_to_update = create_full_mentor_schema(&non_existent_id, &non_existent_user_id, &non_existent_email, "Non Existent Mentor");
let update_res = repo.query_update_mentor(mentor_to_update).await;
assert!(update_res.is_err(), "Should not be able to update a non-existent mentor");
if let Some(err) = update_res.err() {
assert!(err.to_string().contains("Mentor not found"), "Expected 'Mentor not found' error, got: {}", err);
}
Ok(())
}
#[tokio::test]
async fn test_delete_non_existent_mentor() -> Result<()> {
cleanup_db().await;
let app_state = setup_all_test_environment().await;
let repo = MentorsRepository::new(&app_state);
let non_existent_id = Uuid::new_v4().to_string();
let delete_res = repo.query_delete_mentor(non_existent_id.clone()).await;
assert!(delete_res.is_err(), "Should not be able to delete a non-existent mentor");
if let Some(err) = delete_res.err() {
assert!(err.to_string().contains("Mentor not found"), "Expected 'Mentor not found' error, got: {}", err);
}
Ok(())
}
+4
View File
@@ -0,0 +1,4 @@
#[cfg(test)]
pub mod mentor_repository_test;
#[cfg(test)]
pub mod mentor_registration_tests;
+1
View File
@@ -0,0 +1 @@
pub mod mentor;
+379
View File
@@ -0,0 +1,379 @@
#[cfg(test)]
mod auth_login_tests {
use crate::generate_unique_email;
use crate::hash_password;
use crate::mock_test::setup_all_test_environment;
use axum::http::StatusCode;
use imphnen_iam::{
v1::auth::{AuthLoginRequestDto, AuthService},
AppState, UsersRepository, UsersSchema,
};
use serde_json::Value; // Import the new setup function
async fn setup_test_environment() -> AppState {
setup_all_test_environment().await
}
async fn create_test_user_with_role(
state: &AppState,
email: &str,
password: &str,
role_name: &str,
is_active: bool,
) -> UsersSchema {
let role_repo = imphnen_iam::RolesRepository::new(state);
let role = match role_repo.query_role_by_name(role_name.to_string()).await {
Ok(role) => role,
Err(_) => {
let _ = role_repo
.query_create_role(imphnen_iam::RolesRequestCreateDto {
name: role_name.to_string(),
permissions: vec![],
})
.await
.unwrap();
role_repo
.query_role_by_name(role_name.to_string())
.await
.unwrap_or_else(|_| {
panic!("Failed to create {role_name} role");
})
}
};
let user = UsersSchema {
id: crate::make_thing("app_users", &uuid::Uuid::new_v4().to_string()),
email: email.to_string(),
fullname: "Test User".to_string(),
password: hash_password(password).unwrap(),
is_deleted: false,
avatar: None,
phone_number: "081234567890".to_string(),
is_active,
gender: None,
birthdate: None,
role: crate::make_thing("app_roles", &role.id),
mentor_id: None,
created_at: imphnen_utils::get_iso_date(),
updated_at: imphnen_utils::get_iso_date(),
};
let user_repo = UsersRepository::new(state);
user_repo
.query_create_user(user.clone())
.await
.expect("Failed to create test user");
user
}
#[tokio::test]
async fn test_successful_login_with_valid_credentials() {
let state = setup_test_environment().await;
let email = generate_unique_email("test_login_success");
let password = "TestPass123!";
create_test_user_with_role(&state, &email, password, "User", true).await;
let login_dto = AuthLoginRequestDto {
email: email.clone(),
password: password.to_string(),
};
let response = AuthService::mutation_login(login_dto, &state).await;
let (parts, body) = response.into_parts();
assert_eq!(parts.status, StatusCode::OK);
let body_bytes = axum::body::to_bytes(body, usize::MAX).await.unwrap();
let response_json: Value = serde_json::from_slice(&body_bytes).unwrap();
assert!(response_json.get("data").is_some());
assert!(response_json["data"].get("token").is_some());
assert!(response_json["data"]["token"].get("access_token").is_some());
assert!(response_json["data"]["token"]
.get("refresh_token")
.is_some());
assert!(response_json["data"].get("user").is_some());
assert_eq!(response_json["data"]["user"]["email"], email);
}
#[tokio::test]
async fn test_login_with_invalid_email_format() {
let state = setup_test_environment().await;
let login_dto = AuthLoginRequestDto {
email: "invalid-email".to_string(),
password: "TestPass123!".to_string(),
};
let response = AuthService::mutation_login(login_dto, &state).await;
let (parts, body) = response.into_parts();
assert_eq!(parts.status, StatusCode::BAD_REQUEST);
let body_bytes = axum::body::to_bytes(body, usize::MAX).await.unwrap();
let response_json: Value = serde_json::from_slice(&body_bytes).unwrap();
assert_eq!(response_json["message"], "Email not valid");
}
#[tokio::test]
async fn test_login_with_empty_email() {
let state = setup_test_environment().await;
let login_dto = AuthLoginRequestDto {
email: "".to_string(),
password: "TestPass123!".to_string(),
};
let response = AuthService::mutation_login(login_dto, &state).await;
let (parts, body) = response.into_parts();
assert_eq!(parts.status, StatusCode::BAD_REQUEST);
let body_bytes = axum::body::to_bytes(body, usize::MAX).await.unwrap();
let response_json: Value = serde_json::from_slice(&body_bytes).unwrap();
let message = response_json["message"].as_str().unwrap();
assert!(message.contains("Email cannot be empty"));
assert!(message.contains("Email not valid"));
}
#[tokio::test]
async fn test_login_with_empty_password() {
let state = setup_test_environment().await;
let login_dto = AuthLoginRequestDto {
email: generate_unique_email("test_empty_pass"),
password: "".to_string(),
};
let response = AuthService::mutation_login(login_dto, &state).await;
let (parts, body) = response.into_parts();
assert_eq!(parts.status, StatusCode::BAD_REQUEST);
let body_bytes = axum::body::to_bytes(body, usize::MAX).await.unwrap();
let response_json: Value = serde_json::from_slice(&body_bytes).unwrap();
assert_eq!(response_json["message"], "Password cannot be empty");
}
#[tokio::test]
async fn test_login_with_wrong_password() {
let state = setup_test_environment().await;
let email = generate_unique_email("test_wrong_pass");
let correct_password = "TestPass123!";
create_test_user_with_role(&state, &email, correct_password, "User", true).await;
let login_dto = AuthLoginRequestDto {
email: email.clone(),
password: "WrongPassword123!".to_string(),
};
let response = AuthService::mutation_login(login_dto, &state).await;
let (parts, body) = response.into_parts();
assert_eq!(parts.status, StatusCode::BAD_REQUEST);
let body_bytes = axum::body::to_bytes(body, usize::MAX).await.unwrap();
let response_json: Value = serde_json::from_slice(&body_bytes).unwrap();
assert_eq!(response_json["message"], "Email or password not correct");
}
#[tokio::test]
async fn test_login_with_nonexistent_user() {
let state = setup_test_environment().await;
let login_dto = AuthLoginRequestDto {
email: generate_unique_email("nonexistent"),
password: "TestPass123!".to_string(),
};
let response = AuthService::mutation_login(login_dto, &state).await;
let (parts, body) = response.into_parts();
assert_eq!(parts.status, StatusCode::UNAUTHORIZED);
let body_bytes = axum::body::to_bytes(body, usize::MAX).await.unwrap();
let response_json: Value = serde_json::from_slice(&body_bytes).unwrap();
assert!(response_json["message"]
.to_string()
.contains("User not found"));
}
#[tokio::test]
async fn test_login_with_inactive_user() {
let state = setup_test_environment().await;
let email = generate_unique_email("test_inactive");
let password = "TestPass123!";
create_test_user_with_role(&state, &email, password, "User", false).await;
let login_dto = AuthLoginRequestDto {
email: email.clone(),
password: password.to_string(),
};
let response = AuthService::mutation_login(login_dto, &state).await;
let (parts, body) = response.into_parts();
assert_eq!(parts.status, StatusCode::BAD_REQUEST);
let body_bytes = axum::body::to_bytes(body, usize::MAX).await.unwrap();
let response_json: Value = serde_json::from_slice(&body_bytes).unwrap();
assert_eq!(
response_json["message"],
"Account not active, please verify your email"
);
}
#[tokio::test]
async fn test_successful_mentor_login() {
let state = setup_test_environment().await;
let email = generate_unique_email("test_mentor_login");
let password = "TestPass123!";
create_test_user_with_role(&state, &email, password, "Mentor", true).await;
let login_dto = AuthLoginRequestDto {
email: email.clone(),
password: password.to_string(),
};
let response = AuthService::mutation_mentor_login(login_dto, &state).await;
let (parts, body) = response.into_parts();
assert_eq!(parts.status, StatusCode::OK);
let body_bytes = axum::body::to_bytes(body, usize::MAX).await.unwrap();
let response_json: Value = serde_json::from_slice(&body_bytes).unwrap();
assert!(response_json.get("data").is_some());
assert_eq!(response_json["data"]["user"]["role"]["name"], "Mentor");
}
#[tokio::test]
async fn test_mentor_login_with_non_mentor_user() {
let state = setup_test_environment().await;
let email = generate_unique_email("test_user_not_mentor");
let password = "TestPass123!";
create_test_user_with_role(&state, &email, password, "User", true).await;
let login_dto = AuthLoginRequestDto {
email: email.clone(),
password: password.to_string(),
};
let response = AuthService::mutation_mentor_login(login_dto, &state).await;
let (parts, body) = response.into_parts();
assert_eq!(parts.status, StatusCode::FORBIDDEN);
let body_bytes = axum::body::to_bytes(body, usize::MAX).await.unwrap();
let response_json: Value = serde_json::from_slice(&body_bytes).unwrap();
assert_eq!(
response_json["message"],
"User does not have mentor privileges"
);
}
#[tokio::test]
async fn test_mentor_login_with_inactive_mentor() {
let state = setup_test_environment().await;
let email = generate_unique_email("test_inactive_mentor");
let password = "TestPass123!";
create_test_user_with_role(&state, &email, password, "Mentor", false).await;
let login_dto = AuthLoginRequestDto {
email: email.clone(),
password: password.to_string(),
};
let response = AuthService::mutation_mentor_login(login_dto, &state).await;
let (parts, body) = response.into_parts();
assert_eq!(parts.status, StatusCode::BAD_REQUEST);
let body_bytes = axum::body::to_bytes(body, usize::MAX).await.unwrap();
let response_json: Value = serde_json::from_slice(&body_bytes).unwrap();
assert_eq!(
response_json["message"],
"Account not active, please verify your email"
);
}
#[tokio::test]
async fn test_login_creates_user_cache() {
let state = setup_test_environment().await;
let email = generate_unique_email("test_cache");
let password = "TestPass123!";
create_test_user_with_role(&state, &email, password, "User", true).await;
let login_dto = AuthLoginRequestDto {
email: email.clone(),
password: password.to_string(),
};
let response = AuthService::mutation_login(login_dto, &state).await;
let (parts, _) = response.into_parts();
assert_eq!(parts.status, StatusCode::OK);
// Verify user was cached
let auth_repo = imphnen_iam::AuthRepository::new(&state);
let cached_user = auth_repo.query_get_stored_user(email.clone()).await;
assert!(cached_user.is_ok());
assert_eq!(cached_user.unwrap().email, email);
}
#[tokio::test]
async fn test_login_with_special_characters_in_email() {
let state = setup_test_environment().await;
let email = generate_unique_email("test+special");
let password = "TestPass123!";
create_test_user_with_role(&state, &email, password, "User", true).await;
let login_dto = AuthLoginRequestDto {
email: email.clone(),
password: password.to_string(),
};
let response = AuthService::mutation_login(login_dto, &state).await;
let (parts, _) = response.into_parts();
assert_eq!(parts.status, StatusCode::OK);
}
#[tokio::test]
async fn test_login_with_case_sensitive_email() {
let state = setup_test_environment().await;
let email = generate_unique_email("test_case");
let password = "TestPass123!";
create_test_user_with_role(&state, &email, password, "User", true).await;
let login_dto = AuthLoginRequestDto {
email: email.to_uppercase(),
password: password.to_string(),
};
let response = AuthService::mutation_login(login_dto, &state).await;
let (parts, body) = response.into_parts();
// Email should be case-sensitive
assert_eq!(parts.status, StatusCode::UNAUTHORIZED);
let body_bytes = axum::body::to_bytes(body, usize::MAX).await.unwrap();
let response_json: Value = serde_json::from_slice(&body_bytes).unwrap();
assert!(response_json["message"]
.to_string()
.contains("User not found"));
}
}
+93 -53
View File
@@ -1,9 +1,16 @@
#[cfg(test)] #[cfg(test)]
mod auth_repository_test { mod auth_repository_test {
use crate::{ use crate::{
AuthOtpSchema, AuthRepository, ResourceEnum, UsersRepository, UsersSchema, generate_unique_email,
create_mock_app_state, generate_unique_email, get_iso_date, get_role_id, get_iso_date,
get_role_id,
make_thing, make_thing,
setup_all_test_environment, // Import the new setup function
AuthOtpSchema,
AuthRepository,
ResourceEnum,
UsersRepository,
UsersSchema,
}; };
use chrono::{Duration, Utc}; use chrono::{Duration, Utc};
use imphnen_iam::{AppState, RolesDetailQueryDto, UsersDetailQueryDto}; use imphnen_iam::{AppState, RolesDetailQueryDto, UsersDetailQueryDto};
@@ -22,6 +29,7 @@ mod auth_repository_test {
gender: None, gender: None,
birthdate: None, birthdate: None,
role: make_thing("app_roles", &get_role_id(state).await), role: make_thing("app_roles", &get_role_id(state).await),
mentor_id: None,
created_at: get_iso_date(), created_at: get_iso_date(),
updated_at: get_iso_date(), updated_at: get_iso_date(),
} }
@@ -29,27 +37,35 @@ mod auth_repository_test {
#[tokio::test] #[tokio::test]
async fn test_store_and_get_user() { async fn test_store_and_get_user() {
let app_state = create_mock_app_state().await; let app_state = setup_all_test_environment().await; // Use the new setup function
let repo = AuthRepository::new(&app_state); let repo = AuthRepository::new(&app_state);
let email = generate_unique_email("forgot"); let email = generate_unique_email("forgot");
let user = create_mock_user(&app_state, &email).await; let mut user = create_mock_user(&app_state, &email).await;
user.role = make_thing("app_roles", &get_role_id(&app_state).await);
let user_repo = UsersRepository::new(&app_state); let user_repo = UsersRepository::new(&app_state);
let create_user = user_repo.query_create_user(user.clone()).await; let create_user = user_repo.query_create_user(user.clone()).await;
assert!(create_user.is_ok()); assert!(create_user.is_ok());
let user_data = user_repo let user_data = user_repo.query_user_by_email(email).await;
.query_user_by_email(email.to_string()) assert!(
.await user_data.is_ok(),
.unwrap(); "Failed to get user by email: {:?}",
user_data.err()
);
let user_data = user_data.unwrap();
let store = repo.query_store_user(user_data.clone()).await; let store = repo.query_store_user(user_data.clone()).await;
assert!(store.is_ok()); assert!(store.is_ok(), "Failed to store user: {:?}", store.err());
let fetched = repo.query_get_stored_user(user.email.clone()).await; let fetched = repo.query_get_stored_user(user.email.clone()).await;
assert!(fetched.is_ok()); assert!(
fetched.is_ok(),
"Failed to fetch stored user: {:?}",
fetched.err()
);
assert_eq!(fetched.unwrap().email, user.email); assert_eq!(fetched.unwrap().email, user.email);
} }
#[tokio::test] #[tokio::test]
async fn test_delete_stored_user() { async fn test_delete_stored_user() {
let state = create_mock_app_state().await; let state = setup_all_test_environment().await; // Use the new setup function
let auth_repo = AuthRepository::new(&state); let auth_repo = AuthRepository::new(&state);
let email = "delete_me@example.com".to_string(); let email = "delete_me@example.com".to_string();
let mock_user = UsersDetailQueryDto { let mock_user = UsersDetailQueryDto {
@@ -71,15 +87,20 @@ mod auth_repository_test {
}, },
is_deleted: false, is_deleted: false,
password: "".into(), password: "".into(),
mentor_id: None,
created_at: get_iso_date(), created_at: get_iso_date(),
updated_at: get_iso_date(), updated_at: get_iso_date(),
}; };
let _: Option<UsersDetailQueryDto> = state let created: Result<Option<UsersDetailQueryDto>, surrealdb::Error> = state
.surrealdb_mem .surrealdb_mem
.create((ResourceEnum::UsersCache.to_string(), email.clone())) .create((ResourceEnum::UsersCache.to_string(), email.clone()))
.content(mock_user) .content(mock_user)
.await .await;
.unwrap(); assert!(
created.is_ok(),
"Failed to create mock user: {:?}",
created.err()
);
let result = auth_repo.query_delete_stored_user(email.clone()).await; let result = auth_repo.query_delete_stored_user(email.clone()).await;
assert!( assert!(
result.is_ok(), result.is_ok(),
@@ -91,101 +112,120 @@ mod auth_repository_test {
#[tokio::test] #[tokio::test]
async fn test_store_and_get_otp() { async fn test_store_and_get_otp() {
let app_state = create_mock_app_state().await; let app_state = setup_all_test_environment().await; // Use the new setup function
let repo = AuthRepository::new(&app_state); let repo = AuthRepository::new(&app_state);
let email = "otp_user@example.com".to_string(); let email = "otp_user@example.com".to_string();
let otp = 123456; let otp = 123456;
let stored = repo.query_store_otp(email.clone(), otp).await; let stored = repo.query_store_otp(email.clone(), otp).await;
assert!(stored.is_ok()); assert!(stored.is_ok(), "Failed to store OTP: {:?}", stored.err());
let fetched = repo.query_get_stored_otp(email.clone()).await; let fetched = repo.query_get_stored_otp(email.clone()).await;
assert!(fetched.is_ok()); assert!(fetched.is_ok(), "Failed to fetch OTP: {:?}", fetched.err());
assert_eq!(fetched.unwrap(), otp); assert_eq!(fetched.unwrap(), otp);
} }
#[tokio::test] #[tokio::test]
async fn test_delete_stored_otp() { async fn test_delete_stored_otp() {
let app_state = create_mock_app_state().await; let app_state = setup_all_test_environment().await; // Use the new setup function
let repo = AuthRepository::new(&app_state); let repo = AuthRepository::new(&app_state);
let email = "otp_del@example.com".to_string(); let email = "otp_del@example.com".to_string();
let otp = 654321; let otp = 654321;
repo.query_store_otp(email.clone(), otp).await.unwrap(); let store_res = repo.query_store_otp(email.clone(), otp).await;
assert!(
store_res.is_ok(),
"Failed to store OTP: {:?}",
store_res.err()
);
let deleted = repo.query_delete_stored_otp(email.clone()).await; let deleted = repo.query_delete_stored_otp(email.clone()).await;
assert!(deleted.is_ok()); assert!(deleted.is_ok(), "Failed to delete OTP: {:?}", deleted.err());
let fetched = repo.query_get_stored_otp(email.clone()).await; let fetched = repo.query_get_stored_otp(email.clone()).await;
assert!(fetched.is_err()); assert!(
fetched.is_err(),
"OTP should be deleted, but got: {fetched:?}"
);
} }
#[tokio::test] #[tokio::test]
async fn test_expired_otp() { async fn test_expired_otp() {
let app_state = create_mock_app_state().await; let app_state = setup_all_test_environment().await; // Use the new setup function
let repo = AuthRepository::new(&app_state); let repo = AuthRepository::new(&app_state);
let email = "expired_otp@example.com".to_string(); let email = "expired_otp@example.com".to_string();
let otp = 789012; let otp = 789012;
let table = ResourceEnum::OtpCache.to_string(); let table = ResourceEnum::OtpCache.to_string();
let expires_at = Utc::now() - Duration::seconds(1); let expires_at = Utc::now() - Duration::seconds(1);
let _: Option<AuthOtpSchema> = repo let created: Result<Option<AuthOtpSchema>, surrealdb::Error> = repo
.state .state
.surrealdb_mem .surrealdb_mem
.create((table.clone(), email.as_str())) .create((table.clone(), email.as_str()))
.content(AuthOtpSchema { otp, expires_at }) .content(AuthOtpSchema { otp, expires_at })
.await .await;
.unwrap(); assert!(
created.is_ok(),
"Failed to create expired OTP: {:?}",
created.err()
);
let result = repo.query_get_stored_otp(email.clone()).await; let result = repo.query_get_stored_otp(email.clone()).await;
assert!(result.is_err()); assert!(
result.is_err(),
"Expired OTP should not be retrievable, got: {result:?}"
);
if let Some(err) = result.err() {
assert!(
err.to_string().contains("OTP expired"),
"Expected 'OTP expired' error, got: {err}"
);
}
} }
#[tokio::test] #[tokio::test]
async fn test_get_non_existent_stored_user_should_fail() { async fn test_get_non_existent_stored_user_should_fail() {
let app_state = create_mock_app_state().await; let app_state = setup_all_test_environment().await; // Use the new setup function
let repo = AuthRepository::new(&app_state); let repo = AuthRepository::new(&app_state);
let result = repo let result = repo
.query_get_stored_user("not_found@example.com".into()) .query_get_stored_user("not_found@example.com".into())
.await; .await;
assert!(result.is_err()); assert!(result.is_err());
if let Some(err) = result.err() {
assert!(
err.to_string().contains("No stored user data found"),
"Expected 'No stored user data found' error, got: {err}"
);
}
} }
#[tokio::test] #[tokio::test]
async fn test_delete_non_existent_user_should_fail() { async fn test_delete_non_existent_user_should_fail() {
let app_state = create_mock_app_state().await; let app_state = setup_all_test_environment().await; // Use the new setup function
let repo = AuthRepository::new(&app_state); let repo = AuthRepository::new(&app_state);
let result = repo let result = repo
.query_delete_stored_user("ghost@example.com".into()) .query_delete_stored_user("ghost@example.com".into())
.await; .await;
assert!(result.is_err()); assert!(result.is_err());
} if let Some(err) = result.err() {
assert!(
#[tokio::test] err.to_string().contains("Failed delete stored user"),
async fn test_get_expired_otp_should_fail() { "Expected 'Failed delete stored user' error, got: {err}"
use chrono::Duration; );
let app_state = create_mock_app_state().await; }
let repo = AuthRepository::new(&app_state);
let email = "expired_otp@example.com";
let expired_time = chrono::Utc::now() - Duration::seconds(10);
let otp = 123456;
let _: Option<AuthOtpSchema> = repo
.state
.surrealdb_mem
.create((ResourceEnum::OtpCache.to_string(), email))
.content(AuthOtpSchema {
otp,
expires_at: expired_time,
})
.await
.unwrap();
let result = repo.query_get_stored_otp(email.into()).await;
assert!(result.is_err());
} }
#[tokio::test] #[tokio::test]
async fn test_store_and_get_valid_otp() { async fn test_store_and_get_valid_otp() {
let app_state = create_mock_app_state().await; let app_state = setup_all_test_environment().await; // Use the new setup function
let repo = AuthRepository::new(&app_state); let repo = AuthRepository::new(&app_state);
let email = "valid_otp@example.com"; let email = "valid_otp@example.com";
let otp = 654321; let otp = 654321;
let store_result = repo.query_store_otp(email.into(), otp).await; let store_result = repo.query_store_otp(email.into(), otp).await;
assert!(store_result.is_ok()); assert!(
store_result.is_ok(),
"Failed to store valid OTP: {:?}",
store_result.err()
);
let get_result = repo.query_get_stored_otp(email.into()).await; let get_result = repo.query_get_stored_otp(email.into()).await;
assert!(
get_result.is_ok(),
"Failed to get valid OTP: {:?}",
get_result.err()
);
assert_eq!(get_result.unwrap(), otp); assert_eq!(get_result.unwrap(), otp);
} }
} }
+2
View File
@@ -1,2 +1,4 @@
#[cfg(test)] #[cfg(test)]
pub mod auth_login_tests;
#[cfg(test)]
pub mod auth_repository_test; pub mod auth_repository_test;
@@ -1,19 +1,22 @@
use crate::{ use crate::{
create_mock_app_state, get_iso_date, // Import the new setup function and get_iso_date
permissions::{PermissionsRepository, PermissionsSchema}, permissions::{PermissionsRepository, PermissionsSchema},
setup_all_test_environment,
}; };
use chrono::Utc; use chrono::Utc;
fn create_dummy_permission(name: &str) -> PermissionsSchema { fn create_dummy_permission(name: &str) -> PermissionsSchema {
PermissionsSchema { PermissionsSchema {
name: name.to_string(), name: name.to_string(),
created_at: Some(get_iso_date()), // Ensure created_at is always set
updated_at: Some(get_iso_date()), // Ensure updated_at is always set
..Default::default() ..Default::default()
} }
} }
#[tokio::test] #[tokio::test]
async fn test_create_permission_should_succeed() { async fn test_create_permission_should_succeed() {
let state = create_mock_app_state().await; let state = setup_all_test_environment().await; // Use the new setup function
let repo = PermissionsRepository::new(&state); let repo = PermissionsRepository::new(&state);
let permission = create_dummy_permission("Test Permission"); let permission = create_dummy_permission("Test Permission");
let result = repo.query_create_permission(permission).await; let result = repo.query_create_permission(permission).await;
@@ -22,7 +25,7 @@ async fn test_create_permission_should_succeed() {
#[tokio::test] #[tokio::test]
async fn test_query_permission_list_should_return_data() { async fn test_query_permission_list_should_return_data() {
let state = create_mock_app_state().await; let state = setup_all_test_environment().await; // Use the new setup function
let repo = PermissionsRepository::new(&state); let repo = PermissionsRepository::new(&state);
let _ = repo let _ = repo
@@ -45,7 +48,7 @@ async fn test_query_permission_list_should_return_data() {
#[tokio::test] #[tokio::test]
async fn test_query_permission_by_id_should_succeed() { async fn test_query_permission_by_id_should_succeed() {
let state = create_mock_app_state().await; let state = setup_all_test_environment().await; // Use the new setup function
let repo = PermissionsRepository::new(&state); let repo = PermissionsRepository::new(&state);
let permission = create_dummy_permission("Detail"); let permission = create_dummy_permission("Detail");
let _ = repo.query_create_permission(permission.clone()).await; let _ = repo.query_create_permission(permission.clone()).await;
@@ -56,7 +59,7 @@ async fn test_query_permission_by_id_should_succeed() {
#[tokio::test] #[tokio::test]
async fn test_update_permission_should_succeed() { async fn test_update_permission_should_succeed() {
let state = create_mock_app_state().await; let state = setup_all_test_environment().await; // Use the new setup function
let repo = PermissionsRepository::new(&state); let repo = PermissionsRepository::new(&state);
let mut permission = create_dummy_permission("Update This"); let mut permission = create_dummy_permission("Update This");
let _ = repo.query_create_permission(permission.clone()).await; let _ = repo.query_create_permission(permission.clone()).await;
@@ -68,7 +71,7 @@ async fn test_update_permission_should_succeed() {
#[tokio::test] #[tokio::test]
async fn test_delete_permission_should_succeed() { async fn test_delete_permission_should_succeed() {
let state = create_mock_app_state().await; let state = setup_all_test_environment().await; // Use the new setup function
let repo = PermissionsRepository::new(&state); let repo = PermissionsRepository::new(&state);
let permission = create_dummy_permission("To Be Deleted"); let permission = create_dummy_permission("To Be Deleted");
let _ = repo.query_create_permission(permission.clone()).await; let _ = repo.query_create_permission(permission.clone()).await;
@@ -79,33 +82,64 @@ async fn test_delete_permission_should_succeed() {
#[tokio::test] #[tokio::test]
async fn test_delete_permission_should_fail_if_already_deleted() { async fn test_delete_permission_should_fail_if_already_deleted() {
let state = create_mock_app_state().await; let state = setup_all_test_environment().await; // Use the new setup function
let repo = PermissionsRepository::new(&state); let repo = PermissionsRepository::new(&state);
let permission = create_dummy_permission("Delete Twice"); let permission = create_dummy_permission("Delete Twice");
let _ = repo.query_create_permission(permission.clone()).await; let _ = repo.query_create_permission(permission.clone()).await;
let id = permission.id.id.to_raw(); let id = permission.id.id.to_raw();
let _ = repo.query_delete_permission(id.clone()).await; let delete_result = repo.query_delete_permission(id.clone()).await;
let second = repo.query_delete_permission(id).await; assert!(
assert!(second.is_err(), "Should fail on second delete"); delete_result.is_ok(),
"Initial delete failed: {:?}",
delete_result.err()
);
let second_delete_result = repo.query_delete_permission(id).await;
assert!(
second_delete_result.is_err(),
"Should fail on second delete"
);
if let Some(err) = second_delete_result.err() {
assert!(
err.to_string().contains("Permission not found"),
"Expected 'Permission not found' error, got: {err}"
);
}
} }
#[tokio::test] #[tokio::test]
async fn test_update_permission_should_fail_if_deleted() { async fn test_update_permission_should_fail_if_deleted() {
let state = create_mock_app_state().await; let state = setup_all_test_environment().await; // Use the new setup function
let repo = PermissionsRepository::new(&state); let repo = PermissionsRepository::new(&state);
let mut permission = create_dummy_permission("To Be Updated Then Deleted"); let mut permission = create_dummy_permission("To Be Updated Then Deleted");
let _ = repo.query_create_permission(permission.clone()).await; let _ = repo.query_create_permission(permission.clone()).await;
let id = permission.id.id.to_raw(); let id = permission.id.id.to_raw();
let _ = repo.query_delete_permission(id.clone()).await; let delete_result = repo.query_delete_permission(id.clone()).await;
assert!(
delete_result.is_ok(),
"Initial delete failed: {:?}",
delete_result.err()
);
permission.name = "Try Update".into(); permission.name = "Try Update".into();
let result = repo.query_update_permission(permission).await; let result = repo.query_update_permission(permission).await;
assert!(result.is_err(), "Update on deleted should fail"); assert!(result.is_err(), "Update on deleted should fail");
if let Some(err) = result.err() {
assert!(
err.to_string().contains("Permission not found"),
"Expected 'Permission not found' error, got: {err}"
);
}
} }
#[tokio::test] #[tokio::test]
async fn test_query_permission_by_id_should_fail_if_not_found() { async fn test_query_permission_by_id_should_fail_if_not_found() {
let state = create_mock_app_state().await; let state = setup_all_test_environment().await; // Use the new setup function
let repo = PermissionsRepository::new(&state); let repo = PermissionsRepository::new(&state);
let result = repo.query_permission_by_id("non-existent-id".into()).await; let result = repo.query_permission_by_id("non-existent-id".into()).await;
assert!(result.is_err(), "Expected error for not found id"); assert!(result.is_err(), "Expected error for not found id");
if let Some(err) = result.err() {
assert!(
err.to_string().contains("Permission not found"),
"Expected 'Permission not found' error, got: {err}"
);
}
} }
+173 -55
View File
@@ -1,5 +1,5 @@
use crate::{ use crate::{
ResourceEnum, create_mock_app_state, get_iso_date, make_thing, get_iso_date, make_thing,
permissions::{ permissions::{
permissions_repository::PermissionsRepository, permissions_repository::PermissionsRepository,
permissions_schema::PermissionsSchema, permissions_schema::PermissionsSchema,
@@ -8,6 +8,7 @@ use crate::{
roles_dto::{RolesRequestCreateDto, RolesRequestUpdateDto}, roles_dto::{RolesRequestCreateDto, RolesRequestUpdateDto},
roles_repository::RolesRepository, roles_repository::RolesRepository,
}, },
setup_all_test_environment, ResourceEnum,
}; };
use surrealdb::Uuid; use surrealdb::Uuid;
@@ -17,58 +18,92 @@ fn generate_unique_name(prefix: &str) -> String {
#[tokio::test] #[tokio::test]
async fn test_query_create_role_should_succeed() { async fn test_query_create_role_should_succeed() {
let state = create_mock_app_state().await; let state = setup_all_test_environment().await;
let perm_repo = PermissionsRepository::new(&state); let perm_repo = PermissionsRepository::new(&state);
let role_repo = RolesRepository::new(&state); let role_repo = RolesRepository::new(&state);
let perm_id = Uuid::new_v4().to_string(); let perm_id = Uuid::new_v4().to_string();
let permission = PermissionsSchema { let permission = PermissionsSchema {
id: make_thing(&ResourceEnum::Permissions.to_string(), &perm_id.clone()), id: make_thing(&ResourceEnum::Permissions.to_string(), &perm_id),
name: generate_unique_name("read_quiz"), name: generate_unique_name("read_quiz"),
is_deleted: false, is_deleted: false,
created_at: Some(get_iso_date()), created_at: Some(get_iso_date()),
updated_at: Some(get_iso_date()), updated_at: Some(get_iso_date()),
}; };
perm_repo.query_create_permission(permission).await.unwrap(); let perm_res = perm_repo.query_create_permission(permission).await;
assert!(
perm_res.is_ok(),
"Failed to create permission: {:?}",
perm_res.err()
);
let payload = RolesRequestCreateDto { let payload = RolesRequestCreateDto {
name: generate_unique_name("user"), name: generate_unique_name("user"),
permissions: vec![perm_id.clone()], permissions: vec![perm_id.clone()],
}; };
let result = role_repo.query_create_role(payload).await; let result = role_repo.query_create_role(payload).await;
assert!(result.is_ok()); assert!(result.is_ok(), "Failed to create role: {:?}", result.err());
} }
#[tokio::test] #[tokio::test]
async fn test_query_role_by_name_should_return_data() { async fn test_query_role_by_name_should_return_data() {
let state = create_mock_app_state().await; let state = setup_all_test_environment().await;
let role_repo = RolesRepository::new(&state); let role_repo = RolesRepository::new(&state);
let name = generate_unique_name("viewer"); let name = generate_unique_name("viewer");
let payload = RolesRequestCreateDto { let payload = RolesRequestCreateDto {
name: name.clone(), name: name.clone(),
permissions: vec![], permissions: vec![],
}; };
role_repo.query_create_role(payload.clone()).await.unwrap(); let create_res = role_repo.query_create_role(payload.clone()).await;
let role = role_repo.query_role_by_name(name.clone()).await.unwrap(); assert!(
create_res.is_ok(),
"Failed to create role: {:?}",
create_res.err()
);
let role = role_repo.query_role_by_name(name.clone()).await;
assert!(role.is_ok(), "Failed to get role by name: {:?}", role.err());
let role = role.unwrap();
assert_eq!(role.name, name.clone()); assert_eq!(role.name, name.clone());
} }
#[tokio::test] #[tokio::test]
async fn test_query_role_by_id_should_return_data() { async fn test_query_role_by_id_should_return_data() {
let state = create_mock_app_state().await; let state = setup_all_test_environment().await;
let role_repo = RolesRepository::new(&state); let role_repo = RolesRepository::new(&state);
let name = generate_unique_name("tester"); let name = generate_unique_name("tester");
let payload = RolesRequestCreateDto { let payload = RolesRequestCreateDto {
name: name.clone(), name: name.clone(),
permissions: vec![], permissions: vec![],
}; };
role_repo.query_create_role(payload.clone()).await.unwrap();
let role = role_repo.query_role_by_name(name.clone()).await.unwrap(); let create_res = role_repo.query_create_role(payload.clone()).await;
let result = role_repo.query_role_by_id(role.id).await.unwrap();
assert_eq!(result.name, name.clone()); assert!(
create_res.is_ok(),
"Failed to create role: {:?}",
create_res.err()
);
let role = role_repo.query_role_by_name(name.clone()).await;
assert!(role.is_ok(), "Failed to get role by name: {:?}", role.err());
let role = role.unwrap();
let result = role_repo.query_role_by_id(role.id.clone()).await;
assert!(
result.is_ok(),
"Failed to get role by id: {:?}",
result.err()
);
let result_role = result.unwrap();
assert_eq!(result_role.name, name.clone());
} }
#[tokio::test] #[tokio::test]
async fn test_query_update_role_should_update_name_and_permissions() { async fn test_query_update_role_should_update_name_and_permissions() {
let state = create_mock_app_state().await; let state = setup_all_test_environment().await;
let repo = RolesRepository::new(&state); let repo = RolesRepository::new(&state);
let perm_repo = PermissionsRepository::new(&state); let perm_repo = PermissionsRepository::new(&state);
let original_perm_id = Uuid::new_v4().to_string(); let original_perm_id = Uuid::new_v4().to_string();
@@ -77,22 +112,32 @@ async fn test_query_update_role_should_update_name_and_permissions() {
name: generate_unique_name("original_permission"), name: generate_unique_name("original_permission"),
is_deleted: false, is_deleted: false,
created_at: Some(crate::get_iso_date()), created_at: Some(crate::get_iso_date()),
updated_at: None, updated_at: Some(crate::get_iso_date()),
}; };
perm_repo let perm_res = perm_repo.query_create_permission(original_perm).await;
.query_create_permission(original_perm) assert!(
.await perm_res.is_ok(),
.unwrap(); "Failed to create original permission: {:?}",
perm_res.err()
);
let role_upadate_name = generate_unique_name("role_for_update"); let role_upadate_name = generate_unique_name("role_for_update");
let create_payload = RolesRequestCreateDto { let create_payload = RolesRequestCreateDto {
name: role_upadate_name.clone(), name: role_upadate_name.clone(),
permissions: vec![original_perm_id.clone()], permissions: vec![original_perm_id.clone()],
}; };
repo.query_create_role(create_payload).await.unwrap(); let create_res = repo.query_create_role(create_payload).await;
let existing_role = repo assert!(
.query_role_by_name(role_upadate_name.clone()) create_res.is_ok(),
.await "Failed to create role: {:?}",
.unwrap(); create_res.err()
);
let existing_role = repo.query_role_by_name(role_upadate_name.clone()).await;
assert!(
existing_role.is_ok(),
"Failed to get role by name: {:?}",
existing_role.err()
);
let existing_role = existing_role.unwrap();
let existing_role_id = existing_role.id.clone(); let existing_role_id = existing_role.id.clone();
let new_perm_id = Uuid::new_v4().to_string(); let new_perm_id = Uuid::new_v4().to_string();
let new_perm = PermissionsSchema { let new_perm = PermissionsSchema {
@@ -100,10 +145,15 @@ async fn test_query_update_role_should_update_name_and_permissions() {
name: "New Permission".into(), name: "New Permission".into(),
is_deleted: false, is_deleted: false,
created_at: Some(crate::get_iso_date()), created_at: Some(crate::get_iso_date()),
updated_at: None, updated_at: Some(crate::get_iso_date()),
}; };
let new_role_name = generate_unique_name("updated_role_name"); let new_role_name = generate_unique_name("updated_role_name");
perm_repo.query_create_permission(new_perm).await.unwrap(); let perm_res = perm_repo.query_create_permission(new_perm).await;
assert!(
perm_res.is_ok(),
"Failed to create new permission: {:?}",
perm_res.err()
);
let update_payload = RolesRequestUpdateDto { let update_payload = RolesRequestUpdateDto {
name: Some(new_role_name.clone()), name: Some(new_role_name.clone()),
permissions: Some(vec![new_perm_id.clone()]), permissions: Some(vec![new_perm_id.clone()]),
@@ -112,34 +162,57 @@ async fn test_query_update_role_should_update_name_and_permissions() {
let update_result = repo let update_result = repo
.query_update_role(existing_role_id.clone(), update_payload) .query_update_role(existing_role_id.clone(), update_payload)
.await; .await;
assert!(update_result.is_ok()); assert!(
let updated = repo update_result.is_ok(),
.query_role_by_id(existing_role_id.clone()) "Failed to update role: {:?}",
.await update_result.err()
.unwrap(); );
assert_eq!(updated.name, new_role_name.clone()); let updated = repo.query_role_by_id(existing_role_id.clone()).await;
assert!(
updated.is_ok(),
"Failed to get updated role: {:?}",
updated.err()
);
assert_eq!(updated.unwrap().name, new_role_name.clone());
} }
#[tokio::test] #[tokio::test]
async fn test_query_delete_role_should_soft_delete() { async fn test_query_delete_role_should_soft_delete() {
let state = create_mock_app_state().await; let state = setup_all_test_environment().await;
let role_repo = RolesRepository::new(&state); let role_repo = RolesRepository::new(&state);
let name = generate_unique_name("temporary"); let name = generate_unique_name("temporary");
let payload = RolesRequestCreateDto { let payload = RolesRequestCreateDto {
name: name.clone(), name: name.clone(),
permissions: vec![], permissions: vec![],
}; };
role_repo.query_create_role(payload.clone()).await.unwrap(); let create_res = role_repo.query_create_role(payload.clone()).await;
let role = role_repo.query_role_by_name(name.clone()).await.unwrap(); assert!(
create_res.is_ok(),
"Failed to create role: {:?}",
create_res.err()
);
let role = role_repo.query_role_by_name(name.clone()).await;
assert!(role.is_ok(), "Failed to get role by name: {:?}", role.err());
let role = role.unwrap();
let result = role_repo.query_delete_role(role.id.clone()).await; let result = role_repo.query_delete_role(role.id.clone()).await;
assert!(result.is_ok()); assert!(result.is_ok(), "Failed to delete role: {:?}", result.err());
let deleted = role_repo.query_role_by_id(role.id).await; let deleted = role_repo.query_role_by_id(role.id).await;
assert!(deleted.is_err()); assert!(
deleted.is_err(),
"Role should be deleted, but got: {deleted:?}"
);
if let Some(err) = deleted.err() {
assert!(
err.to_string().contains("Role not found"),
"Expected 'Role not found' error, got: {err}"
);
}
} }
#[tokio::test] #[tokio::test]
async fn test_query_update_role_should_fallback_to_existing_permissions_if_none_provided() async fn test_query_update_role_should_fallback_to_existing_permissions_if_none_provided(
{ ) {
let state = create_mock_app_state().await; let state = setup_all_test_environment().await;
let repo = RolesRepository::new(&state); let repo = RolesRepository::new(&state);
let perm_repo = PermissionsRepository::new(&state); let perm_repo = PermissionsRepository::new(&state);
let perm_id = Uuid::new_v4().to_string(); let perm_id = Uuid::new_v4().to_string();
@@ -148,18 +221,31 @@ async fn test_query_update_role_should_fallback_to_existing_permissions_if_none_
name: "Permission for Fallback".into(), name: "Permission for Fallback".into(),
is_deleted: false, is_deleted: false,
created_at: Some(crate::get_iso_date()), created_at: Some(crate::get_iso_date()),
updated_at: None, updated_at: Some(crate::get_iso_date()),
}; };
perm_repo.query_create_permission(permission).await.unwrap(); let perm_res = perm_repo.query_create_permission(permission).await;
assert!(
perm_res.is_ok(),
"Failed to create permission: {:?}",
perm_res.err()
);
let create_payload = RolesRequestCreateDto { let create_payload = RolesRequestCreateDto {
name: "Role With Permission".into(), name: "Role With Permission".into(),
permissions: vec![perm_id.clone()], permissions: vec![perm_id.clone()],
}; };
repo.query_create_role(create_payload).await.unwrap(); let create_res = repo.query_create_role(create_payload).await;
let existing = repo assert!(
.query_role_by_name("Role With Permission".into()) create_res.is_ok(),
.await "Failed to create role: {:?}",
.unwrap(); create_res.err()
);
let existing = repo.query_role_by_name("Role With Permission".into()).await;
assert!(
existing.is_ok(),
"Failed to get role by name: {:?}",
existing.err()
);
let existing = existing.unwrap();
let existing_id = existing.id.clone(); let existing_id = existing.id.clone();
let update_payload = RolesRequestUpdateDto { let update_payload = RolesRequestUpdateDto {
name: Some("Updated Role Name".into()), name: Some("Updated Role Name".into()),
@@ -169,29 +255,61 @@ async fn test_query_update_role_should_fallback_to_existing_permissions_if_none_
let update_res = repo let update_res = repo
.query_update_role(existing_id.clone(), update_payload) .query_update_role(existing_id.clone(), update_payload)
.await; .await;
assert!(update_res.is_ok()); assert!(
update_res.is_ok(),
"Failed to update role (fallback): {:?}",
update_res.err()
);
} }
#[tokio::test] #[tokio::test]
async fn test_query_role_by_name_should_fail_if_not_found() { async fn test_query_role_by_name_should_fail_if_not_found() {
let state = create_mock_app_state().await; let state = setup_all_test_environment().await;
let role_repo = RolesRepository::new(&state); let role_repo = RolesRepository::new(&state);
let result = role_repo.query_role_by_name("ghost-role".into()).await; let result = role_repo.query_role_by_name("ghost-role".into()).await;
assert!(result.is_err()); assert!(result.is_err());
if let Some(err) = result.err() {
assert!(
err.to_string().contains("Role not found"),
"Expected 'Role not found' error, got: {err}"
);
}
} }
#[tokio::test] #[tokio::test]
async fn test_query_delete_role_should_fail_if_already_deleted() { async fn test_query_delete_role_should_fail_if_already_deleted() {
let state = create_mock_app_state().await; let state = setup_all_test_environment().await;
let role_repo = RolesRepository::new(&state); let role_repo = RolesRepository::new(&state);
let name = generate_unique_name("soft_delete_test"); let name = generate_unique_name("soft_delete_test");
let payload = RolesRequestCreateDto { let payload = RolesRequestCreateDto {
name: name.clone(), name: name.clone(),
permissions: vec![], permissions: vec![],
}; };
role_repo.query_create_role(payload.clone()).await.unwrap(); let create_res = role_repo.query_create_role(payload.clone()).await;
let role = role_repo.query_role_by_name(name.clone()).await.unwrap(); assert!(
role_repo.query_delete_role(role.id.clone()).await.unwrap(); create_res.is_ok(),
let result = role_repo.query_delete_role(role.id); "Failed to create role: {:?}",
assert!(result.await.is_err()); create_res.err()
);
let role = role_repo.query_role_by_name(name.clone()).await;
assert!(role.is_ok(), "Failed to get role by name: {:?}", role.err());
let role = role.unwrap();
let del_res = role_repo.query_delete_role(role.id.clone()).await;
assert!(
del_res.is_ok(),
"Failed to delete role: {:?}",
del_res.err()
);
let result_fut = role_repo.query_delete_role(role.id);
let result_val = result_fut.await;
assert!(
result_val.is_err(),
"Role should already be deleted, but got: {result_val:?}"
);
if let Some(err) = result_val.err() {
assert!(
err.to_string().contains("Role not found"),
"Expected 'Role not found' error, got: {err}"
);
}
} }

Some files were not shown because too many files have changed in this diff Show More