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:
MythEclipse
2025-10-28 19:55:38 +07:00
parent 95ae55c9df
commit 5cc5a3dfe1
9 changed files with 1284 additions and 36 deletions
@@ -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, &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,
}); });
} }
// 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::*;