use rocket::serde::{Deserialize, Serialize}; use serde_json::Value; use sqlx::FromRow; use std::collections::HashMap; use std::collections::HashSet; pub const ATTENDANCE_ID_FIELD_ID: &str = "attendance-id"; pub const ATTENDANCE_ID_FIELD_LABEL: &str = "Deltagar-ID"; pub const ATTENDANCE_ID_FIELD_PLACEHOLDER: &str = "Ange ditt deltagar-ID från närvarolistan"; #[derive(Debug, FromRow, Clone)] pub struct TournamentInfo { pub id: i32, pub title: String, pub game: String, pub slug: String, pub tagline: Option, pub start_at: Option>, pub location: Option, pub description: Option, pub contact: Option, pub signup_mode: String, pub team_size_min: i32, pub team_size_max: i32, pub created_at: chrono::DateTime, pub updated_at: chrono::DateTime, } #[derive(Debug, FromRow, Clone)] pub struct TournamentSectionRecord { pub id: i32, pub tournament_id: i32, pub position: i32, pub title: String, pub body: String, } #[derive(Debug, FromRow, Clone)] pub struct TournamentSignupFieldRecord { pub id: i32, pub tournament_id: i32, pub field_key: String, pub scope: String, pub label: String, pub field_type: String, pub required: bool, pub placeholder: Option, pub position: i32, #[sqlx(rename = "unique_field")] pub unique: bool, } #[derive(Debug, FromRow, Clone)] pub struct TournamentRegistrationRow { pub id: i32, pub tournament_id: i32, pub entry_label: Option, pub created_at: chrono::DateTime, } #[derive(Debug, FromRow, Clone)] pub struct TournamentRegistrationValueRow { pub registration_id: i32, pub signup_field_id: i32, pub value: String, } #[derive(Debug, FromRow, Clone)] pub struct TournamentParticipantRow { pub id: i32, pub registration_id: i32, pub position: i32, } #[derive(Debug, FromRow, Clone)] pub struct TournamentParticipantValueRow { pub participant_id: i32, pub signup_field_id: i32, pub value: String, } #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] #[serde(rename_all = "snake_case", crate = "rocket::serde")] pub enum TournamentFieldType { Text, Email, Tel, Discord, } impl Default for TournamentFieldType { fn default() -> Self { TournamentFieldType::Text } } #[derive(Debug, Serialize, Deserialize, Clone)] #[serde(crate = "rocket::serde")] pub struct TournamentSignupField { pub id: String, pub label: String, #[serde(default)] pub field_type: TournamentFieldType, #[serde(default)] pub required: bool, #[serde(default)] pub placeholder: Option, #[serde(default)] pub unique: bool, } #[derive(Debug, Serialize, Deserialize, Clone)] #[serde(crate = "rocket::serde")] pub struct TournamentTeamSize { pub min: i32, pub max: i32, } impl Default for TournamentTeamSize { fn default() -> Self { TournamentTeamSize { min: 1, max: 1 } } } #[derive(Debug, Serialize, Deserialize, Clone)] #[serde(crate = "rocket::serde")] pub struct TournamentSignupConfig { #[serde(default = "TournamentSignupConfig::default_mode")] pub mode: String, #[serde(default = "TournamentSignupConfig::default_team_size")] pub team_size: TournamentTeamSize, #[serde(default)] pub entry_fields: Vec, #[serde(default)] pub participant_fields: Vec, } impl Default for TournamentSignupConfig { fn default() -> Self { TournamentSignupConfig { mode: Self::default_mode(), team_size: Self::default_team_size(), entry_fields: Vec::new(), participant_fields: Vec::new(), } } } impl TournamentSignupConfig { fn default_mode() -> String { "solo".to_string() } fn default_team_size() -> TournamentTeamSize { TournamentTeamSize::default() } pub fn normalized(mut self) -> Self { self.mode = match self.mode.as_str() { "team" => "team".to_string(), _ => "solo".to_string(), }; if self.mode == "solo" { self.team_size.min = 1; self.team_size.max = 1; } else { if self.team_size.min < 1 { self.team_size.min = 1; } if self.team_size.max < self.team_size.min { self.team_size.max = self.team_size.min; } if self.team_size.max > 64 { self.team_size.max = 64; } } self.entry_fields = normalize_signup_fields(self.entry_fields); self.participant_fields = normalize_signup_fields(self.participant_fields); ensure_attendance_field_for_mode(&mut self); self } } fn normalize_signup_fields(mut fields: Vec) -> Vec { let mut seen = HashSet::new(); for field in fields.iter_mut() { let base = if field.id.trim().is_empty() { normalize_field_id(&field.label) } else { normalize_field_id(&field.id) }; let mut candidate = base.clone(); let mut counter = 1; while seen.contains(&candidate) { counter += 1; candidate = format!("{base}-{counter}"); } seen.insert(candidate.clone()); field.id = candidate; field.label = field.label.trim().to_string(); if field.label.is_empty() { field.label = "Fält".to_string(); } field.placeholder = field .placeholder .as_ref() .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()); } fields } fn remove_attendance_id_field( fields: &mut Vec, ) -> Option { let mut attendance_index = None; for (index, field) in fields.iter().enumerate() { if field.id == ATTENDANCE_ID_FIELD_ID { attendance_index = Some(index); break; } } attendance_index.map(|index| { let mut field = fields.remove(index); sanitize_attendance_id_field(&mut field); field }) } fn insert_attendance_field_front( fields: &mut Vec, mut field: TournamentSignupField, ) { sanitize_attendance_id_field(&mut field); fields.insert(0, field); } fn ensure_attendance_field_for_mode(config: &mut TournamentSignupConfig) { let mut attendance = remove_attendance_id_field(&mut config.entry_fields) .or_else(|| remove_attendance_id_field(&mut config.participant_fields)) .unwrap_or_else(default_attendance_id_field); if config.mode == "team" { config .entry_fields .retain(|field| field.id != ATTENDANCE_ID_FIELD_ID); config .participant_fields .retain(|field| field.id != ATTENDANCE_ID_FIELD_ID); insert_attendance_field_front(&mut config.participant_fields, attendance); } else { config .entry_fields .retain(|field| field.id != ATTENDANCE_ID_FIELD_ID); config .participant_fields .retain(|field| field.id != ATTENDANCE_ID_FIELD_ID); insert_attendance_field_front(&mut config.entry_fields, attendance); } } fn sanitize_attendance_id_field(field: &mut TournamentSignupField) { field.id = ATTENDANCE_ID_FIELD_ID.to_string(); field.label = sanitize_attendance_label(&field.label); field.field_type = TournamentFieldType::Text; field.required = true; field.unique = true; field.placeholder = Some( field .placeholder .as_ref() .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()) .unwrap_or_else(|| ATTENDANCE_ID_FIELD_PLACEHOLDER.to_string()), ); } fn sanitize_attendance_label(label: &str) -> String { let trimmed = label.trim(); if trimmed.is_empty() { ATTENDANCE_ID_FIELD_LABEL.to_string() } else { trimmed.to_string() } } fn default_attendance_id_field() -> TournamentSignupField { TournamentSignupField { id: ATTENDANCE_ID_FIELD_ID.to_string(), label: ATTENDANCE_ID_FIELD_LABEL.to_string(), field_type: TournamentFieldType::Text, required: true, placeholder: Some(ATTENDANCE_ID_FIELD_PLACEHOLDER.to_string()), unique: true, } } fn normalize_field_id(input: &str) -> String { let mut slug = input .trim() .to_lowercase() .chars() .map(|ch| match ch { 'a'..='z' | '0'..='9' => ch, _ => '-', }) .collect::(); while slug.contains("--") { slug = slug.replace("--", "-"); } slug = slug.trim_matches('-').to_string(); if slug.is_empty() { "field".to_string() } else { slug } } #[derive(Debug, Serialize, Deserialize, Clone)] #[serde(crate = "rocket::serde")] pub struct TournamentSection { pub title: String, pub body: String, } #[derive(Debug, Serialize, Clone)] #[serde(crate = "rocket::serde")] pub struct TournamentInfoData { pub id: i32, pub title: String, pub game: String, pub slug: String, pub tagline: Option, pub start_at: Option>, pub location: Option, pub description: Option, pub contact: Option, pub registration_url: String, #[serde(default)] pub sections: Vec, #[serde(default)] pub signup_config: TournamentSignupConfig, #[serde(default)] pub total_registrations: i32, #[serde(default)] pub total_participants: i32, pub updated_at: chrono::DateTime, } #[derive(Debug, Serialize, Clone)] #[serde(crate = "rocket::serde")] pub struct TournamentListResponse { pub tournaments: Vec, } #[derive(Debug, Serialize, Clone)] #[serde(crate = "rocket::serde")] pub struct TournamentItemResponse { pub tournament: TournamentInfoData, } #[derive(Debug, Deserialize)] #[serde(crate = "rocket::serde")] pub struct CreateTournamentRequest { pub title: String, pub game: String, pub slug: String, #[serde(default)] pub tagline: Option, #[serde(default)] pub start_at: Option>, #[serde(default)] pub location: Option, #[serde(default)] pub description: Option, #[serde(default)] pub registration_url: Option, #[serde(default)] pub contact: Option, #[serde(default)] pub sections: Vec, #[serde(default)] pub signup_config: TournamentSignupConfig, } #[derive(Debug, Deserialize)] #[serde(crate = "rocket::serde")] pub struct UpdateTournamentRequest { pub title: String, pub game: String, pub slug: String, #[serde(default)] pub tagline: Option, #[serde(default)] pub start_at: Option>, #[serde(default)] pub location: Option, #[serde(default)] pub description: Option, #[serde(default)] pub registration_url: Option, #[serde(default)] pub contact: Option, #[serde(default)] pub sections: Vec, #[serde(default)] pub signup_config: TournamentSignupConfig, } #[derive(Debug, Serialize, Clone)] #[serde(crate = "rocket::serde")] pub struct TournamentRegistrationResponse { pub registration_id: i32, } #[derive(Debug, Deserialize, Clone)] #[serde(crate = "rocket::serde")] pub struct TournamentSignupSubmission { #[serde(default)] pub entry: HashMap, #[serde(default)] pub participants: Vec>, } #[derive(Debug, Serialize, Clone)] #[serde(crate = "rocket::serde")] pub struct TournamentRegistrationItem { pub id: i32, pub created_at: chrono::DateTime, pub entry: Value, pub participants: Value, } #[derive(Debug, Serialize, Clone)] #[serde(crate = "rocket::serde")] pub struct TournamentRegistrationListResponse { pub tournament: TournamentInfoData, pub registrations: Vec, } #[derive(Debug, Serialize, Clone)] #[serde(crate = "rocket::serde")] pub struct TournamentRegistrationDetailResponse { pub tournament: TournamentInfoData, pub registration: TournamentRegistrationItem, }