Compare commits

..
Author SHA1 Message Date
MythEclipse 0b51a56dee feat: Enhance CORS middleware to dynamically include development port and allow OPTIONS method 2025-08-17 16:06:21 +07:00
MythEclipse cb98440b65 feat: Refactor update_user_avatar method to be static and enhance avatar deletion logic 2025-08-17 15:22:15 +07:00
MythEclipse 3c19d262f3 feat: Increase CSRF token maximum age to 30 minutes and enhance logging for token generation 2025-08-17 15:08:58 +07:00
MythEclipse bdffe85544 Update dependencies and refactor OAuth service
- Updated various dependencies in Cargo.toml to their latest versions for improved performance and security.
- Refactored the Google OAuth service to streamline the creation of the OAuth client and improve code readability.
- Changed the way WebSocket messages are sent in clear_db_test.rs to use `into()` for better type handling.
- Enhanced error handling and logging in the Google OAuth callback method.
2025-08-16 19:10:27 +07:00
MythEclipse 9ed619e3fd feat: Optimize password verification by using blocking tasks for improved performance 2025-08-16 13:47:29 +07:00
MythEclipse 00fd7e1907 feat: Refactor password hashing and JWT encoding to use static keys for improved performance and security 2025-08-16 13:43:18 +07:00
MythEclipse b70126734e feat: Refactor password reset flow to use async processing and improve token validation 2025-08-16 13:38:35 +07:00
MythEclipse 6af9461079 a 2025-08-16 13:22:39 +07:00
MythEclipse 57b3662951 feat: Refactor auth service and middleware for improved user data handling and caching 2025-08-16 13:18:55 +07:00
MythEclipse c0c08f2821 Enhance Users Service and DTOs
- Added Default trait to UsersDetailItemDto for easier instantiation.
- Refactored UsersServiceTrait to use Pin<Box<dyn Future<...>> for async functions, improving compatibility with async/await.
- Updated all service methods to return futures instead of using async_trait.
- Improved error handling and logging in upload_file method, ensuring proper handling of multipart data.
- Masked sensitive information in Env struct's Debug implementation for better security in logs.
- Cleaned up Cargo.toml by removing unnecessary async-trait workspace dependency.
2025-08-16 12:48:24 +07:00
MythEclipse 8402415649 feat: Enhance user permissions and update event handling with improved response structures 2025-08-15 00:32:15 +07:00
MythEclipse 01e20baa45 feat: Add axum-extra dependency and refactor auth middleware for improved token handling 2025-08-14 23:16:41 +07:00
MythEclipse 10dc869eaf feat: Enhance permissions and roles management with improved indexing and state handling 2025-08-14 23:11:44 +07:00
MythEclipse 199d0c885e feat: Add axum-extra dependency and implement typed headers
feat: Define unique index on users table for email and fix minor syntax error

refactor: Update permissions_guard to use claims from JWT and improve user retrieval

refactor: Modify user-related service methods to accept user details directly

fix: Update token generation functions to include user ID and permissions

test: Update Google OAuth flow tests to reflect changes in token generation
2025-08-14 22:46:42 +07:00
MythEclipse 89ef48a57e feat: Update permissions for user and gacha claim access 2025-08-14 21:38:47 +07:00
MythEclipse da1826ebba feat: Add file upload with deduplication to MinIO service 2025-08-14 17:03:23 +07:00
MythEclipse 874986f1d6 feat: Implement file upload functionality with MinIO integration
- Added new dependencies for HMAC, hex, and urlencoding in Cargo.toml.
- Introduced FileUploadSchema for handling multipart file uploads.
- Enhanced UsersService with upload_file method to handle file uploads to MinIO.
- Implemented MinioService for managing MinIO interactions, including file uploads and presigned URL generation.
- Updated environment configuration to include MinIO region and secure settings.
- Added validation for file types and sizes during upload.
- Improved error handling and logging for file upload processes.
- Created utility functions for base64 decoding and content type extraction.
2025-08-14 16:53:14 +07:00
MythEclipse bbff555a06 feat(docs): add upload_file endpoint to OpenAPI documentation 2025-08-14 14:16:07 +07:00
Asep Haryana Saputra d3eb687a58 Merge branch 'feat/auth-google' of https://github.com/IMPHNEN/imphnen-backend-service into feat/auth-google 2025-08-14 06:55:26 +00:00
Asep Haryana Saputra c5516d4006 a 2025-08-14 06:54:56 +00:00
MythEclipse 5f51dd281d feat: update dependencies and add MinIO integration
- Updated `async-channel` to version 2.5.0 and added new dependencies in `Cargo.lock`.
- Introduced `minio` crate for file upload functionality.
- Added `career_status` field to user-related DTOs and schemas.
- Implemented file upload endpoint in `users_controller.rs` with multipart support.
- Created `MinioService` for handling file uploads to MinIO.
- Updated user seeding and test cases to accommodate new `career_status` field.
- Refactored permissions guard to return user details.
2025-08-14 13:16:39 +07:00
MythEclipse f6232832af feat(users): Enhance user schema and DTOs with additional fields for website, social links, location, skills, experience, and education 2025-08-13 20:40:02 +07:00
MythEclipse 277cf0770d feat(auth): Add GOOGLE_REDIRECT_URL to .env.example for Google OAuth callback 2025-08-13 01:29:41 +07:00
MythEclipse 650728a385 feat(auth): Update Google OAuth service to support custom redirect URIs 2025-08-12 23:21:34 +07:00
MythEclipse 3cc3e2a7d8 feat(auth): Update Google OAuth flow to support custom redirect URIs 2025-08-12 22:06:26 +07:00
MythEclipse 39fc2f86ad feat(users): Include avatar field in user creation from DTO 2025-08-12 19:50:52 +07:00
Asep Haryana Saputra 7bd0ef2274 a 2025-08-12 12:41:16 +00:00
MythEclipse b40a430c49 feat(auth): Enhance Google OAuth flow with async email extraction and caching 2025-08-12 19:25:10 +07:00
MythEclipse 44b1e09551 fix(users): Change meta parameter extraction from Json to Query in get_user_list function 2025-08-12 18:53:11 +07:00
MythEclipse 1b9251b2dd Refactor mentor and user schemas to centralize personal data management
- Updated `seed_mentor_user.rs` to include additional fields in the `app_users` creation and removed redundant fields from `app_mentors`.
- Modified `seed_users.rs` to initialize new user fields including legal name, domicile, and professional links.
- Adjusted `mentors_dto.rs` to remove personal data fields from `MentorDetailWithUserDto` and `MentorDetailResponseDto`, now sourced from `UsersSchema`.
- Updated `mentors_repository.rs` to search for user data through `user_id` instead of mentor fields.
- Refined `mentors_schema.rs` to remove personal data fields, now managed in `UsersSchema`.
- Enhanced `mentors_service.rs` to populate mentor responses with user data from `UsersSchema`.
- Removed identity document URL and related fields from various DTOs and schemas across the codebase.
- Updated tests to reflect the removal of identity document URL and ensure compatibility with the new schema structure.
2025-08-12 18:43:30 +07:00
MythEclipse 34b6cdabfb feat(users): Add optional fields to user schemas and update user avatar functionality 2025-08-12 17:31:12 +07:00
MythEclipse a25e896fa2 feat(auth): Enhance Google OAuth integration with PKCE support and CSRF validation improvements 2025-08-12 16:11:15 +07:00
Asep Haryana Saputra adf3ef8e28 a 2025-08-12 08:14:05 +00:00
MythEclipse ad46687f07 refactor(tests): Remove unused imports in CSRF token tests 2025-08-12 01:51:10 +07:00
MythEclipse 0ba04abee9 feat(auth): Enhance Google OAuth service with CSRF protection and validation logic
feat(auth): Add error handling for authentication and validation errors
feat(auth): Implement default role assignment for new users in Google OAuth flow
feat(utils): Introduce CSRF token generation and validation utilities
fix(dependencies): Update Cargo.toml to include base64 and sha2 dependencies
2025-08-12 00:32:59 +07:00
MythEclipse 1e25a5b496 feat(auth): Update Google OAuth integration to enhance response structure and improve callback handling 2025-08-11 23:47:15 +07:00
MythEclipse d7178c792d a 2025-08-11 23:06:45 +07:00
MythEclipse d2754de9ef feat(auth): Refactor Google OAuth service and controller to utilize environment variables and enhance response structure 2025-08-11 22:54:10 +07:00
MythEclipse cda950ed7e feat(auth): Implement Google OAuth 2.0 integration with user creation and JWT generation
- Added Google OAuth controller and service to handle authentication via Google.
- Introduced DTOs for Google user and token responses.
- Updated AuthService and UsersService traits to support new Google OAuth functionality.
- Implemented logic to create a new user if they do not exist in the system after Google authentication.
- Enhanced existing user retrieval and JWT generation upon successful login.
- Added tests for Google OAuth flow, including login redirection and callback handling for both new and existing users.
- Updated environment configuration to include Google OAuth credentials.
2025-08-11 22:31:51 +07:00
MythEclipse c6a95c1231 fix: update default surrealdb password to 'root' for consistency 2025-07-31 20:34:36 +07:00
MythEclipse 86116b8157 feat: add logging for SurrealDB queries across multiple repositories 2025-07-31 16:51:08 +07:00
MythEclipse ac06224ed1 feat: enhance logging and dependency management with dotenvy and tracing-subscriber 2025-07-31 15:42:15 +07:00
MythEclipse 83e765f358 feat: add logger module with initialization function using tracing and dotenvy 2025-07-31 15:15:55 +07:00
MythEclipse 558e0ce568 refactor: simplify environment variable loading with helper function and remove unused mail configuration 2025-07-31 15:12:01 +07:00
MythEclipse 148cf3a5cd refactor: streamline environment variable loading and improve logging 2025-07-31 15:07:18 +07:00
Maulana SodiqinandGitHub ee9d23e01b Merge pull request #44 from IMPHNEN/feat/mentor
Add comprehensive tests for mentor repository and authentication
2025-07-21 22:18:14 +07:00
90 changed files with 4842 additions and 1217 deletions
+32 -7
View File
@@ -1,4 +1,30 @@
# RUST_ENV : Mode environment aplikasi (misal: development, production)
# RUST_LOG : Level logging aplikasi Rust (misal: debug, info, warn, error)
# PORT : Port yang digunakan backend service
# SURREALDB_URL : URL koneksi ke SurrealDB (gunakan ws:// untuk WebSocket)
# SURREALDB_USERNAME : Username untuk SurrealDB
# SURREALDB_PASSWORD : Password untuk SurrealDB
# SURREALDB_NAMESPACE : Namespace SurrealDB yang digunakan
# SURREALDB_DBNAME : Nama database SurrealDB
# ACCESS_TOKEN_SECRET : Secret key untuk JWT access token (ganti dengan nilai rahasia)
# REFRESH_TOKEN_SECRET : Secret key untuk JWT refresh token (ganti dengan nilai rahasia)
# SMTP_EMAIL : Email yang digunakan untuk mengirim email (SMTP)
# SMTP_PASSWORD : Password email SMTP
# SMTP_NAME : Nama pengirim email (misal: nama aplikasi)
# SMTP_HOST : Host SMTP server (misal: smtp.gmail.com)
# REDISDB_URL : URL/host Redis database
# FE_URL : URL frontend aplikasi (misal: http://localhost)
# MINIO_ENDPOINT : Endpoint MinIO object storage
# MINIO_BUCKET_NAME : Nama bucket default MinIO
# MINIO_ACCESS_KEY : Access key MinIO atau MINIO_ROOT_USER
# MINIO_SECRET_KEY : Secret key MinIO atau MINIO_ROOT_PASSWORD
# MINIO_ROOT_USER : Username root MinIO (default: minioadmin)
# MINIO_ROOT_PASSWORD : Password root MinIO (default: minioadmin)
# GOOGLE_CLIENT_ID : Client ID OAuth Google (untuk login Google)
# GOOGLE_CLIENT_SECRET : Client Secret OAuth Google
RUST_ENV=development RUST_ENV=development
RUST_LOG=debug
PORT=4099 PORT=4099
SURREALDB_URL=ws://localhost:8000/rpc SURREALDB_URL=ws://localhost:8000/rpc
SURREALDB_USERNAME=root SURREALDB_USERNAME=root
@@ -15,10 +41,9 @@ REDISDB_URL=localhost
FE_URL=http://localhost FE_URL=http://localhost
MINIO_ENDPOINT=http://localhost:9000 MINIO_ENDPOINT=http://localhost:9000
MINIO_BUCKET_NAME=default_bucket MINIO_BUCKET_NAME=default_bucket
MINIO_ACCESS_KEY=your-minio-access-key MINIO_ACCESS_KEY=minioadmin
MINIO_SECRET_KEY=your-minio-secret-key MINIO_SECRET_KEY=minioadmin
MAIL_USER=your-email@example.com MINIO_SECURE=false
MAIL_PASSWORD=your-smtp-password
MAIL_HOST=smtp.gmail.com GOOGLE_CLIENT_ID="your_google_client_id"
MAIL_PORT=465 GOOGLE_CLIENT_SECRET="your_google_client_secret"
MAIL_SECURE=true
Generated
+514 -218
View File
File diff suppressed because it is too large Load Diff
+38 -27
View File
@@ -16,47 +16,58 @@ members = [
[workspace.dependencies] [workspace.dependencies]
oauth2 = "5.0.0"
reqwest = { version = "0.12.23", features = ["json"] }
serde_json = "1.0.142"
axum = { version = "0.8.4", features = ["multipart"] } axum = { version = "0.8.4", features = ["multipart"] }
log = "0.4.25" log = "0.4.27"
serde = { version = "1.0.217", features = ["derive"] } serde = { version = "1.0.219", features = ["derive"] }
serde_json = "1.0.138" tokio = { version = "1.47.1", features = ["full"] }
tokio = { version = "1.45.0", features = ["full"] }
argon2 = { version = "0.5.3", features = ["password-hash"] } argon2 = { version = "0.5.3", features = ["password-hash"] }
jsonwebtoken = "9.3.1" jsonwebtoken = "9.3.1"
chrono = "0.4.41" chrono = "0.4.41"
utoipa = { version = "5.3.1", features = ["axum_extras"] } utoipa = { version = "5.4.0", features = ["axum_extras"] }
utoipa-swagger-ui = { version = "9.0.0", features = ["axum"] } utoipa-swagger-ui = { version = "9.0.2", features = ["axum"] }
lettre = { version = "0.11.16", features = ["tokio1-native-tls"] } lettre = { version = "0.11.18", features = ["tokio1-native-tls"] }
surrealdb = { version = "2.3.7", features = ["kv-mem", "kv-fdb"] } surrealdb = { version = "2.3.7", features = ["kv-mem", "kv-fdb"] }
thiserror = "2.0.12" thiserror = "2.0.14"
anyhow = "1.0.98" anyhow = "1.0.99"
rand = { version = "0.9.1", features = ["std", "alloc"] } rand = { version = "0.9.2", features = ["std", "alloc"] }
rand_distr = "0.5.1" rand_distr = "0.5.1"
tower-http = { version = "0.6.4", features = ["cors", "trace"] } tower-http = { version = "0.6.6", features = ["cors", "trace"] }
http-body-util = "0.1.1" http-body-util = "0.1.3"
validator = { version = "0.20.0", features = ["derive"] } validator = { version = "0.20.0", features = ["derive"] }
lazy_static = "1.4.0" lazy_static = "1.5.0"
regex = "1.11.1" regex = "1.11.1"
axum-test = "17.2.0" axum-test = "17.3.0"
fancy-regex = "0.15.0" axum-extra = { version = "0.10.1", features = ["typed-header"] }
fancy-regex = "0.16.1"
futures = "0.3.31" futures = "0.3.31"
tower = "0.5.2" tower = "0.5.2"
env_logger = "0.11.8" env_logger = "0.11.8"
dotenvy = "0.15.7" tracing = "0.1.41"
tracing = "0.1.40" uuid = { version = "1.18.0", features = ["v4", "fast-rng", "serde"] }
tracing-subscriber = { version = "0.3.18", features = ["fmt"] } strum = { version = "0.27.2", features = ["derive"] }
once_cell = "1.21.3" strum_macros = "0.27.2"
uuid = { version = "1.8.0", features = ["v4", "fast-rng", "serde"] } base64 = "0.22.1"
strum = { version = "0.27.1", features = ["derive"] } sha2 = "0.10.9"
strum_macros = "0.27.1" hmac = "0.12"
hex = "0.4"
urlencoding = "2.1"
hyper = "1.6.0" hyper = "1.6.0"
hyper-util = "0.1.0" hyper-util = "0.1.16"
async-trait = "0.1.75" minio = "0.3.0"
tokio-tungstenite = "0.23"
tokio-test = "0.4.4"
mockall = "0.13.1"
once_cell = "1.21.3"
dotenvy = "0.15.7"
tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }
tokio-tungstenite = "0.27"
url = "2.5" url = "2.5"
futures-util = "0.3" futures-util = "0.3"
http = "1.3"
tests = { path = "./tests" } tests = { path = "./tests" }
imphnen-iam = { path = "./imphnen-iam" } imphnen-iam = { path = "./imphnen-iam" }
imphnen-cms = { path = "./imphnen-cms" } imphnen-cms = { path = "./imphnen-cms" }
+41
View File
@@ -0,0 +1,41 @@
#!/bin/bash
# Script deploy ke VPS
set -e
REMOTE_USER="asephs"
REMOTE_HOST="70.153.9.42"
REMOTE_PATH="/home/asephs/imphnen-backend-service"
# Build project
taskset -c 0,1 cargo build --release -j 2
# Rsync hasil build dan file yang diperlukan
rsync -avz --delete \
target/release/ \
$REMOTE_USER@$REMOTE_HOST:$REMOTE_PATH/target/release/
# Sync file konfigurasi dan source code (jika perlu)
rsync -avz --delete \
imphnen-backend/ \
$REMOTE_USER@$REMOTE_HOST:$REMOTE_PATH/imphnen-backend/
rsync -avz --delete \
docker-compose.yml Dockerfile \
$REMOTE_USER@$REMOTE_HOST:$REMOTE_PATH/
# Tambahkan file lain jika diperlukan
ssh $REMOTE_USER@$REMOTE_HOST << 'EOF'
cd /home/asephs/imphnen-backend-service
export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games:/usr/local/games:/snap/bin"
export PATH=$PATH:/root/.local/share/pnpm
export PATH=$PATH:/home/asephs/.nvm/versions/node/v22.17.1/bin
export PATH=$PATH:/home/asephs/.bun/bin/bun
if [ -f ~/.bashrc ]; then
source ~/.bashrc
fi
pm2 restart 4 --update-env
EOF
echo "Deploy selesai ke $REMOTE_HOST:$REMOTE_PATH"
+16
View File
@@ -14,3 +14,19 @@ services:
command: start --log trace --user root --pass root command: start --log trace --user root --pass root
ports: ports:
- "8000:8000" - "8000:8000"
minio:
image: minio/minio:latest
container_name: minio
ports:
- "9000:9000"
- "9001:9001"
environment:
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin
command: server /data --console-address ":9001"
volumes:
- minio_data:/data
volumes:
minio_data:
+40
View File
@@ -3,6 +3,46 @@ name = "imphnen-backend"
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2021"
[[bin]]
name = "api"
path = "src/main.rs"
[[bin]]
name = "clear_db_test"
path = "src/bin/clear_db_test.rs"
[[bin]]
name = "seeder"
path = "src/bin/seeder.rs"
[[bin]]
name = "seed_events"
path = "src/bin/seed_events.rs"
[[bin]]
name = "seed_gacha_rolls"
path = "src/bin/seed_gacha_rolls.rs"
[[bin]]
name = "seed_mentor_user"
path = "src/bin/seed_mentor_user.rs"
[[bin]]
name = "seed_permissions"
path = "src/bin/seed_permissions.rs"
[[bin]]
name = "seed_roles"
path = "src/bin/seed_roles.rs"
[[bin]]
name = "seed_roles_permissions"
path = "src/bin/seed_roles_permissions.rs"
[[bin]]
name = "seed_users"
path = "src/bin/seed_users.rs"
[dependencies] [dependencies]
imphnen-libs.workspace = true imphnen-libs.workspace = true
imphnen-utils.workspace = true imphnen-utils.workspace = true
+5 -1
View File
@@ -1,3 +1,4 @@
use axum::Router;
use imphnen_gateway::gateway_service; use imphnen_gateway::gateway_service;
use imphnen_libs::axum_init; use imphnen_libs::axum_init;
@@ -5,7 +6,10 @@ use imphnen_libs::axum_init;
async fn main() { async fn main() {
env_logger::init(); env_logger::init();
axum_init(|surrealdb_ws, surrealdb_mem| async { axum_init(|surrealdb_ws, surrealdb_mem| async {
gateway_service(surrealdb_ws, surrealdb_mem).await let app = gateway_service(surrealdb_ws, surrealdb_mem).await;
let mut router = Router::new();
router = router.nest("/api/v1/auth", imphnen_iam::v1::auth::auth_router());
app.merge(router)
}) })
.await; .await;
} }
+6 -6
View File
@@ -30,7 +30,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let url = Url::parse(SURREALDB_URL_WS)?; // Menggunakan SURREALDB_URL_WS statis let url = Url::parse(SURREALDB_URL_WS)?; // Menggunakan SURREALDB_URL_WS statis
let (ws_stream, _) = connect_async(url).await?; let (ws_stream, _) = connect_async(url.as_str()).await?;
let (mut write, mut read) = ws_stream.split(); let (mut write, mut read) = ws_stream.split();
// Authenticate (signin) // Authenticate (signin)
@@ -43,7 +43,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
"id": 1 "id": 1
}).to_string(); }).to_string();
println!("DEBUG: Sending signin query: {}", signin_query); println!("DEBUG: Sending signin query: {}", signin_query);
write.send(Message::Text(signin_query)).await?; write.send(Message::Text(signin_query.into())).await?;
let signin_response = read.next().await.ok_or("Failed to read signin response")?; let signin_response = read.next().await.ok_or("Failed to read signin response")?;
let signin_response_msg = signin_response?; let signin_response_msg = signin_response?;
@@ -60,7 +60,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
"id": 2 "id": 2
}).to_string(); }).to_string();
println!("DEBUG: Sending use query: {}", use_query); println!("DEBUG: Sending use query: {}", use_query);
write.send(Message::Text(use_query)).await?; write.send(Message::Text(use_query.into())).await?;
let use_response = read.next().await.ok_or("Failed to read use response")?; let use_response = read.next().await.ok_or("Failed to read use response")?;
let use_response_msg = use_response?; let use_response_msg = use_response?;
@@ -82,7 +82,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
}).to_string(); }).to_string();
println!("DEBUG: Attempting REMOVE TABLE {}: {}", table, query_json); println!("DEBUG: Attempting REMOVE TABLE {}: {}", table, query_json);
write.send(Message::Text(query_json)).await?; write.send(Message::Text(query_json.into())).await?;
let response_result = read.next().await.ok_or("Stream ended unexpectedly")?; let response_result = read.next().await.ok_or("Stream ended unexpectedly")?;
@@ -100,7 +100,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
}).to_string(); }).to_string();
println!("DEBUG: Attempting DELETE {}: {}", table, delete_all_json); println!("DEBUG: Attempting DELETE {}: {}", table, delete_all_json);
write.send(Message::Text(delete_all_json)).await?; write.send(Message::Text(delete_all_json.into())).await?;
let delete_response_result = read.next().await.ok_or("Stream ended unexpectedly during DELETE type::")?; let delete_response_result = read.next().await.ok_or("Stream ended unexpectedly during DELETE type::")?;
match delete_response_result { match delete_response_result {
@@ -137,7 +137,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
"params": [select_query], "params": [select_query],
"id": i + 1000 "id": i + 1000
}).to_string(); }).to_string();
write.send(Message::Text(select_json)).await?; write.send(Message::Text(select_json.into())).await?;
let select_response_result = read.next().await.ok_or("Stream ended unexpectedly during SELECT check")?; let select_response_result = read.next().await.ok_or("Stream ended unexpectedly during SELECT check")?;
match select_response_result { match select_response_result {
Ok(select_msg) => { Ok(select_msg) => {
+5 -6
View File
@@ -1,21 +1,20 @@
use imphnen_cms::v1::landing::events::events_schema::EventsSchema; use imphnen_cms::v1::landing::events::events_schema::EventsSchema;
use imphnen_libs::enviroment::load_env; use imphnen_utils::{get_iso_date};
use imphnen_utils::{get_iso_date, Env};
use std::error::Error; use std::error::Error;
use surrealdb::engine::any; use surrealdb::engine::any;
use surrealdb::{opt::auth::Root, sql::Thing, Uuid}; // Added Uuid use surrealdb::{opt::auth::Root, sql::Thing, Uuid}; // Added Uuid
#[tokio::main] #[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> { async fn main() -> Result<(), Box<dyn Error>> {
load_env(); let env = &imphnen_libs::enviroment::ENV;
let env = Env::new();
let db = any::connect(&env.surrealdb_url).await?; let db = any::connect(&env.surrealdb_url).await?;
db.signin(Root { db.signin(Root {
username: &env.surrealdb_username, username: &env.surrealdb_username,
password: &env.surrealdb_password, password: &env.surrealdb_password,
}) })
.await?; .await?;
db.use_ns(env.surrealdb_namespace) db.use_ns(env.surrealdb_namespace.clone())
.use_db(env.surrealdb_dbname) .use_db(env.surrealdb_dbname.clone())
.await?; .await?;
let events = vec![ let events = vec![
+4 -7
View File
@@ -1,14 +1,11 @@
use imphnen_libs::enviroment::load_env; use imphnen_utils::{get_iso_date};
use imphnen_utils::{get_iso_date, Env};
use std::error::Error; use std::error::Error;
use surrealdb::opt::auth::Root; use surrealdb::opt::auth::Root;
use surrealdb::sql::Thing; use surrealdb::sql::Thing;
#[tokio::main] #[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> { async fn main() -> Result<(), Box<dyn Error>> {
load_env(); let env = &imphnen_libs::enviroment::ENV;
let env = Env::new();
use surrealdb::engine::any; use surrealdb::engine::any;
let db = any::connect(&env.surrealdb_url).await?; let db = any::connect(&env.surrealdb_url).await?;
db.signin(Root { db.signin(Root {
@@ -16,8 +13,8 @@ async fn main() -> Result<(), Box<dyn Error>> {
password: &env.surrealdb_password, password: &env.surrealdb_password,
}) })
.await?; .await?;
db.use_ns(env.surrealdb_namespace) db.use_ns(env.surrealdb_namespace.clone())
.use_db(env.surrealdb_dbname) .use_db(env.surrealdb_dbname.clone())
.await?; .await?;
db.query("DELETE type::thing('app_gacha_items', $id)") db.query("DELETE type::thing('app_gacha_items', $id)")
+16 -16
View File
@@ -1,13 +1,11 @@
use imphnen_libs::enviroment::load_env; use imphnen_utils::{get_iso_date, hash_password};
use imphnen_utils::{get_iso_date, hash_password, Env};
use serde_json::json; use serde_json::json;
use std::error::Error; use std::error::Error;
use surrealdb::opt::auth::Root; use surrealdb::opt::auth::Root;
#[tokio::main] #[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> { async fn main() -> Result<(), Box<dyn Error>> {
load_env(); let env = &imphnen_libs::enviroment::ENV;
let env = Env::new();
use surrealdb::engine::any; use surrealdb::engine::any;
let db = any::connect(&env.surrealdb_url).await?; let db = any::connect(&env.surrealdb_url).await?;
db.signin(Root { db.signin(Root {
@@ -15,8 +13,8 @@ async fn main() -> Result<(), Box<dyn Error>> {
password: &env.surrealdb_password, password: &env.surrealdb_password,
}) })
.await?; .await?;
db.use_ns(env.surrealdb_namespace) db.use_ns(env.surrealdb_namespace.clone())
.use_db(env.surrealdb_dbname) .use_db(env.surrealdb_dbname.clone())
.await?; .await?;
db.query("DELETE type::thing('app_mentors', $id)") db.query("DELETE type::thing('app_mentors', $id)")
@@ -28,7 +26,7 @@ async fn main() -> Result<(), Box<dyn Error>> {
.await?; .await?;
use surrealdb::sql::Thing; use surrealdb::sql::Thing;
db.query("CREATE type::thing('app_users', $id) SET fullname = $fullname, email = $email, password = $password, avatar = $avatar, phone_number = $phone_number, is_active = $is_active, is_deleted = $is_deleted, mentor_id = $mentor_id, gender = $gender, birthdate = $birthdate, role = $role, created_at = $created_at, updated_at = $updated_at") db.query("CREATE type::thing('app_users', $id) SET fullname = $fullname, email = $email, password = $password, avatar = $avatar, phone_number = $phone_number, is_active = $is_active, is_deleted = $is_deleted, mentor_id = $mentor_id, gender = $gender, birthdate = $birthdate, role = $role, legal_name = $legal_name, domicile = $domicile, identity_document_url = $identity_document_url, phone_for_verification = $phone_for_verification, bio = $bio, last_education = $last_education, linkedin_url = $linkedin_url, github_url = $github_url, cv_url = $cv_url, portfolio_url = $portfolio_url, created_at = $created_at, updated_at = $updated_at")
.bind(("id", "e6f78d23-83bf-5c2b-bcd4-001345678901")) .bind(("id", "e6f78d23-83bf-5c2b-bcd4-001345678901"))
.bind(("fullname", "Mentor User")) .bind(("fullname", "Mentor User"))
.bind(("email", "mentor@example.com")) .bind(("email", "mentor@example.com"))
@@ -41,20 +39,23 @@ async fn main() -> Result<(), Box<dyn Error>> {
.bind(("gender", "male")) .bind(("gender", "male"))
.bind(("birthdate", "1990-05-15")) .bind(("birthdate", "1990-05-15"))
.bind(("role", Thing::from(("app_roles", "3b9f8c4e-6a2d-4f8a-9a12-2d6f8b3c4e5a")))) .bind(("role", Thing::from(("app_roles", "3b9f8c4e-6a2d-4f8a-9a12-2d6f8b3c4e5a"))))
.bind(("legal_name", "Mentor User"))
.bind(("domicile", "Jakarta, Indonesia"))
// .bind(("identity_document_url", "https://example.com/ktp.jpg"))
.bind(("phone_for_verification", "081234567890"))
.bind(("bio", "Saya adalah mentor backend Rust dengan pengalaman 5 tahun dalam pengembangan aplikasi backend yang scalable dan performant."))
.bind(("last_education", "S1 Teknik Informatika"))
.bind(("linkedin_url", "https://linkedin.com/in/mentor"))
.bind(("github_url", "https://github.com/mentor"))
.bind(("cv_url", Option::<String>::None))
.bind(("portfolio_url", Option::<String>::None))
.bind(("created_at", get_iso_date())) .bind(("created_at", get_iso_date()))
.bind(("updated_at", get_iso_date())) .bind(("updated_at", get_iso_date()))
.await?; .await?;
db.query("CREATE type::thing('app_mentors', $id) SET user_id = $user_id, legal_name = $legal_name, identity_document_url = $identity_document_url, phone_for_verification = $phone_for_verification, bio = $bio, linkedin_url = $linkedin_url, github_url = $github_url, cv_url = $cv_url, industries = $industries, expertise = $expertise, languages = $languages, current_company = $current_company, current_role = $current_role, years_of_experience = $years_of_experience, topics_of_interest = $topics_of_interest, preferred_mentee_level = $preferred_mentee_level, preferred_mentoring_formats = $preferred_mentoring_formats, availability_commitment = $availability_commitment, mentoring_rate = $mentoring_rate, status = $status, is_deleted = $is_deleted, created_at = $created_at, updated_at = $updated_at, email = $email") db.query("CREATE type::thing('app_mentors', $id) SET user_id = $user_id, industries = $industries, expertise = $expertise, languages = $languages, current_company = $current_company, current_role = $current_role, years_of_experience = $years_of_experience, topics_of_interest = $topics_of_interest, preferred_mentee_level = $preferred_mentee_level, preferred_mentoring_formats = $preferred_mentoring_formats, availability_commitment = $availability_commitment, mentoring_rate = $mentoring_rate, status = $status, is_deleted = $is_deleted, created_at = $created_at, updated_at = $updated_at")
.bind(("id", "e6f78d23-83bf-5c2b-bcd4-001345678901")) .bind(("id", "e6f78d23-83bf-5c2b-bcd4-001345678901"))
.bind(("user_id", Thing::from(("app_users", "e6f78d23-83bf-5c2b-bcd4-001345678901")))) .bind(("user_id", Thing::from(("app_users", "e6f78d23-83bf-5c2b-bcd4-001345678901"))))
.bind(("legal_name", "Mentor User"))
.bind(("identity_document_url", "https://example.com/ktp.jpg"))
.bind(("phone_for_verification", "081234567890"))
.bind(("bio", "Saya adalah mentor backend Rust dengan pengalaman 5 tahun dalam pengembangan aplikasi backend yang scalable dan performant."))
.bind(("linkedin_url", "https://linkedin.com/in/mentor"))
.bind(("github_url", "https://github.com/mentor"))
.bind(("cv_url", Option::<String>::None))
.bind(("industries", vec!["Software", "Education"])) .bind(("industries", vec!["Software", "Education"]))
.bind(("expertise", vec!["Rust", "Microservices"])) .bind(("expertise", vec!["Rust", "Microservices"]))
.bind(("languages", vec!["Indonesian", "English"])) .bind(("languages", vec!["Indonesian", "English"]))
@@ -74,7 +75,6 @@ async fn main() -> Result<(), Box<dyn Error>> {
.bind(("is_deleted", false)) .bind(("is_deleted", false))
.bind(("created_at", get_iso_date())) .bind(("created_at", get_iso_date()))
.bind(("updated_at", get_iso_date())) .bind(("updated_at", get_iso_date()))
.bind(("email", "mentor@example.com"))
.await?; .await?;
println!("Mentor created successfully!"); println!("Mentor created successfully!");
println!("Updating user with mentor_id..."); println!("Updating user with mentor_id...");
+5 -7
View File
@@ -1,6 +1,5 @@
use imphnen_iam::PermissionsEnum; use imphnen_iam::PermissionsEnum;
use imphnen_libs::enviroment::load_env; use imphnen_utils::{get_iso_date};
use imphnen_utils::{get_iso_date, Env};
use serde_json::json; use serde_json::json;
use std::error::Error; use std::error::Error;
use surrealdb::engine::any; use surrealdb::engine::any;
@@ -8,17 +7,15 @@ use surrealdb::opt::auth::Root;
#[tokio::main] #[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> { async fn main() -> Result<(), Box<dyn Error>> {
load_env(); let env = &imphnen_libs::enviroment::ENV;
let env = Env::new();
let db = any::connect(&env.surrealdb_url).await?; let db = any::connect(&env.surrealdb_url).await?;
db.signin(Root { db.signin(Root {
username: &env.surrealdb_username, username: &env.surrealdb_username,
password: &env.surrealdb_password, password: &env.surrealdb_password,
}) })
.await?; .await?;
db.use_ns(env.surrealdb_namespace) db.use_ns(env.surrealdb_namespace.clone())
.use_db(env.surrealdb_dbname) .use_db(env.surrealdb_dbname.clone())
.await?; .await?;
for permission in [ for permission in [
@@ -54,6 +51,7 @@ async fn main() -> Result<(), Box<dyn Error>> {
PermissionsEnum::ReadOwnMentorProfile, PermissionsEnum::ReadOwnMentorProfile,
PermissionsEnum::UpdateOwnMentorProfile, PermissionsEnum::UpdateOwnMentorProfile,
PermissionsEnum::ReadOwnMentorStatus, PermissionsEnum::ReadOwnMentorStatus,
PermissionsEnum::UpdateMentors,
PermissionsEnum::VerifyMentors, PermissionsEnum::VerifyMentors,
PermissionsEnum::DeleteMentors, PermissionsEnum::DeleteMentors,
] { ] {
+4 -6
View File
@@ -1,21 +1,19 @@
use imphnen_libs::enviroment::load_env; use imphnen_utils::{get_iso_date};
use imphnen_utils::{get_iso_date, Env};
use serde_json::json; use serde_json::json;
use std::error::Error; use std::error::Error;
use surrealdb::engine::any; use surrealdb::engine::any;
use surrealdb::opt::auth::Root; use surrealdb::opt::auth::Root;
#[tokio::main] #[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> { async fn main() -> Result<(), Box<dyn Error>> {
load_env(); let env = &imphnen_libs::enviroment::ENV;
let env = Env::new();
let db = any::connect(&env.surrealdb_url).await?; let db = any::connect(&env.surrealdb_url).await?;
db.signin(Root { db.signin(Root {
username: &env.surrealdb_username, username: &env.surrealdb_username,
password: &env.surrealdb_password, password: &env.surrealdb_password,
}) })
.await?; .await?;
db.use_ns(env.surrealdb_namespace) db.use_ns(env.surrealdb_namespace.clone())
.use_db(env.surrealdb_dbname) .use_db(env.surrealdb_dbname.clone())
.await?; .await?;
let roles = vec![ let roles = vec![
@@ -1,22 +1,28 @@
use imphnen_iam::{get_iso_date, make_thing, Env, PermissionsEnum}; use imphnen_iam::{get_iso_date, make_thing, PermissionsEnum};
use imphnen_libs::enviroment::load_env;
use std::error::Error; use std::error::Error;
use surrealdb::engine::any; use surrealdb::engine::any;
use surrealdb::opt::auth::Root; use surrealdb::opt::auth::Root;
#[tokio::main] #[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> { async fn main() -> Result<(), Box<dyn Error>> {
load_env(); let env = &imphnen_libs::enviroment::ENV;
let env = Env::new();
let db = any::connect(&env.surrealdb_url).await?; let db = any::connect(&env.surrealdb_url).await?;
db.signin(Root { db.signin(Root {
username: &env.surrealdb_username, username: &env.surrealdb_username,
password: &env.surrealdb_password, password: &env.surrealdb_password,
}) })
.await?; .await?;
db.use_ns(env.surrealdb_namespace) db.use_ns(env.surrealdb_namespace.clone())
.use_db(env.surrealdb_dbname) .use_db(env.surrealdb_dbname.clone())
.await?; .await?;
db.query("DEFINE INDEX user_email_index ON TABLE users COLUMNS email UNIQUE;")
.await?;
db.query("DEFINE INDEX role_name_idx ON TABLE roles COLUMNS name UNIQUE;")
.await?;
println!("✅ Index 'user_email_index' defined on table 'users' for column 'email'.");
let roles_permissions = vec![ let roles_permissions = vec![
( (
@@ -59,6 +65,7 @@ async fn main() -> Result<(), Box<dyn Error>> {
( (
"3b9f8c4e-6a2d-4f8a-9a12-2d6f8b3c4e5a", "3b9f8c4e-6a2d-4f8a-9a12-2d6f8b3c4e5a",
vec![ vec![
PermissionsEnum::ReadListUsers, // Added ReadListUsers permission
PermissionsEnum::ReadOwnMentorProfile, PermissionsEnum::ReadOwnMentorProfile,
PermissionsEnum::UpdateOwnMentorProfile, PermissionsEnum::UpdateOwnMentorProfile,
PermissionsEnum::ReadOwnMentorStatus, PermissionsEnum::ReadOwnMentorStatus,
@@ -76,6 +83,8 @@ async fn main() -> Result<(), Box<dyn Error>> {
vec![ vec![
PermissionsEnum::ReadListGachaItems, PermissionsEnum::ReadListGachaItems,
PermissionsEnum::ReadDetailGachaItems, PermissionsEnum::ReadDetailGachaItems,
PermissionsEnum::ReadListUsers,
PermissionsEnum::ReadDetailUsers,
PermissionsEnum::CreateGachaClaims, PermissionsEnum::CreateGachaClaims,
PermissionsEnum::ReadDetailGachaClaims, PermissionsEnum::ReadDetailGachaClaims,
PermissionsEnum::ReadDetailGachaRolls, PermissionsEnum::ReadDetailGachaRolls,
+24 -9
View File
@@ -1,13 +1,11 @@
use imphnen_iam::UsersSchema; use imphnen_iam::UsersSchema;
use imphnen_libs::enviroment::load_env; use imphnen_utils::{get_iso_date, hash_password};
use imphnen_utils::{get_iso_date, hash_password, Env};
use std::error::Error; use std::error::Error;
use surrealdb::{opt::auth::Root, sql::Thing}; use surrealdb::{opt::auth::Root, sql::Thing};
#[tokio::main] #[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> { async fn main() -> Result<(), Box<dyn Error>> {
load_env(); let env = &imphnen_libs::enviroment::ENV;
let env = Env::new();
use surrealdb::engine::any; use surrealdb::engine::any;
let db = any::connect(&env.surrealdb_url).await?; let db = any::connect(&env.surrealdb_url).await?;
db.signin(Root { db.signin(Root {
@@ -15,8 +13,8 @@ async fn main() -> Result<(), Box<dyn Error>> {
password: &env.surrealdb_password, password: &env.surrealdb_password,
}) })
.await?; .await?;
db.use_ns(env.surrealdb_namespace) db.use_ns(env.surrealdb_namespace.clone())
.use_db(env.surrealdb_dbname) .use_db(env.surrealdb_dbname.clone())
.await?; .await?;
let users = vec![ let users = vec![
@@ -48,15 +46,32 @@ async fn main() -> Result<(), Box<dyn Error>> {
let user = UsersSchema { let user = UsersSchema {
id: Thing::from(("app_users", id)), id: Thing::from(("app_users", id)),
fullname: fullname.into(), fullname: fullname.into(),
legal_name: Some(format!("{} Legal Name", fullname)),
email: email.into(), email: email.into(),
password: hash_password("password").unwrap(), password: hash_password("password").unwrap(),
avatar: None, avatar: Some("https://example.com/avatar.jpg".into()),
phone_number: "081234567890".into(), phone_number: "081234567890".into(),
phone_for_verification: Some("081234567890".into()),
is_active: true, is_active: true,
is_deleted: false, is_deleted: false,
mentor_id: None, mentor_id: None,
gender: None, gender: Some("male".into()),
birthdate: None, birthdate: Some("1990-05-15".into()),
domicile: Some("Jakarta, Indonesia".into()),
// identity_document_url: None, // Sudah tidak dipakai, bisa dihapus dari schema jika tidak diperlukan
bio: Some(format!("{} adalah user dengan data pribadi lengkap untuk testing.", fullname)),
last_education: Some("S1 Teknik Informatika".into()),
linkedin_url: Some("https://linkedin.com/in/user".into()),
github_url: Some("https://github.com/user".into()),
cv_url: Some("https://example.com/cv.pdf".into()),
portfolio_url: Some("https://example.com/portfolio".into()),
website_url: Some("https://example.com/website".into()),
twitter_url: Some("https://twitter.com/user".into()),
location: Some("Jakarta, Indonesia".into()),
skills: Some(vec!["JavaScript".into(), "React".into(), "Node.js".into()]),
experience: None,
education: None,
career_status: Some("Senior Developer".into()),
role: Thing::from(("app_roles", role_id)), role: Thing::from(("app_roles", role_id)),
created_at: get_iso_date(), created_at: get_iso_date(),
updated_at: get_iso_date(), updated_at: get_iso_date(),
@@ -1,9 +1,10 @@
use super::{events_dto::EventsQueryDto, events_schema::EventsSchema}; use super::{events_dto::EventsQueryDto, events_schema::EventsSchema};
use anyhow::{Result, bail}; use anyhow::{Result, bail};
use imphnen_libs::{AppState, MetaRequestDto, ResourceEnum, ResponseListSuccessDto}; use imphnen_libs::{AppState, MetaRequestDto, ResourceEnum, ResponseListSuccessDto};
use imphnen_utils::{DetailQueryBuilder, ListQueryBuilder, get_id, get_iso_date}; use imphnen_utils::{DetailQueryBuilder, ListQueryBuilder, get_id, get_iso_date, make_thing};
use std::time::Instant; use std::time::Instant;
use tracing::instrument; use tracing::instrument;
use tracing::info;
pub struct EventsRepository<'a> { pub struct EventsRepository<'a> {
state: &'a AppState, state: &'a AppState,
@@ -25,6 +26,7 @@ impl<'a> EventsRepository<'a> {
.with_pagination(meta.page, Some(10)) .with_pagination(meta.page, Some(10))
.with_sorting(meta.sort_by.as_deref(), meta.order.as_deref()) .with_sorting(meta.sort_by.as_deref(), meta.order.as_deref())
.build(); .build();
info!(query = %query, "Executing SurrealDB query");
let res: Vec<EventsQueryDto> = let res: Vec<EventsQueryDto> =
self.state.surrealdb_ws.query(query).await?.take(0)?; self.state.surrealdb_ws.query(query).await?.take(0)?;
let elapsed = now.elapsed(); let elapsed = now.elapsed();
@@ -44,10 +46,20 @@ impl<'a> EventsRepository<'a> {
pub async fn query_event_by_id(&self, id: String) -> Result<EventsQueryDto> { pub async fn query_event_by_id(&self, id: String) -> Result<EventsQueryDto> {
let now = Instant::now(); let now = Instant::now();
let db = &self.state.surrealdb_ws; let db = &self.state.surrealdb_ws;
// Attempt to parse the ID. If it's a full Thing (e.g., "events:some_id"), extract the ID part.
// Otherwise, assume it's already the raw ID.
let parsed_id = if id.contains(":") {
let thing = make_thing(ResourceEnum::Events.to_string().as_str(), &id);
get_id(&thing)?.1.to_string()
} else {
id.clone()
};
let builder = DetailQueryBuilder::new(ResourceEnum::Events.to_string()) let builder = DetailQueryBuilder::new(ResourceEnum::Events.to_string())
.with_id(&id) .with_id(&parsed_id)
.with_select_fields(vec!["*"]); .with_select_fields(vec!["*"]);
let sql = builder.build(); let sql = builder.build();
info!(query = %sql, "Executing SurrealDB query");
let result: Option<EventsQueryDto> = let result: Option<EventsQueryDto> =
builder.apply_bindings(db.query(sql)).await?.take(0)?; builder.apply_bindings(db.query(sql)).await?.take(0)?;
let elapsed = now.elapsed(); let elapsed = now.elapsed();
@@ -72,6 +84,8 @@ impl<'a> EventsRepository<'a> {
pub async fn query_create_event(&self, data: EventsSchema) -> Result<String> { pub async fn query_create_event(&self, data: EventsSchema) -> Result<String> {
let now = Instant::now(); let now = Instant::now();
let db = &self.state.surrealdb_ws; let db = &self.state.surrealdb_ws;
let query_str = format!("CREATE {} CONTENT ...", ResourceEnum::Events.to_string());
info!(query = %query_str, "Executing SurrealDB query");
let record: Option<EventsSchema> = db let record: Option<EventsSchema> = db
.create(ResourceEnum::Events.to_string()) .create(ResourceEnum::Events.to_string())
.content(data) .content(data)
@@ -106,6 +120,8 @@ impl<'a> EventsRepository<'a> {
}; };
let record_key = get_id(&merged.id)?; let record_key = get_id(&merged.id)?;
let query_str = format!("UPDATE {:?} MERGE ...", record_key);
info!(query = %query_str, "Executing SurrealDB query");
let record: Option<EventsSchema> = db.update(record_key).merge(merged).await?; let record: Option<EventsSchema> = db.update(record_key).merge(merged).await?;
let elapsed = now.elapsed(); let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string()) if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
@@ -130,6 +146,8 @@ impl<'a> EventsRepository<'a> {
} }
let record_key = get_id(&event.id)?; let record_key = get_id(&event.id)?;
let query_str = format!("UPDATE {:?} MERGE {{ is_deleted: true }}", record_key);
info!(query = %query_str, "Executing SurrealDB query");
let record: Option<EventsSchema> = db let record: Option<EventsSchema> = db
.update(record_key) .update(record_key)
.merge(serde_json::json!({ "is_deleted": true })) .merge(serde_json::json!({ "is_deleted": true }))
@@ -7,6 +7,7 @@ use imphnen_utils::{DetailQueryBuilder, ListQueryBuilder, get_id, get_iso_date};
use serde_json; use serde_json;
use std::time::Instant; use std::time::Instant;
use tracing::instrument; use tracing::instrument;
use tracing::info;
pub struct TestimonialsRepository<'a> { pub struct TestimonialsRepository<'a> {
state: &'a AppState, state: &'a AppState,
@@ -28,6 +29,7 @@ impl<'a> TestimonialsRepository<'a> {
.with_pagination(meta.page, Some(10)) .with_pagination(meta.page, Some(10))
.with_sorting(meta.sort_by.as_deref(), meta.order.as_deref()) .with_sorting(meta.sort_by.as_deref(), meta.order.as_deref())
.build(); .build();
info!(query = %query, "Executing SurrealDB query");
let res: Vec<TestimonialsQueryDto> = let res: Vec<TestimonialsQueryDto> =
self.state.surrealdb_ws.query(query).await?.take(0)?; self.state.surrealdb_ws.query(query).await?.take(0)?;
let elapsed = now.elapsed(); let elapsed = now.elapsed();
@@ -55,6 +57,7 @@ impl<'a> TestimonialsRepository<'a> {
.with_condition("is_deleted = false") .with_condition("is_deleted = false")
.with_select_fields(vec!["*", "user.* as user"]); .with_select_fields(vec!["*", "user.* as user"]);
let sql = builder.build(); let sql = builder.build();
info!(query = %sql, "Executing SurrealDB query");
let result: Option<TestimonialsQueryDto> = let result: Option<TestimonialsQueryDto> =
builder.apply_bindings(db.query(sql)).await?.take(0)?; builder.apply_bindings(db.query(sql)).await?.take(0)?;
let elapsed = now.elapsed(); let elapsed = now.elapsed();
@@ -79,9 +82,14 @@ impl<'a> TestimonialsRepository<'a> {
pub async fn query_create_testimonial( pub async fn query_create_testimonial(
&self, &self,
data: TestimonialsSchema, data: TestimonialsSchema,
) -> Result<String> { ) -> Result<TestimonialsSchema> { // Change return type from String to TestimonialsSchema
let now = Instant::now(); let now = Instant::now();
let db = &self.state.surrealdb_ws; let db = &self.state.surrealdb_ws;
info!(
resource = %ResourceEnum::Testimonials.to_string(),
payload = ?data,
"Executing SurrealDB create"
);
let record: Option<TestimonialsSchema> = db let record: Option<TestimonialsSchema> = db
.create(ResourceEnum::Testimonials.to_string()) .create(ResourceEnum::Testimonials.to_string())
.content(data) .content(data)
@@ -94,7 +102,7 @@ impl<'a> TestimonialsRepository<'a> {
} }
match record { match record {
Some(_) => Ok("Success create testimonial".into()), Some(created_testimonial) => Ok(created_testimonial), // Return the created testimonial
None => bail!("Failed to create testimonial"), None => bail!("Failed to create testimonial"),
} }
} }
@@ -120,6 +128,11 @@ impl<'a> TestimonialsRepository<'a> {
}; };
let record_key = get_id(&merged.id)?; let record_key = get_id(&merged.id)?;
info!(
record_key = ?record_key,
payload = ?merged,
"Executing SurrealDB update"
);
let record: Option<TestimonialsSchema> = let record: Option<TestimonialsSchema> =
db.update(record_key).merge(merged).await?; db.update(record_key).merge(merged).await?;
let elapsed = now.elapsed(); let elapsed = now.elapsed();
@@ -145,6 +158,10 @@ impl<'a> TestimonialsRepository<'a> {
} }
let record_key = get_id(&testimonial.id)?; let record_key = get_id(&testimonial.id)?;
info!(
record_key = ?record_key,
"Executing SurrealDB soft delete"
);
let record: Option<TestimonialsSchema> = db let record: Option<TestimonialsSchema> = db
.update(record_key) .update(record_key)
.merge(serde_json::json!({ "is_deleted": true })) .merge(serde_json::json!({ "is_deleted": true }))
@@ -11,7 +11,7 @@ use imphnen_libs::{
AppState, MetaRequestDto, ResponseListSuccessDto, ResponseSuccessDto, AppState, MetaRequestDto, ResponseListSuccessDto, ResponseSuccessDto,
}; };
use imphnen_utils::{ use imphnen_utils::{
common_response, success_list_response, success_response, validate_request, common_response, success_list_response, success_response, success_created_response, validate_request,
}; };
pub struct TestimonialsService; pub struct TestimonialsService;
@@ -46,8 +46,8 @@ impl TestimonialsService {
Ok(testimonial) if !testimonial.is_deleted => { Ok(testimonial) if !testimonial.is_deleted => {
success_response(ResponseSuccessDto { success_response(ResponseSuccessDto {
data: TestimonialsDetailItemDto { data: TestimonialsDetailItemDto {
id: testimonial.id.id.to_raw(), id: testimonial.id.to_raw(),
user_id: testimonial.user.id.id.to_raw(), user_id: testimonial.user.id.to_raw(),
user_fullname: testimonial.user.fullname, user_fullname: testimonial.user.fullname,
role: testimonial.role, role: testimonial.role,
content: testimonial.content, content: testimonial.content,
@@ -72,7 +72,19 @@ impl TestimonialsService {
let repo = TestimonialsRepository::new(state); let repo = TestimonialsRepository::new(state);
let schema = TestimonialsSchema::create(payload, &authenticated_user.id); let schema = TestimonialsSchema::create(payload, &authenticated_user.id);
match repo.query_create_testimonial(schema).await { match repo.query_create_testimonial(schema).await {
Ok(msg) => common_response(StatusCode::CREATED, &msg), Ok(created_testimonial) => {
success_created_response(ResponseSuccessDto {
data: TestimonialsDetailItemDto {
id: created_testimonial.id.to_raw(),
user_id: created_testimonial.user.id.to_raw(),
user_fullname: authenticated_user.fullname.clone(),
role: created_testimonial.role,
content: created_testimonial.content,
created_at: created_testimonial.created_at,
updated_at: created_testimonial.updated_at,
},
})
}
Err(e) => common_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()), Err(e) => common_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
} }
} }
@@ -57,13 +57,13 @@ pub async fn get_mentor_list(
Query(meta): Query<MetaRequestDto>, Query(meta): Query<MetaRequestDto>,
) -> Response { ) -> Response {
match permissions_guard( match permissions_guard(
&headers, headers,
app_state.clone(), Extension(app_state),
vec![PermissionsEnum::ReadListMentors], vec![PermissionsEnum::ReadListMentors],
) )
.await .await
{ {
Ok(_) => MentorsService::get_mentor_list(&app_state, meta).await, Ok((_user, app_state)) => MentorsService::get_mentor_list(&app_state, meta).await,
Err(response) => response, Err(response) => response,
} }
} }
@@ -90,13 +90,13 @@ pub async fn get_mentor_by_id(
Path(id): Path<String>, Path(id): Path<String>,
) -> Response { ) -> Response {
match permissions_guard( match permissions_guard(
&headers, headers,
app_state.clone(), Extension(app_state),
vec![PermissionsEnum::ReadDetailMentors], vec![PermissionsEnum::ReadDetailMentors],
) )
.await .await
{ {
Ok(_) => MentorsService::get_mentor_by_id(&app_state, &id).await, Ok((_user, app_state)) => MentorsService::get_mentor_by_id(&app_state, &id).await,
Err(response) => response, Err(response) => response,
} }
} }
@@ -126,13 +126,13 @@ pub async fn put_update_mentor(
Json(dto): Json<MentorUpdateRequestDto>, Json(dto): Json<MentorUpdateRequestDto>,
) -> Response { ) -> Response {
match permissions_guard( match permissions_guard(
&headers, headers,
app_state.clone(), Extension(app_state),
vec![PermissionsEnum::UpdateMentors], vec![PermissionsEnum::UpdateMentors],
) )
.await .await
{ {
Ok(_) => MentorsService::update_mentor(&app_state, &id, dto).await, Ok((_user, app_state)) => MentorsService::update_mentor(&app_state, &id, dto).await,
Err(response) => response, Err(response) => response,
} }
} }
@@ -159,13 +159,13 @@ pub async fn delete_mentor(
Path(id): Path<String>, Path(id): Path<String>,
) -> Response { ) -> Response {
match permissions_guard( match permissions_guard(
&headers, headers,
app_state.clone(), Extension(app_state),
vec![PermissionsEnum::DeleteMentors], vec![PermissionsEnum::DeleteMentors],
) )
.await .await
{ {
Ok(_) => MentorsService::delete_mentor(&app_state, &id).await, Ok((_user, app_state)) => MentorsService::delete_mentor(&app_state, &id).await,
Err(response) => response, Err(response) => response,
} }
} }
@@ -195,13 +195,13 @@ pub async fn put_verify_mentor(
Json(dto): Json<MentorVerifyRequestDto>, Json(dto): Json<MentorVerifyRequestDto>,
) -> Response { ) -> Response {
match permissions_guard( match permissions_guard(
&headers, headers,
app_state.clone(), Extension(app_state),
vec![PermissionsEnum::VerifyMentors], vec![PermissionsEnum::VerifyMentors],
) )
.await .await
{ {
Ok(_) => MentorsService::verify_mentor(&app_state, &id, dto).await, Ok((_user, app_state)) => MentorsService::verify_mentor(&app_state, &id, dto).await,
Err(response) => response, Err(response) => response,
} }
} }
@@ -221,17 +221,17 @@ pub async fn put_verify_mentor(
) )
)] )]
pub async fn get_mentor_me( pub async fn get_mentor_me(
Extension(app_state): Extension<AppState>,
headers: HeaderMap, headers: HeaderMap,
Extension(app_state): Extension<AppState>,
) -> Response { ) -> Response {
match permissions_guard( match permissions_guard(
&headers, headers.clone(),
app_state.clone(), Extension(app_state),
vec![PermissionsEnum::ReadOwnMentorProfile], vec![PermissionsEnum::ReadOwnMentorProfile],
) )
.await .await
{ {
Ok(_) => { Ok((_user, app_state)) => {
let email = match extract_email(&headers) { let email = match extract_email(&headers) {
Some(email) => email, Some(email) => email,
None => { None => {
@@ -268,18 +268,18 @@ pub async fn get_mentor_me(
) )
)] )]
pub async fn put_update_mentor_me( pub async fn put_update_mentor_me(
Extension(app_state): Extension<AppState>,
headers: HeaderMap, headers: HeaderMap,
Extension(app_state): Extension<AppState>,
Json(dto): Json<MentorUpdateRequestDto>, Json(dto): Json<MentorUpdateRequestDto>,
) -> Response { ) -> Response {
match permissions_guard( match permissions_guard(
&headers, headers.clone(),
app_state.clone(), Extension(app_state),
vec![PermissionsEnum::UpdateOwnMentorProfile], vec![PermissionsEnum::UpdateOwnMentorProfile],
) )
.await .await
{ {
Ok(_) => { Ok((_user, app_state)) => {
let email = match extract_email(&headers) { let email = match extract_email(&headers) {
Some(email) => email, Some(email) => email,
None => { None => {
@@ -325,17 +325,17 @@ pub async fn put_update_mentor_no_id() -> Response {
) )
)] )]
pub async fn get_mentor_status( pub async fn get_mentor_status(
Extension(app_state): Extension<AppState>,
headers: HeaderMap, headers: HeaderMap,
Extension(app_state): Extension<AppState>,
) -> Response { ) -> Response {
match permissions_guard( match permissions_guard(
&headers, headers.clone(),
app_state.clone(), Extension(app_state),
vec![PermissionsEnum::ReadOwnMentorStatus], vec![PermissionsEnum::ReadOwnMentorStatus],
) )
.await .await
{ {
Ok(_) => { Ok((_user, app_state)) => {
let email = match extract_email(&headers) { let email = match extract_email(&headers) {
Some(email) => email, Some(email) => email,
None => { None => {
@@ -18,15 +18,9 @@ pub struct MentorListResponseDto {
pub struct MentorDetailWithUserDto { pub struct MentorDetailWithUserDto {
pub id: Thing, pub id: Thing,
pub user_id: Thing, pub user_id: Thing,
pub fullname: Option<String>, // Personal data is now in UsersSchema, access via user_id
pub email: Option<String>, // Removed: fullname, email, legal_name, identity_document_url,
pub legal_name: String, // phone_for_verification, bio, linkedin_url, github_url, cv_url
pub identity_document_url: String,
pub phone_for_verification: String,
pub bio: String,
pub linkedin_url: Option<String>,
pub github_url: Option<String>,
pub cv_url: Option<String>,
pub industries: Vec<String>, pub industries: Vec<String>,
pub expertise: Vec<String>, pub expertise: Vec<String>,
pub languages: Vec<String>, pub languages: Vec<String>,
@@ -47,15 +41,20 @@ pub struct MentorDetailWithUserDto {
pub struct MentorDetailResponseDto { pub struct MentorDetailResponseDto {
pub id: String, pub id: String,
pub user_id: String, pub user_id: String,
// Personal data fields from UsersSchema
pub fullname: Option<String>, pub fullname: Option<String>,
pub email: Option<String>, pub email: Option<String>,
pub legal_name: String, pub legal_name: Option<String>,
pub identity_document_url: String, pub gender: Option<String>,
pub phone_for_verification: String, pub domicile: Option<String>,
pub bio: String, pub phone_for_verification: Option<String>,
pub bio: Option<String>,
pub last_education: Option<String>,
pub linkedin_url: Option<String>, pub linkedin_url: Option<String>,
pub github_url: Option<String>, pub github_url: Option<String>,
pub cv_url: Option<String>, pub cv_url: Option<String>,
pub portfolio_url: Option<String>,
// Professional data from MentorSchema
pub industries: Vec<String>, pub industries: Vec<String>,
pub expertise: Vec<String>, pub expertise: Vec<String>,
pub languages: Vec<String>, pub languages: Vec<String>,
@@ -94,9 +93,6 @@ pub struct MentorUpdateRequestDto {
pub gender: Option<String>, pub gender: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub domicile: Option<String>, pub domicile: Option<String>,
#[validate(url(message = "Invalid identity document URL"))]
#[serde(skip_serializing_if = "Option::is_none")]
pub identity_document_url: Option<String>,
#[validate(length( #[validate(length(
min = 10, min = 10,
max = 15, max = 15,
@@ -118,6 +114,7 @@ pub struct MentorUpdateRequestDto {
#[validate(url(message = "Invalid CV URL"))] #[validate(url(message = "Invalid CV URL"))]
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub cv_url: Option<String>, pub cv_url: Option<String>,
#[validate(url(message = "Invalid portfolio URL"))]
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub portfolio_url: Option<String>, pub portfolio_url: Option<String>,
#[validate(length(min = 1, message = "At least 1 industry required"))] #[validate(length(min = 1, message = "At least 1 industry required"))]
@@ -279,18 +276,7 @@ pub struct MentoringRate {
pub struct MentorInsertDto { pub struct MentorInsertDto {
pub id: Thing, pub id: Thing,
pub user_id: Option<Thing>, pub user_id: Option<Thing>,
pub email: Option<String>, // Personal data removed - now stored in UsersSchema
pub legal_name: String,
pub gender: Option<String>,
pub domicile: Option<String>,
pub identity_document_url: String,
pub phone_for_verification: String,
pub bio: String,
pub last_education: Option<String>,
pub linkedin_url: Option<String>,
pub github_url: Option<String>,
pub cv_url: Option<String>,
pub portfolio_url: Option<String>,
pub industries: Vec<String>, pub industries: Vec<String>,
pub expertise: Vec<String>, pub expertise: Vec<String>,
pub languages: Vec<String>, pub languages: Vec<String>,
@@ -313,18 +299,7 @@ impl From<MentorSchema> for MentorInsertDto {
MentorInsertDto { MentorInsertDto {
id: schema.id, id: schema.id,
user_id: schema.user_id, user_id: schema.user_id,
email: schema.email, // Personal data removed from MentorSchema
legal_name: schema.legal_name,
gender: schema.gender,
domicile: schema.domicile,
identity_document_url: schema.identity_document_url,
phone_for_verification: schema.phone_for_verification,
bio: schema.bio,
last_education: schema.last_education,
linkedin_url: schema.linkedin_url,
github_url: schema.github_url,
cv_url: schema.cv_url,
portfolio_url: schema.portfolio_url,
industries: schema.industries, industries: schema.industries,
expertise: schema.expertise, expertise: schema.expertise,
languages: schema.languages, languages: schema.languages,
@@ -354,19 +329,10 @@ pub struct MentorVerifyRequestDto {
pub struct MentorDetailQueryDto { pub struct MentorDetailQueryDto {
pub id: Thing, pub id: Thing,
pub user_id: Thing, pub user_id: Thing,
pub fullname: Option<String>, // Personal data has been moved to UsersSchema
pub email: Option<String>, // Use user_id to get: fullname, email, legal_name, gender, domicile,
pub legal_name: String, // identity_document_url, phone_for_verification, bio, last_education,
pub gender: Option<String>, // linkedin_url, github_url, cv_url, portfolio_url
pub domicile: Option<String>,
pub identity_document_url: String,
pub phone_for_verification: String,
pub bio: String,
pub last_education: Option<String>,
pub linkedin_url: Option<String>,
pub github_url: Option<String>,
pub cv_url: Option<String>,
pub portfolio_url: Option<String>,
pub industries: Vec<String>, pub industries: Vec<String>,
pub expertise: Vec<String>, pub expertise: Vec<String>,
pub languages: Vec<String>, pub languages: Vec<String>,
@@ -388,8 +354,8 @@ impl From<MentorDetailQueryDto> for MentorListResponseDto {
fn from(dto: MentorDetailQueryDto) -> Self { fn from(dto: MentorDetailQueryDto) -> Self {
Self { Self {
id: extract_id(&dto.id), id: extract_id(&dto.id),
fullname: dto.fullname, fullname: None, // now in user table, must be populated from service layer
email: dto.email, email: None, // now in user table, must be populated from service layer
status: dto.status, status: dto.status,
created_at: dto.created_at, created_at: dto.created_at,
updated_at: dto.updated_at, updated_at: dto.updated_at,
@@ -402,15 +368,20 @@ impl From<MentorDetailQueryDto> for MentorDetailResponseDto {
Self { Self {
id: extract_id(&dto.id), id: extract_id(&dto.id),
user_id: extract_id(&dto.user_id), user_id: extract_id(&dto.user_id),
fullname: dto.fullname, // Personal data fields are populated in service layer from UsersSchema
email: dto.email, fullname: None, // populated from user table in service layer
legal_name: dto.legal_name, email: None, // populated from user table in service layer
identity_document_url: dto.identity_document_url, legal_name: None, // populated from user table in service layer
phone_for_verification: dto.phone_for_verification, gender: None, // populated from user table in service layer
bio: dto.bio, domicile: None, // populated from user table in service layer
linkedin_url: dto.linkedin_url, phone_for_verification: None, // populated from user table in service layer
github_url: dto.github_url, bio: None, // populated from user table in service layer
cv_url: dto.cv_url, last_education: None, // populated from user table in service layer
linkedin_url: None, // populated from user table in service layer
github_url: None, // populated from user table in service layer
cv_url: None, // populated from user table in service layer
portfolio_url: None, // populated from user table in service layer
// Professional data from mentor
industries: dto.industries, industries: dto.industries,
expertise: dto.expertise, expertise: dto.expertise,
languages: dto.languages, languages: dto.languages,
@@ -433,7 +404,7 @@ impl From<MentorSchema> for MentorRegisterResponseDto {
Self { Self {
id: schema.id.to_string(), id: schema.id.to_string(),
user_id: schema.user_id.map(|id| extract_id(&id)).unwrap_or_default(), user_id: schema.user_id.map(|id| extract_id(&id)).unwrap_or_default(),
email: schema.email, email: None, // schema.email - now in user table
status: schema.status, status: schema.status,
created_at: schema.created_at, created_at: schema.created_at,
updated_at: schema.updated_at, updated_at: schema.updated_at,
@@ -446,19 +417,7 @@ impl From<MentorDetailWithUserDto> for MentorDetailQueryDto {
MentorDetailQueryDto { MentorDetailQueryDto {
id: dto.id, id: dto.id,
user_id: dto.user_id, user_id: dto.user_id,
fullname: dto.fullname, // Personal data removed from MentorDetailWithUserDto - now in UsersSchema
email: dto.email,
legal_name: dto.legal_name,
gender: None, // Frontend form implies these are optional, not present in original MentorDetailWithUserDto
domicile: None, // Frontend form implies these are optional, not present in original MentorDetailWithUserDto
identity_document_url: dto.identity_document_url,
phone_for_verification: dto.phone_for_verification,
bio: dto.bio,
last_education: None, // Frontend form implies these are optional, not present in original MentorDetailWithUserDto
linkedin_url: dto.linkedin_url,
github_url: dto.github_url,
cv_url: dto.cv_url,
portfolio_url: None, // Frontend form implies these are optional, not present in original MentorDetailWithUserDto
industries: dto.industries, industries: dto.industries,
expertise: dto.expertise, expertise: dto.expertise,
languages: dto.languages, languages: dto.languages,
@@ -8,6 +8,7 @@ use imphnen_utils::{DetailQueryBuilder, QueryListBuilder, get_iso_date};
use serde_json::{Map, Value}; use serde_json::{Map, Value};
use std::time::Instant; use std::time::Instant;
use tracing::instrument; use tracing::instrument;
use tracing::info;
pub struct MentorsRepository<'a> { pub struct MentorsRepository<'a> {
pub state: &'a AppState, pub state: &'a AppState,
@@ -27,19 +28,11 @@ impl<'a> MentorsRepository<'a> {
let db = &self.state.surrealdb_ws; let db = &self.state.surrealdb_ws;
let mentors_table = ResourceEnum::Mentors.to_string(); let mentors_table = ResourceEnum::Mentors.to_string();
let builder = QueryListBuilder::new(db, &mentors_table, &meta) let builder = QueryListBuilder::new(db, &mentors_table, &meta)
.search_field("legal_name") .search_field("user_id.legal_name") // Search in user data instead
.select_fields(vec![ .select_fields(vec![
"id", "id",
"user_id", "user_id",
"user_id.fullname as fullname", // Personal data comes from user relation, not mentor table
"email",
"legal_name",
"identity_document_url",
"phone_for_verification",
"bio",
"linkedin_url",
"github_url",
"cv_url",
"industries", "industries",
"expertise", "expertise",
"languages", "languages",
@@ -79,19 +72,11 @@ impl<'a> MentorsRepository<'a> {
let now = Instant::now(); let now = Instant::now();
let db = &self.state.surrealdb_ws; let db = &self.state.surrealdb_ws;
let mut builder = DetailQueryBuilder::new(ResourceEnum::Mentors.to_string()) let mut builder = DetailQueryBuilder::new(ResourceEnum::Mentors.to_string())
.with_where("email", Some(email.clone())) .with_where("user_id.email", Some(email.clone())) // Search in user table
.with_select_fields(vec![ .with_select_fields(vec![
"id", "id",
"user_id", "user_id",
"user_id.fullname as fullname", // Personal data comes from user relation, not mentor table
"email",
"legal_name",
"identity_document_url",
"phone_for_verification",
"bio",
"linkedin_url",
"github_url",
"cv_url",
"industries", "industries",
"expertise", "expertise",
"languages", "languages",
@@ -114,6 +99,7 @@ impl<'a> MentorsRepository<'a> {
} }
let sql = builder.build(); let sql = builder.build();
info!(query = %sql, "Executing SurrealDB query in query_mentor_by_email");
let mentor_opt: Option<MentorDetailWithUserDto> = let mentor_opt: Option<MentorDetailWithUserDto> =
builder.apply_bindings(db.query(sql)).await?.take(0)?; builder.apply_bindings(db.query(sql)).await?.take(0)?;
let elapsed = now.elapsed(); let elapsed = now.elapsed();
@@ -141,15 +127,7 @@ impl<'a> MentorsRepository<'a> {
.with_select_fields(vec![ .with_select_fields(vec![
"id", "id",
"user_id", "user_id",
"user_id.fullname as fullname", // Personal data comes from user relation, not mentor table
"email",
"legal_name",
"identity_document_url",
"phone_for_verification",
"bio",
"linkedin_url",
"github_url",
"cv_url",
"industries", "industries",
"expertise", "expertise",
"languages", "languages",
@@ -172,6 +150,7 @@ impl<'a> MentorsRepository<'a> {
} }
let sql = builder.build(); let sql = builder.build();
info!(query = %sql, "Executing SurrealDB query in query_mentor_by_id");
let mentor_opt: Option<MentorDetailWithUserDto> = let mentor_opt: Option<MentorDetailWithUserDto> =
builder.apply_bindings(db.query(sql)).await?.take(0)?; builder.apply_bindings(db.query(sql)).await?.take(0)?;
let elapsed = now.elapsed(); let elapsed = now.elapsed();
@@ -194,8 +173,10 @@ impl<'a> MentorsRepository<'a> {
let now = Instant::now(); let now = Instant::now();
let db = &self.state.surrealdb_ws; let db = &self.state.surrealdb_ws;
let dto: MentorInsertDto = data.into(); let dto: MentorInsertDto = data.into();
let resource = ResourceEnum::Mentors.to_string();
info!(query = %resource, "Executing SurrealDB create in query_create_mentor");
let record: Option<MentorSchema> = db let record: Option<MentorSchema> = db
.create(ResourceEnum::Mentors.to_string()) .create(resource)
.content(dto.clone()) .content(dto.clone())
.await?; .await?;
let elapsed = now.elapsed(); let elapsed = now.elapsed();
@@ -237,6 +218,7 @@ impl<'a> MentorsRepository<'a> {
merged_data_json.insert("updated_at".to_string(), Value::String(get_iso_date())); merged_data_json.insert("updated_at".to_string(), Value::String(get_iso_date()));
info!(query = ?record_key, "Executing SurrealDB update in query_update_mentor");
let record: Option<MentorSchema> = let record: Option<MentorSchema> =
db.update(record_key).merge(merged_data_json).await?; db.update(record_key).merge(merged_data_json).await?;
let elapsed = now.elapsed(); let elapsed = now.elapsed();
@@ -282,6 +264,7 @@ impl<'a> MentorsRepository<'a> {
patch.insert("is_deleted".to_string(), Value::Bool(true)); patch.insert("is_deleted".to_string(), Value::Bool(true));
patch.insert("updated_at".to_string(), Value::String(get_iso_date())); patch.insert("updated_at".to_string(), Value::String(get_iso_date()));
info!(query = ?record_key, "Executing SurrealDB soft delete in query_delete_mentor");
let record: Option<MentorSchema> = db.update(record_key).merge(patch).await?; let record: Option<MentorSchema> = db.update(record_key).merge(patch).await?;
let elapsed = now.elapsed(); let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string()) if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
@@ -1,5 +1,5 @@
use super::{ use super::{
IdentityAndVerification, MentorDetailQueryDto, MentorUpdateRequestDto, MentorDetailQueryDto, MentorUpdateRequestDto,
MentoringLogistics, MentoringRate, ProfessionalProfile, MentoringLogistics, MentoringRate, ProfessionalProfile,
}; };
use imphnen_libs::ResourceEnum; use imphnen_libs::ResourceEnum;
@@ -12,18 +12,9 @@ pub struct MentorSchema {
pub id: Thing, pub id: Thing,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub user_id: Option<Thing>, pub user_id: Option<Thing>,
pub email: Option<String>, // Personal data has been moved to UsersSchema - use user_id to reference
pub legal_name: String, // phone_for_verification, bio, last_education, linkedin_url, github_url,
pub gender: Option<String>, // cv_url, portfolio_url
pub domicile: Option<String>,
pub identity_document_url: String,
pub phone_for_verification: String,
pub bio: String,
pub last_education: Option<String>,
pub linkedin_url: Option<String>,
pub github_url: Option<String>,
pub cv_url: Option<String>,
pub portfolio_url: Option<String>,
pub industries: Vec<String>, pub industries: Vec<String>,
pub expertise: Vec<String>, pub expertise: Vec<String>,
pub languages: Vec<String>, pub languages: Vec<String>,
@@ -52,18 +43,6 @@ impl Default for MentorSchema {
ResourceEnum::Users.to_string().as_str(), ResourceEnum::Users.to_string().as_str(),
&Uuid::new_v4().to_string(), &Uuid::new_v4().to_string(),
)), )),
email: None,
legal_name: String::new(),
gender: None,
domicile: None,
identity_document_url: String::new(),
phone_for_verification: String::new(),
bio: String::new(),
last_education: None,
linkedin_url: None,
github_url: None,
cv_url: None,
portfolio_url: None,
industries: Vec::new(), industries: Vec::new(),
expertise: Vec::new(), expertise: Vec::new(),
languages: Vec::new(), languages: Vec::new(),
@@ -89,11 +68,9 @@ impl Default for MentorSchema {
impl MentorSchema { impl MentorSchema {
pub fn create( pub fn create(
identity_and_verification: IdentityAndVerification,
professional_profile: ProfessionalProfile, professional_profile: ProfessionalProfile,
mentoring_logistics: MentoringLogistics, mentoring_logistics: MentoringLogistics,
user_id_raw: String, user_id_raw: String,
email_str: String,
) -> Self { ) -> Self {
Self { Self {
id: make_thing( id: make_thing(
@@ -101,18 +78,7 @@ impl MentorSchema {
&Uuid::new_v4().to_string(), &Uuid::new_v4().to_string(),
), ),
user_id: Some(make_thing(&ResourceEnum::Users.to_string(), &user_id_raw)), user_id: Some(make_thing(&ResourceEnum::Users.to_string(), &user_id_raw)),
email: Some(email_str), // Personal data now stored in UsersSchema, not here
legal_name: identity_and_verification.legal_name,
gender: identity_and_verification.gender,
domicile: identity_and_verification.domicile,
identity_document_url: identity_and_verification.identity_document_url,
phone_for_verification: identity_and_verification.phone_for_verification,
bio: professional_profile.bio,
last_education: professional_profile.last_education,
linkedin_url: professional_profile.linkedin_url,
github_url: professional_profile.github_url,
cv_url: professional_profile.cv_url,
portfolio_url: professional_profile.portfolio_url,
industries: professional_profile.industries, industries: professional_profile.industries,
expertise: professional_profile.expertise, expertise: professional_profile.expertise,
languages: professional_profile.languages, languages: professional_profile.languages,
@@ -139,18 +105,7 @@ impl MentorSchema {
Self { Self {
id: dto.id, id: dto.id,
user_id: Some(dto.user_id), user_id: Some(dto.user_id),
email: dto.email, // Personal data now comes from UsersSchema via user_id
legal_name: dto.legal_name,
gender: dto.gender,
domicile: dto.domicile,
identity_document_url: dto.identity_document_url,
phone_for_verification: dto.phone_for_verification,
bio: dto.bio,
last_education: dto.last_education,
linkedin_url: dto.linkedin_url,
github_url: dto.github_url,
cv_url: dto.cv_url,
portfolio_url: dto.portfolio_url,
industries: dto.industries, industries: dto.industries,
expertise: dto.expertise, expertise: dto.expertise,
languages: dto.languages, languages: dto.languages,
@@ -170,40 +125,10 @@ impl MentorSchema {
} }
pub fn update(mut self, dto: MentorUpdateRequestDto) -> Self { pub fn update(mut self, dto: MentorUpdateRequestDto) -> Self {
// Update fields only if they are Some(value), otherwise preserve current value // phone_for_verification, bio, last_education, linkedin_url, github_url,
if let Some(val) = dto.legal_name { // cv_url, portfolio_url) are now updated in UsersSchema, not here
self.legal_name = val;
} // Only update professional fields that are still in MentorSchema
if let Some(val) = dto.gender {
self.gender = Some(val);
}
if let Some(val) = dto.domicile {
self.domicile = Some(val);
}
if let Some(val) = dto.identity_document_url {
self.identity_document_url = val;
}
if let Some(val) = dto.phone_for_verification {
self.phone_for_verification = val;
}
if let Some(val) = dto.bio {
self.bio = val;
}
if let Some(val) = dto.last_education {
self.last_education = Some(val);
}
if let Some(val) = dto.linkedin_url {
self.linkedin_url = Some(val);
}
if let Some(val) = dto.github_url {
self.github_url = Some(val);
}
if let Some(val) = dto.cv_url {
self.cv_url = Some(val);
}
if let Some(val) = dto.portfolio_url {
self.portfolio_url = Some(val);
}
if let Some(val) = dto.industries { if let Some(val) = dto.industries {
self.industries = val; self.industries = val;
} }
@@ -60,6 +60,18 @@ impl MentorsService {
user_schema.fullname = dto.fullname.clone(); user_schema.fullname = dto.fullname.clone();
user_schema.phone_number = dto.phone_number.clone(); user_schema.phone_number = dto.phone_number.clone();
// Update personal data from identity_and_verification
user_schema.legal_name = Some(dto.identity_and_verification.legal_name.clone());
user_schema.gender = dto.identity_and_verification.gender.clone();
user_schema.domicile = dto.identity_and_verification.domicile.clone();
user_schema.phone_for_verification = Some(dto.identity_and_verification.phone_for_verification.clone());
// Update personal data from professional_profile
user_schema.bio = Some(dto.professional_profile.bio.clone());
user_schema.last_education = dto.professional_profile.last_education.clone();
user_schema.linkedin_url = dto.professional_profile.linkedin_url.clone();
user_schema.github_url = dto.professional_profile.github_url.clone();
user_schema.cv_url = dto.professional_profile.cv_url.clone();
user_schema.portfolio_url = dto.professional_profile.portfolio_url.clone();
user_schema.updated_at = imphnen_utils::get_iso_date(); user_schema.updated_at = imphnen_utils::get_iso_date();
let hashed_password = match imphnen_utils::hash_password(&dto.password) { let hashed_password = match imphnen_utils::hash_password(&dto.password) {
@@ -135,6 +147,18 @@ impl MentorsService {
fullname: dto.fullname.clone(), fullname: dto.fullname.clone(),
password: hashed_password, password: hashed_password,
phone_number: dto.phone_number.clone(), phone_number: dto.phone_number.clone(),
// Store personal data from identity_and_verification in user
legal_name: Some(dto.identity_and_verification.legal_name.clone()),
gender: dto.identity_and_verification.gender.clone(),
domicile: dto.identity_and_verification.domicile.clone(),
phone_for_verification: Some(dto.identity_and_verification.phone_for_verification.clone()),
// Store personal data from professional_profile in user
bio: Some(dto.professional_profile.bio.clone()),
last_education: dto.professional_profile.last_education.clone(),
linkedin_url: dto.professional_profile.linkedin_url.clone(),
github_url: dto.professional_profile.github_url.clone(),
cv_url: dto.professional_profile.cv_url.clone(),
portfolio_url: dto.professional_profile.portfolio_url.clone(),
created_at: imphnen_utils::get_iso_date(), created_at: imphnen_utils::get_iso_date(),
updated_at: imphnen_utils::get_iso_date(), updated_at: imphnen_utils::get_iso_date(),
role: imphnen_utils::make_thing( role: imphnen_utils::make_thing(
@@ -187,11 +211,9 @@ impl MentorsService {
} }
let mentor_schema = MentorSchema::create( let mentor_schema = MentorSchema::create(
dto.identity_and_verification,
dto.professional_profile, dto.professional_profile,
dto.mentoring_logistics, dto.mentoring_logistics,
user_id.to_raw(), user_id.to_raw(),
final_user_email.clone(),
); );
match mentor_repo.query_create_mentor(mentor_schema.clone()).await { match mentor_repo.query_create_mentor(mentor_schema.clone()).await {
@@ -233,16 +255,27 @@ impl MentorsService {
pub async fn get_mentor_list(state: &AppState, meta: MetaRequestDto) -> Response { pub async fn get_mentor_list(state: &AppState, meta: MetaRequestDto) -> Response {
let repo = MentorsRepository::new(state); let repo = MentorsRepository::new(state);
let user_repo = UsersRepository::new(state);
match repo.query_mentor_list(meta).await { match repo.query_mentor_list(meta).await {
Ok(result) => { Ok(result) => {
let data: Vec<MentorListResponseDto> = result let mut mentor_list_data: Vec<MentorListResponseDto> = Vec::new();
.data
.into_iter() for mentor_with_user in result.data {
.map(MentorDetailQueryDto::from) let mentor_dto = MentorDetailQueryDto::from(mentor_with_user);
.map(MentorListResponseDto::from) let mut list_item = MentorListResponseDto::from(mentor_dto.clone());
.collect();
// Get user data to populate personal fields
if let Ok(user) = user_repo.query_user_by_id(&mentor_dto.user_id).await {
list_item.fullname = Some(user.fullname);
list_item.email = Some(user.email);
}
mentor_list_data.push(list_item);
}
success_list_response(ResponseListSuccessDto { success_list_response(ResponseListSuccessDto {
data, data: mentor_list_data,
meta: result.meta, meta: result.meta,
}) })
} }
@@ -251,12 +284,56 @@ impl MentorsService {
} }
pub async fn get_mentor_by_id(state: &AppState, id: &str) -> Response { pub async fn get_mentor_by_id(state: &AppState, id: &str) -> Response {
let repo = MentorsRepository::new(state); let mentor_repo = MentorsRepository::new(state);
let user_repo = UsersRepository::new(state);
let thing_id = Thing::from((ResourceEnum::Mentors.to_string().as_str(), id)); let thing_id = Thing::from((ResourceEnum::Mentors.to_string().as_str(), id));
match repo.query_mentor_by_id(&thing_id, false).await {
match mentor_repo.query_mentor_by_id(&thing_id, false).await {
Ok(mentor) => { Ok(mentor) => {
let dto = MentorDetailResponseDto::from(MentorDetailQueryDto::from(mentor)); // Get user data separately
success_response(ResponseSuccessDto { data: dto }) let user_result = user_repo.query_user_by_id(&mentor.user_id).await;
match user_result {
Ok(user) => {
// Combine mentor and user data
let dto = MentorDetailResponseDto {
id: mentor.id.to_raw(),
user_id: mentor.user_id.to_raw(),
// Personal data from user
fullname: Some(user.fullname),
email: Some(user.email),
legal_name: user.legal_name,
gender: user.gender,
domicile: user.domicile,
phone_for_verification: user.phone_for_verification,
bio: user.bio,
last_education: user.last_education,
linkedin_url: user.linkedin_url,
github_url: user.github_url,
cv_url: user.cv_url,
portfolio_url: user.portfolio_url,
// Professional data from mentor
industries: mentor.industries,
expertise: mentor.expertise,
languages: mentor.languages,
current_company: mentor.current_company,
current_role: mentor.current_role,
years_of_experience: mentor.years_of_experience,
topics_of_interest: mentor.topics_of_interest,
preferred_mentee_level: mentor.preferred_mentee_level,
preferred_mentoring_formats: mentor.preferred_mentoring_formats,
availability_commitment: mentor.availability_commitment,
mentoring_rate: mentor.mentoring_rate,
status: mentor.status,
created_at: mentor.created_at,
updated_at: mentor.updated_at,
};
success_response(ResponseSuccessDto { data: dto })
}
Err(_e) => {
error!("Failed to get user data for mentor {}: {}", id, _e);
common_response(StatusCode::INTERNAL_SERVER_ERROR, "Failed to get mentor user data")
}
}
} }
Err(_e) => common_response(StatusCode::NOT_FOUND, &_e.to_string()), Err(_e) => common_response(StatusCode::NOT_FOUND, &_e.to_string()),
} }
+1 -1
View File
@@ -26,5 +26,5 @@ pub fn mentors_router() -> Router {
.route("/update/{id}", put(put_update_mentor)) .route("/update/{id}", put(put_update_mentor))
.route("/update", put(put_update_mentor_no_id)) .route("/update", put(put_update_mentor_no_id))
.route("/delete/{id}", delete(delete_mentor)) .route("/delete/{id}", delete(delete_mentor))
.route("/verify/{id}", put(put(put_verify_mentor))) .route("/verify/{id}", put(put_verify_mentor))
} }
+1
View File
@@ -8,4 +8,5 @@ axum.workspace = true
serde.workspace = true serde.workspace = true
utoipa.workspace = true utoipa.workspace = true
surrealdb.workspace = true surrealdb.workspace = true
anyhow.workspace = true
thiserror.workspace = true thiserror.workspace = true
+27
View File
@@ -9,6 +9,14 @@ pub mod error {
pub enum Error { pub enum Error {
#[error("database error: {0}")] #[error("database error: {0}")]
Db(String), Db(String),
#[error("anyhow error: {0}")]
Anyhow(#[from] anyhow::Error),
#[error("HTTP status code error: {0}")]
StatusCode(StatusCode),
#[error("authentication error: {0}")]
Auth(String),
#[error("validation error: {0}")]
Validation(String),
} }
impl IntoResponse for Error { impl IntoResponse for Error {
@@ -18,6 +26,19 @@ pub mod error {
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
format!("Database error: {detail}"), format!("Database error: {detail}"),
), ),
Error::Anyhow(detail) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Internal server error: {detail}"),
),
Error::StatusCode(s) => (s, format!("HTTP error: {}", s)),
Error::Auth(detail) => (
StatusCode::UNAUTHORIZED,
format!("Authentication error: {detail}"),
),
Error::Validation(detail) => (
StatusCode::BAD_REQUEST,
format!("Validation error: {detail}"),
),
}; };
(status, Json(error_message)).into_response() (status, Json(error_message)).into_response()
} }
@@ -28,4 +49,10 @@ pub mod error {
Self::Db(error.to_string()) Self::Db(error.to_string())
} }
} }
impl From<StatusCode> for Error {
fn from(status: StatusCode) -> Self {
Self::StatusCode(status)
}
}
} }
@@ -25,13 +25,13 @@ pub async fn get_detail_gacha_claim(
Path(id): Path<String>, Path(id): Path<String>,
) -> impl IntoResponse { ) -> impl IntoResponse {
match permissions_guard( match permissions_guard(
&headers, headers,
state.clone(), Extension(state),
vec![PermissionsEnum::CreateGachaClaims], vec![PermissionsEnum::ReadDetailGachaClaims],
) )
.await .await
{ {
Ok(_) => GachaClaimService::get_gacha_claim_by_id(&state, id).await, Ok((_user, state)) => GachaClaimService::get_gacha_claim_by_id(&state, id).await,
Err(response) => response, Err(response) => response,
} }
} }
@@ -54,13 +54,13 @@ pub async fn post_create_gacha_claim(
Json(payload): Json<GachaClaimRequestDto>, Json(payload): Json<GachaClaimRequestDto>,
) -> impl IntoResponse { ) -> impl IntoResponse {
match permissions_guard( match permissions_guard(
&headers, headers,
state.clone(), Extension(state),
vec![PermissionsEnum::CreateGachaClaims], vec![PermissionsEnum::CreateGachaClaims],
) )
.await .await
{ {
Ok(_) => GachaClaimService::create_gacha_claim(&state, payload).await, Ok((_user, state)) => GachaClaimService::create_gacha_claim(&state, payload).await,
Err(response) => response, Err(response) => response,
} }
} }
@@ -3,7 +3,7 @@ use crate::{AppState, ResourceEnum};
use anyhow::{Result, bail}; use anyhow::{Result, bail};
use imphnen_iam::DetailQueryBuilder; use imphnen_iam::DetailQueryBuilder;
use std::time::Instant; use std::time::Instant;
use tracing::instrument; use tracing::{instrument, info};
pub struct GachaClaimRepository<'a> { pub struct GachaClaimRepository<'a> {
state: &'a AppState, state: &'a AppState,
@@ -27,6 +27,7 @@ impl<'a> GachaClaimRepository<'a> {
.with_fetch("item") .with_fetch("item")
.with_fetch("user"); .with_fetch("user");
let sql = builder.build(); let sql = builder.build();
info!(query = %sql, "Executing SurrealDB query");
let result: Option<GachaClaimQueryDto> = let result: Option<GachaClaimQueryDto> =
builder.apply_bindings(db.query(sql)).await?.take(0)?; builder.apply_bindings(db.query(sql)).await?.take(0)?;
let elapsed = now.elapsed(); let elapsed = now.elapsed();
@@ -48,6 +49,11 @@ impl<'a> GachaClaimRepository<'a> {
) -> Result<String> { ) -> Result<String> {
let now = Instant::now(); let now = Instant::now();
let db = &self.state.surrealdb_ws; let db = &self.state.surrealdb_ws;
info!(
resource = %ResourceEnum::GachaClaims.to_string(),
content = ?data,
"Executing SurrealDB create query"
);
let record: Option<GachaClaimSchema> = db let record: Option<GachaClaimSchema> = db
.create(ResourceEnum::GachaClaims.to_string()) .create(ResourceEnum::GachaClaims.to_string())
.content(data) .content(data)
@@ -4,7 +4,7 @@ use anyhow::{Result, bail};
use imphnen_iam::make_thing; use imphnen_iam::make_thing;
use std::time::Instant; use std::time::Instant;
use surrealdb::Uuid; use surrealdb::Uuid;
use tracing::instrument; use tracing::{instrument, info};
pub struct GachaCreditRepository<'a> { pub struct GachaCreditRepository<'a> {
state: &'a AppState, state: &'a AppState,
@@ -27,6 +27,7 @@ impl<'a> GachaCreditRepository<'a> {
ResourceEnum::GachaCredits, ResourceEnum::GachaCredits,
ResourceEnum::Users ResourceEnum::Users
); );
info!(query = %sql, "Executing SurrealDB query");
let result: Vec<GachaCreditSchema> = let result: Vec<GachaCreditSchema> =
db.query(sql).bind(("user_id", user_id)).await?.take(0)?; db.query(sql).bind(("user_id", user_id)).await?.take(0)?;
let elapsed = now.elapsed(); let elapsed = now.elapsed();
@@ -66,6 +67,7 @@ impl<'a> GachaCreditRepository<'a> {
bail!("No extra roll credits remaining"); bail!("No extra roll credits remaining");
} }
credit.available_rolls -= 1; credit.available_rolls -= 1;
info!(operation = "update", table = %ResourceEnum::GachaCredits.to_string(), id = %credit.id.id.to_raw(), "Executing SurrealDB update for consume_credit");
let _: Option<GachaCreditSchema> = db let _: Option<GachaCreditSchema> = db
.update(( .update((
&ResourceEnum::GachaCredits.to_string(), &ResourceEnum::GachaCredits.to_string(),
@@ -91,6 +93,7 @@ impl<'a> GachaCreditRepository<'a> {
let db = &self.state.surrealdb_ws; let db = &self.state.surrealdb_ws;
if let Some(mut credit) = self.query_by_user_id(payload.user_id.clone()).await? { if let Some(mut credit) = self.query_by_user_id(payload.user_id.clone()).await? {
credit.available_rolls += payload.amount; credit.available_rolls += payload.amount;
info!(operation = "update", table = %ResourceEnum::GachaCredits.to_string(), id = %credit.id.id.to_raw(), "Executing SurrealDB update for add_credit");
let _: Option<GachaCreditSchema> = db let _: Option<GachaCreditSchema> = db
.update(( .update((
&ResourceEnum::GachaCredits.to_string(), &ResourceEnum::GachaCredits.to_string(),
@@ -108,6 +111,7 @@ impl<'a> GachaCreditRepository<'a> {
available_rolls: payload.amount, available_rolls: payload.amount,
..Default::default() ..Default::default()
}; };
info!(operation = "create", table = %ResourceEnum::GachaCredits.to_string(), "Executing SurrealDB create for add_credit");
let _: Option<GachaCreditSchema> = db let _: Option<GachaCreditSchema> = db
.create(ResourceEnum::GachaCredits.to_string()) .create(ResourceEnum::GachaCredits.to_string())
.content(data) .content(data)
@@ -1,5 +1,5 @@
use crate::{ use crate::{
AppState, GachaItemDto, GachaItemRequestDto, GachaItemService, MessageResponseDto, AppState, GachaItemDto, GachaItemRequestDto, GachaItemUpdateRequestDto, GachaItemService, MessageResponseDto,
MetaRequestDto, ResponseListSuccessDto, ResponseSuccessDto, MetaRequestDto, ResponseListSuccessDto, ResponseSuccessDto,
}; };
use axum::{ use axum::{
@@ -36,13 +36,13 @@ pub async fn get_gacha_item_list(
Query(meta): Query<MetaRequestDto>, Query(meta): Query<MetaRequestDto>,
) -> impl IntoResponse { ) -> impl IntoResponse {
match permissions_guard( match permissions_guard(
&headers, headers,
state.clone(), Extension(state),
vec![PermissionsEnum::ReadListGachaItems], vec![PermissionsEnum::ReadListGachaItems],
) )
.await .await
{ {
Ok(_) => GachaItemService::get_gacha_item_list(&state, meta).await, Ok((_user, state)) => GachaItemService::get_gacha_item_list(&state, meta).await,
Err(response) => response, Err(response) => response,
} }
} }
@@ -65,13 +65,13 @@ pub async fn get_gacha_item_by_id(
Path(id): Path<String>, Path(id): Path<String>,
) -> impl IntoResponse { ) -> impl IntoResponse {
match permissions_guard( match permissions_guard(
&headers, headers,
state.clone(), Extension(state),
vec![PermissionsEnum::ReadDetailGachaItems], vec![PermissionsEnum::ReadDetailGachaItems],
) )
.await .await
{ {
Ok(_) => GachaItemService::get_gacha_item_by_id(&state, id).await, Ok((_user, state)) => GachaItemService::get_gacha_item_by_id(&state, id).await,
Err(response) => response, Err(response) => response,
} }
} }
@@ -94,13 +94,13 @@ pub async fn post_create_gacha_item(
Json(payload): Json<GachaItemRequestDto>, Json(payload): Json<GachaItemRequestDto>,
) -> impl IntoResponse { ) -> impl IntoResponse {
match permissions_guard( match permissions_guard(
&headers, headers,
state.clone(), Extension(state),
vec![PermissionsEnum::CreateGachaItems], vec![PermissionsEnum::CreateGachaItems],
) )
.await .await
{ {
Ok(_) => GachaItemService::create_gacha_item(&state, payload).await, Ok((_user, state)) => GachaItemService::create_gacha_item(&state, payload).await,
Err(response) => response, Err(response) => response,
} }
} }
@@ -111,7 +111,7 @@ pub async fn post_create_gacha_item(
security( security(
("Bearer" = []) ("Bearer" = [])
), ),
request_body = GachaItemRequestDto, request_body = GachaItemUpdateRequestDto,
responses( responses(
(status = 200, description = "Update gacha item", body = MessageResponseDto) (status = 200, description = "Update gacha item", body = MessageResponseDto)
), ),
@@ -121,16 +121,16 @@ pub async fn put_update_gacha_item(
headers: HeaderMap, headers: HeaderMap,
Extension(state): Extension<AppState>, Extension(state): Extension<AppState>,
Path(id): Path<String>, Path(id): Path<String>,
Json(payload): Json<GachaItemRequestDto>, Json(payload): Json<GachaItemUpdateRequestDto>,
) -> impl IntoResponse { ) -> impl IntoResponse {
match permissions_guard( match permissions_guard(
&headers, headers,
state.clone(), Extension(state),
vec![PermissionsEnum::UpdateGachaItems], vec![PermissionsEnum::UpdateGachaItems],
) )
.await .await
{ {
Ok(_) => GachaItemService::update_gacha_item(&state, payload, id).await, Ok((_user, state)) => GachaItemService::update_gacha_item(&state, payload, id).await,
Err(response) => response, Err(response) => response,
} }
} }
@@ -152,13 +152,13 @@ pub async fn delete_gacha_item(
Path(id): Path<String>, Path(id): Path<String>,
) -> impl IntoResponse { ) -> impl IntoResponse {
match permissions_guard( match permissions_guard(
&headers, headers,
state.clone(), Extension(state),
vec![PermissionsEnum::DeleteGachaItems], vec![PermissionsEnum::DeleteGachaItems],
) )
.await .await
{ {
Ok(_) => GachaItemService::delete_gacha_item(&state, id).await, Ok((_user, state)) => GachaItemService::delete_gacha_item(&state, id).await,
Err(response) => response, Err(response) => response,
} }
} }
@@ -11,6 +11,16 @@ pub struct GachaItemRequestDto {
pub image_url: String, pub image_url: String,
} }
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)]
pub struct GachaItemUpdateRequestDto {
#[validate(length(min = 1, message = "Item name must not be empty"))]
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[validate(length(min = 1, message = "Image URL must not be empty"))]
#[serde(skip_serializing_if = "Option::is_none")]
pub image_url: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] #[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct GachaItemDto { pub struct GachaItemDto {
pub id: String, pub id: String,
@@ -9,6 +9,7 @@ use imphnen_utils::get_iso_date;
use serde_json::{Map, Value}; use serde_json::{Map, Value};
use std::time::Instant; use std::time::Instant;
use tracing::instrument; use tracing::instrument;
use tracing::info;
pub struct GachaItemRepository<'a> { pub struct GachaItemRepository<'a> {
state: &'a AppState, state: &'a AppState,
@@ -25,6 +26,11 @@ impl<'a> GachaItemRepository<'a> {
meta: MetaRequestDto, meta: MetaRequestDto,
) -> Result<ResponseListSuccessDto<Vec<GachaItemDto>>> { ) -> Result<ResponseListSuccessDto<Vec<GachaItemDto>>> {
let now = Instant::now(); let now = Instant::now();
let surreal_query = format!(
"SELECT * FROM {} WHERE is_deleted = false AND name LIKE ?",
ResourceEnum::GachaItems.to_string()
);
info!(query = %surreal_query, "Executing SurrealDB query");
let raw_result: ResponseListSuccessDto<Vec<GachaItemSchema>> = let raw_result: ResponseListSuccessDto<Vec<GachaItemSchema>> =
QueryListBuilder::new( QueryListBuilder::new(
&self.state.surrealdb_ws, &self.state.surrealdb_ws,
@@ -57,6 +63,8 @@ impl<'a> GachaItemRepository<'a> {
pub async fn query_gacha_item_by_id(&self, id: String) -> Result<GachaItemSchema> { pub async fn query_gacha_item_by_id(&self, id: String) -> Result<GachaItemSchema> {
let now = Instant::now(); let now = Instant::now();
let db = &self.state.surrealdb_ws; let db = &self.state.surrealdb_ws;
let surreal_query = format!("SELECT * FROM {} WHERE id = '{}'", ResourceEnum::GachaItems.to_string(), id);
info!(query = %surreal_query, "Executing SurrealDB query");
let result: Option<GachaItemSchema> = db let result: Option<GachaItemSchema> = db
.select((ResourceEnum::GachaItems.to_string(), id.clone())) .select((ResourceEnum::GachaItems.to_string(), id.clone()))
.await?; .await?;
@@ -79,6 +87,8 @@ impl<'a> GachaItemRepository<'a> {
) -> Result<String> { ) -> Result<String> {
let now = Instant::now(); let now = Instant::now();
let db = &self.state.surrealdb_ws; let db = &self.state.surrealdb_ws;
let surreal_query = format!("CREATE {} CONTENT ...", ResourceEnum::GachaItems.to_string());
info!(query = %surreal_query, "Executing SurrealDB query");
let record: Option<GachaItemSchema> = db let record: Option<GachaItemSchema> = db
.create(ResourceEnum::GachaItems.to_string()) .create(ResourceEnum::GachaItems.to_string())
.content(data) .content(data)
@@ -111,6 +121,8 @@ impl<'a> GachaItemRepository<'a> {
created_at: existing.created_at, created_at: existing.created_at,
..data.clone() ..data.clone()
}; };
let surreal_query = format!("UPDATE {:?} MERGE ...", record_key);
info!(query = %surreal_query, "Executing SurrealDB query");
let record: Option<GachaItemSchema> = let record: Option<GachaItemSchema> =
db.update(record_key).merge(merged).await?; db.update(record_key).merge(merged).await?;
let elapsed = now.elapsed(); let elapsed = now.elapsed();
@@ -139,6 +151,8 @@ impl<'a> GachaItemRepository<'a> {
patch.insert("is_deleted".to_string(), Value::Bool(true)); patch.insert("is_deleted".to_string(), Value::Bool(true));
patch.insert("updated_at".to_string(), Value::String(get_iso_date())); patch.insert("updated_at".to_string(), Value::String(get_iso_date()));
let surreal_query = format!("UPDATE {:?} MERGE ...", record_key);
info!(query = %surreal_query, "Executing SurrealDB query");
let record: Option<GachaItemSchema> = db.update(record_key).merge(patch).await?; let record: Option<GachaItemSchema> = db.update(record_key).merge(patch).await?;
let elapsed = now.elapsed(); let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string()) if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
@@ -43,4 +43,8 @@ impl GachaItemSchema {
..Default::default() ..Default::default()
} }
} }
pub fn from_existing(existing: GachaItemSchema) -> Self {
existing
}
} }
@@ -1,11 +1,12 @@
use crate::{ use crate::{
AppState, GachaItemDto, GachaItemRepository, GachaItemRequestDto, GachaItemSchema, AppState, GachaItemDto, GachaItemRepository, GachaItemRequestDto, GachaItemUpdateRequestDto, GachaItemSchema,
MetaRequestDto, ResourceEnum, ResponseListSuccessDto, ResponseSuccessDto, MetaRequestDto, ResourceEnum, ResponseListSuccessDto, ResponseSuccessDto,
common_response, make_thing, success_list_response, success_response, common_response, make_thing, success_list_response, success_response,
validate_request, validate_request,
}; };
use axum::http::StatusCode; use axum::http::StatusCode;
use axum::response::Response; use axum::response::Response;
use imphnen_utils::get_iso_date;
pub struct GachaItemService; pub struct GachaItemService;
@@ -59,20 +60,33 @@ impl GachaItemService {
pub async fn update_gacha_item( pub async fn update_gacha_item(
state: &AppState, state: &AppState,
payload: GachaItemRequestDto, payload: GachaItemUpdateRequestDto,
id: String, id: String,
) -> Response { ) -> Response {
if let Err((status, message)) = validate_request(&payload) { if let Err((status, message)) = validate_request(&payload) {
return common_response(status, &message); return common_response(status, &message);
} }
let repo = GachaItemRepository::new(state); let repo = GachaItemRepository::new(state);
let schema = GachaItemSchema {
id: make_thing(&ResourceEnum::GachaItems.to_string(), &id), // Get current gacha item data first
name: payload.name, let _thing_id = make_thing(&ResourceEnum::GachaItems.to_string(), &id);
image_url: payload.image_url, let current_item = match repo.query_gacha_item_by_id(id.clone()).await {
..Default::default() Ok(item) => item,
Err(_) => return common_response(StatusCode::NOT_FOUND, "Gacha Item not found"),
}; };
match repo.query_update_gacha_item(schema).await {
let mut updated_item = current_item;
updated_item.updated_at = Some(get_iso_date());
// Only update fields that are provided
if let Some(name) = payload.name {
updated_item.name = name;
}
if let Some(image_url) = payload.image_url {
updated_item.image_url = image_url;
}
match repo.query_update_gacha_item(updated_item).await {
Ok(msg) => common_response(StatusCode::OK, &msg), Ok(msg) => common_response(StatusCode::OK, &msg),
Err(e) => { Err(e) => {
if e.to_string().contains("not found") { if e.to_string().contains("not found") {
@@ -25,13 +25,13 @@ pub async fn get_detail_gacha_roll(
Path(id): Path<String>, Path(id): Path<String>,
) -> impl IntoResponse { ) -> impl IntoResponse {
match permissions_guard( match permissions_guard(
&headers, headers,
state.clone(), Extension(state),
vec![PermissionsEnum::ReadDetailGachaRolls], vec![PermissionsEnum::ReadDetailGachaRolls],
) )
.await .await
{ {
Ok(_) => GachaRollService::get_gacha_roll_by_id(&state, id).await, Ok((_user, state)) => GachaRollService::get_gacha_roll_by_id(&state, id).await,
Err(response) => response, Err(response) => response,
} }
} }
@@ -54,13 +54,13 @@ pub async fn post_create_gacha_roll(
Json(payload): Json<GachaRollRequestDto>, Json(payload): Json<GachaRollRequestDto>,
) -> impl IntoResponse { ) -> impl IntoResponse {
match permissions_guard( match permissions_guard(
&headers, headers,
state.clone(), Extension(state),
vec![PermissionsEnum::CreateGachaRolls], vec![PermissionsEnum::CreateGachaRolls],
) )
.await .await
{ {
Ok(_) => GachaRollService::create_gacha_roll(&state, payload).await, Ok((_user, state)) => GachaRollService::create_gacha_roll(&state, payload).await,
Err(response) => response, Err(response) => response,
} }
} }
@@ -81,13 +81,13 @@ pub async fn post_execute_gacha_roll(
Extension(state): Extension<AppState>, Extension(state): Extension<AppState>,
) -> impl IntoResponse { ) -> impl IntoResponse {
match permissions_guard( match permissions_guard(
&headers, headers.clone(),
state.clone(), Extension(state),
vec![PermissionsEnum::ExecuteGachaRolls], vec![PermissionsEnum::ExecuteGachaRolls],
) )
.await .await
{ {
Ok(_) => GachaRollService::execute_roll_once(headers, &state).await, Ok((_user, state)) => GachaRollService::execute_roll_once(headers, &state).await,
Err(response) => response, Err(response) => response,
} }
} }
@@ -110,13 +110,13 @@ pub async fn delete_gacha_roll(
Path(id): Path<String>, Path(id): Path<String>,
) -> impl IntoResponse { ) -> impl IntoResponse {
match permissions_guard( match permissions_guard(
&headers, headers,
state.clone(), Extension(state),
vec![PermissionsEnum::DeleteGachaRolls], vec![PermissionsEnum::DeleteGachaRolls],
) )
.await .await
{ {
Ok(_) => GachaRollService::soft_delete_gacha_roll(&state, id).await, Ok((_user, state)) => GachaRollService::soft_delete_gacha_roll(&state, id).await,
Err(response) => response, Err(response) => response,
} }
} }
@@ -10,6 +10,7 @@ use rand_distr::weighted::WeightedIndex;
use serde_json::{Map, Value}; use serde_json::{Map, Value};
use std::time::Instant; use std::time::Instant;
use tracing::instrument; use tracing::instrument;
use tracing::info;
pub struct GachaRollRepository<'a> { pub struct GachaRollRepository<'a> {
state: &'a AppState, state: &'a AppState,
@@ -33,6 +34,7 @@ impl<'a> GachaRollRepository<'a> {
.with_select_fields(vec!["*"]) .with_select_fields(vec!["*"])
.with_fetch("item"); .with_fetch("item");
let sql = builder.build(); let sql = builder.build();
info!(query = %sql, "Executing SurrealDB query");
let result: Option<GachaRollQueryDto> = let result: Option<GachaRollQueryDto> =
builder.apply_bindings(db.query(sql)).await?.take(0)?; builder.apply_bindings(db.query(sql)).await?.take(0)?;
let elapsed = now.elapsed(); let elapsed = now.elapsed();
@@ -54,6 +56,7 @@ impl<'a> GachaRollRepository<'a> {
) -> Result<String> { ) -> Result<String> {
let now = Instant::now(); let now = Instant::now();
let db = &self.state.surrealdb_ws; let db = &self.state.surrealdb_ws;
info!(query = "CREATE", "Executing SurrealDB create operation for GachaRolls");
let record: Option<GachaRollSchema> = db let record: Option<GachaRollSchema> = db
.create(ResourceEnum::GachaRolls.to_string()) .create(ResourceEnum::GachaRolls.to_string())
.content(data) .content(data)
@@ -77,6 +80,7 @@ impl<'a> GachaRollRepository<'a> {
let table_name = ResourceEnum::GachaRolls.to_string(); let table_name = ResourceEnum::GachaRolls.to_string();
let sql = let sql =
format!("SELECT * FROM {table_name} WHERE is_deleted = false FETCH item"); format!("SELECT * FROM {table_name} WHERE is_deleted = false FETCH item");
info!(query = %sql, "Executing SurrealDB query");
let result: Vec<GachaRollQueryDto> = db.query(sql).await?.take(0)?; let result: Vec<GachaRollQueryDto> = db.query(sql).await?.take(0)?;
let elapsed = now.elapsed(); let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string()) if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
@@ -87,6 +91,7 @@ impl<'a> GachaRollRepository<'a> {
Ok(result) Ok(result)
} }
#[instrument]
pub fn roll_once(rolls: &[GachaRollQueryDto]) -> Option<GachaRollQueryDto> { pub fn roll_once(rolls: &[GachaRollQueryDto]) -> Option<GachaRollQueryDto> {
let filtered: Vec<_> = rolls let filtered: Vec<_> = rolls
.iter() .iter()
@@ -120,6 +125,7 @@ impl<'a> GachaRollRepository<'a> {
patch.insert("is_deleted".to_string(), Value::Bool(true)); patch.insert("is_deleted".to_string(), Value::Bool(true));
patch.insert("updated_at".to_string(), Value::String(get_iso_date())); patch.insert("updated_at".to_string(), Value::String(get_iso_date()));
info!(query = "UPDATE", record_key = ?record_key, "Executing SurrealDB update operation for GachaRolls");
let record: Option<GachaRollSchema> = db.update(record_key).merge(patch).await?; let record: Option<GachaRollSchema> = db.update(record_key).merge(patch).await?;
let elapsed = now.elapsed(); let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string()) if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
+3
View File
@@ -29,6 +29,7 @@ use imphnen_iam::{
RolesRequestUpdateDto, TokenDto, UsersCreateRequestDto, UsersDetailItemDto, RolesRequestUpdateDto, TokenDto, UsersCreateRequestDto, UsersDetailItemDto,
UsersListItemDto, UsersUpdateRequestDto, auth, permissions, roles, users, UsersListItemDto, UsersUpdateRequestDto, auth, permissions, roles, users,
}; };
use imphnen_iam::users::users_controller::FileUploadSchema;
use utoipa::{ use utoipa::{
Modify, OpenApi, Modify, OpenApi,
openapi::security::{Http, HttpAuthScheme, SecurityScheme}, openapi::security::{Http, HttpAuthScheme, SecurityScheme},
@@ -53,6 +54,7 @@ use utoipa::{
users::users_controller::get_user_by_id, users::users_controller::get_user_by_id,
users::users_controller::get_user_me, users::users_controller::get_user_me,
users::users_controller::get_user_list, users::users_controller::get_user_list,
users::users_controller::upload_file,
roles::roles_controller::get_role_list, roles::roles_controller::get_role_list,
roles::roles_controller::get_role_by_id, roles::roles_controller::get_role_by_id,
roles::roles_controller::post_create_role, roles::roles_controller::post_create_role,
@@ -114,6 +116,7 @@ use utoipa::{
UsersListItemDto, UsersListItemDto,
UsersUpdateRequestDto, UsersUpdateRequestDto,
UsersCreateRequestDto, UsersCreateRequestDto,
FileUploadSchema,
GachaClaimItemDto, GachaClaimItemDto,
GachaClaimRequestDto, GachaClaimRequestDto,
GachaItemDto, GachaItemDto,
+7 -1
View File
@@ -6,10 +6,13 @@ edition = "2024"
[dependencies] [dependencies]
imphnen-libs.workspace = true imphnen-libs.workspace = true
imphnen-utils.workspace = true imphnen-utils.workspace = true
imphnen-entities.workspace = true imphnen-entities.workspace = true
axum.workspace = true axum.workspace = true
serde.workspace = true serde.workspace = true
serde_json.workspace = true serde_json = { workspace = true }
oauth2 = { workspace = true }
reqwest = { workspace = true, features = ["json"] }
utoipa.workspace = true utoipa.workspace = true
lazy_static.workspace = true lazy_static.workspace = true
regex.workspace = true regex.workspace = true
@@ -28,7 +31,10 @@ log.workspace = true
once_cell.workspace = true once_cell.workspace = true
tracing.workspace = true tracing.workspace = true
uuid.workspace = true uuid.workspace = true
axum-extra.workspace = true
[dev-dependencies] [dev-dependencies]
dotenvy.workspace = true dotenvy.workspace = true
tokio-test = { workspace = true }
mockall = { workspace = true }
http-body-util.workspace = true http-body-util.workspace = true
+5 -2
View File
@@ -1,10 +1,12 @@
use super::{ use super::{
AuthLoginRequestDto, AuthRefreshTokenRequestDto, AuthRegisterRequestDto, AuthLoginRequestDto, AuthRefreshTokenRequestDto, AuthRegisterRequestDto,
AuthResendOtpRequestDto, AuthService, AuthVerifyEmailRequestDto, AuthResendOtpRequestDto, AuthVerifyEmailRequestDto,
}; };
use crate::{AppState, v1::AuthLoginResponsetDto}; use crate::{AppState, v1::AuthLoginResponsetDto};
use crate::{AuthNewPasswordRequestDto, MessageResponseDto, ResponseSuccessDto}; use crate::{AuthNewPasswordRequestDto, MessageResponseDto, ResponseSuccessDto};
use axum::{Extension, Json, response::IntoResponse}; use axum::{Extension, Json, response::IntoResponse};
use crate::v1::auth::auth_service::AuthServiceTrait;
use crate::v1::auth::auth_service::AuthService;
#[utoipa::path( #[utoipa::path(
post, post,
@@ -137,7 +139,8 @@ pub async fn post_new_password(
tag = "Authentication" tag = "Authentication"
)] )]
pub async fn post_refresh_token( pub async fn post_refresh_token(
Extension(state): Extension<AppState>,
Json(payload): Json<AuthRefreshTokenRequestDto>, Json(payload): Json<AuthRefreshTokenRequestDto>,
) -> impl IntoResponse { ) -> impl IntoResponse {
AuthService::mutation_refresh_token(payload).await AuthService::mutation_refresh_token(payload, &state).await
} }
+2 -2
View File
@@ -32,7 +32,7 @@ pub struct AuthLoginResponsetDto {
pub user: UsersDetailItemDto, pub user: UsersDetailItemDto,
} }
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] #[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Default)]
pub struct TokenDto { pub struct TokenDto {
pub access_token: String, pub access_token: String,
pub refresh_token: String, pub refresh_token: String,
@@ -121,4 +121,4 @@ pub struct AuthSetNewPasswordRequestDto {
pub struct UserCacheSchema { pub struct UserCacheSchema {
pub email: String, pub email: String,
pub permissions: Vec<String>, pub permissions: Vec<String>,
} }
@@ -7,6 +7,8 @@ use crate::{
use anyhow::{Result, anyhow, bail}; use anyhow::{Result, anyhow, bail};
use chrono::{Duration, Utc}; use chrono::{Duration, Utc};
use surrealdb::sql::Thing; use surrealdb::sql::Thing;
use tracing::instrument;
use tracing::info;
pub struct AuthRepository<'a> { pub struct AuthRepository<'a> {
pub state: &'a AppState, pub state: &'a AppState,
@@ -17,6 +19,7 @@ impl<'a> AuthRepository<'a> {
Self { state } Self { state }
} }
#[instrument(skip(self, user), err)]
pub async fn query_store_user(&self, user: UsersDetailQueryDto) -> Result<String> { pub async fn query_store_user(&self, user: UsersDetailQueryDto) -> Result<String> {
if user.email.trim().is_empty() { if user.email.trim().is_empty() {
bail!("Email is required"); bail!("Email is required");
@@ -30,12 +33,14 @@ impl<'a> AuthRepository<'a> {
permissions, permissions,
}; };
info!(query = %format!("DELETE FROM {} WHERE id = '{}'", table, user_id), "Executing SurrealDB query");
let _record: Option<UserCacheSchema> = self let _record: Option<UserCacheSchema> = self
.state .state
.surrealdb_mem .surrealdb_mem
.delete::<Option<UserCacheSchema>>((table.clone(), user_id.clone())) .delete::<Option<UserCacheSchema>>((table.clone(), user_id.clone()))
.await?; .await?;
info!(query = %format!("CREATE {}:{}", table, user_id), "Executing SurrealDB query");
let record: Option<UserCacheSchema> = self let record: Option<UserCacheSchema> = self
.state .state
.surrealdb_mem .surrealdb_mem
@@ -49,10 +54,12 @@ impl<'a> AuthRepository<'a> {
} }
} }
#[instrument(skip(self, email), err)]
pub async fn query_get_stored_user( pub async fn query_get_stored_user(
&self, &self,
email: String, email: String,
) -> Result<UsersDetailQueryDto> { ) -> Result<UsersDetailQueryDto> {
info!(query = %format!("SELECT FROM {} WHERE id = '{}'", ResourceEnum::UsersCache.to_string(), email), "Executing SurrealDB query");
let user_cache: Option<UserCacheSchema> = self let user_cache: Option<UserCacheSchema> = self
.state .state
.surrealdb_mem .surrealdb_mem
@@ -87,13 +94,29 @@ impl<'a> AuthRepository<'a> {
Ok(UsersDetailQueryDto { Ok(UsersDetailQueryDto {
id: Thing::from(("app_users".to_string(), email.clone())), id: Thing::from(("app_users".to_string(), email.clone())),
fullname: "Cached User".to_string(), fullname: "Cached User".to_string(),
legal_name: None,
email: cache.email, email: cache.email,
avatar: None, avatar: None,
phone_number: String::new(), phone_number: String::new(),
phone_for_verification: None,
is_active: true, is_active: true,
is_deleted: false, is_deleted: false,
gender: None, gender: None,
birthdate: None, birthdate: None,
domicile: None,
bio: None,
last_education: None,
linkedin_url: None,
github_url: None,
cv_url: None,
portfolio_url: None,
website_url: None,
twitter_url: None,
location: None,
skills: None,
experience: None,
education: None,
career_status: None,
password: String::new(), password: String::new(),
role: role_detail_query_dto, role: role_detail_query_dto,
created_at: String::new(), created_at: String::new(),
@@ -105,7 +128,9 @@ impl<'a> AuthRepository<'a> {
} }
} }
#[instrument(skip(self, email), err)]
pub async fn query_delete_stored_user(&self, email: String) -> Result<String> { pub async fn query_delete_stored_user(&self, email: String) -> Result<String> {
info!(query = %format!("DELETE FROM {} WHERE id = '{}'", ResourceEnum::UsersCache.to_string(), email), "Executing SurrealDB query");
let record: Option<UsersDetailQueryDto> = self let record: Option<UsersDetailQueryDto> = self
.state .state
.surrealdb_mem .surrealdb_mem
@@ -117,13 +142,16 @@ impl<'a> AuthRepository<'a> {
} }
} }
#[instrument(skip(self, email), err)]
pub async fn query_get_stored_otp(&self, email: String) -> Result<u32> { pub async fn query_get_stored_otp(&self, email: String) -> Result<u32> {
let table = ResourceEnum::OtpCache.to_string(); let table = ResourceEnum::OtpCache.to_string();
let key = (table.as_str(), email.as_str()); let key = (table.as_str(), email.as_str());
info!(query = %format!("SELECT FROM {} WHERE id = '{}'", table, email), "Executing SurrealDB query");
let result: Option<AuthOtpSchema> = self.state.surrealdb_mem.select(key).await?; let result: Option<AuthOtpSchema> = self.state.surrealdb_mem.select(key).await?;
match result { match result {
Some(data) => match Utc::now() > data.expires_at { Some(data) => match Utc::now() > data.expires_at {
true => { true => {
info!(query = %format!("DELETE FROM {} WHERE id = '{}'", table, email), "Executing SurrealDB query");
let _ = self let _ = self
.state .state
.surrealdb_mem .surrealdb_mem
@@ -137,9 +165,11 @@ impl<'a> AuthRepository<'a> {
} }
} }
#[instrument(skip(self, email, otp), err)]
pub async fn query_store_otp(&self, email: String, otp: u32) -> Result<String> { pub async fn query_store_otp(&self, email: String, otp: u32) -> Result<String> {
let expires_at = Utc::now() + Duration::seconds(300); let expires_at = Utc::now() + Duration::seconds(300);
let table: String = ResourceEnum::OtpCache.to_string(); let table: String = ResourceEnum::OtpCache.to_string();
info!(query = %format!("CREATE {}:{}", table, email), "Executing SurrealDB query");
let record: Option<AuthOtpSchema> = self let record: Option<AuthOtpSchema> = self
.state .state
.surrealdb_mem .surrealdb_mem
@@ -152,7 +182,9 @@ impl<'a> AuthRepository<'a> {
} }
} }
#[instrument(skip(self, email), err)]
pub async fn query_delete_stored_otp(&self, email: String) -> Result<String> { pub async fn query_delete_stored_otp(&self, email: String) -> Result<String> {
info!(query = %format!("DELETE FROM {} WHERE id = '{}'", ResourceEnum::OtpCache.to_string(), email), "Executing SurrealDB query");
let record: Option<AuthOtpSchema> = self let record: Option<AuthOtpSchema> = self
.state .state
.surrealdb_mem .surrealdb_mem
+211 -128
View File
@@ -1,38 +1,88 @@
use std::pin::Pin;
use std::future::Future;
use super::{ use super::{
AuthLoginRequestDto, AuthLoginResponsetDto, AuthNewPasswordRequestDto, AuthLoginRequestDto, AuthLoginResponsetDto, AuthNewPasswordRequestDto,
AuthRefreshTokenRequestDto, AuthRegisterRequestDto, AuthRepository, AuthRefreshTokenRequestDto, AuthRegisterRequestDto, AuthRepository,
AuthResendOtpRequestDto, AuthVerifyEmailRequestDto, TokenDto, AuthResendOtpRequestDto, AuthVerifyEmailRequestDto, TokenDto,
}; };
use crate::{ use crate::{
AppState, Env, ResourceEnum, ResponseSuccessDto, RolesEnum, RolesRepository, AppState, ResourceEnum, ResponseSuccessDto, RolesEnum, RolesRepository,
UsersDetailItemDto, UsersRepository, UsersSchema, common_response, UsersDetailItemDto, UsersRepository, UsersSchema, common_response,
decode_refresh_token, encode_access_token, encode_refresh_token, decode_refresh_token, encode_access_token, encode_refresh_token,
encode_reset_password_token, extract_email_token, generate_otp, get_iso_date, encode_reset_password_token, extract_email_token_async, generate_otp, get_iso_date,
hash_password, make_thing, send_email, success_response, validate_request, hash_password, make_thing, send_email, success_response, validate_request,
verify_password, verify_password,
}; };
use axum::{http::StatusCode, response::Response}; use axum::{http::StatusCode, response::Response};
use surrealdb::Uuid; use surrealdb::Uuid;
use tracing::error; use tracing::error;
use tokio;
pub trait AuthServiceTrait: Send + Sync + 'static {
fn mutation_login(
payload: AuthLoginRequestDto,
state: &AppState,
) -> Pin<Box<dyn Future<Output = Response> + Send>>;
fn mutation_mentor_login(
payload: AuthLoginRequestDto,
state: &AppState,
) -> Pin<Box<dyn Future<Output = Response> + Send>>;
fn mutation_register(
payload: AuthRegisterRequestDto,
state: &AppState,
) -> Pin<Box<dyn Future<Output = Response> + Send>>;
fn mutation_resend_otp(
payload: AuthResendOtpRequestDto,
state: &AppState,
) -> Pin<Box<dyn Future<Output = Response> + Send>>;
fn mutation_refresh_token(
payload: AuthRefreshTokenRequestDto,
state: &AppState,
) -> Pin<Box<dyn Future<Output = Response> + Send>>;
fn mutation_forgot_password(
payload: AuthResendOtpRequestDto,
state: &AppState,
) -> Pin<Box<dyn Future<Output = Response> + Send>>;
fn mutation_verify_email(
payload: AuthVerifyEmailRequestDto,
state: &AppState,
) -> Pin<Box<dyn Future<Output = Response> + Send>>;
fn mutation_new_password(
payload: AuthNewPasswordRequestDto,
state: &AppState,
) -> Pin<Box<dyn Future<Output = Response> + Send>>;
}
#[derive(Clone)] // Added Clone derive
pub struct AuthService; pub struct AuthService;
impl AuthService {
pub async fn mutation_login( impl AuthServiceTrait for AuthService {
fn mutation_login(
payload: AuthLoginRequestDto, payload: AuthLoginRequestDto,
state: &AppState, state: &AppState,
) -> Response { ) -> Pin<Box<dyn Future<Output = Response> + Send>> {
let payload = payload;
let state = state.to_owned();
Box::pin(async move {
if let Err((status, message)) = validate_request(&payload) { if let Err((status, message)) = validate_request(&payload) {
return common_response(status, &message); return common_response(status, &message);
} }
let user_repo = UsersRepository::new(state); let user_repo = UsersRepository::new(&state);
let auth_repo = AuthRepository::new(state); let auth_repo = AuthRepository::new(&state);
match user_repo.query_user_by_email(payload.email.clone()).await { let email = &payload.email;
let password = &payload.password;
match user_repo.query_user_by_email(email.to_string()).await {
Ok(user) => { Ok(user) => {
let is_password_correct = let is_password_correct = tokio::task::spawn_blocking({
verify_password(&payload.password, &user.password).unwrap_or(false); let password = password.to_owned();
let user_password = user.password.clone();
move || verify_password(&password, &user_password).unwrap_or(false)
}).await.unwrap_or(false);
if !is_password_correct { if !is_password_correct {
return common_response( return common_response(
@@ -48,12 +98,16 @@ impl AuthService {
); );
} }
let access_token = match encode_access_token(payload.email.clone()) { // Avoid unnecessary clone of user for caching if not needed
let permissions: Vec<String> = user.role.permissions.iter().map(|p| p.name.as_str()).map(str::to_owned).collect();
let user_id = user.id.id.to_raw();
let access_token = match encode_access_token(email.to_string(), user_id.clone(), permissions.clone()) {
Ok(token) => token, Ok(token) => token,
Err(_e) => { Err(_e) => {
error!( error!(
"Failed to generate access token for {}: {}", "Failed to generate access token for {}: {}",
payload.email, _e email, _e
); );
return common_response( return common_response(
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
@@ -62,12 +116,12 @@ impl AuthService {
} }
}; };
let refresh_token = match encode_refresh_token(payload.email.clone()) { let refresh_token = match encode_refresh_token(email.to_string(), user_id, permissions) {
Ok(token) => token, Ok(token) => token,
Err(_e) => { Err(_e) => {
error!( error!(
"Failed to generate refresh token for {}: {}", "Failed to generate refresh token for {}: {}",
payload.email, _e email, _e
); );
return common_response( return common_response(
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
@@ -86,6 +140,7 @@ impl AuthService {
}, },
}; };
// Only clone user if caching is required
if let Err(err_store) = auth_repo.query_store_user(user.clone()).await { if let Err(err_store) = auth_repo.query_store_user(user.clone()).await {
error!( error!(
"Failed to store user cache for {}: {}", "Failed to store user cache for {}: {}",
@@ -102,23 +157,30 @@ impl AuthService {
common_response(StatusCode::UNAUTHORIZED, &err_find.to_string()) common_response(StatusCode::UNAUTHORIZED, &err_find.to_string())
} }
} }
})
} }
pub async fn mutation_mentor_login( fn mutation_mentor_login(
payload: AuthLoginRequestDto, payload: AuthLoginRequestDto,
state: &AppState, state: &AppState,
) -> Response { ) -> Pin<Box<dyn Future<Output = Response> + Send>> {
let payload = payload;
let state = state.to_owned();
Box::pin(async move {
if let Err((status, message)) = validate_request(&payload) { if let Err((status, message)) = validate_request(&payload) {
return common_response(status, &message); return common_response(status, &message);
} }
let user_repo = UsersRepository::new(state); let user_repo = UsersRepository::new(&state);
let auth_repo = AuthRepository::new(state); let auth_repo = AuthRepository::new(&state);
match user_repo.query_user_by_email(payload.email.clone()).await { match user_repo.query_user_by_email(payload.email.clone()).await {
Ok(user) => { Ok(user) => {
let is_password_correct = let is_password_correct = tokio::task::spawn_blocking({
verify_password(&payload.password, &user.password).unwrap_or(false); let password = payload.password.clone();
let user_password = user.password.clone();
move || verify_password(&password, &user_password).unwrap_or(false)
}).await.unwrap_or(false);
if !is_password_correct { if !is_password_correct {
return common_response( return common_response(
@@ -143,7 +205,8 @@ impl AuthService {
); );
} }
let access_token = match encode_access_token(payload.email.clone()) { let permissions: Vec<String> = user.role.permissions.iter().map(|p| p.name.clone()).collect();
let access_token = match encode_access_token(payload.email.clone(), user.id.id.to_raw(), permissions.clone()) {
Ok(token) => token, Ok(token) => token,
Err(_e) => { Err(_e) => {
error!( error!(
@@ -157,7 +220,8 @@ impl AuthService {
} }
}; };
let refresh_token = match encode_refresh_token(payload.email.clone()) { let permissions: Vec<String> = user.role.permissions.iter().map(|p| p.name.clone()).collect();
let refresh_token = match encode_refresh_token(payload.email.clone(), user.id.id.to_raw(), permissions) {
Ok(token) => token, Ok(token) => token,
Err(_e) => { Err(_e) => {
error!( error!(
@@ -197,18 +261,22 @@ impl AuthService {
common_response(StatusCode::UNAUTHORIZED, &err_find.to_string()) common_response(StatusCode::UNAUTHORIZED, &err_find.to_string())
} }
} }
})
} }
pub async fn mutation_register( fn mutation_register(
payload: AuthRegisterRequestDto, payload: AuthRegisterRequestDto,
state: &AppState, state: &AppState,
) -> Response { ) -> Pin<Box<dyn Future<Output = Response> + Send>> {
let payload = payload;
let state = state.to_owned();
Box::pin(async move {
if let Err((status, message)) = validate_request(&payload) { if let Err((status, message)) = validate_request(&payload) {
return common_response(status, &message); return common_response(status, &message);
} }
let user_repo = UsersRepository::new(state); let user_repo = UsersRepository::new(&state);
let auth_repo = AuthRepository::new(state); let auth_repo = AuthRepository::new(&state);
let role_repo = RolesRepository::new(state); let role_repo = RolesRepository::new(&state);
let role = match role_repo let role = match role_repo
.query_role_by_name(RolesEnum::User.to_string()) .query_role_by_name(RolesEnum::User.to_string())
.await .await
@@ -285,7 +353,7 @@ impl AuthService {
created_at: get_iso_date(), created_at: get_iso_date(),
updated_at: get_iso_date(), updated_at: get_iso_date(),
role: role_thing, role: role_thing,
is_active: true, is_active: false,
..Default::default() ..Default::default()
}) })
.await .await
@@ -296,16 +364,20 @@ impl AuthService {
common_response(StatusCode::INTERNAL_SERVER_ERROR, &err_create.to_string()) common_response(StatusCode::INTERNAL_SERVER_ERROR, &err_create.to_string())
} }
} }
})
} }
pub async fn mutation_resend_otp( fn mutation_resend_otp(
payload: AuthResendOtpRequestDto, payload: AuthResendOtpRequestDto,
state: &AppState, state: &AppState,
) -> Response { ) -> Pin<Box<dyn Future<Output = Response> + Send>> {
let payload = payload;
let state = state.to_owned();
Box::pin(async move {
if let Err((status, message)) = validate_request(&payload) { if let Err((status, message)) = validate_request(&payload) {
return common_response(status, &message); return common_response(status, &message);
} }
let user_repo = UsersRepository::new(state); let user_repo = UsersRepository::new(&state);
if user_repo if user_repo
.query_user_by_email(payload.email.clone()) .query_user_by_email(payload.email.clone())
.await .await
@@ -313,7 +385,7 @@ impl AuthService {
{ {
return common_response(StatusCode::BAD_REQUEST, "User not found"); return common_response(StatusCode::BAD_REQUEST, "User not found");
} }
let auth_repo = AuthRepository::new(state); let auth_repo = AuthRepository::new(&state);
let _ = auth_repo.query_get_stored_otp(payload.email.clone()).await; let _ = auth_repo.query_get_stored_otp(payload.email.clone()).await;
let otp = generate_otp::OtpManager::generate_otp(); let otp = generate_otp::OtpManager::generate_otp();
let message = format!("Your OTP code is {otp}"); let message = format!("Your OTP code is {otp}");
@@ -333,34 +405,48 @@ impl AuthService {
common_response(StatusCode::BAD_REQUEST, &err_store.to_string()) common_response(StatusCode::BAD_REQUEST, &err_store.to_string())
} }
} }
})
} }
pub async fn mutation_refresh_token( fn mutation_refresh_token(
payload: AuthRefreshTokenRequestDto, payload: AuthRefreshTokenRequestDto,
) -> Response { state: &AppState,
) -> Pin<Box<dyn Future<Output = Response> + Send>> {
let payload = payload;
let state = state.to_owned();
Box::pin(async move {
if let Err((status, message)) = validate_request(&payload) { if let Err((status, message)) = validate_request(&payload) {
return common_response(status, &message); return common_response(status, &message);
} }
let email = match decode_refresh_token(&payload.refresh_token) {
Ok(token) => token.claims.sub, let user_repo = UsersRepository::new(&state);
let user = match decode_refresh_token(&payload.refresh_token) {
Ok(token_data) => {
match user_repo.query_user_by_email(token_data.claims.sub.clone()).await {
Ok(user) => user,
Err(_) => return common_response(StatusCode::UNAUTHORIZED, "User not found"),
}
},
Err(_e) => { Err(_e) => {
return common_response(StatusCode::UNAUTHORIZED, "Invalid refresh token"); return common_response(StatusCode::UNAUTHORIZED, "Invalid refresh token");
} }
}; };
let access_token = match encode_access_token(email.clone()) {
let permissions: Vec<String> = user.role.permissions.iter().map(|p| p.name.clone()).collect();
let access_token = match encode_access_token(user.email.clone(), user.id.id.to_raw(), permissions.clone()) {
Ok(token) => token, Ok(token) => token,
Err(_e) => { Err(_e) => {
error!("Failed to generate access token for {}: {}", email, _e); error!("Failed to generate access token for {}: {}", user.email, _e);
return common_response( return common_response(
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
"Failed to generate access token", "Failed to generate access token",
); );
} }
}; };
let refresh_token = match encode_refresh_token(email.clone()) { let refresh_token = match encode_refresh_token(user.email.clone(), user.id.id.to_raw(), permissions) {
Ok(token) => token, Ok(token) => token,
Err(_e) => { Err(_e) => {
error!("Failed to generate refresh token for {}: {}", email, _e); error!("Failed to generate refresh token for {}: {}", user.email, _e);
return common_response( return common_response(
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
"Failed to generate refresh token", "Failed to generate refresh token",
@@ -374,148 +460,145 @@ impl AuthService {
}, },
}; };
success_response(response) success_response(response)
})
} }
pub async fn mutation_forgot_password( fn mutation_forgot_password(
payload: AuthResendOtpRequestDto, payload: AuthResendOtpRequestDto,
state: &AppState, state: &AppState,
) -> Response { ) -> Pin<Box<dyn Future<Output = Response> + Send>> {
if let Err((status, message)) = validate_request(&payload) { let payload = payload;
return common_response(status, &message); let state = state.to_owned();
} Box::pin(async move {
let user_repo = UsersRepository::new(state); if let Err((status, message)) = validate_request(&payload) {
let user_result = user_repo.query_user_by_email(payload.email.clone()).await; return common_response(status, &message);
let user = match user_result { }
Ok(user) => user,
Err(err_find) if err_find.to_string().contains("User not found") => { tokio::spawn(async move {
return common_response(StatusCode::BAD_REQUEST, "User not found"); let user_repo = UsersRepository::new(&state);
} if let Ok(user) = user_repo.query_user_by_email(payload.email.clone()).await {
Err(err_other) => { let permissions: Vec<String> = user.role.permissions.iter().map(|p| p.name.clone()).collect();
error!( let token = match encode_reset_password_token(user.email.clone(), user.id.id.to_raw(), permissions) {
"Error finding user for forgot password {}: {}", Ok(token) => token,
payload.email, err_other Err(_e) => {
); error!("Failed to generate reset password token for {}: {}", user.email, _e);
return common_response( return;
StatusCode::INTERNAL_SERVER_ERROR, }
&err_other.to_string(), };
);
} let env = &crate::enviroment::ENV;
}; let fe_url = env.fe_url.clone();
let token = match encode_reset_password_token(user.email.clone()) { let message = format!(
Ok(token) => token, "You have requested a password reset. Please click the link below to continue: {fe_url}/auth/reset-password?token={token}"
Err(_e) => { );
error!(
"Failed to generate reset password token for {}: {}", if let Err(err_send) = send_email(&payload.email, "Reset Password Request", &message) {
user.email, _e error!("Failed to send reset password email to {}: {}", payload.email, err_send);
); }
return common_response( }
StatusCode::INTERNAL_SERVER_ERROR, });
"Failed to generate access token",
); common_response(StatusCode::OK, "If your email is registered, you will receive a password reset link.")
} })
};
let env = Env::new();
let fe_url = env.fe_url;
let message = format!(
"You have requested a password reset. Please click the link below to continue: {fe_url}/auth/reset-password?token={token}"
);
match send_email(&payload.email, "Reset Password Request", &message) {
Ok(_) => common_response(StatusCode::OK, "Reset Password request send"),
Err(err_send) => {
error!(
"Failed to send reset password email to {}: {}",
payload.email, err_send
);
common_response(StatusCode::BAD_REQUEST, &err_send.to_string())
}
}
} }
pub async fn mutation_verify_email( fn mutation_verify_email(
payload: AuthVerifyEmailRequestDto, payload: AuthVerifyEmailRequestDto,
state: &AppState, state: &AppState,
) -> Response { ) -> Pin<Box<dyn Future<Output = Response> + Send>> {
let payload = payload;
let state = state.to_owned();
Box::pin(async move {
if let Err((status, message)) = validate_request(&payload) { if let Err((status, message)) = validate_request(&payload) {
return common_response(status, &message); return common_response(status, &message);
} }
let user_repo = UsersRepository::new(state); let user_repo = UsersRepository::new(&state);
let auth_repo = AuthRepository::new(state); let auth_repo = AuthRepository::new(&state);
let email = payload.email.clone(); let email = payload.email.clone();
let user = match user_repo.query_user_by_email(email.clone()).await { let user = match user_repo.query_user_by_email(email.clone()).await {
Ok(user) if !user.is_deleted => user, Ok(user) => user,
_ => { _ => {
return common_response(StatusCode::NOT_FOUND, "User not found"); return common_response(StatusCode::NOT_FOUND, "User not found");
} }
}; };
if user.is_active {
return common_response(StatusCode::BAD_REQUEST, "User already active");
}
let patch = UsersSchema { let patch = UsersSchema {
id: user.id.clone(), id: user.id.clone(),
is_active: true, is_active: true,
..UsersSchema::from(user.clone()) ..UsersSchema::from(user.clone())
}; };
match auth_repo.query_get_stored_otp(email.clone()).await { match auth_repo.query_get_stored_otp(email.clone()).await {
Ok(stored_otp) => match stored_otp == payload.otp { Ok(stored_otp) => {
true => match user_repo.query_update_user(patch).await { if stored_otp != payload.otp {
Ok(_) => match auth_repo.query_delete_stored_otp(email.clone()).await { // Delete OTP even if it doesn't match
Ok(_) => common_response(StatusCode::OK, "Email verified successfully"), let _ = auth_repo.query_delete_stored_otp(email.clone()).await;
Err(e_del) => { return common_response(StatusCode::BAD_REQUEST, "Failed to verify OTP");
error!("Failed to delete OTP for {}: {}", email, e_del); }
common_response(StatusCode::INTERNAL_SERVER_ERROR, &e_del.to_string())
match user_repo.query_update_user(patch).await {
Ok(_) => {
match auth_repo.query_delete_stored_otp(email.clone()).await {
Ok(_) => common_response(StatusCode::OK, "Email verified successfully"),
Err(e_del) => {
error!("Failed to delete OTP for {}: {}", email, e_del);
common_response(StatusCode::INTERNAL_SERVER_ERROR, &e_del.to_string())
}
} }
}, },
Err(err_update) => { Err(err_update) => common_response(StatusCode::BAD_REQUEST, &err_update.to_string()),
common_response(StatusCode::BAD_REQUEST, &err_update.to_string()) }
}
},
false => match auth_repo.query_delete_stored_otp(email.clone()).await {
Ok(_) => common_response(StatusCode::BAD_REQUEST, "Failed to verify OTP"),
Err(e_del_mismatch) => common_response(
StatusCode::INTERNAL_SERVER_ERROR,
&format!("Failed to delete OTP: {e_del_mismatch}"),
),
},
}, },
Err(err_get) => common_response(StatusCode::BAD_REQUEST, &err_get.to_string()), Err(err_get) => common_response(StatusCode::BAD_REQUEST, &err_get.to_string()),
} }
})
} }
pub async fn mutation_new_password( fn mutation_new_password(
payload: AuthNewPasswordRequestDto, payload: AuthNewPasswordRequestDto,
state: &AppState, state: &AppState,
) -> Response { ) -> Pin<Box<dyn Future<Output = Response> + Send>> {
let payload = payload;
let state = state.to_owned();
Box::pin(async move {
if let Err((status, message)) = validate_request(&payload) { if let Err((status, message)) = validate_request(&payload) {
return common_response(status, &message); return common_response(status, &message);
} }
let repo = UsersRepository::new(state); let repo = UsersRepository::new(&state);
let email = match extract_email_token(payload.token.clone()) { let user_repo = UsersRepository::new(&state);
Some(token) => token, let email = match extract_email_token_async(payload.token.clone()).await {
Some(email) => email,
None => { None => {
return common_response(StatusCode::BAD_REQUEST, "Invalid or missing token"); return common_response(StatusCode::BAD_REQUEST, "Invalid or missing token");
} }
}; };
let user = match user_repo.query_user_by_email(email).await {
Ok(user) => user,
Err(_) => return common_response(StatusCode::BAD_REQUEST, "User not found"),
};
let password = match hash_password(&payload.password) { let password = match hash_password(&payload.password) {
Ok(p) => p, Ok(p) => p,
Err(_e) => { Err(_e) => {
error!("Failed to hash new password for {}: {}", email, _e); error!("Failed to hash new password for {}: {}", user.email, _e);
return common_response( return common_response(
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
"Failed to hash password", "Failed to hash password",
); );
} }
}; };
let user = match repo.query_user_by_email(email.clone()).await {
Ok(user) if !user.is_deleted => user,
_ => {
return common_response(StatusCode::NOT_FOUND, "User not found");
}
};
let patch = UsersSchema { let patch = UsersSchema {
id: user.id.clone(), id: user.id.clone(),
password, password,
..Default::default() ..UsersSchema::from(user.clone())
}; };
match repo.query_update_user(patch).await { match repo.query_update_user(patch).await {
Ok(msg) => common_response(StatusCode::OK, &msg), Ok(msg) => common_response(StatusCode::OK, &msg),
Err(_e) => common_response(StatusCode::BAD_REQUEST, &_e.to_string()), Err(_e) => common_response(StatusCode::BAD_REQUEST, &_e.to_string()),
} }
})
} }
} }
@@ -0,0 +1,96 @@
use axum::{
extract::{Query, State},
response::Redirect,
routing::get,
Json, Router, Extension,
};
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use std::sync::Arc;
use imphnen_libs::enviroment::ENV; // Import ENV
use crate::v1::auth::google::google_oauth_service::{AuthRequest, GoogleOauthService, GoogleOauthServiceImpl};
use imphnen_entities::error_dto::error::Error;
use crate::v1::auth::AuthLoginResponsetDto;
use crate::AppState;
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct GoogleAuthUrlResponse {
pub authorize_url: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct GoogleLoginRequest {
pub redirect_uri: Option<String>,
}
pub struct GoogleOauthController<T> {
google_oauth_service: T,
}
impl GoogleOauthController<GoogleOauthServiceImpl<crate::v1::auth::auth_service::AuthService, crate::v1::users::users_service::UsersService>> {
pub fn new() -> Self {
let google_oauth_service = GoogleOauthServiceImpl::<crate::v1::auth::auth_service::AuthService, crate::v1::users::users_service::UsersService>::with_services(
crate::v1::auth::auth_service::AuthService {},
crate::v1::users::users_service::UsersService {},
&ENV, // Pass a reference to the global ENV static
);
Self::with_service(google_oauth_service)
}
}
impl<T> GoogleOauthController<T>
where
T: GoogleOauthService<crate::v1::auth::auth_service::AuthService, crate::v1::users::users_service::UsersService> + Clone + Send + Sync + 'static,
{
pub fn with_service(google_oauth_service: T) -> Self {
Self {
google_oauth_service,
}
}
pub fn get_routes(&self) -> Router {
Router::new()
.route(
"/login",
get(
move |State(controller): State<Arc<Self>>, Query(params): Query<GoogleLoginRequest>| async move {
controller.google_oauth_login(params).await
},
),
)
.route(
"/callback",
get(
move |State(controller): State<Arc<Self>>, Extension(app_state): Extension<AppState>, Query(auth_request): Query<AuthRequest>| async move {
let controller = Arc::clone(&controller);
controller.google_oauth_callback(auth_request, &app_state).await
},
),
)
.with_state(Arc::new(self.clone()))
}
pub async fn google_oauth_login(&self, params: GoogleLoginRequest) -> Result<Redirect, Error> {
let (authorize_url, _csrf_state) = self.google_oauth_service.generate_auth_url(params.redirect_uri);
Ok(Redirect::to(authorize_url.as_str()))
}
pub async fn google_oauth_callback(&self, auth_request: AuthRequest, app_state: &AppState) -> Result<Json<AuthLoginResponsetDto>, Error> {
let (user, token) = self.google_oauth_service.google_oauth_callback(auth_request, app_state).await?;
let auth_response = AuthLoginResponsetDto {
user,
token,
};
Ok(Json(auth_response))
}
}
impl<T> Clone for GoogleOauthController<T>
where
T: GoogleOauthService<crate::v1::auth::auth_service::AuthService, crate::v1::users::users_service::UsersService> + Clone,
{
fn clone(&self) -> Self {
Self::with_service(self.google_oauth_service.clone())
}
}
@@ -0,0 +1,24 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
pub struct GoogleUser {
pub id: String,
pub email: String,
#[serde(default)]
pub verified_email: bool,
pub name: Option<String>,
pub given_name: Option<String>,
pub family_name: Option<String>,
pub picture: Option<String>,
pub locale: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct GoogleTokenResponse {
pub access_token: String,
pub expires_in: u64,
pub refresh_token: Option<String>,
pub scope: String,
pub token_type: String,
pub id_token: String,
}
@@ -0,0 +1,379 @@
use std::pin::Pin;
use std::future::Future;
use anyhow::Result;
use oauth2::{
AuthUrl, ClientId, ClientSecret, CsrfToken, PkceCodeChallenge, PkceCodeVerifier,
RedirectUrl, Scope, TokenUrl,
};
use serde::{Deserialize, Serialize};
use oauth2::url::Url;
use oauth2::TokenResponse;
use tracing::{info, error};
use imphnen_entities::error_dto::error::Error;
use imphnen_libs::{jsonwebtoken::{encode_access_token, encode_refresh_token}, enviroment::Env, AppState};
use imphnen_utils::{generate_oauth_csrf_token, validate_oauth_csrf_token, validate_csrf_token};
use crate::v1::auth::TokenDto;
use crate::v1::auth::auth_service::AuthServiceTrait;
use crate::v1::users::users_dto::{UsersDetailItemDto, UsersCreateRequestDto};
use crate::v1::users::users_service::UsersServiceTrait;
use super::google_oauth_dto::GoogleUser;
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct AuthRequest {
pub code: String,
pub state: String,
pub redirect_uri: Option<String>,
}
impl AuthRequest {
/// Validate the OAuth callback request
pub fn validate(&self) -> Result<(), Error> {
// Validate code parameter
if self.code.is_empty() || self.code.len() > 2048 {
return Err(Error::Validation("Invalid authorization code".to_string()));
}
// Validate state parameter
if self.state.is_empty() || self.state.len() > 512 {
return Err(Error::Validation("Invalid state parameter".to_string()));
}
// Basic format validation for authorization code
// OAuth 2.0 authorization codes can contain URL-safe characters including base64 characters
if !self.code.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_' || c == '.' || c == '~' || c == '/' || c == '+' || c == '=') {
return Err(Error::Validation("Authorization code contains invalid characters".to_string()));
}
Ok(())
}
/// Validate CSRF state token with signature verification and extract PKCE verifier
pub fn validate_csrf_state_and_get_pkce_verifier(&self, secret: &str) -> Result<PkceCodeVerifier, Error> {
// Maximum age of 30 minutes for OAuth flow (increased from 10)
const MAX_AGE_SECONDS: u64 = 300; // Changed from 30 minutes (1800s) to 5 minutes (300s)
let pkce_verifier_secret = validate_oauth_csrf_token(&self.state, secret, MAX_AGE_SECONDS)
.map_err(|e| {
error!("OAuth CSRF validation failed: {:?}", e);
Error::Auth("Invalid or expired OAuth CSRF state token".to_string())
})?;
Ok(PkceCodeVerifier::new(pkce_verifier_secret))
}
/// Validate CSRF state token with signature verification (legacy method for backward compatibility)
pub fn validate_csrf_state(&self, secret: &str) -> Result<(), Error> {
// Try OAuth CSRF validation first, if it fails, fall back to regular CSRF validation
match validate_oauth_csrf_token(&self.state, secret, 1800) {
Ok(_) => Ok(()),
Err(_) => {
// Fallback to regular CSRF validation for backward compatibility
validate_csrf_token(&self.state, secret, 600)
.map_err(|e| {
error!("CSRF validation failed: {:?}", e);
Error::Auth("Invalid or expired CSRF state token".to_string())
})
}
}
}
}
use crate::{RolesRepository, RolesEnum};
/// Helper function to get default role ID for new OAuth users
async fn get_default_role_id(app_state: &AppState) -> Result<String, Error> {
let role_repo = RolesRepository::new(app_state);
match role_repo.query_role_by_name(RolesEnum::User.to_string()).await {
Ok(role) => {
info!("Using default User role ID: {}", role.id);
Ok(role.id)
},
Err(e) => {
error!("Failed to retrieve User role: {:?}", e);
Err(Error::Anyhow(anyhow::Error::msg("Failed to get default role ID".to_string())))
}
}
}
pub trait GoogleOauthService<A: AuthServiceTrait + Send + Sync + 'static, U: UsersServiceTrait + Send + Sync + 'static>: Send + Sync + 'static {
// Removed new() from trait
fn with_services(auth_service: A, users_service: U, env: &'static Env) -> Self;
fn generate_auth_url(&self, custom_redirect_uri: Option<String>) -> (Url, CsrfToken);
fn google_oauth_callback(&self, auth_request: AuthRequest, app_state: &AppState) -> Pin<Box<dyn Future<Output = Result<(UsersDetailItemDto, TokenDto), Error>> + Send + '_>>; // Changed return type
}
#[derive(Clone)]
pub struct GoogleOauthServiceImpl<A: AuthServiceTrait, U: UsersServiceTrait> {
users_service: U,
env: &'static Env,
_auth_service: A,
}
impl GoogleOauthServiceImpl<crate::v1::auth::auth_service::AuthService, crate::v1::users::users_service::UsersService> {
/// Generate Google OAuth authorization URL
pub fn get_auth_url(&self, custom_redirect_uri: Option<String>) -> String {
let (auth_url, _csrf_token) = self.generate_auth_url(custom_redirect_uri);
auth_url.to_string()
}
}
impl<A, U> GoogleOauthService<A, U> for GoogleOauthServiceImpl<A, U>
where
A: AuthServiceTrait + Send + Sync + 'static,
U: UsersServiceTrait + Send + Sync + 'static,
{
fn with_services(auth_service: A, users_service: U, env: &'static Env) -> Self {
Self {
_auth_service: auth_service,
users_service,
env,
}
}
fn generate_auth_url(&self, custom_redirect_uri: Option<String>) -> (Url, CsrfToken) {
let google_client_id = ClientId::new(self.env.google_client_id.clone());
let google_client_secret = ClientSecret::new(self.env.google_client_secret.clone());
let auth_url = AuthUrl::new("https://accounts.google.com/o/oauth2/v2/auth".to_string())
.expect("Invalid authorization endpoint URL");
let token_url = TokenUrl::new("https://oauth2.googleapis.com/token".to_string())
.expect("Invalid token endpoint URL");
let redirect_uri = custom_redirect_uri.unwrap_or_else(|| self.env.google_redirect_url.clone());
let client = oauth2::basic::BasicClient::new(google_client_id)
.set_client_secret(google_client_secret)
.set_auth_uri(auth_url)
.set_token_uri(token_url)
.set_redirect_uri(
RedirectUrl::new(redirect_uri.clone())
.expect("Invalid redirect URL"),
);
info!("OAuth client configured with redirect URI: {}", redirect_uri);
let (pkce_code_challenge, pkce_code_verifier) = PkceCodeChallenge::new_random_sha256();
info!("Generated PKCE Code Challenge: {}", pkce_code_challenge.as_str());
info!("Generated PKCE Code Verifier: {}", pkce_code_verifier.secret());
// Generate a signed CSRF token with PKCE verifier for stateless validation
let csrf_token_str = generate_oauth_csrf_token(&self.env.access_token_secret, pkce_code_verifier.secret())
.unwrap_or_else(|_| uuid::Uuid::new_v4().to_string()); // Fallback to UUID if signing fails
let (auth_url, csrf_token) = client
.authorize_url(|| CsrfToken::new(csrf_token_str.clone()))
.add_scope(Scope::new("https://www.googleapis.com/auth/userinfo.email".to_string()))
.add_scope(Scope::new("https://www.googleapis.com/auth/userinfo.profile".to_string()))
.set_pkce_challenge(pkce_code_challenge)
.url();
(auth_url, csrf_token)
}
fn google_oauth_callback(&self, auth_request: AuthRequest, app_state: &AppState) -> Pin<Box<dyn Future<Output = Result<(UsersDetailItemDto, TokenDto), Error>> + Send + '_>> {
let self_clone = self; // Use reference instead of clone
let app_state = app_state.to_owned();
Box::pin(async move {
// Validate input parameters first
info!("Received OAuth callback request with state: {}", auth_request.state);
auth_request.validate()?;
// CRITICAL: Validate CSRF state token and extract PKCE verifier
let pkce_verifier = auth_request.validate_csrf_state_and_get_pkce_verifier(&self_clone.env.access_token_secret)?;
info!("Starting Google OAuth callback process");
info!("Redirect URI used: {:?}", auth_request.redirect_uri);
info!("PKCE verifier extracted: {}", pkce_verifier.secret());
info!("PKCE verifier extracted: {}", pkce_verifier.secret());
// Use the SAME redirect URI that was used for auth URL generation
// This is crucial for OAuth security and consistency
let google_client_id = ClientId::new(self_clone.env.google_client_id.clone());
let google_client_secret = ClientSecret::new(self_clone.env.google_client_secret.clone());
let auth_url = AuthUrl::new("https://accounts.google.com/o/oauth2/v2/auth".to_string())
.expect("Invalid authorization endpoint URL");
let token_url = TokenUrl::new("https://oauth2.googleapis.com/token".to_string())
.expect("Invalid token endpoint URL");
let redirect_uri = auth_request.redirect_uri.clone().unwrap_or_else(|| self_clone.env.google_redirect_url.clone());
let client = oauth2::basic::BasicClient::new(google_client_id)
.set_client_secret(google_client_secret)
.set_auth_uri(auth_url)
.set_token_uri(token_url)
.set_redirect_uri(
RedirectUrl::new(redirect_uri.clone())
.expect("Invalid redirect URL"),
);
info!("OAuth client configured with redirect URI: {}", redirect_uri);
// Debug the OAuth client configuration
let effective_redirect_uri = auth_request.redirect_uri.as_ref().unwrap_or(&self_clone.env.google_redirect_url);
info!("Effective redirect URI for OAuth client: {}", effective_redirect_uri);
info!("Google Client ID: {}", self_clone.env.google_client_id);
info!("Attempting to exchange authorization code with Google");
info!("Using PKCE verifier for secure exchange");
info!("Authorization code length: {}", auth_request.code.len());
let token_response = client
.exchange_code(oauth2::AuthorizationCode::new(auth_request.code.clone()))
.set_pkce_verifier(pkce_verifier)
.request_async(&reqwest::Client::new())
.await
.map_err(|e| {
error!("Failed to exchange OAuth code with Google: {}", e);
error!("OAuth code was: {}", auth_request.code);
error!("Redirect URI was: {:?}", auth_request.redirect_uri);
// Debug OAuth client configuration
error!("Google Client ID: {}", self_clone.env.google_client_id);
error!("OAuth client redirect URI configured: {}",
auth_request.redirect_uri.as_ref().unwrap_or(&self_clone.env.google_redirect_url));
// Try to extract more details from the error
match &e {
oauth2::RequestTokenError::ServerResponse(response) => {
error!("Google OAuth Server Response Error: {:?}", response);
},
oauth2::RequestTokenError::Request(req_err) => {
error!("Google OAuth Request Error: {:?}", req_err);
},
oauth2::RequestTokenError::Parse(parse_err, response_body) => {
error!("Google OAuth Parse Error: {:?}", parse_err);
error!("Response body: {:?}", response_body);
},
oauth2::RequestTokenError::Other(other) => {
error!("Google OAuth Other Error: {:?}", other);
},
}
Error::Auth("Authentication error: Failed to exchange authorization code".to_string())
})?;
info!("Successfully exchanged authorization code for access token");
// Extract access token from Google's response
let access_token = token_response.access_token().secret();
info!("Obtained access token from Google, fetching user info...");
let client = reqwest::Client::new();
let user_info_url = "https://www.googleapis.com/oauth2/v2/userinfo";
let google_user: GoogleUser = client
.get(user_info_url)
.bearer_auth(access_token)
.send()
.await
.map_err(|e| {
error!("Failed to fetch user info from Google: {}", e);
Error::Auth("Failed to fetch user information".to_string())
})?
.json()
.await
.map_err(|e| {
error!("Failed to parse user info from Google: {}", e);
Error::Auth("Failed to parse user information".to_string())
})?;
info!("Successfully retrieved user info for email: {}", google_user.email);
info!("Google user picture URL: {:?}", google_user.picture);
info!("Google user data: name={:?}, given_name={:?}, family_name={:?}, picture={:?}",
google_user.name, google_user.given_name, google_user.family_name, google_user.picture);
let user = self_clone.users_service.get_user_by_email(&google_user.email, &app_state).await?;
let user = match user {
Some(mut user) => {
info!("Existing user found for email: {}", google_user.email);
// Update avatar if user doesn't have one and Google provides one
if user.avatar.is_none() && google_user.picture.is_some() {
info!("Updating avatar for existing user: {}", google_user.email);
match U::update_user_avatar(&google_user.email, google_user.picture.clone(), &app_state).await {
Ok(_) => {
info!("Successfully updated avatar for user: {}", google_user.email);
user.avatar = google_user.picture.clone();
},
Err(e) => {
error!("Failed to update avatar for user {}: {:?}", google_user.email, e);
}
}
}
user
},
None => {
info!("Creating new user for email: {}", google_user.email);
// Get default role ID using robust lookup
let default_role_id = get_default_role_id(&app_state).await
.map_err(|e| {
error!("Failed to get default role ID: {:?}", e);
Error::Anyhow(anyhow::Error::msg("Failed to get default role ID for new user".to_string()))
})?;
let new_user = UsersCreateRequestDto {
email: google_user.email.clone(),
password: format!("GOOGLE_OAUTH_{}", uuid::Uuid::new_v4()), // Random placeholder
fullname: google_user.name.clone().unwrap_or_else(|| {
// Fallback: use given_name + family_name if available, otherwise use email prefix
match (&google_user.given_name, &google_user.family_name) {
(Some(given), Some(family)) => format!("{} {}", given, family),
(Some(given), None) => given.clone(),
(None, Some(family)) => family.clone(),
(None, None) => {
// Extract email prefix as last resort
google_user.email.split('@').next().unwrap_or("User").to_string()
}
}
}),
phone_number: "".to_string(), // Will be updated by user later
is_active: true,
role_id: default_role_id,
avatar: google_user.picture.clone(), // Set avatar from Google user picture
};
self_clone.users_service.create_user_by_dto(new_user, &app_state).await?
}
};
let permissions: Vec<String> = user.role.permissions.iter().map(|p| p.name.clone()).collect();
let access_token = encode_access_token(user.email.clone(), user.id.clone(), permissions.clone())
.map_err(|e| {
error!("Failed to generate access token for {}: {:?}", user.email, e);
Error::Auth("Failed to generate access token".to_string())
})?;
let permissions: Vec<String> = user.role.permissions.iter().map(|p| p.name.clone()).collect();
let refresh_token = encode_refresh_token(user.email.clone(), user.id.clone(), permissions)
.map_err(|e| {
error!("Failed to generate refresh token for {}: {:?}", user.email, e);
Error::Auth("Failed to generate refresh token".to_string())
})?;
let token_dto = TokenDto {
access_token,
refresh_token,
};
// Cache the user in auth repository for subsequent requests
let auth_repo = crate::v1::auth::AuthRepository::new(&app_state);
let user_query_dto: crate::v1::users::users_dto::UsersDetailQueryDto = (&user).into();
if let Err(err_store) = auth_repo.query_store_user(user_query_dto).await {
error!(
"Failed to store user cache for {}: {}",
user.email, err_store
);
// Don't fail the login, just log the error
error!("Google OAuth login succeeded but caching failed for user: {}", user.email);
} else {
info!("Successfully cached user {} after Google OAuth login", user.email);
}
info!("Successfully completed Google OAuth for user: {}", user.email);
Ok((user, token_dto))
})
}
}
+3
View File
@@ -0,0 +1,3 @@
pub mod google_oauth_controller;
pub mod google_oauth_dto;
pub mod google_oauth_service;
+2
View File
@@ -5,6 +5,7 @@ pub mod auth_dto;
pub mod auth_repository; pub mod auth_repository;
pub mod auth_schema; pub mod auth_schema;
pub mod auth_service; pub mod auth_service;
pub mod google;
pub use auth_dto::*; pub use auth_dto::*;
pub use auth_repository::*; pub use auth_repository::*;
@@ -13,6 +14,7 @@ pub use auth_service::*;
pub fn auth_router() -> Router { pub fn auth_router() -> Router {
Router::new() Router::new()
.nest("/google", google::google_oauth_controller::GoogleOauthController::new().get_routes())
.route("/forgot", post(auth_controller::post_forgot_password)) .route("/forgot", post(auth_controller::post_forgot_password))
.route("/login", post(auth_controller::post_login)) .route("/login", post(auth_controller::post_login))
.route("/login-mentor", post(auth_controller::post_login_mentor)) .route("/login-mentor", post(auth_controller::post_login_mentor))
@@ -8,7 +8,7 @@ use crate::{
AppState, MessageResponseDto, MetaRequestDto, ResponseListSuccessDto, AppState, MessageResponseDto, MetaRequestDto, ResponseListSuccessDto,
ResponseSuccessDto, ResponseSuccessDto,
v1::{ v1::{
permissions_dto::{PermissionsItemDto, PermissionsRequestDto}, permissions_dto::{PermissionsItemDto, PermissionsRequestDto, PermissionsUpdateRequestDto},
permissions_service::PermissionsService, permissions_service::PermissionsService,
}, },
}; };
@@ -41,13 +41,13 @@ pub async fn get_permission_list(
Query(meta): Query<MetaRequestDto>, Query(meta): Query<MetaRequestDto>,
) -> impl IntoResponse { ) -> impl IntoResponse {
match permissions_guard( match permissions_guard(
&headers, headers,
state.clone(), Extension(state),
vec![PermissionsEnum::ReadListPermissions], vec![PermissionsEnum::ReadListPermissions],
) )
.await .await
{ {
Ok(_) => PermissionsService::get_permission_list(&state, meta).await, Ok((_claims, state)) => PermissionsService::get_permission_list(&state, meta).await,
Err(response) => response, Err(response) => response,
} }
} }
@@ -70,13 +70,13 @@ pub async fn get_permission_by_id(
Path(id): Path<String>, Path(id): Path<String>,
) -> impl IntoResponse { ) -> impl IntoResponse {
match permissions_guard( match permissions_guard(
&headers, headers,
state.clone(), Extension(state),
vec![PermissionsEnum::ReadDetailPermissions], vec![PermissionsEnum::ReadDetailPermissions],
) )
.await .await
{ {
Ok(_) => PermissionsService::get_permission_by_id(&state, id).await, Ok((_claims, state)) => PermissionsService::get_permission_by_id(&state, id).await,
Err(response) => response, Err(response) => response,
} }
} }
@@ -99,13 +99,13 @@ pub async fn post_create_permission(
Json(payload): Json<PermissionsRequestDto>, Json(payload): Json<PermissionsRequestDto>,
) -> impl IntoResponse { ) -> impl IntoResponse {
match permissions_guard( match permissions_guard(
&headers, headers,
state.clone(), Extension(state),
vec![PermissionsEnum::CreatePermissions], vec![PermissionsEnum::CreatePermissions],
) )
.await .await
{ {
Ok(_) => PermissionsService::create_role(&state, payload).await, Ok((_claims, state)) => PermissionsService::create_role(&state, payload).await,
Err(response) => response, Err(response) => response,
} }
} }
@@ -116,7 +116,7 @@ pub async fn post_create_permission(
("Bearer" = []) ("Bearer" = [])
), ),
path = "/v1/permissions/update/{id}", path = "/v1/permissions/update/{id}",
request_body = PermissionsRequestDto, request_body = PermissionsUpdateRequestDto,
responses( responses(
(status = 200, description = "Update permission", body = MessageResponseDto) (status = 200, description = "Update permission", body = MessageResponseDto)
), ),
@@ -126,16 +126,16 @@ pub async fn put_update_permission(
headers: axum::http::HeaderMap, headers: axum::http::HeaderMap,
Extension(state): Extension<AppState>, Extension(state): Extension<AppState>,
Path(id): Path<String>, Path(id): Path<String>,
Json(payload): Json<PermissionsRequestDto>, Json(payload): Json<PermissionsUpdateRequestDto>,
) -> impl IntoResponse { ) -> impl IntoResponse {
match permissions_guard( match permissions_guard(
&headers, headers,
state.clone(), Extension(state),
vec![PermissionsEnum::UpdatePermissions], vec![PermissionsEnum::UpdatePermissions],
) )
.await .await
{ {
Ok(_) => PermissionsService::update_permission(&state, payload, id).await, Ok((_claims, state)) => PermissionsService::update_permission(&state, payload, id).await,
Err(response) => response, Err(response) => response,
} }
} }
@@ -157,13 +157,13 @@ pub async fn delete_permission(
Path(id): Path<String>, Path(id): Path<String>,
) -> impl IntoResponse { ) -> impl IntoResponse {
match permissions_guard( match permissions_guard(
&headers, headers,
state.clone(), Extension(state),
vec![PermissionsEnum::DeletePermissions], vec![PermissionsEnum::DeletePermissions],
) )
.await .await
{ {
Ok(_) => PermissionsService::delete_permission(&state, id).await, Ok((_claims, state)) => PermissionsService::delete_permission(&state, id).await,
Err(response) => response, Err(response) => response,
} }
} }
@@ -9,6 +9,13 @@ pub struct PermissionsRequestDto {
pub name: String, pub name: String,
} }
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)]
pub struct PermissionsUpdateRequestDto {
#[validate(length(min = 1, message = "Permission name must not be empty"))]
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] #[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct PermissionsItemDto { pub struct PermissionsItemDto {
pub id: String, pub id: String,
@@ -1,38 +1,41 @@
use super::PermissionsEnum; use super::PermissionsEnum;
use crate::{AppState, AuthRepository, common_response, extract_email}; use crate::{AppState, common_response, decode_access_token};
use axum::{ use axum::{
http::{HeaderMap, StatusCode}, http::{HeaderMap, StatusCode},
response::Response, response::Response, Extension,
}; };
use axum_extra::headers::{authorization::Bearer, Authorization, HeaderMapExt};
// Removed imphnen_utils::make_thing as it's no longer needed here
pub async fn permissions_guard( pub async fn permissions_guard(
headers: &HeaderMap, headers: HeaderMap,
state: AppState, Extension(state): Extension<AppState>,
required_permissions: Vec<PermissionsEnum>, required_permissions: Vec<PermissionsEnum>,
) -> Result<(), Response> { ) -> Result<(imphnen_libs::jsonwebtoken::Claims, AppState), Response> {
let auth_repo = AuthRepository::new(&state); let auth_header = headers
let email = extract_email(headers).ok_or_else(|| { .typed_get::<Authorization<Bearer>>()
common_response( .ok_or_else(|| {
StatusCode::UNAUTHORIZED, common_response(
"Invalid or missing authorization token", StatusCode::UNAUTHORIZED,
) "Invalid or missing authorization token",
})?; )
let raw_user = auth_repo })?;
.query_get_stored_user(email.clone())
.await let token = auth_header.token();
let claims = decode_access_token(token)
.map_err(|_| { .map_err(|_| {
common_response( common_response(
StatusCode::UNAUTHORIZED, StatusCode::UNAUTHORIZED,
"User session expired or not found", "Invalid or expired token",
) )
})?; })?
let role = raw_user.role; .claims;
let role_permissions: Vec<String> =
role.permissions.into_iter().map(|perm| perm.name).collect();
// Use permissions from JWT for the check
for required in &required_permissions { for required in &required_permissions {
let required_str = required.to_string(); let required_str = required.to_string();
if !role_permissions.contains(&required_str) { if !claims.permissions.contains(&required_str) {
eprintln!(" MISSING REQUIRED PERMISSION: {required_str}"); eprintln!(" MISSING REQUIRED PERMISSION: {required_str}");
return Err(common_response( return Err(common_response(
StatusCode::FORBIDDEN, StatusCode::FORBIDDEN,
@@ -40,5 +43,6 @@ pub async fn permissions_guard(
)); ));
} }
} }
Ok(())
Ok((claims, state))
} }
@@ -7,6 +7,7 @@ use imphnen_utils::{DetailQueryBuilder, QueryListBuilder, extract_id};
use serde_json; use serde_json;
use std::time::Instant; use std::time::Instant;
use tracing::instrument; use tracing::instrument;
use tracing::info;
pub struct PermissionsRepository<'a> { pub struct PermissionsRepository<'a> {
state: &'a AppState, state: &'a AppState,
@@ -23,6 +24,7 @@ impl<'a> PermissionsRepository<'a> {
meta: MetaRequestDto, meta: MetaRequestDto,
) -> Result<ResponseListSuccessDto<Vec<PermissionsItemDto>>> { ) -> Result<ResponseListSuccessDto<Vec<PermissionsItemDto>>> {
let now = Instant::now(); let now = Instant::now();
info!("Executing SurrealDB query: QueryListBuilder for Permissions with meta: {:?}", meta);
let raw_result: ResponseListSuccessDto<Vec<PermissionsSchema>> = let raw_result: ResponseListSuccessDto<Vec<PermissionsSchema>> =
QueryListBuilder::new( QueryListBuilder::new(
&self.state.surrealdb_ws, &self.state.surrealdb_ws,
@@ -61,6 +63,7 @@ impl<'a> PermissionsRepository<'a> {
) -> Result<PermissionsSchema> { ) -> Result<PermissionsSchema> {
let now = Instant::now(); let now = Instant::now();
let db = &self.state.surrealdb_ws; let db = &self.state.surrealdb_ws;
info!(id = %id, "Executing SurrealDB select for Permissions");
let result: Option<PermissionsSchema> = db let result: Option<PermissionsSchema> = db
.select((ResourceEnum::Permissions.to_string(), id.clone())) .select((ResourceEnum::Permissions.to_string(), id.clone()))
.await?; .await?;
@@ -82,6 +85,7 @@ impl<'a> PermissionsRepository<'a> {
id: String, id: String,
) -> Result<PermissionsItemDto> { ) -> Result<PermissionsItemDto> {
let now = Instant::now(); let now = Instant::now();
info!(id = %id, "Executing transformed_query_permission_by_id (delegates to query_permission_by_id)");
let raw_result = self.query_permission_by_id(id.clone()).await?; let raw_result = self.query_permission_by_id(id.clone()).await?;
let elapsed = now.elapsed(); let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string()) if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
@@ -109,6 +113,7 @@ impl<'a> PermissionsRepository<'a> {
.with_where("name", Some(name.clone())) .with_where("name", Some(name.clone()))
.with_select_fields(vec!["*"]); .with_select_fields(vec!["*"]);
let sql = builder.build(); let sql = builder.build();
info!(query = %sql, "Executing SurrealDB query");
let result: Option<PermissionsSchema> = let result: Option<PermissionsSchema> =
builder.apply_bindings(db.query(sql)).await?.take(0)?; builder.apply_bindings(db.query(sql)).await?.take(0)?;
let elapsed = now.elapsed(); let elapsed = now.elapsed();
@@ -130,6 +135,7 @@ impl<'a> PermissionsRepository<'a> {
) -> Result<String> { ) -> Result<String> {
let now = Instant::now(); let now = Instant::now();
let db = &self.state.surrealdb_ws; let db = &self.state.surrealdb_ws;
info!("Executing SurrealDB create for Permissions with data: {:?}", data);
let record: Option<PermissionsSchema> = db let record: Option<PermissionsSchema> = db
.create(ResourceEnum::Permissions.to_string()) .create(ResourceEnum::Permissions.to_string())
.content(data) .content(data)
@@ -162,6 +168,7 @@ impl<'a> PermissionsRepository<'a> {
created_at: existing.created_at, created_at: existing.created_at,
..data.clone() ..data.clone()
}; };
info!(record_key = ?record_key, "Executing SurrealDB update for Permissions");
let record: Option<PermissionsSchema> = let record: Option<PermissionsSchema> =
db.update(record_key).merge(merged).await?; db.update(record_key).merge(merged).await?;
let elapsed = now.elapsed(); let elapsed = now.elapsed();
@@ -188,6 +195,7 @@ impl<'a> PermissionsRepository<'a> {
bail!("Permission already deleted"); bail!("Permission already deleted");
} }
let record_key = get_id(&permission.id)?; let record_key = get_id(&permission.id)?;
info!(record_key = ?record_key, "Executing SurrealDB soft delete for Permissions");
let record: Option<PermissionsSchema> = db let record: Option<PermissionsSchema> = db
.update(record_key) .update(record_key)
.merge(serde_json::json!({ "is_deleted": true })) .merge(serde_json::json!({ "is_deleted": true }))
@@ -2,7 +2,7 @@ use crate::{ResourceEnum, make_thing};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use surrealdb::{Uuid, sql::Thing}; use surrealdb::{Uuid, sql::Thing};
use super::PermissionsItemDto; use super::{PermissionsItemDto, PermissionsQueryDto};
#[derive(Clone, Debug, Serialize, Deserialize)] #[derive(Clone, Debug, Serialize, Deserialize)]
pub struct PermissionsSchema { pub struct PermissionsSchema {
@@ -37,4 +37,14 @@ impl PermissionsSchema {
updated_at: self.updated_at.clone(), updated_at: self.updated_at.clone(),
} }
} }
pub fn from(dto: PermissionsQueryDto) -> Self {
Self {
id: dto.id,
name: dto.name,
is_deleted: false,
created_at: dto.created_at,
updated_at: dto.updated_at,
}
}
} }
@@ -5,8 +5,9 @@ use crate::{
}; };
use axum::http::StatusCode; use axum::http::StatusCode;
use axum::response::Response; use axum::response::Response;
use imphnen_utils::get_iso_date;
use super::PermissionsRequestDto; use super::{PermissionsRequestDto, PermissionsUpdateRequestDto};
pub struct PermissionsService; pub struct PermissionsService;
@@ -70,21 +71,31 @@ impl PermissionsService {
pub async fn update_permission( pub async fn update_permission(
state: &AppState, state: &AppState,
payload: PermissionsRequestDto, payload: PermissionsUpdateRequestDto,
id: String, id: String,
) -> Response { ) -> Response {
if let Err((status, message)) = validate_request(&payload) { if let Err((status, message)) = validate_request(&payload) {
return common_response(status, &message); return common_response(status, &message);
} }
let repo = PermissionsRepository::new(state); let repo = PermissionsRepository::new(state);
match repo
.query_update_permission(PermissionsSchema { // Get current permission data first
id: make_thing(&ResourceEnum::Permissions.to_string(), &id), let thing_id = make_thing(&ResourceEnum::Permissions.to_string(), &id);
name: payload.name, let current_permission = match repo.query_permission_by_id(id.clone()).await {
..Default::default() Ok(permission) => permission,
}) Err(_) => return common_response(StatusCode::NOT_FOUND, "Permission not found"),
.await };
{
let mut updated_permission = current_permission;
updated_permission.id = thing_id;
updated_permission.updated_at = Some(get_iso_date());
// Only update fields that are provided
if let Some(name) = payload.name {
updated_permission.name = name;
}
match repo.query_update_permission(updated_permission).await {
Ok(msg) => common_response(StatusCode::OK, &msg), Ok(msg) => common_response(StatusCode::OK, &msg),
Err(e) => { Err(e) => {
if e.to_string().contains("not found") { if e.to_string().contains("not found") {
+15 -15
View File
@@ -39,13 +39,13 @@ pub async fn get_role_list(
Query(meta): Query<MetaRequestDto>, Query(meta): Query<MetaRequestDto>,
) -> impl IntoResponse { ) -> impl IntoResponse {
match permissions_guard( match permissions_guard(
&headers, headers,
state.clone(), Extension(state),
vec![PermissionsEnum::ReadListRoles], vec![PermissionsEnum::ReadListRoles],
) )
.await .await
{ {
Ok(_) => RolesService::get_role_list(&state, meta).await, Ok((_claims, state)) => RolesService::get_role_list(&state, meta).await,
Err(response) => response, Err(response) => response,
} }
} }
@@ -68,13 +68,13 @@ pub async fn get_role_by_id(
Path(id): Path<String>, Path(id): Path<String>,
) -> impl IntoResponse { ) -> impl IntoResponse {
match permissions_guard( match permissions_guard(
&headers, headers,
state.clone(), Extension(state),
vec![PermissionsEnum::ReadDetailRoles], vec![PermissionsEnum::ReadDetailRoles],
) )
.await .await
{ {
Ok(_) => RolesService::get_role_by_id(&state, id).await, Ok((_claims, state)) => RolesService::get_role_by_id(&state, id).await,
Err(response) => response, Err(response) => response,
} }
} }
@@ -97,13 +97,13 @@ pub async fn post_create_role(
Json(payload): Json<RolesRequestCreateDto>, Json(payload): Json<RolesRequestCreateDto>,
) -> impl IntoResponse { ) -> impl IntoResponse {
match permissions_guard( match permissions_guard(
&headers, headers,
state.clone(), Extension(state),
vec![PermissionsEnum::CreateRoles], vec![PermissionsEnum::CreateRoles],
) )
.await .await
{ {
Ok(_) => RolesService::create_role(&state, payload).await, Ok((_claims, state)) => RolesService::create_role(&state, payload).await,
Err(response) => response, Err(response) => response,
} }
} }
@@ -127,13 +127,13 @@ pub async fn put_update_role(
Json(payload): Json<RolesRequestUpdateDto>, Json(payload): Json<RolesRequestUpdateDto>,
) -> impl IntoResponse { ) -> impl IntoResponse {
match permissions_guard( match permissions_guard(
&headers, headers,
state.clone(), Extension(state),
vec![PermissionsEnum::UpdateRoles], vec![PermissionsEnum::UpdateRoles],
) )
.await .await
{ {
Ok(_) => RolesService::update_role(&state, id, payload).await, Ok((_claims, state)) => RolesService::update_role(&state, id, payload).await,
Err(response) => response, Err(response) => response,
} }
} }
@@ -155,13 +155,13 @@ pub async fn delete_role(
Path(id): Path<String>, Path(id): Path<String>,
) -> impl IntoResponse { ) -> impl IntoResponse {
match permissions_guard( match permissions_guard(
&headers, headers,
state.clone(), Extension(state),
vec![PermissionsEnum::DeleteRoles], vec![PermissionsEnum::DeleteRoles],
) )
.await .await
{ {
Ok(_) => RolesService::delete_role(&state, id).await, Ok((_claims, state)) => RolesService::delete_role(&state, id).await,
Err(response) => response, Err(response) => response,
} }
} }
+1 -1
View File
@@ -28,7 +28,7 @@ pub struct RolesListItemDto {
pub updated_at: Option<String>, pub updated_at: Option<String>,
} }
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] #[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Default)] // Added Default derive
pub struct RolesDetailItemDto { pub struct RolesDetailItemDto {
pub id: String, pub id: String,
pub name: String, pub name: String,
+1
View File
@@ -25,4 +25,5 @@ pub fn users_router() -> Router {
.route("/detail/{id}", get(get_user_by_id)) .route("/detail/{id}", get(get_user_by_id))
.route("/update/{id}", put(put_update_user)) .route("/update/{id}", put(put_update_user))
.route("/update/me", put(put_update_user_me)) .route("/update/me", put(put_update_user_me))
.route("/upload", post(upload_file))
} }
+111 -51
View File
@@ -1,16 +1,27 @@
use crate::{AppState, MetaRequestDto, v1::users_service::UsersService}; use crate::{AppState, MetaRequestDto};
use crate::{ use crate::{
MessageResponseDto, PermissionsEnum, ResponseListSuccessDto, ResponseSuccessDto, MessageResponseDto, PermissionsEnum, ResponseListSuccessDto, ResponseSuccessDto,
UsersCreateRequestDto, UsersDetailItemDto, permissions_guard, UsersCreateRequestDto, UsersDetailItemDto, permissions_guard,
}; };
use axum::extract::{Path, Query}; use axum::extract::{Path, Multipart};
use axum::http::HeaderMap; use axum::http::HeaderMap;
use axum::response::IntoResponse; use axum::response::IntoResponse;
use axum::{Extension, Json}; use axum::{Extension, Json};
use utoipa::ToSchema;
use serde::{Deserialize, Serialize};
use super::{ use super::{
UsersActiveInactiveRequestDto, UsersListItemDto, UsersUpdateRequestDto, UsersActiveInactiveRequestDto, UsersListItemDto, UsersUpdateRequestDto,
}; };
use crate::v1::users::users_service::{UsersServiceTrait, UsersService};
#[derive(Serialize, Deserialize, ToSchema)]
#[schema(description = "File upload form data for multipart/form-data")]
pub struct FileUploadSchema {
/// Binary file data to upload
#[schema(format = "binary")]
pub file: String,
}
#[utoipa::path( #[utoipa::path(
get, get,
@@ -35,33 +46,33 @@ use super::{
pub async fn get_user_list( pub async fn get_user_list(
headers: HeaderMap, headers: HeaderMap,
Extension(state): Extension<AppState>, Extension(state): Extension<AppState>,
Query(meta): Query<MetaRequestDto>, axum::extract::Query(meta): axum::extract::Query<MetaRequestDto>,
) -> impl IntoResponse { ) -> impl IntoResponse {
match permissions_guard( match permissions_guard(
&headers, headers,
state.clone(), Extension(state),
vec![PermissionsEnum::ReadListUsers], vec![PermissionsEnum::ReadListUsers],
) )
.await .await
{ {
Ok(_) => UsersService::get_user_list(&state, meta).await, Ok((_claims, state)) => UsersService::get_user_list(&state, meta).await,
Err(response) => response, Err(response) => response,
} }
} }
#[utoipa::path( #[utoipa::path(
get, get,
security( security(
("Bearer" = []) ("Bearer" = [])
), ),
path = "/v1/users/detail/{id}", path = "/v1/users/detail/{id}",
params( params(
("id" = String, Path, description = "User ID") ("id" = String, Path, description = "User ID")
), ),
responses( responses(
(status = 200, description = "Get user by ID", body = ResponseSuccessDto<UsersDetailItemDto>) (status = 200, description = "Get user by ID", body = ResponseSuccessDto<UsersDetailItemDto>)
), ),
tag = "Users" tag = "Users"
)] )]
pub async fn get_user_by_id( pub async fn get_user_by_id(
headers: HeaderMap, headers: HeaderMap,
@@ -69,34 +80,34 @@ pub async fn get_user_by_id(
Path(id): Path<String>, Path(id): Path<String>,
) -> impl IntoResponse { ) -> impl IntoResponse {
match permissions_guard( match permissions_guard(
&headers, headers,
state.clone(), Extension(state),
vec![PermissionsEnum::ReadDetailUsers], vec![PermissionsEnum::ReadDetailUsers],
) )
.await .await
{ {
Ok(_) => UsersService::get_user_by_id(&state, id).await, Ok((_claims, state)) => UsersService::get_user_by_id(&state, id).await,
Err(response) => response, Err(response) => response,
} }
} }
#[utoipa::path( #[utoipa::path(
get, get,
security( security(
("Bearer" = []) ("Bearer" = [])
), ),
path = "/v1/users/me", path = "/v1/users/me",
responses( responses(
(status = 200, description = "Get user by ID", body = ResponseSuccessDto<UsersDetailItemDto>) (status = 200, description = "Get user by ID", body = ResponseSuccessDto<UsersDetailItemDto>)
), ),
tag = "Users" tag = "Users"
)] )]
pub async fn get_user_me( pub async fn get_user_me(
Extension(state): Extension<AppState>,
headers: HeaderMap, headers: HeaderMap,
Extension(state): Extension<AppState>,
) -> impl IntoResponse { ) -> impl IntoResponse {
match permissions_guard(&headers, state.clone(), vec![]).await { match permissions_guard(headers, Extension(state), vec![]).await {
Ok(_) => UsersService::get_user_me(headers, &state).await, Ok((claims, state)) => UsersService::get_user_me(claims, &state).await,
Err(response) => response, Err(response) => response,
} }
} }
@@ -109,7 +120,7 @@ pub async fn get_user_me(
path = "/v1/users/create", path = "/v1/users/create",
request_body = UsersCreateRequestDto, request_body = UsersCreateRequestDto,
responses( responses(
(status = 201, description = "Create new user", body = MessageResponseDto) (status = 200, description = "Create new user", body = ResponseSuccessDto<UsersDetailItemDto>)
), ),
tag = "Users" tag = "Users"
)] )]
@@ -119,13 +130,13 @@ pub async fn post_create_user(
Json(payload): Json<UsersCreateRequestDto>, Json(payload): Json<UsersCreateRequestDto>,
) -> impl IntoResponse { ) -> impl IntoResponse {
match permissions_guard( match permissions_guard(
&headers, headers,
state.clone(), Extension(state),
vec![PermissionsEnum::CreateUsers], vec![PermissionsEnum::CreateUsers],
) )
.await .await
{ {
Ok(_) => UsersService::create_user(&state, payload).await, Ok((_claims, state)) => UsersService::create_user(&state, payload).await,
Err(response) => response, Err(response) => response,
} }
} }
@@ -136,9 +147,12 @@ pub async fn post_create_user(
("Bearer" = []) ("Bearer" = [])
), ),
path = "/v1/users/update/{id}", path = "/v1/users/update/{id}",
params(
("id" = String, Path, description = "User ID")
),
request_body = UsersUpdateRequestDto, request_body = UsersUpdateRequestDto,
responses( responses(
(status = 200, description = "Update user", body = MessageResponseDto) (status = 200, description = "Update user", body = ResponseSuccessDto<UsersDetailItemDto>)
), ),
tag = "Users" tag = "Users"
)] )]
@@ -149,13 +163,13 @@ pub async fn put_update_user(
Json(payload): Json<UsersUpdateRequestDto>, Json(payload): Json<UsersUpdateRequestDto>,
) -> impl IntoResponse { ) -> impl IntoResponse {
match permissions_guard( match permissions_guard(
&headers, headers,
state.clone(), Extension(state),
vec![PermissionsEnum::UpdateUsers], vec![PermissionsEnum::UpdateUsers],
) )
.await .await
{ {
Ok(_) => UsersService::update_user(&state, id, payload).await, Ok((_claims, state)) => UsersService::update_user(&state, id, payload).await,
Err(response) => response, Err(response) => response,
} }
} }
@@ -168,7 +182,7 @@ pub async fn put_update_user(
path = "/v1/users/update/me", path = "/v1/users/update/me",
request_body = UsersUpdateRequestDto, request_body = UsersUpdateRequestDto,
responses( responses(
(status = 200, description = "Update user me", body = MessageResponseDto) (status = 200, description = "Update current user", body = ResponseSuccessDto<UsersDetailItemDto>)
), ),
tag = "Users" tag = "Users"
)] )]
@@ -177,8 +191,8 @@ pub async fn put_update_user_me(
Extension(state): Extension<AppState>, Extension(state): Extension<AppState>,
Json(payload): Json<UsersUpdateRequestDto>, Json(payload): Json<UsersUpdateRequestDto>,
) -> impl IntoResponse { ) -> impl IntoResponse {
match permissions_guard(&headers, state.clone(), vec![]).await { match permissions_guard(headers.clone(), Extension(state), vec![]).await {
Ok(_) => UsersService::update_user_me(&state, headers, payload).await, Ok((claims, state)) => UsersService::update_user_me(claims, &state, payload).await,
Err(response) => response, Err(response) => response,
} }
} }
@@ -189,9 +203,12 @@ pub async fn put_update_user_me(
("Bearer" = []) ("Bearer" = [])
), ),
path = "/v1/users/activate/{id}", path = "/v1/users/activate/{id}",
params(
("id" = String, Path, description = "User ID")
),
request_body = UsersActiveInactiveRequestDto, request_body = UsersActiveInactiveRequestDto,
responses( responses(
(status = 200, description = "Set user active/inactive", body = MessageResponseDto) (status = 200, description = "Set user active status", body = MessageResponseDto)
), ),
tag = "Users" tag = "Users"
)] )]
@@ -202,13 +219,13 @@ pub async fn patch_user_active_status(
Json(payload): Json<UsersActiveInactiveRequestDto>, Json(payload): Json<UsersActiveInactiveRequestDto>,
) -> impl IntoResponse { ) -> impl IntoResponse {
match permissions_guard( match permissions_guard(
&headers, headers,
state.clone(), Extension(state),
vec![PermissionsEnum::ActivateUsers], vec![PermissionsEnum::ActivateUsers],
) )
.await .await
{ {
Ok(_) => UsersService::set_user_active_status(&state, id, payload).await, Ok((_claims, state)) => UsersService::set_user_active_status(&state, id, payload).await,
Err(response) => response, Err(response) => response,
} }
} }
@@ -230,13 +247,56 @@ pub async fn delete_user(
Path(id): Path<String>, Path(id): Path<String>,
) -> impl IntoResponse { ) -> impl IntoResponse {
match permissions_guard( match permissions_guard(
&headers, headers,
state.clone(), Extension(state),
vec![PermissionsEnum::DeleteUsers], vec![PermissionsEnum::DeleteUsers],
) )
.await .await
{ {
Ok(_) => UsersService::delete_user(&state, id).await, Ok((_claims, state)) => UsersService::delete_user(&state, id).await,
Err(response) => response,
}
}
#[utoipa::path(
post,
security(
("Bearer" = [])
),
path = "/v1/users/upload",
request_body(
content = FileUploadSchema,
description = "Upload file with multipart form data. Only 'file' field is required - file type will be detected automatically from the uploaded file.",
content_type = "multipart/form-data"
),
responses(
(status = 200, description = "Upload file successfully", body = ResponseSuccessDto<serde_json::Value>),
(status = 400, description = "Bad request"),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Internal server error")
),
tag = "Users"
)]
pub async fn upload_file(
headers: HeaderMap,
Extension(state): Extension<AppState>,
multipart: Multipart,
) -> impl IntoResponse {
// Check authentication first
match permissions_guard(
headers,
Extension(state),
vec![], // No specific permission needed, just authentication
)
.await
{
Ok((claims, state)) => {
// Extract user ID from user data
let user_id = claims.user_id.clone(); // Use claims.user_id directly
// Process upload - don't use match here since it returns Response directly
UsersService::upload_file(&state, user_id, multipart).await
},
Err(response) => response, Err(response) => response,
} }
} }
+198 -11
View File
@@ -4,12 +4,31 @@ use serde::{Deserialize, Serialize};
use surrealdb::sql::Thing; use surrealdb::sql::Thing;
use utoipa::ToSchema; use utoipa::ToSchema;
use validator::Validate; use validator::Validate;
use crate::UsersSchema; // Import UsersSchema
lazy_static! { lazy_static! {
static ref PASSWORD_REGEX: regex::Regex = static ref PASSWORD_REGEX: regex::Regex =
regex::Regex::new(r"^[A-Za-z\d@$!%*?&]{8,}$").unwrap(); regex::Regex::new(r"^[A-Za-z\d@$!%*?&]{8,}$").unwrap();
} }
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct ExperienceDto {
pub id: String,
pub company: String,
pub position: String,
pub duration: String,
pub period: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct EducationDto {
pub id: String,
pub institution: String,
pub degree: String,
pub field: String,
pub period: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] #[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct UsersActiveInactiveRequestDto { pub struct UsersActiveInactiveRequestDto {
pub is_active: bool, pub is_active: bool,
@@ -49,6 +68,7 @@ pub struct UsersCreateRequestDto {
pub phone_number: String, pub phone_number: String,
pub is_active: bool, pub is_active: bool,
pub role_id: String, pub role_id: String,
pub avatar: Option<String>,
} }
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)] #[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)]
@@ -57,60 +77,173 @@ pub struct UsersUpdateRequestDto {
length(min = 1, message = "Email cannot be empty"), length(min = 1, message = "Email cannot be empty"),
email(message = "Email not valid") email(message = "Email not valid")
)] )]
pub email: String, #[serde(skip_serializing_if = "Option::is_none")]
pub email: Option<String>,
#[validate(length( #[validate(length(
min = 8, min = 8,
message = "Password must have at least 8 characters" message = "Password must have at least 8 characters"
))] ))]
pub password: String, #[serde(skip_serializing_if = "Option::is_none")]
pub password: Option<String>,
#[validate(length(min = 2, message = "Fullname at least have 2 character"))] #[validate(length(min = 2, message = "Fullname at least have 2 character"))]
pub fullname: String, #[serde(skip_serializing_if = "Option::is_none")]
pub fullname: Option<String>,
#[validate(length(min = 2, message = "Legal name at least have 2 character"))]
#[serde(skip_serializing_if = "Option::is_none")]
pub legal_name: Option<String>,
#[validate(length( #[validate(length(
min = 10, min = 10,
message = "Phone number at least have 10 character" message = "Phone number at least have 10 character"
))] ))]
pub phone_number: String, #[serde(skip_serializing_if = "Option::is_none")]
pub is_active: bool, pub phone_number: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub phone_for_verification: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub is_active: Option<bool>,
#[validate(length(min = 1, message = "Gender is required"))] #[validate(length(min = 1, message = "Gender is required"))]
#[serde(skip_serializing_if = "Option::is_none")]
pub gender: Option<String>, pub gender: Option<String>,
#[validate(length(min = 1, message = "Birthdate is required"))] #[validate(length(min = 1, message = "Birthdate is required"))]
#[serde(skip_serializing_if = "Option::is_none")]
pub birthdate: Option<String>, pub birthdate: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub domicile: Option<String>,
#[validate(length(min = 50, message = "Bio must be at least 50 characters"))]
#[serde(skip_serializing_if = "Option::is_none")]
pub bio: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_education: Option<String>,
#[validate(url(message = "Invalid LinkedIn URL"))]
#[serde(skip_serializing_if = "Option::is_none")]
pub linkedin_url: Option<String>,
#[validate(url(message = "Invalid GitHub URL"))]
#[serde(skip_serializing_if = "Option::is_none")]
pub github_url: Option<String>,
#[validate(url(message = "Invalid CV URL"))]
#[serde(skip_serializing_if = "Option::is_none")]
pub cv_url: Option<String>,
#[validate(url(message = "Invalid portfolio URL"))]
#[serde(skip_serializing_if = "Option::is_none")]
pub portfolio_url: Option<String>,
#[validate(url(message = "Invalid website URL"))]
#[serde(skip_serializing_if = "Option::is_none")]
pub website_url: Option<String>,
#[validate(url(message = "Invalid Twitter URL"))]
#[serde(skip_serializing_if = "Option::is_none")]
pub twitter_url: Option<String>,
#[validate(length(min = 1, message = "Avatar is required"))] #[validate(length(min = 1, message = "Avatar is required"))]
#[serde(skip_serializing_if = "Option::is_none")]
pub avatar: Option<String>, pub avatar: Option<String>,
pub role_id: String, #[serde(skip_serializing_if = "Option::is_none")]
pub role_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub location: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub skills: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub experience: Option<Vec<ExperienceDto>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub education: Option<Vec<EducationDto>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub career_status: Option<String>,
} }
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] #[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Default)]
pub struct UsersDetailItemDto { pub struct UsersDetailItemDto {
pub id: String, pub id: String,
pub role: RolesDetailItemDto, pub role: RolesDetailItemDto,
pub fullname: String, pub fullname: String,
pub legal_name: Option<String>,
pub email: String, pub email: String,
pub avatar: Option<String>, pub avatar: Option<String>,
pub phone_number: String, pub phone_number: String,
pub phone_for_verification: Option<String>,
pub is_active: bool, pub is_active: bool,
pub gender: Option<String>, pub gender: Option<String>,
pub birthdate: Option<String>, pub birthdate: Option<String>,
pub domicile: 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>,
pub website_url: Option<String>,
pub twitter_url: Option<String>,
pub location: Option<String>,
pub skills: Option<Vec<String>>,
pub experience: Option<Vec<ExperienceDto>>,
pub education: Option<Vec<EducationDto>>,
pub career_status: Option<String>,
pub created_at: String, pub created_at: String,
pub updated_at: String, pub updated_at: String,
} }
impl UsersDetailItemDto { impl UsersDetailItemDto {
pub fn from(dto: &UsersDetailQueryDto) -> Self { pub fn from(dto: &UsersDetailQueryDto) -> Self { // Reverted to taking a reference
Self { Self {
id: dto.id.id.to_raw().clone(), id: dto.id.id.to_raw().clone(),
role: RolesDetailItemDto::from(&dto.role), role: RolesDetailItemDto::from(&dto.role),
fullname: dto.fullname.clone(), fullname: dto.fullname.clone(),
legal_name: dto.legal_name.clone(),
email: dto.email.clone(), email: dto.email.clone(),
avatar: dto.avatar.clone(), avatar: dto.avatar.clone(),
phone_number: dto.phone_number.clone(), phone_number: dto.phone_number.clone(), // Corrected from dto.phone.clone()
phone_for_verification: dto.phone_for_verification.clone(),
is_active: dto.is_active, is_active: dto.is_active,
gender: dto.gender.clone(), gender: dto.gender.clone(),
birthdate: dto.birthdate.clone(), birthdate: dto.birthdate.clone(),
domicile: dto.domicile.clone(),
bio: dto.bio.clone(),
last_education: dto.last_education.clone(),
linkedin_url: dto.linkedin_url.clone(),
github_url: dto.github_url.clone(),
cv_url: dto.cv_url.clone(),
portfolio_url: dto.portfolio_url.clone(),
website_url: dto.website_url.clone(),
twitter_url: dto.twitter_url.clone(),
location: dto.location.clone(),
skills: dto.skills.clone(),
experience: dto.experience.clone(),
education: dto.education.clone(),
career_status: dto.career_status.clone(),
created_at: dto.created_at.clone(), created_at: dto.created_at.clone(),
updated_at: dto.updated_at.clone(), updated_at: dto.updated_at.clone(),
} }
} }
pub fn from_schema(schema: &UsersSchema) -> Self {
Self {
id: schema.id.id.to_raw(),
role: RolesDetailItemDto::default(), // Placeholder, role needs to be fetched
fullname: schema.fullname.clone(),
legal_name: schema.legal_name.clone(),
email: schema.email.clone(),
avatar: schema.avatar.clone(),
phone_number: schema.phone_number.clone(),
phone_for_verification: schema.phone_for_verification.clone(),
is_active: schema.is_active,
gender: schema.gender.clone(),
birthdate: schema.birthdate.clone(),
domicile: schema.domicile.clone(),
bio: schema.bio.clone(),
last_education: schema.last_education.clone(),
linkedin_url: schema.linkedin_url.clone(),
github_url: schema.github_url.clone(),
cv_url: schema.cv_url.clone(),
portfolio_url: schema.portfolio_url.clone(),
website_url: schema.website_url.clone(),
twitter_url: schema.twitter_url.clone(),
location: schema.location.clone(),
skills: schema.skills.clone(),
experience: schema.experience.clone(),
education: schema.education.clone(),
career_status: schema.career_status.clone(),
created_at: schema.created_at.clone(),
updated_at: schema.updated_at.clone(),
}
}
} }
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] #[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
@@ -131,7 +264,7 @@ pub struct UsersListQueryDto {
pub id: Thing, pub id: Thing,
pub role: RolesDetailQueryDto, pub role: RolesDetailQueryDto,
pub fullname: String, pub fullname: String,
pub email: String, pub email: String, // Corrected from pub pub email: String,
pub avatar: Option<String>, pub avatar: Option<String>,
pub phone_number: String, pub phone_number: String,
pub is_active: bool, pub is_active: bool,
@@ -159,13 +292,29 @@ impl UsersListQueryDto {
pub struct UsersDetailQueryDto { pub struct UsersDetailQueryDto {
pub id: Thing, pub id: Thing,
pub fullname: String, pub fullname: String,
pub legal_name: Option<String>,
pub email: String, pub email: String,
pub avatar: Option<String>, pub avatar: Option<String>,
pub phone_number: String, pub phone_number: String,
pub phone_for_verification: Option<String>,
pub is_active: bool, pub is_active: bool,
pub is_deleted: bool, pub is_deleted: bool,
pub gender: Option<String>, pub gender: Option<String>,
pub birthdate: Option<String>, pub birthdate: Option<String>,
pub domicile: 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>,
pub website_url: Option<String>,
pub twitter_url: Option<String>,
pub location: Option<String>,
pub skills: Option<Vec<String>>,
pub experience: Option<Vec<ExperienceDto>>,
pub education: Option<Vec<EducationDto>>,
pub career_status: Option<String>,
pub password: String, pub password: String,
pub role: RolesDetailQueryDto, pub role: RolesDetailQueryDto,
pub created_at: String, pub created_at: String,
@@ -179,12 +328,28 @@ impl UsersDetailQueryDto {
id: self.id.clone(), id: self.id.clone(),
role: self.role.clone(), role: self.role.clone(),
fullname: self.fullname.clone(), fullname: self.fullname.clone(),
legal_name: self.legal_name.clone(),
email: self.email.clone(), email: self.email.clone(),
avatar: self.avatar.clone(), avatar: self.avatar.clone(),
phone_number: self.phone_number.clone(), phone_number: self.phone_number.clone(),
phone_for_verification: self.phone_for_verification.clone(),
is_active: self.is_active, is_active: self.is_active,
mentor_id: self.mentor_id.clone(), mentor_id: self.mentor_id.clone(),
gender: self.gender.clone(), gender: self.gender.clone(),
domicile: self.domicile.clone(),
bio: self.bio.clone(),
last_education: self.last_education.clone(),
linkedin_url: self.linkedin_url.clone(),
github_url: self.github_url.clone(),
cv_url: self.cv_url.clone(),
portfolio_url: self.portfolio_url.clone(),
website_url: self.website_url.clone(),
twitter_url: self.twitter_url.clone(),
location: self.location.clone(),
skills: self.skills.clone(),
experience: self.experience.clone(),
education: self.education.clone(),
career_status: self.career_status.clone(),
is_deleted: self.is_deleted, is_deleted: self.is_deleted,
password: self.password.clone(), password: self.password.clone(),
birthdate: self.birthdate.clone(), birthdate: self.birthdate.clone(),
@@ -199,18 +364,40 @@ impl From<&UsersDetailItemDto> for UsersDetailQueryDto {
Self { Self {
id: crate::make_thing(&imphnen_libs::ResourceEnum::Users.to_string(), &dto.id), id: crate::make_thing(&imphnen_libs::ResourceEnum::Users.to_string(), &dto.id),
fullname: dto.fullname.clone(), fullname: dto.fullname.clone(),
legal_name: dto.legal_name.clone(),
email: dto.email.clone(), email: dto.email.clone(),
avatar: dto.avatar.clone(), avatar: dto.avatar.clone(),
phone_number: dto.phone_number.clone(), phone_number: dto.phone_number.clone(),
phone_for_verification: dto.phone_for_verification.clone(),
is_active: dto.is_active, is_active: dto.is_active,
is_deleted: false, is_deleted: false,
gender: dto.gender.clone(), gender: dto.gender.clone(),
birthdate: dto.birthdate.clone(), birthdate: dto.birthdate.clone(),
domicile: dto.domicile.clone(),
bio: dto.bio.clone(),
last_education: dto.last_education.clone(),
linkedin_url: dto.linkedin_url.clone(),
github_url: dto.github_url.clone(),
cv_url: dto.cv_url.clone(),
portfolio_url: dto.portfolio_url.clone(),
website_url: dto.website_url.clone(),
twitter_url: dto.twitter_url.clone(),
location: dto.location.clone(),
skills: dto.skills.clone(),
experience: dto.experience.clone(),
education: dto.education.clone(),
career_status: dto.career_status.clone(),
password: String::new(), password: String::new(),
role: RolesDetailQueryDto::default(), role: RolesDetailQueryDto::default(),
created_at: dto.created_at.clone(), created_at: dto.created_at.clone(),
updated_at: dto.updated_at.clone(), updated_at: dto.updated_at.clone(),
mentor_id: None, mentor_id: None,
} }
} }
} }
impl UsersDetailItemDto {
pub fn extract_permissions_from_user_role(&self) -> Vec<String> {
self.role.permissions.iter().map(|p| p.name.clone()).collect()
}
}
+174 -30
View File
@@ -1,4 +1,4 @@
use super::{UsersCreateRequestDto, UsersDetailQueryDto, UsersUpdateRequestDto}; use super::{UsersCreateRequestDto, UsersDetailQueryDto, UsersUpdateRequestDto, ExperienceDto, EducationDto};
use imphnen_libs::{ResourceEnum, hash_password}; use imphnen_libs::{ResourceEnum, hash_password};
use imphnen_utils::extract_id; use imphnen_utils::extract_id;
use imphnen_utils::{get_iso_date, make_thing}; use imphnen_utils::{get_iso_date, make_thing};
@@ -9,11 +9,15 @@ use surrealdb::{Uuid, sql::Thing};
pub struct UsersSchema { pub struct UsersSchema {
pub id: Thing, pub id: Thing,
pub fullname: String, pub fullname: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub legal_name: Option<String>,
pub email: String, pub email: String,
pub password: String, pub password: String,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub avatar: Option<String>, pub avatar: Option<String>,
pub phone_number: String, pub phone_number: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub phone_for_verification: Option<String>,
pub is_active: bool, pub is_active: bool,
pub is_deleted: bool, pub is_deleted: bool,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
@@ -22,6 +26,34 @@ pub struct UsersSchema {
pub gender: Option<String>, pub gender: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub birthdate: Option<String>, pub birthdate: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub domicile: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub bio: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_education: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub linkedin_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub github_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cv_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub portfolio_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub website_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub twitter_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub location: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub skills: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub experience: Option<Vec<ExperienceDto>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub education: Option<Vec<EducationDto>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub career_status: Option<String>,
pub role: Thing, pub role: Thing,
pub created_at: String, pub created_at: String,
pub updated_at: String, pub updated_at: String,
@@ -35,18 +67,31 @@ impl Default for UsersSchema {
&Uuid::new_v4().to_string(), &Uuid::new_v4().to_string(),
), ),
fullname: String::new(), fullname: String::new(),
legal_name: None,
email: String::new(), email: String::new(),
password: hash_password("").unwrap(), password: hash_password("").unwrap(),
avatar: None, avatar: None,
phone_number: String::new(), phone_number: String::new(),
phone_for_verification: None,
is_active: false, is_active: false,
is_deleted: false, is_deleted: false,
mentor_id: Some(make_thing( mentor_id: None, // Regular users should not have a mentor_id by default
&ResourceEnum::Users.to_string(),
&Uuid::new_v4().to_string(),
)),
gender: None, gender: None,
birthdate: None, birthdate: None,
domicile: None,
bio: None,
last_education: None,
linkedin_url: None,
github_url: None,
cv_url: None,
portfolio_url: None,
website_url: None,
twitter_url: None,
location: None,
skills: None,
experience: None,
education: None,
career_status: None,
role: make_thing( role: make_thing(
&ResourceEnum::Roles.to_string(), &ResourceEnum::Roles.to_string(),
"5713cb37-dc02-4e87-8048-d7a41d352059", "5713cb37-dc02-4e87-8048-d7a41d352059",
@@ -62,19 +107,30 @@ impl UsersSchema {
Self { Self {
id: dto.id, id: dto.id,
fullname: dto.fullname, fullname: dto.fullname,
legal_name: dto.legal_name,
email: dto.email, email: dto.email,
avatar: dto.avatar, avatar: dto.avatar,
phone_number: dto.phone_number, phone_number: dto.phone_number,
phone_for_verification: dto.phone_for_verification,
is_active: dto.is_active, is_active: dto.is_active,
is_deleted: dto.is_deleted, is_deleted: dto.is_deleted,
mentor_id: Some(dto.mentor_id.unwrap_or_else(|| { mentor_id: dto.mentor_id, // Use the actual mentor_id from the DTO, could be None
make_thing(
&ResourceEnum::Users.to_string(),
&Uuid::new_v4().to_string(),
)
})),
gender: dto.gender, gender: dto.gender,
birthdate: dto.birthdate, birthdate: dto.birthdate,
domicile: dto.domicile,
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,
website_url: dto.website_url,
twitter_url: dto.twitter_url,
location: dto.location,
skills: dto.skills,
experience: dto.experience,
education: dto.education,
career_status: dto.career_status,
password: dto.password, password: dto.password,
created_at: dto.created_at, created_at: dto.created_at,
updated_at: dto.updated_at, updated_at: dto.updated_at,
@@ -82,23 +138,101 @@ impl UsersSchema {
} }
} }
pub fn update(user: UsersUpdateRequestDto, id: String) -> Self { pub fn update(_user: UsersUpdateRequestDto, id: String) -> Self {
Self { Self {
id: make_thing(&ResourceEnum::Users.to_string(), &id), id: make_thing(&ResourceEnum::Users.to_string(), &id),
fullname: user.fullname,
email: user.email,
phone_number: user.phone_number,
is_active: user.is_active,
gender: user.gender,
birthdate: user.birthdate,
avatar: user.avatar,
is_deleted: false,
role: make_thing(&ResourceEnum::Roles.to_string(), &user.role_id),
updated_at: get_iso_date(), updated_at: get_iso_date(),
// Set defaults for required fields - these should be overridden by actual data from DB
..Default::default() ..Default::default()
} }
} }
pub fn partial_update(current_user: UsersDetailQueryDto, user: UsersUpdateRequestDto) -> Self {
let mut schema = Self::from(current_user);
schema.updated_at = get_iso_date();
// Only update fields that are provided (Some)
if let Some(fullname) = user.fullname {
schema.fullname = fullname;
}
if let Some(email) = user.email {
schema.email = email;
}
if let Some(password) = user.password {
schema.password = hash_password(&password).unwrap_or_else(|_| password);
}
if let Some(phone_number) = user.phone_number {
schema.phone_number = phone_number;
}
if let Some(is_active) = user.is_active {
schema.is_active = is_active;
}
if let Some(role_id) = user.role_id {
schema.role = make_thing(&ResourceEnum::Roles.to_string(), &role_id);
}
// Optional fields - only update if provided
if let Some(legal_name) = user.legal_name {
schema.legal_name = Some(legal_name);
}
if let Some(phone_for_verification) = user.phone_for_verification {
schema.phone_for_verification = Some(phone_for_verification);
}
if let Some(gender) = user.gender {
schema.gender = Some(gender);
}
if let Some(birthdate) = user.birthdate {
schema.birthdate = Some(birthdate);
}
if let Some(domicile) = user.domicile {
schema.domicile = Some(domicile);
}
if let Some(bio) = user.bio {
schema.bio = Some(bio);
}
if let Some(last_education) = user.last_education {
schema.last_education = Some(last_education);
}
if let Some(linkedin_url) = user.linkedin_url {
schema.linkedin_url = Some(linkedin_url);
}
if let Some(github_url) = user.github_url {
schema.github_url = Some(github_url);
}
if let Some(cv_url) = user.cv_url {
schema.cv_url = Some(cv_url);
}
if let Some(portfolio_url) = user.portfolio_url {
schema.portfolio_url = Some(portfolio_url);
}
if let Some(website_url) = user.website_url {
schema.website_url = Some(website_url);
}
if let Some(twitter_url) = user.twitter_url {
schema.twitter_url = Some(twitter_url);
}
if let Some(location) = user.location {
schema.location = Some(location);
}
if let Some(skills) = user.skills {
schema.skills = Some(skills);
}
if let Some(experience) = user.experience {
schema.experience = Some(experience);
}
if let Some(education) = user.education {
schema.education = Some(education);
}
if let Some(career_status) = user.career_status {
schema.career_status = Some(career_status);
}
if let Some(avatar) = user.avatar {
schema.avatar = Some(avatar);
}
schema
}
pub fn create(user: UsersCreateRequestDto) -> Self { pub fn create(user: UsersCreateRequestDto) -> Self {
let password = hash_password(&user.password).unwrap(); let password = hash_password(&user.password).unwrap();
Self { Self {
@@ -107,17 +241,30 @@ impl UsersSchema {
&Uuid::new_v4().to_string(), &Uuid::new_v4().to_string(),
), ),
fullname: user.fullname, fullname: user.fullname,
legal_name: None,
email: user.email, email: user.email,
password, password,
phone_number: user.phone_number, phone_number: user.phone_number,
phone_for_verification: None,
is_active: false, is_active: false,
mentor_id: Some(make_thing( mentor_id: None, // Regular users should not have a mentor_id by default
&ResourceEnum::Users.to_string(),
&Uuid::new_v4().to_string(),
)),
gender: None, gender: None,
birthdate: None, birthdate: None,
avatar: 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,
avatar: user.avatar,
is_deleted: false, is_deleted: false,
role: make_thing(&ResourceEnum::Roles.to_string(), &user.role_id), role: make_thing(&ResourceEnum::Roles.to_string(), &user.role_id),
created_at: get_iso_date(), created_at: get_iso_date(),
@@ -136,10 +283,7 @@ impl UsersSchema {
pub fn update_mentor_id(mut self, mentor_id: Option<String>) -> Self { pub fn update_mentor_id(mut self, mentor_id: Option<String>) -> Self {
self.mentor_id = match mentor_id { self.mentor_id = match mentor_id {
Some(id) => Some(make_thing(&ResourceEnum::Users.to_string(), &id)), Some(id) => Some(make_thing(&ResourceEnum::Users.to_string(), &id)),
None => Some(make_thing( None => None, // Set to None if no mentor_id provided
&ResourceEnum::Users.to_string(),
&Uuid::new_v4().to_string(),
)),
}; };
self.updated_at = get_iso_date(); self.updated_at = get_iso_date();
self self
+458 -70
View File
@@ -1,26 +1,68 @@
use super::{ use super::{
UsersActiveInactiveRequestDto, UsersCreateRequestDto, UsersDetailItemDto, UsersActiveInactiveRequestDto, UsersCreateRequestDto,
UsersSetNewPasswordRequestDto, UsersUpdateRequestDto, UsersSetNewPasswordRequestDto, UsersUpdateRequestDto,
users_dto::UsersDetailQueryDto, // Add this line
}; };
use crate::{ use crate::{
AppState, MetaRequestDto, ResponseListSuccessDto, UsersRepository, UsersSchema, AppState, MetaRequestDto, ResponseListSuccessDto, UsersRepository, UsersSchema,
}; };
use crate::{ use crate::{
ResponseSuccessDto, common_response, extract_email, success_list_response, ResponseSuccessDto, common_response, success_list_response,
success_response, validate_request, success_response, validate_request,
}; };
use axum::http::HeaderMap; use axum::{http::StatusCode, response::Response, extract::Multipart};
use axum::{http::StatusCode, response::Response}; use imphnen_libs::{ResourceEnum, hash_password, verify_password, MinioConfig, FileType, decode_base64_file, extract_content_type_from_data_url, create_minio_service_from_config};
use imphnen_libs::{ResourceEnum, hash_password, verify_password};
use imphnen_utils::make_thing; use imphnen_utils::make_thing;
use uuid::Uuid; use uuid::Uuid;
use std::pin::Pin;
use std::future::Future;
use anyhow::Result;
use tracing::info;
use tracing::error;
use crate::v1::users::users_dto::{UsersDetailItemDto as UserDto, UsersCreateRequestDto as CreateUserDto};
use serde_json::json;
pub struct UsersService; pub trait UsersServiceTrait: Send + Sync + 'static {
fn get_user_list(state: &AppState, meta: MetaRequestDto) -> Pin<Box<dyn Future<Output = Response> + Send>>;
impl UsersService { fn get_user_by_id(state: &AppState, id: String) -> Pin<Box<dyn Future<Output = Response> + Send>>;
pub async fn get_user_list(state: &AppState, meta: MetaRequestDto) -> Response { fn get_user_me(claims: imphnen_libs::jsonwebtoken::Claims, state: &AppState) -> Pin<Box<dyn Future<Output = Response> + Send>>;
let repo = UsersRepository::new(state); fn create_user(state: &AppState, new_user: UsersCreateRequestDto) -> Pin<Box<dyn Future<Output = Response> + Send>>;
fn update_user(state: &AppState, id: String, user: UsersUpdateRequestDto) -> Pin<Box<dyn Future<Output = Response> + Send>>;
fn update_user_me(claims: imphnen_libs::jsonwebtoken::Claims, state: &AppState, user: UsersUpdateRequestDto) -> Pin<Box<dyn Future<Output = Response> + Send>>;
fn set_user_active_status(state: &AppState, id: String, payload: UsersActiveInactiveRequestDto) -> Pin<Box<dyn Future<Output = Response> + Send>>;
fn update_user_password(state: &AppState, email: String, payload: UsersSetNewPasswordRequestDto) -> Pin<Box<dyn Future<Output = Response> + Send>>;
fn get_user_by_mentor_id(state: &AppState, mentor_id: String) -> Pin<Box<dyn Future<Output = Response> + Send>>;
fn delete_user(state: &AppState, id: String) -> Pin<Box<dyn Future<Output = Response> + Send>>;
fn get_user_by_id_internal(&self, id: &surrealdb::sql::Thing, state: &AppState) -> Pin<Box<dyn Future<Output = Result<UsersDetailQueryDto>> + Send>>;
fn get_user_by_email(&self, email: &str, state: &AppState) -> Pin<Box<dyn Future<Output = Result<Option<UserDto>>> + Send>>;
fn create_user_by_dto(&self, new_user: CreateUserDto, state: &AppState) -> Pin<Box<dyn Future<Output = Result<UserDto>> + Send>>;
fn update_user_avatar(email: &str, avatar_url: Option<String>, state: &AppState) -> Pin<Box<dyn Future<Output = Result<()>> + Send>>;
fn upload_file(state: &AppState, user_id: String, multipart: Multipart) -> Pin<Box<dyn Future<Output = Response> + Send>>;
}
#[derive(Clone)]
pub struct UsersService;
impl UsersService {
}
impl UsersServiceTrait for UsersService {
fn get_user_by_id_internal(&self, id: &surrealdb::sql::Thing, state: &AppState) -> Pin<Box<dyn Future<Output = Result<UsersDetailQueryDto>> + Send>> {
let id = id.to_owned();
let state = state.to_owned();
Box::pin(async move {
let repo = crate::UsersRepository::new(&state);
repo.query_user_by_id(&id).await
})
}
fn get_user_list(state: &AppState, meta: MetaRequestDto) -> Pin<Box<dyn Future<Output = Response> + Send>> {
let state = state.to_owned();
Box::pin(async move {
let repo = UsersRepository::new(&state);
match repo.query_user_list(meta).await { match repo.query_user_list(meta).await {
Ok(data) => { Ok(data) => {
let response = ResponseListSuccessDto { let response = ResponseListSuccessDto {
@@ -31,46 +73,55 @@ impl UsersService {
} }
Err(e) => common_response(StatusCode::BAD_REQUEST, &e.to_string()), Err(e) => common_response(StatusCode::BAD_REQUEST, &e.to_string()),
} }
})
} }
pub async fn get_user_by_id(state: &AppState, id: String) -> Response { fn get_user_by_id(state: &AppState, id: String) -> Pin<Box<dyn Future<Output = Response> + Send>> {
let state = state.to_owned();
let id = id.to_owned();
Box::pin(async move {
if Uuid::parse_str(&id).is_err() { if Uuid::parse_str(&id).is_err() {
return common_response(StatusCode::BAD_REQUEST, "Invalid User ID format"); return common_response(StatusCode::BAD_REQUEST, "Invalid User ID format");
} }
let repo = UsersRepository::new(state); let repo = UsersRepository::new(&state);
let thing_id = make_thing(&ResourceEnum::Users.to_string(), &id); let thing_id = make_thing(&ResourceEnum::Users.to_string(), &id);
match repo.query_user_by_id(&thing_id).await { match repo.query_user_by_id(&thing_id).await {
Ok(user) if !user.is_deleted => success_response(ResponseSuccessDto { Ok(user) if !user.is_deleted => success_response(ResponseSuccessDto {
data: UsersDetailItemDto::from(&user), data: UserDto::from(&user), // Corrected to use UserDto::from by reference
}), }),
Ok(_) => common_response(StatusCode::NOT_FOUND, "User not found"), Ok(_) => common_response(StatusCode::NOT_FOUND, "User not found"),
Err(e) => common_response(StatusCode::NOT_FOUND, &e.to_string()), Err(e) => common_response(StatusCode::NOT_FOUND, &e.to_string()),
} }
})
} }
pub async fn get_user_me(headers: HeaderMap, state: &AppState) -> Response { fn get_user_me(claims: imphnen_libs::jsonwebtoken::Claims, state: &AppState) -> Pin<Box<dyn Future<Output = Response> + Send>> {
let repo = UsersRepository::new(state); let claims = claims.to_owned();
let email = match extract_email(&headers) { let state = state.to_owned();
Some(email) => email, Box::pin(async move {
None => return common_response(StatusCode::UNAUTHORIZED, "Invalid token"), let repo = UsersRepository::new(&state);
}; let thing_id = make_thing(&ResourceEnum::Users.to_string(), &claims.user_id);
match repo.query_user_by_email(email).await { match repo.query_user_by_id(&thing_id).await {
Ok(user) if !user.is_deleted => success_response(ResponseSuccessDto { Ok(user) if !user.is_deleted => success_response(ResponseSuccessDto {
data: UsersDetailItemDto::from(&user), data: UserDto::from(&user),
}), }),
Ok(_) => common_response(StatusCode::NOT_FOUND, "User not found"), Ok(_) => common_response(StatusCode::NOT_FOUND, "User not found"),
Err(e) => common_response(StatusCode::NOT_FOUND, &e.to_string()), Err(e) => common_response(StatusCode::NOT_FOUND, &e.to_string()),
} }
})
} }
pub async fn create_user( fn create_user(
state: &AppState, state: &AppState,
new_user: UsersCreateRequestDto, new_user: UsersCreateRequestDto,
) -> Response { ) -> Pin<Box<dyn Future<Output = Response> + Send>> {
let state = state.to_owned();
let new_user = new_user;
Box::pin(async move {
if let Err((status, message)) = validate_request(&new_user) { if let Err((status, message)) = validate_request(&new_user) {
return common_response(status, &message); return common_response(status, &message);
} }
let repo = UsersRepository::new(state); let repo = UsersRepository::new(&state);
if repo if repo
.query_user_by_email(new_user.email.clone()) .query_user_by_email(new_user.email.clone())
.await .await
@@ -84,60 +135,83 @@ impl UsersService {
common_response(StatusCode::INTERNAL_SERVER_ERROR, &err.to_string()) common_response(StatusCode::INTERNAL_SERVER_ERROR, &err.to_string())
} }
} }
})
} }
pub async fn update_user( fn update_user(
state: &AppState, state: &AppState,
id: String, id: String,
user: UsersUpdateRequestDto, user: UsersUpdateRequestDto,
) -> Response { ) -> Pin<Box<dyn Future<Output = Response> + Send>> {
let state = state.to_owned();
let id = id.to_owned();
let user = user;
Box::pin(async move {
if Uuid::parse_str(&id).is_err() { if Uuid::parse_str(&id).is_err() {
return common_response(StatusCode::BAD_REQUEST, "Invalid User ID format"); return common_response(StatusCode::BAD_REQUEST, "Invalid User ID format");
} }
let repo = UsersRepository::new(state); let repo = UsersRepository::new(&state);
if let Err((status, message)) = validate_request(&user) { if let Err((status, message)) = validate_request(&user) {
return common_response(status, &message); return common_response(status, &message);
} }
let updated_user = UsersSchema::update(user, id);
match repo.query_update_user(updated_user).await { // Get current user data first
Ok(msg) => common_response(StatusCode::OK, &msg), let thing_id = make_thing(&ResourceEnum::Users.to_string(), &id);
Err(e) => common_response(StatusCode::BAD_REQUEST, &e.to_string()), let current_user = match repo.query_user_by_id(&thing_id).await {
}
}
pub async fn update_user_me(
state: &AppState,
headers: HeaderMap,
user: UsersUpdateRequestDto,
) -> Response {
let repo = UsersRepository::new(state);
let email = match extract_email(&headers) {
Some(email) => email,
None => return common_response(StatusCode::UNAUTHORIZED, "Unauthorized"),
};
let user_data = match repo.query_user_by_email(email.clone()).await {
Ok(user) => user, Ok(user) => user,
Err(_) => return common_response(StatusCode::NOT_FOUND, "User not found"), Err(_) => return common_response(StatusCode::NOT_FOUND, "User not found"),
}; };
if let Err((status, message)) = validate_request(&user) {
return common_response(status, &message); let updated_user = UsersSchema::partial_update(current_user, user);
}
let updated_user = UsersSchema::update(user, user_data.id.id.to_raw());
match repo.query_update_user(updated_user).await { match repo.query_update_user(updated_user).await {
Ok(msg) => common_response(StatusCode::OK, &msg), Ok(msg) => common_response(StatusCode::OK, &msg),
Err(e) => common_response(StatusCode::BAD_REQUEST, &e.to_string()), Err(e) => common_response(StatusCode::BAD_REQUEST, &e.to_string()),
} }
})
} }
pub async fn set_user_active_status( fn update_user_me(
claims: imphnen_libs::jsonwebtoken::Claims,
state: &AppState,
user_update_dto: UsersUpdateRequestDto,
) -> Pin<Box<dyn Future<Output = Response> + Send>> {
let claims = claims.to_owned();
let state = state.to_owned();
let user_update_dto = user_update_dto;
Box::pin(async move {
let repo = UsersRepository::new(&state);
let thing_id = make_thing(&ResourceEnum::Users.to_string(), &claims.user_id);
let user_data = match repo.query_user_by_id(&thing_id).await {
Ok(user) => user,
Err(_) => return common_response(StatusCode::NOT_FOUND, "User not found"),
};
if let Err((status, message)) = validate_request(&user_update_dto) {
return common_response(status, &message);
}
let updated_user = UsersSchema::partial_update(user_data, user_update_dto);
match repo.query_update_user(updated_user).await {
Ok(msg) => common_response(StatusCode::OK, &msg),
Err(e) => common_response(StatusCode::BAD_REQUEST, &e.to_string()),
}
})
}
fn set_user_active_status(
state: &AppState, state: &AppState,
id: String, id: String,
payload: UsersActiveInactiveRequestDto, payload: UsersActiveInactiveRequestDto,
) -> Response { ) -> Pin<Box<dyn Future<Output = Response> + Send>> {
let state = state.to_owned();
let id = id.to_owned();
let payload = payload;
Box::pin(async move {
if Uuid::parse_str(&id).is_err() { if Uuid::parse_str(&id).is_err() {
return common_response(StatusCode::BAD_REQUEST, "Invalid User ID format"); return common_response(StatusCode::BAD_REQUEST, "Invalid User ID format");
} }
let repo = UsersRepository::new(state); let repo = UsersRepository::new(&state);
let thing_id = make_thing(&ResourceEnum::Users.to_string(), &id); let thing_id = make_thing(&ResourceEnum::Users.to_string(), &id);
match repo.query_user_by_id(&thing_id).await { match repo.query_user_by_id(&thing_id).await {
Ok(user) if !user.is_deleted => { Ok(user) if !user.is_deleted => {
@@ -154,14 +228,19 @@ impl UsersService {
Ok(_) => common_response(StatusCode::NOT_FOUND, "User not found"), Ok(_) => common_response(StatusCode::NOT_FOUND, "User not found"),
Err(err) => common_response(StatusCode::BAD_REQUEST, &err.to_string()), Err(err) => common_response(StatusCode::BAD_REQUEST, &err.to_string()),
} }
})
} }
pub async fn update_user_password( fn update_user_password(
state: &AppState, state: &AppState,
email: String, email: String,
payload: UsersSetNewPasswordRequestDto, payload: UsersSetNewPasswordRequestDto,
) -> Response { ) -> Pin<Box<dyn Future<Output = Response> + Send>> {
let repo = UsersRepository::new(state); let state = state.to_owned();
let email = email.to_owned();
let payload = payload;
Box::pin(async move {
let repo = UsersRepository::new(&state);
let user = match repo.query_user_by_email(email.clone()).await { let user = match repo.query_user_by_email(email.clone()).await {
Ok(user) if !user.is_deleted => user, Ok(user) if !user.is_deleted => user,
_ => return common_response(StatusCode::NOT_FOUND, "User not found"), _ => return common_response(StatusCode::NOT_FOUND, "User not found"),
@@ -197,28 +276,36 @@ impl UsersService {
Ok(msg) => common_response(StatusCode::OK, &msg), Ok(msg) => common_response(StatusCode::OK, &msg),
Err(e) => common_response(StatusCode::BAD_REQUEST, &e.to_string()), Err(e) => common_response(StatusCode::BAD_REQUEST, &e.to_string()),
} }
})
} }
pub async fn get_user_by_mentor_id( fn get_user_by_mentor_id(
state: &AppState, state: &AppState,
mentor_id: String, mentor_id: String,
) -> Response { ) -> Pin<Box<dyn Future<Output = Response> + Send>> {
let repo = UsersRepository::new(state); let state = state.to_owned();
let mentor_id = mentor_id.to_owned();
Box::pin(async move {
let repo = UsersRepository::new(&state);
let thing_id = make_thing(&ResourceEnum::Mentors.to_string(), &mentor_id); let thing_id = make_thing(&ResourceEnum::Mentors.to_string(), &mentor_id);
match repo.query_user_by_id(&thing_id).await { match repo.query_user_by_id(&thing_id).await {
Ok(user) if !user.is_deleted => success_response(ResponseSuccessDto { Ok(user) if !user.is_deleted => success_response(ResponseSuccessDto {
data: UsersDetailItemDto::from(&user), data: UserDto::from(&user), // Corrected to use UserDto::from by reference
}), }),
Ok(_) => common_response(StatusCode::NOT_FOUND, "User not found"), Ok(_) => common_response(StatusCode::NOT_FOUND, "User not found"),
Err(e) => common_response(StatusCode::NOT_FOUND, &e.to_string()), Err(e) => common_response(StatusCode::NOT_FOUND, &e.to_string()),
} }
})
} }
pub async fn delete_user(state: &AppState, id: String) -> Response { fn delete_user(state: &AppState, id: String) -> Pin<Box<dyn Future<Output = Response> + Send>> {
let state = state.to_owned();
let id = id.to_owned();
Box::pin(async move {
if Uuid::parse_str(&id).is_err() { if Uuid::parse_str(&id).is_err() {
return common_response(StatusCode::BAD_REQUEST, "Invalid User ID format"); return common_response(StatusCode::BAD_REQUEST, "Invalid User ID format");
} }
let repo = UsersRepository::new(state); let repo = UsersRepository::new(&state);
let thing_id = make_thing(&ResourceEnum::Users.to_string(), &id); let thing_id = make_thing(&ResourceEnum::Users.to_string(), &id);
if repo.query_user_by_id(&thing_id).await.is_err() { if repo.query_user_by_id(&thing_id).await.is_err() {
return common_response(StatusCode::BAD_REQUEST, "User not found"); return common_response(StatusCode::BAD_REQUEST, "User not found");
@@ -227,5 +314,306 @@ impl UsersService {
Ok(msg) => common_response(StatusCode::OK, &msg), Ok(msg) => common_response(StatusCode::OK, &msg),
Err(e) => common_response(StatusCode::BAD_REQUEST, &e.to_string()), Err(e) => common_response(StatusCode::BAD_REQUEST, &e.to_string()),
} }
})
} }
}
fn get_user_by_email(&self, email: &str, state: &AppState) -> Pin<Box<dyn Future<Output = Result<Option<UserDto>>> + Send>> {
let email = email.to_owned();
let state = state.to_owned();
Box::pin(async move {
let repo = UsersRepository::new(&state);
let user = repo.query_user_by_email(email.to_string()).await;
match user {
Ok(u) => Ok(Some(UserDto::from(&u))),
Err(e) if e.to_string().contains("User not found") => Ok(None),
Err(e) => Err(anyhow::anyhow!(e.to_string())),
}
})
}
fn create_user_by_dto(&self, new_user: CreateUserDto, state: &AppState) -> Pin<Box<dyn Future<Output = Result<UserDto>> + Send>> {
let new_user = new_user;
let state = state.to_owned();
Box::pin(async move {
let repo = UsersRepository::new(&state);
let email_clone = new_user.email.clone();
let user_schema = UsersSchema {
email: new_user.email,
password: new_user.password,
fullname: new_user.fullname,
phone_number: new_user.phone_number,
is_active: new_user.is_active,
avatar: new_user.avatar,
role: make_thing(&ResourceEnum::Roles.to_string(), &new_user.role_id),
..Default::default()
};
match repo.query_create_user(user_schema).await {
Ok(_msg) => {
let created_user = repo.query_user_by_email(email_clone).await?;
Ok(UserDto::from(&created_user))
},
Err(e) => Err(anyhow::anyhow!(e.to_string())),
}
})
}
fn update_user_avatar(email: &str, avatar_url: Option<String>, state: &AppState) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
let email = email.to_owned();
let avatar_url = avatar_url.to_owned();
let state = state.to_owned();
Box::pin(async move {
let repo = UsersRepository::new(&state);
let mut user = repo.query_user_by_email(email.to_string()).await
.map_err(|e| anyhow::anyhow!("Failed to get user: {}", e))?;
user.avatar = avatar_url;
let user_schema = UsersSchema::from(user);
match repo.query_update_user(user_schema).await {
Ok(_) => {
info!("Successfully updated avatar for user: {}", email);
Ok(())
},
Err(e) => Err(anyhow::anyhow!("Failed to update user avatar: {}", e)),
}
})
}
fn upload_file(state: &AppState, user_id: String, mut multipart: Multipart) -> Pin<Box<dyn Future<Output = Response> + Send>> {
let state = state.to_owned();
let user_id = user_id.to_owned();
Box::pin(async move {
info!("Entering upload_file function for user_id: {}", user_id);
// Initialize MinIO configuration
let minio_config = match MinioConfig::from_env() {
Ok(config) => {
info!("MinIO config loaded successfully.");
config
},
Err(e) => {
error!("Failed to load MinIO config: {}", e);
return common_response(
StatusCode::INTERNAL_SERVER_ERROR,
"MinIO configuration error",
);
}
};
// Store bucket name before minio_config is moved
let bucket_name = minio_config.bucket_name.clone();
info!("MinIO bucket name: {}", bucket_name);
// Initialize MinIO service
let minio_service = match create_minio_service_from_config(minio_config).await {
Ok(service) => {
info!("MinIO service initialized successfully.");
service
},
Err(e) => {
error!("Failed to initialize MinIO service: {}", e);
return common_response(
StatusCode::INTERNAL_SERVER_ERROR,
"MinIO service initialization error",
);
}
};
// Get actual user data from database using user_id (which is a UUID)
let repo = UsersRepository::new(&state);
let thing_id = make_thing(&ResourceEnum::Users.to_string(), &user_id);
let user_data = match repo.query_user_by_id(&thing_id).await {
Ok(user) => {
info!("Found user in DB. User ID: {}, Email: {}", user.id.id.to_raw(), user.email);
user
}
Err(e) => {
error!("Failed to find user in DB for ID {}: {}", user_id, e);
return common_response(
StatusCode::NOT_FOUND,
"User not found",
);
}
};
let actual_user_id = user_data.id.id.to_raw();
let user_email = user_data.email;
let mut file_data: Option<Vec<u8>> = None;
let mut filename: Option<String> = None;
let mut content_type: Option<String> = None;
info!("Starting multipart form processing.");
while let Some(field) = multipart.next_field().await.unwrap_or(None) {
let name = field.name().unwrap_or("").to_string();
info!("Processing multipart field: {}", name);
match name.as_str() {
"file" => {
filename = field.file_name().map(|s| s.to_string());
content_type = field.content_type().map(|s| s.to_string());
info!("Detected file field. Filename: {:?}, Content-Type: {:?}", filename, content_type);
match field.bytes().await {
Ok(bytes) => {
file_data = Some(bytes.to_vec());
info!("Successfully read file data, size: {} bytes", file_data.as_ref().map_or(0, |d| d.len()));
},
Err(e) => {
error!("Failed to read file data from multipart: {}", e);
return common_response(
StatusCode::BAD_REQUEST,
"Failed to read file data",
);
}
}
}
"base64_data" => {
let base64_str = field.text().await.unwrap_or_default();
info!("Detected base64_data field, length: {}", base64_str.len());
if !base64_str.is_empty() {
match decode_base64_file(&base64_str) {
Ok(decoded_data) => {
file_data = Some(decoded_data);
info!("Successfully decoded base64 data, size: {} bytes", file_data.as_ref().map_or(0, |d| d.len()));
if let Some(detected_type) = extract_content_type_from_data_url(&base64_str) {
content_type = Some(detected_type);
info!("Detected content type from base64 data URL: {}", content_type.as_ref().unwrap());
}
}
Err(e) => {
error!("Failed to decode base64 data: {}", e);
return common_response(
StatusCode::BAD_REQUEST,
"Invalid base64 data",
);
}
}
}
}
"filename" => {
filename = Some(field.text().await.unwrap_or_default());
info!("Received filename from field: {:?}", filename);
}
"content_type" => {
content_type = Some(field.text().await.unwrap_or_default());
info!("Received content_type from field: {:?}", content_type);
}
_ => {
info!("Skipping unknown multipart field: {}", name);
}
}
}
info!("Finished multipart form processing.");
// Validate required fields
let file_data = match file_data {
Some(data) => data,
None => {
error!("File data is missing after multipart processing.");
return common_response(
StatusCode::BAD_REQUEST,
"file data is required",
);
}
};
info!("File data extracted, size: {} bytes.", file_data.len());
let filename = filename.unwrap_or_else(|| {
info!("Filename not provided, defaulting to 'unnamed_file'.");
"unnamed_file".to_string()
});
let content_type = content_type.unwrap_or_else(|| {
info!("Content type not provided, defaulting to 'application/octet-stream'.");
"application/octet-stream".to_string()
});
info!("Final filename: {}, Content-Type: {}", filename, content_type);
// Auto-detect file type based on content type and filename
let file_type = FileType::from_content_type(&content_type);
let file_type = if matches!(file_type, FileType::Unknown) {
info!("Content type detection failed, trying from filename.");
FileType::from_filename(&filename)
} else {
file_type
};
info!("Detected file type: {:?}", file_type);
// Validate file type is supported
if matches!(file_type, FileType::Unknown) {
error!("Unsupported file type detected: {:?}", file_type);
return common_response(
StatusCode::BAD_REQUEST,
"Unsupported file type. Supported types: JPEG, PNG, WEBP, GIF, PDF, DOC, DOCX",
);
}
// Validate file type matches content type
if !file_type.allowed_types().contains(&content_type.as_str()) {
error!("File type '{:?}' does not match content type '{}'.", file_type, content_type);
return common_response(
StatusCode::BAD_REQUEST,
&format!("File type '{:?}' does not match content type '{}'", file_type, content_type),
);
}
// Validate file size
if file_data.len() > file_type.max_size() {
error!("File too large. Current size: {} bytes, Max size for {:?}: {} bytes",
file_data.len(), file_type, file_type.max_size());
return common_response(
StatusCode::BAD_REQUEST,
&format!("File too large. Maximum size for {:?} is {} bytes",
file_type, file_type.max_size()),
);
}
info!("File size validated: {} bytes.", file_data.len());
// Create secure upload path with user ID (sanitized for filesystem)
let sanitized_user_id = user_email
.replace("%", "")
.replace(":", "_")
.replace("@", "_at_")
.replace(".", "_");
info!("Sanitized user ID for folder path: {}", sanitized_user_id);
let folder = format!("{}/{}", file_type.as_folder(), sanitized_user_id);
info!("Upload folder: {}", folder);
// Upload file to MinIO with deduplication
info!("Attempting to upload file to MinIO.");
match minio_service.upload_file_with_deduplication(&file_data, &content_type, &folder, &filename).await {
Ok(object_path) => {
info!("File uploaded successfully to MinIO. Object path: {}", object_path);
// Create permanent URL (no expiration)
let permanent_url = format!("https://cdn.asepharyana.tech/{}/{}",
bucket_name, object_path);
info!("Permanent URL: {}", permanent_url);
let response_data = json!({
"filename": filename,
"original_filename": filename,
"uploaded_path": object_path,
"url": permanent_url,
"size": file_data.len(),
"content_type": content_type,
"file_type": format!("{:?}", file_type).to_lowercase(),
"user_id": actual_user_id,
"email": user_email
});
success_response(ResponseSuccessDto {
data: response_data,
})
}
Err(e) => {
error!("Failed to upload file to MinIO: {}", e);
common_response(
StatusCode::INTERNAL_SERVER_ERROR,
&format!("Upload failed: {}", e),
)
}
}
})
}
}
+9
View File
@@ -5,6 +5,7 @@ edition = "2024"
[dependencies] [dependencies]
imphnen-entities.workspace = true imphnen-entities.workspace = true
once_cell = { workspace = true }
log.workspace = true log.workspace = true
axum.workspace = true axum.workspace = true
tokio.workspace = true tokio.workspace = true
@@ -15,3 +16,11 @@ chrono.workspace = true
surrealdb.workspace = true surrealdb.workspace = true
jsonwebtoken.workspace = true jsonwebtoken.workspace = true
dotenvy.workspace = true dotenvy.workspace = true
anyhow.workspace = true
uuid.workspace = true
base64.workspace = true
reqwest.workspace = true
sha2.workspace = true
hmac.workspace = true
hex.workspace = true
urlencoding.workspace = true
+5 -1
View File
@@ -8,7 +8,11 @@ use argon2::{
pub fn hash_password(password: &str) -> Result<String, Error> { pub fn hash_password(password: &str) -> Result<String, Error> {
let salt = SaltString::generate(&mut OsRng); let salt = SaltString::generate(&mut OsRng);
let argon2 = Argon2::default(); let argon2 = Argon2::new(
argon2::Algorithm::Argon2id,
argon2::Version::V0x13,
argon2::Params::new(1024, 1, 1, None).unwrap() // 1MB, 1 iteration, 1 thread (faster, less secure)
);
let password_hash = argon2 let password_hash = argon2
.hash_password(password.as_bytes(), &salt)? .hash_password(password.as_bytes(), &salt)?
.to_string(); .to_string();
+3 -3
View File
@@ -1,16 +1,16 @@
use crate::{Env, load_env, surrealdb_init_mem, surrealdb_init_ws}; use crate::{surrealdb_init_mem, surrealdb_init_ws};
use axum::{Router, serve}; use axum::{Router, serve};
use imphnen_entities::{SurrealMemClient, SurrealWsClient}; use imphnen_entities::{SurrealMemClient, SurrealWsClient};
use std::{future::Future, net::SocketAddr}; use std::{future::Future, net::SocketAddr};
use tokio::net::TcpListener; use tokio::net::TcpListener;
use crate::enviroment::ENV;
pub async fn axum_init<F, Fut>(router_fn: F) pub async fn axum_init<F, Fut>(router_fn: F)
where where
F: FnOnce(SurrealWsClient, SurrealMemClient) -> Fut, F: FnOnce(SurrealWsClient, SurrealMemClient) -> Fut,
Fut: Future<Output = Router>, Fut: Future<Output = Router>,
{ {
load_env(); let env = &ENV;
let env = Env::new();
let surrealdb_ws = surrealdb_init_ws().await.expect("Failed surrealdb ws"); let surrealdb_ws = surrealdb_init_ws().await.expect("Failed surrealdb ws");
+120 -92
View File
@@ -1,100 +1,128 @@
//! Environment configuration module using once_cell::sync::Lazy for one-time loading.
use std::env; use std::env;
use once_cell::sync::Lazy;
// Logging for warnings if .env is missing
use log::{warn, info};
pub fn load_env() { /// Struct holding all environment configuration.
if dotenvy::dotenv().is_ok() {} #[derive(Clone)]
}
pub struct Env { pub struct Env {
pub port: u16, pub port: u16,
pub access_token_secret: String, pub access_token_secret: String,
pub refresh_token_secret: String, pub refresh_token_secret: String,
pub surrealdb_url: String, pub surrealdb_url: String,
pub surrealdb_username: String, pub surrealdb_username: String,
pub surrealdb_password: String, pub surrealdb_password: String,
pub surrealdb_namespace: String, pub surrealdb_namespace: String,
pub surrealdb_dbname: String, pub surrealdb_dbname: String,
pub surrealdb_url_ws: String, pub surrealdb_url_ws: String,
pub smtp_email: String, pub smtp_email: String,
pub smtp_password: String, pub smtp_password: String,
pub smtp_name: String, pub smtp_name: String,
pub smtp_host: String, pub smtp_host: String,
pub redisdb_url: String, pub redisdb_url: String,
pub fe_url: String, pub fe_url: String,
pub rust_env: String, pub rust_env: String,
pub minio_endpoint: String, pub minio_endpoint: String,
pub minio_bucket_name: String, pub minio_bucket_name: String,
pub minio_access_key: String, pub minio_access_key: String,
pub minio_secret_key: String, pub minio_secret_key: String,
pub minio_region: String,
pub minio_secure: bool,
// Google OAuth 2.1
pub google_client_id: String,
pub google_client_secret: String,
pub google_redirect_url: String,
} }
impl Env { // Custom Debug implementation to mask secrets in logs
#[must_use] impl std::fmt::Debug for Env {
pub fn new() -> Self { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
Self { f.debug_struct("Env")
port: env::var("PORT") .field("port", &self.port)
.unwrap_or_else(|_| "3000".to_string()) .field("access_token_secret", &"***")
.parse() .field("refresh_token_secret", &"***")
.unwrap_or(3000), .field("surrealdb_url", &self.surrealdb_url)
.field("surrealdb_username", &self.surrealdb_username)
rust_env: env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string()), .field("surrealdb_password", &"***")
.field("surrealdb_namespace", &self.surrealdb_namespace)
access_token_secret: env::var("ACCESS_TOKEN_SECRET") .field("surrealdb_dbname", &self.surrealdb_dbname)
.unwrap_or_else(|_| "default_access_secret".to_string()), .field("surrealdb_url_ws", &self.surrealdb_url_ws)
.field("smtp_email", &self.smtp_email)
refresh_token_secret: env::var("REFRESH_TOKEN_SECRET") .field("smtp_password", &"***")
.unwrap_or_else(|_| "default_refresh_secret".to_string()), .field("smtp_name", &self.smtp_name)
.field("smtp_host", &self.smtp_host)
surrealdb_url: env::var("SURREALDB_URL") .field("redisdb_url", &self.redisdb_url)
.unwrap_or_else(|_| "http://localhost:8000".to_string()), .field("fe_url", &self.fe_url)
.field("rust_env", &self.rust_env)
surrealdb_username: env::var("SURREALDB_USERNAME") .field("minio_endpoint", &self.minio_endpoint)
.unwrap_or_else(|_| "root".to_string()), .field("minio_bucket_name", &self.minio_bucket_name)
.field("minio_access_key", &"***")
surrealdb_password: env::var("SURREALDB_PASSWORD") .field("minio_secret_key", &"***")
.unwrap_or_else(|_| "password".to_string()), .field("minio_region", &self.minio_region)
.field("minio_secure", &self.minio_secure)
surrealdb_namespace: env::var("SURREALDB_NAMESPACE") .field("google_client_id", &self.google_client_id)
.unwrap_or_else(|_| "namespace".to_string()), .field("google_client_secret", &"***")
.field("google_redirect_url", &self.google_redirect_url)
surrealdb_dbname: env::var("SURREALDB_DBNAME") .finish()
.unwrap_or_else(|_| "database".to_string()), }
surrealdb_url_ws: env::var("SURREALDB_URL_WS")
.unwrap_or_else(|_| "ws://localhost:8000/rpc".to_string()),
smtp_email: env::var("SMTP_EMAIL")
.unwrap_or_else(|_| "no-reply@example.com".to_string()),
smtp_password: env::var("SMTP_PASSWORD")
.unwrap_or_else(|_| "default_smtp_password".to_string()),
smtp_name: env::var("SMTP_NAME").unwrap_or_else(|_| "MyApp SMTP".to_string()),
smtp_host: env::var("SMTP_HOST")
.unwrap_or_else(|_| "smtp.gmail.com".to_string()),
redisdb_url: env::var("REDISDB_URL")
.unwrap_or_else(|_| "localhost".to_string()),
fe_url: env::var("FE_URL").unwrap_or_else(|_| "http://localhost".to_string()),
minio_endpoint: env::var("MINIO_ENDPOINT")
.unwrap_or_else(|_| "http://localhost:9000".to_string()),
minio_bucket_name: env::var("MINIO_BUCKET_NAME")
.unwrap_or_else(|_| "default_bucket".to_string()),
minio_access_key: env::var("MINIO_ACCESS_KEY")
.unwrap_or_else(|_| "minio_access".to_string()),
minio_secret_key: env::var("MINIO_SECRET_KEY")
.unwrap_or_else(|_| "minio_secret".to_string()),
}
}
} }
impl Default for Env { /// Helper to get env var with warning if not set.
fn default() -> Self { fn get_env_with_warning(key: &str, default: &str) -> String {
Self::new() match env::var(key) {
} Ok(val) => val,
Err(_) => {
warn!("Environment variable '{}' is not set. Using default: '{}'", key, default);
default.to_string()
}
}
} }
/// Loads environment variables from .env and system, only once.
pub static ENV: Lazy<Env> = Lazy::new(|| {
// Try to load .env file, log a warning if not found, proceed regardless.
match dotenvy::dotenv() {
Ok(_) => {}
Err(dotenvy::Error::Io(ref e)) if e.kind() == std::io::ErrorKind::NotFound => {
warn!(".env file not found, falling back to system environment variables");
}
Err(_) => {}
}
let env = Env {
port: get_env_with_warning("PORT", "3000")
.parse()
.unwrap_or(3000),
access_token_secret: get_env_with_warning("ACCESS_TOKEN_SECRET", "default_access_secret"),
refresh_token_secret: get_env_with_warning("REFRESH_TOKEN_SECRET", "default_refresh_secret"),
surrealdb_url: get_env_with_warning("SURREALDB_URL", "http://localhost:8000"),
surrealdb_username: get_env_with_warning("SURREALDB_USERNAME", "root"),
surrealdb_password: get_env_with_warning("SURREALDB_PASSWORD", "root"),
surrealdb_namespace: get_env_with_warning("SURREALDB_NAMESPACE", "namespace"),
surrealdb_dbname: get_env_with_warning("SURREALDB_DBNAME", "database"),
smtp_email: get_env_with_warning("SMTP_EMAIL", "no-reply@example.com"),
smtp_password: get_env_with_warning("SMTP_PASSWORD", "default_smtp_password"),
smtp_name: get_env_with_warning("SMTP_NAME", "MyApp SMTP"),
smtp_host: get_env_with_warning("SMTP_HOST", "smtp.gmail.com"),
redisdb_url: get_env_with_warning("REDISDB_URL", "localhost"),
fe_url: get_env_with_warning("FE_URL", "http://localhost"),
rust_env: get_env_with_warning("RUST_ENV", "development"),
minio_endpoint: get_env_with_warning("MINIO_ENDPOINT", "http://localhost:9000"),
minio_bucket_name: get_env_with_warning("MINIO_BUCKET_NAME", "imphnen-uploads"),
minio_access_key: get_env_with_warning("MINIO_ACCESS_KEY", "minio_access"),
minio_secret_key: get_env_with_warning("MINIO_SECRET_KEY", "minio_secret"),
minio_region: get_env_with_warning("MINIO_REGION", "us-east-1"),
minio_secure: get_env_with_warning("MINIO_SECURE", "false")
.parse()
.unwrap_or(false),
surrealdb_url_ws: String::new(),
// Google OAuth 2.1
google_client_id: get_env_with_warning("GOOGLE_CLIENT_ID", "default_google_client_id"),
google_client_secret: get_env_with_warning("GOOGLE_CLIENT_SECRET", "default_google_client_secret"),
google_redirect_url: get_env_with_warning("GOOGLE_REDIRECT_URL", "http://localhost:8000/api/v1/auth/google/callback"),
};
info!("Loaded environment configuration: {:?}", env);
env
});
+32 -22
View File
@@ -1,4 +1,4 @@
use super::Env; use crate::enviroment::ENV;
use axum::http::StatusCode; use axum::http::StatusCode;
use chrono::{Duration, TimeDelta, Utc}; use chrono::{Duration, TimeDelta, Utc};
use jsonwebtoken::{ use jsonwebtoken::{
@@ -11,32 +11,36 @@ pub struct Claims {
pub exp: usize, pub exp: usize,
pub iat: usize, pub iat: usize,
pub sub: String, pub sub: String,
pub user_id: String,
pub permissions: Vec<String>,
} }
pub fn encode_access_token(sub: String) -> Result<String, StatusCode> { static ACCESS_HEADER: once_cell::sync::Lazy<Header> = once_cell::sync::Lazy::new(Header::default);
let env = Env::new(); static ACCESS_KEY: once_cell::sync::Lazy<EncodingKey> = once_cell::sync::Lazy::new(|| {
let secret: String = env.access_token_secret; EncodingKey::from_secret(ENV.access_token_secret.as_ref())
});
pub fn encode_access_token(sub: String, user_id: String, permissions: Vec<String>) -> Result<String, StatusCode> {
let now = Utc::now(); let now = Utc::now();
let expire: TimeDelta = Duration::minutes(15); let expire: TimeDelta = Duration::minutes(15);
let exp: usize = (now + expire).timestamp() as usize; let exp: usize = (now + expire).timestamp() as usize;
let iat: usize = now.timestamp() as usize; let iat: usize = now.timestamp() as usize;
let claim = Claims { iat, exp, sub }; let claim = Claims { iat, exp, sub, user_id, permissions };
encode( encode(
&Header::default(), &ACCESS_HEADER,
&claim, &claim,
&EncodingKey::from_secret(secret.as_ref()), &ACCESS_KEY,
) )
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR) .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
} }
pub fn encode_reset_password_token(sub: String) -> Result<String, StatusCode> { pub fn encode_reset_password_token(sub: String, user_id: String, permissions: Vec<String>) -> Result<String, StatusCode> {
let env = Env::new(); let env = &ENV;
let secret: String = env.access_token_secret; let secret: String = env.access_token_secret.clone();
let now = Utc::now(); let now = Utc::now();
let expire: TimeDelta = Duration::minutes(5); let expire: TimeDelta = Duration::minutes(5);
let exp: usize = (now + expire).timestamp() as usize; let exp: usize = (now + expire).timestamp() as usize;
let iat: usize = now.timestamp() as usize; let iat: usize = now.timestamp() as usize;
let claim = Claims { iat, exp, sub }; let claim = Claims { iat, exp, sub, user_id, permissions };
encode( encode(
&Header::default(), &Header::default(),
&claim, &claim,
@@ -48,8 +52,8 @@ pub fn encode_reset_password_token(sub: String) -> Result<String, StatusCode> {
pub fn decode_access_token( pub fn decode_access_token(
jwt_token: &str, jwt_token: &str,
) -> Result<TokenData<Claims>, StatusCode> { ) -> Result<TokenData<Claims>, StatusCode> {
let env = Env::new(); let env = &ENV;
let secret: String = env.access_token_secret; let secret: String = env.access_token_secret.clone();
let result: Result<TokenData<Claims>, StatusCode> = decode( let result: Result<TokenData<Claims>, StatusCode> = decode(
jwt_token, jwt_token,
&DecodingKey::from_secret(secret.as_ref()), &DecodingKey::from_secret(secret.as_ref()),
@@ -59,18 +63,20 @@ pub fn decode_access_token(
result result
} }
pub fn encode_refresh_token(sub: String) -> Result<String, StatusCode> { static REFRESH_HEADER: once_cell::sync::Lazy<Header> = once_cell::sync::Lazy::new(Header::default);
let env = Env::new(); static REFRESH_KEY: once_cell::sync::Lazy<EncodingKey> = once_cell::sync::Lazy::new(|| {
let secret: String = env.refresh_token_secret; EncodingKey::from_secret(ENV.refresh_token_secret.as_ref())
});
pub fn encode_refresh_token(sub: String, user_id: String, permissions: Vec<String>) -> Result<String, StatusCode> {
let now = Utc::now(); let now = Utc::now();
let expire: TimeDelta = Duration::days(1); let expire: TimeDelta = Duration::days(1);
let exp: usize = (now + expire).timestamp() as usize; let exp: usize = (now + expire).timestamp() as usize;
let iat: usize = now.timestamp() as usize; let iat: usize = now.timestamp() as usize;
let claim = Claims { iat, exp, sub }; let claim = Claims { iat, exp, sub, user_id, permissions };
encode( encode(
&Header::default(), &REFRESH_HEADER,
&claim, &claim,
&EncodingKey::from_secret(secret.as_ref()), &REFRESH_KEY,
) )
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR) .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
} }
@@ -78,13 +84,17 @@ pub fn encode_refresh_token(sub: String) -> Result<String, StatusCode> {
pub fn decode_refresh_token( pub fn decode_refresh_token(
jwt_token: &str, jwt_token: &str,
) -> Result<TokenData<Claims>, StatusCode> { ) -> Result<TokenData<Claims>, StatusCode> {
let env = Env::new(); let env = &ENV;
let secret: String = env.refresh_token_secret; let secret: String = env.refresh_token_secret.clone();
let result: Result<TokenData<Claims>, StatusCode> = decode( let result: Result<TokenData<Claims>, StatusCode> = decode(
jwt_token, jwt_token,
&DecodingKey::from_secret(secret.as_ref()), &DecodingKey::from_secret(secret.as_ref()),
&Validation::default(), &Validation::default(),
) )
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR); .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR);
result result // Explicitly return result
}
pub fn generate_jwt(user_id: &str, permissions: Vec<String>) -> Result<String, StatusCode> {
encode_access_token(user_id.to_string(), user_id.to_string(), permissions)
} }
+6 -6
View File
@@ -1,4 +1,4 @@
use super::Env; use crate::enviroment::ENV;
use lettre::message::Mailbox; use lettre::message::Mailbox;
use lettre::transport::smtp::authentication::Credentials; use lettre::transport::smtp::authentication::Credentials;
use lettre::{Message, SmtpTransport, Transport}; use lettre::{Message, SmtpTransport, Transport};
@@ -9,11 +9,11 @@ pub fn send_email(
subject: &str, subject: &str,
body: &str, body: &str,
) -> Result<(), Box<dyn Error>> { ) -> Result<(), Box<dyn Error>> {
let env = Env::new(); let env = &ENV;
let host = env.smtp_host; let host = env.smtp_host.clone();
let sender_email = env.smtp_email; let sender_email = env.smtp_email.clone();
let sender_name = env.smtp_name; let sender_name = env.smtp_name.clone();
let sender_password = env.smtp_password; let sender_password = env.smtp_password.clone();
let recipient_email = to; let recipient_email = to;
let email = Message::builder() let email = Message::builder()
.from(Mailbox::new( .from(Mailbox::new(
+2
View File
@@ -5,6 +5,7 @@ pub mod axum;
pub mod enviroment; pub mod enviroment;
pub mod jsonwebtoken; pub mod jsonwebtoken;
pub mod lettre; pub mod lettre;
pub mod minio;
pub mod surrealdb; pub mod surrealdb;
pub use argon::*; pub use argon::*;
@@ -13,4 +14,5 @@ pub use enviroment::*;
pub use imphnen_entities::*; pub use imphnen_entities::*;
pub use jsonwebtoken::*; pub use jsonwebtoken::*;
pub use lettre::*; pub use lettre::*;
pub use minio::*;
pub use surrealdb::*; pub use surrealdb::*;
+730
View File
@@ -0,0 +1,730 @@
use anyhow::{anyhow, bail, Result};
use base64::{engine::general_purpose, Engine as _};
use chrono::Utc;
use hmac::{Hmac, Mac};
use sha2::{Digest, Sha256};
use uuid::Uuid;
use crate::enviroment::ENV;
// --- Struct Konfigurasi MinIO ---
#[derive(Debug, Clone)]
pub struct MinioConfig {
pub endpoint: String,
pub access_key: String,
pub secret_key: String,
pub bucket_name: String,
pub region: String,
pub secure: bool,
}
impl MinioConfig {
/// Memuat konfigurasi MinIO dari variabel lingkungan.
pub fn from_env() -> Result<Self> {
Ok(Self {
endpoint: ENV.minio_endpoint.clone(),
access_key: ENV.minio_access_key.clone(),
secret_key: ENV.minio_secret_key.clone(),
bucket_name: ENV.minio_bucket_name.clone(),
region: ENV.minio_region.clone(),
secure: ENV.minio_secure,
})
}
/// Mendapatkan URL endpoint lengkap (http atau https).
pub fn endpoint_url(&self) -> String {
// If endpoint already has protocol, use it as-is
if self.endpoint.starts_with("http://") || self.endpoint.starts_with("https://") {
self.endpoint.clone()
} else {
// Only add protocol if not present
let protocol = if self.secure { "https" } else { "http" };
format!("{}://{}", protocol, self.endpoint)
}
}
}
// --- Layanan MinIO ---
pub struct MinioService {
endpoint: String,
access_key: String,
secret_key: String,
bucket_name: String,
region: String,
client: reqwest::Client,
}
impl MinioService {
/// Membuat instance layanan MinIO baru.
pub async fn new(
endpoint: &str,
access_key: &str,
secret_key: &str,
bucket_name: &str,
region: &str,
) -> Result<Self> {
let service = Self {
endpoint: endpoint.to_string(),
access_key: access_key.to_string(),
secret_key: secret_key.to_string(),
bucket_name: bucket_name.to_string(),
region: region.to_string(),
client: reqwest::Client::new(),
};
Ok(service)
}
/// Mengunggah file biner ke MinIO dengan deduplication berdasarkan hash.
pub async fn upload_file_with_deduplication(
&self,
file_data: &[u8],
content_type: &str,
folder: &str,
original_filename: &str,
) -> Result<String> {
Self::validate_file_type(content_type, file_data)?;
// Calculate file hash
let mut hasher = Sha256::new();
hasher.update(file_data);
let file_hash = format!("{:x}", hasher.finalize());
let short_hash = &file_hash[..16]; // Use first 16 characters for filename
// Check if file with same hash already exists
if let Some(existing_file) = self.check_file_exists_by_hash(folder, short_hash).await? {
log::info!("File with same content already exists: {}", existing_file);
return Ok(existing_file);
}
let file_extension = Self::get_file_extension(original_filename);
let unique_filename = format!("{}/{}-{}.{}", folder, short_hash, Uuid::new_v4(), file_extension);
let object_name = &unique_filename;
// Extract host from endpoint (remove protocol)
let host = self.endpoint
.trim_start_matches("https://")
.trim_start_matches("http://");
let url = format!("https://{}/{}/{}", host, self.bucket_name, object_name);
// Debug logging
log::debug!("MinIO Endpoint config: {}", self.endpoint);
log::debug!("MinIO Region config: {}", self.region);
log::debug!("Upload URL: {}", url);
log::debug!("Object name: {}", object_name);
log::debug!("File hash: {}", short_hash);
let now = Utc::now();
let amz_date = now.format("%Y%m%dT%H%M%SZ").to_string();
let date_stamp = now.format("%Y%m%d").to_string();
// Use UNSIGNED-PAYLOAD for simpler signature
let payload_hash = "UNSIGNED-PAYLOAD".to_string();
let canonical_headers = format!(
"host:{}\nx-amz-content-sha256:{}\nx-amz-date:{}\n",
host, payload_hash, amz_date
);
let signed_headers = "host;x-amz-content-sha256;x-amz-date";
// For path-style, canonical URI should be /bucket/object
let canonical_uri = format!("/{}/{}", self.bucket_name, object_name);
let canonical_request = format!(
"PUT\n{}\n\n{}\n{}\n{}",
canonical_uri, canonical_headers, signed_headers, payload_hash
);
log::debug!("Canonical request:\n{}", canonical_request);
let scope = format!("{}/{}/s3/aws4_request", date_stamp, self.region);
let string_to_sign = format!(
"AWS4-HMAC-SHA256\n{}\n{}\n{}",
amz_date,
scope,
hex::encode(Sha256::digest(canonical_request.as_bytes()))
);
log::debug!("Scope: {}", scope);
log::debug!("String to sign:\n{}", string_to_sign);
let signing_key = self.get_signature_key(&date_stamp)?;
let mut mac = Hmac::<Sha256>::new_from_slice(&signing_key)?;
mac.update(string_to_sign.as_bytes());
let signature = hex::encode(mac.finalize().into_bytes());
log::debug!("Generated signature: {}", signature);
let auth_header = format!(
"AWS4-HMAC-SHA256 Credential={}/{}, SignedHeaders={}, Signature={}",
self.access_key, scope, signed_headers, signature
);
// 5) Send request: Content-Type included but NOT signed
let response = self
.client
.put(&url)
.header("x-amz-date", &amz_date)
.header("x-amz-content-sha256", &payload_hash)
.header("Authorization", &auth_header)
.header("Content-Type", content_type)
// Don't set Host header manually - let reqwest handle it
// Add headers for reverse proxy support (not signed)
.header("X-Forwarded-Proto", "https")
.header("X-Forwarded-Host", host)
.body(file_data.to_vec())
.send()
.await?;
if !response.status().is_success() {
let status = response.status();
let error_body = response.text().await?;
bail!(
"Gagal mengunggah file ke MinIO. Status: {}. Pesan: {}",
status,
error_body
);
}
log::info!("Unggahan berhasil: {} byte ke {}", file_data.len(), unique_filename);
Ok(unique_filename)
}
/// Mengunggah file biner ke MinIO.
pub async fn upload_file(
&self,
file_data: &[u8],
content_type: &str,
folder: &str,
original_filename: &str,
) -> Result<String> {
Self::validate_file_type(content_type, file_data)?;
let file_extension = Self::get_file_extension(original_filename);
let unique_filename = format!("{}/{}.{}", folder, Uuid::new_v4(), file_extension);
let object_name = &unique_filename;
// Extract host from endpoint (remove protocol)
let host = self.endpoint
.trim_start_matches("https://")
.trim_start_matches("http://");
let url = format!("https://{}/{}/{}", host, self.bucket_name, object_name);
// Debug logging
log::debug!("MinIO Endpoint config: {}", self.endpoint);
log::debug!("MinIO Region config: {}", self.region);
log::debug!("MinIO Access Key: {}", self.access_key);
log::debug!("MinIO Bucket: {}", self.bucket_name);
log::debug!("Extracted host: {}", host);
log::debug!("Final URL: {}", url);
let now = Utc::now();
let amz_date = now.format("%Y%m%dT%H%M%SZ").to_string();
let date_stamp = now.format("%Y%m%d").to_string();
// 1) Use UNSIGNED-PAYLOAD for HTTPS uploads (safer for proxies)
let payload_hash = "UNSIGNED-PAYLOAD".to_string();
// 2) Path-style canonical URI: /{bucket}/{object}
let canonical_uri = format!("/{}/{}", self.bucket_name, object_name);
// 3) ONLY sign essential headers (no content-type to avoid proxy issues)
let canonical_headers = format!(
"host:{}\nx-amz-content-sha256:{}\nx-amz-date:{}\n",
host, payload_hash, amz_date
);
let signed_headers = "host;x-amz-content-sha256;x-amz-date";
// 4) Canonical request
let canonical_request = format!(
"PUT\n{}\n\n{}\n{}\n{}",
canonical_uri, canonical_headers, signed_headers, payload_hash
);
// Debug logging
log::debug!("URL: {}", url);
log::debug!("Host: {}", host);
log::debug!("Bucket: {}", self.bucket_name);
log::debug!("Object: {}", object_name);
log::debug!("Canonical URI: {}", canonical_uri);
log::debug!("Payload hash: {}", payload_hash);
log::debug!("Canonical Request:\n{}", canonical_request);
let scope = format!("{}/{}/s3/aws4_request", date_stamp, self.region);
let string_to_sign = format!(
"AWS4-HMAC-SHA256\n{}\n{}\n{}",
amz_date,
scope,
hex::encode(Sha256::digest(canonical_request.as_bytes()))
);
log::debug!("Scope: {}", scope);
log::debug!("String to sign:\n{}", string_to_sign);
let signing_key = self.get_signature_key(&date_stamp)?;
let mut mac = Hmac::<Sha256>::new_from_slice(&signing_key)?;
mac.update(string_to_sign.as_bytes());
let signature = hex::encode(mac.finalize().into_bytes());
log::debug!("Generated signature: {}", signature);
let auth_header = format!(
"AWS4-HMAC-SHA256 Credential={}/{}, SignedHeaders={}, Signature={}",
self.access_key, scope, signed_headers, signature
);
// 5) Send request: Content-Type included but NOT signed
let response = self
.client
.put(&url)
.header("x-amz-date", &amz_date)
.header("x-amz-content-sha256", &payload_hash)
.header("Authorization", &auth_header)
.header("Content-Type", content_type)
// Don't set Host header manually - let reqwest handle it
// Add headers for reverse proxy support (not signed)
.header("X-Forwarded-Proto", "https")
.header("X-Forwarded-Host", host)
.body(file_data.to_vec())
.send()
.await?;
if !response.status().is_success() {
let status = response.status();
let error_body = response.text().await?;
bail!(
"Gagal mengunggah file ke MinIO. Status: {}. Pesan: {}",
status,
error_body
);
}
log::info!("Unggahan berhasil: {} byte ke {}", file_data.len(), unique_filename);
Ok(unique_filename)
}
/// Mengunggah file yang dikodekan base64 ke MinIO.
pub async fn upload_base64_file(
&self,
base64_data: &str,
content_type: &str,
folder: &str,
original_filename: &str,
) -> Result<String> {
let file_data = decode_base64_file(base64_data)?;
self.upload_file(&file_data, content_type, folder, original_filename)
.await
}
/// Menghasilkan URL yang telah ditandatangani sebelumnya untuk mengunduh objek.
pub async fn get_presigned_url(&self, object_name: &str, expiry_seconds: u32) -> Result<String> {
// Extract host from endpoint (remove protocol)
let host = self.endpoint
.trim_start_matches("https://")
.trim_start_matches("http://");
let now = Utc::now();
let amz_date = now.format("%Y%m%dT%H%M%SZ").to_string();
let date_stamp = now.format("%Y%m%d").to_string();
let scope = format!("{}/{}/s3/aws4_request", date_stamp, self.region);
let credential = format!("{}/{}", self.access_key, scope);
let expires_str = expiry_seconds.to_string();
let mut query_params = std::collections::BTreeMap::new();
query_params.insert("X-Amz-Algorithm", "AWS4-HMAC-SHA256");
query_params.insert("X-Amz-Credential", &credential);
query_params.insert("X-Amz-Date", &amz_date);
query_params.insert("X-Amz-Expires", &expires_str);
query_params.insert("X-Amz-SignedHeaders", "host");
let canonical_query_string = query_params
.iter()
.map(|(k, v)| format!("{}={}", urlencoding::encode(k), urlencoding::encode(v)))
.collect::<Vec<_>>()
.join("&");
let canonical_request = format!(
"GET\n/{}/{}\n{}\nhost:{}\n\nhost\nUNSIGNED-PAYLOAD",
self.bucket_name, object_name, canonical_query_string, host
);
let string_to_sign = format!(
"AWS4-HMAC-SHA256\n{}\n{}\n{}",
amz_date,
scope,
hex::encode(Sha256::digest(canonical_request.as_bytes()))
);
let signing_key = self.get_signature_key(&date_stamp)?;
let mut mac = Hmac::<Sha256>::new_from_slice(&signing_key)?;
mac.update(string_to_sign.as_bytes());
let signature = hex::encode(mac.finalize().into_bytes());
let url = format!(
"https://{}/{}/{}?{}&X-Amz-Signature={}",
host, self.bucket_name, object_name, canonical_query_string, signature
);
Ok(url)
}
/// Mengecek apakah file dengan hash tertentu sudah ada di bucket
pub async fn check_file_exists_by_hash(&self, folder: &str, file_hash: &str) -> Result<Option<String>> {
// Extract host from endpoint (remove protocol)
let host = self.endpoint
.trim_start_matches("https://")
.trim_start_matches("http://");
let url = format!("https://{}/{}?list-type=2&prefix={}", host, self.bucket_name, folder);
let now = Utc::now();
let amz_date = now.format("%Y%m%dT%H%M%SZ").to_string();
let date_stamp = now.format("%Y%m%d").to_string();
let payload_hash = hex::encode(Sha256::digest(b""));
let canonical_query_string = format!("list-type=2&prefix={}", urlencoding::encode(folder));
let canonical_headers = format!("host:{}\nx-amz-content-sha256:{}\nx-amz-date:{}\n", host, payload_hash, amz_date);
let signed_headers = "host;x-amz-content-sha256;x-amz-date";
let canonical_request = format!(
"GET\n/{}\n{}\n{}\n{}\n{}",
self.bucket_name, canonical_query_string, canonical_headers, signed_headers, payload_hash
);
let scope = format!("{}/{}/s3/aws4_request", date_stamp, self.region);
let string_to_sign = format!(
"AWS4-HMAC-SHA256\n{}\n{}\n{}",
amz_date,
scope,
hex::encode(Sha256::digest(canonical_request.as_bytes()))
);
let signing_key = self.get_signature_key(&date_stamp)?;
let mut mac = Hmac::<Sha256>::new_from_slice(&signing_key)?;
mac.update(string_to_sign.as_bytes());
let signature = hex::encode(mac.finalize().into_bytes());
let auth_header = format!(
"AWS4-HMAC-SHA256 Credential={}/{}, SignedHeaders={}, Signature={}",
self.access_key, scope, signed_headers, signature
);
let response = self
.client
.get(&url)
.header("x-amz-date", &amz_date)
.header("x-amz-content-sha256", &payload_hash)
.header("Authorization", &auth_header)
.send()
.await?;
if !response.status().is_success() {
return Ok(None);
}
let body = response.text().await?;
// Simple XML parsing to find files with matching hash
// Look for any file that contains the hash in its name
if body.contains(file_hash) {
// Extract the full file path from XML response
// This is a simplified approach - in production you might want proper XML parsing
for line in body.lines() {
if line.contains("<Key>") && line.contains(file_hash) {
if let Some(start) = line.find("<Key>") {
if let Some(end) = line.find("</Key>") {
let file_path = &line[start + 5..end];
return Ok(Some(file_path.to_string()));
}
}
}
}
}
Ok(None)
}
/// Menghapus file dari MinIO.
pub async fn delete_file(&self, object_name: &str) -> Result<()> {
// Extract host from endpoint (remove protocol)
let host = self.endpoint
.trim_start_matches("https://")
.trim_start_matches("http://");
let url = format!("https://{}/{}/{}", host, self.bucket_name, object_name);
let now = Utc::now();
let amz_date = now.format("%Y%m%dT%H%M%SZ").to_string();
let date_stamp = now.format("%Y%m%d").to_string();
let payload_hash = hex::encode(Sha256::digest(b""));
let canonical_headers = format!("host:{}\nx-amz-content-sha256:{}\nx-amz-date:{}\n", host, payload_hash, amz_date);
let signed_headers = "host;x-amz-content-sha256;x-amz-date";
let canonical_request = format!(
"DELETE\n/{}/{}\n\n{}\n{}\n{}",
self.bucket_name, object_name, canonical_headers, signed_headers, payload_hash
);
let scope = format!("{}/{}/s3/aws4_request", date_stamp, self.region);
let string_to_sign = format!(
"AWS4-HMAC-SHA256\n{}\n{}\n{}",
amz_date,
scope,
hex::encode(Sha256::digest(canonical_request.as_bytes()))
);
// Debug logging for signature calculation
log::debug!("Region: {}", self.region);
log::debug!("Scope: {}", scope);
log::debug!("String to sign:\n{}", string_to_sign);
let signing_key = self.get_signature_key(&date_stamp)?;
let mut mac = Hmac::<Sha256>::new_from_slice(&signing_key)?;
mac.update(string_to_sign.as_bytes());
let signature = hex::encode(mac.finalize().into_bytes());
log::debug!("Final signature: {}", signature);
let auth_header = format!(
"AWS4-HMAC-SHA256 Credential={}/{}, SignedHeaders={}, Signature={}",
self.access_key, scope, signed_headers, signature
);
let response = self
.client
.delete(&url)
.header("Host", host)
.header("x-amz-date", &amz_date)
.header("x-amz-content-sha256", &payload_hash)
.header("Authorization", &auth_header)
.send()
.await?;
if !response.status().is_success() {
let status = response.status();
let error_body = response.text().await?;
bail!(
"Gagal menghapus file dari MinIO. Status: {}. Pesan: {}",
status,
error_body
);
}
log::info!("File berhasil dihapus: {}", object_name);
Ok(())
}
/// Fungsi pembantu untuk menghasilkan kunci tanda tangan AWS v4.
fn get_signature_key(&self, date_stamp: &str) -> Result<Vec<u8>> {
let secret = format!("AWS4{}", self.secret_key);
let mut mac1 = Hmac::<Sha256>::new_from_slice(secret.as_bytes())?;
mac1.update(date_stamp.as_bytes());
let date_key = mac1.finalize().into_bytes();
let mut mac2 = Hmac::<Sha256>::new_from_slice(&date_key)?;
mac2.update(self.region.as_bytes());
let date_region_key = mac2.finalize().into_bytes();
let mut mac3 = Hmac::<Sha256>::new_from_slice(&date_region_key)?;
mac3.update(b"s3");
let date_region_service_key = mac3.finalize().into_bytes();
let mut mac4 = Hmac::<Sha256>::new_from_slice(&date_region_service_key)?;
mac4.update(b"aws4_request");
Ok(mac4.finalize().into_bytes().to_vec())
}
/// Memvalidasi jenis file dan ukuran.
fn validate_file_type(content_type: &str, file_data: &[u8]) -> Result<()> {
const MAX_SIZE: usize = 10 * 1024 * 1024; // 10MB
if file_data.len() > MAX_SIZE {
bail!("Ukuran file melebihi batas 10MB");
}
match content_type {
"image/jpeg" | "image/jpg" => {
if !file_data.starts_with(&[0xFF, 0xD8, 0xFF]) {
bail!("File JPEG tidak valid");
}
}
"image/png" => {
if !file_data.starts_with(&[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]) {
bail!("File PNG tidak valid");
}
}
"application/pdf" => {
if !file_data.starts_with(b"%PDF") {
bail!("File PDF tidak valid");
}
}
"image/webp" => {
if !file_data.starts_with(b"RIFF")
|| !file_data.get(8..12).map_or(false, |s| s == b"WEBP")
{
bail!("File WEBP tidak valid");
}
}
"application/msword" | "application/vnd.openxmlformats-officedocument.wordprocessingml.document" => {
if file_data.len() < 512 {
bail!("File dokumen tidak valid");
}
}
_ => {
bail!("Jenis file tidak didukung: {}", content_type);
}
}
Ok(())
}
/// Mendapatkan ekstensi file dari nama file.
fn get_file_extension(filename: &str) -> String {
std::path::Path::new(filename)
.extension()
.and_then(|ext| ext.to_str())
.unwrap_or("bin")
.to_lowercase()
}
}
// --- Struct dan Enum Pembantu ---
#[derive(Debug, Clone)]
pub struct UploadResult {
pub object_name: String,
pub url: String,
pub size: usize,
pub content_type: String,
}
#[derive(Debug, Clone)]
pub enum FileType {
Jpeg,
Png,
Webp,
Gif,
Pdf,
Doc,
Docx,
Unknown,
}
impl FileType {
pub fn as_folder(&self) -> &str {
match self {
FileType::Jpeg | FileType::Png | FileType::Webp | FileType::Gif => "profiles",
FileType::Pdf | FileType::Doc | FileType::Docx => "documents",
FileType::Unknown => "misc",
}
}
pub fn max_size(&self) -> usize {
match self {
FileType::Jpeg | FileType::Png | FileType::Webp | FileType::Gif => 5 * 1024 * 1024, // 5MB for images
FileType::Pdf | FileType::Doc | FileType::Docx => 10 * 1024 * 1024, // 10MB for documents
FileType::Unknown => 5 * 1024 * 1024, // 5MB default
}
}
pub fn allowed_types(&self) -> Vec<&str> {
match self {
FileType::Jpeg => vec!["image/jpeg", "image/jpg"],
FileType::Png => vec!["image/png"],
FileType::Webp => vec!["image/webp"],
FileType::Gif => vec!["image/gif"],
FileType::Pdf => vec!["application/pdf"],
FileType::Doc => vec!["application/msword"],
FileType::Docx => vec!["application/vnd.openxmlformats-officedocument.wordprocessingml.document"],
FileType::Unknown => vec![], // No allowed types for unknown
}
}
pub fn from_content_type(content_type: &str) -> Self {
match content_type {
"image/jpeg" | "image/jpg" => FileType::Jpeg,
"image/png" => FileType::Png,
"image/webp" => FileType::Webp,
"image/gif" => FileType::Gif,
"application/pdf" => FileType::Pdf,
"application/msword" => FileType::Doc,
"application/vnd.openxmlformats-officedocument.wordprocessingml.document" => FileType::Docx,
_ => FileType::Unknown,
}
}
pub fn from_filename(filename: &str) -> Self {
let filename_lower = filename.to_lowercase();
if filename_lower.ends_with(".jpg") || filename_lower.ends_with(".jpeg") {
FileType::Jpeg
} else if filename_lower.ends_with(".png") {
FileType::Png
} else if filename_lower.ends_with(".webp") {
FileType::Webp
} else if filename_lower.ends_with(".gif") {
FileType::Gif
} else if filename_lower.ends_with(".pdf") {
FileType::Pdf
} else if filename_lower.ends_with(".doc") {
FileType::Doc
} else if filename_lower.ends_with(".docx") {
FileType::Docx
} else {
FileType::Unknown
}
}
}
#[derive(Debug, Clone)]
pub struct UploadRequest {
pub user_id: String,
pub file_type: FileType,
pub filename: String,
pub content_type: String,
pub data: Vec<u8>,
}
#[derive(Debug, Clone)]
pub struct FileMetadata {
pub filename: String,
pub content_type: String,
pub size: usize,
pub path: String,
pub url: String,
}
// --- Fungsi Pembantu ---
/// Membuat instance MinioService dari struct MinioConfig.
pub async fn create_minio_service_from_config(config: MinioConfig) -> Result<MinioService> {
MinioService::new(
&config.endpoint, // Use raw endpoint, not endpoint_url()
&config.access_key,
&config.secret_key,
&config.bucket_name,
&config.region,
)
.await
}
/// Mendekode data file base64.
pub fn decode_base64_file(base64_data: &str) -> Result<Vec<u8>> {
let clean_data = if base64_data.contains(',') {
base64_data.split(',').nth(1).unwrap_or(base64_data)
} else {
base64_data
};
general_purpose::STANDARD
.decode(clean_data)
.map_err(|e| anyhow!("Gagal mendekode data base64: {}", e))
}
/// Mengekstrak tipe konten dari URL data.
pub fn extract_content_type_from_data_url(data_url: &str) -> Option<String> {
if data_url.starts_with("data:") {
if let Some(type_part) = data_url.split(';').next() {
return Some(type_part.replace("data:", ""));
}
}
None
}
+3 -5
View File
@@ -1,6 +1,5 @@
use super::Env; use crate::enviroment::ENV;
use crate::SurrealMemClient; use crate::SurrealMemClient;
use crate::enviroment::load_env;
use surrealdb::engine::any; use surrealdb::engine::any;
use surrealdb::engine::local::Mem; use surrealdb::engine::local::Mem;
use surrealdb::opt::auth::Root; use surrealdb::opt::auth::Root;
@@ -10,8 +9,7 @@ pub mod resource;
pub use resource::*; pub use resource::*;
pub async fn surrealdb_init_ws() -> Result<Surreal<any::Any>> { pub async fn surrealdb_init_ws() -> Result<Surreal<any::Any>> {
load_env(); let env = &ENV;
let env = Env::new();
let db = any::connect(&env.surrealdb_url).await?; let db = any::connect(&env.surrealdb_url).await?;
db.signin(Root { db.signin(Root {
@@ -26,7 +24,7 @@ pub async fn surrealdb_init_ws() -> Result<Surreal<any::Any>> {
} }
pub async fn surrealdb_init_mem() -> Result<SurrealMemClient> { pub async fn surrealdb_init_mem() -> Result<SurrealMemClient> {
let env = Env::new(); let env = &ENV;
let db = Surreal::new::<Mem>(()).await?; let db = Surreal::new::<Mem>(()).await?;
db.use_ns(&env.surrealdb_namespace) db.use_ns(&env.surrealdb_namespace)
.use_db(&env.surrealdb_dbname) .use_db(&env.surrealdb_dbname)
+1
View File
@@ -9,6 +9,7 @@ imphnen-libs.workspace = true
imphnen-utils.workspace = true imphnen-utils.workspace = true
imphnen-entities.workspace = true imphnen-entities.workspace = true
axum.workspace = true axum.workspace = true
axum-extra.workspace = true
serde.workspace = true serde.workspace = true
serde_json.workspace = true serde_json.workspace = true
utoipa.workspace = true utoipa.workspace = true
+57 -28
View File
@@ -2,43 +2,72 @@ use axum::{
Extension, extract::Request, http::StatusCode, middleware::Next, Extension, extract::Request, http::StatusCode, middleware::Next,
response::Response, response::Response,
}; };
use imphnen_iam::{UsersDetailQueryDto, UsersRepository}; use imphnen_libs::{AppState, jsonwebtoken::decode_access_token};
use imphnen_libs::AppState; use imphnen_iam::v1::users::users_dto::UsersDetailQueryDto;
use imphnen_utils::{common_response, extract_email}; use imphnen_utils::common_response;
use axum_extra::headers::{authorization::Bearer, Authorization, HeaderMapExt};
use std::convert::Infallible; use std::convert::Infallible;
use imphnen_iam::v1::users::{users_service::{UsersService, UsersServiceTrait}};
use imphnen_libs::ResourceEnum;
use imphnen_utils::make_thing;
pub async fn auth_middleware( pub async fn auth_middleware(
Extension(state): Extension<AppState>, Extension(state): Extension<AppState>,
mut req: Request, mut req: Request,
next: Next, next: Next,
) -> Result<Response, Infallible> { ) -> Result<Response, Infallible> {
let headers = req.headers(); let auth_header = match req
let email = match extract_email(headers) { .headers()
Some(email) => email, .typed_get::<Authorization<Bearer>>() {
Some(header) => header,
None => return Ok(common_response(
StatusCode::UNAUTHORIZED,
"Invalid or missing authorization token",
)),
};
let token = auth_header.token();
let claims = match decode_access_token(token) {
Ok(token_data) => token_data.claims,
Err(_) => return Ok(common_response(
StatusCode::UNAUTHORIZED,
"Invalid or expired token",
)),
};
let user_id = claims.user_id.clone();
let thing_id = make_thing(&ResourceEnum::Users.to_string(), &user_id);
// Try SurrealDB mem first
let mem_db = &state.surrealdb_mem;
let mut user_data: Option<UsersDetailQueryDto> = None;
if let Ok(opt_user) = mem_db.select(("users", &user_id)).await {
if let Some(user) = opt_user {
let user: imphnen_iam::v1::users::users_dto::UsersDetailQueryDto = user;
if !user.is_deleted && !user.role.is_deleted {
user_data = Some(user);
}
}
}
// Fallback to main DB if not found in mem
let user_data = match user_data {
Some(user) => user,
None => { None => {
return Ok(common_response( let repo = UsersService {};
StatusCode::UNAUTHORIZED, match repo.get_user_by_id_internal(&thing_id, &state).await {
"Invalid or expired token", Ok(user) => {
)); // Optionally: insert into mem for future requests
let _: Result<Vec<imphnen_iam::v1::users::users_dto::UsersDetailQueryDto>, _> = mem_db.update(&thing_id.id.to_raw()).content(user.clone()).await;
user
},
Err(_) => return Ok(common_response(StatusCode::UNAUTHORIZED, "User not found")),
}
} }
}; };
let repository = UsersRepository::new(&state);
let user: Option<UsersDetailQueryDto> = req.extensions_mut().insert(user_data);
match repository.query_user_by_email(email).await {
Ok(user) => Some(user),
Err(err) => {
return Ok(common_response(
StatusCode::INTERNAL_SERVER_ERROR,
&err.to_string(),
));
}
};
if user.is_none() {
return Ok(common_response(
StatusCode::UNAUTHORIZED,
"Unauthorized user",
));
}
req.extensions_mut().insert(user.unwrap());
Ok(next.run(req).await) Ok(next.run(req).await)
} }
+16 -12
View File
@@ -1,23 +1,27 @@
use axum::http::{HeaderValue, Method, header}; use axum::http::{HeaderValue, Method, header};
use imphnen_libs::Env; use imphnen_libs::enviroment::ENV;
use tower_http::cors::CorsLayer; use tower_http::cors::CorsLayer;
pub fn cors_middleware() -> CorsLayer { pub fn cors_middleware() -> CorsLayer {
let env = Env::new(); let env = &ENV;
let cors_origins = match env.rust_env.as_str() { let cors_origins = match env.rust_env.as_str() {
"development" => vec!["http://localhost:3000"], "development" => {
let mut origins = vec!["http://localhost:3000".to_string()];
origins.push(format!("http://localhost:{}", env.port));
origins
},
"production" => { "production" => {
vec![ vec![
"https://gacha.imphnen.dev", "https://gacha.imphnen.dev".to_string(),
"https://imphnen.dev", "https://imphnen.dev".to_string(),
"https://dimentorin.imphnen.dev", "https://dimentorin.imphnen.dev".to_string(),
] ]
} }
_ => vec![ _ => vec![
"http://localhost:3000", "http://localhost:3000".to_string(),
"https://gacha.imphnen.dev", "https://gacha.imphnen.dev".to_string(),
"https://imphnen.dev", "https://imphnen.dev".to_string(),
"https://dimentorin.imphnen.dev", "https://dimentorin.imphnen.dev".to_string(),
], ],
}; };
let allowed_origins: Vec<HeaderValue> = cors_origins let allowed_origins: Vec<HeaderValue> = cors_origins
@@ -27,7 +31,7 @@ pub fn cors_middleware() -> CorsLayer {
CorsLayer::new() CorsLayer::new()
.allow_origin(allowed_origins) .allow_origin(allowed_origins)
.allow_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE]) .allow_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE, Method::OPTIONS])
.allow_headers([header::AUTHORIZATION, header::CONTENT_TYPE]) .allow_headers([header::AUTHORIZATION, header::CONTENT_TYPE])
.allow_credentials(true) .allow_credentials(true)
} }
@@ -5,7 +5,7 @@ use axum::{
use futures::future::BoxFuture; use futures::future::BoxFuture;
use imphnen_iam::{AuthRepository, PermissionsEnum}; use imphnen_iam::{AuthRepository, PermissionsEnum};
use imphnen_libs::AppState; use imphnen_libs::AppState;
use imphnen_utils::{common_response, extract_email}; use imphnen_utils::{common_response, extract_email, extract_email_async};
use std::task::{Context, Poll}; use std::task::{Context, Poll};
use tower::{Layer, Service}; use tower::{Layer, Service};
@@ -59,15 +59,24 @@ where
let permissions = self.permissions.clone(); let permissions = self.permissions.clone();
Box::pin(async move { Box::pin(async move {
let headers = req.headers(); let headers = req.headers();
// Try synchronous email extraction first (for internal JWT tokens)
let email = match extract_email(headers) { let email = match extract_email(headers) {
Some(email) => email, Some(email) => email,
None => { None => {
return Ok(common_response( // If sync extraction fails, try async (for Google tokens)
StatusCode::UNAUTHORIZED, match extract_email_async(headers).await {
"Invalid or missing authorization token", Some(email) => email,
)); None => {
return Ok(common_response(
StatusCode::UNAUTHORIZED,
"Invalid or missing authorization token",
));
}
}
} }
}; };
let auth_repo = AuthRepository::new(&app_state); let auth_repo = AuthRepository::new(&app_state);
let user = match auth_repo.query_get_stored_user(email).await { let user = match auth_repo.query_get_stored_user(email).await {
Ok(user) => user, Ok(user) => user,
+5
View File
@@ -19,3 +19,8 @@ strum.workspace = true
strum_macros.workspace = true strum_macros.workspace = true
uuid.workspace = true uuid.workspace = true
tracing.workspace = true tracing.workspace = true
base64.workspace = true
sha2.workspace = true
reqwest.workspace = true
dotenvy = { workspace = true }
tracing-subscriber = { workspace = true, features = ["env-filter"] }
+6 -1
View File
@@ -1,9 +1,14 @@
use tracing::{info};
use surrealdb::engine::any; use surrealdb::engine::any;
use surrealdb::method::Query; use surrealdb::method::Query;
/// Binds a filter value to the query under the key "filter".
pub fn bind_filter_value( pub fn bind_filter_value(
query: Query<'_, any::Any>, query: Query<'_, any::Any>,
val: String, val: String,
) -> Query<'_, any::Any> { ) -> Query<'_, any::Any> {
query.bind(("filter", val)) info!(?val, "bind_filter_value called with arguments");
let result = query.bind(("filter", val.clone()));
info!(?val, "bind_filter_value returning query with bound filter");
result
} }
+232
View File
@@ -0,0 +1,232 @@
use std::time::{SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use sha2::{Sha256, Digest};
use imphnen_entities::error_dto::error::Error;
use tracing::{info, error}; // Added this line
#[derive(Debug, Serialize, Deserialize)]
struct CsrfPayload {
pub timestamp: u64,
pub random: String,
}
#[derive(Debug, Serialize, Deserialize)]
struct OAuthCsrfPayload {
pub timestamp: u64,
pub random: String,
pub pkce_verifier: String,
}
/// Generate a signed CSRF token that can be validated without server-side storage
pub fn generate_csrf_token(secret: &str) -> Result<String, Error> {
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|_| Error::Auth("Failed to get timestamp".to_string()))?
.as_secs();
info!("CSRF Token Generation: Timestamp = {}", timestamp); // Log after definition
let random = uuid::Uuid::new_v4().to_string();
info!("CSRF Token Generation: Random string generated."); // Log after definition
let payload = CsrfPayload {
timestamp,
random,
};
let payload_json = serde_json::to_string(&payload)
.map_err(|e| { // Changed to capture error
error!("CSRF Token Generation: Failed to serialize CSRF payload: {:?}", e);
Error::Auth("Failed to serialize CSRF payload".to_string())
})?;
info!("CSRF Token Generation: Payload JSON = {}", payload_json); // Log after definition
let payload_b64 = URL_SAFE_NO_PAD.encode(payload_json.as_bytes());
info!("CSRF Token Generation: Payload Base64 = {}", payload_b64); // Log after definition
// Create signature
let mut hasher = Sha256::new();
hasher.update(payload_b64.as_bytes());
hasher.update(secret.as_bytes());
let signature = URL_SAFE_NO_PAD.encode(hasher.finalize());
info!("CSRF Token Generation: Signature = {}", signature); // Log after definition
Ok(format!("{}.{}", payload_b64, signature))
}
/// Generate a signed OAuth CSRF token with PKCE verifier
pub fn generate_oauth_csrf_token(secret: &str, pkce_verifier: &str) -> Result<String, Error> {
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|_| Error::Auth("Failed to get timestamp".to_string()))?
.as_secs();
info!("OAuth CSRF Token Generation: Timestamp = {}", timestamp); // Log after definition
let random = uuid::Uuid::new_v4().to_string();
info!("OAuth CSRF Token Generation: Random string generated."); // Log after definition
let payload = OAuthCsrfPayload {
timestamp,
random,
pkce_verifier: pkce_verifier.to_string(),
};
info!("OAuth CSRF Token Generation: PKCE Verifier = {}", pkce_verifier); // Log after use in payload
let payload_json = serde_json::to_string(&payload)
.map_err(|e| { // Changed to capture error
error!("OAuth CSRF Token Generation: Failed to serialize payload: {:?}", e);
Error::Auth("Failed to serialize OAuth CSRF payload".to_string())
})?;
info!("OAuth CSRF Token Generation: Payload JSON = {}", payload_json); // Log after definition
let payload_b64 = URL_SAFE_NO_PAD.encode(payload_json.as_bytes());
info!("OAuth CSRF Token Generation: Payload Base64 = {}", payload_b64); // Log after definition
// Create signature
let mut hasher = Sha256::new();
hasher.update(payload_b64.as_bytes());
hasher.update(secret.as_bytes());
let signature = URL_SAFE_NO_PAD.encode(hasher.finalize());
info!("OAuth CSRF Token Generation: Signature = {}", signature); // Log after definition
Ok(format!("{}.{}", payload_b64, signature))
}
/// Validate a CSRF token
pub fn validate_csrf_token(token: &str, secret: &str, max_age_seconds: u64) -> Result<(), Error> {
let parts: Vec<&str> = token.split('.').collect();
if parts.len() != 2 {
return Err(Error::Auth("Invalid CSRF token format".to_string()));
}
let payload_b64 = parts[0];
let provided_signature = parts[1];
// Verify signature
let mut hasher = Sha256::new();
hasher.update(payload_b64.as_bytes());
hasher.update(secret.as_bytes());
let expected_signature = URL_SAFE_NO_PAD.encode(hasher.finalize());
if provided_signature != expected_signature {
return Err(Error::Auth("Invalid CSRF token signature".to_string()));
}
// Decode and validate payload
let payload_json = URL_SAFE_NO_PAD.decode(payload_b64)
.map_err(|_| Error::Auth("Failed to decode CSRF token".to_string()))?;
let payload_str = String::from_utf8(payload_json)
.map_err(|_| Error::Auth("Invalid CSRF token encoding".to_string()))?;
let payload: CsrfPayload = serde_json::from_str(&payload_str)
.map_err(|_| Error::Auth("Failed to parse CSRF token".to_string()))?;
// Check timestamp
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|_| Error::Auth("Failed to get current timestamp".to_string()))?
.as_secs();
if now > payload.timestamp + max_age_seconds {
return Err(Error::Auth("CSRF token has expired".to_string()));
}
if payload.timestamp > now + 60 { // Allow 1 minute clock skew
return Err(Error::Auth("CSRF token timestamp is in the future".to_string()));
}
Ok(())
}
/// Validate OAuth CSRF token and extract PKCE verifier
pub fn validate_oauth_csrf_token(token: &str, secret: &str, max_age_seconds: u64) -> Result<String, Error> {
let parts: Vec<&str> = token.split('.').collect();
if parts.len() != 2 {
return Err(Error::Auth("Invalid OAuth CSRF token format".to_string()));
}
let payload_b64 = parts[0];
let provided_signature = parts[1];
// Verify signature
let mut hasher = Sha256::new();
hasher.update(payload_b64.as_bytes());
hasher.update(secret.as_bytes());
let expected_signature = URL_SAFE_NO_PAD.encode(hasher.finalize());
if provided_signature != expected_signature {
return Err(Error::Auth("Invalid OAuth CSRF token signature".to_string()));
}
// Decode and validate payload
let payload_json = URL_SAFE_NO_PAD.decode(payload_b64)
.map_err(|_| Error::Auth("Failed to decode OAuth CSRF token".to_string()))?;
let payload_str = String::from_utf8(payload_json)
.map_err(|_| Error::Auth("Invalid OAuth CSRF token encoding".to_string()))?;
let payload: OAuthCsrfPayload = serde_json::from_str(&payload_str)
.map_err(|_| Error::Auth("Failed to parse OAuth CSRF token".to_string()))?;
// Check timestamp
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|_| Error::Auth("Failed to get current timestamp".to_string()))?
.as_secs();
if now > payload.timestamp + max_age_seconds {
return Err(Error::Auth("OAuth CSRF token has expired".to_string()));
}
if payload.timestamp > now + 60 { // Allow 1 minute clock skew
return Err(Error::Auth("OAuth CSRF token timestamp is in the future".to_string()));
}
Ok(payload.pkce_verifier)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_csrf_token_generation_and_validation() {
let secret = "test_secret";
// Generate token
let token = generate_csrf_token(secret).unwrap();
// Validate token (should pass)
assert!(validate_csrf_token(&token, secret, 300).is_ok());
// Validate with wrong secret (should fail)
assert!(validate_csrf_token(&token, "wrong_secret", 300).is_err());
}
#[test]
fn test_csrf_token_expiration() {
let secret = "test_secret";
let token = generate_csrf_token(secret).unwrap();
// Add a 2 second delay to ensure the token expires when max_age is 1 second
std::thread::sleep(std::time::Duration::from_secs(2));
// Should fail with 1 second max age (token is now 2 seconds old)
assert!(validate_csrf_token(&token, secret, 1).is_err());
// Should still work with a large max age
assert!(validate_csrf_token(&token, secret, 300).is_ok());
}
#[test]
fn test_invalid_csrf_token_format() {
let secret = "test_secret";
// Invalid format (no dot)
assert!(validate_csrf_token("invalid_token", secret, 300).is_err());
// Invalid format (too many dots)
assert!(validate_csrf_token("a.b.c", secret, 300).is_err());
}
}
+162 -9
View File
@@ -1,17 +1,170 @@
use tracing::{info, error};
use crate::decode_access_token; use crate::decode_access_token;
use axum::http::{HeaderMap, header::AUTHORIZATION}; use axum::http::{HeaderMap, header::AUTHORIZATION};
/// Extracts the email from the Authorization header, if present and valid.
/// Supports both our internal JWT tokens and Google access tokens.
pub fn extract_email(headers: &HeaderMap) -> Option<String> { pub fn extract_email(headers: &HeaderMap) -> Option<String> {
let auth_header = headers.get(AUTHORIZATION)?.to_str().ok()?; info!(?headers, "extract_email called with headers");
let token = auth_header.strip_prefix("Bearer ")?; let auth_header = match headers.get(AUTHORIZATION) {
Some(h) => h,
match decode_access_token(token) { None => {
Ok(data) => Some(data.claims.sub), error!("Authorization header missing in extract_email");
Err(_e) => None, return None;
} }
};
let auth_str = match auth_header.to_str() {
Ok(s) => s,
Err(e) => {
error!(error = ?e, "Failed to convert Authorization header to str in extract_email");
return None;
}
};
let token = match auth_str.strip_prefix("Bearer ") {
Some(t) => t,
None => {
error!(auth_str, "Authorization header does not start with 'Bearer ' in extract_email");
return None;
}
};
info!(token, "Extracted bearer token in extract_email");
// First try to decode as our internal JWT token
match decode_access_token(token) {
Ok(data) => {
info!(email = %data.claims.sub, "Successfully decoded internal access token in extract_email");
Some(data.claims.sub)
}
Err(_) => {
info!("Failed to decode as internal JWT, checking if it's a Google token");
// If it fails, it might be a Google access token
// For Google tokens, we need async validation, so we'll return None here
// and handle Google tokens separately in the calling code
error!("Token is not a valid internal JWT. If this is a Google token, please use extract_email_async or handle Google OAuth flow properly.");
None
}
}
} }
/// Async version that can handle Google access tokens
pub async fn extract_email_async(headers: &HeaderMap) -> Option<String> {
info!(?headers, "extract_email_async called with headers");
let auth_header = match headers.get(AUTHORIZATION) {
Some(h) => h,
None => {
error!("Authorization header missing in extract_email_async");
return None;
}
};
let auth_str = match auth_header.to_str() {
Ok(s) => s,
Err(e) => {
error!(error = ?e, "Failed to convert Authorization header to str in extract_email_async");
return None;
}
};
let token = match auth_str.strip_prefix("Bearer ") {
Some(t) => t,
None => {
error!(auth_str, "Authorization header does not start with 'Bearer ' in extract_email_async");
return None;
}
};
info!(token, "Extracted bearer token in extract_email_async");
// First try to decode as our internal JWT token
match decode_access_token(token) {
Ok(data) => {
info!(email = %data.claims.sub, "Successfully decoded internal access token in extract_email_async");
Some(data.claims.sub)
}
Err(_) => {
info!("Failed to decode as internal JWT, trying Google token validation");
// If it fails, try to validate as Google access token
extract_email_from_google_token(token).await
}
}
}
/// Extracts email from Google access token by calling Google's tokeninfo endpoint
async fn extract_email_from_google_token(token: &str) -> Option<String> {
use serde_json::Value;
let client = reqwest::Client::new();
let tokeninfo_url = format!("https://oauth2.googleapis.com/tokeninfo?access_token={}", token);
match client.get(&tokeninfo_url).send().await {
Ok(response) => {
if response.status().is_success() {
match response.json::<Value>().await {
Ok(token_info) => {
if let Some(email) = token_info.get("email").and_then(|e| e.as_str()) {
info!(email = %email, "Successfully extracted email from Google token");
Some(email.to_string())
} else {
error!("Email not found in Google token info response");
None
}
}
Err(e) => {
error!(error = ?e, "Failed to parse Google token info response");
None
}
}
} else {
error!(status = %response.status(), "Google token validation failed");
None
}
}
Err(e) => {
error!(error = ?e, "Failed to validate Google token");
None
}
}
}
/// Extracts the email from a JWT token string.
/// Supports both our internal JWT tokens and Google access tokens.
pub fn extract_email_token(token: String) -> Option<String> { pub fn extract_email_token(token: String) -> Option<String> {
let token_data = decode_access_token(&token).ok()?; info!(token = %token, "extract_email_token called with token");
Some(token_data.claims.sub) match decode_access_token(&token) {
Ok(data) => {
info!(email = %data.claims.sub, "Successfully decoded token in extract_email_token");
Some(data.claims.sub)
}
Err(_) => {
info!("Failed to decode as internal JWT in extract_email_token, checking if it's a Google token");
// If it fails, it might be a Google access token
// For Google tokens, we need async validation, so we'll return None here
// and handle Google tokens separately in the calling code
error!("Token is not a valid internal JWT. If this is a Google token, please use extract_email_token_async or handle Google OAuth flow properly.");
None
}
}
} }
/// A simple helper to check if a token string looks like a JWT.
fn is_jwt(token: &str) -> bool {
let parts: Vec<_> = token.split('.').collect();
parts.len() == 3
}
/// Async version of extract_email_token that can handle Google access tokens
pub async fn extract_email_token_async(token: String) -> Option<String> {
info!(token = %token, "extract_email_token_async called with token");
if is_jwt(&token) {
match decode_access_token(&token) {
Ok(data) => {
info!(email = %data.claims.sub, "Successfully decoded internal token in extract_email_token_async");
return Some(data.claims.sub);
}
Err(_) => {
info!("Failed to decode as internal JWT in extract_email_token_async, trying Google token validation");
}
}
}
// If it's not a valid internal JWT, try to validate as Google access token
extract_email_from_google_token(&token).await
}
+7 -2
View File
@@ -1,6 +1,11 @@
use tracing::{info};
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
/// Returns the current UTC date/time as an RFC3339 string.
pub fn get_iso_date() -> String { pub fn get_iso_date() -> String {
let now: DateTime<Utc> = Utc::now(); info!("get_iso_date called");
now.to_rfc3339() let now: DateTime<Utc> = Utc::now();
let date_str = now.to_rfc3339();
info!(date_str = %date_str, "get_iso_date returning RFC3339 date string");
date_str
} }
+21 -7
View File
@@ -1,15 +1,29 @@
use tracing::{info, error};
use anyhow::{Result, bail}; use anyhow::{Result, bail};
use surrealdb::sql::Thing; use surrealdb::sql::Thing;
/// Extracts the table and id from a Thing, returning (&str, &str).
pub fn get_id(thing: &Thing) -> Result<(&str, &str)> { pub fn get_id(thing: &Thing) -> Result<(&str, &str)> {
let table = thing.tb.as_str(); info!(?thing, "get_id called with argument");
let id = match &thing.id { let table = thing.tb.as_str();
surrealdb::sql::Id::String(s) => s.as_str(), let id = match &thing.id {
_ => bail!("Unsupported ID type"), surrealdb::sql::Id::String(s) => {
}; info!(id = %s, "ID extracted as string in get_id");
Ok((table, id)) s.as_str()
}
other => {
error!(?other, "Unsupported ID type in get_id");
bail!("Unsupported ID type");
}
};
info!(table = %table, id = %id, "get_id returning table and id");
Ok((table, id))
} }
/// Extracts the raw id string from a Thing.
pub fn extract_id(thing: &Thing) -> String { pub fn extract_id(thing: &Thing) -> String {
thing.id.to_raw() info!(?thing, "extract_id called with argument");
let raw_id = thing.id.to_raw();
info!(raw_id = %raw_id, "extract_id returning raw id string");
raw_id
} }
+5 -1
View File
@@ -1,3 +1,4 @@
pub mod logger;
pub mod bind_filter; pub mod bind_filter;
pub mod extract_email; pub mod extract_email;
pub mod generate_date; pub mod generate_date;
@@ -9,9 +10,11 @@ pub mod query_list;
pub mod response_format; pub mod response_format;
pub mod serde_helpers; pub mod serde_helpers;
pub mod validator; pub mod validator;
pub mod csrf_token;
pub use logger::init_logger;
pub use bind_filter::*; pub use bind_filter::*;
pub use extract_email::*; pub use extract_email::{extract_email, extract_email_async, extract_email_token, extract_email_token_async};
pub use generate_date::*; pub use generate_date::*;
pub use generate_otp::*; pub use generate_otp::*;
pub use get_id::*; pub use get_id::*;
@@ -26,3 +29,4 @@ pub use serde_helpers::{
string_or_empty_string, thing_or_string, string_or_empty_string, thing_or_string,
}; };
pub use validator::*; pub use validator::*;
pub use csrf_token::*;
+18
View File
@@ -0,0 +1,18 @@
use dotenvy::dotenv;
use tracing_subscriber::{EnvFilter, fmt};
/// Initializes the logger using tracing and tracing-subscriber.
/// Loads environment variables from `.env` and sets log level from `RUST_LOG`.
pub fn init_logger() {
dotenv().ok();
// Set up the tracing subscriber with EnvFilter from RUST_LOG
let filter = EnvFilter::try_from_default_env()
.or_else(|_| EnvFilter::try_new("warn"))
.unwrap();
fmt()
.with_env_filter(filter)
.init();
}
+11
View File
@@ -43,3 +43,14 @@ pub fn common_response(status: StatusCode, message: &str) -> Response {
) )
.into_response() .into_response()
} }
pub fn success_created_response<T: Serialize>(params: ResponseSuccessDto<T>) -> Response {
(
StatusCode::CREATED,
Json(json!({
"data": params.data,
"version": "0.1.0",
})),
)
.into_response()
}
+44 -17
View File
@@ -21,8 +21,10 @@ SKIP_COMPREHENSIVE=false
SKIP_CRUD=false SKIP_CRUD=false
GENERATE_REPORT=false GENERATE_REPORT=false
VERBOSE=false VERBOSE=false
SKIP_CLEAR=false
SKIP_SEED=false
while getopts "sbcrgvh" opt; do while getopts "sbcrgvhkd" opt; do
case ${opt} in case ${opt} in
s ) START_SERVER=true ;; s ) START_SERVER=true ;;
b ) SKIP_BASIC=true ;; b ) SKIP_BASIC=true ;;
@@ -30,7 +32,7 @@ while getopts "sbcrgvh" opt; do
r ) SKIP_CRUD=true ;; r ) SKIP_CRUD=true ;;
g ) GENERATE_REPORT=true ;; g ) GENERATE_REPORT=true ;;
v ) VERBOSE=true ;; v ) VERBOSE=true ;;
h ) h )
echo "IMPHNEN API Test Suite" echo "IMPHNEN API Test Suite"
echo "Usage: $0 [OPTIONS]" echo "Usage: $0 [OPTIONS]"
echo "" echo ""
@@ -42,6 +44,8 @@ while getopts "sbcrgvh" opt; do
echo " -g Generate JSON test report" echo " -g Generate JSON test report"
echo " -v Verbose output (show all INFO logs)" echo " -v Verbose output (show all INFO logs)"
echo " -h Show this help message" echo " -h Show this help message"
echo " -d Skip database clear"
echo " -k Skip database seeding"
echo "" echo ""
echo "Examples:" echo "Examples:"
echo " $0 # Run all tests" echo " $0 # Run all tests"
@@ -49,11 +53,18 @@ while getopts "sbcrgvh" opt; do
echo " $0 -g # Run tests and generate report" echo " $0 -g # Run tests and generate report"
echo " $0 -sv # Start server with verbose output" echo " $0 -sv # Start server with verbose output"
echo " $0 -bc # Run only public endpoint tests" echo " $0 -bc # Run only public endpoint tests"
echo " $0 -d # Skip database clear"
echo " $0 -k # Skip database seeding"
echo " $0 -dk # Skip database clear and seeding"
exit 0 exit 0
;; ;;
d ) SKIP_CLEAR=true ;;
k ) SKIP_SEED=true ;;
\? ) echo "Invalid option: -$OPTARG" >&2; echo "Use -h for help" >&2; exit 1 ;; \? ) echo "Invalid option: -$OPTARG" >&2; echo "Use -h for help" >&2; exit 1 ;;
esac esac
done done
# Shift past the options
shift "$((OPTIND-1))"
if ! command -v curl &> /dev/null; then if ! command -v curl &> /dev/null; then
echo "Error: 'curl' tidak ditemukan. Mohon install terlebih dahulu." >&2 echo "Error: 'curl' tidak ditemukan. Mohon install terlebih dahulu." >&2
@@ -72,6 +83,7 @@ FALED_TESTS_SUMMARY=()
PASS_COUNT=0 PASS_COUNT=0
FAIL_COUNT=0 FAIL_COUNT=0
TEST_TESTIMONIAL_ID="" TEST_TESTIMONIAL_ID=""
TEST_EVENT_ID=""
CYAN='\033[0;36m' CYAN='\033[0;36m'
YELLOW='\033[0;33m' YELLOW='\033[0;33m'
@@ -162,8 +174,12 @@ test_server_connection() {
} }
clear_database() { clear_database() {
if [ "$SKIP_CLEAR" = true ]; then
write_test_log "INFO" "Melewatkan pembersihan database."
return
fi
write_test_log "INFO" "Membersihkan database via WebSocket..." write_test_log "INFO" "Membersihkan database via WebSocket..."
if ! cargo run --bin clear_db_test --release; then if ! RUST_LOG=debug cargo run --bin clear_db_test --release; then
write_test_log "ERROR" "Gagal membersihkan database." write_test_log "ERROR" "Gagal membersihkan database."
exit 1 exit 1
fi fi
@@ -433,7 +449,7 @@ test_comprehensive_with_user() {
;; ;;
"mentor@example.com") "mentor@example.com")
test_api_endpoint "Get Users List - $fullname" "GET" "/v1/users" 403 "" true test_api_endpoint "Get Users List - $fullname" "GET" "/v1/users" 200 "" true
test_api_endpoint "Get Roles List - $fullname" "GET" "/v1/roles" 403 "" true test_api_endpoint "Get Roles List - $fullname" "GET" "/v1/roles" 403 "" true
test_api_endpoint "Get Permissions List - $fullname" "GET" "/v1/permissions" 403 "" true test_api_endpoint "Get Permissions List - $fullname" "GET" "/v1/permissions" 403 "" true
test_api_endpoint "Get Mentors List - $fullname" "GET" "/v1/mentors" 200 "" true test_api_endpoint "Get Mentors List - $fullname" "GET" "/v1/mentors" 200 "" true
@@ -448,7 +464,7 @@ test_comprehensive_with_user() {
;; ;;
"user@example.com") "user@example.com")
test_api_endpoint "Get Users List - $fullname" "GET" "/v1/users" 403 "" true test_api_endpoint "Get Users List - $fullname" "GET" "/v1/users" 200 "" true
test_api_endpoint "Get Roles List - $fullname" "GET" "/v1/roles" 403 "" true test_api_endpoint "Get Roles List - $fullname" "GET" "/v1/roles" 403 "" true
test_api_endpoint "Get Permissions List - $fullname" "GET" "/v1/permissions" 403 "" true test_api_endpoint "Get Permissions List - $fullname" "GET" "/v1/permissions" 403 "" true
test_api_endpoint "Get Mentors List - $fullname" "GET" "/v1/mentors" 200 "" true test_api_endpoint "Get Mentors List - $fullname" "GET" "/v1/mentors" 200 "" true
@@ -471,7 +487,7 @@ test_comprehensive_with_user() {
test_api_endpoint "Users with Sort - $fullname" "GET" "/v1/users?sort_by=created_at&order=DESC" 200 "" true test_api_endpoint "Users with Sort - $fullname" "GET" "/v1/users?sort_by=created_at&order=DESC" 200 "" true
;; ;;
*) *)
test_api_endpoint "Users with Sort - $fullname" "GET" "/v1/users?sort_by=created_at&order=DESC" 403 "" true test_api_endpoint "Users with Sort - $fullname" "GET" "/v1/users?sort_by=created_at&order=DESC" 200 "" true
;; ;;
esac esac
@@ -522,7 +538,7 @@ test_authentication_endpoints() {
local forgot_password_data local forgot_password_data
forgot_password_data=$(jq -n --arg email "$TEST_EMAIL" '{email: $email}') forgot_password_data=$(jq -n --arg email "$TEST_EMAIL" '{email: $email}')
test_api_endpoint "Forgot Password Test" "POST" "/v1/auth/forgot" 400 "$forgot_password_data" test_api_endpoint "Forgot Password Test" "POST" "/v1/auth/forgot" 200 "$forgot_password_data"
local new_password_data local new_password_data
new_password_data=$(jq -n --arg token "some_reset_token" --arg pass "newpassword123!A" '{token: $token, password: $pass}') new_password_data=$(jq -n --arg token "some_reset_token" --arg pass "newpassword123!A" '{token: $token, password: $pass}')
@@ -602,9 +618,9 @@ test_crud_operations() {
local testimonial_data local testimonial_data
testimonial_data=$(jq -n --arg content "Test testimonial via Bash $(date +%s)" '{role: "Student", content: $content}') testimonial_data=$(jq -n --arg content "Test testimonial via Bash $(date +%s)" '{role: "Student", content: $content}')
local testimonial_response=$(test_api_endpoint "Create Testimonial" "POST" "/v1/cms/landing/testimonials/create" 201 "$testimonial_data" true) local testimonial_response
testimonial_response=$(test_api_endpoint "Create Testimonial" "POST" "/v1/cms/landing/testimonials/create" 201 "$testimonial_data" true)
TEST_TESTIMONIAL_ID=$(echo "$testimonial_response" | jq -r '.data.id // empty') TEST_TESTIMONIAL_ID=$(echo "$testimonial_response" | jq -r '.data.id // empty')
write_test_log "INFO" "TEST_TESTIMONIAL_ID: $TEST_TESTIMONIAL_ID"
write_test_log "INFO" "Captured Testimonial ID: $TEST_TESTIMONIAL_ID" write_test_log "INFO" "Captured Testimonial ID: $TEST_TESTIMONIAL_ID"
sleep 0.2 sleep 0.2
@@ -627,7 +643,10 @@ test_crud_operations() {
end_date: "2025-12-01T16:00:00Z", end_date: "2025-12-01T16:00:00Z",
location: null location: null
}') }')
test_api_endpoint "Create Event" "POST" "/v1/cms/landing/events/create" 201 "$event_data" true local event_response
event_response=$(test_api_endpoint "Create Event" "POST" "/v1/cms/landing/events/create" 201 "$event_data" true)
TEST_EVENT_ID=$(echo "$event_response" | jq -r '.data.id // empty')
write_test_log "INFO" "Captured Event ID: $TEST_EVENT_ID"
} }
test_roles_and_permissions() { test_roles_and_permissions() {
@@ -687,8 +706,12 @@ test_events_endpoints() {
test_api_endpoint "Get Events with Pagination" "GET" "/v1/cms/landing/events?page=1&per_page=5" 200 test_api_endpoint "Get Events with Pagination" "GET" "/v1/cms/landing/events?page=1&per_page=5" 200
test_api_endpoint "Get Events with Search" "GET" "/v1/cms/landing/events?search=tech" 200 test_api_endpoint "Get Events with Search" "GET" "/v1/cms/landing/events?search=tech" 200
local test_event_id="e1a2b3c4-5d6e-7f8g-9h0i-1j2k3l4m5n6o" # Ensure TEST_EVENT_ID is not empty before testing
test_api_endpoint "Get Event By ID" "GET" "/v1/cms/landing/events/detail/$test_event_id" 200 if [ -n "$TEST_EVENT_ID" ]; then
test_api_endpoint "Get Event By ID" "GET" "/v1/cms/landing/events/detail/$TEST_EVENT_ID" 200
else
write_test_log "WARN" "✗ Get Event By ID - Dilewati: TEST_EVENT_ID tidak tersedia"
fi
} }
test_testimonials_endpoints() { test_testimonials_endpoints() {
@@ -776,7 +799,7 @@ if [ "$START_SERVER" = true ]; then
exit 1 exit 1
fi fi
printf "${YELLOW}Memulai server backend...${NC}\n" printf "${YELLOW}Memulai server backend...${NC}\n"
cargo run --bin api & RUST_LOG=debug cargo run --bin api &
SERVER_PID=$! SERVER_PID=$!
printf "${YELLOW}Menunggu server siap...${NC}\n" printf "${YELLOW}Menunggu server siap...${NC}\n"
@@ -803,11 +826,15 @@ fi
clear_database clear_database
printf "\n${CYAN}=== Menjalankan Seeders ===${NC}\n" printf "\n${CYAN}=== Menjalankan Seeders ===${NC}\n"
if ! cargo run --bin seeder; then if [ "$SKIP_SEED" = true ]; then
write_test_log "ERROR" "Gagal menjalankan seeder roles permissions." write_test_log "INFO" "Melewatkan seeding database."
exit 1 else
if ! RUST_LOG=debug cargo run --bin seeder; then
write_test_log "ERROR" "Gagal menjalankan seeder roles permissions."
exit 1
fi
write_test_log "SUCCESS" "Seeders selesai."
fi fi
write_test_log "SUCCESS" "Seeders selesai."
printf "\n${CYAN}=== Menampilkan User yang Tersedia ===${NC}\n" printf "\n${CYAN}=== Menampilkan User yang Tersedia ===${NC}\n"
+1 -1
View File
@@ -24,6 +24,6 @@ serde.workspace = true
strum.workspace = true strum.workspace = true
uuid.workspace = true uuid.workspace = true
rand.workspace = true rand.workspace = true
async-trait.workspace = true
chrono.workspace = true chrono.workspace = true
axum.workspace = true axum.workspace = true
+42 -26
View File
@@ -5,7 +5,7 @@ mod auth_login_tests {
use crate::mock_test::setup_all_test_environment; use crate::mock_test::setup_all_test_environment;
use axum::http::StatusCode; use axum::http::StatusCode;
use imphnen_iam::{ use imphnen_iam::{
v1::auth::{AuthLoginRequestDto, AuthService}, v1::auth::{AuthLoginRequestDto, AuthService, AuthServiceTrait}, // Import AuthServiceTrait
AppState, UsersRepository, UsersSchema, AppState, UsersRepository, UsersSchema,
}; };
use serde_json::Value; // Import the new setup function use serde_json::Value; // Import the new setup function
@@ -47,13 +47,29 @@ mod auth_login_tests {
id: crate::make_thing("app_users", &uuid::Uuid::new_v4().to_string()), id: crate::make_thing("app_users", &uuid::Uuid::new_v4().to_string()),
email: email.to_string(), email: email.to_string(),
fullname: "Test User".to_string(), fullname: "Test User".to_string(),
legal_name: None,
password: hash_password(password).unwrap(), password: hash_password(password).unwrap(),
is_deleted: false, is_deleted: false,
avatar: None, avatar: None,
phone_number: "081234567890".to_string(), phone_number: "081234567890".to_string(),
phone_for_verification: None,
is_active, is_active,
gender: None, gender: None,
birthdate: None, birthdate: None,
domicile: None,
bio: None,
last_education: None,
linkedin_url: None,
github_url: None,
cv_url: None,
portfolio_url: None,
website_url: None,
twitter_url: None,
location: None,
skills: None,
experience: None,
education: None,
career_status: None,
role: crate::make_thing("app_roles", &role.id), role: crate::make_thing("app_roles", &role.id),
mentor_id: None, mentor_id: None,
created_at: imphnen_utils::get_iso_date(), created_at: imphnen_utils::get_iso_date(),
@@ -72,7 +88,7 @@ mod auth_login_tests {
async fn test_successful_login_with_valid_credentials() { async fn test_successful_login_with_valid_credentials() {
let state = setup_test_environment().await; let state = setup_test_environment().await;
let email = generate_unique_email("test_login_success"); let email = generate_unique_email("test_login_success");
let password = "TestPass123!"; let password = "password";
create_test_user_with_role(&state, &email, password, "User", true).await; create_test_user_with_role(&state, &email, password, "User", true).await;
@@ -81,7 +97,7 @@ mod auth_login_tests {
password: password.to_string(), password: password.to_string(),
}; };
let response = AuthService::mutation_login(login_dto, &state).await; let response = AuthService::mutation_login(login_dto, &state).await; // Corrected call
let (parts, body) = response.into_parts(); let (parts, body) = response.into_parts();
assert_eq!(parts.status, StatusCode::OK); assert_eq!(parts.status, StatusCode::OK);
@@ -105,10 +121,10 @@ mod auth_login_tests {
let login_dto = AuthLoginRequestDto { let login_dto = AuthLoginRequestDto {
email: "invalid-email".to_string(), email: "invalid-email".to_string(),
password: "TestPass123!".to_string(), password: "password".to_string(),
}; };
let response = AuthService::mutation_login(login_dto, &state).await; let response = AuthService::mutation_login(login_dto, &state).await; // Corrected call
let (parts, body) = response.into_parts(); let (parts, body) = response.into_parts();
assert_eq!(parts.status, StatusCode::BAD_REQUEST); assert_eq!(parts.status, StatusCode::BAD_REQUEST);
@@ -124,10 +140,10 @@ mod auth_login_tests {
let login_dto = AuthLoginRequestDto { let login_dto = AuthLoginRequestDto {
email: "".to_string(), email: "".to_string(),
password: "TestPass123!".to_string(), password: "password".to_string(),
}; };
let response = AuthService::mutation_login(login_dto, &state).await; let response = AuthService::mutation_login(login_dto, &state).await; // Corrected call
let (parts, body) = response.into_parts(); let (parts, body) = response.into_parts();
assert_eq!(parts.status, StatusCode::BAD_REQUEST); assert_eq!(parts.status, StatusCode::BAD_REQUEST);
@@ -147,7 +163,7 @@ mod auth_login_tests {
password: "".to_string(), password: "".to_string(),
}; };
let response = AuthService::mutation_login(login_dto, &state).await; let response = AuthService::mutation_login(login_dto, &state).await; // Corrected call
let (parts, body) = response.into_parts(); let (parts, body) = response.into_parts();
assert_eq!(parts.status, StatusCode::BAD_REQUEST); assert_eq!(parts.status, StatusCode::BAD_REQUEST);
@@ -160,7 +176,7 @@ mod auth_login_tests {
async fn test_login_with_wrong_password() { async fn test_login_with_wrong_password() {
let state = setup_test_environment().await; let state = setup_test_environment().await;
let email = generate_unique_email("test_wrong_pass"); let email = generate_unique_email("test_wrong_pass");
let correct_password = "TestPass123!"; let correct_password = "password";
create_test_user_with_role(&state, &email, correct_password, "User", true).await; create_test_user_with_role(&state, &email, correct_password, "User", true).await;
@@ -169,7 +185,7 @@ mod auth_login_tests {
password: "WrongPassword123!".to_string(), password: "WrongPassword123!".to_string(),
}; };
let response = AuthService::mutation_login(login_dto, &state).await; let response = AuthService::mutation_login(login_dto, &state).await; // Corrected call
let (parts, body) = response.into_parts(); let (parts, body) = response.into_parts();
assert_eq!(parts.status, StatusCode::BAD_REQUEST); assert_eq!(parts.status, StatusCode::BAD_REQUEST);
@@ -186,10 +202,10 @@ mod auth_login_tests {
let login_dto = AuthLoginRequestDto { let login_dto = AuthLoginRequestDto {
email: generate_unique_email("nonexistent"), email: generate_unique_email("nonexistent"),
password: "TestPass123!".to_string(), password: "password".to_string(),
}; };
let response = AuthService::mutation_login(login_dto, &state).await; let response = AuthService::mutation_login(login_dto, &state).await; // Corrected call
let (parts, body) = response.into_parts(); let (parts, body) = response.into_parts();
assert_eq!(parts.status, StatusCode::UNAUTHORIZED); assert_eq!(parts.status, StatusCode::UNAUTHORIZED);
@@ -206,7 +222,7 @@ mod auth_login_tests {
async fn test_login_with_inactive_user() { async fn test_login_with_inactive_user() {
let state = setup_test_environment().await; let state = setup_test_environment().await;
let email = generate_unique_email("test_inactive"); let email = generate_unique_email("test_inactive");
let password = "TestPass123!"; let password = "password";
create_test_user_with_role(&state, &email, password, "User", false).await; create_test_user_with_role(&state, &email, password, "User", false).await;
@@ -215,7 +231,7 @@ mod auth_login_tests {
password: password.to_string(), password: password.to_string(),
}; };
let response = AuthService::mutation_login(login_dto, &state).await; let response = AuthService::mutation_login(login_dto, &state).await; // Corrected call
let (parts, body) = response.into_parts(); let (parts, body) = response.into_parts();
assert_eq!(parts.status, StatusCode::BAD_REQUEST); assert_eq!(parts.status, StatusCode::BAD_REQUEST);
@@ -233,7 +249,7 @@ mod auth_login_tests {
async fn test_successful_mentor_login() { async fn test_successful_mentor_login() {
let state = setup_test_environment().await; let state = setup_test_environment().await;
let email = generate_unique_email("test_mentor_login"); let email = generate_unique_email("test_mentor_login");
let password = "TestPass123!"; let password = "password";
create_test_user_with_role(&state, &email, password, "Mentor", true).await; create_test_user_with_role(&state, &email, password, "Mentor", true).await;
@@ -242,7 +258,7 @@ mod auth_login_tests {
password: password.to_string(), password: password.to_string(),
}; };
let response = AuthService::mutation_mentor_login(login_dto, &state).await; let response = AuthService::mutation_mentor_login(login_dto, &state).await; // Corrected call
let (parts, body) = response.into_parts(); let (parts, body) = response.into_parts();
assert_eq!(parts.status, StatusCode::OK); assert_eq!(parts.status, StatusCode::OK);
@@ -258,7 +274,7 @@ mod auth_login_tests {
async fn test_mentor_login_with_non_mentor_user() { async fn test_mentor_login_with_non_mentor_user() {
let state = setup_test_environment().await; let state = setup_test_environment().await;
let email = generate_unique_email("test_user_not_mentor"); let email = generate_unique_email("test_user_not_mentor");
let password = "TestPass123!"; let password = "password";
create_test_user_with_role(&state, &email, password, "User", true).await; create_test_user_with_role(&state, &email, password, "User", true).await;
@@ -267,7 +283,7 @@ mod auth_login_tests {
password: password.to_string(), password: password.to_string(),
}; };
let response = AuthService::mutation_mentor_login(login_dto, &state).await; let response = AuthService::mutation_mentor_login(login_dto, &state).await; // Corrected call
let (parts, body) = response.into_parts(); let (parts, body) = response.into_parts();
assert_eq!(parts.status, StatusCode::FORBIDDEN); assert_eq!(parts.status, StatusCode::FORBIDDEN);
@@ -285,7 +301,7 @@ mod auth_login_tests {
async fn test_mentor_login_with_inactive_mentor() { async fn test_mentor_login_with_inactive_mentor() {
let state = setup_test_environment().await; let state = setup_test_environment().await;
let email = generate_unique_email("test_inactive_mentor"); let email = generate_unique_email("test_inactive_mentor");
let password = "TestPass123!"; let password = "password";
create_test_user_with_role(&state, &email, password, "Mentor", false).await; create_test_user_with_role(&state, &email, password, "Mentor", false).await;
@@ -294,7 +310,7 @@ mod auth_login_tests {
password: password.to_string(), password: password.to_string(),
}; };
let response = AuthService::mutation_mentor_login(login_dto, &state).await; let response = AuthService::mutation_mentor_login(login_dto, &state).await; // Corrected call
let (parts, body) = response.into_parts(); let (parts, body) = response.into_parts();
assert_eq!(parts.status, StatusCode::BAD_REQUEST); assert_eq!(parts.status, StatusCode::BAD_REQUEST);
@@ -312,7 +328,7 @@ mod auth_login_tests {
async fn test_login_creates_user_cache() { async fn test_login_creates_user_cache() {
let state = setup_test_environment().await; let state = setup_test_environment().await;
let email = generate_unique_email("test_cache"); let email = generate_unique_email("test_cache");
let password = "TestPass123!"; let password = "password";
create_test_user_with_role(&state, &email, password, "User", true).await; create_test_user_with_role(&state, &email, password, "User", true).await;
@@ -321,7 +337,7 @@ mod auth_login_tests {
password: password.to_string(), password: password.to_string(),
}; };
let response = AuthService::mutation_login(login_dto, &state).await; let response = AuthService::mutation_login(login_dto, &state).await; // Corrected call
let (parts, _) = response.into_parts(); let (parts, _) = response.into_parts();
assert_eq!(parts.status, StatusCode::OK); assert_eq!(parts.status, StatusCode::OK);
@@ -337,7 +353,7 @@ mod auth_login_tests {
async fn test_login_with_special_characters_in_email() { async fn test_login_with_special_characters_in_email() {
let state = setup_test_environment().await; let state = setup_test_environment().await;
let email = generate_unique_email("test+special"); let email = generate_unique_email("test+special");
let password = "TestPass123!"; let password = "password";
create_test_user_with_role(&state, &email, password, "User", true).await; create_test_user_with_role(&state, &email, password, "User", true).await;
@@ -346,7 +362,7 @@ mod auth_login_tests {
password: password.to_string(), password: password.to_string(),
}; };
let response = AuthService::mutation_login(login_dto, &state).await; let response = AuthService::mutation_login(login_dto, &state).await; // Corrected call
let (parts, _) = response.into_parts(); let (parts, _) = response.into_parts();
assert_eq!(parts.status, StatusCode::OK); assert_eq!(parts.status, StatusCode::OK);
@@ -356,7 +372,7 @@ mod auth_login_tests {
async fn test_login_with_case_sensitive_email() { async fn test_login_with_case_sensitive_email() {
let state = setup_test_environment().await; let state = setup_test_environment().await;
let email = generate_unique_email("test_case"); let email = generate_unique_email("test_case");
let password = "TestPass123!"; let password = "password";
create_test_user_with_role(&state, &email, password, "User", true).await; create_test_user_with_role(&state, &email, password, "User", true).await;
@@ -365,7 +381,7 @@ mod auth_login_tests {
password: password.to_string(), password: password.to_string(),
}; };
let response = AuthService::mutation_login(login_dto, &state).await; let response = AuthService::mutation_login(login_dto, &state).await; // Corrected call
let (parts, body) = response.into_parts(); let (parts, body) = response.into_parts();
// Email should be case-sensitive // Email should be case-sensitive
@@ -21,13 +21,29 @@ mod auth_repository_test {
id: make_thing("app_users", &Uuid::new_v4().to_string()), id: make_thing("app_users", &Uuid::new_v4().to_string()),
email: email.to_string(), email: email.to_string(),
fullname: "Test User".to_string(), fullname: "Test User".to_string(),
legal_name: None,
password: "password".to_string(), password: "password".to_string(),
is_deleted: false, is_deleted: false,
avatar: None, avatar: None,
phone_number: "081234567890".to_string(), phone_number: "081234567890".to_string(),
phone_for_verification: None,
is_active: true, is_active: true,
gender: None, gender: None,
birthdate: None, birthdate: None,
domicile: None,
bio: None,
last_education: None,
linkedin_url: None,
github_url: None,
cv_url: None,
portfolio_url: None,
website_url: None,
twitter_url: None,
location: None,
skills: None,
experience: None,
education: None,
career_status: None,
role: make_thing("app_roles", &get_role_id(state).await), role: make_thing("app_roles", &get_role_id(state).await),
mentor_id: None, mentor_id: None,
created_at: get_iso_date(), created_at: get_iso_date(),
@@ -71,12 +87,28 @@ mod auth_repository_test {
let mock_user = UsersDetailQueryDto { let mock_user = UsersDetailQueryDto {
id: make_thing(&ResourceEnum::UsersCache.to_string(), &email), id: make_thing(&ResourceEnum::UsersCache.to_string(), &email),
fullname: "Test User".into(), fullname: "Test User".into(),
legal_name: None,
email: email.clone(), email: email.clone(),
avatar: None, avatar: None,
phone_number: "08123456789".into(), phone_number: "08123456789".into(),
phone_for_verification: None,
is_active: true, is_active: true,
gender: None, gender: None,
birthdate: None, birthdate: None,
domicile: None,
bio: None,
last_education: None,
linkedin_url: None,
github_url: None,
cv_url: None,
portfolio_url: None,
website_url: None,
twitter_url: None,
location: None,
skills: None,
experience: None,
education: None,
career_status: None,
role: RolesDetailQueryDto { role: RolesDetailQueryDto {
id: make_thing("app_roles", &Uuid::new_v4().to_string()), id: make_thing("app_roles", &Uuid::new_v4().to_string()),
name: "Dummy Role".into(), name: "Dummy Role".into(),
@@ -0,0 +1,280 @@
#[cfg(test)]
mod tests {
use axum::{
body::Body,
http::{self, Request, StatusCode},
Router,
};
use http_body_util::BodyExt; // for `collect` and `to_bytes`
use tower::ServiceExt; // for `call`, `oneshot`, and `ready`
use mockall::predicate::*;
use mockall::mock;
use serde_json::json;
use imphnen_iam::v1::auth::{AuthLoginResponsetDto, TokenDto};
use imphnen_iam::v1::auth::google::google_oauth_controller::GoogleOauthController;
use imphnen_iam::v1::auth::google::google_oauth_service::{AuthRequest, GoogleOauthService, GoogleOauthServiceImpl};
use imphnen_iam::v1::users::users_dto::{UsersDetailItemDto, UsersCreateRequestDto}; // Corrected: removed UserDto alias, used UsersCreateRequestDto
use imphnen_entities::error_dto::ErrorResponse;
use imphnen_libs::jsonwebtoken::generate_jwt;
use imphnen_libs::enviroment::{ENV, Env}; // Import ENV and Env
mock! {
pub GoogleOauthServiceMock {}
impl GoogleOauthService for GoogleOauthServiceMock {
fn new() -> Self;
fn with_services(auth_service: crate::v1::auth::auth_service::AuthService, users_service: crate::v1::users::users_service::UsersService, env: &'static Env) -> Self; // Updated signature
fn google_oauth_client(&self) -> oauth2::basic::BasicClient;
fn generate_auth_url(&self) -> (url::Url, oauth2::CsrfToken);
async fn google_oauth_callback(&self, auth_request: AuthRequest, app_state: &crate::AppState) -> anyhow::Result<(crate::v1::users::users_dto::UsersDetailItemDto, crate::v1::auth::TokenDto), imphnen_entities::error_dto::error::Error>;
}
}
mock! {
pub AuthServiceMock {}
impl crate::v1::auth::auth_service::AuthServiceTrait for AuthServiceMock {
async fn mutation_login(payload: crate::v1::auth::AuthLoginRequestDto, state: &crate::AppState) -> axum::response::Response;
async fn mutation_mentor_login(payload: crate::v1::auth::AuthLoginRequestDto, state: &crate::AppState) -> axum::response::Response;
async fn mutation_register(payload: crate::v1::auth::AuthRegisterRequestDto, state: &crate::AppState) -> axum::response::Response;
async fn mutation_resend_otp(payload: crate::v1::auth::AuthResendOtpRequestDto, state: &crate::AppState) -> axum::response::Response;
async fn mutation_refresh_token(payload: crate::v1::auth::AuthRefreshTokenRequestDto) -> axum::response::Response;
async fn mutation_forgot_password(payload: crate::v1::auth::AuthResendOtpRequestDto, state: &crate::AppState) -> axum::response::Response;
async fn mutation_verify_email(payload: crate::v1::auth::AuthVerifyEmailRequestDto, state: &crate::AppState) -> axum::response::Response;
async fn mutation_new_password(payload: crate::v1::auth::AuthNewPasswordRequestDto, state: &crate::AppState) -> axum::response::Response;
}
}
mock! {
pub UsersServiceMock {}
impl crate::v1::users::users_service::UsersServiceTrait for UsersServiceMock {
async fn get_user_list(state: &crate::AppState, meta: crate::MetaRequestDto) -> axum::response::Response;
async fn get_user_by_id(state: &crate::AppState, id: String) -> axum::response::Response;
async fn get_user_me(headers: http::HeaderMap, state: &crate::AppState) -> axum::response::Response;
async fn create_user(state: &crate::AppState, new_user: crate::v1::users::UsersCreateRequestDto) -> axum::response::Response;
async fn update_user(state: &crate::AppState, id: String, user: crate::v1::users::UsersUpdateRequestDto) -> axum::response::Response;
async fn update_user_me(state: &crate::AppState, headers: http::HeaderMap, user: crate::v1::users::UsersUpdateRequestDto) -> axum::response::Response;
async fn set_user_active_status(state: &crate::AppState, id: String, payload: crate::v1::users::UsersActiveInactiveRequestDto) -> axum::response::Response;
async fn update_user_password(state: &crate::AppState, email: String, payload: crate::v1::users::UsersSetNewPasswordRequestDto) -> axum::response::Response;
async fn get_user_by_mentor_id(state: &crate::AppState, mentor_id: String) -> axum::response::Response;
async fn delete_user(state: &crate::AppState, id: String) -> axum::response::Response;
async fn get_user_by_email(&self, email: &str) -> anyhow::Result<Option<UsersDetailItemDto>>; // Updated return type
async fn create_user_by_dto(&self, new_user: UsersCreateRequestDto) -> anyhow::Result<UsersDetailItemDto>; // Updated return type
}
}
async fn setup_app_with_mocked_google_oauth_service(
mock_google_oauth_service: MockGoogleOauthServiceMock,
) -> Router {
let google_oauth_controller =
GoogleOauthController::with_service(mock_google_oauth_service);
google_oauth_controller.get_routes()
}
#[tokio::test]
async fn google_login_redirects_to_google_auth_url() {
let app = setup_app_with_mocked_google_oauth_service(
GoogleOauthServiceImpl::with_services(
AuthServiceMock::new(),
UsersServiceMock::new(),
&ENV, // Pass ENV
)
).await;
let request = Request::builder()
.uri("/google/login")
.body(Body::empty())
.unwrap();
let response = app.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::FOUND);
let location_header = response.headers().get(http::header::LOCATION).unwrap();
let location_str = location_header.to_str().unwrap();
assert!(location_str.starts_with("https://accounts.google.com/o/oauth2/v2/auth"));
assert!(location_str.contains("client_id="));
assert!(location_str.contains("redirect_uri="));
assert!(location_str.contains("response_type=code"));
assert!(location_str.contains("scope="));
assert!(location_str.contains("state="));
assert!(location_str.contains("access_type=offline"));
assert!(location_str.contains("prompt=consent"));
}
#[tokio::test]
async fn google_callback_new_user_creates_user_and_returns_login_response() {
let mut mock_google_oauth_service = MockGoogleOauthServiceMock::new();
let mut mock_users_service = UsersServiceMock::new(); // Changed from MockUsersServiceMock to UsersServiceMock
let user_email = "new.user@example.com".to_string();
let expected_access_token = generate_jwt(&user_email, "test_user_id", vec![]).unwrap();
let expected_refresh_token = generate_jwt(&user_email, "test_user_id", vec![]).unwrap();
let expected_response_dto = AuthLoginResponsetDto {
token: TokenDto {
access_token: expected_access_token.clone(),
refresh_token: expected_refresh_token.clone(),
},
user: UsersDetailItemDto {
id: "test_user_id".to_string(),
email: user_email.clone(),
fullname: "Test User".to_string(), // Updated field
phone_number: "1234567890".to_string(), // Updated field
is_active: true,
gender: None, // Added field
birthdate: None, // Added field
created_at: chrono::Utc::now().to_rfc3339(),
updated_at: chrono::Utc::now().to_rfc3339(),
role: imphnen_iam::v1::roles::roles_dto::RolesDetailItemDto {
id: "default_role_id".to_string(),
name: "User".to_string(),
permissions: vec![],
created_at: chrono::Utc::now().to_rfc3339(),
updated_at: chrono::Utc::now().to_rfc3339(),
},
},
};
mock_google_oauth_service.expect_google_oauth_callback()
.with(eq(AuthRequest { code: "some_code".to_string(), state: "some_state".to_string() }))
.returning(move |_| Ok(expected_response_dto.clone()));
mock_users_service.expect_get_user_by_email()
.with(eq(user_email.clone()))
.returning(|_| Ok(None)); // Simulate no existing user
mock_users_service.expect_create_user_by_dto()
.returning(|create_user_dto| {
Ok(UsersDetailItemDto { // Changed to UsersDetailItemDto
id: "test_user_id".to_string(),
email: create_user_dto.email,
fullname: create_user_dto.fullname, // Updated field
phone_number: create_user_dto.phone_number, // Updated field
is_active: create_user_dto.is_active,
gender: None, // Added field
birthdate: None, // Added field
created_at: chrono::Utc::now().to_rfc3339(),
updated_at: chrono::Utc::now().to_rfc3339(),
role: imphnen_iam::v1::roles::roles_dto::RolesDetailItemDto {
id: "default_role_id".to_string(),
name: "User".to_string(),
permissions: vec![],
created_at: chrono::Utc::now().to_rfc3339(),
updated_at: chrono::Utc::now().to_rfc3339(),
},
})
});
let app = setup_app_with_mocked_google_oauth_service(
GoogleOauthServiceImpl::with_services(
AuthServiceMock::new(),
mock_users_service,
&ENV, // Pass ENV here
)
).await;
let request = Request::builder()
.uri("/google/callback?code=some_code&state=some_state")
.body(Body::empty())
.unwrap();
let response = app.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response.into_body().collect().await.unwrap().to_bytes();
let json_body: AuthLoginResponsetDto = serde_json::from_slice(&body).unwrap();
assert_eq!(json_body.token.access_token, expected_access_token);
assert_eq!(json_body.token.refresh_token, expected_refresh_token);
assert_eq!(json_body.user.email, user_email);
}
#[tokio::test]
async fn google_callback_existing_user_returns_login_response() {
let mut mock_google_oauth_service = MockGoogleOauthServiceMock::new();
let mut mock_users_service = UsersServiceMock::new(); // Changed from MockUsersServiceMock to UsersServiceMock
let user_email = "existing.user@example.com".to_string();
let expected_access_token = generate_jwt(&user_email, "existing_user_id", vec![]).unwrap();
let expected_refresh_token = generate_jwt(&user_email, "existing_user_id", vec![]).unwrap();
let existing_user_dto = UsersDetailItemDto { // Changed from UserDto to UsersDetailItemDto
id: "existing_user_id".to_string(),
email: user_email.clone(),
fullname: "Existing User".to_string(), // Updated field
phone_number: "0987654321".to_string(), // Updated field
is_active: true,
gender: None, // Added field
birthdate: None, // Added field
created_at: chrono::Utc::now().to_rfc3339(),
updated_at: chrono::Utc::now().to_rfc3339(),
role: imphnen_iam::v1::roles::roles_dto::RolesDetailItemDto {
id: "default_role_id".to_string(),
name: "User".to_string(),
permissions: vec![],
created_at: chrono::Utc::now().to_rfc3339(),
updated_at: chrono::Utc::now().to_rfc3339(),
},
};
let expected_response_dto = AuthLoginResponsetDto {
token: TokenDto {
access_token: expected_access_token.clone(),
refresh_token: expected_refresh_token.clone(),
},
user: existing_user_dto.clone(), // Cloned
};
mock_google_oauth_service.expect_google_oauth_callback()
.with(eq(AuthRequest { code: "some_code".to_string(), state: "some_state".to_string() }))
.returning(move |_| Ok(expected_response_dto.clone()));
mock_users_service.expect_get_user_by_email()
.with(eq(user_email.clone()))
.returning(move |_| {
Ok(Some(UsersDetailItemDto { // Changed to UsersDetailItemDto
id: "existing_user_id".to_string(),
email: user_email.clone(),
fullname: "Existing User".to_string(), // Updated field
phone_number: "0987654321".to_string(), // Updated field
is_active: true,
gender: None, // Added field
birthdate: None, // Added field
created_at: chrono::Utc::now().to_rfc3339(),
updated_at: chrono::Utc::now().to_rfc3339(),
role: imphnen_iam::v1::roles::roles_dto::RolesDetailItemDto {
id: "default_role_id".to_string(),
name: "User".to_string(),
permissions: vec![],
created_at: chrono::Utc::now().to_rfc3339(),
updated_at: chrono::Utc::now().to_rfc3339(),
},
}))
}); // Simulate existing user
// Ensure create_user_by_dto is NOT called for existing user
mock_users_service.expect_create_user_by_dto().times(0);
let app = setup_app_with_mocked_google_oauth_service(
GoogleOauthServiceImpl::with_services(
AuthServiceMock::new(),
mock_users_service,
&ENV, // Pass ENV here
)
).await;
let request = Request::builder()
.uri("/google/callback?code=some_code&state=some_state")
.body(Body::empty())
.unwrap();
let response = app.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response.into_body().collect().await.unwrap().to_bytes();
let json_body: AuthLoginResponsetDto = serde_json::from_slice(&body).unwrap();
assert_eq!(json_body.token.access_token, expected_access_token);
assert_eq!(json_body.token.refresh_token, expected_refresh_token);
assert_eq!(json_body.user.email, user_email);
}
}
View File
+16
View File
@@ -21,13 +21,29 @@ pub fn create_test_user(
id: make_thing("app_users", &Uuid::new_v4().to_string()), id: make_thing("app_users", &Uuid::new_v4().to_string()),
email: email.to_string(), email: email.to_string(),
fullname: format!("Randomize {} {}", fullname, rand::random::<u32>()), fullname: format!("Randomize {} {}", fullname, rand::random::<u32>()),
legal_name: None,
password: hash_password("secret").unwrap(), password: hash_password("secret").unwrap(),
is_deleted: false, is_deleted: false,
avatar: None, avatar: None,
phone_number: "081234567890".to_string(), phone_number: "081234567890".to_string(),
phone_for_verification: None,
is_active, is_active,
gender: None, gender: None,
birthdate: None, birthdate: None,
domicile: None,
bio: None,
last_education: None,
linkedin_url: None,
github_url: None,
cv_url: None,
portfolio_url: None,
website_url: None,
twitter_url: None,
location: None,
skills: None,
experience: None,
education: None,
career_status: None,
role: make_thing("app_roles", role_id), role: make_thing("app_roles", role_id),
created_at: get_iso_date(), created_at: get_iso_date(),
updated_at: get_iso_date(), updated_at: get_iso_date(),
+18 -12
View File
@@ -1,7 +1,7 @@
// Restore only the necessary imports to fix unresolved function errors
use crate::{get_iso_date, hash_password}; use crate::{get_iso_date, hash_password};
use imphnen_entities::AppState; use imphnen_entities::AppState;
use imphnen_iam::{PermissionsEnum, UsersSchema}; use imphnen_iam::{PermissionsEnum, UsersSchema};
use imphnen_libs::enviroment::load_env;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use strum::IntoEnumIterator; use strum::IntoEnumIterator;
use surrealdb::engine::{any, local}; use surrealdb::engine::{any, local};
@@ -29,23 +29,15 @@ struct RoleSeedData {
} }
pub async fn create_mock_app_state() -> AppState { pub async fn create_mock_app_state() -> AppState {
load_env();
let db_ws = any::connect("ws://127.00.1:8000/rpc").await.unwrap(); let db_ws = any::connect("ws://127.00.1:8000/rpc").await.unwrap();
let db_mem = Surreal::new::<local::Mem>(()).await.unwrap(); let db_mem = Surreal::new::<local::Mem>(()).await.unwrap();
let unique_id = Uuid::new_v4().to_string(); let unique_id = Uuid::new_v4().to_string();
let ns = format!("test_ns_{unique_id}"); let ns = format!("test_ns_{unique_id}");
let db = format!("test_db_{unique_id}"); let db = format!("test_db_{unique_id}");
db_ws db_ws
.signin(Root { .signin(Root {
username: "root", username: "root",
@@ -54,11 +46,8 @@ pub async fn create_mock_app_state() -> AppState {
.await .await
.unwrap(); .unwrap();
db_ws.use_ns(&ns).use_db(&db).await.unwrap(); db_ws.use_ns(&ns).use_db(&db).await.unwrap();
AppState { AppState {
surrealdb_ws: db_ws, surrealdb_ws: db_ws,
surrealdb_mem: db_mem, surrealdb_mem: db_mem,
@@ -160,15 +149,31 @@ pub async fn seed_users_for_test(
let user = UsersSchema { let user = UsersSchema {
id: Thing::from(("app_users", id)), id: Thing::from(("app_users", id)),
fullname: fullname.into(), fullname: fullname.into(),
legal_name: None,
email: email.into(), email: email.into(),
password: hash_password("password").unwrap(), password: hash_password("password").unwrap(),
avatar: None, avatar: None,
phone_number: "081234567890".into(), phone_number: "081234567890".into(),
phone_for_verification: None,
is_active: true, is_active: true,
is_deleted: false, is_deleted: false,
mentor_id: None, mentor_id: None,
gender: None, gender: None,
birthdate: None, birthdate: None,
domicile: None,
bio: None,
last_education: None,
linkedin_url: None,
github_url: None,
cv_url: None,
portfolio_url: None,
website_url: None,
twitter_url: None,
location: None,
skills: None,
experience: None,
education: None,
career_status: None,
role: Thing::from(("app_roles", role_id)), role: Thing::from(("app_roles", role_id)),
created_at: get_iso_date(), created_at: get_iso_date(),
updated_at: get_iso_date(), updated_at: get_iso_date(),
@@ -183,6 +188,7 @@ pub async fn seed_users_for_test(
} }
pub async fn setup_all_test_environment() -> AppState { pub async fn setup_all_test_environment() -> AppState {
debug!("Setting up all test environment in setup_all_test_environment()");
let app_state = create_mock_app_state().await; let app_state = create_mock_app_state().await;
app_state app_state
.surrealdb_mem .surrealdb_mem