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,
|
HackathonEventUpdateRequestDto, HackathonSubmissionCreateRequestDto,
|
||||||
HackathonSubmissionDto, HackathonSubmissionUpdateRequestDto, HackathonTimelineCreateRequestDto,
|
HackathonSubmissionDto, HackathonSubmissionUpdateRequestDto, HackathonTimelineCreateRequestDto,
|
||||||
HackathonTimelineDto, HackathonTimelineUpdateRequestDto, HackathonUpdateRequestDto,
|
HackathonTimelineDto, HackathonTimelineUpdateRequestDto, HackathonUpdateRequestDto,
|
||||||
|
HackathonStatusChangeRequestDto,
|
||||||
};
|
};
|
||||||
use super::hackathon_service::{HackathonService, HackathonServiceTrait};
|
use super::hackathon_service::{HackathonService, HackathonServiceTrait};
|
||||||
use super::hackathon_schema::SubmissionStatus;
|
use super::hackathon_schema::SubmissionStatus;
|
||||||
|
use super::hackathon_atomic_service::{HackathonAtomicService, HackathonCompleteSetupRequestDto, HackathonCompleteSetupResponseDto};
|
||||||
use crate::v1::hackathon::HackathonRepository;
|
use crate::v1::hackathon::HackathonRepository;
|
||||||
use crate::{AppState, ResponseSuccessDto, ErrorDto};
|
use crate::{AppState, ResponseSuccessDto, ErrorDto};
|
||||||
use imphnen_entities::{PermissionsEnum, UsersDetailQueryDto};
|
use imphnen_entities::{PermissionsEnum, UsersDetailQueryDto};
|
||||||
use imphnen_libs::{MetaRequestDto, ResponseListSuccessDto};
|
use imphnen_libs::{MetaRequestDto, ResponseListSuccessDto, jsonwebtoken::Claims};
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Extension, Path, Query},
|
extract::{Extension, Path, Query},
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
Json, Router,
|
Json, Router,
|
||||||
response::IntoResponse,
|
response::IntoResponse,
|
||||||
routing::{delete, get, post, put},
|
routing::{delete, get, patch, post, put},
|
||||||
};
|
};
|
||||||
use axum::body::Bytes;
|
use axum::body::Bytes;
|
||||||
use futures::future;
|
use futures::future;
|
||||||
@@ -1066,14 +1068,158 @@ pub async fn post_admin_manage_sensitive_data(
|
|||||||
Ok((StatusCode::OK, Json(response)).into_response())
|
Ok((StatusCode::OK, Json(response)).into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn hackathon_routes() -> Router {
|
// Atomic hackathon creation with timeline and events
|
||||||
// AppState would be properly injected in real usage via Axum's state management
|
#[utoipa::path(
|
||||||
// For now, we'll create routes without middleware that requires AppState
|
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()
|
Router::new()
|
||||||
// Hackathon routes - simplified for compilation
|
// Hackathon routes
|
||||||
.route("/", post(create_hackathon))
|
.route("/", post(create_hackathon))
|
||||||
|
.route("/complete", post(create_hackathon_complete))
|
||||||
.route("/{id}", put(update_hackathon))
|
.route("/{id}", put(update_hackathon))
|
||||||
|
.route("/{id}/status", patch(change_hackathon_status))
|
||||||
.route("/{id}", delete(delete_hackathon))
|
.route("/{id}", delete(delete_hackathon))
|
||||||
|
|
||||||
// Hackathon Events routes
|
// Hackathon Events routes
|
||||||
|
|||||||
@@ -618,4 +618,13 @@ pub struct AdminSensitiveDataDto {
|
|||||||
pub struct AdminSensitiveDataResponseDto {
|
pub struct AdminSensitiveDataResponseDto {
|
||||||
pub data: Vec<AdminSensitiveDataDto>,
|
pub data: Vec<AdminSensitiveDataDto>,
|
||||||
pub message: String,
|
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"),
|
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
|
// Hackathon Events CRUD operations
|
||||||
|
|||||||
@@ -11,6 +11,13 @@ use super::hackathon_dto::{
|
|||||||
};
|
};
|
||||||
use super::hackathon_repository::HackathonRepository;
|
use super::hackathon_repository::HackathonRepository;
|
||||||
use super::hackathon_schema::SubmissionStatus;
|
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 crate::{AppState, ResponseSuccessDto, ErrorDto};
|
||||||
use imphnen_utils::{validator::validate_request};
|
use imphnen_utils::{validator::validate_request};
|
||||||
use imphnen_libs::{MetaRequestDto, ResponseListSuccessDto};
|
use imphnen_libs::{MetaRequestDto, ResponseListSuccessDto};
|
||||||
@@ -156,7 +163,7 @@ impl HackathonServiceTrait for HackathonService {
|
|||||||
|
|
||||||
let state = state.to_owned();
|
let state = state.to_owned();
|
||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
// Validate request
|
// 1. Validate request input
|
||||||
if let Err((_, error_message)) = validate_request(&payload) {
|
if let Err((_, error_message)) = validate_request(&payload) {
|
||||||
return Err(ErrorDto {
|
return Err(ErrorDto {
|
||||||
status: StatusCode::BAD_REQUEST.as_u16(),
|
status: StatusCode::BAD_REQUEST.as_u16(),
|
||||||
@@ -165,38 +172,70 @@ impl HackathonServiceTrait for HackathonService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Business logic validation
|
// 2. Validate dates consistency
|
||||||
if payload.end_date <= payload.start_date {
|
if let Err(e) = validate_dates(
|
||||||
|
&payload.start_date,
|
||||||
|
&payload.end_date,
|
||||||
|
&payload.registration_deadline,
|
||||||
|
) {
|
||||||
return Err(ErrorDto {
|
return Err(ErrorDto {
|
||||||
status: StatusCode::BAD_REQUEST.as_u16(),
|
status: StatusCode::BAD_REQUEST.as_u16(),
|
||||||
message: "End date must be after start date".to_string(),
|
message: e.to_string(),
|
||||||
details: None,
|
details: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Registration deadline should be before the hackathon end date (allowing registration up
|
// 3. Validate organizers
|
||||||
// to the start of or during the hackathon depending on business rules). Tests in this
|
if let Err(e) = validate_organizers(&payload.organizers) {
|
||||||
// repository set the deadline between start and end, so validate against end_date here.
|
|
||||||
if payload.registration_deadline >= payload.end_date {
|
|
||||||
return Err(ErrorDto {
|
return Err(ErrorDto {
|
||||||
status: StatusCode::BAD_REQUEST.as_u16(),
|
status: StatusCode::BAD_REQUEST.as_u16(),
|
||||||
message: "Registration deadline must be before end date".to_string(),
|
message: e.to_string(),
|
||||||
details: None,
|
details: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if payload.organizers.is_empty() {
|
// 4. Validate prizes if provided
|
||||||
return Err(ErrorDto {
|
if let Some(ref prizes) = payload.prizes {
|
||||||
status: StatusCode::BAD_REQUEST.as_u16(),
|
let prize_schemas: Vec<super::hackathon_schema::Prize> = prizes
|
||||||
message: "At least one organizer is required".to_string(),
|
.iter()
|
||||||
details: None,
|
.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 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) => {
|
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);
|
let dto = HackathonDto::from(hackathon);
|
||||||
Ok(ResponseSuccessDto { data: dto })
|
Ok(ResponseSuccessDto { data: dto })
|
||||||
}
|
}
|
||||||
@@ -273,7 +312,7 @@ impl HackathonServiceTrait for HackathonService {
|
|||||||
|
|
||||||
let state = state.to_owned();
|
let state = state.to_owned();
|
||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
// Validate request
|
// 1. Validate request
|
||||||
if let Err(errors) = validate_request(&payload) {
|
if let Err(errors) = validate_request(&payload) {
|
||||||
return Err(ErrorDto {
|
return Err(ErrorDto {
|
||||||
status: StatusCode::BAD_REQUEST.as_u16(),
|
status: StatusCode::BAD_REQUEST.as_u16(),
|
||||||
@@ -283,8 +322,9 @@ impl HackathonServiceTrait for HackathonService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let repo = HackathonRepository::new(&state);
|
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 {
|
let existing = match repo.get_hackathon_by_id(id.clone()).await {
|
||||||
Ok(h) => h,
|
Ok(h) => h,
|
||||||
Err(_) => {
|
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 start_date = payload.start_date.unwrap_or(existing.start_date);
|
||||||
let end_date = payload.end_date.unwrap_or(existing.end_date);
|
let end_date = payload.end_date.unwrap_or(existing.end_date);
|
||||||
let registration_deadline = payload.registration_deadline.unwrap_or(existing.registration_deadline);
|
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 {
|
return Err(ErrorDto {
|
||||||
status: StatusCode::BAD_REQUEST.as_u16(),
|
status: StatusCode::BAD_REQUEST.as_u16(),
|
||||||
message: "End date must be after start date".to_string(),
|
message: e.to_string(),
|
||||||
details: None,
|
details: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Same rule as create_hackathon: the registration deadline must be before the end date.
|
// 4. Validate organizers if being updated
|
||||||
if registration_deadline >= end_date {
|
if let Some(ref organizers) = payload.organizers {
|
||||||
return Err(ErrorDto {
|
if let Err(e) = validate_organizers(organizers) {
|
||||||
status: StatusCode::BAD_REQUEST.as_u16(),
|
return Err(ErrorDto {
|
||||||
message: "Registration deadline must be before end date".to_string(),
|
status: StatusCode::BAD_REQUEST.as_u16(),
|
||||||
details: None,
|
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) => {
|
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);
|
let dto = HackathonDto::from(hackathon);
|
||||||
Ok(ResponseSuccessDto { data: dto })
|
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_repository;
|
||||||
pub mod hackathon_schema;
|
pub mod hackathon_schema;
|
||||||
pub mod hackathon_service;
|
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
|
// Export types and functions
|
||||||
pub use hackathon_dto::*;
|
pub use hackathon_dto::*;
|
||||||
pub use hackathon_repository::HackathonRepository;
|
pub use hackathon_repository::HackathonRepository;
|
||||||
pub use hackathon_schema::*;
|
pub use hackathon_schema::*;
|
||||||
pub use hackathon_service::{HackathonService, HackathonServiceTrait};
|
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
|
// Export controller functions
|
||||||
pub use hackathon_controller::*;
|
pub use hackathon_controller::*;
|
||||||
|
|||||||
Reference in New Issue
Block a user