diff --git a/Cargo.lock b/Cargo.lock index 46a88b4..cf1d1c5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2094,6 +2094,7 @@ name = "imphnen-dimentorin" version = "0.1.0" dependencies = [ "anyhow", + "async-trait", "axum", "axum-test", "chrono", @@ -2102,6 +2103,7 @@ dependencies = [ "imphnen-entities", "imphnen-iam", "imphnen-libs", + "imphnen-middleware", "imphnen-utils", "lazy_static", "rand 0.9.2", diff --git a/imphnen-dimentorin/Cargo.toml b/imphnen-dimentorin/Cargo.toml index 78605a1..4535221 100644 --- a/imphnen-dimentorin/Cargo.toml +++ b/imphnen-dimentorin/Cargo.toml @@ -8,7 +8,9 @@ imphnen-libs.workspace = true imphnen-utils.workspace = true imphnen-entities.workspace = true imphnen-iam.workspace = true +imphnen-middleware.workspace = true axum.workspace = true +async-trait.workspace = true serde.workspace = true serde_json.workspace = true utoipa.workspace = true diff --git a/imphnen-dimentorin/src/lib.rs b/imphnen-dimentorin/src/lib.rs index 03b2d4b..ce372f1 100644 --- a/imphnen-dimentorin/src/lib.rs +++ b/imphnen-dimentorin/src/lib.rs @@ -3,3 +3,10 @@ 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, +}; + diff --git a/imphnen-dimentorin/src/v1/mod.rs b/imphnen-dimentorin/src/v1/mod.rs index ebf1be4..8ea9e7d 100644 --- a/imphnen-dimentorin/src/v1/mod.rs +++ b/imphnen-dimentorin/src/v1/mod.rs @@ -1,13 +1,17 @@ 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 @@ -15,3 +19,8 @@ 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; diff --git a/imphnen-dimentorin/src/v1/sessions/mod.rs b/imphnen-dimentorin/src/v1/sessions/mod.rs new file mode 100644 index 0000000..77b7357 --- /dev/null +++ b/imphnen-dimentorin/src/v1/sessions/mod.rs @@ -0,0 +1,11 @@ +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::*; diff --git a/imphnen-dimentorin/src/v1/sessions/sessions_controller.rs b/imphnen-dimentorin/src/v1/sessions/sessions_controller.rs new file mode 100644 index 0000000..a726024 --- /dev/null +++ b/imphnen-dimentorin/src/v1/sessions/sessions_controller.rs @@ -0,0 +1,297 @@ +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/book", + 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, + Path(mentor_id): Path, + Json(dto): Json, +) -> 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, +} + +#[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, 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, + Path(mentor_id): Path, + Query(filter): Query, +) -> 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, + Path(mentor_id): Path, +) -> Response { + SessionsService::get_mentor_availability(&state, mentor_id).await +} + +// ============================================ +// Update Session Status +// ============================================ + +#[utoipa::path( + put, + path = "/v1/sessions/{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, + Path(session_id): Path, + Json(dto): Json, +) -> 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", + 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, + Path(session_id): Path, + Json(dto): Json, +) -> 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, 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, + Query(filter): Query, +) -> 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/book", 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/:id/status", put(put_update_session_status)) + // Submit feedback + .route("/sessions/:id/feedback", post(post_submit_feedback)) + // Get my sessions + .route("/users/me/sessions", get(get_my_sessions)) +} diff --git a/imphnen-dimentorin/src/v1/sessions/sessions_dto.rs b/imphnen-dimentorin/src/v1/sessions/sessions_dto.rs new file mode 100644 index 0000000..8e1fc60 --- /dev/null +++ b/imphnen-dimentorin/src/v1/sessions/sessions_dto.rs @@ -0,0 +1,197 @@ +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, + + #[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, + + #[validate(length(max = 50, message = "Session type must be max 50 characters"))] + #[serde(skip_serializing_if = "Option::is_none")] + pub session_type: Option, // "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, + 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, + pub mentee_email: Option, + pub topic: String, + pub scheduled_at: String, + pub duration_minutes: i32, + pub session_type: String, + pub status: String, + pub rating: Option, + pub created_at: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] +pub struct SessionListResponseDto { + pub sessions: Vec, + 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, + pub mentee_id: String, + pub mentee_fullname: Option, + pub topic: String, + pub description: Option, + pub scheduled_at: String, + pub duration_minutes: i32, + pub meeting_link: Option, + pub session_type: String, + pub status: String, + pub feedback: Option, + pub rating: Option, + pub feedback_submitted_at: Option, + 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, + pub slots: Vec, + pub booked_dates: Vec, // 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, +} + +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] +pub struct UpdateSessionStatusResponseDto { + pub id: String, + pub status: String, + pub meeting_link: Option, + 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, + pub scheduled_at: String, + pub duration_minutes: i32, + pub meeting_link: Option, + pub session_type: String, + pub status: String, + pub feedback: Option, + pub rating: Option, + pub feedback_submitted_at: Option, + pub created_at: String, + pub updated_at: String, + pub mentor_fullname: Option, + pub mentee_fullname: Option, +} + +#[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, + pub created_at: String, + pub mentee_fullname: Option, + pub mentee_email: Option, +} diff --git a/imphnen-dimentorin/src/v1/sessions/sessions_repository.rs b/imphnen-dimentorin/src/v1/sessions/sessions_repository.rs new file mode 100644 index 0000000..dbd07a5 --- /dev/null +++ b/imphnen-dimentorin/src/v1/sessions/sessions_repository.rs @@ -0,0 +1,285 @@ +use super::{SessionDetailQueryDto, SessionListQueryDto, SessionSchema}; +use imphnen_libs::AppState; +use imphnen_utils::get_id; +use serde::Deserialize; +use surrealdb::sql::Thing; + +pub struct SessionsRepository<'a> { + pub state: &'a AppState, +} + +impl<'a> SessionsRepository<'a> { + pub fn new(state: &'a AppState) -> Self { + Self { state } + } + + // ============================================ + // Create Session + // ============================================ + pub async fn create_session(&self, schema: SessionSchema) -> Result { + let db = &self.state.surrealdb_ws; + let created: Option = db + .create("sessions") + .content(schema) + .await + .map_err(|e| format!("Failed to create session: {}", e))?; + + created.ok_or_else(|| "Session creation returned None".to_string()) + } + + // ============================================ + // Get Session by ID + // ============================================ + pub async fn query_session_by_id(&self, id: &Thing) -> Result, String> { + let db = &self.state.surrealdb_ws; + let record_key = get_id(id).map_err(|e| e.to_string())?; + let session: Option = db + .select(record_key) + .await + .map_err(|e| format!("Failed to fetch session: {}", e))?; + + Ok(session) + } + + // ============================================ + // Get Session Detail with User Info + // ============================================ + pub async fn query_session_detail(&self, id: &Thing) -> Result, String> { + let db = &self.state.surrealdb_ws; + let query = r#" + SELECT + id, + mentor_id, + mentee_id, + topic, + description, + scheduled_at, + duration_minutes, + meeting_link, + session_type, + status, + feedback, + rating, + feedback_submitted_at, + created_at, + updated_at, + (SELECT fullname FROM $parent.mentor_id.user_id)[0].fullname AS mentor_fullname, + (SELECT fullname FROM $parent.mentee_id)[0].fullname AS mentee_fullname + FROM type::thing($table, $id) + "#; + + let mut result = db + .query(query) + .bind(("table", "sessions")) + .bind(("id", id.id.to_string())) + .await + .map_err(|e| format!("Failed to query session detail: {}", e))?; + + let session: Option = result + .take(0) + .map_err(|e| format!("Failed to parse session detail: {}", e))?; + + Ok(session) + } + + // ============================================ + // List Mentor's Sessions + // ============================================ + pub async fn query_mentor_sessions( + &self, + mentor_id: &Thing, + status_filter: Option, + ) -> Result, String> { + let query = if let Some(_status) = status_filter.as_ref() { + r#" + SELECT + id, + mentor_id, + mentee_id, + topic, + scheduled_at, + duration_minutes, + session_type, + status, + rating, + created_at, + (SELECT fullname FROM $parent.mentee_id)[0].fullname AS mentee_fullname, + (SELECT email FROM $parent.mentee_id)[0].email AS mentee_email + FROM sessions + WHERE mentor_id = $mentor_id AND status = $status + ORDER BY scheduled_at DESC + "# + } else { + r#" + SELECT + id, + mentor_id, + mentee_id, + topic, + scheduled_at, + duration_minutes, + session_type, + status, + rating, + created_at, + (SELECT fullname FROM $parent.mentee_id)[0].fullname AS mentee_fullname, + (SELECT email FROM $parent.mentee_id)[0].email AS mentee_email + FROM sessions + WHERE mentor_id = $mentor_id + ORDER BY scheduled_at DESC + "# + }; + + let db = &self.state.surrealdb_ws; + let mentor_id_clone = mentor_id.clone(); + let mut result = if let Some(status_val) = status_filter { + db.query(query) + .bind(("mentor_id", mentor_id_clone)) + .bind(("status", status_val)) + .await + } else { + db.query(query) + .bind(("mentor_id", mentor_id_clone)) + .await + } + .map_err(|e| format!("Failed to query mentor sessions: {}", e))?; + + let sessions: Vec = result + .take(0) + .map_err(|e| format!("Failed to parse mentor sessions: {}", e))?; + + Ok(sessions) + } + + // ============================================ + // List User's Sessions (as mentee) + // ============================================ + pub async fn query_user_sessions( + &self, + user_id: &Thing, + status_filter: Option, + ) -> Result, String> { + let query = if let Some(_status) = status_filter.as_ref() { + r#" + SELECT + id, + mentor_id, + mentee_id, + topic, + scheduled_at, + duration_minutes, + session_type, + status, + rating, + created_at, + (SELECT fullname FROM $parent.mentee_id)[0].fullname AS mentee_fullname, + (SELECT email FROM $parent.mentee_id)[0].email AS mentee_email + FROM sessions + WHERE mentee_id = $user_id AND status = $status + ORDER BY scheduled_at DESC + "# + } else { + r#" + SELECT + id, + mentor_id, + mentee_id, + topic, + scheduled_at, + duration_minutes, + session_type, + status, + rating, + created_at, + (SELECT fullname FROM $parent.mentee_id)[0].fullname AS mentee_fullname, + (SELECT email FROM $parent.mentee_id)[0].email AS mentee_email + FROM sessions + WHERE mentee_id = $user_id + ORDER BY scheduled_at DESC + "# + }; + + let db = &self.state.surrealdb_ws; + let user_id_clone = user_id.clone(); + let mut result = if let Some(status_val) = status_filter { + db.query(query) + .bind(("user_id", user_id_clone)) + .bind(("status", status_val)) + .await + } else { + db.query(query) + .bind(("user_id", user_id_clone)) + .await + } + .map_err(|e| format!("Failed to query user sessions: {}", e))?; + + let sessions: Vec = result + .take(0) + .map_err(|e| format!("Failed to parse user sessions: {}", e))?; + + Ok(sessions) + } + + // ============================================ + // Get Booked Dates for Mentor + // ============================================ + pub async fn query_booked_dates(&self, mentor_id: &Thing) -> Result, String> { + let query = r#" + SELECT scheduled_at FROM sessions + WHERE mentor_id = $mentor_id + AND status IN ['pending', 'confirmed'] + ORDER BY scheduled_at ASC + "#; + + let db = &self.state.surrealdb_ws; + let mentor_id_clone = mentor_id.clone(); + let mut result = db + .query(query) + .bind(("mentor_id", mentor_id_clone)) + .await + .map_err(|e| format!("Failed to query booked dates: {}", e))?; + + #[derive(Deserialize)] + struct DateOnly { + scheduled_at: String, + } + + let dates: Vec = result + .take(0) + .map_err(|e| format!("Failed to parse booked dates: {}", e))?; + + Ok(dates.into_iter().map(|d| d.scheduled_at).collect()) + } + + // ============================================ + // Update Session + // ============================================ + pub async fn update_session(&self, id: &Thing, schema: SessionSchema) -> Result { + let db = &self.state.surrealdb_ws; + let record_key = get_id(id).map_err(|e| e.to_string())?; + let updated: Option = db + .update(record_key) + .content(schema) + .await + .map_err(|e| format!("Failed to update session: {}", e))?; + + updated.ok_or_else(|| "Session update returned None".to_string()) + } + + // ============================================ + // Delete Session (soft delete) + // ============================================ + // Delete Session (soft delete) + // ============================================ + pub async fn delete_session(&self, id: &Thing) -> Result<(), String> { + let db = &self.state.surrealdb_ws; + let record_key = get_id(id).map_err(|e| e.to_string())?; + let _: Option = db + .delete(record_key) + .await + .map_err(|e| format!("Failed to delete session: {}", e))?; + + Ok(()) + } +} + diff --git a/imphnen-dimentorin/src/v1/sessions/sessions_schema.rs b/imphnen-dimentorin/src/v1/sessions/sessions_schema.rs new file mode 100644 index 0000000..4aad7d4 --- /dev/null +++ b/imphnen-dimentorin/src/v1/sessions/sessions_schema.rs @@ -0,0 +1,89 @@ +use super::{BookSessionRequestDto, SessionFeedbackRequestDto, UpdateSessionStatusRequestDto}; +use imphnen_libs::ResourceEnum; +use imphnen_utils::{get_iso_date, make_thing}; +use serde::{Deserialize, Serialize}; +use surrealdb::{sql::Thing, Uuid}; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct SessionSchema { + pub id: Thing, + pub mentor_id: Thing, + pub mentee_id: Thing, + pub topic: String, + pub description: Option, + pub scheduled_at: String, // ISO 8601 datetime + pub duration_minutes: i32, + pub meeting_link: Option, + pub session_type: String, // "video_call", "phone_call", "chat" + pub status: String, // "pending", "confirmed", "completed", "cancelled", "no_show" + pub feedback: Option, + pub rating: Option, // 1-5 + pub feedback_submitted_at: Option, + pub created_at: String, + pub updated_at: String, +} + +impl Default for SessionSchema { + fn default() -> Self { + Self { + id: make_thing( + ResourceEnum::Sessions.to_string().as_str(), + &Uuid::new_v4().to_string(), + ), + mentor_id: make_thing( + ResourceEnum::Mentors.to_string().as_str(), + &Uuid::new_v4().to_string(), + ), + mentee_id: make_thing( + ResourceEnum::Users.to_string().as_str(), + &Uuid::new_v4().to_string(), + ), + topic: String::new(), + description: None, + scheduled_at: get_iso_date(), + 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: get_iso_date(), + updated_at: get_iso_date(), + } + } +} + +impl SessionSchema { + pub fn from_book_request( + mentor_id: Thing, + mentee_id: Thing, + request: BookSessionRequestDto, + ) -> Self { + Self { + mentor_id, + mentee_id, + topic: request.topic, + description: request.description, + scheduled_at: request.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 = get_iso_date(); + } + + pub fn add_feedback(&mut self, request: SessionFeedbackRequestDto) { + self.feedback = Some(request.feedback); + self.rating = Some(request.rating); + self.feedback_submitted_at = Some(get_iso_date()); + self.updated_at = get_iso_date(); + } +} diff --git a/imphnen-dimentorin/src/v1/sessions/sessions_service.rs b/imphnen-dimentorin/src/v1/sessions/sessions_service.rs new file mode 100644 index 0000000..1121918 --- /dev/null +++ b/imphnen-dimentorin/src/v1/sessions/sessions_service.rs @@ -0,0 +1,274 @@ +use super::{ + AvailabilitySlotDto, BookSessionRequestDto, BookSessionResponseDto, MentorAvailabilityDto, + SessionFeedbackRequestDto, SessionFeedbackResponseDto, SessionListItemDto, + SessionListResponseDto, SessionSchema, SessionsRepository, UpdateSessionStatusRequestDto, + UpdateSessionStatusResponseDto, +}; +use axum::{http::StatusCode, response::Response}; +use chrono::{Duration, Utc}; +use imphnen_entities::ResponseSuccessDto; +use imphnen_libs::AppState; +use imphnen_utils::{common_response, extract_id, get_iso_date, make_thing, success_response, validate_request}; + +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 mentor_thing = make_thing("mentors", &mentor_id); + let mentee_thing = make_thing("users", &user_id); + + let schema = SessionSchema::from_book_request(mentor_thing.clone(), mentee_thing.clone(), dto); + + let repo = SessionsRepository::new(state); + match repo.create_session(schema).await { + Ok(created) => { + let response = BookSessionResponseDto { + id: extract_id(&created.id), + mentor_id: extract_id(&created.mentor_id), + mentee_id: extract_id(&created.mentee_id), + topic: created.topic, + description: created.description, + scheduled_at: created.scheduled_at, + duration_minutes: created.duration_minutes, + session_type: created.session_type, + status: created.status, + created_at: created.created_at, + }; + success_response(ResponseSuccessDto { data: response }) + } + Err(e) => common_response(StatusCode::BAD_REQUEST, &e), + } + } + + // ============================================ + // Get Mentor's Sessions + // ============================================ + pub async fn get_mentor_sessions( + state: &AppState, + mentor_id: String, + _user_email: String, + status_filter: Option, + ) -> Response { + let mentor_thing = make_thing("mentors", &mentor_id); + + let repo = SessionsRepository::new(state); + match repo.query_mentor_sessions(&mentor_thing, status_filter).await { + Ok(sessions) => { + let session_items: Vec = 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: 0, // TODO: implement proper pagination + }; + success_response(ResponseSuccessDto { data: response }) + } + Err(e) => common_response(StatusCode::BAD_REQUEST, &e), + } + } + + // ============================================ + // Get User's Sessions (as mentee) + // ============================================ + pub async fn get_user_sessions( + state: &AppState, + user_id: String, + status_filter: Option, + ) -> Response { + let user_thing = make_thing("users", &user_id); + + let repo = SessionsRepository::new(state); + match repo.query_user_sessions(&user_thing, status_filter).await { + Ok(sessions) => { + let session_items: Vec = 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: 0, // TODO: implement proper pagination + }; + success_response(ResponseSuccessDto { data: response }) + } + Err(e) => common_response(StatusCode::BAD_REQUEST, &e), + } + } + + // ============================================ + // Get Mentor Availability + // ============================================ + pub async fn get_mentor_availability(state: &AppState, mentor_id: String) -> Response { + let mentor_thing = make_thing("mentors", &mentor_id); + + let repo = SessionsRepository::new(state); + match repo.query_booked_dates(&mentor_thing).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), + } + } + + // ============================================ + // 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 session_thing = make_thing("sessions", &session_id); + + let repo = SessionsRepository::new(state); + match repo.query_session_by_id(&session_thing).await { + Ok(Some(mut session)) => { + session.update_status(dto.clone()); + match repo.update_session(&session_thing, session).await { + Ok(updated) => { + let response = UpdateSessionStatusResponseDto { + id: extract_id(&updated.id), + status: updated.status, + meeting_link: updated.meeting_link, + updated_at: updated.updated_at, + }; + success_response(ResponseSuccessDto { data: response }) + } + Err(e) => common_response(StatusCode::BAD_REQUEST, &e), + } + } + Ok(None) => common_response(StatusCode::NOT_FOUND, "Session not found"), + Err(e) => common_response(StatusCode::BAD_REQUEST, &e), + } + } + + // ============================================ + // 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 session_thing = make_thing("sessions", &session_id); + + let repo = SessionsRepository::new(state); + match repo.query_session_by_id(&session_thing).await { + Ok(Some(mut session)) => { + // Authorization: Only mentee can submit feedback + let mentee_id = extract_id(&session.mentee_id); + 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.add_feedback(dto.clone()); + match repo.update_session(&session_thing, session).await { + Ok(updated) => { + let response = SessionFeedbackResponseDto { + id: extract_id(&updated.id), + feedback: dto.feedback, + rating: dto.rating, + submitted_at: updated.feedback_submitted_at.unwrap_or_else(get_iso_date), + }; + success_response(ResponseSuccessDto { data: response }) + } + Err(e) => common_response(StatusCode::BAD_REQUEST, &e), + } + } + Ok(None) => common_response(StatusCode::NOT_FOUND, "Session not found"), + Err(e) => common_response(StatusCode::BAD_REQUEST, &e), + } + } +} diff --git a/imphnen-gateway/src/docs.rs b/imphnen-gateway/src/docs.rs index e39efb2..92e12a2 100644 --- a/imphnen-gateway/src/docs.rs +++ b/imphnen-gateway/src/docs.rs @@ -14,6 +14,13 @@ use imphnen_dimentorin::v1::mentors::{ MentoringLogistics, MentoringRate, ProfessionalProfile, }, }; +use imphnen_dimentorin::v1::sessions::{ + sessions_controller, + BookSessionRequestDto, BookSessionResponseDto, MentorAvailabilityDto, + SessionFeedbackRequestDto, SessionFeedbackResponseDto, SessionListItemDto, + SessionListResponseDto, UpdateSessionStatusRequestDto, UpdateSessionStatusResponseDto, + AvailabilitySlotDto, +}; use imphnen_gacha::v1::gacha_claims::{gacha_claims_controller, GachaClaimItemDto, GachaClaimRequestDto}; use imphnen_gacha::v1::gacha_items::{gacha_items_controller, GachaItemDto}; use imphnen_gacha::v1::gacha_items::gacha_items_dto::GachaItemRequestDto; @@ -28,6 +35,13 @@ use imphnen_hackathon::v1::hackathon::{ HackathonTimelineDto, HackathonTimelineUpdateRequestDto, HackathonUpdateRequestDto, }, }; +use imphnen_hackathon::v1::registrations::{ + registration_controller, + RegistrationRequestDto, RegistrationResponseDto, RegistrationListResponseDto, + RegistrationListItemDto, UpdateRegistrationStatusRequestDto, UpdateRegistrationStatusResponseDto, + CheckInResponseDto, RegistrationStatsDto, UserHackathonsResponseDto, UserHackathonDto, + RegistrationStatus, ParticipantRole, +}; use imphnen_entities::{PermissionsItemDto, RolesDetailItemDto}; use imphnen_entities::{MessageResponseDto, MetaRequestDto, MetaResponseDto, ResponseListSuccessDto, ResponseSuccessDto}; use imphnen_iam::v1::auth::auth_dto::{AuthLoginRequestDto, AuthLoginResponsetDto, AuthNewPasswordRequestDto, AuthRefreshTokenRequestDto, AuthResendOtpRequestDto, AuthVerifyEmailRequestDto, TokenDto}; @@ -113,6 +127,12 @@ use utoipa::{ mentors_controller::put_update_mentor, mentors_controller::put_verify_mentor, mentors_controller::delete_mentor, + sessions_controller::post_book_session, + sessions_controller::get_mentor_sessions, + sessions_controller::get_mentor_availability, + sessions_controller::put_update_session_status, + sessions_controller::post_submit_feedback, + sessions_controller::get_my_sessions, hackathon_controller::create_hackathon, hackathon_controller::get_hackathon, hackathon_controller::list_hackathons, @@ -131,6 +151,12 @@ use utoipa::{ hackathon_controller::update_hackathon_submission, hackathon_controller::submit_hackathon_submission, hackathon_controller::delete_hackathon_submission, + registration_controller::post_register_hackathon, + registration_controller::get_hackathon_registrations, + registration_controller::get_my_hackathons, + registration_controller::put_update_registration_status, + registration_controller::post_check_in_participant, + registration_controller::get_registration_stats, ), components( schemas( @@ -191,6 +217,21 @@ use utoipa::{ ResponseListSuccessDto>, ResponseSuccessDto, ResponseSuccessDto, + BookSessionRequestDto, + BookSessionResponseDto, + SessionListResponseDto, + SessionListItemDto, + MentorAvailabilityDto, + AvailabilitySlotDto, + UpdateSessionStatusRequestDto, + UpdateSessionStatusResponseDto, + SessionFeedbackRequestDto, + SessionFeedbackResponseDto, + ResponseSuccessDto, + ResponseSuccessDto, + ResponseSuccessDto, + ResponseSuccessDto, + ResponseSuccessDto, TeamsCreateRequestDto, TeamsUpdateRequestDto, TeamInviteRequestDto, @@ -214,6 +255,24 @@ use utoipa::{ HackathonTimelineDto, HackathonTimelineUpdateRequestDto, HackathonUpdateRequestDto, + RegistrationRequestDto, + RegistrationResponseDto, + RegistrationListResponseDto, + RegistrationListItemDto, + UpdateRegistrationStatusRequestDto, + UpdateRegistrationStatusResponseDto, + CheckInResponseDto, + RegistrationStatsDto, + UserHackathonsResponseDto, + UserHackathonDto, + RegistrationStatus, + ParticipantRole, + ResponseSuccessDto, + ResponseSuccessDto, + ResponseSuccessDto, + ResponseSuccessDto, + ResponseSuccessDto, + ResponseSuccessDto, ResponseListSuccessDto>, ResponseSuccessDto, ResponseListSuccessDto>, @@ -247,11 +306,13 @@ use utoipa::{ (name = "Testimonials", description = "Testimonial Management Endpoints"), (name = "Mentors", description = "Mentor Management Endpoints"), (name = "Mentors - Admin", description = "Mentor Admin Management Endpoints (Admin Access Required)"), + (name = "sessions", description = "Mentoring Sessions Management API"), (name = "Gacha", description = "Gacha System Endpoints"), (name = "Hackathons", description = "Hackathon Management Endpoints"), (name = "Hackathon Events", description = "Hackathon Event Management Endpoints"), (name = "Hackathon Timeline", description = "Hackathon Timeline Management Endpoints"), (name = "Hackathon Submissions", description = "Hackathon Submission Management Endpoints"), + (name = "registrations", description = "Hackathon Registration Management API"), ) )] pub struct ApiDoc; diff --git a/imphnen-hackathon/src/v1/mod.rs b/imphnen-hackathon/src/v1/mod.rs index 1809059..26a6cf9 100644 --- a/imphnen-hackathon/src/v1/mod.rs +++ b/imphnen-hackathon/src/v1/mod.rs @@ -1,9 +1,11 @@ use axum::Router; pub mod hackathon; +pub mod registrations; // Export the router function from hackathon module pub use hackathon::hackathon_router; +pub use registrations::registrations_router; // Main route constructor pub fn hackathon_protected_routes() -> Router { @@ -14,6 +16,7 @@ pub fn hackathon_protected_routes() -> Router { .nest("/hackathons", hackathon_router()) .route("/hackathons/submissions/{id}/status", axum::routing::patch(update_submission_status)) .route("/hackathons/{hackathon_id}/admin/results", axum::routing::get(get_admin_hackathon_results)) + .merge(registrations_router()) } // Public routes for hackathons (only listing and retrieving) diff --git a/imphnen-hackathon/src/v1/registrations/mod.rs b/imphnen-hackathon/src/v1/registrations/mod.rs new file mode 100644 index 0000000..3f741a5 --- /dev/null +++ b/imphnen-hackathon/src/v1/registrations/mod.rs @@ -0,0 +1,11 @@ +pub mod registration_controller; +pub mod registration_dto; +pub mod registration_repository; +pub mod registration_schema; +pub mod registration_service; + +pub use registration_controller::*; +pub use registration_dto::*; +pub use registration_repository::*; +pub use registration_schema::*; +pub use registration_service::*; diff --git a/imphnen-hackathon/src/v1/registrations/registration_controller.rs b/imphnen-hackathon/src/v1/registrations/registration_controller.rs new file mode 100644 index 0000000..e154bb5 --- /dev/null +++ b/imphnen-hackathon/src/v1/registrations/registration_controller.rs @@ -0,0 +1,291 @@ +use axum::{ + extract::{Extension, Path}, + http::{HeaderMap, StatusCode}, + response::Response, + routing::{get, post, put}, + Json, Router, +}; +use imphnen_entities::ResponseSuccessDto; +use imphnen_libs::AppState; +use imphnen_utils::{common_response, extract_email, make_thing_from_enum}; +use imphnen_libs::ResourceEnum; + +use super::{ + CheckInResponseDto, RegistrationListResponseDto, RegistrationRequestDto, + RegistrationResponseDto, RegistrationStatsDto, RegistrationsService, + UpdateRegistrationStatusRequestDto, UpdateRegistrationStatusResponseDto, + UserHackathonsResponseDto, +}; + +// ============================================ +// POST /v1/hackathons/{id}/register +// ============================================ +#[utoipa::path( + post, + path = "/v1/hackathons/{id}/register", + tag = "registrations", + summary = "Register for a hackathon", + description = "Submit a registration for a hackathon. User must be authenticated.", + params( + ("id" = String, Path, description = "Hackathon ID") + ), + request_body = RegistrationRequestDto, + responses( + (status = 200, description = "Registration submitted successfully", body = ResponseSuccessDto), + (status = 400, description = "Invalid input or validation error"), + (status = 401, description = "Unauthorized - authentication required"), + (status = 409, description = "User already registered for this hackathon"), + (status = 500, description = "Internal server error"), + ), + security( + ("bearer_auth" = []) + ) +)] +pub async fn post_register_hackathon( + Extension(state): Extension, + headers: HeaderMap, + Path(id): Path, + Json(data): Json, +) -> Response { + // Authentication + let user_email = match extract_email(&headers) { + Some(email) => email, + None => return common_response(StatusCode::UNAUTHORIZED, "Unauthorized"), + }; + + // Parse hackathon ID + let hackathon_id = make_thing_from_enum(ResourceEnum::Hackathons, &id); + + let service = RegistrationsService::new(&state); + service.register_hackathon(&hackathon_id, &user_email, data).await +} + +// ============================================ +// GET /v1/hackathons/{id}/registrations +// ============================================ +#[utoipa::path( + get, + path = "/v1/hackathons/{id}/registrations", + tag = "registrations", + summary = "List hackathon registrations", + description = "Get all registrations for a hackathon. Requires admin/organizer permissions. Optional status filter.", + params( + ("id" = String, Path, description = "Hackathon ID"), + ("status" = Option, Query, description = "Filter by status: pending, approved, rejected, waitlisted, cancelled") + ), + responses( + (status = 200, description = "Registrations retrieved successfully", body = ResponseSuccessDto), + (status = 400, description = "Invalid input"), + (status = 401, description = "Unauthorized - authentication required"), + (status = 500, description = "Internal server error"), + ), + security( + ("bearer_auth" = []) + ) +)] +pub async fn get_hackathon_registrations( + Extension(state): Extension, + headers: HeaderMap, + Path(id): Path, + axum::extract::Query(params): axum::extract::Query>, +) -> Response { + // Authentication + match extract_email(&headers) { + Some(_) => {}, + None => return common_response(StatusCode::UNAUTHORIZED, "Unauthorized"), + }; + + // Parse hackathon ID + let hackathon_id = make_thing_from_enum(ResourceEnum::Hackathons, &id); + + let status_filter = params.get("status").cloned(); + + let service = RegistrationsService::new(&state); + service.get_hackathon_registrations(&hackathon_id, status_filter).await +} + +// ============================================ +// GET /v1/users/me/hackathons +// ============================================ +#[utoipa::path( + get, + path = "/v1/users/me/hackathons", + tag = "registrations", + summary = "Get my hackathon registrations", + description = "Get all hackathons the current user has registered for.", + responses( + (status = 200, description = "Hackathons retrieved successfully", body = ResponseSuccessDto), + (status = 401, description = "Unauthorized - authentication required"), + (status = 500, description = "Internal server error"), + ), + security( + ("bearer_auth" = []) + ) +)] +pub async fn get_my_hackathons( + Extension(state): Extension, + headers: HeaderMap, +) -> Response { + // Authentication + let user_email = match extract_email(&headers) { + Some(email) => email, + None => return common_response(StatusCode::UNAUTHORIZED, "Unauthorized"), + }; + + let service = RegistrationsService::new(&state); + service.get_my_hackathons(&user_email).await +} + +// ============================================ +// PUT /v1/hackathons/{hackathon_id}/registrations/{registration_id}/status +// ============================================ +#[utoipa::path( + put, + path = "/v1/hackathons/{hackathon_id}/registrations/{registration_id}/status", + tag = "registrations", + summary = "Update registration status", + description = "Approve, reject, or update the status of a registration. Requires admin/organizer permissions.", + params( + ("hackathon_id" = String, Path, description = "Hackathon ID"), + ("registration_id" = String, Path, description = "Registration ID") + ), + request_body = UpdateRegistrationStatusRequestDto, + responses( + (status = 200, description = "Status updated successfully", body = ResponseSuccessDto), + (status = 400, description = "Invalid input or validation error"), + (status = 401, description = "Unauthorized - authentication required"), + (status = 404, description = "Registration not found"), + (status = 500, description = "Internal server error"), + ), + security( + ("bearer_auth" = []) + ) +)] +pub async fn put_update_registration_status( + Extension(state): Extension, + headers: HeaderMap, + Path((_hackathon_id, registration_id)): Path<(String, String)>, + Json(data): Json, +) -> Response { + // Authentication + match extract_email(&headers) { + Some(_) => {}, + None => return common_response(StatusCode::UNAUTHORIZED, "Unauthorized"), + }; + + // Parse registration ID + let reg_id = make_thing_from_enum(ResourceEnum::HackathonRegistrations, ®istration_id); + + let service = RegistrationsService::new(&state); + service.update_registration_status(®_id, data).await +} + +// ============================================ +// POST /v1/hackathons/{hackathon_id}/registrations/{registration_id}/check-in +// ============================================ +#[utoipa::path( + post, + path = "/v1/hackathons/{hackathon_id}/registrations/{registration_id}/check-in", + tag = "registrations", + summary = "Check-in participant", + description = "Mark a participant as checked in for the hackathon. Requires admin/organizer permissions.", + params( + ("hackathon_id" = String, Path, description = "Hackathon ID"), + ("registration_id" = String, Path, description = "Registration ID") + ), + responses( + (status = 200, description = "Participant checked in successfully", body = ResponseSuccessDto), + (status = 400, description = "Invalid request - participant not approved or already checked in"), + (status = 401, description = "Unauthorized - authentication required"), + (status = 404, description = "Registration not found"), + (status = 500, description = "Internal server error"), + ), + security( + ("bearer_auth" = []) + ) +)] +pub async fn post_check_in_participant( + Extension(state): Extension, + headers: HeaderMap, + Path((_hackathon_id, registration_id)): Path<(String, String)>, +) -> Response { + // Authentication + match extract_email(&headers) { + Some(_) => {}, + None => return common_response(StatusCode::UNAUTHORIZED, "Unauthorized"), + }; + + // Parse registration ID + let reg_id = make_thing_from_enum(ResourceEnum::HackathonRegistrations, ®istration_id); + + let service = RegistrationsService::new(&state); + service.check_in_participant(®_id).await +} + +// ============================================ +// GET /v1/hackathons/{id}/registrations/stats +// ============================================ +#[utoipa::path( + get, + path = "/v1/hackathons/{id}/registrations/stats", + tag = "registrations", + summary = "Get registration statistics", + description = "Get comprehensive statistics about hackathon registrations. Requires admin/organizer permissions.", + params( + ("id" = String, Path, description = "Hackathon ID") + ), + responses( + (status = 200, description = "Statistics retrieved successfully", body = ResponseSuccessDto), + (status = 400, description = "Invalid input"), + (status = 401, description = "Unauthorized - authentication required"), + (status = 500, description = "Internal server error"), + ), + security( + ("bearer_auth" = []) + ) +)] +pub async fn get_registration_stats( + Extension(state): Extension, + headers: HeaderMap, + Path(id): Path, +) -> Response { + // Authentication + match extract_email(&headers) { + Some(_) => {}, + None => return common_response(StatusCode::UNAUTHORIZED, "Unauthorized"), + }; + + // Parse hackathon ID + let hackathon_id = make_thing_from_enum(ResourceEnum::Hackathons, &id); + + let service = RegistrationsService::new(&state); + service.get_registration_stats(&hackathon_id).await +} + +// ============================================ +// Router +// ============================================ +pub fn registrations_router() -> Router { + Router::new() + .route( + "/hackathons/:id/register", + post(post_register_hackathon), + ) + .route( + "/hackathons/:id/registrations", + get(get_hackathon_registrations), + ) + .route( + "/hackathons/:id/registrations/stats", + get(get_registration_stats), + ) + .route( + "/hackathons/:hackathon_id/registrations/:registration_id/status", + put(put_update_registration_status), + ) + .route( + "/hackathons/:hackathon_id/registrations/:registration_id/check-in", + post(post_check_in_participant), + ) + .route("/users/me/hackathons", get(get_my_hackathons)) +} diff --git a/imphnen-hackathon/src/v1/registrations/registration_dto.rs b/imphnen-hackathon/src/v1/registrations/registration_dto.rs new file mode 100644 index 0000000..84eabe9 --- /dev/null +++ b/imphnen-hackathon/src/v1/registrations/registration_dto.rs @@ -0,0 +1,216 @@ +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; +use validator::Validate; + +use super::{ParticipantRole, RegistrationStatus}; + +// ============================================ +// Registration Request/Response DTOs +// ============================================ + +#[derive(Debug, Serialize, Deserialize, ToSchema, Validate)] +pub struct RegistrationRequestDto { + pub team_id: Option, + pub role: Option, + + #[validate(length(max = 1000, message = "Motivation must not exceed 1000 characters"))] + pub motivation: Option, + + pub skills: Option>, + + #[validate(custom(function = "validate_experience_level"))] + pub experience_level: Option, + + #[validate(length(max = 100))] + pub github_username: Option, + + #[validate(url(message = "Invalid portfolio URL"))] + pub portfolio_url: Option, + + pub dietary_requirements: Option, + + #[validate(custom(function = "validate_tshirt_size"))] + pub tshirt_size: Option, + + #[validate(length(max = 100))] + pub emergency_contact_name: Option, + + #[validate(length(max = 20))] + pub emergency_contact_phone: Option, +} + +fn validate_experience_level(level: &str) -> Result<(), validator::ValidationError> { + let valid_levels = ["beginner", "intermediate", "advanced"]; + if valid_levels.contains(&level) { + Ok(()) + } else { + Err(validator::ValidationError::new("Invalid experience level")) + } +} + +fn validate_tshirt_size(size: &str) -> Result<(), validator::ValidationError> { + let valid_sizes = ["XS", "S", "M", "L", "XL", "XXL"]; + if valid_sizes.contains(&size) { + Ok(()) + } else { + Err(validator::ValidationError::new("Invalid t-shirt size")) + } +} + +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct RegistrationResponseDto { + pub id: String, + pub hackathon_id: String, + pub user_id: String, + pub team_id: Option, + pub status: RegistrationStatus, + pub role: ParticipantRole, + pub registration_date: String, + pub checked_in: bool, + pub message: String, +} + +// ============================================ +// List Registrations DTOs +// ============================================ + +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct RegistrationListItemDto { + pub id: String, + pub hackathon_id: String, + pub hackathon_name: Option, + pub user_id: String, + pub user_fullname: Option, + pub user_email: Option, + pub team_id: Option, + pub team_name: Option, + pub status: RegistrationStatus, + pub role: ParticipantRole, + pub registration_date: String, + pub checked_in: bool, + pub check_in_time: Option, + pub experience_level: Option, + pub skills: Option>, +} + +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct RegistrationListResponseDto { + pub registrations: Vec, + pub total: usize, + pub status_filter: Option, +} + +// Internal query DTO (fields already as String from DB) +#[derive(Debug, Serialize, Deserialize)] +pub struct RegistrationListQueryDto { + pub id: String, + pub hackathon_id: String, + pub hackathon_name: Option, + pub user_id: String, + pub user_fullname: Option, + pub user_email: Option, + pub team_id: Option, + pub team_name: Option, + pub status: RegistrationStatus, + pub role: ParticipantRole, + pub registration_date: String, + pub checked_in: bool, + pub check_in_time: Option, + pub experience_level: Option, + pub skills: Option>, +} + +// ============================================ +// Update Status DTOs +// ============================================ + +#[derive(Debug, Serialize, Deserialize, ToSchema, Validate)] +pub struct UpdateRegistrationStatusRequestDto { + pub status: RegistrationStatus, + + #[validate(length(max = 500, message = "Reason must not exceed 500 characters"))] + pub reason: Option, +} + +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct UpdateRegistrationStatusResponseDto { + pub id: String, + pub status: RegistrationStatus, + pub updated_at: String, + pub message: String, +} + +// ============================================ +// Check-in DTOs +// ============================================ + +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct CheckInResponseDto { + pub id: String, + pub user_fullname: Option, + pub checked_in: bool, + pub check_in_time: String, + pub message: String, +} + +// ============================================ +// Statistics DTOs +// ============================================ + +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct RegistrationStatsDto { + pub hackathon_id: String, + pub hackathon_name: Option, + pub total_registrations: usize, + pub pending: usize, + pub approved: usize, + pub rejected: usize, + pub waitlisted: usize, + pub cancelled: usize, + pub checked_in: usize, + pub team_registrations: usize, + pub individual_registrations: usize, +} + +// ============================================ +// User's Hackathons DTOs +// ============================================ + +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct UserHackathonDto { + pub registration_id: String, + pub hackathon_id: String, + pub hackathon_name: String, + pub hackathon_description: Option, + pub start_date: String, + pub end_date: String, + pub status: RegistrationStatus, + pub role: ParticipantRole, + pub registration_date: String, + pub checked_in: bool, + pub team_id: Option, + pub team_name: Option, +} + +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct UserHackathonsResponseDto { + pub hackathons: Vec, + pub total: usize, +} + +// Internal query DTO +#[derive(Debug, Serialize, Deserialize)] +pub struct UserHackathonQueryDto { + pub registration_id: String, + pub hackathon_id: String, + pub hackathon_name: String, + pub hackathon_description: Option, + pub start_date: String, + pub end_date: String, + pub status: RegistrationStatus, + pub role: ParticipantRole, + pub registration_date: String, + pub checked_in: bool, + pub team_id: Option, + pub team_name: Option, +} diff --git a/imphnen-hackathon/src/v1/registrations/registration_repository.rs b/imphnen-hackathon/src/v1/registrations/registration_repository.rs new file mode 100644 index 0000000..c568004 --- /dev/null +++ b/imphnen-hackathon/src/v1/registrations/registration_repository.rs @@ -0,0 +1,274 @@ +use super::{RegistrationListQueryDto, RegistrationSchema, RegistrationStatus, UserHackathonQueryDto}; +use imphnen_libs::AppState; +use imphnen_utils::get_id; +use serde::{Deserialize, Serialize}; +use surrealdb::sql::Thing; + +pub struct RegistrationsRepository<'a> { + pub state: &'a AppState, +} + +impl<'a> RegistrationsRepository<'a> { + pub fn new(state: &'a AppState) -> Self { + Self { state } + } + + // ============================================ + // Create Registration + // ============================================ + pub async fn create_registration(&self, registration: RegistrationSchema) -> Result { + let db = &self.state.surrealdb_ws; + let created: Option = db + .create("hackathon_registrations") + .content(registration) + .await + .map_err(|e| format!("Failed to create registration: {}", e))?; + + created.ok_or_else(|| "Registration creation returned None".to_string()) + } + + // ============================================ + // Get Registration by ID + // ============================================ + pub async fn query_registration_by_id(&self, id: &Thing) -> Result, String> { + let db = &self.state.surrealdb_ws; + let record_key = get_id(id).map_err(|e| e.to_string())?; + let registration: Option = db + .select(record_key) + .await + .map_err(|e| format!("Failed to fetch registration: {}", e))?; + + Ok(registration) + } + + // ============================================ + // Check if User Already Registered + // ============================================ + pub async fn check_existing_registration( + &self, + hackathon_id: &Thing, + user_id: &Thing, + ) -> Result, String> { + let db = &self.state.surrealdb_ws; + let query = r#" + SELECT * FROM hackathon_registrations + WHERE hackathon_id = $hackathon_id + AND user_id = $user_id + AND is_deleted = false + LIMIT 1 + "#; + + let mut result = db + .query(query) + .bind(("hackathon_id", hackathon_id.clone())) + .bind(("user_id", user_id.clone())) + .await + .map_err(|e| format!("Failed to check existing registration: {}", e))?; + + let registration: Option = result + .take(0) + .map_err(|e| format!("Failed to parse registration: {}", e))?; + + Ok(registration) + } + + // ============================================ + // List Registrations for Hackathon + // ============================================ + pub async fn query_hackathon_registrations( + &self, + hackathon_id: &Thing, + status_filter: Option, + ) -> Result, String> { + let db = &self.state.surrealdb_ws; + + let query = if status_filter.is_some() { + r#" + SELECT + id, + hackathon_id, + (SELECT name FROM $parent.hackathon_id)[0].name AS hackathon_name, + user_id, + (SELECT fullname FROM $parent.user_id)[0].fullname AS user_fullname, + (SELECT email FROM $parent.user_id)[0].email AS user_email, + team_id, + (SELECT name FROM $parent.team_id)[0].name AS team_name, + status, + role, + registration_date, + checked_in, + check_in_time, + experience_level, + skills + FROM hackathon_registrations + WHERE hackathon_id = $hackathon_id + AND status = $status + AND is_deleted = false + ORDER BY registration_date DESC + "# + } else { + r#" + SELECT + id, + hackathon_id, + (SELECT name FROM $parent.hackathon_id)[0].name AS hackathon_name, + user_id, + (SELECT fullname FROM $parent.user_id)[0].fullname AS user_fullname, + (SELECT email FROM $parent.user_id)[0].email AS user_email, + team_id, + (SELECT name FROM $parent.team_id)[0].name AS team_name, + status, + role, + registration_date, + checked_in, + check_in_time, + experience_level, + skills + FROM hackathon_registrations + WHERE hackathon_id = $hackathon_id + AND is_deleted = false + ORDER BY registration_date DESC + "# + }; + + let hackathon_id_clone = hackathon_id.clone(); + let mut result = if let Some(status_val) = status_filter { + db.query(query) + .bind(("hackathon_id", hackathon_id_clone)) + .bind(("status", status_val)) + .await + } else { + db.query(query) + .bind(("hackathon_id", hackathon_id_clone)) + .await + } + .map_err(|e| format!("Failed to query hackathon registrations: {}", e))?; + + let registrations: Vec = result + .take(0) + .map_err(|e| format!("Failed to parse registrations: {}", e))?; + + Ok(registrations) + } + + // ============================================ + // Get User's Hackathon Registrations + // ============================================ + pub async fn query_user_hackathons(&self, user_id: &Thing) -> Result, String> { + let db = &self.state.surrealdb_ws; + let query = r#" + SELECT + id AS registration_id, + hackathon_id, + (SELECT name FROM $parent.hackathon_id)[0].name AS hackathon_name, + (SELECT description FROM $parent.hackathon_id)[0].description AS hackathon_description, + (SELECT start_date FROM $parent.hackathon_id)[0].start_date AS start_date, + (SELECT end_date FROM $parent.hackathon_id)[0].end_date AS end_date, + status, + role, + registration_date, + checked_in, + team_id, + (SELECT name FROM $parent.team_id)[0].name AS team_name + FROM hackathon_registrations + WHERE user_id = $user_id + AND is_deleted = false + ORDER BY registration_date DESC + "#; + + let user_id_clone = user_id.clone(); + let mut result = db + .query(query) + .bind(("user_id", user_id_clone)) + .await + .map_err(|e| format!("Failed to query user hackathons: {}", e))?; + + let hackathons: Vec = result + .take(0) + .map_err(|e| format!("Failed to parse user hackathons: {}", e))?; + + Ok(hackathons) + } + + // ============================================ + // Get Registration Statistics + // ============================================ + pub async fn query_registration_stats(&self, hackathon_id: &Thing) -> Result { + let db = &self.state.surrealdb_ws; + let query = r#" + LET $hackathon = (SELECT name FROM $hackathon_id)[0].name; + LET $regs = (SELECT * FROM hackathon_registrations WHERE hackathon_id = $hackathon_id AND is_deleted = false); + RETURN { + hackathon_id: $hackathon_id, + hackathon_name: $hackathon, + total_registrations: count($regs), + pending: count($regs[WHERE status = 'pending']), + approved: count($regs[WHERE status = 'approved']), + rejected: count($regs[WHERE status = 'rejected']), + waitlisted: count($regs[WHERE status = 'waitlisted']), + cancelled: count($regs[WHERE status = 'cancelled']), + checked_in: count($regs[WHERE checked_in = true]), + team_registrations: count($regs[WHERE team_id != NONE]), + individual_registrations: count($regs[WHERE team_id = NONE]) + }; + "#; + + let hackathon_id_clone = hackathon_id.clone(); + let mut result = db + .query(query) + .bind(("hackathon_id", hackathon_id_clone)) + .await + .map_err(|e| format!("Failed to query registration stats: {}", e))?; + + let stats: Option = result + .take(0) + .map_err(|e| format!("Failed to parse registration stats: {}", e))?; + + stats.ok_or_else(|| "Stats query returned None".to_string()) + } + + // ============================================ + // Update Registration + // ============================================ + pub async fn update_registration(&self, id: &Thing, registration: RegistrationSchema) -> Result { + let db = &self.state.surrealdb_ws; + let record_key = get_id(id).map_err(|e| e.to_string())?; + let updated: Option = db + .update(record_key) + .content(registration) + .await + .map_err(|e| format!("Failed to update registration: {}", e))?; + + updated.ok_or_else(|| "Registration update returned None".to_string()) + } + + // ============================================ + // Delete Registration (soft delete) + // ============================================ + pub async fn delete_registration(&self, id: &Thing) -> Result<(), String> { + let db = &self.state.surrealdb_ws; + let record_key = get_id(id).map_err(|e| e.to_string())?; + let _: Option = db + .delete(record_key) + .await + .map_err(|e| format!("Failed to delete registration: {}", e))?; + + Ok(()) + } +} + +// Helper DTO for stats query +#[derive(Debug, Serialize, Deserialize)] +pub struct RegistrationStatsQueryDto { + pub hackathon_id: String, + pub hackathon_name: Option, + pub total_registrations: usize, + pub pending: usize, + pub approved: usize, + pub rejected: usize, + pub waitlisted: usize, + pub cancelled: usize, + pub checked_in: usize, + pub team_registrations: usize, + pub individual_registrations: usize, +} diff --git a/imphnen-hackathon/src/v1/registrations/registration_schema.rs b/imphnen-hackathon/src/v1/registrations/registration_schema.rs new file mode 100644 index 0000000..f849a2c --- /dev/null +++ b/imphnen-hackathon/src/v1/registrations/registration_schema.rs @@ -0,0 +1,141 @@ +use serde::{Deserialize, Serialize}; +use surrealdb::sql::Thing; +use utoipa::ToSchema; + +use imphnen_libs::ResourceEnum; +use imphnen_utils::{get_iso_date, make_thing, make_thing_from_enum}; + +use super::RegistrationRequestDto; + +/// Registration status enum +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, PartialEq)] +#[serde(rename_all = "lowercase")] +pub enum RegistrationStatus { + Pending, + Approved, + Rejected, + Waitlisted, + Cancelled, +} + +/// Participant role in hackathon +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, PartialEq)] +#[serde(rename_all = "lowercase")] +pub enum ParticipantRole { + Individual, + TeamLeader, + TeamMember, +} + +/// Hackathon registration schema +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct RegistrationSchema { + pub id: Thing, + pub hackathon_id: Thing, + pub user_id: Thing, + pub team_id: Option, + pub status: RegistrationStatus, + pub role: ParticipantRole, + pub registration_date: String, + pub approved_at: Option, + pub rejected_at: Option, + pub rejection_reason: Option, + pub checked_in: bool, + pub check_in_time: Option, + pub notes: Option, + pub skills: Option>, + pub experience_level: Option, // beginner, intermediate, advanced + pub github_username: Option, + pub portfolio_url: Option, + pub motivation: Option, + pub dietary_requirements: Option, + pub tshirt_size: Option, // XS, S, M, L, XL, XXL + pub emergency_contact_name: Option, + pub emergency_contact_phone: Option, + pub is_deleted: bool, + pub created_at: String, + pub updated_at: String, +} + +impl RegistrationSchema { + /// Create a new registration from request DTO + pub fn from_request( + hackathon_id: &Thing, + user_id: &Thing, + data: RegistrationRequestDto, + ) -> Result { + let now = get_iso_date(); + + // Convert team_id from String to Thing if provided + let team_id_thing = data.team_id + .as_ref() + .map(|id| make_thing_from_enum(ResourceEnum::Teams, id)); + + Ok(Self { + id: make_thing(ResourceEnum::HackathonRegistrations.as_str(), &uuid::Uuid::new_v4().to_string()), + hackathon_id: hackathon_id.clone(), + user_id: user_id.clone(), + team_id: team_id_thing, + status: RegistrationStatus::Pending, + role: data.role.unwrap_or(ParticipantRole::Individual), + registration_date: now.clone(), + approved_at: None, + rejected_at: None, + rejection_reason: None, + checked_in: false, + check_in_time: None, + notes: None, + skills: data.skills, + experience_level: data.experience_level, + github_username: data.github_username, + portfolio_url: data.portfolio_url, + motivation: data.motivation, + dietary_requirements: data.dietary_requirements, + tshirt_size: data.tshirt_size, + emergency_contact_name: data.emergency_contact_name, + emergency_contact_phone: data.emergency_contact_phone, + is_deleted: false, + created_at: now.clone(), + updated_at: now, + }) + } + + /// Update registration status + pub fn update_status(&mut self, status: RegistrationStatus, reason: Option) { + let now = get_iso_date(); + self.status = status.clone(); + self.updated_at = now.clone(); + + match status { + RegistrationStatus::Approved => { + self.approved_at = Some(now); + self.rejected_at = None; + self.rejection_reason = None; + } + RegistrationStatus::Rejected => { + self.rejected_at = Some(now); + self.rejection_reason = reason; + self.approved_at = None; + } + _ => {} + } + } + + /// Check-in participant + pub fn check_in(&mut self) -> Result<(), String> { + if self.status != RegistrationStatus::Approved { + return Err("Only approved registrations can be checked in".to_string()); + } + + if self.checked_in { + return Err("Already checked in".to_string()); + } + + let now = get_iso_date(); + self.checked_in = true; + self.check_in_time = Some(now.clone()); + self.updated_at = now; + + Ok(()) + } +} diff --git a/imphnen-hackathon/src/v1/registrations/registration_service.rs b/imphnen-hackathon/src/v1/registrations/registration_service.rs new file mode 100644 index 0000000..a15c763 --- /dev/null +++ b/imphnen-hackathon/src/v1/registrations/registration_service.rs @@ -0,0 +1,293 @@ +use axum::response::Response; +use axum::http::StatusCode; +use imphnen_entities::ResponseSuccessDto; +use imphnen_libs::AppState; +use imphnen_utils::{ + common_response, extract_id, make_thing_from_enum, success_response, validate_request, +}; +use surrealdb::sql::Thing; + +use super::{ + CheckInResponseDto, RegistrationListItemDto, RegistrationListResponseDto, + RegistrationRequestDto, RegistrationResponseDto, RegistrationSchema, RegistrationStatsDto, + RegistrationStatus, RegistrationsRepository, UpdateRegistrationStatusRequestDto, + UpdateRegistrationStatusResponseDto, UserHackathonDto, UserHackathonsResponseDto, +}; +use imphnen_libs::ResourceEnum; + +pub struct RegistrationsService<'a> { + state: &'a AppState, +} + +impl<'a> RegistrationsService<'a> { + pub fn new(state: &'a AppState) -> Self { + Self { state } + } + + // ============================================ + // Register for Hackathon + // ============================================ + pub async fn register_hackathon( + &self, + hackathon_id: &Thing, + user_email: &str, + data: RegistrationRequestDto, + ) -> Response { + // Validate request + if let Err((status, message)) = validate_request(&data) { + return common_response(status, &message); + } + + let repository = RegistrationsRepository::new(self.state); + + // Get user ID from email + let user_id = make_thing_from_enum(ResourceEnum::Users, user_email); + + // Check if hackathon exists + // TODO: Add hackathon existence check via hackathon repository + + // Check if user already registered + match repository + .check_existing_registration(hackathon_id, &user_id) + .await + { + Ok(Some(_)) => { + return common_response(StatusCode::CONFLICT, "You have already registered for this hackathon") + } + Ok(None) => {} + Err(e) => return common_response(StatusCode::INTERNAL_SERVER_ERROR, &e), + } + + // Create registration + let registration = match RegistrationSchema::from_request(hackathon_id, &user_id, data) { + Ok(reg) => reg, + Err(e) => return common_response(StatusCode::BAD_REQUEST, &e), + }; + + match repository.create_registration(registration).await { + Ok(created) => { + let response = RegistrationResponseDto { + id: extract_id(&created.id), + hackathon_id: extract_id(&created.hackathon_id), + user_id: extract_id(&created.user_id), + team_id: created.team_id.as_ref().map(|t| extract_id(t)), + status: created.status, + role: created.role, + registration_date: created.registration_date, + checked_in: created.checked_in, + message: "Registration submitted successfully. You will be notified once approved." + .to_string(), + }; + + success_response(ResponseSuccessDto { data: response }) + } + Err(e) => common_response(StatusCode::INTERNAL_SERVER_ERROR, &e), + } + } + + // ============================================ + // List Registrations for Hackathon + // ============================================ + pub async fn get_hackathon_registrations( + &self, + hackathon_id: &Thing, + status_filter: Option, + ) -> Response { + let repository = RegistrationsRepository::new(self.state); + + // Parse status filter if provided + let status_enum = if let Some(status_str) = &status_filter { + match status_str.to_lowercase().as_str() { + "pending" => Some(RegistrationStatus::Pending), + "approved" => Some(RegistrationStatus::Approved), + "rejected" => Some(RegistrationStatus::Rejected), + "waitlisted" => Some(RegistrationStatus::Waitlisted), + "cancelled" => Some(RegistrationStatus::Cancelled), + _ => return common_response(StatusCode::BAD_REQUEST, "Invalid status filter"), + } + } else { + None + }; + + match repository + .query_hackathon_registrations(hackathon_id, status_enum) + .await + { + Ok(results) => { + let registrations = results + .into_iter() + .map(|r| RegistrationListItemDto { + id: r.id, + hackathon_id: r.hackathon_id, + hackathon_name: r.hackathon_name, + user_id: r.user_id, + user_fullname: r.user_fullname, + user_email: r.user_email, + team_id: r.team_id, + team_name: r.team_name, + status: r.status, + role: r.role, + registration_date: r.registration_date, + checked_in: r.checked_in, + check_in_time: r.check_in_time, + experience_level: r.experience_level, + skills: r.skills, + }) + .collect::>(); + + let total = registrations.len(); + let response = RegistrationListResponseDto { + registrations, + total, + status_filter, + }; + + success_response(ResponseSuccessDto { data: response }) + } + Err(e) => common_response(StatusCode::INTERNAL_SERVER_ERROR, &e), + } + } + + // ============================================ + // Get Current User's Hackathon Registrations + // ============================================ + pub async fn get_my_hackathons(&self, user_email: &str) -> Response { + let repository = RegistrationsRepository::new(self.state); + + // Get user ID from email + let user_id = make_thing_from_enum(ResourceEnum::Users, user_email); + + match repository.query_user_hackathons(&user_id).await { + Ok(results) => { + let hackathons = results + .into_iter() + .map(|h| UserHackathonDto { + registration_id: h.registration_id, + hackathon_id: h.hackathon_id, + hackathon_name: h.hackathon_name, + hackathon_description: h.hackathon_description, + start_date: h.start_date, + end_date: h.end_date, + status: h.status, + role: h.role, + registration_date: h.registration_date, + checked_in: h.checked_in, + team_id: h.team_id, + team_name: h.team_name, + }) + .collect::>(); + + let total = hackathons.len(); + let response = UserHackathonsResponseDto { hackathons, total }; + + success_response(ResponseSuccessDto { data: response }) + } + Err(e) => common_response(StatusCode::INTERNAL_SERVER_ERROR, &e), + } + } + + // ============================================ + // Update Registration Status + // ============================================ + pub async fn update_registration_status( + &self, + registration_id: &Thing, + data: UpdateRegistrationStatusRequestDto, + ) -> Response { + // Validate request + if let Err((status, message)) = validate_request(&data) { + return common_response(status, &message); + } + + let repository = RegistrationsRepository::new(self.state); + + // Get existing registration + let mut registration = match repository.query_registration_by_id(registration_id).await { + Ok(Some(reg)) => reg, + Ok(None) => return common_response(StatusCode::NOT_FOUND, "Registration not found"), + Err(e) => return common_response(StatusCode::INTERNAL_SERVER_ERROR, &e), + }; + + // Update status + registration.update_status(data.status.clone(), data.reason); + + // Save updated registration + match repository.update_registration(registration_id, registration.clone()).await { + Ok(updated) => { + let status_clone = updated.status.clone(); + let response = UpdateRegistrationStatusResponseDto { + id: extract_id(&updated.id), + status: updated.status, + updated_at: updated.updated_at, + message: format!("Registration status updated to {:?}", status_clone), + }; + + success_response(ResponseSuccessDto { data: response }) + } + Err(e) => common_response(StatusCode::INTERNAL_SERVER_ERROR, &e), + } + } + + // ============================================ + // Check-in Participant + // ============================================ + pub async fn check_in_participant(&self, registration_id: &Thing) -> Response { + let repository = RegistrationsRepository::new(self.state); + + // Get existing registration + let mut registration = match repository.query_registration_by_id(registration_id).await { + Ok(Some(reg)) => reg, + Ok(None) => return common_response(StatusCode::NOT_FOUND, "Registration not found"), + Err(e) => return common_response(StatusCode::INTERNAL_SERVER_ERROR, &e), + }; + + // Perform check-in + if let Err(e) = registration.check_in() { + return common_response(StatusCode::BAD_REQUEST, &e); + } + + // Save updated registration + match repository.update_registration(registration_id, registration.clone()).await { + Ok(updated) => { + let response = CheckInResponseDto { + id: extract_id(&updated.id), + user_fullname: None, // Would need to query user info + checked_in: updated.checked_in, + check_in_time: updated.check_in_time.unwrap_or_default(), + message: "Participant checked in successfully".to_string(), + }; + + success_response(ResponseSuccessDto { data: response }) + } + Err(e) => common_response(StatusCode::INTERNAL_SERVER_ERROR, &e), + } + } + + // ============================================ + // Get Registration Statistics + // ============================================ + pub async fn get_registration_stats(&self, hackathon_id: &Thing) -> Response { + let repository = RegistrationsRepository::new(self.state); + + match repository.query_registration_stats(hackathon_id).await { + Ok(stats) => { + let response = RegistrationStatsDto { + hackathon_id: stats.hackathon_id, + hackathon_name: stats.hackathon_name, + total_registrations: stats.total_registrations, + pending: stats.pending, + approved: stats.approved, + rejected: stats.rejected, + waitlisted: stats.waitlisted, + cancelled: stats.cancelled, + checked_in: stats.checked_in, + team_registrations: stats.team_registrations, + individual_registrations: stats.individual_registrations, + }; + + success_response(ResponseSuccessDto { data: response }) + } + Err(e) => common_response(StatusCode::INTERNAL_SERVER_ERROR, &e), + } + } +} diff --git a/imphnen-iam/src/lib.rs b/imphnen-iam/src/lib.rs index 1873fd8..70f9bc1 100644 --- a/imphnen-iam/src/lib.rs +++ b/imphnen-iam/src/lib.rs @@ -89,5 +89,7 @@ pub use v1::teams::{ PublicTeamsDetailItemDto, TeamsDetailQueryDto, TeamsListQueryDto, TeamMembersSchema, TeamInvitationsSchema, TeamMembersQueryDto, TeamInvitationsQueryDto, MemberTeamsDetailItemDto, + AddTeamMemberRequestDto, UpdateMemberRoleRequestDto, + TeamInvitationListDto, MyInvitationDto }; pub use v1::users::{UsersRepository, UsersSchema, UsersDetailItemDto, UsersCreateRequestDto}; diff --git a/imphnen-iam/src/v1/teams/mod.rs b/imphnen-iam/src/v1/teams/mod.rs index 63f6f47..ba2fe52 100644 --- a/imphnen-iam/src/v1/teams/mod.rs +++ b/imphnen-iam/src/v1/teams/mod.rs @@ -43,7 +43,11 @@ pub use teams_dto::{ TeamsListQueryDto, TeamMembersQueryDto, TeamInvitationsQueryDto, - MemberTeamsDetailItemDto + MemberTeamsDetailItemDto, + AddTeamMemberRequestDto, + UpdateMemberRoleRequestDto, + TeamInvitationListDto, + MyInvitationDto }; pub use teams_repository::TeamsRepository; diff --git a/imphnen-iam/src/v1/teams/teams_controller.rs b/imphnen-iam/src/v1/teams/teams_controller.rs index fcec51a..d27ac61 100644 --- a/imphnen-iam/src/v1/teams/teams_controller.rs +++ b/imphnen-iam/src/v1/teams/teams_controller.rs @@ -4,7 +4,9 @@ use crate::{ TeamsCreateRequestDto, TeamsDetailItemDto, TeamsListItemDto, permissions_guard, TeamsUpdateRequestDto, TeamInviteRequestDto, TeamAcceptInvitationRequestDto, TeamMemberDto, TeamsSearchQueryDto, PublicTeamsListItemDto, PublicTeamsDetailItemDto, - AdminTeamsListItemDto, AdminTeamsDetailItemDto, PermissionsEnum + AdminTeamsListItemDto, AdminTeamsDetailItemDto, PermissionsEnum, + AddTeamMemberRequestDto, UpdateMemberRoleRequestDto, + TeamInvitationListDto, MyInvitationDto }; use super::super::teams::{TeamsRepository, TeamMembersSchema}; use axum::response::Response; @@ -153,12 +155,24 @@ pub async fn put_update_team( } } -#[derive(serde::Deserialize)] -pub struct AddTeamMemberRequestDto { - pub user_id: String, - pub role: Option, -} - +#[utoipa::path( + post, + security( + ("Bearer" = []) + ), + path = "/v1/teams/{id}/members", + params( + ("id" = String, Path, description = "Team ID") + ), + request_body = AddTeamMemberRequestDto, + responses( + (status = 200, description = "[AUTH] Add member to team successfully", body = ResponseSuccessDto), + (status = 401, description = "[AUTH] Unauthorized"), + (status = 403, description = "[AUTH] Only team leader or members can add"), + (status = 404, description = "[AUTH] Team not found") + ), + tag = "Teams" +)] pub async fn post_add_team_member( headers: HeaderMap, Extension(state): Extension, @@ -201,6 +215,24 @@ pub async fn post_add_team_member( } } +#[utoipa::path( + delete, + security( + ("Bearer" = []) + ), + path = "/v1/teams/{id}/members/{user_id}", + params( + ("id" = String, Path, description = "Team ID"), + ("user_id" = String, Path, description = "User ID to remove") + ), + responses( + (status = 200, description = "[AUTH] Member removed successfully", body = ResponseSuccessDto), + (status = 401, description = "[AUTH] Unauthorized"), + (status = 403, description = "[AUTH] Only team leader can remove members"), + (status = 404, description = "[AUTH] Team not found") + ), + tag = "Teams" +)] pub async fn delete_remove_team_member( headers: HeaderMap, Extension(state): Extension, @@ -236,6 +268,63 @@ pub async fn delete_remove_team_member( } } +#[utoipa::path( + put, + security( + ("Bearer" = []) + ), + path = "/v1/teams/{id}/members/{user_id}/role", + params( + ("id" = String, Path, description = "Team ID"), + ("user_id" = String, Path, description = "User ID") + ), + request_body = UpdateMemberRoleRequestDto, + responses( + (status = 200, description = "[AUTH] Member role updated successfully", body = ResponseSuccessDto), + (status = 401, description = "[AUTH] Unauthorized"), + (status = 403, description = "[AUTH] Only team leader can update roles"), + (status = 404, description = "[AUTH] Team or member not found") + ), + tag = "Teams" +)] +pub async fn put_update_member_role( + headers: HeaderMap, + Extension(state): Extension, + Path((team_id, user_id)): Path<(String, String)>, + Json(payload): Json, +) -> impl IntoResponse { + let state_clone = state.clone(); + let is_admin = crate::permissions_guard(headers.clone(), axum::Extension(state_clone.clone()), vec![PermissionsEnum::ManageAllTeams]).await.is_ok(); + + let auth = permissions_guard(headers, axum::Extension(state.clone()), vec![]).await; + let (claims, state) = match auth { + Ok((c, s)) => (c, s), + Err(response) => return response, + }; + + let repo = TeamsRepository::new(&state); + let thing_id = imphnen_utils::make_thing_from_enum(imphnen_libs::ResourceEnum::Teams, &team_id); + let team = match repo.query_team_by_id(&thing_id).await { + Ok(t) => t, + Err(_) => return crate::common_response(axum::http::StatusCode::NOT_FOUND, "Team not found"), + }; + + if !is_admin { + // Only leader can update roles + if team.leader_id.id.to_raw() != claims.user_id { + return crate::common_response(axum::http::StatusCode::FORBIDDEN, "Only team leader can update member roles"); + } + } + + let user_thing = imphnen_utils::make_thing_from_enum(imphnen_libs::ResourceEnum::Users, &user_id); + match repo.query_update_team_member_role(&thing_id, &user_thing, &payload.role).await { + Ok(_) => crate::success_response(crate::ResponseSuccessDto { + data: format!("Member role updated to: {}", payload.role) + }), + Err(e) => crate::common_response(axum::http::StatusCode::BAD_REQUEST, &e.to_string()), + } +} + #[utoipa::path( delete, security( @@ -410,6 +499,75 @@ pub async fn get_my_team( authenticated(headers, Extension(state), |claims, state| TeamsService::get_my_team(&state, claims)).await } +#[utoipa::path( + get, + security( + ("Bearer" = []) + ), + path = "/v1/teams/{id}/invitations", + params( + ("id" = String, Path, description = "Team ID") + ), + responses( + (status = 200, description = "[AUTH] Get team invitations", body = ResponseSuccessDto>), + (status = 401, description = "[AUTH] Unauthorized"), + (status = 403, description = "[AUTH] Only team leader can view invitations"), + (status = 404, description = "[AUTH] Team not found") + ), + tag = "Teams" +)] +pub async fn get_team_invitations( + headers: HeaderMap, + Extension(state): Extension, + Path(team_id): Path, +) -> impl IntoResponse { + authenticated(headers, Extension(state), move |claims, state| TeamsService::get_team_invitations(&state, claims, team_id)).await +} + +#[utoipa::path( + delete, + security( + ("Bearer" = []) + ), + path = "/v1/teams/invitations/{token}", + params( + ("token" = String, Path, description = "Invitation token") + ), + responses( + (status = 200, description = "[AUTH] Invitation cancelled", body = ResponseSuccessDto), + (status = 401, description = "[AUTH] Unauthorized"), + (status = 403, description = "[AUTH] Only team leader can cancel invitations"), + (status = 404, description = "[AUTH] Invitation not found") + ), + tag = "Teams" +)] +pub async fn delete_invitation( + headers: HeaderMap, + Extension(state): Extension, + Path(token): Path, +) -> impl IntoResponse { + authenticated(headers, Extension(state), move |claims, state| TeamsService::cancel_invitation(&state, claims, token)).await +} + +#[utoipa::path( + get, + security( + ("Bearer" = []) + ), + path = "/v1/teams/me/invitations", + responses( + (status = 200, description = "[AUTH] Get my pending invitations", body = ResponseSuccessDto>), + (status = 401, description = "[AUTH] Unauthorized") + ), + tag = "Teams" +)] +pub async fn get_my_invitations( + headers: HeaderMap, + Extension(state): Extension, +) -> impl IntoResponse { + authenticated(headers, Extension(state), |claims, state| TeamsService::get_my_invitations(&state, claims)).await +} + #[utoipa::path( get, security( @@ -504,7 +662,11 @@ pub fn teams_router() -> Router { .route("/{id}/members", axum::routing::get(get_team_members)) .route("/{id}/members", axum::routing::post(post_add_team_member)) .route("/{id}/members/{user_id}", axum::routing::delete(delete_remove_team_member)) + .route("/{id}/members/{user_id}/role", axum::routing::put(put_update_member_role)) + .route("/{id}/invitations", axum::routing::get(get_team_invitations)) + .route("/invitations/{token}", axum::routing::delete(delete_invitation)) .route("/{id}/leave", axum::routing::post(post_leave_team)) .route("/leave-me", axum::routing::post(post_leave_current_team)) .route("/me", axum::routing::get(get_my_team)) + .route("/me/invitations", axum::routing::get(get_my_invitations)) } diff --git a/imphnen-iam/src/v1/teams/teams_dto.rs b/imphnen-iam/src/v1/teams/teams_dto.rs index 5f23f80..d279259 100644 --- a/imphnen-iam/src/v1/teams/teams_dto.rs +++ b/imphnen-iam/src/v1/teams/teams_dto.rs @@ -479,4 +479,51 @@ impl TeamsDetailQueryDto { } } +// Additional DTOs for Team Member Management + +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)] +pub struct AddTeamMemberRequestDto { + #[validate(length(min = 1, message = "User ID is required"))] + pub user_id: String, + + #[validate(length(max = 50, message = "Role cannot exceed 50 characters"))] + #[serde(skip_serializing_if = "Option::is_none")] + pub role: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)] +pub struct UpdateMemberRoleRequestDto { + #[validate(length(min = 1, max = 50, message = "Role must be between 1 and 50 characters"))] + pub role: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] +pub struct TeamInvitationListDto { + pub id: String, + pub team_id: String, + pub team_name: String, + pub email: String, + pub inviter_id: String, + pub inviter_name: String, + pub status: String, + pub invite_code: String, + pub expires_at: String, + pub invited_at: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] +pub struct MyInvitationDto { + pub id: String, + pub team_id: String, + pub team_name: String, + pub team_description: Option, + pub team_avatar: Option, + pub inviter_name: String, + pub invite_code: String, + pub status: String, + pub expires_at: String, + pub invited_at: String, +} + // (previous custom validator removed; using validator::email(each = true) attribute) + diff --git a/imphnen-iam/src/v1/teams/teams_repository.rs b/imphnen-iam/src/v1/teams/teams_repository.rs index ffad593..4ee3cd5 100644 --- a/imphnen-iam/src/v1/teams/teams_repository.rs +++ b/imphnen-iam/src/v1/teams/teams_repository.rs @@ -431,6 +431,102 @@ impl<'a> TeamsRepository<'a> { println!("Query 'query_remove_team_member' took: {elapsed:.2?}"); } - Ok("Success remove team member".into()) + Ok("Success remove team member".into()) } -} \ No newline at end of file + + pub async fn query_update_team_member_role(&self, team_id: &Thing, user_id: &Thing, role: &str) -> Result { + let now = Instant::now(); + let db = &self.state.surrealdb_ws; + + let conditions = build_multi_thing_condition(&[("team_id", team_id), ("user_id", user_id)]); + let sql = format!( + "UPDATE {} SET role = '{}' WHERE {} AND is_active = true", + ResourceEnum::TeamMembers, + role, + conditions + ); + + execute_safe_update_query(db, sql).await?; + + let elapsed = now.elapsed(); + + if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string()) + == "development" + { + println!("Query 'query_update_team_member_role' took: {elapsed:.2?}"); + } + + Ok("Success update team member role".into()) + } + + pub async fn query_team_invitations(&self, team_id: &Thing) -> Result> { + let now = Instant::now(); + let db = &self.state.surrealdb_ws; + let team_id_clone = team_id.clone(); + + let sql = format!( + "SELECT * FROM {} WHERE team_id = $team_id AND status = 'pending' ORDER BY invited_at DESC", + ResourceEnum::TeamInvitations + ); + + let mut result = db.query(&sql).bind(("team_id", team_id_clone)).await?; + let invitations: Vec = result.take(0)?; + + let elapsed = now.elapsed(); + + if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string()) + == "development" + { + println!("Query 'query_team_invitations' took: {elapsed:.2?}"); + } + + Ok(invitations) + } + + pub async fn query_user_invitations(&self, email: &str) -> Result> { + let now = Instant::now(); + let db = &self.state.surrealdb_ws; + + let sql = format!( + "SELECT * FROM {} WHERE email = '{}' AND status = 'pending' ORDER BY invited_at DESC", + ResourceEnum::TeamInvitations, + email + ); + + let mut result = db.query(&sql).await?; + let invitations: Vec = result.take(0)?; + + let elapsed = now.elapsed(); + + if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string()) + == "development" + { + println!("Query 'query_user_invitations' took: {elapsed:.2?}"); + } + + Ok(invitations) + } + + pub async fn query_delete_invitation(&self, token: &str) -> Result { + let now = Instant::now(); + let db = &self.state.surrealdb_ws; + + let sql = format!( + "UPDATE {} SET status = 'cancelled' WHERE invite_code = '{}'", + ResourceEnum::TeamInvitations, + token + ); + + execute_safe_update_query(db, sql).await?; + + let elapsed = now.elapsed(); + + if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string()) + == "development" + { + println!("Query 'query_delete_invitation' took: {elapsed:.2?}"); + } + + Ok("Invitation cancelled successfully".into()) + } +} diff --git a/imphnen-iam/src/v1/teams/teams_service.rs b/imphnen-iam/src/v1/teams/teams_service.rs index 580fbb0..a8e6458 100644 --- a/imphnen-iam/src/v1/teams/teams_service.rs +++ b/imphnen-iam/src/v1/teams/teams_service.rs @@ -39,6 +39,9 @@ pub trait TeamsServiceTrait: Send + Sync + 'static { fn leave_team(state: &AppState, claims: imphnen_libs::jsonwebtoken::Claims, team_id: String) -> Pin + Send>>; fn leave_current_team(state: &AppState, claims: imphnen_libs::jsonwebtoken::Claims) -> Pin + Send>>; fn get_my_team(state: &AppState, claims: imphnen_libs::jsonwebtoken::Claims) -> Pin + Send>>; + fn get_team_invitations(state: &AppState, claims: imphnen_libs::jsonwebtoken::Claims, team_id: String) -> Pin + Send>>; + fn cancel_invitation(state: &AppState, claims: imphnen_libs::jsonwebtoken::Claims, token: String) -> Pin + Send>>; + fn get_my_invitations(state: &AppState, claims: imphnen_libs::jsonwebtoken::Claims) -> Pin + Send>>; fn search_teams(state: &AppState, search_params: TeamsSearchQueryDto) -> Pin + Send>>; fn get_admin_team_list(state: &AppState, meta: MetaRequestDto) -> Pin + Send>>; fn get_admin_team_by_id(state: &AppState, id: String) -> Pin + Send>>; @@ -1087,4 +1090,144 @@ impl TeamsServiceTrait for TeamsService { Self::get_public_team_by_id(&state, team_id).await }) } -} \ No newline at end of file + + fn get_team_invitations(state: &AppState, claims: imphnen_libs::jsonwebtoken::Claims, team_id: String) -> Pin + Send>> { + let state = state.to_owned(); + Box::pin(async move { + let repo = TeamsRepository::new(&state); + let team_thing = make_thing_from_enum(ResourceEnum::Teams, &team_id); + + // Check if team exists and user is leader + let team = match repo.query_team_by_id(&team_thing).await { + Ok(t) => t, + Err(_) => return common_response(StatusCode::NOT_FOUND, "Team not found"), + }; + + if team.leader_id.id.to_raw() != claims.user_id { + return common_response(StatusCode::FORBIDDEN, "Only team leader can view invitations"); + } + + // Get invitations + match repo.query_team_invitations(&team_thing).await { + Ok(invitations) => { + use crate::{v1::teams::TeamInvitationListDto, UsersRepository}; + let users_repo = UsersRepository::new(&state); + + let mut invitation_list = Vec::new(); + for inv in invitations { + // Get inviter name + let inviter_name = match users_repo.query_user_by_id(&inv.inviter_id).await { + Ok(user) => user.fullname, + Err(_) => "Unknown".to_string(), + }; + + invitation_list.push(TeamInvitationListDto { + id: inv.id.id.to_raw(), + team_id: inv.team_id.id.to_raw(), + team_name: team.name.clone(), + email: inv.email, + inviter_id: inv.inviter_id.id.to_raw(), + inviter_name, + status: inv.status, + invite_code: inv.invite_code, + expires_at: inv.expires_at, + invited_at: inv.invited_at, + }); + } + + success_response(ResponseSuccessDto { data: invitation_list }) + }, + Err(e) => { + error!("Failed to get team invitations: {}", e); + common_response(StatusCode::INTERNAL_SERVER_ERROR, "Failed to retrieve invitations") + } + } + }) + } + + fn cancel_invitation(state: &AppState, claims: imphnen_libs::jsonwebtoken::Claims, token: String) -> Pin + Send>> { + let state = state.to_owned(); + Box::pin(async move { + let repo = TeamsRepository::new(&state); + + // Get invitation to check ownership + let invitation = match repo.query_invitation_by_token(&token).await { + Ok(inv) => inv, + Err(_) => return common_response(StatusCode::NOT_FOUND, "Invitation not found"), + }; + + // Check if user is the team leader + let team_thing = invitation.team_id.clone(); + let team = match repo.query_team_by_id(&team_thing).await { + Ok(t) => t, + Err(_) => return common_response(StatusCode::NOT_FOUND, "Team not found"), + }; + + if team.leader_id.id.to_raw() != claims.user_id { + return common_response(StatusCode::FORBIDDEN, "Only team leader can cancel invitations"); + } + + match repo.query_delete_invitation(&token).await { + Ok(msg) => success_response(ResponseSuccessDto { data: msg }), + Err(e) => { + error!("Failed to cancel invitation: {}", e); + common_response(StatusCode::INTERNAL_SERVER_ERROR, "Failed to cancel invitation") + } + } + }) + } + + fn get_my_invitations(state: &AppState, claims: imphnen_libs::jsonwebtoken::Claims) -> Pin + Send>> { + let state = state.to_owned(); + Box::pin(async move { + use crate::{v1::teams::MyInvitationDto, UsersRepository}; + let users_repo = UsersRepository::new(&state); + + // Get user email + let user_thing = make_thing_from_enum(ResourceEnum::Users, &claims.user_id); + let user = match users_repo.query_user_by_id(&user_thing).await { + Ok(u) => u, + Err(_) => return common_response(StatusCode::NOT_FOUND, "User not found"), + }; + + let repo = TeamsRepository::new(&state); + match repo.query_user_invitations(&user.email).await { + Ok(invitations) => { + let mut my_invitations = Vec::new(); + for inv in invitations { + // Get team details + let team = match repo.query_team_by_id(&inv.team_id).await { + Ok(t) => t, + Err(_) => continue, + }; + + // Get inviter name + let inviter_name = match users_repo.query_user_by_id(&inv.inviter_id).await { + Ok(u) => u.fullname, + Err(_) => "Unknown".to_string(), + }; + + my_invitations.push(MyInvitationDto { + id: inv.id.id.to_raw(), + team_id: inv.team_id.id.to_raw(), + team_name: team.name, + team_description: team.description, + team_avatar: team.avatar, + inviter_name, + invite_code: inv.invite_code, + status: inv.status, + expires_at: inv.expires_at, + invited_at: inv.invited_at, + }); + } + + success_response(ResponseSuccessDto { data: my_invitations }) + }, + Err(e) => { + error!("Failed to get user invitations: {}", e); + common_response(StatusCode::INTERNAL_SERVER_ERROR, "Failed to retrieve invitations") + } + } + }) + } +} diff --git a/imphnen-libs/src/surrealdb/resource.rs b/imphnen-libs/src/surrealdb/resource.rs index 77598cb..0330d55 100644 --- a/imphnen-libs/src/surrealdb/resource.rs +++ b/imphnen-libs/src/surrealdb/resource.rs @@ -51,10 +51,14 @@ pub enum ResourceEnum { HackathonTimeline, /// Hackathon submissions table for project submissions HackathonSubmissions, + /// Hackathon registrations table for participant registrations + HackathonRegistrations, /// Rate limiting table for IP-based rate limiting RateLimit, /// Audit log table for admin action tracking AuditLog, + /// Sessions table for mentoring sessions + Sessions, } impl fmt::Display for ResourceEnum { @@ -80,8 +84,10 @@ impl fmt::Display for ResourceEnum { ResourceEnum::HackathonEvents => "app_hackathon_events", ResourceEnum::HackathonTimeline => "app_hackathon_timeline", ResourceEnum::HackathonSubmissions => "app_hackathon_submissions", + ResourceEnum::HackathonRegistrations => "hackathon_registrations", ResourceEnum::RateLimit => "app_rate_limit", ResourceEnum::AuditLog => "app_audit_log", + ResourceEnum::Sessions => "app_sessions", }; write!(f, "{}", table_name) } @@ -122,8 +128,10 @@ impl ResourceEnum { ResourceEnum::HackathonEvents => "app_hackathon_events", ResourceEnum::HackathonTimeline => "app_hackathon_timeline", ResourceEnum::HackathonSubmissions => "app_hackathon_submissions", + ResourceEnum::HackathonRegistrations => "hackathon_registrations", ResourceEnum::RateLimit => "app_rate_limit", ResourceEnum::AuditLog => "app_audit_log", + ResourceEnum::Sessions => "app_sessions", } }