postgress

This commit is contained in:
MythEclipse
2025-12-01 00:20:42 +07:00
parent 6fe495eed1
commit b429b3a9c7
325 changed files with 35728 additions and 50259 deletions
-21
View File
@@ -1,21 +0,0 @@
pub mod v1;
// Re-export core entity types used across the hackathon system
pub use imphnen_entities::{
CountResult,
Error,
ErrorDto,
MessageResponseDto,
MetaRequestDto,
MetaResponseDto,
ResponseListSuccessDto,
ResponseSuccessDto,
};
// Explicitly import only what we need from libs and utils to avoid pollution
pub use imphnen_libs::{
AppState,
};
// Re-export public v1 API
pub use v1::hackathon::hackathon_controller::hackathon_routes;
@@ -1,277 +0,0 @@
use super::hackathon_dto::{
HackathonCreateRequestDto, HackathonDto, HackathonEventCreateRequestDto,
HackathonTimelineCreateRequestDto,
};
use super::hackathon_repository::HackathonRepository;
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(())
}
}
@@ -1,193 +0,0 @@
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),
}),
})
}
}
@@ -1,164 +0,0 @@
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
}
}
File diff suppressed because it is too large Load Diff
@@ -1,630 +0,0 @@
use chrono::{DateTime, Utc};
use lazy_static::lazy_static;
use regex::Regex;
use serde::{Deserialize, Serialize};
use utoipa::{ToSchema, schema};
use validator::{Validate, ValidationError};
// Custom validators
pub fn validate_url_format(url: &str) -> Result<(), ValidationError> {
lazy_static! {
static ref URL_REGEX: Regex = Regex::new(r"^https?://[^\s$.?#].[^\s]*$").unwrap();
}
if URL_REGEX.is_match(url) {
Ok(())
} else {
Err(ValidationError::new("invalid_url"))
}
}
pub fn validate_github_url(url: &str) -> Result<(), ValidationError> {
lazy_static! {
static ref GITHUB_REGEX: Regex = Regex::new(r"^https?://github\.com/[a-zA-Z0-9_-]+(/[a-zA-Z0-9_-]+)?$").unwrap();
}
if GITHUB_REGEX.is_match(url) {
Ok(())
} else {
Err(ValidationError::new("invalid_github_url"))
}
}
pub fn validate_demo_url(url: &str) -> Result<(), ValidationError> {
lazy_static! {
static ref DEMO_URL_REGEX: Regex = Regex::new(r"^https?://(?:www\.)?[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+(/[^\s]*)?$").unwrap();
}
if DEMO_URL_REGEX.is_match(url) {
Ok(())
} else {
Err(ValidationError::new("invalid_demo_url"))
}
}
use crate::v1::hackathon::hackathon_schema::{
HackathonEventType, HackathonEventsSchema, HackathonPhase, HackathonSchema,
HackathonStatus, HackathonSubmissionsSchema, HackathonTimelineSchema,
SubmissionStatus,
HackathonParticipantSchema,
};
// Hackathon DTOs
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)]
pub struct HackathonCreateRequestDto {
#[validate(length(min = 1, max = 100, message = "Hackathon name must be between 1 and 100 characters"))]
pub name: String,
#[validate(length(min = 1, max = 1000, message = "Description must be between 1 and 1000 characters"))]
pub description: String,
#[schema(value_type = String, format = DateTime)]
pub start_date: DateTime<Utc>,
#[schema(value_type = String, format = DateTime)]
pub end_date: DateTime<Utc>,
#[schema(value_type = String, format = DateTime)]
pub registration_deadline: DateTime<Utc>,
#[validate(range(min = 1, max = 10000, message = "Max participants must be between 1 and 10000"))]
pub max_participants: Option<u32>,
#[validate(length(max = 200, message = "Theme cannot exceed 200 characters"))]
pub theme: Option<String>,
#[validate(length(max = 2000, message = "Rules cannot exceed 2000 characters"))]
pub rules: Option<String>,
pub prizes: Option<Vec<PrizeDto>>,
pub previous_winners: Option<Vec<WinnerDto>>,
#[validate(length(min = 1, message = "Organizers list cannot be empty"))]
pub organizers: Vec<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)]
pub struct HackathonUpdateRequestDto {
#[validate(length(min = 1, max = 100, message = "Hackathon name must be between 1 and 100 characters"))]
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[validate(length(min = 1, max = 1000, message = "Description must be between 1 and 1000 characters"))]
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[schema(value_type = String, format = DateTime)]
pub start_date: Option<DateTime<Utc>>,
#[schema(value_type = String, format = DateTime)]
pub end_date: Option<DateTime<Utc>>,
#[schema(value_type = String, format = DateTime)]
pub registration_deadline: Option<DateTime<Utc>>,
#[validate(range(min = 1, max = 10000, message = "Max participants must be between 1 and 10000"))]
#[serde(skip_serializing_if = "Option::is_none")]
pub max_participants: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub theme: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rules: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub prizes: Option<Vec<PrizeDto>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub previous_winners: Option<Vec<WinnerDto>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub organizers: Option<Vec<String>>,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct HackathonDto {
pub id: String,
pub name: String,
pub description: String,
#[schema(value_type = String, format = DateTime)]
pub start_date: DateTime<Utc>,
#[schema(value_type = String, format = DateTime)]
pub end_date: DateTime<Utc>,
#[schema(value_type = String, format = DateTime)]
pub registration_deadline: DateTime<Utc>,
pub max_participants: Option<u32>,
pub status: HackathonStatus,
pub theme: Option<String>,
pub rules: Option<String>,
pub prizes: Option<Vec<PrizeDto>>,
pub previous_winners: Option<Vec<WinnerDto>>,
pub organizers: Vec<String>,
pub is_deleted: bool,
pub created_at: Option<String>,
pub updated_at: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)]
pub struct PrizeDto {
#[validate(range(min = 1, message = "Position must be at least 1"))]
pub position: u32,
#[validate(length(min = 1, message = "Prize title cannot be empty"))]
pub title: String,
pub description: Option<String>,
pub value: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)]
pub struct WinnerDto {
#[validate(range(min = 1, message = "Position must be at least 1"))]
pub position: u32,
pub team_id: String,
#[validate(length(min = 1, message = "Project name cannot be empty"))]
pub project_name: String,
pub team_name: Option<String>,
}
// Hackathon Events DTOs
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)]
pub struct HackathonEventCreateRequestDto {
#[validate(length(min = 1, message = "Event title cannot be empty"))]
pub title: String,
pub description: Option<String>,
pub event_type: HackathonEventType,
#[schema(value_type = String, format = DateTime)]
pub start_time: DateTime<Utc>,
#[schema(value_type = String, format = DateTime)]
pub end_time: DateTime<Utc>,
pub location: Option<String>,
pub virtual_link: Option<String>,
#[validate(range(min = 1, message = "Max attendees must be at least 1"))]
pub max_attendees: Option<u32>,
pub is_mandatory: bool,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)]
pub struct HackathonEventUpdateRequestDto {
#[validate(length(min = 1, message = "Event title cannot be empty"))]
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub event_type: Option<HackathonEventType>,
#[serde(skip_serializing_if = "Option::is_none")]
#[schema(value_type = String, format = DateTime)]
pub start_time: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
#[schema(value_type = String, format = DateTime)]
pub end_time: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub location: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub virtual_link: Option<String>,
#[validate(range(min = 1, message = "Max attendees must be at least 1"))]
#[serde(skip_serializing_if = "Option::is_none")]
pub max_attendees: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub is_mandatory: Option<bool>,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct HackathonEventDto {
pub id: String,
pub hackathon_id: String,
pub title: String,
pub description: Option<String>,
pub event_type: HackathonEventType,
#[schema(value_type = String, format = DateTime)]
pub start_time: DateTime<Utc>,
#[schema(value_type = String, format = DateTime)]
pub end_time: DateTime<Utc>,
pub location: Option<String>,
pub virtual_link: Option<String>,
pub max_attendees: Option<u32>,
pub is_mandatory: bool,
pub is_deleted: bool,
pub created_at: Option<String>,
pub updated_at: Option<String>,
}
// Hackathon Timeline DTOs
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)]
pub struct HackathonTimelineCreateRequestDto {
pub phase: HackathonPhase,
// Accept either `title` or `name` in incoming JSON (tests may send `name`).
// Make it optional so missing title doesn't cause a 422; service/repo will
// fallback to an empty title or a sensible default.
#[serde(alias = "name")]
#[serde(default)]
pub title: Option<String>,
pub description: Option<String>,
#[schema(value_type = String, format = DateTime)]
pub start_date: DateTime<Utc>,
#[schema(value_type = String, format = DateTime)]
pub end_date: DateTime<Utc>,
#[serde(default)]
pub is_active: Option<bool>,
#[serde(default)]
#[validate(range(min = 0, message = "Order must be non-negative"))]
pub order: Option<u32>,
}
// Custom validator for HackathonPhase (case-insensitive)
pub fn validate_hackathon_phase(phase: &str) -> Result<(), ValidationError> {
let normalized = phase.to_lowercase();
match normalized.as_str() {
"registration" | "ideation" | "development" | "submission" | "judging" | "awards" => Ok(()),
_ => Err(ValidationError::new("invalid_hackathon_phase")),
}
}
// Custom validator to ensure start_date is in the future
pub fn validate_future_date(date: &DateTime<Utc>) -> Result<(), ValidationError> {
let now = Utc::now();
if date <= &now {
Err(ValidationError::new("start_date_must_be_in_future"))
} else {
Ok(())
}
}
// Custom validator to ensure end_date is in the future or current
pub fn validate_future_or_current_date(date: &DateTime<Utc>) -> Result<(), ValidationError> {
let now = Utc::now();
if date < &now {
Err(ValidationError::new("end_date_must_be_in_future_or_current"))
} else {
Ok(())
}
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)]
pub struct HackathonTimelineUpdateRequestDto {
#[serde(skip_serializing_if = "Option::is_none")]
pub phase: Option<HackathonPhase>,
#[validate(length(min = 1, message = "Timeline title cannot be empty"))]
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[schema(value_type = String, format = DateTime)]
pub start_date: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
#[schema(value_type = String, format = DateTime)]
pub end_date: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub is_active: Option<bool>,
#[validate(range(min = 0, message = "Order must be non-negative"))]
#[serde(skip_serializing_if = "Option::is_none")]
pub order: Option<u32>,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct HackathonTimelineDto {
pub id: String,
pub hackathon_id: String,
pub phase: HackathonPhase,
pub title: String,
pub description: Option<String>,
#[schema(value_type = String, format = DateTime)]
pub start_date: DateTime<Utc>,
#[schema(value_type = String, format = DateTime)]
pub end_date: DateTime<Utc>,
pub is_active: bool,
pub order: u32,
pub is_deleted: bool,
pub created_at: Option<String>,
pub updated_at: Option<String>,
}
// Hackathon Submissions DTOs
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)]
pub struct HackathonSubmissionCreateRequestDto {
#[validate(length(min = 1, message = "Project name cannot be empty"))]
pub project_name: String,
#[validate(length(min = 1, message = "Description cannot be empty"))]
pub description: String,
pub repository_url: Option<String>,
pub upload_file_url: Option<String>, // URL to uploaded zip/pdf file
pub demo_url: Option<String>,
pub slides_url: Option<String>,
pub technologies: Vec<String>,
// Social media contacts for demo (at least one required)
pub contact_instagram: Option<String>,
pub contact_twitter: Option<String>,
pub contact_linkedin: Option<String>,
pub contact_facebook: Option<String>,
pub contact_youtube: Option<String>,
pub contact_tiktok: Option<String>,
pub contact_other: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)]
pub struct HackathonSubmissionUpdateRequestDto {
#[validate(length(min = 1, message = "Project name cannot be empty"))]
#[serde(skip_serializing_if = "Option::is_none")]
pub project_name: Option<String>,
#[validate(length(min = 1, message = "Description cannot be empty"))]
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub repository_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub upload_file_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub demo_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub slides_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub technologies: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub contact_instagram: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub contact_twitter: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub contact_linkedin: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub contact_facebook: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub contact_youtube: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub contact_tiktok: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub contact_other: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct HackathonSubmissionDto {
pub id: String,
pub hackathon_id: String,
pub team_id: String,
pub project_name: String,
pub description: String,
pub repository_url: Option<String>,
pub upload_file_url: Option<String>,
pub demo_url: Option<String>,
pub slides_url: Option<String>,
pub technologies: Vec<String>,
pub contact_instagram: Option<String>,
pub contact_twitter: Option<String>,
pub contact_linkedin: Option<String>,
pub contact_facebook: Option<String>,
pub contact_youtube: Option<String>,
pub contact_tiktok: Option<String>,
pub contact_other: Option<String>,
#[serde(rename = "status")]
pub submission_status: SubmissionStatus,
pub judge_feedback: Option<String>,
#[schema(value_type = String, format = DateTime)]
pub submitted_at: DateTime<Utc>,
pub is_deleted: bool,
pub created_at: Option<String>,
pub updated_at: Option<String>,
}
// Query DTOs
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)]
pub struct HackathonQueryDto {
#[serde(skip_serializing_if = "Option::is_none")]
pub status: Option<HackathonStatus>,
#[serde(skip_serializing_if = "Option::is_none")]
pub organizer_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub limit: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub offset: Option<u32>,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)]
pub struct HackathonEventQueryDto {
pub hackathon_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub event_type: Option<HackathonEventType>,
#[serde(skip_serializing_if = "Option::is_none")]
pub limit: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub offset: Option<u32>,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)]
pub struct HackathonTimelineQueryDto {
pub hackathon_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub phase: Option<HackathonPhase>,
#[serde(skip_serializing_if = "Option::is_none")]
pub is_active: Option<bool>,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)]
pub struct HackathonSubmissionQueryDto {
pub hackathon_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub team_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub submission_status: Option<SubmissionStatus>,
#[serde(skip_serializing_if = "Option::is_none")]
pub limit: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub offset: Option<u32>,
}
// Conversion implementations
impl From<HackathonSchema> for HackathonDto {
fn from(schema: HackathonSchema) -> Self {
Self {
id: schema.id.id.to_raw(),
name: schema.name,
description: schema.description,
start_date: schema.start_date,
end_date: schema.end_date,
registration_deadline: schema.registration_deadline,
max_participants: schema.max_participants,
status: schema.status,
theme: schema.theme,
rules: schema.rules,
prizes: schema.prizes.map(|prizes| {
prizes
.into_iter()
.map(|p| PrizeDto {
position: p.position,
title: p.title,
description: p.description,
value: p.value,
})
.collect()
}),
previous_winners: schema.previous_winners.map(|winners| {
winners
.into_iter()
.map(|w| WinnerDto {
position: w.position,
team_id: w.team_id,
project_name: w.project_name,
team_name: w.team_name,
})
.collect()
}),
organizers: schema.organizers,
is_deleted: schema.is_deleted,
created_at: schema.created_at,
updated_at: schema.updated_at,
}
}
}
impl From<HackathonEventsSchema> for HackathonEventDto {
fn from(schema: HackathonEventsSchema) -> Self {
Self {
id: schema.id.id.to_raw(),
hackathon_id: schema.hackathon_id.id.to_raw(),
title: schema.title,
description: schema.description,
event_type: schema.event_type,
start_time: schema.start_time,
end_time: schema.end_time,
location: schema.location,
virtual_link: schema.virtual_link,
max_attendees: schema.max_attendees,
is_mandatory: schema.is_mandatory,
is_deleted: schema.is_deleted,
created_at: schema.created_at,
updated_at: schema.updated_at,
}
}
}
impl From<HackathonTimelineSchema> for HackathonTimelineDto {
fn from(schema: HackathonTimelineSchema) -> Self {
Self {
id: schema.id.id.to_raw(),
hackathon_id: schema.hackathon_id.id.to_raw(),
phase: schema.phase,
title: schema.title,
description: schema.description,
start_date: schema.start_date,
end_date: schema.end_date,
is_active: schema.is_active,
order: schema.order,
is_deleted: schema.is_deleted,
created_at: schema.created_at,
updated_at: schema.updated_at,
}
}
}
impl From<HackathonSubmissionsSchema> for HackathonSubmissionDto {
fn from(schema: HackathonSubmissionsSchema) -> Self {
Self {
id: schema.id.id.to_raw(),
hackathon_id: schema.hackathon_id.id.to_raw(),
team_id: schema.team_id.map(|t| t.id.to_raw()).unwrap_or_default(),
project_name: schema.project_name.unwrap_or_default(),
description: schema.description.unwrap_or_default(),
repository_url: schema.repository_url,
upload_file_url: schema.upload_file_url,
demo_url: schema.demo_url,
slides_url: schema.slides_url,
technologies: schema.technologies.unwrap_or_default(),
contact_instagram: schema.contact_instagram,
contact_twitter: schema.contact_twitter,
contact_linkedin: schema.contact_linkedin,
contact_facebook: schema.contact_facebook,
contact_youtube: schema.contact_youtube,
contact_tiktok: schema.contact_tiktok,
contact_other: schema.contact_other,
submission_status: schema.submission_status.unwrap_or(super::hackathon_schema::SubmissionStatus::Draft),
judge_feedback: schema.judge_feedback,
submitted_at: schema.submitted_at.unwrap_or(chrono::Utc::now()),
is_deleted: schema.is_deleted,
created_at: schema.created_at,
updated_at: schema.updated_at,
}
}
}
// Hackathon Participant DTOs
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)]
pub struct RegisterParticipantRequestDto {
#[validate(length(min = 1, message = "user_id cannot be empty"))]
pub user_id: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct HackathonParticipantDto {
pub id: String,
pub hackathon_id: String,
pub user_id: String,
pub is_deleted: bool,
pub created_at: Option<String>,
pub updated_at: Option<String>,
}
impl From<HackathonParticipantSchema> for HackathonParticipantDto {
fn from(schema: HackathonParticipantSchema) -> Self {
Self {
id: schema.id.id.to_raw(),
hackathon_id: schema.hackathon_id.id.to_raw(),
user_id: schema.user_id,
is_deleted: schema.is_deleted,
created_at: schema.created_at,
updated_at: schema.updated_at,
}
}
}
// Admin Sensitive Data Management DTOs
#[derive(Debug, Deserialize, Serialize, Validate, ToSchema)]
pub struct AdminManageSensitiveDataRequestDto {
#[validate(length(min = 1, message = "At least one user ID is required"))]
pub user_ids: Vec<String>,
#[validate(length(min = 1, message = "At least one raw score is required"))]
pub raw_scores: Vec<i32>,
pub personal_info: bool,
}
#[derive(Debug, Serialize, ToSchema)]
pub struct AdminSensitiveDataMemberDto {
pub user_id: String,
pub masked_email: String,
pub masked_phone: String,
pub name: String,
pub role: String,
}
#[derive(Debug, Serialize, ToSchema)]
pub struct AdminSensitiveDataDto {
pub submission_id: String,
pub team_id: String,
pub project_name: String,
pub description: String,
pub technologies: Vec<String>,
pub score: Option<i32>,
pub members: Vec<AdminSensitiveDataMemberDto>,
pub raw_scores: Option<Vec<i32>>,
pub submission_date: String,
}
#[derive(Debug, Serialize, ToSchema)]
pub struct AdminSensitiveDataResponseDto {
pub data: Vec<AdminSensitiveDataDto>,
pub message: String,
}
// Status Change DTOs
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)]
pub struct HackathonStatusChangeRequestDto {
pub status: HackathonStatus,
#[serde(skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub actor_id: Option<String>,
}
@@ -1,905 +0,0 @@
use super::hackathon_dto::{
HackathonCreateRequestDto, HackathonEventCreateRequestDto,
HackathonEventUpdateRequestDto, HackathonSubmissionCreateRequestDto,
HackathonSubmissionUpdateRequestDto, HackathonTimelineCreateRequestDto,
HackathonTimelineUpdateRequestDto, HackathonUpdateRequestDto,
};
use super::hackathon_schema::{
HackathonEventsSchema, HackathonPhase, HackathonSchema, HackathonSubmissionsSchema, HackathonTimelineSchema,
Prize,
};
use imphnen_libs::ResourceEnum;
use anyhow::{Result, anyhow, bail};
use imphnen_libs::AppState;
use imphnen_utils::{QueryListBuilder, get_iso_date};
use std::collections::HashMap;
use surrealdb::sql::Thing;
use tracing::{instrument, info};
#[derive(Clone)]
pub struct HackathonRepository<'a> {
pub state: &'a AppState,
}
impl<'a> HackathonRepository<'a> {
pub fn new(state: &'a AppState) -> Self {
Self { state }
}
// Normalize an incoming id so callers can pass either the full thing string
// (e.g. "app_hackathons:1") or the raw id ("1"). If the id starts with
// the table prefix ("{table}:") the prefix is stripped.
fn normalize_id(&self, table: &str, id: &str) -> String {
if id.starts_with(&format!("{}:", table)) {
if let Some((_, rest)) = id.split_once(':') {
rest.to_string()
} else {
id.to_string()
}
} else {
id.to_string()
}
}
}
// Hackathon CRUD operations
impl<'a> HackathonRepository<'a> {
#[instrument(skip(self, hackathon), err)]
pub async fn create_hackathon(&self, hackathon: HackathonCreateRequestDto) -> Result<HackathonSchema> {
let table = ResourceEnum::Hackathons.to_string();
let id = surrealdb::Uuid::new_v4().to_string();
let prizes: Option<Vec<Prize>> = hackathon.prizes.map(|p| {
p.into_iter()
.map(|prize| Prize {
position: prize.position,
title: prize.title,
description: prize.description,
value: prize.value,
})
.collect()
});
let previous_winners: Option<Vec<super::hackathon_schema::Winner>> = hackathon.previous_winners.map(|w| {
w.into_iter()
.map(|winner| super::hackathon_schema::Winner {
position: winner.position,
team_id: winner.team_id,
project_name: winner.project_name,
team_name: winner.team_name,
})
.collect()
});
let schema = HackathonSchema {
id: Thing::from((table.clone(), id.clone())),
name: hackathon.name,
description: hackathon.description,
start_date: hackathon.start_date,
end_date: hackathon.end_date,
registration_deadline: hackathon.registration_deadline,
max_participants: hackathon.max_participants,
status: super::hackathon_schema::HackathonStatus::Draft,
theme: hackathon.theme,
rules: hackathon.rules,
prizes,
previous_winners,
organizers: hackathon.organizers,
is_deleted: false,
created_at: Some(get_iso_date()),
updated_at: Some(get_iso_date()),
};
info!(query = %format!("CREATE {}:{}", table, id), "Executing SurrealDB query");
let record: Option<HackathonSchema> = self
.state.surrealdb_ws
.create((table, id))
.content(schema.clone())
.await?;
match record {
Some(h) => Ok(h),
None => bail!("Failed to create hackathon"),
}
}
#[instrument(skip(self, id), err)]
pub async fn get_hackathon_by_id(&self, id: String) -> Result<HackathonSchema> {
let table = ResourceEnum::Hackathons.to_string();
info!(query = %format!("SELECT * FROM {} WHERE id = '{}'", table, id), "Executing SurrealDB query");
let normalized_id = self.normalize_id(&table, &id);
let record: Option<HackathonSchema> = self
.state
.surrealdb_ws
.select((table, normalized_id.clone()))
.await?;
match record {
Some(h) => {
if h.is_deleted {
bail!("Hackathon not found");
}
Ok(h)
}
None => bail!("Hackathon not found"),
}
}
#[instrument(skip(self, meta), err)]
pub async fn list_hackathons(&self, meta: imphnen_libs::MetaRequestDto) -> Result<imphnen_libs::ResponseListSuccessDto<Vec<HackathonSchema>>> {
let table = ResourceEnum::Hackathons.to_string();
let builder = QueryListBuilder::new(&self.state.surrealdb_ws, &table, &meta)
.with_condition("is_deleted = false")
.search_field("name")
.select_fields(vec!["*"]);
let mut result = builder.build().await?;
// Ensure deterministic ordering for listings by sorting on created_at (oldest first).
// Tests expect insertion order (first created appears first). created_at is an Option<String>
// with ISO 8601 format from `get_iso_date()`, so string comparison is chronologically correct.
result.data.sort_by_key(|s: &HackathonSchema| s.created_at.clone());
Ok(result)
}
#[instrument(skip(self, id, updates), err)]
pub async fn update_hackathon(&self, id: String, updates: HackathonUpdateRequestDto) -> Result<HackathonSchema> {
let table = ResourceEnum::Hackathons.to_string();
// First get the existing hackathon
let mut existing = self.get_hackathon_by_id(id.clone()).await?;
// Apply updates
if let Some(name) = updates.name {
existing.name = name;
}
if let Some(description) = updates.description {
existing.description = description;
}
if let Some(start_date) = updates.start_date {
existing.start_date = start_date;
}
if let Some(end_date) = updates.end_date {
existing.end_date = end_date;
}
if let Some(registration_deadline) = updates.registration_deadline {
existing.registration_deadline = registration_deadline;
}
if let Some(max_participants) = updates.max_participants {
existing.max_participants = Some(max_participants);
}
if let Some(theme) = updates.theme {
existing.theme = Some(theme);
}
if let Some(rules) = updates.rules {
existing.rules = Some(rules);
}
if let Some(prizes) = updates.prizes {
let prizes_schema: Vec<Prize> = prizes
.into_iter()
.map(|p| Prize {
position: p.position,
title: p.title,
description: p.description,
value: p.value,
})
.collect();
existing.prizes = Some(prizes_schema);
if let Some(previous_winners) = updates.previous_winners {
let winners_schema: Vec<super::hackathon_schema::Winner> = previous_winners
.into_iter()
.map(|w| super::hackathon_schema::Winner {
position: w.position,
team_id: w.team_id,
project_name: w.project_name,
team_name: w.team_name,
})
.collect();
existing.previous_winners = Some(winners_schema);
}
}
if let Some(organizers) = updates.organizers {
existing.organizers = organizers;
}
existing.updated_at = Some(get_iso_date());
info!(query = %format!("UPDATE {} SET ... WHERE id = '{}'", table, id), "Executing SurrealDB query");
let record: Option<HackathonSchema> = self
.state.surrealdb_ws
.update((table, id))
.content(existing.clone())
.await?;
match record {
Some(h) => Ok(h),
None => bail!("Failed to update hackathon"),
}
}
#[instrument(skip(self, id), err)]
pub async fn delete_hackathon(&self, id: String) -> Result<String> {
let table = ResourceEnum::Hackathons.to_string();
// Soft delete by setting is_deleted = true
let updates: HashMap<String, serde_json::Value> = HashMap::from([
("is_deleted".to_string(), true.into()),
("updated_at".to_string(), get_iso_date().into()),
]);
info!(query = %format!("UPDATE {} SET is_deleted = true WHERE id = '{}'", table, id), "Executing SurrealDB query");
let normalized_id = self.normalize_id(&table, &id);
info!(query = %format!("UPDATE {} SET is_deleted = true WHERE id = '{}'", table, normalized_id), "Executing SurrealDB query");
let record: Option<HackathonSchema> = self
.state.surrealdb_ws
.update((table, normalized_id.clone()))
.merge(serde_json::to_value(updates)?)
.await?;
match record {
Some(_) => Ok("Hackathon deleted successfully".to_string()),
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
impl<'a> HackathonRepository<'a> {
#[instrument(skip(self, hackathon_id, event), err)]
pub async fn create_hackathon_event(&self, hackathon_id: String, event: HackathonEventCreateRequestDto) -> Result<HackathonEventsSchema> {
let table = ResourceEnum::HackathonEvents.to_string();
let id = surrealdb::Uuid::new_v4().to_string();
let normalized_hackathon_id = self.normalize_id("app_hackathons", &hackathon_id);
let schema = HackathonEventsSchema {
id: Thing::from((table.clone(), id.clone())),
hackathon_id: Thing::from(("app_hackathons".to_string(), normalized_hackathon_id)),
title: event.title,
description: event.description,
event_type: event.event_type,
start_time: event.start_time,
end_time: event.end_time,
location: event.location,
virtual_link: event.virtual_link,
max_attendees: event.max_attendees,
is_mandatory: event.is_mandatory,
is_deleted: false,
created_at: Some(get_iso_date()),
updated_at: Some(get_iso_date()),
};
info!(query = %format!("CREATE {}:{}", table, id), "Executing SurrealDB query");
let record: Option<HackathonEventsSchema> = self
.state.surrealdb_ws
.create((table, id))
.content(schema.clone())
.await?;
match record {
Some(e) => Ok(e),
None => bail!("Failed to create hackathon event"),
}
}
#[instrument(skip(self, meta, hackathon_id), err)]
pub async fn list_hackathon_events(&self, meta: imphnen_libs::MetaRequestDto, hackathon_id: String) -> Result<imphnen_libs::ResponseListSuccessDto<Vec<HackathonEventsSchema>>> {
let table = ResourceEnum::HackathonEvents.to_string();
let normalized_hackathon_id = self.normalize_id("app_hackathons", &hackathon_id);
let builder = QueryListBuilder::new(&self.state.surrealdb_ws, &table, &meta)
.with_condition("is_deleted = false")
.with_condition(&format!("hackathon_id = type::thing('app_hackathons', '{}')", normalized_hackathon_id))
.search_field("title")
.select_fields(vec!["*"]);
let mut result = builder.build().await?;
// Sort events by created_at (oldest first) to ensure deterministic ordering for tests
result.data.sort_by_key(|s: &HackathonEventsSchema| s.created_at.clone());
Ok(result)
}
#[instrument(skip(self, id), err)]
pub async fn get_hackathon_event_by_id(&self, id: String) -> Result<HackathonEventsSchema> {
let table = ResourceEnum::HackathonEvents.to_string();
let existing: Option<HackathonEventsSchema> = self.state.surrealdb_ws
.select((table, id.clone()))
.await?;
let event = existing.ok_or_else(|| anyhow!("Event not found"))?;
if event.is_deleted {
bail!("Event not found");
}
Ok(event)
}
#[instrument(skip(self, id, updates), err)]
pub async fn update_hackathon_event(&self, id: String, updates: HackathonEventUpdateRequestDto) -> Result<HackathonEventsSchema> {
let table = ResourceEnum::HackathonEvents.to_string();
// Get existing event
let existing: Option<HackathonEventsSchema> = self.state.surrealdb_ws.select((table.clone(), id.clone())).await?;
let mut existing = existing.ok_or_else(|| anyhow!("Event not found"))?;
if existing.is_deleted {
bail!("Event not found");
}
// Apply updates
if let Some(title) = updates.title {
existing.title = title;
}
if let Some(description) = updates.description {
existing.description = Some(description);
}
if let Some(event_type) = updates.event_type {
existing.event_type = event_type;
}
if let Some(start_time) = updates.start_time {
existing.start_time = start_time;
}
if let Some(end_time) = updates.end_time {
existing.end_time = end_time;
}
if let Some(location) = updates.location {
existing.location = Some(location);
}
if let Some(virtual_link) = updates.virtual_link {
existing.virtual_link = Some(virtual_link);
}
if let Some(max_attendees) = updates.max_attendees {
existing.max_attendees = Some(max_attendees);
}
if let Some(is_mandatory) = updates.is_mandatory {
existing.is_mandatory = is_mandatory;
}
existing.updated_at = Some(get_iso_date());
info!(query = %format!("UPDATE {} SET ... WHERE id = '{}'", table, id), "Executing SurrealDB query");
let record: Option<HackathonEventsSchema> = self
.state.surrealdb_ws
.update((table, id))
.content(existing.clone())
.await?;
match record {
Some(e) => Ok(e),
None => bail!("Failed to update hackathon event"),
}
}
#[instrument(skip(self, id), err)]
pub async fn delete_hackathon_event(&self, id: String) -> Result<String> {
let table = ResourceEnum::HackathonEvents.to_string();
let updates: HashMap<String, serde_json::Value> = HashMap::from([
("is_deleted".to_string(), true.into()),
("updated_at".to_string(), get_iso_date().into()),
]);
info!(query = %format!("UPDATE {} SET is_deleted = true WHERE id = '{}'", table, id), "Executing SurrealDB query");
let record: Option<HackathonEventsSchema> = self
.state.surrealdb_ws
.update((table, id))
.merge(serde_json::to_value(updates)?)
.await?;
match record {
Some(_) => Ok("Event deleted successfully".to_string()),
None => bail!("Failed to delete event"),
}
}
}
// Hackathon Timeline CRUD operations
impl<'a> HackathonRepository<'a> {
#[instrument(skip(self, hackathon_id, timeline), err)]
pub async fn create_hackathon_timeline(&self, hackathon_id: String, timeline: HackathonTimelineCreateRequestDto) -> Result<HackathonTimelineSchema> {
let table = ResourceEnum::HackathonTimeline.to_string();
let id = surrealdb::Uuid::new_v4().to_string();
let normalized_hackathon_id = self.normalize_id("app_hackathons", &hackathon_id);
let phase_clone = timeline.phase.clone();
let schema = HackathonTimelineSchema {
id: Thing::from((table.clone(), id.clone())),
hackathon_id: Thing::from(("app_hackathons".to_string(), normalized_hackathon_id)),
phase: phase_clone.clone(),
title: timeline.title.unwrap_or_else(|| phase_clone.to_string()),
description: timeline.description,
start_date: timeline.start_date,
end_date: timeline.end_date,
is_active: timeline.is_active.unwrap_or(false),
order: timeline.order.unwrap_or(0),
is_deleted: false,
created_at: Some(get_iso_date()),
updated_at: Some(get_iso_date()),
};
info!(query = %format!("CREATE {}:{}", table, id), "Executing SurrealDB query");
let record: Option<HackathonTimelineSchema> = self
.state.surrealdb_ws
.create((table, id))
.content(schema.clone())
.await?;
match record {
Some(t) => Ok(t),
None => bail!("Failed to create hackathon timeline"),
}
}
#[instrument(skip(self, meta, hackathon_id), err)]
pub async fn list_hackathon_timeline(&self, meta: imphnen_libs::MetaRequestDto, hackathon_id: String) -> Result<imphnen_libs::ResponseListSuccessDto<Vec<HackathonTimelineSchema>>> {
let table = ResourceEnum::HackathonTimeline.to_string();
let normalized_hackathon_id = self.normalize_id("app_hackathons", &hackathon_id);
let builder = QueryListBuilder::new(&self.state.surrealdb_ws, &table, &meta)
.with_condition("is_deleted = false")
.with_condition(&format!("hackathon_id = type::thing('app_hackathons', '{}')", normalized_hackathon_id))
.search_field("title")
.select_fields(vec!["*"]);
let result = builder.build().await?;
Ok(result)
}
#[instrument(skip(self, id), err)]
pub async fn get_hackathon_timeline_by_id(&self, id: String) -> Result<HackathonTimelineSchema> {
let table = ResourceEnum::HackathonTimeline.to_string();
let existing: Option<HackathonTimelineSchema> = self.state.surrealdb_ws
.select((table, id.clone()))
.await?;
let timeline = existing.ok_or_else(|| anyhow!("Timeline not found"))?;
if timeline.is_deleted {
bail!("Timeline not found");
}
Ok(timeline)
}
#[instrument(skip(self, id, updates), err)]
pub async fn update_hackathon_timeline(&self, id: String, updates: HackathonTimelineUpdateRequestDto) -> Result<HackathonTimelineSchema> {
let table = ResourceEnum::HackathonTimeline.to_string();
let existing: Option<HackathonTimelineSchema> = self.state.surrealdb_ws.select((table.clone(), id.clone())).await?;
let mut existing = existing.ok_or_else(|| anyhow!("Timeline not found"))?;
if existing.is_deleted {
bail!("Timeline not found");
}
// Apply updates
if let Some(phase) = updates.phase {
existing.phase = phase;
}
if let Some(title) = updates.title {
existing.title = title;
}
if let Some(description) = updates.description {
existing.description = Some(description);
}
if let Some(start_date) = updates.start_date {
existing.start_date = start_date;
}
if let Some(end_date) = updates.end_date {
existing.end_date = end_date;
}
if let Some(is_active) = updates.is_active {
existing.is_active = is_active;
}
if let Some(order) = updates.order {
existing.order = order;
}
existing.updated_at = Some(get_iso_date());
info!(query = %format!("UPDATE {} SET ... WHERE id = '{}'", table, id), "Executing SurrealDB query");
let record: Option<HackathonTimelineSchema> = self
.state.surrealdb_ws
.update((table, id))
.content(existing.clone())
.await?;
match record {
Some(t) => Ok(t),
None => bail!("Failed to update hackathon timeline"),
}
}
#[instrument(skip(self, id), err)]
pub async fn delete_hackathon_timeline(&self, id: String) -> Result<String> {
let table = ResourceEnum::HackathonTimeline.to_string();
let updates: HashMap<String, serde_json::Value> = HashMap::from([
("is_deleted".to_string(), true.into()),
("updated_at".to_string(), get_iso_date().into()),
]);
info!(query = %format!("UPDATE {} SET is_deleted = true WHERE id = '{}'", table, id), "Executing SurrealDB query");
let record: Option<HackathonTimelineSchema> = self
.state.surrealdb_ws
.update((table, id))
.merge(serde_json::to_value(updates)?)
.await?;
match record {
Some(_) => Ok("Timeline deleted successfully".to_string()),
None => bail!("Failed to delete timeline"),
}
}
}
// Hackathon Submissions CRUD operations
impl<'a> HackathonRepository<'a> {
#[instrument(skip(self, hackathon_id, team_id, submission), err)]
pub async fn create_hackathon_submission(&self, hackathon_id: String, team_id: String, submission: HackathonSubmissionCreateRequestDto) -> Result<HackathonSubmissionsSchema> {
let table = ResourceEnum::HackathonSubmissions.to_string();
let id = surrealdb::Uuid::new_v4().to_string();
let normalized_hackathon_id = self.normalize_id("app_hackathons", &hackathon_id);
let normalized_team_id = self.normalize_id("app_teams", &team_id);
let schema = HackathonSubmissionsSchema {
id: Thing::from((table.clone(), id.clone())),
hackathon_id: Thing::from(("app_hackathons".to_string(), normalized_hackathon_id)),
team_id: Some(Thing::from(("app_teams".to_string(), normalized_team_id))),
project_name: Some(submission.project_name),
description: Some(submission.description),
repository_url: submission.repository_url,
upload_file_url: submission.upload_file_url,
demo_url: submission.demo_url,
slides_url: submission.slides_url,
technologies: Some(submission.technologies),
contact_instagram: submission.contact_instagram,
contact_twitter: submission.contact_twitter,
contact_linkedin: submission.contact_linkedin,
contact_facebook: submission.contact_facebook,
contact_youtube: submission.contact_youtube,
contact_tiktok: submission.contact_tiktok,
contact_other: submission.contact_other,
submission_status: Some(super::hackathon_schema::SubmissionStatus::Draft),
judge_feedback: None,
submitted_at: Some(chrono::Utc::now()),
is_deleted: false,
created_at: Some(get_iso_date()),
updated_at: Some(get_iso_date()),
};
info!(query = %format!("CREATE {}:{}", table, id), "Executing SurrealDB query");
let record: Option<HackathonSubmissionsSchema> = self
.state.surrealdb_ws
.create((table, id))
.content(schema.clone())
.await?;
match record {
Some(s) => Ok(s),
None => bail!("Failed to create hackathon submission"),
}
}
#[instrument(skip(self, meta, hackathon_id), err)]
pub async fn list_hackathon_submissions(&self, meta: imphnen_libs::MetaRequestDto, hackathon_id: String) -> Result<imphnen_libs::ResponseListSuccessDto<Vec<HackathonSubmissionsSchema>>> {
let table = ResourceEnum::HackathonSubmissions.to_string();
let normalized_hackathon_id = self.normalize_id("app_hackathons", &hackathon_id);
let builder = QueryListBuilder::new(&self.state.surrealdb_ws, &table, &meta)
.with_condition("is_deleted = false")
// Some stray records (from earlier bugs) may lack team_id; ensure we only fetch proper submissions
.with_condition("team_id IS NOT NULL")
// Ensure required string fields exist to prevent deserialization errors
.with_condition("project_name IS NOT NULL")
.with_condition("description IS NOT NULL")
.with_condition("technologies IS NOT NULL")
.with_condition(&format!("hackathon_id = type::thing('app_hackathons', '{}')", normalized_hackathon_id))
.search_field("project_name")
.select_fields(vec!["*"]);
let mut result = builder.build().await?;
// Ensure deterministic ordering for listings by sorting on created_at (oldest first).
result.data.sort_by_key(|s: &HackathonSubmissionsSchema| s.created_at.clone());
Ok(result)
}
#[instrument(skip(self, meta, team_id), err)]
pub async fn list_submissions_by_team(&self, meta: imphnen_libs::MetaRequestDto, team_id: String) -> Result<imphnen_libs::ResponseListSuccessDto<Vec<HackathonSubmissionsSchema>>> {
let table = ResourceEnum::HackathonSubmissions.to_string();
let normalized_team_id = self.normalize_id("app_teams", &team_id);
let builder = QueryListBuilder::new(&self.state.surrealdb_ws, &table, &meta)
.with_condition("is_deleted = false")
// Ensure we don't deserialize records without a team_id
.with_condition("team_id IS NOT NULL")
// Ensure required string fields exist to prevent deserialization errors
.with_condition("project_name IS NOT NULL")
.with_condition("description IS NOT NULL")
.with_condition("technologies IS NOT NULL")
.with_condition(&format!("team_id = type::thing('app_teams', '{}')", normalized_team_id))
.search_field("project_name")
.select_fields(vec!["*"]);
let mut result = builder.build().await?;
result.data.sort_by_key(|s: &HackathonSubmissionsSchema| s.created_at.clone());
Ok(result)
}
#[instrument(skip(self, id, status, feedback), err)]
pub async fn update_submission_status(&self, id: String, status: super::hackathon_schema::SubmissionStatus, feedback: Option<String>) -> Result<HackathonSubmissionsSchema> {
let table = ResourceEnum::HackathonSubmissions.to_string();
let existing: Option<HackathonSubmissionsSchema> = self.state.surrealdb_ws.select((table.clone(), id.clone())).await?;
let mut existing = existing.ok_or_else(|| anyhow!("Submission not found"))?;
if existing.is_deleted {
bail!("Submission not found");
}
existing.submission_status = Some(status);
existing.judge_feedback = feedback;
existing.updated_at = Some(get_iso_date());
let record: Option<HackathonSubmissionsSchema> = self
.state.surrealdb_ws
.update((table, id))
.content(existing.clone())
.await?;
match record {
Some(s) => Ok(s),
None => bail!("Failed to update submission status"),
}
}
#[instrument(skip(self, id, updates), err)]
pub async fn update_hackathon_submission(&self, id: String, updates: HackathonSubmissionUpdateRequestDto) -> Result<HackathonSubmissionsSchema> {
let table = ResourceEnum::HackathonSubmissions.to_string();
let existing: Option<HackathonSubmissionsSchema> = self.state.surrealdb_ws.select((table.clone(), id.clone())).await?;
let mut existing = existing.ok_or_else(|| anyhow!("Submission not found"))?;
if existing.is_deleted {
bail!("Submission not found");
}
// Apply updates
if let Some(project_name) = updates.project_name {
existing.project_name = Some(project_name);
}
if let Some(description) = updates.description {
existing.description = Some(description);
}
if let Some(repository_url) = updates.repository_url {
existing.repository_url = Some(repository_url);
}
if let Some(upload_file_url) = updates.upload_file_url {
existing.upload_file_url = Some(upload_file_url);
}
if let Some(demo_url) = updates.demo_url {
existing.demo_url = Some(demo_url);
}
if let Some(slides_url) = updates.slides_url {
existing.slides_url = Some(slides_url);
}
if let Some(technologies) = updates.technologies {
existing.technologies = Some(technologies);
}
if let Some(contact_instagram) = updates.contact_instagram {
existing.contact_instagram = Some(contact_instagram);
}
if let Some(contact_twitter) = updates.contact_twitter {
existing.contact_twitter = Some(contact_twitter);
}
if let Some(contact_linkedin) = updates.contact_linkedin {
existing.contact_linkedin = Some(contact_linkedin);
}
if let Some(contact_facebook) = updates.contact_facebook {
existing.contact_facebook = Some(contact_facebook);
}
if let Some(contact_youtube) = updates.contact_youtube {
existing.contact_youtube = Some(contact_youtube);
}
if let Some(contact_tiktok) = updates.contact_tiktok {
existing.contact_tiktok = Some(contact_tiktok);
}
if let Some(contact_other) = updates.contact_other {
existing.contact_other = Some(contact_other);
}
existing.updated_at = Some(get_iso_date());
info!(query = %format!("UPDATE {} SET ... WHERE id = '{}'", table, id), "Executing SurrealDB query");
let record: Option<HackathonSubmissionsSchema> = self
.state.surrealdb_ws
.update((table, id))
.content(existing.clone())
.await?;
match record {
Some(s) => Ok(s),
None => bail!("Failed to update hackathon submission"),
}
}
#[instrument(skip(self, id), err)]
pub async fn get_hackathon_submission_by_id(&self, id: String) -> Result<HackathonSubmissionsSchema> {
let table = ResourceEnum::HackathonSubmissions.to_string();
info!(query = %format!("SELECT * FROM {} WHERE id = '{}'", table, id), "Executing SurrealDB query");
let record: Option<HackathonSubmissionsSchema> = self
.state
.surrealdb_ws
.select((table, id))
.await?;
match record {
Some(s) => {
if s.is_deleted {
bail!("Submission not found");
}
Ok(s)
}
None => bail!("Submission not found"),
}
}
#[instrument(skip(self, id), err)]
pub async fn submit_hackathon_submission(&self, id: String) -> Result<HackathonSubmissionsSchema> {
let table = ResourceEnum::HackathonSubmissions.to_string();
let existing: Option<HackathonSubmissionsSchema> = self.state.surrealdb_ws.select((table.clone(), id.clone())).await?;
let mut existing = existing.ok_or_else(|| anyhow!("Submission not found"))?;
if existing.is_deleted {
bail!("Submission not found");
}
existing.submission_status = Some(super::hackathon_schema::SubmissionStatus::Submitted);
existing.submitted_at = Some(chrono::Utc::now());
existing.updated_at = Some(get_iso_date());
info!(query = %format!("UPDATE {} SET submission_status = 'Submitted' WHERE id = '{}'", table, id), "Executing SurrealDB query");
let record: Option<HackathonSubmissionsSchema> = self
.state.surrealdb_ws
.update((table, id))
.content(existing.clone())
.await?;
match record {
Some(s) => Ok(s),
None => bail!("Failed to submit hackathon submission"),
}
}
#[instrument(skip(self, id), err)]
pub async fn delete_hackathon_submission(&self, id: String) -> Result<String> {
let table = ResourceEnum::HackathonSubmissions.to_string();
let updates: HashMap<String, serde_json::Value> = HashMap::from([
("is_deleted".to_string(), true.into()),
("updated_at".to_string(), get_iso_date().into()),
]);
info!(query = %format!("UPDATE {} SET is_deleted = true WHERE id = '{}'", table, id), "Executing SurrealDB query");
let record: Option<HackathonSubmissionsSchema> = self
.state.surrealdb_ws
.update((table, id))
.merge(serde_json::to_value(updates)?)
.await?;
match record {
Some(_) => Ok("Submission deleted successfully".to_string()),
None => bail!("Failed to delete submission"),
}
}
#[instrument(skip(self, hackathon_id), err)]
pub async fn get_submission_timeline_phase(&self, hackathon_id: String) -> Result<Option<HackathonTimelineSchema>> {
let table = ResourceEnum::HackathonTimeline.to_string();
info!(query = %format!("SELECT * FROM {} WHERE hackathon_id = 'app_hackathons:{}' AND phase = 'Submission' AND is_deleted = false LIMIT 1", table, hackathon_id), "Executing SurrealDB query");
let mut result = self.state.surrealdb_ws
.query("SELECT * FROM type::table($table) WHERE hackathon_id = type::thing('app_hackathons', $hackathon_id) AND phase = $phase AND is_deleted = false LIMIT 1")
.bind(("table", table))
.bind(("hackathon_id", hackathon_id))
.bind(("phase", HackathonPhase::Submission))
.await?;
let timeline: Option<HackathonTimelineSchema> = result.take(0)?;
Ok(timeline)
}
}
// Hackathon Participants CRUD operations
impl<'a> HackathonRepository<'a> {
#[instrument(skip(self, hackathon_id, user_id), err)]
pub async fn create_hackathon_participant(&self, hackathon_id: String, user_id: String) -> Result<super::hackathon_schema::HackathonParticipantSchema> {
// Use the dedicated participants table to avoid polluting submissions
let table = "app_hackathon_participants".to_string();
let id = surrealdb::Uuid::new_v4().to_string();
let normalized_hackathon_id = self.normalize_id("app_hackathons", &hackathon_id);
let schema = super::hackathon_schema::HackathonParticipantSchema {
id: Thing::from((table.clone(), id.clone())),
hackathon_id: Thing::from(("app_hackathons".to_string(), normalized_hackathon_id)),
user_id,
is_deleted: false,
created_at: Some(get_iso_date()),
updated_at: Some(get_iso_date()),
};
info!(query = %format!("CREATE {}:{}", table, id), "Executing SurrealDB query");
let record: Option<super::hackathon_schema::HackathonParticipantSchema> = self
.state
.surrealdb_ws
.create((table, id))
.content(schema.clone())
.await?;
match record {
Some(p) => Ok(p),
None => bail!("Failed to create participant"),
}
}
#[instrument(skip(self, meta, hackathon_id), err)]
pub async fn list_hackathon_participants(&self, meta: imphnen_libs::MetaRequestDto, hackathon_id: String) -> Result<imphnen_libs::ResponseListSuccessDto<Vec<super::hackathon_schema::HackathonParticipantSchema>>> {
let table = "app_hackathon_participants".to_string();
let normalized_hackathon_id = self.normalize_id("app_hackathons", &hackathon_id);
let builder = QueryListBuilder::new(&self.state.surrealdb_ws, &table, &meta)
.with_condition("is_deleted = false")
.with_condition(&format!("hackathon_id = type::thing('app_hackathons', '{}')", normalized_hackathon_id))
.select_fields(vec!["*"]);
let mut result = builder.build().await?;
// sort by created_at for deterministic results
result.data.sort_by_key(|s: &super::hackathon_schema::HackathonParticipantSchema| s.created_at.clone());
Ok(result)
}
}
@@ -1,340 +0,0 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize, Deserializer};
use std::str::FromStr;
use serde::de;
use surrealdb::sql::Thing;
use imphnen_utils::make_thing;
use imphnen_utils::get_iso_date;
use imphnen_libs::ResourceEnum;
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct HackathonSchema {
pub id: Thing,
pub name: String,
pub description: String,
pub start_date: DateTime<Utc>,
pub end_date: DateTime<Utc>,
pub registration_deadline: DateTime<Utc>,
pub max_participants: Option<u32>,
pub status: HackathonStatus,
pub theme: Option<String>,
pub rules: Option<String>,
pub prizes: Option<Vec<Prize>>,
pub previous_winners: Option<Vec<Winner>>,
pub organizers: Vec<String>, // User IDs
pub is_deleted: bool,
pub created_at: Option<String>,
pub updated_at: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct HackathonEventsSchema {
pub id: Thing,
pub hackathon_id: Thing,
pub title: String,
pub description: Option<String>,
pub event_type: HackathonEventType,
pub start_time: DateTime<Utc>,
pub end_time: DateTime<Utc>,
pub location: Option<String>,
pub virtual_link: Option<String>,
pub max_attendees: Option<u32>,
pub is_mandatory: bool,
pub is_deleted: bool,
pub created_at: Option<String>,
pub updated_at: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct HackathonTimelineSchema {
pub id: Thing,
pub hackathon_id: Thing,
pub phase: HackathonPhase,
pub title: String,
pub description: Option<String>,
pub start_date: DateTime<Utc>,
pub end_date: DateTime<Utc>,
pub is_active: bool,
pub order: u32,
pub is_deleted: bool,
pub created_at: Option<String>,
pub updated_at: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct HackathonSubmissionsSchema {
pub id: Thing,
pub hackathon_id: Thing,
pub team_id: Option<Thing>,
pub project_name: Option<String>,
pub description: Option<String>,
pub repository_url: Option<String>,
pub upload_file_url: Option<String>,
pub demo_url: Option<String>,
pub slides_url: Option<String>,
pub technologies: Option<Vec<String>>,
pub contact_instagram: Option<String>,
pub contact_twitter: Option<String>,
pub contact_linkedin: Option<String>,
pub contact_facebook: Option<String>,
pub contact_youtube: Option<String>,
pub contact_tiktok: Option<String>,
pub contact_other: Option<String>,
pub submission_status: Option<SubmissionStatus>,
pub judge_feedback: Option<String>,
pub submitted_at: Option<DateTime<Utc>>,
pub is_deleted: bool,
pub created_at: Option<String>,
pub updated_at: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Prize {
pub position: u32,
pub title: String,
pub description: Option<String>,
pub value: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Winner {
pub position: u32,
pub team_id: String,
pub project_name: String,
pub team_name: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, utoipa::ToSchema, strum::Display)]
pub enum HackathonStatus {
Draft,
RegistrationOpen,
RegistrationClosed,
InProgress,
Judging,
Completed,
Cancelled,
}
#[derive(Clone, Debug, Serialize, PartialEq, utoipa::ToSchema, strum::Display)]
pub enum HackathonPhase {
Registration,
Ideation,
Development,
Submission,
Judging,
Awards,
}
// Add as_str method for HackathonPhase
impl HackathonPhase {
pub fn as_str(&self) -> &str {
match self {
HackathonPhase::Registration => "registration",
HackathonPhase::Ideation => "ideation",
HackathonPhase::Development => "development",
HackathonPhase::Submission => "submission",
HackathonPhase::Judging => "judging",
HackathonPhase::Awards => "awards",
}
}
}
// Manual Deserialize implementation for case-insensitive support
impl<'de> Deserialize<'de> for HackathonPhase {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
let normalized = s.to_lowercase();
match normalized.as_str() {
"registration" => Ok(HackathonPhase::Registration),
"ideation" => Ok(HackathonPhase::Ideation),
"development" => Ok(HackathonPhase::Development),
"submission" => Ok(HackathonPhase::Submission),
"judging" => Ok(HackathonPhase::Judging),
"awards" => Ok(HackathonPhase::Awards),
_ => Err(serde::de::Error::custom(format!("Invalid HackathonPhase: {}", s)))
}
}
}
#[derive(Clone, Debug, Serialize, PartialEq, utoipa::ToSchema, strum::Display)]
pub enum HackathonEventType {
Workshop,
Keynote,
Networking,
Judging,
Ceremony,
Other,
}
// Implement case-insensitive string parsing for HackathonEventType
impl FromStr for HackathonEventType {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"workshop" => Ok(Self::Workshop),
"keynote" => Ok(Self::Keynote),
"networking" => Ok(Self::Networking),
"judging" => Ok(Self::Judging),
"ceremony" => Ok(Self::Ceremony),
"other" => Ok(Self::Other),
_ => Err(format!("Invalid HackathonEventType: {}", s)),
}
}
}
// Manual Deserialize implementation for case-insensitive support
impl<'de> Deserialize<'de> for HackathonEventType {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
Self::from_str(&s).map_err(de::Error::custom)
}
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, utoipa::ToSchema, strum::Display)]
pub enum SubmissionStatus {
Draft,
Submitted,
Accepted,
UnderReview,
Shortlisted,
Winner,
Rejected,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct HackathonParticipantSchema {
pub id: Thing,
pub hackathon_id: Thing,
pub user_id: String,
pub is_deleted: bool,
pub created_at: Option<String>,
pub updated_at: Option<String>,
}
impl Default for HackathonParticipantSchema {
fn default() -> Self {
HackathonParticipantSchema {
id: make_thing(
"app_hackathon_participants",
&surrealdb::Uuid::new_v4().to_string(),
),
hackathon_id: Thing::from(("app_hackathons".to_string(), surrealdb::sql::Id::rand())),
user_id: String::new(),
is_deleted: false,
created_at: Some(get_iso_date()),
updated_at: Some(get_iso_date()),
}
}
}
impl Default for HackathonSchema {
fn default() -> Self {
HackathonSchema {
id: make_thing(
&ResourceEnum::Hackathons.to_string(),
&surrealdb::Uuid::new_v4().to_string(),
),
name: String::new(),
description: String::new(),
start_date: Utc::now(),
end_date: Utc::now(),
registration_deadline: Utc::now(),
max_participants: None,
status: HackathonStatus::Draft,
theme: None,
rules: None,
prizes: None,
previous_winners: None,
organizers: vec![],
is_deleted: false,
created_at: Some(get_iso_date()),
updated_at: Some(get_iso_date()),
}
}
}
impl Default for HackathonEventsSchema {
fn default() -> Self {
HackathonEventsSchema {
id: make_thing(
&ResourceEnum::HackathonEvents.to_string(),
&surrealdb::Uuid::new_v4().to_string(),
),
hackathon_id: Thing::from(("app_hackathons".to_string(), surrealdb::sql::Id::rand())),
title: String::new(),
description: None,
event_type: HackathonEventType::Other,
start_time: Utc::now(),
end_time: Utc::now(),
location: None,
virtual_link: None,
max_attendees: None,
is_mandatory: false,
is_deleted: false,
created_at: Some(get_iso_date()),
updated_at: Some(get_iso_date()),
}
}
}
impl Default for HackathonTimelineSchema {
fn default() -> Self {
HackathonTimelineSchema {
id: make_thing(
&ResourceEnum::HackathonTimeline.to_string(),
&surrealdb::Uuid::new_v4().to_string(),
),
hackathon_id: Thing::from(("app_hackathons".to_string(), surrealdb::sql::Id::rand())),
phase: HackathonPhase::Registration,
title: String::new(),
description: None,
start_date: Utc::now(),
end_date: Utc::now(),
is_active: false,
order: 0,
is_deleted: false,
created_at: Some(get_iso_date()),
updated_at: Some(get_iso_date()),
}
}
}
impl Default for HackathonSubmissionsSchema {
fn default() -> Self {
HackathonSubmissionsSchema {
id: make_thing(
&ResourceEnum::HackathonSubmissions.to_string(),
&surrealdb::Uuid::new_v4().to_string(),
),
hackathon_id: Thing::from(("app_hackathons".to_string(), surrealdb::sql::Id::rand())),
team_id: Some(Thing::from(("app_teams".to_string(), surrealdb::sql::Id::rand()))),
project_name: Some(String::new()),
description: Some(String::new()),
repository_url: None,
upload_file_url: None,
demo_url: None,
slides_url: None,
technologies: Some(vec![]),
contact_instagram: None,
contact_twitter: None,
contact_linkedin: None,
contact_facebook: None,
contact_youtube: None,
contact_tiktok: None,
contact_other: None,
submission_status: Some(SubmissionStatus::Draft),
judge_feedback: None,
submitted_at: Some(Utc::now()),
is_deleted: false,
created_at: Some(get_iso_date()),
updated_at: Some(get_iso_date()),
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,340 +0,0 @@
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
}
}
-28
View File
@@ -1,28 +0,0 @@
use axum::Router;
pub mod hackathon_controller;
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::*;
pub fn hackathon_router() -> Router {
hackathon_controller::hackathon_routes()
}
-41
View File
@@ -1,41 +0,0 @@
use axum::Router;
pub mod hackathon;
pub mod notifications;
pub mod registrations;
// Export the router function from hackathon module
pub use hackathon::hackathon_router;
pub use notifications::notifications_router;
pub use registrations::registrations_router;
// Main route constructor
pub fn hackathon_protected_routes() -> Router {
// Protected routes include the main hackathon router (create/update/delete) and
// a protected route for updating submission status.
use hackathon::hackathon_controller::{update_submission_status, get_admin_hackathon_results};
Router::new()
.nest("/hackathons", hackathon_router())
.route("/hackathons/submissions/update/{id}/status", axum::routing::patch(update_submission_status))
.route("/hackathons/{hackathon_id}/admin/results", axum::routing::get(get_admin_hackathon_results))
.merge(registrations_router())
.merge(notifications_router())
}
// Public routes for hackathons (only listing and retrieving)
pub fn hackathon_public_routes() -> Router {
use hackathon::hackathon_controller::{
list_hackathons,
search_hackathons,
get_user_hackathon_submissions,
get_public_hackathon_results,
};
Router::new()
.nest("/hackathons", Router::new()
.route("/", axum::routing::get(list_hackathons))
.route("/{id}/results", axum::routing::get(get_public_hackathon_results))
.route("/search", axum::routing::post(search_hackathons))
)
.route("/users/{user_id}/hackathon-submissions", axum::routing::get(get_user_hackathon_submissions))
}
@@ -1,7 +0,0 @@
pub mod notification_controller;
pub mod notification_dto;
pub mod notification_repository;
pub mod notification_schema;
pub mod notification_service;
pub use notification_controller::notifications_router;
@@ -1,171 +0,0 @@
use super::notification_dto::{
DeleteNotificationResponseDto, MarkAllAsReadResponseDto, MarkAsReadResponseDto, NotificationListQueryDto, NotificationListResponseDto,
UnreadCountResponseDto,
};
use super::notification_service::Service;
use axum::{
extract::{Extension, Path, Query},
http::{HeaderMap, Response, StatusCode},
routing::{delete, get, put},
Router, body::Body,
};
use imphnen_libs::AppState;
use imphnen_utils::{extract_email::extract_email, response_format::common_response};
/// Get user's notifications with optional filtering
#[utoipa::path(
get,
path = "/v1/notifications",
tags = ["notifications"],
params(
("page_size" = Option<usize>, Query, description = "Number of notifications per page (1-100, default: 20)"),
("page" = Option<usize>, Query, description = "Page number (min: 1, default: 1)"),
("is_read" = Option<bool>, Query, description = "Filter by read status"),
("notification_type" = Option<String>, Query, description = "Filter by notification type"),
),
responses(
(status = 200, description = "Successfully retrieved notifications", body = NotificationListResponseDto),
(status = 401, description = "Unauthorized - Invalid or missing token"),
),
security(
("bearer" = [])
)
)]
pub async fn get_notifications_handler(
headers: HeaderMap,
Query(query): Query<NotificationListQueryDto>,
Extension(state): Extension<AppState>,
) -> Response<Body> {
match extract_email(&headers) {
Some(email) => {
let service = Service::new(&state);
service.get_notifications(&email, query).await
}
None => common_response(StatusCode::UNAUTHORIZED, "Unauthorized"),
}
}
/// Mark a notification as read
#[utoipa::path(
put,
path = "/v1/notifications/update/{id}/read",
tags = ["notifications"],
params(
("id" = String, Path, description = "Notification ID")
),
responses(
(status = 200, description = "Successfully marked as read", body = MarkAsReadResponseDto),
(status = 401, description = "Unauthorized - Invalid or missing token"),
(status = 403, description = "Forbidden - Not the notification owner"),
(status = 404, description = "Notification not found"),
),
security(
("bearer" = [])
)
)]
pub async fn mark_as_read_handler(
headers: HeaderMap,
Path(id): Path<String>,
Extension(state): Extension<AppState>,
) -> Response<Body> {
match extract_email(&headers) {
Some(email) => {
let service = Service::new(&state);
service.mark_as_read(&email, &id).await
}
None => common_response(StatusCode::UNAUTHORIZED, "Unauthorized"),
}
}
/// Mark all notifications as read
#[utoipa::path(
put,
path = "/v1/notifications/read-all",
tags = ["notifications"],
responses(
(status = 200, description = "Successfully marked all notifications as read", body = MarkAllAsReadResponseDto),
(status = 401, description = "Unauthorized - Invalid or missing token"),
),
security(
("bearer" = [])
)
)]
pub async fn mark_all_as_read_handler(
headers: HeaderMap,
Extension(state): Extension<AppState>,
) -> Response<Body> {
match extract_email(&headers) {
Some(email) => {
let service = Service::new(&state);
service.mark_all_as_read(&email).await
}
None => common_response(StatusCode::UNAUTHORIZED, "Unauthorized"),
}
}
/// Delete a notification
#[utoipa::path(
delete,
path = "/v1/notifications/delete/{id}",
tags = ["notifications"],
params(
("id" = String, Path, description = "Notification ID"),
),
responses(
(status = 200, description = "Successfully deleted notification", body = DeleteNotificationResponseDto),
(status = 401, description = "Unauthorized - Invalid or missing token"),
(status = 403, description = "Forbidden - Not the notification owner"),
(status = 404, description = "Notification not found"),
),
security(
("bearer" = [])
)
)]
pub async fn delete_notification_handler(
headers: HeaderMap,
Path(id): Path<String>,
Extension(state): Extension<AppState>,
) -> Response<Body> {
match extract_email(&headers) {
Some(email) => {
let service = Service::new(&state);
service.delete_notification(&email, &id).await
}
None => common_response(StatusCode::UNAUTHORIZED, "Unauthorized"),
}
}
/// Get unread notifications count
#[utoipa::path(
get,
path = "/v1/notifications/unread/count",
tags = ["notifications"],
responses(
(status = 200, description = "Successfully retrieved unread count", body = UnreadCountResponseDto),
(status = 401, description = "Unauthorized - Invalid or missing token"),
),
security(
("bearer" = [])
)
)]
pub async fn get_unread_count_handler(
headers: HeaderMap,
Extension(state): Extension<AppState>,
) -> Response<Body> {
match extract_email(&headers) {
Some(email) => {
let service = Service::new(&state);
service.get_unread_count(&email).await
}
None => common_response(StatusCode::UNAUTHORIZED, "Unauthorized"),
}
}
pub fn notifications_router() -> Router {
Router::new()
.route("/notifications", get(get_notifications_handler))
.route("/notifications/update/{id}/read", put(mark_as_read_handler))
.route("/notifications/read-all", put(mark_all_as_read_handler))
.route("/notifications/delete/{id}", delete(delete_notification_handler))
.route("/notifications/unread/count", get(get_unread_count_handler))
}
@@ -1,72 +0,0 @@
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use validator::Validate;
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct NotificationDto {
pub id: String,
pub notification_type: String,
pub title: String,
pub message: String,
pub is_read: bool,
pub created_at: String,
pub read_at: Option<String>,
pub related_id: Option<String>,
pub action_url: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct NotificationListResponseDto {
pub notifications: Vec<NotificationDto>,
pub total: usize,
pub unread_count: usize,
pub page: usize,
pub page_size: usize,
}
#[derive(Debug, Clone, Deserialize, Validate, ToSchema)]
pub struct NotificationListQueryDto {
#[validate(range(min = 1, max = 100))]
#[serde(default = "default_page_size")]
pub page_size: usize,
#[validate(range(min = 1))]
#[serde(default = "default_page")]
pub page: usize,
pub is_read: Option<bool>,
pub notification_type: Option<String>,
}
fn default_page_size() -> usize {
20
}
fn default_page() -> usize {
1
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct MarkAsReadResponseDto {
pub id: String,
pub is_read: bool,
pub read_at: String,
pub message: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct MarkAllAsReadResponseDto {
pub updated_count: usize,
pub message: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct DeleteNotificationResponseDto {
pub id: String,
pub message: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct UnreadCountResponseDto {
pub unread_count: usize,
}
@@ -1,148 +0,0 @@
use crate::v1::notifications::notification_schema::NotificationSchema;
use imphnen_libs::AppState;
use imphnen_utils::get_id;
use surrealdb::sql::Thing;
pub struct Repository<'a> {
state: &'a AppState,
}
impl<'a> Repository<'a> {
pub fn new(state: &'a AppState) -> Self {
Self { state }
}
pub async fn query_user_notifications(
&self,
user_id: &Thing,
is_read: Option<bool>,
notification_type: Option<String>,
page: usize,
page_size: usize,
) -> Result<Vec<NotificationSchema>, String> {
let db = &self.state.surrealdb_ws;
let offset = (page - 1) * page_size;
let mut query = "SELECT * FROM notifications WHERE user_id = $user_id ".to_string();
if let Some(is_read_val) = is_read {
query.push_str(&format!(" AND is_read = {} ", is_read_val));
}
if let Some(ref notif_type) = notification_type {
query.push_str(&format!(" AND notification_type = '{}' ", notif_type));
}
query.push_str(&format!(
" ORDER BY created_at DESC LIMIT {} START {} ",
page_size, offset
));
let user_id_clone = user_id.clone();
let mut result = db
.query(&query)
.bind(("user_id", user_id_clone))
.await
.map_err(|e| format!("Query failed: {}", e))?;
let notifications: Vec<NotificationSchema> = result.take(0).map_err(|e| format!("Failed to parse results: {}", e))?;
Ok(notifications)
}
pub async fn count_user_notifications(
&self,
user_id: &Thing,
is_read: Option<bool>,
notification_type: Option<String>,
) -> Result<usize, String> {
let db = &self.state.surrealdb_ws;
let mut query = "SELECT count() as total FROM notifications WHERE user_id = $user_id ".to_string();
if let Some(is_read_val) = is_read {
query.push_str(&format!(" AND is_read = {} ", is_read_val));
}
if let Some(ref notif_type) = notification_type {
query.push_str(&format!(" AND notification_type = '{}' ", notif_type));
}
query.push_str(" GROUP ALL ");
let user_id_clone = user_id.clone();
let mut result = db
.query(&query)
.bind(("user_id", user_id_clone))
.await
.map_err(|e| format!("Query failed: {}", e))?;
let count: Option<usize> = result.take("total").map_err(|e| format!("Failed to get count: {}", e))?;
Ok(count.unwrap_or(0))
}
pub async fn query_notification_by_id(
&self,
notification_id: &Thing,
) -> Result<NotificationSchema, String> {
let db = &self.state.surrealdb_ws;
let record_key = get_id(notification_id).map_err(|e| e.to_string())?;
let notification: Option<NotificationSchema> = db
.select(record_key)
.await
.map_err(|e| format!("Failed to fetch notification: {}", e))?;
notification.ok_or("Notification not found".to_string())
}
pub async fn update_notification(
&self,
notification_id: &Thing,
notification: NotificationSchema,
) -> Result<NotificationSchema, String> {
let db = &self.state.surrealdb_ws;
let record_key = get_id(notification_id).map_err(|e| e.to_string())?;
let updated: Option<NotificationSchema> = db
.update(record_key)
.content(notification)
.await
.map_err(|e| format!("Failed to update notification: {}", e))?;
updated.ok_or("Failed to update notification".to_string())
}
pub async fn mark_all_as_read(&self, user_id: &Thing) -> Result<usize, String> {
let db = &self.state.surrealdb_ws;
let query = "UPDATE notifications SET is_read = true, read_at = time::now() WHERE user_id = $user_id AND is_read = false";
let user_id_clone = user_id.clone();
let mut result = db
.query(query)
.bind(("user_id", user_id_clone))
.await
.map_err(|e| format!("Query failed: {}", e))?;
let updated: Vec<NotificationSchema> = result.take(0).map_err(|e| format!("Failed to parse results: {}", e))?;
Ok(updated.len())
}
pub async fn delete_notification(&self, notification_id: &Thing) -> Result<(), String> {
let db = &self.state.surrealdb_ws;
let record_key = get_id(notification_id).map_err(|e| e.to_string())?;
let _: Option<NotificationSchema> = db
.delete(record_key)
.await
.map_err(|e| format!("Failed to delete notification: {}", e))?;
Ok(())
}
pub async fn count_unread_notifications(&self, user_id: &Thing) -> Result<usize, String> {
let db = &self.state.surrealdb_ws;
let query = "SELECT count() as total FROM notifications WHERE user_id = $user_id AND is_read = false GROUP ALL";
let user_id_clone = user_id.clone();
let mut result = db
.query(query)
.bind(("user_id", user_id_clone))
.await
.map_err(|e| format!("Query failed: {}", e))?;
let count: Option<usize> = result.take("total").map_err(|e| format!("Failed to get count: {}", e))?;
Ok(count.unwrap_or(0))
}
}
@@ -1,47 +0,0 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use surrealdb::sql::Thing;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum NotificationType {
#[serde(rename = "registration_approved")]
RegistrationApproved,
#[serde(rename = "registration_rejected")]
RegistrationRejected,
#[serde(rename = "registration_waitlisted")]
RegistrationWaitlisted,
#[serde(rename = "hackathon_reminder")]
HackathonReminder,
#[serde(rename = "team_invite")]
TeamInvite,
#[serde(rename = "team_update")]
TeamUpdate,
#[serde(rename = "hackathon_update")]
HackathonUpdate,
#[serde(rename = "check_in_reminder")]
CheckInReminder,
#[serde(rename = "announcement")]
Announcement,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NotificationSchema {
pub id: Thing,
pub user_id: Thing,
pub notification_type: NotificationType,
pub title: String,
pub message: String,
pub is_read: bool,
pub created_at: DateTime<Utc>,
pub read_at: Option<DateTime<Utc>>,
pub related_id: Option<Thing>, // Could be hackathon_id, registration_id, team_id, etc.
pub action_url: Option<String>,
pub metadata: Option<serde_json::Value>, // For additional flexible data
}
impl NotificationSchema {
pub fn mark_as_read(&mut self) {
self.is_read = true;
self.read_at = Some(Utc::now());
}
}
@@ -1,233 +0,0 @@
use super::notification_dto::{
DeleteNotificationResponseDto, MarkAllAsReadResponseDto, MarkAsReadResponseDto,
NotificationDto, NotificationListQueryDto, NotificationListResponseDto,
UnreadCountResponseDto,
};
use super::notification_repository::Repository;
use axum::http::{Response, StatusCode};
use axum::response::IntoResponse;
use axum::body::Body;
use imphnen_entities::common_dto::ResponseSuccessDto;
use imphnen_libs::AppState;
use imphnen_utils::{
extract_id, make_thing, response_format::success_response, error_response,
validator::validate_request, AppError,
};
pub struct Service<'a> {
state: &'a AppState,
}
impl<'a> Service<'a> {
pub fn new(state: &'a AppState) -> Self {
Self { state }
}
pub async fn get_notifications(
&self,
user_email: &str,
query: NotificationListQueryDto,
) -> Response<Body> {
if let Err((_status, message)) = validate_request(&query) {
return error_response(AppError::ValidationError(message));
}
let user_id = make_thing("users", user_email);
let repository = Repository::new(self.state);
let notifications_result = repository
.query_user_notifications(
&user_id,
query.is_read,
query.notification_type.clone(),
query.page,
query.page_size,
)
.await;
let notifications = match notifications_result {
Ok(notifs) => notifs,
Err(err) => {
return error_response(AppError::InternalServerError(err.to_string()));
}
};
let total_result = repository
.count_user_notifications(&user_id, query.is_read, query.notification_type)
.await;
let total = match total_result {
Ok(count) => count,
Err(err) => {
return error_response(AppError::InternalServerError(err.to_string()));
}
};
let unread_count_result = repository.count_unread_notifications(&user_id).await;
let unread_count = match unread_count_result {
Ok(count) => count,
Err(err) => {
return error_response(AppError::InternalServerError(err.to_string()));
}
};
let notification_dtos: Vec<NotificationDto> = notifications
.into_iter()
.map(|n| NotificationDto {
id: extract_id(&n.id),
notification_type: format!("{:?}", n.notification_type),
title: n.title,
message: n.message,
is_read: n.is_read,
created_at: n.created_at.to_rfc3339(),
read_at: n.read_at.map(|dt| dt.to_rfc3339()),
related_id: n.related_id.map(|id| extract_id(&id)),
action_url: n.action_url,
})
.collect();
let response = NotificationListResponseDto {
notifications: notification_dtos,
total,
unread_count,
page: query.page,
page_size: query.page_size,
};
success_response(ResponseSuccessDto { data: response })
}
pub async fn mark_as_read(
&self,
user_email: &str,
notification_id: &str,
) -> Response<Body> {
let user_id = make_thing("users", user_email);
let notif_id = make_thing("notifications", notification_id);
let repository = Repository::new(self.state);
let notification_result = repository.query_notification_by_id(&notif_id).await;
let mut notification = match notification_result {
Ok(notif) => notif,
Err(_) => {
return (
StatusCode::NOT_FOUND,
"Notification not found".to_string(),
)
.into_response();
}
};
// Verify ownership
if notification.user_id != user_id {
return (
StatusCode::FORBIDDEN,
"You don't have permission to access this notification".to_string(),
)
.into_response();
}
if notification.is_read {
return (
StatusCode::BAD_REQUEST,
"Notification is already marked as read".to_string(),
)
.into_response();
}
notification.mark_as_read();
match repository.update_notification(&notif_id, notification.clone()).await {
Ok(updated) => {
let response = MarkAsReadResponseDto {
id: extract_id(&updated.id),
is_read: updated.is_read,
read_at: updated.read_at.unwrap().to_rfc3339(),
message: "Notification marked as read".to_string(),
};
success_response(ResponseSuccessDto { data: response })
}
Err(err) => (StatusCode::INTERNAL_SERVER_ERROR, err).into_response(),
}
}
pub async fn mark_all_as_read(&self, user_email: &str) -> Response<Body> {
let user_id = make_thing("users", user_email);
let repository = Repository::new(self.state);
match repository.mark_all_as_read(&user_id).await {
Ok(count) => {
let response = MarkAllAsReadResponseDto {
updated_count: count,
message: format!("{} notification(s) marked as read", count),
};
success_response(ResponseSuccessDto { data: response })
}
Err(err) => (StatusCode::INTERNAL_SERVER_ERROR, err).into_response(),
}
}
pub async fn delete_notification(
&self,
user_email: &str,
notification_id: &str,
) -> Response<Body> {
let user_id = make_thing("users", user_email);
let notif_id = make_thing("notifications", notification_id);
let repository = Repository::new(self.state);
let notification_result = repository.query_notification_by_id(&notif_id).await;
let notification = match notification_result {
Ok(notif) => notif,
Err(_) => {
return (
StatusCode::NOT_FOUND,
"Notification not found".to_string(),
)
.into_response();
}
};
// Verify ownership
if notification.user_id != user_id {
return (
StatusCode::FORBIDDEN,
"You don't have permission to delete this notification".to_string(),
)
.into_response();
}
match repository.delete_notification(&notif_id).await {
Ok(_) => {
let response = DeleteNotificationResponseDto {
id: notification_id.to_string(),
message: "Notification deleted successfully".to_string(),
};
success_response(ResponseSuccessDto { data: response })
}
Err(err) => (StatusCode::INTERNAL_SERVER_ERROR, err).into_response(),
}
}
pub async fn get_unread_count(&self, user_email: &str) -> Response<Body> {
let user_id = make_thing("users", user_email);
let repository = Repository::new(self.state);
match repository.count_unread_notifications(&user_id).await {
Ok(count) => {
let response = UnreadCountResponseDto {
unread_count: count,
};
success_response(ResponseSuccessDto { data: response })
}
Err(err) => (StatusCode::INTERNAL_SERVER_ERROR, err).into_response(),
}
}
}
@@ -1,11 +0,0 @@
pub mod registration_controller;
pub mod registration_dto;
pub mod registration_repository;
pub mod registration_schema;
pub mod registration_service;
pub use registration_controller::*;
pub use registration_dto::*;
pub use registration_repository::*;
pub use registration_schema::*;
pub use registration_service::*;
@@ -1,291 +0,0 @@
use axum::{
extract::{Extension, Path},
http::{HeaderMap, StatusCode},
response::Response,
routing::{get, post, put},
Json, Router,
};
use imphnen_entities::ResponseSuccessDto;
use imphnen_libs::AppState;
use imphnen_utils::{common_response, extract_email, make_thing_from_enum};
use imphnen_libs::ResourceEnum;
use super::{
CheckInResponseDto, RegistrationListResponseDto, RegistrationRequestDto,
RegistrationResponseDto, RegistrationStatsDto, RegistrationsService,
UpdateRegistrationStatusRequestDto, UpdateRegistrationStatusResponseDto,
UserHackathonsResponseDto,
};
// ============================================
// POST /v1/hackathons/{id}/registrations/create
// ============================================
#[utoipa::path(
post,
path = "/v1/hackathons/{id}/registrations/create",
tag = "registrations",
summary = "Register for a hackathon",
description = "Submit a registration for a hackathon. User must be authenticated.",
params(
("id" = String, Path, description = "Hackathon ID")
),
request_body = RegistrationRequestDto,
responses(
(status = 200, description = "Registration submitted successfully", body = ResponseSuccessDto<RegistrationResponseDto>),
(status = 400, description = "Invalid input or validation error"),
(status = 401, description = "Unauthorized - authentication required"),
(status = 409, description = "User already registered for this hackathon"),
(status = 500, description = "Internal server error"),
),
security(
("bearer_auth" = [])
)
)]
pub async fn post_register_hackathon(
Extension(state): Extension<AppState>,
headers: HeaderMap,
Path(id): Path<String>,
Json(data): Json<RegistrationRequestDto>,
) -> Response {
// Authentication
let user_email = match extract_email(&headers) {
Some(email) => email,
None => return common_response(StatusCode::UNAUTHORIZED, "Unauthorized"),
};
// Parse hackathon ID
let hackathon_id = make_thing_from_enum(ResourceEnum::Hackathons, &id);
let service = RegistrationsService::new(&state);
service.register_hackathon(&hackathon_id, &id, &user_email, data).await
}
// ============================================
// GET /v1/hackathons/{id}/registrations
// ============================================
#[utoipa::path(
get,
path = "/v1/hackathons/{id}/registrations",
tag = "registrations",
summary = "List hackathon registrations",
description = "Get all registrations for a hackathon. Requires admin/organizer permissions. Optional status filter.",
params(
("id" = String, Path, description = "Hackathon ID"),
("status" = Option<String>, Query, description = "Filter by status: pending, approved, rejected, waitlisted, cancelled")
),
responses(
(status = 200, description = "Registrations retrieved successfully", body = ResponseSuccessDto<RegistrationListResponseDto>),
(status = 400, description = "Invalid input"),
(status = 401, description = "Unauthorized - authentication required"),
(status = 500, description = "Internal server error"),
),
security(
("bearer_auth" = [])
)
)]
pub async fn get_hackathon_registrations(
Extension(state): Extension<AppState>,
headers: HeaderMap,
Path(id): Path<String>,
axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String, String>>,
) -> Response {
// Authentication
match extract_email(&headers) {
Some(_) => {},
None => return common_response(StatusCode::UNAUTHORIZED, "Unauthorized"),
};
// Parse hackathon ID
let hackathon_id = make_thing_from_enum(ResourceEnum::Hackathons, &id);
let status_filter = params.get("status").cloned();
let service = RegistrationsService::new(&state);
service.get_hackathon_registrations(&hackathon_id, status_filter).await
}
// ============================================
// GET /v1/users/me/hackathons
// ============================================
#[utoipa::path(
get,
path = "/v1/users/me/hackathons",
tag = "registrations",
summary = "Get my hackathon registrations",
description = "Get all hackathons the current user has registered for.",
responses(
(status = 200, description = "Hackathons retrieved successfully", body = ResponseSuccessDto<UserHackathonsResponseDto>),
(status = 401, description = "Unauthorized - authentication required"),
(status = 500, description = "Internal server error"),
),
security(
("bearer_auth" = [])
)
)]
pub async fn get_my_hackathons(
Extension(state): Extension<AppState>,
headers: HeaderMap,
) -> Response {
// Authentication
let user_email = match extract_email(&headers) {
Some(email) => email,
None => return common_response(StatusCode::UNAUTHORIZED, "Unauthorized"),
};
let service = RegistrationsService::new(&state);
service.get_my_hackathons(&user_email).await
}
// ============================================
// PUT /v1/hackathons/{hackathon_id}/registrations/update/{registration_id}/status
// ============================================
#[utoipa::path(
put,
path = "/v1/hackathons/{hackathon_id}/registrations/update/{registration_id}/status",
tag = "registrations",
summary = "Update registration status",
description = "Update the status of a hackathon registration (admin/organizer only).",
params(
("hackathon_id" = String, Path, description = "Hackathon ID"),
("registration_id" = String, Path, description = "Registration ID")
),
request_body = UpdateRegistrationStatusRequestDto,
responses(
(status = 200, description = "Status updated successfully", body = ResponseSuccessDto<UpdateRegistrationStatusResponseDto>),
(status = 400, description = "Invalid input or validation error"),
(status = 401, description = "Unauthorized - authentication required"),
(status = 404, description = "Registration not found"),
(status = 500, description = "Internal server error"),
),
security(
("bearer_auth" = [])
)
)]
pub async fn put_update_registration_status(
Extension(state): Extension<AppState>,
headers: HeaderMap,
Path((_hackathon_id, registration_id)): Path<(String, String)>,
Json(data): Json<UpdateRegistrationStatusRequestDto>,
) -> Response {
// Authentication
match extract_email(&headers) {
Some(_) => {},
None => return common_response(StatusCode::UNAUTHORIZED, "Unauthorized"),
};
// Parse registration ID
let reg_id = make_thing_from_enum(ResourceEnum::HackathonRegistrations, &registration_id);
let service = RegistrationsService::new(&state);
service.update_registration_status(&reg_id, data).await
}
// ============================================
// POST /v1/hackathons/{hackathon_id}/registrations/{registration_id}/check-in
// ============================================
#[utoipa::path(
post,
path = "/v1/hackathons/{hackathon_id}/registrations/{registration_id}/check-in",
tag = "registrations",
summary = "Check-in participant",
description = "Mark a participant as checked in for the hackathon. Requires admin/organizer permissions.",
params(
("hackathon_id" = String, Path, description = "Hackathon ID"),
("registration_id" = String, Path, description = "Registration ID")
),
responses(
(status = 200, description = "Participant checked in successfully", body = ResponseSuccessDto<CheckInResponseDto>),
(status = 400, description = "Invalid request - participant not approved or already checked in"),
(status = 401, description = "Unauthorized - authentication required"),
(status = 404, description = "Registration not found"),
(status = 500, description = "Internal server error"),
),
security(
("bearer_auth" = [])
)
)]
pub async fn post_check_in_participant(
Extension(state): Extension<AppState>,
headers: HeaderMap,
Path((_hackathon_id, registration_id)): Path<(String, String)>,
) -> Response {
// Authentication
match extract_email(&headers) {
Some(_) => {},
None => return common_response(StatusCode::UNAUTHORIZED, "Unauthorized"),
};
// Parse registration ID
let reg_id = make_thing_from_enum(ResourceEnum::HackathonRegistrations, &registration_id);
let service = RegistrationsService::new(&state);
service.check_in_participant(&reg_id).await
}
// ============================================
// GET /v1/hackathons/{id}/registrations/stats
// ============================================
#[utoipa::path(
get,
path = "/v1/hackathons/{id}/registrations/stats",
tag = "registrations",
summary = "Get registration statistics",
description = "Get comprehensive statistics about hackathon registrations. Requires admin/organizer permissions.",
params(
("id" = String, Path, description = "Hackathon ID")
),
responses(
(status = 200, description = "Statistics retrieved successfully", body = ResponseSuccessDto<RegistrationStatsDto>),
(status = 400, description = "Invalid input"),
(status = 401, description = "Unauthorized - authentication required"),
(status = 500, description = "Internal server error"),
),
security(
("bearer_auth" = [])
)
)]
pub async fn get_registration_stats(
Extension(state): Extension<AppState>,
headers: HeaderMap,
Path(id): Path<String>,
) -> Response {
// Authentication
match extract_email(&headers) {
Some(_) => {},
None => return common_response(StatusCode::UNAUTHORIZED, "Unauthorized"),
};
// Parse hackathon ID
let hackathon_id = make_thing_from_enum(ResourceEnum::Hackathons, &id);
let service = RegistrationsService::new(&state);
service.get_registration_stats(&hackathon_id).await
}
// ============================================
// Router
// ============================================
pub fn registrations_router() -> Router {
Router::new()
.route(
"/hackathons/{id}/registrations/create",
post(post_register_hackathon),
)
.route(
"/hackathons/{id}/registrations",
get(get_hackathon_registrations),
)
.route(
"/hackathons/{id}/registrations/stats",
get(get_registration_stats),
)
.route(
"/hackathons/{hackathon_id}/registrations/update/{registration_id}/status",
put(put_update_registration_status),
)
.route(
"/hackathons/{hackathon_id}/registrations/{registration_id}/check-in",
post(post_check_in_participant),
)
.route("/users/me/hackathons", get(get_my_hackathons))
}
@@ -1,216 +0,0 @@
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use validator::Validate;
use super::{ParticipantRole, RegistrationStatus};
// ============================================
// Registration Request/Response DTOs
// ============================================
#[derive(Debug, Serialize, Deserialize, ToSchema, Validate)]
pub struct RegistrationRequestDto {
pub team_id: Option<String>,
pub role: Option<ParticipantRole>,
#[validate(length(max = 1000, message = "Motivation must not exceed 1000 characters"))]
pub motivation: Option<String>,
pub skills: Option<Vec<String>>,
#[validate(custom(function = "validate_experience_level"))]
pub experience_level: Option<String>,
#[validate(length(max = 100))]
pub github_username: Option<String>,
#[validate(url(message = "Invalid portfolio URL"))]
pub portfolio_url: Option<String>,
pub dietary_requirements: Option<String>,
#[validate(custom(function = "validate_tshirt_size"))]
pub tshirt_size: Option<String>,
#[validate(length(max = 100))]
pub emergency_contact_name: Option<String>,
#[validate(length(max = 20))]
pub emergency_contact_phone: Option<String>,
}
fn validate_experience_level(level: &str) -> Result<(), validator::ValidationError> {
let valid_levels = ["beginner", "intermediate", "advanced"];
if valid_levels.contains(&level) {
Ok(())
} else {
Err(validator::ValidationError::new("Invalid experience level"))
}
}
fn validate_tshirt_size(size: &str) -> Result<(), validator::ValidationError> {
let valid_sizes = ["XS", "S", "M", "L", "XL", "XXL"];
if valid_sizes.contains(&size) {
Ok(())
} else {
Err(validator::ValidationError::new("Invalid t-shirt size"))
}
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct RegistrationResponseDto {
pub id: String,
pub hackathon_id: String,
pub user_id: String,
pub team_id: Option<String>,
pub status: RegistrationStatus,
pub role: ParticipantRole,
pub registration_date: String,
pub checked_in: bool,
pub message: String,
}
// ============================================
// List Registrations DTOs
// ============================================
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct RegistrationListItemDto {
pub id: String,
pub hackathon_id: String,
pub hackathon_name: Option<String>,
pub user_id: String,
pub user_fullname: Option<String>,
pub user_email: Option<String>,
pub team_id: Option<String>,
pub team_name: Option<String>,
pub status: RegistrationStatus,
pub role: ParticipantRole,
pub registration_date: String,
pub checked_in: bool,
pub check_in_time: Option<String>,
pub experience_level: Option<String>,
pub skills: Option<Vec<String>>,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct RegistrationListResponseDto {
pub registrations: Vec<RegistrationListItemDto>,
pub total: usize,
pub status_filter: Option<String>,
}
// Internal query DTO (fields already as String from DB)
#[derive(Debug, Serialize, Deserialize)]
pub struct RegistrationListQueryDto {
pub id: String,
pub hackathon_id: String,
pub hackathon_name: Option<String>,
pub user_id: String,
pub user_fullname: Option<String>,
pub user_email: Option<String>,
pub team_id: Option<String>,
pub team_name: Option<String>,
pub status: RegistrationStatus,
pub role: ParticipantRole,
pub registration_date: String,
pub checked_in: bool,
pub check_in_time: Option<String>,
pub experience_level: Option<String>,
pub skills: Option<Vec<String>>,
}
// ============================================
// Update Status DTOs
// ============================================
#[derive(Debug, Serialize, Deserialize, ToSchema, Validate)]
pub struct UpdateRegistrationStatusRequestDto {
pub status: RegistrationStatus,
#[validate(length(max = 500, message = "Reason must not exceed 500 characters"))]
pub reason: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct UpdateRegistrationStatusResponseDto {
pub id: String,
pub status: RegistrationStatus,
pub updated_at: String,
pub message: String,
}
// ============================================
// Check-in DTOs
// ============================================
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct CheckInResponseDto {
pub id: String,
pub user_fullname: Option<String>,
pub checked_in: bool,
pub check_in_time: String,
pub message: String,
}
// ============================================
// Statistics DTOs
// ============================================
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct RegistrationStatsDto {
pub hackathon_id: String,
pub hackathon_name: Option<String>,
pub total_registrations: usize,
pub pending: usize,
pub approved: usize,
pub rejected: usize,
pub waitlisted: usize,
pub cancelled: usize,
pub checked_in: usize,
pub team_registrations: usize,
pub individual_registrations: usize,
}
// ============================================
// User's Hackathons DTOs
// ============================================
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct UserHackathonDto {
pub registration_id: String,
pub hackathon_id: String,
pub hackathon_name: Option<String>,
pub hackathon_description: Option<String>,
pub start_date: Option<String>,
pub end_date: Option<String>,
pub status: RegistrationStatus,
pub role: ParticipantRole,
pub registration_date: String,
pub checked_in: bool,
pub team_id: Option<String>,
pub team_name: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct UserHackathonsResponseDto {
pub hackathons: Vec<UserHackathonDto>,
pub total: usize,
}
// Internal query DTO
#[derive(Debug, Serialize, Deserialize)]
pub struct UserHackathonQueryDto {
pub registration_id: String,
pub hackathon_id: String,
pub hackathon_name: Option<String>,
pub hackathon_description: Option<String>,
pub start_date: Option<String>,
pub end_date: Option<String>,
pub status: RegistrationStatus,
pub role: ParticipantRole,
pub registration_date: String,
pub checked_in: bool,
pub team_id: Option<String>,
pub team_name: Option<String>,
}
@@ -1,403 +0,0 @@
use super::{ParticipantRole, RegistrationListQueryDto, RegistrationSchema, RegistrationStatus, UserHackathonQueryDto};
use imphnen_libs::AppState;
use imphnen_utils::get_id;
use serde::{Deserialize, Serialize};
use surrealdb::sql::Thing;
pub struct RegistrationsRepository<'a> {
pub state: &'a AppState,
}
impl<'a> RegistrationsRepository<'a> {
pub fn new(state: &'a AppState) -> Self {
Self { state }
}
// ============================================
// Create Registration
// ============================================
pub async fn create_registration(&self, registration: RegistrationSchema) -> Result<RegistrationSchema, String> {
let db = &self.state.surrealdb_ws;
let created: Option<RegistrationSchema> = db
.create("hackathon_registrations")
.content(registration)
.await
.map_err(|e| format!("Failed to create registration: {}", e))?;
created.ok_or_else(|| "Registration creation returned None".to_string())
}
// ============================================
// Get Registration by ID
// ============================================
pub async fn query_registration_by_id(&self, id: &Thing) -> Result<Option<RegistrationSchema>, String> {
let db = &self.state.surrealdb_ws;
let record_key = get_id(id).map_err(|e| e.to_string())?;
let registration: Option<RegistrationSchema> = db
.select(record_key)
.await
.map_err(|e| format!("Failed to fetch registration: {}", e))?;
Ok(registration)
}
// ============================================
// Check if User Already Registered
// ============================================
pub async fn check_existing_registration(
&self,
hackathon_id: &Thing,
user_id: &Thing,
) -> Result<Option<RegistrationSchema>, String> {
let db = &self.state.surrealdb_ws;
let query = r#"
SELECT * FROM hackathon_registrations
WHERE hackathon_id = $hackathon_id
AND user_id = $user_id
AND is_deleted = false
LIMIT 1
"#;
let mut result = db
.query(query)
.bind(("hackathon_id", hackathon_id.clone()))
.bind(("user_id", user_id.clone()))
.await
.map_err(|e| format!("Failed to check existing registration: {}", e))?;
let registration: Option<RegistrationSchema> = result
.take(0)
.map_err(|e| format!("Failed to parse registration: {}", e))?;
Ok(registration)
}
// ============================================
// List Registrations for Hackathon
// ============================================
pub async fn query_hackathon_registrations(
&self,
hackathon_id: &Thing,
status_filter: Option<RegistrationStatus>,
) -> Result<Vec<RegistrationListQueryDto>, String> {
let db = &self.state.surrealdb_ws;
// Use FETCH to retrieve related data in a single query
let query = if status_filter.is_some() {
r#"
SELECT
string::join(':', id.tb, id.id) AS id,
string::join(':', hackathon_id.tb, hackathon_id.id) AS hackathon_id,
hackathon_id.name AS hackathon_name,
string::join(':', user_id.tb, user_id.id) AS user_id,
user_id.fullname AS user_fullname,
user_id.email AS user_email,
(IF team_id != NONE THEN string::join(':', team_id.tb, team_id.id) ELSE NONE END) AS team_id,
(IF team_id != NONE THEN team_id.name ELSE NONE END) AS team_name,
status,
role,
registration_date,
checked_in,
check_in_time,
experience_level,
skills
FROM hackathon_registrations
WHERE hackathon_id = $hackathon_id
AND status = $status
AND is_deleted = false
FETCH hackathon_id, user_id, team_id;
"#
} else {
r#"
SELECT
string::join(':', id.tb, id.id) AS id,
string::join(':', hackathon_id.tb, hackathon_id.id) AS hackathon_id,
hackathon_id.name AS hackathon_name,
string::join(':', user_id.tb, user_id.id) AS user_id,
user_id.fullname AS user_fullname,
user_id.email AS user_email,
(IF team_id != NONE THEN string::join(':', team_id.tb, team_id.id) ELSE NONE END) AS team_id,
(IF team_id != NONE THEN team_id.name ELSE NONE END) AS team_name,
status,
role,
registration_date,
checked_in,
check_in_time,
experience_level,
skills
FROM hackathon_registrations
WHERE hackathon_id = $hackathon_id
AND is_deleted = false
FETCH hackathon_id, user_id, team_id;
"#
};
let hackathon_id_clone = hackathon_id.clone();
let mut result = if let Some(status_val) = status_filter {
db.query(query)
.bind(("hackathon_id", hackathon_id_clone))
.bind(("status", status_val))
.await
} else {
db.query(query)
.bind(("hackathon_id", hackathon_id_clone))
.await
}
.map_err(|e| format!("Failed to query hackathon registrations: {}", e))?;
// Use intermediate struct for parsing with all fields including related data
#[derive(Debug, Serialize, Deserialize)]
struct SimpleReg {
id: String,
hackathon_id: String,
hackathon_name: Option<String>,
user_id: String,
user_fullname: Option<String>,
user_email: Option<String>,
team_id: Option<String>,
team_name: Option<String>,
status: RegistrationStatus,
role: ParticipantRole,
registration_date: String,
checked_in: bool,
check_in_time: Option<String>,
experience_level: Option<String>,
skills: Option<Vec<String>>,
}
let simple: Vec<SimpleReg> = result
.take(0)
.map_err(|e| format!("Failed to parse registrations: {}", e))?;
// Convert to full DTO with all fetched data
let mut registrations: Vec<RegistrationListQueryDto> = simple
.into_iter()
.map(|r| RegistrationListQueryDto {
id: r.id,
hackathon_id: r.hackathon_id,
hackathon_name: r.hackathon_name,
user_id: r.user_id,
user_fullname: r.user_fullname,
user_email: r.user_email,
team_id: r.team_id,
team_name: r.team_name,
status: r.status,
role: r.role,
registration_date: r.registration_date.clone(),
checked_in: r.checked_in,
check_in_time: r.check_in_time,
experience_level: r.experience_level,
skills: r.skills,
})
.collect();
// Sort by registration_date DESC (newest first)
registrations.sort_by(|a, b| b.registration_date.cmp(&a.registration_date));
Ok(registrations)
}
// ============================================
// Get User's Hackathon Registrations
// ============================================
pub async fn query_user_hackathons(&self, user_id: &Thing) -> Result<Vec<UserHackathonQueryDto>, String> {
let db = &self.state.surrealdb_ws;
// Use FETCH to retrieve related hackathon and team data
let query = r#"
SELECT
string::join(':', id.tb, id.id) AS registration_id,
string::join(':', hackathon_id.tb, hackathon_id.id) AS hackathon_id,
hackathon_id.name AS hackathon_name,
hackathon_id.description AS hackathon_description,
hackathon_id.start_date AS start_date,
hackathon_id.end_date AS end_date,
status,
role,
registration_date,
checked_in,
(IF team_id != NONE THEN string::join(':', team_id.tb, team_id.id) ELSE NONE END) AS team_id,
(IF team_id != NONE THEN team_id.name ELSE NONE END) AS team_name
FROM hackathon_registrations
WHERE user_id = $user_id
AND is_deleted = false
FETCH hackathon_id, team_id;
"#;
let user_id_clone = user_id.clone();
let mut result = db
.query(query)
.bind(("user_id", user_id_clone))
.await
.map_err(|e| format!("Failed to query user hackathons: {}", e))?;
#[derive(Debug, Serialize, Deserialize)]
struct SimpleUserHackathon {
registration_id: String,
hackathon_id: String,
hackathon_name: Option<String>,
hackathon_description: Option<String>,
start_date: Option<String>,
end_date: Option<String>,
status: RegistrationStatus,
role: ParticipantRole,
registration_date: String,
checked_in: bool,
team_id: Option<String>,
team_name: Option<String>,
}
let simple: Vec<SimpleUserHackathon> = result
.take(0)
.map_err(|e| format!("Failed to parse user hackathons: {}", e))?;
// Convert to full DTO with all fetched data
let mut hackathons: Vec<UserHackathonQueryDto> = simple
.into_iter()
.map(|h| UserHackathonQueryDto {
registration_id: h.registration_id,
hackathon_id: h.hackathon_id,
hackathon_name: h.hackathon_name,
hackathon_description: h.hackathon_description,
start_date: h.start_date,
end_date: h.end_date,
status: h.status,
role: h.role,
registration_date: h.registration_date.clone(),
checked_in: h.checked_in,
team_id: h.team_id,
team_name: h.team_name,
})
.collect();
// Sort by registration_date DESC (newest first)
hackathons.sort_by(|a, b| b.registration_date.cmp(&a.registration_date));
Ok(hackathons)
}
// ============================================
// Get Registration Statistics
// ============================================
pub async fn query_registration_stats(&self, hackathon_id: &Thing) -> Result<RegistrationStatsQueryDto, String> {
let db = &self.state.surrealdb_ws;
// Get hackathon name first
let hackathon_query = r#"
SELECT name FROM hackathons WHERE id = $hackathon_id LIMIT 1
"#;
let hackathon_id_clone = hackathon_id.clone();
let mut hackathon_result = db
.query(hackathon_query)
.bind(("hackathon_id", hackathon_id_clone.clone()))
.await
.map_err(|e| format!("Failed to fetch hackathon name: {}", e))?;
#[derive(Debug, Serialize, Deserialize)]
struct HackathonName {
name: String,
}
let hackathon_names: Vec<HackathonName> = hackathon_result
.take(0)
.map_err(|e| format!("Failed to parse hackathon name: {}", e))?;
let hackathon_name = hackathon_names.first().map(|h| h.name.clone());
// Get all registrations
let query = r#"
SELECT * FROM hackathon_registrations
WHERE hackathon_id = $hackathon_id
AND is_deleted = false
"#;
let mut result = db
.query(query)
.bind(("hackathon_id", hackathon_id_clone))
.await
.map_err(|e| format!("Failed to query registrations for stats: {}", e))?;
#[derive(Debug, Serialize, Deserialize)]
struct RegForStats {
status: RegistrationStatus,
checked_in: bool,
team_id: Option<String>,
}
let regs: Vec<RegForStats> = result
.take(0)
.map_err(|e| format!("Failed to parse registrations for stats: {}", e))?;
// Calculate stats manually
let total = regs.len();
let pending = regs.iter().filter(|r| matches!(r.status, RegistrationStatus::Pending)).count();
let approved = regs.iter().filter(|r| matches!(r.status, RegistrationStatus::Approved)).count();
let rejected = regs.iter().filter(|r| matches!(r.status, RegistrationStatus::Rejected)).count();
let waitlisted = regs.iter().filter(|r| matches!(r.status, RegistrationStatus::Waitlisted)).count();
let cancelled = regs.iter().filter(|r| matches!(r.status, RegistrationStatus::Cancelled)).count();
let checked_in = regs.iter().filter(|r| r.checked_in).count();
let team_registrations = regs.iter().filter(|r| r.team_id.is_some()).count();
let individual_registrations = regs.iter().filter(|r| r.team_id.is_none()).count();
let hackathon_id_str = format!("{}", hackathon_id);
Ok(RegistrationStatsQueryDto {
hackathon_id: hackathon_id_str,
hackathon_name,
total_registrations: total,
pending,
approved,
rejected,
waitlisted,
cancelled,
checked_in,
team_registrations,
individual_registrations,
})
}
// ============================================
// Update Registration
// ============================================
pub async fn update_registration(&self, id: &Thing, registration: RegistrationSchema) -> Result<RegistrationSchema, String> {
let db = &self.state.surrealdb_ws;
let record_key = get_id(id).map_err(|e| e.to_string())?;
let updated: Option<RegistrationSchema> = db
.update(record_key)
.content(registration)
.await
.map_err(|e| format!("Failed to update registration: {}", e))?;
updated.ok_or_else(|| "Registration update returned None".to_string())
}
// ============================================
// Delete Registration (soft delete)
// ============================================
pub async fn delete_registration(&self, id: &Thing) -> Result<(), String> {
let db = &self.state.surrealdb_ws;
let record_key = get_id(id).map_err(|e| e.to_string())?;
let _: Option<RegistrationSchema> = db
.delete(record_key)
.await
.map_err(|e| format!("Failed to delete registration: {}", e))?;
Ok(())
}
}
// Helper DTO for stats query
#[derive(Debug, Serialize, Deserialize)]
pub struct RegistrationStatsQueryDto {
pub hackathon_id: String,
pub hackathon_name: Option<String>,
pub total_registrations: usize,
pub pending: usize,
pub approved: usize,
pub rejected: usize,
pub waitlisted: usize,
pub cancelled: usize,
pub checked_in: usize,
pub team_registrations: usize,
pub individual_registrations: usize,
}
@@ -1,141 +0,0 @@
use serde::{Deserialize, Serialize};
use surrealdb::sql::Thing;
use utoipa::ToSchema;
use imphnen_libs::ResourceEnum;
use imphnen_utils::{get_iso_date, make_thing, make_thing_from_enum};
use super::RegistrationRequestDto;
/// Registration status enum
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum RegistrationStatus {
Pending,
Approved,
Rejected,
Waitlisted,
Cancelled,
}
/// Participant role in hackathon
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum ParticipantRole {
Individual,
TeamLeader,
TeamMember,
}
/// Hackathon registration schema
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct RegistrationSchema {
pub id: Thing,
pub hackathon_id: Thing,
pub user_id: Thing,
pub team_id: Option<Thing>,
pub status: RegistrationStatus,
pub role: ParticipantRole,
pub registration_date: String,
pub approved_at: Option<String>,
pub rejected_at: Option<String>,
pub rejection_reason: Option<String>,
pub checked_in: bool,
pub check_in_time: Option<String>,
pub notes: Option<String>,
pub skills: Option<Vec<String>>,
pub experience_level: Option<String>, // beginner, intermediate, advanced
pub github_username: Option<String>,
pub portfolio_url: Option<String>,
pub motivation: Option<String>,
pub dietary_requirements: Option<String>,
pub tshirt_size: Option<String>, // XS, S, M, L, XL, XXL
pub emergency_contact_name: Option<String>,
pub emergency_contact_phone: Option<String>,
pub is_deleted: bool,
pub created_at: String,
pub updated_at: String,
}
impl RegistrationSchema {
/// Create a new registration from request DTO
pub fn from_request(
hackathon_id: &Thing,
user_id: &Thing,
data: RegistrationRequestDto,
) -> Result<Self, String> {
let now = get_iso_date();
// Convert team_id from String to Thing if provided
let team_id_thing = data.team_id
.as_ref()
.map(|id| make_thing_from_enum(ResourceEnum::Teams, id));
Ok(Self {
id: make_thing(ResourceEnum::HackathonRegistrations.as_str(), &uuid::Uuid::new_v4().to_string()),
hackathon_id: hackathon_id.clone(),
user_id: user_id.clone(),
team_id: team_id_thing,
status: RegistrationStatus::Pending,
role: data.role.unwrap_or(ParticipantRole::Individual),
registration_date: now.clone(),
approved_at: None,
rejected_at: None,
rejection_reason: None,
checked_in: false,
check_in_time: None,
notes: None,
skills: data.skills,
experience_level: data.experience_level,
github_username: data.github_username,
portfolio_url: data.portfolio_url,
motivation: data.motivation,
dietary_requirements: data.dietary_requirements,
tshirt_size: data.tshirt_size,
emergency_contact_name: data.emergency_contact_name,
emergency_contact_phone: data.emergency_contact_phone,
is_deleted: false,
created_at: now.clone(),
updated_at: now,
})
}
/// Update registration status
pub fn update_status(&mut self, status: RegistrationStatus, reason: Option<String>) {
let now = get_iso_date();
self.status = status.clone();
self.updated_at = now.clone();
match status {
RegistrationStatus::Approved => {
self.approved_at = Some(now);
self.rejected_at = None;
self.rejection_reason = None;
}
RegistrationStatus::Rejected => {
self.rejected_at = Some(now);
self.rejection_reason = reason;
self.approved_at = None;
}
_ => {}
}
}
/// Check-in participant
pub fn check_in(&mut self) -> Result<(), String> {
if self.status != RegistrationStatus::Approved {
return Err("Only approved registrations can be checked in".to_string());
}
if self.checked_in {
return Err("Already checked in".to_string());
}
let now = get_iso_date();
self.checked_in = true;
self.check_in_time = Some(now.clone());
self.updated_at = now;
Ok(())
}
}
@@ -1,308 +0,0 @@
use axum::response::Response;
use axum::http::StatusCode;
use imphnen_entities::ResponseSuccessDto;
use imphnen_libs::AppState;
use imphnen_utils::{
common_response, extract_id, make_thing_from_enum, success_response, validate_request,
};
use surrealdb::sql::Thing;
use super::{
CheckInResponseDto, RegistrationListItemDto, RegistrationListResponseDto,
RegistrationRequestDto, RegistrationResponseDto, RegistrationSchema,
RegistrationStatsDto, RegistrationStatus,
RegistrationsRepository, UpdateRegistrationStatusRequestDto,
UpdateRegistrationStatusResponseDto, UserHackathonDto, UserHackathonsResponseDto,
};
use crate::v1::hackathon::HackathonRepository;
use imphnen_libs::ResourceEnum;
pub struct RegistrationsService<'a> {
state: &'a AppState,
}
impl<'a> RegistrationsService<'a> {
pub fn new(state: &'a AppState) -> Self {
Self { state }
}
// ============================================
// Register for Hackathon
// ============================================
pub async fn register_hackathon(
&self,
hackathon_id: &Thing,
hackathon_id_str: &str,
user_email: &str,
data: RegistrationRequestDto,
) -> Response {
// Validate request
if let Err((status, message)) = validate_request(&data) {
return common_response(status, &message);
}
let repository = RegistrationsRepository::new(self.state);
// Get user ID from email
let user_id = make_thing_from_enum(ResourceEnum::Users, user_email);
// Check if hackathon exists
let hackathon_repo = HackathonRepository::new(self.state);
// Use the raw string ID from the path parameter
match hackathon_repo.get_hackathon_by_id(hackathon_id_str.to_string()).await {
Err(e) => {
// Method returns error if hackathon not found or is deleted
let error_msg = e.to_string();
if error_msg.contains("not found") || error_msg.contains("Hackathon not found") {
return common_response(StatusCode::NOT_FOUND, "Hackathon not found");
}
return common_response(StatusCode::INTERNAL_SERVER_ERROR, &format!("Failed to verify hackathon: {}", e));
}
Ok(_) => {} // Hackathon exists, continue
}
// Check if user already registered
match repository
.check_existing_registration(hackathon_id, &user_id)
.await
{
Ok(Some(_)) => {
return common_response(StatusCode::CONFLICT, "You have already registered for this hackathon")
}
Ok(None) => {}
Err(e) => return common_response(StatusCode::INTERNAL_SERVER_ERROR, &e),
}
// Create registration
let registration = match RegistrationSchema::from_request(hackathon_id, &user_id, data) {
Ok(reg) => reg,
Err(e) => return common_response(StatusCode::BAD_REQUEST, &e),
};
match repository.create_registration(registration).await {
Ok(created) => {
let response = RegistrationResponseDto {
id: extract_id(&created.id),
hackathon_id: extract_id(&created.hackathon_id),
user_id: extract_id(&created.user_id),
team_id: created.team_id.as_ref().map(|t| extract_id(t)),
status: created.status,
role: created.role,
registration_date: created.registration_date,
checked_in: created.checked_in,
message: "Registration submitted successfully. You will be notified once approved."
.to_string(),
};
success_response(ResponseSuccessDto { data: response })
}
Err(e) => common_response(StatusCode::INTERNAL_SERVER_ERROR, &e),
}
}
// ============================================
// List Registrations for Hackathon
// ============================================
pub async fn get_hackathon_registrations(
&self,
hackathon_id: &Thing,
status_filter: Option<String>,
) -> Response {
let repository = RegistrationsRepository::new(self.state);
// Parse status filter if provided
let status_enum = if let Some(status_str) = &status_filter {
match status_str.to_lowercase().as_str() {
"pending" => Some(RegistrationStatus::Pending),
"approved" => Some(RegistrationStatus::Approved),
"rejected" => Some(RegistrationStatus::Rejected),
"waitlisted" => Some(RegistrationStatus::Waitlisted),
"cancelled" => Some(RegistrationStatus::Cancelled),
_ => return common_response(StatusCode::BAD_REQUEST, "Invalid status filter"),
}
} else {
None
};
match repository
.query_hackathon_registrations(hackathon_id, status_enum)
.await
{
Ok(results) => {
let registrations = results
.into_iter()
.map(|r| RegistrationListItemDto {
id: r.id,
hackathon_id: r.hackathon_id,
hackathon_name: r.hackathon_name,
user_id: r.user_id,
user_fullname: r.user_fullname,
user_email: r.user_email,
team_id: r.team_id,
team_name: r.team_name,
status: r.status,
role: r.role,
registration_date: r.registration_date,
checked_in: r.checked_in,
check_in_time: r.check_in_time,
experience_level: r.experience_level,
skills: r.skills,
})
.collect::<Vec<_>>();
let total = registrations.len();
let response = RegistrationListResponseDto {
registrations,
total,
status_filter,
};
success_response(ResponseSuccessDto { data: response })
}
Err(e) => common_response(StatusCode::INTERNAL_SERVER_ERROR, &e),
}
}
// ============================================
// Get Current User's Hackathon Registrations
// ============================================
pub async fn get_my_hackathons(&self, user_email: &str) -> Response {
let repository = RegistrationsRepository::new(self.state);
// Get user ID from email
let user_id = make_thing_from_enum(ResourceEnum::Users, user_email);
match repository.query_user_hackathons(&user_id).await {
Ok(results) => {
let hackathons = results
.into_iter()
.map(|h| UserHackathonDto {
registration_id: h.registration_id,
hackathon_id: h.hackathon_id,
hackathon_name: h.hackathon_name,
hackathon_description: h.hackathon_description,
start_date: h.start_date,
end_date: h.end_date,
status: h.status,
role: h.role,
registration_date: h.registration_date,
checked_in: h.checked_in,
team_id: h.team_id,
team_name: h.team_name,
})
.collect::<Vec<_>>();
let total = hackathons.len();
let response = UserHackathonsResponseDto { hackathons, total };
success_response(ResponseSuccessDto { data: response })
}
Err(e) => common_response(StatusCode::INTERNAL_SERVER_ERROR, &e),
}
}
// ============================================
// Update Registration Status
// ============================================
pub async fn update_registration_status(
&self,
registration_id: &Thing,
data: UpdateRegistrationStatusRequestDto,
) -> Response {
// Validate request
if let Err((status, message)) = validate_request(&data) {
return common_response(status, &message);
}
let repository = RegistrationsRepository::new(self.state);
// Get existing registration
let mut registration = match repository.query_registration_by_id(registration_id).await {
Ok(Some(reg)) => reg,
Ok(None) => return common_response(StatusCode::NOT_FOUND, "Registration not found"),
Err(e) => return common_response(StatusCode::INTERNAL_SERVER_ERROR, &e),
};
// Update status
registration.update_status(data.status.clone(), data.reason);
// Save updated registration
match repository.update_registration(registration_id, registration.clone()).await {
Ok(updated) => {
let status_clone = updated.status.clone();
let response = UpdateRegistrationStatusResponseDto {
id: extract_id(&updated.id),
status: updated.status,
updated_at: updated.updated_at,
message: format!("Registration status updated to {:?}", status_clone),
};
success_response(ResponseSuccessDto { data: response })
}
Err(e) => common_response(StatusCode::INTERNAL_SERVER_ERROR, &e),
}
}
// ============================================
// Check-in Participant
// ============================================
pub async fn check_in_participant(&self, registration_id: &Thing) -> Response {
let repository = RegistrationsRepository::new(self.state);
// Get existing registration
let mut registration = match repository.query_registration_by_id(registration_id).await {
Ok(Some(reg)) => reg,
Ok(None) => return common_response(StatusCode::NOT_FOUND, "Registration not found"),
Err(e) => return common_response(StatusCode::INTERNAL_SERVER_ERROR, &e),
};
// Perform check-in
if let Err(e) = registration.check_in() {
return common_response(StatusCode::BAD_REQUEST, &e);
}
// Save updated registration
match repository.update_registration(registration_id, registration.clone()).await {
Ok(updated) => {
let response = CheckInResponseDto {
id: extract_id(&updated.id),
user_fullname: None, // Would need to query user info
checked_in: updated.checked_in,
check_in_time: updated.check_in_time.unwrap_or_default(),
message: "Participant checked in successfully".to_string(),
};
success_response(ResponseSuccessDto { data: response })
}
Err(e) => common_response(StatusCode::INTERNAL_SERVER_ERROR, &e),
}
}
// ============================================
// Get Registration Statistics
// ============================================
pub async fn get_registration_stats(&self, hackathon_id: &Thing) -> Response {
let repository = RegistrationsRepository::new(self.state);
match repository.query_registration_stats(hackathon_id).await {
Ok(stats) => {
let response = RegistrationStatsDto {
hackathon_id: stats.hackathon_id,
hackathon_name: stats.hackathon_name,
total_registrations: stats.total_registrations,
pending: stats.pending,
approved: stats.approved,
rejected: stats.rejected,
waitlisted: stats.waitlisted,
cancelled: stats.cancelled,
checked_in: stats.checked_in,
team_registrations: stats.team_registrations,
individual_registrations: stats.individual_registrations,
};
success_response(ResponseSuccessDto { data: response })
}
Err(e) => common_response(StatusCode::INTERNAL_SERVER_ERROR, &e),
}
}
}