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
@@ -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()))
}