refactor: migrate to clean architecture with trait-based DI (v0.2.0)

Complete architectural overhaul across all 12 crates:

- Replace validator crate with zod-rs for all DTO validation
- Replace manual pagination with paginator-rs/paginator-sea-orm
- Migrate all modules (iam, cms, gacha, dimentorin) to clean architecture:
  domain → application → infrastructure layers
- Introduce trait-based DI (Arc<dyn Trait>) at every layer for repositories and services
- Delete all v1/ legacy SurrealDB-era code across every crate
- Replace opaque response helpers with typed IntoResponse structs (ApiSuccess, ApiCreated, ApiPaginated, ApiMessage)
- Remove dual_mode_repository, migration_validation_errors, validator.rs dead code
- Zero cargo clippy warnings; release build clean

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
maulanasdqn
2026-04-02 13:39:52 +07:00
co-authored by Claude Sonnet 4.6
parent 1b3366d735
commit e432a1a743
379 changed files with 9013 additions and 30532 deletions
+4 -11
View File
@@ -1,12 +1,5 @@
pub mod v1;
// Explicitly export only what's needed from v1
pub use v1::dimentorin_router;
pub use v1::mentors::mentors_router;
pub use v1::sessions::{
sessions_router, BookSessionRequestDto, BookSessionResponseDto, MentorAvailabilityDto,
SessionFeedbackRequestDto, SessionFeedbackResponseDto, SessionListItemDto,
SessionListResponseDto, UpdateSessionStatusRequestDto, UpdateSessionStatusResponseDto,
AvailabilitySlotDto,
};
pub mod mentors;
pub mod sessions;
pub use mentors::{mentors_public_routes, mentors_protected_routes};
pub use sessions::{sessions_public_routes, sessions_protected_routes};
@@ -0,0 +1,411 @@
use std::sync::Arc;
use async_trait::async_trait;
use paginator_rs::PaginationParams;
use paginator_utils::PaginatorResponse;
use uuid::Uuid;
use imphnen_utils::AppError;
use imphnen_libs::{AppState, hash_password};
use imphnen_entities::{RolesDetailQueryDto, users::UserProfileExtensionDto};
use imphnen_iam::users::domain::{UserRepository, UserEntity};
use imphnen_iam::roles::domain::RoleRepository;
use tracing::error;
use crate::mentors::domain::{MentorEntity, MentorRepository, MentorService};
use crate::mentors::infrastructure::http::dto::{
MentorDetailResponseDto, MentorListResponseDto, MentorRegisterResponseDto,
MentorUpdateRequestDto, MentorUserRegisterRequestDto, MentorVerifyRequestDto,
};
pub struct MentorServiceImpl {
repo: Arc<dyn MentorRepository>,
state: Arc<AppState>,
user_repo: Arc<dyn UserRepository>,
role_repo: Arc<dyn RoleRepository>,
}
impl MentorServiceImpl {
pub fn new(
repo: Arc<dyn MentorRepository>,
state: Arc<AppState>,
user_repo: Arc<dyn UserRepository>,
role_repo: Arc<dyn RoleRepository>,
) -> Self {
Self { repo, state, user_repo, role_repo }
}
fn build_detail_response(
entity: &MentorEntity,
user: Option<&imphnen_entities::UsersDetailQueryDto>,
) -> MentorDetailResponseDto {
MentorDetailResponseDto {
id: entity.id.to_string(),
user_id: entity.user_id.to_string(),
fullname: user.map(|u| u.fullname.clone()),
email: user.map(|u| u.email.clone()),
legal_name: user.and_then(|u| u.legal_name.clone()),
gender: user
.and_then(|u| u.profile_extension.as_ref())
.and_then(|ext| ext.gender.clone()),
domicile: user
.and_then(|u| u.profile_extension.as_ref())
.and_then(|ext| ext.domicile.clone()),
phone_for_verification: user
.and_then(|u| u.profile_extension.as_ref())
.and_then(|ext| ext.phone_for_verification.clone()),
bio: user
.and_then(|u| u.profile_extension.as_ref())
.and_then(|ext| ext.bio.clone()),
last_education: user
.and_then(|u| u.profile_extension.as_ref())
.and_then(|ext| ext.last_education.clone()),
linkedin_url: user
.and_then(|u| u.profile_extension.as_ref())
.and_then(|ext| ext.linkedin_url.clone()),
github_url: user
.and_then(|u| u.profile_extension.as_ref())
.and_then(|ext| ext.github_url.clone()),
cv_url: user
.and_then(|u| u.profile_extension.as_ref())
.and_then(|ext| ext.cv_url.clone()),
portfolio_url: user
.and_then(|u| u.profile_extension.as_ref())
.and_then(|ext| ext.portfolio_url.clone()),
industries: entity.industries.clone(),
expertise: entity.expertise.clone(),
languages: entity.languages.clone(),
current_company: entity.current_company.clone(),
current_role: entity.current_role.clone(),
years_of_experience: entity.years_of_experience,
topics_of_interest: entity.topics_of_interest.clone(),
preferred_mentee_level: entity.preferred_mentee_level.clone(),
preferred_mentoring_formats: entity.preferred_mentoring_formats.clone(),
availability_commitment: entity.availability_commitment.clone(),
mentoring_rate: entity.mentoring_rate,
status: entity.status.clone(),
created_at: entity.created_at.to_rfc3339(),
updated_at: entity.updated_at.to_rfc3339(),
}
}
}
#[async_trait]
impl MentorService for MentorServiceImpl {
async fn list(
&self,
params: PaginationParams,
) -> Result<PaginatorResponse<MentorListResponseDto>, AppError> {
let result = self.repo.find_all(params).await?;
let mut items: Vec<MentorListResponseDto> = Vec::with_capacity(result.data.len());
for entity in &result.data {
let mut item = MentorListResponseDto {
id: entity.id.to_string(),
user_id: entity.user_id.to_string(),
fullname: None,
email: None,
status: entity.status.clone(),
created_at: entity.created_at.to_rfc3339(),
updated_at: entity.updated_at.to_rfc3339(),
};
if let Ok(info) = self.state.user_lookup_service
.get_user_by_id(entity.user_id, self.state.as_ref())
.await
{
item.fullname = Some(info.basic_info.fullname);
item.email = Some(info.basic_info.email);
}
items.push(item);
}
Ok(PaginatorResponse { data: items, meta: result.meta })
}
async fn get_by_id(&self, id: Uuid) -> Result<MentorDetailResponseDto, AppError> {
let entity = self.repo.find_by_id(id, false).await?;
let user = self.state.user_lookup_service
.get_user_by_id(entity.user_id, self.state.as_ref())
.await
.ok()
.map(|i| i.basic_info);
Ok(Self::build_detail_response(&entity, user.as_ref()))
}
async fn get_by_email(&self, email: &str) -> Result<MentorDetailResponseDto, AppError> {
let user_dto = self.state.user_lookup_service
.get_user_by_email(email, self.state.as_ref())
.await
.map(|i| i.basic_info)
.map_err(|_| AppError::NotFoundError("User not found".to_string()))?;
let user_id = Uuid::parse_str(&user_dto.id)
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
let entity = self.repo.find_by_user_id(user_id, false).await?;
Ok(Self::build_detail_response(&entity, Some(&user_dto)))
}
async fn register(
&self,
dto: MentorUserRegisterRequestDto,
) -> Result<MentorRegisterResponseDto, AppError> {
let user_email = dto.email.clone();
let user_id: Uuid = match self.user_repo.find_by_email(user_email.clone()).await {
Ok(mut entity) => {
let existing_user_id = Uuid::parse_str(&entity.id)
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
if self.repo.find_by_user_id(existing_user_id, false).await.is_ok() {
return Err(AppError::ConflictError(
"Mentor profile already exists for this user".to_string(),
));
}
let mentor_role = self.role_repo
.find_by_name("Mentor".to_string())
.await
.map_err(|_| AppError::BadRequestError("Mentor Role Not Found".to_string()))?;
entity.fullname = dto.fullname.clone();
entity.is_active = false;
entity.role = RolesDetailQueryDto {
id: mentor_role.id.to_string(),
name: mentor_role.name.clone(),
..Default::default()
};
entity.password = hash_password(&dto.password).map_err(|e| {
error!("Failed to hash password for {}: {}", user_email, e);
AppError::InternalServerError("Failed to hash password".to_string())
})?;
let mut profile_ext = entity.profile_extension.clone().unwrap_or_default();
profile_ext.phone_number = dto.phone_number.clone();
profile_ext.phone_for_verification = dto.identity_and_verification.phone_for_verification.clone();
profile_ext.gender = dto.identity_and_verification.gender.clone();
profile_ext.domicile = dto.identity_and_verification.domicile.clone();
profile_ext.bio = Some(dto.professional_profile.bio.clone());
profile_ext.last_education = dto.professional_profile.last_education.clone();
profile_ext.linkedin_url = dto.professional_profile.linkedin_url.clone();
profile_ext.github_url = dto.professional_profile.github_url.clone();
profile_ext.cv_url = dto.professional_profile.cv_url.clone();
profile_ext.portfolio_url = dto.professional_profile.portfolio_url.clone();
entity.profile_extension = Some(profile_ext);
let uid_str = entity.id.clone();
self.user_repo.update(entity).await.map_err(|e| {
error!("Failed to update user {} to mentor role: {}", user_email, e);
AppError::InternalServerError(e.to_string())
})?;
Uuid::parse_str(&uid_str)
.map_err(|e| AppError::InternalServerError(e.to_string()))?
}
Err(_) => {
let mentor_role = self.role_repo
.find_by_name("Mentor".to_string())
.await
.map_err(|_| AppError::BadRequestError("Mentor Role Not Found".to_string()))?;
let hashed_password = hash_password(&dto.password).map_err(|e| {
error!("Failed to hash password for new user {}: {}", user_email, e);
AppError::InternalServerError("Failed to hash password".to_string())
})?;
let new_user_id = Uuid::new_v4();
let profile_ext = UserProfileExtensionDto {
phone_number: dto.phone_number.clone(),
phone_for_verification: dto.identity_and_verification.phone_for_verification.clone(),
gender: dto.identity_and_verification.gender.clone(),
domicile: dto.identity_and_verification.domicile.clone(),
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(),
..Default::default()
};
let new_entity = UserEntity {
id: new_user_id.to_string(),
email: dto.email.clone(),
fullname: dto.fullname.clone(),
legal_name: Some(dto.identity_and_verification.legal_name.clone()),
password: hashed_password,
is_active: false,
role: RolesDetailQueryDto {
id: mentor_role.id.to_string(),
name: mentor_role.name.clone(),
..Default::default()
},
profile_extension: Some(profile_ext),
created_at: imphnen_utils::get_iso_date(),
updated_at: imphnen_utils::get_iso_date(),
..Default::default()
};
self.user_repo.create(new_entity).await.map_err(|e| {
error!("Failed to create new user {}: {}", user_email, e);
AppError::InternalServerError(e.to_string())
})?;
new_user_id
}
};
let new_entity = MentorEntity {
id: Uuid::new_v4(),
user_id,
industries: dto.professional_profile.industries.clone(),
expertise: dto.professional_profile.expertise.clone(),
languages: dto.professional_profile.languages.clone(),
current_company: dto.professional_profile.current_company.clone(),
current_role: dto.professional_profile.current_role.clone(),
years_of_experience: dto.professional_profile.years_of_experience,
topics_of_interest: dto.mentoring_logistics.topics_of_interest.clone(),
preferred_mentee_level: dto.mentoring_logistics.preferred_mentee_level.clone(),
preferred_mentoring_formats: dto.mentoring_logistics.preferred_mentoring_formats.clone(),
availability_commitment: dto.mentoring_logistics.availability_commitment.clone(),
mentoring_rate: dto.mentoring_logistics.mentoring_rate_amount as f64,
status: "pending".to_string(),
is_deleted: false,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
};
let mentor_id = self.repo.create(new_entity.clone()).await.map_err(|e| {
error!("Failed to create mentor profile for {}: {}", user_email, e);
e
})?;
Ok(MentorRegisterResponseDto {
id: mentor_id.to_string(),
user_id: user_id.to_string(),
email: Some(user_email),
status: "pending".to_string(),
created_at: new_entity.created_at.to_rfc3339(),
updated_at: new_entity.updated_at.to_rfc3339(),
})
}
async fn update(
&self,
id: Uuid,
dto: MentorUpdateRequestDto,
) -> Result<MentorDetailResponseDto, AppError> {
let mut entity = self.repo.find_by_id(id, false).await?;
if let Some(val) = dto.industries { entity.industries = val; }
if let Some(val) = dto.expertise { entity.expertise = val; }
if let Some(val) = dto.languages { entity.languages = val; }
if let Some(val) = dto.current_company { entity.current_company = val; }
if let Some(val) = dto.current_role { entity.current_role = val; }
if let Some(val) = dto.years_of_experience { entity.years_of_experience = val; }
if let Some(val) = dto.topics_of_interest { entity.topics_of_interest = val; }
if let Some(val) = dto.preferred_mentee_level { entity.preferred_mentee_level = val; }
if let Some(val) = dto.preferred_mentoring_formats { entity.preferred_mentoring_formats = val; }
if let Some(val) = dto.availability_commitment { entity.availability_commitment = val; }
if let Some(val) = dto.mentoring_rate_amount { entity.mentoring_rate = val as f64; }
entity.updated_at = chrono::Utc::now();
self.repo.update(entity).await?;
let updated = self.repo.find_by_id(id, false).await?;
let user = self.state.user_lookup_service
.get_user_by_id(updated.user_id, self.state.as_ref())
.await
.ok()
.map(|i| i.basic_info);
Ok(Self::build_detail_response(&updated, user.as_ref()))
}
async fn update_me(
&self,
email: &str,
dto: MentorUpdateRequestDto,
) -> Result<MentorDetailResponseDto, AppError> {
let user_dto = self.state.user_lookup_service
.get_user_by_email(email, self.state.as_ref())
.await
.map(|i| i.basic_info)
.map_err(|_| AppError::NotFoundError("User not found".to_string()))?;
let user_id = Uuid::parse_str(&user_dto.id)
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
let mut entity = self.repo.find_by_user_id(user_id, false).await?;
if let Some(val) = dto.industries { entity.industries = val; }
if let Some(val) = dto.expertise { entity.expertise = val; }
if let Some(val) = dto.languages { entity.languages = val; }
if let Some(val) = dto.current_company { entity.current_company = val; }
if let Some(val) = dto.current_role { entity.current_role = val; }
if let Some(val) = dto.years_of_experience { entity.years_of_experience = val; }
if let Some(val) = dto.topics_of_interest { entity.topics_of_interest = val; }
if let Some(val) = dto.preferred_mentee_level { entity.preferred_mentee_level = val; }
if let Some(val) = dto.preferred_mentoring_formats { entity.preferred_mentoring_formats = val; }
if let Some(val) = dto.availability_commitment { entity.availability_commitment = val; }
if let Some(val) = dto.mentoring_rate_amount { entity.mentoring_rate = val as f64; }
entity.updated_at = chrono::Utc::now();
let entity_id = entity.id;
self.repo.update(entity).await?;
let updated = self.repo.find_by_id(entity_id, false).await?;
let refreshed_user = self.state.user_lookup_service
.get_user_by_id(updated.user_id, self.state.as_ref())
.await
.ok()
.map(|i| i.basic_info);
Ok(Self::build_detail_response(&updated, refreshed_user.as_ref()))
}
async fn delete(&self, id: Uuid) -> Result<(), AppError> {
self.repo.soft_delete(id).await
}
async fn verify(
&self,
id: Uuid,
dto: MentorVerifyRequestDto,
) -> Result<MentorDetailResponseDto, AppError> {
let mut entity = self.repo.find_by_id(id, false).await?;
entity.status = dto.status;
entity.updated_at = chrono::Utc::now();
self.repo.update(entity).await?;
let updated = self.repo.find_by_id(id, false).await?;
let user = self.state.user_lookup_service
.get_user_by_id(updated.user_id, self.state.as_ref())
.await
.ok()
.map(|i| i.basic_info);
Ok(Self::build_detail_response(&updated, user.as_ref()))
}
async fn get_status(&self, email: &str) -> Result<String, AppError> {
let user_dto = self.state.user_lookup_service
.get_user_by_email(email, self.state.as_ref())
.await
.map(|i| i.basic_info)
.map_err(|_| {
AppError::NotFoundError("No mentor application found for current user".to_string())
})?;
let user_id = Uuid::parse_str(&user_dto.id)
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
let entity = self.repo.find_by_user_id(user_id, false).await.map_err(|_| {
AppError::NotFoundError("No mentor application found for current user".to_string())
})?;
Ok(entity.status)
}
async fn get_entity_by_id(
&self,
id: Uuid,
include_deleted: bool,
) -> Result<MentorEntity, AppError> {
self.repo.find_by_id(id, include_deleted).await
}
}
@@ -0,0 +1,3 @@
pub mod mentor_service;
pub use mentor_service::MentorServiceImpl;
@@ -0,0 +1,22 @@
use uuid::Uuid;
#[derive(Clone, Debug)]
pub struct MentorEntity {
pub id: Uuid,
pub user_id: Uuid,
pub industries: Vec<String>,
pub expertise: Vec<String>,
pub languages: Vec<String>,
pub current_company: String,
pub current_role: String,
pub years_of_experience: i32,
pub topics_of_interest: Vec<String>,
pub preferred_mentee_level: Vec<String>,
pub preferred_mentoring_formats: Vec<String>,
pub availability_commitment: String,
pub mentoring_rate: f64,
pub status: String,
pub is_deleted: bool,
pub created_at: chrono::DateTime<chrono::Utc>,
pub updated_at: chrono::DateTime<chrono::Utc>,
}
@@ -0,0 +1,7 @@
pub mod mentor;
pub mod repository;
pub mod service;
pub use mentor::MentorEntity;
pub use repository::MentorRepository;
pub use service::MentorService;
@@ -0,0 +1,33 @@
use async_trait::async_trait;
use paginator_rs::PaginationParams;
use paginator_utils::PaginatorResponse;
use uuid::Uuid;
use imphnen_utils::AppError;
use super::mentor::MentorEntity;
#[async_trait]
pub trait MentorRepository: Send + Sync {
async fn find_all(
&self,
params: PaginationParams,
) -> Result<PaginatorResponse<MentorEntity>, AppError>;
async fn find_by_id(
&self,
id: Uuid,
include_deleted: bool,
) -> Result<MentorEntity, AppError>;
async fn find_by_user_id(
&self,
user_id: Uuid,
include_deleted: bool,
) -> Result<MentorEntity, AppError>;
async fn create(&self, entity: MentorEntity) -> Result<Uuid, AppError>;
async fn update(&self, entity: MentorEntity) -> Result<(), AppError>;
async fn soft_delete(&self, id: Uuid) -> Result<(), AppError>;
}
@@ -0,0 +1,55 @@
use async_trait::async_trait;
use paginator_rs::PaginationParams;
use paginator_utils::PaginatorResponse;
use uuid::Uuid;
use imphnen_utils::AppError;
use super::mentor::MentorEntity;
use crate::mentors::infrastructure::http::dto::{
MentorDetailResponseDto, MentorListResponseDto, MentorRegisterResponseDto,
MentorUpdateRequestDto, MentorUserRegisterRequestDto, MentorVerifyRequestDto,
};
#[async_trait]
pub trait MentorService: Send + Sync {
async fn list(
&self,
params: PaginationParams,
) -> Result<PaginatorResponse<MentorListResponseDto>, AppError>;
async fn get_by_id(&self, id: Uuid) -> Result<MentorDetailResponseDto, AppError>;
async fn get_by_email(&self, email: &str) -> Result<MentorDetailResponseDto, AppError>;
async fn register(
&self,
dto: MentorUserRegisterRequestDto,
) -> Result<MentorRegisterResponseDto, AppError>;
async fn update(
&self,
id: Uuid,
dto: MentorUpdateRequestDto,
) -> Result<MentorDetailResponseDto, AppError>;
async fn update_me(
&self,
email: &str,
dto: MentorUpdateRequestDto,
) -> Result<MentorDetailResponseDto, AppError>;
async fn delete(&self, id: Uuid) -> Result<(), AppError>;
async fn verify(
&self,
id: Uuid,
dto: MentorVerifyRequestDto,
) -> Result<MentorDetailResponseDto, AppError>;
async fn get_status(&self, email: &str) -> Result<String, AppError>;
async fn get_entity_by_id(
&self,
id: Uuid,
include_deleted: bool,
) -> Result<MentorEntity, AppError>;
}
@@ -0,0 +1,261 @@
use imphnen_libs::ZodValidate;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use zod_rs::prelude::*;
// ============================================================
// Response DTOs
// ============================================================
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct MentorListResponseDto {
pub id: String,
pub user_id: String,
pub fullname: Option<String>,
pub email: Option<String>,
pub status: String,
pub created_at: String,
pub updated_at: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct MentorDetailResponseDto {
pub id: String,
pub user_id: String,
pub fullname: Option<String>,
pub email: Option<String>,
pub legal_name: Option<String>,
pub gender: Option<String>,
pub domicile: Option<String>,
pub phone_for_verification: Option<String>,
pub bio: Option<String>,
pub last_education: Option<String>,
pub linkedin_url: Option<String>,
pub github_url: Option<String>,
pub cv_url: Option<String>,
pub portfolio_url: Option<String>,
pub industries: Vec<String>,
pub expertise: Vec<String>,
pub languages: Vec<String>,
pub current_company: String,
pub current_role: String,
pub years_of_experience: i32,
pub topics_of_interest: Vec<String>,
pub preferred_mentee_level: Vec<String>,
pub preferred_mentoring_formats: Vec<String>,
pub availability_commitment: String,
pub mentoring_rate: f64,
pub status: String,
pub created_at: String,
pub updated_at: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct MentorRegisterResponseDto {
pub id: String,
pub user_id: String,
pub email: Option<String>,
pub status: String,
pub created_at: String,
pub updated_at: String,
}
// ============================================================
// Request DTOs
// ============================================================
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, ZodSchema)]
pub struct MentorUserRegisterRequestDto {
#[zod(email, min_length(1))]
pub email: String,
#[zod(min_length(8), regex(pattern = "^[A-Za-z\\d@$!%*?&]{8,}$"))]
pub password: String,
#[zod(min_length(2))]
pub fullname: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub phone_number: Option<String>,
pub identity_and_verification: IdentityAndVerification,
pub professional_profile: ProfessionalProfile,
pub mentoring_logistics: MentoringLogistics,
}
impl ZodValidate for MentorUserRegisterRequestDto {
fn zod_validate(value: &serde_json::Value) -> Result<Self, String> {
Self::validate_and_parse(value).map_err(|e| e.to_string())
}
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, ZodSchema)]
pub struct IdentityAndVerification {
#[zod(min_length(3))]
pub legal_name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub gender: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub domicile: Option<String>,
#[zod(url)]
pub identity_document_url: String,
#[zod(min_length(10), max_length(15))]
#[serde(skip_serializing_if = "Option::is_none")]
pub phone_for_verification: Option<String>,
}
impl ZodValidate for IdentityAndVerification {
fn zod_validate(value: &serde_json::Value) -> Result<Self, String> {
Self::validate_and_parse(value).map_err(|e| e.to_string())
}
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, ZodSchema)]
pub struct ProfessionalProfile {
#[zod(min_length(50))]
pub bio: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_education: Option<String>,
#[zod(url)]
#[serde(skip_serializing_if = "Option::is_none")]
pub linkedin_url: Option<String>,
#[zod(url)]
#[serde(skip_serializing_if = "Option::is_none")]
pub github_url: Option<String>,
#[zod(url)]
#[serde(skip_serializing_if = "Option::is_none")]
pub cv_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub portfolio_url: Option<String>,
pub industries: Vec<String>,
pub expertise: Vec<String>,
pub languages: Vec<String>,
#[zod(min_length(1))]
pub current_company: String,
#[zod(min_length(1))]
pub current_role: String,
#[zod(min(2.0), int)]
pub years_of_experience: i32,
}
impl ZodValidate for ProfessionalProfile {
fn zod_validate(value: &serde_json::Value) -> Result<Self, String> {
Self::validate_and_parse(value).map_err(|e| e.to_string())
}
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, ZodSchema)]
pub struct MentoringLogistics {
pub topics_of_interest: Vec<String>,
pub preferred_mentee_level: Vec<String>,
pub preferred_mentoring_formats: Vec<String>,
#[zod(min_length(5))]
pub availability_commitment: String,
#[zod(min(1.0))]
pub mentoring_rate_amount: u64,
}
impl ZodValidate for MentoringLogistics {
fn zod_validate(value: &serde_json::Value) -> Result<Self, String> {
Self::validate_and_parse(value).map_err(|e| e.to_string())
}
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, ZodSchema)]
pub struct MentorUpdateRequestDto {
#[zod(min_length(3))]
#[serde(skip_serializing_if = "Option::is_none")]
pub legal_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub gender: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub domicile: Option<String>,
#[zod(min_length(10), max_length(15))]
#[serde(skip_serializing_if = "Option::is_none")]
pub phone_for_verification: Option<String>,
#[zod(min_length(50))]
#[serde(skip_serializing_if = "Option::is_none")]
pub bio: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_education: Option<String>,
#[zod(url)]
#[serde(skip_serializing_if = "Option::is_none")]
pub linkedin_url: Option<String>,
#[zod(url)]
#[serde(skip_serializing_if = "Option::is_none")]
pub github_url: Option<String>,
#[zod(url)]
#[serde(skip_serializing_if = "Option::is_none")]
pub cv_url: Option<String>,
#[zod(url)]
#[serde(skip_serializing_if = "Option::is_none")]
pub portfolio_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub industries: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub expertise: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub languages: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub current_company: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub current_role: Option<String>,
#[zod(min(2.0), int)]
#[serde(skip_serializing_if = "Option::is_none")]
pub years_of_experience: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub topics_of_interest: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub preferred_mentee_level: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub preferred_mentoring_formats: Option<Vec<String>>,
#[zod(min_length(5))]
#[serde(skip_serializing_if = "Option::is_none")]
pub availability_commitment: Option<String>,
#[zod(min(1.0))]
#[serde(skip_serializing_if = "Option::is_none")]
pub mentoring_rate_amount: Option<u64>,
}
impl ZodValidate for MentorUpdateRequestDto {
fn zod_validate(value: &serde_json::Value) -> Result<Self, String> {
Self::validate_and_parse(value).map_err(|e| e.to_string())
}
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, ZodSchema)]
pub struct MentorVerifyRequestDto {
#[zod(min_length(1))]
pub status: String,
}
impl ZodValidate for MentorVerifyRequestDto {
fn zod_validate(value: &serde_json::Value) -> Result<Self, String> {
Self::validate_and_parse(value).map_err(|e| e.to_string())
}
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, ZodSchema, Default)]
pub struct MentoringRate {
#[zod(min(1.0))]
pub amount: u64,
#[zod(min_length(1))]
pub currency: String,
#[zod(min_length(1))]
pub per_duration: String,
}
impl ZodValidate for MentoringRate {
fn zod_validate(value: &serde_json::Value) -> Result<Self, String> {
Self::validate_and_parse(value).map_err(|e| e.to_string())
}
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, ZodSchema)]
pub struct MentorRegisterFromTokenRequestDto {
pub identity_and_verification: IdentityAndVerification,
pub professional_profile: ProfessionalProfile,
pub mentoring_logistics: MentoringLogistics,
}
impl ZodValidate for MentorRegisterFromTokenRequestDto {
fn zod_validate(value: &serde_json::Value) -> Result<Self, String> {
Self::validate_and_parse(value).map_err(|e| e.to_string())
}
}
@@ -0,0 +1,279 @@
use std::sync::Arc;
use axum::{
extract::{Extension, Path},
http::HeaderMap,
response::{IntoResponse, Response},
};
use paginator_axum::PaginationQuery;
use uuid::Uuid;
use imphnen_libs::{AppState, ValidatedJson};
use imphnen_utils::{ApiSuccess, ApiPaginated, ApiMessage, extract_email};
use imphnen_iam::{PermissionsEnum, require_permissions};
use imphnen_utils::AppError;
use crate::mentors::domain::MentorService;
use super::dto::{
MentorDetailResponseDto, MentorListResponseDto, MentorRegisterResponseDto,
MentorUpdateRequestDto, MentorUserRegisterRequestDto, MentorVerifyRequestDto,
};
#[utoipa::path(
post,
path = "/v1/mentors/create",
request_body = MentorUserRegisterRequestDto,
responses(
(status = 200, description = "[PUBLIC] Mentor registered successfully", body = MentorRegisterResponseDto),
(status = 400, description = "[PUBLIC] Bad request - validation error"),
(status = 409, description = "[PUBLIC] Conflict - user already has mentor profile"),
(status = 500, description = "[PUBLIC] Internal server error")
),
tag = "Mentors"
)]
pub async fn post_register_mentor(
Extension(service): Extension<Arc<dyn MentorService>>,
ValidatedJson(dto): ValidatedJson<MentorUserRegisterRequestDto>,
) -> Response {
match service.register(dto).await {
Ok(resp) => ApiSuccess(resp).into_response(),
Err(e) => ApiMessage::new(e.status_code(), e.to_string()).into_response(),
}
}
#[utoipa::path(
get,
path = "/v1/mentors",
params(
("page" = Option<u64>, Query, description = "Page number"),
("per_page" = Option<u64>, Query, description = "Items per page"),
("search" = Option<String>, Query, description = "Search query"),
("sort_by" = Option<String>, Query, description = "Sort by field"),
("order" = Option<String>, Query, description = "Sort order (ASC/DESC)"),
),
responses(
(status = 200, description = "[ADMIN] Get list of mentors", body = Vec<MentorListResponseDto>),
(status = 500, description = "[ADMIN] Internal server error")
),
tag = "Mentors",
security(("Bearer" = []))
)]
pub async fn get_mentor_list(
headers: HeaderMap,
Extension(state): Extension<AppState>,
Extension(service): Extension<Arc<dyn MentorService>>,
PaginationQuery(params): PaginationQuery,
) -> Result<impl IntoResponse, AppError> {
require_permissions!(headers, state, [PermissionsEnum::ReadListMentors], {
let result = service.list(params).await?;
Ok(ApiPaginated(result))
})
}
#[utoipa::path(
get,
path = "/v1/mentors/detail/{id}",
params(
("id" = String, Path, description = "Mentor ID")
),
responses(
(status = 200, description = "[ADMIN] Get mentor by ID", body = MentorDetailResponseDto),
(status = 404, description = "[ADMIN] Mentor not found"),
(status = 500, description = "[ADMIN] Internal server error")
),
tag = "Mentors",
security(("Bearer" = []))
)]
pub async fn get_mentor_by_id(
headers: HeaderMap,
Extension(state): Extension<AppState>,
Extension(service): Extension<Arc<dyn MentorService>>,
Path(id): Path<String>,
) -> Result<impl IntoResponse, AppError> {
let mentor_uuid = Uuid::parse_str(&id)
.map_err(|_| AppError::BadRequestError("Invalid mentor ID format. Must be a valid UUID.".to_string()))?;
require_permissions!(headers, state, [PermissionsEnum::ReadDetailMentors], {
let dto = service.get_by_id(mentor_uuid).await?;
Ok(ApiSuccess(dto))
})
}
#[utoipa::path(
put,
path = "/v1/mentors/update/{id}",
params(
("id" = String, Path, description = "Mentor ID")
),
request_body = MentorUpdateRequestDto,
responses(
(status = 200, description = "[ADMIN] Mentor updated successfully", body = MentorDetailResponseDto),
(status = 400, description = "[ADMIN] Bad request - validation error"),
(status = 404, description = "[ADMIN] Mentor not found"),
(status = 500, description = "[ADMIN] Internal server error")
),
tag = "Mentors - Admin",
security(("Bearer" = []))
)]
pub async fn put_update_mentor(
headers: HeaderMap,
Extension(state): Extension<AppState>,
Extension(service): Extension<Arc<dyn MentorService>>,
Path(id): Path<String>,
ValidatedJson(dto): ValidatedJson<MentorUpdateRequestDto>,
) -> Result<impl IntoResponse, AppError> {
let mentor_uuid = Uuid::parse_str(&id)
.map_err(|_| AppError::BadRequestError("Invalid mentor ID format. Must be a valid UUID.".to_string()))?;
require_permissions!(headers, state, [PermissionsEnum::UpdateMentors], {
let result = service.update(mentor_uuid, dto).await?;
Ok(ApiSuccess(result))
})
}
#[utoipa::path(
delete,
path = "/v1/mentors/delete/{id}",
params(
("id" = String, Path, description = "Mentor ID")
),
responses(
(status = 200, description = "[ADMIN] Mentor deleted successfully"),
(status = 404, description = "[ADMIN] Mentor not found"),
(status = 500, description = "[ADMIN] Internal server error")
),
tag = "Mentors - Admin",
security(("Bearer" = []))
)]
pub async fn delete_mentor(
headers: HeaderMap,
Extension(state): Extension<AppState>,
Extension(service): Extension<Arc<dyn MentorService>>,
Path(id): Path<String>,
) -> Result<impl IntoResponse, AppError> {
let mentor_uuid = Uuid::parse_str(&id)
.map_err(|_| AppError::BadRequestError("Invalid mentor ID format. Must be a valid UUID.".to_string()))?;
require_permissions!(headers, state, [PermissionsEnum::DeleteMentors], {
service.delete(mentor_uuid).await?;
Ok(ApiMessage::ok("Mentor deleted successfully"))
})
}
#[utoipa::path(
put,
path = "/v1/mentors/verify/{id}",
params(
("id" = String, Path, description = "Mentor ID")
),
request_body = MentorVerifyRequestDto,
responses(
(status = 200, description = "[ADMIN] Mentor verified successfully", body = MentorDetailResponseDto),
(status = 400, description = "[ADMIN] Bad request - validation error"),
(status = 404, description = "[ADMIN] Mentor not found"),
(status = 500, description = "[ADMIN] Internal server error")
),
tag = "Mentors - Admin",
security(("Bearer" = []))
)]
pub async fn put_verify_mentor(
headers: HeaderMap,
Extension(state): Extension<AppState>,
Extension(service): Extension<Arc<dyn MentorService>>,
Path(id): Path<String>,
ValidatedJson(dto): ValidatedJson<MentorVerifyRequestDto>,
) -> Result<impl IntoResponse, AppError> {
let mentor_uuid = Uuid::parse_str(&id)
.map_err(|_| AppError::BadRequestError("Invalid mentor ID format. Must be a valid UUID.".to_string()))?;
require_permissions!(headers, state, [PermissionsEnum::VerifyMentors], {
let result = service.verify(mentor_uuid, dto).await?;
Ok(ApiSuccess(result))
})
}
#[utoipa::path(
get,
path = "/v1/mentors/me",
responses(
(status = 200, description = "[MENTOR] Current user's mentor profile", body = MentorDetailResponseDto),
(status = 401, description = "[MENTOR] Unauthorized - invalid token"),
(status = 403, description = "[MENTOR] Mentor profile not found for current user"),
(status = 500, description = "[MENTOR] Internal server error")
),
tag = "Mentors",
security(("Bearer" = []))
)]
pub async fn get_mentor_me(
headers: HeaderMap,
Extension(state): Extension<AppState>,
Extension(service): Extension<Arc<dyn MentorService>>,
) -> Result<impl IntoResponse, AppError> {
require_permissions!(headers.clone(), state, [PermissionsEnum::ReadOwnMentorProfile], {
let email = extract_email(&headers)
.ok_or_else(|| AppError::AuthenticationError("Token tidak valid".to_string()))?;
let dto = service.get_by_email(&email).await
.map_err(|_| AppError::ForbiddenError("Mentor profile not found for current user".to_string()))?;
Ok(ApiSuccess(dto))
})
}
#[utoipa::path(
put,
path = "/v1/mentors/me/update",
request_body = MentorUpdateRequestDto,
responses(
(status = 200, description = "[MENTOR] Mentor profile updated successfully", body = MentorDetailResponseDto),
(status = 400, description = "[MENTOR] Bad request - validation error"),
(status = 401, description = "[MENTOR] Unauthorized - invalid token"),
(status = 404, description = "[MENTOR] Mentor profile not found"),
(status = 500, description = "[MENTOR] Internal server error")
),
tag = "Mentors",
security(("Bearer" = []))
)]
pub async fn put_update_mentor_me(
headers: HeaderMap,
Extension(state): Extension<AppState>,
Extension(service): Extension<Arc<dyn MentorService>>,
ValidatedJson(dto): ValidatedJson<MentorUpdateRequestDto>,
) -> Result<impl IntoResponse, AppError> {
require_permissions!(headers.clone(), state, [PermissionsEnum::UpdateOwnMentorProfile], {
let email = extract_email(&headers)
.ok_or_else(|| AppError::AuthenticationError("Token tidak valid".to_string()))?;
let resp = service.update_me(&email, dto).await?;
Ok(ApiSuccess(resp))
})
}
#[utoipa::path(
put,
path = "/v1/mentors/update",
request_body = MentorUpdateRequestDto,
responses(
(status = 400, description = "[PUBLIC] Bad request - Mentor ID is required for update"),
),
tag = "Mentors - Admin"
)]
pub async fn put_update_mentor_no_id() -> impl IntoResponse {
ApiMessage::new(axum::http::StatusCode::BAD_REQUEST, "Mentor ID is required for update")
}
#[utoipa::path(
get,
path = "/v1/mentors/me/status",
responses(
(status = 200, description = "[MENTOR] Mentor application status", body = String),
(status = 401, description = "[MENTOR] Unauthorized - invalid token"),
(status = 403, description = "[MENTOR] No mentor application found for current user"),
(status = 500, description = "[MENTOR] Internal server error")
),
tag = "Mentors",
security(("Bearer" = []))
)]
pub async fn get_mentor_status(
headers: HeaderMap,
Extension(state): Extension<AppState>,
Extension(service): Extension<Arc<dyn MentorService>>,
) -> Result<impl IntoResponse, AppError> {
require_permissions!(headers.clone(), state, [PermissionsEnum::ReadOwnMentorStatus], {
let email = extract_email(&headers)
.ok_or_else(|| AppError::AuthenticationError("Token tidak valid".to_string()))?;
let status = service.get_status(&email).await
.map_err(|_| AppError::ForbiddenError("No mentor application found for current user".to_string()))?;
Ok(ApiMessage::ok(&status))
})
}
@@ -0,0 +1,5 @@
pub mod dto;
pub mod handlers;
pub mod routes;
pub use routes::{mentors_protected_routes, mentors_public_routes};
@@ -0,0 +1,47 @@
use std::sync::Arc;
use axum::{
routing::{delete, get, post, put},
Extension, Router,
};
use sea_orm::DatabaseConnection;
use imphnen_libs::AppState;
use imphnen_iam::users::infrastructure::persistence::PostgresUserRepository;
use imphnen_iam::roles::infrastructure::persistence::PostgresRoleRepository;
use crate::mentors::application::MentorServiceImpl;
use crate::mentors::domain::MentorService;
use crate::mentors::infrastructure::persistence::PostgresMentorRepository;
use super::handlers::{
delete_mentor, get_mentor_by_id, get_mentor_list, get_mentor_me, get_mentor_status,
post_register_mentor, put_update_mentor, put_update_mentor_me, put_update_mentor_no_id,
put_verify_mentor,
};
fn build_service(db: DatabaseConnection, state: Arc<AppState>) -> Arc<dyn MentorService> {
let user_repo = Arc::new(PostgresUserRepository::new(db.clone()));
let role_repo = Arc::new(PostgresRoleRepository::new(db.clone()));
let repo = Arc::new(PostgresMentorRepository::new(db));
Arc::new(MentorServiceImpl::new(repo, state, user_repo, role_repo))
}
pub fn mentors_public_routes(db: DatabaseConnection, state: Arc<AppState>) -> Router {
let service = build_service(db, state);
Router::new()
.route("/mentors/create", post(post_register_mentor))
.layer(Extension(service))
}
pub fn mentors_protected_routes(db: DatabaseConnection, state: Arc<AppState>) -> Router {
let svc = build_service(db, Arc::clone(&state));
Router::new()
.route("/mentors", get(get_mentor_list))
.route("/mentors/me", get(get_mentor_me))
.route("/mentors/me/update", put(put_update_mentor_me))
.route("/mentors/me/status", get(get_mentor_status))
.route("/mentors/detail/{id}", get(get_mentor_by_id))
.route("/mentors/update/{id}", put(put_update_mentor))
.route("/mentors/update", put(put_update_mentor_no_id))
.route("/mentors/delete/{id}", delete(delete_mentor))
.route("/mentors/verify/{id}", put(put_verify_mentor))
.layer(Extension(svc))
.layer(Extension((*state).clone()))
}
@@ -0,0 +1,2 @@
pub mod http;
pub mod persistence;
@@ -0,0 +1,3 @@
pub mod postgres_mentor_repository;
pub use postgres_mentor_repository::PostgresMentorRepository;
@@ -0,0 +1,283 @@
use std::sync::Arc;
use async_trait::async_trait;
use sea_orm::prelude::*;
use sea_orm::{ActiveValue, Order, QueryOrder, PaginatorTrait};
use paginator_rs::{PaginationParams, SortDirection};
use paginator_utils::{PaginatorResponse, PaginatorResponseMeta};
use uuid::Uuid;
use imphnen_utils::AppError;
use imphnen_entities::seaorm::auth::mentors::{
Entity as MentorsEntity,
Column as MentorColumn,
ActiveModel as MentorActiveModel,
Model as MentorModel,
};
use crate::mentors::domain::{mentor::MentorEntity, repository::MentorRepository};
fn model_to_entity(model: MentorModel) -> MentorEntity {
MentorEntity {
id: model.id,
user_id: model.user_id,
industries: serde_json::from_value(
model.industries.unwrap_or(serde_json::Value::Array(vec![])),
)
.unwrap_or_default(),
expertise: serde_json::from_value(
model.expertise.unwrap_or(serde_json::Value::Array(vec![])),
)
.unwrap_or_default(),
languages: serde_json::from_value(
model.languages.unwrap_or(serde_json::Value::Array(vec![])),
)
.unwrap_or_default(),
current_company: model.current_company.unwrap_or_default(),
current_role: model.current_role.unwrap_or_default(),
years_of_experience: model.years_of_experience.unwrap_or(0),
topics_of_interest: serde_json::from_value(
model.topics_of_interest.unwrap_or(serde_json::Value::Array(vec![])),
)
.unwrap_or_default(),
preferred_mentee_level: serde_json::from_str(
&model.preferred_mentee_level.unwrap_or_default(),
)
.unwrap_or_default(),
preferred_mentoring_formats: serde_json::from_value(
model
.preferred_mentoring_formats
.unwrap_or(serde_json::Value::Array(vec![])),
)
.unwrap_or_default(),
availability_commitment: model.availability_commitment.unwrap_or_default(),
mentoring_rate: model.mentoring_rate.unwrap_or(0.0),
status: model.status.unwrap_or_default(),
is_deleted: model.is_deleted,
created_at: model.created_at,
updated_at: model.updated_at,
}
}
pub struct PostgresMentorRepository {
db: Arc<DatabaseConnection>,
}
impl PostgresMentorRepository {
pub fn new(db: DatabaseConnection) -> Self {
Self { db: Arc::new(db) }
}
}
#[async_trait]
impl MentorRepository for PostgresMentorRepository {
async fn find_all(
&self,
params: PaginationParams,
) -> Result<PaginatorResponse<MentorEntity>, AppError> {
let page = params.page.max(1);
let per_page = params.per_page.clamp(1, 100);
let mut query = MentorsEntity::find()
.filter(MentorColumn::IsDeleted.eq(false));
query = match params.sort_by.as_deref() {
Some("updated_at") => match params.sort_direction {
Some(SortDirection::Asc) => query.order_by(MentorColumn::UpdatedAt, Order::Asc),
_ => query.order_by(MentorColumn::UpdatedAt, Order::Desc),
},
_ => match params.sort_direction {
Some(SortDirection::Asc) => query.order_by(MentorColumn::CreatedAt, Order::Asc),
_ => query.order_by(MentorColumn::CreatedAt, Order::Desc),
},
};
let paginator = query.paginate(self.db.as_ref(), per_page as u64);
let total = paginator
.num_items()
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
let mentors = paginator
.fetch_page((page - 1) as u64)
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
let data = mentors.into_iter().map(model_to_entity).collect();
let meta = PaginatorResponseMeta::new(page, per_page, total as u32);
Ok(PaginatorResponse { data, meta })
}
async fn find_by_id(
&self,
id: Uuid,
include_deleted: bool,
) -> Result<MentorEntity, AppError> {
let mut query = MentorsEntity::find_by_id(id);
if !include_deleted {
query = query.filter(MentorColumn::IsDeleted.eq(false));
}
let model = query
.one(self.db.as_ref())
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?
.ok_or_else(|| AppError::NotFoundError("Mentor not found".to_string()))?;
Ok(model_to_entity(model))
}
async fn find_by_user_id(
&self,
user_id: Uuid,
include_deleted: bool,
) -> Result<MentorEntity, AppError> {
let mut query = MentorsEntity::find()
.filter(MentorColumn::UserId.eq(user_id));
if !include_deleted {
query = query.filter(MentorColumn::IsDeleted.eq(false));
}
let model = query
.one(self.db.as_ref())
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?
.ok_or_else(|| AppError::NotFoundError("Mentor not found".to_string()))?;
Ok(model_to_entity(model))
}
async fn create(&self, entity: MentorEntity) -> Result<Uuid, AppError> {
let active_model = MentorActiveModel {
user_id: ActiveValue::Set(entity.user_id),
industries: ActiveValue::Set(Some(
serde_json::to_value(&entity.industries)
.map_err(|e| AppError::InternalServerError(e.to_string()))?,
)),
expertise: ActiveValue::Set(Some(
serde_json::to_value(&entity.expertise)
.map_err(|e| AppError::InternalServerError(e.to_string()))?,
)),
languages: ActiveValue::Set(Some(
serde_json::to_value(&entity.languages)
.map_err(|e| AppError::InternalServerError(e.to_string()))?,
)),
current_company: ActiveValue::Set(Some(entity.current_company)),
current_role: ActiveValue::Set(Some(entity.current_role)),
years_of_experience: ActiveValue::Set(Some(entity.years_of_experience)),
topics_of_interest: ActiveValue::Set(Some(
serde_json::to_value(&entity.topics_of_interest)
.map_err(|e| AppError::InternalServerError(e.to_string()))?,
)),
preferred_mentee_level: ActiveValue::Set(Some(
serde_json::to_string(&entity.preferred_mentee_level)
.map_err(|e| AppError::InternalServerError(e.to_string()))?,
)),
preferred_mentoring_formats: ActiveValue::Set(Some(
serde_json::to_value(&entity.preferred_mentoring_formats)
.map_err(|e| AppError::InternalServerError(e.to_string()))?,
)),
availability_commitment: ActiveValue::Set(Some(entity.availability_commitment)),
mentoring_rate: ActiveValue::Set(Some(entity.mentoring_rate)),
status: ActiveValue::Set(Some(entity.status)),
is_deleted: ActiveValue::Set(false),
created_at: ActiveValue::Set(chrono::Utc::now()),
updated_at: ActiveValue::Set(chrono::Utc::now()),
..Default::default()
};
let result = MentorsEntity::insert(active_model)
.exec(self.db.as_ref())
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok(result.last_insert_id)
}
async fn update(&self, entity: MentorEntity) -> Result<(), AppError> {
let mut active_model: MentorActiveModel = MentorsEntity::find_by_id(entity.id)
.one(self.db.as_ref())
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?
.ok_or_else(|| AppError::NotFoundError("Mentor not found".to_string()))?
.into();
if !entity.industries.is_empty() {
active_model.industries = ActiveValue::Set(Some(
serde_json::to_value(&entity.industries)
.map_err(|e| AppError::InternalServerError(e.to_string()))?,
));
}
if !entity.expertise.is_empty() {
active_model.expertise = ActiveValue::Set(Some(
serde_json::to_value(&entity.expertise)
.map_err(|e| AppError::InternalServerError(e.to_string()))?,
));
}
if !entity.languages.is_empty() {
active_model.languages = ActiveValue::Set(Some(
serde_json::to_value(&entity.languages)
.map_err(|e| AppError::InternalServerError(e.to_string()))?,
));
}
if !entity.current_company.is_empty() {
active_model.current_company = ActiveValue::Set(Some(entity.current_company));
}
if !entity.current_role.is_empty() {
active_model.current_role = ActiveValue::Set(Some(entity.current_role));
}
active_model.years_of_experience = ActiveValue::Set(Some(entity.years_of_experience));
if !entity.topics_of_interest.is_empty() {
active_model.topics_of_interest = ActiveValue::Set(Some(
serde_json::to_value(&entity.topics_of_interest)
.map_err(|e| AppError::InternalServerError(e.to_string()))?,
));
}
if !entity.preferred_mentee_level.is_empty() {
active_model.preferred_mentee_level = ActiveValue::Set(Some(
serde_json::to_string(&entity.preferred_mentee_level)
.map_err(|e| AppError::InternalServerError(e.to_string()))?,
));
}
if !entity.preferred_mentoring_formats.is_empty() {
active_model.preferred_mentoring_formats = ActiveValue::Set(Some(
serde_json::to_value(&entity.preferred_mentoring_formats)
.map_err(|e| AppError::InternalServerError(e.to_string()))?,
));
}
if !entity.availability_commitment.is_empty() {
active_model.availability_commitment =
ActiveValue::Set(Some(entity.availability_commitment));
}
active_model.mentoring_rate = ActiveValue::Set(Some(entity.mentoring_rate));
if !entity.status.is_empty() {
active_model.status = ActiveValue::Set(Some(entity.status));
}
active_model.updated_at = ActiveValue::Set(chrono::Utc::now());
active_model
.update(self.db.as_ref())
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok(())
}
async fn soft_delete(&self, id: Uuid) -> Result<(), AppError> {
let model = MentorsEntity::find_by_id(id)
.filter(MentorColumn::IsDeleted.eq(false))
.one(self.db.as_ref())
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?
.ok_or_else(|| AppError::NotFoundError("Mentor not found".to_string()))?;
let mut active_model: MentorActiveModel = model.into();
active_model.is_deleted = ActiveValue::Set(true);
active_model.updated_at = ActiveValue::Set(chrono::Utc::now());
active_model
.update(self.db.as_ref())
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok(())
}
}
+5
View File
@@ -0,0 +1,5 @@
pub mod application;
pub mod domain;
pub mod infrastructure;
pub use infrastructure::http::routes::{mentors_protected_routes, mentors_public_routes};
@@ -0,0 +1,3 @@
pub mod session_service;
pub use session_service::SessionServiceImpl;
@@ -0,0 +1,310 @@
use std::sync::Arc;
use async_trait::async_trait;
use chrono::{DateTime, Duration, Utc};
use uuid::Uuid;
use imphnen_utils::AppError;
use crate::sessions::domain::{SessionEntity, SessionRepository, SessionService};
use crate::sessions::infrastructure::http::dto::{
AvailabilitySlotDto, BookSessionRequestDto, BookSessionResponseDto, MentorAvailabilityDto,
SessionDetailDto, SessionFeedbackRequestDto, SessionFeedbackResponseDto, SessionListItemDto,
SessionListResponseDto, UpdateSessionStatusRequestDto, UpdateSessionStatusResponseDto,
};
pub struct SessionServiceImpl {
repo: Arc<dyn SessionRepository>,
}
impl SessionServiceImpl {
pub fn new(repo: Arc<dyn SessionRepository>) -> Self {
Self { repo }
}
}
#[async_trait]
impl SessionService for SessionServiceImpl {
async fn book_session(
&self,
mentor_id: String,
user_id: String,
dto: BookSessionRequestDto,
) -> Result<BookSessionResponseDto, AppError> {
let scheduled_at = DateTime::parse_from_rfc3339(&dto.scheduled_at)
.map_err(|e| AppError::BadRequestError(format!("Invalid scheduled_at format: {}", e)))?
.with_timezone(&Utc);
let mentor_uuid = Uuid::parse_str(&mentor_id)
.map_err(|e| AppError::BadRequestError(format!("Invalid mentor ID: {}", e)))?;
let mentee_uuid = Uuid::parse_str(&user_id)
.map_err(|e| AppError::BadRequestError(format!("Invalid user ID: {}", e)))?;
let entity = SessionEntity {
id: Uuid::new_v4(),
mentor_id: mentor_uuid,
mentee_id: mentee_uuid,
topic: dto.topic,
description: dto.description,
scheduled_at,
duration_minutes: dto.duration_minutes.unwrap_or(60),
meeting_link: None,
session_type: dto.session_type.unwrap_or_else(|| "video_call".to_string()),
status: "pending".to_string(),
feedback: None,
rating: None,
feedback_submitted_at: None,
created_at: Utc::now(),
updated_at: Utc::now(),
};
let created = self.repo.create(entity).await?;
Ok(BookSessionResponseDto {
id: created.id.to_string(),
mentor_id: created.mentor_id.to_string(),
mentee_id: created.mentee_id.to_string(),
topic: created.topic,
description: created.description,
scheduled_at: created.scheduled_at.to_rfc3339(),
duration_minutes: created.duration_minutes,
session_type: created.session_type,
status: created.status,
created_at: created.created_at.to_rfc3339(),
})
}
async fn get_mentor_sessions(
&self,
mentor_id: String,
status_filter: Option<String>,
) -> Result<SessionListResponseDto, AppError> {
let mentor_uuid = Uuid::parse_str(&mentor_id)
.map_err(|e| AppError::BadRequestError(format!("Invalid mentor ID: {}", e)))?;
let count = self
.repo
.count_by_mentor(mentor_uuid, status_filter.clone())
.await?;
let sessions = self
.repo
.find_by_mentor_id(mentor_uuid, status_filter)
.await?;
let items: Vec<SessionListItemDto> = sessions
.into_iter()
.map(|s| SessionListItemDto {
id: s.id.to_string(),
mentor_id: s.mentor_id.to_string(),
mentee_id: s.mentee_id.to_string(),
mentee_fullname: None,
mentee_email: None,
topic: s.topic,
scheduled_at: s.scheduled_at.to_rfc3339(),
duration_minutes: s.duration_minutes,
session_type: s.session_type,
status: s.status,
rating: s.rating,
created_at: s.created_at.to_rfc3339(),
})
.collect();
Ok(SessionListResponseDto {
sessions: items,
total: count,
})
}
async fn get_user_sessions(
&self,
user_id: String,
status_filter: Option<String>,
) -> Result<SessionListResponseDto, AppError> {
let user_uuid = Uuid::parse_str(&user_id)
.map_err(|e| AppError::BadRequestError(format!("Invalid user ID: {}", e)))?;
let count = self
.repo
.count_by_mentee(user_uuid, status_filter.clone())
.await?;
let sessions = self
.repo
.find_by_mentee_id(user_uuid, status_filter)
.await?;
let items: Vec<SessionListItemDto> = sessions
.into_iter()
.map(|s| SessionListItemDto {
id: s.id.to_string(),
mentor_id: s.mentor_id.to_string(),
mentee_id: s.mentee_id.to_string(),
mentee_fullname: None,
mentee_email: None,
topic: s.topic,
scheduled_at: s.scheduled_at.to_rfc3339(),
duration_minutes: s.duration_minutes,
session_type: s.session_type,
status: s.status,
rating: s.rating,
created_at: s.created_at.to_rfc3339(),
})
.collect();
Ok(SessionListResponseDto {
sessions: items,
total: count,
})
}
async fn get_mentor_availability(
&self,
mentor_id: String,
) -> Result<MentorAvailabilityDto, AppError> {
let mentor_uuid = Uuid::parse_str(&mentor_id)
.map_err(|e| AppError::BadRequestError(format!("Invalid mentor ID: {}", e)))?;
let booked_dates = self.repo.find_booked_dates(mentor_uuid).await?;
let mut slots = Vec::new();
let today = Utc::now().date_naive();
for i in 0..7 {
let date = today + Duration::days(i);
let date_str = date.format("%Y-%m-%d").to_string();
for hour in 9..17 {
let time_str = format!("{:02}:00", hour);
let datetime_prefix = format!("{}T{}", date_str, time_str);
let is_booked = booked_dates
.iter()
.any(|d| d.starts_with(&datetime_prefix[..13]));
slots.push(AvailabilitySlotDto {
date: date_str.clone(),
time: time_str,
available: !is_booked,
});
}
}
Ok(MentorAvailabilityDto {
mentor_id,
availability_commitment: "Available weekdays 9 AM - 5 PM".to_string(),
preferred_formats: vec!["video_call".to_string(), "phone_call".to_string()],
slots,
booked_dates,
})
}
async fn update_session_status(
&self,
session_id: String,
_user_id: String,
dto: UpdateSessionStatusRequestDto,
) -> Result<UpdateSessionStatusResponseDto, AppError> {
let session_uuid = Uuid::parse_str(&session_id)
.map_err(|e| AppError::BadRequestError(format!("Invalid session ID: {}", e)))?;
let mut session = self
.repo
.find_by_id(session_uuid)
.await?
.ok_or_else(|| AppError::NotFoundError("Session not found".to_string()))?;
session.status = dto.status;
if let Some(link) = dto.meeting_link {
session.meeting_link = Some(link);
}
session.updated_at = Utc::now();
let updated = self.repo.update(session_uuid, session).await?;
Ok(UpdateSessionStatusResponseDto {
id: updated.id.to_string(),
status: updated.status,
meeting_link: updated.meeting_link,
updated_at: updated.updated_at.to_rfc3339(),
})
}
async fn submit_feedback(
&self,
session_id: String,
user_id: String,
dto: SessionFeedbackRequestDto,
) -> Result<SessionFeedbackResponseDto, AppError> {
let session_uuid = Uuid::parse_str(&session_id)
.map_err(|e| AppError::BadRequestError(format!("Invalid session ID: {}", e)))?;
let mut session = self
.repo
.find_by_id(session_uuid)
.await?
.ok_or_else(|| AppError::NotFoundError("Session not found".to_string()))?;
if session.mentee_id.to_string() != user_id {
return Err(AppError::ForbiddenError(
"Only the mentee can submit feedback".to_string(),
));
}
if session.status != "completed" {
return Err(AppError::BadRequestError(
"Feedback can only be submitted for completed sessions".to_string(),
));
}
session.feedback = Some(dto.feedback.clone());
session.rating = Some(dto.rating);
session.feedback_submitted_at = Some(Utc::now());
session.updated_at = Utc::now();
let updated = self.repo.update(session_uuid, session).await?;
let submitted_at = updated
.feedback_submitted_at
.unwrap_or_else(Utc::now)
.to_rfc3339();
Ok(SessionFeedbackResponseDto {
id: updated.id.to_string(),
feedback: dto.feedback,
rating: dto.rating,
submitted_at,
})
}
async fn get_session_detail(
&self,
session_id: String,
) -> Result<SessionDetailDto, AppError> {
let session_uuid = Uuid::parse_str(&session_id)
.map_err(|e| AppError::BadRequestError(format!("Invalid session ID: {}", e)))?;
let session = self
.repo
.find_by_id(session_uuid)
.await?
.ok_or_else(|| AppError::NotFoundError("Session not found".to_string()))?;
Ok(SessionDetailDto {
id: session.id.to_string(),
mentor_id: session.mentor_id.to_string(),
mentor_fullname: None,
mentee_id: session.mentee_id.to_string(),
mentee_fullname: None,
topic: session.topic,
description: session.description,
scheduled_at: session.scheduled_at.to_rfc3339(),
duration_minutes: session.duration_minutes,
meeting_link: session.meeting_link,
session_type: session.session_type,
status: session.status,
feedback: session.feedback,
rating: session.rating,
feedback_submitted_at: session.feedback_submitted_at.map(|dt| dt.to_rfc3339()),
created_at: session.created_at.to_rfc3339(),
updated_at: session.updated_at.to_rfc3339(),
})
}
}
@@ -0,0 +1,7 @@
pub mod repository;
pub mod service;
pub mod session;
pub use repository::SessionRepository;
pub use service::SessionService;
pub use session::SessionEntity;
@@ -0,0 +1,48 @@
use async_trait::async_trait;
use paginator_rs::PaginationParams;
use paginator_utils::PaginatorResponse;
use uuid::Uuid;
use imphnen_utils::AppError;
use super::session::SessionEntity;
#[async_trait]
pub trait SessionRepository: Send + Sync {
async fn create(&self, entity: SessionEntity) -> Result<SessionEntity, AppError>;
async fn find_by_id(&self, id: Uuid) -> Result<Option<SessionEntity>, AppError>;
async fn find_by_mentor_id(
&self,
mentor_id: Uuid,
status_filter: Option<String>,
) -> Result<Vec<SessionEntity>, AppError>;
async fn find_by_mentee_id(
&self,
mentee_id: Uuid,
status_filter: Option<String>,
) -> Result<Vec<SessionEntity>, AppError>;
async fn find_booked_dates(&self, mentor_id: Uuid) -> Result<Vec<String>, AppError>;
async fn update(&self, id: Uuid, entity: SessionEntity) -> Result<SessionEntity, AppError>;
async fn delete(&self, id: Uuid) -> Result<(), AppError>;
async fn count_by_mentor(
&self,
mentor_id: Uuid,
status_filter: Option<String>,
) -> Result<usize, AppError>;
async fn count_by_mentee(
&self,
mentee_id: Uuid,
status_filter: Option<String>,
) -> Result<usize, AppError>;
async fn find_all_paginated(
&self,
params: PaginationParams,
) -> Result<PaginatorResponse<SessionEntity>, AppError>;
}
@@ -0,0 +1,53 @@
use async_trait::async_trait;
use imphnen_utils::AppError;
use crate::sessions::infrastructure::http::dto::{
BookSessionRequestDto, BookSessionResponseDto, MentorAvailabilityDto,
SessionDetailDto, SessionFeedbackRequestDto, SessionFeedbackResponseDto,
SessionListResponseDto, UpdateSessionStatusRequestDto, UpdateSessionStatusResponseDto,
};
#[async_trait]
pub trait SessionService: Send + Sync {
async fn book_session(
&self,
mentor_id: String,
user_id: String,
dto: BookSessionRequestDto,
) -> Result<BookSessionResponseDto, AppError>;
async fn get_mentor_sessions(
&self,
mentor_id: String,
status_filter: Option<String>,
) -> Result<SessionListResponseDto, AppError>;
async fn get_user_sessions(
&self,
user_id: String,
status_filter: Option<String>,
) -> Result<SessionListResponseDto, AppError>;
async fn get_mentor_availability(
&self,
mentor_id: String,
) -> Result<MentorAvailabilityDto, AppError>;
async fn update_session_status(
&self,
session_id: String,
user_id: String,
dto: UpdateSessionStatusRequestDto,
) -> Result<UpdateSessionStatusResponseDto, AppError>;
async fn submit_feedback(
&self,
session_id: String,
user_id: String,
dto: SessionFeedbackRequestDto,
) -> Result<SessionFeedbackResponseDto, AppError>;
async fn get_session_detail(
&self,
session_id: String,
) -> Result<SessionDetailDto, AppError>;
}
@@ -0,0 +1,21 @@
use chrono::{DateTime, Utc};
use uuid::Uuid;
#[derive(Clone, Debug)]
pub struct SessionEntity {
pub id: Uuid,
pub mentor_id: Uuid,
pub mentee_id: Uuid,
pub topic: String,
pub description: Option<String>,
pub scheduled_at: DateTime<Utc>,
pub duration_minutes: i32,
pub meeting_link: Option<String>,
pub session_type: String,
pub status: String,
pub feedback: Option<String>,
pub rating: Option<i32>,
pub feedback_submitted_at: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
@@ -0,0 +1,153 @@
use imphnen_libs::ZodValidate;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use zod_rs::prelude::*;
// ============================================================
// Request DTOs
// ============================================================
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, ZodSchema)]
pub struct BookSessionRequestDto {
#[zod(min_length(3), max_length(200))]
pub topic: String,
#[zod(max_length(1000))]
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[zod(min_length(1))]
pub scheduled_at: String,
#[zod(min(15.0), max(240.0), int)]
#[serde(skip_serializing_if = "Option::is_none")]
pub duration_minutes: Option<i32>,
#[zod(max_length(50))]
#[serde(skip_serializing_if = "Option::is_none")]
pub session_type: Option<String>,
}
impl ZodValidate for BookSessionRequestDto {
fn zod_validate(value: &serde_json::Value) -> Result<Self, String> {
Self::validate_and_parse(value).map_err(|e| e.to_string())
}
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, ZodSchema)]
pub struct UpdateSessionStatusRequestDto {
#[zod(min_length(1), max_length(50))]
pub status: String,
#[zod(url)]
#[serde(skip_serializing_if = "Option::is_none")]
pub meeting_link: Option<String>,
}
impl ZodValidate for UpdateSessionStatusRequestDto {
fn zod_validate(value: &serde_json::Value) -> Result<Self, String> {
Self::validate_and_parse(value).map_err(|e| e.to_string())
}
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, ZodSchema)]
pub struct SessionFeedbackRequestDto {
#[zod(min_length(10), max_length(2000))]
pub feedback: String,
#[zod(min(1.0), max(5.0), int)]
pub rating: i32,
}
impl ZodValidate for SessionFeedbackRequestDto {
fn zod_validate(value: &serde_json::Value) -> Result<Self, String> {
Self::validate_and_parse(value).map_err(|e| e.to_string())
}
}
// ============================================================
// Response DTOs
// ============================================================
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct BookSessionResponseDto {
pub id: String,
pub mentor_id: String,
pub mentee_id: String,
pub topic: String,
pub description: Option<String>,
pub scheduled_at: String,
pub duration_minutes: i32,
pub session_type: String,
pub status: String,
pub created_at: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct SessionListItemDto {
pub id: String,
pub mentor_id: String,
pub mentee_id: String,
pub mentee_fullname: Option<String>,
pub mentee_email: Option<String>,
pub topic: String,
pub scheduled_at: String,
pub duration_minutes: i32,
pub session_type: String,
pub status: String,
pub rating: Option<i32>,
pub created_at: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct SessionListResponseDto {
pub sessions: Vec<SessionListItemDto>,
pub total: usize,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct SessionDetailDto {
pub id: String,
pub mentor_id: String,
pub mentor_fullname: Option<String>,
pub mentee_id: String,
pub mentee_fullname: Option<String>,
pub topic: String,
pub description: Option<String>,
pub scheduled_at: String,
pub duration_minutes: i32,
pub meeting_link: Option<String>,
pub session_type: String,
pub status: String,
pub feedback: Option<String>,
pub rating: Option<i32>,
pub feedback_submitted_at: Option<String>,
pub created_at: String,
pub updated_at: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct AvailabilitySlotDto {
pub date: String,
pub time: String,
pub available: bool,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct MentorAvailabilityDto {
pub mentor_id: String,
pub availability_commitment: String,
pub preferred_formats: Vec<String>,
pub slots: Vec<AvailabilitySlotDto>,
pub booked_dates: Vec<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct UpdateSessionStatusResponseDto {
pub id: String,
pub status: String,
pub meeting_link: Option<String>,
pub updated_at: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct SessionFeedbackResponseDto {
pub id: String,
pub feedback: String,
pub rating: i32,
pub submitted_at: String,
}
@@ -0,0 +1,177 @@
use std::sync::Arc;
use axum::{
extract::{Extension, Path, Query},
http::HeaderMap,
response::IntoResponse,
};
use serde::Deserialize;
use imphnen_libs::ValidatedJson;
use imphnen_utils::{ApiSuccess, extract_email};
use imphnen_utils::AppError;
use crate::sessions::domain::SessionService;
use super::dto::{
BookSessionRequestDto, BookSessionResponseDto, MentorAvailabilityDto,
SessionFeedbackRequestDto, SessionFeedbackResponseDto, SessionListResponseDto,
UpdateSessionStatusRequestDto, UpdateSessionStatusResponseDto,
};
#[derive(Deserialize)]
pub struct SessionStatusFilter {
pub status: Option<String>,
}
#[utoipa::path(
post,
path = "/v1/mentors/{id}/sessions/create",
tag = "sessions",
security(("Bearer" = [])),
params(
("id" = String, Path, description = "Mentor ID"),
),
request_body = BookSessionRequestDto,
responses(
(status = 201, description = "Session booked successfully", body = BookSessionResponseDto),
(status = 400, description = "Invalid request"),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Mentor not found"),
)
)]
pub async fn post_book_session(
headers: HeaderMap,
Extension(service): Extension<Arc<dyn SessionService>>,
Path(mentor_id): Path<String>,
ValidatedJson(dto): ValidatedJson<BookSessionRequestDto>,
) -> Result<impl IntoResponse, AppError> {
let user_email = extract_email(&headers)
.ok_or_else(|| AppError::AuthenticationError("Token tidak valid".to_string()))?;
let resp = service.book_session(mentor_id, user_email, dto).await?;
Ok(ApiSuccess(resp))
}
#[utoipa::path(
get,
path = "/v1/mentors/{id}/sessions",
tag = "sessions",
security(("Bearer" = [])),
params(
("id" = String, Path, description = "Mentor ID"),
("status" = Option<String>, Query, description = "Filter by status"),
),
responses(
(status = 200, description = "Sessions retrieved successfully", body = SessionListResponseDto),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Mentor not found"),
)
)]
pub async fn get_mentor_sessions(
headers: HeaderMap,
Extension(service): Extension<Arc<dyn SessionService>>,
Path(mentor_id): Path<String>,
Query(filter): Query<SessionStatusFilter>,
) -> Result<impl IntoResponse, AppError> {
let _user_email = extract_email(&headers)
.ok_or_else(|| AppError::AuthenticationError("Token tidak valid".to_string()))?;
let resp = service.get_mentor_sessions(mentor_id, filter.status).await?;
Ok(ApiSuccess(resp))
}
#[utoipa::path(
get,
path = "/v1/mentors/{id}/availability",
tag = "sessions",
params(
("id" = String, Path, description = "Mentor ID"),
),
responses(
(status = 200, description = "Availability retrieved successfully", body = MentorAvailabilityDto),
(status = 404, description = "Mentor not found"),
)
)]
pub async fn get_mentor_availability(
Extension(service): Extension<Arc<dyn SessionService>>,
Path(mentor_id): Path<String>,
) -> Result<impl IntoResponse, AppError> {
let resp = service.get_mentor_availability(mentor_id).await?;
Ok(ApiSuccess(resp))
}
#[utoipa::path(
put,
path = "/v1/sessions/update/{id}/status",
tag = "sessions",
security(("Bearer" = [])),
params(
("id" = String, Path, description = "Session ID"),
),
request_body = UpdateSessionStatusRequestDto,
responses(
(status = 200, description = "Status updated successfully", body = UpdateSessionStatusResponseDto),
(status = 400, description = "Invalid request"),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Session not found"),
)
)]
pub async fn put_update_session_status(
headers: HeaderMap,
Extension(service): Extension<Arc<dyn SessionService>>,
Path(session_id): Path<String>,
ValidatedJson(dto): ValidatedJson<UpdateSessionStatusRequestDto>,
) -> Result<impl IntoResponse, AppError> {
let user_email = extract_email(&headers)
.ok_or_else(|| AppError::AuthenticationError("Token tidak valid".to_string()))?;
let resp = service.update_session_status(session_id, user_email, dto).await?;
Ok(ApiSuccess(resp))
}
#[utoipa::path(
post,
path = "/v1/sessions/{id}/feedback/create",
tag = "sessions",
security(("Bearer" = [])),
params(
("id" = String, Path, description = "Session ID"),
),
request_body = SessionFeedbackRequestDto,
responses(
(status = 200, description = "Feedback submitted successfully", body = SessionFeedbackResponseDto),
(status = 400, description = "Invalid request or session not completed"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
(status = 404, description = "Session not found"),
)
)]
pub async fn post_submit_feedback(
headers: HeaderMap,
Extension(service): Extension<Arc<dyn SessionService>>,
Path(session_id): Path<String>,
ValidatedJson(dto): ValidatedJson<SessionFeedbackRequestDto>,
) -> Result<impl IntoResponse, AppError> {
let user_email = extract_email(&headers)
.ok_or_else(|| AppError::AuthenticationError("Token tidak valid".to_string()))?;
let resp = service.submit_feedback(session_id, user_email, dto).await?;
Ok(ApiSuccess(resp))
}
#[utoipa::path(
get,
path = "/v1/users/me/sessions",
tag = "sessions",
security(("Bearer" = [])),
params(
("status" = Option<String>, Query, description = "Filter by status"),
),
responses(
(status = 200, description = "Sessions retrieved successfully", body = SessionListResponseDto),
(status = 401, description = "Unauthorized"),
)
)]
pub async fn get_my_sessions(
headers: HeaderMap,
Extension(service): Extension<Arc<dyn SessionService>>,
Query(filter): Query<SessionStatusFilter>,
) -> Result<impl IntoResponse, AppError> {
let user_email = extract_email(&headers)
.ok_or_else(|| AppError::AuthenticationError("Token tidak valid".to_string()))?;
let resp = service.get_user_sessions(user_email, filter.status).await?;
Ok(ApiSuccess(resp))
}
@@ -0,0 +1,5 @@
pub mod dto;
pub mod handlers;
pub mod routes;
pub use routes::{sessions_protected_routes, sessions_public_routes};
@@ -0,0 +1,38 @@
use std::sync::Arc;
use axum::{
routing::{get, post, put},
Extension, Router,
};
use sea_orm::DatabaseConnection;
use imphnen_libs::AppState;
use crate::sessions::application::SessionServiceImpl;
use crate::sessions::domain::SessionService;
use crate::sessions::infrastructure::persistence::PostgresSessionRepository;
use super::handlers::{
get_mentor_availability, get_mentor_sessions, get_my_sessions, post_book_session,
post_submit_feedback, put_update_session_status,
};
fn build_service(db: DatabaseConnection) -> Arc<dyn SessionService> {
let repo = Arc::new(PostgresSessionRepository::new(db));
Arc::new(SessionServiceImpl::new(repo))
}
pub fn sessions_public_routes(db: DatabaseConnection) -> Router {
let service = build_service(db);
Router::new()
.route("/mentors/{id}/availability", get(get_mentor_availability))
.layer(Extension(service))
}
pub fn sessions_protected_routes(db: DatabaseConnection, state: Arc<AppState>) -> Router {
let service = build_service(db);
Router::new()
.route("/mentors/{id}/sessions/create", post(post_book_session))
.route("/mentors/{id}/sessions", get(get_mentor_sessions))
.route("/sessions/update/{id}/status", put(put_update_session_status))
.route("/sessions/{id}/feedback/create", post(post_submit_feedback))
.route("/users/me/sessions", get(get_my_sessions))
.layer(Extension(service))
.layer(Extension((*state).clone()))
}
@@ -0,0 +1,2 @@
pub mod http;
pub mod persistence;
@@ -0,0 +1,3 @@
pub mod postgres_session_repository;
pub use postgres_session_repository::PostgresSessionRepository;
@@ -0,0 +1,255 @@
use std::sync::Arc;
use async_trait::async_trait;
use sea_orm::prelude::*;
use sea_orm::{ActiveValue, IntoActiveModel, Order, QueryOrder, PaginatorTrait};
use paginator_rs::{PaginationParams, SortDirection};
use paginator_utils::{PaginatorResponse, PaginatorResponseMeta};
use uuid::Uuid;
use imphnen_utils::AppError;
use imphnen_entities::seaorm::auth::sessions::{
Entity as SessionsEntity,
Column as SessionColumn,
ActiveModel as SessionActiveModel,
Model as SessionModel,
};
use crate::sessions::domain::{session::SessionEntity, repository::SessionRepository};
fn model_to_entity(model: SessionModel) -> SessionEntity {
SessionEntity {
id: model.id,
mentor_id: model.mentor_id,
mentee_id: model.mentee_id,
topic: model.topic,
description: model.description,
scheduled_at: model.scheduled_at,
duration_minutes: model.duration_minutes,
meeting_link: model.meeting_link,
session_type: model.session_type,
status: model.status,
feedback: model.feedback,
rating: model.rating,
feedback_submitted_at: model.feedback_submitted_at,
created_at: model.created_at,
updated_at: model.updated_at,
}
}
pub struct PostgresSessionRepository {
db: Arc<DatabaseConnection>,
}
impl PostgresSessionRepository {
pub fn new(db: DatabaseConnection) -> Self {
Self { db: Arc::new(db) }
}
}
#[async_trait]
impl SessionRepository for PostgresSessionRepository {
async fn create(&self, entity: SessionEntity) -> Result<SessionEntity, AppError> {
let active_model = SessionActiveModel {
id: ActiveValue::Set(entity.id),
mentor_id: ActiveValue::Set(entity.mentor_id),
mentee_id: ActiveValue::Set(entity.mentee_id),
topic: ActiveValue::Set(entity.topic.clone()),
description: ActiveValue::Set(entity.description.clone()),
scheduled_at: ActiveValue::Set(entity.scheduled_at),
duration_minutes: ActiveValue::Set(entity.duration_minutes),
meeting_link: ActiveValue::Set(entity.meeting_link.clone()),
session_type: ActiveValue::Set(entity.session_type.clone()),
status: ActiveValue::Set(entity.status.clone()),
feedback: ActiveValue::Set(entity.feedback.clone()),
rating: ActiveValue::Set(entity.rating),
feedback_submitted_at: ActiveValue::Set(entity.feedback_submitted_at),
created_at: ActiveValue::Set(entity.created_at),
updated_at: ActiveValue::Set(entity.updated_at),
};
let model: SessionModel = active_model
.insert(self.db.as_ref())
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok(model_to_entity(model))
}
async fn find_by_id(&self, id: Uuid) -> Result<Option<SessionEntity>, AppError> {
let model = SessionsEntity::find_by_id(id)
.one(self.db.as_ref())
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok(model.map(model_to_entity))
}
async fn find_by_mentor_id(
&self,
mentor_id: Uuid,
status_filter: Option<String>,
) -> Result<Vec<SessionEntity>, AppError> {
let mut query = SessionsEntity::find()
.filter(SessionColumn::MentorId.eq(mentor_id))
.order_by(SessionColumn::ScheduledAt, Order::Desc);
if let Some(status) = status_filter {
query = query.filter(SessionColumn::Status.eq(status));
}
let models = query
.all(self.db.as_ref())
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok(models.into_iter().map(model_to_entity).collect())
}
async fn find_by_mentee_id(
&self,
mentee_id: Uuid,
status_filter: Option<String>,
) -> Result<Vec<SessionEntity>, AppError> {
let mut query = SessionsEntity::find()
.filter(SessionColumn::MenteeId.eq(mentee_id))
.order_by(SessionColumn::ScheduledAt, Order::Desc);
if let Some(status) = status_filter {
query = query.filter(SessionColumn::Status.eq(status));
}
let models = query
.all(self.db.as_ref())
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok(models.into_iter().map(model_to_entity).collect())
}
async fn find_booked_dates(&self, mentor_id: Uuid) -> Result<Vec<String>, AppError> {
let sessions = SessionsEntity::find()
.filter(SessionColumn::MentorId.eq(mentor_id))
.filter(SessionColumn::Status.is_in(["pending", "confirmed"]))
.order_by(SessionColumn::ScheduledAt, Order::Asc)
.all(self.db.as_ref())
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok(sessions
.into_iter()
.map(|s| s.scheduled_at.to_rfc3339())
.collect())
}
async fn update(&self, id: Uuid, entity: SessionEntity) -> Result<SessionEntity, AppError> {
let model = SessionsEntity::find_by_id(id)
.one(self.db.as_ref())
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?
.ok_or_else(|| AppError::NotFoundError("Session not found".to_string()))?;
let mut active_model = model.into_active_model();
active_model.topic = ActiveValue::Set(entity.topic);
active_model.description = ActiveValue::Set(entity.description);
active_model.scheduled_at = ActiveValue::Set(entity.scheduled_at);
active_model.duration_minutes = ActiveValue::Set(entity.duration_minutes);
active_model.meeting_link = ActiveValue::Set(entity.meeting_link);
active_model.session_type = ActiveValue::Set(entity.session_type);
active_model.status = ActiveValue::Set(entity.status);
active_model.feedback = ActiveValue::Set(entity.feedback);
active_model.rating = ActiveValue::Set(entity.rating);
active_model.feedback_submitted_at = ActiveValue::Set(entity.feedback_submitted_at);
active_model.updated_at = ActiveValue::Set(entity.updated_at);
let updated: SessionModel = active_model
.update(self.db.as_ref())
.await
.map_err(|e: sea_orm::DbErr| AppError::InternalServerError(e.to_string()))?;
Ok(model_to_entity(updated))
}
async fn delete(&self, id: Uuid) -> Result<(), AppError> {
let model = SessionsEntity::find_by_id(id)
.one(self.db.as_ref())
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?
.ok_or_else(|| AppError::NotFoundError("Session not found".to_string()))?;
model
.into_active_model()
.delete(self.db.as_ref())
.await
.map_err(|e: sea_orm::DbErr| AppError::InternalServerError(e.to_string()))?;
Ok(())
}
async fn count_by_mentor(
&self,
mentor_id: Uuid,
status_filter: Option<String>,
) -> Result<usize, AppError> {
let mut query = SessionsEntity::find()
.filter(SessionColumn::MentorId.eq(mentor_id));
if let Some(status) = status_filter {
query = query.filter(SessionColumn::Status.eq(status));
}
let count = query
.count(self.db.as_ref())
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok(count as usize)
}
async fn count_by_mentee(
&self,
mentee_id: Uuid,
status_filter: Option<String>,
) -> Result<usize, AppError> {
let mut query = SessionsEntity::find()
.filter(SessionColumn::MenteeId.eq(mentee_id));
if let Some(status) = status_filter {
query = query.filter(SessionColumn::Status.eq(status));
}
let count = query
.count(self.db.as_ref())
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
Ok(count as usize)
}
async fn find_all_paginated(
&self,
params: PaginationParams,
) -> Result<PaginatorResponse<SessionEntity>, AppError> {
let page = params.page.max(1);
let per_page = params.per_page.clamp(1, 100);
let query = match params.sort_direction {
Some(SortDirection::Asc) => SessionsEntity::find()
.order_by(SessionColumn::CreatedAt, Order::Asc),
_ => SessionsEntity::find()
.order_by(SessionColumn::CreatedAt, Order::Desc),
};
let paginator = query.paginate(self.db.as_ref(), per_page as u64);
let total = paginator
.num_items()
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
let sessions = paginator
.fetch_page((page - 1) as u64)
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
let data = sessions.into_iter().map(model_to_entity).collect();
let meta = PaginatorResponseMeta::new(page, per_page, total as u32);
Ok(PaginatorResponse { data, meta })
}
}
+5
View File
@@ -0,0 +1,5 @@
pub mod application;
pub mod domain;
pub mod infrastructure;
pub use infrastructure::http::routes::{sessions_protected_routes, sessions_public_routes};
@@ -1,333 +0,0 @@
use super::{
MentorDetailResponseDto, MentorListResponseDto, MentorUpdateRequestDto,
MentorUserRegisterRequestDto, MentorVerifyRequestDto, MentorsService,
};
use crate::v1::mentors::mentors_dto::MentorRegisterResponseDto;
use ::axum::{
extract::{Extension, Path, Query},
http::HeaderMap,
response::Response,
};
use imphnen_entities::MetaRequestDto;
use uuid::Uuid;
use axum::http::StatusCode;
use imphnen_utils::common_response;
use imphnen_libs::{AppState, ValidatedJson};
use imphnen_iam::{PermissionsEnum, require_permissions};
use imphnen_utils::extract_email;
#[utoipa::path(
post,
path = "/v1/mentors/create",
request_body = MentorUserRegisterRequestDto,
responses(
(status = 200, description = "[PUBLIC] Mentor registered successfully", body = MentorRegisterResponseDto),
(status = 400, description = "[PUBLIC] Bad request - validation error"),
(status = 409, description = "[PUBLIC] Conflict - user already has mentor profile"),
(status = 500, description = "[PUBLIC] Internal server error")
),
tag = "Mentors"
)]
pub async fn post_register_mentor(
Extension(app_state): Extension<AppState>,
ValidatedJson(dto): ValidatedJson<MentorUserRegisterRequestDto>,
) -> Response {
MentorsService::register_mentor(&app_state, dto).await
}
#[utoipa::path(
get,
path = "/v1/mentors",
params(
("page" = Option<u64>, Query, description = "Page number"),
("per_page" = Option<u64>, Query, description = "Items per page"),
("search" = Option<String>, Query, description = "Search query"),
("sort_by" = Option<String>, Query, description = "Sort by field"),
("order" = Option<String>, Query, description = "Sort order (ASC/DESC)"),
),
responses(
(status = 200, description = "[ADMIN] Get list of mentors", body = Vec<MentorListResponseDto>),
(status = 500, description = "[ADMIN] Internal server error")
),
tag = "Mentors",
security(
("Bearer" = [])
)
)]
pub async fn get_mentor_list(
headers: HeaderMap,
Extension(app_state): Extension<AppState>,
Query(meta): Query<MetaRequestDto>,
) -> Response {
require_permissions!(headers, app_state, [PermissionsEnum::ReadListMentors], {
MentorsService::get_mentor_list(&app_state, meta).await
})
}
#[utoipa::path(
get,
path = "/v1/mentors/detail/{id}",
params(
("id" = String, Path, description = "Mentor ID")
),
responses(
(status = 200, description = "[ADMIN] Get mentor by ID", body = MentorDetailResponseDto),
(status = 404, description = "[ADMIN] Mentor not found"),
(status = 500, description = "[ADMIN] Internal server error")
),
tag = "Mentors",
security(
("Bearer" = [])
)
)]
pub async fn get_mentor_by_id(
headers: HeaderMap,
Extension(app_state): Extension<AppState>,
Path(id): Path<String>,
) -> Response {
// Validate UUID format
let _mentor_uuid = match Uuid::parse_str(&id) {
Ok(uuid) => uuid,
Err(_) => {
return common_response(
StatusCode::BAD_REQUEST,
"Invalid mentor ID format. Must be a valid UUID."
);
}
};
require_permissions!(headers, app_state, [PermissionsEnum::ReadDetailMentors], {
MentorsService::get_mentor_by_id(&app_state, &id).await
})
}
#[utoipa::path(
put,
path = "/v1/mentors/update/{id}",
params(
("id" = String, Path, description = "Mentor ID")
),
request_body = MentorUpdateRequestDto,
responses(
(status = 200, description = "[ADMIN] Mentor updated successfully", body = MentorDetailResponseDto),
(status = 400, description = "[ADMIN] Bad request - validation error"),
(status = 404, description = "[ADMIN] Mentor not found"),
(status = 500, description = "[ADMIN] Internal server error")
),
tag = "Mentors - Admin",
security(
("Bearer" = [])
)
)]
pub async fn put_update_mentor(
headers: HeaderMap,
Extension(app_state): Extension<AppState>,
Path(id): Path<String>,
ValidatedJson(dto): ValidatedJson<MentorUpdateRequestDto>,
) -> Response {
// Validate UUID format
let _mentor_uuid = match Uuid::parse_str(&id) {
Ok(uuid) => uuid,
Err(_) => {
return common_response(
StatusCode::BAD_REQUEST,
"Invalid mentor ID format. Must be a valid UUID."
);
}
};
require_permissions!(headers, app_state, [PermissionsEnum::UpdateMentors], {
MentorsService::update_mentor(&app_state, &id, dto).await
})
}
#[utoipa::path(
delete,
path = "/v1/mentors/delete/{id}",
params(
("id" = String, Path, description = "Mentor ID")
),
responses(
(status = 200, description = "[ADMIN] Mentor deleted successfully"),
(status = 404, description = "[ADMIN] Mentor not found"),
(status = 500, description = "[ADMIN] Internal server error")
),
tag = "Mentors - Admin",
security(
("Bearer" = [])
)
)]
pub async fn delete_mentor(
headers: HeaderMap,
Extension(app_state): Extension<AppState>,
Path(id): Path<String>,
) -> Response {
// Validate UUID format
let _mentor_uuid = match Uuid::parse_str(&id) {
Ok(uuid) => uuid,
Err(_) => {
return common_response(
StatusCode::BAD_REQUEST,
"Invalid mentor ID format. Must be a valid UUID."
);
}
};
require_permissions!(headers, app_state, [PermissionsEnum::DeleteMentors], {
MentorsService::delete_mentor(&app_state, &id).await
})
}
#[utoipa::path(
put,
path = "/v1/mentors/verify/{id}",
params(
("id" = String, Path, description = "Mentor ID")
),
request_body = MentorVerifyRequestDto,
responses(
(status = 200, description = "[ADMIN] Mentor verified successfully", body = MentorDetailResponseDto),
(status = 400, description = "[ADMIN] Bad request - validation error"),
(status = 404, description = "[ADMIN] Mentor not found"),
(status = 500, description = "[ADMIN] Internal server error")
),
tag = "Mentors - Admin",
security(
("Bearer" = [])
)
)]
pub async fn put_verify_mentor(
headers: HeaderMap,
Extension(app_state): Extension<AppState>,
Path(id): Path<String>,
ValidatedJson(dto): ValidatedJson<MentorVerifyRequestDto>,
) -> Response {
// Validate UUID format
let _mentor_uuid = match Uuid::parse_str(&id) {
Ok(uuid) => uuid,
Err(_) => {
return common_response(
StatusCode::BAD_REQUEST,
"Invalid mentor ID format. Must be a valid UUID."
);
}
};
require_permissions!(headers, app_state, [PermissionsEnum::VerifyMentors], {
MentorsService::verify_mentor(&app_state, &id, dto).await
})
}
#[utoipa::path(
get,
path = "/v1/mentors/me",
responses(
(status = 200, description = "[MENTOR] Current user's mentor profile", body = MentorDetailResponseDto),
(status = 401, description = "[MENTOR] Unauthorized - invalid token"),
(status = 403, description = "[MENTOR] Mentor profile not found for current user"),
(status = 500, description = "[MENTOR] Internal server error")
),
tag = "Mentors",
security(
("Bearer" = [])
)
)]
pub async fn get_mentor_me(
headers: HeaderMap,
Extension(app_state): Extension<AppState>,
) -> Response {
require_permissions!(headers.clone(), app_state, [PermissionsEnum::ReadOwnMentorProfile], {
let email = match extract_email(&headers) {
Some(email) => email,
None => {
return imphnen_utils::common_response(
axum::http::StatusCode::UNAUTHORIZED,
"Token tidak valid",
);
}
};
MentorsService::get_mentor_me(&app_state, &email).await
})
}
#[utoipa::path(
put,
path = "/v1/mentors/me/update",
request_body = MentorUpdateRequestDto,
responses(
(status = 200, description = "[MENTOR] Mentor profile updated successfully", body = MentorDetailResponseDto),
(status = 400, description = "[MENTOR] Bad request - validation error"),
(status = 401, description = "[MENTOR] Unauthorized - invalid token"),
(status = 404, description = "[MENTOR] Mentor profile not found"),
(status = 500, description = "[MENTOR] Internal server error")
),
tag = "Mentors",
security(
("Bearer" = [])
)
)]
pub async fn put_update_mentor_me(
headers: HeaderMap,
Extension(app_state): Extension<AppState>,
ValidatedJson(dto): ValidatedJson<MentorUpdateRequestDto>,
) -> Response {
require_permissions!(headers.clone(), app_state, [PermissionsEnum::UpdateOwnMentorProfile], {
let email = match extract_email(&headers) {
Some(email) => email,
None => {
return imphnen_utils::common_response(
axum::http::StatusCode::UNAUTHORIZED,
"Token tidak valid",
);
}
};
MentorsService::update_mentor_me(&app_state, &email, dto).await
})
}
#[utoipa::path(
put,
path = "/v1/mentors/update",
request_body = MentorUpdateRequestDto,
responses(
(status = 400, description = "[PUBLIC] Bad request - Mentor ID is required for update"),
),
tag = "Mentors - Admin"
)]
pub async fn put_update_mentor_no_id() -> Response {
imphnen_utils::common_response(
axum::http::StatusCode::BAD_REQUEST,
"Mentor ID is required for update",
)
}
#[utoipa::path(
get,
path = "/v1/mentors/me/status",
responses(
(status = 200, description = "[MENTOR] Mentor application status", body = String),
(status = 401, description = "[MENTOR] Unauthorized - invalid token"),
(status = 403, description = "[MENTOR] No mentor application found for current user"),
(status = 500, description = "[MENTOR] Internal server error")
),
tag = "Mentors",
security(
("Bearer" = [])
)
)]
pub async fn get_mentor_status(
headers: HeaderMap,
Extension(app_state): Extension<AppState>,
) -> Response {
require_permissions!(headers.clone(), app_state, [PermissionsEnum::ReadOwnMentorStatus], {
let email = match extract_email(&headers) {
Some(email) => email,
None => {
return imphnen_utils::common_response(
axum::http::StatusCode::UNAUTHORIZED,
"Token tidak valid",
);
}
};
MentorsService::get_mentor_status(&app_state, &email).await
})
}
@@ -1,438 +0,0 @@
use crate::v1::mentors::MentorSchema;
use crate::v1::sessions::sessions_schema::Thing;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use validator::Validate;
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct MentorListResponseDto {
pub id: String,
pub fullname: Option<String>,
pub email: Option<String>,
pub status: String,
pub created_at: String,
pub updated_at: String,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct MentorDetailWithUserDto {
pub id: String,
pub user_id: String,
// Personal data is now in UsersSchema, access via user_id
// Removed: fullname, email, legal_name, identity_document_url,
// phone_for_verification, bio, linkedin_url, github_url, cv_url
pub industries: Vec<String>,
pub expertise: Vec<String>,
pub languages: Vec<String>,
pub current_company: String,
pub current_role: String,
pub years_of_experience: i32,
pub topics_of_interest: Vec<String>,
pub preferred_mentee_level: Vec<String>,
pub preferred_mentoring_formats: Vec<String>,
pub availability_commitment: String,
pub mentoring_rate: f64,
pub status: String,
pub is_deleted: bool,
pub created_at: String,
pub updated_at: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct MentorDetailResponseDto {
pub id: String,
pub user_id: String,
// Personal data fields from UsersSchema
pub fullname: Option<String>,
pub email: Option<String>,
pub legal_name: Option<String>,
pub gender: Option<String>,
pub domicile: Option<String>,
pub phone_for_verification: Option<String>,
pub bio: Option<String>,
pub last_education: Option<String>,
pub linkedin_url: Option<String>,
pub github_url: Option<String>,
pub cv_url: Option<String>,
pub portfolio_url: Option<String>,
// Professional data from MentorSchema
pub industries: Vec<String>,
pub expertise: Vec<String>,
pub languages: Vec<String>,
pub current_company: String,
pub current_role: String,
pub years_of_experience: i32,
pub topics_of_interest: Vec<String>,
pub preferred_mentee_level: Vec<String>,
pub preferred_mentoring_formats: Vec<String>,
pub availability_commitment: String,
pub mentoring_rate: f64,
pub status: String,
pub created_at: String,
pub updated_at: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)]
pub struct MentorRegisterResponseDto {
pub id: String,
pub user_id: String,
pub email: Option<String>,
pub status: String,
pub created_at: String,
pub updated_at: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)]
pub struct MentorUpdateRequestDto {
#[validate(length(
min = 3,
message = "Legal name must be at least 3 characters"
))]
#[serde(skip_serializing_if = "Option::is_none")]
pub legal_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub gender: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub domicile: Option<String>,
#[validate(length(
min = 10,
max = 15,
message = "Phone must be 10-15 characters"
))]
#[serde(skip_serializing_if = "Option::is_none")]
pub phone_for_verification: Option<String>,
#[validate(length(min = 50, message = "Bio must be at least 50 characters"))]
#[serde(skip_serializing_if = "Option::is_none")]
pub bio: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_education: Option<String>,
#[validate(url(message = "Invalid LinkedIn URL"))]
#[serde(skip_serializing_if = "Option::is_none")]
pub linkedin_url: Option<String>,
#[validate(url(message = "Invalid GitHub URL"))]
#[serde(skip_serializing_if = "Option::is_none")]
pub github_url: Option<String>,
#[validate(url(message = "Invalid CV URL"))]
#[serde(skip_serializing_if = "Option::is_none")]
pub cv_url: Option<String>,
#[validate(url(message = "Invalid portfolio URL"))]
#[serde(skip_serializing_if = "Option::is_none")]
pub portfolio_url: Option<String>,
#[validate(length(min = 1, message = "At least 1 industry required"))]
#[serde(skip_serializing_if = "Option::is_none")]
pub industries: Option<Vec<String>>,
#[validate(length(min = 1, message = "At least 1 expertise required"))]
#[serde(skip_serializing_if = "Option::is_none")]
pub expertise: Option<Vec<String>>,
#[validate(length(min = 1, message = "At least 1 language required"))]
#[serde(skip_serializing_if = "Option::is_none")]
pub languages: Option<Vec<String>>,
#[validate(length(min = 1, message = "Current company required"))]
#[serde(skip_serializing_if = "Option::is_none")]
pub current_company: Option<String>,
#[validate(length(min = 1, message = "Current role required"))]
#[serde(skip_serializing_if = "Option::is_none")]
pub current_role: Option<String>,
#[validate(range(min = 2, message = "At least 2 years of experience required"))]
#[serde(skip_serializing_if = "Option::is_none")]
pub years_of_experience: Option<i32>,
#[validate(length(min = 1, message = "At least 1 topic required"))]
#[serde(skip_serializing_if = "Option::is_none")]
pub topics_of_interest: Option<Vec<String>>,
#[validate(length(min = 1, message = "At least 1 mentee level required"))]
#[serde(skip_serializing_if = "Option::is_none")]
pub preferred_mentee_level: Option<Vec<String>>,
#[validate(length(min = 1, message = "At least 1 mentoring format required"))]
#[serde(skip_serializing_if = "Option::is_none")]
pub preferred_mentoring_formats: Option<Vec<String>>,
#[validate(length(
min = 5,
message = "Availability commitment must be at least 5 characters"
))]
#[serde(skip_serializing_if = "Option::is_none")]
pub availability_commitment: Option<String>,
#[validate(range(min = 1, message = "Amount must be at least 1"))]
#[serde(skip_serializing_if = "Option::is_none")]
pub mentoring_rate_amount: Option<u64>,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)]
pub struct MentorUserRegisterRequestDto {
#[validate(
length(min = 1, message = "Email cannot be empty"),
email(message = "Email not valid")
)]
pub email: String,
#[validate(length(
min = 8,
message = "Password must have at least 8 characters"
))]
#[validate(custom(
function = "imphnen_iam::v1::auth::auth_dto::validate_password_complexity",
message = "Password must include uppercase, lowercase, number, and special character"
))]
pub password: String,
#[validate(length(min = 2, message = "Fullname at least have 2 character"))]
pub fullname: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub phone_number: Option<String>,
#[validate(nested)]
pub identity_and_verification: IdentityAndVerification,
#[validate(nested)]
pub professional_profile: ProfessionalProfile,
#[validate(nested)]
pub mentoring_logistics: MentoringLogistics,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)]
pub struct MentorRegisterFromTokenRequestDto {
#[validate(nested)]
pub identity_and_verification: IdentityAndVerification,
#[validate(nested)]
pub professional_profile: ProfessionalProfile,
#[validate(nested)]
pub mentoring_logistics: MentoringLogistics,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)]
pub struct IdentityAndVerification {
#[validate(length(
min = 3,
message = "Legal name must be at least 3 characters"
))]
pub legal_name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub gender: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub domicile: Option<String>,
#[validate(url(message = "Invalid identity document URL"))]
pub identity_document_url: String,
#[validate(length(
min = 10,
max = 15,
message = "Phone must be 10-15 characters"
))]
#[serde(skip_serializing_if = "Option::is_none")]
pub phone_for_verification: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)]
pub struct ProfessionalProfile {
#[validate(length(min = 50, message = "Bio must be at least 50 characters"))]
pub bio: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_education: Option<String>,
#[validate(url(message = "Invalid LinkedIn URL"))]
#[serde(skip_serializing_if = "Option::is_none")]
pub linkedin_url: Option<String>,
#[validate(url(message = "Invalid GitHub URL"))]
#[serde(skip_serializing_if = "Option::is_none")]
pub github_url: Option<String>,
#[validate(url(message = "Invalid CV URL"))]
#[serde(skip_serializing_if = "Option::is_none")]
pub cv_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub portfolio_url: Option<String>,
#[validate(length(min = 1, message = "At least 1 industry required"))]
pub industries: Vec<String>,
#[validate(length(min = 1, message = "At least 1 expertise required"))]
pub expertise: Vec<String>,
#[validate(length(min = 1, message = "At least 1 language required"))]
pub languages: Vec<String>,
#[validate(length(min = 1, message = "Current company required"))]
pub current_company: String,
#[validate(length(min = 1, message = "Current role required"))]
pub current_role: String,
#[validate(range(min = 2, message = "At least 2 years of experience required"))]
pub years_of_experience: i32,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)]
pub struct MentoringLogistics {
#[validate(length(min = 1, message = "At least 1 topic required"))]
pub topics_of_interest: Vec<String>,
#[validate(length(min = 1, message = "At least 1 mentee level required"))]
pub preferred_mentee_level: Vec<String>,
#[validate(length(min = 1, message = "At least 1 mentoring format required"))]
pub preferred_mentoring_formats: Vec<String>,
#[validate(length(
min = 5,
message = "Availability commitment must be at least 5 characters"
))]
pub availability_commitment: String,
#[validate(range(min = 1, message = "Amount must be at least 1"))]
pub mentoring_rate_amount: u64,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate, Default)]
pub struct MentoringRate {
#[validate(range(min = 1, message = "Amount must be at least 1"))]
pub amount: u64,
#[validate(length(min = 1, message = "Currency is required"))]
pub currency: String,
#[validate(length(min = 1, message = "Per duration is required"))]
pub per_duration: String,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct MentorInsertDto {
pub id: Thing,
pub user_id: Option<Thing>,
// Personal data removed - now stored in UsersSchema
pub industries: Vec<String>,
pub expertise: Vec<String>,
pub languages: Vec<String>,
pub current_company: String,
pub current_role: String,
pub years_of_experience: i32,
pub topics_of_interest: Vec<String>,
pub preferred_mentee_level: Vec<String>,
pub preferred_mentoring_formats: Vec<String>,
pub availability_commitment: String,
pub mentoring_rate: f64,
pub status: String,
pub is_deleted: bool,
pub created_at: String,
pub updated_at: String,
}
impl From<MentorSchema> for MentorInsertDto {
fn from(schema: MentorSchema) -> Self {
MentorInsertDto {
id: schema.id,
user_id: schema.user_id,
// Personal data removed from MentorSchema
industries: schema.industries,
expertise: schema.expertise,
languages: schema.languages,
current_company: schema.current_company,
current_role: schema.current_role,
years_of_experience: schema.years_of_experience,
topics_of_interest: schema.topics_of_interest,
preferred_mentee_level: schema.preferred_mentee_level,
preferred_mentoring_formats: schema.preferred_mentoring_formats,
availability_commitment: schema.availability_commitment,
mentoring_rate: schema.mentoring_rate,
status: schema.status,
is_deleted: schema.is_deleted,
created_at: schema.created_at,
updated_at: schema.updated_at,
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)]
pub struct MentorVerifyRequestDto {
#[validate(length(min = 1, message = "Status is required"))]
pub status: String,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct MentorDetailQueryDto {
pub id: Thing,
pub user_id: Thing,
// Personal data has been moved to UsersSchema
// Use user_id to get: fullname, email, legal_name, gender, domicile,
// identity_document_url, phone_for_verification, bio, last_education,
// linkedin_url, github_url, cv_url, portfolio_url
pub industries: Vec<String>,
pub expertise: Vec<String>,
pub languages: Vec<String>,
pub current_company: String,
pub current_role: String,
pub years_of_experience: i32,
pub topics_of_interest: Vec<String>,
pub preferred_mentee_level: Vec<String>,
pub preferred_mentoring_formats: Vec<String>,
pub availability_commitment: String,
pub mentoring_rate: f64,
pub status: String,
pub is_deleted: bool,
pub created_at: String,
pub updated_at: String,
}
impl From<MentorDetailQueryDto> for MentorListResponseDto {
fn from(dto: MentorDetailQueryDto) -> Self {
Self {
id: dto.id.clone(),
fullname: None, // now in user table, must be populated from service layer
email: None, // now in user table, must be populated from service layer
status: dto.status,
created_at: dto.created_at,
updated_at: dto.updated_at,
}
}
}
impl From<MentorDetailQueryDto> for MentorDetailResponseDto {
fn from(dto: MentorDetailQueryDto) -> Self {
Self {
id: dto.id.clone(),
user_id: dto.user_id.clone(),
// Personal data fields are populated in service layer from UsersSchema
fullname: None, // populated from user table in service layer
email: None, // populated from user table in service layer
legal_name: None, // populated from user table in service layer
gender: None, // populated from user table in service layer
domicile: None, // populated from user table in service layer
phone_for_verification: None, // populated from user table in service layer
bio: None, // populated from user table in service layer
last_education: None, // populated from user table in service layer
linkedin_url: None, // populated from user table in service layer
github_url: None, // populated from user table in service layer
cv_url: None, // populated from user table in service layer
portfolio_url: None, // populated from user table in service layer
// Professional data from mentor
industries: dto.industries,
expertise: dto.expertise,
languages: dto.languages,
current_company: dto.current_company,
current_role: dto.current_role,
years_of_experience: dto.years_of_experience,
topics_of_interest: dto.topics_of_interest,
preferred_mentee_level: dto.preferred_mentee_level,
preferred_mentoring_formats: dto.preferred_mentoring_formats,
availability_commitment: dto.availability_commitment,
mentoring_rate: dto.mentoring_rate,
status: dto.status,
created_at: dto.created_at,
updated_at: dto.updated_at,
}
}
}
impl From<MentorSchema> for MentorRegisterResponseDto {
fn from(schema: MentorSchema) -> Self {
Self {
id: schema.id.to_string(),
user_id: schema.user_id.unwrap_or_default(),
email: None, // schema.email - now in user table
status: schema.status,
created_at: schema.created_at,
updated_at: schema.updated_at,
}
}
}
impl From<MentorDetailWithUserDto> for MentorDetailQueryDto {
fn from(dto: MentorDetailWithUserDto) -> Self {
MentorDetailQueryDto {
id: dto.id,
user_id: dto.user_id,
// Personal data removed from MentorDetailWithUserDto - now in UsersSchema
industries: dto.industries,
expertise: dto.expertise,
languages: dto.languages,
current_company: dto.current_company,
current_role: dto.current_role,
years_of_experience: dto.years_of_experience,
topics_of_interest: dto.topics_of_interest,
preferred_mentee_level: dto.preferred_mentee_level,
preferred_mentoring_formats: dto.preferred_mentoring_formats,
availability_commitment: dto.availability_commitment,
mentoring_rate: dto.mentoring_rate,
status: dto.status,
is_deleted: dto.is_deleted,
created_at: dto.created_at,
updated_at: dto.updated_at,
}
}
}
@@ -1,323 +0,0 @@
use crate::v1::mentors::mentors_schema::MentorSchema;
use imphnen_libs::AppState;
use imphnen_libs::AppStatePostgresExt;
use imphnen_entities::seaorm::auth::mentors::{Entity as Mentors, ActiveModel as MentorActiveModel, Column as MentorColumn};
use imphnen_entities::seaorm::auth::users::Entity as Users;
use anyhow::{Result, bail};
use sea_orm::*;
use sea_orm::EntityTrait;
use uuid::Uuid;
use sea_orm::ActiveModelTrait as ActiveModelTraitSpecific;
use anyhow::anyhow;
use imphnen_entities::{MetaRequestDto, ResponseListSuccessDto};
use imphnen_utils::Result as UtilsResult;
use crate::v1::mentors::mentors_dto::MentorDetailQueryDto;
use std::time::Instant;
use tracing::{instrument, info};
use serde_json;
use chrono::Utc;
pub struct MentorsRepository<'a> {
state: &'a AppState,
}
impl<'a> MentorsRepository<'a> {
pub fn new(state: &'a AppState) -> Self {
Self { state }
}
fn get_db(&self) -> &DatabaseConnection {
self.state.postgres_db()
}
#[instrument(skip(self, data), err)]
pub async fn query_create_mentor(&self, data: MentorSchema) -> Result<String> {
let now = Instant::now();
let mentor_active_model = MentorActiveModel {
user_id: ActiveValue::Set(Uuid::parse_str(&data.user_id.unwrap_or_default())?),
industries: ActiveValue::Set(Some(data.industries.into())),
expertise: ActiveValue::Set(Some(data.expertise.into())),
languages: ActiveValue::Set(Some(data.languages.into())),
current_company: ActiveValue::Set(Some(data.current_company)),
current_role: ActiveValue::Set(Some(data.current_role)),
years_of_experience: ActiveValue::Set(Some(data.years_of_experience)),
topics_of_interest: ActiveValue::Set(Some(data.topics_of_interest.into())),
preferred_mentee_level: ActiveValue::Set(Some(serde_json::to_string(&data.preferred_mentee_level).unwrap())),
preferred_mentoring_formats: ActiveValue::Set(Some(data.preferred_mentoring_formats.into())),
availability_commitment: ActiveValue::Set(Some(data.availability_commitment)),
mentoring_rate: ActiveValue::Set(Some(data.mentoring_rate)),
status: ActiveValue::Set(Some(data.status)),
created_at: ActiveValue::Set(Utc::now()),
updated_at: ActiveValue::Set(Utc::now()),
..Default::default()
};
info!("Executing PostgreSQL create in query_create_mentor");
let result = <MentorActiveModel as sea_orm::ActiveModelTrait>::insert(mentor_active_model, self.get_db()).await?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string()) == "development" {
println!("Query 'query_create_mentor' took: {elapsed:.2?}");
}
Ok(result.id.to_string())
}
#[instrument(skip(self, data), err)]
pub async fn query_update_mentor(&self, data: MentorSchema) -> Result<String> {
let now = Instant::now();
let mentor_id = Uuid::parse_str(&data.id)?;
let existing_mentor = Mentors::find_by_id(mentor_id)
.one(self.get_db())
.await?
.ok_or_else(|| anyhow!("Mentor not found"))?;
let mut mentor_active_model: MentorActiveModel = existing_mentor.into();
if !data.industries.is_empty() { mentor_active_model.industries = ActiveValue::Set(Some(serde_json::to_value(data.industries).unwrap())); }
if !data.expertise.is_empty() { mentor_active_model.expertise = ActiveValue::Set(Some(serde_json::to_value(data.expertise).unwrap())); }
if !data.languages.is_empty() { mentor_active_model.languages = ActiveValue::Set(Some(serde_json::to_value(data.languages).unwrap())); }
if !data.current_company.is_empty() { mentor_active_model.current_company = ActiveValue::Set(Some(data.current_company)); }
if !data.current_role.is_empty() { mentor_active_model.current_role = ActiveValue::Set(Some(data.current_role)); }
mentor_active_model.years_of_experience = ActiveValue::Set(Some(data.years_of_experience));
if !data.topics_of_interest.is_empty() { mentor_active_model.topics_of_interest = ActiveValue::Set(Some(serde_json::to_value(data.topics_of_interest).unwrap())); }
if !data.preferred_mentee_level.is_empty() { mentor_active_model.preferred_mentee_level = ActiveValue::Set(Some(serde_json::to_string(&data.preferred_mentee_level).unwrap())); }
if !data.preferred_mentoring_formats.is_empty() { mentor_active_model.preferred_mentoring_formats = ActiveValue::Set(Some(serde_json::to_value(data.preferred_mentoring_formats).unwrap())); }
if !data.availability_commitment.is_empty() { mentor_active_model.availability_commitment = ActiveValue::Set(Some(data.availability_commitment)); }
mentor_active_model.mentoring_rate = ActiveValue::Set(Some(data.mentoring_rate));
if !data.status.is_empty() { mentor_active_model.status = ActiveValue::Set(Some(data.status)); }
mentor_active_model.updated_at = ActiveValue::Set(Utc::now());
info!("Executing PostgreSQL update in query_update_mentor");
let result = mentor_active_model.update(self.get_db()).await?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string()) == "development" {
println!("Query 'query_update_mentor' took: {elapsed:.2?}");
}
Ok(format!("Success update mentor: {}", result.id))
}
#[instrument(skip(self, id), err)]
pub async fn query_delete_mentor(&self, id: &str) -> Result<String> {
let now = Instant::now();
let mentor = Mentors::find_by_id(Uuid::parse_str(id)?)
.one(self.get_db())
.await?
.ok_or_else(|| anyhow!("Mentor not found"))?;
if mentor.is_deleted {
bail!("Mentor is already soft deleted");
}
let mut mentor_active_model: MentorActiveModel = mentor.into();
mentor_active_model.is_deleted = ActiveValue::Set(true);
mentor_active_model.updated_at = ActiveValue::Set(Utc::now());
info!("Executing PostgreSQL soft delete in query_delete_mentor");
let result = mentor_active_model.update(self.get_db()).await?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string()) == "development" {
println!("Query 'query_delete_mentor' took: {elapsed:.2?}");
}
Ok(format!("Success soft delete mentor: {}", result.id))
}
#[instrument(skip(self, email, include_deleted), err)]
pub async fn query_mentor_by_email(
&self,
email: String,
include_deleted: bool,
) -> UtilsResult<MentorDetailQueryDto> {
let now = Instant::now();
let db = self.get_db();
let mut query = Mentors::find()
.find_also_related(Users);
if !include_deleted {
query = query.filter(MentorColumn::IsDeleted.eq(false));
}
let (mentor, user) = query
.filter(imphnen_entities::seaorm::auth::users::Column::Email.eq(email))
.one(db)
.await?
.ok_or_else(|| anyhow::anyhow!("Mentor not found"))?;
let _user = user.ok_or_else(|| anyhow::anyhow!("User not found for mentor"))?;
let result = MentorDetailQueryDto {
id: mentor.id.to_string(),
user_id: mentor.user_id.to_string(),
industries: serde_json::from_value(mentor.industries.clone().unwrap_or(serde_json::Value::Null)).unwrap_or_default(),
expertise: serde_json::from_value(mentor.expertise.clone().unwrap_or(serde_json::Value::Null)).unwrap_or_default(),
languages: serde_json::from_value(mentor.languages.clone().unwrap_or(serde_json::Value::Null)).unwrap_or_default(),
current_company: mentor.current_company.unwrap_or_default(),
current_role: mentor.current_role.unwrap_or_default(),
years_of_experience: mentor.years_of_experience.unwrap_or(0),
topics_of_interest: serde_json::from_value(mentor.topics_of_interest.clone().unwrap_or(serde_json::Value::Null)).unwrap_or_default(),
preferred_mentee_level: serde_json::from_str(&mentor.preferred_mentee_level.unwrap_or_default()).unwrap_or_default(),
preferred_mentoring_formats: serde_json::from_value(mentor.preferred_mentoring_formats.clone().unwrap_or(serde_json::Value::Null)).unwrap_or_default(),
availability_commitment: mentor.availability_commitment.unwrap_or_default(),
mentoring_rate: mentor.mentoring_rate.unwrap_or(0.0),
status: mentor.status.unwrap_or_default(),
is_deleted: mentor.is_deleted,
created_at: mentor.created_at.to_rfc3339(),
updated_at: mentor.updated_at.to_rfc3339(),
};
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string()) == "development" {
println!("Query 'query_mentor_by_email' took: {elapsed:.2?}");
}
Ok(result)
}
#[instrument(skip(self, meta), err)]
pub async fn query_mentor_list(
&self,
meta: MetaRequestDto,
) -> UtilsResult<ResponseListSuccessDto<Vec<MentorDetailQueryDto>>> {
let now = Instant::now();
let db = self.get_db();
let page = meta.page.unwrap_or(1);
let per_page = meta.per_page.unwrap_or(10);
let mut query = Mentors::find()
.filter(MentorColumn::IsDeleted.eq(false))
.find_also_related(Users);
// Apply sorting
if let Some(sort_by) = &meta.sort_by {
let order = meta.order.as_deref().unwrap_or("asc");
match sort_by.as_str() {
"created_at" => {
if order == "desc" {
query = query.order_by_desc(MentorColumn::CreatedAt);
} else {
query = query.order_by_asc(MentorColumn::CreatedAt);
}
}
"updated_at" => {
if order == "desc" {
query = query.order_by_desc(MentorColumn::UpdatedAt);
} else {
query = query.order_by_asc(MentorColumn::UpdatedAt);
}
}
_ => {
query = query.order_by_desc(MentorColumn::CreatedAt);
}
}
} else {
query = query.order_by_desc(MentorColumn::CreatedAt);
}
let paginator = query.paginate(db, per_page);
let total_pages = paginator.num_pages().await?;
let mentors = paginator.fetch_page(page - 1).await?;
let data: Vec<MentorDetailQueryDto> = mentors
.into_iter()
.filter_map(|(mentor, user)| {
user.map(|_u| MentorDetailQueryDto {
id: mentor.id.to_string(),
user_id: mentor.user_id.to_string(),
industries: serde_json::from_value(mentor.industries.clone().unwrap_or(serde_json::Value::Null)).unwrap_or_default(),
expertise: serde_json::from_value(mentor.expertise.clone().unwrap_or(serde_json::Value::Null)).unwrap_or_default(),
languages: serde_json::from_value(mentor.languages.clone().unwrap_or(serde_json::Value::Null)).unwrap_or_default(),
current_company: mentor.current_company.unwrap_or_default(),
current_role: mentor.current_role.unwrap_or_default(),
years_of_experience: mentor.years_of_experience.unwrap_or(0),
topics_of_interest: serde_json::from_value(mentor.topics_of_interest.clone().unwrap_or(serde_json::Value::Null)).unwrap_or_default(),
preferred_mentee_level: serde_json::from_str(&mentor.preferred_mentee_level.unwrap_or_default()).unwrap_or_default(),
preferred_mentoring_formats: serde_json::from_value(mentor.preferred_mentoring_formats.clone().unwrap_or(serde_json::Value::Null)).unwrap_or_default(),
availability_commitment: mentor.availability_commitment.unwrap_or_default(),
mentoring_rate: mentor.mentoring_rate.unwrap_or(0.0),
status: mentor.status.unwrap_or_default(),
is_deleted: mentor.is_deleted,
created_at: mentor.created_at.to_rfc3339(),
updated_at: mentor.updated_at.to_rfc3339(),
})
})
.collect();
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string()) == "development" {
println!("Query 'query_mentor_list' took: {elapsed:.2?}");
}
let response = ResponseListSuccessDto {
data,
meta: Some(imphnen_entities::MetaResponseDto {
page: Some(page),
per_page: Some(per_page),
total: Some(total_pages),
}),
};
Ok(response)
}
#[instrument(skip(self, id, include_deleted), err)]
pub async fn query_mentor_by_id(
&self,
id: &str,
include_deleted: bool,
) -> UtilsResult<MentorDetailQueryDto> {
let now = Instant::now();
let db = self.get_db();
let mentor_id = Uuid::parse_str(id).map_err(|e| anyhow!("Invalid mentor ID: {}", e))?;
let mut query = Mentors::find_by_id(mentor_id)
.find_also_related(Users);
if !include_deleted {
query = query.filter(MentorColumn::IsDeleted.eq(false));
}
let (mentor, user) = query
.one(db)
.await?
.ok_or_else(|| anyhow::anyhow!("Mentor not found"))?;
let _user = user.ok_or_else(|| anyhow::anyhow!("User not found for mentor"))?;
let result = MentorDetailQueryDto {
id: mentor.id.to_string(),
user_id: mentor.user_id.to_string(),
industries: serde_json::from_value(mentor.industries.clone().unwrap_or(serde_json::Value::Null)).unwrap_or_default(),
expertise: serde_json::from_value(mentor.expertise.clone().unwrap_or(serde_json::Value::Null)).unwrap_or_default(),
languages: serde_json::from_value(mentor.languages.clone().unwrap_or(serde_json::Value::Null)).unwrap_or_default(),
current_company: mentor.current_company.unwrap_or_default(),
current_role: mentor.current_role.unwrap_or_default(),
years_of_experience: mentor.years_of_experience.unwrap_or(0),
topics_of_interest: serde_json::from_value(mentor.topics_of_interest.clone().unwrap_or(serde_json::Value::Null)).unwrap_or_default(),
preferred_mentee_level: serde_json::from_str(&mentor.preferred_mentee_level.unwrap_or_default()).unwrap_or_default(),
preferred_mentoring_formats: serde_json::from_value(mentor.preferred_mentoring_formats.clone().unwrap_or(serde_json::Value::Null)).unwrap_or_default(),
availability_commitment: mentor.availability_commitment.unwrap_or_default(),
mentoring_rate: mentor.mentoring_rate.unwrap_or(0.0),
status: mentor.status.unwrap_or_default(),
is_deleted: mentor.is_deleted,
created_at: mentor.created_at.to_rfc3339(),
updated_at: mentor.updated_at.to_rfc3339(),
};
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string()) == "development" {
println!("Query 'query_mentor_by_id' took: {elapsed:.2?}");
}
Ok(result)
}
}
@@ -1,168 +0,0 @@
use super::{
MentorDetailQueryDto, MentorUpdateRequestDto,
MentoringLogistics, ProfessionalProfile,
};
use crate::v1::sessions::sessions_schema::Thing;
use imphnen_entities::ResourceEnum;
use imphnen_utils::{get_iso_date, make_thing};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct MentorSchema {
pub id: Thing,
#[serde(skip_serializing_if = "Option::is_none")]
pub user_id: Option<Thing>,
// Personal data has been moved to UsersSchema - use user_id to reference
// phone_for_verification, bio, last_education, linkedin_url, github_url,
// cv_url, portfolio_url
pub industries: Vec<String>,
pub expertise: Vec<String>,
pub languages: Vec<String>,
pub current_company: String,
pub current_role: String,
pub years_of_experience: i32,
pub topics_of_interest: Vec<String>,
pub preferred_mentee_level: Vec<String>,
pub preferred_mentoring_formats: Vec<String>,
pub availability_commitment: String,
pub mentoring_rate: f64,
pub status: String,
pub is_deleted: bool,
pub created_at: String,
pub updated_at: String,
}
impl Default for MentorSchema {
fn default() -> Self {
Self {
id: make_thing(
ResourceEnum::Mentors.to_string().as_str(),
&Uuid::new_v4().to_string(),
),
user_id: Some(make_thing(
ResourceEnum::Users.to_string().as_str(),
&Uuid::new_v4().to_string(),
)),
industries: Vec::new(),
expertise: Vec::new(),
languages: Vec::new(),
current_company: String::new(),
current_role: String::new(),
years_of_experience: 0,
topics_of_interest: Vec::new(),
preferred_mentee_level: Vec::new(),
preferred_mentoring_formats: Vec::new(),
availability_commitment: String::new(),
mentoring_rate: 0.0,
status: "pending".to_string(),
is_deleted: false,
created_at: get_iso_date(),
updated_at: get_iso_date(),
}
}
}
impl MentorSchema {
pub fn create(
professional_profile: ProfessionalProfile,
mentoring_logistics: MentoringLogistics,
user_id_raw: String,
) -> Self {
Self {
id: make_thing(
&ResourceEnum::Mentors.to_string(),
&Uuid::new_v4().to_string(),
),
user_id: Some(make_thing(&ResourceEnum::Users.to_string(), &user_id_raw)),
// Personal data now stored in UsersSchema, not here
industries: professional_profile.industries,
expertise: professional_profile.expertise,
languages: professional_profile.languages,
current_company: professional_profile.current_company,
current_role: professional_profile.current_role,
years_of_experience: professional_profile.years_of_experience,
topics_of_interest: mentoring_logistics.topics_of_interest,
preferred_mentee_level: mentoring_logistics.preferred_mentee_level,
preferred_mentoring_formats: mentoring_logistics.preferred_mentoring_formats,
availability_commitment: mentoring_logistics.availability_commitment,
mentoring_rate: mentoring_logistics.mentoring_rate_amount as f64,
status: "pending".to_string(),
is_deleted: false,
created_at: get_iso_date(),
updated_at: get_iso_date(),
}
}
pub fn from(dto: MentorDetailQueryDto) -> Self {
Self {
id: dto.id,
user_id: Some(dto.user_id),
// Personal data now comes from UsersSchema via user_id
industries: dto.industries,
expertise: dto.expertise,
languages: dto.languages,
current_company: dto.current_company,
current_role: dto.current_role,
years_of_experience: dto.years_of_experience,
topics_of_interest: dto.topics_of_interest,
preferred_mentee_level: dto.preferred_mentee_level,
preferred_mentoring_formats: dto.preferred_mentoring_formats,
availability_commitment: dto.availability_commitment,
mentoring_rate: dto.mentoring_rate,
status: dto.status,
is_deleted: dto.is_deleted,
created_at: dto.created_at,
updated_at: dto.updated_at,
}
}
pub fn update(mut self, dto: MentorUpdateRequestDto) -> Self {
// phone_for_verification, bio, last_education, linkedin_url, github_url,
// cv_url, portfolio_url) are now updated in UsersSchema, not here
// Only update professional fields that are still in MentorSchema
if let Some(val) = dto.industries {
self.industries = val;
}
if let Some(val) = dto.expertise {
self.expertise = val;
}
if let Some(val) = dto.languages {
self.languages = val;
}
if let Some(val) = dto.current_company {
self.current_company = val;
}
if let Some(val) = dto.current_role {
self.current_role = val;
}
if let Some(val) = dto.years_of_experience {
self.years_of_experience = val;
}
if let Some(val) = dto.topics_of_interest {
self.topics_of_interest = val;
}
if let Some(val) = dto.preferred_mentee_level {
self.preferred_mentee_level = val;
}
if let Some(val) = dto.preferred_mentoring_formats {
self.preferred_mentoring_formats = val;
}
if let Some(val) = dto.availability_commitment {
self.availability_commitment = val;
}
if let Some(val) = dto.mentoring_rate_amount {
self.mentoring_rate = val as f64;
}
self.updated_at = get_iso_date();
self
}
pub fn update_status(mut self, status: String) -> Self {
self.status = status;
self.updated_at = get_iso_date();
self
}
}
@@ -1,451 +0,0 @@
use crate::v1::mentors::{
MentorDetailResponseDto, MentorListResponseDto,
MentorRegisterResponseDto, MentorSchema, MentorUpdateRequestDto,
MentorUserRegisterRequestDto, MentorVerifyRequestDto, MentorsRepository,
};
use axum::http::StatusCode;
use axum::response::Response;
use imphnen_entities::{
MetaRequestDto, ResponseListSuccessDto, ResponseSuccessDto,
};
use imphnen_libs::AppState;
use imphnen_iam::{
v1::auth::AuthRepository,
RolesEnum, RolesRepository, UsersRepository, UsersSchema,
};
use imphnen_libs::argon::hash_password;
use imphnen_utils::{
common_response, success_list_response, success_response, validator::validate_request,
};
use uuid::Uuid;
use tracing::error;
pub struct MentorsService;
impl MentorsService {
pub async fn register_mentor(
state: &AppState,
dto: MentorUserRegisterRequestDto,
) -> Response {
if let Err((status, message)) = validate_request(&dto) {
return common_response(status, &message);
}
let user_repo = UsersRepository::new(state);
let mentor_repo = MentorsRepository::new(state);
let role_repo = RolesRepository::new(state);
let _auth_repo = AuthRepository::new(state);
let user_email = &dto.email;
let mut _user_to_update: Option<UsersSchema> = None;
let existing_user_result =
user_repo.query_user_by_email(user_email.clone()).await;
let user_id;
let final_user_email = user_email.clone();
if let Ok(user_detail_query_dto) = existing_user_result {
if mentor_repo
.query_mentor_by_email(user_email.clone(), false)
.await
.is_ok()
{
return common_response(
StatusCode::CONFLICT,
"Mentor profile already exists for this user",
);
}
let mut user_schema = UsersSchema::from(user_detail_query_dto);
user_schema.fullname = Some(dto.fullname.clone());
// Update profile_extension fields
let mut profile_ext = user_schema.profile_extension.clone().unwrap_or_default();
profile_ext.phone_number = dto.phone_number.clone();
profile_ext.phone_for_verification = dto.identity_and_verification.phone_for_verification.clone();
profile_ext.gender = dto.identity_and_verification.gender.clone();
profile_ext.domicile = dto.identity_and_verification.domicile.clone();
profile_ext.bio = Some(dto.professional_profile.bio.clone());
profile_ext.last_education = dto.professional_profile.last_education.clone();
profile_ext.linkedin_url = dto.professional_profile.linkedin_url.clone();
profile_ext.github_url = dto.professional_profile.github_url.clone();
profile_ext.cv_url = dto.professional_profile.cv_url.clone();
profile_ext.portfolio_url = dto.professional_profile.portfolio_url.clone();
user_schema.profile_extension = Some(profile_ext);
user_schema.updated_at = imphnen_utils::get_iso_date();
let hashed_password = match hash_password(&dto.password) {
Ok(hash) => hash,
Err(_e) => {
error!(
"Failed to hash password during update for {}: {}",
final_user_email, _e
);
return common_response(
StatusCode::INTERNAL_SERVER_ERROR,
"Failed to hash password",
);
}
};
user_schema.password = Some(hashed_password);
let mentor_role = match role_repo
.query_role_by_name(RolesEnum::Mentor.to_string())
.await
{
Ok(role) => role,
Err(_e) => {
return common_response(StatusCode::BAD_REQUEST, "Mentor Role Not Found");
}
};
user_schema.mentor_id = Some(imphnen_utils::make_thing_from_enum("Roles", &mentor_role.id));
user_schema.is_active = false;
if let Err(_err) = user_repo.query_update_user(user_schema.clone()).await {
error!(
"Failed to update existing user {} to mentor role: {}",
final_user_email, _err
);
return common_response(
StatusCode::INTERNAL_SERVER_ERROR,
&_err.to_string(),
);
}
user_id = user_schema.id.clone();
} else {
let mentor_role = match role_repo
.query_role_by_name(RolesEnum::Mentor.to_string())
.await
{
Ok(role) => role,
Err(_e) => {
return common_response(StatusCode::BAD_REQUEST, "Mentor Role Not Found");
}
};
let hashed_password = match hash_password(&dto.password) {
Ok(hash) => hash,
Err(_e) => {
error!(
"Failed to hash password for new user {}: {}",
final_user_email, _e
);
return common_response(
StatusCode::INTERNAL_SERVER_ERROR,
"Failed to hash password",
);
}
};
let mut new_user_schema = UsersSchema {
id: imphnen_utils::make_thing_from_enum(
"Users",
&Uuid::new_v4().to_string(),
),
email: Some(dto.email),
fullname: Some(dto.fullname),
password: Some(hashed_password),
// Set phone number in profile extension instead
// Store personal data from identity_and_verification in user
legal_name: Some(dto.identity_and_verification.legal_name.clone()),
// Use profile_extension for these fields
// Store personal data from professional_profile in user
created_at: imphnen_utils::get_iso_date(),
updated_at: imphnen_utils::get_iso_date(),
mentor_id: Some(imphnen_utils::make_thing_from_enum(
"Roles",
&mentor_role.id,
)),
is_active: false,
..Default::default()
};
// populate profile_extension
let mut profile_ext = new_user_schema.profile_extension.clone().unwrap_or_default();
profile_ext.phone_number = dto.phone_number.clone();
profile_ext.phone_for_verification = dto.identity_and_verification.phone_for_verification.clone();
profile_ext.gender = dto.identity_and_verification.gender.clone();
profile_ext.domicile = dto.identity_and_verification.domicile.clone();
profile_ext.bio = Some(dto.professional_profile.bio.clone());
profile_ext.last_education = dto.professional_profile.last_education.clone();
profile_ext.linkedin_url = dto.professional_profile.linkedin_url.clone();
profile_ext.github_url = dto.professional_profile.github_url.clone();
profile_ext.cv_url = dto.professional_profile.cv_url.clone();
profile_ext.portfolio_url = dto.professional_profile.portfolio_url.clone();
new_user_schema.profile_extension = Some(profile_ext);
user_id = new_user_schema.id.clone();
match user_repo.query_create_user(new_user_schema).await {
Ok(_) => {}
Err(_err) => {
error!("Failed to create new user {}: {}", final_user_email, _err);
return common_response(
StatusCode::INTERNAL_SERVER_ERROR,
&_err.to_string(),
);
}
}
}
// Skip OTP for now - implement later if needed
let mentor_schema = MentorSchema::create(
dto.professional_profile,
dto.mentoring_logistics,
user_id.clone(),
);
match mentor_repo.query_create_mentor(mentor_schema.clone()).await {
Ok(mentor_profile_id) => {
let user_after_mentor_creation_dto = user_repo
.query_user_by_email(final_user_email.clone())
.await
.unwrap();
let mut user_after_mentor_creation_schema =
UsersSchema::from(user_after_mentor_creation_dto);
user_after_mentor_creation_schema = user_after_mentor_creation_schema
.update_mentor_id(Some(mentor_profile_id));
if let Err(_e) = user_repo
.query_update_user(user_after_mentor_creation_schema)
.await
{
error!(
"Failed to update user's mentor_id for {}: {}",
final_user_email, _e
);
return common_response(
StatusCode::INTERNAL_SERVER_ERROR,
&_e.to_string(),
);
}
let response_dto = MentorRegisterResponseDto::from(mentor_schema);
success_response(ResponseSuccessDto { data: response_dto })
}
Err(_e) => {
error!(
"Failed to create mentor profile for {}: {}",
final_user_email, _e
);
common_response(StatusCode::INTERNAL_SERVER_ERROR, &_e.to_string())
}
}
}
pub async fn get_mentor_list(state: &AppState, meta: MetaRequestDto) -> Response {
let repo = MentorsRepository::new(state);
let user_repo = UsersRepository::new(state);
match repo.query_mentor_list(meta).await {
Ok(result) => {
let mut mentor_list_data: Vec<MentorListResponseDto> = Vec::new();
for mentor_with_user in result.data {
let mentor_dto = mentor_with_user;
let mut list_item = MentorListResponseDto::from(mentor_dto.clone());
// Get user data to populate personal fields
if let Ok(user) = user_repo.query_user_by_id(&mentor_dto.user_id).await {
list_item.fullname = Some(user.fullname);
list_item.email = Some(user.email);
}
mentor_list_data.push(list_item);
}
success_list_response(ResponseListSuccessDto {
data: mentor_list_data,
meta: result.meta,
})
}
Err(_e) => common_response(StatusCode::INTERNAL_SERVER_ERROR, &_e.to_string()),
}
}
pub async fn get_mentor_by_id(state: &AppState, id: &str) -> Response {
let mentor_repo = MentorsRepository::new(state);
let user_repo = UsersRepository::new(state);
let thing_id = imphnen_utils::make_thing_from_enum("Mentors", id); match mentor_repo.query_mentor_by_id(&thing_id, false).await {
Ok(mentor) => {
// Get user data separately
let user_result = user_repo.query_user_by_id(&mentor.user_id).await;
match user_result {
Ok(user) => {
// Combine mentor and user data
let dto = MentorDetailResponseDto {
id: mentor.id.clone(),
user_id: mentor.user_id.clone(),
// Personal data from user
fullname: Some(user.fullname),
email: Some(user.email),
legal_name: user.legal_name,
gender: user.profile_extension.as_ref().and_then(|ext| ext.gender.clone()),
domicile: user.profile_extension.as_ref().and_then(|ext| ext.domicile.clone()),
phone_for_verification: user.profile_extension.as_ref().and_then(|ext| ext.phone_for_verification.clone()),
bio: user.profile_extension.as_ref().and_then(|ext| ext.bio.clone()),
last_education: user.profile_extension.as_ref().and_then(|ext| ext.last_education.clone()),
linkedin_url: user.profile_extension.as_ref().and_then(|ext| ext.linkedin_url.clone()),
github_url: user.profile_extension.as_ref().and_then(|ext| ext.github_url.clone()),
cv_url: user.profile_extension.as_ref().and_then(|ext| ext.cv_url.clone()),
portfolio_url: user.profile_extension.as_ref().and_then(|ext| ext.portfolio_url.clone()),
// 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()),
}
}
pub async fn update_mentor(
state: &AppState,
id: &str,
dto: MentorUpdateRequestDto,
) -> Response {
if let Err((status, message)) = validate_request(&dto) {
return common_response(status, &message);
}
let repo = MentorsRepository::new(state);
let mentor_uuid = Uuid::parse_str(id).map_err(|_| {
common_response(
StatusCode::BAD_REQUEST,
"Invalid mentor ID format"
)
}).unwrap();
let existing_mentor = match repo.query_mentor_by_id(&mentor_uuid.to_string(), false).await {
Ok(mentor) => mentor,
Err(_e) => return common_response(StatusCode::NOT_FOUND, &_e.to_string()),
};
let mut schema = MentorSchema::from(existing_mentor);
schema = schema.update(dto);
match repo.query_update_mentor(schema).await {
Ok(_) => {
let updated_mentor =
repo.query_mentor_by_id(id, false).await.unwrap();
let response_dto =
MentorDetailResponseDto::from(updated_mentor);
success_response(ResponseSuccessDto { data: response_dto })
}
Err(_e) => common_response(StatusCode::INTERNAL_SERVER_ERROR, &_e.to_string()),
}
}
pub async fn delete_mentor(state: &AppState, id: &str) -> Response {
let repo = MentorsRepository::new(state);
match repo.query_delete_mentor(id).await {
Ok(msg) => common_response(StatusCode::OK, &msg),
Err(_e) => common_response(StatusCode::NOT_FOUND, &_e.to_string()),
}
}
pub async fn get_mentor_me(state: &AppState, email: &str) -> Response {
let repo = MentorsRepository::new(state);
match repo.query_mentor_by_email(email.to_string(), false).await {
Ok(mentor) => {
let dto = MentorDetailResponseDto::from(mentor);
success_response(ResponseSuccessDto { data: dto })
}
Err(_e) => common_response(
StatusCode::FORBIDDEN,
"Mentor profile not found for current user",
),
}
}
pub async fn update_mentor_me(
state: &AppState,
email: &str,
dto: MentorUpdateRequestDto,
) -> Response {
if let Err((status, message)) = validate_request(&dto) {
return common_response(status, &message);
}
let repo = MentorsRepository::new(state);
let existing_mentor =
match repo.query_mentor_by_email(email.to_string(), false).await {
Ok(mentor) => mentor,
Err(_e) => return common_response(StatusCode::FORBIDDEN, &_e.to_string()),
};
let mut schema = MentorSchema::from(existing_mentor);
schema = schema.update(dto);
match repo.query_update_mentor(schema).await {
Ok(_) => {
let updated_mentor = repo
.query_mentor_by_email(email.to_string(), false)
.await
.unwrap();
let response_dto =
MentorDetailResponseDto::from(updated_mentor);
success_response(ResponseSuccessDto { data: response_dto })
}
Err(_e) => common_response(StatusCode::INTERNAL_SERVER_ERROR, &_e.to_string()),
}
}
pub async fn get_mentor_status(state: &AppState, email: &str) -> Response {
let repo = MentorsRepository::new(state);
match repo.query_mentor_by_email(email.to_string(), false).await {
Ok(mentor) => common_response(StatusCode::OK, &mentor.status),
Err(_e) => common_response(
StatusCode::FORBIDDEN,
"No mentor application found for current user",
),
}
}
pub async fn verify_mentor(
state: &AppState,
id: &str,
dto: MentorVerifyRequestDto,
) -> Response {
let repo = MentorsRepository::new(state);
let mentor_uuid = Uuid::parse_str(id).map_err(|_| {
common_response(
StatusCode::BAD_REQUEST,
"Invalid mentor ID format"
)
}).unwrap();
let existing_mentor = match repo.query_mentor_by_id(&mentor_uuid.to_string(), false).await {
Ok(mentor) => mentor,
Err(_e) => return common_response(StatusCode::NOT_FOUND, &_e.to_string()),
};
let mut schema = MentorSchema::from(existing_mentor);
schema = schema.update_status(dto.status);
match repo.query_update_mentor(schema).await {
Ok(_) => {
let updated_mentor =
repo.query_mentor_by_id(&mentor_uuid.to_string(), false).await.unwrap();
let response_dto =
MentorDetailResponseDto::from(updated_mentor);
success_response(ResponseSuccessDto { data: response_dto })
}
Err(_e) => common_response(StatusCode::INTERNAL_SERVER_ERROR, &_e.to_string()),
}
}
}
-61
View File
@@ -1,61 +0,0 @@
use axum::{
Router,
routing::{delete, get, post, put},
};
pub mod mentors_controller;
pub mod mentors_dto;
pub mod mentors_repository;
pub mod mentors_schema;
pub mod mentors_service;
// Explicitly export only public controller functions and key types
pub use mentors_controller::{
post_register_mentor,
get_mentor_list,
get_mentor_by_id,
put_update_mentor,
delete_mentor,
put_verify_mentor,
get_mentor_me,
put_update_mentor_me,
put_update_mentor_no_id,
get_mentor_status,
};
// Export key DTO types used across the API
pub use mentors_dto::{
MentorListResponseDto,
MentorDetailResponseDto,
MentorRegisterResponseDto,
MentorUpdateRequestDto,
MentorUserRegisterRequestDto,
MentorVerifyRequestDto,
MentorDetailQueryDto,
ProfessionalProfile,
MentoringLogistics,
MentoringRate,
IdentityAndVerification,
MentorInsertDto,
};
// Export service and repository for internal use
pub use mentors_service::MentorsService;
pub use mentors_repository::MentorsRepository;
// Export schema types for database interactions
pub use mentors_schema::MentorSchema;
pub fn mentors_router() -> Router {
Router::new()
.route("/", get(get_mentor_list))
.route("/create", post(post_register_mentor))
.route("/me", get(get_mentor_me))
.route("/me/update", put(put_update_mentor_me))
.route("/me/status", get(get_mentor_status))
.route("/detail/{id}", get(get_mentor_by_id))
.route("/update/{id}", put(put_update_mentor))
.route("/update", put(put_update_mentor_no_id))
.route("/delete/{id}", delete(delete_mentor))
.route("/verify/{id}", put(put_verify_mentor))
}
-26
View File
@@ -1,26 +0,0 @@
use axum::Router;
pub mod mentors;
pub mod sessions;
/// Creates the main Dimentorin router with all version 1 endpoints
/// Routes:
/// - /mentors -> mentors::mentors_router()
/// - /sessions -> sessions::sessions_router()
/// - /users/me/sessions -> sessions::get_my_sessions()
pub fn dimentorin_router() -> Router {
Router::new()
.nest("/mentors", mentors::mentors_router())
.merge(sessions::sessions_router())
}
// Explicitly re-export key items for easier consumption
pub use mentors::mentors_router;
pub use mentors::MentorsService;
pub use mentors::MentorsRepository;
pub use mentors::MentorSchema;
pub use sessions::sessions_router;
pub use sessions::SessionsService;
pub use sessions::SessionsRepository;
pub use sessions::SessionSchema;
-11
View File
@@ -1,11 +0,0 @@
pub mod sessions_controller;
pub mod sessions_dto;
pub mod sessions_repository;
pub mod sessions_schema;
pub mod sessions_service;
pub use sessions_controller::*;
pub use sessions_dto::*;
pub use sessions_repository::*;
pub use sessions_schema::*;
pub use sessions_service::*;
@@ -1,297 +0,0 @@
use super::{
BookSessionRequestDto, BookSessionResponseDto, MentorAvailabilityDto,
SessionFeedbackRequestDto, SessionFeedbackResponseDto, SessionListResponseDto,
SessionsService, UpdateSessionStatusRequestDto, UpdateSessionStatusResponseDto,
};
use axum::{
extract::{Extension, Path, Query},
http::HeaderMap,
response::Response,
routing::{get, post, put},
Json, Router,
};
use imphnen_libs::AppState;
use imphnen_utils::extract_email;
use serde::Deserialize;
use utoipa::OpenApi;
#[derive(OpenApi)]
#[openapi(
paths(
post_book_session,
get_mentor_sessions,
get_mentor_availability,
put_update_session_status,
post_submit_feedback,
get_my_sessions,
),
components(schemas(
BookSessionRequestDto,
BookSessionResponseDto,
SessionListResponseDto,
super::SessionListItemDto,
MentorAvailabilityDto,
super::AvailabilitySlotDto,
UpdateSessionStatusRequestDto,
UpdateSessionStatusResponseDto,
SessionFeedbackRequestDto,
SessionFeedbackResponseDto,
)),
tags(
(name = "sessions", description = "Mentoring Sessions Management API")
)
)]
pub struct SessionsApiDoc;
// ============================================
// Book Session
// ============================================
#[utoipa::path(
post,
path = "/v1/mentors/{id}/sessions/create",
tag = "sessions",
summary = "Book a mentoring session",
description = "Book a mentoring session with a specific mentor. Requires authentication.",
security(("Bearer" = [])),
params(
("id" = String, Path, description = "Mentor ID"),
),
request_body = BookSessionRequestDto,
responses(
(status = 201, description = "Session booked successfully", body = BookSessionResponseDto),
(status = 400, description = "Invalid request"),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Mentor not found"),
)
)]
pub async fn post_book_session(
headers: HeaderMap,
Extension(state): Extension<AppState>,
Path(mentor_id): Path<String>,
Json(dto): Json<BookSessionRequestDto>,
) -> Response {
let user_email = match extract_email(&headers) {
Some(email) => email,
None => {
return imphnen_utils::common_response(
axum::http::StatusCode::UNAUTHORIZED,
"Token tidak valid",
);
}
};
SessionsService::book_session(&state, mentor_id, user_email, dto).await
}
// ============================================
// Get Mentor's Sessions
// ============================================
#[derive(Deserialize)]
pub struct SessionStatusFilter {
status: Option<String>,
}
#[utoipa::path(
get,
path = "/v1/mentors/{id}/sessions",
tag = "sessions",
summary = "List mentor's sessions",
description = "Get all sessions for a specific mentor. Only accessible by the mentor themselves or admin.",
security(("Bearer" = [])),
params(
("id" = String, Path, description = "Mentor ID"),
("status" = Option<String>, Query, description = "Filter by status (pending, confirmed, completed, cancelled, no_show)"),
),
responses(
(status = 200, description = "Sessions retrieved successfully", body = SessionListResponseDto),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
(status = 404, description = "Mentor not found"),
)
)]
pub async fn get_mentor_sessions(
headers: HeaderMap,
Extension(state): Extension<AppState>,
Path(mentor_id): Path<String>,
Query(filter): Query<SessionStatusFilter>,
) -> Response {
let user_email = match extract_email(&headers) {
Some(email) => email,
None => {
return imphnen_utils::common_response(
axum::http::StatusCode::UNAUTHORIZED,
"Token tidak valid",
);
}
};
SessionsService::get_mentor_sessions(&state, mentor_id, user_email, filter.status).await
}
// ============================================
// Get Mentor Availability
// ============================================
#[utoipa::path(
get,
path = "/v1/mentors/{id}/availability",
tag = "sessions",
summary = "Get mentor availability",
description = "Get available time slots for booking with a mentor. Public endpoint.",
params(
("id" = String, Path, description = "Mentor ID"),
),
responses(
(status = 200, description = "Availability retrieved successfully", body = MentorAvailabilityDto),
(status = 404, description = "Mentor not found"),
)
)]
pub async fn get_mentor_availability(
Extension(state): Extension<AppState>,
Path(mentor_id): Path<String>,
) -> Response {
SessionsService::get_mentor_availability(&state, mentor_id).await
}
// ============================================
// Update Session Status
// ============================================
#[utoipa::path(
put,
path = "/v1/sessions/update/{id}/status",
tag = "sessions",
summary = "Update session status",
description = "Update the status of a session (confirm, complete, cancel). Only accessible by the mentor.",
security(("Bearer" = [])),
params(
("id" = String, Path, description = "Session ID"),
),
request_body = UpdateSessionStatusRequestDto,
responses(
(status = 200, description = "Status updated successfully", body = UpdateSessionStatusResponseDto),
(status = 400, description = "Invalid request"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
(status = 404, description = "Session not found"),
)
)]
pub async fn put_update_session_status(
headers: HeaderMap,
Extension(state): Extension<AppState>,
Path(session_id): Path<String>,
Json(dto): Json<UpdateSessionStatusRequestDto>,
) -> Response {
let user_email = match extract_email(&headers) {
Some(email) => email,
None => {
return imphnen_utils::common_response(
axum::http::StatusCode::UNAUTHORIZED,
"Token tidak valid",
);
}
};
SessionsService::update_session_status(&state, session_id, user_email, dto).await
}
// ============================================
// Submit Feedback
// ============================================
#[utoipa::path(
post,
path = "/v1/sessions/{id}/feedback/create",
tag = "sessions",
summary = "Submit session feedback",
description = "Submit feedback and rating for a completed session. Only accessible by the mentee.",
security(("Bearer" = [])),
params(
("id" = String, Path, description = "Session ID"),
),
request_body = SessionFeedbackRequestDto,
responses(
(status = 200, description = "Feedback submitted successfully", body = SessionFeedbackResponseDto),
(status = 400, description = "Invalid request or session not completed"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
(status = 404, description = "Session not found"),
)
)]
pub async fn post_submit_feedback(
headers: HeaderMap,
Extension(state): Extension<AppState>,
Path(session_id): Path<String>,
Json(dto): Json<SessionFeedbackRequestDto>,
) -> Response {
let user_email = match extract_email(&headers) {
Some(email) => email,
None => {
return imphnen_utils::common_response(
axum::http::StatusCode::UNAUTHORIZED,
"Token tidak valid",
);
}
};
SessionsService::submit_feedback(&state, session_id, user_email, dto).await
}
// ============================================
// Get User's Sessions
// ============================================
#[utoipa::path(
get,
path = "/v1/users/me/sessions",
tag = "sessions",
summary = "Get my sessions",
description = "Get all sessions for the authenticated user (as mentee). Requires authentication.",
security(("Bearer" = [])),
params(
("status" = Option<String>, Query, description = "Filter by status (pending, confirmed, completed, cancelled, no_show)"),
),
responses(
(status = 200, description = "Sessions retrieved successfully", body = SessionListResponseDto),
(status = 401, description = "Unauthorized"),
)
)]
pub async fn get_my_sessions(
headers: HeaderMap,
Extension(state): Extension<AppState>,
Query(filter): Query<SessionStatusFilter>,
) -> Response {
let user_email = match extract_email(&headers) {
Some(email) => email,
None => {
return imphnen_utils::common_response(
axum::http::StatusCode::UNAUTHORIZED,
"Token tidak valid",
);
}
};
SessionsService::get_user_sessions(&state, user_email, filter.status).await
}
// ============================================
// Router
// ============================================
pub fn sessions_router() -> Router {
Router::new()
// Book session (under mentors path)
.route("/mentors/{id}/sessions/create", post(post_book_session))
// Get mentor's sessions
.route("/mentors/{id}/sessions", get(get_mentor_sessions))
// Get mentor availability (public - no auth)
.route("/mentors/{id}/availability", get(get_mentor_availability))
// Update session status
.route("/sessions/update/{id}/status", put(put_update_session_status))
// Submit feedback
.route("/sessions/{id}/feedback/create", post(post_submit_feedback))
// Get my sessions
.route("/users/me/sessions", get(get_my_sessions))
}
@@ -1,197 +0,0 @@
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use validator::Validate;
// ============================================
// Book Session (POST /v1/mentors/{id}/sessions/book)
// ============================================
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)]
pub struct BookSessionRequestDto {
#[validate(length(min = 3, max = 200, message = "Topic must be 3-200 characters"))]
pub topic: String,
#[validate(length(max = 1000, message = "Description must be max 1000 characters"))]
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[validate(length(min = 1, message = "Scheduled time is required"))]
pub scheduled_at: String, // ISO 8601 datetime
#[validate(range(min = 15, max = 240, message = "Duration must be 15-240 minutes"))]
#[serde(skip_serializing_if = "Option::is_none")]
pub duration_minutes: Option<i32>,
#[validate(length(max = 50, message = "Session type must be max 50 characters"))]
#[serde(skip_serializing_if = "Option::is_none")]
pub session_type: Option<String>, // "video_call", "phone_call", "chat"
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct BookSessionResponseDto {
pub id: String,
pub mentor_id: String,
pub mentee_id: String,
pub topic: String,
pub description: Option<String>,
pub scheduled_at: String,
pub duration_minutes: i32,
pub session_type: String,
pub status: String,
pub created_at: String,
}
// ============================================
// List Sessions (GET /v1/mentors/{id}/sessions & /v1/users/me/sessions)
// ============================================
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct SessionListItemDto {
pub id: String,
pub mentor_id: String,
pub mentee_id: String,
pub mentee_fullname: Option<String>,
pub mentee_email: Option<String>,
pub topic: String,
pub scheduled_at: String,
pub duration_minutes: i32,
pub session_type: String,
pub status: String,
pub rating: Option<i32>,
pub created_at: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct SessionListResponseDto {
pub sessions: Vec<SessionListItemDto>,
pub total: usize,
}
// ============================================
// Session Detail
// ============================================
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct SessionDetailDto {
pub id: String,
pub mentor_id: String,
pub mentor_fullname: Option<String>,
pub mentee_id: String,
pub mentee_fullname: Option<String>,
pub topic: String,
pub description: Option<String>,
pub scheduled_at: String,
pub duration_minutes: i32,
pub meeting_link: Option<String>,
pub session_type: String,
pub status: String,
pub feedback: Option<String>,
pub rating: Option<i32>,
pub feedback_submitted_at: Option<String>,
pub created_at: String,
pub updated_at: String,
}
// ============================================
// Mentor Availability (GET /v1/mentors/{id}/availability)
// ============================================
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct AvailabilitySlotDto {
pub date: String, // YYYY-MM-DD
pub time: String, // HH:MM
pub available: bool,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct MentorAvailabilityDto {
pub mentor_id: String,
pub availability_commitment: String,
pub preferred_formats: Vec<String>,
pub slots: Vec<AvailabilitySlotDto>,
pub booked_dates: Vec<String>, // Dates with existing sessions
}
// ============================================
// Update Session Status (PUT /v1/sessions/{id}/status)
// ============================================
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)]
pub struct UpdateSessionStatusRequestDto {
#[validate(length(min = 1, max = 50, message = "Status must be 1-50 characters"))]
pub status: String, // "confirmed", "completed", "cancelled", "no_show"
#[validate(url(message = "Meeting link must be a valid URL"))]
#[serde(skip_serializing_if = "Option::is_none")]
pub meeting_link: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct UpdateSessionStatusResponseDto {
pub id: String,
pub status: String,
pub meeting_link: Option<String>,
pub updated_at: String,
}
// ============================================
// Submit Feedback (POST /v1/sessions/{id}/feedback)
// ============================================
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)]
pub struct SessionFeedbackRequestDto {
#[validate(length(min = 10, max = 2000, message = "Feedback must be 10-2000 characters"))]
pub feedback: String,
#[validate(range(min = 1, max = 5, message = "Rating must be 1-5"))]
pub rating: i32,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct SessionFeedbackResponseDto {
pub id: String,
pub feedback: String,
pub rating: i32,
pub submitted_at: String,
}
// ============================================
// Query DTOs (internal use)
// ============================================
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct SessionDetailQueryDto {
pub id: String,
pub mentor_id: String,
pub mentee_id: String,
pub topic: String,
pub description: Option<String>,
pub scheduled_at: String,
pub duration_minutes: i32,
pub meeting_link: Option<String>,
pub session_type: String,
pub status: String,
pub feedback: Option<String>,
pub rating: Option<i32>,
pub feedback_submitted_at: Option<String>,
pub created_at: String,
pub updated_at: String,
pub mentor_fullname: Option<String>,
pub mentee_fullname: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct SessionListQueryDto {
pub id: String,
pub mentor_id: String,
pub mentee_id: String,
pub topic: String,
pub scheduled_at: String,
pub duration_minutes: i32,
pub session_type: String,
pub status: String,
pub rating: Option<i32>,
pub created_at: String,
pub mentee_fullname: Option<String>,
pub mentee_email: Option<String>,
}
@@ -1,369 +0,0 @@
use super::{SessionDetailQueryDto, SessionListQueryDto, SessionSchema};
use anyhow::{anyhow, Result};
use imphnen_libs::{AppState, AppStatePostgresExt};
use sea_orm::*;
use uuid::Uuid;
use imphnen_entities::seaorm::auth::sessions::{
Entity as Sessions, Model as SessionModel, ActiveModel as SessionActiveModel, Column as SessionColumn,
};
use imphnen_entities::seaorm::auth::users::Entity as Users;
pub struct SessionsRepository<'a> {
pub db: &'a DatabaseConnection,
}
impl<'a> SessionsRepository<'a> {
pub fn new(state: &'a AppState) -> Self {
Self { db: state.postgres_db() }
}
// ============================================
// Create Session
// ============================================
pub async fn create_session(&self, schema: SessionSchema) -> Result<SessionSchema> {
let mut result = schema.clone();
let session_active_model = SessionActiveModel {
id: Set(schema.id),
mentor_id: Set(schema.mentor_id),
mentee_id: Set(schema.mentee_id),
topic: Set(schema.topic),
description: Set(schema.description),
scheduled_at: Set(schema.scheduled_at),
duration_minutes: Set(schema.duration_minutes),
meeting_link: Set(schema.meeting_link),
session_type: Set(schema.session_type),
status: Set(schema.status),
feedback: Set(schema.feedback),
rating: Set(schema.rating),
feedback_submitted_at: Set(schema.feedback_submitted_at),
created_at: Set(schema.created_at),
updated_at: Set(schema.updated_at),
};
let session_model: SessionModel = session_active_model.insert(self.db).await?;
result.id = session_model.id;
Ok(result)
}
// ============================================
// Get Session by ID
// ============================================
pub async fn query_session_by_id(&self, id: &str) -> Result<Option<SessionSchema>> {
let session_model: Option<SessionModel> = Sessions::find_by_id(Uuid::parse_str(id).map_err(|e| anyhow!("Invalid session ID: {}", e))?)
.one(self.db)
.await
.map_err(|e| anyhow!("Failed to fetch session: {}", e))?;
if let Some(session) = session_model {
let schema = SessionSchema {
id: session.id,
mentor_id: session.mentor_id,
mentee_id: session.mentee_id,
topic: session.topic,
description: session.description,
scheduled_at: session.scheduled_at,
duration_minutes: session.duration_minutes,
meeting_link: session.meeting_link,
session_type: session.session_type,
status: session.status,
feedback: session.feedback,
rating: session.rating,
feedback_submitted_at: session.feedback_submitted_at,
created_at: session.created_at,
updated_at: session.updated_at,
};
Ok(Some(schema))
} else {
Ok(None)
}
}
// ============================================
// Get Session Detail with User Info
// ============================================
pub async fn query_session_detail(&self, id: &str) -> Result<Option<SessionDetailQueryDto>> {
let session_id = Uuid::parse_str(id).map_err(|e| anyhow!("Invalid session ID: {}", e))?;
let session = Sessions::find_by_id(session_id)
.one(self.db)
.await
.map_err(|e| anyhow!("Failed to fetch session: {}", e))?
.ok_or_else(|| anyhow!("Session not found"))?;
let mentor = Users::find_by_id(session.mentor_id)
.one(self.db)
.await
.map_err(|e| anyhow!("Failed to fetch mentor: {}", e))?
.ok_or_else(|| anyhow!("Mentor not found"))?;
let mentee = Users::find_by_id(session.mentee_id)
.one(self.db)
.await
.map_err(|e| anyhow!("Failed to fetch mentee: {}", e))?
.ok_or_else(|| anyhow!("Mentee not found"))?;
let session_detail = SessionDetailQueryDto {
id: session.id.to_string(),
mentor_id: session.mentor_id.to_string(),
mentee_id: session.mentee_id.to_string(),
topic: session.topic,
description: session.description,
scheduled_at: session.scheduled_at.to_rfc3339(),
duration_minutes: session.duration_minutes,
meeting_link: session.meeting_link,
session_type: session.session_type,
status: session.status,
feedback: session.feedback,
rating: session.rating,
feedback_submitted_at: session.feedback_submitted_at.map(|dt| dt.to_rfc3339()),
created_at: session.created_at.to_rfc3339(),
updated_at: session.updated_at.to_rfc3339(),
mentor_fullname: Some(format!("{} {}", mentor.first_name.unwrap_or_default(), mentor.last_name.unwrap_or_default())),
mentee_fullname: Some(format!("{} {}", mentee.first_name.unwrap_or_default(), mentee.last_name.unwrap_or_default())),
};
Ok(Some(session_detail))
}
// ============================================
// List Mentor's Sessions
// ============================================
pub async fn query_mentor_sessions(
&self,
mentor_id: &str,
status_filter: Option<String>,
) -> Result<Vec<SessionListQueryDto>> {
let mentor_uuid = Uuid::parse_str(mentor_id).map_err(|e| anyhow!("Invalid mentor ID: {}", e))?;
let query = Sessions::find()
.filter(SessionColumn::MentorId.eq(mentor_uuid))
.order_by_desc(SessionColumn::ScheduledAt);
let query = if let Some(status) = status_filter {
query.filter(SessionColumn::Status.eq(status))
} else {
query
};
let sessions = query.all(self.db).await.map_err(|e| anyhow!("Failed to query mentor sessions: {}", e))?;
let mut session_list = Vec::with_capacity(sessions.len());
for session in sessions {
// Join with users table to get mentee details
let mentee = Users::find_by_id(session.mentee_id)
.one(self.db)
.await
.map_err(|e| anyhow!("Failed to fetch mentee: {}", e))?;
let session_dto = SessionListQueryDto {
id: session.id.to_string(),
mentor_id: session.mentor_id.to_string(),
mentee_id: session.mentee_id.to_string(),
topic: session.topic,
scheduled_at: session.scheduled_at.to_rfc3339(),
duration_minutes: session.duration_minutes,
session_type: session.session_type,
status: session.status,
rating: session.rating,
created_at: session.created_at.to_rfc3339(),
mentee_fullname: mentee.as_ref().map(|m| format!("{} {}", m.first_name.clone().unwrap_or_default(), m.last_name.clone().unwrap_or_default())),
mentee_email: mentee.as_ref().map(|u| u.email.clone()), // Assuming Users model has an email field
};
session_list.push(session_dto);
}
Ok(session_list)
}
// ============================================
// List User's Sessions (as mentee)
// ============================================
pub async fn query_user_sessions(
&self,
user_id: &str,
status_filter: Option<String>,
) -> Result<Vec<SessionListQueryDto>> {
let user_uuid = Uuid::parse_str(user_id).map_err(|e| anyhow!("Invalid user ID: {}", e))?;
let query = Sessions::find()
.filter(SessionColumn::MenteeId.eq(user_uuid))
.order_by_desc(SessionColumn::ScheduledAt);
let query = if let Some(status) = status_filter {
query.filter(SessionColumn::Status.eq(status))
} else {
query
};
let sessions = query.all(self.db).await.map_err(|e| anyhow!("Failed to query user sessions: {}", e))?;
let mut session_list = Vec::with_capacity(sessions.len());
for session in sessions {
// Join with users table to get mentor details
let _mentor = Users::find_by_id(session.mentor_id)
.one(self.db)
.await
.map_err(|e| anyhow!("Failed to fetch mentor: {}", e))?;
let session_dto = SessionListQueryDto {
id: session.id.to_string(),
mentor_id: session.mentor_id.to_string(),
mentee_id: session.mentee_id.to_string(),
topic: session.topic,
scheduled_at: session.scheduled_at.to_rfc3339(),
duration_minutes: session.duration_minutes,
session_type: session.session_type,
status: session.status,
rating: session.rating,
created_at: session.created_at.to_rfc3339(),
mentee_fullname: Some(session.mentee_id.to_string()), // Simplified - should get from user table
mentee_email: Some("user@example.com".to_string()), // Simplified - should get from user table
};
session_list.push(session_dto);
}
Ok(session_list)
}
// ============================================
// Get Booked Dates for Mentor
// ============================================
pub async fn query_booked_dates(&self, mentor_id: &str) -> Result<Vec<String>> {
let mentor_uuid = Uuid::parse_str(mentor_id).map_err(|e| anyhow!("Invalid mentor ID: {}", e))?;
let sessions = Sessions::find()
.filter(SessionColumn::MentorId.eq(mentor_uuid))
.filter(SessionColumn::Status.is_in(["pending", "confirmed"]))
.order_by_asc(SessionColumn::ScheduledAt)
.all(self.db)
.await
.map_err(|e| anyhow!("Failed to query booked dates: {}", e))?;
Ok(sessions.into_iter()
.map(|s| s.scheduled_at.to_rfc3339())
.collect())
}
// ============================================
// Update Session
// ============================================
pub async fn update_session(&self, id: &str, schema: SessionSchema) -> Result<SessionSchema> {
let session_id = Uuid::parse_str(id).map_err(|e| anyhow!("Invalid session ID: {}", e))?;
let mut result = schema.clone();
// Fetch existing session
let session_model = Sessions::find_by_id(session_id)
.one(self.db)
.await
.map_err(|e| anyhow!("Failed to fetch session for update: {}", e))?
.ok_or_else(|| anyhow!("Session not found"))?;
// Convert to ActiveModel for update
let mut session_active_model = session_model.into_active_model();
// Update fields
session_active_model.topic = Set(schema.topic);
session_active_model.description = Set(schema.description);
session_active_model.scheduled_at = Set(schema.scheduled_at);
session_active_model.duration_minutes = Set(schema.duration_minutes);
session_active_model.meeting_link = Set(schema.meeting_link);
session_active_model.session_type = Set(schema.session_type);
session_active_model.status = Set(schema.status);
session_active_model.feedback = Set(schema.feedback.clone());
session_active_model.rating = Set(schema.rating);
session_active_model.feedback_submitted_at = Set(schema.feedback_submitted_at);
session_active_model.updated_at = Set(schema.updated_at);
// Save updated session
let updated_session = session_active_model.update(self.db).await.map_err(|e| anyhow!("Failed to update session: {}", e))?;
// Convert back to SessionSchema for response
result.id = updated_session.id;
Ok(result)
}
// ============================================
// Count Mentor Sessions
// ============================================
pub async fn count_mentor_sessions(
&self,
mentor_id: &str,
status_filter: Option<String>,
) -> Result<usize> {
let mentor_uuid = Uuid::parse_str(mentor_id).map_err(|e| anyhow!("Invalid mentor ID: {}", e))?;
let query = Sessions::find()
.filter(SessionColumn::MentorId.eq(mentor_uuid));
let query = if let Some(status) = status_filter {
query.filter(SessionColumn::Status.eq(status))
} else {
query
};
let count = query.count(self.db).await.map_err(|e| anyhow!("Failed to count mentor sessions: {}", e))?;
Ok(count as usize)
}
// ============================================
// Count User Sessions
// ============================================
pub async fn count_user_sessions(
&self,
user_id: &str,
status_filter: Option<String>,
) -> Result<usize> {
let user_uuid = Uuid::parse_str(user_id).map_err(|e| anyhow!("Invalid user ID: {}", e))?;
let query = Sessions::find()
.filter(SessionColumn::MenteeId.eq(user_uuid));
let query = if let Some(status) = status_filter {
query.filter(SessionColumn::Status.eq(status))
} else {
query
};
let count = query.count(self.db).await.map_err(|e| anyhow!("Failed to count user sessions: {}", e))?;
Ok(count as usize)
}
// ============================================
// Delete Session (soft delete)
// ============================================
pub async fn delete_session(&self, id: &str) -> Result<()> {
let session_id = Uuid::parse_str(id).map_err(|e| anyhow!("Invalid session ID: {}", e))?;
// Fetch the session first
let session_model = Sessions::find_by_id(session_id)
.one(self.db)
.await
.map_err(|e| anyhow!("Failed to fetch session for deletion: {}", e))?
.ok_or_else(|| anyhow!("Session not found"))?;
// Convert to ActiveModel for deletion
let session_active_model = session_model.into_active_model();
// For soft delete, we would typically set an `is_deleted` flag
// Since the original implementation didn't have this, we'll just delete the record
// If you want to implement soft delete, add an `is_deleted` field to the SessionModel
let _ = session_active_model.delete(self.db).await.map_err(|e| anyhow!("Failed to delete session: {}", e))?;
Ok(())
}
}
@@ -1,93 +0,0 @@
use super::{BookSessionRequestDto, SessionFeedbackRequestDto, UpdateSessionStatusRequestDto};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use imphnen_entities::error_dto::error::Error;
// Type alias for Thing
pub type Thing = String;
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct SessionSchema {
pub id: Uuid,
pub mentor_id: Uuid,
pub mentee_id: Uuid,
pub topic: String,
pub description: Option<String>,
pub scheduled_at: DateTime<Utc>,
pub duration_minutes: i32,
pub meeting_link: Option<String>,
pub session_type: String, // "video_call", "phone_call", "chat"
pub status: String, // "pending", "confirmed", "completed", "cancelled", "no_show"
pub feedback: Option<String>,
pub rating: Option<i32>, // 1-5
pub feedback_submitted_at: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
impl Default for SessionSchema {
fn default() -> Self {
let now = Utc::now();
Self {
id: Uuid::new_v4(),
mentor_id: Uuid::new_v4(),
mentee_id: Uuid::new_v4(),
topic: String::new(),
description: None,
scheduled_at: now,
duration_minutes: 60,
meeting_link: None,
session_type: "video_call".to_string(),
status: "pending".to_string(),
feedback: None,
rating: None,
feedback_submitted_at: None,
created_at: now,
updated_at: now,
}
}
}
impl SessionSchema {
pub fn from_book_request(
mentor_id: Thing,
mentee_id: Thing,
request: BookSessionRequestDto,
) -> Result<Self, Error> {
let scheduled_at = DateTime::parse_from_rfc3339(&request.scheduled_at)
.map_err(|e| Error::Validation(format!("Invalid scheduled_at format: {}", e)))?
.with_timezone(&Utc);
let mentor_id = Uuid::parse_str(&mentor_id)
.map_err(|e| Error::Validation(format!("Invalid mentor_id: {}", e)))?;
let mentee_id = Uuid::parse_str(&mentee_id)
.map_err(|e| Error::Validation(format!("Invalid mentee_id: {}", e)))?;
Ok(Self {
mentor_id,
mentee_id,
topic: request.topic,
description: request.description,
scheduled_at,
duration_minutes: request.duration_minutes.unwrap_or(60),
session_type: request.session_type.unwrap_or_else(|| "video_call".to_string()),
..Default::default()
})
}
pub fn update_status(&mut self, request: UpdateSessionStatusRequestDto) {
self.status = request.status;
if let Some(link) = request.meeting_link {
self.meeting_link = Some(link);
}
self.updated_at = Utc::now();
}
pub fn add_feedback(&mut self, request: SessionFeedbackRequestDto) {
self.feedback = Some(request.feedback);
self.rating = Some(request.rating);
self.feedback_submitted_at = Some(Utc::now());
self.updated_at = Utc::now();
}
}
@@ -1,312 +0,0 @@
use super::{
AvailabilitySlotDto, BookSessionRequestDto, BookSessionResponseDto, MentorAvailabilityDto,
SessionFeedbackRequestDto, SessionFeedbackResponseDto, SessionListItemDto,
SessionListResponseDto, SessionSchema, SessionsRepository, UpdateSessionStatusRequestDto,
UpdateSessionStatusResponseDto,
};
use axum::{http::StatusCode, response::Response};
use chrono::{DateTime, Duration, Utc};
use imphnen_entities::ResponseSuccessDto;
use imphnen_libs::AppState;
use imphnen_utils::{common_response, success_response, validator::validate_request};
use uuid;
pub struct SessionsService;
impl SessionsService {
// ============================================
// Book Session
// ============================================
pub async fn book_session(
state: &AppState,
mentor_id: String,
user_id: String,
dto: BookSessionRequestDto,
) -> Response {
if let Err((status, message)) = validate_request(&dto) {
return common_response(status, &message);
}
let scheduled_at = match DateTime::parse_from_rfc3339(&dto.scheduled_at) {
Ok(dt) => dt.with_timezone(&Utc),
Err(e) => return common_response(StatusCode::BAD_REQUEST, &format!("Invalid scheduled_at format: {}", e)),
};
let schema = SessionSchema {
id: uuid::Uuid::new_v4(),
mentor_id: match uuid::Uuid::parse_str(&mentor_id) {
Ok(id) => id,
Err(e) => return common_response(StatusCode::BAD_REQUEST, &format!("Invalid mentor ID: {}", e)),
},
mentee_id: match uuid::Uuid::parse_str(&user_id) {
Ok(id) => id,
Err(e) => return common_response(StatusCode::BAD_REQUEST, &format!("Invalid user ID: {}", e)),
},
topic: dto.topic,
description: dto.description,
scheduled_at,
duration_minutes: dto.duration_minutes.unwrap_or(60),
meeting_link: None,
session_type: dto.session_type.unwrap_or_else(|| "video_call".to_string()),
status: "pending".to_string(),
feedback: None,
rating: None,
feedback_submitted_at: None,
created_at: Utc::now(),
updated_at: Utc::now(),
};
let repo = SessionsRepository::new(state);
match repo.create_session(schema).await {
Ok(created) => {
let response = BookSessionResponseDto {
id: created.id.to_string(),
mentor_id: created.mentor_id.to_string(),
mentee_id: created.mentee_id.to_string(),
topic: created.topic,
description: created.description,
scheduled_at: created.scheduled_at.to_rfc3339(),
duration_minutes: created.duration_minutes,
session_type: created.session_type,
status: created.status,
created_at: created.created_at.to_rfc3339(),
};
success_response(ResponseSuccessDto { data: response })
}
Err(e) => common_response(StatusCode::BAD_REQUEST, &e.to_string()),
}
}
// ============================================
// Get Mentor's Sessions
// ============================================
pub async fn get_mentor_sessions(
state: &AppState,
mentor_id: String,
_user_email: String,
status_filter: Option<String>,
) -> Response {
let repo = SessionsRepository::new(state);
// Get count and sessions
let count = match repo.count_mentor_sessions(&mentor_id, status_filter.clone()).await {
Ok(c) => c,
Err(e) => return common_response(StatusCode::INTERNAL_SERVER_ERROR, &format!("Failed to count sessions: {}", e)),
};
match repo.query_mentor_sessions(&mentor_id, status_filter).await {
Ok(sessions) => {
let session_items: Vec<SessionListItemDto> = sessions
.into_iter()
.map(|s| SessionListItemDto {
id: s.id,
mentor_id: s.mentor_id,
mentee_id: s.mentee_id,
mentee_fullname: s.mentee_fullname,
mentee_email: s.mentee_email,
topic: s.topic,
scheduled_at: s.scheduled_at,
duration_minutes: s.duration_minutes,
session_type: s.session_type,
status: s.status,
rating: s.rating,
created_at: s.created_at,
})
.collect();
let response = SessionListResponseDto {
sessions: session_items,
total: count,
};
success_response(ResponseSuccessDto { data: response })
}
Err(e) => common_response(StatusCode::BAD_REQUEST, &e.to_string()),
}
}
// ============================================
// Get User's Sessions (as mentee)
// ============================================
pub async fn get_user_sessions(
state: &AppState,
user_id: String,
status_filter: Option<String>,
) -> Response {
let repo = SessionsRepository::new(state);
// Get count and sessions
let count = match repo.count_user_sessions(&user_id, status_filter.clone()).await {
Ok(c) => c,
Err(e) => return common_response(StatusCode::INTERNAL_SERVER_ERROR, &format!("Failed to count sessions: {}", e)),
};
match repo.query_user_sessions(&user_id, status_filter).await {
Ok(sessions) => {
let session_items: Vec<SessionListItemDto> = sessions
.into_iter()
.map(|s| SessionListItemDto {
id: s.id,
mentor_id: s.mentor_id,
mentee_id: s.mentee_id,
mentee_fullname: s.mentee_fullname,
mentee_email: s.mentee_email,
topic: s.topic,
scheduled_at: s.scheduled_at,
duration_minutes: s.duration_minutes,
session_type: s.session_type,
status: s.status,
rating: s.rating,
created_at: s.created_at,
})
.collect();
let response = SessionListResponseDto {
sessions: session_items,
total: count,
};
success_response(ResponseSuccessDto { data: response })
}
Err(e) => common_response(StatusCode::BAD_REQUEST, &e.to_string()),
}
}
// ============================================
// Get Mentor Availability
// ============================================
pub async fn get_mentor_availability(state: &AppState, mentor_id: String) -> Response {
let repo = SessionsRepository::new(state);
match repo.query_booked_dates(&mentor_id).await {
Ok(booked_dates) => {
// Generate sample availability slots (next 7 days)
let mut slots = Vec::new();
let today = Utc::now().date_naive();
for i in 0..7 {
let date = today + Duration::days(i);
let date_str = date.format("%Y-%m-%d").to_string();
// Generate time slots (9 AM to 5 PM, every hour)
for hour in 9..17 {
let time_str = format!("{:02}:00", hour);
let datetime_str = format!("{}T{}:00Z", date_str, time_str);
// Check if this slot is booked
let is_booked = booked_dates.iter().any(|d| d.starts_with(&datetime_str[..13]));
slots.push(AvailabilitySlotDto {
date: date_str.clone(),
time: time_str,
available: !is_booked,
});
}
}
let response = MentorAvailabilityDto {
mentor_id,
availability_commitment: "Available weekdays 9 AM - 5 PM".to_string(),
preferred_formats: vec!["video_call".to_string(), "phone_call".to_string()],
slots,
booked_dates,
};
success_response(ResponseSuccessDto { data: response })
}
Err(e) => common_response(StatusCode::NOT_FOUND, &e.to_string()),
}
}
// ============================================
// Update Session Status
// ============================================
pub async fn update_session_status(
state: &AppState,
session_id: String,
_user_id: String,
dto: UpdateSessionStatusRequestDto,
) -> Response {
if let Err((status, message)) = validate_request(&dto) {
return common_response(status, &message);
}
let repo = SessionsRepository::new(state);
match repo.query_session_by_id(&session_id).await {
Ok(Some(mut session)) => {
session.status = dto.status.clone();
if let Some(link) = &dto.meeting_link {
session.meeting_link = Some(link.clone());
}
session.updated_at = Utc::now();
match repo.update_session(&session_id, session).await {
Ok(updated) => {
let response = UpdateSessionStatusResponseDto {
id: updated.id.to_string(),
status: updated.status,
meeting_link: updated.meeting_link,
updated_at: updated.updated_at.to_rfc3339(),
};
success_response(ResponseSuccessDto { data: response })
}
Err(e) => common_response(StatusCode::BAD_REQUEST, &e.to_string()),
}
}
Ok(None) => common_response(StatusCode::NOT_FOUND, "Session not found"),
Err(e) => common_response(StatusCode::BAD_REQUEST, &e.to_string()),
}
}
// ============================================
// Submit Feedback
// ============================================
pub async fn submit_feedback(
state: &AppState,
session_id: String,
user_id: String,
dto: SessionFeedbackRequestDto,
) -> Response {
if let Err((status, message)) = validate_request(&dto) {
return common_response(status, &message);
}
let repo = SessionsRepository::new(state);
match repo.query_session_by_id(&session_id).await {
Ok(Some(mut session)) => {
// Authorization: Only mentee can submit feedback
let mentee_id = session.mentee_id.to_string();
if mentee_id != user_id {
return common_response(
StatusCode::FORBIDDEN,
"Unauthorized: Only the mentee can submit feedback",
);
}
// Validate session is completed
if session.status != "completed" {
return common_response(
StatusCode::BAD_REQUEST,
"Feedback can only be submitted for completed sessions",
);
}
session.feedback = Some(dto.feedback.clone());
session.rating = Some(dto.rating);
session.feedback_submitted_at = Some(Utc::now());
session.updated_at = Utc::now();
match repo.update_session(&session_id, session).await {
Ok(updated) => {
let response = SessionFeedbackResponseDto {
id: updated.id.to_string(),
feedback: dto.feedback,
rating: dto.rating,
submitted_at: updated.feedback_submitted_at.unwrap_or(Utc::now()).to_rfc3339(),
};
success_response(ResponseSuccessDto { data: response })
}
Err(e) => common_response(StatusCode::BAD_REQUEST, &e.to_string()),
}
}
Ok(None) => common_response(StatusCode::NOT_FOUND, "Session not found"),
Err(e) => common_response(StatusCode::BAD_REQUEST, &e.to_string()),
}
}
}
View File
-14
View File
@@ -1,14 +0,0 @@
/// Version 2 of the Dimentorin API - currently under development
/// This module will contain all version 2 endpoints following API versioning best practices
use axum::Router;
/// Placeholder for version 2 router
/// To be implemented when version 2 endpoints are ready
pub fn dimentorin_v2_router() -> Router {
Router::new()
// Version 2 endpoints will be added here following the same pattern as v1
}
// Re-export the v1 router for backward compatibility
pub use crate::v1::dimentorin_router;