feat(dimentorin): mentor stats endpoint public - total sessions, unique mentees, avg rating

- GET /mentors/{id}/stats (public, no auth)
- resolve mentor profile id -> user id in get_mentor_sessions (FK uses app_users.id)
This commit is contained in:
asepharyana
2026-08-04 23:09:08 +07:00
parent 9b5efeff87
commit 3692b81324
13 changed files with 181 additions and 22 deletions
+39
View File
@@ -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".
@@ -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<dyn SessionRepository>,
pub db: Arc<DatabaseConnection>,
}
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<SessionListItem> = sessions
@@ -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<dyn SessionRepository>) -> Self {
pub fn new(
repo: Arc<dyn SessionRepository>,
db: Arc<sea_orm::DatabaseConnection>,
) -> 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<MentorStats, AppError> {
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,
@@ -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,
};
@@ -11,6 +11,13 @@ pub trait SessionRepository: Send + Sync {
async fn find_by_id(&self, id: Uuid) -> Result<Option<SessionEntity>, 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<Option<Uuid>, AppError>;
async fn find_by_mentor_id(
&self,
mentor_id: Uuid,
@@ -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<MentorAvailability, AppError>;
async fn get_mentor_stats(
&self,
mentor_id: String,
) -> Result<MentorStats, AppError>;
async fn update_session_status(
&self,
session_id: String,
@@ -73,6 +73,14 @@ pub struct MentorAvailability {
pub booked_dates: Vec<String>,
}
#[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<String>,
@@ -6,6 +6,7 @@ pub use request::{
};
pub use response::{
AvailabilitySlotDto, BookSessionResponseDto, MentorAvailabilityDto,
SessionDetailDto, SessionFeedbackResponseDto, SessionListItemDto,
SessionListResponseDto, UpdateSessionStatusResponseDto,
MentorStatsDto, SessionDetailDto, SessionFeedbackResponseDto,
SessionListItemDto, SessionListResponseDto,
UpdateSessionStatusResponseDto,
};
@@ -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<MentorAvailability> 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<MentorStats> 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,
@@ -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,
};
@@ -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<Arc<dyn SessionService>>,
Path(mentor_id): Path<String>,
) -> Result<impl IntoResponse, AppError> {
let resp = MentorStatsDto::from(service.get_mentor_stats(mentor_id).await?);
Ok(ApiSuccess(resp))
}
#[utoipa::path(
get,
path = "/v1/dimentorin/sessions/me",
@@ -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<dyn SessionService> {
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))
}
@@ -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<DatabaseConnection>) -> 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<Option<Uuid>, 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,