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:
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
+18
-2
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user