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,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
}
}