diff --git a/docs/dev-audit-findings.md b/docs/dev-audit-findings.md new file mode 100644 index 0000000..54d2c59 --- /dev/null +++ b/docs/dev-audit-findings.md @@ -0,0 +1,39 @@ +# Dimentorin — Catatan Temuan Infra (Dev Audit, 2026-08-04) + +Dokumen ini mencatat temuan yang membutuhkan perhatian tim sebelum produksi. +Semua diuji lokal (Postgres `dimentorin`, backend :4099). + +## 1. SMTP email verification broken (blocker aktivasi user baru) + +- Endpoint `POST /v1/iam/auth/send-otp` gagal: `SMTP transport error (535): Username and Password not accepted` — kredensial `.env` (`SMTP_EMAIL=dev@example.com`, `SMTP_PASSWORD=dev`) ditolak Google SMTP. +- `POST /v1/iam/auth/verify-email` tetap butuh OTP untuk memanggil, tapi lihat poin 2. +- **Dampak**: mentee/mentor baru tak bisa menerima OTP lewat email → tak bisa aktivasi → tak bisa login, kecuali via verify-email langsung. +- **Diperlukan**: SMTP credential institution yang valid (Gmail App Password atau SMTP relay), sebaiknya dari BWS secret management, bukan hardcode. + +## 2. verify-email TIDAK memverifikasi OTP (security issue) + +`imphnen-iam/src/auth/application/mod.rs` → `verify_email()`: + +```rust +async fn verify_email(&self, payload: VerifyEmailInput) -> ... { + let user = ...find_by_email...; + if user.is_active { return Err(...); } + self.user_repo.update(UserEntity { is_active: true, ..user }).await?; + Ok(()) +} +``` + +- OTP di-generate (`OtpManager::generate_otp`) + dikirim via email, TAPI **`payload.otp` tidak pernah divalidasi**. +- `OtpManager::validate_otp` ada tapi tak dipanggil di handler ini. +- **Dampak**: siapa pun yang tahu email bisa mengaktifkan akun dengan otp sembarang. Verifikasi email berbasis kepemilikan = tidak ada. +- **Fix yang disarankan**: `verify_email` harus menyimpan `OtpData` (hash+expiry) saat kirim, lalu memanggil `OtpManager::validate_otp(stored, payload.otp)` sebelum set `is_active`. + +## 3. (OK, sudah benar) Register mentor + booking + +- `POST /v1/dimentorin/mentors/create` → 200, user + mentor profile dibuat, status `pending`, user tak tampil di list public sampai verified. +- `POST /v1/dimentorin/mentors/{id}/sessions/create` → 200, session pending. +- Kedua endpoint fungsional setelah fix UUID (commit 9b5efef). + +## Rekomendasi + +Tangani #1 dan #2 sebelum go-live. #2 adalah kelas bug "OTP di-generate tapi tak dipakai" — sisi verifikasi email saat ini tidak lebih dari form "set is_active=true tanpa autentikasi". \ No newline at end of file diff --git a/imphnen-dimentorin/src/sessions/application/session_query_service.rs b/imphnen-dimentorin/src/sessions/application/session_query_service.rs index 58812a1..95808df 100644 --- a/imphnen-dimentorin/src/sessions/application/session_query_service.rs +++ b/imphnen-dimentorin/src/sessions/application/session_query_service.rs @@ -3,12 +3,17 @@ use crate::sessions::domain::{ SessionRepository, }; use chrono::{Duration, Utc}; +use imphnen_entities::seaorm::auth::mentors::{ + Column as MentorColumn, Entity as MentorsEntity, +}; use imphnen_utils::AppError; +use sea_orm::{ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter}; use std::sync::Arc; use uuid::Uuid; pub struct SessionQueryService { pub repo: Arc, + pub db: Arc, } impl SessionQueryService { @@ -20,14 +25,20 @@ impl SessionQueryService { let mentor_uuid = Uuid::parse_str(&mentor_id) .map_err(|e| AppError::BadRequestError(format!("Invalid mentor ID: {}", e)))?; + // Resolve mentor profile id -> user id (sessions.mentor_id FK ke app_users) + let mentor = MentorsEntity::find_by_id(mentor_uuid) + .one(self.db.as_ref()) + .await? + .ok_or_else(|| AppError::NotFoundError("Mentor not found".to_string()))?; + let count = self .repo - .count_by_mentor(mentor_uuid, status_filter.clone()) + .count_by_mentor(mentor.user_id, status_filter.clone()) .await?; let sessions = self .repo - .find_by_mentor_id(mentor_uuid, status_filter) + .find_by_mentor_id(mentor.user_id, status_filter) .await?; let items: Vec = sessions diff --git a/imphnen-dimentorin/src/sessions/application/session_service.rs b/imphnen-dimentorin/src/sessions/application/session_service.rs index 8fa95a9..d38aadd 100644 --- a/imphnen-dimentorin/src/sessions/application/session_service.rs +++ b/imphnen-dimentorin/src/sessions/application/session_service.rs @@ -1,9 +1,9 @@ use super::session_booking_service::SessionBookingService; use super::session_query_service::SessionQueryService; use crate::sessions::domain::{ - BookSessionCommand, BookedSession, MentorAvailability, SessionDetail, - SessionFeedbackCommand, SessionFeedbackResult, SessionList, SessionRepository, - SessionService, UpdateSessionStatusCommand, UpdatedSessionStatus, + BookSessionCommand, BookedSession, MentorAvailability, MentorStats, + SessionDetail, SessionFeedbackCommand, SessionFeedbackResult, SessionList, + SessionRepository, SessionService, UpdateSessionStatusCommand, UpdatedSessionStatus, }; use async_trait::async_trait; use imphnen_utils::AppError; @@ -15,12 +15,15 @@ pub struct SessionServiceImpl { } impl SessionServiceImpl { - pub fn new(repo: Arc) -> Self { + pub fn new( + repo: Arc, + db: Arc, + ) -> Self { Self { booking: SessionBookingService { repo: Arc::clone(&repo), }, - query: SessionQueryService { repo }, + query: SessionQueryService { repo, db }, } } } @@ -47,6 +50,34 @@ impl SessionService for SessionServiceImpl { .await } + async fn get_mentor_stats( + &self, + mentor_id: String, + ) -> Result { + let list = self.query.get_mentor_sessions(mentor_id.clone(), None).await?; + let mut mentees = std::collections::HashSet::new(); + let mut rating_sum = 0i64; + let mut rating_count = 0i64; + for s in &list.sessions { + mentees.insert(s.mentee_id.clone()); + if let Some(r) = s.rating { + rating_sum += r as i64; + rating_count += 1; + } + } + let avg = if rating_count > 0 { + rating_sum as f64 / rating_count as f64 + } else { + 0.0 + }; + Ok(MentorStats { + mentor_id, + total_sessions: list.total as u64, + unique_mentees: mentees.len() as u64, + avg_rating: (avg * 10.0).round() / 10.0, + }) + } + async fn get_user_sessions( &self, user_id: String, diff --git a/imphnen-dimentorin/src/sessions/domain/mod.rs b/imphnen-dimentorin/src/sessions/domain/mod.rs index 9e0d5aa..341e5ff 100644 --- a/imphnen-dimentorin/src/sessions/domain/mod.rs +++ b/imphnen-dimentorin/src/sessions/domain/mod.rs @@ -8,6 +8,6 @@ pub use service::SessionService; pub use session::SessionEntity; pub use session_types::{ AvailabilitySlot, BookSessionCommand, BookedSession, MentorAvailability, - SessionDetail, SessionFeedbackCommand, SessionFeedbackResult, SessionList, - SessionListItem, UpdateSessionStatusCommand, UpdatedSessionStatus, + MentorStats, SessionDetail, SessionFeedbackCommand, SessionFeedbackResult, + SessionList, SessionListItem, UpdateSessionStatusCommand, UpdatedSessionStatus, }; diff --git a/imphnen-dimentorin/src/sessions/domain/repository.rs b/imphnen-dimentorin/src/sessions/domain/repository.rs index 4f20a54..5ac7aca 100644 --- a/imphnen-dimentorin/src/sessions/domain/repository.rs +++ b/imphnen-dimentorin/src/sessions/domain/repository.rs @@ -11,6 +11,13 @@ pub trait SessionRepository: Send + Sync { async fn find_by_id(&self, id: Uuid) -> Result, AppError>; + /// Resolve mentor *profile* id (app_mentors.id) to the owning user id + /// (app_users.id) — session rows store the user id. + async fn find_mentor_user_id( + &self, + profile_id: Uuid, + ) -> Result, AppError>; + async fn find_by_mentor_id( &self, mentor_id: Uuid, diff --git a/imphnen-dimentorin/src/sessions/domain/service.rs b/imphnen-dimentorin/src/sessions/domain/service.rs index e1451c0..ec9cf9b 100644 --- a/imphnen-dimentorin/src/sessions/domain/service.rs +++ b/imphnen-dimentorin/src/sessions/domain/service.rs @@ -1,6 +1,6 @@ use super::session_types::{ - BookSessionCommand, BookedSession, MentorAvailability, SessionDetail, - SessionFeedbackCommand, SessionFeedbackResult, SessionList, + BookSessionCommand, BookedSession, MentorAvailability, MentorStats, + SessionDetail, SessionFeedbackCommand, SessionFeedbackResult, SessionList, UpdateSessionStatusCommand, UpdatedSessionStatus, }; use async_trait::async_trait; @@ -32,6 +32,11 @@ pub trait SessionService: Send + Sync { mentor_id: String, ) -> Result; + async fn get_mentor_stats( + &self, + mentor_id: String, + ) -> Result; + async fn update_session_status( &self, session_id: String, diff --git a/imphnen-dimentorin/src/sessions/domain/session_types.rs b/imphnen-dimentorin/src/sessions/domain/session_types.rs index 5fae06d..74170c6 100644 --- a/imphnen-dimentorin/src/sessions/domain/session_types.rs +++ b/imphnen-dimentorin/src/sessions/domain/session_types.rs @@ -73,6 +73,14 @@ pub struct MentorAvailability { pub booked_dates: Vec, } +#[derive(Clone, Debug)] +pub struct MentorStats { + pub mentor_id: String, + pub total_sessions: u64, + pub unique_mentees: u64, + pub avg_rating: f64, +} + pub struct UpdateSessionStatusCommand { pub status: String, pub meeting_link: Option, diff --git a/imphnen-dimentorin/src/sessions/infrastructure/http/dto/mod.rs b/imphnen-dimentorin/src/sessions/infrastructure/http/dto/mod.rs index e6a5bee..8ee13fc 100644 --- a/imphnen-dimentorin/src/sessions/infrastructure/http/dto/mod.rs +++ b/imphnen-dimentorin/src/sessions/infrastructure/http/dto/mod.rs @@ -6,6 +6,7 @@ pub use request::{ }; pub use response::{ AvailabilitySlotDto, BookSessionResponseDto, MentorAvailabilityDto, - SessionDetailDto, SessionFeedbackResponseDto, SessionListItemDto, - SessionListResponseDto, UpdateSessionStatusResponseDto, + MentorStatsDto, SessionDetailDto, SessionFeedbackResponseDto, + SessionListItemDto, SessionListResponseDto, + UpdateSessionStatusResponseDto, }; diff --git a/imphnen-dimentorin/src/sessions/infrastructure/http/dto/response.rs b/imphnen-dimentorin/src/sessions/infrastructure/http/dto/response.rs index d191f34..e5f12a0 100644 --- a/imphnen-dimentorin/src/sessions/infrastructure/http/dto/response.rs +++ b/imphnen-dimentorin/src/sessions/infrastructure/http/dto/response.rs @@ -1,5 +1,5 @@ use crate::sessions::domain::{ - AvailabilitySlot, BookedSession, MentorAvailability, SessionDetail, + AvailabilitySlot, BookedSession, MentorAvailability, MentorStats, SessionDetail, SessionFeedbackResult, SessionList, SessionListItem, UpdatedSessionStatus, }; use serde::{Deserialize, Serialize}; @@ -173,6 +173,25 @@ impl From for MentorAvailabilityDto { } } +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] +pub struct MentorStatsDto { + pub mentor_id: String, + pub total_sessions: u64, + pub unique_mentees: u64, + pub avg_rating: f64, +} + +impl From for MentorStatsDto { + fn from(s: MentorStats) -> Self { + Self { + mentor_id: s.mentor_id, + total_sessions: s.total_sessions, + unique_mentees: s.unique_mentees, + avg_rating: s.avg_rating, + } + } +} + #[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] pub struct UpdateSessionStatusResponseDto { pub id: String, diff --git a/imphnen-dimentorin/src/sessions/infrastructure/http/handlers/mod.rs b/imphnen-dimentorin/src/sessions/infrastructure/http/handlers/mod.rs index 1a01370..662ea59 100644 --- a/imphnen-dimentorin/src/sessions/infrastructure/http/handlers/mod.rs +++ b/imphnen-dimentorin/src/sessions/infrastructure/http/handlers/mod.rs @@ -5,5 +5,5 @@ pub use mutation_handlers::{ post_book_session, post_submit_feedback, put_update_session_status, }; pub use query_handlers::{ - get_mentor_availability, get_mentor_sessions, get_my_sessions, + get_mentor_availability, get_mentor_sessions, get_mentor_stats, get_my_sessions, }; diff --git a/imphnen-dimentorin/src/sessions/infrastructure/http/handlers/query_handlers.rs b/imphnen-dimentorin/src/sessions/infrastructure/http/handlers/query_handlers.rs index b4120c3..b12487f 100644 --- a/imphnen-dimentorin/src/sessions/infrastructure/http/handlers/query_handlers.rs +++ b/imphnen-dimentorin/src/sessions/infrastructure/http/handlers/query_handlers.rs @@ -1,4 +1,4 @@ -use super::super::dto::{MentorAvailabilityDto, SessionListResponseDto}; +use super::super::dto::{MentorAvailabilityDto, MentorStatsDto, SessionListResponseDto}; use crate::sessions::domain::SessionService; use axum::{ extract::{Extension, Path, Query}, @@ -68,6 +68,25 @@ pub async fn get_mentor_availability( Ok(ApiSuccess(resp)) } +#[utoipa::path( + get, + path = "/v1/dimentorin/mentors/{id}/stats", + tag = "sessions", + params( + ("id" = String, Path, description = "Mentor id"), + ), + responses( + (status = 200, description = "Mentor stats retrieved successfully", body = MentorStatsDto), + ) +)] +pub async fn get_mentor_stats( + Extension(service): Extension>, + Path(mentor_id): Path, +) -> Result { + let resp = MentorStatsDto::from(service.get_mentor_stats(mentor_id).await?); + Ok(ApiSuccess(resp)) +} + #[utoipa::path( get, path = "/v1/dimentorin/sessions/me", diff --git a/imphnen-dimentorin/src/sessions/infrastructure/http/routes.rs b/imphnen-dimentorin/src/sessions/infrastructure/http/routes.rs index 70b6a46..db03f13 100644 --- a/imphnen-dimentorin/src/sessions/infrastructure/http/routes.rs +++ b/imphnen-dimentorin/src/sessions/infrastructure/http/routes.rs @@ -1,6 +1,7 @@ use super::handlers::{ - get_mentor_availability, get_mentor_sessions, get_my_sessions, post_book_session, - post_submit_feedback, put_update_session_status, + get_mentor_availability, get_mentor_sessions, get_mentor_stats, + get_my_sessions, post_book_session, post_submit_feedback, + put_update_session_status, }; use crate::sessions::application::SessionServiceImpl; use crate::sessions::domain::SessionService; @@ -14,14 +15,16 @@ use sea_orm::DatabaseConnection; use std::sync::Arc; fn build_service(db: DatabaseConnection) -> Arc { - let repo = Arc::new(PostgresSessionRepository::new(db)); - Arc::new(SessionServiceImpl::new(repo)) + let db_arc = Arc::new(db); + let repo = Arc::new(PostgresSessionRepository::new(Arc::clone(&db_arc))); + Arc::new(SessionServiceImpl::new(repo, db_arc)) } pub fn sessions_public_routes(db: DatabaseConnection) -> Router { let service = build_service(db); Router::new() .route("/mentors/{id}/availability", get(get_mentor_availability)) + .route("/mentors/{id}/stats", get(get_mentor_stats)) .layer(Extension(service)) } diff --git a/imphnen-dimentorin/src/sessions/infrastructure/persistence/postgres_session_repository.rs b/imphnen-dimentorin/src/sessions/infrastructure/persistence/postgres_session_repository.rs index 85d68c3..fb69d32 100644 --- a/imphnen-dimentorin/src/sessions/infrastructure/persistence/postgres_session_repository.rs +++ b/imphnen-dimentorin/src/sessions/infrastructure/persistence/postgres_session_repository.rs @@ -39,8 +39,8 @@ pub struct PostgresSessionRepository { } impl PostgresSessionRepository { - pub fn new(db: DatabaseConnection) -> Self { - Self { db: Arc::new(db) } + pub fn new(db: Arc) -> Self { + Self { db } } } @@ -82,6 +82,22 @@ impl SessionRepository for PostgresSessionRepository { Ok(model.map(model_to_entity)) } + async fn find_mentor_user_id( + &self, + profile_id: Uuid, + ) -> Result, AppError> { + use imphnen_entities::seaorm::auth::mentors::{ + Column as MentorColumn, Entity as MentorsEntity, + }; + use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; + let model = MentorsEntity::find() + .filter(MentorColumn::Id.eq(profile_id)) + .one(self.db.as_ref()) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + Ok(model.map(|m| m.user_id)) + } + async fn find_by_mentor_id( &self, mentor_id: Uuid,