diff --git a/api/migrations/20250101002000_create_tournament_info.sql b/api/migrations/20250101002000_create_tournament_info.sql new file mode 100644 index 0000000..691f8dc --- /dev/null +++ b/api/migrations/20250101002000_create_tournament_info.sql @@ -0,0 +1,18 @@ +CREATE TABLE IF NOT EXISTS tournament_info ( + id SERIAL PRIMARY KEY, + title TEXT NOT NULL, + game TEXT NOT NULL, + slug TEXT NOT NULL, + tagline TEXT, + start_at TIMESTAMPTZ, + location TEXT, + description TEXT, + contact TEXT, + signup_mode TEXT NOT NULL DEFAULT 'solo', + team_size_min INTEGER NOT NULL DEFAULT 1, + team_size_max INTEGER NOT NULL DEFAULT 1, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE UNIQUE INDEX IF NOT EXISTS tournament_info_slug_idx ON tournament_info(slug); diff --git a/api/migrations/20250101003000_enable_multi_tournaments.sql b/api/migrations/20250101003000_enable_multi_tournaments.sql new file mode 100644 index 0000000..e4c6079 --- /dev/null +++ b/api/migrations/20250101003000_enable_multi_tournaments.sql @@ -0,0 +1,10 @@ +CREATE TABLE IF NOT EXISTS tournament_sections ( + id SERIAL PRIMARY KEY, + tournament_id INTEGER NOT NULL REFERENCES tournament_info(id) ON DELETE CASCADE, + position INTEGER NOT NULL DEFAULT 0, + title TEXT NOT NULL, + body TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_tournament_sections_tournament + ON tournament_sections (tournament_id, position, id); diff --git a/api/migrations/20250102004000_add_tournament_fields.sql b/api/migrations/20250102004000_add_tournament_fields.sql new file mode 100644 index 0000000..bf9321d --- /dev/null +++ b/api/migrations/20250102004000_add_tournament_fields.sql @@ -0,0 +1,15 @@ +CREATE TABLE IF NOT EXISTS tournament_signup_fields ( + id SERIAL PRIMARY KEY, + tournament_id INTEGER NOT NULL REFERENCES tournament_info(id) ON DELETE CASCADE, + field_key TEXT NOT NULL, + scope TEXT NOT NULL CHECK (scope IN ('entry', 'participant')), + label TEXT NOT NULL, + field_type TEXT NOT NULL, + required BOOLEAN NOT NULL DEFAULT FALSE, + placeholder TEXT, + position INTEGER NOT NULL DEFAULT 0, + UNIQUE (tournament_id, field_key) +); + +CREATE INDEX IF NOT EXISTS idx_tournament_signup_fields_tournament + ON tournament_signup_fields (tournament_id, scope, position, id); diff --git a/api/migrations/20250102005000_add_tournament_signup.sql b/api/migrations/20250102005000_add_tournament_signup.sql new file mode 100644 index 0000000..89f4003 --- /dev/null +++ b/api/migrations/20250102005000_add_tournament_signup.sql @@ -0,0 +1,32 @@ +CREATE TABLE IF NOT EXISTS tournament_registrations ( + id SERIAL PRIMARY KEY, + tournament_id INTEGER NOT NULL REFERENCES tournament_info(id) ON DELETE CASCADE, + entry_label TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS tournament_registration_values ( + registration_id INTEGER NOT NULL REFERENCES tournament_registrations(id) ON DELETE CASCADE, + signup_field_id INTEGER NOT NULL REFERENCES tournament_signup_fields(id) ON DELETE CASCADE, + value TEXT NOT NULL, + PRIMARY KEY (registration_id, signup_field_id) +); + +CREATE TABLE IF NOT EXISTS tournament_participants ( + id SERIAL PRIMARY KEY, + registration_id INTEGER NOT NULL REFERENCES tournament_registrations(id) ON DELETE CASCADE, + position INTEGER NOT NULL DEFAULT 0 +); + +CREATE TABLE IF NOT EXISTS tournament_participant_values ( + participant_id INTEGER NOT NULL REFERENCES tournament_participants(id) ON DELETE CASCADE, + signup_field_id INTEGER NOT NULL REFERENCES tournament_signup_fields(id) ON DELETE CASCADE, + value TEXT NOT NULL, + PRIMARY KEY (participant_id, signup_field_id) +); + +CREATE INDEX IF NOT EXISTS idx_tournament_registrations_tournament + ON tournament_registrations (tournament_id, created_at DESC, id DESC); + +CREATE INDEX IF NOT EXISTS idx_tournament_participants_registration + ON tournament_participants (registration_id, position, id); diff --git a/api/migrations/20250102006000_add_unique_flag_to_signup_fields.sql b/api/migrations/20250102006000_add_unique_flag_to_signup_fields.sql new file mode 100644 index 0000000..381c0f4 --- /dev/null +++ b/api/migrations/20250102006000_add_unique_flag_to_signup_fields.sql @@ -0,0 +1,2 @@ +ALTER TABLE tournament_signup_fields + ADD COLUMN unique_field BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/api/src/main.rs b/api/src/main.rs index a2f19cb..0aeb9b3 100644 --- a/api/src/main.rs +++ b/api/src/main.rs @@ -2,23 +2,21 @@ mod auth; mod config; mod error; mod models; +mod routes; mod seed; use auth::{generate_token, AuthUser}; use config::AppConfig; use error::ApiError; -use models::{ - LoginRequest, LoginResponse, NewPersonRequest, Person, PersonActionResponse, PersonResponse, - PersonsResponse, UpdatePersonRequest, User, -}; +use models::{AppEvent, LoginRequest, LoginResponse, User}; use rocket::http::{Cookie, CookieJar, SameSite, Status}; use rocket::response::stream::{Event, EventStream}; use rocket::serde::json::Json; use rocket::time::Duration; use rocket::tokio::sync::broadcast::{self, error::RecvError}; -use rocket::{get, post, put, routes, State}; +use rocket::{get, post, routes, State}; use sqlx::postgres::PgPoolOptions; -use sqlx::{PgPool, QueryBuilder}; +use sqlx::PgPool; pub struct AppState { pub db: PgPool, @@ -28,7 +26,7 @@ pub struct AppState { pub cookie_name: String, pub cookie_secure: bool, pub cookie_same_site: SameSite, - pub event_sender: broadcast::Sender, + pub event_sender: broadcast::Sender, } #[rocket::main] @@ -67,19 +65,8 @@ async fn main() -> Result<(), rocket::Error> { let rocket = rocket::build() .manage(state) .mount("/", routes![healthz, login, logout, events]) - .mount( - "/persons", - routes![ - search_persons, - list_checked_in, - checkin_person, - checkout_person, - mark_inside, - mark_outside, - create_person, - update_person - ], - ); + .mount("/persons", routes::persons::routes()) + .mount("/tournament", routes::tournaments::routes()); rocket.launch().await?; Ok(()) @@ -170,674 +157,3 @@ fn events(_user: AuthUser, state: &State) -> EventStream![Event + '_] } } } - -#[get("/search?")] -async fn search_persons( - _user: AuthUser, - state: &State, - q: &str, -) -> Result, ApiError> { - let query = q.trim(); - if query.is_empty() { - return Err(ApiError::bad_request("Söktext krävs.")); - } - - let like_pattern = format!("%{}%", query); - let id_value = query.parse::().ok(); - - let persons = if let Some(id) = id_value { - sqlx::query_as::<_, Person>( - r#" - SELECT - id, - first_name, - last_name, - grade, - parent_name, - parent_phone_number, - checked_in, - inside, - visitor, - sleeping_spot - FROM persons - WHERE first_name ILIKE $1 - OR last_name ILIKE $1 - OR parent_name ILIKE $1 - OR parent_phone_number ILIKE $1 - OR (first_name || ' ' || last_name) ILIKE $1 - OR id = $2 - ORDER BY last_name, first_name - "#, - ) - .bind(&like_pattern) - .bind(id) - .fetch_all(&state.db) - .await? - } else { - sqlx::query_as::<_, Person>( - r#" - SELECT - id, - first_name, - last_name, - grade, - parent_name, - parent_phone_number, - checked_in, - inside, - visitor, - sleeping_spot - FROM persons - WHERE first_name ILIKE $1 - OR last_name ILIKE $1 - OR parent_name ILIKE $1 - OR parent_phone_number ILIKE $1 - OR (first_name || ' ' || last_name) ILIKE $1 - ORDER BY last_name, first_name - "#, - ) - .bind(&like_pattern) - .fetch_all(&state.db) - .await? - }; - - let persons = persons.into_iter().map(PersonResponse::from).collect(); - Ok(Json(PersonsResponse { persons })) -} - -#[get("/checked-in?&&")] -async fn list_checked_in( - _user: AuthUser, - state: &State, - q: Option<&str>, - status: Option<&str>, - checked: Option<&str>, -) -> Result, ApiError> { - let inside_filter = match status.map(|s| s.trim().to_lowercase()) { - Some(value) if value.is_empty() || value == "all" => None, - Some(value) if value == "inside" => Some(true), - Some(value) if value == "outside" => Some(false), - Some(_) => return Err(ApiError::bad_request("Ogiltigt statusvärde.")), - None => None, - }; - - let checked_filter = match checked.map(|s| s.trim().to_lowercase()) { - Some(value) if value.is_empty() || value == "all" => None, - Some(value) if value == "true" || value == "checked" || value == "in" => Some(true), - Some(value) if value == "false" || value == "unchecked" || value == "out" => Some(false), - Some(_) => return Err(ApiError::bad_request("Ogiltigt filter för incheckning.")), - None => None, - }; - - let search_term = q - .map(|raw| raw.trim()) - .filter(|trimmed| !trimmed.is_empty()) - .map(|trimmed| trimmed.to_string()); - let like_pattern = search_term.as_ref().map(|s| format!("%{}%", s)); - let id_value = search_term.as_ref().and_then(|s| s.parse::().ok()); - - let mut qb = QueryBuilder::::new( - "SELECT id, first_name, last_name, grade, parent_name, parent_phone_number, checked_in, inside, visitor, sleeping_spot FROM persons", - ); - - let mut first_condition = true; - let mut append_condition = |qb: &mut QueryBuilder| { - if first_condition { - qb.push(" WHERE "); - first_condition = false; - } else { - qb.push(" AND "); - } - }; - - if let Some(checked) = checked_filter { - append_condition(&mut qb); - qb.push("checked_in = ").push_bind(checked); - } - - if let Some(inside) = inside_filter { - append_condition(&mut qb); - qb.push("inside = ").push_bind(inside); - } - - if let Some(like) = like_pattern.as_ref() { - append_condition(&mut qb); - qb.push("("); - qb.push("first_name ILIKE ").push_bind(like); - qb.push(" OR last_name ILIKE ").push_bind(like); - qb.push(" OR parent_name ILIKE ").push_bind(like); - qb.push(" OR parent_phone_number ILIKE ").push_bind(like); - qb.push(" OR (first_name || ' ' || last_name) ILIKE ") - .push_bind(like); - if let Some(id) = id_value { - qb.push(" OR id = ").push_bind(id); - } - qb.push(")"); - } - - if let Some(id) = id_value { - qb.push(" ORDER BY CASE WHEN id = ") - .push_bind(id) - .push(" THEN 0 ELSE 1 END, id, last_name, first_name"); - } else { - qb.push(" ORDER BY id, last_name, first_name"); - } - - let persons = qb.build_query_as::().fetch_all(&state.db).await?; - - let persons = persons.into_iter().map(PersonResponse::from).collect(); - Ok(Json(PersonsResponse { persons })) -} - -#[post("//checkin")] -async fn checkin_person( - _user: AuthUser, - state: &State, - id: i32, -) -> Result, ApiError> { - update_checked_in(state, id, true).await -} - -#[post("//checkout")] -async fn checkout_person( - _user: AuthUser, - state: &State, - id: i32, -) -> Result, ApiError> { - update_checked_in(state, id, false).await -} - -#[post("//inside")] -async fn mark_inside( - _user: AuthUser, - state: &State, - id: i32, -) -> Result, ApiError> { - update_inside(state, id, true).await -} - -#[post("//outside")] -async fn mark_outside( - _user: AuthUser, - state: &State, - id: i32, -) -> Result, ApiError> { - update_inside(state, id, false).await -} - -#[post("/", data = "")] -async fn create_person( - _user: AuthUser, - state: &State, - payload: Json, -) -> Result, ApiError> { - let NewPersonRequest { - first_name, - last_name, - grade, - parent_name, - parent_phone_number, - id, - checked_in, - inside, - visitor, - sleeping_spot, - } = payload.into_inner(); - - let first_name = first_name.trim().to_string(); - if first_name.is_empty() { - return Err(ApiError::bad_request("Förnamn får inte vara tomt.")); - } - - let last_name = last_name.trim().to_string(); - if last_name.is_empty() { - return Err(ApiError::bad_request("Efternamn får inte vara tomt.")); - } - - if grade.map(|value| value < 0).unwrap_or(false) { - return Err(ApiError::bad_request("Klass måste vara noll eller högre.")); - } - - let parent_name = normalize_optional_string(&parent_name); - let parent_phone_number = normalize_optional_string(&parent_phone_number); - - let checked_in = checked_in.unwrap_or(false); - let inside = inside.unwrap_or(false); - let visitor = visitor.unwrap_or(false); - let sleeping_spot = sleeping_spot.unwrap_or(false); - - if (checked_in || inside) && !fields_are_complete(grade, &parent_name, &parent_phone_number) { - return Err(ApiError::bad_request( - "Kontaktperson, telefon och klass krävs innan incheckning.", - )); - } - - let person = match id { - Some(id) => sqlx::query_as::<_, Person>( - r#" - INSERT INTO persons ( - id, - first_name, - last_name, - grade, - parent_name, - parent_phone_number, - checked_in, - inside, - visitor, - sleeping_spot - ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) - RETURNING - id, - first_name, - last_name, - grade, - parent_name, - parent_phone_number, - checked_in, - inside, - visitor, - sleeping_spot - "#, - ) - .bind(id) - .bind(&first_name) - .bind(&last_name) - .bind(grade) - .bind(parent_name.as_deref()) - .bind(parent_phone_number.as_deref()) - .bind(checked_in) - .bind(inside) - .bind(visitor) - .bind(sleeping_spot) - .fetch_one(&state.db) - .await - .map_err(|err| map_db_error(err, "Kunde inte skapa person"))?, - None => sqlx::query_as::<_, Person>( - r#" - INSERT INTO persons ( - first_name, - last_name, - grade, - parent_name, - parent_phone_number, - checked_in, - inside, - visitor, - sleeping_spot - ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) - RETURNING - id, - first_name, - last_name, - grade, - parent_name, - parent_phone_number, - checked_in, - inside, - visitor, - sleeping_spot - "#, - ) - .bind(&first_name) - .bind(&last_name) - .bind(grade) - .bind(parent_name.as_deref()) - .bind(parent_phone_number.as_deref()) - .bind(checked_in) - .bind(inside) - .bind(visitor) - .bind(sleeping_spot) - .fetch_one(&state.db) - .await - .map_err(|err| map_db_error(err, "Kunde inte skapa person"))?, - }; - - let response = PersonActionResponse { - person: person.into(), - }; - broadcast_person_update(state, &response); - Ok(Json(response)) -} - -#[put("/", data = "")] -async fn update_person( - _user: AuthUser, - state: &State, - id: i32, - payload: Json, -) -> Result, ApiError> { - let UpdatePersonRequest { - first_name, - last_name, - grade, - parent_name, - parent_phone_number, - checked_in, - inside, - visitor, - sleeping_spot, - } = payload.into_inner(); - - let first_name = first_name.trim().to_string(); - if first_name.is_empty() { - return Err(ApiError::bad_request("Förnamn får inte vara tomt.")); - } - - let last_name = last_name.trim().to_string(); - if last_name.is_empty() { - return Err(ApiError::bad_request("Efternamn får inte vara tomt.")); - } - - if grade.map(|value| value < 0).unwrap_or(false) { - return Err(ApiError::bad_request("Klass måste vara noll eller högre.")); - } - - let parent_name_present = parent_name.is_some(); - let parent_phone_present = parent_phone_number.is_some(); - let grade_present = grade.is_some(); - - let parent_name = normalize_optional_string(&parent_name); - let parent_phone_number = normalize_optional_string(&parent_phone_number); - - let (final_checked_in, final_inside) = match (checked_in, inside) { - (Some(false), _) => (Some(false), Some(false)), - (Some(true), Some(value)) => (Some(true), Some(value)), - (Some(true), None) => (Some(true), Some(true)), - (None, Some(value)) => (None, Some(value)), - (None, None) => (None, None), - }; - - let existing = sqlx::query_as::<_, Person>( - r#" - SELECT - id, - first_name, - last_name, - grade, - parent_name, - parent_phone_number, - checked_in, - inside, - visitor, - sleeping_spot - FROM persons - WHERE id = $1 - "#, - ) - .bind(id) - .fetch_optional(&state.db) - .await?; - - let existing = match existing { - Some(person) => person, - None => return Err(ApiError::not_found("Personen hittades inte.")), - }; - - let parent_name = if parent_name_present { - parent_name - } else { - existing.parent_name.clone() - }; - - let parent_phone_number = if parent_phone_present { - parent_phone_number - } else { - existing.parent_phone_number.clone() - }; - - let grade = if grade_present { grade } else { existing.grade }; - - let desired_checked_in = final_checked_in.unwrap_or(existing.checked_in); - let desired_inside = final_inside.unwrap_or(existing.inside); - - if (desired_checked_in || desired_inside) - && !fields_are_complete(grade, &parent_name, &parent_phone_number) - { - return Err(ApiError::bad_request( - "Kontaktperson, telefon och klass krävs innan incheckning.", - )); - } - - let person = sqlx::query_as::<_, Person>( - r#" - UPDATE persons - SET first_name = $2, - last_name = $3, - grade = $4, - parent_name = $5, - parent_phone_number = $6, - checked_in = COALESCE($7, checked_in), - inside = COALESCE($8, inside), - visitor = $9, - sleeping_spot = $10 - WHERE id = $1 - RETURNING - id, - first_name, - last_name, - grade, - parent_name, - parent_phone_number, - checked_in, - inside, - visitor, - sleeping_spot - "#, - ) - .bind(id) - .bind(&first_name) - .bind(&last_name) - .bind(grade) - .bind(parent_name.as_deref()) - .bind(parent_phone_number.as_deref()) - .bind(final_checked_in) - .bind(final_inside) - .bind(visitor) - .bind(sleeping_spot) - .fetch_optional(&state.db) - .await?; - - match person { - Some(person) => { - let response = PersonActionResponse { - person: person.into(), - }; - broadcast_person_update(state, &response); - Ok(Json(response)) - } - None => Err(ApiError::not_found("Personen hittades inte.")), - } -} - -async fn update_checked_in( - state: &State, - id: i32, - value: bool, -) -> Result, ApiError> { - let existing = sqlx::query_as::<_, Person>( - r#" - SELECT - id, - first_name, - last_name, - grade, - parent_name, - parent_phone_number, - checked_in, - inside, - visitor, - sleeping_spot - FROM persons - WHERE id = $1 - "#, - ) - .bind(id) - .fetch_optional(&state.db) - .await?; - - let existing = match existing { - Some(person) => person, - None => return Err(ApiError::not_found("Personen hittades inte.")), - }; - - if value && !person_is_complete(&existing) { - return Err(ApiError::bad_request( - "Kontaktperson, telefon och klass krävs innan incheckning.", - )); - } - - let person = sqlx::query_as::<_, Person>( - r#" - UPDATE persons - SET checked_in = $2, - inside = $2 - WHERE id = $1 - RETURNING - id, - first_name, - last_name, - grade, - parent_name, - parent_phone_number, - checked_in, - inside, - visitor, - sleeping_spot - "#, - ) - .bind(id) - .bind(value) - .fetch_optional(&state.db) - .await?; - - match person { - Some(person) => { - let response = PersonActionResponse { - person: person.into(), - }; - broadcast_person_update(state, &response); - Ok(Json(response)) - } - None => Err(ApiError::not_found("Personen hittades inte.")), - } -} - -async fn update_inside( - state: &State, - id: i32, - value: bool, -) -> Result, ApiError> { - let person = sqlx::query_as::<_, Person>( - r#" - UPDATE persons - SET inside = $2 - WHERE id = $1 - AND checked_in = TRUE - RETURNING - id, - first_name, - last_name, - grade, - parent_name, - parent_phone_number, - checked_in, - inside, - visitor, - sleeping_spot - "#, - ) - .bind(id) - .bind(value) - .fetch_optional(&state.db) - .await?; - - match person { - Some(person) => { - let response = PersonActionResponse { - person: person.into(), - }; - broadcast_person_update(state, &response); - Ok(Json(response)) - } - None => { - let checked = - sqlx::query_scalar::<_, bool>("SELECT checked_in FROM persons WHERE id = $1") - .bind(id) - .fetch_optional(&state.db) - .await?; - - match checked { - Some(_) => Err(ApiError::bad_request( - "Personen måste vara incheckad för att uppdatera inne/ute.", - )), - None => Err(ApiError::not_found("Personen hittades inte.")), - } - } - } -} - -fn map_db_error(err: sqlx::Error, context: &str) -> ApiError { - if let sqlx::Error::Database(db_err) = &err { - if let Some(code) = db_err.code() { - if code == "23505" { - return ApiError::bad_request( - "Krock i databasen – kontrollera id eller kontaktuppgifter.", - ); - } - } - eprintln!("Database error ({}): {:?}", context, db_err); - return ApiError::internal("Databasfel."); - } - - eprintln!("Database error ({}): {:?}", context, err); - ApiError::internal("Databasfel.") -} - -fn broadcast_person_update(state: &State, response: &PersonActionResponse) { - let _ = state.event_sender.send(response.clone()); -} - -fn normalize_optional_string(value: &Option) -> Option { - value.as_ref().and_then(|text| { - let trimmed = text.trim(); - if trimmed.is_empty() { - None - } else { - Some(trimmed.to_string()) - } - }) -} - -fn fields_are_complete( - grade: Option, - parent_name: &Option, - parent_phone_number: &Option, -) -> bool { - grade.is_some() - && parent_name - .as_ref() - .map(|value| !value.trim().is_empty()) - .unwrap_or(false) - && phone_is_valid(parent_phone_number) -} - -fn person_is_complete(person: &Person) -> bool { - fields_are_complete( - person.grade, - &person.parent_name, - &person.parent_phone_number, - ) -} - -fn phone_is_valid(phone: &Option) -> bool { - phone - .as_ref() - .map(|value| { - let digits: String = value.chars().filter(|c| c.is_ascii_digit()).collect(); - digits.len() == 10 - }) - .unwrap_or(false) -} diff --git a/api/src/models/event.rs b/api/src/models/event.rs new file mode 100644 index 0000000..6e4ddd8 --- /dev/null +++ b/api/src/models/event.rs @@ -0,0 +1,14 @@ +use rocket::serde::Serialize; + +use super::{person::PersonResponse, tournament::TournamentInfoData}; + +#[derive(Debug, Serialize, Clone)] +#[serde(tag = "type", crate = "rocket::serde")] +pub enum AppEvent { + #[serde(rename = "person_updated")] + PersonUpdated { person: PersonResponse }, + #[serde(rename = "tournament_upserted")] + TournamentUpserted { tournament: TournamentInfoData }, + #[serde(rename = "tournament_deleted")] + TournamentDeleted { tournament_id: i32 }, +} diff --git a/api/src/models/mod.rs b/api/src/models/mod.rs new file mode 100644 index 0000000..f6f2f51 --- /dev/null +++ b/api/src/models/mod.rs @@ -0,0 +1,7 @@ +pub mod event; +pub mod person; +pub mod tournament; + +pub use event::*; +pub use person::*; +pub use tournament::*; diff --git a/api/src/models.rs b/api/src/models/person.rs similarity index 98% rename from api/src/models.rs rename to api/src/models/person.rs index 5eb1f55..6786051 100644 --- a/api/src/models.rs +++ b/api/src/models/person.rs @@ -9,7 +9,7 @@ pub struct User { pub created_at: chrono::DateTime, } -#[derive(Debug, FromRow)] +#[derive(Debug, FromRow, Clone)] pub struct Person { pub id: i32, pub first_name: String, diff --git a/api/src/models/tournament.rs b/api/src/models/tournament.rs new file mode 100644 index 0000000..5d5ba6b --- /dev/null +++ b/api/src/models/tournament.rs @@ -0,0 +1,366 @@ +use rocket::serde::{Deserialize, Serialize}; +use serde_json::Value; +use sqlx::FromRow; +use std::collections::HashMap; +use std::collections::HashSet; + +#[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); + + 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 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, +} diff --git a/api/src/routes/mod.rs b/api/src/routes/mod.rs new file mode 100644 index 0000000..b4f5621 --- /dev/null +++ b/api/src/routes/mod.rs @@ -0,0 +1,2 @@ +pub mod persons; +pub mod tournaments; diff --git a/api/src/routes/persons.rs b/api/src/routes/persons.rs new file mode 100644 index 0000000..954da28 --- /dev/null +++ b/api/src/routes/persons.rs @@ -0,0 +1,506 @@ +use crate::auth::AuthUser; +use crate::error::ApiError; +use crate::models::{ + AppEvent, NewPersonRequest, Person, PersonActionResponse, PersonResponse, PersonsResponse, + UpdatePersonRequest, +}; +use crate::AppState; +use rocket::serde::json::Json; +use rocket::Route; +use sqlx::QueryBuilder; + +pub fn routes() -> Vec { + rocket::routes![ + search_persons, + list_checked_in, + checkin_person, + checkout_person, + mark_inside, + mark_outside, + create_person, + update_person + ] +} + +#[rocket::get("/search?")] +pub async fn search_persons( + _user: AuthUser, + state: &rocket::State, + q: &str, +) -> Result, ApiError> { + let query = q.trim(); + if query.is_empty() { + return Err(ApiError::bad_request("Söktext krävs.")); + } + + let like_pattern = format!("%{}%", query); + let id_value = query.parse::().ok(); + + let persons = if let Some(id) = id_value { + sqlx::query_as::<_, Person>( + r#" + SELECT + id, + first_name, + last_name, + grade, + parent_name, + parent_phone_number, + checked_in, + inside, + visitor, + sleeping_spot + FROM persons + WHERE id = $1 + UNION + SELECT + id, + first_name, + last_name, + grade, + parent_name, + parent_phone_number, + checked_in, + inside, + visitor, + sleeping_spot + FROM persons + WHERE + CONCAT(first_name, ' ', last_name) ILIKE $2 + OR parent_name ILIKE $2 + OR parent_phone_number ILIKE $2 + LIMIT 50 + "#, + ) + .bind(id) + .bind(&like_pattern) + .fetch_all(&state.db) + .await? + } else { + sqlx::query_as::<_, Person>( + r#" + SELECT + id, + first_name, + last_name, + grade, + parent_name, + parent_phone_number, + checked_in, + inside, + visitor, + sleeping_spot + FROM persons + WHERE + CONCAT(first_name, ' ', last_name) ILIKE $1 + OR parent_name ILIKE $1 + OR parent_phone_number ILIKE $1 + ORDER BY checked_in DESC, inside DESC, id ASC + LIMIT 50 + "#, + ) + .bind(&like_pattern) + .fetch_all(&state.db) + .await? + }; + + let response = PersonsResponse { + persons: persons.into_iter().map(PersonResponse::from).collect(), + }; + + Ok(Json(response)) +} + +#[rocket::get("/checked-in?&&")] +pub async fn list_checked_in( + _user: AuthUser, + state: &rocket::State, + checked: Option, + status: Option<&str>, + q: Option<&str>, +) -> Result, ApiError> { + let mut query_builder = QueryBuilder::new( + r#" + SELECT + id, + first_name, + last_name, + grade, + parent_name, + parent_phone_number, + checked_in, + inside, + visitor, + sleeping_spot + FROM persons + "#, + ); + + let mut conditions = Vec::new(); + + if let Some(checked_in) = checked { + if checked_in { + conditions.push("checked_in = true".to_string()); + } else { + conditions.push("checked_in = false".to_string()); + } + } + + if let Some(status) = status { + match status { + "inside" => conditions.push("inside = true".to_string()), + "outside" => conditions.push("inside = false".to_string()), + _ => {} + } + } + + if let Some(query) = q.and_then(|value| { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } + }) { + conditions.push(format!( + "(CONCAT(first_name, ' ', last_name) ILIKE '%{query}%' OR parent_name ILIKE '%{query}%' OR parent_phone_number ILIKE '%{query}%')" + )); + } + + if !conditions.is_empty() { + query_builder.push(" WHERE "); + for (index, condition) in conditions.iter().enumerate() { + if index > 0 { + query_builder.push(" AND "); + } + query_builder.push(condition.as_str()); + } + } + + query_builder.push(" ORDER BY checked_in DESC, inside DESC, id ASC"); + + let persons = query_builder + .build_query_as::() + .fetch_all(&state.db) + .await?; + + Ok(Json(PersonsResponse { + persons: persons.into_iter().map(PersonResponse::from).collect(), + })) +} + +#[rocket::post("//checkin")] +pub async fn checkin_person( + _user: AuthUser, + state: &rocket::State, + id: i32, +) -> Result, ApiError> { + let person = sqlx::query_as::<_, Person>( + r#" + UPDATE persons + SET checked_in = true + WHERE id = $1 + RETURNING + id, + first_name, + last_name, + grade, + parent_name, + parent_phone_number, + checked_in, + inside, + visitor, + sleeping_spot + "#, + ) + .bind(id) + .fetch_optional(&state.db) + .await? + .ok_or_else(|| ApiError::not_found("Personen hittades inte."))?; + + let response = PersonActionResponse { + person: person.clone().into(), + }; + + let _ = state.event_sender.send(AppEvent::PersonUpdated { + person: response.person.clone(), + }); + + Ok(Json(response)) +} + +#[rocket::post("//checkout")] +pub async fn checkout_person( + _user: AuthUser, + state: &rocket::State, + id: i32, +) -> Result, ApiError> { + let person = sqlx::query_as::<_, Person>( + r#" + UPDATE persons + SET checked_in = false, inside = false + WHERE id = $1 + RETURNING + id, + first_name, + last_name, + grade, + parent_name, + parent_phone_number, + checked_in, + inside, + visitor, + sleeping_spot + "#, + ) + .bind(id) + .fetch_optional(&state.db) + .await? + .ok_or_else(|| ApiError::not_found("Personen hittades inte."))?; + + let response = PersonActionResponse { + person: person.clone().into(), + }; + + let _ = state.event_sender.send(AppEvent::PersonUpdated { + person: response.person.clone(), + }); + + Ok(Json(response)) +} + +#[rocket::post("//inside")] +pub async fn mark_inside( + _user: AuthUser, + state: &rocket::State, + id: i32, +) -> Result, ApiError> { + let person = sqlx::query_as::<_, Person>( + r#" + UPDATE persons + SET inside = true + WHERE id = $1 + RETURNING + id, + first_name, + last_name, + grade, + parent_name, + parent_phone_number, + checked_in, + inside, + visitor, + sleeping_spot + "#, + ) + .bind(id) + .fetch_optional(&state.db) + .await? + .ok_or_else(|| ApiError::not_found("Personen hittades inte."))?; + + let response = PersonActionResponse { + person: person.clone().into(), + }; + + let _ = state.event_sender.send(AppEvent::PersonUpdated { + person: response.person.clone(), + }); + + Ok(Json(response)) +} + +#[rocket::post("//outside")] +pub async fn mark_outside( + _user: AuthUser, + state: &rocket::State, + id: i32, +) -> Result, ApiError> { + let person = sqlx::query_as::<_, Person>( + r#" + UPDATE persons + SET inside = false + WHERE id = $1 + RETURNING + id, + first_name, + last_name, + grade, + parent_name, + parent_phone_number, + checked_in, + inside, + visitor, + sleeping_spot + "#, + ) + .bind(id) + .fetch_optional(&state.db) + .await? + .ok_or_else(|| ApiError::not_found("Personen hittades inte."))?; + + let response = PersonActionResponse { + person: person.clone().into(), + }; + + let _ = state.event_sender.send(AppEvent::PersonUpdated { + person: response.person.clone(), + }); + + Ok(Json(response)) +} + +#[rocket::post("/", data = "")] +pub async fn create_person( + _user: AuthUser, + state: &rocket::State, + payload: Json, +) -> Result, ApiError> { + let data = payload.into_inner(); + + if data.first_name.trim().is_empty() { + return Err(ApiError::bad_request("Förnamn krävs.")); + } + + if data.last_name.trim().is_empty() { + return Err(ApiError::bad_request("Efternamn krävs.")); + } + + if let Some(ref phone) = data.parent_phone_number { + let digits: String = phone.chars().filter(|c| c.is_ascii_digit()).collect(); + if digits.len() != 10 { + return Err(ApiError::bad_request( + "Telefonnumret måste innehålla tio siffror.", + )); + } + } + + let person = sqlx::query_as::<_, Person>( + r#" + INSERT INTO persons ( + first_name, + last_name, + grade, + parent_name, + parent_phone_number, + checked_in, + inside, + visitor, + sleeping_spot, + id + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, + CASE WHEN $10 IS NULL THEN nextval('persons_id_seq') ELSE $10 END + ) + RETURNING + id, + first_name, + last_name, + grade, + parent_name, + parent_phone_number, + checked_in, + inside, + visitor, + sleeping_spot + "#, + ) + .bind(data.first_name.trim()) + .bind(data.last_name.trim()) + .bind(data.grade) + .bind(data.parent_name.map(|v| v.trim().to_string())) + .bind(data.parent_phone_number.map(|v| v.trim().to_string())) + .bind(data.checked_in.unwrap_or(false)) + .bind(data.inside.unwrap_or(false)) + .bind(data.visitor.unwrap_or(false)) + .bind(data.sleeping_spot.unwrap_or(false)) + .bind(data.id) + .fetch_one(&state.db) + .await?; + + let response = PersonActionResponse { + person: person.clone().into(), + }; + + let _ = state.event_sender.send(AppEvent::PersonUpdated { + person: response.person.clone(), + }); + + Ok(Json(response)) +} + +#[rocket::put("/", data = "")] +pub async fn update_person( + _user: AuthUser, + state: &rocket::State, + id: i32, + payload: Json, +) -> Result, ApiError> { + let data = payload.into_inner(); + + if data.first_name.trim().is_empty() { + return Err(ApiError::bad_request("Förnamn krävs.")); + } + + if data.last_name.trim().is_empty() { + return Err(ApiError::bad_request("Efternamn krävs.")); + } + + if let Some(ref phone) = data.parent_phone_number { + let digits: String = phone.chars().filter(|c| c.is_ascii_digit()).collect(); + if digits.len() != 10 { + return Err(ApiError::bad_request( + "Telefonnumret måste innehålla tio siffror.", + )); + } + } + + let person = sqlx::query_as::<_, Person>( + r#" + UPDATE persons + SET + first_name = $2, + last_name = $3, + grade = $4, + parent_name = $5, + parent_phone_number = $6, + checked_in = COALESCE($7, checked_in), + inside = COALESCE($8, inside), + visitor = $9, + sleeping_spot = $10 + WHERE id = $1 + RETURNING + id, + first_name, + last_name, + grade, + parent_name, + parent_phone_number, + checked_in, + inside, + visitor, + sleeping_spot + "#, + ) + .bind(id) + .bind(data.first_name.trim()) + .bind(data.last_name.trim()) + .bind(data.grade) + .bind(data.parent_name.map(|v| v.trim().to_string())) + .bind(data.parent_phone_number.map(|v| v.trim().to_string())) + .bind(data.checked_in) + .bind(data.inside) + .bind(data.visitor) + .bind(data.sleeping_spot) + .fetch_optional(&state.db) + .await? + .ok_or_else(|| ApiError::not_found("Personen hittades inte."))?; + + let response = PersonActionResponse { + person: person.clone().into(), + }; + + let _ = state.event_sender.send(AppEvent::PersonUpdated { + person: response.person.clone(), + }); + + Ok(Json(response)) +} diff --git a/api/src/routes/tournaments.rs b/api/src/routes/tournaments.rs new file mode 100644 index 0000000..a558faa --- /dev/null +++ b/api/src/routes/tournaments.rs @@ -0,0 +1,1356 @@ +use crate::auth::AuthUser; +use crate::error::ApiError; +use crate::models::{ + AppEvent, CreateTournamentRequest, TournamentFieldType, TournamentInfo, TournamentInfoData, + TournamentItemResponse, TournamentListResponse, TournamentParticipantRow, + TournamentParticipantValueRow, TournamentRegistrationDetailResponse, + TournamentRegistrationItem, TournamentRegistrationListResponse, TournamentRegistrationResponse, + TournamentRegistrationRow, TournamentRegistrationValueRow, TournamentSection, + TournamentSectionRecord, TournamentSignupConfig, TournamentSignupField, + TournamentSignupFieldRecord, TournamentSignupSubmission, UpdateTournamentRequest, +}; +use crate::AppState; +use rocket::serde::json::Json; +use rocket::Route; +use serde_json::{Map, Value}; +use sqlx::{Postgres, Transaction}; +use std::collections::HashMap; + +pub fn routes() -> Vec { + rocket::routes![ + list_tournaments, + create_tournament, + get_tournament, + get_tournament_by_slug, + update_tournament, + delete_tournament, + list_registrations_by_slug, + get_registration_detail_by_slug, + create_registration_by_slug + ] +} + +type PgTx<'a> = Transaction<'a, Postgres>; + +fn normalize_optional(value: Option) -> Option { + value.and_then(|v| { + let trimmed = v.trim().to_string(); + if trimmed.is_empty() { + None + } else { + Some(trimmed) + } + }) +} + +fn slugify(value: &str) -> String { + let mut slug = value + .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() { + "turnering".to_string() + } else { + slug + } +} + +fn normalize_sections(sections: Vec) -> Vec { + sections + .into_iter() + .filter_map(|section| { + let title = section.title.trim().to_string(); + let body = section.body.trim().to_string(); + if title.is_empty() && body.is_empty() { + None + } else { + Some(TournamentSection { title, body }) + } + }) + .collect() +} + +fn field_type_to_db(field_type: &TournamentFieldType) -> &'static str { + match field_type { + TournamentFieldType::Text => "text", + TournamentFieldType::Email => "email", + TournamentFieldType::Tel => "tel", + TournamentFieldType::Discord => "discord", + } +} + +fn field_type_from_db(value: &str) -> TournamentFieldType { + match value { + "email" => TournamentFieldType::Email, + "tel" => TournamentFieldType::Tel, + "discord" => TournamentFieldType::Discord, + _ => TournamentFieldType::Text, + } +} + +fn build_registration_url(slug: &str) -> String { + format!("/tournament/{slug}") +} + +async fn load_sections( + pool: &sqlx::PgPool, + tournament_id: i32, +) -> Result, ApiError> { + let records = sqlx::query_as::<_, TournamentSectionRecord>( + r#" + SELECT id, tournament_id, position, title, body + FROM tournament_sections + WHERE tournament_id = $1 + ORDER BY position ASC, id ASC + "#, + ) + .bind(tournament_id) + .fetch_all(pool) + .await?; + + Ok(records + .into_iter() + .map(|record| TournamentSection { + title: record.title, + body: record.body, + }) + .collect()) +} + +async fn load_signup_field_records( + pool: &sqlx::PgPool, + tournament_id: i32, +) -> Result, ApiError> { + sqlx::query_as::<_, TournamentSignupFieldRecord>( + r#" + SELECT + id, + tournament_id, + field_key, + scope, + label, + field_type, + required, + placeholder, + position, + unique_field + FROM tournament_signup_fields + WHERE tournament_id = $1 + ORDER BY scope, position ASC, id ASC + "#, + ) + .bind(tournament_id) + .fetch_all(pool) + .await + .map_err(Into::into) +} + +fn build_signup_config( + info: &TournamentInfo, + fields: &[TournamentSignupFieldRecord], +) -> TournamentSignupConfig { + let mut entry_fields = Vec::new(); + let mut participant_fields = Vec::new(); + + for record in fields { + let field = TournamentSignupField { + id: record.field_key.clone(), + label: record.label.clone(), + field_type: field_type_from_db(&record.field_type), + required: record.required, + placeholder: record.placeholder.clone(), + unique: record.unique, + }; + + if record.scope == "participant" { + participant_fields.push(field); + } else { + entry_fields.push(field); + } + } + + TournamentSignupConfig { + mode: match info.signup_mode.as_str() { + "team" => "team".to_string(), + _ => "solo".to_string(), + }, + team_size: crate::models::TournamentTeamSize { + min: info.team_size_min, + max: info.team_size_max, + }, + entry_fields, + participant_fields, + } +} + +async fn load_tournament_data( + pool: &sqlx::PgPool, + info: TournamentInfo, +) -> Result { + let sections = load_sections(pool, info.id).await?; + let field_records = load_signup_field_records(pool, info.id).await?; + let signup_config = build_signup_config(&info, &field_records); + + let total_registrations = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM tournament_registrations WHERE tournament_id = $1", + ) + .bind(info.id) + .fetch_one(pool) + .await?; + let total_registrations = (total_registrations.min(i32::MAX as i64)) as i32; + + let total_participants = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) + FROM tournament_participants + WHERE registration_id IN ( + SELECT id FROM tournament_registrations WHERE tournament_id = $1 + )", + ) + .bind(info.id) + .fetch_one(pool) + .await?; + let total_participants = (total_participants.min(i32::MAX as i64)) as i32; + + Ok(TournamentInfoData { + id: info.id, + title: info.title, + game: info.game, + slug: info.slug.clone(), + tagline: info.tagline, + start_at: info.start_at, + location: info.location, + description: info.description, + contact: info.contact, + registration_url: build_registration_url(&info.slug), + sections, + signup_config, + total_registrations, + total_participants, + updated_at: info.updated_at, + }) +} + +async fn insert_sections( + tx: &mut PgTx<'_>, + tournament_id: i32, + sections: &[TournamentSection], +) -> Result<(), sqlx::Error> { + sqlx::query("DELETE FROM tournament_sections WHERE tournament_id = $1") + .bind(tournament_id) + .execute(&mut **tx) + .await?; + + for (index, section) in sections.iter().enumerate() { + sqlx::query( + r#" + INSERT INTO tournament_sections (tournament_id, position, title, body) + VALUES ($1, $2, $3, $4) + "#, + ) + .bind(tournament_id) + .bind(index as i32) + .bind(section.title.trim()) + .bind(section.body.trim()) + .execute(&mut **tx) + .await?; + } + + Ok(()) +} + +async fn insert_signup_fields( + tx: &mut PgTx<'_>, + tournament_id: i32, + config: &TournamentSignupConfig, +) -> Result<(), sqlx::Error> { + sqlx::query("DELETE FROM tournament_signup_fields WHERE tournament_id = $1") + .bind(tournament_id) + .execute(&mut **tx) + .await?; + + for (index, field) in config.entry_fields.iter().enumerate() { + sqlx::query( + r#" + INSERT INTO tournament_signup_fields ( + tournament_id, + field_key, + scope, + label, + field_type, + required, + placeholder, + position, + unique_field + ) VALUES ($1, $2, 'entry', $3, $4, $5, $6, $7, $8) + "#, + ) + .bind(tournament_id) + .bind(&field.id) + .bind(field.label.trim()) + .bind(field_type_to_db(&field.field_type)) + .bind(field.required) + .bind( + field + .placeholder + .as_ref() + .map(|v| v.trim()) + .filter(|v| !v.is_empty()), + ) + .bind(index as i32) + .bind(field.unique) + .execute(&mut **tx) + .await?; + } + + for (index, field) in config.participant_fields.iter().enumerate() { + sqlx::query( + r#" + INSERT INTO tournament_signup_fields ( + tournament_id, + field_key, + scope, + label, + field_type, + required, + placeholder, + position, + unique_field + ) VALUES ($1, $2, 'participant', $3, $4, $5, $6, $7, $8) + "#, + ) + .bind(tournament_id) + .bind(&field.id) + .bind(field.label.trim()) + .bind(field_type_to_db(&field.field_type)) + .bind(field.required) + .bind( + field + .placeholder + .as_ref() + .map(|v| v.trim()) + .filter(|v| !v.is_empty()), + ) + .bind(index as i32) + .bind(field.unique) + .execute(&mut **tx) + .await?; + } + + Ok(()) +} + +async fn insert_registration_values( + tx: &mut PgTx<'_>, + registration_id: i32, + field: &TournamentSignupFieldRecord, + value: &str, +) -> Result<(), sqlx::Error> { + if value.is_empty() { + return Ok(()); + } + + sqlx::query( + r#" + INSERT INTO tournament_registration_values (registration_id, signup_field_id, value) + VALUES ($1, $2, $3) + ON CONFLICT (registration_id, signup_field_id) DO UPDATE SET value = EXCLUDED.value + "#, + ) + .bind(registration_id) + .bind(field.id) + .bind(value) + .execute(&mut **tx) + .await?; + + Ok(()) +} + +async fn insert_participant_values( + tx: &mut PgTx<'_>, + participant_id: i32, + field: &TournamentSignupFieldRecord, + value: &str, +) -> Result<(), sqlx::Error> { + if value.is_empty() { + return Ok(()); + } + + sqlx::query( + r#" + INSERT INTO tournament_participant_values (participant_id, signup_field_id, value) + VALUES ($1, $2, $3) + ON CONFLICT (participant_id, signup_field_id) DO UPDATE SET value = EXCLUDED.value + "#, + ) + .bind(participant_id) + .bind(field.id) + .bind(value) + .execute(&mut **tx) + .await?; + + Ok(()) +} + +#[rocket::get("/")] +pub async fn list_tournaments( + state: &rocket::State, +) -> Result, ApiError> { + let infos = sqlx::query_as::<_, TournamentInfo>( + r#" + SELECT + id, + title, + game, + slug, + tagline, + start_at, + location, + description, + contact, + signup_mode, + team_size_min, + team_size_max, + created_at, + updated_at + FROM tournament_info + ORDER BY start_at NULLS LAST, updated_at DESC + "#, + ) + .fetch_all(&state.db) + .await?; + + let mut tournaments = Vec::with_capacity(infos.len()); + for info in infos { + tournaments.push(load_tournament_data(&state.db, info).await?); + } + + Ok(Json(TournamentListResponse { tournaments })) +} + +#[rocket::post("/", data = "")] +pub async fn create_tournament( + _user: AuthUser, + state: &rocket::State, + payload: Json, +) -> Result, ApiError> { + let request = payload.into_inner(); + + if request.title.trim().is_empty() { + return Err(ApiError::bad_request("Turneringen måste ha en titel.")); + } + + if request.game.trim().is_empty() { + return Err(ApiError::bad_request("Ange vilket spel som spelas.")); + } + + let slug = slugify(&request.slug); + if slug.trim().is_empty() { + return Err(ApiError::bad_request("Ange en giltig slug.")); + } + + let sections = normalize_sections(request.sections); + let signup_config = request.signup_config.normalized(); + let tagline = normalize_optional(request.tagline); + let location = normalize_optional(request.location); + let description = normalize_optional(request.description); + let contact = normalize_optional(request.contact); + + let mut tx = state.db.begin().await?; + + let tournament = sqlx::query_as::<_, TournamentInfo>( + r#" + INSERT INTO tournament_info ( + title, + game, + slug, + tagline, + start_at, + location, + description, + contact, + signup_mode, + team_size_min, + team_size_max + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11 + ) + RETURNING + id, + title, + game, + slug, + tagline, + start_at, + location, + description, + contact, + signup_mode, + team_size_min, + team_size_max, + created_at, + updated_at + "#, + ) + .bind(request.title.trim()) + .bind(request.game.trim()) + .bind(&slug) + .bind(tagline) + .bind(request.start_at) + .bind(location) + .bind(description) + .bind(contact) + .bind(signup_config.mode.as_str()) + .bind(signup_config.team_size.min) + .bind(signup_config.team_size.max) + .fetch_one(&mut *tx) + .await + .map_err(|err| { + if let sqlx::Error::Database(db_err) = &err { + if db_err.constraint() == Some("tournament_info_slug_idx") { + return ApiError::bad_request("Sluggen används redan av en annan turnering."); + } + } + err.into() + })?; + + insert_sections(&mut tx, tournament.id, §ions).await?; + insert_signup_fields(&mut tx, tournament.id, &signup_config).await?; + + tx.commit().await?; + + let tournament_data = load_tournament_data(&state.db, tournament.clone()).await?; + let _ = state.event_sender.send(AppEvent::TournamentUpserted { + tournament: tournament_data.clone(), + }); + + Ok(Json(TournamentItemResponse { + tournament: tournament_data, + })) +} + +#[rocket::get("/")] +pub async fn get_tournament( + state: &rocket::State, + id: i32, +) -> Result, ApiError> { + let info = sqlx::query_as::<_, TournamentInfo>( + r#" + SELECT + id, + title, + game, + slug, + tagline, + start_at, + location, + description, + contact, + signup_mode, + team_size_min, + team_size_max, + created_at, + updated_at + FROM tournament_info + WHERE id = $1 + "#, + ) + .bind(id) + .fetch_optional(&state.db) + .await? + .ok_or_else(|| ApiError::not_found("Turneringen hittades inte."))?; + + let tournament = load_tournament_data(&state.db, info).await?; + Ok(Json(TournamentItemResponse { tournament })) +} + +#[rocket::get("/slug/")] +pub async fn get_tournament_by_slug( + state: &rocket::State, + slug: &str, +) -> Result, ApiError> { + let info = sqlx::query_as::<_, TournamentInfo>( + r#" + SELECT + id, + title, + game, + slug, + tagline, + start_at, + location, + description, + contact, + signup_mode, + team_size_min, + team_size_max, + created_at, + updated_at + FROM tournament_info + WHERE slug = $1 + "#, + ) + .bind(slug) + .fetch_optional(&state.db) + .await? + .ok_or_else(|| ApiError::not_found("Turneringen hittades inte."))?; + + let tournament = load_tournament_data(&state.db, info).await?; + Ok(Json(TournamentItemResponse { tournament })) +} + +#[rocket::put("/", data = "")] +pub async fn update_tournament( + _user: AuthUser, + state: &rocket::State, + id: i32, + payload: Json, +) -> Result, ApiError> { + let request = payload.into_inner(); + + if request.title.trim().is_empty() { + return Err(ApiError::bad_request("Turneringen måste ha en titel.")); + } + + if request.game.trim().is_empty() { + return Err(ApiError::bad_request("Ange vilket spel som spelas.")); + } + + let slug = slugify(&request.slug); + if slug.trim().is_empty() { + return Err(ApiError::bad_request("Ange en giltig slug.")); + } + + let sections = normalize_sections(request.sections); + let signup_config = request.signup_config.normalized(); + let tagline = normalize_optional(request.tagline); + let location = normalize_optional(request.location); + let description = normalize_optional(request.description); + let contact = normalize_optional(request.contact); + + let mut tx = state.db.begin().await?; + + let info = sqlx::query_as::<_, TournamentInfo>( + r#" + UPDATE tournament_info + SET + title = $2, + game = $3, + slug = $4, + tagline = $5, + start_at = $6, + location = $7, + description = $8, + contact = $9, + signup_mode = $10, + team_size_min = $11, + team_size_max = $12, + updated_at = NOW() + WHERE id = $1 + RETURNING + id, + title, + game, + slug, + tagline, + start_at, + location, + description, + contact, + signup_mode, + team_size_min, + team_size_max, + created_at, + updated_at + "#, + ) + .bind(id) + .bind(request.title.trim()) + .bind(request.game.trim()) + .bind(&slug) + .bind(tagline) + .bind(request.start_at) + .bind(location) + .bind(description) + .bind(contact) + .bind(signup_config.mode.as_str()) + .bind(signup_config.team_size.min) + .bind(signup_config.team_size.max) + .fetch_optional(&mut *tx) + .await + .map_err(|err| { + if let sqlx::Error::Database(db_err) = &err { + if db_err.constraint() == Some("tournament_info_slug_idx") { + return ApiError::bad_request("Sluggen används redan av en annan turnering."); + } + } + err.into() + })? + .ok_or_else(|| ApiError::not_found("Turneringen hittades inte."))?; + + insert_sections(&mut tx, id, §ions).await?; + insert_signup_fields(&mut tx, id, &signup_config).await?; + + tx.commit().await?; + + let tournament = load_tournament_data(&state.db, info).await?; + let _ = state.event_sender.send(AppEvent::TournamentUpserted { + tournament: tournament.clone(), + }); + + Ok(Json(TournamentItemResponse { tournament })) +} + +#[rocket::delete("/")] +pub async fn delete_tournament( + _user: AuthUser, + state: &rocket::State, + id: i32, +) -> Result, ApiError> { + let info = sqlx::query_as::<_, TournamentInfo>( + r#" + DELETE FROM tournament_info + WHERE id = $1 + RETURNING + id, + title, + game, + slug, + tagline, + start_at, + location, + description, + contact, + signup_mode, + team_size_min, + team_size_max, + created_at, + updated_at + "#, + ) + .bind(id) + .fetch_optional(&state.db) + .await? + .ok_or_else(|| ApiError::not_found("Turneringen hittades inte."))?; + + let tournament = load_tournament_data(&state.db, info).await?; + let _ = state + .event_sender + .send(AppEvent::TournamentDeleted { tournament_id: id }); + + Ok(Json(TournamentItemResponse { tournament })) +} + +fn validate_submission( + config: &TournamentSignupConfig, + submission: &TournamentSignupSubmission, +) -> Result<(), ApiError> { + for field in &config.entry_fields { + if field.required { + if submission + .entry + .get(&field.id) + .map(|value| value.trim().is_empty()) + .unwrap_or(true) + { + return Err(ApiError::bad_request(format!( + "Fältet '{label}' måste fyllas i.", + label = field.label + ))); + } + } + } + + let participant_count = submission.participants.len(); + + if config.mode == "solo" { + if participant_count != 1 { + return Err(ApiError::bad_request( + "Antalet deltagare måste vara exakt 1.", + )); + } + } else { + let min = config.team_size.min.max(1) as usize; + let max = config.team_size.max.max(config.team_size.min) as usize; + if participant_count < min { + return Err(ApiError::bad_request(format!( + "Minst {min_participants} spelare krävs.", + min_participants = min + ))); + } + if participant_count > max { + return Err(ApiError::bad_request(format!( + "Högst {max_participants} spelare tillåts.", + max_participants = max + ))); + } + } + + for (index, participant) in submission.participants.iter().enumerate() { + for field in &config.participant_fields { + if field.required { + if participant + .get(&field.id) + .map(|value| value.trim().is_empty()) + .unwrap_or(true) + { + return Err(ApiError::bad_request(format!( + "Spelare #{number}: '{label}' måste fyllas i.", + number = index + 1, + label = field.label + ))); + } + } + } + } + + Ok(()) +} + +fn trimmed(value: Option<&String>) -> Option { + value + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()) +} + +#[rocket::post("/slug//signup", data = "")] +pub async fn create_registration_by_slug( + state: &rocket::State, + slug: &str, + payload: Json, +) -> Result, ApiError> { + let info = sqlx::query_as::<_, TournamentInfo>( + r#" + SELECT + id, + title, + game, + slug, + tagline, + start_at, + location, + description, + contact, + signup_mode, + team_size_min, + team_size_max, + created_at, + updated_at + FROM tournament_info + WHERE slug = $1 + "#, + ) + .bind(slug) + .fetch_optional(&state.db) + .await? + .ok_or_else(|| ApiError::not_found("Turneringen hittades inte."))?; + + let field_records = load_signup_field_records(&state.db, info.id).await?; + let config = build_signup_config(&info, &field_records); + let submission = payload.into_inner(); + + validate_submission(&config, &submission)?; + + let mut entry_values: HashMap = HashMap::new(); + for field in &config.entry_fields { + let value = submission + .entry + .get(&field.id) + .map(|v| v.trim().to_string()) + .unwrap_or_default(); + entry_values.insert(field.id.clone(), value); + } + + let mut participant_values: Vec> = Vec::new(); + for participant in submission.participants { + let mut map = HashMap::new(); + for field in &config.participant_fields { + let value = participant + .get(&field.id) + .map(|v| v.trim().to_string()) + .unwrap_or_default(); + map.insert(field.id.clone(), value); + } + participant_values.push(map); + } + + let mut field_map: HashMap = HashMap::new(); + for record in field_records { + field_map.insert(record.field_key.clone(), record); + } + + let mut tx = state.db.begin().await?; + + let entry_label = config + .entry_fields + .first() + .and_then(|field| entry_values.get(&field.id)) + .and_then(|value| trimmed(Some(value))); + + let entry_label_requires_unique = config + .entry_fields + .first() + .map(|field| field.unique) + .unwrap_or(false); + + if entry_label_requires_unique { + if let Some(label) = entry_label.clone() { + let is_duplicate = sqlx::query_scalar::<_, bool>( + r#" + SELECT EXISTS ( + SELECT 1 + FROM tournament_registrations + WHERE tournament_id = $1 AND entry_label = $2 + ) + "#, + ) + .bind(info.id) + .bind(&label) + .fetch_one(&state.db) + .await?; + + if is_duplicate { + return Err(ApiError::bad_request( + "Den här spelaren eller laget är redan anmäld till turneringen.", + )); + } + } + } + + for field in &config.entry_fields { + if !field.unique { + continue; + } + + if let Some(value) = entry_values.get(&field.id) { + if value.is_empty() { + continue; + } + + if let Some(record) = field_map.get(&field.id) { + let exists = sqlx::query_scalar::<_, bool>( + r#" + SELECT EXISTS ( + SELECT 1 + FROM tournament_registration_values v + INNER JOIN tournament_registrations r ON r.id = v.registration_id + WHERE r.tournament_id = $1 + AND v.signup_field_id = $2 + AND v.value = $3 + ) + "#, + ) + .bind(info.id) + .bind(record.id) + .bind(value) + .fetch_one(&mut *tx) + .await?; + + if exists { + return Err(ApiError::bad_request(format!( + "Värdet för '{label}' används redan i en annan anmälan.", + label = field.label + ))); + } + } + } + } + + for participant_values in &participant_values { + for field in &config.participant_fields { + if !field.unique { + continue; + } + + if let Some(value) = participant_values.get(&field.id) { + if value.is_empty() { + continue; + } + + if let Some(record) = field_map.get(&field.id) { + let exists = sqlx::query_scalar::<_, bool>( + r#" + SELECT EXISTS ( + SELECT 1 + FROM tournament_participant_values pv + INNER JOIN tournament_participants tp ON tp.id = pv.participant_id + INNER JOIN tournament_registrations tr ON tr.id = tp.registration_id + WHERE tr.tournament_id = $1 + AND pv.signup_field_id = $2 + AND pv.value = $3 + ) + "#, + ) + .bind(info.id) + .bind(record.id) + .bind(value) + .fetch_one(&mut *tx) + .await?; + + if exists { + return Err(ApiError::bad_request(format!( + "Värdet för '{label}' används redan i en annan anmälan.", + label = field.label + ))); + } + } + } + } + } + + let registration = sqlx::query_as::<_, TournamentRegistrationRow>( + r#" + INSERT INTO tournament_registrations (tournament_id, entry_label) + VALUES ($1, $2) + RETURNING id, tournament_id, entry_label, created_at + "#, + ) + .bind(info.id) + .bind(entry_label) + .fetch_one(&mut *tx) + .await?; + + for field in &config.entry_fields { + if let Some(record) = field_map.get(&field.id) { + if let Some(value) = entry_values.get(&field.id) { + insert_registration_values(&mut tx, registration.id, record, value).await?; + } + } + } + + for (position, values) in participant_values.iter().enumerate() { + let participant = sqlx::query_as::<_, TournamentParticipantRow>( + r#" + INSERT INTO tournament_participants (registration_id, position) + VALUES ($1, $2) + RETURNING id, registration_id, position + "#, + ) + .bind(registration.id) + .bind(position as i32) + .fetch_one(&mut *tx) + .await?; + + for field in &config.participant_fields { + if let Some(record) = field_map.get(&field.id) { + if let Some(value) = values.get(&field.id) { + insert_participant_values(&mut tx, participant.id, record, value).await?; + } + } + } + } + + tx.commit().await?; + + Ok(Json(TournamentRegistrationResponse { + registration_id: registration.id, + })) +} + +#[rocket::get("/slug//registrations")] +pub async fn list_registrations_by_slug( + _user: AuthUser, + state: &rocket::State, + slug: &str, +) -> Result, ApiError> { + let info = sqlx::query_as::<_, TournamentInfo>( + r#" + SELECT + id, + title, + game, + slug, + tagline, + start_at, + location, + description, + contact, + signup_mode, + team_size_min, + team_size_max, + created_at, + updated_at + FROM tournament_info + WHERE slug = $1 + "#, + ) + .bind(slug) + .fetch_optional(&state.db) + .await? + .ok_or_else(|| ApiError::not_found("Turneringen hittades inte."))?; + + let tournament = load_tournament_data(&state.db, info.clone()).await?; + + let registrations = sqlx::query_as::<_, TournamentRegistrationRow>( + r#" + SELECT id, tournament_id, entry_label, created_at + FROM tournament_registrations + WHERE tournament_id = $1 + ORDER BY created_at DESC, id DESC + "#, + ) + .bind(info.id) + .fetch_all(&state.db) + .await?; + + let registration_ids: Vec = registrations.iter().map(|row| row.id).collect(); + + let mut entry_value_rows = Vec::new(); + let mut participant_rows = Vec::new(); + let mut participant_value_rows = Vec::new(); + + if !registration_ids.is_empty() { + entry_value_rows = sqlx::query_as::<_, TournamentRegistrationValueRow>( + r#" + SELECT registration_id, signup_field_id, value + FROM tournament_registration_values + WHERE registration_id = ANY($1) + "#, + ) + .bind(®istration_ids) + .fetch_all(&state.db) + .await?; + + participant_rows = sqlx::query_as::<_, TournamentParticipantRow>( + r#" + SELECT id, registration_id, position + FROM tournament_participants + WHERE registration_id = ANY($1) + ORDER BY position ASC, id ASC + "#, + ) + .bind(®istration_ids) + .fetch_all(&state.db) + .await?; + + let participant_ids: Vec = participant_rows.iter().map(|row| row.id).collect(); + if !participant_ids.is_empty() { + participant_value_rows = sqlx::query_as::<_, TournamentParticipantValueRow>( + r#" + SELECT participant_id, signup_field_id, value + FROM tournament_participant_values + WHERE participant_id = ANY($1) + "#, + ) + .bind(&participant_ids) + .fetch_all(&state.db) + .await?; + } + } + + let field_records = load_signup_field_records(&state.db, info.id).await?; + let mut field_by_id: HashMap = HashMap::new(); + for record in &field_records { + field_by_id.insert(record.id, record); + } + + let mut entry_values: HashMap> = HashMap::new(); + for row in entry_value_rows { + if let Some(field) = field_by_id.get(&row.signup_field_id) { + if field.scope == "entry" { + let map = entry_values + .entry(row.registration_id) + .or_insert_with(Map::new); + map.insert(field.field_key.clone(), Value::String(row.value)); + } + } + } + + let mut participants_by_registration: HashMap> = + HashMap::new(); + for participant in participant_rows { + participants_by_registration + .entry(participant.registration_id) + .or_insert_with(Vec::new) + .push(participant); + } + + let mut participant_value_map: HashMap> = HashMap::new(); + for row in participant_value_rows { + if let Some(field) = field_by_id.get(&row.signup_field_id) { + if field.scope == "participant" { + let map = participant_value_map + .entry(row.participant_id) + .or_insert_with(Map::new); + map.insert(field.field_key.clone(), Value::String(row.value)); + } + } + } + + let mut registration_items = Vec::with_capacity(registrations.len()); + for registration in registrations { + let entry = entry_values + .remove(®istration.id) + .map(Value::Object) + .unwrap_or_else(|| Value::Object(Map::new())); + + let mut participant_array = Vec::new(); + if let Some(participants) = participants_by_registration.remove(®istration.id) { + for participant in participants { + let values = participant_value_map + .remove(&participant.id) + .map(Value::Object) + .unwrap_or_else(|| Value::Object(Map::new())); + participant_array.push(values); + } + } + + registration_items.push(TournamentRegistrationItem { + id: registration.id, + created_at: registration.created_at, + entry, + participants: Value::Array(participant_array), + }); + } + + Ok(Json(TournamentRegistrationListResponse { + tournament, + registrations: registration_items, + })) +} + +#[rocket::get("/slug//registrations/")] +pub async fn get_registration_detail_by_slug( + state: &rocket::State, + slug: &str, + registration_id: i32, +) -> Result, ApiError> { + let info = sqlx::query_as::<_, TournamentInfo>( + r#" + SELECT + id, + title, + game, + slug, + tagline, + start_at, + location, + description, + contact, + signup_mode, + team_size_min, + team_size_max, + created_at, + updated_at + FROM tournament_info + WHERE slug = $1 + "#, + ) + .bind(slug) + .fetch_optional(&state.db) + .await? + .ok_or_else(|| ApiError::not_found("Turneringen hittades inte."))?; + + let registration = sqlx::query_as::<_, TournamentRegistrationRow>( + r#" + SELECT id, tournament_id, entry_label, created_at + FROM tournament_registrations + WHERE id = $1 AND tournament_id = $2 + "#, + ) + .bind(registration_id) + .bind(info.id) + .fetch_optional(&state.db) + .await? + .ok_or_else(|| ApiError::not_found("Anmälan hittades inte."))?; + + let entry_value_rows = sqlx::query_as::<_, TournamentRegistrationValueRow>( + r#" + SELECT registration_id, signup_field_id, value + FROM tournament_registration_values + WHERE registration_id = $1 + "#, + ) + .bind(registration.id) + .fetch_all(&state.db) + .await?; + + let participant_rows = sqlx::query_as::<_, TournamentParticipantRow>( + r#" + SELECT id, registration_id, position + FROM tournament_participants + WHERE registration_id = $1 + ORDER BY position ASC, id ASC + "#, + ) + .bind(registration.id) + .fetch_all(&state.db) + .await?; + + let participant_value_rows = if participant_rows.is_empty() { + Vec::new() + } else { + let participant_ids: Vec = participant_rows.iter().map(|row| row.id).collect(); + sqlx::query_as::<_, TournamentParticipantValueRow>( + r#" + SELECT participant_id, signup_field_id, value + FROM tournament_participant_values + WHERE participant_id = ANY($1) + "#, + ) + .bind(&participant_ids) + .fetch_all(&state.db) + .await? + }; + + let field_records = load_signup_field_records(&state.db, info.id).await?; + let mut field_by_id: HashMap = HashMap::new(); + for record in &field_records { + field_by_id.insert(record.id, record); + } + + let mut entry_map = Map::new(); + for row in entry_value_rows { + if let Some(field) = field_by_id.get(&row.signup_field_id) { + if field.scope == "entry" { + entry_map.insert(field.field_key.clone(), Value::String(row.value)); + } + } + } + + let mut participant_value_map: HashMap> = HashMap::new(); + for row in participant_value_rows { + if let Some(field) = field_by_id.get(&row.signup_field_id) { + if field.scope == "participant" { + let map = participant_value_map + .entry(row.participant_id) + .or_insert_with(Map::new); + map.insert(field.field_key.clone(), Value::String(row.value)); + } + } + } + + let mut participant_array = Vec::new(); + for participant in participant_rows { + let values = participant_value_map + .remove(&participant.id) + .map(Value::Object) + .unwrap_or_else(|| Value::Object(Map::new())); + participant_array.push(values); + } + + let registration_item = TournamentRegistrationItem { + id: registration.id, + created_at: registration.created_at, + entry: Value::Object(entry_map), + participants: Value::Array(participant_array), + }; + + let tournament = load_tournament_data(&state.db, info.clone()).await?; + + Ok(Json(TournamentRegistrationDetailResponse { + tournament, + registration: registration_item, + })) +} diff --git a/web/src/lib/client/event-stream.ts b/web/src/lib/client/event-stream.ts new file mode 100644 index 0000000..bc75805 --- /dev/null +++ b/web/src/lib/client/event-stream.ts @@ -0,0 +1,41 @@ +import type { Person, TournamentInfo } from '$lib/types'; + +export type AppEvent = + | { type: 'person_updated'; person: Person } + | { type: 'tournament_upserted'; tournament: TournamentInfo } + | { type: 'tournament_deleted'; tournament_id: number }; + +export function listenToEvents(onEvent: (event: AppEvent) => void) { + let stopped = false; + let source: EventSource | null = null; + + function connect() { + if (stopped) return; + source = new EventSource('/api/events'); + source.onmessage = (event) => { + try { + const data = JSON.parse(event.data) as AppEvent; + if (data && typeof data.type === 'string') { + onEvent(data); + } + } catch (err) { + console.error('Failed to parse event stream payload', err); + } + }; + + source.onerror = () => { + source?.close(); + source = null; + if (stopped) return; + setTimeout(connect, 2000); + }; + } + + connect(); + + return () => { + stopped = true; + source?.close(); + source = null; + }; +} diff --git a/web/src/lib/client/person-events.ts b/web/src/lib/client/person-events.ts index 939a2ec..95867dc 100644 --- a/web/src/lib/client/person-events.ts +++ b/web/src/lib/client/person-events.ts @@ -1,40 +1,10 @@ import type { Person } from '$lib/types'; - -export type PersonEvent = { - person: Person; -}; +import { listenToEvents } from '$lib/client/event-stream'; export function listenToPersonEvents(onPerson: (person: Person) => void) { - let stopped = false; - let source: EventSource | null = null; - - function connect() { - if (stopped) return; - source = new EventSource('/api/events'); - source.onmessage = (event) => { - try { - const data = JSON.parse(event.data) as PersonEvent; - if (data.person) { - onPerson(data.person); - } - } catch (err) { - console.error('Failed to parse person event', err); - } - }; - - source.onerror = () => { - source?.close(); - source = null; - if (stopped) return; - setTimeout(connect, 2000); - }; - } - - connect(); - - return () => { - stopped = true; - source?.close(); - source = null; - }; + return listenToEvents((event) => { + if (event.type === 'person_updated') { + onPerson(event.person); + } + }); } diff --git a/web/src/lib/client/tournament-events.ts b/web/src/lib/client/tournament-events.ts new file mode 100644 index 0000000..f6ce13f --- /dev/null +++ b/web/src/lib/client/tournament-events.ts @@ -0,0 +1,21 @@ +import type { TournamentInfo } from '$lib/types'; +import { listenToEvents } from '$lib/client/event-stream'; + +export type TournamentEvent = + | { type: 'tournament_upserted'; tournament: TournamentInfo } + | { type: 'tournament_deleted'; tournament_id: number }; + +export function listenToTournamentEvents( + onUpsert: (tournament: TournamentInfo) => void, + onDelete: (tournamentId: number) => void +) { + return listenToEvents((event) => { + if (event.type === 'tournament_upserted') { + onUpsert(event.tournament); + return; + } + if (event.type === 'tournament_deleted') { + onDelete(event.tournament_id); + } + }); +} diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 47a4196..8f4fafe 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -10,3 +10,66 @@ export interface Person { visitor: boolean; sleeping_spot: boolean; } + +export interface TournamentSection { + title: string; + body: string; +} + +export type TournamentFieldType = 'text' | 'email' | 'tel' | 'discord'; + +export interface TournamentSignupField { + id: string; + label: string; + field_type: TournamentFieldType; + required: boolean; + placeholder?: string | null; + unique: boolean; +} + +export interface TournamentTeamSize { + min: number; + max: number; +} + +export interface TournamentSignupConfig { + mode: 'solo' | 'team'; + team_size: TournamentTeamSize; + entry_fields: TournamentSignupField[]; + participant_fields: TournamentSignupField[]; +} + +export interface TournamentRegistrationItem { + id: number; + created_at: string; + entry: Record; + participants: Record[]; +} + +export interface TournamentRegistrationList { + tournament: TournamentInfo; + registrations: TournamentRegistrationItem[]; +} + +export interface TournamentRegistrationDetail { + tournament: TournamentInfo; + registration: TournamentRegistrationItem; +} + +export interface TournamentInfo { + id: number; + title: string; + game: string; + slug: string; + tagline: string | null; + start_at: string | null; + location: string | null; + description: string | null; + registration_url: string; + contact: string | null; + sections: TournamentSection[]; + signup_config: TournamentSignupConfig; + updated_at: string; + total_registrations: number; + total_participants: number; +} diff --git a/web/src/routes/(admin)/admin/+layout.server.ts b/web/src/routes/(admin)/admin/+layout.server.ts new file mode 100644 index 0000000..e502b37 --- /dev/null +++ b/web/src/routes/(admin)/admin/+layout.server.ts @@ -0,0 +1,14 @@ +import { redirect } from '@sveltejs/kit'; +import type { LayoutServerLoad } from './$types'; +import { AUTH_COOKIE_NAME } from '$lib/server/config'; + +export const load: LayoutServerLoad = async ({ cookies, url }) => { + const isLoggedIn = Boolean(cookies.get(AUTH_COOKIE_NAME)); + const isLoginRoute = url.pathname === '/admin/login'; + + if (!isLoggedIn && !isLoginRoute) { + throw redirect(302, '/admin/login'); + } + + return { isLoggedIn }; +}; diff --git a/web/src/routes/(admin)/admin/+layout.svelte b/web/src/routes/(admin)/admin/+layout.svelte new file mode 100644 index 0000000..af2508d --- /dev/null +++ b/web/src/routes/(admin)/admin/+layout.svelte @@ -0,0 +1,172 @@ + + + + + + +
+
+
+
+
+ + VBytes + +

{panel.title}

+
+
+ {#if panel.swapHref} + + {panel.swapLabel} + + {/if} + {#if ui.loggedIn} + + {/if} +
+
+ {#if panel.nav.length > 0 && ui.loggedIn} + + {/if} + {#if ui.message} +

{ui.message}

+ {/if} +
+
+
+ {@render children?.()} +
+
diff --git a/web/src/routes/(admin)/admin/+page.svelte b/web/src/routes/(admin)/admin/+page.svelte new file mode 100644 index 0000000..f9e5dcf --- /dev/null +++ b/web/src/routes/(admin)/admin/+page.svelte @@ -0,0 +1,32 @@ + + Adminpanel + + +
+
+

Välj adminområde

+

+ Här kan du välja vilket verktyg du vill administrera efter inloggning. +

+
+ +
diff --git a/web/src/routes/(admin)/admin/checkin/+layout.svelte b/web/src/routes/(admin)/admin/checkin/+layout.svelte new file mode 100644 index 0000000..8470a01 --- /dev/null +++ b/web/src/routes/(admin)/admin/checkin/+layout.svelte @@ -0,0 +1,6 @@ + + +{@render children?.()} diff --git a/web/src/routes/(admin)/admin/checkin/+page.server.ts b/web/src/routes/(admin)/admin/checkin/+page.server.ts new file mode 100644 index 0000000..45fd039 --- /dev/null +++ b/web/src/routes/(admin)/admin/checkin/+page.server.ts @@ -0,0 +1,5 @@ +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = async () => { + return {}; +}; diff --git a/web/src/routes/+page.svelte b/web/src/routes/(admin)/admin/checkin/+page.svelte similarity index 99% rename from web/src/routes/+page.svelte rename to web/src/routes/(admin)/admin/checkin/+page.svelte index 9ff4250..3dbc8f1 100644 --- a/web/src/routes/+page.svelte +++ b/web/src/routes/(admin)/admin/checkin/+page.svelte @@ -28,7 +28,7 @@ async function apiFetch(url: string, init?: RequestInit) { const response = await fetch(url, init); if (response.status === 401) { - await goto('/login'); + await goto('/admin/login'); return null; } return response; diff --git a/web/src/routes/checked-in/+page.svelte b/web/src/routes/(admin)/admin/checkin/checked-in/+page.svelte similarity index 96% rename from web/src/routes/checked-in/+page.svelte rename to web/src/routes/(admin)/admin/checkin/checked-in/+page.svelte index 6a05d4d..328b1a9 100644 --- a/web/src/routes/checked-in/+page.svelte +++ b/web/src/routes/(admin)/admin/checkin/checked-in/+page.svelte @@ -33,7 +33,7 @@ type VisitorFilter = 'all' | 'besoksplats' | 'lanplats'; async function apiFetch(url: string) { const response = await fetch(url); if (response.status === 401) { - await goto('/login'); + await goto('/admin/login'); return null; } return response; @@ -301,17 +301,17 @@ type VisitorFilter = 'all' | 'besoksplats' | 'lanplats'; - {#if errorMessage} -

{errorMessage}

- {/if} - {#if infoMessage && !errorMessage} -

{infoMessage}

- {/if} - + {#if errorMessage} +

{errorMessage}

+ {/if} + {#if infoMessage && !errorMessage} +

{infoMessage}

+ {/if} -
- {#each persons as person} -
+ {#if persons.length > 0} +
+ {#each persons as person} +

{fullName(person)}

@@ -361,7 +361,9 @@ type VisitorFilter = 'all' | 'besoksplats' | 'lanplats';
{/each} -
+ + {/if} + - {#if errorMessage} -

{errorMessage}

- {/if} - {#if infoMessage && !errorMessage} -

{infoMessage}

- {/if} - {#if actionMessage && !errorMessage} -

- {actionMessage} -

- {/if} - + {#if errorMessage} +

{errorMessage}

+ {/if} + {#if infoMessage && !errorMessage} +

{infoMessage}

+ {/if} + {#if actionMessage && !errorMessage} +

+ {actionMessage} +

+ {/if} -
- {#each persons as person} -
+ {#if persons.length > 0} +
+ {#each persons as person} +

{fullName(person)}

@@ -290,7 +290,9 @@ {person.inside ? 'Markera ute' : 'Markera inne'}
-
- {/each} -
+ + {/each} + + {/if} + diff --git a/web/src/routes/login/+page.server.ts b/web/src/routes/(admin)/admin/login/+page.server.ts similarity index 100% rename from web/src/routes/login/+page.server.ts rename to web/src/routes/(admin)/admin/login/+page.server.ts diff --git a/web/src/routes/login/+page.svelte b/web/src/routes/(admin)/admin/login/+page.svelte similarity index 98% rename from web/src/routes/login/+page.svelte rename to web/src/routes/(admin)/admin/login/+page.svelte index 1458446..731501f 100644 --- a/web/src/routes/login/+page.svelte +++ b/web/src/routes/(admin)/admin/login/+page.svelte @@ -29,7 +29,7 @@ return; } - await goto('/', { invalidateAll: true }); + await goto('/admin', { invalidateAll: true }); } catch (err) { console.error('Login failed', err); errorMessage = 'Ett oväntat fel inträffade. Försök igen.'; diff --git a/web/src/routes/(admin)/admin/tournament/+layout.svelte b/web/src/routes/(admin)/admin/tournament/+layout.svelte new file mode 100644 index 0000000..8470a01 --- /dev/null +++ b/web/src/routes/(admin)/admin/tournament/+layout.svelte @@ -0,0 +1,6 @@ + + +{@render children?.()} diff --git a/web/src/routes/(admin)/admin/tournament/+page.server.ts b/web/src/routes/(admin)/admin/tournament/+page.server.ts new file mode 100644 index 0000000..d2958d4 --- /dev/null +++ b/web/src/routes/(admin)/admin/tournament/+page.server.ts @@ -0,0 +1,18 @@ +import type { PageServerLoad } from './$types'; +import type { TournamentInfo } from '$lib/types'; + +export const load: PageServerLoad = async ({ fetch }) => { + try { + const response = await fetch('/api/tournament'); + if (!response.ok) { + return { tournaments: [] as TournamentInfo[] }; + } + const data = await response.json(); + return { + tournaments: (data?.tournaments ?? []) as TournamentInfo[] + }; + } catch (err) { + console.error('Failed to load tournaments', err); + return { tournaments: [] as TournamentInfo[] }; + } +}; diff --git a/web/src/routes/(admin)/admin/tournament/+page.svelte b/web/src/routes/(admin)/admin/tournament/+page.svelte new file mode 100644 index 0000000..56909a1 --- /dev/null +++ b/web/src/routes/(admin)/admin/tournament/+page.svelte @@ -0,0 +1,1551 @@ + + + + Turneringsadmin + + +
+ {#if activeTab() === 'overview'} +
+
+
+

Översikt

+

Filtrera turneringar och öppna deras publika sidor.

+
+
+ +
+
+ + {#if filteredOverview().length > 0} +
+ {#each filteredOverview() as tournament (tournament.id)} +
+
+

{tournament.game}

+

{tournament.title}

+ {#if tournament.tagline} +

{tournament.tagline}

+ {/if} + {#if tournament.start_at} +

{formatDateTime(tournament.start_at) ?? tournament.start_at}

+ {/if} +

{registrationSummary(tournament)}

+
+
+ + Öppna sida + + {#if tournament.slug} + + Visa anmälningar + + {/if} + +
+
+ {/each} +
+ {:else} +

+ Inga turneringar hittades. Skapa en ny via fliken "Skapa ny". +

+ {/if} +
+ {:else if activeTab() === 'create'} +
+
+

Skapa turnering

+

Fälten används även för att automatiskt bygga anmälningssidan.

+
+ +
+
+ + + + + + + + +
+ +
+
+

Sektioner för anmälningssidan

+ +
+ + {#if createState.form.sections.length === 0} +

+ Lägg till sektioner som beskriver regler, format eller andra detaljer. +

+ {:else} +
+ {#each createState.form.sections as section, index (sectionKey(index))} +
+
+ + +
+ +
+ {/each} +
+ {/if} +
+ +
+
+

Anmälningsinställningar

+

Ställ in vad som krävs när spelare eller lag registrerar sig.

+
+ +
+ + {#if createState.form.signup.mode === 'team'} +
+ + +
+ {/if} +
+ +
+
+

Anmälningsfält

+ +
+ + {#if createState.form.signup.entry_fields.length === 0} +

+ Ange vilka uppgifter laget eller spelaren ska fylla i vid anmälan. +

+ {:else} +
+ {#each createState.form.signup.entry_fields as field, index (signupFieldKey(index, field))} +
+
+ + + + + +
+ +
+ {/each} +
+ {/if} +
+ + {#if createState.form.signup.mode === 'team'} +
+
+

Spelaruppgifter

+ +
+ + {#if createState.form.signup.participant_fields.length === 0} +

+ Lägg till fält för varje spelare, t.ex. nick eller kontaktuppgifter. +

+ {:else} +
+ {#each createState.form.signup.participant_fields as field, index (signupFieldKey(index, field))} +
+
+ + + + + +
+ +
+ {/each} +
+ {/if} +
+ {/if} +
+ +
+
+ {#if createState.success} + {createState.success} + {:else if createState.error} + {createState.error} + {:else if createState.saving} + Sparar… + {:else} + Förhandsgranska och spara turneringen. + {/if} +
+ +
+
+ + {#if createState.form.title.trim()} +
+

Förhandsvisning av anmälningssida

+

Visas för deltagare på /tournament/{createState.form.slug || 'slug'}.

+ +
+
+

{createState.form.game || 'Spel'}

+

{createState.form.title || 'Titel'}

+ {#if createState.form.tagline.trim()} +

{createState.form.tagline}

+ {/if} +
+ {#if createState.form.start_at} +

Start {formatDateTime(new Date(createState.form.start_at).toISOString()) ?? ''}

+ {/if} + {#if createState.form.location.trim()} +

Plats: {createState.form.location}

+ {/if} + {#if createState.form.description.trim()} +

{createState.form.description}

+ {/if} + {#if createState.form.sections.length > 0} +
+ {#each payloadSections(createState.form.sections) as section, index (sectionKey(index))} +
+

{section.title}

+

{section.body}

+
+ {/each} +
+ {/if} + {#if createState.form.contact.trim()} +

Kontakt: {createState.form.contact}

+ {/if} + {#if createState.form.signup.entry_fields.length > 0} +
+

Anmälningsfält

+
    + {#each createState.form.signup.entry_fields as field} +
  • {field.label} · {field.field_type === 'text' ? 'Text' : field.field_type} {field.required ? '• obligatoriskt' : ''} {field.unique ? '• unikt' : ''}
  • + {/each} +
+
+ {/if} + {#if createState.form.signup.mode === 'team' && createState.form.signup.participant_fields.length > 0} +
+

Spelaruppgifter

+
    + {#each createState.form.signup.participant_fields as field} +
  • {field.label} · {field.field_type === 'text' ? 'Text' : field.field_type} {field.required ? '• obligatoriskt' : ''} {field.unique ? '• unikt' : ''}
  • + {/each} +
+
+ {/if} +
+
+ {/if} +
+ {:else} +
+
+

Hantera turneringar

+

Välj en turnering i listan för att uppdatera innehållet.

+ {#if selectedTournamentInfo()} + {@const selected = selectedTournamentInfo()!} +

{registrationSummary(selected)}

+ {/if} +
+ +
+ + +
+
+
+ + + + + + + + +
+ +
+
+

Sektioner

+ +
+ + {#if manageState.form.sections.length === 0} +

+ Inga sektioner ännu. Lägg till regler, format eller andra detaljer. +

+ {:else} +
+ {#each manageState.form.sections as section, index (sectionKey(index))} +
+
+ + +
+ +
+ {/each} +
+ {/if} +
+ +
+
+ {#if manageState.success} + {manageState.success} + {:else if manageState.error} + {manageState.error} + {:else if manageState.saving} + Sparar… + {:else} + Ändringarna publiceras direkt. + {/if} +
+
+ {#if manageState.selectedId !== null} + + {/if} + +
+
+
+ + {#if manageState.form.title.trim()} +
+

Förhandsvisning av anmälningssida

+

Visas för deltagare på /tournament/{manageState.form.slug || 'slug'}.

+ +
+
+

{manageState.form.game || 'Spel'}

+

{manageState.form.title || 'Titel'}

+ {#if manageState.form.tagline.trim()} +

{manageState.form.tagline}

+ {/if} +
+ {#if manageState.form.start_at} +

Start {formatDateTime(new Date(manageState.form.start_at).toISOString()) ?? ''}

+ {/if} + {#if manageState.form.location.trim()} +

Plats: {manageState.form.location}

+ {/if} + {#if manageState.form.description.trim()} +

{manageState.form.description}

+ {/if} + {#if manageState.form.sections.length > 0} +
+ {#each payloadSections(manageState.form.sections) as section, index (sectionKey(index))} +
+

{section.title}

+

{section.body}

+
+ {/each} +
+ {/if} + {#if manageState.form.contact.trim()} +

Kontakt: {manageState.form.contact}

+ {/if} + {#if manageState.form.signup.entry_fields.length > 0} +
+

Anmälningsfält

+
    + {#each manageState.form.signup.entry_fields as field} +
  • {field.label} · {field.field_type === 'text' ? 'Text' : field.field_type} {field.required ? '• obligatoriskt' : ''} {field.unique ? '• unikt' : ''}
  • + {/each} +
+
+ {/if} + {#if manageState.form.signup.mode === 'team' && manageState.form.signup.participant_fields.length > 0} +
+

Spelaruppgifter

+
    + {#each manageState.form.signup.participant_fields as field} +
  • {field.label} · {field.field_type === 'text' ? 'Text' : field.field_type} {field.required ? '• obligatoriskt' : ''} {field.unique ? '• unikt' : ''}
  • + {/each} +
+
+ {/if} +
+
+ {/if} + + {#if manageState.selectedId !== null} + + {/if} +
+
+
+ {/if} +
diff --git a/web/src/routes/(admin)/admin/tournament/[slug]/registrations/+page.server.ts b/web/src/routes/(admin)/admin/tournament/[slug]/registrations/+page.server.ts new file mode 100644 index 0000000..e92be9b --- /dev/null +++ b/web/src/routes/(admin)/admin/tournament/[slug]/registrations/+page.server.ts @@ -0,0 +1,25 @@ +import { error } from '@sveltejs/kit'; +import type { PageServerLoad } from './$types'; +import type { TournamentRegistrationList } from '$lib/types'; + +export const load: PageServerLoad = async ({ fetch, params }) => { + const response = await fetch(`/api/tournament/slug/${params.slug}/registrations`); + const text = await response.text(); + + if (!response.ok) { + let message = 'Kunde inte hämta anmälningar.'; + try { + const body = JSON.parse(text); + message = body.message ?? message; + } catch { + if (text) message = text; + } + throw error(response.status, message); + } + + const data = JSON.parse(text) as TournamentRegistrationList; + return { + tournament: data.tournament, + registrations: data.registrations + }; +}; diff --git a/web/src/routes/(admin)/admin/tournament/[slug]/registrations/+page.svelte b/web/src/routes/(admin)/admin/tournament/[slug]/registrations/+page.svelte new file mode 100644 index 0000000..210a857 --- /dev/null +++ b/web/src/routes/(admin)/admin/tournament/[slug]/registrations/+page.svelte @@ -0,0 +1,152 @@ + + + + Anmälningar – {tournament.title} | Admin + + +
+
+
+
+

Admin

+

{tournament.title}

+

{tournament.game}

+
+ +
+ +
+

Sammanfattning

+
+
+

Anmälningar

+

{registrations.length}

+
+ {#if tournament.start_at} +
+

Start

+

{formatDateTime(tournament.start_at) ?? tournament.start_at}

+
+ {/if} +
+

Format

+

+ {tournament.signup_config.mode === 'team' + ? `Lag (${tournament.signup_config.team_size.min}–${tournament.signup_config.team_size.max} spelare)` + : 'Individuell'} +

+
+
+
+ +
+
+

Registreringar

+ {#if registrations.length > 0} +

Senaste: {formatDateTime(registrations[0].created_at) ?? registrations[0].created_at}

+ {/if} +
+ + {#if registrations.length === 0} +

+ Inga anmälningar ännu. Dela länken till /tournament/{tournament.slug} för att samla in registreringar. +

+ {:else} +
+ {#each registrations as registration} +
+
+

Anmälan #{registration.id}

+

+ Skapad {formatDateTime(registration.created_at) ?? registration.created_at} +

+
+ + {#if entryFields.length > 0} +
+ {#each entryFields as field} +
+

{field.label}

+

{fieldValue(registration.entry, field) || '—'}

+
+ {/each} +
+ {/if} + +
+

Spelare

+ {#if participantFields.length === 0} + {#if registration.participants.length === 0} +

Inga spelare angivna.

+ {:else} +

Antal spelare: {registration.participants.length}

+ {/if} + {:else if registration.participants.length === 0} +

Inga spelare angivna.

+ {:else} +
+ {#each registration.participants as participant, index} +
+

Spelare {index + 1}

+
    + {#each participantFields as field} +
  • + {field.label}: + {fieldValue(participant, field) || '—'} +
  • + {/each} +
+
+ {/each} +
+ {/if} +
+
+ {/each} +
+ {/if} +
+
+
diff --git a/web/src/routes/(tournament)/tournament/+page.server.ts b/web/src/routes/(tournament)/tournament/+page.server.ts new file mode 100644 index 0000000..b5d2615 --- /dev/null +++ b/web/src/routes/(tournament)/tournament/+page.server.ts @@ -0,0 +1,16 @@ +import type { PageServerLoad } from './$types'; +import type { TournamentInfo } from '$lib/types'; + +export const load: PageServerLoad = async ({ fetch }) => { + try { + const response = await fetch('/api/tournament'); + if (!response.ok) { + return { tournaments: [] as TournamentInfo[] }; + } + const data = await response.json(); + return { tournaments: (data?.tournaments ?? []) as TournamentInfo[] }; + } catch (err) { + console.error('Failed to load tournaments', err); + return { tournaments: [] as TournamentInfo[] }; + } +}; diff --git a/web/src/routes/(tournament)/tournament/+page.svelte b/web/src/routes/(tournament)/tournament/+page.svelte new file mode 100644 index 0000000..c774f9c --- /dev/null +++ b/web/src/routes/(tournament)/tournament/+page.svelte @@ -0,0 +1,141 @@ + + + + LAN Tournament + + +
+
+
+

VBytes LAN

+

{featuredTournament?.game ?? 'Turnering'}

+

{featuredTournament?.title ?? 'Turnering & Community'}

+ {#if featuredTournament?.tagline} +

{featuredTournament.tagline}

+ {:else} +

+ Samla laget, följ brackets i realtid och håll koll på allt som händer under turneringen. +

+ {/if} +
+ + {#if featuredTournament} +
+ {#if featuredTournament.start_at} +

Start: {formatDate(featuredTournament.start_at) ?? featuredTournament.start_at}

+ {/if} + {#if featuredTournament.location} +

Plats: {featuredTournament.location}

+ {/if} + {#if featuredTournament.description} +

{featuredTournament.description}

+ {/if} +
+ {#if featuredTournament.slug} + + Anmäl laget + + {/if} + {#if featuredTournament.contact} + + Kontakt: {featuredTournament.contact} + + {/if} +
+
+ {/if} + + {#if otherTournaments.length > 0} +
+

Fler event

+
+ {#each otherTournaments as tournament} +
+

{tournament.title}

+ {#if tournament.start_at} +

{formatDate(tournament.start_at) ?? tournament.start_at}

+ {/if} + {#if tournament.tagline} +

{tournament.tagline}

+ {:else if tournament.description} +

{tournament.description}

+ {/if} +
+ {#if tournament.slug} + + Anmälan + + {/if} + {#if tournament.contact} + + Kontakt: {tournament.contact} + + {/if} +
+
+ {/each} +
+
+ {/if} + + + Till admin + +
+
diff --git a/web/src/routes/(tournament)/tournament/[slug]/+page.server.ts b/web/src/routes/(tournament)/tournament/[slug]/+page.server.ts new file mode 100644 index 0000000..08c54f6 --- /dev/null +++ b/web/src/routes/(tournament)/tournament/[slug]/+page.server.ts @@ -0,0 +1,31 @@ +import { error } from '@sveltejs/kit'; +import type { PageServerLoad } from './$types'; +import type { TournamentInfo } from '$lib/types'; + +export const load: PageServerLoad = async ({ fetch, params }) => { + try { + const response = await fetch(`/api/tournament/slug/${params.slug}`); + const text = await response.text(); + if (!response.ok) { + let message = 'Kunde inte hämta turneringen.'; + try { + const body = JSON.parse(text); + message = body.message ?? message; + } catch { + if (text) message = text; + } + throw error(response.status, message); + } + + const body = JSON.parse(text) as { tournament: TournamentInfo }; + if (!body?.tournament) { + throw error(404, 'Turneringen hittades inte.'); + } + + return { tournament: body.tournament }; + } catch (err) { + if (err instanceof Response) throw err; + console.error('Failed to load tournament detail', err); + throw error(500, 'Kunde inte hämta turneringen.'); + } +}; diff --git a/web/src/routes/(tournament)/tournament/[slug]/+page.svelte b/web/src/routes/(tournament)/tournament/[slug]/+page.svelte new file mode 100644 index 0000000..5ce788d --- /dev/null +++ b/web/src/routes/(tournament)/tournament/[slug]/+page.svelte @@ -0,0 +1,534 @@ + + + + {tournament.title} – VBytes LAN + + +
+
+ + +
+
+

VBytes LAN

+

{tournament.title}

+ {#if tournament.tagline} +

{tournament.tagline}

+ {/if} +
+
+ {#if formattedStart} +
+

Start

+

{formattedStart}

+
+ {/if} + {#if tournament.location} +
+

Plats

+

{tournament.location}

+
+ {/if} + {#if tournament.contact} +
+

Kontakt

+

{tournament.contact}

+
+ {/if} +
+
+ + {#if tournament.description} +
+

Beskrivning

+

{tournament.description}

+
+ {/if} + + {#if tournament.sections.length > 0} +
+ {#each tournament.sections as section, index (section.title + index)} +
+

{section.title}

+

{section.body}

+
+ {/each} +
+ {/if} + +
+
+

Anmälan

+ {#if signupConfig.mode === 'team'} +

Lagstorlek: {signupConfig.team_size.min}–{signupConfig.team_size.max} spelare.

+ {:else} +

Individuell anmälan.

+ {/if} +
+ +
+ {#if signupConfig.entry_fields.length > 0} +
+

Lag / deltagare

+
+ {#each signupConfig.entry_fields as field} + + {/each} +
+
+ +{#if signup.showSuccessModal} +
+ +
+{/if} + + {/if} + +
+
+

Spelare

+ {#if signupConfig.mode === 'team'} + + {/if} +
+ + {#if signup.participants.length > 0} +
+ {#each signup.participants as participant, index (index)} +
+
+ {participantDisplayName(index)} + {#if signupConfig.mode === 'team'} + + {/if} +
+ + {#if signupConfig.participant_fields.length > 0} +
+ {#each signupConfig.participant_fields as field} + + {/each} +
+ {:else} +

Inga spelarspecifika fält krävs.

+ {/if} +
+ {/each} +
+ {/if} +
+ +
+ {#if signup.error} +

{signup.error}

+ {:else if signup.success} +

{signup.success}

+ {:else} +

Din anmälan skickas direkt till arrangören.

+ {/if} +
+ + +
+
+ +
+

Senast uppdaterad {formatDateTime(tournament.updated_at) ?? tournament.updated_at}

+ + Administrera + +
+
+
diff --git a/web/src/routes/(tournament)/tournament/[slug]/signup-success/[registration]/+page.server.ts b/web/src/routes/(tournament)/tournament/[slug]/signup-success/[registration]/+page.server.ts new file mode 100644 index 0000000..231852e --- /dev/null +++ b/web/src/routes/(tournament)/tournament/[slug]/signup-success/[registration]/+page.server.ts @@ -0,0 +1,54 @@ +import { error, redirect } from '@sveltejs/kit'; +import type { PageServerLoad } from './$types'; +import type { TournamentRegistrationDetail } from '$lib/types'; +import { API_BASE_URL } from '$lib/server/config'; + +export const load: PageServerLoad = async ({ params }) => { + const registrationId = Number.parseInt(params.registration, 10); + if (!Number.isFinite(registrationId) || registrationId <= 0) { + throw error(400, 'Ogiltigt registrerings-id.'); + } + + const apiUrl = new URL( + `/tournament/slug/${params.slug}/registrations/${registrationId}`, + API_BASE_URL + ).toString(); + + try { + const response = await fetch(apiUrl, { + method: 'GET', + headers: { + accept: 'application/json' + } + }); + const text = await response.text(); + + if (!response.ok) { + let message = 'Kunde inte hämta anmälan.'; + try { + const body = JSON.parse(text); + message = body.message ?? message; + } catch { + if (text) message = text; + } + + if (response.status === 404) { + throw redirect(302, `/tournament/${params.slug}?signup-missing=1`); + } + + console.error('Failed to load registration detail', { + status: response.status, + message, + text + }); + throw error(response.status, message); + } + + const data = JSON.parse(text) as TournamentRegistrationDetail; + return { data }; + } catch (err) { + if (err instanceof Response) throw err; + console.error('Unexpected error loading signup success', err); + throw error(500, 'Kunde inte hämta anmälan.'); + } +}; diff --git a/web/src/routes/(tournament)/tournament/[slug]/signup-success/[registration]/+page.svelte b/web/src/routes/(tournament)/tournament/[slug]/signup-success/[registration]/+page.svelte new file mode 100644 index 0000000..2141f7a --- /dev/null +++ b/web/src/routes/(tournament)/tournament/[slug]/signup-success/[registration]/+page.svelte @@ -0,0 +1,157 @@ + + + + Anmälan bekräftad – {tournament.title} + + +
+
+
+

VBytes LAN

+

Anmälan bekräftad

+

Du är registrerad till {tournament.title}.

+

+ Skapad {formatDateTime(registration.created_at) ?? registration.created_at} +

+
+ +
+
+

Turnering

+

Spel: {tournament.game}

+ {#if tournament.start_at} +

+ Start: + {formatDateTime(tournament.start_at) ?? tournament.start_at} +

+ {/if} + {#if tournament.location} +

+ Plats: {tournament.location} +

+ {/if} +
+ +
+

Format

+ {#if tournament.signup_config.mode === 'team'} +

+ Lag {tournament.signup_config.team_size.min}–{tournament.signup_config.team_size.max} spelare +

+ {:else} +

Individuell anmälan

+ {/if} + {#if tournament.contact} +

+ Kontakt: {tournament.contact} +

+ {/if} +
+
+ +
+

Anmälningsuppgifter

+ {#if entryFields.length === 0} +

+ Den här turneringen kräver inga uppgifter utöver spelare. +

+ {:else} +
+ {#each entryFields as field} +
+

{field.label}

+

{fieldValue(entryValues, field) || '—'}

+
+ {/each} +
+ {/if} +
+ +
+

Spelare

+ {#if participantFields.length === 0} + {#if participantValues.length === 0} +

Inga spelare angivna.

+ {:else} +

Antal spelare: {participantValues.length}

+ {/if} + {:else if participantValues.length === 0} +

Inga spelare angivna.

+ {:else} +
+ {#each participantValues as participant, index} +
+

Spelare {index + 1}

+
    + {#each participantFields as field} +
  • + {field.label}: + {fieldValue(participant, field) || '—'} +
  • + {/each} +
+
+ {/each} +
+ {/if} +
+ + +
+
diff --git a/web/src/routes/+layout.server.ts b/web/src/routes/+layout.server.ts deleted file mode 100644 index fa909e0..0000000 --- a/web/src/routes/+layout.server.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { LayoutServerLoad } from './$types'; -import { AUTH_COOKIE_NAME } from '$lib/server/config'; - -export const load: LayoutServerLoad = async ({ cookies }) => { - const isLoggedIn = Boolean(cookies.get(AUTH_COOKIE_NAME)); - return { isLoggedIn }; -}; diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index bd80e7b..7d3c43a 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -1,127 +1,7 @@ - - - - -
-
-
-
-
-

VBytes

-

Gästhantering

-
- {#if ui.loggedIn} - - {/if} -
- {#if ui.loggedIn} - - {/if} -
- {#if ui.message} -

{ui.message}

- {/if} -
-
- {@render children?.()} -
-
+{@render children?.()} diff --git a/web/src/routes/+page.server.ts b/web/src/routes/+page.server.ts index 73fdab8..cb1fbdb 100644 --- a/web/src/routes/+page.server.ts +++ b/web/src/routes/+page.server.ts @@ -1,11 +1,6 @@ import { redirect } from '@sveltejs/kit'; import type { PageServerLoad } from './$types'; -import { AUTH_COOKIE_NAME } from '$lib/server/config'; -export const load: PageServerLoad = async ({ cookies }) => { - if (!cookies.get(AUTH_COOKIE_NAME)) { - throw redirect(302, '/login'); - } - - return {}; +export const load: PageServerLoad = () => { + throw redirect(302, '/tournament'); }; diff --git a/web/src/routes/api/tournament/+server.ts b/web/src/routes/api/tournament/+server.ts new file mode 100644 index 0000000..8e6e5af --- /dev/null +++ b/web/src/routes/api/tournament/+server.ts @@ -0,0 +1,63 @@ +import { error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { proxyRequest } from '$lib/server/backend'; + +function buildHeaders(response: Response, setCookies: string[]): Headers { + const headers = new Headers(); + const contentType = response.headers.get('content-type'); + if (contentType) { + headers.set('content-type', contentType); + } else { + headers.set('content-type', 'application/json'); + } + for (const cookie of setCookies) { + headers.append('set-cookie', cookie); + } + return headers; +} + +export const GET: RequestHandler = async (event) => { + const { response, setCookies } = await proxyRequest(event, '/tournament', { + method: 'GET' + }); + + const text = await response.text(); + const headers = buildHeaders(response, setCookies); + + if (!response.ok) { + let message = 'Kunde inte hämta turneringar.'; + try { + const body = JSON.parse(text); + message = body.message ?? message; + } catch { + if (text) message = text; + } + throw error(response.status, message); + } + + return new Response(text, { status: response.status, headers }); +}; + +export const POST: RequestHandler = async (event) => { + const payload = await event.request.json(); + const { response, setCookies } = await proxyRequest(event, '/tournament', { + method: 'POST', + body: JSON.stringify(payload) + }); + + const text = await response.text(); + const headers = buildHeaders(response, setCookies); + + if (!response.ok) { + let message = 'Kunde inte skapa turnering.'; + try { + const body = JSON.parse(text); + message = body.message ?? message; + } catch { + if (text) message = text; + } + throw error(response.status, message); + } + + return new Response(text, { status: response.status, headers }); +}; diff --git a/web/src/routes/api/tournament/[id]/+server.ts b/web/src/routes/api/tournament/[id]/+server.ts new file mode 100644 index 0000000..3de5cb4 --- /dev/null +++ b/web/src/routes/api/tournament/[id]/+server.ts @@ -0,0 +1,50 @@ +import { error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { proxyRequest } from '$lib/server/backend'; + +function buildHeaders(response: Response, setCookies: string[]): Headers { + const headers = new Headers(); + const contentType = response.headers.get('content-type'); + if (contentType) { + headers.set('content-type', contentType); + } else { + headers.set('content-type', 'application/json'); + } + for (const cookie of setCookies) { + headers.append('set-cookie', cookie); + } + return headers; +} + +async function proxyTournament(event: Parameters[0], init: RequestInit) { + const path = `/tournament/${event.params.id}`; + const { response, setCookies } = await proxyRequest(event, path, init); + const text = await response.text(); + const headers = buildHeaders(response, setCookies); + + if (!response.ok) { + let message = 'Kunde inte hantera turnering.'; + try { + const body = JSON.parse(text); + message = body.message ?? message; + } catch { + if (text) message = text; + } + throw error(response.status, message); + } + + return new Response(text, { status: response.status, headers }); +} + +export const GET: RequestHandler = async (event) => proxyTournament(event, { method: 'GET' }); + +export const PUT: RequestHandler = async (event) => { + const payload = await event.request.json(); + return proxyTournament(event, { + method: 'PUT', + body: JSON.stringify(payload) + }); +}; + +export const DELETE: RequestHandler = async (event) => + proxyTournament(event, { method: 'DELETE' }); diff --git a/web/src/routes/api/tournament/slug/[slug]/+server.ts b/web/src/routes/api/tournament/slug/[slug]/+server.ts new file mode 100644 index 0000000..c052247 --- /dev/null +++ b/web/src/routes/api/tournament/slug/[slug]/+server.ts @@ -0,0 +1,39 @@ +import { error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { proxyRequest } from '$lib/server/backend'; + +function buildHeaders(response: Response, setCookies: string[]): Headers { + const headers = new Headers(); + const contentType = response.headers.get('content-type'); + if (contentType) { + headers.set('content-type', contentType); + } else { + headers.set('content-type', 'application/json'); + } + for (const cookie of setCookies) { + headers.append('set-cookie', cookie); + } + return headers; +} + +async function proxyTournament(event: Parameters[0], init: RequestInit) { + const path = `/tournament/slug/${event.params.slug}`; + const { response, setCookies } = await proxyRequest(event, path, init); + const text = await response.text(); + const headers = buildHeaders(response, setCookies); + + if (!response.ok) { + let message = 'Kunde inte hämta turnering.'; + try { + const body = JSON.parse(text); + message = body.message ?? message; + } catch { + if (text) message = text; + } + throw error(response.status, message); + } + + return new Response(text, { status: response.status, headers }); +} + +export const GET: RequestHandler = async (event) => proxyTournament(event, { method: 'GET' }); diff --git a/web/src/routes/api/tournament/slug/[slug]/registrations/+server.ts b/web/src/routes/api/tournament/slug/[slug]/registrations/+server.ts new file mode 100644 index 0000000..10acbfd --- /dev/null +++ b/web/src/routes/api/tournament/slug/[slug]/registrations/+server.ts @@ -0,0 +1,30 @@ +import { error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { proxyRequest } from '$lib/server/backend'; + +export const GET: RequestHandler = async (event) => { + const { response, setCookies } = await proxyRequest( + event, + `/tournament/slug/${event.params.slug}/registrations`, + { method: 'GET' } + ); + + const text = await response.text(); + const headers = new Headers(); + const contentType = response.headers.get('content-type'); + if (contentType) headers.set('content-type', contentType); + for (const cookie of setCookies) headers.append('set-cookie', cookie); + + if (!response.ok) { + let message = 'Kunde inte hämta anmälningar.'; + try { + const body = JSON.parse(text); + message = body.message ?? message; + } catch { + if (text) message = text; + } + throw error(response.status, message); + } + + return new Response(text, { status: response.status, headers }); +}; diff --git a/web/src/routes/api/tournament/slug/[slug]/registrations/[registration]/+server.ts b/web/src/routes/api/tournament/slug/[slug]/registrations/[registration]/+server.ts new file mode 100644 index 0000000..d9ec73e --- /dev/null +++ b/web/src/routes/api/tournament/slug/[slug]/registrations/[registration]/+server.ts @@ -0,0 +1,37 @@ +import { error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { proxyRequest } from '$lib/server/backend'; + +function buildHeaders(response: Response, setCookies: string[]): Headers { + const headers = new Headers(); + const contentType = response.headers.get('content-type'); + if (contentType) { + headers.set('content-type', contentType); + } else { + headers.set('content-type', 'application/json'); + } + for (const cookie of setCookies) { + headers.append('set-cookie', cookie); + } + return headers; +} + +export const GET: RequestHandler = async (event) => { + const path = `/tournament/slug/${event.params.slug}/registrations/${event.params.registration}`; + const { response, setCookies } = await proxyRequest(event, path, { method: 'GET' }); + const text = await response.text(); + const headers = buildHeaders(response, setCookies); + + if (!response.ok) { + let message = 'Kunde inte hämta anmälan.'; + try { + const body = JSON.parse(text); + message = body.message ?? message; + } catch { + if (text) message = text; + } + throw error(response.status, message); + } + + return new Response(text, { status: response.status, headers }); +}; diff --git a/web/src/routes/api/tournament/slug/[slug]/signup/+server.ts b/web/src/routes/api/tournament/slug/[slug]/signup/+server.ts new file mode 100644 index 0000000..8cc6d5b --- /dev/null +++ b/web/src/routes/api/tournament/slug/[slug]/signup/+server.ts @@ -0,0 +1,45 @@ +import { error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { proxyRequest } from '$lib/server/backend'; + +function buildHeaders(response: Response, setCookies: string[]): Headers { + const headers = new Headers(); + const contentType = response.headers.get('content-type'); + if (contentType) { + headers.set('content-type', contentType); + } else { + headers.set('content-type', 'application/json'); + } + for (const cookie of setCookies) { + headers.append('set-cookie', cookie); + } + return headers; +} + +export const POST: RequestHandler = async (event) => { + const body = await event.request.text(); + const { response, setCookies } = await proxyRequest( + event, + `/tournament/slug/${event.params.slug}/signup`, + { + method: 'POST', + body + } + ); + + const text = await response.text(); + const headers = buildHeaders(response, setCookies); + + if (!response.ok) { + let message = 'Kunde inte skicka anmälan.'; + try { + const parsed = JSON.parse(text); + message = parsed.message ?? message; + } catch { + if (text) message = text; + } + throw error(response.status, message); + } + + return new Response(text, { status: response.status, headers }); +};