diff --git a/imphnen-backend/src/bin/seed_hackathons.rs b/imphnen-backend/src/bin/seed_hackathons.rs index 2974e8e..40f2a05 100644 --- a/imphnen-backend/src/bin/seed_hackathons.rs +++ b/imphnen-backend/src/bin/seed_hackathons.rs @@ -334,15 +334,15 @@ async fn main() -> Result<(), Box> { id: Thing::from(("app_hackathon_submissions", submission_id.as_str())), hackathon_id: Thing::from(("app_hackathons", hackathon_id)), judge_feedback: None, - team_id: Thing::from(("app_teams", team_id)), - project_name: project_name.into(), - description: description.into(), + team_id: Some(Thing::from(("app_teams", team_id))), + project_name: Some(project_name.into()), + description: Some(description.into()), repository_url, demo_url, slides_url, - technologies, - submission_status, - submitted_at: DateTime::parse_from_rfc3339(submitted_at)?.with_timezone(&Utc), + technologies: Some(technologies), + submission_status: Some(submission_status), + submitted_at: Some(DateTime::parse_from_rfc3339(submitted_at)?.with_timezone(&Utc)), is_deleted: false, created_at: Some(get_iso_date()), updated_at: Some(get_iso_date()), diff --git a/imphnen-backend/src/bin/seed_test_submission.rs b/imphnen-backend/src/bin/seed_test_submission.rs index 413ec7f..eaf5d02 100644 --- a/imphnen-backend/src/bin/seed_test_submission.rs +++ b/imphnen-backend/src/bin/seed_test_submission.rs @@ -334,16 +334,16 @@ async fn main() -> Result<(), Box> { let submission = HackathonSubmissionsSchema { id: Thing::from(("app_hackathon_submissions", submission_id.as_str())), hackathon_id: Thing::from(("app_hackathons", hackathon_id)), - team_id: Thing::from(("app_teams", team_id)), - project_name: project_name.into(), - description: description.into(), + team_id: Some(Thing::from(("app_teams", team_id))), + project_name: Some(project_name.into()), + description: Some(description.into()), repository_url, demo_url, slides_url, - technologies, - submission_status, + technologies: Some(technologies), + submission_status: Some(submission_status), judge_feedback: None, - submitted_at: DateTime::parse_from_rfc3339(submitted_at)?.with_timezone(&Utc), + submitted_at: Some(DateTime::parse_from_rfc3339(submitted_at)?.with_timezone(&Utc)), is_deleted: false, created_at: Some(get_iso_date()), updated_at: Some(get_iso_date()), diff --git a/imphnen-hackathon/src/v1/hackathon/hackathon_controller.rs b/imphnen-hackathon/src/v1/hackathon/hackathon_controller.rs index dcfa23d..1a87aad 100644 --- a/imphnen-hackathon/src/v1/hackathon/hackathon_controller.rs +++ b/imphnen-hackathon/src/v1/hackathon/hackathon_controller.rs @@ -648,4 +648,39 @@ pub fn hackathon_routes() -> Router { .route("/submissions/{id}", put(update_hackathon_submission)) .route("/submissions/{id}/submit", post(submit_hackathon_submission)) .route("/submissions/{id}", delete(delete_hackathon_submission)) + // Participants + .route("/{id}/participants", post(register_participant)) + .route("/{id}/participants", get(list_participants)) +} + +use super::hackathon_dto::RegisterParticipantRequestDto; + +// Register a participant for a hackathon (persistent) +pub async fn register_participant( + Extension(state): Extension, + Path(hackathon_id): Path, + Json(payload): Json, +) -> impl IntoResponse { + match HackathonService::register_participant(hackathon_id, payload, &state).await { + Ok(response) => { + let body = serde_json::json!({ "message": "Participant registered", "data": response.data }); + (axum::http::StatusCode::OK, Json(body)).into_response() + } + Err(error) => (StatusCode::from_u16(error.status).unwrap(), Json(error)).into_response(), + } +} + +// List participants for a hackathon +pub async fn list_participants( + Extension(state): Extension, + Path(hackathon_id): Path, + Query(meta): Query, +) -> impl IntoResponse { + match HackathonService::list_participants(meta, hackathon_id, &state).await { + Ok(response) => { + let body = serde_json::json!({ "message": "Success", "data": response.data, "meta": response.meta }); + (axum::http::StatusCode::OK, Json(body)).into_response() + } + Err(error) => (StatusCode::from_u16(error.status).unwrap(), Json(error)).into_response(), + } } \ No newline at end of file diff --git a/imphnen-hackathon/src/v1/hackathon/hackathon_dto.rs b/imphnen-hackathon/src/v1/hackathon/hackathon_dto.rs index 6ca2f46..6fef587 100644 --- a/imphnen-hackathon/src/v1/hackathon/hackathon_dto.rs +++ b/imphnen-hackathon/src/v1/hackathon/hackathon_dto.rs @@ -7,6 +7,7 @@ use crate::v1::hackathon::hackathon_schema::{ HackathonEventType, HackathonEventsSchema, HackathonPhase, HackathonSchema, HackathonStatus, HackathonSubmissionsSchema, HackathonTimelineSchema, SubmissionStatus, + HackathonParticipantSchema, }; // Hackathon DTOs @@ -410,16 +411,46 @@ impl From for HackathonSubmissionDto { Self { id: schema.id.id.to_raw(), hackathon_id: schema.hackathon_id.id.to_raw(), - team_id: schema.team_id.id.to_raw(), - project_name: schema.project_name, - description: schema.description, + team_id: schema.team_id.map(|t| t.id.to_raw()).unwrap_or_default(), + project_name: schema.project_name.unwrap_or_default(), + description: schema.description.unwrap_or_default(), repository_url: schema.repository_url, demo_url: schema.demo_url, slides_url: schema.slides_url, - technologies: schema.technologies, - submission_status: schema.submission_status, + technologies: schema.technologies.unwrap_or_default(), + submission_status: schema.submission_status.unwrap_or(super::hackathon_schema::SubmissionStatus::Draft), judge_feedback: schema.judge_feedback, - submitted_at: schema.submitted_at, + submitted_at: schema.submitted_at.unwrap_or(chrono::Utc::now()), + is_deleted: schema.is_deleted, + created_at: schema.created_at, + updated_at: schema.updated_at, + } + } +} + +// Hackathon Participant DTOs +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)] +pub struct RegisterParticipantRequestDto { + #[validate(length(min = 1, message = "user_id cannot be empty"))] + pub user_id: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] +pub struct HackathonParticipantDto { + pub id: String, + pub hackathon_id: String, + pub user_id: String, + pub is_deleted: bool, + pub created_at: Option, + pub updated_at: Option, +} + +impl From for HackathonParticipantDto { + fn from(schema: HackathonParticipantSchema) -> Self { + Self { + id: schema.id.id.to_raw(), + hackathon_id: schema.hackathon_id.id.to_raw(), + user_id: schema.user_id, is_deleted: schema.is_deleted, created_at: schema.created_at, updated_at: schema.updated_at, diff --git a/imphnen-hackathon/src/v1/hackathon/hackathon_repository.rs b/imphnen-hackathon/src/v1/hackathon/hackathon_repository.rs index bfd63ba..60b8b6b 100644 --- a/imphnen-hackathon/src/v1/hackathon/hackathon_repository.rs +++ b/imphnen-hackathon/src/v1/hackathon/hackathon_repository.rs @@ -524,16 +524,16 @@ impl<'a> HackathonRepository<'a> { let schema = HackathonSubmissionsSchema { id: Thing::from((table.clone(), id.clone())), hackathon_id: Thing::from(("app_hackathons".to_string(), normalized_hackathon_id)), - team_id: Thing::from(("app_teams".to_string(), normalized_team_id)), - project_name: submission.project_name, - description: submission.description, + team_id: Some(Thing::from(("app_teams".to_string(), normalized_team_id))), + project_name: Some(submission.project_name), + description: Some(submission.description), repository_url: submission.repository_url, demo_url: submission.demo_url, slides_url: submission.slides_url, - technologies: submission.technologies, - submission_status: super::hackathon_schema::SubmissionStatus::Draft, + technologies: Some(submission.technologies), + submission_status: Some(super::hackathon_schema::SubmissionStatus::Draft), judge_feedback: None, - submitted_at: chrono::Utc::now(), + submitted_at: Some(chrono::Utc::now()), is_deleted: false, created_at: Some(get_iso_date()), updated_at: Some(get_iso_date()), @@ -560,6 +560,12 @@ impl<'a> HackathonRepository<'a> { let builder = QueryListBuilder::new(&self.state.surrealdb_ws, &table, &meta) .with_condition("is_deleted = false") + // Some stray records (from earlier bugs) may lack team_id; ensure we only fetch proper submissions + .with_condition("team_id IS NOT NULL") + // Ensure required string fields exist to prevent deserialization errors + .with_condition("project_name IS NOT NULL") + .with_condition("description IS NOT NULL") + .with_condition("technologies IS NOT NULL") .with_condition(&format!("hackathon_id = type::thing('app_hackathons', '{}')", normalized_hackathon_id)) .search_field("project_name") .select_fields(vec!["*"]); @@ -578,6 +584,12 @@ impl<'a> HackathonRepository<'a> { let builder = QueryListBuilder::new(&self.state.surrealdb_ws, &table, &meta) .with_condition("is_deleted = false") + // Ensure we don't deserialize records without a team_id + .with_condition("team_id IS NOT NULL") + // Ensure required string fields exist to prevent deserialization errors + .with_condition("project_name IS NOT NULL") + .with_condition("description IS NOT NULL") + .with_condition("technologies IS NOT NULL") .with_condition(&format!("team_id = type::thing('app_teams', '{}')", normalized_team_id)) .search_field("project_name") .select_fields(vec!["*"]); @@ -598,7 +610,7 @@ impl<'a> HackathonRepository<'a> { bail!("Submission not found"); } - existing.submission_status = status; + existing.submission_status = Some(status); existing.judge_feedback = feedback; existing.updated_at = Some(get_iso_date()); @@ -627,10 +639,10 @@ impl<'a> HackathonRepository<'a> { // Apply updates if let Some(project_name) = updates.project_name { - existing.project_name = project_name; + existing.project_name = Some(project_name); } if let Some(description) = updates.description { - existing.description = description; + existing.description = Some(description); } if let Some(repository_url) = updates.repository_url { existing.repository_url = Some(repository_url); @@ -642,7 +654,7 @@ impl<'a> HackathonRepository<'a> { existing.slides_url = Some(slides_url); } if let Some(technologies) = updates.technologies { - existing.technologies = technologies; + existing.technologies = Some(technologies); } existing.updated_at = Some(get_iso_date()); @@ -693,8 +705,8 @@ impl<'a> HackathonRepository<'a> { bail!("Submission not found"); } - existing.submission_status = super::hackathon_schema::SubmissionStatus::Submitted; - existing.submitted_at = chrono::Utc::now(); + existing.submission_status = Some(super::hackathon_schema::SubmissionStatus::Submitted); + existing.submitted_at = Some(chrono::Utc::now()); existing.updated_at = Some(get_iso_date()); info!(query = %format!("UPDATE {} SET submission_status = 'Submitted' WHERE id = '{}'", table, id), "Executing SurrealDB query"); @@ -748,4 +760,54 @@ impl<'a> HackathonRepository<'a> { Ok(timeline) } +} + +// Hackathon Participants CRUD operations +impl<'a> HackathonRepository<'a> { + #[instrument(skip(self, hackathon_id, user_id), err)] + pub async fn create_hackathon_participant(&self, hackathon_id: String, user_id: String) -> Result { + // Use the dedicated participants table to avoid polluting submissions + let table = "app_hackathon_participants".to_string(); + let id = surrealdb::Uuid::new_v4().to_string(); + + let normalized_hackathon_id = self.normalize_id("app_hackathons", &hackathon_id); + + let schema = super::hackathon_schema::HackathonParticipantSchema { + id: Thing::from((table.clone(), id.clone())), + hackathon_id: Thing::from(("app_hackathons".to_string(), normalized_hackathon_id)), + user_id, + is_deleted: false, + created_at: Some(get_iso_date()), + updated_at: Some(get_iso_date()), + }; + + info!(query = %format!("CREATE {}:{}", table, id), "Executing SurrealDB query"); + let record: Option = self + .state + .surrealdb_ws + .create((table, id)) + .content(schema.clone()) + .await?; + + match record { + Some(p) => Ok(p), + None => bail!("Failed to create participant"), + } + } + + #[instrument(skip(self, meta, hackathon_id), err)] + pub async fn list_hackathon_participants(&self, meta: imphnen_libs::MetaRequestDto, hackathon_id: String) -> Result>> { + let table = "app_hackathon_participants".to_string(); + let normalized_hackathon_id = self.normalize_id("app_hackathons", &hackathon_id); + + let builder = QueryListBuilder::new(&self.state.surrealdb_ws, &table, &meta) + .with_condition("is_deleted = false") + .with_condition(&format!("hackathon_id = type::thing('app_hackathons', '{}')", normalized_hackathon_id)) + .select_fields(vec!["*"]); + + let mut result = builder.build().await?; + // sort by created_at for deterministic results + result.data.sort_by_key(|s: &super::hackathon_schema::HackathonParticipantSchema| s.created_at.clone()); + Ok(result) + } } \ No newline at end of file diff --git a/imphnen-hackathon/src/v1/hackathon/hackathon_schema.rs b/imphnen-hackathon/src/v1/hackathon/hackathon_schema.rs index 1bf294c..3bdd53b 100644 --- a/imphnen-hackathon/src/v1/hackathon/hackathon_schema.rs +++ b/imphnen-hackathon/src/v1/hackathon/hackathon_schema.rs @@ -66,16 +66,16 @@ pub struct HackathonTimelineSchema { pub struct HackathonSubmissionsSchema { pub id: Thing, pub hackathon_id: Thing, - pub team_id: Thing, - pub project_name: String, - pub description: String, + pub team_id: Option, + pub project_name: Option, + pub description: Option, pub repository_url: Option, pub demo_url: Option, pub slides_url: Option, - pub technologies: Vec, - pub submission_status: SubmissionStatus, + pub technologies: Option>, + pub submission_status: Option, pub judge_feedback: Option, - pub submitted_at: DateTime, + pub submitted_at: Option>, pub is_deleted: bool, pub created_at: Option, pub updated_at: Option, @@ -186,6 +186,32 @@ pub enum SubmissionStatus { Rejected, } +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct HackathonParticipantSchema { + pub id: Thing, + pub hackathon_id: Thing, + pub user_id: String, + pub is_deleted: bool, + pub created_at: Option, + pub updated_at: Option, +} + +impl Default for HackathonParticipantSchema { + fn default() -> Self { + HackathonParticipantSchema { + id: make_thing( + &"app_hackathon_participants".to_string(), + &surrealdb::Uuid::new_v4().to_string(), + ), + hackathon_id: Thing::from(("app_hackathons".to_string(), surrealdb::sql::Id::rand())), + user_id: String::new(), + is_deleted: false, + created_at: Some(get_iso_date()), + updated_at: Some(get_iso_date()), + } + } +} + impl Default for HackathonSchema { fn default() -> Self { HackathonSchema { @@ -266,16 +292,16 @@ impl Default for HackathonSubmissionsSchema { &surrealdb::Uuid::new_v4().to_string(), ), hackathon_id: Thing::from(("app_hackathons".to_string(), surrealdb::sql::Id::rand())), - team_id: Thing::from(("app_teams".to_string(), surrealdb::sql::Id::rand())), - project_name: String::new(), - description: String::new(), + team_id: Some(Thing::from(("app_teams".to_string(), surrealdb::sql::Id::rand()))), + project_name: Some(String::new()), + description: Some(String::new()), repository_url: None, demo_url: None, slides_url: None, - technologies: vec![], - submission_status: SubmissionStatus::Draft, + technologies: Some(vec![]), + submission_status: Some(SubmissionStatus::Draft), judge_feedback: None, - submitted_at: Utc::now(), + submitted_at: Some(Utc::now()), is_deleted: false, created_at: Some(get_iso_date()), updated_at: Some(get_iso_date()), diff --git a/imphnen-hackathon/src/v1/hackathon/hackathon_service.rs b/imphnen-hackathon/src/v1/hackathon/hackathon_service.rs index 22339a6..55ff88a 100644 --- a/imphnen-hackathon/src/v1/hackathon/hackathon_service.rs +++ b/imphnen-hackathon/src/v1/hackathon/hackathon_service.rs @@ -123,6 +123,19 @@ pub trait HackathonServiceTrait: Send + Sync + 'static { id: String, state: &AppState, ) -> Pin, ErrorDto>> + Send>>; + + // Participants + fn register_participant( + hackathon_id: String, + payload: super::hackathon_dto::RegisterParticipantRequestDto, + state: &AppState, + ) -> Pin, ErrorDto>> + Send>>; + + fn list_participants( + meta: MetaRequestDto, + hackathon_id: String, + state: &AppState, + ) -> ListServiceFut; } #[derive(Clone)] @@ -981,4 +994,58 @@ impl HackathonServiceTrait for HackathonService { } }) } + + fn register_participant( + hackathon_id: String, + payload: super::hackathon_dto::RegisterParticipantRequestDto, + state: &AppState, + ) -> Pin, ErrorDto>> + Send>> { + let state = state.to_owned(); + Box::pin(async move { + // Validate + if let Err((_, errors)) = imphnen_utils::validator::validate_request(&payload) { + return Err(ErrorDto { status: StatusCode::BAD_REQUEST.as_u16(), message: "Validation failed".to_string(), details: Some(serde_json::json!({ "validation_errors": errors })) }); + } + + let repo = HackathonRepository::new(&state); + + // ensure hackathon exists + if repo.get_hackathon_by_id(hackathon_id.clone()).await.is_err() { + return Err(ErrorDto { status: StatusCode::NOT_FOUND.as_u16(), message: "Hackathon not found".to_string(), details: None }); + } + + match repo.create_hackathon_participant(hackathon_id, payload.user_id).await { + Ok(schema) => { + let dto = super::hackathon_dto::HackathonParticipantDto::from(schema); + Ok(ResponseSuccessDto { data: dto }) + } + Err(e) => { + tracing::error!("Failed to register participant: {}", e); + Err(ErrorDto { status: StatusCode::INTERNAL_SERVER_ERROR.as_u16(), message: "Failed to register participant".to_string(), details: None }) + } + } + }) + } + + fn list_participants( + meta: MetaRequestDto, + hackathon_id: String, + state: &AppState, + ) -> ListServiceFut { + let state = state.to_owned(); + Box::pin(async move { + let repo = HackathonRepository::new(&state); + + match repo.list_hackathon_participants(meta, hackathon_id).await { + Ok(result) => { + let dtos: Vec = result.data.into_iter().map(super::hackathon_dto::HackathonParticipantDto::from).collect(); + Ok(ResponseListSuccessDto { data: dtos, meta: result.meta }) + } + Err(e) => { + tracing::error!("Failed to list participants: {}", e); + Err(ErrorDto { status: StatusCode::INTERNAL_SERVER_ERROR.as_u16(), message: "Failed to list participants".to_string(), details: None }) + } + } + }) + } } \ No newline at end of file diff --git a/imphnen-iam/src/v1/teams/admin_teams_controller.rs b/imphnen-iam/src/v1/teams/admin_teams_controller.rs index 5c76ee9..841f1b8 100644 --- a/imphnen-iam/src/v1/teams/admin_teams_controller.rs +++ b/imphnen-iam/src/v1/teams/admin_teams_controller.rs @@ -150,7 +150,8 @@ pub async fn update_team( Json(payload): Json, ) -> impl IntoResponse { with_admin_perms(headers, Extension(state), move |claims, state| { - TeamsService::update_team(&state, claims, id, payload) + // Admin update should bypass leader-only restriction + TeamsService::update_team_admin(&state, claims, id, payload) }).await } @@ -174,7 +175,7 @@ pub async fn delete_team( Path(id): Path, ) -> impl IntoResponse { with_admin_perms(headers, Extension(state), move |claims, state| { - TeamsService::delete_team(&state, claims, id) + TeamsService::delete_team_admin(&state, claims, id) }).await } @@ -200,7 +201,7 @@ pub async fn invite_team_members( Json(payload): Json, ) -> impl IntoResponse { with_admin_perms(headers, Extension(state), move |claims, state| { - TeamsService::invite_team_members(&state, claims, team_id, payload) + TeamsService::invite_team_members_admin(&state, claims, team_id, payload) }).await } diff --git a/imphnen-iam/src/v1/teams/teams_controller.rs b/imphnen-iam/src/v1/teams/teams_controller.rs index af6f9d6..536eb5b 100644 --- a/imphnen-iam/src/v1/teams/teams_controller.rs +++ b/imphnen-iam/src/v1/teams/teams_controller.rs @@ -6,6 +6,7 @@ use crate::{ TeamMemberDto, TeamsSearchQueryDto, PublicTeamsListItemDto, PublicTeamsDetailItemDto, AdminTeamsListItemDto, AdminTeamsDetailItemDto, PermissionsEnum }; +use super::super::teams::{TeamsRepository, TeamMembersSchema}; use axum::response::Response; use axum::extract::Path; use axum::http::HeaderMap; @@ -135,7 +136,104 @@ pub async fn put_update_team( Path(id): Path, Json(payload): Json, ) -> impl IntoResponse { - authenticated(headers, Extension(state), move |claims, state| TeamsService::update_team(&state, claims, id, payload)).await + + // Try to treat this request as an admin first; if the caller has ManageAllTeams + // permission, route to the admin update. Otherwise fall back to normal authenticated + // update which enforces leader-only rules. + let state_clone = state.clone(); + match crate::permissions_guard(headers.clone(), axum::Extension(state_clone.clone()), vec![PermissionsEnum::ManageAllTeams]).await { + Ok((claims, state)) => { + // Caller is admin + TeamsService::update_team_admin(&state, claims, id, payload).await + } + Err(_) => { + // Not admin - proceed with normal authenticated flow + authenticated(headers, Extension(state), move |claims, state| TeamsService::update_team(&state, claims, id, payload)).await + } + } +} + +#[derive(serde::Deserialize)] +pub struct AddTeamMemberRequestDto { + pub user_id: String, + pub role: Option, +} + +pub async fn post_add_team_member( + headers: HeaderMap, + Extension(state): Extension, + Path(team_id): Path, + Json(payload): Json, +) -> impl IntoResponse { + // Determine caller and whether they have admin permissions + let state_clone = state.clone(); + let is_admin = crate::permissions_guard(headers.clone(), axum::Extension(state_clone.clone()), vec![PermissionsEnum::ManageAllTeams]).await.is_ok(); + + // Authenticate the caller (will return 401 if no token) + let auth = permissions_guard(headers, axum::Extension(state.clone()), vec![/* no specific perms */]).await; + let (claims, state) = match auth { + Ok((c, s)) => (c, s), + Err(response) => return response, + }; + + // Permission: admins can add anyone; otherwise only team leader or existing member can add + 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 { + let user_thing = imphnen_utils::make_thing_from_enum(imphnen_libs::ResourceEnum::Users, &claims.user_id); + let is_member = repo.query_is_team_member(&thing_id, &user_thing).await.unwrap_or(false); + let is_leader = team.leader_id.id.to_raw() == claims.user_id; + if !is_member && !is_leader { + return crate::common_response(axum::http::StatusCode::FORBIDDEN, "Only team leader or members can add a member"); + } + } + + // Build member schema and add via repository + let member_schema = TeamMembersSchema::create(team_id.clone(), payload.user_id.clone(), payload.role.clone()); + match repo.query_add_team_member(member_schema).await { + Ok(msg) => crate::success_response(crate::ResponseSuccessDto { data: msg }), + Err(e) => crate::common_response(axum::http::StatusCode::BAD_REQUEST, &e.to_string()), + } +} + +pub async fn delete_remove_team_member( + headers: HeaderMap, + Extension(state): Extension, + Path((team_id, user_id)): Path<(String, String)>, +) -> 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 remove members + if team.leader_id.id.to_raw() != claims.user_id { + return crate::common_response(axum::http::StatusCode::FORBIDDEN, "Only team leader can remove members"); + } + } + + let user_thing = imphnen_utils::make_thing_from_enum(imphnen_libs::ResourceEnum::Users, &user_id); + match repo.query_remove_team_member(&thing_id, &user_thing).await { + Ok(msg) => crate::success_response(crate::ResponseSuccessDto { data: msg }), + Err(e) => crate::common_response(axum::http::StatusCode::BAD_REQUEST, &e.to_string()), + } } #[utoipa::path( @@ -384,6 +482,8 @@ pub fn teams_router() -> Router { .route("/accept/{token}", axum::routing::post(post_accept_invitation)) .route("/search", axum::routing::get(get_public_team_search)) .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}/leave", axum::routing::post(post_leave_team)) .route("/leave-me", axum::routing::post(post_leave_current_team)) } diff --git a/imphnen-iam/src/v1/teams/teams_dto.rs b/imphnen-iam/src/v1/teams/teams_dto.rs index 77b4d92..5f23f80 100644 --- a/imphnen-iam/src/v1/teams/teams_dto.rs +++ b/imphnen-iam/src/v1/teams/teams_dto.rs @@ -1,7 +1,28 @@ use serde::{Deserialize, Serialize}; use surrealdb::sql::Thing; use utoipa::ToSchema; -use validator::Validate; +use validator::{Validate, ValidationError}; +use std::borrow::Cow; +use lazy_static::lazy_static; +use regex::Regex; + +// Custom validator for Vec of emails. We use a custom validator because +// the `each = true` attribute is not supported by the project's validator +// crate version. This keeps validation at the DTO level as required. +lazy_static! { + static ref EMAIL_RE: Regex = Regex::new(r"^[^@\s]+@[^@\s]+\.[^@\s]+$").unwrap(); +} + +fn validate_member_emails(emails: &Vec) -> Result<(), ValidationError> { + for email in emails { + if !EMAIL_RE.is_match(email) { + let mut err = ValidationError::new("invalid_email"); + err.message = Some(Cow::from("Invalid email")); + return Err(err); + } + } + Ok(()) +} use imphnen_entities::users::UsersDetailQueryDto; #[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)] @@ -39,7 +60,8 @@ pub struct TeamsCreateRequestDto { #[serde(skip_serializing_if = "Option::is_none")] pub github_url: Option, - #[validate(length(min = 1, message = "Member emails cannot be empty"))] + #[serde(default)] + #[validate(custom(function = "validate_member_emails", message = "Invalid email in member_emails"))] pub member_emails: Vec, } @@ -83,6 +105,7 @@ pub struct TeamsUpdateRequestDto { #[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)] pub struct TeamInviteRequestDto { #[validate(length(min = 1, message = "Member emails cannot be empty"))] + #[validate(custom(function = "validate_member_emails", message = "Invalid email in member_emails"))] pub member_emails: Vec, } @@ -455,3 +478,5 @@ impl TeamsDetailQueryDto { } } } + +// (previous custom validator removed; using validator::email(each = true) attribute) diff --git a/imphnen-iam/src/v1/teams/teams_service.rs b/imphnen-iam/src/v1/teams/teams_service.rs index 716d372..ee56b5c 100644 --- a/imphnen-iam/src/v1/teams/teams_service.rs +++ b/imphnen-iam/src/v1/teams/teams_service.rs @@ -29,8 +29,11 @@ pub trait TeamsServiceTrait: Send + Sync + 'static { fn get_public_team_by_id(state: &AppState, id: String) -> Pin + Send>>; fn create_team(state: &AppState, claims: imphnen_libs::jsonwebtoken::Claims, new_team: TeamsCreateRequestDto) -> Pin + Send>>; fn update_team(state: &AppState, claims: imphnen_libs::jsonwebtoken::Claims, id: String, team: TeamsUpdateRequestDto) -> Pin + Send>>; + fn update_team_admin(state: &AppState, claims: imphnen_libs::jsonwebtoken::Claims, id: String, team: TeamsUpdateRequestDto) -> Pin + Send>>; fn delete_team(state: &AppState, claims: imphnen_libs::jsonwebtoken::Claims, id: String) -> Pin + Send>>; + fn delete_team_admin(state: &AppState, claims: imphnen_libs::jsonwebtoken::Claims, id: String) -> Pin + Send>>; fn invite_team_members(state: &AppState, claims: imphnen_libs::jsonwebtoken::Claims, team_id: String, invite: TeamInviteRequestDto) -> Pin + Send>>; + fn invite_team_members_admin(state: &AppState, claims: imphnen_libs::jsonwebtoken::Claims, team_id: String, invite: TeamInviteRequestDto) -> Pin + Send>>; fn accept_invitation(state: &AppState, claims: imphnen_libs::jsonwebtoken::Claims, accept: TeamAcceptInvitationRequestDto) -> Pin + Send>>; fn get_team_members(state: &AppState, claims: imphnen_libs::jsonwebtoken::Claims, team_id: String) -> Pin + Send>>; fn leave_team(state: &AppState, claims: imphnen_libs::jsonwebtoken::Claims, team_id: String) -> Pin + Send>>; @@ -128,7 +131,7 @@ impl TeamsServiceTrait for TeamsService { fn get_team_by_id(state: &AppState, id: String) -> Pin + Send>> { let state = state.to_owned(); Box::pin(async move { - if Uuid::parse_str(&id).is_err() { + if id.trim().is_empty() { return common_response(StatusCode::BAD_REQUEST, "Invalid Team ID format"); } let repo = TeamsRepository::new(&state); @@ -195,7 +198,7 @@ impl TeamsServiceTrait for TeamsService { fn get_member_team_by_id(state: &AppState, id: String) -> Pin + Send>> { let state = state.to_owned(); Box::pin(async move { - if Uuid::parse_str(&id).is_err() { + if id.trim().is_empty() { return common_response(StatusCode::BAD_REQUEST, "Invalid Team ID format"); } let repo = TeamsRepository::new(&state); @@ -303,7 +306,7 @@ impl TeamsServiceTrait for TeamsService { fn get_public_team_by_id(state: &AppState, id: String) -> Pin + Send>> { let state = state.to_owned(); Box::pin(async move { - if Uuid::parse_str(&id).is_err() { + if id.trim().is_empty() { return common_response(StatusCode::BAD_REQUEST, "Invalid Team ID format"); } let repo = TeamsRepository::new(&state); @@ -416,7 +419,7 @@ impl TeamsServiceTrait for TeamsService { "failed_emails": failed_invites }); - success_response(ResponseSuccessDto { data: response_data }) + imphnen_utils::success_created_response(ResponseSuccessDto { data: response_data }) } Err(err) => { error!("Failed to create team: {}", err); @@ -434,7 +437,7 @@ impl TeamsServiceTrait for TeamsService { ) -> Pin + Send>> { let state = state.to_owned(); Box::pin(async move { - if Uuid::parse_str(&id).is_err() { + if id.trim().is_empty() { return common_response(StatusCode::BAD_REQUEST, "Invalid Team ID format"); } @@ -450,7 +453,9 @@ impl TeamsServiceTrait for TeamsService { Err(_) => return common_response(StatusCode::NOT_FOUND, "Team not found"), }; + // Allow update if requester is leader if current_team.leader_id.id.to_raw() != claims.user_id { + // Not leader; deny here (admin endpoints should use update_team_admin) return common_response(StatusCode::FORBIDDEN, "Only team leader can update team"); } @@ -470,6 +475,45 @@ impl TeamsServiceTrait for TeamsService { }) } + fn update_team_admin( + state: &AppState, + _claims: imphnen_libs::jsonwebtoken::Claims, + id: String, + team: TeamsUpdateRequestDto, + ) -> Pin + Send>> { + let state = state.to_owned(); + Box::pin(async move { + if id.trim().is_empty() { + return common_response(StatusCode::BAD_REQUEST, "Invalid Team ID format"); + } + + if let Err((status, message)) = validate_request(&team) { + return common_response(status, &message); + } + + let repo = TeamsRepository::new(&state); + let thing_id = make_thing_from_enum(ResourceEnum::Teams, &id); + let current_team = match repo.query_team_by_id(&thing_id).await { + Ok(team) => team, + Err(_) => return common_response(StatusCode::NOT_FOUND, "Team not found"), + }; + + let updated_team = TeamsSchema { + id: current_team.id, + leader_id: current_team.leader_id, + is_active: current_team.is_active, + is_deleted: current_team.is_deleted, + created_at: current_team.created_at, + ..TeamsSchema::default() + }.update(team); + + match repo.query_update_team(updated_team).await { + Ok(msg) => common_response(StatusCode::OK, &msg), + Err(e) => common_response(StatusCode::BAD_REQUEST, &e.to_string()), + } + }) + } + fn delete_team( state: &AppState, claims: imphnen_libs::jsonwebtoken::Claims, @@ -477,7 +521,7 @@ impl TeamsServiceTrait for TeamsService { ) -> Pin + Send>> { let state = state.to_owned(); Box::pin(async move { - if Uuid::parse_str(&id).is_err() { + if id.trim().is_empty() { return common_response(StatusCode::BAD_REQUEST, "Invalid Team ID format"); } @@ -500,6 +544,31 @@ impl TeamsServiceTrait for TeamsService { }) } + fn delete_team_admin( + state: &AppState, + _claims: imphnen_libs::jsonwebtoken::Claims, + id: String, + ) -> Pin + Send>> { + let state = state.to_owned(); + Box::pin(async move { + if id.trim().is_empty() { + return common_response(StatusCode::BAD_REQUEST, "Invalid Team ID format"); + } + + let repo = TeamsRepository::new(&state); + let thing_id = make_thing_from_enum(ResourceEnum::Teams, &id); + let _team = match repo.query_team_by_id(&thing_id).await { + Ok(team) => team, + Err(_) => return common_response(StatusCode::NOT_FOUND, "Team not found"), + }; + + match repo.query_delete_team(id).await { + Ok(msg) => common_response(StatusCode::OK, &msg), + Err(e) => common_response(StatusCode::BAD_REQUEST, &e.to_string()), + } + }) + } + fn invite_team_members( state: &AppState, claims: imphnen_libs::jsonwebtoken::Claims, @@ -508,7 +577,7 @@ impl TeamsServiceTrait for TeamsService { ) -> Pin + Send>> { let state = state.to_owned(); Box::pin(async move { - if Uuid::parse_str(&team_id).is_err() { + if team_id.trim().is_empty() { return common_response(StatusCode::BAD_REQUEST, "Invalid Team ID format"); } @@ -584,6 +653,80 @@ impl TeamsServiceTrait for TeamsService { }) } + fn invite_team_members_admin( + state: &AppState, + claims: imphnen_libs::jsonwebtoken::Claims, + team_id: String, + invite: TeamInviteRequestDto, + ) -> Pin + Send>> { + let state = state.to_owned(); + Box::pin(async move { + if team_id.trim().is_empty() { + return common_response(StatusCode::BAD_REQUEST, "Invalid Team ID format"); + } + + if let Err((status, message)) = validate_request(&invite) { + return common_response(status, &message); + } + + let repo = TeamsRepository::new(&state); + let users_repo = UsersRepository::new(&state); + let thing_id = make_thing_from_enum(ResourceEnum::Teams, &team_id); + let team = match repo.query_team_by_id(&thing_id).await { + Ok(team) => team, + Err(_) => return common_response(StatusCode::NOT_FOUND, "Team not found"), + }; + + let mut successful_invites = Vec::new(); + let mut failed_invites = Vec::new(); + + for email in invite.member_emails { + let existing_user = users_repo.query_user_by_email(email.clone()).await.ok(); + let is_existing_user = existing_user.is_some(); + let token = Self::generate_invitation_token().await; + let invitation = TeamInvitationsSchema::create( + team_id.clone(), + email.clone(), + claims.user_id.clone(), + token.clone(), + ); + + match repo.query_create_invitation(invitation).await { + Ok(_) => { + let inviter_user = match users_repo.query_user_by_id(&make_thing_from_enum(ResourceEnum::Users, &claims.user_id)).await { + Ok(user) => user, + Err(_) => return common_response(StatusCode::INTERNAL_SERVER_ERROR, "Failed to get inviter user information"), + }; + if let Err(e) = Self::send_invitation_email( + &team.name, + &inviter_user.fullname, + &email, + &token, + is_existing_user, + ).await { + error!("Failed to send invitation email to {}: {}", email, e); + failed_invites.push(email); + } else { + successful_invites.push(email); + } + } + Err(e) => { + error!("Failed to create invitation for {}: {}", email, e); + failed_invites.push(email); + } + } + } + + let response_data = json!({ + "invitations_sent": successful_invites.len(), + "invitations_failed": failed_invites.len(), + "failed_emails": failed_invites + }); + + success_response(ResponseSuccessDto { data: response_data }) + }) + } + fn accept_invitation( state: &AppState, claims: imphnen_libs::jsonwebtoken::Claims, @@ -667,7 +810,7 @@ impl TeamsServiceTrait for TeamsService { ) -> Pin + Send>> { let state = state.to_owned(); Box::pin(async move { - if Uuid::parse_str(&team_id).is_err() { + if team_id.trim().is_empty() { return common_response(StatusCode::BAD_REQUEST, "Invalid Team ID format"); } @@ -725,7 +868,7 @@ impl TeamsServiceTrait for TeamsService { ) -> Pin + Send>> { let state = state.to_owned(); Box::pin(async move { - if Uuid::parse_str(&team_id).is_err() { + if team_id.trim().is_empty() { return common_response(StatusCode::BAD_REQUEST, "Invalid Team ID format"); } @@ -794,7 +937,7 @@ impl TeamsServiceTrait for TeamsService { fn get_admin_team_by_id(state: &AppState, id: String) -> Pin + Send>> { let state = state.to_owned(); Box::pin(async move { - if Uuid::parse_str(&id).is_err() { + if id.trim().is_empty() { return common_response(StatusCode::BAD_REQUEST, "Invalid Team ID format"); } let repo = TeamsRepository::new(&state); @@ -844,7 +987,7 @@ impl TeamsServiceTrait for TeamsService { fn get_admin_team_members(state: &AppState, team_id: String) -> Pin + Send>> { let state = state.to_owned(); Box::pin(async move { - if Uuid::parse_str(&team_id).is_err() { + if team_id.trim().is_empty() { return common_response(StatusCode::BAD_REQUEST, "Invalid Team ID format"); } let repo = TeamsRepository::new(&state);