vbytes-lan-attendence/api/src/models/tournament.rs

457 lines
12 KiB
Rust

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<String>,
pub start_at: Option<chrono::DateTime<chrono::Utc>>,
pub location: Option<String>,
pub description: Option<String>,
pub contact: Option<String>,
pub signup_mode: String,
pub team_size_min: i32,
pub team_size_max: i32,
pub created_at: chrono::DateTime<chrono::Utc>,
pub updated_at: chrono::DateTime<chrono::Utc>,
}
#[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<String>,
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<String>,
pub created_at: chrono::DateTime<chrono::Utc>,
}
#[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<String>,
#[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<TournamentSignupField>,
#[serde(default)]
pub participant_fields: Vec<TournamentSignupField>,
}
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<TournamentSignupField>) -> Vec<TournamentSignupField> {
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<TournamentSignupField>,
) -> Option<TournamentSignupField> {
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<TournamentSignupField>,
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::<String>();
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<String>,
pub start_at: Option<chrono::DateTime<chrono::Utc>>,
pub location: Option<String>,
pub description: Option<String>,
pub contact: Option<String>,
pub registration_url: String,
#[serde(default)]
pub sections: Vec<TournamentSection>,
#[serde(default)]
pub signup_config: TournamentSignupConfig,
#[serde(default)]
pub total_registrations: i32,
#[serde(default)]
pub total_participants: i32,
pub updated_at: chrono::DateTime<chrono::Utc>,
}
#[derive(Debug, Serialize, Clone)]
#[serde(crate = "rocket::serde")]
pub struct TournamentListResponse {
pub tournaments: Vec<TournamentInfoData>,
}
#[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<String>,
#[serde(default)]
pub start_at: Option<chrono::DateTime<chrono::Utc>>,
#[serde(default)]
pub location: Option<String>,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub registration_url: Option<String>,
#[serde(default)]
pub contact: Option<String>,
#[serde(default)]
pub sections: Vec<TournamentSection>,
#[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<String>,
#[serde(default)]
pub start_at: Option<chrono::DateTime<chrono::Utc>>,
#[serde(default)]
pub location: Option<String>,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub registration_url: Option<String>,
#[serde(default)]
pub contact: Option<String>,
#[serde(default)]
pub sections: Vec<TournamentSection>,
#[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<String, String>,
#[serde(default)]
pub participants: Vec<HashMap<String, String>>,
}
#[derive(Debug, Serialize, Clone)]
#[serde(crate = "rocket::serde")]
pub struct TournamentRegistrationItem {
pub id: i32,
pub created_at: chrono::DateTime<chrono::Utc>,
pub entry: Value,
pub participants: Value,
}
#[derive(Debug, Serialize, Clone)]
#[serde(crate = "rocket::serde")]
pub struct TournamentRegistrationListResponse {
pub tournament: TournamentInfoData,
pub registrations: Vec<TournamentRegistrationItem>,
}
#[derive(Debug, Serialize, Clone)]
#[serde(crate = "rocket::serde")]
pub struct TournamentRegistrationDetailResponse {
pub tournament: TournamentInfoData,
pub registration: TournamentRegistrationItem,
}