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_LOG=debug
PORT=4099
SURREALDB_URL=ws://localhost:8000/rpc
SURREALDB_USERNAME=root
@@ -15,10 +41,9 @@ REDISDB_URL=localhost
FE_URL=http://localhost
MINIO_ENDPOINT=http://localhost:9000
MINIO_BUCKET_NAME=default_bucket
MINIO_ACCESS_KEY=your-minio-access-key
MINIO_SECRET_KEY=your-minio-secret-key
MAIL_USER=your-email@example.com
MAIL_PASSWORD=your-smtp-password
MAIL_HOST=smtp.gmail.com
MAIL_PORT=465
MAIL_SECURE=true
MINIO_ACCESS_KEY=minioadmin
MINIO_SECRET_KEY=minioadmin
MINIO_SECURE=false
GOOGLE_CLIENT_ID="your_google_client_id"
GOOGLE_CLIENT_SECRET="your_google_client_secret"
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]
oauth2 = "5.0.0"
reqwest = { version = "0.12.23", features = ["json"] }
serde_json = "1.0.142"
axum = { version = "0.8.4", features = ["multipart"] }
log = "0.4.25"
serde = { version = "1.0.217", features = ["derive"] }
serde_json = "1.0.138"
tokio = { version = "1.45.0", features = ["full"] }
log = "0.4.27"
serde = { version = "1.0.219", features = ["derive"] }
tokio = { version = "1.47.1", features = ["full"] }
argon2 = { version = "0.5.3", features = ["password-hash"] }
jsonwebtoken = "9.3.1"
chrono = "0.4.41"
utoipa = { version = "5.3.1", features = ["axum_extras"] }
utoipa-swagger-ui = { version = "9.0.0", features = ["axum"] }
lettre = { version = "0.11.16", features = ["tokio1-native-tls"] }
utoipa = { version = "5.4.0", features = ["axum_extras"] }
utoipa-swagger-ui = { version = "9.0.2", features = ["axum"] }
lettre = { version = "0.11.18", features = ["tokio1-native-tls"] }
surrealdb = { version = "2.3.7", features = ["kv-mem", "kv-fdb"] }
thiserror = "2.0.12"
anyhow = "1.0.98"
rand = { version = "0.9.1", features = ["std", "alloc"] }
thiserror = "2.0.14"
anyhow = "1.0.99"
rand = { version = "0.9.2", features = ["std", "alloc"] }
rand_distr = "0.5.1"
tower-http = { version = "0.6.4", features = ["cors", "trace"] }
http-body-util = "0.1.1"
tower-http = { version = "0.6.6", features = ["cors", "trace"] }
http-body-util = "0.1.3"
validator = { version = "0.20.0", features = ["derive"] }
lazy_static = "1.4.0"
lazy_static = "1.5.0"
regex = "1.11.1"
axum-test = "17.2.0"
fancy-regex = "0.15.0"
axum-test = "17.3.0"
axum-extra = { version = "0.10.1", features = ["typed-header"] }
fancy-regex = "0.16.1"
futures = "0.3.31"
tower = "0.5.2"
env_logger = "0.11.8"
dotenvy = "0.15.7"
tracing = "0.1.40"
tracing-subscriber = { version = "0.3.18", features = ["fmt"] }
once_cell = "1.21.3"
uuid = { version = "1.8.0", features = ["v4", "fast-rng", "serde"] }
strum = { version = "0.27.1", features = ["derive"] }
strum_macros = "0.27.1"
tracing = "0.1.41"
uuid = { version = "1.18.0", features = ["v4", "fast-rng", "serde"] }
strum = { version = "0.27.2", features = ["derive"] }
strum_macros = "0.27.2"
base64 = "0.22.1"
sha2 = "0.10.9"
hmac = "0.12"
hex = "0.4"
urlencoding = "2.1"
hyper = "1.6.0"
hyper-util = "0.1.0"
async-trait = "0.1.75"
tokio-tungstenite = "0.23"
hyper-util = "0.1.16"
minio = "0.3.0"
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"
futures-util = "0.3"
http = "1.3"
tests = { path = "./tests" }
imphnen-iam = { path = "./imphnen-iam" }
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
ports:
- "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"
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]
imphnen-libs.workspace = true
imphnen-utils.workspace = true
+5 -1
View File
@@ -1,3 +1,4 @@
use axum::Router;
use imphnen_gateway::gateway_service;
use imphnen_libs::axum_init;
@@ -5,7 +6,10 @@ use imphnen_libs::axum_init;
async fn main() {
env_logger::init();
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;
}
+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 (ws_stream, _) = connect_async(url).await?;
let (ws_stream, _) = connect_async(url.as_str()).await?;
let (mut write, mut read) = ws_stream.split();
// Authenticate (signin)
@@ -43,7 +43,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
"id": 1
}).to_string();
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_msg = signin_response?;
@@ -60,7 +60,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
"id": 2
}).to_string();
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_msg = use_response?;
@@ -82,7 +82,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
}).to_string();
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")?;
@@ -100,7 +100,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
}).to_string();
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::")?;
match delete_response_result {
@@ -137,7 +137,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
"params": [select_query],
"id": i + 1000
}).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")?;
match select_response_result {
Ok(select_msg) => {
+5 -6
View File
@@ -1,21 +1,20 @@
use imphnen_cms::v1::landing::events::events_schema::EventsSchema;
use imphnen_libs::enviroment::load_env;
use imphnen_utils::{get_iso_date, Env};
use imphnen_utils::{get_iso_date};
use std::error::Error;
use surrealdb::engine::any;
use surrealdb::{opt::auth::Root, sql::Thing, Uuid}; // Added Uuid
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
load_env();
let env = Env::new();
let env = &imphnen_libs::enviroment::ENV;
let db = any::connect(&env.surrealdb_url).await?;
db.signin(Root {
username: &env.surrealdb_username,
password: &env.surrealdb_password,
})
.await?;
db.use_ns(env.surrealdb_namespace)
.use_db(env.surrealdb_dbname)
db.use_ns(env.surrealdb_namespace.clone())
.use_db(env.surrealdb_dbname.clone())
.await?;
let events = vec![
+4 -7
View File
@@ -1,14 +1,11 @@
use imphnen_libs::enviroment::load_env;
use imphnen_utils::{get_iso_date, Env};
use imphnen_utils::{get_iso_date};
use std::error::Error;
use surrealdb::opt::auth::Root;
use surrealdb::sql::Thing;
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
load_env();
let env = Env::new();
let env = &imphnen_libs::enviroment::ENV;
use surrealdb::engine::any;
let db = any::connect(&env.surrealdb_url).await?;
db.signin(Root {
@@ -16,8 +13,8 @@ async fn main() -> Result<(), Box<dyn Error>> {
password: &env.surrealdb_password,
})
.await?;
db.use_ns(env.surrealdb_namespace)
.use_db(env.surrealdb_dbname)
db.use_ns(env.surrealdb_namespace.clone())
.use_db(env.surrealdb_dbname.clone())
.await?;
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_libs::enviroment::load_env;
use imphnen_utils::{get_iso_date, Env};
use imphnen_utils::{get_iso_date};
use serde_json::json;
use std::error::Error;
use surrealdb::engine::any;
@@ -8,17 +7,15 @@ use surrealdb::opt::auth::Root;
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
load_env();
let env = Env::new();
let env = &imphnen_libs::enviroment::ENV;
let db = any::connect(&env.surrealdb_url).await?;
db.signin(Root {
username: &env.surrealdb_username,
password: &env.surrealdb_password,
})
.await?;
db.use_ns(env.surrealdb_namespace)
.use_db(env.surrealdb_dbname)
db.use_ns(env.surrealdb_namespace.clone())
.use_db(env.surrealdb_dbname.clone())
.await?;
for permission in [
@@ -54,6 +51,7 @@ async fn main() -> Result<(), Box<dyn Error>> {
PermissionsEnum::ReadOwnMentorProfile,
PermissionsEnum::UpdateOwnMentorProfile,
PermissionsEnum::ReadOwnMentorStatus,
PermissionsEnum::UpdateMentors,
PermissionsEnum::VerifyMentors,
PermissionsEnum::DeleteMentors,
] {
+4 -6
View File
@@ -1,21 +1,19 @@
use imphnen_libs::enviroment::load_env;
use imphnen_utils::{get_iso_date, Env};
use imphnen_utils::{get_iso_date};
use serde_json::json;
use std::error::Error;
use surrealdb::engine::any;
use surrealdb::opt::auth::Root;
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
load_env();
let env = Env::new();
let env = &imphnen_libs::enviroment::ENV;
let db = any::connect(&env.surrealdb_url).await?;
db.signin(Root {
username: &env.surrealdb_username,
password: &env.surrealdb_password,
})
.await?;
db.use_ns(env.surrealdb_namespace)
.use_db(env.surrealdb_dbname)
db.use_ns(env.surrealdb_namespace.clone())
.use_db(env.surrealdb_dbname.clone())
.await?;
let roles = vec![
@@ -1,22 +1,28 @@
use imphnen_iam::{get_iso_date, make_thing, Env, PermissionsEnum};
use imphnen_libs::enviroment::load_env;
use imphnen_iam::{get_iso_date, make_thing, PermissionsEnum};
use std::error::Error;
use surrealdb::engine::any;
use surrealdb::opt::auth::Root;
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
load_env();
let env = Env::new();
let env = &imphnen_libs::enviroment::ENV;
let db = any::connect(&env.surrealdb_url).await?;
db.signin(Root {
username: &env.surrealdb_username,
password: &env.surrealdb_password,
})
.await?;
db.use_ns(env.surrealdb_namespace)
.use_db(env.surrealdb_dbname)
db.use_ns(env.surrealdb_namespace.clone())
.use_db(env.surrealdb_dbname.clone())
.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![
(
@@ -59,6 +65,7 @@ async fn main() -> Result<(), Box<dyn Error>> {
(
"3b9f8c4e-6a2d-4f8a-9a12-2d6f8b3c4e5a",
vec![
PermissionsEnum::ReadListUsers, // Added ReadListUsers permission
PermissionsEnum::ReadOwnMentorProfile,
PermissionsEnum::UpdateOwnMentorProfile,
PermissionsEnum::ReadOwnMentorStatus,
@@ -76,6 +83,8 @@ async fn main() -> Result<(), Box<dyn Error>> {
vec![
PermissionsEnum::ReadListGachaItems,
PermissionsEnum::ReadDetailGachaItems,
PermissionsEnum::ReadListUsers,
PermissionsEnum::ReadDetailUsers,
PermissionsEnum::CreateGachaClaims,
PermissionsEnum::ReadDetailGachaClaims,
PermissionsEnum::ReadDetailGachaRolls,
+24 -9
View File
@@ -1,13 +1,11 @@
use imphnen_iam::UsersSchema;
use imphnen_libs::enviroment::load_env;
use imphnen_utils::{get_iso_date, hash_password, Env};
use imphnen_utils::{get_iso_date, hash_password};
use std::error::Error;
use surrealdb::{opt::auth::Root, sql::Thing};
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
load_env();
let env = Env::new();
let env = &imphnen_libs::enviroment::ENV;
use surrealdb::engine::any;
let db = any::connect(&env.surrealdb_url).await?;
db.signin(Root {
@@ -15,8 +13,8 @@ async fn main() -> Result<(), Box<dyn Error>> {
password: &env.surrealdb_password,
})
.await?;
db.use_ns(env.surrealdb_namespace)
.use_db(env.surrealdb_dbname)
db.use_ns(env.surrealdb_namespace.clone())
.use_db(env.surrealdb_dbname.clone())
.await?;
let users = vec![
@@ -48,15 +46,32 @@ async fn main() -> Result<(), Box<dyn Error>> {
let user = UsersSchema {
id: Thing::from(("app_users", id)),
fullname: fullname.into(),
legal_name: Some(format!("{} Legal Name", fullname)),
email: email.into(),
password: hash_password("password").unwrap(),
avatar: None,
avatar: Some("https://example.com/avatar.jpg".into()),
phone_number: "081234567890".into(),
phone_for_verification: Some("081234567890".into()),
is_active: true,
is_deleted: false,
mentor_id: None,
gender: None,
birthdate: None,
gender: Some("male".into()),
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)),
created_at: get_iso_date(),
updated_at: get_iso_date(),
@@ -1,9 +1,10 @@
use super::{events_dto::EventsQueryDto, events_schema::EventsSchema};
use anyhow::{Result, bail};
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 tracing::instrument;
use tracing::info;
pub struct EventsRepository<'a> {
state: &'a AppState,
@@ -25,6 +26,7 @@ impl<'a> EventsRepository<'a> {
.with_pagination(meta.page, Some(10))
.with_sorting(meta.sort_by.as_deref(), meta.order.as_deref())
.build();
info!(query = %query, "Executing SurrealDB query");
let res: Vec<EventsQueryDto> =
self.state.surrealdb_ws.query(query).await?.take(0)?;
let elapsed = now.elapsed();
@@ -44,10 +46,20 @@ impl<'a> EventsRepository<'a> {
pub async fn query_event_by_id(&self, id: String) -> Result<EventsQueryDto> {
let now = Instant::now();
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())
.with_id(&id)
.with_id(&parsed_id)
.with_select_fields(vec!["*"]);
let sql = builder.build();
info!(query = %sql, "Executing SurrealDB query");
let result: Option<EventsQueryDto> =
builder.apply_bindings(db.query(sql)).await?.take(0)?;
let elapsed = now.elapsed();
@@ -72,6 +84,8 @@ impl<'a> EventsRepository<'a> {
pub async fn query_create_event(&self, data: EventsSchema) -> Result<String> {
let now = Instant::now();
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
.create(ResourceEnum::Events.to_string())
.content(data)
@@ -106,6 +120,8 @@ impl<'a> EventsRepository<'a> {
};
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 elapsed = now.elapsed();
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 query_str = format!("UPDATE {:?} MERGE {{ is_deleted: true }}", record_key);
info!(query = %query_str, "Executing SurrealDB query");
let record: Option<EventsSchema> = db
.update(record_key)
.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 std::time::Instant;
use tracing::instrument;
use tracing::info;
pub struct TestimonialsRepository<'a> {
state: &'a AppState,
@@ -28,6 +29,7 @@ impl<'a> TestimonialsRepository<'a> {
.with_pagination(meta.page, Some(10))
.with_sorting(meta.sort_by.as_deref(), meta.order.as_deref())
.build();
info!(query = %query, "Executing SurrealDB query");
let res: Vec<TestimonialsQueryDto> =
self.state.surrealdb_ws.query(query).await?.take(0)?;
let elapsed = now.elapsed();
@@ -55,6 +57,7 @@ impl<'a> TestimonialsRepository<'a> {
.with_condition("is_deleted = false")
.with_select_fields(vec!["*", "user.* as user"]);
let sql = builder.build();
info!(query = %sql, "Executing SurrealDB query");
let result: Option<TestimonialsQueryDto> =
builder.apply_bindings(db.query(sql)).await?.take(0)?;
let elapsed = now.elapsed();
@@ -79,9 +82,14 @@ impl<'a> TestimonialsRepository<'a> {
pub async fn query_create_testimonial(
&self,
data: TestimonialsSchema,
) -> Result<String> {
) -> Result<TestimonialsSchema> { // Change return type from String to TestimonialsSchema
let now = Instant::now();
let db = &self.state.surrealdb_ws;
info!(
resource = %ResourceEnum::Testimonials.to_string(),
payload = ?data,
"Executing SurrealDB create"
);
let record: Option<TestimonialsSchema> = db
.create(ResourceEnum::Testimonials.to_string())
.content(data)
@@ -94,7 +102,7 @@ impl<'a> TestimonialsRepository<'a> {
}
match record {
Some(_) => Ok("Success create testimonial".into()),
Some(created_testimonial) => Ok(created_testimonial), // Return the created testimonial
None => bail!("Failed to create testimonial"),
}
}
@@ -120,6 +128,11 @@ impl<'a> TestimonialsRepository<'a> {
};
let record_key = get_id(&merged.id)?;
info!(
record_key = ?record_key,
payload = ?merged,
"Executing SurrealDB update"
);
let record: Option<TestimonialsSchema> =
db.update(record_key).merge(merged).await?;
let elapsed = now.elapsed();
@@ -145,6 +158,10 @@ impl<'a> TestimonialsRepository<'a> {
}
let record_key = get_id(&testimonial.id)?;
info!(
record_key = ?record_key,
"Executing SurrealDB soft delete"
);
let record: Option<TestimonialsSchema> = db
.update(record_key)
.merge(serde_json::json!({ "is_deleted": true }))
@@ -11,7 +11,7 @@ use imphnen_libs::{
AppState, MetaRequestDto, ResponseListSuccessDto, ResponseSuccessDto,
};
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;
@@ -46,8 +46,8 @@ impl TestimonialsService {
Ok(testimonial) if !testimonial.is_deleted => {
success_response(ResponseSuccessDto {
data: TestimonialsDetailItemDto {
id: testimonial.id.id.to_raw(),
user_id: testimonial.user.id.id.to_raw(),
id: testimonial.id.to_raw(),
user_id: testimonial.user.id.to_raw(),
user_fullname: testimonial.user.fullname,
role: testimonial.role,
content: testimonial.content,
@@ -72,7 +72,19 @@ impl TestimonialsService {
let repo = TestimonialsRepository::new(state);
let schema = TestimonialsSchema::create(payload, &authenticated_user.id);
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()),
}
}
@@ -57,13 +57,13 @@ pub async fn get_mentor_list(
Query(meta): Query<MetaRequestDto>,
) -> Response {
match permissions_guard(
&headers,
app_state.clone(),
headers,
Extension(app_state),
vec![PermissionsEnum::ReadListMentors],
)
.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,
}
}
@@ -90,13 +90,13 @@ pub async fn get_mentor_by_id(
Path(id): Path<String>,
) -> Response {
match permissions_guard(
&headers,
app_state.clone(),
headers,
Extension(app_state),
vec![PermissionsEnum::ReadDetailMentors],
)
.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,
}
}
@@ -126,13 +126,13 @@ pub async fn put_update_mentor(
Json(dto): Json<MentorUpdateRequestDto>,
) -> Response {
match permissions_guard(
&headers,
app_state.clone(),
headers,
Extension(app_state),
vec![PermissionsEnum::UpdateMentors],
)
.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,
}
}
@@ -159,13 +159,13 @@ pub async fn delete_mentor(
Path(id): Path<String>,
) -> Response {
match permissions_guard(
&headers,
app_state.clone(),
headers,
Extension(app_state),
vec![PermissionsEnum::DeleteMentors],
)
.await
{
Ok(_) => MentorsService::delete_mentor(&app_state, &id).await,
Ok((_user, app_state)) => MentorsService::delete_mentor(&app_state, &id).await,
Err(response) => response,
}
}
@@ -195,13 +195,13 @@ pub async fn put_verify_mentor(
Json(dto): Json<MentorVerifyRequestDto>,
) -> Response {
match permissions_guard(
&headers,
app_state.clone(),
headers,
Extension(app_state),
vec![PermissionsEnum::VerifyMentors],
)
.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,
}
}
@@ -221,17 +221,17 @@ pub async fn put_verify_mentor(
)
)]
pub async fn get_mentor_me(
Extension(app_state): Extension<AppState>,
headers: HeaderMap,
Extension(app_state): Extension<AppState>,
) -> Response {
match permissions_guard(
&headers,
app_state.clone(),
headers.clone(),
Extension(app_state),
vec![PermissionsEnum::ReadOwnMentorProfile],
)
.await
{
Ok(_) => {
Ok((_user, app_state)) => {
let email = match extract_email(&headers) {
Some(email) => email,
None => {
@@ -268,18 +268,18 @@ pub async fn get_mentor_me(
)
)]
pub async fn put_update_mentor_me(
Extension(app_state): Extension<AppState>,
headers: HeaderMap,
Extension(app_state): Extension<AppState>,
Json(dto): Json<MentorUpdateRequestDto>,
) -> Response {
match permissions_guard(
&headers,
app_state.clone(),
headers.clone(),
Extension(app_state),
vec![PermissionsEnum::UpdateOwnMentorProfile],
)
.await
{
Ok(_) => {
Ok((_user, app_state)) => {
let email = match extract_email(&headers) {
Some(email) => email,
None => {
@@ -325,17 +325,17 @@ pub async fn put_update_mentor_no_id() -> Response {
)
)]
pub async fn get_mentor_status(
Extension(app_state): Extension<AppState>,
headers: HeaderMap,
Extension(app_state): Extension<AppState>,
) -> Response {
match permissions_guard(
&headers,
app_state.clone(),
headers.clone(),
Extension(app_state),
vec![PermissionsEnum::ReadOwnMentorStatus],
)
.await
{
Ok(_) => {
Ok((_user, app_state)) => {
let email = match extract_email(&headers) {
Some(email) => email,
None => {
@@ -18,15 +18,9 @@ pub struct MentorListResponseDto {
pub struct MentorDetailWithUserDto {
pub id: Thing,
pub user_id: Thing,
pub fullname: Option<String>,
pub email: Option<String>,
pub legal_name: String,
pub identity_document_url: String,
pub phone_for_verification: String,
pub bio: String,
pub linkedin_url: Option<String>,
pub github_url: Option<String>,
pub cv_url: Option<String>,
// Personal data is now in UsersSchema, access via user_id
// Removed: fullname, email, legal_name, identity_document_url,
// phone_for_verification, bio, linkedin_url, github_url, cv_url
pub industries: Vec<String>,
pub expertise: Vec<String>,
pub languages: Vec<String>,
@@ -47,15 +41,20 @@ pub struct MentorDetailWithUserDto {
pub struct MentorDetailResponseDto {
pub id: String,
pub user_id: String,
// Personal data fields from UsersSchema
pub fullname: Option<String>,
pub email: Option<String>,
pub legal_name: String,
pub identity_document_url: String,
pub phone_for_verification: String,
pub bio: String,
pub legal_name: Option<String>,
pub gender: Option<String>,
pub domicile: Option<String>,
pub phone_for_verification: Option<String>,
pub bio: Option<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>,
// Professional data from MentorSchema
pub industries: Vec<String>,
pub expertise: Vec<String>,
pub languages: Vec<String>,
@@ -94,9 +93,6 @@ pub struct MentorUpdateRequestDto {
pub gender: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub domicile: Option<String>,
#[validate(url(message = "Invalid identity document URL"))]
#[serde(skip_serializing_if = "Option::is_none")]
pub identity_document_url: Option<String>,
#[validate(length(
min = 10,
max = 15,
@@ -118,6 +114,7 @@ pub struct MentorUpdateRequestDto {
#[validate(url(message = "Invalid CV URL"))]
#[serde(skip_serializing_if = "Option::is_none")]
pub cv_url: Option<String>,
#[validate(url(message = "Invalid portfolio URL"))]
#[serde(skip_serializing_if = "Option::is_none")]
pub portfolio_url: Option<String>,
#[validate(length(min = 1, message = "At least 1 industry required"))]
@@ -279,18 +276,7 @@ pub struct MentoringRate {
pub struct MentorInsertDto {
pub id: Thing,
pub user_id: Option<Thing>,
pub email: Option<String>,
pub legal_name: String,
pub gender: Option<String>,
pub domicile: Option<String>,
pub identity_document_url: String,
pub phone_for_verification: String,
pub bio: String,
pub last_education: Option<String>,
pub linkedin_url: Option<String>,
pub github_url: Option<String>,
pub cv_url: Option<String>,
pub portfolio_url: Option<String>,
// Personal data removed - now stored in UsersSchema
pub industries: Vec<String>,
pub expertise: Vec<String>,
pub languages: Vec<String>,
@@ -313,18 +299,7 @@ impl From<MentorSchema> for MentorInsertDto {
MentorInsertDto {
id: schema.id,
user_id: schema.user_id,
email: schema.email,
legal_name: schema.legal_name,
gender: schema.gender,
domicile: schema.domicile,
identity_document_url: schema.identity_document_url,
phone_for_verification: schema.phone_for_verification,
bio: schema.bio,
last_education: schema.last_education,
linkedin_url: schema.linkedin_url,
github_url: schema.github_url,
cv_url: schema.cv_url,
portfolio_url: schema.portfolio_url,
// Personal data removed from MentorSchema
industries: schema.industries,
expertise: schema.expertise,
languages: schema.languages,
@@ -354,19 +329,10 @@ pub struct MentorVerifyRequestDto {
pub struct MentorDetailQueryDto {
pub id: Thing,
pub user_id: Thing,
pub fullname: Option<String>,
pub email: Option<String>,
pub legal_name: String,
pub gender: Option<String>,
pub domicile: Option<String>,
pub identity_document_url: String,
pub phone_for_verification: String,
pub bio: String,
pub last_education: Option<String>,
pub linkedin_url: Option<String>,
pub github_url: Option<String>,
pub cv_url: Option<String>,
pub portfolio_url: Option<String>,
// Personal data has been moved to UsersSchema
// Use user_id to get: fullname, email, legal_name, gender, domicile,
// identity_document_url, phone_for_verification, bio, last_education,
// linkedin_url, github_url, cv_url, portfolio_url
pub industries: Vec<String>,
pub expertise: Vec<String>,
pub languages: Vec<String>,
@@ -388,8 +354,8 @@ impl From<MentorDetailQueryDto> for MentorListResponseDto {
fn from(dto: MentorDetailQueryDto) -> Self {
Self {
id: extract_id(&dto.id),
fullname: dto.fullname,
email: dto.email,
fullname: None, // now in user table, must be populated from service layer
email: None, // now in user table, must be populated from service layer
status: dto.status,
created_at: dto.created_at,
updated_at: dto.updated_at,
@@ -402,15 +368,20 @@ impl From<MentorDetailQueryDto> for MentorDetailResponseDto {
Self {
id: extract_id(&dto.id),
user_id: extract_id(&dto.user_id),
fullname: dto.fullname,
email: dto.email,
legal_name: dto.legal_name,
identity_document_url: dto.identity_document_url,
phone_for_verification: dto.phone_for_verification,
bio: dto.bio,
linkedin_url: dto.linkedin_url,
github_url: dto.github_url,
cv_url: dto.cv_url,
// Personal data fields are populated in service layer from UsersSchema
fullname: None, // populated from user table in service layer
email: None, // populated from user table in service layer
legal_name: None, // populated from user table in service layer
gender: None, // populated from user table in service layer
domicile: None, // populated from user table in service layer
phone_for_verification: None, // populated from user table in service layer
bio: None, // populated from user table in service layer
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,
expertise: dto.expertise,
languages: dto.languages,
@@ -433,7 +404,7 @@ impl From<MentorSchema> for MentorRegisterResponseDto {
Self {
id: schema.id.to_string(),
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,
created_at: schema.created_at,
updated_at: schema.updated_at,
@@ -446,19 +417,7 @@ impl From<MentorDetailWithUserDto> for MentorDetailQueryDto {
MentorDetailQueryDto {
id: dto.id,
user_id: dto.user_id,
fullname: dto.fullname,
email: dto.email,
legal_name: dto.legal_name,
gender: None, // Frontend form implies these are optional, not present in original MentorDetailWithUserDto
domicile: None, // Frontend form implies these are optional, not present in original MentorDetailWithUserDto
identity_document_url: dto.identity_document_url,
phone_for_verification: dto.phone_for_verification,
bio: dto.bio,
last_education: None, // Frontend form implies these are optional, not present in original MentorDetailWithUserDto
linkedin_url: dto.linkedin_url,
github_url: dto.github_url,
cv_url: dto.cv_url,
portfolio_url: None, // Frontend form implies these are optional, not present in original MentorDetailWithUserDto
// Personal data removed from MentorDetailWithUserDto - now in UsersSchema
industries: dto.industries,
expertise: dto.expertise,
languages: dto.languages,
@@ -8,6 +8,7 @@ use imphnen_utils::{DetailQueryBuilder, QueryListBuilder, get_iso_date};
use serde_json::{Map, Value};
use std::time::Instant;
use tracing::instrument;
use tracing::info;
pub struct MentorsRepository<'a> {
pub state: &'a AppState,
@@ -27,19 +28,11 @@ impl<'a> MentorsRepository<'a> {
let db = &self.state.surrealdb_ws;
let mentors_table = ResourceEnum::Mentors.to_string();
let builder = QueryListBuilder::new(db, &mentors_table, &meta)
.search_field("legal_name")
.search_field("user_id.legal_name") // Search in user data instead
.select_fields(vec![
"id",
"user_id",
"user_id.fullname as fullname",
"email",
"legal_name",
"identity_document_url",
"phone_for_verification",
"bio",
"linkedin_url",
"github_url",
"cv_url",
// Personal data comes from user relation, not mentor table
"industries",
"expertise",
"languages",
@@ -79,19 +72,11 @@ impl<'a> MentorsRepository<'a> {
let now = Instant::now();
let db = &self.state.surrealdb_ws;
let mut builder = DetailQueryBuilder::new(ResourceEnum::Mentors.to_string())
.with_where("email", Some(email.clone()))
.with_where("user_id.email", Some(email.clone())) // Search in user table
.with_select_fields(vec![
"id",
"user_id",
"user_id.fullname as fullname",
"email",
"legal_name",
"identity_document_url",
"phone_for_verification",
"bio",
"linkedin_url",
"github_url",
"cv_url",
// Personal data comes from user relation, not mentor table
"industries",
"expertise",
"languages",
@@ -114,6 +99,7 @@ impl<'a> MentorsRepository<'a> {
}
let sql = builder.build();
info!(query = %sql, "Executing SurrealDB query in query_mentor_by_email");
let mentor_opt: Option<MentorDetailWithUserDto> =
builder.apply_bindings(db.query(sql)).await?.take(0)?;
let elapsed = now.elapsed();
@@ -141,15 +127,7 @@ impl<'a> MentorsRepository<'a> {
.with_select_fields(vec![
"id",
"user_id",
"user_id.fullname as fullname",
"email",
"legal_name",
"identity_document_url",
"phone_for_verification",
"bio",
"linkedin_url",
"github_url",
"cv_url",
// Personal data comes from user relation, not mentor table
"industries",
"expertise",
"languages",
@@ -172,6 +150,7 @@ impl<'a> MentorsRepository<'a> {
}
let sql = builder.build();
info!(query = %sql, "Executing SurrealDB query in query_mentor_by_id");
let mentor_opt: Option<MentorDetailWithUserDto> =
builder.apply_bindings(db.query(sql)).await?.take(0)?;
let elapsed = now.elapsed();
@@ -194,8 +173,10 @@ impl<'a> MentorsRepository<'a> {
let now = Instant::now();
let db = &self.state.surrealdb_ws;
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
.create(ResourceEnum::Mentors.to_string())
.create(resource)
.content(dto.clone())
.await?;
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()));
info!(query = ?record_key, "Executing SurrealDB update in query_update_mentor");
let record: Option<MentorSchema> =
db.update(record_key).merge(merged_data_json).await?;
let elapsed = now.elapsed();
@@ -282,6 +264,7 @@ impl<'a> MentorsRepository<'a> {
patch.insert("is_deleted".to_string(), Value::Bool(true));
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 elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
@@ -1,5 +1,5 @@
use super::{
IdentityAndVerification, MentorDetailQueryDto, MentorUpdateRequestDto,
MentorDetailQueryDto, MentorUpdateRequestDto,
MentoringLogistics, MentoringRate, ProfessionalProfile,
};
use imphnen_libs::ResourceEnum;
@@ -12,18 +12,9 @@ pub struct MentorSchema {
pub id: Thing,
#[serde(skip_serializing_if = "Option::is_none")]
pub user_id: Option<Thing>,
pub email: Option<String>,
pub legal_name: String,
pub gender: Option<String>,
pub domicile: Option<String>,
pub identity_document_url: String,
pub phone_for_verification: String,
pub bio: String,
pub last_education: Option<String>,
pub linkedin_url: Option<String>,
pub github_url: Option<String>,
pub cv_url: Option<String>,
pub portfolio_url: Option<String>,
// Personal data has been moved to UsersSchema - use user_id to reference
// phone_for_verification, bio, last_education, linkedin_url, github_url,
// cv_url, portfolio_url
pub industries: Vec<String>,
pub expertise: Vec<String>,
pub languages: Vec<String>,
@@ -52,18 +43,6 @@ impl Default for MentorSchema {
ResourceEnum::Users.to_string().as_str(),
&Uuid::new_v4().to_string(),
)),
email: None,
legal_name: String::new(),
gender: None,
domicile: None,
identity_document_url: String::new(),
phone_for_verification: String::new(),
bio: String::new(),
last_education: None,
linkedin_url: None,
github_url: None,
cv_url: None,
portfolio_url: None,
industries: Vec::new(),
expertise: Vec::new(),
languages: Vec::new(),
@@ -89,11 +68,9 @@ impl Default for MentorSchema {
impl MentorSchema {
pub fn create(
identity_and_verification: IdentityAndVerification,
professional_profile: ProfessionalProfile,
mentoring_logistics: MentoringLogistics,
user_id_raw: String,
email_str: String,
) -> Self {
Self {
id: make_thing(
@@ -101,18 +78,7 @@ impl MentorSchema {
&Uuid::new_v4().to_string(),
),
user_id: Some(make_thing(&ResourceEnum::Users.to_string(), &user_id_raw)),
email: Some(email_str),
legal_name: identity_and_verification.legal_name,
gender: identity_and_verification.gender,
domicile: identity_and_verification.domicile,
identity_document_url: identity_and_verification.identity_document_url,
phone_for_verification: identity_and_verification.phone_for_verification,
bio: professional_profile.bio,
last_education: professional_profile.last_education,
linkedin_url: professional_profile.linkedin_url,
github_url: professional_profile.github_url,
cv_url: professional_profile.cv_url,
portfolio_url: professional_profile.portfolio_url,
// Personal data now stored in UsersSchema, not here
industries: professional_profile.industries,
expertise: professional_profile.expertise,
languages: professional_profile.languages,
@@ -139,18 +105,7 @@ impl MentorSchema {
Self {
id: dto.id,
user_id: Some(dto.user_id),
email: dto.email,
legal_name: dto.legal_name,
gender: dto.gender,
domicile: dto.domicile,
identity_document_url: dto.identity_document_url,
phone_for_verification: dto.phone_for_verification,
bio: dto.bio,
last_education: dto.last_education,
linkedin_url: dto.linkedin_url,
github_url: dto.github_url,
cv_url: dto.cv_url,
portfolio_url: dto.portfolio_url,
// Personal data now comes from UsersSchema via user_id
industries: dto.industries,
expertise: dto.expertise,
languages: dto.languages,
@@ -170,40 +125,10 @@ impl MentorSchema {
}
pub fn update(mut self, dto: MentorUpdateRequestDto) -> Self {
// Update fields only if they are Some(value), otherwise preserve current value
if let Some(val) = dto.legal_name {
self.legal_name = val;
}
if let Some(val) = dto.gender {
self.gender = Some(val);
}
if let Some(val) = dto.domicile {
self.domicile = Some(val);
}
if let Some(val) = dto.identity_document_url {
self.identity_document_url = val;
}
if let Some(val) = dto.phone_for_verification {
self.phone_for_verification = val;
}
if let Some(val) = dto.bio {
self.bio = val;
}
if let Some(val) = dto.last_education {
self.last_education = Some(val);
}
if let Some(val) = dto.linkedin_url {
self.linkedin_url = Some(val);
}
if let Some(val) = dto.github_url {
self.github_url = Some(val);
}
if let Some(val) = dto.cv_url {
self.cv_url = Some(val);
}
if let Some(val) = dto.portfolio_url {
self.portfolio_url = Some(val);
}
// phone_for_verification, bio, last_education, linkedin_url, github_url,
// cv_url, portfolio_url) are now updated in UsersSchema, not here
// Only update professional fields that are still in MentorSchema
if let Some(val) = dto.industries {
self.industries = val;
}
@@ -60,6 +60,18 @@ impl MentorsService {
user_schema.fullname = dto.fullname.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();
let hashed_password = match imphnen_utils::hash_password(&dto.password) {
@@ -135,6 +147,18 @@ impl MentorsService {
fullname: dto.fullname.clone(),
password: hashed_password,
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(),
updated_at: imphnen_utils::get_iso_date(),
role: imphnen_utils::make_thing(
@@ -187,11 +211,9 @@ impl MentorsService {
}
let mentor_schema = MentorSchema::create(
dto.identity_and_verification,
dto.professional_profile,
dto.mentoring_logistics,
user_id.to_raw(),
final_user_email.clone(),
);
match mentor_repo.query_create_mentor(mentor_schema.clone()).await {
@@ -233,16 +255,27 @@ impl MentorsService {
pub async fn get_mentor_list(state: &AppState, meta: MetaRequestDto) -> Response {
let repo = MentorsRepository::new(state);
let user_repo = UsersRepository::new(state);
match repo.query_mentor_list(meta).await {
Ok(result) => {
let data: Vec<MentorListResponseDto> = result
.data
.into_iter()
.map(MentorDetailQueryDto::from)
.map(MentorListResponseDto::from)
.collect();
let mut mentor_list_data: Vec<MentorListResponseDto> = Vec::new();
for mentor_with_user in result.data {
let mentor_dto = MentorDetailQueryDto::from(mentor_with_user);
let mut list_item = MentorListResponseDto::from(mentor_dto.clone());
// 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 {
data,
data: mentor_list_data,
meta: result.meta,
})
}
@@ -251,12 +284,56 @@ impl MentorsService {
}
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));
match repo.query_mentor_by_id(&thing_id, false).await {
match mentor_repo.query_mentor_by_id(&thing_id, false).await {
Ok(mentor) => {
let dto = MentorDetailResponseDto::from(MentorDetailQueryDto::from(mentor));
success_response(ResponseSuccessDto { data: dto })
// Get user data separately
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()),
}
+1 -1
View File
@@ -26,5 +26,5 @@ pub fn mentors_router() -> Router {
.route("/update/{id}", put(put_update_mentor))
.route("/update", put(put_update_mentor_no_id))
.route("/delete/{id}", delete(delete_mentor))
.route("/verify/{id}", put(put(put_verify_mentor)))
.route("/verify/{id}", put(put_verify_mentor))
}
+1
View File
@@ -8,4 +8,5 @@ axum.workspace = true
serde.workspace = true
utoipa.workspace = true
surrealdb.workspace = true
anyhow.workspace = true
thiserror.workspace = true
+27
View File
@@ -9,6 +9,14 @@ pub mod error {
pub enum Error {
#[error("database error: {0}")]
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 {
@@ -18,6 +26,19 @@ pub mod error {
StatusCode::INTERNAL_SERVER_ERROR,
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()
}
@@ -28,4 +49,10 @@ pub mod error {
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>,
) -> impl IntoResponse {
match permissions_guard(
&headers,
state.clone(),
vec![PermissionsEnum::CreateGachaClaims],
headers,
Extension(state),
vec![PermissionsEnum::ReadDetailGachaClaims],
)
.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,
}
}
@@ -54,13 +54,13 @@ pub async fn post_create_gacha_claim(
Json(payload): Json<GachaClaimRequestDto>,
) -> impl IntoResponse {
match permissions_guard(
&headers,
state.clone(),
headers,
Extension(state),
vec![PermissionsEnum::CreateGachaClaims],
)
.await
{
Ok(_) => GachaClaimService::create_gacha_claim(&state, payload).await,
Ok((_user, state)) => GachaClaimService::create_gacha_claim(&state, payload).await,
Err(response) => response,
}
}
@@ -3,7 +3,7 @@ use crate::{AppState, ResourceEnum};
use anyhow::{Result, bail};
use imphnen_iam::DetailQueryBuilder;
use std::time::Instant;
use tracing::instrument;
use tracing::{instrument, info};
pub struct GachaClaimRepository<'a> {
state: &'a AppState,
@@ -27,6 +27,7 @@ impl<'a> GachaClaimRepository<'a> {
.with_fetch("item")
.with_fetch("user");
let sql = builder.build();
info!(query = %sql, "Executing SurrealDB query");
let result: Option<GachaClaimQueryDto> =
builder.apply_bindings(db.query(sql)).await?.take(0)?;
let elapsed = now.elapsed();
@@ -48,6 +49,11 @@ impl<'a> GachaClaimRepository<'a> {
) -> Result<String> {
let now = Instant::now();
let db = &self.state.surrealdb_ws;
info!(
resource = %ResourceEnum::GachaClaims.to_string(),
content = ?data,
"Executing SurrealDB create query"
);
let record: Option<GachaClaimSchema> = db
.create(ResourceEnum::GachaClaims.to_string())
.content(data)
@@ -4,7 +4,7 @@ use anyhow::{Result, bail};
use imphnen_iam::make_thing;
use std::time::Instant;
use surrealdb::Uuid;
use tracing::instrument;
use tracing::{instrument, info};
pub struct GachaCreditRepository<'a> {
state: &'a AppState,
@@ -27,6 +27,7 @@ impl<'a> GachaCreditRepository<'a> {
ResourceEnum::GachaCredits,
ResourceEnum::Users
);
info!(query = %sql, "Executing SurrealDB query");
let result: Vec<GachaCreditSchema> =
db.query(sql).bind(("user_id", user_id)).await?.take(0)?;
let elapsed = now.elapsed();
@@ -66,6 +67,7 @@ impl<'a> GachaCreditRepository<'a> {
bail!("No extra roll credits remaining");
}
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
.update((
&ResourceEnum::GachaCredits.to_string(),
@@ -91,6 +93,7 @@ impl<'a> GachaCreditRepository<'a> {
let db = &self.state.surrealdb_ws;
if let Some(mut credit) = self.query_by_user_id(payload.user_id.clone()).await? {
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
.update((
&ResourceEnum::GachaCredits.to_string(),
@@ -108,6 +111,7 @@ impl<'a> GachaCreditRepository<'a> {
available_rolls: payload.amount,
..Default::default()
};
info!(operation = "create", table = %ResourceEnum::GachaCredits.to_string(), "Executing SurrealDB create for add_credit");
let _: Option<GachaCreditSchema> = db
.create(ResourceEnum::GachaCredits.to_string())
.content(data)
@@ -1,5 +1,5 @@
use crate::{
AppState, GachaItemDto, GachaItemRequestDto, GachaItemService, MessageResponseDto,
AppState, GachaItemDto, GachaItemRequestDto, GachaItemUpdateRequestDto, GachaItemService, MessageResponseDto,
MetaRequestDto, ResponseListSuccessDto, ResponseSuccessDto,
};
use axum::{
@@ -36,13 +36,13 @@ pub async fn get_gacha_item_list(
Query(meta): Query<MetaRequestDto>,
) -> impl IntoResponse {
match permissions_guard(
&headers,
state.clone(),
headers,
Extension(state),
vec![PermissionsEnum::ReadListGachaItems],
)
.await
{
Ok(_) => GachaItemService::get_gacha_item_list(&state, meta).await,
Ok((_user, state)) => GachaItemService::get_gacha_item_list(&state, meta).await,
Err(response) => response,
}
}
@@ -65,13 +65,13 @@ pub async fn get_gacha_item_by_id(
Path(id): Path<String>,
) -> impl IntoResponse {
match permissions_guard(
&headers,
state.clone(),
headers,
Extension(state),
vec![PermissionsEnum::ReadDetailGachaItems],
)
.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,
}
}
@@ -94,13 +94,13 @@ pub async fn post_create_gacha_item(
Json(payload): Json<GachaItemRequestDto>,
) -> impl IntoResponse {
match permissions_guard(
&headers,
state.clone(),
headers,
Extension(state),
vec![PermissionsEnum::CreateGachaItems],
)
.await
{
Ok(_) => GachaItemService::create_gacha_item(&state, payload).await,
Ok((_user, state)) => GachaItemService::create_gacha_item(&state, payload).await,
Err(response) => response,
}
}
@@ -111,7 +111,7 @@ pub async fn post_create_gacha_item(
security(
("Bearer" = [])
),
request_body = GachaItemRequestDto,
request_body = GachaItemUpdateRequestDto,
responses(
(status = 200, description = "Update gacha item", body = MessageResponseDto)
),
@@ -121,16 +121,16 @@ pub async fn put_update_gacha_item(
headers: HeaderMap,
Extension(state): Extension<AppState>,
Path(id): Path<String>,
Json(payload): Json<GachaItemRequestDto>,
Json(payload): Json<GachaItemUpdateRequestDto>,
) -> impl IntoResponse {
match permissions_guard(
&headers,
state.clone(),
headers,
Extension(state),
vec![PermissionsEnum::UpdateGachaItems],
)
.await
{
Ok(_) => GachaItemService::update_gacha_item(&state, payload, id).await,
Ok((_user, state)) => GachaItemService::update_gacha_item(&state, payload, id).await,
Err(response) => response,
}
}
@@ -152,13 +152,13 @@ pub async fn delete_gacha_item(
Path(id): Path<String>,
) -> impl IntoResponse {
match permissions_guard(
&headers,
state.clone(),
headers,
Extension(state),
vec![PermissionsEnum::DeleteGachaItems],
)
.await
{
Ok(_) => GachaItemService::delete_gacha_item(&state, id).await,
Ok((_user, state)) => GachaItemService::delete_gacha_item(&state, id).await,
Err(response) => response,
}
}
@@ -11,6 +11,16 @@ pub struct GachaItemRequestDto {
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)]
pub struct GachaItemDto {
pub id: String,
@@ -9,6 +9,7 @@ use imphnen_utils::get_iso_date;
use serde_json::{Map, Value};
use std::time::Instant;
use tracing::instrument;
use tracing::info;
pub struct GachaItemRepository<'a> {
state: &'a AppState,
@@ -25,6 +26,11 @@ impl<'a> GachaItemRepository<'a> {
meta: MetaRequestDto,
) -> Result<ResponseListSuccessDto<Vec<GachaItemDto>>> {
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>> =
QueryListBuilder::new(
&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> {
let now = Instant::now();
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
.select((ResourceEnum::GachaItems.to_string(), id.clone()))
.await?;
@@ -79,6 +87,8 @@ impl<'a> GachaItemRepository<'a> {
) -> Result<String> {
let now = Instant::now();
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
.create(ResourceEnum::GachaItems.to_string())
.content(data)
@@ -111,6 +121,8 @@ impl<'a> GachaItemRepository<'a> {
created_at: existing.created_at,
..data.clone()
};
let surreal_query = format!("UPDATE {:?} MERGE ...", record_key);
info!(query = %surreal_query, "Executing SurrealDB query");
let record: Option<GachaItemSchema> =
db.update(record_key).merge(merged).await?;
let elapsed = now.elapsed();
@@ -139,6 +151,8 @@ impl<'a> GachaItemRepository<'a> {
patch.insert("is_deleted".to_string(), Value::Bool(true));
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 elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
@@ -43,4 +43,8 @@ impl GachaItemSchema {
..Default::default()
}
}
pub fn from_existing(existing: GachaItemSchema) -> Self {
existing
}
}
@@ -1,11 +1,12 @@
use crate::{
AppState, GachaItemDto, GachaItemRepository, GachaItemRequestDto, GachaItemSchema,
AppState, GachaItemDto, GachaItemRepository, GachaItemRequestDto, GachaItemUpdateRequestDto, GachaItemSchema,
MetaRequestDto, ResourceEnum, ResponseListSuccessDto, ResponseSuccessDto,
common_response, make_thing, success_list_response, success_response,
validate_request,
};
use axum::http::StatusCode;
use axum::response::Response;
use imphnen_utils::get_iso_date;
pub struct GachaItemService;
@@ -59,20 +60,33 @@ impl GachaItemService {
pub async fn update_gacha_item(
state: &AppState,
payload: GachaItemRequestDto,
payload: GachaItemUpdateRequestDto,
id: String,
) -> Response {
if let Err((status, message)) = validate_request(&payload) {
return common_response(status, &message);
}
let repo = GachaItemRepository::new(state);
let schema = GachaItemSchema {
id: make_thing(&ResourceEnum::GachaItems.to_string(), &id),
name: payload.name,
image_url: payload.image_url,
..Default::default()
// Get current gacha item data first
let _thing_id = make_thing(&ResourceEnum::GachaItems.to_string(), &id);
let current_item = match repo.query_gacha_item_by_id(id.clone()).await {
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),
Err(e) => {
if e.to_string().contains("not found") {
@@ -25,13 +25,13 @@ pub async fn get_detail_gacha_roll(
Path(id): Path<String>,
) -> impl IntoResponse {
match permissions_guard(
&headers,
state.clone(),
headers,
Extension(state),
vec![PermissionsEnum::ReadDetailGachaRolls],
)
.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,
}
}
@@ -54,13 +54,13 @@ pub async fn post_create_gacha_roll(
Json(payload): Json<GachaRollRequestDto>,
) -> impl IntoResponse {
match permissions_guard(
&headers,
state.clone(),
headers,
Extension(state),
vec![PermissionsEnum::CreateGachaRolls],
)
.await
{
Ok(_) => GachaRollService::create_gacha_roll(&state, payload).await,
Ok((_user, state)) => GachaRollService::create_gacha_roll(&state, payload).await,
Err(response) => response,
}
}
@@ -81,13 +81,13 @@ pub async fn post_execute_gacha_roll(
Extension(state): Extension<AppState>,
) -> impl IntoResponse {
match permissions_guard(
&headers,
state.clone(),
headers.clone(),
Extension(state),
vec![PermissionsEnum::ExecuteGachaRolls],
)
.await
{
Ok(_) => GachaRollService::execute_roll_once(headers, &state).await,
Ok((_user, state)) => GachaRollService::execute_roll_once(headers, &state).await,
Err(response) => response,
}
}
@@ -110,13 +110,13 @@ pub async fn delete_gacha_roll(
Path(id): Path<String>,
) -> impl IntoResponse {
match permissions_guard(
&headers,
state.clone(),
headers,
Extension(state),
vec![PermissionsEnum::DeleteGachaRolls],
)
.await
{
Ok(_) => GachaRollService::soft_delete_gacha_roll(&state, id).await,
Ok((_user, state)) => GachaRollService::soft_delete_gacha_roll(&state, id).await,
Err(response) => response,
}
}
@@ -10,6 +10,7 @@ use rand_distr::weighted::WeightedIndex;
use serde_json::{Map, Value};
use std::time::Instant;
use tracing::instrument;
use tracing::info;
pub struct GachaRollRepository<'a> {
state: &'a AppState,
@@ -33,6 +34,7 @@ impl<'a> GachaRollRepository<'a> {
.with_select_fields(vec!["*"])
.with_fetch("item");
let sql = builder.build();
info!(query = %sql, "Executing SurrealDB query");
let result: Option<GachaRollQueryDto> =
builder.apply_bindings(db.query(sql)).await?.take(0)?;
let elapsed = now.elapsed();
@@ -54,6 +56,7 @@ impl<'a> GachaRollRepository<'a> {
) -> Result<String> {
let now = Instant::now();
let db = &self.state.surrealdb_ws;
info!(query = "CREATE", "Executing SurrealDB create operation for GachaRolls");
let record: Option<GachaRollSchema> = db
.create(ResourceEnum::GachaRolls.to_string())
.content(data)
@@ -77,6 +80,7 @@ impl<'a> GachaRollRepository<'a> {
let table_name = ResourceEnum::GachaRolls.to_string();
let sql =
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 elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
@@ -87,6 +91,7 @@ impl<'a> GachaRollRepository<'a> {
Ok(result)
}
#[instrument]
pub fn roll_once(rolls: &[GachaRollQueryDto]) -> Option<GachaRollQueryDto> {
let filtered: Vec<_> = rolls
.iter()
@@ -120,6 +125,7 @@ impl<'a> GachaRollRepository<'a> {
patch.insert("is_deleted".to_string(), Value::Bool(true));
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 elapsed = now.elapsed();
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,
UsersListItemDto, UsersUpdateRequestDto, auth, permissions, roles, users,
};
use imphnen_iam::users::users_controller::FileUploadSchema;
use utoipa::{
Modify, OpenApi,
openapi::security::{Http, HttpAuthScheme, SecurityScheme},
@@ -53,6 +54,7 @@ use utoipa::{
users::users_controller::get_user_by_id,
users::users_controller::get_user_me,
users::users_controller::get_user_list,
users::users_controller::upload_file,
roles::roles_controller::get_role_list,
roles::roles_controller::get_role_by_id,
roles::roles_controller::post_create_role,
@@ -114,6 +116,7 @@ use utoipa::{
UsersListItemDto,
UsersUpdateRequestDto,
UsersCreateRequestDto,
FileUploadSchema,
GachaClaimItemDto,
GachaClaimRequestDto,
GachaItemDto,
+7 -1
View File
@@ -6,10 +6,13 @@ edition = "2024"
[dependencies]
imphnen-libs.workspace = true
imphnen-utils.workspace = true
imphnen-entities.workspace = true
axum.workspace = true
serde.workspace = true
serde_json.workspace = true
serde_json = { workspace = true }
oauth2 = { workspace = true }
reqwest = { workspace = true, features = ["json"] }
utoipa.workspace = true
lazy_static.workspace = true
regex.workspace = true
@@ -28,7 +31,10 @@ log.workspace = true
once_cell.workspace = true
tracing.workspace = true
uuid.workspace = true
axum-extra.workspace = true
[dev-dependencies]
dotenvy.workspace = true
tokio-test = { workspace = true }
mockall = { workspace = true }
http-body-util.workspace = true
+5 -2
View File
@@ -1,10 +1,12 @@
use super::{
AuthLoginRequestDto, AuthRefreshTokenRequestDto, AuthRegisterRequestDto,
AuthResendOtpRequestDto, AuthService, AuthVerifyEmailRequestDto,
AuthResendOtpRequestDto, AuthVerifyEmailRequestDto,
};
use crate::{AppState, v1::AuthLoginResponsetDto};
use crate::{AuthNewPasswordRequestDto, MessageResponseDto, ResponseSuccessDto};
use axum::{Extension, Json, response::IntoResponse};
use crate::v1::auth::auth_service::AuthServiceTrait;
use crate::v1::auth::auth_service::AuthService;
#[utoipa::path(
post,
@@ -137,7 +139,8 @@ pub async fn post_new_password(
tag = "Authentication"
)]
pub async fn post_refresh_token(
Extension(state): Extension<AppState>,
Json(payload): Json<AuthRefreshTokenRequestDto>,
) -> impl IntoResponse {
AuthService::mutation_refresh_token(payload).await
AuthService::mutation_refresh_token(payload, &state).await
}
+2 -2
View File
@@ -32,7 +32,7 @@ pub struct AuthLoginResponsetDto {
pub user: UsersDetailItemDto,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Default)]
pub struct TokenDto {
pub access_token: String,
pub refresh_token: String,
@@ -121,4 +121,4 @@ pub struct AuthSetNewPasswordRequestDto {
pub struct UserCacheSchema {
pub email: String,
pub permissions: Vec<String>,
}
}
@@ -7,6 +7,8 @@ use crate::{
use anyhow::{Result, anyhow, bail};
use chrono::{Duration, Utc};
use surrealdb::sql::Thing;
use tracing::instrument;
use tracing::info;
pub struct AuthRepository<'a> {
pub state: &'a AppState,
@@ -17,6 +19,7 @@ impl<'a> AuthRepository<'a> {
Self { state }
}
#[instrument(skip(self, user), err)]
pub async fn query_store_user(&self, user: UsersDetailQueryDto) -> Result<String> {
if user.email.trim().is_empty() {
bail!("Email is required");
@@ -30,12 +33,14 @@ impl<'a> AuthRepository<'a> {
permissions,
};
info!(query = %format!("DELETE FROM {} WHERE id = '{}'", table, user_id), "Executing SurrealDB query");
let _record: Option<UserCacheSchema> = self
.state
.surrealdb_mem
.delete::<Option<UserCacheSchema>>((table.clone(), user_id.clone()))
.await?;
info!(query = %format!("CREATE {}:{}", table, user_id), "Executing SurrealDB query");
let record: Option<UserCacheSchema> = self
.state
.surrealdb_mem
@@ -49,10 +54,12 @@ impl<'a> AuthRepository<'a> {
}
}
#[instrument(skip(self, email), err)]
pub async fn query_get_stored_user(
&self,
email: String,
) -> Result<UsersDetailQueryDto> {
info!(query = %format!("SELECT FROM {} WHERE id = '{}'", ResourceEnum::UsersCache.to_string(), email), "Executing SurrealDB query");
let user_cache: Option<UserCacheSchema> = self
.state
.surrealdb_mem
@@ -87,13 +94,29 @@ impl<'a> AuthRepository<'a> {
Ok(UsersDetailQueryDto {
id: Thing::from(("app_users".to_string(), email.clone())),
fullname: "Cached User".to_string(),
legal_name: None,
email: cache.email,
avatar: None,
phone_number: String::new(),
phone_for_verification: None,
is_active: true,
is_deleted: false,
gender: 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(),
role: role_detail_query_dto,
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> {
info!(query = %format!("DELETE FROM {} WHERE id = '{}'", ResourceEnum::UsersCache.to_string(), email), "Executing SurrealDB query");
let record: Option<UsersDetailQueryDto> = self
.state
.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> {
let table = ResourceEnum::OtpCache.to_string();
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?;
match result {
Some(data) => match Utc::now() > data.expires_at {
true => {
info!(query = %format!("DELETE FROM {} WHERE id = '{}'", table, email), "Executing SurrealDB query");
let _ = self
.state
.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> {
let expires_at = Utc::now() + Duration::seconds(300);
let table: String = ResourceEnum::OtpCache.to_string();
info!(query = %format!("CREATE {}:{}", table, email), "Executing SurrealDB query");
let record: Option<AuthOtpSchema> = self
.state
.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> {
info!(query = %format!("DELETE FROM {} WHERE id = '{}'", ResourceEnum::OtpCache.to_string(), email), "Executing SurrealDB query");
let record: Option<AuthOtpSchema> = self
.state
.surrealdb_mem
+211 -128
View File
@@ -1,38 +1,88 @@
use std::pin::Pin;
use std::future::Future;
use super::{
AuthLoginRequestDto, AuthLoginResponsetDto, AuthNewPasswordRequestDto,
AuthRefreshTokenRequestDto, AuthRegisterRequestDto, AuthRepository,
AuthResendOtpRequestDto, AuthVerifyEmailRequestDto, TokenDto,
};
use crate::{
AppState, Env, ResourceEnum, ResponseSuccessDto, RolesEnum, RolesRepository,
AppState, ResourceEnum, ResponseSuccessDto, RolesEnum, RolesRepository,
UsersDetailItemDto, UsersRepository, UsersSchema, common_response,
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,
verify_password,
};
use axum::{http::StatusCode, response::Response};
use surrealdb::Uuid;
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;
impl AuthService {
pub async fn mutation_login(
impl AuthServiceTrait for AuthService {
fn mutation_login(
payload: AuthLoginRequestDto,
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) {
return common_response(status, &message);
}
let user_repo = UsersRepository::new(state);
let auth_repo = AuthRepository::new(state);
let user_repo = UsersRepository::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) => {
let is_password_correct =
verify_password(&payload.password, &user.password).unwrap_or(false);
let is_password_correct = tokio::task::spawn_blocking({
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 {
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,
Err(_e) => {
error!(
"Failed to generate access token for {}: {}",
payload.email, _e
email, _e
);
return common_response(
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,
Err(_e) => {
error!(
"Failed to generate refresh token for {}: {}",
payload.email, _e
email, _e
);
return common_response(
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 {
error!(
"Failed to store user cache for {}: {}",
@@ -102,23 +157,30 @@ impl AuthService {
common_response(StatusCode::UNAUTHORIZED, &err_find.to_string())
}
}
})
}
pub async fn mutation_mentor_login(
fn mutation_mentor_login(
payload: AuthLoginRequestDto,
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) {
return common_response(status, &message);
}
let user_repo = UsersRepository::new(state);
let auth_repo = AuthRepository::new(state);
let user_repo = UsersRepository::new(&state);
let auth_repo = AuthRepository::new(&state);
match user_repo.query_user_by_email(payload.email.clone()).await {
Ok(user) => {
let is_password_correct =
verify_password(&payload.password, &user.password).unwrap_or(false);
let is_password_correct = tokio::task::spawn_blocking({
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 {
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,
Err(_e) => {
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,
Err(_e) => {
error!(
@@ -197,18 +261,22 @@ impl AuthService {
common_response(StatusCode::UNAUTHORIZED, &err_find.to_string())
}
}
})
}
pub async fn mutation_register(
fn mutation_register(
payload: AuthRegisterRequestDto,
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) {
return common_response(status, &message);
}
let user_repo = UsersRepository::new(state);
let auth_repo = AuthRepository::new(state);
let role_repo = RolesRepository::new(state);
let user_repo = UsersRepository::new(&state);
let auth_repo = AuthRepository::new(&state);
let role_repo = RolesRepository::new(&state);
let role = match role_repo
.query_role_by_name(RolesEnum::User.to_string())
.await
@@ -285,7 +353,7 @@ impl AuthService {
created_at: get_iso_date(),
updated_at: get_iso_date(),
role: role_thing,
is_active: true,
is_active: false,
..Default::default()
})
.await
@@ -296,16 +364,20 @@ impl AuthService {
common_response(StatusCode::INTERNAL_SERVER_ERROR, &err_create.to_string())
}
}
})
}
pub async fn mutation_resend_otp(
fn mutation_resend_otp(
payload: AuthResendOtpRequestDto,
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) {
return common_response(status, &message);
}
let user_repo = UsersRepository::new(state);
let user_repo = UsersRepository::new(&state);
if user_repo
.query_user_by_email(payload.email.clone())
.await
@@ -313,7 +385,7 @@ impl AuthService {
{
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 otp = generate_otp::OtpManager::generate_otp();
let message = format!("Your OTP code is {otp}");
@@ -333,34 +405,48 @@ impl AuthService {
common_response(StatusCode::BAD_REQUEST, &err_store.to_string())
}
}
})
}
pub async fn mutation_refresh_token(
fn mutation_refresh_token(
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) {
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) => {
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,
Err(_e) => {
error!("Failed to generate access token for {}: {}", email, _e);
error!("Failed to generate access token for {}: {}", user.email, _e);
return common_response(
StatusCode::INTERNAL_SERVER_ERROR,
"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,
Err(_e) => {
error!("Failed to generate refresh token for {}: {}", email, _e);
error!("Failed to generate refresh token for {}: {}", user.email, _e);
return common_response(
StatusCode::INTERNAL_SERVER_ERROR,
"Failed to generate refresh token",
@@ -374,148 +460,145 @@ impl AuthService {
},
};
success_response(response)
})
}
pub async fn mutation_forgot_password(
fn mutation_forgot_password(
payload: AuthResendOtpRequestDto,
state: &AppState,
) -> Response {
if let Err((status, message)) = validate_request(&payload) {
return common_response(status, &message);
}
let user_repo = UsersRepository::new(state);
let user_result = user_repo.query_user_by_email(payload.email.clone()).await;
let user = match user_result {
Ok(user) => user,
Err(err_find) if err_find.to_string().contains("User not found") => {
return common_response(StatusCode::BAD_REQUEST, "User not found");
}
Err(err_other) => {
error!(
"Error finding user for forgot password {}: {}",
payload.email, err_other
);
return common_response(
StatusCode::INTERNAL_SERVER_ERROR,
&err_other.to_string(),
);
}
};
let token = match encode_reset_password_token(user.email.clone()) {
Ok(token) => token,
Err(_e) => {
error!(
"Failed to generate reset password token for {}: {}",
user.email, _e
);
return common_response(
StatusCode::INTERNAL_SERVER_ERROR,
"Failed to generate access token",
);
}
};
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())
}
}
) -> 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) {
return common_response(status, &message);
}
tokio::spawn(async move {
let user_repo = UsersRepository::new(&state);
if let Ok(user) = user_repo.query_user_by_email(payload.email.clone()).await {
let permissions: Vec<String> = user.role.permissions.iter().map(|p| p.name.clone()).collect();
let token = match encode_reset_password_token(user.email.clone(), user.id.id.to_raw(), permissions) {
Ok(token) => token,
Err(_e) => {
error!("Failed to generate reset password token for {}: {}", user.email, _e);
return;
}
};
let env = &crate::enviroment::ENV;
let fe_url = env.fe_url.clone();
let message = format!(
"You have requested a password reset. Please click the link below to continue: {fe_url}/auth/reset-password?token={token}"
);
if let Err(err_send) = send_email(&payload.email, "Reset Password Request", &message) {
error!("Failed to send reset password email to {}: {}", payload.email, err_send);
}
}
});
common_response(StatusCode::OK, "If your email is registered, you will receive a password reset link.")
})
}
pub async fn mutation_verify_email(
fn mutation_verify_email(
payload: AuthVerifyEmailRequestDto,
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) {
return common_response(status, &message);
}
let user_repo = UsersRepository::new(state);
let auth_repo = AuthRepository::new(state);
let user_repo = UsersRepository::new(&state);
let auth_repo = AuthRepository::new(&state);
let email = payload.email.clone();
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");
}
};
if user.is_active {
return common_response(StatusCode::BAD_REQUEST, "User already active");
}
let patch = UsersSchema {
id: user.id.clone(),
is_active: true,
..UsersSchema::from(user.clone())
};
match auth_repo.query_get_stored_otp(email.clone()).await {
Ok(stored_otp) => match stored_otp == payload.otp {
true => 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())
Ok(stored_otp) => {
if stored_otp != payload.otp {
// Delete OTP even if it doesn't match
let _ = auth_repo.query_delete_stored_otp(email.clone()).await;
return common_response(StatusCode::BAD_REQUEST, "Failed to verify OTP");
}
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) => {
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_update) => common_response(StatusCode::BAD_REQUEST, &err_update.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,
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) {
return common_response(status, &message);
}
let repo = UsersRepository::new(state);
let email = match extract_email_token(payload.token.clone()) {
Some(token) => token,
let repo = UsersRepository::new(&state);
let user_repo = UsersRepository::new(&state);
let email = match extract_email_token_async(payload.token.clone()).await {
Some(email) => email,
None => {
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) {
Ok(p) => p,
Err(_e) => {
error!("Failed to hash new password for {}: {}", email, _e);
error!("Failed to hash new password for {}: {}", user.email, _e);
return common_response(
StatusCode::INTERNAL_SERVER_ERROR,
"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 {
id: user.id.clone(),
password,
..Default::default()
..UsersSchema::from(user.clone())
};
match repo.query_update_user(patch).await {
Ok(msg) => common_response(StatusCode::OK, &msg),
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_schema;
pub mod auth_service;
pub mod google;
pub use auth_dto::*;
pub use auth_repository::*;
@@ -13,6 +14,7 @@ pub use auth_service::*;
pub fn auth_router() -> Router {
Router::new()
.nest("/google", google::google_oauth_controller::GoogleOauthController::new().get_routes())
.route("/forgot", post(auth_controller::post_forgot_password))
.route("/login", post(auth_controller::post_login))
.route("/login-mentor", post(auth_controller::post_login_mentor))
@@ -8,7 +8,7 @@ use crate::{
AppState, MessageResponseDto, MetaRequestDto, ResponseListSuccessDto,
ResponseSuccessDto,
v1::{
permissions_dto::{PermissionsItemDto, PermissionsRequestDto},
permissions_dto::{PermissionsItemDto, PermissionsRequestDto, PermissionsUpdateRequestDto},
permissions_service::PermissionsService,
},
};
@@ -41,13 +41,13 @@ pub async fn get_permission_list(
Query(meta): Query<MetaRequestDto>,
) -> impl IntoResponse {
match permissions_guard(
&headers,
state.clone(),
headers,
Extension(state),
vec![PermissionsEnum::ReadListPermissions],
)
.await
{
Ok(_) => PermissionsService::get_permission_list(&state, meta).await,
Ok((_claims, state)) => PermissionsService::get_permission_list(&state, meta).await,
Err(response) => response,
}
}
@@ -70,13 +70,13 @@ pub async fn get_permission_by_id(
Path(id): Path<String>,
) -> impl IntoResponse {
match permissions_guard(
&headers,
state.clone(),
headers,
Extension(state),
vec![PermissionsEnum::ReadDetailPermissions],
)
.await
{
Ok(_) => PermissionsService::get_permission_by_id(&state, id).await,
Ok((_claims, state)) => PermissionsService::get_permission_by_id(&state, id).await,
Err(response) => response,
}
}
@@ -99,13 +99,13 @@ pub async fn post_create_permission(
Json(payload): Json<PermissionsRequestDto>,
) -> impl IntoResponse {
match permissions_guard(
&headers,
state.clone(),
headers,
Extension(state),
vec![PermissionsEnum::CreatePermissions],
)
.await
{
Ok(_) => PermissionsService::create_role(&state, payload).await,
Ok((_claims, state)) => PermissionsService::create_role(&state, payload).await,
Err(response) => response,
}
}
@@ -116,7 +116,7 @@ pub async fn post_create_permission(
("Bearer" = [])
),
path = "/v1/permissions/update/{id}",
request_body = PermissionsRequestDto,
request_body = PermissionsUpdateRequestDto,
responses(
(status = 200, description = "Update permission", body = MessageResponseDto)
),
@@ -126,16 +126,16 @@ pub async fn put_update_permission(
headers: axum::http::HeaderMap,
Extension(state): Extension<AppState>,
Path(id): Path<String>,
Json(payload): Json<PermissionsRequestDto>,
Json(payload): Json<PermissionsUpdateRequestDto>,
) -> impl IntoResponse {
match permissions_guard(
&headers,
state.clone(),
headers,
Extension(state),
vec![PermissionsEnum::UpdatePermissions],
)
.await
{
Ok(_) => PermissionsService::update_permission(&state, payload, id).await,
Ok((_claims, state)) => PermissionsService::update_permission(&state, payload, id).await,
Err(response) => response,
}
}
@@ -157,13 +157,13 @@ pub async fn delete_permission(
Path(id): Path<String>,
) -> impl IntoResponse {
match permissions_guard(
&headers,
state.clone(),
headers,
Extension(state),
vec![PermissionsEnum::DeletePermissions],
)
.await
{
Ok(_) => PermissionsService::delete_permission(&state, id).await,
Ok((_claims, state)) => PermissionsService::delete_permission(&state, id).await,
Err(response) => response,
}
}
@@ -9,6 +9,13 @@ pub struct PermissionsRequestDto {
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)]
pub struct PermissionsItemDto {
pub id: String,
@@ -1,38 +1,41 @@
use super::PermissionsEnum;
use crate::{AppState, AuthRepository, common_response, extract_email};
use crate::{AppState, common_response, decode_access_token};
use axum::{
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(
headers: &HeaderMap,
state: AppState,
headers: HeaderMap,
Extension(state): Extension<AppState>,
required_permissions: Vec<PermissionsEnum>,
) -> Result<(), Response> {
let auth_repo = AuthRepository::new(&state);
let email = extract_email(headers).ok_or_else(|| {
common_response(
StatusCode::UNAUTHORIZED,
"Invalid or missing authorization token",
)
})?;
let raw_user = auth_repo
.query_get_stored_user(email.clone())
.await
) -> Result<(imphnen_libs::jsonwebtoken::Claims, AppState), Response> {
let auth_header = headers
.typed_get::<Authorization<Bearer>>()
.ok_or_else(|| {
common_response(
StatusCode::UNAUTHORIZED,
"Invalid or missing authorization token",
)
})?;
let token = auth_header.token();
let claims = decode_access_token(token)
.map_err(|_| {
common_response(
StatusCode::UNAUTHORIZED,
"User session expired or not found",
"Invalid or expired token",
)
})?;
let role = raw_user.role;
let role_permissions: Vec<String> =
role.permissions.into_iter().map(|perm| perm.name).collect();
})?
.claims;
// Use permissions from JWT for the check
for required in &required_permissions {
let required_str = required.to_string();
if !role_permissions.contains(&required_str) {
if !claims.permissions.contains(&required_str) {
eprintln!(" MISSING REQUIRED PERMISSION: {required_str}");
return Err(common_response(
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