From 5cc5a3dfe196640202f7838596db23c79d01e001 Mon Sep 17 00:00:00 2001 From: MythEclipse Date: Tue, 28 Oct 2025 19:55:38 +0700 Subject: [PATCH] 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. --- .../v1/hackathon/hackathon_atomic_service.rs | 278 ++++++++++++++ .../hackathon/hackathon_audit_repository.rs | 193 ++++++++++ .../v1/hackathon/hackathon_audit_schema.rs | 164 +++++++++ .../src/v1/hackathon/hackathon_controller.rs | 158 +++++++- .../src/v1/hackathon/hackathon_dto.rs | 11 +- .../src/v1/hackathon/hackathon_repository.rs | 25 ++ .../src/v1/hackathon/hackathon_service.rs | 143 ++++++-- .../src/v1/hackathon/hackathon_validation.rs | 340 ++++++++++++++++++ imphnen-hackathon/src/v1/hackathon/mod.rs | 8 + 9 files changed, 1284 insertions(+), 36 deletions(-) create mode 100644 imphnen-hackathon/src/v1/hackathon/hackathon_atomic_service.rs create mode 100644 imphnen-hackathon/src/v1/hackathon/hackathon_audit_repository.rs create mode 100644 imphnen-hackathon/src/v1/hackathon/hackathon_audit_schema.rs create mode 100644 imphnen-hackathon/src/v1/hackathon/hackathon_validation.rs diff --git a/imphnen-hackathon/src/v1/hackathon/hackathon_atomic_service.rs b/imphnen-hackathon/src/v1/hackathon/hackathon_atomic_service.rs new file mode 100644 index 0000000..6d53cd9 --- /dev/null +++ b/imphnen-hackathon/src/v1/hackathon/hackathon_atomic_service.rs @@ -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, + + #[serde(skip_serializing_if = "Option::is_none")] + pub events: Option>, + + /// Actor ID for audit logging + pub actor_id: String, + + #[serde(skip_serializing_if = "Option::is_none")] + pub actor_email: Option, +} + +/// Response DTO for complete hackathon setup +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] +pub struct HackathonCompleteSetupResponseDto { + pub hackathon: HackathonDto, + pub timelines: Vec, + pub events: Option>, + 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, 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 = 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::>(), + &[], + &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::>(), + &[], + &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::>(), + &created_events.iter().map(|e| e.id.id.to_string()).collect::>(), + &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(()) + } +} diff --git a/imphnen-hackathon/src/v1/hackathon/hackathon_audit_repository.rs b/imphnen-hackathon/src/v1/hackathon/hackathon_audit_repository.rs new file mode 100644 index 0000000..00cf626 --- /dev/null +++ b/imphnen-hackathon/src/v1/hackathon/hackathon_audit_repository.rs @@ -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 { + 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 = 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>> { + 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 = self.state.surrealdb_ws.query(&query).await?.take(0)?; + let count_result: Vec = + 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>> { + 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 = self.state.surrealdb_ws.query(&query).await?.take(0)?; + let count_result: Vec = + 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>> { + 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 = self.state.surrealdb_ws.query(&query).await?.take(0)?; + let count_result: Vec = + 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>> { + 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 = self.state.surrealdb_ws.query(&query).await?.take(0)?; + let count_result: Vec = + 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), + }), + }) + } +} diff --git a/imphnen-hackathon/src/v1/hackathon/hackathon_audit_schema.rs b/imphnen-hackathon/src/v1/hackathon/hackathon_audit_schema.rs new file mode 100644 index 0000000..5638ae8 --- /dev/null +++ b/imphnen-hackathon/src/v1/hackathon/hackathon_audit_schema.rs @@ -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, // None for system-wide events + pub action: AuditAction, + pub actor_id: String, // User who performed the action + pub actor_email: Option, // For better traceability + pub resource_type: String, // hackathon, timeline, event, submission + pub resource_id: Option, // ID of the affected resource + pub changes: Option, // JSON of what changed + pub old_value: Option, + pub new_value: Option, + pub ip_address: Option, + pub user_agent: Option, + pub timestamp: DateTime, + 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, + action: AuditAction, + actor_id: String, + resource_type: String, + resource_id: Option, + ) -> 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, + user_agent: Option, + actor_email: Option, + ) -> Self { + self.ip_address = ip_address; + self.user_agent = user_agent; + self.actor_email = actor_email; + self + } +} diff --git a/imphnen-hackathon/src/v1/hackathon/hackathon_controller.rs b/imphnen-hackathon/src/v1/hackathon/hackathon_controller.rs index 7c0f63d..a387abf 100644 --- a/imphnen-hackathon/src/v1/hackathon/hackathon_controller.rs +++ b/imphnen-hackathon/src/v1/hackathon/hackathon_controller.rs @@ -4,19 +4,21 @@ use super::hackathon_dto::{ HackathonEventUpdateRequestDto, HackathonSubmissionCreateRequestDto, HackathonSubmissionDto, HackathonSubmissionUpdateRequestDto, HackathonTimelineCreateRequestDto, HackathonTimelineDto, HackathonTimelineUpdateRequestDto, HackathonUpdateRequestDto, + HackathonStatusChangeRequestDto, }; use super::hackathon_service::{HackathonService, HackathonServiceTrait}; use super::hackathon_schema::SubmissionStatus; +use super::hackathon_atomic_service::{HackathonAtomicService, HackathonCompleteSetupRequestDto, HackathonCompleteSetupResponseDto}; use crate::v1::hackathon::HackathonRepository; use crate::{AppState, ResponseSuccessDto, ErrorDto}; use imphnen_entities::{PermissionsEnum, UsersDetailQueryDto}; -use imphnen_libs::{MetaRequestDto, ResponseListSuccessDto}; +use imphnen_libs::{MetaRequestDto, ResponseListSuccessDto, jsonwebtoken::Claims}; use axum::{ extract::{Extension, Path, Query}, http::StatusCode, Json, Router, response::IntoResponse, - routing::{delete, get, post, put}, + routing::{delete, get, patch, post, put}, }; use axum::body::Bytes; use futures::future; @@ -1066,14 +1068,158 @@ pub async fn post_admin_manage_sensitive_data( Ok((StatusCode::OK, Json(response)).into_response()) } -pub fn hackathon_routes() -> Router { - // AppState would be properly injected in real usage via Axum's state management - // For now, we'll create routes without middleware that requires AppState +// Atomic hackathon creation with timeline and events +#[utoipa::path( + post, + security( + ("Bearer" = []) + ), + path = "/v1/hackathons/complete", + request_body = HackathonCompleteSetupRequestDto, + responses( + (status = 201, description = "[ADMIN] Hackathon created atomically with timeline and events", body = ResponseSuccessDto), + (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, + Json(payload): Json, +) -> 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), + (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, + Path(id): Path, + Json(payload): Json, +) -> impl IntoResponse { + use super::hackathon_validation::{can_transition_status, validate_ready_for_registration}; + use super::hackathon_audit_schema::{AuditAction, HackathonAuditLogSchema}; + use super::hackathon_audit_repository::HackathonAuditRepository; + + let repo = HackathonRepository::new(&state); + let audit_repo = HackathonAuditRepository::new(&state); + + // Get existing hackathon + let existing = match repo.get_hackathon_by_id(id.clone()).await { + Ok(h) => h, + Err(_) => { + return (StatusCode::NOT_FOUND, Json(ErrorDto { + status: StatusCode::NOT_FOUND.as_u16(), + message: "Hackathon not found".to_string(), + details: None, + })).into_response(); + } + }; + + // Validate status transition + if let Err(e) = can_transition_status(&existing.status, &payload.status) { + return (StatusCode::BAD_REQUEST, Json(ErrorDto { + status: StatusCode::BAD_REQUEST.as_u16(), + message: e.to_string(), + details: None, + })).into_response(); + } + + // Additional validation for RegistrationOpen + if payload.status == super::hackathon_schema::HackathonStatus::RegistrationOpen { + if let Err(e) = validate_ready_for_registration(&existing) { + return (StatusCode::BAD_REQUEST, Json(ErrorDto { + status: StatusCode::BAD_REQUEST.as_u16(), + message: format!("Cannot open registration: {}", e), + details: None, + })).into_response(); + } + } + + // Update status + let update_dto = HackathonUpdateRequestDto { + name: None, + description: None, + start_date: None, + end_date: None, + registration_deadline: None, + max_participants: None, + theme: None, + rules: None, + prizes: None, + previous_winners: None, + organizers: None, + }; + + // Manually update status in repository + let updated = match repo.update_hackathon_status(id.clone(), payload.status.clone()).await { + Ok(h) => h, + Err(e) => { + return (StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorDto { + status: StatusCode::INTERNAL_SERVER_ERROR.as_u16(), + message: format!("Failed to update status: {}", e), + details: None, + })).into_response(); + } + }; + + // Create audit log + let old_value = serde_json::json!({"status": existing.status}); + let new_value = serde_json::json!({"status": payload.status, "reason": payload.reason}); + + let audit_log = HackathonAuditLogSchema::new( + Some(updated.id.clone()), + AuditAction::HackathonStatusChanged, + payload.actor_id.unwrap_or_else(|| "system".to_string()), + "hackathon".to_string(), + Some(id), + ) + .with_old_new_values(old_value, new_value); + + if let Err(e) = audit_repo.log(audit_log).await { + tracing::error!("Failed to create audit log: {}", e); + } + + let dto = HackathonDto::from(updated); + (StatusCode::OK, Json(ResponseSuccessDto { data: dto })).into_response() +} + +pub fn hackathon_routes() -> Router { Router::new() - // Hackathon routes - simplified for compilation + // Hackathon routes .route("/", post(create_hackathon)) + .route("/complete", post(create_hackathon_complete)) .route("/{id}", put(update_hackathon)) + .route("/{id}/status", patch(change_hackathon_status)) .route("/{id}", delete(delete_hackathon)) // Hackathon Events routes diff --git a/imphnen-hackathon/src/v1/hackathon/hackathon_dto.rs b/imphnen-hackathon/src/v1/hackathon/hackathon_dto.rs index 87f21e8..e018047 100644 --- a/imphnen-hackathon/src/v1/hackathon/hackathon_dto.rs +++ b/imphnen-hackathon/src/v1/hackathon/hackathon_dto.rs @@ -618,4 +618,13 @@ pub struct AdminSensitiveDataDto { pub struct AdminSensitiveDataResponseDto { pub data: Vec, pub message: String, -} \ No newline at end of file +} +// 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, + #[serde(skip_serializing_if = "Option::is_none")] + pub actor_id: Option, +} diff --git a/imphnen-hackathon/src/v1/hackathon/hackathon_repository.rs b/imphnen-hackathon/src/v1/hackathon/hackathon_repository.rs index 6c58093..94a135a 100644 --- a/imphnen-hackathon/src/v1/hackathon/hackathon_repository.rs +++ b/imphnen-hackathon/src/v1/hackathon/hackathon_repository.rs @@ -246,6 +246,31 @@ impl<'a> HackathonRepository<'a> { None => bail!("Failed to delete hackathon"), } } + + #[instrument(skip(self, id, status), err)] + pub async fn update_hackathon_status(&self, id: String, status: super::hackathon_schema::HackathonStatus) -> Result { + 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 = 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 diff --git a/imphnen-hackathon/src/v1/hackathon/hackathon_service.rs b/imphnen-hackathon/src/v1/hackathon/hackathon_service.rs index 52ef741..f2d0288 100644 --- a/imphnen-hackathon/src/v1/hackathon/hackathon_service.rs +++ b/imphnen-hackathon/src/v1/hackathon/hackathon_service.rs @@ -11,6 +11,13 @@ use super::hackathon_dto::{ }; use super::hackathon_repository::HackathonRepository; use super::hackathon_schema::SubmissionStatus; +use super::hackathon_audit_schema::{AuditAction, HackathonAuditLogSchema}; +use super::hackathon_audit_repository::HackathonAuditRepository; +use super::hackathon_validation::{ + can_transition_status, validate_dates, validate_organizers, + validate_prizes, validate_ready_for_registration, validate_timeline_phases, + validate_can_delete, validate_registration_allowed, validate_submission_allowed, +}; use crate::{AppState, ResponseSuccessDto, ErrorDto}; use imphnen_utils::{validator::validate_request}; use imphnen_libs::{MetaRequestDto, ResponseListSuccessDto}; @@ -156,7 +163,7 @@ impl HackathonServiceTrait for HackathonService { let state = state.to_owned(); Box::pin(async move { - // Validate request + // 1. Validate request input if let Err((_, error_message)) = validate_request(&payload) { return Err(ErrorDto { status: StatusCode::BAD_REQUEST.as_u16(), @@ -165,38 +172,70 @@ impl HackathonServiceTrait for HackathonService { }); } - // Business logic validation - if payload.end_date <= payload.start_date { + // 2. Validate dates consistency + if let Err(e) = validate_dates( + &payload.start_date, + &payload.end_date, + &payload.registration_deadline, + ) { return Err(ErrorDto { status: StatusCode::BAD_REQUEST.as_u16(), - message: "End date must be after start date".to_string(), + message: e.to_string(), details: None, }); } - // Registration deadline should be before the hackathon end date (allowing registration up - // to the start of or during the hackathon depending on business rules). Tests in this - // repository set the deadline between start and end, so validate against end_date here. - if payload.registration_deadline >= payload.end_date { + // 3. Validate organizers + if let Err(e) = validate_organizers(&payload.organizers) { return Err(ErrorDto { status: StatusCode::BAD_REQUEST.as_u16(), - message: "Registration deadline must be before end date".to_string(), + message: e.to_string(), details: None, }); } - if payload.organizers.is_empty() { - return Err(ErrorDto { - status: StatusCode::BAD_REQUEST.as_u16(), - message: "At least one organizer is required".to_string(), - details: None, - }); + // 4. Validate prizes if provided + if let Some(ref prizes) = payload.prizes { + let prize_schemas: Vec = prizes + .iter() + .map(|p| super::hackathon_schema::Prize { + position: p.position, + title: p.title.clone(), + description: p.description.clone(), + value: p.value.clone(), + }) + .collect(); + + if let Err(e) = validate_prizes(&prize_schemas) { + return Err(ErrorDto { + status: StatusCode::BAD_REQUEST.as_u16(), + message: e.to_string(), + details: None, + }); + } } let repo = HackathonRepository::new(&state); + let audit_repo = HackathonAuditRepository::new(&state); - match repo.create_hackathon(payload).await { + // 5. Create hackathon + match repo.create_hackathon(payload.clone()).await { Ok(hackathon) => { + // 6. Create audit log + let audit_log = HackathonAuditLogSchema::new( + Some(hackathon.id.clone()), + AuditAction::HackathonCreated, + payload.organizers.first().unwrap_or(&"system".to_string()).clone(), + "hackathon".to_string(), + Some(hackathon.id.id.to_string()), + ) + .with_changes(serde_json::to_value(&hackathon).unwrap_or_default()); + + if let Err(e) = audit_repo.log(audit_log).await { + tracing::error!("Failed to create audit log: {}", e); + // Don't fail the request if audit logging fails + } + let dto = HackathonDto::from(hackathon); Ok(ResponseSuccessDto { data: dto }) } @@ -273,7 +312,7 @@ impl HackathonServiceTrait for HackathonService { let state = state.to_owned(); Box::pin(async move { - // Validate request + // 1. Validate request if let Err(errors) = validate_request(&payload) { return Err(ErrorDto { status: StatusCode::BAD_REQUEST.as_u16(), @@ -283,8 +322,9 @@ impl HackathonServiceTrait for HackathonService { } let repo = HackathonRepository::new(&state); + let audit_repo = HackathonAuditRepository::new(&state); - // Get existing hackathon for validation + // 2. Get existing hackathon for validation let existing = match repo.get_hackathon_by_id(id.clone()).await { Ok(h) => h, Err(_) => { @@ -296,30 +336,75 @@ impl HackathonServiceTrait for HackathonService { } }; - // Business logic validation + // 3. Validate dates consistency let start_date = payload.start_date.unwrap_or(existing.start_date); let end_date = payload.end_date.unwrap_or(existing.end_date); let registration_deadline = payload.registration_deadline.unwrap_or(existing.registration_deadline); - if end_date <= start_date { + if let Err(e) = validate_dates(&start_date, &end_date, ®istration_deadline) { return Err(ErrorDto { status: StatusCode::BAD_REQUEST.as_u16(), - message: "End date must be after start date".to_string(), + message: e.to_string(), details: None, }); } - // Same rule as create_hackathon: the registration deadline must be before the end date. - if registration_deadline >= end_date { - return Err(ErrorDto { - status: StatusCode::BAD_REQUEST.as_u16(), - message: "Registration deadline must be before end date".to_string(), - details: None, - }); + // 4. Validate organizers if being updated + if let Some(ref organizers) = payload.organizers { + if let Err(e) = validate_organizers(organizers) { + return Err(ErrorDto { + status: StatusCode::BAD_REQUEST.as_u16(), + message: e.to_string(), + details: None, + }); + } } - match repo.update_hackathon(id, payload).await { + // 5. Validate prizes if being updated + if let Some(ref prizes) = payload.prizes { + let prize_schemas: Vec = prizes + .iter() + .map(|p| super::hackathon_schema::Prize { + position: p.position, + title: p.title.clone(), + description: p.description.clone(), + value: p.value.clone(), + }) + .collect(); + + if let Err(e) = validate_prizes(&prize_schemas) { + return Err(ErrorDto { + status: StatusCode::BAD_REQUEST.as_u16(), + message: e.to_string(), + details: None, + }); + } + } + + // 6. Store old value for audit log + let old_value = serde_json::to_value(&existing).unwrap_or_default(); + + // 7. Update hackathon + match repo.update_hackathon(id.clone(), payload.clone()).await { Ok(hackathon) => { + // 8. Create audit log + let new_value = serde_json::to_value(&hackathon).unwrap_or_default(); + let changes = serde_json::to_value(&payload).unwrap_or_default(); + + let audit_log = HackathonAuditLogSchema::new( + Some(hackathon.id.clone()), + AuditAction::HackathonUpdated, + existing.organizers.first().unwrap_or(&"system".to_string()).clone(), + "hackathon".to_string(), + Some(id), + ) + .with_changes(changes) + .with_old_new_values(old_value, new_value); + + if let Err(e) = audit_repo.log(audit_log).await { + tracing::error!("Failed to create audit log: {}", e); + } + let dto = HackathonDto::from(hackathon); Ok(ResponseSuccessDto { data: dto }) } diff --git a/imphnen-hackathon/src/v1/hackathon/hackathon_validation.rs b/imphnen-hackathon/src/v1/hackathon/hackathon_validation.rs new file mode 100644 index 0000000..a2f0aed --- /dev/null +++ b/imphnen-hackathon/src/v1/hackathon/hackathon_validation.rs @@ -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 = 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 = 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 = 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 = 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 = prizes.iter().map(|p| p.position).collect(); + let unique_positions: HashSet = 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, + end_date: &chrono::DateTime, + registration_deadline: &chrono::DateTime, +) -> 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 { + 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 + } +} diff --git a/imphnen-hackathon/src/v1/hackathon/mod.rs b/imphnen-hackathon/src/v1/hackathon/mod.rs index b244e34..31d5d86 100644 --- a/imphnen-hackathon/src/v1/hackathon/mod.rs +++ b/imphnen-hackathon/src/v1/hackathon/mod.rs @@ -5,12 +5,20 @@ pub mod hackathon_dto; pub mod hackathon_repository; pub mod hackathon_schema; pub mod hackathon_service; +pub mod hackathon_audit_schema; +pub mod hackathon_audit_repository; +pub mod hackathon_validation; +pub mod hackathon_atomic_service; // Export types and functions pub use hackathon_dto::*; pub use hackathon_repository::HackathonRepository; pub use hackathon_schema::*; pub use hackathon_service::{HackathonService, HackathonServiceTrait}; +pub use hackathon_audit_schema::*; +pub use hackathon_audit_repository::HackathonAuditRepository; +pub use hackathon_validation::*; +pub use hackathon_atomic_service::*; // Export controller functions pub use hackathon_controller::*;