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:
MythEclipse
2025-10-11 15:06:12 +07:00
parent c10443f881
commit 6ef624c169
11 changed files with 549 additions and 59 deletions
@@ -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<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,
HackathonStatus, HackathonSubmissionsSchema, HackathonTimelineSchema,
SubmissionStatus,
HackathonParticipantSchema,
};
// Hackathon DTOs
@@ -410,16 +411,46 @@ impl From<HackathonSubmissionsSchema> 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<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,
created_at: schema.created_at,
updated_at: schema.updated_at,
@@ -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<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 id: Thing,
pub hackathon_id: Thing,
pub team_id: Thing,
pub project_name: String,
pub description: String,
pub team_id: Option<Thing>,
pub project_name: Option<String>,
pub description: Option<String>,
pub repository_url: Option<String>,
pub demo_url: Option<String>,
pub slides_url: Option<String>,
pub technologies: Vec<String>,
pub submission_status: SubmissionStatus,
pub technologies: Option<Vec<String>>,
pub submission_status: Option<SubmissionStatus>,
pub judge_feedback: Option<String>,
pub submitted_at: DateTime<Utc>,
pub submitted_at: Option<DateTime<Utc>>,
pub is_deleted: bool,
pub created_at: Option<String>,
pub updated_at: Option<String>,
@@ -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<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 {
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()),
@@ -123,6 +123,19 @@ pub trait HackathonServiceTrait: Send + Sync + 'static {
id: String,
state: &AppState,
) -> 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)]
@@ -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 })
}
}
})
}
}