Merge pull request #45 from IMPHNEN/feat/auth-google

Feat/auth google
This commit is contained in:
Maulana Sodiqin
2025-08-17 18:26:24 +07:00
committed by GitHub
90 changed files with 4842 additions and 1217 deletions
+32 -7
View File
@@ -1,4 +1,30 @@
# RUST_ENV : Mode environment aplikasi (misal: development, production)
# RUST_LOG : Level logging aplikasi Rust (misal: debug, info, warn, error)
# PORT : Port yang digunakan backend service
# SURREALDB_URL : URL koneksi ke SurrealDB (gunakan ws:// untuk WebSocket)
# SURREALDB_USERNAME : Username untuk SurrealDB
# SURREALDB_PASSWORD : Password untuk SurrealDB
# SURREALDB_NAMESPACE : Namespace SurrealDB yang digunakan
# SURREALDB_DBNAME : Nama database SurrealDB
# ACCESS_TOKEN_SECRET : Secret key untuk JWT access token (ganti dengan nilai rahasia)
# REFRESH_TOKEN_SECRET : Secret key untuk JWT refresh token (ganti dengan nilai rahasia)
# SMTP_EMAIL : Email yang digunakan untuk mengirim email (SMTP)
# SMTP_PASSWORD : Password email SMTP
# SMTP_NAME : Nama pengirim email (misal: nama aplikasi)
# SMTP_HOST : Host SMTP server (misal: smtp.gmail.com)
# REDISDB_URL : URL/host Redis database
# FE_URL : URL frontend aplikasi (misal: http://localhost)
# MINIO_ENDPOINT : Endpoint MinIO object storage
# MINIO_BUCKET_NAME : Nama bucket default MinIO
# MINIO_ACCESS_KEY : Access key MinIO atau MINIO_ROOT_USER
# MINIO_SECRET_KEY : Secret key MinIO atau MINIO_ROOT_PASSWORD
# MINIO_ROOT_USER : Username root MinIO (default: minioadmin)
# MINIO_ROOT_PASSWORD : Password root MinIO (default: minioadmin)
# GOOGLE_CLIENT_ID : Client ID OAuth Google (untuk login Google)
# GOOGLE_CLIENT_SECRET : Client Secret OAuth Google
RUST_ENV=development RUST_ENV=development
RUST_LOG=debug
PORT=4099 PORT=4099
SURREALDB_URL=ws://localhost:8000/rpc SURREALDB_URL=ws://localhost:8000/rpc
SURREALDB_USERNAME=root SURREALDB_USERNAME=root
@@ -15,10 +41,9 @@ REDISDB_URL=localhost
FE_URL=http://localhost FE_URL=http://localhost
MINIO_ENDPOINT=http://localhost:9000 MINIO_ENDPOINT=http://localhost:9000
MINIO_BUCKET_NAME=default_bucket MINIO_BUCKET_NAME=default_bucket
MINIO_ACCESS_KEY=your-minio-access-key MINIO_ACCESS_KEY=minioadmin
MINIO_SECRET_KEY=your-minio-secret-key MINIO_SECRET_KEY=minioadmin
MAIL_USER=your-email@example.com MINIO_SECURE=false
MAIL_PASSWORD=your-smtp-password
MAIL_HOST=smtp.gmail.com GOOGLE_CLIENT_ID="your_google_client_id"
MAIL_PORT=465 GOOGLE_CLIENT_SECRET="your_google_client_secret"
MAIL_SECURE=true
Generated
+514 -218
View File
File diff suppressed because it is too large Load Diff
+38 -27
View File
@@ -16,47 +16,58 @@ members = [
[workspace.dependencies] [workspace.dependencies]
oauth2 = "5.0.0"
reqwest = { version = "0.12.23", features = ["json"] }
serde_json = "1.0.142"
axum = { version = "0.8.4", features = ["multipart"] } axum = { version = "0.8.4", features = ["multipart"] }
log = "0.4.25" log = "0.4.27"
serde = { version = "1.0.217", features = ["derive"] } serde = { version = "1.0.219", features = ["derive"] }
serde_json = "1.0.138" tokio = { version = "1.47.1", features = ["full"] }
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.4.0", features = ["axum_extras"] }
utoipa-swagger-ui = { version = "9.0.0", features = ["axum"] } utoipa-swagger-ui = { version = "9.0.2", features = ["axum"] }
lettre = { version = "0.11.16", features = ["tokio1-native-tls"] } lettre = { version = "0.11.18", features = ["tokio1-native-tls"] }
surrealdb = { version = "2.3.7", features = ["kv-mem", "kv-fdb"] } surrealdb = { version = "2.3.7", features = ["kv-mem", "kv-fdb"] }
thiserror = "2.0.12" thiserror = "2.0.14"
anyhow = "1.0.98" anyhow = "1.0.99"
rand = { version = "0.9.1", features = ["std", "alloc"] } rand = { version = "0.9.2", features = ["std", "alloc"] }
rand_distr = "0.5.1" rand_distr = "0.5.1"
tower-http = { version = "0.6.4", features = ["cors", "trace"] } tower-http = { version = "0.6.6", features = ["cors", "trace"] }
http-body-util = "0.1.1" http-body-util = "0.1.3"
validator = { version = "0.20.0", features = ["derive"] } validator = { version = "0.20.0", features = ["derive"] }
lazy_static = "1.4.0" lazy_static = "1.5.0"
regex = "1.11.1" regex = "1.11.1"
axum-test = "17.2.0" axum-test = "17.3.0"
fancy-regex = "0.15.0" axum-extra = { version = "0.10.1", features = ["typed-header"] }
fancy-regex = "0.16.1"
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" tracing = "0.1.41"
tracing = "0.1.40" uuid = { version = "1.18.0", features = ["v4", "fast-rng", "serde"] }
tracing-subscriber = { version = "0.3.18", features = ["fmt"] } strum = { version = "0.27.2", features = ["derive"] }
once_cell = "1.21.3" strum_macros = "0.27.2"
uuid = { version = "1.8.0", features = ["v4", "fast-rng", "serde"] } base64 = "0.22.1"
strum = { version = "0.27.1", features = ["derive"] } sha2 = "0.10.9"
strum_macros = "0.27.1" hmac = "0.12"
hex = "0.4"
urlencoding = "2.1"
hyper = "1.6.0" hyper = "1.6.0"
hyper-util = "0.1.0" hyper-util = "0.1.16"
async-trait = "0.1.75" minio = "0.3.0"
tokio-tungstenite = "0.23"
tokio-test = "0.4.4"
mockall = "0.13.1"
once_cell = "1.21.3"
dotenvy = "0.15.7"
tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }
tokio-tungstenite = "0.27"
url = "2.5" url = "2.5"
futures-util = "0.3" futures-util = "0.3"
http = "1.3"
tests = { path = "./tests" } tests = { path = "./tests" }
imphnen-iam = { path = "./imphnen-iam" } imphnen-iam = { path = "./imphnen-iam" }
imphnen-cms = { path = "./imphnen-cms" } imphnen-cms = { path = "./imphnen-cms" }
+41
View File
@@ -0,0 +1,41 @@
#!/bin/bash
# Script deploy ke VPS
set -e
REMOTE_USER="asephs"
REMOTE_HOST="70.153.9.42"
REMOTE_PATH="/home/asephs/imphnen-backend-service"
# Build project
taskset -c 0,1 cargo build --release -j 2
# Rsync hasil build dan file yang diperlukan
rsync -avz --delete \
target/release/ \
$REMOTE_USER@$REMOTE_HOST:$REMOTE_PATH/target/release/
# Sync file konfigurasi dan source code (jika perlu)
rsync -avz --delete \
imphnen-backend/ \
$REMOTE_USER@$REMOTE_HOST:$REMOTE_PATH/imphnen-backend/
rsync -avz --delete \
docker-compose.yml Dockerfile \
$REMOTE_USER@$REMOTE_HOST:$REMOTE_PATH/
# Tambahkan file lain jika diperlukan
ssh $REMOTE_USER@$REMOTE_HOST << 'EOF'
cd /home/asephs/imphnen-backend-service
export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games:/usr/local/games:/snap/bin"
export PATH=$PATH:/root/.local/share/pnpm
export PATH=$PATH:/home/asephs/.nvm/versions/node/v22.17.1/bin
export PATH=$PATH:/home/asephs/.bun/bin/bun
if [ -f ~/.bashrc ]; then
source ~/.bashrc
fi
pm2 restart 4 --update-env
EOF
echo "Deploy selesai ke $REMOTE_HOST:$REMOTE_PATH"
+16
View File
@@ -14,3 +14,19 @@ services:
command: start --log trace --user root --pass root command: start --log trace --user root --pass root
ports: ports:
- "8000:8000" - "8000:8000"
minio:
image: minio/minio:latest
container_name: minio
ports:
- "9000:9000"
- "9001:9001"
environment:
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin
command: server /data --console-address ":9001"
volumes:
- minio_data:/data
volumes:
minio_data:
+40
View File
@@ -3,6 +3,46 @@ name = "imphnen-backend"
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2021"
[[bin]]
name = "api"
path = "src/main.rs"
[[bin]]
name = "clear_db_test"
path = "src/bin/clear_db_test.rs"
[[bin]]
name = "seeder"
path = "src/bin/seeder.rs"
[[bin]]
name = "seed_events"
path = "src/bin/seed_events.rs"
[[bin]]
name = "seed_gacha_rolls"
path = "src/bin/seed_gacha_rolls.rs"
[[bin]]
name = "seed_mentor_user"
path = "src/bin/seed_mentor_user.rs"
[[bin]]
name = "seed_permissions"
path = "src/bin/seed_permissions.rs"
[[bin]]
name = "seed_roles"
path = "src/bin/seed_roles.rs"
[[bin]]
name = "seed_roles_permissions"
path = "src/bin/seed_roles_permissions.rs"
[[bin]]
name = "seed_users"
path = "src/bin/seed_users.rs"
[dependencies] [dependencies]
imphnen-libs.workspace = true imphnen-libs.workspace = true
imphnen-utils.workspace = true imphnen-utils.workspace = true
+5 -1
View File
@@ -1,3 +1,4 @@
use axum::Router;
use imphnen_gateway::gateway_service; use imphnen_gateway::gateway_service;
use imphnen_libs::axum_init; use imphnen_libs::axum_init;
@@ -5,7 +6,10 @@ use imphnen_libs::axum_init;
async fn main() { async fn main() {
env_logger::init(); env_logger::init();
axum_init(|surrealdb_ws, surrealdb_mem| async { axum_init(|surrealdb_ws, surrealdb_mem| async {
gateway_service(surrealdb_ws, surrealdb_mem).await let app = gateway_service(surrealdb_ws, surrealdb_mem).await;
let mut router = Router::new();
router = router.nest("/api/v1/auth", imphnen_iam::v1::auth::auth_router());
app.merge(router)
}) })
.await; .await;
} }
+6 -6
View File
@@ -30,7 +30,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let url = Url::parse(SURREALDB_URL_WS)?; // Menggunakan SURREALDB_URL_WS statis let url = Url::parse(SURREALDB_URL_WS)?; // Menggunakan SURREALDB_URL_WS statis
let (ws_stream, _) = connect_async(url).await?; let (ws_stream, _) = connect_async(url.as_str()).await?;
let (mut write, mut read) = ws_stream.split(); let (mut write, mut read) = ws_stream.split();
// Authenticate (signin) // Authenticate (signin)
@@ -43,7 +43,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
"id": 1 "id": 1
}).to_string(); }).to_string();
println!("DEBUG: Sending signin query: {}", signin_query); println!("DEBUG: Sending signin query: {}", signin_query);
write.send(Message::Text(signin_query)).await?; write.send(Message::Text(signin_query.into())).await?;
let signin_response = read.next().await.ok_or("Failed to read signin response")?; let signin_response = read.next().await.ok_or("Failed to read signin response")?;
let signin_response_msg = signin_response?; let signin_response_msg = signin_response?;
@@ -60,7 +60,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
"id": 2 "id": 2
}).to_string(); }).to_string();
println!("DEBUG: Sending use query: {}", use_query); println!("DEBUG: Sending use query: {}", use_query);
write.send(Message::Text(use_query)).await?; write.send(Message::Text(use_query.into())).await?;
let use_response = read.next().await.ok_or("Failed to read use response")?; let use_response = read.next().await.ok_or("Failed to read use response")?;
let use_response_msg = use_response?; let use_response_msg = use_response?;
@@ -82,7 +82,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
}).to_string(); }).to_string();
println!("DEBUG: Attempting REMOVE TABLE {}: {}", table, query_json); println!("DEBUG: Attempting REMOVE TABLE {}: {}", table, query_json);
write.send(Message::Text(query_json)).await?; write.send(Message::Text(query_json.into())).await?;
let response_result = read.next().await.ok_or("Stream ended unexpectedly")?; let response_result = read.next().await.ok_or("Stream ended unexpectedly")?;
@@ -100,7 +100,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
}).to_string(); }).to_string();
println!("DEBUG: Attempting DELETE {}: {}", table, delete_all_json); println!("DEBUG: Attempting DELETE {}: {}", table, delete_all_json);
write.send(Message::Text(delete_all_json)).await?; write.send(Message::Text(delete_all_json.into())).await?;
let delete_response_result = read.next().await.ok_or("Stream ended unexpectedly during DELETE type::")?; let delete_response_result = read.next().await.ok_or("Stream ended unexpectedly during DELETE type::")?;
match delete_response_result { match delete_response_result {
@@ -137,7 +137,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
"params": [select_query], "params": [select_query],
"id": i + 1000 "id": i + 1000
}).to_string(); }).to_string();
write.send(Message::Text(select_json)).await?; write.send(Message::Text(select_json.into())).await?;
let select_response_result = read.next().await.ok_or("Stream ended unexpectedly during SELECT check")?; let select_response_result = read.next().await.ok_or("Stream ended unexpectedly during SELECT check")?;
match select_response_result { match select_response_result {
Ok(select_msg) => { Ok(select_msg) => {
+5 -6
View File
@@ -1,21 +1,20 @@
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};
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, Uuid}; // Added Uuid use surrealdb::{opt::auth::Root, sql::Thing, Uuid}; // Added Uuid
#[tokio::main] #[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> { async fn main() -> Result<(), Box<dyn Error>> {
load_env(); let env = &imphnen_libs::enviroment::ENV;
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,
password: &env.surrealdb_password, password: &env.surrealdb_password,
}) })
.await?; .await?;
db.use_ns(env.surrealdb_namespace) db.use_ns(env.surrealdb_namespace.clone())
.use_db(env.surrealdb_dbname) .use_db(env.surrealdb_dbname.clone())
.await?; .await?;
let events = vec![ let events = vec![
+4 -7
View File
@@ -1,14 +1,11 @@
use imphnen_libs::enviroment::load_env; use imphnen_utils::{get_iso_date};
use imphnen_utils::{get_iso_date, Env};
use std::error::Error; use std::error::Error;
use surrealdb::opt::auth::Root; use surrealdb::opt::auth::Root;
use surrealdb::sql::Thing; use surrealdb::sql::Thing;
#[tokio::main] #[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> { async fn main() -> Result<(), Box<dyn Error>> {
load_env(); let env = &imphnen_libs::enviroment::ENV;
let env = Env::new();
use surrealdb::engine::any; use surrealdb::engine::any;
let db = any::connect(&env.surrealdb_url).await?; let db = any::connect(&env.surrealdb_url).await?;
db.signin(Root { db.signin(Root {
@@ -16,8 +13,8 @@ async fn main() -> Result<(), Box<dyn Error>> {
password: &env.surrealdb_password, password: &env.surrealdb_password,
}) })
.await?; .await?;
db.use_ns(env.surrealdb_namespace) db.use_ns(env.surrealdb_namespace.clone())
.use_db(env.surrealdb_dbname) .use_db(env.surrealdb_dbname.clone())
.await?; .await?;
db.query("DELETE type::thing('app_gacha_items', $id)") db.query("DELETE type::thing('app_gacha_items', $id)")
File diff suppressed because one or more lines are too long
+5 -7
View File
@@ -1,6 +1,5 @@
use imphnen_iam::PermissionsEnum; use imphnen_iam::PermissionsEnum;
use imphnen_libs::enviroment::load_env; use imphnen_utils::{get_iso_date};
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::engine::any; use surrealdb::engine::any;
@@ -8,17 +7,15 @@ 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(); let env = &imphnen_libs::enviroment::ENV;
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,
password: &env.surrealdb_password, password: &env.surrealdb_password,
}) })
.await?; .await?;
db.use_ns(env.surrealdb_namespace) db.use_ns(env.surrealdb_namespace.clone())
.use_db(env.surrealdb_dbname) .use_db(env.surrealdb_dbname.clone())
.await?; .await?;
for permission in [ for permission in [
@@ -54,6 +51,7 @@ async fn main() -> Result<(), Box<dyn Error>> {
PermissionsEnum::ReadOwnMentorProfile, PermissionsEnum::ReadOwnMentorProfile,
PermissionsEnum::UpdateOwnMentorProfile, PermissionsEnum::UpdateOwnMentorProfile,
PermissionsEnum::ReadOwnMentorStatus, PermissionsEnum::ReadOwnMentorStatus,
PermissionsEnum::UpdateMentors,
PermissionsEnum::VerifyMentors, PermissionsEnum::VerifyMentors,
PermissionsEnum::DeleteMentors, PermissionsEnum::DeleteMentors,
] { ] {
+4 -6
View File
@@ -1,21 +1,19 @@
use imphnen_libs::enviroment::load_env; use imphnen_utils::{get_iso_date};
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::engine::any; use surrealdb::engine::any;
use surrealdb::opt::auth::Root; 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(); let env = &imphnen_libs::enviroment::ENV;
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,
password: &env.surrealdb_password, password: &env.surrealdb_password,
}) })
.await?; .await?;
db.use_ns(env.surrealdb_namespace) db.use_ns(env.surrealdb_namespace.clone())
.use_db(env.surrealdb_dbname) .use_db(env.surrealdb_dbname.clone())
.await?; .await?;
let roles = vec![ let roles = vec![
@@ -1,22 +1,28 @@
use imphnen_iam::{get_iso_date, make_thing, Env, PermissionsEnum}; use imphnen_iam::{get_iso_date, make_thing, PermissionsEnum};
use imphnen_libs::enviroment::load_env;
use std::error::Error; use std::error::Error;
use surrealdb::engine::any; use surrealdb::engine::any;
use surrealdb::opt::auth::Root; 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(); let env = &imphnen_libs::enviroment::ENV;
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,
password: &env.surrealdb_password, password: &env.surrealdb_password,
}) })
.await?; .await?;
db.use_ns(env.surrealdb_namespace) db.use_ns(env.surrealdb_namespace.clone())
.use_db(env.surrealdb_dbname) .use_db(env.surrealdb_dbname.clone())
.await?; .await?;
db.query("DEFINE INDEX user_email_index ON TABLE users COLUMNS email UNIQUE;")
.await?;
db.query("DEFINE INDEX role_name_idx ON TABLE roles COLUMNS name UNIQUE;")
.await?;
println!("✅ Index 'user_email_index' defined on table 'users' for column 'email'.");
let roles_permissions = vec![ let roles_permissions = vec![
( (
@@ -59,6 +65,7 @@ async fn main() -> Result<(), Box<dyn Error>> {
( (
"3b9f8c4e-6a2d-4f8a-9a12-2d6f8b3c4e5a", "3b9f8c4e-6a2d-4f8a-9a12-2d6f8b3c4e5a",
vec![ vec![
PermissionsEnum::ReadListUsers, // Added ReadListUsers permission
PermissionsEnum::ReadOwnMentorProfile, PermissionsEnum::ReadOwnMentorProfile,
PermissionsEnum::UpdateOwnMentorProfile, PermissionsEnum::UpdateOwnMentorProfile,
PermissionsEnum::ReadOwnMentorStatus, PermissionsEnum::ReadOwnMentorStatus,
@@ -76,6 +83,8 @@ async fn main() -> Result<(), Box<dyn Error>> {
vec![ vec![
PermissionsEnum::ReadListGachaItems, PermissionsEnum::ReadListGachaItems,
PermissionsEnum::ReadDetailGachaItems, PermissionsEnum::ReadDetailGachaItems,
PermissionsEnum::ReadListUsers,
PermissionsEnum::ReadDetailUsers,
PermissionsEnum::CreateGachaClaims, PermissionsEnum::CreateGachaClaims,
PermissionsEnum::ReadDetailGachaClaims, PermissionsEnum::ReadDetailGachaClaims,
PermissionsEnum::ReadDetailGachaRolls, PermissionsEnum::ReadDetailGachaRolls,
+24 -9
View File
@@ -1,13 +1,11 @@
use imphnen_iam::UsersSchema; use imphnen_iam::UsersSchema;
use imphnen_libs::enviroment::load_env; use imphnen_utils::{get_iso_date, hash_password};
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>> {
load_env(); let env = &imphnen_libs::enviroment::ENV;
let env = Env::new();
use surrealdb::engine::any; use surrealdb::engine::any;
let db = any::connect(&env.surrealdb_url).await?; let db = any::connect(&env.surrealdb_url).await?;
db.signin(Root { db.signin(Root {
@@ -15,8 +13,8 @@ async fn main() -> Result<(), Box<dyn Error>> {
password: &env.surrealdb_password, password: &env.surrealdb_password,
}) })
.await?; .await?;
db.use_ns(env.surrealdb_namespace) db.use_ns(env.surrealdb_namespace.clone())
.use_db(env.surrealdb_dbname) .use_db(env.surrealdb_dbname.clone())
.await?; .await?;
let users = vec![ let users = vec![
@@ -48,15 +46,32 @@ async fn main() -> Result<(), Box<dyn Error>> {
let user = UsersSchema { let user = UsersSchema {
id: Thing::from(("app_users", id)), id: Thing::from(("app_users", id)),
fullname: fullname.into(), fullname: fullname.into(),
legal_name: Some(format!("{} Legal Name", fullname)),
email: email.into(), email: email.into(),
password: hash_password("password").unwrap(), password: hash_password("password").unwrap(),
avatar: None, avatar: Some("https://example.com/avatar.jpg".into()),
phone_number: "081234567890".into(), phone_number: "081234567890".into(),
phone_for_verification: Some("081234567890".into()),
is_active: true, is_active: true,
is_deleted: false, is_deleted: false,
mentor_id: None, mentor_id: None,
gender: None, gender: Some("male".into()),
birthdate: None, birthdate: Some("1990-05-15".into()),
domicile: Some("Jakarta, Indonesia".into()),
// identity_document_url: None, // Sudah tidak dipakai, bisa dihapus dari schema jika tidak diperlukan
bio: Some(format!("{} adalah user dengan data pribadi lengkap untuk testing.", fullname)),
last_education: Some("S1 Teknik Informatika".into()),
linkedin_url: Some("https://linkedin.com/in/user".into()),
github_url: Some("https://github.com/user".into()),
cv_url: Some("https://example.com/cv.pdf".into()),
portfolio_url: Some("https://example.com/portfolio".into()),
website_url: Some("https://example.com/website".into()),
twitter_url: Some("https://twitter.com/user".into()),
location: Some("Jakarta, Indonesia".into()),
skills: Some(vec!["JavaScript".into(), "React".into(), "Node.js".into()]),
experience: None,
education: None,
career_status: Some("Senior Developer".into()),
role: Thing::from(("app_roles", role_id)), role: Thing::from(("app_roles", role_id)),
created_at: get_iso_date(), created_at: get_iso_date(),
updated_at: get_iso_date(), updated_at: get_iso_date(),
@@ -1,9 +1,10 @@
use super::{events_dto::EventsQueryDto, events_schema::EventsSchema}; 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, make_thing};
use std::time::Instant; use std::time::Instant;
use tracing::instrument; use tracing::instrument;
use tracing::info;
pub struct EventsRepository<'a> { pub struct EventsRepository<'a> {
state: &'a AppState, state: &'a AppState,
@@ -25,6 +26,7 @@ impl<'a> EventsRepository<'a> {
.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();
info!(query = %query, "Executing SurrealDB query");
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(); let elapsed = now.elapsed();
@@ -44,10 +46,20 @@ impl<'a> EventsRepository<'a> {
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 now = Instant::now();
let db = &self.state.surrealdb_ws; let db = &self.state.surrealdb_ws;
// Attempt to parse the ID. If it's a full Thing (e.g., "events:some_id"), extract the ID part.
// Otherwise, assume it's already the raw ID.
let parsed_id = if id.contains(":") {
let thing = make_thing(ResourceEnum::Events.to_string().as_str(), &id);
get_id(&thing)?.1.to_string()
} else {
id.clone()
};
let builder = DetailQueryBuilder::new(ResourceEnum::Events.to_string()) let builder = DetailQueryBuilder::new(ResourceEnum::Events.to_string())
.with_id(&id) .with_id(&parsed_id)
.with_select_fields(vec!["*"]); .with_select_fields(vec!["*"]);
let sql = builder.build(); let sql = builder.build();
info!(query = %sql, "Executing SurrealDB query");
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(); let elapsed = now.elapsed();
@@ -72,6 +84,8 @@ impl<'a> EventsRepository<'a> {
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 now = Instant::now();
let db = &self.state.surrealdb_ws; let db = &self.state.surrealdb_ws;
let query_str = format!("CREATE {} CONTENT ...", ResourceEnum::Events.to_string());
info!(query = %query_str, "Executing SurrealDB query");
let record: Option<EventsSchema> = db let record: Option<EventsSchema> = db
.create(ResourceEnum::Events.to_string()) .create(ResourceEnum::Events.to_string())
.content(data) .content(data)
@@ -106,6 +120,8 @@ impl<'a> EventsRepository<'a> {
}; };
let record_key = get_id(&merged.id)?; let record_key = get_id(&merged.id)?;
let query_str = format!("UPDATE {:?} MERGE ...", record_key);
info!(query = %query_str, "Executing SurrealDB query");
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(); let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string()) if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
@@ -130,6 +146,8 @@ impl<'a> EventsRepository<'a> {
} }
let record_key = get_id(&event.id)?; let record_key = get_id(&event.id)?;
let query_str = format!("UPDATE {:?} MERGE {{ is_deleted: true }}", record_key);
info!(query = %query_str, "Executing SurrealDB query");
let record: Option<EventsSchema> = db let record: Option<EventsSchema> = db
.update(record_key) .update(record_key)
.merge(serde_json::json!({ "is_deleted": true })) .merge(serde_json::json!({ "is_deleted": true }))
@@ -7,6 +7,7 @@ use imphnen_utils::{DetailQueryBuilder, ListQueryBuilder, get_id, get_iso_date};
use serde_json; use serde_json;
use std::time::Instant; use std::time::Instant;
use tracing::instrument; use tracing::instrument;
use tracing::info;
pub struct TestimonialsRepository<'a> { pub struct TestimonialsRepository<'a> {
state: &'a AppState, state: &'a AppState,
@@ -28,6 +29,7 @@ impl<'a> TestimonialsRepository<'a> {
.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();
info!(query = %query, "Executing SurrealDB query");
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(); let elapsed = now.elapsed();
@@ -55,6 +57,7 @@ impl<'a> TestimonialsRepository<'a> {
.with_condition("is_deleted = false") .with_condition("is_deleted = false")
.with_select_fields(vec!["*", "user.* as user"]); .with_select_fields(vec!["*", "user.* as user"]);
let sql = builder.build(); let sql = builder.build();
info!(query = %sql, "Executing SurrealDB query");
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(); let elapsed = now.elapsed();
@@ -79,9 +82,14 @@ impl<'a> TestimonialsRepository<'a> {
pub async fn query_create_testimonial( pub async fn query_create_testimonial(
&self, &self,
data: TestimonialsSchema, data: TestimonialsSchema,
) -> Result<String> { ) -> Result<TestimonialsSchema> { // Change return type from String to TestimonialsSchema
let now = Instant::now(); let now = Instant::now();
let db = &self.state.surrealdb_ws; let db = &self.state.surrealdb_ws;
info!(
resource = %ResourceEnum::Testimonials.to_string(),
payload = ?data,
"Executing SurrealDB create"
);
let record: Option<TestimonialsSchema> = db let record: Option<TestimonialsSchema> = db
.create(ResourceEnum::Testimonials.to_string()) .create(ResourceEnum::Testimonials.to_string())
.content(data) .content(data)
@@ -94,7 +102,7 @@ impl<'a> TestimonialsRepository<'a> {
} }
match record { match record {
Some(_) => Ok("Success create testimonial".into()), Some(created_testimonial) => Ok(created_testimonial), // Return the created testimonial
None => bail!("Failed to create testimonial"), None => bail!("Failed to create testimonial"),
} }
} }
@@ -120,6 +128,11 @@ impl<'a> TestimonialsRepository<'a> {
}; };
let record_key = get_id(&merged.id)?; let record_key = get_id(&merged.id)?;
info!(
record_key = ?record_key,
payload = ?merged,
"Executing SurrealDB update"
);
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(); let elapsed = now.elapsed();
@@ -145,6 +158,10 @@ impl<'a> TestimonialsRepository<'a> {
} }
let record_key = get_id(&testimonial.id)?; let record_key = get_id(&testimonial.id)?;
info!(
record_key = ?record_key,
"Executing SurrealDB soft delete"
);
let record: Option<TestimonialsSchema> = db let record: Option<TestimonialsSchema> = db
.update(record_key) .update(record_key)
.merge(serde_json::json!({ "is_deleted": true })) .merge(serde_json::json!({ "is_deleted": true }))
@@ -11,7 +11,7 @@ use imphnen_libs::{
AppState, MetaRequestDto, ResponseListSuccessDto, ResponseSuccessDto, AppState, MetaRequestDto, ResponseListSuccessDto, ResponseSuccessDto,
}; };
use imphnen_utils::{ use imphnen_utils::{
common_response, success_list_response, success_response, validate_request, common_response, success_list_response, success_response, success_created_response, validate_request,
}; };
pub struct TestimonialsService; pub struct TestimonialsService;
@@ -46,8 +46,8 @@ impl TestimonialsService {
Ok(testimonial) if !testimonial.is_deleted => { Ok(testimonial) if !testimonial.is_deleted => {
success_response(ResponseSuccessDto { success_response(ResponseSuccessDto {
data: TestimonialsDetailItemDto { data: TestimonialsDetailItemDto {
id: testimonial.id.id.to_raw(), id: testimonial.id.to_raw(),
user_id: testimonial.user.id.id.to_raw(), user_id: testimonial.user.id.to_raw(),
user_fullname: testimonial.user.fullname, user_fullname: testimonial.user.fullname,
role: testimonial.role, role: testimonial.role,
content: testimonial.content, content: testimonial.content,
@@ -72,7 +72,19 @@ impl TestimonialsService {
let repo = TestimonialsRepository::new(state); let repo = TestimonialsRepository::new(state);
let schema = TestimonialsSchema::create(payload, &authenticated_user.id); let schema = TestimonialsSchema::create(payload, &authenticated_user.id);
match repo.query_create_testimonial(schema).await { match repo.query_create_testimonial(schema).await {
Ok(msg) => common_response(StatusCode::CREATED, &msg), Ok(created_testimonial) => {
success_created_response(ResponseSuccessDto {
data: TestimonialsDetailItemDto {
id: created_testimonial.id.to_raw(),
user_id: created_testimonial.user.id.to_raw(),
user_fullname: authenticated_user.fullname.clone(),
role: created_testimonial.role,
content: created_testimonial.content,
created_at: created_testimonial.created_at,
updated_at: created_testimonial.updated_at,
},
})
}
Err(e) => common_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()), Err(e) => common_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
} }
} }
@@ -57,13 +57,13 @@ pub async fn get_mentor_list(
Query(meta): Query<MetaRequestDto>, Query(meta): Query<MetaRequestDto>,
) -> Response { ) -> Response {
match permissions_guard( match permissions_guard(
&headers, headers,
app_state.clone(), Extension(app_state),
vec![PermissionsEnum::ReadListMentors], vec![PermissionsEnum::ReadListMentors],
) )
.await .await
{ {
Ok(_) => MentorsService::get_mentor_list(&app_state, meta).await, Ok((_user, app_state)) => MentorsService::get_mentor_list(&app_state, meta).await,
Err(response) => response, Err(response) => response,
} }
} }
@@ -90,13 +90,13 @@ pub async fn get_mentor_by_id(
Path(id): Path<String>, Path(id): Path<String>,
) -> Response { ) -> Response {
match permissions_guard( match permissions_guard(
&headers, headers,
app_state.clone(), Extension(app_state),
vec![PermissionsEnum::ReadDetailMentors], vec![PermissionsEnum::ReadDetailMentors],
) )
.await .await
{ {
Ok(_) => MentorsService::get_mentor_by_id(&app_state, &id).await, Ok((_user, app_state)) => MentorsService::get_mentor_by_id(&app_state, &id).await,
Err(response) => response, Err(response) => response,
} }
} }
@@ -126,13 +126,13 @@ pub async fn put_update_mentor(
Json(dto): Json<MentorUpdateRequestDto>, Json(dto): Json<MentorUpdateRequestDto>,
) -> Response { ) -> Response {
match permissions_guard( match permissions_guard(
&headers, headers,
app_state.clone(), Extension(app_state),
vec![PermissionsEnum::UpdateMentors], vec![PermissionsEnum::UpdateMentors],
) )
.await .await
{ {
Ok(_) => MentorsService::update_mentor(&app_state, &id, dto).await, Ok((_user, app_state)) => MentorsService::update_mentor(&app_state, &id, dto).await,
Err(response) => response, Err(response) => response,
} }
} }
@@ -159,13 +159,13 @@ pub async fn delete_mentor(
Path(id): Path<String>, Path(id): Path<String>,
) -> Response { ) -> Response {
match permissions_guard( match permissions_guard(
&headers, headers,
app_state.clone(), Extension(app_state),
vec![PermissionsEnum::DeleteMentors], vec![PermissionsEnum::DeleteMentors],
) )
.await .await
{ {
Ok(_) => MentorsService::delete_mentor(&app_state, &id).await, Ok((_user, app_state)) => MentorsService::delete_mentor(&app_state, &id).await,
Err(response) => response, Err(response) => response,
} }
} }
@@ -195,13 +195,13 @@ pub async fn put_verify_mentor(
Json(dto): Json<MentorVerifyRequestDto>, Json(dto): Json<MentorVerifyRequestDto>,
) -> Response { ) -> Response {
match permissions_guard( match permissions_guard(
&headers, headers,
app_state.clone(), Extension(app_state),
vec![PermissionsEnum::VerifyMentors], vec![PermissionsEnum::VerifyMentors],
) )
.await .await
{ {
Ok(_) => MentorsService::verify_mentor(&app_state, &id, dto).await, Ok((_user, app_state)) => MentorsService::verify_mentor(&app_state, &id, dto).await,
Err(response) => response, Err(response) => response,
} }
} }
@@ -221,17 +221,17 @@ pub async fn put_verify_mentor(
) )
)] )]
pub async fn get_mentor_me( pub async fn get_mentor_me(
Extension(app_state): Extension<AppState>,
headers: HeaderMap, headers: HeaderMap,
Extension(app_state): Extension<AppState>,
) -> Response { ) -> Response {
match permissions_guard( match permissions_guard(
&headers, headers.clone(),
app_state.clone(), Extension(app_state),
vec![PermissionsEnum::ReadOwnMentorProfile], vec![PermissionsEnum::ReadOwnMentorProfile],
) )
.await .await
{ {
Ok(_) => { Ok((_user, app_state)) => {
let email = match extract_email(&headers) { let email = match extract_email(&headers) {
Some(email) => email, Some(email) => email,
None => { None => {
@@ -268,18 +268,18 @@ pub async fn get_mentor_me(
) )
)] )]
pub async fn put_update_mentor_me( pub async fn put_update_mentor_me(
Extension(app_state): Extension<AppState>,
headers: HeaderMap, headers: HeaderMap,
Extension(app_state): Extension<AppState>,
Json(dto): Json<MentorUpdateRequestDto>, Json(dto): Json<MentorUpdateRequestDto>,
) -> Response { ) -> Response {
match permissions_guard( match permissions_guard(
&headers, headers.clone(),
app_state.clone(), Extension(app_state),
vec![PermissionsEnum::UpdateOwnMentorProfile], vec![PermissionsEnum::UpdateOwnMentorProfile],
) )
.await .await
{ {
Ok(_) => { Ok((_user, app_state)) => {
let email = match extract_email(&headers) { let email = match extract_email(&headers) {
Some(email) => email, Some(email) => email,
None => { None => {
@@ -325,17 +325,17 @@ pub async fn put_update_mentor_no_id() -> Response {
) )
)] )]
pub async fn get_mentor_status( pub async fn get_mentor_status(
Extension(app_state): Extension<AppState>,
headers: HeaderMap, headers: HeaderMap,
Extension(app_state): Extension<AppState>,
) -> Response { ) -> Response {
match permissions_guard( match permissions_guard(
&headers, headers.clone(),
app_state.clone(), Extension(app_state),
vec![PermissionsEnum::ReadOwnMentorStatus], vec![PermissionsEnum::ReadOwnMentorStatus],
) )
.await .await
{ {
Ok(_) => { Ok((_user, app_state)) => {
let email = match extract_email(&headers) { let email = match extract_email(&headers) {
Some(email) => email, Some(email) => email,
None => { None => {
@@ -18,15 +18,9 @@ pub struct MentorListResponseDto {
pub struct MentorDetailWithUserDto { pub struct MentorDetailWithUserDto {
pub id: Thing, pub id: Thing,
pub user_id: Thing, pub user_id: Thing,
pub fullname: Option<String>, // Personal data is now in UsersSchema, access via user_id
pub email: Option<String>, // Removed: fullname, email, legal_name, identity_document_url,
pub legal_name: String, // phone_for_verification, bio, linkedin_url, github_url, cv_url
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 industries: Vec<String>,
pub expertise: Vec<String>, pub expertise: Vec<String>,
pub languages: Vec<String>, pub languages: Vec<String>,
@@ -47,15 +41,20 @@ pub struct MentorDetailWithUserDto {
pub struct MentorDetailResponseDto { pub struct MentorDetailResponseDto {
pub id: String, pub id: String,
pub user_id: String, pub user_id: String,
// Personal data fields from UsersSchema
pub fullname: Option<String>, pub fullname: Option<String>,
pub email: Option<String>, pub email: Option<String>,
pub legal_name: String, pub legal_name: Option<String>,
pub identity_document_url: String, pub gender: Option<String>,
pub phone_for_verification: String, pub domicile: Option<String>,
pub bio: String, pub phone_for_verification: Option<String>,
pub bio: Option<String>,
pub last_education: Option<String>,
pub linkedin_url: Option<String>, pub linkedin_url: Option<String>,
pub github_url: Option<String>, pub github_url: Option<String>,
pub cv_url: Option<String>, pub cv_url: Option<String>,
pub portfolio_url: Option<String>,
// Professional data from MentorSchema
pub industries: Vec<String>, pub industries: Vec<String>,
pub expertise: Vec<String>, pub expertise: Vec<String>,
pub languages: Vec<String>, pub languages: Vec<String>,
@@ -94,9 +93,6 @@ pub struct MentorUpdateRequestDto {
pub gender: Option<String>, pub gender: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub domicile: Option<String>, 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( #[validate(length(
min = 10, min = 10,
max = 15, max = 15,
@@ -118,6 +114,7 @@ pub struct MentorUpdateRequestDto {
#[validate(url(message = "Invalid CV URL"))] #[validate(url(message = "Invalid CV URL"))]
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub cv_url: Option<String>, pub cv_url: Option<String>,
#[validate(url(message = "Invalid portfolio URL"))]
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub portfolio_url: Option<String>, pub portfolio_url: Option<String>,
#[validate(length(min = 1, message = "At least 1 industry required"))] #[validate(length(min = 1, message = "At least 1 industry required"))]
@@ -279,18 +276,7 @@ pub struct MentoringRate {
pub struct MentorInsertDto { pub struct MentorInsertDto {
pub id: Thing, pub id: Thing,
pub user_id: Option<Thing>, pub user_id: Option<Thing>,
pub email: Option<String>, // Personal data removed - now stored in UsersSchema
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 industries: Vec<String>,
pub expertise: Vec<String>, pub expertise: Vec<String>,
pub languages: Vec<String>, pub languages: Vec<String>,
@@ -313,18 +299,7 @@ impl From<MentorSchema> for MentorInsertDto {
MentorInsertDto { MentorInsertDto {
id: schema.id, id: schema.id,
user_id: schema.user_id, user_id: schema.user_id,
email: schema.email, // Personal data removed from MentorSchema
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, industries: schema.industries,
expertise: schema.expertise, expertise: schema.expertise,
languages: schema.languages, languages: schema.languages,
@@ -354,19 +329,10 @@ pub struct MentorVerifyRequestDto {
pub struct MentorDetailQueryDto { pub struct MentorDetailQueryDto {
pub id: Thing, pub id: Thing,
pub user_id: Thing, pub user_id: Thing,
pub fullname: Option<String>, // Personal data has been moved to UsersSchema
pub email: Option<String>, // Use user_id to get: fullname, email, legal_name, gender, domicile,
pub legal_name: String, // identity_document_url, phone_for_verification, bio, last_education,
pub gender: Option<String>, // linkedin_url, github_url, cv_url, portfolio_url
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 industries: Vec<String>,
pub expertise: Vec<String>, pub expertise: Vec<String>,
pub languages: Vec<String>, pub languages: Vec<String>,
@@ -388,8 +354,8 @@ impl From<MentorDetailQueryDto> for MentorListResponseDto {
fn from(dto: MentorDetailQueryDto) -> Self { fn from(dto: MentorDetailQueryDto) -> Self {
Self { Self {
id: extract_id(&dto.id), id: extract_id(&dto.id),
fullname: dto.fullname, fullname: None, // now in user table, must be populated from service layer
email: dto.email, email: None, // now in user table, must be populated from service layer
status: dto.status, status: dto.status,
created_at: dto.created_at, created_at: dto.created_at,
updated_at: dto.updated_at, updated_at: dto.updated_at,
@@ -402,15 +368,20 @@ impl From<MentorDetailQueryDto> for MentorDetailResponseDto {
Self { Self {
id: extract_id(&dto.id), id: extract_id(&dto.id),
user_id: extract_id(&dto.user_id), user_id: extract_id(&dto.user_id),
fullname: dto.fullname, // Personal data fields are populated in service layer from UsersSchema
email: dto.email, fullname: None, // populated from user table in service layer
legal_name: dto.legal_name, email: None, // populated from user table in service layer
identity_document_url: dto.identity_document_url, legal_name: None, // populated from user table in service layer
phone_for_verification: dto.phone_for_verification, gender: None, // populated from user table in service layer
bio: dto.bio, domicile: None, // populated from user table in service layer
linkedin_url: dto.linkedin_url, phone_for_verification: None, // populated from user table in service layer
github_url: dto.github_url, bio: None, // populated from user table in service layer
cv_url: dto.cv_url, last_education: None, // populated from user table in service layer
linkedin_url: None, // populated from user table in service layer
github_url: None, // populated from user table in service layer
cv_url: None, // populated from user table in service layer
portfolio_url: None, // populated from user table in service layer
// Professional data from mentor
industries: dto.industries, industries: dto.industries,
expertise: dto.expertise, expertise: dto.expertise,
languages: dto.languages, languages: dto.languages,
@@ -433,7 +404,7 @@ impl From<MentorSchema> for MentorRegisterResponseDto {
Self { Self {
id: schema.id.to_string(), id: schema.id.to_string(),
user_id: schema.user_id.map(|id| extract_id(&id)).unwrap_or_default(), user_id: schema.user_id.map(|id| extract_id(&id)).unwrap_or_default(),
email: schema.email, email: None, // schema.email - now in user table
status: schema.status, status: schema.status,
created_at: schema.created_at, created_at: schema.created_at,
updated_at: schema.updated_at, updated_at: schema.updated_at,
@@ -446,19 +417,7 @@ impl From<MentorDetailWithUserDto> for MentorDetailQueryDto {
MentorDetailQueryDto { MentorDetailQueryDto {
id: dto.id, id: dto.id,
user_id: dto.user_id, user_id: dto.user_id,
fullname: dto.fullname, // Personal data removed from MentorDetailWithUserDto - now in UsersSchema
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, industries: dto.industries,
expertise: dto.expertise, expertise: dto.expertise,
languages: dto.languages, languages: dto.languages,
@@ -8,6 +8,7 @@ use imphnen_utils::{DetailQueryBuilder, QueryListBuilder, get_iso_date};
use serde_json::{Map, Value}; use serde_json::{Map, Value};
use std::time::Instant; use std::time::Instant;
use tracing::instrument; use tracing::instrument;
use tracing::info;
pub struct MentorsRepository<'a> { pub struct MentorsRepository<'a> {
pub state: &'a AppState, pub state: &'a AppState,
@@ -27,19 +28,11 @@ impl<'a> MentorsRepository<'a> {
let db = &self.state.surrealdb_ws; let db = &self.state.surrealdb_ws;
let mentors_table = ResourceEnum::Mentors.to_string(); let mentors_table = ResourceEnum::Mentors.to_string();
let builder = QueryListBuilder::new(db, &mentors_table, &meta) let builder = QueryListBuilder::new(db, &mentors_table, &meta)
.search_field("legal_name") .search_field("user_id.legal_name") // Search in user data instead
.select_fields(vec![ .select_fields(vec![
"id", "id",
"user_id", "user_id",
"user_id.fullname as fullname", // Personal data comes from user relation, not mentor table
"email",
"legal_name",
"identity_document_url",
"phone_for_verification",
"bio",
"linkedin_url",
"github_url",
"cv_url",
"industries", "industries",
"expertise", "expertise",
"languages", "languages",
@@ -79,19 +72,11 @@ impl<'a> MentorsRepository<'a> {
let now = Instant::now(); let now = Instant::now();
let db = &self.state.surrealdb_ws; let db = &self.state.surrealdb_ws;
let mut builder = DetailQueryBuilder::new(ResourceEnum::Mentors.to_string()) let mut builder = DetailQueryBuilder::new(ResourceEnum::Mentors.to_string())
.with_where("email", Some(email.clone())) .with_where("user_id.email", Some(email.clone())) // Search in user table
.with_select_fields(vec![ .with_select_fields(vec![
"id", "id",
"user_id", "user_id",
"user_id.fullname as fullname", // Personal data comes from user relation, not mentor table
"email",
"legal_name",
"identity_document_url",
"phone_for_verification",
"bio",
"linkedin_url",
"github_url",
"cv_url",
"industries", "industries",
"expertise", "expertise",
"languages", "languages",
@@ -114,6 +99,7 @@ impl<'a> MentorsRepository<'a> {
} }
let sql = builder.build(); let sql = builder.build();
info!(query = %sql, "Executing SurrealDB query in query_mentor_by_email");
let mentor_opt: Option<MentorDetailWithUserDto> = let mentor_opt: Option<MentorDetailWithUserDto> =
builder.apply_bindings(db.query(sql)).await?.take(0)?; builder.apply_bindings(db.query(sql)).await?.take(0)?;
let elapsed = now.elapsed(); let elapsed = now.elapsed();
@@ -141,15 +127,7 @@ impl<'a> MentorsRepository<'a> {
.with_select_fields(vec![ .with_select_fields(vec![
"id", "id",
"user_id", "user_id",
"user_id.fullname as fullname", // Personal data comes from user relation, not mentor table
"email",
"legal_name",
"identity_document_url",
"phone_for_verification",
"bio",
"linkedin_url",
"github_url",
"cv_url",
"industries", "industries",
"expertise", "expertise",
"languages", "languages",
@@ -172,6 +150,7 @@ impl<'a> MentorsRepository<'a> {
} }
let sql = builder.build(); let sql = builder.build();
info!(query = %sql, "Executing SurrealDB query in query_mentor_by_id");
let mentor_opt: Option<MentorDetailWithUserDto> = let mentor_opt: Option<MentorDetailWithUserDto> =
builder.apply_bindings(db.query(sql)).await?.take(0)?; builder.apply_bindings(db.query(sql)).await?.take(0)?;
let elapsed = now.elapsed(); let elapsed = now.elapsed();
@@ -194,8 +173,10 @@ impl<'a> MentorsRepository<'a> {
let now = Instant::now(); let now = Instant::now();
let db = &self.state.surrealdb_ws; let db = &self.state.surrealdb_ws;
let dto: MentorInsertDto = data.into(); let dto: MentorInsertDto = data.into();
let resource = ResourceEnum::Mentors.to_string();
info!(query = %resource, "Executing SurrealDB create in query_create_mentor");
let record: Option<MentorSchema> = db let record: Option<MentorSchema> = db
.create(ResourceEnum::Mentors.to_string()) .create(resource)
.content(dto.clone()) .content(dto.clone())
.await?; .await?;
let elapsed = now.elapsed(); let elapsed = now.elapsed();
@@ -237,6 +218,7 @@ impl<'a> MentorsRepository<'a> {
merged_data_json.insert("updated_at".to_string(), Value::String(get_iso_date())); merged_data_json.insert("updated_at".to_string(), Value::String(get_iso_date()));
info!(query = ?record_key, "Executing SurrealDB update in query_update_mentor");
let record: Option<MentorSchema> = let record: Option<MentorSchema> =
db.update(record_key).merge(merged_data_json).await?; db.update(record_key).merge(merged_data_json).await?;
let elapsed = now.elapsed(); let elapsed = now.elapsed();
@@ -282,6 +264,7 @@ impl<'a> MentorsRepository<'a> {
patch.insert("is_deleted".to_string(), Value::Bool(true)); patch.insert("is_deleted".to_string(), Value::Bool(true));
patch.insert("updated_at".to_string(), Value::String(get_iso_date())); patch.insert("updated_at".to_string(), Value::String(get_iso_date()));
info!(query = ?record_key, "Executing SurrealDB soft delete in query_delete_mentor");
let record: Option<MentorSchema> = db.update(record_key).merge(patch).await?; let record: Option<MentorSchema> = db.update(record_key).merge(patch).await?;
let elapsed = now.elapsed(); let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string()) if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
@@ -1,5 +1,5 @@
use super::{ use super::{
IdentityAndVerification, MentorDetailQueryDto, MentorUpdateRequestDto, MentorDetailQueryDto, MentorUpdateRequestDto,
MentoringLogistics, MentoringRate, ProfessionalProfile, MentoringLogistics, MentoringRate, ProfessionalProfile,
}; };
use imphnen_libs::ResourceEnum; use imphnen_libs::ResourceEnum;
@@ -12,18 +12,9 @@ pub struct MentorSchema {
pub id: Thing, pub id: Thing,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub user_id: Option<Thing>, pub user_id: Option<Thing>,
pub email: Option<String>, // Personal data has been moved to UsersSchema - use user_id to reference
pub legal_name: String, // phone_for_verification, bio, last_education, linkedin_url, github_url,
pub gender: Option<String>, // cv_url, portfolio_url
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 industries: Vec<String>,
pub expertise: Vec<String>, pub expertise: Vec<String>,
pub languages: Vec<String>, pub languages: Vec<String>,
@@ -52,18 +43,6 @@ impl Default for MentorSchema {
ResourceEnum::Users.to_string().as_str(), ResourceEnum::Users.to_string().as_str(),
&Uuid::new_v4().to_string(), &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(), industries: Vec::new(),
expertise: Vec::new(), expertise: Vec::new(),
languages: Vec::new(), languages: Vec::new(),
@@ -89,11 +68,9 @@ impl Default for MentorSchema {
impl MentorSchema { impl MentorSchema {
pub fn create( pub fn create(
identity_and_verification: IdentityAndVerification,
professional_profile: ProfessionalProfile, professional_profile: ProfessionalProfile,
mentoring_logistics: MentoringLogistics, mentoring_logistics: MentoringLogistics,
user_id_raw: String, user_id_raw: String,
email_str: String,
) -> Self { ) -> Self {
Self { Self {
id: make_thing( id: make_thing(
@@ -101,18 +78,7 @@ impl MentorSchema {
&Uuid::new_v4().to_string(), &Uuid::new_v4().to_string(),
), ),
user_id: Some(make_thing(&ResourceEnum::Users.to_string(), &user_id_raw)), user_id: Some(make_thing(&ResourceEnum::Users.to_string(), &user_id_raw)),
email: Some(email_str), // Personal data now stored in UsersSchema, not here
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, industries: professional_profile.industries,
expertise: professional_profile.expertise, expertise: professional_profile.expertise,
languages: professional_profile.languages, languages: professional_profile.languages,
@@ -139,18 +105,7 @@ impl MentorSchema {
Self { Self {
id: dto.id, id: dto.id,
user_id: Some(dto.user_id), user_id: Some(dto.user_id),
email: dto.email, // Personal data now comes from UsersSchema via user_id
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, industries: dto.industries,
expertise: dto.expertise, expertise: dto.expertise,
languages: dto.languages, languages: dto.languages,
@@ -170,40 +125,10 @@ impl MentorSchema {
} }
pub fn update(mut self, dto: MentorUpdateRequestDto) -> Self { pub fn update(mut self, dto: MentorUpdateRequestDto) -> Self {
// Update fields only if they are Some(value), otherwise preserve current value // phone_for_verification, bio, last_education, linkedin_url, github_url,
if let Some(val) = dto.legal_name { // cv_url, portfolio_url) are now updated in UsersSchema, not here
self.legal_name = val;
} // Only update professional fields that are still in MentorSchema
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 { if let Some(val) = dto.industries {
self.industries = val; self.industries = val;
} }
@@ -60,6 +60,18 @@ impl MentorsService {
user_schema.fullname = dto.fullname.clone(); user_schema.fullname = dto.fullname.clone();
user_schema.phone_number = dto.phone_number.clone(); user_schema.phone_number = dto.phone_number.clone();
// Update personal data from identity_and_verification
user_schema.legal_name = Some(dto.identity_and_verification.legal_name.clone());
user_schema.gender = dto.identity_and_verification.gender.clone();
user_schema.domicile = dto.identity_and_verification.domicile.clone();
user_schema.phone_for_verification = Some(dto.identity_and_verification.phone_for_verification.clone());
// Update personal data from professional_profile
user_schema.bio = Some(dto.professional_profile.bio.clone());
user_schema.last_education = dto.professional_profile.last_education.clone();
user_schema.linkedin_url = dto.professional_profile.linkedin_url.clone();
user_schema.github_url = dto.professional_profile.github_url.clone();
user_schema.cv_url = dto.professional_profile.cv_url.clone();
user_schema.portfolio_url = dto.professional_profile.portfolio_url.clone();
user_schema.updated_at = imphnen_utils::get_iso_date(); user_schema.updated_at = imphnen_utils::get_iso_date();
let hashed_password = match imphnen_utils::hash_password(&dto.password) { let hashed_password = match imphnen_utils::hash_password(&dto.password) {
@@ -135,6 +147,18 @@ impl MentorsService {
fullname: dto.fullname.clone(), fullname: dto.fullname.clone(),
password: hashed_password, password: hashed_password,
phone_number: dto.phone_number.clone(), phone_number: dto.phone_number.clone(),
// Store personal data from identity_and_verification in user
legal_name: Some(dto.identity_and_verification.legal_name.clone()),
gender: dto.identity_and_verification.gender.clone(),
domicile: dto.identity_and_verification.domicile.clone(),
phone_for_verification: Some(dto.identity_and_verification.phone_for_verification.clone()),
// Store personal data from professional_profile in user
bio: Some(dto.professional_profile.bio.clone()),
last_education: dto.professional_profile.last_education.clone(),
linkedin_url: dto.professional_profile.linkedin_url.clone(),
github_url: dto.professional_profile.github_url.clone(),
cv_url: dto.professional_profile.cv_url.clone(),
portfolio_url: dto.professional_profile.portfolio_url.clone(),
created_at: imphnen_utils::get_iso_date(), created_at: imphnen_utils::get_iso_date(),
updated_at: imphnen_utils::get_iso_date(), updated_at: imphnen_utils::get_iso_date(),
role: imphnen_utils::make_thing( role: imphnen_utils::make_thing(
@@ -187,11 +211,9 @@ impl MentorsService {
} }
let mentor_schema = MentorSchema::create( let mentor_schema = MentorSchema::create(
dto.identity_and_verification,
dto.professional_profile, dto.professional_profile,
dto.mentoring_logistics, dto.mentoring_logistics,
user_id.to_raw(), user_id.to_raw(),
final_user_email.clone(),
); );
match mentor_repo.query_create_mentor(mentor_schema.clone()).await { match mentor_repo.query_create_mentor(mentor_schema.clone()).await {
@@ -233,16 +255,27 @@ impl MentorsService {
pub async fn get_mentor_list(state: &AppState, meta: MetaRequestDto) -> Response { pub async fn get_mentor_list(state: &AppState, meta: MetaRequestDto) -> Response {
let repo = MentorsRepository::new(state); let repo = MentorsRepository::new(state);
let user_repo = UsersRepository::new(state);
match repo.query_mentor_list(meta).await { match repo.query_mentor_list(meta).await {
Ok(result) => { Ok(result) => {
let data: Vec<MentorListResponseDto> = result let mut mentor_list_data: Vec<MentorListResponseDto> = Vec::new();
.data
.into_iter() for mentor_with_user in result.data {
.map(MentorDetailQueryDto::from) let mentor_dto = MentorDetailQueryDto::from(mentor_with_user);
.map(MentorListResponseDto::from) let mut list_item = MentorListResponseDto::from(mentor_dto.clone());
.collect();
// Get user data to populate personal fields
if let Ok(user) = user_repo.query_user_by_id(&mentor_dto.user_id).await {
list_item.fullname = Some(user.fullname);
list_item.email = Some(user.email);
}
mentor_list_data.push(list_item);
}
success_list_response(ResponseListSuccessDto { success_list_response(ResponseListSuccessDto {
data, data: mentor_list_data,
meta: result.meta, meta: result.meta,
}) })
} }
@@ -251,12 +284,56 @@ impl MentorsService {
} }
pub async fn get_mentor_by_id(state: &AppState, id: &str) -> Response { pub async fn get_mentor_by_id(state: &AppState, id: &str) -> Response {
let repo = MentorsRepository::new(state); let mentor_repo = MentorsRepository::new(state);
let user_repo = UsersRepository::new(state);
let thing_id = Thing::from((ResourceEnum::Mentors.to_string().as_str(), id)); let thing_id = Thing::from((ResourceEnum::Mentors.to_string().as_str(), id));
match repo.query_mentor_by_id(&thing_id, false).await {
match mentor_repo.query_mentor_by_id(&thing_id, false).await {
Ok(mentor) => { Ok(mentor) => {
let dto = MentorDetailResponseDto::from(MentorDetailQueryDto::from(mentor)); // Get user data separately
success_response(ResponseSuccessDto { data: dto }) let user_result = user_repo.query_user_by_id(&mentor.user_id).await;
match user_result {
Ok(user) => {
// Combine mentor and user data
let dto = MentorDetailResponseDto {
id: mentor.id.to_raw(),
user_id: mentor.user_id.to_raw(),
// Personal data from user
fullname: Some(user.fullname),
email: Some(user.email),
legal_name: user.legal_name,
gender: user.gender,
domicile: user.domicile,
phone_for_verification: user.phone_for_verification,
bio: user.bio,
last_education: user.last_education,
linkedin_url: user.linkedin_url,
github_url: user.github_url,
cv_url: user.cv_url,
portfolio_url: user.portfolio_url,
// Professional data from mentor
industries: mentor.industries,
expertise: mentor.expertise,
languages: mentor.languages,
current_company: mentor.current_company,
current_role: mentor.current_role,
years_of_experience: mentor.years_of_experience,
topics_of_interest: mentor.topics_of_interest,
preferred_mentee_level: mentor.preferred_mentee_level,
preferred_mentoring_formats: mentor.preferred_mentoring_formats,
availability_commitment: mentor.availability_commitment,
mentoring_rate: mentor.mentoring_rate,
status: mentor.status,
created_at: mentor.created_at,
updated_at: mentor.updated_at,
};
success_response(ResponseSuccessDto { data: dto })
}
Err(_e) => {
error!("Failed to get user data for mentor {}: {}", id, _e);
common_response(StatusCode::INTERNAL_SERVER_ERROR, "Failed to get mentor user data")
}
}
} }
Err(_e) => common_response(StatusCode::NOT_FOUND, &_e.to_string()), Err(_e) => common_response(StatusCode::NOT_FOUND, &_e.to_string()),
} }
+1 -1
View File
@@ -26,5 +26,5 @@ pub fn mentors_router() -> Router {
.route("/update/{id}", put(put_update_mentor)) .route("/update/{id}", put(put_update_mentor))
.route("/update", put(put_update_mentor_no_id)) .route("/update", put(put_update_mentor_no_id))
.route("/delete/{id}", delete(delete_mentor)) .route("/delete/{id}", delete(delete_mentor))
.route("/verify/{id}", put(put(put_verify_mentor))) .route("/verify/{id}", put(put_verify_mentor))
} }
+1
View File
@@ -8,4 +8,5 @@ axum.workspace = true
serde.workspace = true serde.workspace = true
utoipa.workspace = true utoipa.workspace = true
surrealdb.workspace = true surrealdb.workspace = true
anyhow.workspace = true
thiserror.workspace = true thiserror.workspace = true
+27
View File
@@ -9,6 +9,14 @@ pub mod error {
pub enum Error { pub enum Error {
#[error("database error: {0}")] #[error("database error: {0}")]
Db(String), Db(String),
#[error("anyhow error: {0}")]
Anyhow(#[from] anyhow::Error),
#[error("HTTP status code error: {0}")]
StatusCode(StatusCode),
#[error("authentication error: {0}")]
Auth(String),
#[error("validation error: {0}")]
Validation(String),
} }
impl IntoResponse for Error { impl IntoResponse for Error {
@@ -18,6 +26,19 @@ pub mod error {
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
format!("Database error: {detail}"), format!("Database error: {detail}"),
), ),
Error::Anyhow(detail) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Internal server error: {detail}"),
),
Error::StatusCode(s) => (s, format!("HTTP error: {}", s)),
Error::Auth(detail) => (
StatusCode::UNAUTHORIZED,
format!("Authentication error: {detail}"),
),
Error::Validation(detail) => (
StatusCode::BAD_REQUEST,
format!("Validation error: {detail}"),
),
}; };
(status, Json(error_message)).into_response() (status, Json(error_message)).into_response()
} }
@@ -28,4 +49,10 @@ pub mod error {
Self::Db(error.to_string()) Self::Db(error.to_string())
} }
} }
impl From<StatusCode> for Error {
fn from(status: StatusCode) -> Self {
Self::StatusCode(status)
}
}
} }
@@ -25,13 +25,13 @@ pub async fn get_detail_gacha_claim(
Path(id): Path<String>, Path(id): Path<String>,
) -> impl IntoResponse { ) -> impl IntoResponse {
match permissions_guard( match permissions_guard(
&headers, headers,
state.clone(), Extension(state),
vec![PermissionsEnum::CreateGachaClaims], vec![PermissionsEnum::ReadDetailGachaClaims],
) )
.await .await
{ {
Ok(_) => GachaClaimService::get_gacha_claim_by_id(&state, id).await, Ok((_user, state)) => GachaClaimService::get_gacha_claim_by_id(&state, id).await,
Err(response) => response, Err(response) => response,
} }
} }
@@ -54,13 +54,13 @@ pub async fn post_create_gacha_claim(
Json(payload): Json<GachaClaimRequestDto>, Json(payload): Json<GachaClaimRequestDto>,
) -> impl IntoResponse { ) -> impl IntoResponse {
match permissions_guard( match permissions_guard(
&headers, headers,
state.clone(), Extension(state),
vec![PermissionsEnum::CreateGachaClaims], vec![PermissionsEnum::CreateGachaClaims],
) )
.await .await
{ {
Ok(_) => GachaClaimService::create_gacha_claim(&state, payload).await, Ok((_user, state)) => GachaClaimService::create_gacha_claim(&state, payload).await,
Err(response) => response, Err(response) => response,
} }
} }
@@ -3,7 +3,7 @@ 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 std::time::Instant;
use tracing::instrument; use tracing::{instrument, info};
pub struct GachaClaimRepository<'a> { pub struct GachaClaimRepository<'a> {
state: &'a AppState, state: &'a AppState,
@@ -27,6 +27,7 @@ impl<'a> GachaClaimRepository<'a> {
.with_fetch("item") .with_fetch("item")
.with_fetch("user"); .with_fetch("user");
let sql = builder.build(); let sql = builder.build();
info!(query = %sql, "Executing SurrealDB query");
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(); let elapsed = now.elapsed();
@@ -48,6 +49,11 @@ impl<'a> GachaClaimRepository<'a> {
) -> Result<String> { ) -> Result<String> {
let now = Instant::now(); let now = Instant::now();
let db = &self.state.surrealdb_ws; let db = &self.state.surrealdb_ws;
info!(
resource = %ResourceEnum::GachaClaims.to_string(),
content = ?data,
"Executing SurrealDB create query"
);
let record: Option<GachaClaimSchema> = db let record: Option<GachaClaimSchema> = db
.create(ResourceEnum::GachaClaims.to_string()) .create(ResourceEnum::GachaClaims.to_string())
.content(data) .content(data)
@@ -4,7 +4,7 @@ use anyhow::{Result, bail};
use imphnen_iam::make_thing; use imphnen_iam::make_thing;
use std::time::Instant; use std::time::Instant;
use surrealdb::Uuid; use surrealdb::Uuid;
use tracing::instrument; use tracing::{instrument, info};
pub struct GachaCreditRepository<'a> { pub struct GachaCreditRepository<'a> {
state: &'a AppState, state: &'a AppState,
@@ -27,6 +27,7 @@ impl<'a> GachaCreditRepository<'a> {
ResourceEnum::GachaCredits, ResourceEnum::GachaCredits,
ResourceEnum::Users ResourceEnum::Users
); );
info!(query = %sql, "Executing SurrealDB query");
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(); let elapsed = now.elapsed();
@@ -66,6 +67,7 @@ impl<'a> GachaCreditRepository<'a> {
bail!("No extra roll credits remaining"); bail!("No extra roll credits remaining");
} }
credit.available_rolls -= 1; credit.available_rolls -= 1;
info!(operation = "update", table = %ResourceEnum::GachaCredits.to_string(), id = %credit.id.id.to_raw(), "Executing SurrealDB update for consume_credit");
let _: Option<GachaCreditSchema> = db let _: Option<GachaCreditSchema> = db
.update(( .update((
&ResourceEnum::GachaCredits.to_string(), &ResourceEnum::GachaCredits.to_string(),
@@ -91,6 +93,7 @@ impl<'a> GachaCreditRepository<'a> {
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;
info!(operation = "update", table = %ResourceEnum::GachaCredits.to_string(), id = %credit.id.id.to_raw(), "Executing SurrealDB update for add_credit");
let _: Option<GachaCreditSchema> = db let _: Option<GachaCreditSchema> = db
.update(( .update((
&ResourceEnum::GachaCredits.to_string(), &ResourceEnum::GachaCredits.to_string(),
@@ -108,6 +111,7 @@ impl<'a> GachaCreditRepository<'a> {
available_rolls: payload.amount, available_rolls: payload.amount,
..Default::default() ..Default::default()
}; };
info!(operation = "create", table = %ResourceEnum::GachaCredits.to_string(), "Executing SurrealDB create for add_credit");
let _: Option<GachaCreditSchema> = db let _: Option<GachaCreditSchema> = db
.create(ResourceEnum::GachaCredits.to_string()) .create(ResourceEnum::GachaCredits.to_string())
.content(data) .content(data)
@@ -1,5 +1,5 @@
use crate::{ use crate::{
AppState, GachaItemDto, GachaItemRequestDto, GachaItemService, MessageResponseDto, AppState, GachaItemDto, GachaItemRequestDto, GachaItemUpdateRequestDto, GachaItemService, MessageResponseDto,
MetaRequestDto, ResponseListSuccessDto, ResponseSuccessDto, MetaRequestDto, ResponseListSuccessDto, ResponseSuccessDto,
}; };
use axum::{ use axum::{
@@ -36,13 +36,13 @@ pub async fn get_gacha_item_list(
Query(meta): Query<MetaRequestDto>, Query(meta): Query<MetaRequestDto>,
) -> impl IntoResponse { ) -> impl IntoResponse {
match permissions_guard( match permissions_guard(
&headers, headers,
state.clone(), Extension(state),
vec![PermissionsEnum::ReadListGachaItems], vec![PermissionsEnum::ReadListGachaItems],
) )
.await .await
{ {
Ok(_) => GachaItemService::get_gacha_item_list(&state, meta).await, Ok((_user, state)) => GachaItemService::get_gacha_item_list(&state, meta).await,
Err(response) => response, Err(response) => response,
} }
} }
@@ -65,13 +65,13 @@ pub async fn get_gacha_item_by_id(
Path(id): Path<String>, Path(id): Path<String>,
) -> impl IntoResponse { ) -> impl IntoResponse {
match permissions_guard( match permissions_guard(
&headers, headers,
state.clone(), Extension(state),
vec![PermissionsEnum::ReadDetailGachaItems], vec![PermissionsEnum::ReadDetailGachaItems],
) )
.await .await
{ {
Ok(_) => GachaItemService::get_gacha_item_by_id(&state, id).await, Ok((_user, state)) => GachaItemService::get_gacha_item_by_id(&state, id).await,
Err(response) => response, Err(response) => response,
} }
} }
@@ -94,13 +94,13 @@ pub async fn post_create_gacha_item(
Json(payload): Json<GachaItemRequestDto>, Json(payload): Json<GachaItemRequestDto>,
) -> impl IntoResponse { ) -> impl IntoResponse {
match permissions_guard( match permissions_guard(
&headers, headers,
state.clone(), Extension(state),
vec![PermissionsEnum::CreateGachaItems], vec![PermissionsEnum::CreateGachaItems],
) )
.await .await
{ {
Ok(_) => GachaItemService::create_gacha_item(&state, payload).await, Ok((_user, state)) => GachaItemService::create_gacha_item(&state, payload).await,
Err(response) => response, Err(response) => response,
} }
} }
@@ -111,7 +111,7 @@ pub async fn post_create_gacha_item(
security( security(
("Bearer" = []) ("Bearer" = [])
), ),
request_body = GachaItemRequestDto, request_body = GachaItemUpdateRequestDto,
responses( responses(
(status = 200, description = "Update gacha item", body = MessageResponseDto) (status = 200, description = "Update gacha item", body = MessageResponseDto)
), ),
@@ -121,16 +121,16 @@ pub async fn put_update_gacha_item(
headers: HeaderMap, headers: HeaderMap,
Extension(state): Extension<AppState>, Extension(state): Extension<AppState>,
Path(id): Path<String>, Path(id): Path<String>,
Json(payload): Json<GachaItemRequestDto>, Json(payload): Json<GachaItemUpdateRequestDto>,
) -> impl IntoResponse { ) -> impl IntoResponse {
match permissions_guard( match permissions_guard(
&headers, headers,
state.clone(), Extension(state),
vec![PermissionsEnum::UpdateGachaItems], vec![PermissionsEnum::UpdateGachaItems],
) )
.await .await
{ {
Ok(_) => GachaItemService::update_gacha_item(&state, payload, id).await, Ok((_user, state)) => GachaItemService::update_gacha_item(&state, payload, id).await,
Err(response) => response, Err(response) => response,
} }
} }
@@ -152,13 +152,13 @@ pub async fn delete_gacha_item(
Path(id): Path<String>, Path(id): Path<String>,
) -> impl IntoResponse { ) -> impl IntoResponse {
match permissions_guard( match permissions_guard(
&headers, headers,
state.clone(), Extension(state),
vec![PermissionsEnum::DeleteGachaItems], vec![PermissionsEnum::DeleteGachaItems],
) )
.await .await
{ {
Ok(_) => GachaItemService::delete_gacha_item(&state, id).await, Ok((_user, state)) => GachaItemService::delete_gacha_item(&state, id).await,
Err(response) => response, Err(response) => response,
} }
} }
@@ -11,6 +11,16 @@ pub struct GachaItemRequestDto {
pub image_url: String, pub image_url: String,
} }
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)]
pub struct GachaItemUpdateRequestDto {
#[validate(length(min = 1, message = "Item name must not be empty"))]
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[validate(length(min = 1, message = "Image URL must not be empty"))]
#[serde(skip_serializing_if = "Option::is_none")]
pub image_url: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] #[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct GachaItemDto { pub struct GachaItemDto {
pub id: String, pub id: String,
@@ -9,6 +9,7 @@ use imphnen_utils::get_iso_date;
use serde_json::{Map, Value}; use serde_json::{Map, Value};
use std::time::Instant; use std::time::Instant;
use tracing::instrument; use tracing::instrument;
use tracing::info;
pub struct GachaItemRepository<'a> { pub struct GachaItemRepository<'a> {
state: &'a AppState, state: &'a AppState,
@@ -25,6 +26,11 @@ impl<'a> GachaItemRepository<'a> {
meta: MetaRequestDto, meta: MetaRequestDto,
) -> Result<ResponseListSuccessDto<Vec<GachaItemDto>>> { ) -> Result<ResponseListSuccessDto<Vec<GachaItemDto>>> {
let now = Instant::now(); let now = Instant::now();
let surreal_query = format!(
"SELECT * FROM {} WHERE is_deleted = false AND name LIKE ?",
ResourceEnum::GachaItems.to_string()
);
info!(query = %surreal_query, "Executing SurrealDB query");
let raw_result: ResponseListSuccessDto<Vec<GachaItemSchema>> = let raw_result: ResponseListSuccessDto<Vec<GachaItemSchema>> =
QueryListBuilder::new( QueryListBuilder::new(
&self.state.surrealdb_ws, &self.state.surrealdb_ws,
@@ -57,6 +63,8 @@ impl<'a> GachaItemRepository<'a> {
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 now = Instant::now();
let db = &self.state.surrealdb_ws; let db = &self.state.surrealdb_ws;
let surreal_query = format!("SELECT * FROM {} WHERE id = '{}'", ResourceEnum::GachaItems.to_string(), id);
info!(query = %surreal_query, "Executing SurrealDB query");
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?;
@@ -79,6 +87,8 @@ impl<'a> GachaItemRepository<'a> {
) -> Result<String> { ) -> Result<String> {
let now = Instant::now(); let now = Instant::now();
let db = &self.state.surrealdb_ws; let db = &self.state.surrealdb_ws;
let surreal_query = format!("CREATE {} CONTENT ...", ResourceEnum::GachaItems.to_string());
info!(query = %surreal_query, "Executing SurrealDB query");
let record: Option<GachaItemSchema> = db let record: Option<GachaItemSchema> = db
.create(ResourceEnum::GachaItems.to_string()) .create(ResourceEnum::GachaItems.to_string())
.content(data) .content(data)
@@ -111,6 +121,8 @@ impl<'a> GachaItemRepository<'a> {
created_at: existing.created_at, created_at: existing.created_at,
..data.clone() ..data.clone()
}; };
let surreal_query = format!("UPDATE {:?} MERGE ...", record_key);
info!(query = %surreal_query, "Executing SurrealDB query");
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(); let elapsed = now.elapsed();
@@ -139,6 +151,8 @@ impl<'a> GachaItemRepository<'a> {
patch.insert("is_deleted".to_string(), Value::Bool(true)); patch.insert("is_deleted".to_string(), Value::Bool(true));
patch.insert("updated_at".to_string(), Value::String(get_iso_date())); patch.insert("updated_at".to_string(), Value::String(get_iso_date()));
let surreal_query = format!("UPDATE {:?} MERGE ...", record_key);
info!(query = %surreal_query, "Executing SurrealDB query");
let record: Option<GachaItemSchema> = db.update(record_key).merge(patch).await?; let record: Option<GachaItemSchema> = db.update(record_key).merge(patch).await?;
let elapsed = now.elapsed(); let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string()) if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
@@ -43,4 +43,8 @@ impl GachaItemSchema {
..Default::default() ..Default::default()
} }
} }
pub fn from_existing(existing: GachaItemSchema) -> Self {
existing
}
} }
@@ -1,11 +1,12 @@
use crate::{ use crate::{
AppState, GachaItemDto, GachaItemRepository, GachaItemRequestDto, GachaItemSchema, AppState, GachaItemDto, GachaItemRepository, GachaItemRequestDto, GachaItemUpdateRequestDto, GachaItemSchema,
MetaRequestDto, ResourceEnum, ResponseListSuccessDto, ResponseSuccessDto, MetaRequestDto, ResourceEnum, ResponseListSuccessDto, ResponseSuccessDto,
common_response, make_thing, success_list_response, success_response, common_response, make_thing, success_list_response, success_response,
validate_request, validate_request,
}; };
use axum::http::StatusCode; use axum::http::StatusCode;
use axum::response::Response; use axum::response::Response;
use imphnen_utils::get_iso_date;
pub struct GachaItemService; pub struct GachaItemService;
@@ -59,20 +60,33 @@ impl GachaItemService {
pub async fn update_gacha_item( pub async fn update_gacha_item(
state: &AppState, state: &AppState,
payload: GachaItemRequestDto, payload: GachaItemUpdateRequestDto,
id: String, id: String,
) -> Response { ) -> Response {
if let Err((status, message)) = validate_request(&payload) { if let Err((status, message)) = validate_request(&payload) {
return common_response(status, &message); return common_response(status, &message);
} }
let repo = GachaItemRepository::new(state); let repo = GachaItemRepository::new(state);
let schema = GachaItemSchema {
id: make_thing(&ResourceEnum::GachaItems.to_string(), &id), // Get current gacha item data first
name: payload.name, let _thing_id = make_thing(&ResourceEnum::GachaItems.to_string(), &id);
image_url: payload.image_url, let current_item = match repo.query_gacha_item_by_id(id.clone()).await {
..Default::default() Ok(item) => item,
Err(_) => return common_response(StatusCode::NOT_FOUND, "Gacha Item not found"),
}; };
match repo.query_update_gacha_item(schema).await {
let mut updated_item = current_item;
updated_item.updated_at = Some(get_iso_date());
// Only update fields that are provided
if let Some(name) = payload.name {
updated_item.name = name;
}
if let Some(image_url) = payload.image_url {
updated_item.image_url = image_url;
}
match repo.query_update_gacha_item(updated_item).await {
Ok(msg) => common_response(StatusCode::OK, &msg), Ok(msg) => common_response(StatusCode::OK, &msg),
Err(e) => { Err(e) => {
if e.to_string().contains("not found") { if e.to_string().contains("not found") {
@@ -25,13 +25,13 @@ pub async fn get_detail_gacha_roll(
Path(id): Path<String>, Path(id): Path<String>,
) -> impl IntoResponse { ) -> impl IntoResponse {
match permissions_guard( match permissions_guard(
&headers, headers,
state.clone(), Extension(state),
vec![PermissionsEnum::ReadDetailGachaRolls], vec![PermissionsEnum::ReadDetailGachaRolls],
) )
.await .await
{ {
Ok(_) => GachaRollService::get_gacha_roll_by_id(&state, id).await, Ok((_user, state)) => GachaRollService::get_gacha_roll_by_id(&state, id).await,
Err(response) => response, Err(response) => response,
} }
} }
@@ -54,13 +54,13 @@ pub async fn post_create_gacha_roll(
Json(payload): Json<GachaRollRequestDto>, Json(payload): Json<GachaRollRequestDto>,
) -> impl IntoResponse { ) -> impl IntoResponse {
match permissions_guard( match permissions_guard(
&headers, headers,
state.clone(), Extension(state),
vec![PermissionsEnum::CreateGachaRolls], vec![PermissionsEnum::CreateGachaRolls],
) )
.await .await
{ {
Ok(_) => GachaRollService::create_gacha_roll(&state, payload).await, Ok((_user, state)) => GachaRollService::create_gacha_roll(&state, payload).await,
Err(response) => response, Err(response) => response,
} }
} }
@@ -81,13 +81,13 @@ pub async fn post_execute_gacha_roll(
Extension(state): Extension<AppState>, Extension(state): Extension<AppState>,
) -> impl IntoResponse { ) -> impl IntoResponse {
match permissions_guard( match permissions_guard(
&headers, headers.clone(),
state.clone(), Extension(state),
vec![PermissionsEnum::ExecuteGachaRolls], vec![PermissionsEnum::ExecuteGachaRolls],
) )
.await .await
{ {
Ok(_) => GachaRollService::execute_roll_once(headers, &state).await, Ok((_user, state)) => GachaRollService::execute_roll_once(headers, &state).await,
Err(response) => response, Err(response) => response,
} }
} }
@@ -110,13 +110,13 @@ pub async fn delete_gacha_roll(
Path(id): Path<String>, Path(id): Path<String>,
) -> impl IntoResponse { ) -> impl IntoResponse {
match permissions_guard( match permissions_guard(
&headers, headers,
state.clone(), Extension(state),
vec![PermissionsEnum::DeleteGachaRolls], vec![PermissionsEnum::DeleteGachaRolls],
) )
.await .await
{ {
Ok(_) => GachaRollService::soft_delete_gacha_roll(&state, id).await, Ok((_user, state)) => GachaRollService::soft_delete_gacha_roll(&state, id).await,
Err(response) => response, Err(response) => response,
} }
} }
@@ -10,6 +10,7 @@ use rand_distr::weighted::WeightedIndex;
use serde_json::{Map, Value}; use serde_json::{Map, Value};
use std::time::Instant; use std::time::Instant;
use tracing::instrument; use tracing::instrument;
use tracing::info;
pub struct GachaRollRepository<'a> { pub struct GachaRollRepository<'a> {
state: &'a AppState, state: &'a AppState,
@@ -33,6 +34,7 @@ impl<'a> GachaRollRepository<'a> {
.with_select_fields(vec!["*"]) .with_select_fields(vec!["*"])
.with_fetch("item"); .with_fetch("item");
let sql = builder.build(); let sql = builder.build();
info!(query = %sql, "Executing SurrealDB query");
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(); let elapsed = now.elapsed();
@@ -54,6 +56,7 @@ impl<'a> GachaRollRepository<'a> {
) -> Result<String> { ) -> Result<String> {
let now = Instant::now(); let now = Instant::now();
let db = &self.state.surrealdb_ws; let db = &self.state.surrealdb_ws;
info!(query = "CREATE", "Executing SurrealDB create operation for GachaRolls");
let record: Option<GachaRollSchema> = db let record: Option<GachaRollSchema> = db
.create(ResourceEnum::GachaRolls.to_string()) .create(ResourceEnum::GachaRolls.to_string())
.content(data) .content(data)
@@ -77,6 +80,7 @@ impl<'a> GachaRollRepository<'a> {
let table_name = ResourceEnum::GachaRolls.to_string(); let table_name = ResourceEnum::GachaRolls.to_string();
let sql = let sql =
format!("SELECT * FROM {table_name} WHERE is_deleted = false FETCH item"); format!("SELECT * FROM {table_name} WHERE is_deleted = false FETCH item");
info!(query = %sql, "Executing SurrealDB query");
let result: Vec<GachaRollQueryDto> = db.query(sql).await?.take(0)?; let result: Vec<GachaRollQueryDto> = db.query(sql).await?.take(0)?;
let elapsed = now.elapsed(); let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string()) if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
@@ -87,6 +91,7 @@ impl<'a> GachaRollRepository<'a> {
Ok(result) Ok(result)
} }
#[instrument]
pub fn roll_once(rolls: &[GachaRollQueryDto]) -> Option<GachaRollQueryDto> { pub fn roll_once(rolls: &[GachaRollQueryDto]) -> Option<GachaRollQueryDto> {
let filtered: Vec<_> = rolls let filtered: Vec<_> = rolls
.iter() .iter()
@@ -120,6 +125,7 @@ impl<'a> GachaRollRepository<'a> {
patch.insert("is_deleted".to_string(), Value::Bool(true)); patch.insert("is_deleted".to_string(), Value::Bool(true));
patch.insert("updated_at".to_string(), Value::String(get_iso_date())); patch.insert("updated_at".to_string(), Value::String(get_iso_date()));
info!(query = "UPDATE", record_key = ?record_key, "Executing SurrealDB update operation for GachaRolls");
let record: Option<GachaRollSchema> = db.update(record_key).merge(patch).await?; let record: Option<GachaRollSchema> = db.update(record_key).merge(patch).await?;
let elapsed = now.elapsed(); let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string()) if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
+3
View File
@@ -29,6 +29,7 @@ use imphnen_iam::{
RolesRequestUpdateDto, TokenDto, UsersCreateRequestDto, UsersDetailItemDto, RolesRequestUpdateDto, TokenDto, UsersCreateRequestDto, UsersDetailItemDto,
UsersListItemDto, UsersUpdateRequestDto, auth, permissions, roles, users, UsersListItemDto, UsersUpdateRequestDto, auth, permissions, roles, users,
}; };
use imphnen_iam::users::users_controller::FileUploadSchema;
use utoipa::{ use utoipa::{
Modify, OpenApi, Modify, OpenApi,
openapi::security::{Http, HttpAuthScheme, SecurityScheme}, openapi::security::{Http, HttpAuthScheme, SecurityScheme},
@@ -53,6 +54,7 @@ use utoipa::{
users::users_controller::get_user_by_id, users::users_controller::get_user_by_id,
users::users_controller::get_user_me, users::users_controller::get_user_me,
users::users_controller::get_user_list, users::users_controller::get_user_list,
users::users_controller::upload_file,
roles::roles_controller::get_role_list, roles::roles_controller::get_role_list,
roles::roles_controller::get_role_by_id, roles::roles_controller::get_role_by_id,
roles::roles_controller::post_create_role, roles::roles_controller::post_create_role,
@@ -114,6 +116,7 @@ use utoipa::{
UsersListItemDto, UsersListItemDto,
UsersUpdateRequestDto, UsersUpdateRequestDto,
UsersCreateRequestDto, UsersCreateRequestDto,
FileUploadSchema,
GachaClaimItemDto, GachaClaimItemDto,
GachaClaimRequestDto, GachaClaimRequestDto,
GachaItemDto, GachaItemDto,
+7 -1
View File
@@ -6,10 +6,13 @@ edition = "2024"
[dependencies] [dependencies]
imphnen-libs.workspace = true imphnen-libs.workspace = true
imphnen-utils.workspace = true imphnen-utils.workspace = true
imphnen-entities.workspace = true imphnen-entities.workspace = true
axum.workspace = true axum.workspace = true
serde.workspace = true serde.workspace = true
serde_json.workspace = true serde_json = { workspace = true }
oauth2 = { workspace = true }
reqwest = { workspace = true, features = ["json"] }
utoipa.workspace = true utoipa.workspace = true
lazy_static.workspace = true lazy_static.workspace = true
regex.workspace = true regex.workspace = true
@@ -28,7 +31,10 @@ log.workspace = true
once_cell.workspace = true once_cell.workspace = true
tracing.workspace = true tracing.workspace = true
uuid.workspace = true uuid.workspace = true
axum-extra.workspace = true
[dev-dependencies] [dev-dependencies]
dotenvy.workspace = true dotenvy.workspace = true
tokio-test = { workspace = true }
mockall = { workspace = true }
http-body-util.workspace = true http-body-util.workspace = true
+5 -2
View File
@@ -1,10 +1,12 @@
use super::{ use super::{
AuthLoginRequestDto, AuthRefreshTokenRequestDto, AuthRegisterRequestDto, AuthLoginRequestDto, AuthRefreshTokenRequestDto, AuthRegisterRequestDto,
AuthResendOtpRequestDto, AuthService, AuthVerifyEmailRequestDto, AuthResendOtpRequestDto, AuthVerifyEmailRequestDto,
}; };
use crate::{AppState, v1::AuthLoginResponsetDto}; use crate::{AppState, v1::AuthLoginResponsetDto};
use crate::{AuthNewPasswordRequestDto, MessageResponseDto, ResponseSuccessDto}; use crate::{AuthNewPasswordRequestDto, MessageResponseDto, ResponseSuccessDto};
use axum::{Extension, Json, response::IntoResponse}; use axum::{Extension, Json, response::IntoResponse};
use crate::v1::auth::auth_service::AuthServiceTrait;
use crate::v1::auth::auth_service::AuthService;
#[utoipa::path( #[utoipa::path(
post, post,
@@ -137,7 +139,8 @@ pub async fn post_new_password(
tag = "Authentication" tag = "Authentication"
)] )]
pub async fn post_refresh_token( pub async fn post_refresh_token(
Extension(state): Extension<AppState>,
Json(payload): Json<AuthRefreshTokenRequestDto>, Json(payload): Json<AuthRefreshTokenRequestDto>,
) -> impl IntoResponse { ) -> impl IntoResponse {
AuthService::mutation_refresh_token(payload).await AuthService::mutation_refresh_token(payload, &state).await
} }
+1 -1
View File
@@ -32,7 +32,7 @@ pub struct AuthLoginResponsetDto {
pub user: UsersDetailItemDto, pub user: UsersDetailItemDto,
} }
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] #[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Default)]
pub struct TokenDto { pub struct TokenDto {
pub access_token: String, pub access_token: String,
pub refresh_token: String, pub refresh_token: String,
@@ -7,6 +7,8 @@ use crate::{
use anyhow::{Result, anyhow, bail}; use anyhow::{Result, anyhow, bail};
use chrono::{Duration, Utc}; use chrono::{Duration, Utc};
use surrealdb::sql::Thing; use surrealdb::sql::Thing;
use tracing::instrument;
use tracing::info;
pub struct AuthRepository<'a> { pub struct AuthRepository<'a> {
pub state: &'a AppState, pub state: &'a AppState,
@@ -17,6 +19,7 @@ impl<'a> AuthRepository<'a> {
Self { state } Self { state }
} }
#[instrument(skip(self, user), err)]
pub async fn query_store_user(&self, user: UsersDetailQueryDto) -> Result<String> { pub async fn query_store_user(&self, user: UsersDetailQueryDto) -> Result<String> {
if user.email.trim().is_empty() { if user.email.trim().is_empty() {
bail!("Email is required"); bail!("Email is required");
@@ -30,12 +33,14 @@ impl<'a> AuthRepository<'a> {
permissions, permissions,
}; };
info!(query = %format!("DELETE FROM {} WHERE id = '{}'", table, user_id), "Executing SurrealDB query");
let _record: Option<UserCacheSchema> = self let _record: Option<UserCacheSchema> = self
.state .state
.surrealdb_mem .surrealdb_mem
.delete::<Option<UserCacheSchema>>((table.clone(), user_id.clone())) .delete::<Option<UserCacheSchema>>((table.clone(), user_id.clone()))
.await?; .await?;
info!(query = %format!("CREATE {}:{}", table, user_id), "Executing SurrealDB query");
let record: Option<UserCacheSchema> = self let record: Option<UserCacheSchema> = self
.state .state
.surrealdb_mem .surrealdb_mem
@@ -49,10 +54,12 @@ impl<'a> AuthRepository<'a> {
} }
} }
#[instrument(skip(self, email), err)]
pub async fn query_get_stored_user( pub async fn query_get_stored_user(
&self, &self,
email: String, email: String,
) -> Result<UsersDetailQueryDto> { ) -> Result<UsersDetailQueryDto> {
info!(query = %format!("SELECT FROM {} WHERE id = '{}'", ResourceEnum::UsersCache.to_string(), email), "Executing SurrealDB query");
let user_cache: Option<UserCacheSchema> = self let user_cache: Option<UserCacheSchema> = self
.state .state
.surrealdb_mem .surrealdb_mem
@@ -87,13 +94,29 @@ impl<'a> AuthRepository<'a> {
Ok(UsersDetailQueryDto { Ok(UsersDetailQueryDto {
id: Thing::from(("app_users".to_string(), email.clone())), id: Thing::from(("app_users".to_string(), email.clone())),
fullname: "Cached User".to_string(), fullname: "Cached User".to_string(),
legal_name: None,
email: cache.email, email: cache.email,
avatar: None, avatar: None,
phone_number: String::new(), phone_number: String::new(),
phone_for_verification: None,
is_active: true, is_active: true,
is_deleted: false, is_deleted: false,
gender: None, gender: None,
birthdate: None, birthdate: None,
domicile: None,
bio: None,
last_education: None,
linkedin_url: None,
github_url: None,
cv_url: None,
portfolio_url: None,
website_url: None,
twitter_url: None,
location: None,
skills: None,
experience: None,
education: None,
career_status: None,
password: String::new(), password: String::new(),
role: role_detail_query_dto, role: role_detail_query_dto,
created_at: String::new(), created_at: String::new(),
@@ -105,7 +128,9 @@ impl<'a> AuthRepository<'a> {
} }
} }
#[instrument(skip(self, email), err)]
pub async fn query_delete_stored_user(&self, email: String) -> Result<String> { pub async fn query_delete_stored_user(&self, email: String) -> Result<String> {
info!(query = %format!("DELETE FROM {} WHERE id = '{}'", ResourceEnum::UsersCache.to_string(), email), "Executing SurrealDB query");
let record: Option<UsersDetailQueryDto> = self let record: Option<UsersDetailQueryDto> = self
.state .state
.surrealdb_mem .surrealdb_mem
@@ -117,13 +142,16 @@ impl<'a> AuthRepository<'a> {
} }
} }
#[instrument(skip(self, email), err)]
pub async fn query_get_stored_otp(&self, email: String) -> Result<u32> { pub async fn query_get_stored_otp(&self, email: String) -> Result<u32> {
let table = ResourceEnum::OtpCache.to_string(); let table = ResourceEnum::OtpCache.to_string();
let key = (table.as_str(), email.as_str()); let key = (table.as_str(), email.as_str());
info!(query = %format!("SELECT FROM {} WHERE id = '{}'", table, email), "Executing SurrealDB query");
let result: Option<AuthOtpSchema> = self.state.surrealdb_mem.select(key).await?; let result: Option<AuthOtpSchema> = self.state.surrealdb_mem.select(key).await?;
match result { match result {
Some(data) => match Utc::now() > data.expires_at { Some(data) => match Utc::now() > data.expires_at {
true => { true => {
info!(query = %format!("DELETE FROM {} WHERE id = '{}'", table, email), "Executing SurrealDB query");
let _ = self let _ = self
.state .state
.surrealdb_mem .surrealdb_mem
@@ -137,9 +165,11 @@ impl<'a> AuthRepository<'a> {
} }
} }
#[instrument(skip(self, email, otp), err)]
pub async fn query_store_otp(&self, email: String, otp: u32) -> Result<String> { pub async fn query_store_otp(&self, email: String, otp: u32) -> Result<String> {
let expires_at = Utc::now() + Duration::seconds(300); let expires_at = Utc::now() + Duration::seconds(300);
let table: String = ResourceEnum::OtpCache.to_string(); let table: String = ResourceEnum::OtpCache.to_string();
info!(query = %format!("CREATE {}:{}", table, email), "Executing SurrealDB query");
let record: Option<AuthOtpSchema> = self let record: Option<AuthOtpSchema> = self
.state .state
.surrealdb_mem .surrealdb_mem
@@ -152,7 +182,9 @@ impl<'a> AuthRepository<'a> {
} }
} }
#[instrument(skip(self, email), err)]
pub async fn query_delete_stored_otp(&self, email: String) -> Result<String> { pub async fn query_delete_stored_otp(&self, email: String) -> Result<String> {
info!(query = %format!("DELETE FROM {} WHERE id = '{}'", ResourceEnum::OtpCache.to_string(), email), "Executing SurrealDB query");
let record: Option<AuthOtpSchema> = self let record: Option<AuthOtpSchema> = self
.state .state
.surrealdb_mem .surrealdb_mem
+210 -127
View File
@@ -1,38 +1,88 @@
use std::pin::Pin;
use std::future::Future;
use super::{ use super::{
AuthLoginRequestDto, AuthLoginResponsetDto, AuthNewPasswordRequestDto, AuthLoginRequestDto, AuthLoginResponsetDto, AuthNewPasswordRequestDto,
AuthRefreshTokenRequestDto, AuthRegisterRequestDto, AuthRepository, AuthRefreshTokenRequestDto, AuthRegisterRequestDto, AuthRepository,
AuthResendOtpRequestDto, AuthVerifyEmailRequestDto, TokenDto, AuthResendOtpRequestDto, AuthVerifyEmailRequestDto, TokenDto,
}; };
use crate::{ use crate::{
AppState, Env, ResourceEnum, ResponseSuccessDto, RolesEnum, RolesRepository, AppState, ResourceEnum, ResponseSuccessDto, RolesEnum, RolesRepository,
UsersDetailItemDto, UsersRepository, UsersSchema, common_response, UsersDetailItemDto, UsersRepository, UsersSchema, common_response,
decode_refresh_token, encode_access_token, encode_refresh_token, decode_refresh_token, encode_access_token, encode_refresh_token,
encode_reset_password_token, extract_email_token, generate_otp, get_iso_date, encode_reset_password_token, extract_email_token_async, generate_otp, get_iso_date,
hash_password, make_thing, send_email, success_response, validate_request, hash_password, make_thing, send_email, success_response, validate_request,
verify_password, verify_password,
}; };
use axum::{http::StatusCode, response::Response}; use axum::{http::StatusCode, response::Response};
use surrealdb::Uuid; use surrealdb::Uuid;
use tracing::error; use tracing::error;
use tokio;
pub trait AuthServiceTrait: Send + Sync + 'static {
fn mutation_login(
payload: AuthLoginRequestDto,
state: &AppState,
) -> Pin<Box<dyn Future<Output = Response> + Send>>;
fn mutation_mentor_login(
payload: AuthLoginRequestDto,
state: &AppState,
) -> Pin<Box<dyn Future<Output = Response> + Send>>;
fn mutation_register(
payload: AuthRegisterRequestDto,
state: &AppState,
) -> Pin<Box<dyn Future<Output = Response> + Send>>;
fn mutation_resend_otp(
payload: AuthResendOtpRequestDto,
state: &AppState,
) -> Pin<Box<dyn Future<Output = Response> + Send>>;
fn mutation_refresh_token(
payload: AuthRefreshTokenRequestDto,
state: &AppState,
) -> Pin<Box<dyn Future<Output = Response> + Send>>;
fn mutation_forgot_password(
payload: AuthResendOtpRequestDto,
state: &AppState,
) -> Pin<Box<dyn Future<Output = Response> + Send>>;
fn mutation_verify_email(
payload: AuthVerifyEmailRequestDto,
state: &AppState,
) -> Pin<Box<dyn Future<Output = Response> + Send>>;
fn mutation_new_password(
payload: AuthNewPasswordRequestDto,
state: &AppState,
) -> Pin<Box<dyn Future<Output = Response> + Send>>;
}
#[derive(Clone)] // Added Clone derive
pub struct AuthService; pub struct AuthService;
impl AuthService {
pub async fn mutation_login( impl AuthServiceTrait for AuthService {
fn mutation_login(
payload: AuthLoginRequestDto, payload: AuthLoginRequestDto,
state: &AppState, state: &AppState,
) -> Response { ) -> Pin<Box<dyn Future<Output = Response> + Send>> {
let payload = payload;
let state = state.to_owned();
Box::pin(async move {
if let Err((status, message)) = validate_request(&payload) { if let Err((status, message)) = validate_request(&payload) {
return common_response(status, &message); return common_response(status, &message);
} }
let user_repo = UsersRepository::new(state); let user_repo = UsersRepository::new(&state);
let auth_repo = AuthRepository::new(state); let auth_repo = AuthRepository::new(&state);
match user_repo.query_user_by_email(payload.email.clone()).await { let email = &payload.email;
let password = &payload.password;
match user_repo.query_user_by_email(email.to_string()).await {
Ok(user) => { Ok(user) => {
let is_password_correct = let is_password_correct = tokio::task::spawn_blocking({
verify_password(&payload.password, &user.password).unwrap_or(false); let password = password.to_owned();
let user_password = user.password.clone();
move || verify_password(&password, &user_password).unwrap_or(false)
}).await.unwrap_or(false);
if !is_password_correct { if !is_password_correct {
return common_response( return common_response(
@@ -48,12 +98,16 @@ impl AuthService {
); );
} }
let access_token = match encode_access_token(payload.email.clone()) { // Avoid unnecessary clone of user for caching if not needed
let permissions: Vec<String> = user.role.permissions.iter().map(|p| p.name.as_str()).map(str::to_owned).collect();
let user_id = user.id.id.to_raw();
let access_token = match encode_access_token(email.to_string(), user_id.clone(), permissions.clone()) {
Ok(token) => token, Ok(token) => token,
Err(_e) => { Err(_e) => {
error!( error!(
"Failed to generate access token for {}: {}", "Failed to generate access token for {}: {}",
payload.email, _e email, _e
); );
return common_response( return common_response(
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
@@ -62,12 +116,12 @@ impl AuthService {
} }
}; };
let refresh_token = match encode_refresh_token(payload.email.clone()) { let refresh_token = match encode_refresh_token(email.to_string(), user_id, permissions) {
Ok(token) => token, Ok(token) => token,
Err(_e) => { Err(_e) => {
error!( error!(
"Failed to generate refresh token for {}: {}", "Failed to generate refresh token for {}: {}",
payload.email, _e email, _e
); );
return common_response( return common_response(
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
@@ -86,6 +140,7 @@ impl AuthService {
}, },
}; };
// Only clone user if caching is required
if let Err(err_store) = auth_repo.query_store_user(user.clone()).await { if let Err(err_store) = auth_repo.query_store_user(user.clone()).await {
error!( error!(
"Failed to store user cache for {}: {}", "Failed to store user cache for {}: {}",
@@ -102,23 +157,30 @@ impl AuthService {
common_response(StatusCode::UNAUTHORIZED, &err_find.to_string()) common_response(StatusCode::UNAUTHORIZED, &err_find.to_string())
} }
} }
})
} }
pub async fn mutation_mentor_login( fn mutation_mentor_login(
payload: AuthLoginRequestDto, payload: AuthLoginRequestDto,
state: &AppState, state: &AppState,
) -> Response { ) -> Pin<Box<dyn Future<Output = Response> + Send>> {
let payload = payload;
let state = state.to_owned();
Box::pin(async move {
if let Err((status, message)) = validate_request(&payload) { if let Err((status, message)) = validate_request(&payload) {
return common_response(status, &message); return common_response(status, &message);
} }
let user_repo = UsersRepository::new(state); let user_repo = UsersRepository::new(&state);
let auth_repo = AuthRepository::new(state); let auth_repo = AuthRepository::new(&state);
match user_repo.query_user_by_email(payload.email.clone()).await { match user_repo.query_user_by_email(payload.email.clone()).await {
Ok(user) => { Ok(user) => {
let is_password_correct = let is_password_correct = tokio::task::spawn_blocking({
verify_password(&payload.password, &user.password).unwrap_or(false); let password = payload.password.clone();
let user_password = user.password.clone();
move || verify_password(&password, &user_password).unwrap_or(false)
}).await.unwrap_or(false);
if !is_password_correct { if !is_password_correct {
return common_response( return common_response(
@@ -143,7 +205,8 @@ impl AuthService {
); );
} }
let access_token = match encode_access_token(payload.email.clone()) { let permissions: Vec<String> = user.role.permissions.iter().map(|p| p.name.clone()).collect();
let access_token = match encode_access_token(payload.email.clone(), user.id.id.to_raw(), permissions.clone()) {
Ok(token) => token, Ok(token) => token,
Err(_e) => { Err(_e) => {
error!( error!(
@@ -157,7 +220,8 @@ impl AuthService {
} }
}; };
let refresh_token = match encode_refresh_token(payload.email.clone()) { let permissions: Vec<String> = user.role.permissions.iter().map(|p| p.name.clone()).collect();
let refresh_token = match encode_refresh_token(payload.email.clone(), user.id.id.to_raw(), permissions) {
Ok(token) => token, Ok(token) => token,
Err(_e) => { Err(_e) => {
error!( error!(
@@ -197,18 +261,22 @@ impl AuthService {
common_response(StatusCode::UNAUTHORIZED, &err_find.to_string()) common_response(StatusCode::UNAUTHORIZED, &err_find.to_string())
} }
} }
})
} }
pub async fn mutation_register( fn mutation_register(
payload: AuthRegisterRequestDto, payload: AuthRegisterRequestDto,
state: &AppState, state: &AppState,
) -> Response { ) -> Pin<Box<dyn Future<Output = Response> + Send>> {
let payload = payload;
let state = state.to_owned();
Box::pin(async move {
if let Err((status, message)) = validate_request(&payload) { if let Err((status, message)) = validate_request(&payload) {
return common_response(status, &message); return common_response(status, &message);
} }
let user_repo = UsersRepository::new(state); let user_repo = UsersRepository::new(&state);
let auth_repo = AuthRepository::new(state); let auth_repo = AuthRepository::new(&state);
let role_repo = RolesRepository::new(state); let role_repo = RolesRepository::new(&state);
let role = match role_repo let role = match role_repo
.query_role_by_name(RolesEnum::User.to_string()) .query_role_by_name(RolesEnum::User.to_string())
.await .await
@@ -285,7 +353,7 @@ 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, is_active: false,
..Default::default() ..Default::default()
}) })
.await .await
@@ -296,16 +364,20 @@ impl AuthService {
common_response(StatusCode::INTERNAL_SERVER_ERROR, &err_create.to_string()) common_response(StatusCode::INTERNAL_SERVER_ERROR, &err_create.to_string())
} }
} }
})
} }
pub async fn mutation_resend_otp( fn mutation_resend_otp(
payload: AuthResendOtpRequestDto, payload: AuthResendOtpRequestDto,
state: &AppState, state: &AppState,
) -> Response { ) -> Pin<Box<dyn Future<Output = Response> + Send>> {
let payload = payload;
let state = state.to_owned();
Box::pin(async move {
if let Err((status, message)) = validate_request(&payload) { if let Err((status, message)) = validate_request(&payload) {
return common_response(status, &message); return common_response(status, &message);
} }
let user_repo = UsersRepository::new(state); let user_repo = UsersRepository::new(&state);
if user_repo if user_repo
.query_user_by_email(payload.email.clone()) .query_user_by_email(payload.email.clone())
.await .await
@@ -313,7 +385,7 @@ impl AuthService {
{ {
return common_response(StatusCode::BAD_REQUEST, "User not found"); return common_response(StatusCode::BAD_REQUEST, "User not found");
} }
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}");
@@ -333,34 +405,48 @@ impl AuthService {
common_response(StatusCode::BAD_REQUEST, &err_store.to_string()) common_response(StatusCode::BAD_REQUEST, &err_store.to_string())
} }
} }
})
} }
pub async fn mutation_refresh_token( fn mutation_refresh_token(
payload: AuthRefreshTokenRequestDto, payload: AuthRefreshTokenRequestDto,
) -> Response { state: &AppState,
) -> Pin<Box<dyn Future<Output = Response> + Send>> {
let payload = payload;
let state = state.to_owned();
Box::pin(async move {
if let Err((status, message)) = validate_request(&payload) { if let Err((status, message)) = validate_request(&payload) {
return common_response(status, &message); return common_response(status, &message);
} }
let email = match decode_refresh_token(&payload.refresh_token) {
Ok(token) => token.claims.sub, let user_repo = UsersRepository::new(&state);
let user = match decode_refresh_token(&payload.refresh_token) {
Ok(token_data) => {
match user_repo.query_user_by_email(token_data.claims.sub.clone()).await {
Ok(user) => user,
Err(_) => return common_response(StatusCode::UNAUTHORIZED, "User not found"),
}
},
Err(_e) => { 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 permissions: Vec<String> = user.role.permissions.iter().map(|p| p.name.clone()).collect();
let access_token = match encode_access_token(user.email.clone(), user.id.id.to_raw(), permissions.clone()) {
Ok(token) => token, Ok(token) => token,
Err(_e) => { Err(_e) => {
error!("Failed to generate access token for {}: {}", email, _e); error!("Failed to generate access 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",
); );
} }
}; };
let refresh_token = match encode_refresh_token(email.clone()) { let refresh_token = match encode_refresh_token(user.email.clone(), user.id.id.to_raw(), permissions) {
Ok(token) => token, Ok(token) => token,
Err(_e) => { Err(_e) => {
error!("Failed to generate refresh token for {}: {}", email, _e); error!("Failed to generate refresh token for {}: {}", user.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",
@@ -374,148 +460,145 @@ impl AuthService {
}, },
}; };
success_response(response) success_response(response)
})
} }
pub async fn mutation_forgot_password( fn mutation_forgot_password(
payload: AuthResendOtpRequestDto, payload: AuthResendOtpRequestDto,
state: &AppState, state: &AppState,
) -> Response { ) -> Pin<Box<dyn Future<Output = Response> + Send>> {
if let Err((status, message)) = validate_request(&payload) { let payload = payload;
return common_response(status, &message); let state = state.to_owned();
} Box::pin(async move {
let user_repo = UsersRepository::new(state); if let Err((status, message)) = validate_request(&payload) {
let user_result = user_repo.query_user_by_email(payload.email.clone()).await; return common_response(status, &message);
let user = match user_result { }
Ok(user) => user,
Err(err_find) if err_find.to_string().contains("User not found") => { tokio::spawn(async move {
return common_response(StatusCode::BAD_REQUEST, "User not found"); let user_repo = UsersRepository::new(&state);
} if let Ok(user) = user_repo.query_user_by_email(payload.email.clone()).await {
Err(err_other) => { let permissions: Vec<String> = user.role.permissions.iter().map(|p| p.name.clone()).collect();
error!( let token = match encode_reset_password_token(user.email.clone(), user.id.id.to_raw(), permissions) {
"Error finding user for forgot password {}: {}", Ok(token) => token,
payload.email, err_other Err(_e) => {
); error!("Failed to generate reset password token for {}: {}", user.email, _e);
return common_response( return;
StatusCode::INTERNAL_SERVER_ERROR, }
&err_other.to_string(), };
);
} let env = &crate::enviroment::ENV;
}; let fe_url = env.fe_url.clone();
let token = match encode_reset_password_token(user.email.clone()) { let message = format!(
Ok(token) => token, "You have requested a password reset. Please click the link below to continue: {fe_url}/auth/reset-password?token={token}"
Err(_e) => { );
error!(
"Failed to generate reset password token for {}: {}", if let Err(err_send) = send_email(&payload.email, "Reset Password Request", &message) {
user.email, _e error!("Failed to send reset password email to {}: {}", payload.email, err_send);
); }
return common_response( }
StatusCode::INTERNAL_SERVER_ERROR, });
"Failed to generate access token",
); common_response(StatusCode::OK, "If your email is registered, you will receive a password reset link.")
} })
};
let env = Env::new();
let fe_url = env.fe_url;
let message = format!(
"You have requested a password reset. Please click the link below to continue: {fe_url}/auth/reset-password?token={token}"
);
match send_email(&payload.email, "Reset Password Request", &message) {
Ok(_) => common_response(StatusCode::OK, "Reset Password request send"),
Err(err_send) => {
error!(
"Failed to send reset password email to {}: {}",
payload.email, err_send
);
common_response(StatusCode::BAD_REQUEST, &err_send.to_string())
}
}
} }
pub async fn mutation_verify_email( fn mutation_verify_email(
payload: AuthVerifyEmailRequestDto, payload: AuthVerifyEmailRequestDto,
state: &AppState, state: &AppState,
) -> Response { ) -> Pin<Box<dyn Future<Output = Response> + Send>> {
let payload = payload;
let state = state.to_owned();
Box::pin(async move {
if let Err((status, message)) = validate_request(&payload) { if let Err((status, message)) = validate_request(&payload) {
return common_response(status, &message); return common_response(status, &message);
} }
let user_repo = UsersRepository::new(state); let user_repo = UsersRepository::new(&state);
let auth_repo = AuthRepository::new(state); let auth_repo = AuthRepository::new(&state);
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) => user,
_ => { _ => {
return common_response(StatusCode::NOT_FOUND, "User not found"); return common_response(StatusCode::NOT_FOUND, "User not found");
} }
}; };
if user.is_active {
return common_response(StatusCode::BAD_REQUEST, "User already active");
}
let patch = UsersSchema { let patch = UsersSchema {
id: user.id.clone(), id: user.id.clone(),
is_active: true, is_active: true,
..UsersSchema::from(user.clone()) ..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) => {
true => match user_repo.query_update_user(patch).await { if stored_otp != payload.otp {
Ok(_) => match auth_repo.query_delete_stored_otp(email.clone()).await { // Delete OTP even if it doesn't match
Ok(_) => common_response(StatusCode::OK, "Email verified successfully"), let _ = auth_repo.query_delete_stored_otp(email.clone()).await;
Err(e_del) => { return common_response(StatusCode::BAD_REQUEST, "Failed to verify OTP");
error!("Failed to delete OTP for {}: {}", email, e_del); }
common_response(StatusCode::INTERNAL_SERVER_ERROR, &e_del.to_string())
match user_repo.query_update_user(patch).await {
Ok(_) => {
match auth_repo.query_delete_stored_otp(email.clone()).await {
Ok(_) => common_response(StatusCode::OK, "Email verified successfully"),
Err(e_del) => {
error!("Failed to delete OTP for {}: {}", email, e_del);
common_response(StatusCode::INTERNAL_SERVER_ERROR, &e_del.to_string())
}
} }
}, },
Err(err_update) => { Err(err_update) => common_response(StatusCode::BAD_REQUEST, &err_update.to_string()),
common_response(StatusCode::BAD_REQUEST, &err_update.to_string()) }
}
},
false => match auth_repo.query_delete_stored_otp(email.clone()).await {
Ok(_) => common_response(StatusCode::BAD_REQUEST, "Failed to verify OTP"),
Err(e_del_mismatch) => common_response(
StatusCode::INTERNAL_SERVER_ERROR,
&format!("Failed to delete OTP: {e_del_mismatch}"),
),
},
}, },
Err(err_get) => common_response(StatusCode::BAD_REQUEST, &err_get.to_string()), Err(err_get) => common_response(StatusCode::BAD_REQUEST, &err_get.to_string()),
} }
})
} }
pub async fn mutation_new_password( fn mutation_new_password(
payload: AuthNewPasswordRequestDto, payload: AuthNewPasswordRequestDto,
state: &AppState, state: &AppState,
) -> Response { ) -> Pin<Box<dyn Future<Output = Response> + Send>> {
let payload = payload;
let state = state.to_owned();
Box::pin(async move {
if let Err((status, message)) = validate_request(&payload) { if let Err((status, message)) = validate_request(&payload) {
return common_response(status, &message); return common_response(status, &message);
} }
let repo = UsersRepository::new(state); let repo = UsersRepository::new(&state);
let email = match extract_email_token(payload.token.clone()) { let user_repo = UsersRepository::new(&state);
Some(token) => token, let email = match extract_email_token_async(payload.token.clone()).await {
Some(email) => email,
None => { None => {
return common_response(StatusCode::BAD_REQUEST, "Invalid or missing token"); return common_response(StatusCode::BAD_REQUEST, "Invalid or missing token");
} }
}; };
let user = match user_repo.query_user_by_email(email).await {
Ok(user) => user,
Err(_) => return common_response(StatusCode::BAD_REQUEST, "User not found"),
};
let password = match hash_password(&payload.password) { let password = match hash_password(&payload.password) {
Ok(p) => p, Ok(p) => p,
Err(_e) => { Err(_e) => {
error!("Failed to hash new password for {}: {}", email, _e); error!("Failed to hash new password for {}: {}", user.email, _e);
return common_response( return common_response(
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
"Failed to hash password", "Failed to hash password",
); );
} }
}; };
let user = match repo.query_user_by_email(email.clone()).await {
Ok(user) if !user.is_deleted => user,
_ => {
return common_response(StatusCode::NOT_FOUND, "User not found");
}
};
let patch = UsersSchema { let patch = UsersSchema {
id: user.id.clone(), id: user.id.clone(),
password, password,
..Default::default() ..UsersSchema::from(user.clone())
}; };
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()),
} }
})
} }
} }
@@ -0,0 +1,96 @@
use axum::{
extract::{Query, State},
response::Redirect,
routing::get,
Json, Router, Extension,
};
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use std::sync::Arc;
use imphnen_libs::enviroment::ENV; // Import ENV
use crate::v1::auth::google::google_oauth_service::{AuthRequest, GoogleOauthService, GoogleOauthServiceImpl};
use imphnen_entities::error_dto::error::Error;
use crate::v1::auth::AuthLoginResponsetDto;
use crate::AppState;
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct GoogleAuthUrlResponse {
pub authorize_url: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct GoogleLoginRequest {
pub redirect_uri: Option<String>,
}
pub struct GoogleOauthController<T> {
google_oauth_service: T,
}
impl GoogleOauthController<GoogleOauthServiceImpl<crate::v1::auth::auth_service::AuthService, crate::v1::users::users_service::UsersService>> {
pub fn new() -> Self {
let google_oauth_service = GoogleOauthServiceImpl::<crate::v1::auth::auth_service::AuthService, crate::v1::users::users_service::UsersService>::with_services(
crate::v1::auth::auth_service::AuthService {},
crate::v1::users::users_service::UsersService {},
&ENV, // Pass a reference to the global ENV static
);
Self::with_service(google_oauth_service)
}
}
impl<T> GoogleOauthController<T>
where
T: GoogleOauthService<crate::v1::auth::auth_service::AuthService, crate::v1::users::users_service::UsersService> + Clone + Send + Sync + 'static,
{
pub fn with_service(google_oauth_service: T) -> Self {
Self {
google_oauth_service,
}
}
pub fn get_routes(&self) -> Router {
Router::new()
.route(
"/login",
get(
move |State(controller): State<Arc<Self>>, Query(params): Query<GoogleLoginRequest>| async move {
controller.google_oauth_login(params).await
},
),
)
.route(
"/callback",
get(
move |State(controller): State<Arc<Self>>, Extension(app_state): Extension<AppState>, Query(auth_request): Query<AuthRequest>| async move {
let controller = Arc::clone(&controller);
controller.google_oauth_callback(auth_request, &app_state).await
},
),
)
.with_state(Arc::new(self.clone()))
}
pub async fn google_oauth_login(&self, params: GoogleLoginRequest) -> Result<Redirect, Error> {
let (authorize_url, _csrf_state) = self.google_oauth_service.generate_auth_url(params.redirect_uri);
Ok(Redirect::to(authorize_url.as_str()))
}
pub async fn google_oauth_callback(&self, auth_request: AuthRequest, app_state: &AppState) -> Result<Json<AuthLoginResponsetDto>, Error> {
let (user, token) = self.google_oauth_service.google_oauth_callback(auth_request, app_state).await?;
let auth_response = AuthLoginResponsetDto {
user,
token,
};
Ok(Json(auth_response))
}
}
impl<T> Clone for GoogleOauthController<T>
where
T: GoogleOauthService<crate::v1::auth::auth_service::AuthService, crate::v1::users::users_service::UsersService> + Clone,
{
fn clone(&self) -> Self {
Self::with_service(self.google_oauth_service.clone())
}
}
@@ -0,0 +1,24 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
pub struct GoogleUser {
pub id: String,
pub email: String,
#[serde(default)]
pub verified_email: bool,
pub name: Option<String>,
pub given_name: Option<String>,
pub family_name: Option<String>,
pub picture: Option<String>,
pub locale: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct GoogleTokenResponse {
pub access_token: String,
pub expires_in: u64,
pub refresh_token: Option<String>,
pub scope: String,
pub token_type: String,
pub id_token: String,
}
@@ -0,0 +1,379 @@
use std::pin::Pin;
use std::future::Future;
use anyhow::Result;
use oauth2::{
AuthUrl, ClientId, ClientSecret, CsrfToken, PkceCodeChallenge, PkceCodeVerifier,
RedirectUrl, Scope, TokenUrl,
};
use serde::{Deserialize, Serialize};
use oauth2::url::Url;
use oauth2::TokenResponse;
use tracing::{info, error};
use imphnen_entities::error_dto::error::Error;
use imphnen_libs::{jsonwebtoken::{encode_access_token, encode_refresh_token}, enviroment::Env, AppState};
use imphnen_utils::{generate_oauth_csrf_token, validate_oauth_csrf_token, validate_csrf_token};
use crate::v1::auth::TokenDto;
use crate::v1::auth::auth_service::AuthServiceTrait;
use crate::v1::users::users_dto::{UsersDetailItemDto, UsersCreateRequestDto};
use crate::v1::users::users_service::UsersServiceTrait;
use super::google_oauth_dto::GoogleUser;
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct AuthRequest {
pub code: String,
pub state: String,
pub redirect_uri: Option<String>,
}
impl AuthRequest {
/// Validate the OAuth callback request
pub fn validate(&self) -> Result<(), Error> {
// Validate code parameter
if self.code.is_empty() || self.code.len() > 2048 {
return Err(Error::Validation("Invalid authorization code".to_string()));
}
// Validate state parameter
if self.state.is_empty() || self.state.len() > 512 {
return Err(Error::Validation("Invalid state parameter".to_string()));
}
// Basic format validation for authorization code
// OAuth 2.0 authorization codes can contain URL-safe characters including base64 characters
if !self.code.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_' || c == '.' || c == '~' || c == '/' || c == '+' || c == '=') {
return Err(Error::Validation("Authorization code contains invalid characters".to_string()));
}
Ok(())
}
/// Validate CSRF state token with signature verification and extract PKCE verifier
pub fn validate_csrf_state_and_get_pkce_verifier(&self, secret: &str) -> Result<PkceCodeVerifier, Error> {
// Maximum age of 30 minutes for OAuth flow (increased from 10)
const MAX_AGE_SECONDS: u64 = 300; // Changed from 30 minutes (1800s) to 5 minutes (300s)
let pkce_verifier_secret = validate_oauth_csrf_token(&self.state, secret, MAX_AGE_SECONDS)
.map_err(|e| {
error!("OAuth CSRF validation failed: {:?}", e);
Error::Auth("Invalid or expired OAuth CSRF state token".to_string())
})?;
Ok(PkceCodeVerifier::new(pkce_verifier_secret))
}
/// Validate CSRF state token with signature verification (legacy method for backward compatibility)
pub fn validate_csrf_state(&self, secret: &str) -> Result<(), Error> {
// Try OAuth CSRF validation first, if it fails, fall back to regular CSRF validation
match validate_oauth_csrf_token(&self.state, secret, 1800) {
Ok(_) => Ok(()),
Err(_) => {
// Fallback to regular CSRF validation for backward compatibility
validate_csrf_token(&self.state, secret, 600)
.map_err(|e| {
error!("CSRF validation failed: {:?}", e);
Error::Auth("Invalid or expired CSRF state token".to_string())
})
}
}
}
}
use crate::{RolesRepository, RolesEnum};
/// Helper function to get default role ID for new OAuth users
async fn get_default_role_id(app_state: &AppState) -> Result<String, Error> {
let role_repo = RolesRepository::new(app_state);
match role_repo.query_role_by_name(RolesEnum::User.to_string()).await {
Ok(role) => {
info!("Using default User role ID: {}", role.id);
Ok(role.id)
},
Err(e) => {
error!("Failed to retrieve User role: {:?}", e);
Err(Error::Anyhow(anyhow::Error::msg("Failed to get default role ID".to_string())))
}
}
}
pub trait GoogleOauthService<A: AuthServiceTrait + Send + Sync + 'static, U: UsersServiceTrait + Send + Sync + 'static>: Send + Sync + 'static {
// Removed new() from trait
fn with_services(auth_service: A, users_service: U, env: &'static Env) -> Self;
fn generate_auth_url(&self, custom_redirect_uri: Option<String>) -> (Url, CsrfToken);
fn google_oauth_callback(&self, auth_request: AuthRequest, app_state: &AppState) -> Pin<Box<dyn Future<Output = Result<(UsersDetailItemDto, TokenDto), Error>> + Send + '_>>; // Changed return type
}
#[derive(Clone)]
pub struct GoogleOauthServiceImpl<A: AuthServiceTrait, U: UsersServiceTrait> {
users_service: U,
env: &'static Env,
_auth_service: A,
}
impl GoogleOauthServiceImpl<crate::v1::auth::auth_service::AuthService, crate::v1::users::users_service::UsersService> {
/// Generate Google OAuth authorization URL
pub fn get_auth_url(&self, custom_redirect_uri: Option<String>) -> String {
let (auth_url, _csrf_token) = self.generate_auth_url(custom_redirect_uri);
auth_url.to_string()
}
}
impl<A, U> GoogleOauthService<A, U> for GoogleOauthServiceImpl<A, U>
where
A: AuthServiceTrait + Send + Sync + 'static,
U: UsersServiceTrait + Send + Sync + 'static,
{
fn with_services(auth_service: A, users_service: U, env: &'static Env) -> Self {
Self {
_auth_service: auth_service,
users_service,
env,
}
}
fn generate_auth_url(&self, custom_redirect_uri: Option<String>) -> (Url, CsrfToken) {
let google_client_id = ClientId::new(self.env.google_client_id.clone());
let google_client_secret = ClientSecret::new(self.env.google_client_secret.clone());
let auth_url = AuthUrl::new("https://accounts.google.com/o/oauth2/v2/auth".to_string())
.expect("Invalid authorization endpoint URL");
let token_url = TokenUrl::new("https://oauth2.googleapis.com/token".to_string())
.expect("Invalid token endpoint URL");
let redirect_uri = custom_redirect_uri.unwrap_or_else(|| self.env.google_redirect_url.clone());
let client = oauth2::basic::BasicClient::new(google_client_id)
.set_client_secret(google_client_secret)
.set_auth_uri(auth_url)
.set_token_uri(token_url)
.set_redirect_uri(
RedirectUrl::new(redirect_uri.clone())
.expect("Invalid redirect URL"),
);
info!("OAuth client configured with redirect URI: {}", redirect_uri);
let (pkce_code_challenge, pkce_code_verifier) = PkceCodeChallenge::new_random_sha256();
info!("Generated PKCE Code Challenge: {}", pkce_code_challenge.as_str());
info!("Generated PKCE Code Verifier: {}", pkce_code_verifier.secret());
// Generate a signed CSRF token with PKCE verifier for stateless validation
let csrf_token_str = generate_oauth_csrf_token(&self.env.access_token_secret, pkce_code_verifier.secret())
.unwrap_or_else(|_| uuid::Uuid::new_v4().to_string()); // Fallback to UUID if signing fails
let (auth_url, csrf_token) = client
.authorize_url(|| CsrfToken::new(csrf_token_str.clone()))
.add_scope(Scope::new("https://www.googleapis.com/auth/userinfo.email".to_string()))
.add_scope(Scope::new("https://www.googleapis.com/auth/userinfo.profile".to_string()))
.set_pkce_challenge(pkce_code_challenge)
.url();
(auth_url, csrf_token)
}
fn google_oauth_callback(&self, auth_request: AuthRequest, app_state: &AppState) -> Pin<Box<dyn Future<Output = Result<(UsersDetailItemDto, TokenDto), Error>> + Send + '_>> {
let self_clone = self; // Use reference instead of clone
let app_state = app_state.to_owned();
Box::pin(async move {
// Validate input parameters first
info!("Received OAuth callback request with state: {}", auth_request.state);
auth_request.validate()?;
// CRITICAL: Validate CSRF state token and extract PKCE verifier
let pkce_verifier = auth_request.validate_csrf_state_and_get_pkce_verifier(&self_clone.env.access_token_secret)?;
info!("Starting Google OAuth callback process");
info!("Redirect URI used: {:?}", auth_request.redirect_uri);
info!("PKCE verifier extracted: {}", pkce_verifier.secret());
info!("PKCE verifier extracted: {}", pkce_verifier.secret());
// Use the SAME redirect URI that was used for auth URL generation
// This is crucial for OAuth security and consistency
let google_client_id = ClientId::new(self_clone.env.google_client_id.clone());
let google_client_secret = ClientSecret::new(self_clone.env.google_client_secret.clone());
let auth_url = AuthUrl::new("https://accounts.google.com/o/oauth2/v2/auth".to_string())
.expect("Invalid authorization endpoint URL");
let token_url = TokenUrl::new("https://oauth2.googleapis.com/token".to_string())
.expect("Invalid token endpoint URL");
let redirect_uri = auth_request.redirect_uri.clone().unwrap_or_else(|| self_clone.env.google_redirect_url.clone());
let client = oauth2::basic::BasicClient::new(google_client_id)
.set_client_secret(google_client_secret)
.set_auth_uri(auth_url)
.set_token_uri(token_url)
.set_redirect_uri(
RedirectUrl::new(redirect_uri.clone())
.expect("Invalid redirect URL"),
);
info!("OAuth client configured with redirect URI: {}", redirect_uri);
// Debug the OAuth client configuration
let effective_redirect_uri = auth_request.redirect_uri.as_ref().unwrap_or(&self_clone.env.google_redirect_url);
info!("Effective redirect URI for OAuth client: {}", effective_redirect_uri);
info!("Google Client ID: {}", self_clone.env.google_client_id);
info!("Attempting to exchange authorization code with Google");
info!("Using PKCE verifier for secure exchange");
info!("Authorization code length: {}", auth_request.code.len());
let token_response = client
.exchange_code(oauth2::AuthorizationCode::new(auth_request.code.clone()))
.set_pkce_verifier(pkce_verifier)
.request_async(&reqwest::Client::new())
.await
.map_err(|e| {
error!("Failed to exchange OAuth code with Google: {}", e);
error!("OAuth code was: {}", auth_request.code);
error!("Redirect URI was: {:?}", auth_request.redirect_uri);
// Debug OAuth client configuration
error!("Google Client ID: {}", self_clone.env.google_client_id);
error!("OAuth client redirect URI configured: {}",
auth_request.redirect_uri.as_ref().unwrap_or(&self_clone.env.google_redirect_url));
// Try to extract more details from the error
match &e {
oauth2::RequestTokenError::ServerResponse(response) => {
error!("Google OAuth Server Response Error: {:?}", response);
},
oauth2::RequestTokenError::Request(req_err) => {
error!("Google OAuth Request Error: {:?}", req_err);
},
oauth2::RequestTokenError::Parse(parse_err, response_body) => {
error!("Google OAuth Parse Error: {:?}", parse_err);
error!("Response body: {:?}", response_body);
},
oauth2::RequestTokenError::Other(other) => {
error!("Google OAuth Other Error: {:?}", other);
},
}
Error::Auth("Authentication error: Failed to exchange authorization code".to_string())
})?;
info!("Successfully exchanged authorization code for access token");
// Extract access token from Google's response
let access_token = token_response.access_token().secret();
info!("Obtained access token from Google, fetching user info...");
let client = reqwest::Client::new();
let user_info_url = "https://www.googleapis.com/oauth2/v2/userinfo";
let google_user: GoogleUser = client
.get(user_info_url)
.bearer_auth(access_token)
.send()
.await
.map_err(|e| {
error!("Failed to fetch user info from Google: {}", e);
Error::Auth("Failed to fetch user information".to_string())
})?
.json()
.await
.map_err(|e| {
error!("Failed to parse user info from Google: {}", e);
Error::Auth("Failed to parse user information".to_string())
})?;
info!("Successfully retrieved user info for email: {}", google_user.email);
info!("Google user picture URL: {:?}", google_user.picture);
info!("Google user data: name={:?}, given_name={:?}, family_name={:?}, picture={:?}",
google_user.name, google_user.given_name, google_user.family_name, google_user.picture);
let user = self_clone.users_service.get_user_by_email(&google_user.email, &app_state).await?;
let user = match user {
Some(mut user) => {
info!("Existing user found for email: {}", google_user.email);
// Update avatar if user doesn't have one and Google provides one
if user.avatar.is_none() && google_user.picture.is_some() {
info!("Updating avatar for existing user: {}", google_user.email);
match U::update_user_avatar(&google_user.email, google_user.picture.clone(), &app_state).await {
Ok(_) => {
info!("Successfully updated avatar for user: {}", google_user.email);
user.avatar = google_user.picture.clone();
},
Err(e) => {
error!("Failed to update avatar for user {}: {:?}", google_user.email, e);
}
}
}
user
},
None => {
info!("Creating new user for email: {}", google_user.email);
// Get default role ID using robust lookup
let default_role_id = get_default_role_id(&app_state).await
.map_err(|e| {
error!("Failed to get default role ID: {:?}", e);
Error::Anyhow(anyhow::Error::msg("Failed to get default role ID for new user".to_string()))
})?;
let new_user = UsersCreateRequestDto {
email: google_user.email.clone(),
password: format!("GOOGLE_OAUTH_{}", uuid::Uuid::new_v4()), // Random placeholder
fullname: google_user.name.clone().unwrap_or_else(|| {
// Fallback: use given_name + family_name if available, otherwise use email prefix
match (&google_user.given_name, &google_user.family_name) {
(Some(given), Some(family)) => format!("{} {}", given, family),
(Some(given), None) => given.clone(),
(None, Some(family)) => family.clone(),
(None, None) => {
// Extract email prefix as last resort
google_user.email.split('@').next().unwrap_or("User").to_string()
}
}
}),
phone_number: "".to_string(), // Will be updated by user later
is_active: true,
role_id: default_role_id,
avatar: google_user.picture.clone(), // Set avatar from Google user picture
};
self_clone.users_service.create_user_by_dto(new_user, &app_state).await?
}
};
let permissions: Vec<String> = user.role.permissions.iter().map(|p| p.name.clone()).collect();
let access_token = encode_access_token(user.email.clone(), user.id.clone(), permissions.clone())
.map_err(|e| {
error!("Failed to generate access token for {}: {:?}", user.email, e);
Error::Auth("Failed to generate access token".to_string())
})?;
let permissions: Vec<String> = user.role.permissions.iter().map(|p| p.name.clone()).collect();
let refresh_token = encode_refresh_token(user.email.clone(), user.id.clone(), permissions)
.map_err(|e| {
error!("Failed to generate refresh token for {}: {:?}", user.email, e);
Error::Auth("Failed to generate refresh token".to_string())
})?;
let token_dto = TokenDto {
access_token,
refresh_token,
};
// Cache the user in auth repository for subsequent requests
let auth_repo = crate::v1::auth::AuthRepository::new(&app_state);
let user_query_dto: crate::v1::users::users_dto::UsersDetailQueryDto = (&user).into();
if let Err(err_store) = auth_repo.query_store_user(user_query_dto).await {
error!(
"Failed to store user cache for {}: {}",
user.email, err_store
);
// Don't fail the login, just log the error
error!("Google OAuth login succeeded but caching failed for user: {}", user.email);
} else {
info!("Successfully cached user {} after Google OAuth login", user.email);
}
info!("Successfully completed Google OAuth for user: {}", user.email);
Ok((user, token_dto))
})
}
}
+3
View File
@@ -0,0 +1,3 @@
pub mod google_oauth_controller;
pub mod google_oauth_dto;
pub mod google_oauth_service;
+2
View File
@@ -5,6 +5,7 @@ pub mod auth_dto;
pub mod auth_repository; pub mod auth_repository;
pub mod auth_schema; pub mod auth_schema;
pub mod auth_service; pub mod auth_service;
pub mod google;
pub use auth_dto::*; pub use auth_dto::*;
pub use auth_repository::*; pub use auth_repository::*;
@@ -13,6 +14,7 @@ pub use auth_service::*;
pub fn auth_router() -> Router { pub fn auth_router() -> Router {
Router::new() Router::new()
.nest("/google", google::google_oauth_controller::GoogleOauthController::new().get_routes())
.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("/login-mentor", post(auth_controller::post_login_mentor))
@@ -8,7 +8,7 @@ use crate::{
AppState, MessageResponseDto, MetaRequestDto, ResponseListSuccessDto, AppState, MessageResponseDto, MetaRequestDto, ResponseListSuccessDto,
ResponseSuccessDto, ResponseSuccessDto,
v1::{ v1::{
permissions_dto::{PermissionsItemDto, PermissionsRequestDto}, permissions_dto::{PermissionsItemDto, PermissionsRequestDto, PermissionsUpdateRequestDto},
permissions_service::PermissionsService, permissions_service::PermissionsService,
}, },
}; };
@@ -41,13 +41,13 @@ pub async fn get_permission_list(
Query(meta): Query<MetaRequestDto>, Query(meta): Query<MetaRequestDto>,
) -> impl IntoResponse { ) -> impl IntoResponse {
match permissions_guard( match permissions_guard(
&headers, headers,
state.clone(), Extension(state),
vec![PermissionsEnum::ReadListPermissions], vec![PermissionsEnum::ReadListPermissions],
) )
.await .await
{ {
Ok(_) => PermissionsService::get_permission_list(&state, meta).await, Ok((_claims, state)) => PermissionsService::get_permission_list(&state, meta).await,
Err(response) => response, Err(response) => response,
} }
} }
@@ -70,13 +70,13 @@ pub async fn get_permission_by_id(
Path(id): Path<String>, Path(id): Path<String>,
) -> impl IntoResponse { ) -> impl IntoResponse {
match permissions_guard( match permissions_guard(
&headers, headers,
state.clone(), Extension(state),
vec![PermissionsEnum::ReadDetailPermissions], vec![PermissionsEnum::ReadDetailPermissions],
) )
.await .await
{ {
Ok(_) => PermissionsService::get_permission_by_id(&state, id).await, Ok((_claims, state)) => PermissionsService::get_permission_by_id(&state, id).await,
Err(response) => response, Err(response) => response,
} }
} }
@@ -99,13 +99,13 @@ pub async fn post_create_permission(
Json(payload): Json<PermissionsRequestDto>, Json(payload): Json<PermissionsRequestDto>,
) -> impl IntoResponse { ) -> impl IntoResponse {
match permissions_guard( match permissions_guard(
&headers, headers,
state.clone(), Extension(state),
vec![PermissionsEnum::CreatePermissions], vec![PermissionsEnum::CreatePermissions],
) )
.await .await
{ {
Ok(_) => PermissionsService::create_role(&state, payload).await, Ok((_claims, state)) => PermissionsService::create_role(&state, payload).await,
Err(response) => response, Err(response) => response,
} }
} }
@@ -116,7 +116,7 @@ pub async fn post_create_permission(
("Bearer" = []) ("Bearer" = [])
), ),
path = "/v1/permissions/update/{id}", path = "/v1/permissions/update/{id}",
request_body = PermissionsRequestDto, request_body = PermissionsUpdateRequestDto,
responses( responses(
(status = 200, description = "Update permission", body = MessageResponseDto) (status = 200, description = "Update permission", body = MessageResponseDto)
), ),
@@ -126,16 +126,16 @@ pub async fn put_update_permission(
headers: axum::http::HeaderMap, headers: axum::http::HeaderMap,
Extension(state): Extension<AppState>, Extension(state): Extension<AppState>,
Path(id): Path<String>, Path(id): Path<String>,
Json(payload): Json<PermissionsRequestDto>, Json(payload): Json<PermissionsUpdateRequestDto>,
) -> impl IntoResponse { ) -> impl IntoResponse {
match permissions_guard( match permissions_guard(
&headers, headers,
state.clone(), Extension(state),
vec![PermissionsEnum::UpdatePermissions], vec![PermissionsEnum::UpdatePermissions],
) )
.await .await
{ {
Ok(_) => PermissionsService::update_permission(&state, payload, id).await, Ok((_claims, state)) => PermissionsService::update_permission(&state, payload, id).await,
Err(response) => response, Err(response) => response,
} }
} }
@@ -157,13 +157,13 @@ pub async fn delete_permission(
Path(id): Path<String>, Path(id): Path<String>,
) -> impl IntoResponse { ) -> impl IntoResponse {
match permissions_guard( match permissions_guard(
&headers, headers,
state.clone(), Extension(state),
vec![PermissionsEnum::DeletePermissions], vec![PermissionsEnum::DeletePermissions],
) )
.await .await
{ {
Ok(_) => PermissionsService::delete_permission(&state, id).await, Ok((_claims, state)) => PermissionsService::delete_permission(&state, id).await,
Err(response) => response, Err(response) => response,
} }
} }
@@ -9,6 +9,13 @@ pub struct PermissionsRequestDto {
pub name: String, pub name: String,
} }
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)]
pub struct PermissionsUpdateRequestDto {
#[validate(length(min = 1, message = "Permission name must not be empty"))]
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] #[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct PermissionsItemDto { pub struct PermissionsItemDto {
pub id: String, pub id: String,
@@ -1,38 +1,41 @@
use super::PermissionsEnum; use super::PermissionsEnum;
use crate::{AppState, AuthRepository, common_response, extract_email}; use crate::{AppState, common_response, decode_access_token};
use axum::{ use axum::{
http::{HeaderMap, StatusCode}, http::{HeaderMap, StatusCode},
response::Response, response::Response, Extension,
}; };
use axum_extra::headers::{authorization::Bearer, Authorization, HeaderMapExt};
// Removed imphnen_utils::make_thing as it's no longer needed here
pub async fn permissions_guard( pub async fn permissions_guard(
headers: &HeaderMap, headers: HeaderMap,
state: AppState, Extension(state): Extension<AppState>,
required_permissions: Vec<PermissionsEnum>, required_permissions: Vec<PermissionsEnum>,
) -> Result<(), Response> { ) -> Result<(imphnen_libs::jsonwebtoken::Claims, AppState), Response> {
let auth_repo = AuthRepository::new(&state); let auth_header = headers
let email = extract_email(headers).ok_or_else(|| { .typed_get::<Authorization<Bearer>>()
common_response( .ok_or_else(|| {
StatusCode::UNAUTHORIZED, common_response(
"Invalid or missing authorization token", StatusCode::UNAUTHORIZED,
) "Invalid or missing authorization token",
})?; )
let raw_user = auth_repo })?;
.query_get_stored_user(email.clone())
.await let token = auth_header.token();
let claims = decode_access_token(token)
.map_err(|_| { .map_err(|_| {
common_response( common_response(
StatusCode::UNAUTHORIZED, StatusCode::UNAUTHORIZED,
"User session expired or not found", "Invalid or expired token",
) )
})?; })?
let role = raw_user.role; .claims;
let role_permissions: Vec<String> =
role.permissions.into_iter().map(|perm| perm.name).collect();
// Use permissions from JWT for the check
for required in &required_permissions { for required in &required_permissions {
let required_str = required.to_string(); let required_str = required.to_string();
if !role_permissions.contains(&required_str) { if !claims.permissions.contains(&required_str) {
eprintln!(" MISSING REQUIRED PERMISSION: {required_str}"); eprintln!(" MISSING REQUIRED PERMISSION: {required_str}");
return Err(common_response( return Err(common_response(
StatusCode::FORBIDDEN, StatusCode::FORBIDDEN,
@@ -40,5 +43,6 @@ pub async fn permissions_guard(
)); ));
} }
} }
Ok(())
Ok((claims, state))
} }

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