feat: Implement hackathon status change functionality with audit logging
- Added HackathonStatusChangeRequestDto for status change requests. - Implemented update_hackathon_status method in HackathonRepository to handle status updates. - Enhanced HackathonService to validate and process status changes, including audit logging. - Introduced HackathonAuditLogSchema to track changes and actions related to hackathons. - Created HackathonAuditRepository for managing audit logs. - Added validation functions for hackathon operations, including dates, organizers, and prizes. - Implemented atomic service for creating hackathons with timelines and events, ensuring all-or-nothing behavior. - Updated mod.rs to include new modules for audit logging and validation.
This commit is contained in:
@@ -0,0 +1,278 @@
|
||||
use super::hackathon_dto::{
|
||||
HackathonCreateRequestDto, HackathonDto, HackathonEventCreateRequestDto,
|
||||
HackathonTimelineCreateRequestDto,
|
||||
};
|
||||
use super::hackathon_repository::HackathonRepository;
|
||||
use super::hackathon_schema::{HackathonSchema, HackathonEventsSchema, HackathonTimelineSchema};
|
||||
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(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
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),
|
||||
}),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -4,19 +4,21 @@ use super::hackathon_dto::{
|
||||
HackathonEventUpdateRequestDto, HackathonSubmissionCreateRequestDto,
|
||||
HackathonSubmissionDto, HackathonSubmissionUpdateRequestDto, HackathonTimelineCreateRequestDto,
|
||||
HackathonTimelineDto, HackathonTimelineUpdateRequestDto, HackathonUpdateRequestDto,
|
||||
HackathonStatusChangeRequestDto,
|
||||
};
|
||||
use super::hackathon_service::{HackathonService, HackathonServiceTrait};
|
||||
use super::hackathon_schema::SubmissionStatus;
|
||||
use super::hackathon_atomic_service::{HackathonAtomicService, HackathonCompleteSetupRequestDto, HackathonCompleteSetupResponseDto};
|
||||
use crate::v1::hackathon::HackathonRepository;
|
||||
use crate::{AppState, ResponseSuccessDto, ErrorDto};
|
||||
use imphnen_entities::{PermissionsEnum, UsersDetailQueryDto};
|
||||
use imphnen_libs::{MetaRequestDto, ResponseListSuccessDto};
|
||||
use imphnen_libs::{MetaRequestDto, ResponseListSuccessDto, jsonwebtoken::Claims};
|
||||
use axum::{
|
||||
extract::{Extension, Path, Query},
|
||||
http::StatusCode,
|
||||
Json, Router,
|
||||
response::IntoResponse,
|
||||
routing::{delete, get, post, put},
|
||||
routing::{delete, get, patch, post, put},
|
||||
};
|
||||
use axum::body::Bytes;
|
||||
use futures::future;
|
||||
@@ -1066,14 +1068,158 @@ pub async fn post_admin_manage_sensitive_data(
|
||||
Ok((StatusCode::OK, Json(response)).into_response())
|
||||
}
|
||||
|
||||
pub fn hackathon_routes() -> Router {
|
||||
// AppState would be properly injected in real usage via Axum's state management
|
||||
// For now, we'll create routes without middleware that requires AppState
|
||||
// Atomic hackathon creation with timeline and events
|
||||
#[utoipa::path(
|
||||
post,
|
||||
security(
|
||||
("Bearer" = [])
|
||||
),
|
||||
path = "/v1/hackathons/complete",
|
||||
request_body = HackathonCompleteSetupRequestDto,
|
||||
responses(
|
||||
(status = 201, description = "[ADMIN] Hackathon created atomically with timeline and events", body = ResponseSuccessDto<HackathonCompleteSetupResponseDto>),
|
||||
(status = 400, description = "[ADMIN] Bad request or validation failed", body = ErrorDto),
|
||||
(status = 403, description = "[ADMIN] Forbidden", body = ErrorDto),
|
||||
(status = 500, description = "[ADMIN] Internal server error, changes rolled back", body = ErrorDto)
|
||||
),
|
||||
tag = "Hackathons"
|
||||
)]
|
||||
pub async fn create_hackathon_complete(
|
||||
_headers: HeaderMap,
|
||||
Extension(state): Extension<AppState>,
|
||||
Json(payload): Json<HackathonCompleteSetupRequestDto>,
|
||||
) -> impl IntoResponse {
|
||||
match HackathonAtomicService::create_hackathon_complete(payload, &state).await {
|
||||
Ok(response) => {
|
||||
(axum::http::StatusCode::CREATED, Json(serde_json::json!({
|
||||
"message": "Hackathon created successfully with timeline and events",
|
||||
"data": response.data
|
||||
}))).into_response()
|
||||
}
|
||||
Err(error) => (StatusCode::from_u16(error.status).unwrap(), Json(error)).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
// Change hackathon status with validation
|
||||
#[utoipa::path(
|
||||
patch,
|
||||
security(
|
||||
("Bearer" = [])
|
||||
),
|
||||
path = "/v1/hackathons/{id}/status",
|
||||
params(
|
||||
("id" = String, Path, description = "Hackathon ID")
|
||||
),
|
||||
request_body = HackathonStatusChangeRequestDto,
|
||||
responses(
|
||||
(status = 200, description = "[ADMIN] Status changed successfully", body = ResponseSuccessDto<HackathonDto>),
|
||||
(status = 400, description = "[ADMIN] Invalid status transition", body = ErrorDto),
|
||||
(status = 403, description = "[ADMIN] Forbidden", body = ErrorDto),
|
||||
(status = 404, description = "[ADMIN] Hackathon not found", body = ErrorDto),
|
||||
(status = 500, description = "[ADMIN] Internal server error", body = ErrorDto)
|
||||
),
|
||||
tag = "Hackathons"
|
||||
)]
|
||||
pub async fn change_hackathon_status(
|
||||
_headers: HeaderMap,
|
||||
Extension(state): Extension<AppState>,
|
||||
Path(id): Path<String>,
|
||||
Json(payload): Json<HackathonStatusChangeRequestDto>,
|
||||
) -> impl IntoResponse {
|
||||
use super::hackathon_validation::{can_transition_status, validate_ready_for_registration};
|
||||
use super::hackathon_audit_schema::{AuditAction, HackathonAuditLogSchema};
|
||||
use super::hackathon_audit_repository::HackathonAuditRepository;
|
||||
|
||||
let repo = HackathonRepository::new(&state);
|
||||
let audit_repo = HackathonAuditRepository::new(&state);
|
||||
|
||||
// Get existing hackathon
|
||||
let existing = match repo.get_hackathon_by_id(id.clone()).await {
|
||||
Ok(h) => h,
|
||||
Err(_) => {
|
||||
return (StatusCode::NOT_FOUND, Json(ErrorDto {
|
||||
status: StatusCode::NOT_FOUND.as_u16(),
|
||||
message: "Hackathon not found".to_string(),
|
||||
details: None,
|
||||
})).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
// Validate status transition
|
||||
if let Err(e) = can_transition_status(&existing.status, &payload.status) {
|
||||
return (StatusCode::BAD_REQUEST, Json(ErrorDto {
|
||||
status: StatusCode::BAD_REQUEST.as_u16(),
|
||||
message: e.to_string(),
|
||||
details: None,
|
||||
})).into_response();
|
||||
}
|
||||
|
||||
// Additional validation for RegistrationOpen
|
||||
if payload.status == super::hackathon_schema::HackathonStatus::RegistrationOpen {
|
||||
if let Err(e) = validate_ready_for_registration(&existing) {
|
||||
return (StatusCode::BAD_REQUEST, Json(ErrorDto {
|
||||
status: StatusCode::BAD_REQUEST.as_u16(),
|
||||
message: format!("Cannot open registration: {}", e),
|
||||
details: None,
|
||||
})).into_response();
|
||||
}
|
||||
}
|
||||
|
||||
// Update status
|
||||
let update_dto = HackathonUpdateRequestDto {
|
||||
name: None,
|
||||
description: None,
|
||||
start_date: None,
|
||||
end_date: None,
|
||||
registration_deadline: None,
|
||||
max_participants: None,
|
||||
theme: None,
|
||||
rules: None,
|
||||
prizes: None,
|
||||
previous_winners: None,
|
||||
organizers: None,
|
||||
};
|
||||
|
||||
// Manually update status in repository
|
||||
let updated = match repo.update_hackathon_status(id.clone(), payload.status.clone()).await {
|
||||
Ok(h) => h,
|
||||
Err(e) => {
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorDto {
|
||||
status: StatusCode::INTERNAL_SERVER_ERROR.as_u16(),
|
||||
message: format!("Failed to update status: {}", e),
|
||||
details: None,
|
||||
})).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
// Create audit log
|
||||
let old_value = serde_json::json!({"status": existing.status});
|
||||
let new_value = serde_json::json!({"status": payload.status, "reason": payload.reason});
|
||||
|
||||
let audit_log = HackathonAuditLogSchema::new(
|
||||
Some(updated.id.clone()),
|
||||
AuditAction::HackathonStatusChanged,
|
||||
payload.actor_id.unwrap_or_else(|| "system".to_string()),
|
||||
"hackathon".to_string(),
|
||||
Some(id),
|
||||
)
|
||||
.with_old_new_values(old_value, new_value);
|
||||
|
||||
if let Err(e) = audit_repo.log(audit_log).await {
|
||||
tracing::error!("Failed to create audit log: {}", e);
|
||||
}
|
||||
|
||||
let dto = HackathonDto::from(updated);
|
||||
(StatusCode::OK, Json(ResponseSuccessDto { data: dto })).into_response()
|
||||
}
|
||||
|
||||
pub fn hackathon_routes() -> Router {
|
||||
Router::new()
|
||||
// Hackathon routes - simplified for compilation
|
||||
// Hackathon routes
|
||||
.route("/", post(create_hackathon))
|
||||
.route("/complete", post(create_hackathon_complete))
|
||||
.route("/{id}", put(update_hackathon))
|
||||
.route("/{id}/status", patch(change_hackathon_status))
|
||||
.route("/{id}", delete(delete_hackathon))
|
||||
|
||||
// Hackathon Events routes
|
||||
|
||||
@@ -618,4 +618,13 @@ pub struct AdminSensitiveDataDto {
|
||||
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>,
|
||||
}
|
||||
|
||||
@@ -246,6 +246,31 @@ impl<'a> HackathonRepository<'a> {
|
||||
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
|
||||
|
||||
@@ -11,6 +11,13 @@ use super::hackathon_dto::{
|
||||
};
|
||||
use super::hackathon_repository::HackathonRepository;
|
||||
use super::hackathon_schema::SubmissionStatus;
|
||||
use super::hackathon_audit_schema::{AuditAction, HackathonAuditLogSchema};
|
||||
use super::hackathon_audit_repository::HackathonAuditRepository;
|
||||
use super::hackathon_validation::{
|
||||
can_transition_status, validate_dates, validate_organizers,
|
||||
validate_prizes, validate_ready_for_registration, validate_timeline_phases,
|
||||
validate_can_delete, validate_registration_allowed, validate_submission_allowed,
|
||||
};
|
||||
use crate::{AppState, ResponseSuccessDto, ErrorDto};
|
||||
use imphnen_utils::{validator::validate_request};
|
||||
use imphnen_libs::{MetaRequestDto, ResponseListSuccessDto};
|
||||
@@ -156,7 +163,7 @@ impl HackathonServiceTrait for HackathonService {
|
||||
|
||||
let state = state.to_owned();
|
||||
Box::pin(async move {
|
||||
// Validate request
|
||||
// 1. Validate request input
|
||||
if let Err((_, error_message)) = validate_request(&payload) {
|
||||
return Err(ErrorDto {
|
||||
status: StatusCode::BAD_REQUEST.as_u16(),
|
||||
@@ -165,38 +172,70 @@ impl HackathonServiceTrait for HackathonService {
|
||||
});
|
||||
}
|
||||
|
||||
// Business logic validation
|
||||
if payload.end_date <= payload.start_date {
|
||||
// 2. Validate dates consistency
|
||||
if let Err(e) = validate_dates(
|
||||
&payload.start_date,
|
||||
&payload.end_date,
|
||||
&payload.registration_deadline,
|
||||
) {
|
||||
return Err(ErrorDto {
|
||||
status: StatusCode::BAD_REQUEST.as_u16(),
|
||||
message: "End date must be after start date".to_string(),
|
||||
message: e.to_string(),
|
||||
details: None,
|
||||
});
|
||||
}
|
||||
|
||||
// Registration deadline should be before the hackathon end date (allowing registration up
|
||||
// to the start of or during the hackathon depending on business rules). Tests in this
|
||||
// repository set the deadline between start and end, so validate against end_date here.
|
||||
if payload.registration_deadline >= payload.end_date {
|
||||
// 3. Validate organizers
|
||||
if let Err(e) = validate_organizers(&payload.organizers) {
|
||||
return Err(ErrorDto {
|
||||
status: StatusCode::BAD_REQUEST.as_u16(),
|
||||
message: "Registration deadline must be before end date".to_string(),
|
||||
message: e.to_string(),
|
||||
details: None,
|
||||
});
|
||||
}
|
||||
|
||||
if payload.organizers.is_empty() {
|
||||
return Err(ErrorDto {
|
||||
status: StatusCode::BAD_REQUEST.as_u16(),
|
||||
message: "At least one organizer is required".to_string(),
|
||||
details: None,
|
||||
});
|
||||
// 4. Validate prizes if provided
|
||||
if let Some(ref prizes) = payload.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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let repo = HackathonRepository::new(&state);
|
||||
let audit_repo = HackathonAuditRepository::new(&state);
|
||||
|
||||
match repo.create_hackathon(payload).await {
|
||||
// 5. Create hackathon
|
||||
match repo.create_hackathon(payload.clone()).await {
|
||||
Ok(hackathon) => {
|
||||
// 6. Create audit log
|
||||
let audit_log = HackathonAuditLogSchema::new(
|
||||
Some(hackathon.id.clone()),
|
||||
AuditAction::HackathonCreated,
|
||||
payload.organizers.first().unwrap_or(&"system".to_string()).clone(),
|
||||
"hackathon".to_string(),
|
||||
Some(hackathon.id.id.to_string()),
|
||||
)
|
||||
.with_changes(serde_json::to_value(&hackathon).unwrap_or_default());
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
let dto = HackathonDto::from(hackathon);
|
||||
Ok(ResponseSuccessDto { data: dto })
|
||||
}
|
||||
@@ -273,7 +312,7 @@ impl HackathonServiceTrait for HackathonService {
|
||||
|
||||
let state = state.to_owned();
|
||||
Box::pin(async move {
|
||||
// Validate request
|
||||
// 1. Validate request
|
||||
if let Err(errors) = validate_request(&payload) {
|
||||
return Err(ErrorDto {
|
||||
status: StatusCode::BAD_REQUEST.as_u16(),
|
||||
@@ -283,8 +322,9 @@ impl HackathonServiceTrait for HackathonService {
|
||||
}
|
||||
|
||||
let repo = HackathonRepository::new(&state);
|
||||
let audit_repo = HackathonAuditRepository::new(&state);
|
||||
|
||||
// Get existing hackathon for validation
|
||||
// 2. Get existing hackathon for validation
|
||||
let existing = match repo.get_hackathon_by_id(id.clone()).await {
|
||||
Ok(h) => h,
|
||||
Err(_) => {
|
||||
@@ -296,30 +336,75 @@ impl HackathonServiceTrait for HackathonService {
|
||||
}
|
||||
};
|
||||
|
||||
// Business logic validation
|
||||
// 3. Validate dates consistency
|
||||
let start_date = payload.start_date.unwrap_or(existing.start_date);
|
||||
let end_date = payload.end_date.unwrap_or(existing.end_date);
|
||||
let registration_deadline = payload.registration_deadline.unwrap_or(existing.registration_deadline);
|
||||
|
||||
if end_date <= start_date {
|
||||
if let Err(e) = validate_dates(&start_date, &end_date, ®istration_deadline) {
|
||||
return Err(ErrorDto {
|
||||
status: StatusCode::BAD_REQUEST.as_u16(),
|
||||
message: "End date must be after start date".to_string(),
|
||||
message: e.to_string(),
|
||||
details: None,
|
||||
});
|
||||
}
|
||||
|
||||
// Same rule as create_hackathon: the registration deadline must be before the end date.
|
||||
if registration_deadline >= end_date {
|
||||
return Err(ErrorDto {
|
||||
status: StatusCode::BAD_REQUEST.as_u16(),
|
||||
message: "Registration deadline must be before end date".to_string(),
|
||||
details: None,
|
||||
});
|
||||
// 4. Validate organizers if being updated
|
||||
if let Some(ref organizers) = payload.organizers {
|
||||
if let Err(e) = validate_organizers(organizers) {
|
||||
return Err(ErrorDto {
|
||||
status: StatusCode::BAD_REQUEST.as_u16(),
|
||||
message: e.to_string(),
|
||||
details: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
match repo.update_hackathon(id, payload).await {
|
||||
// 5. Validate prizes if being updated
|
||||
if let Some(ref prizes) = payload.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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Store old value for audit log
|
||||
let old_value = serde_json::to_value(&existing).unwrap_or_default();
|
||||
|
||||
// 7. Update hackathon
|
||||
match repo.update_hackathon(id.clone(), payload.clone()).await {
|
||||
Ok(hackathon) => {
|
||||
// 8. Create audit log
|
||||
let new_value = serde_json::to_value(&hackathon).unwrap_or_default();
|
||||
let changes = serde_json::to_value(&payload).unwrap_or_default();
|
||||
|
||||
let audit_log = HackathonAuditLogSchema::new(
|
||||
Some(hackathon.id.clone()),
|
||||
AuditAction::HackathonUpdated,
|
||||
existing.organizers.first().unwrap_or(&"system".to_string()).clone(),
|
||||
"hackathon".to_string(),
|
||||
Some(id),
|
||||
)
|
||||
.with_changes(changes)
|
||||
.with_old_new_values(old_value, new_value);
|
||||
|
||||
if let Err(e) = audit_repo.log(audit_log).await {
|
||||
tracing::error!("Failed to create audit log: {}", e);
|
||||
}
|
||||
|
||||
let dto = HackathonDto::from(hackathon);
|
||||
Ok(ResponseSuccessDto { data: dto })
|
||||
}
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -5,12 +5,20 @@ 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::*;
|
||||
|
||||
Reference in New Issue
Block a user