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.
This commit is contained in:
MythEclipse
2025-08-14 13:16:39 +07:00
parent f6232832af
commit 5f51dd281d
16 changed files with 770 additions and 17 deletions
@@ -116,6 +116,7 @@ impl<'a> AuthRepository<'a> {
skills: None,
experience: None,
education: None,
career_status: None,
password: String::new(),
role: role_detail_query_dto,
created_at: String::new(),
@@ -1,5 +1,5 @@
use super::PermissionsEnum;
use crate::{AppState, AuthRepository, common_response, extract_email, extract_email_async};
use crate::{AppState, AuthRepository, common_response, extract_email, extract_email_async, UsersDetailQueryDto};
use axum::{
http::{HeaderMap, StatusCode},
response::Response,
@@ -9,7 +9,7 @@ pub async fn permissions_guard(
headers: &HeaderMap,
state: AppState,
required_permissions: Vec<PermissionsEnum>,
) -> Result<(), Response> {
) -> Result<UsersDetailQueryDto, Response> {
let auth_repo = AuthRepository::new(&state);
// Try synchronous email extraction first (for internal JWT tokens)
@@ -38,9 +38,8 @@ pub async fn permissions_guard(
"User session expired or not found",
)
})?;
let role = raw_user.role;
let role_permissions: Vec<String> =
role.permissions.into_iter().map(|perm| perm.name).collect();
raw_user.role.permissions.iter().map(|perm| perm.name.clone()).collect();
for required in &required_permissions {
let required_str = required.to_string();
@@ -52,5 +51,5 @@ pub async fn permissions_guard(
));
}
}
Ok(())
Ok(raw_user)
}
+1
View File
@@ -25,4 +25,5 @@ pub fn users_router() -> Router {
.route("/detail/{id}", get(get_user_by_id))
.route("/update/{id}", put(put_update_user))
.route("/update/me", put(put_update_user_me))
.route("/upload", post(upload_file))
}
+51 -6
View File
@@ -3,7 +3,7 @@ use crate::{
MessageResponseDto, PermissionsEnum, ResponseListSuccessDto, ResponseSuccessDto,
UsersCreateRequestDto, UsersDetailItemDto, permissions_guard,
};
use axum::extract::{Path}; // Removed Query
use axum::extract::{Path, Multipart};
use axum::http::HeaderMap;
use axum::response::IntoResponse;
use axum::{Extension, Json};
@@ -72,7 +72,7 @@ pub async fn get_user_by_id(
match permissions_guard(
&headers,
state.clone(),
vec![PermissionsEnum::ReadDetailUsers],
vec![PermissionsEnum::ReadListUsers],
)
.await
{
@@ -110,7 +110,7 @@ pub async fn get_user_me(
path = "/v1/users/create",
request_body = UsersCreateRequestDto,
responses(
(status = 201, description = "Create new user", body = MessageResponseDto)
(status = 200, description = "Create new user", body = ResponseSuccessDto<UsersDetailItemDto>)
),
tag = "Users"
)]
@@ -137,9 +137,12 @@ pub async fn post_create_user(
("Bearer" = [])
),
path = "/v1/users/update/{id}",
params(
("id" = String, Path, description = "User ID")
),
request_body = UsersUpdateRequestDto,
responses(
(status = 200, description = "Update user", body = MessageResponseDto)
(status = 200, description = "Update user", body = ResponseSuccessDto<UsersDetailItemDto>)
),
tag = "Users"
)]
@@ -169,7 +172,7 @@ pub async fn put_update_user(
path = "/v1/users/update/me",
request_body = UsersUpdateRequestDto,
responses(
(status = 200, description = "Update user me", body = MessageResponseDto)
(status = 200, description = "Update current user", body = ResponseSuccessDto<UsersDetailItemDto>)
),
tag = "Users"
)]
@@ -190,9 +193,12 @@ pub async fn put_update_user_me(
("Bearer" = [])
),
path = "/v1/users/activate/{id}",
params(
("id" = String, Path, description = "User ID")
),
request_body = UsersActiveInactiveRequestDto,
responses(
(status = 200, description = "Set user active/inactive", body = MessageResponseDto)
(status = 200, description = "Set user active status", body = MessageResponseDto)
),
tag = "Users"
)]
@@ -241,3 +247,42 @@ pub async fn delete_user(
Err(response) => response,
}
}
#[utoipa::path(
post,
security(
("Bearer" = [])
),
path = "/v1/users/upload",
request_body(content = String, description = "Upload 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,
state.clone(),
vec![], // No specific permission needed, just authentication
)
.await
{
Ok(user) => {
// Extract user ID from user data
let user_id = user.id.to_string();
// Process upload - don't use match here since it returns Response directly
UsersService::upload_file(&state, user_id, multipart).await
},
Err(response) => response,
}
}
+8
View File
@@ -145,6 +145,8 @@ pub struct UsersUpdateRequestDto {
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)]
@@ -173,6 +175,7 @@ pub struct UsersDetailItemDto {
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 updated_at: String,
}
@@ -204,6 +207,7 @@ impl UsersDetailItemDto {
skills: dto.skills.clone(),
experience: dto.experience.clone(),
education: dto.education.clone(),
career_status: dto.career_status.clone(),
created_at: dto.created_at.clone(),
updated_at: dto.updated_at.clone(),
}
@@ -235,6 +239,7 @@ impl UsersDetailItemDto {
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(),
}
@@ -309,6 +314,7 @@ pub struct UsersDetailQueryDto {
pub skills: Option<Vec<String>>,
pub experience: Option<Vec<ExperienceDto>>,
pub education: Option<Vec<EducationDto>>,
pub career_status: Option<String>,
pub password: String,
pub role: RolesDetailQueryDto,
pub created_at: String,
@@ -343,6 +349,7 @@ impl UsersDetailQueryDto {
skills: self.skills.clone(),
experience: self.experience.clone(),
education: self.education.clone(),
career_status: self.career_status.clone(),
is_deleted: self.is_deleted,
password: self.password.clone(),
birthdate: self.birthdate.clone(),
@@ -379,6 +386,7 @@ impl From<&UsersDetailItemDto> for UsersDetailQueryDto {
skills: dto.skills.clone(),
experience: dto.experience.clone(),
education: dto.education.clone(),
career_status: dto.career_status.clone(),
password: String::new(),
role: RolesDetailQueryDto::default(),
created_at: dto.created_at.clone(),
+8
View File
@@ -52,6 +52,8 @@ pub struct UsersSchema {
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 created_at: String,
pub updated_at: String,
@@ -89,6 +91,7 @@ impl Default for UsersSchema {
skills: None,
experience: None,
education: None,
career_status: None,
role: make_thing(
&ResourceEnum::Roles.to_string(),
"5713cb37-dc02-4e87-8048-d7a41d352059",
@@ -127,6 +130,7 @@ impl UsersSchema {
skills: dto.skills,
experience: dto.experience,
education: dto.education,
career_status: dto.career_status,
password: dto.password,
created_at: dto.created_at,
updated_at: dto.updated_at,
@@ -219,6 +223,9 @@ impl UsersSchema {
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);
}
@@ -256,6 +263,7 @@ impl UsersSchema {
skills: None,
experience: None,
education: None,
career_status: None,
avatar: user.avatar,
is_deleted: false,
role: make_thing(&ResourceEnum::Roles.to_string(), &user.role_id),
+42 -1
View File
@@ -10,7 +10,7 @@ use crate::{
success_response, validate_request,
};
use axum::http::HeaderMap;
use axum::{http::StatusCode, response::Response};
use axum::{http::StatusCode, response::Response, extract::Multipart};
use imphnen_libs::{ResourceEnum, hash_password, verify_password, surrealdb_init_ws, surrealdb_init_mem};
use imphnen_utils::make_thing;
use uuid::Uuid;
@@ -18,6 +18,7 @@ use anyhow::Result;
use async_trait::async_trait;
use tracing::info;
use crate::v1::users::users_dto::{UsersDetailItemDto as UserDto, UsersCreateRequestDto as CreateUserDto};
use serde_json::json;
#[async_trait]
pub trait UsersServiceTrait: Send + Sync + 'static {
@@ -35,6 +36,7 @@ pub trait UsersServiceTrait: Send + Sync + 'static {
async fn get_user_by_email(&self, email: &str) -> Result<Option<UserDto>>;
async fn create_user_by_dto(&self, new_user: CreateUserDto) -> Result<UserDto>;
async fn update_user_avatar(&self, email: &str, avatar_url: Option<String>) -> Result<()>;
async fn upload_file(state: &AppState, user_id: String, multipart: Multipart) -> Response;
}
#[derive(Clone)]
@@ -362,4 +364,43 @@ impl UsersServiceTrait for UsersService {
Err(e) => Err(anyhow::anyhow!("Failed to update user avatar: {}", e)),
}
}
async fn upload_file(_state: &AppState, _user_id: String, mut multipart: Multipart) -> Response {
// Temporary implementation without MinIO to fix compilation
// Process multipart form
while let Some(field) = multipart.next_field().await.unwrap_or(None) {
let name = field.name().unwrap_or("").to_string();
let filename = field.file_name().map(|s| s.to_string()).unwrap_or_else(|| "unnamed".to_string());
let content_type = field.content_type().map(|s| s.to_string()).unwrap_or_else(|| "application/octet-stream".to_string());
// Get file data
let data = match field.bytes().await {
Ok(bytes) => bytes,
Err(_) => {
return common_response(
StatusCode::BAD_REQUEST,
"Failed to read file data",
);
}
};
// For now, just return basic file info
let response_data = json!({
"field_name": name,
"filename": filename,
"content_type": content_type,
"size": data.len(),
"message": "File received successfully (MinIO upload will be implemented later)"
});
return success_response(ResponseSuccessDto {
data: response_data,
});
}
common_response(
StatusCode::BAD_REQUEST,
"No file provided",
)
}
}