feat: Enhance hackathon submission and participant management
- Updated HackathonSubmissionsSchema to use Option types for team_id, project_name, description, technologies, submission_status, and submitted_at. - Modified seed_hackathons and seed_test_submission scripts to accommodate new optional fields. - Added routes for participant registration and listing in hackathon_controller. - Implemented register_participant and list_participants functions in hackathon_controller. - Introduced HackathonParticipantSchema and corresponding DTOs for participant management. - Enhanced HackathonRepository with CRUD operations for hackathon participants. - Updated HackathonService to include methods for participant registration and listing. - Refactored TeamsService to allow admin-level updates and invitations, bypassing leader-only restrictions. - Added validation for member emails in TeamsCreateRequestDto and TeamInviteRequestDto.
This commit is contained in:
@@ -334,15 +334,15 @@ async fn main() -> Result<(), Box<dyn Error>> {
|
|||||||
id: Thing::from(("app_hackathon_submissions", submission_id.as_str())),
|
id: Thing::from(("app_hackathon_submissions", submission_id.as_str())),
|
||||||
hackathon_id: Thing::from(("app_hackathons", hackathon_id)),
|
hackathon_id: Thing::from(("app_hackathons", hackathon_id)),
|
||||||
judge_feedback: None,
|
judge_feedback: None,
|
||||||
team_id: Thing::from(("app_teams", team_id)),
|
team_id: Some(Thing::from(("app_teams", team_id))),
|
||||||
project_name: project_name.into(),
|
project_name: Some(project_name.into()),
|
||||||
description: description.into(),
|
description: Some(description.into()),
|
||||||
repository_url,
|
repository_url,
|
||||||
demo_url,
|
demo_url,
|
||||||
slides_url,
|
slides_url,
|
||||||
technologies,
|
technologies: Some(technologies),
|
||||||
submission_status,
|
submission_status: Some(submission_status),
|
||||||
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,
|
is_deleted: false,
|
||||||
created_at: Some(get_iso_date()),
|
created_at: Some(get_iso_date()),
|
||||||
updated_at: Some(get_iso_date()),
|
updated_at: Some(get_iso_date()),
|
||||||
|
|||||||
@@ -334,16 +334,16 @@ async fn main() -> Result<(), Box<dyn Error>> {
|
|||||||
let submission = HackathonSubmissionsSchema {
|
let submission = HackathonSubmissionsSchema {
|
||||||
id: Thing::from(("app_hackathon_submissions", submission_id.as_str())),
|
id: Thing::from(("app_hackathon_submissions", submission_id.as_str())),
|
||||||
hackathon_id: Thing::from(("app_hackathons", hackathon_id)),
|
hackathon_id: Thing::from(("app_hackathons", hackathon_id)),
|
||||||
team_id: Thing::from(("app_teams", team_id)),
|
team_id: Some(Thing::from(("app_teams", team_id))),
|
||||||
project_name: project_name.into(),
|
project_name: Some(project_name.into()),
|
||||||
description: description.into(),
|
description: Some(description.into()),
|
||||||
repository_url,
|
repository_url,
|
||||||
demo_url,
|
demo_url,
|
||||||
slides_url,
|
slides_url,
|
||||||
technologies,
|
technologies: Some(technologies),
|
||||||
submission_status,
|
submission_status: Some(submission_status),
|
||||||
judge_feedback: None,
|
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,
|
is_deleted: false,
|
||||||
created_at: Some(get_iso_date()),
|
created_at: Some(get_iso_date()),
|
||||||
updated_at: Some(get_iso_date()),
|
updated_at: Some(get_iso_date()),
|
||||||
|
|||||||
@@ -648,4 +648,39 @@ pub fn hackathon_routes() -> Router {
|
|||||||
.route("/submissions/{id}", put(update_hackathon_submission))
|
.route("/submissions/{id}", put(update_hackathon_submission))
|
||||||
.route("/submissions/{id}/submit", post(submit_hackathon_submission))
|
.route("/submissions/{id}/submit", post(submit_hackathon_submission))
|
||||||
.route("/submissions/{id}", delete(delete_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<AppState>,
|
||||||
|
Path(hackathon_id): Path<String>,
|
||||||
|
Json(payload): Json<RegisterParticipantRequestDto>,
|
||||||
|
) -> 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<AppState>,
|
||||||
|
Path(hackathon_id): Path<String>,
|
||||||
|
Query(meta): Query<imphnen_libs::MetaRequestDto>,
|
||||||
|
) -> 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(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -7,6 +7,7 @@ use crate::v1::hackathon::hackathon_schema::{
|
|||||||
HackathonEventType, HackathonEventsSchema, HackathonPhase, HackathonSchema,
|
HackathonEventType, HackathonEventsSchema, HackathonPhase, HackathonSchema,
|
||||||
HackathonStatus, HackathonSubmissionsSchema, HackathonTimelineSchema,
|
HackathonStatus, HackathonSubmissionsSchema, HackathonTimelineSchema,
|
||||||
SubmissionStatus,
|
SubmissionStatus,
|
||||||
|
HackathonParticipantSchema,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Hackathon DTOs
|
// Hackathon DTOs
|
||||||
@@ -410,16 +411,46 @@ impl From<HackathonSubmissionsSchema> for HackathonSubmissionDto {
|
|||||||
Self {
|
Self {
|
||||||
id: schema.id.id.to_raw(),
|
id: schema.id.id.to_raw(),
|
||||||
hackathon_id: schema.hackathon_id.id.to_raw(),
|
hackathon_id: schema.hackathon_id.id.to_raw(),
|
||||||
team_id: schema.team_id.id.to_raw(),
|
team_id: schema.team_id.map(|t| t.id.to_raw()).unwrap_or_default(),
|
||||||
project_name: schema.project_name,
|
project_name: schema.project_name.unwrap_or_default(),
|
||||||
description: schema.description,
|
description: schema.description.unwrap_or_default(),
|
||||||
repository_url: schema.repository_url,
|
repository_url: schema.repository_url,
|
||||||
demo_url: schema.demo_url,
|
demo_url: schema.demo_url,
|
||||||
slides_url: schema.slides_url,
|
slides_url: schema.slides_url,
|
||||||
technologies: schema.technologies,
|
technologies: schema.technologies.unwrap_or_default(),
|
||||||
submission_status: schema.submission_status,
|
submission_status: schema.submission_status.unwrap_or(super::hackathon_schema::SubmissionStatus::Draft),
|
||||||
judge_feedback: schema.judge_feedback,
|
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<String>,
|
||||||
|
pub updated_at: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<HackathonParticipantSchema> 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,
|
is_deleted: schema.is_deleted,
|
||||||
created_at: schema.created_at,
|
created_at: schema.created_at,
|
||||||
updated_at: schema.updated_at,
|
updated_at: schema.updated_at,
|
||||||
|
|||||||
@@ -524,16 +524,16 @@ impl<'a> HackathonRepository<'a> {
|
|||||||
let schema = HackathonSubmissionsSchema {
|
let schema = HackathonSubmissionsSchema {
|
||||||
id: Thing::from((table.clone(), id.clone())),
|
id: Thing::from((table.clone(), id.clone())),
|
||||||
hackathon_id: Thing::from(("app_hackathons".to_string(), normalized_hackathon_id)),
|
hackathon_id: Thing::from(("app_hackathons".to_string(), normalized_hackathon_id)),
|
||||||
team_id: Thing::from(("app_teams".to_string(), normalized_team_id)),
|
team_id: Some(Thing::from(("app_teams".to_string(), normalized_team_id))),
|
||||||
project_name: submission.project_name,
|
project_name: Some(submission.project_name),
|
||||||
description: submission.description,
|
description: Some(submission.description),
|
||||||
repository_url: submission.repository_url,
|
repository_url: submission.repository_url,
|
||||||
demo_url: submission.demo_url,
|
demo_url: submission.demo_url,
|
||||||
slides_url: submission.slides_url,
|
slides_url: submission.slides_url,
|
||||||
technologies: submission.technologies,
|
technologies: Some(submission.technologies),
|
||||||
submission_status: super::hackathon_schema::SubmissionStatus::Draft,
|
submission_status: Some(super::hackathon_schema::SubmissionStatus::Draft),
|
||||||
judge_feedback: None,
|
judge_feedback: None,
|
||||||
submitted_at: chrono::Utc::now(),
|
submitted_at: Some(chrono::Utc::now()),
|
||||||
is_deleted: false,
|
is_deleted: false,
|
||||||
created_at: Some(get_iso_date()),
|
created_at: Some(get_iso_date()),
|
||||||
updated_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)
|
let builder = QueryListBuilder::new(&self.state.surrealdb_ws, &table, &meta)
|
||||||
.with_condition("is_deleted = false")
|
.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))
|
.with_condition(&format!("hackathon_id = type::thing('app_hackathons', '{}')", normalized_hackathon_id))
|
||||||
.search_field("project_name")
|
.search_field("project_name")
|
||||||
.select_fields(vec!["*"]);
|
.select_fields(vec!["*"]);
|
||||||
@@ -578,6 +584,12 @@ impl<'a> HackathonRepository<'a> {
|
|||||||
|
|
||||||
let builder = QueryListBuilder::new(&self.state.surrealdb_ws, &table, &meta)
|
let builder = QueryListBuilder::new(&self.state.surrealdb_ws, &table, &meta)
|
||||||
.with_condition("is_deleted = false")
|
.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))
|
.with_condition(&format!("team_id = type::thing('app_teams', '{}')", normalized_team_id))
|
||||||
.search_field("project_name")
|
.search_field("project_name")
|
||||||
.select_fields(vec!["*"]);
|
.select_fields(vec!["*"]);
|
||||||
@@ -598,7 +610,7 @@ impl<'a> HackathonRepository<'a> {
|
|||||||
bail!("Submission not found");
|
bail!("Submission not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
existing.submission_status = status;
|
existing.submission_status = Some(status);
|
||||||
existing.judge_feedback = feedback;
|
existing.judge_feedback = feedback;
|
||||||
existing.updated_at = Some(get_iso_date());
|
existing.updated_at = Some(get_iso_date());
|
||||||
|
|
||||||
@@ -627,10 +639,10 @@ impl<'a> HackathonRepository<'a> {
|
|||||||
|
|
||||||
// Apply updates
|
// Apply updates
|
||||||
if let Some(project_name) = updates.project_name {
|
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 {
|
if let Some(description) = updates.description {
|
||||||
existing.description = description;
|
existing.description = Some(description);
|
||||||
}
|
}
|
||||||
if let Some(repository_url) = updates.repository_url {
|
if let Some(repository_url) = updates.repository_url {
|
||||||
existing.repository_url = Some(repository_url);
|
existing.repository_url = Some(repository_url);
|
||||||
@@ -642,7 +654,7 @@ impl<'a> HackathonRepository<'a> {
|
|||||||
existing.slides_url = Some(slides_url);
|
existing.slides_url = Some(slides_url);
|
||||||
}
|
}
|
||||||
if let Some(technologies) = updates.technologies {
|
if let Some(technologies) = updates.technologies {
|
||||||
existing.technologies = technologies;
|
existing.technologies = Some(technologies);
|
||||||
}
|
}
|
||||||
|
|
||||||
existing.updated_at = Some(get_iso_date());
|
existing.updated_at = Some(get_iso_date());
|
||||||
@@ -693,8 +705,8 @@ impl<'a> HackathonRepository<'a> {
|
|||||||
bail!("Submission not found");
|
bail!("Submission not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
existing.submission_status = super::hackathon_schema::SubmissionStatus::Submitted;
|
existing.submission_status = Some(super::hackathon_schema::SubmissionStatus::Submitted);
|
||||||
existing.submitted_at = chrono::Utc::now();
|
existing.submitted_at = Some(chrono::Utc::now());
|
||||||
existing.updated_at = Some(get_iso_date());
|
existing.updated_at = Some(get_iso_date());
|
||||||
|
|
||||||
info!(query = %format!("UPDATE {} SET submission_status = 'Submitted' WHERE id = '{}'", table, id), "Executing SurrealDB query");
|
info!(query = %format!("UPDATE {} SET submission_status = 'Submitted' WHERE id = '{}'", table, id), "Executing SurrealDB query");
|
||||||
@@ -749,3 +761,53 @@ impl<'a> HackathonRepository<'a> {
|
|||||||
Ok(timeline)
|
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<super::hackathon_schema::HackathonParticipantSchema> {
|
||||||
|
// 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<super::hackathon_schema::HackathonParticipantSchema> = 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<imphnen_libs::ResponseListSuccessDto<Vec<super::hackathon_schema::HackathonParticipantSchema>>> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -66,16 +66,16 @@ pub struct HackathonTimelineSchema {
|
|||||||
pub struct HackathonSubmissionsSchema {
|
pub struct HackathonSubmissionsSchema {
|
||||||
pub id: Thing,
|
pub id: Thing,
|
||||||
pub hackathon_id: Thing,
|
pub hackathon_id: Thing,
|
||||||
pub team_id: Thing,
|
pub team_id: Option<Thing>,
|
||||||
pub project_name: String,
|
pub project_name: Option<String>,
|
||||||
pub description: String,
|
pub description: Option<String>,
|
||||||
pub repository_url: Option<String>,
|
pub repository_url: Option<String>,
|
||||||
pub demo_url: Option<String>,
|
pub demo_url: Option<String>,
|
||||||
pub slides_url: Option<String>,
|
pub slides_url: Option<String>,
|
||||||
pub technologies: Vec<String>,
|
pub technologies: Option<Vec<String>>,
|
||||||
pub submission_status: SubmissionStatus,
|
pub submission_status: Option<SubmissionStatus>,
|
||||||
pub judge_feedback: Option<String>,
|
pub judge_feedback: Option<String>,
|
||||||
pub submitted_at: DateTime<Utc>,
|
pub submitted_at: Option<DateTime<Utc>>,
|
||||||
pub is_deleted: bool,
|
pub is_deleted: bool,
|
||||||
pub created_at: Option<String>,
|
pub created_at: Option<String>,
|
||||||
pub updated_at: Option<String>,
|
pub updated_at: Option<String>,
|
||||||
@@ -186,6 +186,32 @@ pub enum SubmissionStatus {
|
|||||||
Rejected,
|
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<String>,
|
||||||
|
pub updated_at: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
impl Default for HackathonSchema {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
HackathonSchema {
|
HackathonSchema {
|
||||||
@@ -266,16 +292,16 @@ impl Default for HackathonSubmissionsSchema {
|
|||||||
&surrealdb::Uuid::new_v4().to_string(),
|
&surrealdb::Uuid::new_v4().to_string(),
|
||||||
),
|
),
|
||||||
hackathon_id: Thing::from(("app_hackathons".to_string(), surrealdb::sql::Id::rand())),
|
hackathon_id: Thing::from(("app_hackathons".to_string(), surrealdb::sql::Id::rand())),
|
||||||
team_id: Thing::from(("app_teams".to_string(), surrealdb::sql::Id::rand())),
|
team_id: Some(Thing::from(("app_teams".to_string(), surrealdb::sql::Id::rand()))),
|
||||||
project_name: String::new(),
|
project_name: Some(String::new()),
|
||||||
description: String::new(),
|
description: Some(String::new()),
|
||||||
repository_url: None,
|
repository_url: None,
|
||||||
demo_url: None,
|
demo_url: None,
|
||||||
slides_url: None,
|
slides_url: None,
|
||||||
technologies: vec![],
|
technologies: Some(vec![]),
|
||||||
submission_status: SubmissionStatus::Draft,
|
submission_status: Some(SubmissionStatus::Draft),
|
||||||
judge_feedback: None,
|
judge_feedback: None,
|
||||||
submitted_at: Utc::now(),
|
submitted_at: Some(Utc::now()),
|
||||||
is_deleted: false,
|
is_deleted: false,
|
||||||
created_at: Some(get_iso_date()),
|
created_at: Some(get_iso_date()),
|
||||||
updated_at: Some(get_iso_date()),
|
updated_at: Some(get_iso_date()),
|
||||||
|
|||||||
@@ -123,6 +123,19 @@ pub trait HackathonServiceTrait: Send + Sync + 'static {
|
|||||||
id: String,
|
id: String,
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
) -> Pin<Box<dyn Future<Output = Result<ResponseSuccessDto<String>, ErrorDto>> + Send>>;
|
) -> Pin<Box<dyn Future<Output = Result<ResponseSuccessDto<String>, ErrorDto>> + Send>>;
|
||||||
|
|
||||||
|
// Participants
|
||||||
|
fn register_participant(
|
||||||
|
hackathon_id: String,
|
||||||
|
payload: super::hackathon_dto::RegisterParticipantRequestDto,
|
||||||
|
state: &AppState,
|
||||||
|
) -> Pin<Box<dyn Future<Output = Result<ResponseSuccessDto<super::hackathon_dto::HackathonParticipantDto>, ErrorDto>> + Send>>;
|
||||||
|
|
||||||
|
fn list_participants(
|
||||||
|
meta: MetaRequestDto,
|
||||||
|
hackathon_id: String,
|
||||||
|
state: &AppState,
|
||||||
|
) -> ListServiceFut<super::hackathon_dto::HackathonParticipantDto>;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -981,4 +994,58 @@ impl HackathonServiceTrait for HackathonService {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn register_participant(
|
||||||
|
hackathon_id: String,
|
||||||
|
payload: super::hackathon_dto::RegisterParticipantRequestDto,
|
||||||
|
state: &AppState,
|
||||||
|
) -> Pin<Box<dyn Future<Output = Result<ResponseSuccessDto<super::hackathon_dto::HackathonParticipantDto>, 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<super::hackathon_dto::HackathonParticipantDto> {
|
||||||
|
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<super::hackathon_dto::HackathonParticipantDto> = 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -150,7 +150,8 @@ pub async fn update_team(
|
|||||||
Json(payload): Json<TeamsUpdateRequestDto>,
|
Json(payload): Json<TeamsUpdateRequestDto>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
with_admin_perms(headers, Extension(state), move |claims, state| {
|
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
|
}).await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,7 +175,7 @@ pub async fn delete_team(
|
|||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
with_admin_perms(headers, Extension(state), move |claims, state| {
|
with_admin_perms(headers, Extension(state), move |claims, state| {
|
||||||
TeamsService::delete_team(&state, claims, id)
|
TeamsService::delete_team_admin(&state, claims, id)
|
||||||
}).await
|
}).await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,7 +201,7 @@ pub async fn invite_team_members(
|
|||||||
Json(payload): Json<TeamInviteRequestDto>,
|
Json(payload): Json<TeamInviteRequestDto>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
with_admin_perms(headers, Extension(state), move |claims, state| {
|
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
|
}).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use crate::{
|
|||||||
TeamMemberDto, TeamsSearchQueryDto, PublicTeamsListItemDto, PublicTeamsDetailItemDto,
|
TeamMemberDto, TeamsSearchQueryDto, PublicTeamsListItemDto, PublicTeamsDetailItemDto,
|
||||||
AdminTeamsListItemDto, AdminTeamsDetailItemDto, PermissionsEnum
|
AdminTeamsListItemDto, AdminTeamsDetailItemDto, PermissionsEnum
|
||||||
};
|
};
|
||||||
|
use super::super::teams::{TeamsRepository, TeamMembersSchema};
|
||||||
use axum::response::Response;
|
use axum::response::Response;
|
||||||
use axum::extract::Path;
|
use axum::extract::Path;
|
||||||
use axum::http::HeaderMap;
|
use axum::http::HeaderMap;
|
||||||
@@ -135,7 +136,104 @@ pub async fn put_update_team(
|
|||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
Json(payload): Json<TeamsUpdateRequestDto>,
|
Json(payload): Json<TeamsUpdateRequestDto>,
|
||||||
) -> impl IntoResponse {
|
) -> 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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn post_add_team_member(
|
||||||
|
headers: HeaderMap,
|
||||||
|
Extension(state): Extension<AppState>,
|
||||||
|
Path(team_id): Path<String>,
|
||||||
|
Json(payload): Json<AddTeamMemberRequestDto>,
|
||||||
|
) -> 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<AppState>,
|
||||||
|
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(
|
#[utoipa::path(
|
||||||
@@ -384,6 +482,8 @@ pub fn teams_router() -> Router {
|
|||||||
.route("/accept/{token}", axum::routing::post(post_accept_invitation))
|
.route("/accept/{token}", axum::routing::post(post_accept_invitation))
|
||||||
.route("/search", axum::routing::get(get_public_team_search))
|
.route("/search", axum::routing::get(get_public_team_search))
|
||||||
.route("/{id}/members", axum::routing::get(get_team_members))
|
.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("/{id}/leave", axum::routing::post(post_leave_team))
|
||||||
.route("/leave-me", axum::routing::post(post_leave_current_team))
|
.route("/leave-me", axum::routing::post(post_leave_current_team))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,28 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use surrealdb::sql::Thing;
|
use surrealdb::sql::Thing;
|
||||||
use utoipa::ToSchema;
|
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<String> 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<String>) -> 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;
|
use imphnen_entities::users::UsersDetailQueryDto;
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)]
|
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)]
|
||||||
@@ -39,7 +60,8 @@ pub struct TeamsCreateRequestDto {
|
|||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub github_url: Option<String>,
|
pub github_url: Option<String>,
|
||||||
|
|
||||||
#[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<String>,
|
pub member_emails: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,6 +105,7 @@ pub struct TeamsUpdateRequestDto {
|
|||||||
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)]
|
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)]
|
||||||
pub struct TeamInviteRequestDto {
|
pub struct TeamInviteRequestDto {
|
||||||
#[validate(length(min = 1, message = "Member emails cannot be empty"))]
|
#[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<String>,
|
pub member_emails: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -455,3 +478,5 @@ impl TeamsDetailQueryDto {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// (previous custom validator removed; using validator::email(each = true) attribute)
|
||||||
|
|||||||
@@ -29,8 +29,11 @@ pub trait TeamsServiceTrait: Send + Sync + 'static {
|
|||||||
fn get_public_team_by_id(state: &AppState, id: String) -> Pin<Box<dyn Future<Output = Response> + Send>>;
|
fn get_public_team_by_id(state: &AppState, id: String) -> Pin<Box<dyn Future<Output = Response> + Send>>;
|
||||||
fn create_team(state: &AppState, claims: imphnen_libs::jsonwebtoken::Claims, new_team: TeamsCreateRequestDto) -> Pin<Box<dyn Future<Output = Response> + Send>>;
|
fn create_team(state: &AppState, claims: imphnen_libs::jsonwebtoken::Claims, new_team: TeamsCreateRequestDto) -> Pin<Box<dyn Future<Output = Response> + Send>>;
|
||||||
fn update_team(state: &AppState, claims: imphnen_libs::jsonwebtoken::Claims, id: String, team: TeamsUpdateRequestDto) -> Pin<Box<dyn Future<Output = Response> + Send>>;
|
fn update_team(state: &AppState, claims: imphnen_libs::jsonwebtoken::Claims, id: String, team: TeamsUpdateRequestDto) -> Pin<Box<dyn Future<Output = Response> + Send>>;
|
||||||
|
fn update_team_admin(state: &AppState, claims: imphnen_libs::jsonwebtoken::Claims, id: String, team: TeamsUpdateRequestDto) -> Pin<Box<dyn Future<Output = Response> + Send>>;
|
||||||
fn delete_team(state: &AppState, claims: imphnen_libs::jsonwebtoken::Claims, id: String) -> Pin<Box<dyn Future<Output = Response> + Send>>;
|
fn delete_team(state: &AppState, claims: imphnen_libs::jsonwebtoken::Claims, id: String) -> Pin<Box<dyn Future<Output = Response> + Send>>;
|
||||||
|
fn delete_team_admin(state: &AppState, claims: imphnen_libs::jsonwebtoken::Claims, id: String) -> Pin<Box<dyn Future<Output = Response> + Send>>;
|
||||||
fn invite_team_members(state: &AppState, claims: imphnen_libs::jsonwebtoken::Claims, team_id: String, invite: TeamInviteRequestDto) -> Pin<Box<dyn Future<Output = Response> + Send>>;
|
fn invite_team_members(state: &AppState, claims: imphnen_libs::jsonwebtoken::Claims, team_id: String, invite: TeamInviteRequestDto) -> Pin<Box<dyn Future<Output = Response> + Send>>;
|
||||||
|
fn invite_team_members_admin(state: &AppState, claims: imphnen_libs::jsonwebtoken::Claims, team_id: String, invite: TeamInviteRequestDto) -> Pin<Box<dyn Future<Output = Response> + Send>>;
|
||||||
fn accept_invitation(state: &AppState, claims: imphnen_libs::jsonwebtoken::Claims, accept: TeamAcceptInvitationRequestDto) -> Pin<Box<dyn Future<Output = Response> + Send>>;
|
fn accept_invitation(state: &AppState, claims: imphnen_libs::jsonwebtoken::Claims, accept: TeamAcceptInvitationRequestDto) -> Pin<Box<dyn Future<Output = Response> + Send>>;
|
||||||
fn get_team_members(state: &AppState, claims: imphnen_libs::jsonwebtoken::Claims, team_id: String) -> Pin<Box<dyn Future<Output = Response> + Send>>;
|
fn get_team_members(state: &AppState, claims: imphnen_libs::jsonwebtoken::Claims, team_id: String) -> Pin<Box<dyn Future<Output = Response> + Send>>;
|
||||||
fn leave_team(state: &AppState, claims: imphnen_libs::jsonwebtoken::Claims, team_id: String) -> Pin<Box<dyn Future<Output = Response> + Send>>;
|
fn leave_team(state: &AppState, claims: imphnen_libs::jsonwebtoken::Claims, team_id: String) -> Pin<Box<dyn Future<Output = Response> + Send>>;
|
||||||
@@ -128,7 +131,7 @@ impl TeamsServiceTrait for TeamsService {
|
|||||||
fn get_team_by_id(state: &AppState, id: String) -> Pin<Box<dyn Future<Output = Response> + Send>> {
|
fn get_team_by_id(state: &AppState, id: String) -> Pin<Box<dyn Future<Output = Response> + Send>> {
|
||||||
let state = state.to_owned();
|
let state = state.to_owned();
|
||||||
Box::pin(async move {
|
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");
|
return common_response(StatusCode::BAD_REQUEST, "Invalid Team ID format");
|
||||||
}
|
}
|
||||||
let repo = TeamsRepository::new(&state);
|
let repo = TeamsRepository::new(&state);
|
||||||
@@ -195,7 +198,7 @@ impl TeamsServiceTrait for TeamsService {
|
|||||||
fn get_member_team_by_id(state: &AppState, id: String) -> Pin<Box<dyn Future<Output = Response> + Send>> {
|
fn get_member_team_by_id(state: &AppState, id: String) -> Pin<Box<dyn Future<Output = Response> + Send>> {
|
||||||
let state = state.to_owned();
|
let state = state.to_owned();
|
||||||
Box::pin(async move {
|
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");
|
return common_response(StatusCode::BAD_REQUEST, "Invalid Team ID format");
|
||||||
}
|
}
|
||||||
let repo = TeamsRepository::new(&state);
|
let repo = TeamsRepository::new(&state);
|
||||||
@@ -303,7 +306,7 @@ impl TeamsServiceTrait for TeamsService {
|
|||||||
fn get_public_team_by_id(state: &AppState, id: String) -> Pin<Box<dyn Future<Output = Response> + Send>> {
|
fn get_public_team_by_id(state: &AppState, id: String) -> Pin<Box<dyn Future<Output = Response> + Send>> {
|
||||||
let state = state.to_owned();
|
let state = state.to_owned();
|
||||||
Box::pin(async move {
|
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");
|
return common_response(StatusCode::BAD_REQUEST, "Invalid Team ID format");
|
||||||
}
|
}
|
||||||
let repo = TeamsRepository::new(&state);
|
let repo = TeamsRepository::new(&state);
|
||||||
@@ -416,7 +419,7 @@ impl TeamsServiceTrait for TeamsService {
|
|||||||
"failed_emails": failed_invites
|
"failed_emails": failed_invites
|
||||||
});
|
});
|
||||||
|
|
||||||
success_response(ResponseSuccessDto { data: response_data })
|
imphnen_utils::success_created_response(ResponseSuccessDto { data: response_data })
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
error!("Failed to create team: {}", err);
|
error!("Failed to create team: {}", err);
|
||||||
@@ -434,7 +437,7 @@ impl TeamsServiceTrait for TeamsService {
|
|||||||
) -> Pin<Box<dyn Future<Output = Response> + Send>> {
|
) -> Pin<Box<dyn Future<Output = Response> + Send>> {
|
||||||
let state = state.to_owned();
|
let state = state.to_owned();
|
||||||
Box::pin(async move {
|
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");
|
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"),
|
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 {
|
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");
|
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<Box<dyn Future<Output = Response> + 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(
|
fn delete_team(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
claims: imphnen_libs::jsonwebtoken::Claims,
|
claims: imphnen_libs::jsonwebtoken::Claims,
|
||||||
@@ -477,7 +521,7 @@ impl TeamsServiceTrait for TeamsService {
|
|||||||
) -> Pin<Box<dyn Future<Output = Response> + Send>> {
|
) -> Pin<Box<dyn Future<Output = Response> + Send>> {
|
||||||
let state = state.to_owned();
|
let state = state.to_owned();
|
||||||
Box::pin(async move {
|
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");
|
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<Box<dyn Future<Output = Response> + 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(
|
fn invite_team_members(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
claims: imphnen_libs::jsonwebtoken::Claims,
|
claims: imphnen_libs::jsonwebtoken::Claims,
|
||||||
@@ -508,7 +577,7 @@ impl TeamsServiceTrait for TeamsService {
|
|||||||
) -> Pin<Box<dyn Future<Output = Response> + Send>> {
|
) -> Pin<Box<dyn Future<Output = Response> + Send>> {
|
||||||
let state = state.to_owned();
|
let state = state.to_owned();
|
||||||
Box::pin(async move {
|
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");
|
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<Box<dyn Future<Output = Response> + 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(
|
fn accept_invitation(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
claims: imphnen_libs::jsonwebtoken::Claims,
|
claims: imphnen_libs::jsonwebtoken::Claims,
|
||||||
@@ -667,7 +810,7 @@ impl TeamsServiceTrait for TeamsService {
|
|||||||
) -> Pin<Box<dyn Future<Output = Response> + Send>> {
|
) -> Pin<Box<dyn Future<Output = Response> + Send>> {
|
||||||
let state = state.to_owned();
|
let state = state.to_owned();
|
||||||
Box::pin(async move {
|
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");
|
return common_response(StatusCode::BAD_REQUEST, "Invalid Team ID format");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -725,7 +868,7 @@ impl TeamsServiceTrait for TeamsService {
|
|||||||
) -> Pin<Box<dyn Future<Output = Response> + Send>> {
|
) -> Pin<Box<dyn Future<Output = Response> + Send>> {
|
||||||
let state = state.to_owned();
|
let state = state.to_owned();
|
||||||
Box::pin(async move {
|
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");
|
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<Box<dyn Future<Output = Response> + Send>> {
|
fn get_admin_team_by_id(state: &AppState, id: String) -> Pin<Box<dyn Future<Output = Response> + Send>> {
|
||||||
let state = state.to_owned();
|
let state = state.to_owned();
|
||||||
Box::pin(async move {
|
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");
|
return common_response(StatusCode::BAD_REQUEST, "Invalid Team ID format");
|
||||||
}
|
}
|
||||||
let repo = TeamsRepository::new(&state);
|
let repo = TeamsRepository::new(&state);
|
||||||
@@ -844,7 +987,7 @@ impl TeamsServiceTrait for TeamsService {
|
|||||||
fn get_admin_team_members(state: &AppState, team_id: String) -> Pin<Box<dyn Future<Output = Response> + Send>> {
|
fn get_admin_team_members(state: &AppState, team_id: String) -> Pin<Box<dyn Future<Output = Response> + Send>> {
|
||||||
let state = state.to_owned();
|
let state = state.to_owned();
|
||||||
Box::pin(async move {
|
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");
|
return common_response(StatusCode::BAD_REQUEST, "Invalid Team ID format");
|
||||||
}
|
}
|
||||||
let repo = TeamsRepository::new(&state);
|
let repo = TeamsRepository::new(&state);
|
||||||
|
|||||||
Reference in New Issue
Block a user