postgress
This commit is contained in:
@@ -1,44 +0,0 @@
|
||||
[package]
|
||||
name = "imphnen-hackathon"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
imphnen-libs.workspace = true
|
||||
imphnen-utils.workspace = true
|
||||
|
||||
imphnen-entities.workspace = true
|
||||
imphnen-iam.workspace = true
|
||||
async-trait.workspace = true
|
||||
axum.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json = { workspace = true }
|
||||
oauth2 = { workspace = true }
|
||||
reqwest = { workspace = true, features = ["json"] }
|
||||
utoipa.workspace = true
|
||||
lazy_static.workspace = true
|
||||
regex.workspace = true
|
||||
validator.workspace = true
|
||||
axum-test.workspace = true
|
||||
surrealdb.workspace = true
|
||||
rand.workspace = true
|
||||
tokio.workspace = true
|
||||
chrono.workspace = true
|
||||
anyhow.workspace = true
|
||||
tower-http.workspace = true
|
||||
utoipa-swagger-ui.workspace = true
|
||||
strum.workspace = true
|
||||
strum_macros.workspace = true
|
||||
log.workspace = true
|
||||
once_cell.workspace = true
|
||||
tracing.workspace = true
|
||||
uuid.workspace = true
|
||||
axum-extra.workspace = true
|
||||
tower.workspace = true
|
||||
futures.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
dotenvy.workspace = true
|
||||
tokio-test = { workspace = true }
|
||||
mockall = { workspace = true }
|
||||
http-body-util.workspace = true
|
||||
@@ -1,21 +0,0 @@
|
||||
pub mod v1;
|
||||
|
||||
// Re-export core entity types used across the hackathon system
|
||||
pub use imphnen_entities::{
|
||||
CountResult,
|
||||
Error,
|
||||
ErrorDto,
|
||||
MessageResponseDto,
|
||||
MetaRequestDto,
|
||||
MetaResponseDto,
|
||||
ResponseListSuccessDto,
|
||||
ResponseSuccessDto,
|
||||
};
|
||||
|
||||
// Explicitly import only what we need from libs and utils to avoid pollution
|
||||
pub use imphnen_libs::{
|
||||
AppState,
|
||||
};
|
||||
|
||||
// Re-export public v1 API
|
||||
pub use v1::hackathon::hackathon_controller::hackathon_routes;
|
||||
@@ -1,277 +0,0 @@
|
||||
use super::hackathon_dto::{
|
||||
HackathonCreateRequestDto, HackathonDto, HackathonEventCreateRequestDto,
|
||||
HackathonTimelineCreateRequestDto,
|
||||
};
|
||||
use super::hackathon_repository::HackathonRepository;
|
||||
use super::hackathon_audit_schema::{AuditAction, HackathonAuditLogSchema};
|
||||
use super::hackathon_audit_repository::HackathonAuditRepository;
|
||||
use super::hackathon_validation::{validate_timeline_phases, validate_dates, validate_organizers, validate_prizes, MAX_EVENTS_PER_HACKATHON};
|
||||
use crate::{AppState, ResponseSuccessDto, ErrorDto};
|
||||
use axum::http::StatusCode;
|
||||
use anyhow::Result;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
use validator::Validate;
|
||||
|
||||
/// Request DTO for atomic hackathon creation with timeline and events
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)]
|
||||
pub struct HackathonCompleteSetupRequestDto {
|
||||
#[validate(nested)]
|
||||
pub hackathon: HackathonCreateRequestDto,
|
||||
|
||||
#[validate(length(min = 1, message = "At least one timeline phase is required"))]
|
||||
pub timelines: Vec<HackathonTimelineCreateRequestDto>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub events: Option<Vec<HackathonEventCreateRequestDto>>,
|
||||
|
||||
/// Actor ID for audit logging
|
||||
pub actor_id: String,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub actor_email: Option<String>,
|
||||
}
|
||||
|
||||
/// Response DTO for complete hackathon setup
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct HackathonCompleteSetupResponseDto {
|
||||
pub hackathon: HackathonDto,
|
||||
pub timelines: Vec<super::hackathon_dto::HackathonTimelineDto>,
|
||||
pub events: Option<Vec<super::hackathon_dto::HackathonEventDto>>,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// Service for atomic hackathon operations
|
||||
pub struct HackathonAtomicService;
|
||||
|
||||
impl HackathonAtomicService {
|
||||
/// Create hackathon with timeline and events atomically
|
||||
/// This ensures all-or-nothing creation - if any step fails, nothing is created
|
||||
pub async fn create_hackathon_complete(
|
||||
payload: HackathonCompleteSetupRequestDto,
|
||||
state: &AppState,
|
||||
) -> Result<ResponseSuccessDto<HackathonCompleteSetupResponseDto>, ErrorDto> {
|
||||
// 1. Validate all inputs before any database operations
|
||||
if let Err((_, error_message)) = 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": error_message })),
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Validate dates
|
||||
if let Err(e) = validate_dates(
|
||||
&payload.hackathon.start_date,
|
||||
&payload.hackathon.end_date,
|
||||
&payload.hackathon.registration_deadline,
|
||||
) {
|
||||
return Err(ErrorDto {
|
||||
status: StatusCode::BAD_REQUEST.as_u16(),
|
||||
message: e.to_string(),
|
||||
details: None,
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Validate organizers
|
||||
if let Err(e) = validate_organizers(&payload.hackathon.organizers) {
|
||||
return Err(ErrorDto {
|
||||
status: StatusCode::BAD_REQUEST.as_u16(),
|
||||
message: e.to_string(),
|
||||
details: None,
|
||||
});
|
||||
}
|
||||
|
||||
// 4. Validate prizes if provided
|
||||
if let Some(ref prizes) = payload.hackathon.prizes {
|
||||
let prize_schemas: Vec<super::hackathon_schema::Prize> = prizes
|
||||
.iter()
|
||||
.map(|p| super::hackathon_schema::Prize {
|
||||
position: p.position,
|
||||
title: p.title.clone(),
|
||||
description: p.description.clone(),
|
||||
value: p.value.clone(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
if let Err(e) = validate_prizes(&prize_schemas) {
|
||||
return Err(ErrorDto {
|
||||
status: StatusCode::BAD_REQUEST.as_u16(),
|
||||
message: e.to_string(),
|
||||
details: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Validate events count if provided
|
||||
if let Some(ref events) = payload.events {
|
||||
if events.len() > MAX_EVENTS_PER_HACKATHON {
|
||||
return Err(ErrorDto {
|
||||
status: StatusCode::BAD_REQUEST.as_u16(),
|
||||
message: format!(
|
||||
"Maximum {} events allowed per hackathon",
|
||||
MAX_EVENTS_PER_HACKATHON
|
||||
),
|
||||
details: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let repo = HackathonRepository::new(state);
|
||||
let audit_repo = HackathonAuditRepository::new(state);
|
||||
|
||||
// 6. Create hackathon first
|
||||
let hackathon = match repo.create_hackathon(payload.hackathon.clone()).await {
|
||||
Ok(h) => h,
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to create hackathon: {}", e);
|
||||
return Err(ErrorDto {
|
||||
status: StatusCode::INTERNAL_SERVER_ERROR.as_u16(),
|
||||
message: "Failed to create hackathon".to_string(),
|
||||
details: Some(serde_json::json!({ "error": e.to_string() })),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
let hackathon_id = hackathon.id.id.to_string();
|
||||
|
||||
// 7. Create timelines - if this fails, we should ideally rollback hackathon
|
||||
let mut created_timelines = Vec::new();
|
||||
for timeline_dto in &payload.timelines {
|
||||
match repo.create_hackathon_timeline(hackathon_id.clone(), timeline_dto.clone()).await {
|
||||
Ok(timeline) => created_timelines.push(timeline),
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to create timeline, attempting cleanup: {}", e);
|
||||
// Attempt to delete hackathon and created timelines
|
||||
let _ = Self::cleanup_failed_creation(
|
||||
&hackathon_id,
|
||||
&created_timelines.iter().map(|t| t.id.id.to_string()).collect::<Vec<_>>(),
|
||||
&[],
|
||||
&repo,
|
||||
)
|
||||
.await;
|
||||
|
||||
return Err(ErrorDto {
|
||||
status: StatusCode::INTERNAL_SERVER_ERROR.as_u16(),
|
||||
message: "Failed to create timeline, changes rolled back".to_string(),
|
||||
details: Some(serde_json::json!({ "error": e.to_string() })),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 8. Validate timeline phases after all are created
|
||||
if let Err(e) = validate_timeline_phases(&hackathon, &created_timelines) {
|
||||
tracing::error!("Timeline validation failed, attempting cleanup: {}", e);
|
||||
let _ = Self::cleanup_failed_creation(
|
||||
&hackathon_id,
|
||||
&created_timelines.iter().map(|t| t.id.id.to_string()).collect::<Vec<_>>(),
|
||||
&[],
|
||||
&repo,
|
||||
)
|
||||
.await;
|
||||
|
||||
return Err(ErrorDto {
|
||||
status: StatusCode::BAD_REQUEST.as_u16(),
|
||||
message: format!("Timeline validation failed: {}", e),
|
||||
details: None,
|
||||
});
|
||||
}
|
||||
|
||||
// 9. Create events if provided
|
||||
let mut created_events = Vec::new();
|
||||
if let Some(ref events) = payload.events {
|
||||
for event_dto in events {
|
||||
match repo.create_hackathon_event(hackathon_id.clone(), event_dto.clone()).await {
|
||||
Ok(event) => created_events.push(event),
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to create event, attempting cleanup: {}", e);
|
||||
let _ = Self::cleanup_failed_creation(
|
||||
&hackathon_id,
|
||||
&created_timelines.iter().map(|t| t.id.id.to_string()).collect::<Vec<_>>(),
|
||||
&created_events.iter().map(|e| e.id.id.to_string()).collect::<Vec<_>>(),
|
||||
&repo,
|
||||
)
|
||||
.await;
|
||||
|
||||
return Err(ErrorDto {
|
||||
status: StatusCode::INTERNAL_SERVER_ERROR.as_u16(),
|
||||
message: "Failed to create event, changes rolled back".to_string(),
|
||||
details: Some(serde_json::json!({ "error": e.to_string() })),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 10. Log audit trail
|
||||
let audit_log = HackathonAuditLogSchema::new(
|
||||
Some(hackathon.id.clone()),
|
||||
AuditAction::HackathonCreated,
|
||||
payload.actor_id.clone(),
|
||||
"hackathon".to_string(),
|
||||
Some(hackathon_id.clone()),
|
||||
)
|
||||
.with_changes(serde_json::to_value(&payload).unwrap_or_default())
|
||||
.with_request_info(None, None, payload.actor_email.clone());
|
||||
|
||||
if let Err(e) = audit_repo.log(audit_log).await {
|
||||
tracing::error!("Failed to create audit log: {}", e);
|
||||
// Don't fail the request if audit logging fails
|
||||
}
|
||||
|
||||
// 11. Return success response
|
||||
let response = HackathonCompleteSetupResponseDto {
|
||||
hackathon: super::hackathon_dto::HackathonDto::from(hackathon),
|
||||
timelines: created_timelines
|
||||
.into_iter()
|
||||
.map(super::hackathon_dto::HackathonTimelineDto::from)
|
||||
.collect(),
|
||||
events: if created_events.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(
|
||||
created_events
|
||||
.into_iter()
|
||||
.map(super::hackathon_dto::HackathonEventDto::from)
|
||||
.collect(),
|
||||
)
|
||||
},
|
||||
message: "Hackathon created successfully with timeline and events".to_string(),
|
||||
};
|
||||
|
||||
Ok(ResponseSuccessDto { data: response })
|
||||
}
|
||||
|
||||
/// Cleanup failed creation by deleting created resources
|
||||
async fn cleanup_failed_creation(
|
||||
hackathon_id: &str,
|
||||
timeline_ids: &[String],
|
||||
event_ids: &[String],
|
||||
repo: &HackathonRepository<'_>,
|
||||
) -> Result<()> {
|
||||
tracing::info!("Starting cleanup for failed hackathon creation");
|
||||
|
||||
// Delete events
|
||||
for event_id in event_ids {
|
||||
if let Err(e) = repo.delete_hackathon_event(event_id.to_string()).await {
|
||||
tracing::error!("Failed to cleanup event {}: {}", event_id, e);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete timelines
|
||||
for timeline_id in timeline_ids {
|
||||
if let Err(e) = repo.delete_hackathon_timeline(timeline_id.to_string()).await {
|
||||
tracing::error!("Failed to cleanup timeline {}: {}", timeline_id, e);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete hackathon
|
||||
if let Err(e) = repo.delete_hackathon(hackathon_id.to_string()).await {
|
||||
tracing::error!("Failed to cleanup hackathon {}: {}", hackathon_id, e);
|
||||
}
|
||||
|
||||
tracing::info!("Cleanup completed");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
use super::hackathon_audit_schema::{AuditAction, HackathonAuditLogSchema};
|
||||
use anyhow::Result;
|
||||
use imphnen_libs::{AppState, MetaRequestDto, ResponseListSuccessDto};
|
||||
use surrealdb::sql::Thing;
|
||||
use tracing::{info, instrument};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct HackathonAuditRepository<'a> {
|
||||
pub state: &'a AppState,
|
||||
}
|
||||
|
||||
impl<'a> HackathonAuditRepository<'a> {
|
||||
pub fn new(state: &'a AppState) -> Self {
|
||||
Self { state }
|
||||
}
|
||||
|
||||
#[instrument(skip(self, log), err)]
|
||||
pub async fn log(&self, log: HackathonAuditLogSchema) -> Result<HackathonAuditLogSchema> {
|
||||
let table = "app_hackathon_audit_logs";
|
||||
let id = log.id.id.to_string();
|
||||
|
||||
info!(
|
||||
action = %log.action,
|
||||
actor_id = %log.actor_id,
|
||||
resource_type = %log.resource_type,
|
||||
"Creating audit log entry"
|
||||
);
|
||||
|
||||
let record: Option<HackathonAuditLogSchema> = self
|
||||
.state
|
||||
.surrealdb_ws
|
||||
.create((table, id.clone()))
|
||||
.content(log.clone())
|
||||
.await?;
|
||||
|
||||
record.ok_or_else(|| anyhow::anyhow!("Failed to create audit log"))
|
||||
}
|
||||
|
||||
#[instrument(skip(self), err)]
|
||||
pub async fn get_logs_by_hackathon(
|
||||
&self,
|
||||
hackathon_id: &Thing,
|
||||
meta: MetaRequestDto,
|
||||
) -> Result<ResponseListSuccessDto<Vec<HackathonAuditLogSchema>>> {
|
||||
let table = "app_hackathon_audit_logs";
|
||||
let page = meta.page.unwrap_or(1);
|
||||
let per_page = meta.per_page.unwrap_or(50);
|
||||
let start = (page - 1) * per_page;
|
||||
|
||||
let condition = format!("hackathon_id = {}", hackathon_id);
|
||||
|
||||
let query = format!(
|
||||
"SELECT * FROM {} WHERE {} ORDER BY timestamp DESC LIMIT {} START {}",
|
||||
table, condition, per_page, start
|
||||
);
|
||||
|
||||
let count_query = format!(
|
||||
"SELECT count() as count FROM {} WHERE {} GROUP ALL",
|
||||
table, condition
|
||||
);
|
||||
|
||||
info!(query = %query, "Executing query to get audit logs");
|
||||
|
||||
let logs: Vec<HackathonAuditLogSchema> = self.state.surrealdb_ws.query(&query).await?.take(0)?;
|
||||
let count_result: Vec<imphnen_entities::common_dto::CountResult> =
|
||||
self.state.surrealdb_ws.query(&count_query).await?.take(0)?;
|
||||
|
||||
let total = count_result.first().map(|r| r.count).unwrap_or(0);
|
||||
|
||||
Ok(ResponseListSuccessDto {
|
||||
data: logs,
|
||||
meta: Some(imphnen_libs::MetaResponseDto {
|
||||
page: Some(page),
|
||||
per_page: Some(per_page),
|
||||
total: Some(total),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
#[instrument(skip(self), err)]
|
||||
pub async fn get_logs_by_actor(
|
||||
&self,
|
||||
actor_id: &str,
|
||||
meta: MetaRequestDto,
|
||||
) -> Result<ResponseListSuccessDto<Vec<HackathonAuditLogSchema>>> {
|
||||
let table = "app_hackathon_audit_logs";
|
||||
let page = meta.page.unwrap_or(1);
|
||||
let per_page = meta.per_page.unwrap_or(50);
|
||||
let start = (page - 1) * per_page;
|
||||
|
||||
let condition = format!("actor_id = '{}'", actor_id);
|
||||
|
||||
let query = format!(
|
||||
"SELECT * FROM {} WHERE {} ORDER BY timestamp DESC LIMIT {} START {}",
|
||||
table, condition, per_page, start
|
||||
);
|
||||
|
||||
let count_query = format!(
|
||||
"SELECT count() as count FROM {} WHERE {} GROUP ALL",
|
||||
table, condition
|
||||
);
|
||||
|
||||
let logs: Vec<HackathonAuditLogSchema> = self.state.surrealdb_ws.query(&query).await?.take(0)?;
|
||||
let count_result: Vec<imphnen_entities::common_dto::CountResult> =
|
||||
self.state.surrealdb_ws.query(&count_query).await?.take(0)?;
|
||||
|
||||
let total = count_result.first().map(|r| r.count).unwrap_or(0);
|
||||
|
||||
Ok(ResponseListSuccessDto {
|
||||
data: logs,
|
||||
meta: Some(imphnen_libs::MetaResponseDto {
|
||||
page: Some(page),
|
||||
per_page: Some(per_page),
|
||||
total: Some(total),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
#[instrument(skip(self), err)]
|
||||
pub async fn get_logs_by_action(
|
||||
&self,
|
||||
action: AuditAction,
|
||||
meta: MetaRequestDto,
|
||||
) -> Result<ResponseListSuccessDto<Vec<HackathonAuditLogSchema>>> {
|
||||
let table = "app_hackathon_audit_logs";
|
||||
let page = meta.page.unwrap_or(1);
|
||||
let per_page = meta.per_page.unwrap_or(50);
|
||||
let start = (page - 1) * per_page;
|
||||
|
||||
let condition = format!("action = '{}'", action.to_string());
|
||||
|
||||
let query = format!(
|
||||
"SELECT * FROM {} WHERE {} ORDER BY timestamp DESC LIMIT {} START {}",
|
||||
table, condition, per_page, start
|
||||
);
|
||||
|
||||
let count_query = format!(
|
||||
"SELECT count() as count FROM {} WHERE {} GROUP ALL",
|
||||
table, condition
|
||||
);
|
||||
|
||||
let logs: Vec<HackathonAuditLogSchema> = self.state.surrealdb_ws.query(&query).await?.take(0)?;
|
||||
let count_result: Vec<imphnen_entities::common_dto::CountResult> =
|
||||
self.state.surrealdb_ws.query(&count_query).await?.take(0)?;
|
||||
|
||||
let total = count_result.first().map(|r| r.count).unwrap_or(0);
|
||||
|
||||
Ok(ResponseListSuccessDto {
|
||||
data: logs,
|
||||
meta: Some(imphnen_libs::MetaResponseDto {
|
||||
page: Some(page),
|
||||
per_page: Some(per_page),
|
||||
total: Some(total),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
#[instrument(skip(self), err)]
|
||||
pub async fn get_all_logs(
|
||||
&self,
|
||||
meta: MetaRequestDto,
|
||||
) -> Result<ResponseListSuccessDto<Vec<HackathonAuditLogSchema>>> {
|
||||
let table = "app_hackathon_audit_logs";
|
||||
let page = meta.page.unwrap_or(1);
|
||||
let per_page = meta.per_page.unwrap_or(50);
|
||||
let start = (page - 1) * per_page;
|
||||
|
||||
let query = format!(
|
||||
"SELECT * FROM {} ORDER BY timestamp DESC LIMIT {} START {}",
|
||||
table, per_page, start
|
||||
);
|
||||
|
||||
let count_query = format!(
|
||||
"SELECT count() as count FROM {} GROUP ALL",
|
||||
table
|
||||
);
|
||||
|
||||
let logs: Vec<HackathonAuditLogSchema> = self.state.surrealdb_ws.query(&query).await?.take(0)?;
|
||||
let count_result: Vec<imphnen_entities::common_dto::CountResult> =
|
||||
self.state.surrealdb_ws.query(&count_query).await?.take(0)?;
|
||||
|
||||
let total = count_result.first().map(|r| r.count).unwrap_or(0);
|
||||
|
||||
Ok(ResponseListSuccessDto {
|
||||
data: logs,
|
||||
meta: Some(imphnen_libs::MetaResponseDto {
|
||||
page: Some(page),
|
||||
per_page: Some(per_page),
|
||||
total: Some(total),
|
||||
}),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,164 +0,0 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use surrealdb::sql::Thing;
|
||||
use imphnen_utils::{get_iso_date, make_thing};
|
||||
|
||||
/// Audit log schema for tracking all hackathon-related changes
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct HackathonAuditLogSchema {
|
||||
pub id: Thing,
|
||||
pub hackathon_id: Option<Thing>, // None for system-wide events
|
||||
pub action: AuditAction,
|
||||
pub actor_id: String, // User who performed the action
|
||||
pub actor_email: Option<String>, // For better traceability
|
||||
pub resource_type: String, // hackathon, timeline, event, submission
|
||||
pub resource_id: Option<String>, // ID of the affected resource
|
||||
pub changes: Option<serde_json::Value>, // JSON of what changed
|
||||
pub old_value: Option<serde_json::Value>,
|
||||
pub new_value: Option<serde_json::Value>,
|
||||
pub ip_address: Option<String>,
|
||||
pub user_agent: Option<String>,
|
||||
pub timestamp: DateTime<Utc>,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub enum AuditAction {
|
||||
// Hackathon actions
|
||||
HackathonCreated,
|
||||
HackathonUpdated,
|
||||
HackathonDeleted,
|
||||
HackathonStatusChanged,
|
||||
|
||||
// Timeline actions
|
||||
TimelineCreated,
|
||||
TimelineUpdated,
|
||||
TimelineDeleted,
|
||||
TimelineActivated,
|
||||
|
||||
// Event actions
|
||||
EventCreated,
|
||||
EventUpdated,
|
||||
EventDeleted,
|
||||
|
||||
// Submission actions
|
||||
SubmissionCreated,
|
||||
SubmissionUpdated,
|
||||
SubmissionDeleted,
|
||||
SubmissionStatusChanged,
|
||||
|
||||
// Participant actions
|
||||
ParticipantRegistered,
|
||||
ParticipantRemoved,
|
||||
|
||||
// Organizer actions
|
||||
OrganizerAdded,
|
||||
OrganizerRemoved,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for AuditAction {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
AuditAction::HackathonCreated => write!(f, "hackathon_created"),
|
||||
AuditAction::HackathonUpdated => write!(f, "hackathon_updated"),
|
||||
AuditAction::HackathonDeleted => write!(f, "hackathon_deleted"),
|
||||
AuditAction::HackathonStatusChanged => write!(f, "hackathon_status_changed"),
|
||||
AuditAction::TimelineCreated => write!(f, "timeline_created"),
|
||||
AuditAction::TimelineUpdated => write!(f, "timeline_updated"),
|
||||
AuditAction::TimelineDeleted => write!(f, "timeline_deleted"),
|
||||
AuditAction::TimelineActivated => write!(f, "timeline_activated"),
|
||||
AuditAction::EventCreated => write!(f, "event_created"),
|
||||
AuditAction::EventUpdated => write!(f, "event_updated"),
|
||||
AuditAction::EventDeleted => write!(f, "event_deleted"),
|
||||
AuditAction::SubmissionCreated => write!(f, "submission_created"),
|
||||
AuditAction::SubmissionUpdated => write!(f, "submission_updated"),
|
||||
AuditAction::SubmissionDeleted => write!(f, "submission_deleted"),
|
||||
AuditAction::SubmissionStatusChanged => write!(f, "submission_status_changed"),
|
||||
AuditAction::ParticipantRegistered => write!(f, "participant_registered"),
|
||||
AuditAction::ParticipantRemoved => write!(f, "participant_removed"),
|
||||
AuditAction::OrganizerAdded => write!(f, "organizer_added"),
|
||||
AuditAction::OrganizerRemoved => write!(f, "organizer_removed"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for HackathonAuditLogSchema {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
id: make_thing(
|
||||
"app_hackathon_audit_logs",
|
||||
&surrealdb::Uuid::new_v4().to_string(),
|
||||
),
|
||||
hackathon_id: None,
|
||||
action: AuditAction::HackathonCreated,
|
||||
actor_id: String::new(),
|
||||
actor_email: None,
|
||||
resource_type: String::new(),
|
||||
resource_id: None,
|
||||
changes: None,
|
||||
old_value: None,
|
||||
new_value: None,
|
||||
ip_address: None,
|
||||
user_agent: None,
|
||||
timestamp: Utc::now(),
|
||||
created_at: get_iso_date(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl HackathonAuditLogSchema {
|
||||
pub fn new(
|
||||
hackathon_id: Option<Thing>,
|
||||
action: AuditAction,
|
||||
actor_id: String,
|
||||
resource_type: String,
|
||||
resource_id: Option<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: make_thing(
|
||||
"app_hackathon_audit_logs",
|
||||
&surrealdb::Uuid::new_v4().to_string(),
|
||||
),
|
||||
hackathon_id,
|
||||
action,
|
||||
actor_id,
|
||||
actor_email: None,
|
||||
resource_type,
|
||||
resource_id,
|
||||
changes: None,
|
||||
old_value: None,
|
||||
new_value: None,
|
||||
ip_address: None,
|
||||
user_agent: None,
|
||||
timestamp: Utc::now(),
|
||||
created_at: get_iso_date(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_changes(mut self, changes: serde_json::Value) -> Self {
|
||||
self.changes = Some(changes);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_old_new_values(
|
||||
mut self,
|
||||
old_value: serde_json::Value,
|
||||
new_value: serde_json::Value,
|
||||
) -> Self {
|
||||
self.old_value = Some(old_value);
|
||||
self.new_value = Some(new_value);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_request_info(
|
||||
mut self,
|
||||
ip_address: Option<String>,
|
||||
user_agent: Option<String>,
|
||||
actor_email: Option<String>,
|
||||
) -> Self {
|
||||
self.ip_address = ip_address;
|
||||
self.user_agent = user_agent;
|
||||
self.actor_email = actor_email;
|
||||
self
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,630 +0,0 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use lazy_static::lazy_static;
|
||||
use regex::Regex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::{ToSchema, schema};
|
||||
use validator::{Validate, ValidationError};
|
||||
|
||||
// Custom validators
|
||||
pub fn validate_url_format(url: &str) -> Result<(), ValidationError> {
|
||||
lazy_static! {
|
||||
static ref URL_REGEX: Regex = Regex::new(r"^https?://[^\s$.?#].[^\s]*$").unwrap();
|
||||
}
|
||||
if URL_REGEX.is_match(url) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ValidationError::new("invalid_url"))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn validate_github_url(url: &str) -> Result<(), ValidationError> {
|
||||
lazy_static! {
|
||||
static ref GITHUB_REGEX: Regex = Regex::new(r"^https?://github\.com/[a-zA-Z0-9_-]+(/[a-zA-Z0-9_-]+)?$").unwrap();
|
||||
}
|
||||
if GITHUB_REGEX.is_match(url) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ValidationError::new("invalid_github_url"))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn validate_demo_url(url: &str) -> Result<(), ValidationError> {
|
||||
lazy_static! {
|
||||
static ref DEMO_URL_REGEX: Regex = Regex::new(r"^https?://(?:www\.)?[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+(/[^\s]*)?$").unwrap();
|
||||
}
|
||||
if DEMO_URL_REGEX.is_match(url) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ValidationError::new("invalid_demo_url"))
|
||||
}
|
||||
}
|
||||
|
||||
use crate::v1::hackathon::hackathon_schema::{
|
||||
HackathonEventType, HackathonEventsSchema, HackathonPhase, HackathonSchema,
|
||||
HackathonStatus, HackathonSubmissionsSchema, HackathonTimelineSchema,
|
||||
SubmissionStatus,
|
||||
HackathonParticipantSchema,
|
||||
};
|
||||
|
||||
// Hackathon DTOs
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)]
|
||||
pub struct HackathonCreateRequestDto {
|
||||
#[validate(length(min = 1, max = 100, message = "Hackathon name must be between 1 and 100 characters"))]
|
||||
pub name: String,
|
||||
|
||||
#[validate(length(min = 1, max = 1000, message = "Description must be between 1 and 1000 characters"))]
|
||||
pub description: String,
|
||||
|
||||
#[schema(value_type = String, format = DateTime)]
|
||||
pub start_date: DateTime<Utc>,
|
||||
|
||||
#[schema(value_type = String, format = DateTime)]
|
||||
pub end_date: DateTime<Utc>,
|
||||
|
||||
#[schema(value_type = String, format = DateTime)]
|
||||
pub registration_deadline: DateTime<Utc>,
|
||||
|
||||
#[validate(range(min = 1, max = 10000, message = "Max participants must be between 1 and 10000"))]
|
||||
pub max_participants: Option<u32>,
|
||||
|
||||
#[validate(length(max = 200, message = "Theme cannot exceed 200 characters"))]
|
||||
pub theme: Option<String>,
|
||||
|
||||
#[validate(length(max = 2000, message = "Rules cannot exceed 2000 characters"))]
|
||||
pub rules: Option<String>,
|
||||
|
||||
pub prizes: Option<Vec<PrizeDto>>,
|
||||
pub previous_winners: Option<Vec<WinnerDto>>,
|
||||
|
||||
#[validate(length(min = 1, message = "Organizers list cannot be empty"))]
|
||||
pub organizers: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)]
|
||||
pub struct HackathonUpdateRequestDto {
|
||||
#[validate(length(min = 1, max = 100, message = "Hackathon name must be between 1 and 100 characters"))]
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub name: Option<String>,
|
||||
#[validate(length(min = 1, max = 1000, message = "Description must be between 1 and 1000 characters"))]
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[schema(value_type = String, format = DateTime)]
|
||||
pub start_date: Option<DateTime<Utc>>,
|
||||
#[schema(value_type = String, format = DateTime)]
|
||||
pub end_date: Option<DateTime<Utc>>,
|
||||
#[schema(value_type = String, format = DateTime)]
|
||||
pub registration_deadline: Option<DateTime<Utc>>,
|
||||
#[validate(range(min = 1, max = 10000, message = "Max participants must be between 1 and 10000"))]
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub max_participants: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub theme: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub rules: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub prizes: Option<Vec<PrizeDto>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub previous_winners: Option<Vec<WinnerDto>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub organizers: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct HackathonDto {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
#[schema(value_type = String, format = DateTime)]
|
||||
pub start_date: DateTime<Utc>,
|
||||
#[schema(value_type = String, format = DateTime)]
|
||||
pub end_date: DateTime<Utc>,
|
||||
#[schema(value_type = String, format = DateTime)]
|
||||
pub registration_deadline: DateTime<Utc>,
|
||||
pub max_participants: Option<u32>,
|
||||
pub status: HackathonStatus,
|
||||
pub theme: Option<String>,
|
||||
pub rules: Option<String>,
|
||||
pub prizes: Option<Vec<PrizeDto>>,
|
||||
pub previous_winners: Option<Vec<WinnerDto>>,
|
||||
pub organizers: Vec<String>,
|
||||
pub is_deleted: bool,
|
||||
pub created_at: Option<String>,
|
||||
pub updated_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)]
|
||||
pub struct PrizeDto {
|
||||
#[validate(range(min = 1, message = "Position must be at least 1"))]
|
||||
pub position: u32,
|
||||
#[validate(length(min = 1, message = "Prize title cannot be empty"))]
|
||||
pub title: String,
|
||||
pub description: Option<String>,
|
||||
pub value: Option<String>,
|
||||
}
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)]
|
||||
pub struct WinnerDto {
|
||||
#[validate(range(min = 1, message = "Position must be at least 1"))]
|
||||
pub position: u32,
|
||||
pub team_id: String,
|
||||
#[validate(length(min = 1, message = "Project name cannot be empty"))]
|
||||
pub project_name: String,
|
||||
pub team_name: Option<String>,
|
||||
}
|
||||
|
||||
// Hackathon Events DTOs
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)]
|
||||
pub struct HackathonEventCreateRequestDto {
|
||||
#[validate(length(min = 1, message = "Event title cannot be empty"))]
|
||||
pub title: String,
|
||||
pub description: Option<String>,
|
||||
pub event_type: HackathonEventType,
|
||||
#[schema(value_type = String, format = DateTime)]
|
||||
pub start_time: DateTime<Utc>,
|
||||
#[schema(value_type = String, format = DateTime)]
|
||||
pub end_time: DateTime<Utc>,
|
||||
pub location: Option<String>,
|
||||
pub virtual_link: Option<String>,
|
||||
#[validate(range(min = 1, message = "Max attendees must be at least 1"))]
|
||||
pub max_attendees: Option<u32>,
|
||||
pub is_mandatory: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)]
|
||||
pub struct HackathonEventUpdateRequestDto {
|
||||
#[validate(length(min = 1, message = "Event title cannot be empty"))]
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub title: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub event_type: Option<HackathonEventType>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[schema(value_type = String, format = DateTime)]
|
||||
pub start_time: Option<DateTime<Utc>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[schema(value_type = String, format = DateTime)]
|
||||
pub end_time: Option<DateTime<Utc>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub location: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub virtual_link: Option<String>,
|
||||
#[validate(range(min = 1, message = "Max attendees must be at least 1"))]
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub max_attendees: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub is_mandatory: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct HackathonEventDto {
|
||||
pub id: String,
|
||||
pub hackathon_id: String,
|
||||
pub title: String,
|
||||
pub description: Option<String>,
|
||||
pub event_type: HackathonEventType,
|
||||
#[schema(value_type = String, format = DateTime)]
|
||||
pub start_time: DateTime<Utc>,
|
||||
#[schema(value_type = String, format = DateTime)]
|
||||
pub end_time: DateTime<Utc>,
|
||||
pub location: Option<String>,
|
||||
pub virtual_link: Option<String>,
|
||||
pub max_attendees: Option<u32>,
|
||||
pub is_mandatory: bool,
|
||||
pub is_deleted: bool,
|
||||
pub created_at: Option<String>,
|
||||
pub updated_at: Option<String>,
|
||||
}
|
||||
|
||||
// Hackathon Timeline DTOs
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)]
|
||||
pub struct HackathonTimelineCreateRequestDto {
|
||||
pub phase: HackathonPhase,
|
||||
// Accept either `title` or `name` in incoming JSON (tests may send `name`).
|
||||
// Make it optional so missing title doesn't cause a 422; service/repo will
|
||||
// fallback to an empty title or a sensible default.
|
||||
#[serde(alias = "name")]
|
||||
#[serde(default)]
|
||||
pub title: Option<String>,
|
||||
pub description: Option<String>,
|
||||
#[schema(value_type = String, format = DateTime)]
|
||||
pub start_date: DateTime<Utc>,
|
||||
#[schema(value_type = String, format = DateTime)]
|
||||
pub end_date: DateTime<Utc>,
|
||||
#[serde(default)]
|
||||
pub is_active: Option<bool>,
|
||||
#[serde(default)]
|
||||
#[validate(range(min = 0, message = "Order must be non-negative"))]
|
||||
pub order: Option<u32>,
|
||||
}
|
||||
|
||||
// Custom validator for HackathonPhase (case-insensitive)
|
||||
pub fn validate_hackathon_phase(phase: &str) -> Result<(), ValidationError> {
|
||||
let normalized = phase.to_lowercase();
|
||||
match normalized.as_str() {
|
||||
"registration" | "ideation" | "development" | "submission" | "judging" | "awards" => Ok(()),
|
||||
_ => Err(ValidationError::new("invalid_hackathon_phase")),
|
||||
}
|
||||
}
|
||||
|
||||
// Custom validator to ensure start_date is in the future
|
||||
pub fn validate_future_date(date: &DateTime<Utc>) -> Result<(), ValidationError> {
|
||||
let now = Utc::now();
|
||||
if date <= &now {
|
||||
Err(ValidationError::new("start_date_must_be_in_future"))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// Custom validator to ensure end_date is in the future or current
|
||||
pub fn validate_future_or_current_date(date: &DateTime<Utc>) -> Result<(), ValidationError> {
|
||||
let now = Utc::now();
|
||||
if date < &now {
|
||||
Err(ValidationError::new("end_date_must_be_in_future_or_current"))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)]
|
||||
pub struct HackathonTimelineUpdateRequestDto {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub phase: Option<HackathonPhase>,
|
||||
#[validate(length(min = 1, message = "Timeline title cannot be empty"))]
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub title: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[schema(value_type = String, format = DateTime)]
|
||||
pub start_date: Option<DateTime<Utc>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[schema(value_type = String, format = DateTime)]
|
||||
pub end_date: Option<DateTime<Utc>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub is_active: Option<bool>,
|
||||
#[validate(range(min = 0, message = "Order must be non-negative"))]
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub order: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct HackathonTimelineDto {
|
||||
pub id: String,
|
||||
pub hackathon_id: String,
|
||||
pub phase: HackathonPhase,
|
||||
pub title: String,
|
||||
pub description: Option<String>,
|
||||
#[schema(value_type = String, format = DateTime)]
|
||||
pub start_date: DateTime<Utc>,
|
||||
#[schema(value_type = String, format = DateTime)]
|
||||
pub end_date: DateTime<Utc>,
|
||||
pub is_active: bool,
|
||||
pub order: u32,
|
||||
pub is_deleted: bool,
|
||||
pub created_at: Option<String>,
|
||||
pub updated_at: Option<String>,
|
||||
}
|
||||
|
||||
// Hackathon Submissions DTOs
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)]
|
||||
pub struct HackathonSubmissionCreateRequestDto {
|
||||
#[validate(length(min = 1, message = "Project name cannot be empty"))]
|
||||
pub project_name: String,
|
||||
#[validate(length(min = 1, message = "Description cannot be empty"))]
|
||||
pub description: String,
|
||||
pub repository_url: Option<String>,
|
||||
pub upload_file_url: Option<String>, // URL to uploaded zip/pdf file
|
||||
pub demo_url: Option<String>,
|
||||
pub slides_url: Option<String>,
|
||||
pub technologies: Vec<String>,
|
||||
// Social media contacts for demo (at least one required)
|
||||
pub contact_instagram: Option<String>,
|
||||
pub contact_twitter: Option<String>,
|
||||
pub contact_linkedin: Option<String>,
|
||||
pub contact_facebook: Option<String>,
|
||||
pub contact_youtube: Option<String>,
|
||||
pub contact_tiktok: Option<String>,
|
||||
pub contact_other: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)]
|
||||
pub struct HackathonSubmissionUpdateRequestDto {
|
||||
#[validate(length(min = 1, message = "Project name cannot be empty"))]
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub project_name: Option<String>,
|
||||
#[validate(length(min = 1, message = "Description cannot be empty"))]
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub repository_url: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub upload_file_url: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub demo_url: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub slides_url: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub technologies: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub contact_instagram: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub contact_twitter: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub contact_linkedin: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub contact_facebook: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub contact_youtube: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub contact_tiktok: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub contact_other: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct HackathonSubmissionDto {
|
||||
pub id: String,
|
||||
pub hackathon_id: String,
|
||||
pub team_id: String,
|
||||
pub project_name: String,
|
||||
pub description: String,
|
||||
pub repository_url: Option<String>,
|
||||
pub upload_file_url: Option<String>,
|
||||
pub demo_url: Option<String>,
|
||||
pub slides_url: Option<String>,
|
||||
pub technologies: Vec<String>,
|
||||
pub contact_instagram: Option<String>,
|
||||
pub contact_twitter: Option<String>,
|
||||
pub contact_linkedin: Option<String>,
|
||||
pub contact_facebook: Option<String>,
|
||||
pub contact_youtube: Option<String>,
|
||||
pub contact_tiktok: Option<String>,
|
||||
pub contact_other: Option<String>,
|
||||
#[serde(rename = "status")]
|
||||
pub submission_status: SubmissionStatus,
|
||||
pub judge_feedback: Option<String>,
|
||||
#[schema(value_type = String, format = DateTime)]
|
||||
pub submitted_at: DateTime<Utc>,
|
||||
pub is_deleted: bool,
|
||||
pub created_at: Option<String>,
|
||||
pub updated_at: Option<String>,
|
||||
}
|
||||
|
||||
// Query DTOs
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)]
|
||||
pub struct HackathonQueryDto {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub status: Option<HackathonStatus>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub organizer_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub limit: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub offset: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)]
|
||||
pub struct HackathonEventQueryDto {
|
||||
pub hackathon_id: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub event_type: Option<HackathonEventType>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub limit: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub offset: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)]
|
||||
pub struct HackathonTimelineQueryDto {
|
||||
pub hackathon_id: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub phase: Option<HackathonPhase>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub is_active: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)]
|
||||
pub struct HackathonSubmissionQueryDto {
|
||||
pub hackathon_id: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub team_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub submission_status: Option<SubmissionStatus>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub limit: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub offset: Option<u32>,
|
||||
}
|
||||
|
||||
// Conversion implementations
|
||||
impl From<HackathonSchema> for HackathonDto {
|
||||
fn from(schema: HackathonSchema) -> Self {
|
||||
Self {
|
||||
id: schema.id.id.to_raw(),
|
||||
name: schema.name,
|
||||
description: schema.description,
|
||||
start_date: schema.start_date,
|
||||
end_date: schema.end_date,
|
||||
registration_deadline: schema.registration_deadline,
|
||||
max_participants: schema.max_participants,
|
||||
status: schema.status,
|
||||
theme: schema.theme,
|
||||
rules: schema.rules,
|
||||
prizes: schema.prizes.map(|prizes| {
|
||||
prizes
|
||||
.into_iter()
|
||||
.map(|p| PrizeDto {
|
||||
position: p.position,
|
||||
title: p.title,
|
||||
description: p.description,
|
||||
value: p.value,
|
||||
})
|
||||
.collect()
|
||||
}),
|
||||
previous_winners: schema.previous_winners.map(|winners| {
|
||||
winners
|
||||
.into_iter()
|
||||
.map(|w| WinnerDto {
|
||||
position: w.position,
|
||||
team_id: w.team_id,
|
||||
project_name: w.project_name,
|
||||
team_name: w.team_name,
|
||||
})
|
||||
.collect()
|
||||
}),
|
||||
organizers: schema.organizers,
|
||||
is_deleted: schema.is_deleted,
|
||||
created_at: schema.created_at,
|
||||
updated_at: schema.updated_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<HackathonEventsSchema> for HackathonEventDto {
|
||||
fn from(schema: HackathonEventsSchema) -> Self {
|
||||
Self {
|
||||
id: schema.id.id.to_raw(),
|
||||
hackathon_id: schema.hackathon_id.id.to_raw(),
|
||||
title: schema.title,
|
||||
description: schema.description,
|
||||
event_type: schema.event_type,
|
||||
start_time: schema.start_time,
|
||||
end_time: schema.end_time,
|
||||
location: schema.location,
|
||||
virtual_link: schema.virtual_link,
|
||||
max_attendees: schema.max_attendees,
|
||||
is_mandatory: schema.is_mandatory,
|
||||
is_deleted: schema.is_deleted,
|
||||
created_at: schema.created_at,
|
||||
updated_at: schema.updated_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<HackathonTimelineSchema> for HackathonTimelineDto {
|
||||
fn from(schema: HackathonTimelineSchema) -> Self {
|
||||
Self {
|
||||
id: schema.id.id.to_raw(),
|
||||
hackathon_id: schema.hackathon_id.id.to_raw(),
|
||||
phase: schema.phase,
|
||||
title: schema.title,
|
||||
description: schema.description,
|
||||
start_date: schema.start_date,
|
||||
end_date: schema.end_date,
|
||||
is_active: schema.is_active,
|
||||
order: schema.order,
|
||||
is_deleted: schema.is_deleted,
|
||||
created_at: schema.created_at,
|
||||
updated_at: schema.updated_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<HackathonSubmissionsSchema> for HackathonSubmissionDto {
|
||||
fn from(schema: HackathonSubmissionsSchema) -> Self {
|
||||
Self {
|
||||
id: schema.id.id.to_raw(),
|
||||
hackathon_id: schema.hackathon_id.id.to_raw(),
|
||||
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,
|
||||
upload_file_url: schema.upload_file_url,
|
||||
demo_url: schema.demo_url,
|
||||
slides_url: schema.slides_url,
|
||||
technologies: schema.technologies.unwrap_or_default(),
|
||||
contact_instagram: schema.contact_instagram,
|
||||
contact_twitter: schema.contact_twitter,
|
||||
contact_linkedin: schema.contact_linkedin,
|
||||
contact_facebook: schema.contact_facebook,
|
||||
contact_youtube: schema.contact_youtube,
|
||||
contact_tiktok: schema.contact_tiktok,
|
||||
contact_other: schema.contact_other,
|
||||
submission_status: schema.submission_status.unwrap_or(super::hackathon_schema::SubmissionStatus::Draft),
|
||||
judge_feedback: schema.judge_feedback,
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Admin Sensitive Data Management DTOs
|
||||
#[derive(Debug, Deserialize, Serialize, Validate, ToSchema)]
|
||||
pub struct AdminManageSensitiveDataRequestDto {
|
||||
#[validate(length(min = 1, message = "At least one user ID is required"))]
|
||||
pub user_ids: Vec<String>,
|
||||
#[validate(length(min = 1, message = "At least one raw score is required"))]
|
||||
pub raw_scores: Vec<i32>,
|
||||
pub personal_info: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct AdminSensitiveDataMemberDto {
|
||||
pub user_id: String,
|
||||
pub masked_email: String,
|
||||
pub masked_phone: String,
|
||||
pub name: String,
|
||||
pub role: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct AdminSensitiveDataDto {
|
||||
pub submission_id: String,
|
||||
pub team_id: String,
|
||||
pub project_name: String,
|
||||
pub description: String,
|
||||
pub technologies: Vec<String>,
|
||||
pub score: Option<i32>,
|
||||
pub members: Vec<AdminSensitiveDataMemberDto>,
|
||||
pub raw_scores: Option<Vec<i32>>,
|
||||
pub submission_date: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct AdminSensitiveDataResponseDto {
|
||||
pub data: Vec<AdminSensitiveDataDto>,
|
||||
pub message: String,
|
||||
}
|
||||
// Status Change DTOs
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)]
|
||||
pub struct HackathonStatusChangeRequestDto {
|
||||
pub status: HackathonStatus,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub reason: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub actor_id: Option<String>,
|
||||
}
|
||||
@@ -1,905 +0,0 @@
|
||||
use super::hackathon_dto::{
|
||||
HackathonCreateRequestDto, HackathonEventCreateRequestDto,
|
||||
HackathonEventUpdateRequestDto, HackathonSubmissionCreateRequestDto,
|
||||
HackathonSubmissionUpdateRequestDto, HackathonTimelineCreateRequestDto,
|
||||
HackathonTimelineUpdateRequestDto, HackathonUpdateRequestDto,
|
||||
};
|
||||
use super::hackathon_schema::{
|
||||
HackathonEventsSchema, HackathonPhase, HackathonSchema, HackathonSubmissionsSchema, HackathonTimelineSchema,
|
||||
Prize,
|
||||
};
|
||||
use imphnen_libs::ResourceEnum;
|
||||
use anyhow::{Result, anyhow, bail};
|
||||
|
||||
use imphnen_libs::AppState;
|
||||
use imphnen_utils::{QueryListBuilder, get_iso_date};
|
||||
|
||||
use std::collections::HashMap;
|
||||
use surrealdb::sql::Thing;
|
||||
use tracing::{instrument, info};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct HackathonRepository<'a> {
|
||||
pub state: &'a AppState,
|
||||
}
|
||||
|
||||
impl<'a> HackathonRepository<'a> {
|
||||
pub fn new(state: &'a AppState) -> Self {
|
||||
Self { state }
|
||||
}
|
||||
|
||||
// Normalize an incoming id so callers can pass either the full thing string
|
||||
// (e.g. "app_hackathons:1") or the raw id ("1"). If the id starts with
|
||||
// the table prefix ("{table}:") the prefix is stripped.
|
||||
fn normalize_id(&self, table: &str, id: &str) -> String {
|
||||
if id.starts_with(&format!("{}:", table)) {
|
||||
if let Some((_, rest)) = id.split_once(':') {
|
||||
rest.to_string()
|
||||
} else {
|
||||
id.to_string()
|
||||
}
|
||||
} else {
|
||||
id.to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Hackathon CRUD operations
|
||||
impl<'a> HackathonRepository<'a> {
|
||||
#[instrument(skip(self, hackathon), err)]
|
||||
pub async fn create_hackathon(&self, hackathon: HackathonCreateRequestDto) -> Result<HackathonSchema> {
|
||||
let table = ResourceEnum::Hackathons.to_string();
|
||||
let id = surrealdb::Uuid::new_v4().to_string();
|
||||
|
||||
let prizes: Option<Vec<Prize>> = hackathon.prizes.map(|p| {
|
||||
p.into_iter()
|
||||
.map(|prize| Prize {
|
||||
position: prize.position,
|
||||
title: prize.title,
|
||||
description: prize.description,
|
||||
value: prize.value,
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
let previous_winners: Option<Vec<super::hackathon_schema::Winner>> = hackathon.previous_winners.map(|w| {
|
||||
w.into_iter()
|
||||
.map(|winner| super::hackathon_schema::Winner {
|
||||
position: winner.position,
|
||||
team_id: winner.team_id,
|
||||
project_name: winner.project_name,
|
||||
team_name: winner.team_name,
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
|
||||
let schema = HackathonSchema {
|
||||
id: Thing::from((table.clone(), id.clone())),
|
||||
name: hackathon.name,
|
||||
description: hackathon.description,
|
||||
start_date: hackathon.start_date,
|
||||
end_date: hackathon.end_date,
|
||||
registration_deadline: hackathon.registration_deadline,
|
||||
max_participants: hackathon.max_participants,
|
||||
status: super::hackathon_schema::HackathonStatus::Draft,
|
||||
theme: hackathon.theme,
|
||||
rules: hackathon.rules,
|
||||
prizes,
|
||||
previous_winners,
|
||||
organizers: hackathon.organizers,
|
||||
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<HackathonSchema> = self
|
||||
.state.surrealdb_ws
|
||||
.create((table, id))
|
||||
.content(schema.clone())
|
||||
.await?;
|
||||
|
||||
match record {
|
||||
Some(h) => Ok(h),
|
||||
None => bail!("Failed to create hackathon"),
|
||||
}
|
||||
}
|
||||
|
||||
#[instrument(skip(self, id), err)]
|
||||
pub async fn get_hackathon_by_id(&self, id: String) -> Result<HackathonSchema> {
|
||||
let table = ResourceEnum::Hackathons.to_string();
|
||||
info!(query = %format!("SELECT * FROM {} WHERE id = '{}'", table, id), "Executing SurrealDB query");
|
||||
|
||||
let normalized_id = self.normalize_id(&table, &id);
|
||||
|
||||
let record: Option<HackathonSchema> = self
|
||||
.state
|
||||
.surrealdb_ws
|
||||
.select((table, normalized_id.clone()))
|
||||
.await?;
|
||||
|
||||
match record {
|
||||
Some(h) => {
|
||||
if h.is_deleted {
|
||||
bail!("Hackathon not found");
|
||||
}
|
||||
Ok(h)
|
||||
}
|
||||
None => bail!("Hackathon not found"),
|
||||
}
|
||||
}
|
||||
|
||||
#[instrument(skip(self, meta), err)]
|
||||
pub async fn list_hackathons(&self, meta: imphnen_libs::MetaRequestDto) -> Result<imphnen_libs::ResponseListSuccessDto<Vec<HackathonSchema>>> {
|
||||
let table = ResourceEnum::Hackathons.to_string();
|
||||
|
||||
let builder = QueryListBuilder::new(&self.state.surrealdb_ws, &table, &meta)
|
||||
.with_condition("is_deleted = false")
|
||||
.search_field("name")
|
||||
.select_fields(vec!["*"]);
|
||||
|
||||
let mut result = builder.build().await?;
|
||||
|
||||
// Ensure deterministic ordering for listings by sorting on created_at (oldest first).
|
||||
// Tests expect insertion order (first created appears first). created_at is an Option<String>
|
||||
// with ISO 8601 format from `get_iso_date()`, so string comparison is chronologically correct.
|
||||
result.data.sort_by_key(|s: &HackathonSchema| s.created_at.clone());
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[instrument(skip(self, id, updates), err)]
|
||||
pub async fn update_hackathon(&self, id: String, updates: HackathonUpdateRequestDto) -> Result<HackathonSchema> {
|
||||
let table = ResourceEnum::Hackathons.to_string();
|
||||
|
||||
// First get the existing hackathon
|
||||
let mut existing = self.get_hackathon_by_id(id.clone()).await?;
|
||||
|
||||
// Apply updates
|
||||
if let Some(name) = updates.name {
|
||||
existing.name = name;
|
||||
}
|
||||
if let Some(description) = updates.description {
|
||||
existing.description = description;
|
||||
}
|
||||
if let Some(start_date) = updates.start_date {
|
||||
existing.start_date = start_date;
|
||||
}
|
||||
if let Some(end_date) = updates.end_date {
|
||||
existing.end_date = end_date;
|
||||
}
|
||||
if let Some(registration_deadline) = updates.registration_deadline {
|
||||
existing.registration_deadline = registration_deadline;
|
||||
}
|
||||
if let Some(max_participants) = updates.max_participants {
|
||||
existing.max_participants = Some(max_participants);
|
||||
}
|
||||
if let Some(theme) = updates.theme {
|
||||
existing.theme = Some(theme);
|
||||
}
|
||||
if let Some(rules) = updates.rules {
|
||||
existing.rules = Some(rules);
|
||||
}
|
||||
if let Some(prizes) = updates.prizes {
|
||||
let prizes_schema: Vec<Prize> = prizes
|
||||
.into_iter()
|
||||
.map(|p| Prize {
|
||||
position: p.position,
|
||||
title: p.title,
|
||||
description: p.description,
|
||||
value: p.value,
|
||||
})
|
||||
.collect();
|
||||
existing.prizes = Some(prizes_schema);
|
||||
if let Some(previous_winners) = updates.previous_winners {
|
||||
let winners_schema: Vec<super::hackathon_schema::Winner> = previous_winners
|
||||
.into_iter()
|
||||
.map(|w| super::hackathon_schema::Winner {
|
||||
position: w.position,
|
||||
team_id: w.team_id,
|
||||
project_name: w.project_name,
|
||||
team_name: w.team_name,
|
||||
})
|
||||
.collect();
|
||||
existing.previous_winners = Some(winners_schema);
|
||||
}
|
||||
}
|
||||
if let Some(organizers) = updates.organizers {
|
||||
existing.organizers = organizers;
|
||||
}
|
||||
|
||||
existing.updated_at = Some(get_iso_date());
|
||||
|
||||
info!(query = %format!("UPDATE {} SET ... WHERE id = '{}'", table, id), "Executing SurrealDB query");
|
||||
let record: Option<HackathonSchema> = self
|
||||
.state.surrealdb_ws
|
||||
.update((table, id))
|
||||
.content(existing.clone())
|
||||
.await?;
|
||||
|
||||
match record {
|
||||
Some(h) => Ok(h),
|
||||
None => bail!("Failed to update hackathon"),
|
||||
}
|
||||
}
|
||||
|
||||
#[instrument(skip(self, id), err)]
|
||||
pub async fn delete_hackathon(&self, id: String) -> Result<String> {
|
||||
let table = ResourceEnum::Hackathons.to_string();
|
||||
|
||||
// Soft delete by setting is_deleted = true
|
||||
let updates: HashMap<String, serde_json::Value> = HashMap::from([
|
||||
("is_deleted".to_string(), true.into()),
|
||||
("updated_at".to_string(), get_iso_date().into()),
|
||||
]);
|
||||
|
||||
info!(query = %format!("UPDATE {} SET is_deleted = true WHERE id = '{}'", table, id), "Executing SurrealDB query");
|
||||
let normalized_id = self.normalize_id(&table, &id);
|
||||
info!(query = %format!("UPDATE {} SET is_deleted = true WHERE id = '{}'", table, normalized_id), "Executing SurrealDB query");
|
||||
let record: Option<HackathonSchema> = self
|
||||
.state.surrealdb_ws
|
||||
.update((table, normalized_id.clone()))
|
||||
.merge(serde_json::to_value(updates)?)
|
||||
.await?;
|
||||
|
||||
match record {
|
||||
Some(_) => Ok("Hackathon deleted successfully".to_string()),
|
||||
None => bail!("Failed to delete hackathon"),
|
||||
}
|
||||
}
|
||||
|
||||
#[instrument(skip(self, id, status), err)]
|
||||
pub async fn update_hackathon_status(&self, id: String, status: super::hackathon_schema::HackathonStatus) -> Result<HackathonSchema> {
|
||||
let table = ResourceEnum::Hackathons.to_string();
|
||||
|
||||
// Get existing hackathon
|
||||
let mut existing = self.get_hackathon_by_id(id.clone()).await?;
|
||||
|
||||
// Update status
|
||||
existing.status = status;
|
||||
existing.updated_at = Some(get_iso_date());
|
||||
|
||||
info!(query = %format!("UPDATE {} SET status = {:?} WHERE id = '{}'", table, existing.status, id), "Executing SurrealDB query");
|
||||
let normalized_id = self.normalize_id(&table, &id);
|
||||
let record: Option<HackathonSchema> = self
|
||||
.state.surrealdb_ws
|
||||
.update((table, normalized_id))
|
||||
.content(existing.clone())
|
||||
.await?;
|
||||
|
||||
match record {
|
||||
Some(h) => Ok(h),
|
||||
None => bail!("Failed to update hackathon status"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Hackathon Events CRUD operations
|
||||
impl<'a> HackathonRepository<'a> {
|
||||
#[instrument(skip(self, hackathon_id, event), err)]
|
||||
pub async fn create_hackathon_event(&self, hackathon_id: String, event: HackathonEventCreateRequestDto) -> Result<HackathonEventsSchema> {
|
||||
let table = ResourceEnum::HackathonEvents.to_string();
|
||||
let id = surrealdb::Uuid::new_v4().to_string();
|
||||
|
||||
let normalized_hackathon_id = self.normalize_id("app_hackathons", &hackathon_id);
|
||||
|
||||
let schema = HackathonEventsSchema {
|
||||
id: Thing::from((table.clone(), id.clone())),
|
||||
hackathon_id: Thing::from(("app_hackathons".to_string(), normalized_hackathon_id)),
|
||||
title: event.title,
|
||||
description: event.description,
|
||||
event_type: event.event_type,
|
||||
start_time: event.start_time,
|
||||
end_time: event.end_time,
|
||||
location: event.location,
|
||||
virtual_link: event.virtual_link,
|
||||
max_attendees: event.max_attendees,
|
||||
is_mandatory: event.is_mandatory,
|
||||
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<HackathonEventsSchema> = self
|
||||
.state.surrealdb_ws
|
||||
.create((table, id))
|
||||
.content(schema.clone())
|
||||
.await?;
|
||||
|
||||
match record {
|
||||
Some(e) => Ok(e),
|
||||
None => bail!("Failed to create hackathon event"),
|
||||
}
|
||||
}
|
||||
|
||||
#[instrument(skip(self, meta, hackathon_id), err)]
|
||||
pub async fn list_hackathon_events(&self, meta: imphnen_libs::MetaRequestDto, hackathon_id: String) -> Result<imphnen_libs::ResponseListSuccessDto<Vec<HackathonEventsSchema>>> {
|
||||
let table = ResourceEnum::HackathonEvents.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))
|
||||
.search_field("title")
|
||||
.select_fields(vec!["*"]);
|
||||
|
||||
let mut result = builder.build().await?;
|
||||
|
||||
// Sort events by created_at (oldest first) to ensure deterministic ordering for tests
|
||||
result.data.sort_by_key(|s: &HackathonEventsSchema| s.created_at.clone());
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[instrument(skip(self, id), err)]
|
||||
pub async fn get_hackathon_event_by_id(&self, id: String) -> Result<HackathonEventsSchema> {
|
||||
let table = ResourceEnum::HackathonEvents.to_string();
|
||||
|
||||
let existing: Option<HackathonEventsSchema> = self.state.surrealdb_ws
|
||||
.select((table, id.clone()))
|
||||
.await?;
|
||||
|
||||
let event = existing.ok_or_else(|| anyhow!("Event not found"))?;
|
||||
|
||||
if event.is_deleted {
|
||||
bail!("Event not found");
|
||||
}
|
||||
|
||||
Ok(event)
|
||||
}
|
||||
|
||||
#[instrument(skip(self, id, updates), err)]
|
||||
pub async fn update_hackathon_event(&self, id: String, updates: HackathonEventUpdateRequestDto) -> Result<HackathonEventsSchema> {
|
||||
let table = ResourceEnum::HackathonEvents.to_string();
|
||||
|
||||
// Get existing event
|
||||
let existing: Option<HackathonEventsSchema> = self.state.surrealdb_ws.select((table.clone(), id.clone())).await?;
|
||||
let mut existing = existing.ok_or_else(|| anyhow!("Event not found"))?;
|
||||
|
||||
if existing.is_deleted {
|
||||
bail!("Event not found");
|
||||
}
|
||||
|
||||
// Apply updates
|
||||
if let Some(title) = updates.title {
|
||||
existing.title = title;
|
||||
}
|
||||
if let Some(description) = updates.description {
|
||||
existing.description = Some(description);
|
||||
}
|
||||
if let Some(event_type) = updates.event_type {
|
||||
existing.event_type = event_type;
|
||||
}
|
||||
if let Some(start_time) = updates.start_time {
|
||||
existing.start_time = start_time;
|
||||
}
|
||||
if let Some(end_time) = updates.end_time {
|
||||
existing.end_time = end_time;
|
||||
}
|
||||
if let Some(location) = updates.location {
|
||||
existing.location = Some(location);
|
||||
}
|
||||
if let Some(virtual_link) = updates.virtual_link {
|
||||
existing.virtual_link = Some(virtual_link);
|
||||
}
|
||||
if let Some(max_attendees) = updates.max_attendees {
|
||||
existing.max_attendees = Some(max_attendees);
|
||||
}
|
||||
if let Some(is_mandatory) = updates.is_mandatory {
|
||||
existing.is_mandatory = is_mandatory;
|
||||
}
|
||||
|
||||
existing.updated_at = Some(get_iso_date());
|
||||
|
||||
info!(query = %format!("UPDATE {} SET ... WHERE id = '{}'", table, id), "Executing SurrealDB query");
|
||||
let record: Option<HackathonEventsSchema> = self
|
||||
.state.surrealdb_ws
|
||||
.update((table, id))
|
||||
.content(existing.clone())
|
||||
.await?;
|
||||
|
||||
match record {
|
||||
Some(e) => Ok(e),
|
||||
None => bail!("Failed to update hackathon event"),
|
||||
}
|
||||
}
|
||||
|
||||
#[instrument(skip(self, id), err)]
|
||||
pub async fn delete_hackathon_event(&self, id: String) -> Result<String> {
|
||||
let table = ResourceEnum::HackathonEvents.to_string();
|
||||
|
||||
let updates: HashMap<String, serde_json::Value> = HashMap::from([
|
||||
("is_deleted".to_string(), true.into()),
|
||||
("updated_at".to_string(), get_iso_date().into()),
|
||||
]);
|
||||
|
||||
info!(query = %format!("UPDATE {} SET is_deleted = true WHERE id = '{}'", table, id), "Executing SurrealDB query");
|
||||
let record: Option<HackathonEventsSchema> = self
|
||||
.state.surrealdb_ws
|
||||
.update((table, id))
|
||||
.merge(serde_json::to_value(updates)?)
|
||||
.await?;
|
||||
|
||||
match record {
|
||||
Some(_) => Ok("Event deleted successfully".to_string()),
|
||||
None => bail!("Failed to delete event"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Hackathon Timeline CRUD operations
|
||||
impl<'a> HackathonRepository<'a> {
|
||||
#[instrument(skip(self, hackathon_id, timeline), err)]
|
||||
pub async fn create_hackathon_timeline(&self, hackathon_id: String, timeline: HackathonTimelineCreateRequestDto) -> Result<HackathonTimelineSchema> {
|
||||
let table = ResourceEnum::HackathonTimeline.to_string();
|
||||
let id = surrealdb::Uuid::new_v4().to_string();
|
||||
|
||||
let normalized_hackathon_id = self.normalize_id("app_hackathons", &hackathon_id);
|
||||
|
||||
let phase_clone = timeline.phase.clone();
|
||||
let schema = HackathonTimelineSchema {
|
||||
id: Thing::from((table.clone(), id.clone())),
|
||||
hackathon_id: Thing::from(("app_hackathons".to_string(), normalized_hackathon_id)),
|
||||
phase: phase_clone.clone(),
|
||||
title: timeline.title.unwrap_or_else(|| phase_clone.to_string()),
|
||||
description: timeline.description,
|
||||
start_date: timeline.start_date,
|
||||
end_date: timeline.end_date,
|
||||
is_active: timeline.is_active.unwrap_or(false),
|
||||
order: timeline.order.unwrap_or(0),
|
||||
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<HackathonTimelineSchema> = self
|
||||
.state.surrealdb_ws
|
||||
.create((table, id))
|
||||
.content(schema.clone())
|
||||
.await?;
|
||||
|
||||
match record {
|
||||
Some(t) => Ok(t),
|
||||
None => bail!("Failed to create hackathon timeline"),
|
||||
}
|
||||
}
|
||||
|
||||
#[instrument(skip(self, meta, hackathon_id), err)]
|
||||
pub async fn list_hackathon_timeline(&self, meta: imphnen_libs::MetaRequestDto, hackathon_id: String) -> Result<imphnen_libs::ResponseListSuccessDto<Vec<HackathonTimelineSchema>>> {
|
||||
let table = ResourceEnum::HackathonTimeline.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))
|
||||
.search_field("title")
|
||||
.select_fields(vec!["*"]);
|
||||
|
||||
let result = builder.build().await?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[instrument(skip(self, id), err)]
|
||||
pub async fn get_hackathon_timeline_by_id(&self, id: String) -> Result<HackathonTimelineSchema> {
|
||||
let table = ResourceEnum::HackathonTimeline.to_string();
|
||||
|
||||
let existing: Option<HackathonTimelineSchema> = self.state.surrealdb_ws
|
||||
.select((table, id.clone()))
|
||||
.await?;
|
||||
|
||||
let timeline = existing.ok_or_else(|| anyhow!("Timeline not found"))?;
|
||||
|
||||
if timeline.is_deleted {
|
||||
bail!("Timeline not found");
|
||||
}
|
||||
|
||||
Ok(timeline)
|
||||
}
|
||||
|
||||
#[instrument(skip(self, id, updates), err)]
|
||||
pub async fn update_hackathon_timeline(&self, id: String, updates: HackathonTimelineUpdateRequestDto) -> Result<HackathonTimelineSchema> {
|
||||
let table = ResourceEnum::HackathonTimeline.to_string();
|
||||
|
||||
let existing: Option<HackathonTimelineSchema> = self.state.surrealdb_ws.select((table.clone(), id.clone())).await?;
|
||||
let mut existing = existing.ok_or_else(|| anyhow!("Timeline not found"))?;
|
||||
|
||||
if existing.is_deleted {
|
||||
bail!("Timeline not found");
|
||||
}
|
||||
|
||||
// Apply updates
|
||||
if let Some(phase) = updates.phase {
|
||||
existing.phase = phase;
|
||||
}
|
||||
if let Some(title) = updates.title {
|
||||
existing.title = title;
|
||||
}
|
||||
if let Some(description) = updates.description {
|
||||
existing.description = Some(description);
|
||||
}
|
||||
if let Some(start_date) = updates.start_date {
|
||||
existing.start_date = start_date;
|
||||
}
|
||||
if let Some(end_date) = updates.end_date {
|
||||
existing.end_date = end_date;
|
||||
}
|
||||
if let Some(is_active) = updates.is_active {
|
||||
existing.is_active = is_active;
|
||||
}
|
||||
if let Some(order) = updates.order {
|
||||
existing.order = order;
|
||||
}
|
||||
|
||||
existing.updated_at = Some(get_iso_date());
|
||||
|
||||
info!(query = %format!("UPDATE {} SET ... WHERE id = '{}'", table, id), "Executing SurrealDB query");
|
||||
let record: Option<HackathonTimelineSchema> = self
|
||||
.state.surrealdb_ws
|
||||
.update((table, id))
|
||||
.content(existing.clone())
|
||||
.await?;
|
||||
|
||||
match record {
|
||||
Some(t) => Ok(t),
|
||||
None => bail!("Failed to update hackathon timeline"),
|
||||
}
|
||||
}
|
||||
|
||||
#[instrument(skip(self, id), err)]
|
||||
pub async fn delete_hackathon_timeline(&self, id: String) -> Result<String> {
|
||||
let table = ResourceEnum::HackathonTimeline.to_string();
|
||||
|
||||
let updates: HashMap<String, serde_json::Value> = HashMap::from([
|
||||
("is_deleted".to_string(), true.into()),
|
||||
("updated_at".to_string(), get_iso_date().into()),
|
||||
]);
|
||||
|
||||
info!(query = %format!("UPDATE {} SET is_deleted = true WHERE id = '{}'", table, id), "Executing SurrealDB query");
|
||||
let record: Option<HackathonTimelineSchema> = self
|
||||
.state.surrealdb_ws
|
||||
.update((table, id))
|
||||
.merge(serde_json::to_value(updates)?)
|
||||
.await?;
|
||||
|
||||
match record {
|
||||
Some(_) => Ok("Timeline deleted successfully".to_string()),
|
||||
None => bail!("Failed to delete timeline"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Hackathon Submissions CRUD operations
|
||||
impl<'a> HackathonRepository<'a> {
|
||||
#[instrument(skip(self, hackathon_id, team_id, submission), err)]
|
||||
pub async fn create_hackathon_submission(&self, hackathon_id: String, team_id: String, submission: HackathonSubmissionCreateRequestDto) -> Result<HackathonSubmissionsSchema> {
|
||||
let table = ResourceEnum::HackathonSubmissions.to_string();
|
||||
let id = surrealdb::Uuid::new_v4().to_string();
|
||||
|
||||
let normalized_hackathon_id = self.normalize_id("app_hackathons", &hackathon_id);
|
||||
let normalized_team_id = self.normalize_id("app_teams", &team_id);
|
||||
|
||||
let schema = HackathonSubmissionsSchema {
|
||||
id: Thing::from((table.clone(), id.clone())),
|
||||
hackathon_id: Thing::from(("app_hackathons".to_string(), normalized_hackathon_id)),
|
||||
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,
|
||||
upload_file_url: submission.upload_file_url,
|
||||
demo_url: submission.demo_url,
|
||||
slides_url: submission.slides_url,
|
||||
technologies: Some(submission.technologies),
|
||||
contact_instagram: submission.contact_instagram,
|
||||
contact_twitter: submission.contact_twitter,
|
||||
contact_linkedin: submission.contact_linkedin,
|
||||
contact_facebook: submission.contact_facebook,
|
||||
contact_youtube: submission.contact_youtube,
|
||||
contact_tiktok: submission.contact_tiktok,
|
||||
contact_other: submission.contact_other,
|
||||
submission_status: Some(super::hackathon_schema::SubmissionStatus::Draft),
|
||||
judge_feedback: None,
|
||||
submitted_at: Some(chrono::Utc::now()),
|
||||
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<HackathonSubmissionsSchema> = self
|
||||
.state.surrealdb_ws
|
||||
.create((table, id))
|
||||
.content(schema.clone())
|
||||
.await?;
|
||||
|
||||
match record {
|
||||
Some(s) => Ok(s),
|
||||
None => bail!("Failed to create hackathon submission"),
|
||||
}
|
||||
}
|
||||
|
||||
#[instrument(skip(self, meta, hackathon_id), err)]
|
||||
pub async fn list_hackathon_submissions(&self, meta: imphnen_libs::MetaRequestDto, hackathon_id: String) -> Result<imphnen_libs::ResponseListSuccessDto<Vec<HackathonSubmissionsSchema>>> {
|
||||
let table = ResourceEnum::HackathonSubmissions.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")
|
||||
// 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!["*"]);
|
||||
|
||||
let mut result = builder.build().await?;
|
||||
// Ensure deterministic ordering for listings by sorting on created_at (oldest first).
|
||||
result.data.sort_by_key(|s: &HackathonSubmissionsSchema| s.created_at.clone());
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[instrument(skip(self, meta, team_id), err)]
|
||||
pub async fn list_submissions_by_team(&self, meta: imphnen_libs::MetaRequestDto, team_id: String) -> Result<imphnen_libs::ResponseListSuccessDto<Vec<HackathonSubmissionsSchema>>> {
|
||||
let table = ResourceEnum::HackathonSubmissions.to_string();
|
||||
|
||||
let normalized_team_id = self.normalize_id("app_teams", &team_id);
|
||||
|
||||
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!["*"]);
|
||||
|
||||
let mut result = builder.build().await?;
|
||||
result.data.sort_by_key(|s: &HackathonSubmissionsSchema| s.created_at.clone());
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[instrument(skip(self, id, status, feedback), err)]
|
||||
pub async fn update_submission_status(&self, id: String, status: super::hackathon_schema::SubmissionStatus, feedback: Option<String>) -> Result<HackathonSubmissionsSchema> {
|
||||
let table = ResourceEnum::HackathonSubmissions.to_string();
|
||||
|
||||
let existing: Option<HackathonSubmissionsSchema> = self.state.surrealdb_ws.select((table.clone(), id.clone())).await?;
|
||||
let mut existing = existing.ok_or_else(|| anyhow!("Submission not found"))?;
|
||||
|
||||
if existing.is_deleted {
|
||||
bail!("Submission not found");
|
||||
}
|
||||
|
||||
existing.submission_status = Some(status);
|
||||
existing.judge_feedback = feedback;
|
||||
existing.updated_at = Some(get_iso_date());
|
||||
|
||||
let record: Option<HackathonSubmissionsSchema> = self
|
||||
.state.surrealdb_ws
|
||||
.update((table, id))
|
||||
.content(existing.clone())
|
||||
.await?;
|
||||
|
||||
match record {
|
||||
Some(s) => Ok(s),
|
||||
None => bail!("Failed to update submission status"),
|
||||
}
|
||||
}
|
||||
|
||||
#[instrument(skip(self, id, updates), err)]
|
||||
pub async fn update_hackathon_submission(&self, id: String, updates: HackathonSubmissionUpdateRequestDto) -> Result<HackathonSubmissionsSchema> {
|
||||
let table = ResourceEnum::HackathonSubmissions.to_string();
|
||||
|
||||
let existing: Option<HackathonSubmissionsSchema> = self.state.surrealdb_ws.select((table.clone(), id.clone())).await?;
|
||||
let mut existing = existing.ok_or_else(|| anyhow!("Submission not found"))?;
|
||||
|
||||
if existing.is_deleted {
|
||||
bail!("Submission not found");
|
||||
}
|
||||
|
||||
// Apply updates
|
||||
if let Some(project_name) = updates.project_name {
|
||||
existing.project_name = Some(project_name);
|
||||
}
|
||||
if let Some(description) = updates.description {
|
||||
existing.description = Some(description);
|
||||
}
|
||||
if let Some(repository_url) = updates.repository_url {
|
||||
existing.repository_url = Some(repository_url);
|
||||
}
|
||||
if let Some(upload_file_url) = updates.upload_file_url {
|
||||
existing.upload_file_url = Some(upload_file_url);
|
||||
}
|
||||
if let Some(demo_url) = updates.demo_url {
|
||||
existing.demo_url = Some(demo_url);
|
||||
}
|
||||
if let Some(slides_url) = updates.slides_url {
|
||||
existing.slides_url = Some(slides_url);
|
||||
}
|
||||
if let Some(technologies) = updates.technologies {
|
||||
existing.technologies = Some(technologies);
|
||||
}
|
||||
if let Some(contact_instagram) = updates.contact_instagram {
|
||||
existing.contact_instagram = Some(contact_instagram);
|
||||
}
|
||||
if let Some(contact_twitter) = updates.contact_twitter {
|
||||
existing.contact_twitter = Some(contact_twitter);
|
||||
}
|
||||
if let Some(contact_linkedin) = updates.contact_linkedin {
|
||||
existing.contact_linkedin = Some(contact_linkedin);
|
||||
}
|
||||
if let Some(contact_facebook) = updates.contact_facebook {
|
||||
existing.contact_facebook = Some(contact_facebook);
|
||||
}
|
||||
if let Some(contact_youtube) = updates.contact_youtube {
|
||||
existing.contact_youtube = Some(contact_youtube);
|
||||
}
|
||||
if let Some(contact_tiktok) = updates.contact_tiktok {
|
||||
existing.contact_tiktok = Some(contact_tiktok);
|
||||
}
|
||||
if let Some(contact_other) = updates.contact_other {
|
||||
existing.contact_other = Some(contact_other);
|
||||
}
|
||||
|
||||
existing.updated_at = Some(get_iso_date());
|
||||
|
||||
info!(query = %format!("UPDATE {} SET ... WHERE id = '{}'", table, id), "Executing SurrealDB query");
|
||||
let record: Option<HackathonSubmissionsSchema> = self
|
||||
.state.surrealdb_ws
|
||||
.update((table, id))
|
||||
.content(existing.clone())
|
||||
.await?;
|
||||
|
||||
match record {
|
||||
Some(s) => Ok(s),
|
||||
None => bail!("Failed to update hackathon submission"),
|
||||
}
|
||||
}
|
||||
|
||||
#[instrument(skip(self, id), err)]
|
||||
pub async fn get_hackathon_submission_by_id(&self, id: String) -> Result<HackathonSubmissionsSchema> {
|
||||
let table = ResourceEnum::HackathonSubmissions.to_string();
|
||||
info!(query = %format!("SELECT * FROM {} WHERE id = '{}'", table, id), "Executing SurrealDB query");
|
||||
|
||||
let record: Option<HackathonSubmissionsSchema> = self
|
||||
.state
|
||||
.surrealdb_ws
|
||||
.select((table, id))
|
||||
.await?;
|
||||
|
||||
match record {
|
||||
Some(s) => {
|
||||
if s.is_deleted {
|
||||
bail!("Submission not found");
|
||||
}
|
||||
Ok(s)
|
||||
}
|
||||
None => bail!("Submission not found"),
|
||||
}
|
||||
}
|
||||
|
||||
#[instrument(skip(self, id), err)]
|
||||
pub async fn submit_hackathon_submission(&self, id: String) -> Result<HackathonSubmissionsSchema> {
|
||||
let table = ResourceEnum::HackathonSubmissions.to_string();
|
||||
|
||||
let existing: Option<HackathonSubmissionsSchema> = self.state.surrealdb_ws.select((table.clone(), id.clone())).await?;
|
||||
let mut existing = existing.ok_or_else(|| anyhow!("Submission not found"))?;
|
||||
|
||||
if existing.is_deleted {
|
||||
bail!("Submission not found");
|
||||
}
|
||||
|
||||
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");
|
||||
let record: Option<HackathonSubmissionsSchema> = self
|
||||
.state.surrealdb_ws
|
||||
.update((table, id))
|
||||
.content(existing.clone())
|
||||
.await?;
|
||||
|
||||
match record {
|
||||
Some(s) => Ok(s),
|
||||
None => bail!("Failed to submit hackathon submission"),
|
||||
}
|
||||
}
|
||||
|
||||
#[instrument(skip(self, id), err)]
|
||||
pub async fn delete_hackathon_submission(&self, id: String) -> Result<String> {
|
||||
let table = ResourceEnum::HackathonSubmissions.to_string();
|
||||
|
||||
let updates: HashMap<String, serde_json::Value> = HashMap::from([
|
||||
("is_deleted".to_string(), true.into()),
|
||||
("updated_at".to_string(), get_iso_date().into()),
|
||||
]);
|
||||
|
||||
info!(query = %format!("UPDATE {} SET is_deleted = true WHERE id = '{}'", table, id), "Executing SurrealDB query");
|
||||
let record: Option<HackathonSubmissionsSchema> = self
|
||||
.state.surrealdb_ws
|
||||
.update((table, id))
|
||||
.merge(serde_json::to_value(updates)?)
|
||||
.await?;
|
||||
|
||||
match record {
|
||||
Some(_) => Ok("Submission deleted successfully".to_string()),
|
||||
None => bail!("Failed to delete submission"),
|
||||
}
|
||||
}
|
||||
#[instrument(skip(self, hackathon_id), err)]
|
||||
pub async fn get_submission_timeline_phase(&self, hackathon_id: String) -> Result<Option<HackathonTimelineSchema>> {
|
||||
let table = ResourceEnum::HackathonTimeline.to_string();
|
||||
|
||||
info!(query = %format!("SELECT * FROM {} WHERE hackathon_id = 'app_hackathons:{}' AND phase = 'Submission' AND is_deleted = false LIMIT 1", table, hackathon_id), "Executing SurrealDB query");
|
||||
|
||||
let mut result = self.state.surrealdb_ws
|
||||
.query("SELECT * FROM type::table($table) WHERE hackathon_id = type::thing('app_hackathons', $hackathon_id) AND phase = $phase AND is_deleted = false LIMIT 1")
|
||||
.bind(("table", table))
|
||||
.bind(("hackathon_id", hackathon_id))
|
||||
.bind(("phase", HackathonPhase::Submission))
|
||||
.await?;
|
||||
|
||||
let timeline: Option<HackathonTimelineSchema> = result.take(0)?;
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,340 +0,0 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize, Deserializer};
|
||||
use std::str::FromStr;
|
||||
use serde::de;
|
||||
use surrealdb::sql::Thing;
|
||||
|
||||
use imphnen_utils::make_thing;
|
||||
use imphnen_utils::get_iso_date;
|
||||
use imphnen_libs::ResourceEnum;
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct HackathonSchema {
|
||||
pub id: Thing,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub start_date: DateTime<Utc>,
|
||||
pub end_date: DateTime<Utc>,
|
||||
pub registration_deadline: DateTime<Utc>,
|
||||
pub max_participants: Option<u32>,
|
||||
pub status: HackathonStatus,
|
||||
pub theme: Option<String>,
|
||||
pub rules: Option<String>,
|
||||
pub prizes: Option<Vec<Prize>>,
|
||||
pub previous_winners: Option<Vec<Winner>>,
|
||||
pub organizers: Vec<String>, // User IDs
|
||||
pub is_deleted: bool,
|
||||
pub created_at: Option<String>,
|
||||
pub updated_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct HackathonEventsSchema {
|
||||
pub id: Thing,
|
||||
pub hackathon_id: Thing,
|
||||
pub title: String,
|
||||
pub description: Option<String>,
|
||||
pub event_type: HackathonEventType,
|
||||
pub start_time: DateTime<Utc>,
|
||||
pub end_time: DateTime<Utc>,
|
||||
pub location: Option<String>,
|
||||
pub virtual_link: Option<String>,
|
||||
pub max_attendees: Option<u32>,
|
||||
pub is_mandatory: bool,
|
||||
pub is_deleted: bool,
|
||||
pub created_at: Option<String>,
|
||||
pub updated_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct HackathonTimelineSchema {
|
||||
pub id: Thing,
|
||||
pub hackathon_id: Thing,
|
||||
pub phase: HackathonPhase,
|
||||
pub title: String,
|
||||
pub description: Option<String>,
|
||||
pub start_date: DateTime<Utc>,
|
||||
pub end_date: DateTime<Utc>,
|
||||
pub is_active: bool,
|
||||
pub order: u32,
|
||||
pub is_deleted: bool,
|
||||
pub created_at: Option<String>,
|
||||
pub updated_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct HackathonSubmissionsSchema {
|
||||
pub id: Thing,
|
||||
pub hackathon_id: Thing,
|
||||
pub team_id: Option<Thing>,
|
||||
pub project_name: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub repository_url: Option<String>,
|
||||
pub upload_file_url: Option<String>,
|
||||
pub demo_url: Option<String>,
|
||||
pub slides_url: Option<String>,
|
||||
pub technologies: Option<Vec<String>>,
|
||||
pub contact_instagram: Option<String>,
|
||||
pub contact_twitter: Option<String>,
|
||||
pub contact_linkedin: Option<String>,
|
||||
pub contact_facebook: Option<String>,
|
||||
pub contact_youtube: Option<String>,
|
||||
pub contact_tiktok: Option<String>,
|
||||
pub contact_other: Option<String>,
|
||||
pub submission_status: Option<SubmissionStatus>,
|
||||
pub judge_feedback: Option<String>,
|
||||
pub submitted_at: Option<DateTime<Utc>>,
|
||||
pub is_deleted: bool,
|
||||
pub created_at: Option<String>,
|
||||
pub updated_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct Prize {
|
||||
pub position: u32,
|
||||
pub title: String,
|
||||
pub description: Option<String>,
|
||||
pub value: Option<String>,
|
||||
}
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct Winner {
|
||||
pub position: u32,
|
||||
pub team_id: String,
|
||||
pub project_name: String,
|
||||
pub team_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, utoipa::ToSchema, strum::Display)]
|
||||
pub enum HackathonStatus {
|
||||
Draft,
|
||||
RegistrationOpen,
|
||||
RegistrationClosed,
|
||||
InProgress,
|
||||
Judging,
|
||||
Completed,
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, PartialEq, utoipa::ToSchema, strum::Display)]
|
||||
pub enum HackathonPhase {
|
||||
Registration,
|
||||
Ideation,
|
||||
Development,
|
||||
Submission,
|
||||
Judging,
|
||||
Awards,
|
||||
}
|
||||
|
||||
// Add as_str method for HackathonPhase
|
||||
impl HackathonPhase {
|
||||
pub fn as_str(&self) -> &str {
|
||||
match self {
|
||||
HackathonPhase::Registration => "registration",
|
||||
HackathonPhase::Ideation => "ideation",
|
||||
HackathonPhase::Development => "development",
|
||||
HackathonPhase::Submission => "submission",
|
||||
HackathonPhase::Judging => "judging",
|
||||
HackathonPhase::Awards => "awards",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Manual Deserialize implementation for case-insensitive support
|
||||
impl<'de> Deserialize<'de> for HackathonPhase {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let s = String::deserialize(deserializer)?;
|
||||
let normalized = s.to_lowercase();
|
||||
|
||||
match normalized.as_str() {
|
||||
"registration" => Ok(HackathonPhase::Registration),
|
||||
"ideation" => Ok(HackathonPhase::Ideation),
|
||||
"development" => Ok(HackathonPhase::Development),
|
||||
"submission" => Ok(HackathonPhase::Submission),
|
||||
"judging" => Ok(HackathonPhase::Judging),
|
||||
"awards" => Ok(HackathonPhase::Awards),
|
||||
_ => Err(serde::de::Error::custom(format!("Invalid HackathonPhase: {}", s)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, PartialEq, utoipa::ToSchema, strum::Display)]
|
||||
pub enum HackathonEventType {
|
||||
Workshop,
|
||||
Keynote,
|
||||
Networking,
|
||||
Judging,
|
||||
Ceremony,
|
||||
Other,
|
||||
}
|
||||
|
||||
// Implement case-insensitive string parsing for HackathonEventType
|
||||
impl FromStr for HackathonEventType {
|
||||
type Err = String;
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s.to_lowercase().as_str() {
|
||||
"workshop" => Ok(Self::Workshop),
|
||||
"keynote" => Ok(Self::Keynote),
|
||||
"networking" => Ok(Self::Networking),
|
||||
"judging" => Ok(Self::Judging),
|
||||
"ceremony" => Ok(Self::Ceremony),
|
||||
"other" => Ok(Self::Other),
|
||||
_ => Err(format!("Invalid HackathonEventType: {}", s)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Manual Deserialize implementation for case-insensitive support
|
||||
impl<'de> Deserialize<'de> for HackathonEventType {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let s = String::deserialize(deserializer)?;
|
||||
Self::from_str(&s).map_err(de::Error::custom)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, utoipa::ToSchema, strum::Display)]
|
||||
pub enum SubmissionStatus {
|
||||
Draft,
|
||||
Submitted,
|
||||
Accepted,
|
||||
UnderReview,
|
||||
Shortlisted,
|
||||
Winner,
|
||||
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",
|
||||
&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 {
|
||||
id: make_thing(
|
||||
&ResourceEnum::Hackathons.to_string(),
|
||||
&surrealdb::Uuid::new_v4().to_string(),
|
||||
),
|
||||
name: String::new(),
|
||||
description: String::new(),
|
||||
start_date: Utc::now(),
|
||||
end_date: Utc::now(),
|
||||
registration_deadline: Utc::now(),
|
||||
max_participants: None,
|
||||
status: HackathonStatus::Draft,
|
||||
theme: None,
|
||||
rules: None,
|
||||
prizes: None,
|
||||
previous_winners: None,
|
||||
organizers: vec![],
|
||||
is_deleted: false,
|
||||
created_at: Some(get_iso_date()),
|
||||
updated_at: Some(get_iso_date()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for HackathonEventsSchema {
|
||||
fn default() -> Self {
|
||||
HackathonEventsSchema {
|
||||
id: make_thing(
|
||||
&ResourceEnum::HackathonEvents.to_string(),
|
||||
&surrealdb::Uuid::new_v4().to_string(),
|
||||
),
|
||||
hackathon_id: Thing::from(("app_hackathons".to_string(), surrealdb::sql::Id::rand())),
|
||||
title: String::new(),
|
||||
description: None,
|
||||
event_type: HackathonEventType::Other,
|
||||
start_time: Utc::now(),
|
||||
end_time: Utc::now(),
|
||||
location: None,
|
||||
virtual_link: None,
|
||||
max_attendees: None,
|
||||
is_mandatory: false,
|
||||
is_deleted: false,
|
||||
created_at: Some(get_iso_date()),
|
||||
updated_at: Some(get_iso_date()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for HackathonTimelineSchema {
|
||||
fn default() -> Self {
|
||||
HackathonTimelineSchema {
|
||||
id: make_thing(
|
||||
&ResourceEnum::HackathonTimeline.to_string(),
|
||||
&surrealdb::Uuid::new_v4().to_string(),
|
||||
),
|
||||
hackathon_id: Thing::from(("app_hackathons".to_string(), surrealdb::sql::Id::rand())),
|
||||
phase: HackathonPhase::Registration,
|
||||
title: String::new(),
|
||||
description: None,
|
||||
start_date: Utc::now(),
|
||||
end_date: Utc::now(),
|
||||
is_active: false,
|
||||
order: 0,
|
||||
is_deleted: false,
|
||||
created_at: Some(get_iso_date()),
|
||||
updated_at: Some(get_iso_date()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for HackathonSubmissionsSchema {
|
||||
fn default() -> Self {
|
||||
HackathonSubmissionsSchema {
|
||||
id: make_thing(
|
||||
&ResourceEnum::HackathonSubmissions.to_string(),
|
||||
&surrealdb::Uuid::new_v4().to_string(),
|
||||
),
|
||||
hackathon_id: Thing::from(("app_hackathons".to_string(), surrealdb::sql::Id::rand())),
|
||||
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,
|
||||
upload_file_url: None,
|
||||
demo_url: None,
|
||||
slides_url: None,
|
||||
technologies: Some(vec![]),
|
||||
contact_instagram: None,
|
||||
contact_twitter: None,
|
||||
contact_linkedin: None,
|
||||
contact_facebook: None,
|
||||
contact_youtube: None,
|
||||
contact_tiktok: None,
|
||||
contact_other: None,
|
||||
submission_status: Some(SubmissionStatus::Draft),
|
||||
judge_feedback: None,
|
||||
submitted_at: Some(Utc::now()),
|
||||
is_deleted: false,
|
||||
created_at: Some(get_iso_date()),
|
||||
updated_at: Some(get_iso_date()),
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,340 +0,0 @@
|
||||
use super::hackathon_schema::{HackathonSchema, HackathonStatus, HackathonTimelineSchema, HackathonPhase};
|
||||
use anyhow::{Result, bail};
|
||||
use std::collections::HashSet;
|
||||
use chrono::Utc;
|
||||
|
||||
/// Validation rules for hackathon operations
|
||||
|
||||
// Constants for limits
|
||||
pub const MAX_ORGANIZERS: usize = 20;
|
||||
pub const MAX_TIMELINES: usize = 10;
|
||||
pub const MAX_EVENTS_PER_HACKATHON: usize = 50;
|
||||
pub const MAX_PRIZES: usize = 20;
|
||||
pub const MIN_TEAM_SIZE: u32 = 2;
|
||||
pub const MAX_TEAM_SIZE: u32 = 10;
|
||||
|
||||
/// Validate status transition
|
||||
pub fn can_transition_status(
|
||||
current: &HackathonStatus,
|
||||
next: &HackathonStatus,
|
||||
) -> Result<()> {
|
||||
use HackathonStatus::*;
|
||||
|
||||
let allowed_transitions: Vec<HackathonStatus> = match current {
|
||||
Draft => vec![RegistrationOpen, Cancelled],
|
||||
RegistrationOpen => vec![RegistrationClosed, Cancelled],
|
||||
RegistrationClosed => vec![InProgress, RegistrationOpen, Cancelled], // Allow reopen
|
||||
InProgress => vec![Judging, Cancelled],
|
||||
Judging => vec![Completed, Cancelled],
|
||||
Completed => vec![], // Terminal state
|
||||
Cancelled => vec![], // Terminal state
|
||||
};
|
||||
|
||||
if allowed_transitions.contains(next) {
|
||||
Ok(())
|
||||
} else {
|
||||
bail!(
|
||||
"Invalid status transition: {:?} -> {:?}. Allowed transitions from {:?} are: {:?}",
|
||||
current,
|
||||
next,
|
||||
current,
|
||||
allowed_transitions
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate hackathon is ready for registration
|
||||
pub fn validate_ready_for_registration(hackathon: &HackathonSchema) -> Result<()> {
|
||||
// Check required fields for registration
|
||||
if hackathon.theme.is_none() || hackathon.theme.as_ref().unwrap().trim().is_empty() {
|
||||
bail!("Theme is required before opening registration");
|
||||
}
|
||||
|
||||
if hackathon.rules.is_none() || hackathon.rules.as_ref().unwrap().trim().is_empty() {
|
||||
bail!("Rules are required before opening registration");
|
||||
}
|
||||
|
||||
if hackathon.prizes.is_none() || hackathon.prizes.as_ref().unwrap().is_empty() {
|
||||
bail!("At least one prize is required before opening registration");
|
||||
}
|
||||
|
||||
if hackathon.organizers.is_empty() {
|
||||
bail!("At least one organizer is required");
|
||||
}
|
||||
|
||||
// Check dates are valid
|
||||
let now = Utc::now();
|
||||
if hackathon.registration_deadline < now {
|
||||
bail!("Registration deadline must be in the future");
|
||||
}
|
||||
|
||||
if hackathon.start_date < now {
|
||||
bail!("Start date must be in the future");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate timeline phases
|
||||
pub fn validate_timeline_phases(
|
||||
hackathon: &HackathonSchema,
|
||||
timelines: &[HackathonTimelineSchema],
|
||||
) -> Result<()> {
|
||||
if timelines.is_empty() {
|
||||
bail!("At least one timeline phase is required");
|
||||
}
|
||||
|
||||
if timelines.len() > MAX_TIMELINES {
|
||||
bail!("Maximum {} timeline phases allowed", MAX_TIMELINES);
|
||||
}
|
||||
|
||||
// Check required phases exist
|
||||
let phases: Vec<HackathonPhase> = timelines.iter().map(|t| t.phase.clone()).collect();
|
||||
|
||||
if !phases.contains(&HackathonPhase::Registration) {
|
||||
bail!("Registration phase is required");
|
||||
}
|
||||
|
||||
if !phases.contains(&HackathonPhase::Submission) {
|
||||
bail!("Submission phase is required");
|
||||
}
|
||||
|
||||
// Check for duplicate phases
|
||||
let unique_phases: HashSet<String> = timelines.iter()
|
||||
.map(|t| t.phase.to_string())
|
||||
.collect();
|
||||
if unique_phases.len() != timelines.len() {
|
||||
bail!("Duplicate timeline phases found");
|
||||
}
|
||||
|
||||
// Check order is sequential
|
||||
let mut orders: Vec<u32> = timelines.iter().map(|t| t.order).collect();
|
||||
orders.sort();
|
||||
for (i, &order) in orders.iter().enumerate() {
|
||||
if order != i as u32 {
|
||||
bail!("Timeline phases must have sequential order (expected {}, got {})", i, order);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for overlapping timelines
|
||||
let mut sorted_timelines = timelines.to_vec();
|
||||
sorted_timelines.sort_by(|a, b| a.start_date.cmp(&b.start_date));
|
||||
|
||||
for i in 0..sorted_timelines.len() - 1 {
|
||||
if sorted_timelines[i].end_date > sorted_timelines[i + 1].start_date {
|
||||
bail!(
|
||||
"Timeline phases cannot overlap: '{}' (ends {}) overlaps with '{}' (starts {})",
|
||||
sorted_timelines[i].title,
|
||||
sorted_timelines[i].end_date,
|
||||
sorted_timelines[i + 1].title,
|
||||
sorted_timelines[i + 1].start_date
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Check timeline covers entire hackathon duration
|
||||
let first = sorted_timelines.first().unwrap();
|
||||
let last = sorted_timelines.last().unwrap();
|
||||
|
||||
// Allow small tolerance (1 hour) for timezone differences
|
||||
let tolerance = chrono::Duration::hours(1);
|
||||
|
||||
if (first.start_date - hackathon.start_date).abs() > tolerance {
|
||||
bail!(
|
||||
"Timeline must start at hackathon start date (Timeline: {}, Hackathon: {})",
|
||||
first.start_date,
|
||||
hackathon.start_date
|
||||
);
|
||||
}
|
||||
|
||||
if (last.end_date - hackathon.end_date).abs() > tolerance {
|
||||
bail!(
|
||||
"Timeline must end at hackathon end date (Timeline: {}, Hackathon: {})",
|
||||
last.end_date,
|
||||
hackathon.end_date
|
||||
);
|
||||
}
|
||||
|
||||
// Check only one timeline is active
|
||||
let active_count = timelines.iter().filter(|t| t.is_active).count();
|
||||
if active_count > 1 {
|
||||
bail!("Only one timeline phase can be active at a time");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate organizers list
|
||||
pub fn validate_organizers(organizers: &[String]) -> Result<()> {
|
||||
if organizers.is_empty() {
|
||||
bail!("At least one organizer is required");
|
||||
}
|
||||
|
||||
if organizers.len() > MAX_ORGANIZERS {
|
||||
bail!("Maximum {} organizers allowed", MAX_ORGANIZERS);
|
||||
}
|
||||
|
||||
// Check for duplicates
|
||||
let unique: HashSet<&String> = organizers.iter().collect();
|
||||
if unique.len() != organizers.len() {
|
||||
bail!("Duplicate organizers found");
|
||||
}
|
||||
|
||||
// Check for empty or invalid IDs
|
||||
for organizer in organizers {
|
||||
if organizer.trim().is_empty() {
|
||||
bail!("Organizer ID cannot be empty");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate prizes
|
||||
pub fn validate_prizes(prizes: &[super::hackathon_schema::Prize]) -> Result<()> {
|
||||
if prizes.is_empty() {
|
||||
bail!("At least one prize is required");
|
||||
}
|
||||
|
||||
if prizes.len() > MAX_PRIZES {
|
||||
bail!("Maximum {} prizes allowed", MAX_PRIZES);
|
||||
}
|
||||
|
||||
// Check for duplicate positions
|
||||
let positions: Vec<u32> = prizes.iter().map(|p| p.position).collect();
|
||||
let unique_positions: HashSet<u32> = positions.iter().copied().collect();
|
||||
if unique_positions.len() != positions.len() {
|
||||
bail!("Duplicate prize positions found");
|
||||
}
|
||||
|
||||
// Check positions are valid (starting from 1)
|
||||
for prize in prizes {
|
||||
if prize.position == 0 {
|
||||
bail!("Prize position must start from 1");
|
||||
}
|
||||
if prize.title.trim().is_empty() {
|
||||
bail!("Prize title cannot be empty");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate dates consistency
|
||||
pub fn validate_dates(
|
||||
start_date: &chrono::DateTime<Utc>,
|
||||
end_date: &chrono::DateTime<Utc>,
|
||||
registration_deadline: &chrono::DateTime<Utc>,
|
||||
) -> Result<()> {
|
||||
if end_date <= start_date {
|
||||
bail!("End date must be after start date");
|
||||
}
|
||||
|
||||
if registration_deadline >= end_date {
|
||||
bail!("Registration deadline must be before end date");
|
||||
}
|
||||
|
||||
// Registration deadline should ideally be before or at start date
|
||||
if registration_deadline > start_date {
|
||||
// Allow but warn - some hackathons allow registration during event
|
||||
tracing::warn!(
|
||||
"Registration deadline ({}) is after start date ({})",
|
||||
registration_deadline,
|
||||
start_date
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate hackathon can be deleted
|
||||
pub fn validate_can_delete(hackathon: &HackathonSchema) -> Result<()> {
|
||||
// Cannot delete completed hackathons (for historical records)
|
||||
if hackathon.status == HackathonStatus::Completed {
|
||||
bail!("Cannot delete completed hackathons. They are kept for historical records.");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get current active phase based on timeline
|
||||
pub fn get_current_phase(timelines: &[HackathonTimelineSchema]) -> Option<HackathonPhase> {
|
||||
let now = Utc::now();
|
||||
|
||||
// Find the timeline that contains current time
|
||||
for timeline in timelines {
|
||||
if timeline.start_date <= now && timeline.end_date >= now {
|
||||
return Some(timeline.phase.clone());
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Validate submission is allowed in current phase
|
||||
pub fn validate_submission_allowed(timelines: &[HackathonTimelineSchema]) -> Result<()> {
|
||||
let current_phase = get_current_phase(timelines);
|
||||
|
||||
match current_phase {
|
||||
Some(HackathonPhase::Submission) => Ok(()),
|
||||
Some(phase) => bail!("Submissions are not allowed in {:?} phase", phase),
|
||||
None => bail!("No active phase found"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate registration is allowed
|
||||
pub fn validate_registration_allowed(
|
||||
hackathon: &HackathonSchema,
|
||||
current_participant_count: u32,
|
||||
) -> Result<()> {
|
||||
// Check status
|
||||
if hackathon.status != HackathonStatus::RegistrationOpen {
|
||||
bail!("Registration is not open for this hackathon");
|
||||
}
|
||||
|
||||
// Check deadline
|
||||
let now = Utc::now();
|
||||
if hackathon.registration_deadline < now {
|
||||
bail!("Registration deadline has passed");
|
||||
}
|
||||
|
||||
// Check max participants
|
||||
if let Some(max) = hackathon.max_participants {
|
||||
if current_participant_count >= max {
|
||||
bail!("Maximum participant limit reached");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::Duration;
|
||||
|
||||
#[test]
|
||||
fn test_status_transitions() {
|
||||
assert!(can_transition_status(&HackathonStatus::Draft, &HackathonStatus::RegistrationOpen).is_ok());
|
||||
assert!(can_transition_status(&HackathonStatus::Draft, &HackathonStatus::Completed).is_err());
|
||||
assert!(can_transition_status(&HackathonStatus::Completed, &HackathonStatus::Draft).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_organizers() {
|
||||
assert!(validate_organizers(&vec!["org1".to_string()]).is_ok());
|
||||
assert!(validate_organizers(&vec![]).is_err());
|
||||
assert!(validate_organizers(&vec!["org1".to_string(), "org1".to_string()]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_dates() {
|
||||
let now = Utc::now();
|
||||
let start = now + Duration::days(1);
|
||||
let end = now + Duration::days(7);
|
||||
let deadline = now + Duration::hours(12);
|
||||
|
||||
assert!(validate_dates(&start, &end, &deadline).is_ok());
|
||||
assert!(validate_dates(&end, &start, &deadline).is_err()); // end before start
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
use axum::Router;
|
||||
|
||||
pub mod hackathon_controller;
|
||||
pub mod hackathon_dto;
|
||||
pub mod hackathon_repository;
|
||||
pub mod hackathon_schema;
|
||||
pub mod hackathon_service;
|
||||
pub mod hackathon_audit_schema;
|
||||
pub mod hackathon_audit_repository;
|
||||
pub mod hackathon_validation;
|
||||
pub mod hackathon_atomic_service;
|
||||
|
||||
// Export types and functions
|
||||
pub use hackathon_dto::*;
|
||||
pub use hackathon_repository::HackathonRepository;
|
||||
pub use hackathon_schema::*;
|
||||
pub use hackathon_service::{HackathonService, HackathonServiceTrait};
|
||||
pub use hackathon_audit_schema::*;
|
||||
pub use hackathon_audit_repository::HackathonAuditRepository;
|
||||
pub use hackathon_validation::*;
|
||||
pub use hackathon_atomic_service::*;
|
||||
|
||||
// Export controller functions
|
||||
pub use hackathon_controller::*;
|
||||
|
||||
pub fn hackathon_router() -> Router {
|
||||
hackathon_controller::hackathon_routes()
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
use axum::Router;
|
||||
|
||||
pub mod hackathon;
|
||||
pub mod notifications;
|
||||
pub mod registrations;
|
||||
|
||||
// Export the router function from hackathon module
|
||||
pub use hackathon::hackathon_router;
|
||||
pub use notifications::notifications_router;
|
||||
pub use registrations::registrations_router;
|
||||
|
||||
// Main route constructor
|
||||
pub fn hackathon_protected_routes() -> Router {
|
||||
// Protected routes include the main hackathon router (create/update/delete) and
|
||||
// a protected route for updating submission status.
|
||||
use hackathon::hackathon_controller::{update_submission_status, get_admin_hackathon_results};
|
||||
Router::new()
|
||||
.nest("/hackathons", hackathon_router())
|
||||
.route("/hackathons/submissions/update/{id}/status", axum::routing::patch(update_submission_status))
|
||||
.route("/hackathons/{hackathon_id}/admin/results", axum::routing::get(get_admin_hackathon_results))
|
||||
.merge(registrations_router())
|
||||
.merge(notifications_router())
|
||||
}
|
||||
|
||||
// Public routes for hackathons (only listing and retrieving)
|
||||
pub fn hackathon_public_routes() -> Router {
|
||||
use hackathon::hackathon_controller::{
|
||||
list_hackathons,
|
||||
search_hackathons,
|
||||
get_user_hackathon_submissions,
|
||||
get_public_hackathon_results,
|
||||
};
|
||||
|
||||
Router::new()
|
||||
.nest("/hackathons", Router::new()
|
||||
.route("/", axum::routing::get(list_hackathons))
|
||||
.route("/{id}/results", axum::routing::get(get_public_hackathon_results))
|
||||
.route("/search", axum::routing::post(search_hackathons))
|
||||
)
|
||||
.route("/users/{user_id}/hackathon-submissions", axum::routing::get(get_user_hackathon_submissions))
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
pub mod notification_controller;
|
||||
pub mod notification_dto;
|
||||
pub mod notification_repository;
|
||||
pub mod notification_schema;
|
||||
pub mod notification_service;
|
||||
|
||||
pub use notification_controller::notifications_router;
|
||||
@@ -1,171 +0,0 @@
|
||||
use super::notification_dto::{
|
||||
DeleteNotificationResponseDto, MarkAllAsReadResponseDto, MarkAsReadResponseDto, NotificationListQueryDto, NotificationListResponseDto,
|
||||
UnreadCountResponseDto,
|
||||
};
|
||||
use super::notification_service::Service;
|
||||
use axum::{
|
||||
extract::{Extension, Path, Query},
|
||||
http::{HeaderMap, Response, StatusCode},
|
||||
routing::{delete, get, put},
|
||||
Router, body::Body,
|
||||
};
|
||||
use imphnen_libs::AppState;
|
||||
use imphnen_utils::{extract_email::extract_email, response_format::common_response};
|
||||
|
||||
/// Get user's notifications with optional filtering
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/v1/notifications",
|
||||
tags = ["notifications"],
|
||||
params(
|
||||
("page_size" = Option<usize>, Query, description = "Number of notifications per page (1-100, default: 20)"),
|
||||
("page" = Option<usize>, Query, description = "Page number (min: 1, default: 1)"),
|
||||
("is_read" = Option<bool>, Query, description = "Filter by read status"),
|
||||
("notification_type" = Option<String>, Query, description = "Filter by notification type"),
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Successfully retrieved notifications", body = NotificationListResponseDto),
|
||||
(status = 401, description = "Unauthorized - Invalid or missing token"),
|
||||
),
|
||||
security(
|
||||
("bearer" = [])
|
||||
)
|
||||
)]
|
||||
pub async fn get_notifications_handler(
|
||||
headers: HeaderMap,
|
||||
Query(query): Query<NotificationListQueryDto>,
|
||||
Extension(state): Extension<AppState>,
|
||||
) -> Response<Body> {
|
||||
match extract_email(&headers) {
|
||||
Some(email) => {
|
||||
let service = Service::new(&state);
|
||||
service.get_notifications(&email, query).await
|
||||
}
|
||||
None => common_response(StatusCode::UNAUTHORIZED, "Unauthorized"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark a notification as read
|
||||
#[utoipa::path(
|
||||
put,
|
||||
path = "/v1/notifications/update/{id}/read",
|
||||
tags = ["notifications"],
|
||||
params(
|
||||
("id" = String, Path, description = "Notification ID")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Successfully marked as read", body = MarkAsReadResponseDto),
|
||||
(status = 401, description = "Unauthorized - Invalid or missing token"),
|
||||
(status = 403, description = "Forbidden - Not the notification owner"),
|
||||
(status = 404, description = "Notification not found"),
|
||||
),
|
||||
security(
|
||||
("bearer" = [])
|
||||
)
|
||||
)]
|
||||
pub async fn mark_as_read_handler(
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<String>,
|
||||
Extension(state): Extension<AppState>,
|
||||
) -> Response<Body> {
|
||||
match extract_email(&headers) {
|
||||
Some(email) => {
|
||||
let service = Service::new(&state);
|
||||
service.mark_as_read(&email, &id).await
|
||||
}
|
||||
None => common_response(StatusCode::UNAUTHORIZED, "Unauthorized"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark all notifications as read
|
||||
#[utoipa::path(
|
||||
put,
|
||||
path = "/v1/notifications/read-all",
|
||||
tags = ["notifications"],
|
||||
responses(
|
||||
(status = 200, description = "Successfully marked all notifications as read", body = MarkAllAsReadResponseDto),
|
||||
(status = 401, description = "Unauthorized - Invalid or missing token"),
|
||||
),
|
||||
security(
|
||||
("bearer" = [])
|
||||
)
|
||||
)]
|
||||
pub async fn mark_all_as_read_handler(
|
||||
headers: HeaderMap,
|
||||
Extension(state): Extension<AppState>,
|
||||
) -> Response<Body> {
|
||||
match extract_email(&headers) {
|
||||
Some(email) => {
|
||||
let service = Service::new(&state);
|
||||
service.mark_all_as_read(&email).await
|
||||
}
|
||||
None => common_response(StatusCode::UNAUTHORIZED, "Unauthorized"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete a notification
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/v1/notifications/delete/{id}",
|
||||
tags = ["notifications"],
|
||||
params(
|
||||
("id" = String, Path, description = "Notification ID"),
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Successfully deleted notification", body = DeleteNotificationResponseDto),
|
||||
(status = 401, description = "Unauthorized - Invalid or missing token"),
|
||||
(status = 403, description = "Forbidden - Not the notification owner"),
|
||||
(status = 404, description = "Notification not found"),
|
||||
),
|
||||
security(
|
||||
("bearer" = [])
|
||||
)
|
||||
)]
|
||||
pub async fn delete_notification_handler(
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<String>,
|
||||
Extension(state): Extension<AppState>,
|
||||
) -> Response<Body> {
|
||||
match extract_email(&headers) {
|
||||
Some(email) => {
|
||||
let service = Service::new(&state);
|
||||
service.delete_notification(&email, &id).await
|
||||
}
|
||||
None => common_response(StatusCode::UNAUTHORIZED, "Unauthorized"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get unread notifications count
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/v1/notifications/unread/count",
|
||||
tags = ["notifications"],
|
||||
responses(
|
||||
(status = 200, description = "Successfully retrieved unread count", body = UnreadCountResponseDto),
|
||||
(status = 401, description = "Unauthorized - Invalid or missing token"),
|
||||
),
|
||||
security(
|
||||
("bearer" = [])
|
||||
)
|
||||
)]
|
||||
pub async fn get_unread_count_handler(
|
||||
headers: HeaderMap,
|
||||
Extension(state): Extension<AppState>,
|
||||
) -> Response<Body> {
|
||||
match extract_email(&headers) {
|
||||
Some(email) => {
|
||||
let service = Service::new(&state);
|
||||
service.get_unread_count(&email).await
|
||||
}
|
||||
None => common_response(StatusCode::UNAUTHORIZED, "Unauthorized"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn notifications_router() -> Router {
|
||||
Router::new()
|
||||
.route("/notifications", get(get_notifications_handler))
|
||||
.route("/notifications/update/{id}/read", put(mark_as_read_handler))
|
||||
.route("/notifications/read-all", put(mark_all_as_read_handler))
|
||||
.route("/notifications/delete/{id}", delete(delete_notification_handler))
|
||||
.route("/notifications/unread/count", get(get_unread_count_handler))
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
use validator::Validate;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct NotificationDto {
|
||||
pub id: String,
|
||||
pub notification_type: String,
|
||||
pub title: String,
|
||||
pub message: String,
|
||||
pub is_read: bool,
|
||||
pub created_at: String,
|
||||
pub read_at: Option<String>,
|
||||
pub related_id: Option<String>,
|
||||
pub action_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct NotificationListResponseDto {
|
||||
pub notifications: Vec<NotificationDto>,
|
||||
pub total: usize,
|
||||
pub unread_count: usize,
|
||||
pub page: usize,
|
||||
pub page_size: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Validate, ToSchema)]
|
||||
pub struct NotificationListQueryDto {
|
||||
#[validate(range(min = 1, max = 100))]
|
||||
#[serde(default = "default_page_size")]
|
||||
pub page_size: usize,
|
||||
|
||||
#[validate(range(min = 1))]
|
||||
#[serde(default = "default_page")]
|
||||
pub page: usize,
|
||||
|
||||
pub is_read: Option<bool>,
|
||||
pub notification_type: Option<String>,
|
||||
}
|
||||
|
||||
fn default_page_size() -> usize {
|
||||
20
|
||||
}
|
||||
|
||||
fn default_page() -> usize {
|
||||
1
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct MarkAsReadResponseDto {
|
||||
pub id: String,
|
||||
pub is_read: bool,
|
||||
pub read_at: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct MarkAllAsReadResponseDto {
|
||||
pub updated_count: usize,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct DeleteNotificationResponseDto {
|
||||
pub id: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct UnreadCountResponseDto {
|
||||
pub unread_count: usize,
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
use crate::v1::notifications::notification_schema::NotificationSchema;
|
||||
use imphnen_libs::AppState;
|
||||
use imphnen_utils::get_id;
|
||||
use surrealdb::sql::Thing;
|
||||
|
||||
pub struct Repository<'a> {
|
||||
state: &'a AppState,
|
||||
}
|
||||
|
||||
impl<'a> Repository<'a> {
|
||||
pub fn new(state: &'a AppState) -> Self {
|
||||
Self { state }
|
||||
}
|
||||
|
||||
pub async fn query_user_notifications(
|
||||
&self,
|
||||
user_id: &Thing,
|
||||
is_read: Option<bool>,
|
||||
notification_type: Option<String>,
|
||||
page: usize,
|
||||
page_size: usize,
|
||||
) -> Result<Vec<NotificationSchema>, String> {
|
||||
let db = &self.state.surrealdb_ws;
|
||||
let offset = (page - 1) * page_size;
|
||||
|
||||
let mut query = "SELECT * FROM notifications WHERE user_id = $user_id ".to_string();
|
||||
|
||||
if let Some(is_read_val) = is_read {
|
||||
query.push_str(&format!(" AND is_read = {} ", is_read_val));
|
||||
}
|
||||
|
||||
if let Some(ref notif_type) = notification_type {
|
||||
query.push_str(&format!(" AND notification_type = '{}' ", notif_type));
|
||||
}
|
||||
|
||||
query.push_str(&format!(
|
||||
" ORDER BY created_at DESC LIMIT {} START {} ",
|
||||
page_size, offset
|
||||
));
|
||||
|
||||
let user_id_clone = user_id.clone();
|
||||
let mut result = db
|
||||
.query(&query)
|
||||
.bind(("user_id", user_id_clone))
|
||||
.await
|
||||
.map_err(|e| format!("Query failed: {}", e))?;
|
||||
let notifications: Vec<NotificationSchema> = result.take(0).map_err(|e| format!("Failed to parse results: {}", e))?;
|
||||
Ok(notifications)
|
||||
}
|
||||
|
||||
pub async fn count_user_notifications(
|
||||
&self,
|
||||
user_id: &Thing,
|
||||
is_read: Option<bool>,
|
||||
notification_type: Option<String>,
|
||||
) -> Result<usize, String> {
|
||||
let db = &self.state.surrealdb_ws;
|
||||
|
||||
let mut query = "SELECT count() as total FROM notifications WHERE user_id = $user_id ".to_string();
|
||||
|
||||
if let Some(is_read_val) = is_read {
|
||||
query.push_str(&format!(" AND is_read = {} ", is_read_val));
|
||||
}
|
||||
|
||||
if let Some(ref notif_type) = notification_type {
|
||||
query.push_str(&format!(" AND notification_type = '{}' ", notif_type));
|
||||
}
|
||||
|
||||
query.push_str(" GROUP ALL ");
|
||||
|
||||
let user_id_clone = user_id.clone();
|
||||
let mut result = db
|
||||
.query(&query)
|
||||
.bind(("user_id", user_id_clone))
|
||||
.await
|
||||
.map_err(|e| format!("Query failed: {}", e))?;
|
||||
let count: Option<usize> = result.take("total").map_err(|e| format!("Failed to get count: {}", e))?;
|
||||
Ok(count.unwrap_or(0))
|
||||
}
|
||||
|
||||
pub async fn query_notification_by_id(
|
||||
&self,
|
||||
notification_id: &Thing,
|
||||
) -> Result<NotificationSchema, String> {
|
||||
let db = &self.state.surrealdb_ws;
|
||||
let record_key = get_id(notification_id).map_err(|e| e.to_string())?;
|
||||
let notification: Option<NotificationSchema> = db
|
||||
.select(record_key)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to fetch notification: {}", e))?;
|
||||
notification.ok_or("Notification not found".to_string())
|
||||
}
|
||||
|
||||
pub async fn update_notification(
|
||||
&self,
|
||||
notification_id: &Thing,
|
||||
notification: NotificationSchema,
|
||||
) -> Result<NotificationSchema, String> {
|
||||
let db = &self.state.surrealdb_ws;
|
||||
let record_key = get_id(notification_id).map_err(|e| e.to_string())?;
|
||||
let updated: Option<NotificationSchema> = db
|
||||
.update(record_key)
|
||||
.content(notification)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to update notification: {}", e))?;
|
||||
updated.ok_or("Failed to update notification".to_string())
|
||||
}
|
||||
|
||||
pub async fn mark_all_as_read(&self, user_id: &Thing) -> Result<usize, String> {
|
||||
let db = &self.state.surrealdb_ws;
|
||||
|
||||
let query = "UPDATE notifications SET is_read = true, read_at = time::now() WHERE user_id = $user_id AND is_read = false";
|
||||
|
||||
let user_id_clone = user_id.clone();
|
||||
let mut result = db
|
||||
.query(query)
|
||||
.bind(("user_id", user_id_clone))
|
||||
.await
|
||||
.map_err(|e| format!("Query failed: {}", e))?;
|
||||
let updated: Vec<NotificationSchema> = result.take(0).map_err(|e| format!("Failed to parse results: {}", e))?;
|
||||
Ok(updated.len())
|
||||
}
|
||||
|
||||
pub async fn delete_notification(&self, notification_id: &Thing) -> Result<(), String> {
|
||||
let db = &self.state.surrealdb_ws;
|
||||
let record_key = get_id(notification_id).map_err(|e| e.to_string())?;
|
||||
let _: Option<NotificationSchema> = db
|
||||
.delete(record_key)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to delete notification: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn count_unread_notifications(&self, user_id: &Thing) -> Result<usize, String> {
|
||||
let db = &self.state.surrealdb_ws;
|
||||
|
||||
let query = "SELECT count() as total FROM notifications WHERE user_id = $user_id AND is_read = false GROUP ALL";
|
||||
|
||||
let user_id_clone = user_id.clone();
|
||||
let mut result = db
|
||||
.query(query)
|
||||
.bind(("user_id", user_id_clone))
|
||||
.await
|
||||
.map_err(|e| format!("Query failed: {}", e))?;
|
||||
let count: Option<usize> = result.take("total").map_err(|e| format!("Failed to get count: {}", e))?;
|
||||
Ok(count.unwrap_or(0))
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use surrealdb::sql::Thing;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum NotificationType {
|
||||
#[serde(rename = "registration_approved")]
|
||||
RegistrationApproved,
|
||||
#[serde(rename = "registration_rejected")]
|
||||
RegistrationRejected,
|
||||
#[serde(rename = "registration_waitlisted")]
|
||||
RegistrationWaitlisted,
|
||||
#[serde(rename = "hackathon_reminder")]
|
||||
HackathonReminder,
|
||||
#[serde(rename = "team_invite")]
|
||||
TeamInvite,
|
||||
#[serde(rename = "team_update")]
|
||||
TeamUpdate,
|
||||
#[serde(rename = "hackathon_update")]
|
||||
HackathonUpdate,
|
||||
#[serde(rename = "check_in_reminder")]
|
||||
CheckInReminder,
|
||||
#[serde(rename = "announcement")]
|
||||
Announcement,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NotificationSchema {
|
||||
pub id: Thing,
|
||||
pub user_id: Thing,
|
||||
pub notification_type: NotificationType,
|
||||
pub title: String,
|
||||
pub message: String,
|
||||
pub is_read: bool,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub read_at: Option<DateTime<Utc>>,
|
||||
pub related_id: Option<Thing>, // Could be hackathon_id, registration_id, team_id, etc.
|
||||
pub action_url: Option<String>,
|
||||
pub metadata: Option<serde_json::Value>, // For additional flexible data
|
||||
}
|
||||
|
||||
impl NotificationSchema {
|
||||
pub fn mark_as_read(&mut self) {
|
||||
self.is_read = true;
|
||||
self.read_at = Some(Utc::now());
|
||||
}
|
||||
}
|
||||
@@ -1,233 +0,0 @@
|
||||
use super::notification_dto::{
|
||||
DeleteNotificationResponseDto, MarkAllAsReadResponseDto, MarkAsReadResponseDto,
|
||||
NotificationDto, NotificationListQueryDto, NotificationListResponseDto,
|
||||
UnreadCountResponseDto,
|
||||
};
|
||||
use super::notification_repository::Repository;
|
||||
use axum::http::{Response, StatusCode};
|
||||
use axum::response::IntoResponse;
|
||||
use axum::body::Body;
|
||||
use imphnen_entities::common_dto::ResponseSuccessDto;
|
||||
use imphnen_libs::AppState;
|
||||
use imphnen_utils::{
|
||||
extract_id, make_thing, response_format::success_response, error_response,
|
||||
validator::validate_request, AppError,
|
||||
};
|
||||
|
||||
pub struct Service<'a> {
|
||||
state: &'a AppState,
|
||||
}
|
||||
|
||||
impl<'a> Service<'a> {
|
||||
pub fn new(state: &'a AppState) -> Self {
|
||||
Self { state }
|
||||
}
|
||||
|
||||
pub async fn get_notifications(
|
||||
&self,
|
||||
user_email: &str,
|
||||
query: NotificationListQueryDto,
|
||||
) -> Response<Body> {
|
||||
if let Err((_status, message)) = validate_request(&query) {
|
||||
return error_response(AppError::ValidationError(message));
|
||||
}
|
||||
|
||||
let user_id = make_thing("users", user_email);
|
||||
let repository = Repository::new(self.state);
|
||||
|
||||
let notifications_result = repository
|
||||
.query_user_notifications(
|
||||
&user_id,
|
||||
query.is_read,
|
||||
query.notification_type.clone(),
|
||||
query.page,
|
||||
query.page_size,
|
||||
)
|
||||
.await;
|
||||
|
||||
let notifications = match notifications_result {
|
||||
Ok(notifs) => notifs,
|
||||
Err(err) => {
|
||||
return error_response(AppError::InternalServerError(err.to_string()));
|
||||
}
|
||||
};
|
||||
|
||||
let total_result = repository
|
||||
.count_user_notifications(&user_id, query.is_read, query.notification_type)
|
||||
.await;
|
||||
|
||||
let total = match total_result {
|
||||
Ok(count) => count,
|
||||
Err(err) => {
|
||||
return error_response(AppError::InternalServerError(err.to_string()));
|
||||
}
|
||||
};
|
||||
|
||||
let unread_count_result = repository.count_unread_notifications(&user_id).await;
|
||||
|
||||
let unread_count = match unread_count_result {
|
||||
Ok(count) => count,
|
||||
Err(err) => {
|
||||
return error_response(AppError::InternalServerError(err.to_string()));
|
||||
}
|
||||
};
|
||||
|
||||
let notification_dtos: Vec<NotificationDto> = notifications
|
||||
.into_iter()
|
||||
.map(|n| NotificationDto {
|
||||
id: extract_id(&n.id),
|
||||
notification_type: format!("{:?}", n.notification_type),
|
||||
title: n.title,
|
||||
message: n.message,
|
||||
is_read: n.is_read,
|
||||
created_at: n.created_at.to_rfc3339(),
|
||||
read_at: n.read_at.map(|dt| dt.to_rfc3339()),
|
||||
related_id: n.related_id.map(|id| extract_id(&id)),
|
||||
action_url: n.action_url,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let response = NotificationListResponseDto {
|
||||
notifications: notification_dtos,
|
||||
total,
|
||||
unread_count,
|
||||
page: query.page,
|
||||
page_size: query.page_size,
|
||||
};
|
||||
|
||||
success_response(ResponseSuccessDto { data: response })
|
||||
}
|
||||
|
||||
pub async fn mark_as_read(
|
||||
&self,
|
||||
user_email: &str,
|
||||
notification_id: &str,
|
||||
) -> Response<Body> {
|
||||
let user_id = make_thing("users", user_email);
|
||||
let notif_id = make_thing("notifications", notification_id);
|
||||
let repository = Repository::new(self.state);
|
||||
|
||||
let notification_result = repository.query_notification_by_id(¬if_id).await;
|
||||
|
||||
let mut notification = match notification_result {
|
||||
Ok(notif) => notif,
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
"Notification not found".to_string(),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
// Verify ownership
|
||||
if notification.user_id != user_id {
|
||||
return (
|
||||
StatusCode::FORBIDDEN,
|
||||
"You don't have permission to access this notification".to_string(),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
if notification.is_read {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Notification is already marked as read".to_string(),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
notification.mark_as_read();
|
||||
|
||||
match repository.update_notification(¬if_id, notification.clone()).await {
|
||||
Ok(updated) => {
|
||||
let response = MarkAsReadResponseDto {
|
||||
id: extract_id(&updated.id),
|
||||
is_read: updated.is_read,
|
||||
read_at: updated.read_at.unwrap().to_rfc3339(),
|
||||
message: "Notification marked as read".to_string(),
|
||||
};
|
||||
|
||||
success_response(ResponseSuccessDto { data: response })
|
||||
}
|
||||
Err(err) => (StatusCode::INTERNAL_SERVER_ERROR, err).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn mark_all_as_read(&self, user_email: &str) -> Response<Body> {
|
||||
let user_id = make_thing("users", user_email);
|
||||
let repository = Repository::new(self.state);
|
||||
|
||||
match repository.mark_all_as_read(&user_id).await {
|
||||
Ok(count) => {
|
||||
let response = MarkAllAsReadResponseDto {
|
||||
updated_count: count,
|
||||
message: format!("{} notification(s) marked as read", count),
|
||||
};
|
||||
|
||||
success_response(ResponseSuccessDto { data: response })
|
||||
}
|
||||
Err(err) => (StatusCode::INTERNAL_SERVER_ERROR, err).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn delete_notification(
|
||||
&self,
|
||||
user_email: &str,
|
||||
notification_id: &str,
|
||||
) -> Response<Body> {
|
||||
let user_id = make_thing("users", user_email);
|
||||
let notif_id = make_thing("notifications", notification_id);
|
||||
let repository = Repository::new(self.state);
|
||||
|
||||
let notification_result = repository.query_notification_by_id(¬if_id).await;
|
||||
|
||||
let notification = match notification_result {
|
||||
Ok(notif) => notif,
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
"Notification not found".to_string(),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
// Verify ownership
|
||||
if notification.user_id != user_id {
|
||||
return (
|
||||
StatusCode::FORBIDDEN,
|
||||
"You don't have permission to delete this notification".to_string(),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
match repository.delete_notification(¬if_id).await {
|
||||
Ok(_) => {
|
||||
let response = DeleteNotificationResponseDto {
|
||||
id: notification_id.to_string(),
|
||||
message: "Notification deleted successfully".to_string(),
|
||||
};
|
||||
|
||||
success_response(ResponseSuccessDto { data: response })
|
||||
}
|
||||
Err(err) => (StatusCode::INTERNAL_SERVER_ERROR, err).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_unread_count(&self, user_email: &str) -> Response<Body> {
|
||||
let user_id = make_thing("users", user_email);
|
||||
let repository = Repository::new(self.state);
|
||||
|
||||
match repository.count_unread_notifications(&user_id).await {
|
||||
Ok(count) => {
|
||||
let response = UnreadCountResponseDto {
|
||||
unread_count: count,
|
||||
};
|
||||
|
||||
success_response(ResponseSuccessDto { data: response })
|
||||
}
|
||||
Err(err) => (StatusCode::INTERNAL_SERVER_ERROR, err).into_response(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
pub mod registration_controller;
|
||||
pub mod registration_dto;
|
||||
pub mod registration_repository;
|
||||
pub mod registration_schema;
|
||||
pub mod registration_service;
|
||||
|
||||
pub use registration_controller::*;
|
||||
pub use registration_dto::*;
|
||||
pub use registration_repository::*;
|
||||
pub use registration_schema::*;
|
||||
pub use registration_service::*;
|
||||
@@ -1,291 +0,0 @@
|
||||
use axum::{
|
||||
extract::{Extension, Path},
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::Response,
|
||||
routing::{get, post, put},
|
||||
Json, Router,
|
||||
};
|
||||
use imphnen_entities::ResponseSuccessDto;
|
||||
use imphnen_libs::AppState;
|
||||
use imphnen_utils::{common_response, extract_email, make_thing_from_enum};
|
||||
use imphnen_libs::ResourceEnum;
|
||||
|
||||
use super::{
|
||||
CheckInResponseDto, RegistrationListResponseDto, RegistrationRequestDto,
|
||||
RegistrationResponseDto, RegistrationStatsDto, RegistrationsService,
|
||||
UpdateRegistrationStatusRequestDto, UpdateRegistrationStatusResponseDto,
|
||||
UserHackathonsResponseDto,
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// POST /v1/hackathons/{id}/registrations/create
|
||||
// ============================================
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/v1/hackathons/{id}/registrations/create",
|
||||
tag = "registrations",
|
||||
summary = "Register for a hackathon",
|
||||
description = "Submit a registration for a hackathon. User must be authenticated.",
|
||||
params(
|
||||
("id" = String, Path, description = "Hackathon ID")
|
||||
),
|
||||
request_body = RegistrationRequestDto,
|
||||
responses(
|
||||
(status = 200, description = "Registration submitted successfully", body = ResponseSuccessDto<RegistrationResponseDto>),
|
||||
(status = 400, description = "Invalid input or validation error"),
|
||||
(status = 401, description = "Unauthorized - authentication required"),
|
||||
(status = 409, description = "User already registered for this hackathon"),
|
||||
(status = 500, description = "Internal server error"),
|
||||
),
|
||||
security(
|
||||
("bearer_auth" = [])
|
||||
)
|
||||
)]
|
||||
pub async fn post_register_hackathon(
|
||||
Extension(state): Extension<AppState>,
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<String>,
|
||||
Json(data): Json<RegistrationRequestDto>,
|
||||
) -> Response {
|
||||
// Authentication
|
||||
let user_email = match extract_email(&headers) {
|
||||
Some(email) => email,
|
||||
None => return common_response(StatusCode::UNAUTHORIZED, "Unauthorized"),
|
||||
};
|
||||
|
||||
// Parse hackathon ID
|
||||
let hackathon_id = make_thing_from_enum(ResourceEnum::Hackathons, &id);
|
||||
|
||||
let service = RegistrationsService::new(&state);
|
||||
service.register_hackathon(&hackathon_id, &id, &user_email, data).await
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// GET /v1/hackathons/{id}/registrations
|
||||
// ============================================
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/v1/hackathons/{id}/registrations",
|
||||
tag = "registrations",
|
||||
summary = "List hackathon registrations",
|
||||
description = "Get all registrations for a hackathon. Requires admin/organizer permissions. Optional status filter.",
|
||||
params(
|
||||
("id" = String, Path, description = "Hackathon ID"),
|
||||
("status" = Option<String>, Query, description = "Filter by status: pending, approved, rejected, waitlisted, cancelled")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Registrations retrieved successfully", body = ResponseSuccessDto<RegistrationListResponseDto>),
|
||||
(status = 400, description = "Invalid input"),
|
||||
(status = 401, description = "Unauthorized - authentication required"),
|
||||
(status = 500, description = "Internal server error"),
|
||||
),
|
||||
security(
|
||||
("bearer_auth" = [])
|
||||
)
|
||||
)]
|
||||
pub async fn get_hackathon_registrations(
|
||||
Extension(state): Extension<AppState>,
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<String>,
|
||||
axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String, String>>,
|
||||
) -> Response {
|
||||
// Authentication
|
||||
match extract_email(&headers) {
|
||||
Some(_) => {},
|
||||
None => return common_response(StatusCode::UNAUTHORIZED, "Unauthorized"),
|
||||
};
|
||||
|
||||
// Parse hackathon ID
|
||||
let hackathon_id = make_thing_from_enum(ResourceEnum::Hackathons, &id);
|
||||
|
||||
let status_filter = params.get("status").cloned();
|
||||
|
||||
let service = RegistrationsService::new(&state);
|
||||
service.get_hackathon_registrations(&hackathon_id, status_filter).await
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// GET /v1/users/me/hackathons
|
||||
// ============================================
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/v1/users/me/hackathons",
|
||||
tag = "registrations",
|
||||
summary = "Get my hackathon registrations",
|
||||
description = "Get all hackathons the current user has registered for.",
|
||||
responses(
|
||||
(status = 200, description = "Hackathons retrieved successfully", body = ResponseSuccessDto<UserHackathonsResponseDto>),
|
||||
(status = 401, description = "Unauthorized - authentication required"),
|
||||
(status = 500, description = "Internal server error"),
|
||||
),
|
||||
security(
|
||||
("bearer_auth" = [])
|
||||
)
|
||||
)]
|
||||
pub async fn get_my_hackathons(
|
||||
Extension(state): Extension<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> Response {
|
||||
// Authentication
|
||||
let user_email = match extract_email(&headers) {
|
||||
Some(email) => email,
|
||||
None => return common_response(StatusCode::UNAUTHORIZED, "Unauthorized"),
|
||||
};
|
||||
|
||||
let service = RegistrationsService::new(&state);
|
||||
service.get_my_hackathons(&user_email).await
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// PUT /v1/hackathons/{hackathon_id}/registrations/update/{registration_id}/status
|
||||
// ============================================
|
||||
#[utoipa::path(
|
||||
put,
|
||||
path = "/v1/hackathons/{hackathon_id}/registrations/update/{registration_id}/status",
|
||||
tag = "registrations",
|
||||
summary = "Update registration status",
|
||||
description = "Update the status of a hackathon registration (admin/organizer only).",
|
||||
params(
|
||||
("hackathon_id" = String, Path, description = "Hackathon ID"),
|
||||
("registration_id" = String, Path, description = "Registration ID")
|
||||
),
|
||||
request_body = UpdateRegistrationStatusRequestDto,
|
||||
responses(
|
||||
(status = 200, description = "Status updated successfully", body = ResponseSuccessDto<UpdateRegistrationStatusResponseDto>),
|
||||
(status = 400, description = "Invalid input or validation error"),
|
||||
(status = 401, description = "Unauthorized - authentication required"),
|
||||
(status = 404, description = "Registration not found"),
|
||||
(status = 500, description = "Internal server error"),
|
||||
),
|
||||
security(
|
||||
("bearer_auth" = [])
|
||||
)
|
||||
)]
|
||||
pub async fn put_update_registration_status(
|
||||
Extension(state): Extension<AppState>,
|
||||
headers: HeaderMap,
|
||||
Path((_hackathon_id, registration_id)): Path<(String, String)>,
|
||||
Json(data): Json<UpdateRegistrationStatusRequestDto>,
|
||||
) -> Response {
|
||||
// Authentication
|
||||
match extract_email(&headers) {
|
||||
Some(_) => {},
|
||||
None => return common_response(StatusCode::UNAUTHORIZED, "Unauthorized"),
|
||||
};
|
||||
|
||||
// Parse registration ID
|
||||
let reg_id = make_thing_from_enum(ResourceEnum::HackathonRegistrations, ®istration_id);
|
||||
|
||||
let service = RegistrationsService::new(&state);
|
||||
service.update_registration_status(®_id, data).await
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// POST /v1/hackathons/{hackathon_id}/registrations/{registration_id}/check-in
|
||||
// ============================================
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/v1/hackathons/{hackathon_id}/registrations/{registration_id}/check-in",
|
||||
tag = "registrations",
|
||||
summary = "Check-in participant",
|
||||
description = "Mark a participant as checked in for the hackathon. Requires admin/organizer permissions.",
|
||||
params(
|
||||
("hackathon_id" = String, Path, description = "Hackathon ID"),
|
||||
("registration_id" = String, Path, description = "Registration ID")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Participant checked in successfully", body = ResponseSuccessDto<CheckInResponseDto>),
|
||||
(status = 400, description = "Invalid request - participant not approved or already checked in"),
|
||||
(status = 401, description = "Unauthorized - authentication required"),
|
||||
(status = 404, description = "Registration not found"),
|
||||
(status = 500, description = "Internal server error"),
|
||||
),
|
||||
security(
|
||||
("bearer_auth" = [])
|
||||
)
|
||||
)]
|
||||
pub async fn post_check_in_participant(
|
||||
Extension(state): Extension<AppState>,
|
||||
headers: HeaderMap,
|
||||
Path((_hackathon_id, registration_id)): Path<(String, String)>,
|
||||
) -> Response {
|
||||
// Authentication
|
||||
match extract_email(&headers) {
|
||||
Some(_) => {},
|
||||
None => return common_response(StatusCode::UNAUTHORIZED, "Unauthorized"),
|
||||
};
|
||||
|
||||
// Parse registration ID
|
||||
let reg_id = make_thing_from_enum(ResourceEnum::HackathonRegistrations, ®istration_id);
|
||||
|
||||
let service = RegistrationsService::new(&state);
|
||||
service.check_in_participant(®_id).await
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// GET /v1/hackathons/{id}/registrations/stats
|
||||
// ============================================
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/v1/hackathons/{id}/registrations/stats",
|
||||
tag = "registrations",
|
||||
summary = "Get registration statistics",
|
||||
description = "Get comprehensive statistics about hackathon registrations. Requires admin/organizer permissions.",
|
||||
params(
|
||||
("id" = String, Path, description = "Hackathon ID")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Statistics retrieved successfully", body = ResponseSuccessDto<RegistrationStatsDto>),
|
||||
(status = 400, description = "Invalid input"),
|
||||
(status = 401, description = "Unauthorized - authentication required"),
|
||||
(status = 500, description = "Internal server error"),
|
||||
),
|
||||
security(
|
||||
("bearer_auth" = [])
|
||||
)
|
||||
)]
|
||||
pub async fn get_registration_stats(
|
||||
Extension(state): Extension<AppState>,
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<String>,
|
||||
) -> Response {
|
||||
// Authentication
|
||||
match extract_email(&headers) {
|
||||
Some(_) => {},
|
||||
None => return common_response(StatusCode::UNAUTHORIZED, "Unauthorized"),
|
||||
};
|
||||
|
||||
// Parse hackathon ID
|
||||
let hackathon_id = make_thing_from_enum(ResourceEnum::Hackathons, &id);
|
||||
|
||||
let service = RegistrationsService::new(&state);
|
||||
service.get_registration_stats(&hackathon_id).await
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Router
|
||||
// ============================================
|
||||
pub fn registrations_router() -> Router {
|
||||
Router::new()
|
||||
.route(
|
||||
"/hackathons/{id}/registrations/create",
|
||||
post(post_register_hackathon),
|
||||
)
|
||||
.route(
|
||||
"/hackathons/{id}/registrations",
|
||||
get(get_hackathon_registrations),
|
||||
)
|
||||
.route(
|
||||
"/hackathons/{id}/registrations/stats",
|
||||
get(get_registration_stats),
|
||||
)
|
||||
.route(
|
||||
"/hackathons/{hackathon_id}/registrations/update/{registration_id}/status",
|
||||
put(put_update_registration_status),
|
||||
)
|
||||
.route(
|
||||
"/hackathons/{hackathon_id}/registrations/{registration_id}/check-in",
|
||||
post(post_check_in_participant),
|
||||
)
|
||||
.route("/users/me/hackathons", get(get_my_hackathons))
|
||||
}
|
||||
@@ -1,216 +0,0 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
use validator::Validate;
|
||||
|
||||
use super::{ParticipantRole, RegistrationStatus};
|
||||
|
||||
// ============================================
|
||||
// Registration Request/Response DTOs
|
||||
// ============================================
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema, Validate)]
|
||||
pub struct RegistrationRequestDto {
|
||||
pub team_id: Option<String>,
|
||||
pub role: Option<ParticipantRole>,
|
||||
|
||||
#[validate(length(max = 1000, message = "Motivation must not exceed 1000 characters"))]
|
||||
pub motivation: Option<String>,
|
||||
|
||||
pub skills: Option<Vec<String>>,
|
||||
|
||||
#[validate(custom(function = "validate_experience_level"))]
|
||||
pub experience_level: Option<String>,
|
||||
|
||||
#[validate(length(max = 100))]
|
||||
pub github_username: Option<String>,
|
||||
|
||||
#[validate(url(message = "Invalid portfolio URL"))]
|
||||
pub portfolio_url: Option<String>,
|
||||
|
||||
pub dietary_requirements: Option<String>,
|
||||
|
||||
#[validate(custom(function = "validate_tshirt_size"))]
|
||||
pub tshirt_size: Option<String>,
|
||||
|
||||
#[validate(length(max = 100))]
|
||||
pub emergency_contact_name: Option<String>,
|
||||
|
||||
#[validate(length(max = 20))]
|
||||
pub emergency_contact_phone: Option<String>,
|
||||
}
|
||||
|
||||
fn validate_experience_level(level: &str) -> Result<(), validator::ValidationError> {
|
||||
let valid_levels = ["beginner", "intermediate", "advanced"];
|
||||
if valid_levels.contains(&level) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(validator::ValidationError::new("Invalid experience level"))
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_tshirt_size(size: &str) -> Result<(), validator::ValidationError> {
|
||||
let valid_sizes = ["XS", "S", "M", "L", "XL", "XXL"];
|
||||
if valid_sizes.contains(&size) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(validator::ValidationError::new("Invalid t-shirt size"))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct RegistrationResponseDto {
|
||||
pub id: String,
|
||||
pub hackathon_id: String,
|
||||
pub user_id: String,
|
||||
pub team_id: Option<String>,
|
||||
pub status: RegistrationStatus,
|
||||
pub role: ParticipantRole,
|
||||
pub registration_date: String,
|
||||
pub checked_in: bool,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// List Registrations DTOs
|
||||
// ============================================
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct RegistrationListItemDto {
|
||||
pub id: String,
|
||||
pub hackathon_id: String,
|
||||
pub hackathon_name: Option<String>,
|
||||
pub user_id: String,
|
||||
pub user_fullname: Option<String>,
|
||||
pub user_email: Option<String>,
|
||||
pub team_id: Option<String>,
|
||||
pub team_name: Option<String>,
|
||||
pub status: RegistrationStatus,
|
||||
pub role: ParticipantRole,
|
||||
pub registration_date: String,
|
||||
pub checked_in: bool,
|
||||
pub check_in_time: Option<String>,
|
||||
pub experience_level: Option<String>,
|
||||
pub skills: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct RegistrationListResponseDto {
|
||||
pub registrations: Vec<RegistrationListItemDto>,
|
||||
pub total: usize,
|
||||
pub status_filter: Option<String>,
|
||||
}
|
||||
|
||||
// Internal query DTO (fields already as String from DB)
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct RegistrationListQueryDto {
|
||||
pub id: String,
|
||||
pub hackathon_id: String,
|
||||
pub hackathon_name: Option<String>,
|
||||
pub user_id: String,
|
||||
pub user_fullname: Option<String>,
|
||||
pub user_email: Option<String>,
|
||||
pub team_id: Option<String>,
|
||||
pub team_name: Option<String>,
|
||||
pub status: RegistrationStatus,
|
||||
pub role: ParticipantRole,
|
||||
pub registration_date: String,
|
||||
pub checked_in: bool,
|
||||
pub check_in_time: Option<String>,
|
||||
pub experience_level: Option<String>,
|
||||
pub skills: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Update Status DTOs
|
||||
// ============================================
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema, Validate)]
|
||||
pub struct UpdateRegistrationStatusRequestDto {
|
||||
pub status: RegistrationStatus,
|
||||
|
||||
#[validate(length(max = 500, message = "Reason must not exceed 500 characters"))]
|
||||
pub reason: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct UpdateRegistrationStatusResponseDto {
|
||||
pub id: String,
|
||||
pub status: RegistrationStatus,
|
||||
pub updated_at: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Check-in DTOs
|
||||
// ============================================
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct CheckInResponseDto {
|
||||
pub id: String,
|
||||
pub user_fullname: Option<String>,
|
||||
pub checked_in: bool,
|
||||
pub check_in_time: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Statistics DTOs
|
||||
// ============================================
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct RegistrationStatsDto {
|
||||
pub hackathon_id: String,
|
||||
pub hackathon_name: Option<String>,
|
||||
pub total_registrations: usize,
|
||||
pub pending: usize,
|
||||
pub approved: usize,
|
||||
pub rejected: usize,
|
||||
pub waitlisted: usize,
|
||||
pub cancelled: usize,
|
||||
pub checked_in: usize,
|
||||
pub team_registrations: usize,
|
||||
pub individual_registrations: usize,
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// User's Hackathons DTOs
|
||||
// ============================================
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct UserHackathonDto {
|
||||
pub registration_id: String,
|
||||
pub hackathon_id: String,
|
||||
pub hackathon_name: Option<String>,
|
||||
pub hackathon_description: Option<String>,
|
||||
pub start_date: Option<String>,
|
||||
pub end_date: Option<String>,
|
||||
pub status: RegistrationStatus,
|
||||
pub role: ParticipantRole,
|
||||
pub registration_date: String,
|
||||
pub checked_in: bool,
|
||||
pub team_id: Option<String>,
|
||||
pub team_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct UserHackathonsResponseDto {
|
||||
pub hackathons: Vec<UserHackathonDto>,
|
||||
pub total: usize,
|
||||
}
|
||||
|
||||
// Internal query DTO
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct UserHackathonQueryDto {
|
||||
pub registration_id: String,
|
||||
pub hackathon_id: String,
|
||||
pub hackathon_name: Option<String>,
|
||||
pub hackathon_description: Option<String>,
|
||||
pub start_date: Option<String>,
|
||||
pub end_date: Option<String>,
|
||||
pub status: RegistrationStatus,
|
||||
pub role: ParticipantRole,
|
||||
pub registration_date: String,
|
||||
pub checked_in: bool,
|
||||
pub team_id: Option<String>,
|
||||
pub team_name: Option<String>,
|
||||
}
|
||||
@@ -1,403 +0,0 @@
|
||||
use super::{ParticipantRole, RegistrationListQueryDto, RegistrationSchema, RegistrationStatus, UserHackathonQueryDto};
|
||||
use imphnen_libs::AppState;
|
||||
use imphnen_utils::get_id;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use surrealdb::sql::Thing;
|
||||
|
||||
pub struct RegistrationsRepository<'a> {
|
||||
pub state: &'a AppState,
|
||||
}
|
||||
|
||||
impl<'a> RegistrationsRepository<'a> {
|
||||
pub fn new(state: &'a AppState) -> Self {
|
||||
Self { state }
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Create Registration
|
||||
// ============================================
|
||||
pub async fn create_registration(&self, registration: RegistrationSchema) -> Result<RegistrationSchema, String> {
|
||||
let db = &self.state.surrealdb_ws;
|
||||
let created: Option<RegistrationSchema> = db
|
||||
.create("hackathon_registrations")
|
||||
.content(registration)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to create registration: {}", e))?;
|
||||
|
||||
created.ok_or_else(|| "Registration creation returned None".to_string())
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Get Registration by ID
|
||||
// ============================================
|
||||
pub async fn query_registration_by_id(&self, id: &Thing) -> Result<Option<RegistrationSchema>, String> {
|
||||
let db = &self.state.surrealdb_ws;
|
||||
let record_key = get_id(id).map_err(|e| e.to_string())?;
|
||||
let registration: Option<RegistrationSchema> = db
|
||||
.select(record_key)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to fetch registration: {}", e))?;
|
||||
|
||||
Ok(registration)
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Check if User Already Registered
|
||||
// ============================================
|
||||
pub async fn check_existing_registration(
|
||||
&self,
|
||||
hackathon_id: &Thing,
|
||||
user_id: &Thing,
|
||||
) -> Result<Option<RegistrationSchema>, String> {
|
||||
let db = &self.state.surrealdb_ws;
|
||||
let query = r#"
|
||||
SELECT * FROM hackathon_registrations
|
||||
WHERE hackathon_id = $hackathon_id
|
||||
AND user_id = $user_id
|
||||
AND is_deleted = false
|
||||
LIMIT 1
|
||||
"#;
|
||||
|
||||
let mut result = db
|
||||
.query(query)
|
||||
.bind(("hackathon_id", hackathon_id.clone()))
|
||||
.bind(("user_id", user_id.clone()))
|
||||
.await
|
||||
.map_err(|e| format!("Failed to check existing registration: {}", e))?;
|
||||
|
||||
let registration: Option<RegistrationSchema> = result
|
||||
.take(0)
|
||||
.map_err(|e| format!("Failed to parse registration: {}", e))?;
|
||||
|
||||
Ok(registration)
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// List Registrations for Hackathon
|
||||
// ============================================
|
||||
pub async fn query_hackathon_registrations(
|
||||
&self,
|
||||
hackathon_id: &Thing,
|
||||
status_filter: Option<RegistrationStatus>,
|
||||
) -> Result<Vec<RegistrationListQueryDto>, String> {
|
||||
let db = &self.state.surrealdb_ws;
|
||||
|
||||
// Use FETCH to retrieve related data in a single query
|
||||
let query = if status_filter.is_some() {
|
||||
r#"
|
||||
SELECT
|
||||
string::join(':', id.tb, id.id) AS id,
|
||||
string::join(':', hackathon_id.tb, hackathon_id.id) AS hackathon_id,
|
||||
hackathon_id.name AS hackathon_name,
|
||||
string::join(':', user_id.tb, user_id.id) AS user_id,
|
||||
user_id.fullname AS user_fullname,
|
||||
user_id.email AS user_email,
|
||||
(IF team_id != NONE THEN string::join(':', team_id.tb, team_id.id) ELSE NONE END) AS team_id,
|
||||
(IF team_id != NONE THEN team_id.name ELSE NONE END) AS team_name,
|
||||
status,
|
||||
role,
|
||||
registration_date,
|
||||
checked_in,
|
||||
check_in_time,
|
||||
experience_level,
|
||||
skills
|
||||
FROM hackathon_registrations
|
||||
WHERE hackathon_id = $hackathon_id
|
||||
AND status = $status
|
||||
AND is_deleted = false
|
||||
FETCH hackathon_id, user_id, team_id;
|
||||
"#
|
||||
} else {
|
||||
r#"
|
||||
SELECT
|
||||
string::join(':', id.tb, id.id) AS id,
|
||||
string::join(':', hackathon_id.tb, hackathon_id.id) AS hackathon_id,
|
||||
hackathon_id.name AS hackathon_name,
|
||||
string::join(':', user_id.tb, user_id.id) AS user_id,
|
||||
user_id.fullname AS user_fullname,
|
||||
user_id.email AS user_email,
|
||||
(IF team_id != NONE THEN string::join(':', team_id.tb, team_id.id) ELSE NONE END) AS team_id,
|
||||
(IF team_id != NONE THEN team_id.name ELSE NONE END) AS team_name,
|
||||
status,
|
||||
role,
|
||||
registration_date,
|
||||
checked_in,
|
||||
check_in_time,
|
||||
experience_level,
|
||||
skills
|
||||
FROM hackathon_registrations
|
||||
WHERE hackathon_id = $hackathon_id
|
||||
AND is_deleted = false
|
||||
FETCH hackathon_id, user_id, team_id;
|
||||
"#
|
||||
};
|
||||
|
||||
let hackathon_id_clone = hackathon_id.clone();
|
||||
let mut result = if let Some(status_val) = status_filter {
|
||||
db.query(query)
|
||||
.bind(("hackathon_id", hackathon_id_clone))
|
||||
.bind(("status", status_val))
|
||||
.await
|
||||
} else {
|
||||
db.query(query)
|
||||
.bind(("hackathon_id", hackathon_id_clone))
|
||||
.await
|
||||
}
|
||||
.map_err(|e| format!("Failed to query hackathon registrations: {}", e))?;
|
||||
|
||||
// Use intermediate struct for parsing with all fields including related data
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct SimpleReg {
|
||||
id: String,
|
||||
hackathon_id: String,
|
||||
hackathon_name: Option<String>,
|
||||
user_id: String,
|
||||
user_fullname: Option<String>,
|
||||
user_email: Option<String>,
|
||||
team_id: Option<String>,
|
||||
team_name: Option<String>,
|
||||
status: RegistrationStatus,
|
||||
role: ParticipantRole,
|
||||
registration_date: String,
|
||||
checked_in: bool,
|
||||
check_in_time: Option<String>,
|
||||
experience_level: Option<String>,
|
||||
skills: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
let simple: Vec<SimpleReg> = result
|
||||
.take(0)
|
||||
.map_err(|e| format!("Failed to parse registrations: {}", e))?;
|
||||
|
||||
// Convert to full DTO with all fetched data
|
||||
let mut registrations: Vec<RegistrationListQueryDto> = simple
|
||||
.into_iter()
|
||||
.map(|r| RegistrationListQueryDto {
|
||||
id: r.id,
|
||||
hackathon_id: r.hackathon_id,
|
||||
hackathon_name: r.hackathon_name,
|
||||
user_id: r.user_id,
|
||||
user_fullname: r.user_fullname,
|
||||
user_email: r.user_email,
|
||||
team_id: r.team_id,
|
||||
team_name: r.team_name,
|
||||
status: r.status,
|
||||
role: r.role,
|
||||
registration_date: r.registration_date.clone(),
|
||||
checked_in: r.checked_in,
|
||||
check_in_time: r.check_in_time,
|
||||
experience_level: r.experience_level,
|
||||
skills: r.skills,
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Sort by registration_date DESC (newest first)
|
||||
registrations.sort_by(|a, b| b.registration_date.cmp(&a.registration_date));
|
||||
|
||||
Ok(registrations)
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Get User's Hackathon Registrations
|
||||
// ============================================
|
||||
pub async fn query_user_hackathons(&self, user_id: &Thing) -> Result<Vec<UserHackathonQueryDto>, String> {
|
||||
let db = &self.state.surrealdb_ws;
|
||||
// Use FETCH to retrieve related hackathon and team data
|
||||
let query = r#"
|
||||
SELECT
|
||||
string::join(':', id.tb, id.id) AS registration_id,
|
||||
string::join(':', hackathon_id.tb, hackathon_id.id) AS hackathon_id,
|
||||
hackathon_id.name AS hackathon_name,
|
||||
hackathon_id.description AS hackathon_description,
|
||||
hackathon_id.start_date AS start_date,
|
||||
hackathon_id.end_date AS end_date,
|
||||
status,
|
||||
role,
|
||||
registration_date,
|
||||
checked_in,
|
||||
(IF team_id != NONE THEN string::join(':', team_id.tb, team_id.id) ELSE NONE END) AS team_id,
|
||||
(IF team_id != NONE THEN team_id.name ELSE NONE END) AS team_name
|
||||
FROM hackathon_registrations
|
||||
WHERE user_id = $user_id
|
||||
AND is_deleted = false
|
||||
FETCH hackathon_id, team_id;
|
||||
"#;
|
||||
|
||||
let user_id_clone = user_id.clone();
|
||||
let mut result = db
|
||||
.query(query)
|
||||
.bind(("user_id", user_id_clone))
|
||||
.await
|
||||
.map_err(|e| format!("Failed to query user hackathons: {}", e))?;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct SimpleUserHackathon {
|
||||
registration_id: String,
|
||||
hackathon_id: String,
|
||||
hackathon_name: Option<String>,
|
||||
hackathon_description: Option<String>,
|
||||
start_date: Option<String>,
|
||||
end_date: Option<String>,
|
||||
status: RegistrationStatus,
|
||||
role: ParticipantRole,
|
||||
registration_date: String,
|
||||
checked_in: bool,
|
||||
team_id: Option<String>,
|
||||
team_name: Option<String>,
|
||||
}
|
||||
|
||||
let simple: Vec<SimpleUserHackathon> = result
|
||||
.take(0)
|
||||
.map_err(|e| format!("Failed to parse user hackathons: {}", e))?;
|
||||
|
||||
// Convert to full DTO with all fetched data
|
||||
let mut hackathons: Vec<UserHackathonQueryDto> = simple
|
||||
.into_iter()
|
||||
.map(|h| UserHackathonQueryDto {
|
||||
registration_id: h.registration_id,
|
||||
hackathon_id: h.hackathon_id,
|
||||
hackathon_name: h.hackathon_name,
|
||||
hackathon_description: h.hackathon_description,
|
||||
start_date: h.start_date,
|
||||
end_date: h.end_date,
|
||||
status: h.status,
|
||||
role: h.role,
|
||||
registration_date: h.registration_date.clone(),
|
||||
checked_in: h.checked_in,
|
||||
team_id: h.team_id,
|
||||
team_name: h.team_name,
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Sort by registration_date DESC (newest first)
|
||||
hackathons.sort_by(|a, b| b.registration_date.cmp(&a.registration_date));
|
||||
|
||||
Ok(hackathons)
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Get Registration Statistics
|
||||
// ============================================
|
||||
pub async fn query_registration_stats(&self, hackathon_id: &Thing) -> Result<RegistrationStatsQueryDto, String> {
|
||||
let db = &self.state.surrealdb_ws;
|
||||
|
||||
// Get hackathon name first
|
||||
let hackathon_query = r#"
|
||||
SELECT name FROM hackathons WHERE id = $hackathon_id LIMIT 1
|
||||
"#;
|
||||
|
||||
let hackathon_id_clone = hackathon_id.clone();
|
||||
let mut hackathon_result = db
|
||||
.query(hackathon_query)
|
||||
.bind(("hackathon_id", hackathon_id_clone.clone()))
|
||||
.await
|
||||
.map_err(|e| format!("Failed to fetch hackathon name: {}", e))?;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct HackathonName {
|
||||
name: String,
|
||||
}
|
||||
|
||||
let hackathon_names: Vec<HackathonName> = hackathon_result
|
||||
.take(0)
|
||||
.map_err(|e| format!("Failed to parse hackathon name: {}", e))?;
|
||||
|
||||
let hackathon_name = hackathon_names.first().map(|h| h.name.clone());
|
||||
|
||||
// Get all registrations
|
||||
let query = r#"
|
||||
SELECT * FROM hackathon_registrations
|
||||
WHERE hackathon_id = $hackathon_id
|
||||
AND is_deleted = false
|
||||
"#;
|
||||
|
||||
let mut result = db
|
||||
.query(query)
|
||||
.bind(("hackathon_id", hackathon_id_clone))
|
||||
.await
|
||||
.map_err(|e| format!("Failed to query registrations for stats: {}", e))?;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct RegForStats {
|
||||
status: RegistrationStatus,
|
||||
checked_in: bool,
|
||||
team_id: Option<String>,
|
||||
}
|
||||
|
||||
let regs: Vec<RegForStats> = result
|
||||
.take(0)
|
||||
.map_err(|e| format!("Failed to parse registrations for stats: {}", e))?;
|
||||
|
||||
// Calculate stats manually
|
||||
let total = regs.len();
|
||||
let pending = regs.iter().filter(|r| matches!(r.status, RegistrationStatus::Pending)).count();
|
||||
let approved = regs.iter().filter(|r| matches!(r.status, RegistrationStatus::Approved)).count();
|
||||
let rejected = regs.iter().filter(|r| matches!(r.status, RegistrationStatus::Rejected)).count();
|
||||
let waitlisted = regs.iter().filter(|r| matches!(r.status, RegistrationStatus::Waitlisted)).count();
|
||||
let cancelled = regs.iter().filter(|r| matches!(r.status, RegistrationStatus::Cancelled)).count();
|
||||
let checked_in = regs.iter().filter(|r| r.checked_in).count();
|
||||
let team_registrations = regs.iter().filter(|r| r.team_id.is_some()).count();
|
||||
let individual_registrations = regs.iter().filter(|r| r.team_id.is_none()).count();
|
||||
|
||||
let hackathon_id_str = format!("{}", hackathon_id);
|
||||
|
||||
Ok(RegistrationStatsQueryDto {
|
||||
hackathon_id: hackathon_id_str,
|
||||
hackathon_name,
|
||||
total_registrations: total,
|
||||
pending,
|
||||
approved,
|
||||
rejected,
|
||||
waitlisted,
|
||||
cancelled,
|
||||
checked_in,
|
||||
team_registrations,
|
||||
individual_registrations,
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Update Registration
|
||||
// ============================================
|
||||
pub async fn update_registration(&self, id: &Thing, registration: RegistrationSchema) -> Result<RegistrationSchema, String> {
|
||||
let db = &self.state.surrealdb_ws;
|
||||
let record_key = get_id(id).map_err(|e| e.to_string())?;
|
||||
let updated: Option<RegistrationSchema> = db
|
||||
.update(record_key)
|
||||
.content(registration)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to update registration: {}", e))?;
|
||||
|
||||
updated.ok_or_else(|| "Registration update returned None".to_string())
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Delete Registration (soft delete)
|
||||
// ============================================
|
||||
pub async fn delete_registration(&self, id: &Thing) -> Result<(), String> {
|
||||
let db = &self.state.surrealdb_ws;
|
||||
let record_key = get_id(id).map_err(|e| e.to_string())?;
|
||||
let _: Option<RegistrationSchema> = db
|
||||
.delete(record_key)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to delete registration: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// Helper DTO for stats query
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct RegistrationStatsQueryDto {
|
||||
pub hackathon_id: String,
|
||||
pub hackathon_name: Option<String>,
|
||||
pub total_registrations: usize,
|
||||
pub pending: usize,
|
||||
pub approved: usize,
|
||||
pub rejected: usize,
|
||||
pub waitlisted: usize,
|
||||
pub cancelled: usize,
|
||||
pub checked_in: usize,
|
||||
pub team_registrations: usize,
|
||||
pub individual_registrations: usize,
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use surrealdb::sql::Thing;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use imphnen_libs::ResourceEnum;
|
||||
use imphnen_utils::{get_iso_date, make_thing, make_thing_from_enum};
|
||||
|
||||
use super::RegistrationRequestDto;
|
||||
|
||||
/// Registration status enum
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, PartialEq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum RegistrationStatus {
|
||||
Pending,
|
||||
Approved,
|
||||
Rejected,
|
||||
Waitlisted,
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
/// Participant role in hackathon
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, PartialEq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ParticipantRole {
|
||||
Individual,
|
||||
TeamLeader,
|
||||
TeamMember,
|
||||
}
|
||||
|
||||
/// Hackathon registration schema
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct RegistrationSchema {
|
||||
pub id: Thing,
|
||||
pub hackathon_id: Thing,
|
||||
pub user_id: Thing,
|
||||
pub team_id: Option<Thing>,
|
||||
pub status: RegistrationStatus,
|
||||
pub role: ParticipantRole,
|
||||
pub registration_date: String,
|
||||
pub approved_at: Option<String>,
|
||||
pub rejected_at: Option<String>,
|
||||
pub rejection_reason: Option<String>,
|
||||
pub checked_in: bool,
|
||||
pub check_in_time: Option<String>,
|
||||
pub notes: Option<String>,
|
||||
pub skills: Option<Vec<String>>,
|
||||
pub experience_level: Option<String>, // beginner, intermediate, advanced
|
||||
pub github_username: Option<String>,
|
||||
pub portfolio_url: Option<String>,
|
||||
pub motivation: Option<String>,
|
||||
pub dietary_requirements: Option<String>,
|
||||
pub tshirt_size: Option<String>, // XS, S, M, L, XL, XXL
|
||||
pub emergency_contact_name: Option<String>,
|
||||
pub emergency_contact_phone: Option<String>,
|
||||
pub is_deleted: bool,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
impl RegistrationSchema {
|
||||
/// Create a new registration from request DTO
|
||||
pub fn from_request(
|
||||
hackathon_id: &Thing,
|
||||
user_id: &Thing,
|
||||
data: RegistrationRequestDto,
|
||||
) -> Result<Self, String> {
|
||||
let now = get_iso_date();
|
||||
|
||||
// Convert team_id from String to Thing if provided
|
||||
let team_id_thing = data.team_id
|
||||
.as_ref()
|
||||
.map(|id| make_thing_from_enum(ResourceEnum::Teams, id));
|
||||
|
||||
Ok(Self {
|
||||
id: make_thing(ResourceEnum::HackathonRegistrations.as_str(), &uuid::Uuid::new_v4().to_string()),
|
||||
hackathon_id: hackathon_id.clone(),
|
||||
user_id: user_id.clone(),
|
||||
team_id: team_id_thing,
|
||||
status: RegistrationStatus::Pending,
|
||||
role: data.role.unwrap_or(ParticipantRole::Individual),
|
||||
registration_date: now.clone(),
|
||||
approved_at: None,
|
||||
rejected_at: None,
|
||||
rejection_reason: None,
|
||||
checked_in: false,
|
||||
check_in_time: None,
|
||||
notes: None,
|
||||
skills: data.skills,
|
||||
experience_level: data.experience_level,
|
||||
github_username: data.github_username,
|
||||
portfolio_url: data.portfolio_url,
|
||||
motivation: data.motivation,
|
||||
dietary_requirements: data.dietary_requirements,
|
||||
tshirt_size: data.tshirt_size,
|
||||
emergency_contact_name: data.emergency_contact_name,
|
||||
emergency_contact_phone: data.emergency_contact_phone,
|
||||
is_deleted: false,
|
||||
created_at: now.clone(),
|
||||
updated_at: now,
|
||||
})
|
||||
}
|
||||
|
||||
/// Update registration status
|
||||
pub fn update_status(&mut self, status: RegistrationStatus, reason: Option<String>) {
|
||||
let now = get_iso_date();
|
||||
self.status = status.clone();
|
||||
self.updated_at = now.clone();
|
||||
|
||||
match status {
|
||||
RegistrationStatus::Approved => {
|
||||
self.approved_at = Some(now);
|
||||
self.rejected_at = None;
|
||||
self.rejection_reason = None;
|
||||
}
|
||||
RegistrationStatus::Rejected => {
|
||||
self.rejected_at = Some(now);
|
||||
self.rejection_reason = reason;
|
||||
self.approved_at = None;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Check-in participant
|
||||
pub fn check_in(&mut self) -> Result<(), String> {
|
||||
if self.status != RegistrationStatus::Approved {
|
||||
return Err("Only approved registrations can be checked in".to_string());
|
||||
}
|
||||
|
||||
if self.checked_in {
|
||||
return Err("Already checked in".to_string());
|
||||
}
|
||||
|
||||
let now = get_iso_date();
|
||||
self.checked_in = true;
|
||||
self.check_in_time = Some(now.clone());
|
||||
self.updated_at = now;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,308 +0,0 @@
|
||||
use axum::response::Response;
|
||||
use axum::http::StatusCode;
|
||||
use imphnen_entities::ResponseSuccessDto;
|
||||
use imphnen_libs::AppState;
|
||||
use imphnen_utils::{
|
||||
common_response, extract_id, make_thing_from_enum, success_response, validate_request,
|
||||
};
|
||||
use surrealdb::sql::Thing;
|
||||
|
||||
use super::{
|
||||
CheckInResponseDto, RegistrationListItemDto, RegistrationListResponseDto,
|
||||
RegistrationRequestDto, RegistrationResponseDto, RegistrationSchema,
|
||||
RegistrationStatsDto, RegistrationStatus,
|
||||
RegistrationsRepository, UpdateRegistrationStatusRequestDto,
|
||||
UpdateRegistrationStatusResponseDto, UserHackathonDto, UserHackathonsResponseDto,
|
||||
};
|
||||
use crate::v1::hackathon::HackathonRepository;
|
||||
use imphnen_libs::ResourceEnum;
|
||||
|
||||
pub struct RegistrationsService<'a> {
|
||||
state: &'a AppState,
|
||||
}
|
||||
|
||||
impl<'a> RegistrationsService<'a> {
|
||||
pub fn new(state: &'a AppState) -> Self {
|
||||
Self { state }
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Register for Hackathon
|
||||
// ============================================
|
||||
pub async fn register_hackathon(
|
||||
&self,
|
||||
hackathon_id: &Thing,
|
||||
hackathon_id_str: &str,
|
||||
user_email: &str,
|
||||
data: RegistrationRequestDto,
|
||||
) -> Response {
|
||||
// Validate request
|
||||
if let Err((status, message)) = validate_request(&data) {
|
||||
return common_response(status, &message);
|
||||
}
|
||||
|
||||
let repository = RegistrationsRepository::new(self.state);
|
||||
|
||||
// Get user ID from email
|
||||
let user_id = make_thing_from_enum(ResourceEnum::Users, user_email);
|
||||
|
||||
// Check if hackathon exists
|
||||
let hackathon_repo = HackathonRepository::new(self.state);
|
||||
// Use the raw string ID from the path parameter
|
||||
match hackathon_repo.get_hackathon_by_id(hackathon_id_str.to_string()).await {
|
||||
Err(e) => {
|
||||
// Method returns error if hackathon not found or is deleted
|
||||
let error_msg = e.to_string();
|
||||
if error_msg.contains("not found") || error_msg.contains("Hackathon not found") {
|
||||
return common_response(StatusCode::NOT_FOUND, "Hackathon not found");
|
||||
}
|
||||
return common_response(StatusCode::INTERNAL_SERVER_ERROR, &format!("Failed to verify hackathon: {}", e));
|
||||
}
|
||||
Ok(_) => {} // Hackathon exists, continue
|
||||
}
|
||||
|
||||
// Check if user already registered
|
||||
match repository
|
||||
.check_existing_registration(hackathon_id, &user_id)
|
||||
.await
|
||||
{
|
||||
Ok(Some(_)) => {
|
||||
return common_response(StatusCode::CONFLICT, "You have already registered for this hackathon")
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(e) => return common_response(StatusCode::INTERNAL_SERVER_ERROR, &e),
|
||||
}
|
||||
|
||||
// Create registration
|
||||
let registration = match RegistrationSchema::from_request(hackathon_id, &user_id, data) {
|
||||
Ok(reg) => reg,
|
||||
Err(e) => return common_response(StatusCode::BAD_REQUEST, &e),
|
||||
};
|
||||
|
||||
match repository.create_registration(registration).await {
|
||||
Ok(created) => {
|
||||
let response = RegistrationResponseDto {
|
||||
id: extract_id(&created.id),
|
||||
hackathon_id: extract_id(&created.hackathon_id),
|
||||
user_id: extract_id(&created.user_id),
|
||||
team_id: created.team_id.as_ref().map(|t| extract_id(t)),
|
||||
status: created.status,
|
||||
role: created.role,
|
||||
registration_date: created.registration_date,
|
||||
checked_in: created.checked_in,
|
||||
message: "Registration submitted successfully. You will be notified once approved."
|
||||
.to_string(),
|
||||
};
|
||||
|
||||
success_response(ResponseSuccessDto { data: response })
|
||||
}
|
||||
Err(e) => common_response(StatusCode::INTERNAL_SERVER_ERROR, &e),
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// List Registrations for Hackathon
|
||||
// ============================================
|
||||
pub async fn get_hackathon_registrations(
|
||||
&self,
|
||||
hackathon_id: &Thing,
|
||||
status_filter: Option<String>,
|
||||
) -> Response {
|
||||
let repository = RegistrationsRepository::new(self.state);
|
||||
|
||||
// Parse status filter if provided
|
||||
let status_enum = if let Some(status_str) = &status_filter {
|
||||
match status_str.to_lowercase().as_str() {
|
||||
"pending" => Some(RegistrationStatus::Pending),
|
||||
"approved" => Some(RegistrationStatus::Approved),
|
||||
"rejected" => Some(RegistrationStatus::Rejected),
|
||||
"waitlisted" => Some(RegistrationStatus::Waitlisted),
|
||||
"cancelled" => Some(RegistrationStatus::Cancelled),
|
||||
_ => return common_response(StatusCode::BAD_REQUEST, "Invalid status filter"),
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
match repository
|
||||
.query_hackathon_registrations(hackathon_id, status_enum)
|
||||
.await
|
||||
{
|
||||
Ok(results) => {
|
||||
let registrations = results
|
||||
.into_iter()
|
||||
.map(|r| RegistrationListItemDto {
|
||||
id: r.id,
|
||||
hackathon_id: r.hackathon_id,
|
||||
hackathon_name: r.hackathon_name,
|
||||
user_id: r.user_id,
|
||||
user_fullname: r.user_fullname,
|
||||
user_email: r.user_email,
|
||||
team_id: r.team_id,
|
||||
team_name: r.team_name,
|
||||
status: r.status,
|
||||
role: r.role,
|
||||
registration_date: r.registration_date,
|
||||
checked_in: r.checked_in,
|
||||
check_in_time: r.check_in_time,
|
||||
experience_level: r.experience_level,
|
||||
skills: r.skills,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let total = registrations.len();
|
||||
let response = RegistrationListResponseDto {
|
||||
registrations,
|
||||
total,
|
||||
status_filter,
|
||||
};
|
||||
|
||||
success_response(ResponseSuccessDto { data: response })
|
||||
}
|
||||
Err(e) => common_response(StatusCode::INTERNAL_SERVER_ERROR, &e),
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Get Current User's Hackathon Registrations
|
||||
// ============================================
|
||||
pub async fn get_my_hackathons(&self, user_email: &str) -> Response {
|
||||
let repository = RegistrationsRepository::new(self.state);
|
||||
|
||||
// Get user ID from email
|
||||
let user_id = make_thing_from_enum(ResourceEnum::Users, user_email);
|
||||
|
||||
match repository.query_user_hackathons(&user_id).await {
|
||||
Ok(results) => {
|
||||
let hackathons = results
|
||||
.into_iter()
|
||||
.map(|h| UserHackathonDto {
|
||||
registration_id: h.registration_id,
|
||||
hackathon_id: h.hackathon_id,
|
||||
hackathon_name: h.hackathon_name,
|
||||
hackathon_description: h.hackathon_description,
|
||||
start_date: h.start_date,
|
||||
end_date: h.end_date,
|
||||
status: h.status,
|
||||
role: h.role,
|
||||
registration_date: h.registration_date,
|
||||
checked_in: h.checked_in,
|
||||
team_id: h.team_id,
|
||||
team_name: h.team_name,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let total = hackathons.len();
|
||||
let response = UserHackathonsResponseDto { hackathons, total };
|
||||
|
||||
success_response(ResponseSuccessDto { data: response })
|
||||
}
|
||||
Err(e) => common_response(StatusCode::INTERNAL_SERVER_ERROR, &e),
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Update Registration Status
|
||||
// ============================================
|
||||
pub async fn update_registration_status(
|
||||
&self,
|
||||
registration_id: &Thing,
|
||||
data: UpdateRegistrationStatusRequestDto,
|
||||
) -> Response {
|
||||
// Validate request
|
||||
if let Err((status, message)) = validate_request(&data) {
|
||||
return common_response(status, &message);
|
||||
}
|
||||
|
||||
let repository = RegistrationsRepository::new(self.state);
|
||||
|
||||
// Get existing registration
|
||||
let mut registration = match repository.query_registration_by_id(registration_id).await {
|
||||
Ok(Some(reg)) => reg,
|
||||
Ok(None) => return common_response(StatusCode::NOT_FOUND, "Registration not found"),
|
||||
Err(e) => return common_response(StatusCode::INTERNAL_SERVER_ERROR, &e),
|
||||
};
|
||||
|
||||
// Update status
|
||||
registration.update_status(data.status.clone(), data.reason);
|
||||
|
||||
// Save updated registration
|
||||
match repository.update_registration(registration_id, registration.clone()).await {
|
||||
Ok(updated) => {
|
||||
let status_clone = updated.status.clone();
|
||||
let response = UpdateRegistrationStatusResponseDto {
|
||||
id: extract_id(&updated.id),
|
||||
status: updated.status,
|
||||
updated_at: updated.updated_at,
|
||||
message: format!("Registration status updated to {:?}", status_clone),
|
||||
};
|
||||
|
||||
success_response(ResponseSuccessDto { data: response })
|
||||
}
|
||||
Err(e) => common_response(StatusCode::INTERNAL_SERVER_ERROR, &e),
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Check-in Participant
|
||||
// ============================================
|
||||
pub async fn check_in_participant(&self, registration_id: &Thing) -> Response {
|
||||
let repository = RegistrationsRepository::new(self.state);
|
||||
|
||||
// Get existing registration
|
||||
let mut registration = match repository.query_registration_by_id(registration_id).await {
|
||||
Ok(Some(reg)) => reg,
|
||||
Ok(None) => return common_response(StatusCode::NOT_FOUND, "Registration not found"),
|
||||
Err(e) => return common_response(StatusCode::INTERNAL_SERVER_ERROR, &e),
|
||||
};
|
||||
|
||||
// Perform check-in
|
||||
if let Err(e) = registration.check_in() {
|
||||
return common_response(StatusCode::BAD_REQUEST, &e);
|
||||
}
|
||||
|
||||
// Save updated registration
|
||||
match repository.update_registration(registration_id, registration.clone()).await {
|
||||
Ok(updated) => {
|
||||
let response = CheckInResponseDto {
|
||||
id: extract_id(&updated.id),
|
||||
user_fullname: None, // Would need to query user info
|
||||
checked_in: updated.checked_in,
|
||||
check_in_time: updated.check_in_time.unwrap_or_default(),
|
||||
message: "Participant checked in successfully".to_string(),
|
||||
};
|
||||
|
||||
success_response(ResponseSuccessDto { data: response })
|
||||
}
|
||||
Err(e) => common_response(StatusCode::INTERNAL_SERVER_ERROR, &e),
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Get Registration Statistics
|
||||
// ============================================
|
||||
pub async fn get_registration_stats(&self, hackathon_id: &Thing) -> Response {
|
||||
let repository = RegistrationsRepository::new(self.state);
|
||||
|
||||
match repository.query_registration_stats(hackathon_id).await {
|
||||
Ok(stats) => {
|
||||
let response = RegistrationStatsDto {
|
||||
hackathon_id: stats.hackathon_id,
|
||||
hackathon_name: stats.hackathon_name,
|
||||
total_registrations: stats.total_registrations,
|
||||
pending: stats.pending,
|
||||
approved: stats.approved,
|
||||
rejected: stats.rejected,
|
||||
waitlisted: stats.waitlisted,
|
||||
cancelled: stats.cancelled,
|
||||
checked_in: stats.checked_in,
|
||||
team_registrations: stats.team_registrations,
|
||||
individual_registrations: stats.individual_registrations,
|
||||
};
|
||||
|
||||
success_response(ResponseSuccessDto { data: response })
|
||||
}
|
||||
Err(e) => common_response(StatusCode::INTERNAL_SERVER_ERROR, &e),
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user