erste Version Online-Nennen

This commit is contained in:
stefan
2025-06-05 12:57:07 +02:00
parent 8fcc279679
commit ef59fa35b1
27 changed files with 3081 additions and 207 deletions
Binary file not shown.
View File
+8 -1
View File
@@ -34,10 +34,17 @@ dependencies {
// JDBC Treiber für PostgreSQL (nur zur Laufzeit benötigt) // JDBC Treiber für PostgreSQL (nur zur Laufzeit benötigt)
runtimeOnly(libs.postgresql.driver) runtimeOnly(libs.postgresql.driver)
// H2 Datenbank für Tests und lokale Entwicklung // H2 Datenbank für Tests und lokale Entwicklung (legacy)
runtimeOnly(libs.h2.driver) runtimeOnly(libs.h2.driver)
// SQLite Datenbank für Tests und lokale Entwicklung
runtimeOnly("org.xerial:sqlite-jdbc:3.43.0.0")
// HikariCP für Connection Pooling // HikariCP für Connection Pooling
implementation(libs.hikari.cp) implementation(libs.hikari.cp)
// Jakarta Mail für E-Mail-Funktionalität
implementation("com.sun.mail:jakarta.mail:2.0.1")
implementation("jakarta.activation:jakarta.activation-api:2.1.2")
implementation("org.eclipse.angus:angus-activation:2.0.1")
} }
+11 -90
View File
@@ -1,18 +1,11 @@
package at.mocode package at.mocode
import at.mocode.model.Turnier
import at.mocode.plugins.configureDatabase import at.mocode.plugins.configureDatabase
import at.mocode.tables.TurniereTable import at.mocode.routes.configureAdminRoutes
import io.ktor.http.* import at.mocode.routes.configureHomeRoutes
import at.mocode.routes.configureNennungRoutes
import io.ktor.server.application.* import io.ktor.server.application.*
import io.ktor.server.html.*
import io.ktor.server.netty.* import io.ktor.server.netty.*
import io.ktor.server.routing.*
import kotlinx.html.*
import org.jetbrains.exposed.sql.insert
import org.jetbrains.exposed.sql.selectAll
import org.jetbrains.exposed.sql.transactions.transaction
import org.slf4j.LoggerFactory
@@ -20,87 +13,15 @@ fun main(args: Array<String>) {
EngineMain.main(args) EngineMain.main(args)
} }
/**
* Application module configuration.
*/
fun Application.module() { fun Application.module() {
// Configure database first
// Als Erstes die Datenbank konfigurieren:
configureDatabase() configureDatabase()
// Danach deine anderen Konfigurationen (Routing etc.): // Configure routes
routing { configureHomeRoutes()
get("/") { configureNennungRoutes()
// Logger holen (optional, aber nützlich) configureAdminRoutes()
val log = LoggerFactory.getLogger("RootRoute")
// --- Datenbankoperationen ---
// alle DB-Zugriffe mit Exposed sollten in einer Transaktion stattfinden
val turniereFromDb = transaction {
// Optional: Füge ein Test-Turnier hinzu, WENN die Tabelle leer ist.
// Das ist nur für den ersten Test praktisch.
if (TurniereTable.selectAll().count() == 0L) {
log.info("Turnier table is empty, inserting dummy tournament...")
TurniereTable.insert {
it[id] = "dummy-01" // Eindeutige ID
it[name] = "Erstes DB Turnier"
it[datum] = "19.04.2025" // Heutiges Datum?
it[logoUrl] = null // Optional, kann null sein
it[ausschreibungUrl] = "/pdfs/ausschreibung_dummy.pdf" // Beispielpfad
}
}
// Lese ALLE Einträge aus der TurniereTable
log.info("Fetching all tournaments from database...")
TurniereTable.selectAll().map { row ->
// Wandle jede Datenbank-Zeile (row) wieder in ein Turnier-Objekt um
Turnier(
id = row[TurniereTable.id],
name = row[TurniereTable.name],
datum = row[TurniereTable.datum],
logoUrl = row[TurniereTable.logoUrl],
ausschreibungUrl = row[TurniereTable.ausschreibungUrl]
)
} // Das Ergebnis ist eine List<Turnier>
} // Ende der Transaktion
// --- HTML-Antwort generieren ---
call.respondHtml(HttpStatusCode.OK) {
head {
title { +"Meldestelle Portal" }
}
body {
h1 { +"Willkommen beim Meldestelle Portal!" }
p { +"Datenbankverbindung erfolgreich!" } // Kleine Bestätigung
hr()
h2 { +"Aktuelle Turniere (aus Datenbank):" } // Geänderte Überschrift
// Gib die Turnierliste aus der Datenbank aus
ul {
if (turniereFromDb.isEmpty()) {
li { +"Keine Turniere in der Datenbank gefunden." }
} else {
// Schleife über die Liste aus der DB
turniereFromDb.forEach { turnier ->
li {
strong { +turnier.name }
+" (${turnier.datum})"
// Füge die Buttons wieder hinzu
+" "
if (turnier.ausschreibungUrl != null) {
a(href = turnier.ausschreibungUrl, target = "_blank") {
button { +"Ausschreibung" }
}
+" "
}
a(href = "/nennung/${turnier.id}") {
button { +"Online Nennen" }
}
}
}
}
}
// Link zum (noch nicht funktionierenden) Admin-Bereich
hr()
p { a(href = "/admin/tournaments") { +"Zur Turnierverwaltung (TODO)" } }
}
} // <--- HIER endet der respondHtml-Block
} // Ende get("/")
}
} }
@@ -0,0 +1,44 @@
package at.mocode.config
import at.mocode.email.EmailService
import at.mocode.repository.NennungRepository
import at.mocode.repository.TurnierRepository
import at.mocode.views.HomeView
import at.mocode.views.NennungView
import org.slf4j.LoggerFactory
/**
* Simple dependency injection container for the application.
* This singleton provides access to all services and repositories.
*/
object DependencyInjection {
private val log = LoggerFactory.getLogger(DependencyInjection::class.java)
// Repositories
val turnierRepository by lazy {
log.debug("Creating TurnierRepository")
TurnierRepository()
}
val nennungRepository by lazy {
log.debug("Creating NennungRepository")
NennungRepository()
}
// Services
val emailService by lazy {
log.debug("Creating EmailService")
EmailService.getInstance()
}
// Views
val homeView by lazy {
log.debug("Creating HomeView")
HomeView()
}
val nennungView by lazy {
log.debug("Creating NennungView")
NennungView()
}
}
@@ -0,0 +1,107 @@
package at.mocode.config
import java.io.File
import java.io.FileInputStream
import java.util.Properties
import org.slf4j.LoggerFactory
/**
* Configuration for email service.
* Loads configuration from environment variables with fallbacks to the .env file and then default values.
*/
object EmailConfig {
private val log = LoggerFactory.getLogger(EmailConfig::class.java)
// Load values from the .env file if they exist
private val envProperties = loadEnvFile()
/**
* Loads environment variables from .env file
* @return Property object containing the variables from .env file or empty Properties if a file doesn't exist
*/
private fun loadEnvFile(): Properties {
val properties = Properties()
// Try multiple possible locations for the .env file
val possibleLocations = listOf(
".env", // Current working directory
"../.env", // Parent directory
"../../.env" // Grandparent directory
)
for (location in possibleLocations) {
val envFile = File(location)
if (envFile.exists()) {
try {
FileInputStream(envFile).use { fis ->
properties.load(fis)
}
log.info("Loaded .env file from: ${envFile.absolutePath}")
break // Stop after successfully loading the file
} catch (e: Exception) {
log.error("Error loading .env file from ${envFile.absolutePath}: ${e.message}")
}
}
}
return properties
}
/**
* Helper function to get value from environment, .env file, or default
* @param key The environment variable key
* @param defaultValue The default value to use if the key is not found
* @return The value from environment, .env file, or default
*/
private fun getConfigValue(key: String, defaultValue: String): String {
val value = System.getenv(key) ?: envProperties.getProperty(key) ?: defaultValue
if (value == defaultValue) {
log.debug("Using default value for $key: $defaultValue")
}
return value
}
// SMTP server configuration
val smtpHost: String = getConfigValue("SMTP_HOST", "smtp.gmail.com")
val smtpPort: Int = System.getenv("SMTP_PORT")?.toIntOrNull()
?: envProperties.getProperty("SMTP_PORT")?.toIntOrNull()
?: 587
// Authentication credentials
val smtpUsername: String = getConfigValue("SMTP_USER", "")
val smtpPassword: String = getConfigValue("SMTP_PASSWORD", "")
// Email addresses
val recipientEmail: String = getConfigValue("RECIPIENT_EMAIL", "")
val senderEmail: String = getConfigValue("SMTP_SENDER_EMAIL", smtpUsername)
/**
* Validates that all required configuration is present.
* @return true if the configuration is valid, false otherwise
*/
fun isValid(): Boolean {
return smtpHost.isNotBlank() &&
smtpPort > 0 &&
smtpUsername.isNotBlank() &&
smtpPassword.isNotBlank() &&
recipientEmail.isNotBlank() &&
senderEmail.isNotBlank()
}
/**
* Returns a string describing any missing configuration.
* @return A string with error messages or empty string if the configuration is valid
*/
fun getValidationErrors(): String {
val errors = mutableListOf<String>()
if (smtpHost.isBlank()) errors.add("SMTP_HOST is not configured")
if (smtpPort <= 0) errors.add("SMTP_PORT is invalid")
if (smtpUsername.isBlank()) errors.add("SMTP_USER is not configured")
if (smtpPassword.isBlank()) errors.add("SMTP_PASSWORD is not configured")
if (recipientEmail.isBlank()) errors.add("RECIPIENT_EMAIL is not configured")
if (senderEmail.isBlank()) errors.add("SMTP_SENDER_EMAIL is not configured")
return errors.joinToString(", ")
}
}
@@ -0,0 +1,298 @@
package at.mocode.email
import at.mocode.config.EmailConfig
import at.mocode.model.Nennung
import jakarta.mail.*
import jakarta.mail.internet.InternetAddress
import jakarta.mail.internet.MimeBodyPart
import jakarta.mail.internet.MimeMessage
import jakarta.mail.internet.MimeMultipart
import java.util.*
import java.util.concurrent.locks.ReentrantLock
import kotlin.concurrent.withLock
import org.slf4j.LoggerFactory
/**
* Service for sending email notifications for form submissions.
* Implemented as a singleton to avoid multiple initializations.
* Thread-safe implementation with improved error handling and HTML support.
*/
class EmailService private constructor(
private val smtpHost: String,
private val smtpPort: Int,
private val smtpUsername: String,
private val smtpPassword: String,
private val recipientEmail: String,
private val senderEmail: String = smtpUsername
) {
private val log = LoggerFactory.getLogger(EmailService::class.java)
private val maxRetries = 3
private val retryDelayMs = 1000L
companion object {
private var instance: EmailService? = null
private val lock = ReentrantLock()
/**
* Gets the singleton instance of EmailService.
* Initializes it if it doesn't exist yet.
* Thread-safe implementation using a lock.
*/
fun getInstance(): EmailService {
return lock.withLock {
if (instance == null) {
instance = EmailService(
smtpHost = EmailConfig.smtpHost,
smtpPort = EmailConfig.smtpPort,
smtpUsername = EmailConfig.smtpUsername,
smtpPassword = EmailConfig.smtpPassword,
recipientEmail = EmailConfig.recipientEmail,
senderEmail = EmailConfig.senderEmail
)
}
instance!!
}
}
}
/**
* Creates email session with the configured properties.
*
* @param debug Whether to enable debug mode for the mail session
* @return The configured mail session
*/
private fun createSession(debug: Boolean = false): Session {
val properties = Properties().apply {
put("mail.smtp.auth", "true")
put("mail.smtp.starttls.enable", "true")
put("mail.smtp.host", smtpHost)
put("mail.smtp.port", smtpPort.toString())
put("mail.debug", debug.toString())
put("mail.smtp.ssl.protocols", "TLSv1.2")
put("mail.smtp.connectiontimeout", "10000")
put("mail.smtp.timeout", "10000")
}
return Session.getInstance(properties, object : Authenticator() {
override fun getPasswordAuthentication(): PasswordAuthentication {
return PasswordAuthentication(smtpUsername, smtpPassword)
}
})
}
/**
* Validates an email address format.
*
* @param email The email address to validate
* @return true if the email format is valid, false otherwise
*/
private fun isValidEmail(email: String): Boolean {
val emailRegex = Regex("^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,6}$")
return email.matches(emailRegex)
}
/**
* Sends an email notification with the form submission data.
* Includes retry mechanism for transient failures.
*
* @param nennung The form submission data
* @return true if the email was sent successfully, false otherwise
*/
fun sendNennungEmail(nennung: Nennung): Boolean {
log.info("Attempting to send email for ${nennung.riderName} with ${nennung.horseName}")
log.debug("SMTP Configuration: Host=$smtpHost, Port=$smtpPort, Username=$smtpUsername")
log.debug("Email addresses: From=$senderEmail, To=$recipientEmail")
// Validate email addresses
if (!isValidEmail(senderEmail) || !isValidEmail(recipientEmail)) {
log.error("Invalid email address format: sender=$senderEmail, recipient=$recipientEmail")
return false
}
var attempts = 0
var lastException: Exception? = null
while (attempts < maxRetries) {
attempts++
try {
val session = createSession()
val message = MimeMessage(session).apply {
setFrom(InternetAddress(senderEmail))
setRecipients(Message.RecipientType.TO, InternetAddress.parse(recipientEmail))
subject = nennung.turnier?.let { "Neue Nennung für ${it.name}: ${nennung.riderName} mit ${nennung.horseName}" }
?: "Neue Nennung: ${nennung.riderName} mit ${nennung.horseName}"
// Create multipart message with both plain text and HTML versions
val multipart = MimeMultipart("alternative")
// Plain text part
val textPart = MimeBodyPart().apply {
setText(createEmailContent(nennung, false))
}
multipart.addBodyPart(textPart)
// HTML part
val htmlPart = MimeBodyPart().apply {
setContent(createEmailContent(nennung, true), "text/html; charset=utf-8")
}
multipart.addBodyPart(htmlPart)
// Set content to the multipart
setContent(multipart)
}
log.info("Email message prepared, attempting to send... (Attempt $attempts of $maxRetries)")
Transport.send(message)
log.info("Email sent successfully!")
return true
} catch (e: MessagingException) {
lastException = e
log.warn("Failed to send email (Attempt $attempts of $maxRetries): ${e.message}")
if (attempts < maxRetries) {
log.info("Retrying in ${retryDelayMs}ms...")
Thread.sleep(retryDelayMs)
}
} catch (e: Exception) {
lastException = e
log.error("Unexpected error sending email: ${e.message}", e)
break // Don't retry on non-messaging exceptions
}
}
log.error("Failed to send email after $attempts attempts", lastException)
return false
}
/**
* Sends a test email to verify the email configuration.
*
* @param recipient Optional recipient email address (defaults to configured recipient)
* @param debug Whether to enable debug mode for the mail session
* @return true if the test email was sent successfully, false otherwise
*/
fun sendTestEmail(recipient: String = recipientEmail, debug: Boolean = false): Boolean {
log.info("Sending test email to $recipient")
if (!isValidEmail(recipient)) {
log.error("Invalid recipient email address format: $recipient")
return false
}
try {
val session = createSession(debug)
val message = MimeMessage(session).apply {
setFrom(InternetAddress(senderEmail))
setRecipients(Message.RecipientType.TO, InternetAddress.parse(recipient))
subject = "Test Email from Meldestelle Application"
val multipart = MimeMultipart("alternative")
// Plain text part
val textPart = MimeBodyPart().apply {
setText("Dies ist eine Test-Email vom Meldestelle System.\n\nWenn Sie diese Email erhalten haben, funktioniert die Email-Konfiguration korrekt.")
}
multipart.addBodyPart(textPart)
// HTML part
val htmlPart = MimeBodyPart().apply {
setContent("""
<html>
<body>
<h2>Dies ist eine Test-Email vom Meldestelle System</h2>
<p>Wenn Sie diese Email erhalten haben, funktioniert die Email-Konfiguration korrekt.</p>
<p>SMTP-Konfiguration:</p>
<ul>
<li>Host: $smtpHost</li>
<li>Port: $smtpPort</li>
<li>Benutzername: $smtpUsername</li>
</ul>
</body>
</html>
""".trimIndent(), "text/html; charset=utf-8")
}
multipart.addBodyPart(htmlPart)
setContent(multipart)
}
log.info("Test email prepared, attempting to send...")
Transport.send(message)
log.info("Test email sent successfully!")
return true
} catch (e: MessagingException) {
log.error("Failed to send test email: ${e.message}", e)
return false
} catch (e: Exception) {
log.error("Unexpected error sending test email: ${e.message}", e)
return false
}
}
/**
* Creates the email content from the form submission data.
*
* @param nennung The form submission data
* @param html Whether to generate HTML content (true) or plain text (false)
* @return The formatted email content
*/
private fun createEmailContent(nennung: Nennung, html: Boolean = false): String {
if (!html) {
// Plain text version
val sb = StringBuilder()
// Add tournament information if available
nennung.turnier?.let { turnier ->
sb.appendLine(turnier.name)
sb.appendLine()
}
sb.appendLine("Reiter: ${nennung.riderName}")
sb.appendLine("Pferd: ${nennung.horseName}")
sb.appendLine("E-Mail: ${nennung.email}")
sb.appendLine("Telefon: ${nennung.phone}")
sb.appendLine()
sb.appendLine("Bewerbe:")
nennung.selectedEvents.forEach { event ->
sb.appendLine("- $event")
}
sb.appendLine()
if (nennung.comments.isNotBlank()) {
sb.appendLine("Bemerkungen:")
sb.appendLine(nennung.comments)
}
return sb.toString()
} else {
// HTML version
val sb = StringBuilder()
sb.append("<html><body>")
// Add tournament information if available
nennung.turnier?.let { turnier ->
sb.append("<h2>${turnier.name}</h2>")
}
sb.append("<p><strong>Reiter:</strong> ${nennung.riderName}</p>")
sb.append("<p><strong>Pferd:</strong> ${nennung.horseName}</p>")
sb.append("<p><strong>E-Mail:</strong> <a href=\"mailto:${nennung.email}\">${nennung.email}</a></p>")
sb.append("<p><strong>Telefon:</strong> ${nennung.phone}</p>")
sb.append("<h3>Bewerbe:</h3>")
sb.append("<ul>")
nennung.selectedEvents.forEach { event ->
sb.append("<li>$event</li>")
}
sb.append("</ul>")
if (nennung.comments.isNotBlank()) {
sb.append("<h3>Bemerkungen:</h3>")
sb.append("<p>${nennung.comments.replace("\n", "<br>")}</p>")
}
sb.append("</body></html>")
return sb.toString()
}
}
}
@@ -0,0 +1,22 @@
package at.mocode.model
import kotlinx.serialization.Serializable
/**
* Represents a competition (Bewerb) within a tournament.
* A competition has specific details like number, title, class, and optional task.
*/
@Serializable
data class Bewerb(
/** Competition number, e.g. 1, 2, etc. */
val nummer: Int,
/** Title of the competition, e.g. "Stilspringprüfung" or "Dressurprüfung" */
val titel: String,
/** Class/level of the competition, e.g. "60 cm" or "Kl. A" */
val klasse: String = "",
/** Optional task identifier, e.g. "DRA 1" */
val task: String? = null
)
@@ -4,13 +4,24 @@ import kotlinx.serialization.Serializable
@Serializable @Serializable
data class Nennung( data class Nennung(
// Wir brauchen die Turnier-ID, um die Nennung zuzuordnen /** Name of the rider */
val turnierId: String, val riderName: String,
// Einfache Felder für den Start
val riderName: String = "", // Standardwerte für leeres Formular /** Name of the horse */
val horseName: String = "", val horseName: String,
val email: String = "",
val comments: String? = null /** Email address for contact */
// Hier kommen später Felder hinzu: Verein, Lizenznr., Tel, val email: String,
// und vor allem: die Auswahl der Prüfungen!
/** Phone number for contact */
val phone: String,
/** List of selected event numbers */
val selectedEvents: List<String>,
/** Additional comments or wishes */
val comments: String,
/** The tournament this registration is for */
val turnier: Turnier
) )
@@ -4,11 +4,15 @@ import kotlinx.serialization.Serializable
@Serializable @Serializable
data class Turnier( data class Turnier(
val id: String, // Eine eindeutige ID für das Turnier (z.B. eine UUID als String) /** The name of the tournament, e.g. "CSN-C NEU CSNP-C NEU NEUMARKT/M., OÖ" */
val name: String, // Der Name, z.B. "CDN-C Edelhof April 2025" val name: String,
val datum: String, // Das Datum oder der Zeitraum, erstmal als Text, z.B. "14.04.2025 - 15.04.2025"
val logoUrl: String? = null, // Optional: Link zum Logo des Veranstalters /** The date of the tournament as a formatted string, e.g. "7.JUNI 2025" */
val ausschreibungUrl: String? = null // Optional: Link zur Ausschreibungs-PDF val datum: String,
// Hier können später viele weitere Felder hinzukommen:
// Ort, Veranstalter, Status (geplant, läuft, beendet), Disziplinen etc. /** Unique identifier for the tournament */
val number: Int,
/** List of competitions (Bewerbe) associated with this tournament */
var bewerbe: List<Bewerb> = emptyList()
) )
@@ -4,52 +4,67 @@ import com.zaxxer.hikari.HikariConfig
import com.zaxxer.hikari.HikariDataSource import com.zaxxer.hikari.HikariDataSource
import org.jetbrains.exposed.sql.Database import org.jetbrains.exposed.sql.Database
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import org.jetbrains.exposed.sql.SchemaUtils import org.jetbrains.exposed.sql.SchemaUtils
import org.jetbrains.exposed.sql.transactions.transaction import org.jetbrains.exposed.sql.transactions.transaction
import at.mocode.tables.TurniereTable import at.mocode.tables.TurniereTable
import at.mocode.tables.BewerbeTable
import at.mocode.tables.NennungenTable
import at.mocode.tables.NennungEventsTable
import java.io.File
/**
* Configures the database connection based on the environment.
* Supports three environments:
* - Test: Uses in-memory SQLite
* - Development (IDEA): Uses file-based SQLite
* - Production/Docker: Uses PostgreSQL with connection pooling
*/
fun configureDatabase() { fun configureDatabase() {
val log = LoggerFactory.getLogger("DatabaseInitialization") val log = LoggerFactory.getLogger("DatabaseInitialization")
var connectionSuccessful = false // Flag: Wurde irgendeine Verbindung hergestellt? var connectionSuccessful = false // Flag: Was any connection established?
// Prüfen, ob wir in einer Testumgebung sind (z.B. über System Property) // Check if we're in a test environment (e.g. via System Property)
val isTestEnvironment = System.getProperty("isTestEnvironment")?.toBoolean() ?: false val isTestEnvironment = System.getProperty("isTestEnvironment")?.toBoolean() ?: false
if (isTestEnvironment) { if (isTestEnvironment) {
log.info("Test environment detected, using in-memory H2 database (test)...") log.info("Test environment detected, using SQLite in-memory database (test)...")
try { try {
// H2 im PostgreSQL-Kompatibilitätsmodus starten, kann helfen Database.connect("jdbc:sqlite::memory:", driver = "org.sqlite.JDBC")
Database.connect("jdbc:h2:mem:test;DB_CLOSE_DELAY=-1;MODE=PostgreSQL", driver = "org.h2.Driver") log.info("Connected to SQLite in-memory (test) successfully.")
log.info("Connected to H2 (test) successfully.")
connectionSuccessful = true connectionSuccessful = true
} catch (e: Exception) { } catch (e: Exception) {
log.error("Failed to connect to H2 (test)!", e) log.error("Failed to connect to SQLite (test)!", e)
throw e // Fehler weiterwerfen, Test soll fehlschlagen throw e // Rethrow error, test should fail
} }
} else { } else {
// Prüfen, ob wir in IDEA laufen (keine Docker Umgebungsvariablen gesetzt) // Check if we're running in IDEA (no Docker environment variables set)
// wir prüfen nur eine Variable, das reicht meistens // We only check one variable, that's usually enough
val dbHostFromEnv = System.getenv("DB_HOST") val dbHostFromEnv = System.getenv("DB_HOST")
val isIdeaEnvironment = (dbHostFromEnv == null) val isIdeaEnvironment = (dbHostFromEnv == null)
if (isIdeaEnvironment) { if (isIdeaEnvironment) {
log.info("IDEA environment detected (missing DB_HOST), using in-memory H2 database (dev)...") // Ensure the data directory exists
val dataDir = File("data")
if (!dataDir.exists()) {
dataDir.mkdir()
}
log.info("IDEA environment detected (missing DB_HOST), using SQLite file database (dev)...")
try { try {
Database.connect("jdbc:h2:mem:dev;DB_CLOSE_DELAY=-1;MODE=PostgreSQL", driver = "org.h2.Driver") Database.connect("jdbc:sqlite:data/meldestelle.db", driver = "org.sqlite.JDBC")
log.info("Connected to H2 (dev) successfully.") log.info("Connected to SQLite file database (dev) successfully.")
connectionSuccessful = true connectionSuccessful = true
} catch (e: Exception) { } catch (e: Exception) {
log.error("Failed to connect to H2 (dev)!", e) log.error("Failed to connect to SQLite (dev)!", e)
// Hier vielleicht nicht werfen, damit App in IDE trotzdem startet? Oder doch? → Aktuell wirft es. // Maybe don't throw here so the app starts in IDE anyway? Currently it throws.
throw e throw e
} }
} else { } else {
// Normale Docker/Produktionsumgebung -> PostgreSQL verwenden // Normal Docker/Production environment -> use PostgreSQL
log.info("Production/Docker environment detected, connecting to PostgreSQL...") log.info("Production/Docker environment detected, connecting to PostgreSQL...")
try { try {
// Lese Konfiguration direkt aus Umgebungsvariablen // Read configuration directly from environment variables
val dbHost = dbHostFromEnv // Sicherer Fallback val dbHost = dbHostFromEnv // Safe fallback
val dbPort = System.getenv("DB_PORT") ?: "5432" val dbPort = System.getenv("DB_PORT") ?: "5432"
val dbName = System.getenv("DB_NAME") ?: error("DB_NAME not set in environment") val dbName = System.getenv("DB_NAME") ?: error("DB_NAME not set in environment")
val dbUser = System.getenv("DB_USER") ?: error("DB_USER not set in environment") val dbUser = System.getenv("DB_USER") ?: error("DB_USER not set in environment")
@@ -74,34 +89,24 @@ fun configureDatabase() {
connectionSuccessful = true connectionSuccessful = true
} catch (e: Exception) { } catch (e: Exception) {
log.error("Failed to initialize PostgreSQL connection pool!", e) log.error("Failed to initialize PostgreSQL connection pool!", e)
throw e // Fehler weiterwerfen, App soll nicht starten ohne DB in Prod throw e // Rethrow error, app should not start without DB in prod
} }
} }
} }
// --- Schema Initialisierung (JETZT ZENTRALISIERT) --- // --- Schema Initialization (NOW CENTRALIZED) ---
// Führe dies nur aus, wenn *irgendeine* DB-Verbindung erfolgreich war // Only execute this if *any* DB connection was successful
transaction { // Führe Schema-Operationen in einer Transaktion aus transaction { // Execute schema operations in a transaction
log.info("Initializing/Verifying database schema...") log.info("Initializing/Verifying database schema...")
try { try {
// Erstellt die Tabelle(n), falls sie noch nicht existieren // Create the table(s) if they don't exist yet
SchemaUtils.create(TurniereTable) SchemaUtils.create(TurniereTable, BewerbeTable, NennungenTable, NennungEventsTable)
// Füge hier später weitere Tabellen hinzu:
// SchemaUtils.create(TurniereTable, NennungenTable, ...)
log.info("Database schema initialized successfully (tables created/verified).") log.info("Database schema initialized successfully (tables created/verified).")
} catch (e: Exception) { } catch (e: Exception) {
log.error("Failed to initialize database schema!", e) log.error("Failed to initialize database schema!", e)
// Hier könntest du entscheiden, ob ein Fehler beim Schema kritisch ist // Here you could decide if a schema error is critical
// throw e // Auskommentiert: App startet evtl. trotzdem, auch wenn Schema fehlt/falsch ist // throw e // Commented out: App might start anyway, even if schema is missing/wrong
} }
} }
// --- TODO für den NÄCHSTEN Schritt ---
// Hier kommt später die Logik zum Erstellen der Tabellen hin,
// z.B. innerhalb einer Transaktion:
// transaction {
// SchemaUtils.create(TurniereTable) // Erstellt die Tabelle, wenn sie nicht existiert
// }
// ------------------------------------
} }
@@ -0,0 +1,80 @@
package at.mocode.repository
import at.mocode.config.DependencyInjection
import at.mocode.model.Nennung
import at.mocode.tables.NennungEventsTable
import at.mocode.tables.NennungenTable
import org.jetbrains.exposed.sql.insert
import org.jetbrains.exposed.sql.transactions.transaction
import org.slf4j.LoggerFactory
/**
* Repository class for handling registration-related database operations.
*/
class NennungRepository {
private val log = LoggerFactory.getLogger(this::class.java)
private val turnierRepository by lazy { DependencyInjection.turnierRepository }
/**
* Saves a registration to the database.
* @param nennung The registration to save
* @return The ID of the saved registration
*/
fun saveNennung(nennung: Nennung): Int = transaction {
log.info("Saving registration for ${nennung.riderName} with ${nennung.horseName}")
// Insert the registration
val nennungId = NennungenTable.insert {
it[riderName] = nennung.riderName
it[horseName] = nennung.horseName
it[email] = nennung.email
it[phone] = nennung.phone
it[comments] = nennung.comments
it[turnierNumber] = nennung.turnier.number
} get NennungenTable.id
// Insert the selected events
nennung.selectedEvents.forEach { eventNumber ->
NennungEventsTable.insert {
it[NennungEventsTable.nennungId] = nennungId
it[NennungEventsTable.eventNumber] = eventNumber
}
}
log.info("Registration saved with ID: $nennungId")
nennungId
}
/**
* Creates a Nennung object from form parameters.
* @param riderName The name of the rider
* @param horseName The name of the horse
* @param email The email address for contact
* @param phone The phone number for contact
* @param selectedEvents The list of selected event numbers
* @param comments Additional comments or wishes
* @param turnierNumber The number of the tournament
* @return The created Nennung object, or null if the tournament was not found
*/
fun createNennung(
riderName: String,
horseName: String,
email: String,
phone: String,
selectedEvents: List<String>,
comments: String,
turnierNumber: Int
): Nennung? {
val turnier = turnierRepository.getTurnierByNumber(turnierNumber) ?: return null
return Nennung(
riderName = riderName,
horseName = horseName,
email = email,
phone = phone,
selectedEvents = selectedEvents,
comments = comments,
turnier = turnier
)
}
}
@@ -0,0 +1,246 @@
package at.mocode.repository
import at.mocode.model.Bewerb
import at.mocode.model.Turnier
import at.mocode.tables.BewerbeTable
import at.mocode.tables.TurniereTable
import org.jetbrains.exposed.sql.*
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
import org.jetbrains.exposed.sql.selectAll
import org.jetbrains.exposed.sql.transactions.transaction
import org.slf4j.LoggerFactory
/**
* Repository class for handling tournament-related database operations.
*/
class TurnierRepository {
private val log = LoggerFactory.getLogger(this::class.java)
/**
* Creates a new tournament with competitions.
* @param number The tournament number
* @param name The tournament name
* @param datum The tournament date
* @param bewerbe List of competitions for the tournament
* @return The created tournament or null if creation failed
*/
fun createTurnier(number: Int, name: String, datum: String, bewerbe: List<Bewerb>): Turnier? = transaction {
try {
// Check if a tournament with this number already exists
val existingTurnier = TurniereTable.select { TurniereTable.number eq number }.singleOrNull()
if (existingTurnier != null) {
log.error("Tournament with number $number already exists")
return@transaction null
}
// Insert tournament
TurniereTable.insert {
it[TurniereTable.number] = number
it[TurniereTable.name] = name
it[TurniereTable.datum] = datum
}
// Insert competitions
bewerbe.forEach { bewerb ->
BewerbeTable.insert {
it[BewerbeTable.nummer] = bewerb.nummer
it[BewerbeTable.titel] = bewerb.titel
it[BewerbeTable.klasse] = bewerb.klasse
it[BewerbeTable.task] = bewerb.task
it[BewerbeTable.turnierNumber] = number
}
}
// Return the created tournament
Turnier(
number = number,
name = name,
datum = datum,
bewerbe = bewerbe
)
} catch (e: Exception) {
log.error("Error creating tournament", e)
null
}
}
/**
* Updates an existing tournament with competitions.
* @param number The tournament number
* @param name The new tournament name
* @param datum The new tournament date
* @param bewerbe The new list of competitions
* @return The updated tournament or null if update failed
*/
fun updateTurnier(number: Int, name: String, datum: String, bewerbe: List<Bewerb>): Turnier? = transaction {
try {
// Check if the tournament exists
val existingTurnier = TurniereTable.select { TurniereTable.number eq number }.singleOrNull()
if (existingTurnier == null) {
log.error("Tournament with number $number not found")
return@transaction null
}
// Update tournament
TurniereTable.update({ TurniereTable.number eq number }) {
it[TurniereTable.name] = name
it[TurniereTable.datum] = datum
}
// Delete existing competitions
BewerbeTable.deleteWhere { BewerbeTable.turnierNumber eq number }
// Insert new competitions
bewerbe.forEach { bewerb ->
BewerbeTable.insert {
it[BewerbeTable.nummer] = bewerb.nummer
it[BewerbeTable.titel] = bewerb.titel
it[BewerbeTable.klasse] = bewerb.klasse
it[BewerbeTable.task] = bewerb.task
it[BewerbeTable.turnierNumber] = number
}
}
// Return the updated tournament
Turnier(
number = number,
name = name,
datum = datum,
bewerbe = bewerbe
)
} catch (e: Exception) {
log.error("Error updating tournament", e)
null
}
}
/**
* Retrieves all tournaments from the database with their associated competitions.
* @return List of Turnier objects
*/
fun getAllTurniere(): List<Turnier> = transaction {
log.info("Fetching all tournaments from database...")
// Get all tournaments
val turniere = TurniereTable.selectAll().map { row ->
Turnier(
name = row[TurniereTable.name],
datum = row[TurniereTable.datum],
number = row[TurniereTable.number]
)
}
// For each tournament, get its competitions
turniere.forEach { turnier ->
val bewerbeList = BewerbeTable.selectAll().where { BewerbeTable.turnierNumber eq turnier.number }.map { row ->
Bewerb(
nummer = row[BewerbeTable.nummer],
titel = row[BewerbeTable.titel],
klasse = row[BewerbeTable.klasse],
task = row[BewerbeTable.task]
)
}
turnier.bewerbe = bewerbeList
}
turniere
}
/**
* Inserts dummy tournaments with competitions if the table is empty.
*/
fun insertDummyTurnierIfEmpty() = transaction {
if (TurniereTable.selectAll().count() == 0L) {
log.info("Turnier table is empty, inserting dummy tournaments...")
// Insert first tournament
val turnierNumber1 = 25319
TurniereTable.insert {
it[TurniereTable.number] = turnierNumber1
it[TurniereTable.name] = "CSN-C Edelhof April 2025"
it[TurniereTable.datum] = "14.04.2025 - 15.04.2025"
}
// Insert competitions for first tournament
BewerbeTable.insert {
it[BewerbeTable.nummer] = 1
it[BewerbeTable.titel] = "Stilspringprüfung"
it[BewerbeTable.klasse] = "60 cm"
it[BewerbeTable.task] = null
it[BewerbeTable.turnierNumber] = turnierNumber1
}
BewerbeTable.insert {
it[BewerbeTable.nummer] = 2
it[BewerbeTable.titel] = "Dressurprüfung"
it[BewerbeTable.klasse] = "Kl. A"
it[BewerbeTable.task] = "DRA 1"
it[BewerbeTable.turnierNumber] = turnierNumber1
}
// Insert second tournament (as specified in the issue description)
val turnierNumber2 = 25320
TurniereTable.insert {
it[TurniereTable.number] = turnierNumber2
it[TurniereTable.name] = "CDN-C CDNP-C NEU Neumarkt/M., OÖ"
it[TurniereTable.datum] = "8. JUNI 2025"
}
// Insert competitions for second tournament
BewerbeTable.insert {
it[BewerbeTable.nummer] = 1
it[BewerbeTable.titel] = "Dressurprüfung"
it[BewerbeTable.klasse] = "Kl. A"
it[BewerbeTable.task] = "DRA 2"
it[BewerbeTable.turnierNumber] = turnierNumber2
}
BewerbeTable.insert {
it[BewerbeTable.nummer] = 2
it[BewerbeTable.titel] = "Dressurprüfung"
it[BewerbeTable.klasse] = "Kl. L"
it[BewerbeTable.task] = "DRL 1"
it[BewerbeTable.turnierNumber] = turnierNumber2
}
BewerbeTable.insert {
it[BewerbeTable.nummer] = 3
it[BewerbeTable.titel] = "Dressurprüfung"
it[BewerbeTable.klasse] = "Kl. L"
it[BewerbeTable.task] = "DRL 2"
it[BewerbeTable.turnierNumber] = turnierNumber2
}
log.info("Dummy tournaments and competitions inserted successfully.")
}
}
/**
* Gets a tournament by its number.
* @param number The tournament number
* @return The tournament or null if not found
*/
fun getTurnierByNumber(number: Int): Turnier? = transaction {
// Get the tournament
val turnierRow = TurniereTable.selectAll().where { TurniereTable.number eq number }.singleOrNull() ?: return@transaction null
val turnier = Turnier(
name = turnierRow[TurniereTable.name],
datum = turnierRow[TurniereTable.datum],
number = turnierRow[TurniereTable.number]
)
// Get competitions for this tournament
val bewerbeList = BewerbeTable.selectAll().where { BewerbeTable.turnierNumber eq number }.map { row ->
Bewerb(
nummer = row[BewerbeTable.nummer],
titel = row[BewerbeTable.titel],
klasse = row[BewerbeTable.klasse],
task = row[BewerbeTable.task]
)
}
turnier.bewerbe = bewerbeList
turnier
}
}
@@ -0,0 +1,162 @@
package at.mocode.routes
import at.mocode.model.Bewerb
import at.mocode.repository.TurnierRepository
import at.mocode.views.AdminView
import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import org.slf4j.LoggerFactory
/**
* Configures routes for tournament administration.
*/
fun Route.adminRoutes() {
val log = LoggerFactory.getLogger("AdminRoutes")
val turnierRepository = TurnierRepository()
val adminView = AdminView()
// Route to display the tournament management page
get("/admin/tournaments") {
log.info("Handling request to tournament management page")
// Get all tournaments
val turniere = turnierRepository.getAllTurniere()
// Render the tournament management page
adminView.renderTournamentManagementPage(call, turniere)
}
// Route to display the tournament edit page
get("/admin/tournaments/edit/{number}") {
val turnierNumber = call.parameters["number"]?.toIntOrNull()
if (turnierNumber == null) {
call.respond(HttpStatusCode.BadRequest, "Invalid tournament number")
return@get
}
log.info("Displaying edit form for tournament $turnierNumber")
// Get the tournament
val turnier = turnierRepository.getTurnierByNumber(turnierNumber)
if (turnier == null) {
call.respond(HttpStatusCode.NotFound, "Tournament not found")
return@get
}
// Render the edit form
adminView.renderTournamentEditPage(call, turnier)
}
// Route to handle tournament creation
post("/admin/tournaments/add") {
log.info("Processing tournament creation")
// Parse form parameters
val formParameters = call.receiveParameters()
val number = formParameters["number"]?.toIntOrNull()
val name = formParameters["name"]
val datum = formParameters["datum"]
// Validate required fields
if (number == null || name.isNullOrBlank() || datum.isNullOrBlank()) {
call.respond(HttpStatusCode.BadRequest, "All fields are required")
return@post
}
// Parse competitions
val bewerbNummern = formParameters.getAll("bewerb-nummer[]")?.mapNotNull { it.toIntOrNull() } ?: emptyList()
val bewerbTitel = formParameters.getAll("bewerb-titel[]") ?: emptyList()
val bewerbKlasse = formParameters.getAll("bewerb-klasse[]") ?: emptyList()
val bewerbTask = formParameters.getAll("bewerb-task[]") ?: emptyList()
// Create list of competitions
val bewerbe = mutableListOf<Bewerb>()
for (i in bewerbNummern.indices) {
if (i < bewerbTitel.size && i < bewerbKlasse.size && i < bewerbTask.size) {
bewerbe.add(
Bewerb(
nummer = bewerbNummern[i],
titel = bewerbTitel[i],
klasse = bewerbKlasse[i],
task = if (bewerbTask[i].isNotBlank()) bewerbTask[i] else null
)
)
}
}
// Create the tournament
val turnier = turnierRepository.createTurnier(number, name, datum, bewerbe)
if (turnier == null) {
call.respond(HttpStatusCode.InternalServerError, "Failed to create tournament")
return@post
}
// Redirect to tournament management page
call.respondRedirect("/admin/tournaments")
}
// Route to handle tournament update
post("/admin/tournaments/update/{number}") {
val turnierNumber = call.parameters["number"]?.toIntOrNull()
if (turnierNumber == null) {
call.respond(HttpStatusCode.BadRequest, "Invalid tournament number")
return@post
}
log.info("Processing tournament update for tournament $turnierNumber")
// Parse form parameters
val formParameters = call.receiveParameters()
val name = formParameters["name"]
val datum = formParameters["datum"]
// Validate required fields
if (name.isNullOrBlank() || datum.isNullOrBlank()) {
call.respond(HttpStatusCode.BadRequest, "All fields are required")
return@post
}
// Parse competitions
val bewerbNummern = formParameters.getAll("bewerb-nummer[]")?.mapNotNull { it.toIntOrNull() } ?: emptyList()
val bewerbTitel = formParameters.getAll("bewerb-titel[]") ?: emptyList()
val bewerbKlasse = formParameters.getAll("bewerb-klasse[]") ?: emptyList()
val bewerbTask = formParameters.getAll("bewerb-task[]") ?: emptyList()
// Create list of competitions
val bewerbe = mutableListOf<Bewerb>()
for (i in bewerbNummern.indices) {
if (i < bewerbTitel.size && i < bewerbKlasse.size && i < bewerbTask.size) {
bewerbe.add(
Bewerb(
nummer = bewerbNummern[i],
titel = bewerbTitel[i],
klasse = bewerbKlasse[i],
task = if (bewerbTask[i].isNotBlank()) bewerbTask[i] else null
)
)
}
}
// Update the tournament
val turnier = turnierRepository.updateTurnier(turnierNumber, name, datum, bewerbe)
if (turnier == null) {
call.respond(HttpStatusCode.InternalServerError, "Failed to update tournament")
return@post
}
// Redirect to tournament management page
call.respondRedirect("/admin/tournaments")
}
}
/**
* Extension function to register admin routes.
*/
fun Application.configureAdminRoutes() {
routing {
adminRoutes()
}
}
@@ -0,0 +1,37 @@
package at.mocode.routes
import at.mocode.config.DependencyInjection
import io.ktor.server.application.*
import io.ktor.server.routing.*
import org.slf4j.LoggerFactory
/**
* Configures routes for the home page.
*/
fun Route.homeRoutes() {
val log = LoggerFactory.getLogger("HomeRoutes")
val turnierRepository = DependencyInjection.turnierRepository
val homeView = DependencyInjection.homeView
get("/") {
log.info("Handling request to home page")
// Insert dummy tournament if needed
turnierRepository.insertDummyTurnierIfEmpty()
// Get all tournaments
val turniere = turnierRepository.getAllTurniere()
// Render the home page
homeView.renderHomePage(call, turniere)
}
}
/**
* Extension function to register home routes.
*/
fun Application.configureHomeRoutes() {
routing {
homeRoutes()
}
}
@@ -0,0 +1,119 @@
package at.mocode.routes
import at.mocode.config.DependencyInjection
import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import org.slf4j.LoggerFactory
/**
* Configures routes for tournament registration forms.
*/
fun Route.nennungRoutes() {
val log = LoggerFactory.getLogger("NennungRoutes")
val turnierRepository = DependencyInjection.turnierRepository
val nennungRepository = DependencyInjection.nennungRepository
val nennungView = DependencyInjection.nennungView
val emailService = DependencyInjection.emailService
// Route to display the registration form for a specific tournament
get("/nennung/{number}") {
val turnierNumber = call.parameters["number"]?.toIntOrNull()
if (turnierNumber == null) {
call.respond(HttpStatusCode.BadRequest, "Invalid tournament number")
return@get
}
log.info("Displaying registration form for tournament $turnierNumber")
// Get the tournament
val turnier = turnierRepository.getTurnierByNumber(turnierNumber)
if (turnier == null) {
call.respond(HttpStatusCode.NotFound, "Tournament not found")
return@get
}
// Render the registration form
nennungView.renderNennungForm(call, turnier)
}
// Route to handle form submissions
post("/nennung/{number}/submit") {
val turnierNumber = call.parameters["number"]?.toIntOrNull()
if (turnierNumber == null) {
call.respond(HttpStatusCode.BadRequest, "Invalid tournament number")
return@post
}
log.info("Processing registration form submission for tournament $turnierNumber")
// Get the tournament
val turnier = turnierRepository.getTurnierByNumber(turnierNumber)
if (turnier == null) {
call.respond(HttpStatusCode.NotFound, "Tournament not found")
return@post
}
// Parse form parameters
val formParameters = call.receiveParameters()
val riderName = formParameters["riderName"] ?: ""
val horseName = formParameters["horseName"] ?: ""
val email = formParameters["email"] ?: ""
val phone = formParameters["phone"] ?: ""
val comments = formParameters["comments"] ?: ""
val selectedEvents = formParameters.getAll("selectedEvents") ?: emptyList()
// Validate required fields
if (riderName.isBlank() || horseName.isBlank()) {
call.respond(HttpStatusCode.BadRequest, "Rider name and horse name are required")
return@post
}
// Validate that at least one contact method is provided
if (email.isBlank() && phone.isBlank()) {
call.respond(HttpStatusCode.BadRequest, "Either email or phone number is required")
return@post
}
// Create Nennung object using repository
val nennung = nennungRepository.createNennung(
riderName = riderName,
horseName = horseName,
email = email,
phone = phone,
selectedEvents = selectedEvents,
comments = comments,
turnierNumber = turnierNumber
)
if (nennung == null) {
call.respond(HttpStatusCode.InternalServerError, "Failed to create registration")
return@post
}
// Save the registration to the database
val nennungId = nennungRepository.saveNennung(nennung)
log.info("Registration saved with ID: $nennungId")
// Send email notification
val emailSent = emailService.sendNennungEmail(nennung)
if (!emailSent) {
log.error("Failed to send email notification for registration: $riderName with $horseName")
// Continue anyway, we don't want to block the user if email fails
}
// Render confirmation page
nennungView.renderConfirmationPage(call, turnier, riderName, horseName)
}
}
/**
* Extension function to register nennung routes.
*/
fun Application.configureNennungRoutes() {
routing {
nennungRoutes()
}
}
+129 -17
View File
@@ -3,30 +3,142 @@ package at.mocode.tables
import org.jetbrains.exposed.sql.Table import org.jetbrains.exposed.sql.Table
import org.jetbrains.exposed.sql.Column import org.jetbrains.exposed.sql.Column
// Definiert die Struktur der Tabelle "turniere" in der Datenbank /**
object TurniereTable : Table("turniere") { // "turniere" ist der Name der Tabelle in PostgreSQL * Defines the structure of the "turniere" (tournaments) table in the database.
*/
object TurniereTable : Table("turniere") {
/**
* Unique number for the tournament, used as primary key.
*/
val number: Column<Int> = integer("number").uniqueIndex()
// Spaltendefinitionen - wir mappen die Felder unserer data class Turnier /**
// wir wählen hier passende SQL-Datentypen aus. * Name of the tournament, max 255 characters.
*/
// id: Wir nehmen VARCHAR(36) an, falls wir UUIDs als Strings speichern.
// uniqueIndex() sorgt für Eindeutigkeit und ist gut für Primärschlüssel.
val id: Column<String> = varchar("id", 36).uniqueIndex()
// name: Ein Textfeld, max. 255 Zeichen
val name: Column<String> = varchar("name", 255) val name: Column<String> = varchar("name", 255)
// datum: Vorerst einfacher Text, max. 100 Zeichen /**
* Date of the tournament as text, max 100 characters.
*/
val datum: Column<String> = varchar("datum", 100) val datum: Column<String> = varchar("datum", 100)
// logoUrl: Textfeld, max. 500 Zeichen, kann NULL sein (.nullable()) // Define the 'number' column as the primary key for this table
val logoUrl: Column<String?> = varchar("logo_url", 500).nullable() override val primaryKey = PrimaryKey(number)
}
// ausschreibungUrl: Textfeld, max. 500 Zeichen, kann NULL sein /**
val ausschreibungUrl: Column<String?> = varchar("ausschreibung_url", 500).nullable() * Defines the structure of the "bewerbe" (competitions) table in the database.
*/
object BewerbeTable : Table("bewerbe") {
/**
* Auto-generated ID for the competition.
*/
val id: Column<Int> = integer("id").autoIncrement()
// Definiert die Spalte 'id' als Primärschlüssel für diese Tabelle /**
* Number of the competition.
*/
val nummer: Column<Int> = integer("nummer")
/**
* Title of the competition.
*/
val titel: Column<String> = varchar("titel", 255)
/**
* Class/level of the competition.
*/
val klasse: Column<String> = varchar("klasse", 100)
/**
* Optional task identifier.
*/
val task: Column<String?> = varchar("task", 100).nullable()
/**
* Foreign key to the tournament table.
*/
val turnierNumber: Column<Int> = integer("turnier_number")
init {
foreignKey(turnierNumber to TurniereTable.number)
}
// Define the 'id' column as the primary key for this table
override val primaryKey = PrimaryKey(id) override val primaryKey = PrimaryKey(id)
} }
// Hier können später weitere Table-Objekte für Nennung, Prüfung etc. hinzukommen. /**
* Defines the structure of the "nennungen" (registrations) table in the database.
*/
object NennungenTable : Table("nennungen") {
/**
* Auto-generated ID for the registration.
*/
val id: Column<Int> = integer("id").autoIncrement()
/**
* Name of the rider.
*/
val riderName: Column<String> = varchar("rider_name", 255)
/**
* Name of the horse.
*/
val horseName: Column<String> = varchar("horse_name", 255)
/**
* Email address for contact.
*/
val email: Column<String> = varchar("email", 255)
/**
* Phone number for contact.
*/
val phone: Column<String> = varchar("phone", 100)
/**
* Additional comments or wishes.
*/
val comments: Column<String> = text("comments")
/**
* Foreign key to the tournament table.
*/
val turnierNumber: Column<Int> = integer("turnier_number")
init {
foreignKey(turnierNumber to TurniereTable.number)
}
// Define the 'id' column as the primary key for this table
override val primaryKey = PrimaryKey(id)
}
/**
* Defines the structure of the "nennung_events" table in the database.
* This table stores the selected competitions for each registration.
*/
object NennungEventsTable : Table("nennung_events") {
/**
* Auto-generated ID for the entry.
*/
val id: Column<Int> = integer("id").autoIncrement()
/**
* Foreign key to the registration table.
*/
val nennungId: Column<Int> = integer("nennung_id")
/**
* Number of the selected competition.
*/
val eventNumber: Column<String> = varchar("event_number", 100)
init {
foreignKey(nennungId to NennungenTable.id)
}
// Define the 'id' column as the primary key for this table
override val primaryKey = PrimaryKey(id)
}
@@ -0,0 +1,561 @@
package at.mocode.views
import at.mocode.model.Turnier
import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.html.*
import kotlinx.html.*
/**
* View class for generating HTML for the admin pages.
*/
class AdminView {
private val layoutTemplate = LayoutTemplate()
/**
* Generates the HTML response for the tournament management page.
* @param call The ApplicationCall to respond to
* @param turniere List of tournaments to display
*/
suspend fun renderTournamentManagementPage(call: ApplicationCall, turniere: List<Turnier>) {
call.respondHtml(HttpStatusCode.OK) {
layoutTemplate.apply {
applyLayout("Turnierverwaltung") {
h1 { +"Turnierverwaltung" }
// Form to add a new tournament
div(classes = "admin-section mb-4") {
h2 {
i("fas fa-plus-circle") {}
+" Neues Turnier hinzufügen"
}
form(action = "/admin/tournaments/add", method = FormMethod.post, classes = "admin-form") {
div(classes = "form-row") {
div(classes = "form-group form-group-third") {
label {
htmlFor = "turnier-number"
+"Turniernummer:"
}
input(type = InputType.number) {
id = "turnier-number"
name = "number"
required = true
placeholder = "z.B. 12345"
}
}
div(classes = "form-group form-group-third") {
label {
htmlFor = "turnier-name"
+"Turniername:"
}
input(type = InputType.text) {
id = "turnier-name"
name = "name"
required = true
placeholder = "Name des Turniers"
}
}
div(classes = "form-group form-group-third") {
label {
htmlFor = "turnier-datum"
+"Datum:"
}
input(type = InputType.text) {
id = "turnier-datum"
name = "datum"
required = true
placeholder = "z.B. 01.01.2023"
}
}
}
div(classes = "bewerbe-section mt-3") {
h3 {
i("fas fa-trophy") {}
+" Bewerbe"
}
div {
id = "bewerbe-container"
}
button(type = ButtonType.button, classes = "button button-secondary mt-2") {
onClick = "addBewerbField()"
i("fas fa-plus") {}
+" Bewerb hinzufügen"
}
}
div(classes = "form-actions mt-4") {
button(type = ButtonType.submit, classes = "button") {
i("fas fa-save") {}
+" Turnier speichern"
}
}
}
}
// Table of existing tournaments
div(classes = "admin-section") {
h2 {
i("fas fa-list") {}
+" Vorhandene Turniere"
}
if (turniere.isEmpty()) {
div(classes = "empty-state") {
i("fas fa-info-circle") {}
p { +"Keine Turniere vorhanden" }
}
} else {
div(classes = "table-responsive") {
table {
thead {
tr {
th { +"Nummer" }
th { +"Name" }
th { +"Datum" }
th { +"Bewerbe" }
th { +"Aktionen" }
}
}
tbody {
turniere.forEach { turnier ->
tr {
td { +turnier.number.toString() }
td { +turnier.name }
td { +turnier.datum }
td {
if (turnier.bewerbe.isEmpty()) {
+"Keine Bewerbe"
} else {
ul(classes = "competition-list") {
turnier.bewerbe.forEach { bewerb ->
li {
+"${bewerb.nummer}. ${bewerb.titel} - ${bewerb.klasse}"
if (bewerb.task != null) {
+" (${bewerb.task})"
}
}
}
}
}
}
td {
button(type = ButtonType.button, classes = "button button-secondary") {
onClick = "loadTurnierForEdit(${turnier.number})"
i("fas fa-edit") {}
+" Bearbeiten"
}
}
}
}
}
}
}
}
}
// Additional styles specific to the admin page
style {
+"""
.admin-section {
background-color: var(--container-bg);
border-radius: 8px;
padding: 1.5rem;
margin-bottom: 2rem;
box-shadow: 0 2px 10px rgba(0,0,0,0.05);
}
.admin-section h2 {
border-bottom: 1px solid var(--border-color);
padding-bottom: 0.75rem;
margin-bottom: 1.5rem;
color: var(--primary-color);
}
.admin-form {
max-width: 100%;
}
.form-row {
display: flex;
flex-wrap: wrap;
margin: 0 -10px;
}
.form-group-third {
flex: 0 0 calc(33.333% - 20px);
margin: 0 10px;
}
.bewerbe-section {
border-top: 1px solid var(--border-color);
padding-top: 1.5rem;
}
.bewerb-container {
background-color: var(--light-bg);
border-radius: 8px;
padding: 1.5rem;
margin-bottom: 1rem;
border: 1px solid var(--border-color);
}
.bewerb-container h4 {
margin-top: 0;
color: var(--secondary-color);
border-bottom: 1px solid var(--border-color);
padding-bottom: 0.5rem;
margin-bottom: 1rem;
}
.table-responsive {
overflow-x: auto;
}
.competition-list {
margin: 0;
padding-left: 1.2rem;
}
.competition-list li {
margin-bottom: 0.25rem;
}
.empty-state {
text-align: center;
padding: 2rem;
color: var(--light-text);
}
.empty-state i {
font-size: 3rem;
margin-bottom: 1rem;
display: block;
}
@media (max-width: 768px) {
.form-group-third {
flex: 0 0 calc(100% - 20px);
}
}
"""
}
// JavaScript for dynamic form handling
script(type = "text/javascript") {
unsafe {
+"""
let bewerbCounter = 0;
function addBewerbField() {
bewerbCounter++;
const container = document.getElementById('bewerbe-container');
const bewerbDiv = document.createElement('div');
bewerbDiv.className = 'bewerb-container';
bewerbDiv.id = 'bewerb-' + bewerbCounter;
bewerbDiv.innerHTML = '<h4><i class="fas fa-trophy"></i> Bewerb ' + bewerbCounter + '</h4>' +
'<div class="form-row">' +
'<div class="form-group form-group-third">' +
'<label for="bewerb-nummer-' + bewerbCounter + '">Nummer:</label>' +
'<input type="number" id="bewerb-nummer-' + bewerbCounter + '" name="bewerb-nummer[]" placeholder="Bewerbnummer" required>' +
'</div>' +
'<div class="form-group form-group-third">' +
'<label for="bewerb-titel-' + bewerbCounter + '">Titel:</label>' +
'<input type="text" id="bewerb-titel-' + bewerbCounter + '" name="bewerb-titel[]" placeholder="Titel des Bewerbs" required>' +
'</div>' +
'<div class="form-group form-group-third">' +
'<label for="bewerb-klasse-' + bewerbCounter + '">Klasse:</label>' +
'<input type="text" id="bewerb-klasse-' + bewerbCounter + '" name="bewerb-klasse[]" placeholder="Klasse" required>' +
'</div>' +
'</div>' +
'<div class="form-group">' +
'<label for="bewerb-task-' + bewerbCounter + '">Task (optional):</label>' +
'<input type="text" id="bewerb-task-' + bewerbCounter + '" name="bewerb-task[]" placeholder="Optionale Task-Beschreibung">' +
'</div>' +
'<button type="button" onclick="removeBewerbField(' + bewerbCounter + ')" class="button button-secondary"><i class="fas fa-trash"></i> Bewerb entfernen</button>';
container.appendChild(bewerbDiv);
}
function removeBewerbField(id) {
const bewerbDiv = document.getElementById('bewerb-' + id);
bewerbDiv.remove();
}
function loadTurnierForEdit(number) {
window.location.href = '/admin/tournaments/edit/' + number;
}
"""
}
}
}
}
}
}
/**
* Generates the HTML response for the tournament edit page.
* @param call The ApplicationCall to respond to
* @param turnier The tournament to edit
*/
suspend fun renderTournamentEditPage(call: ApplicationCall, turnier: Turnier) {
call.respondHtml(HttpStatusCode.OK) {
layoutTemplate.apply {
applyLayout("Turnier bearbeiten") {
h1 { +"Turnier bearbeiten" }
div(classes = "admin-section") {
form(action = "/admin/tournaments/update/${turnier.number}", method = FormMethod.post, classes = "admin-form") {
div(classes = "form-row") {
div(classes = "form-group form-group-third") {
label {
htmlFor = "turnier-number"
+"Turniernummer:"
}
input(type = InputType.number) {
id = "turnier-number"
name = "number"
value = turnier.number.toString()
readonly = true
classes = setOf("readonly-field")
}
}
div(classes = "form-group form-group-third") {
label {
htmlFor = "turnier-name"
+"Turniername:"
}
input(type = InputType.text) {
id = "turnier-name"
name = "name"
value = turnier.name
required = true
}
}
div(classes = "form-group form-group-third") {
label {
htmlFor = "turnier-datum"
+"Datum:"
}
input(type = InputType.text) {
id = "turnier-datum"
name = "datum"
value = turnier.datum
required = true
}
}
}
div(classes = "bewerbe-section mt-3") {
h3 {
i("fas fa-trophy") {}
+" Bewerbe"
}
div {
id = "bewerbe-container"
// Render existing competitions
turnier.bewerbe.forEachIndexed { index, bewerb ->
val bewerbIndex = index + 1
div(classes = "bewerb-container") {
id = "bewerb-$bewerbIndex"
h4 {
i("fas fa-trophy") {}
+" Bewerb $bewerbIndex"
}
div(classes = "form-row") {
div(classes = "form-group form-group-third") {
label {
htmlFor = "bewerb-nummer-$bewerbIndex"
+"Nummer:"
}
input(type = InputType.number) {
id = "bewerb-nummer-$bewerbIndex"
name = "bewerb-nummer[]"
value = bewerb.nummer.toString()
required = true
}
}
div(classes = "form-group form-group-third") {
label {
htmlFor = "bewerb-titel-$bewerbIndex"
+"Titel:"
}
input(type = InputType.text) {
id = "bewerb-titel-$bewerbIndex"
name = "bewerb-titel[]"
value = bewerb.titel
required = true
}
}
div(classes = "form-group form-group-third") {
label {
htmlFor = "bewerb-klasse-$bewerbIndex"
+"Klasse:"
}
input(type = InputType.text) {
id = "bewerb-klasse-$bewerbIndex"
name = "bewerb-klasse[]"
value = bewerb.klasse
required = true
}
}
}
div(classes = "form-group") {
label {
htmlFor = "bewerb-task-$bewerbIndex"
+"Task (optional):"
}
input(type = InputType.text) {
id = "bewerb-task-$bewerbIndex"
name = "bewerb-task[]"
value = bewerb.task ?: ""
}
}
button(type = ButtonType.button, classes = "button button-secondary") {
onClick = "removeBewerbField($bewerbIndex)"
i("fas fa-trash") {}
+" Bewerb entfernen"
}
}
}
}
button(type = ButtonType.button, classes = "button button-secondary mt-2") {
onClick = "addBewerbField()"
i("fas fa-plus") {}
+" Bewerb hinzufügen"
}
}
div(classes = "form-actions mt-4") {
button(type = ButtonType.submit, classes = "button") {
i("fas fa-save") {}
+" Turnier aktualisieren"
}
a(href = "/admin/tournaments", classes = "button button-secondary ml-2") {
i("fas fa-arrow-left") {}
+" Zurück zur Turnierverwaltung"
}
}
}
}
// Additional styles specific to the edit page
style {
+"""
.admin-section {
background-color: var(--container-bg);
border-radius: 8px;
padding: 1.5rem;
margin-bottom: 2rem;
box-shadow: 0 2px 10px rgba(0,0,0,0.05);
}
.admin-form {
max-width: 100%;
}
.form-row {
display: flex;
flex-wrap: wrap;
margin: 0 -10px;
}
.form-group-third {
flex: 0 0 calc(33.333% - 20px);
margin: 0 10px;
}
.bewerbe-section {
border-top: 1px solid var(--border-color);
padding-top: 1.5rem;
}
.bewerb-container {
background-color: var(--light-bg);
border-radius: 8px;
padding: 1.5rem;
margin-bottom: 1rem;
border: 1px solid var(--border-color);
}
.bewerb-container h4 {
margin-top: 0;
color: var(--secondary-color);
border-bottom: 1px solid var(--border-color);
padding-bottom: 0.5rem;
margin-bottom: 1rem;
}
.readonly-field {
background-color: var(--light-bg);
cursor: not-allowed;
}
.ml-2 {
margin-left: 0.5rem;
}
@media (max-width: 768px) {
.form-group-third {
flex: 0 0 calc(100% - 20px);
}
.ml-2 {
margin-left: 0;
margin-top: 0.5rem;
}
}
"""
}
// JavaScript for dynamic form handling
script(type = "text/javascript") {
unsafe {
raw("let bewerbCounter = ${turnier.bewerbe.size};")
+"""
function addBewerbField() {
bewerbCounter++;
const container = document.getElementById('bewerbe-container');
const bewerbDiv = document.createElement('div');
bewerbDiv.className = 'bewerb-container';
bewerbDiv.id = 'bewerb-' + bewerbCounter;
bewerbDiv.innerHTML = '<h4><i class="fas fa-trophy"></i> Neuer Bewerb</h4>' +
'<div class="form-row">' +
'<div class="form-group form-group-third">' +
'<label for="bewerb-nummer-' + bewerbCounter + '">Nummer:</label>' +
'<input type="number" id="bewerb-nummer-' + bewerbCounter + '" name="bewerb-nummer[]" placeholder="Bewerbnummer" required>' +
'</div>' +
'<div class="form-group form-group-third">' +
'<label for="bewerb-titel-' + bewerbCounter + '">Titel:</label>' +
'<input type="text" id="bewerb-titel-' + bewerbCounter + '" name="bewerb-titel[]" placeholder="Titel des Bewerbs" required>' +
'</div>' +
'<div class="form-group form-group-third">' +
'<label for="bewerb-klasse-' + bewerbCounter + '">Klasse:</label>' +
'<input type="text" id="bewerb-klasse-' + bewerbCounter + '" name="bewerb-klasse[]" placeholder="Klasse" required>' +
'</div>' +
'</div>' +
'<div class="form-group">' +
'<label for="bewerb-task-' + bewerbCounter + '">Task (optional):</label>' +
'<input type="text" id="bewerb-task-' + bewerbCounter + '" name="bewerb-task[]" placeholder="Optionale Task-Beschreibung">' +
'</div>' +
'<button type="button" onclick="removeBewerbField(' + bewerbCounter + ')" class="button button-secondary"><i class="fas fa-trash"></i> Bewerb entfernen</button>';
container.appendChild(bewerbDiv);
}
function removeBewerbField(id) {
const bewerbDiv = document.getElementById('bewerb-' + id);
bewerbDiv.remove();
}
"""
}
}
}
}
}
}
}
@@ -0,0 +1,135 @@
package at.mocode.views
import at.mocode.model.Turnier
import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.html.*
import kotlinx.html.*
/**
* View class for generating HTML for the home page.
*/
class HomeView {
private val layoutTemplate = LayoutTemplate()
/**
* Generates the HTML response for the home page.
* @param call The ApplicationCall to respond to
* @param turniere List of tournaments to display
*/
suspend fun renderHomePage(call: ApplicationCall, turniere: List<Turnier>) {
call.respondHtml(HttpStatusCode.OK) {
layoutTemplate.apply {
applyLayout("Meldestelle Portal - Startseite") {
h1 { +"Willkommen beim Meldestelle Portal!" }
p(classes = "text-center mb-3") { +"Hier finden Sie alle aktuellen Turniere und können sich online anmelden." }
div(classes = "mt-4") {
h2 { +"Aktuelle Turniere" }
if (turniere.isEmpty()) {
div(classes = "text-center mt-3 mb-3") {
p { +"Keine Turniere in der Datenbank gefunden." }
}
} else {
div(classes = "tournament-list") {
turniere.forEach { turnier ->
div(classes = "tournament-item mb-3") {
div(classes = "tournament-header") {
h3 { +turnier.name }
p {
i("far fa-calendar-alt") {}
+" ${turnier.datum}"
}
p {
i("fas fa-hashtag") {}
+" Turnier-Nr.: ${turnier.number}"
}
}
div(classes = "tournament-competitions mt-2") {
h4 { +"Bewerbe:" }
if (turnier.bewerbe.isEmpty()) {
p { +"Keine Bewerbe verfügbar" }
} else {
ul {
turnier.bewerbe.forEach { bewerb ->
li {
+"${bewerb.nummer}. ${bewerb.titel} - ${bewerb.klasse}"
if (bewerb.task != null) {
+" (${bewerb.task})"
}
}
}
}
}
}
div(classes = "tournament-actions mt-2") {
a(href = "/nennung/${turnier.number}", classes = "button") {
i("fas fa-edit") {}
+" Online Nennen"
}
}
}
}
}
}
}
style {
+"""
.tournament-list {
display: flex;
flex-direction: column;
gap: 16px;
max-width: 800px;
margin: 0 auto;
}
.tournament-item {
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 24px;
transition: box-shadow 0.3s;
background-color: var(--container-bg);
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
}
.tournament-item:hover {
box-shadow: 0 5px 15px rgba(0,0,0,0.08);
}
.tournament-header h3 {
margin-top: 0;
color: var(--primary-color);
font-size: 1.4rem;
}
.tournament-header p {
color: var(--light-text);
margin-bottom: 0.5rem;
font-size: 0.95rem;
}
.tournament-competitions h4 {
font-size: 1.1rem;
margin-bottom: 0.5rem;
color: var(--secondary-color);
}
.tournament-competitions ul {
padding-left: 1.2rem;
}
.tournament-actions {
margin-top: 1rem;
text-align: right;
}
"""
}
}
}
}
}
}
@@ -0,0 +1,477 @@
package at.mocode.views
import kotlinx.html.*
/**
* Common layout template for all pages in the application.
* Provides consistent styling, header, footer, and responsive design.
*/
class LayoutTemplate {
/**
* Applies the common layout template to the provided content.
* @param title The page title
* @param showNavbar Whether to show the navigation bar
* @param showAdminLink Whether to show the admin link in the navbar
* @param content The content builder function
*/
fun HTML.applyLayout(
title: String,
showNavbar: Boolean = true,
showAdminLink: Boolean = true,
content: FlowContent.() -> Unit
) {
head {
meta(charset = "UTF-8")
meta(name = "viewport", content = "width=device-width, initial-scale=1.0")
title { +title }
link(rel = "stylesheet", href = "https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css")
link(rel = "stylesheet", href = "https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap")
link(rel = "stylesheet", href = "https://fonts.googleapis.com/icon?family=Material+Icons")
link(rel = "stylesheet", href = "https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/css/materialize.min.css")
style {
+"""
/* Base styles */
:root {
--primary-color: #5d8aa8;
--primary-hover: #4a7a98;
--secondary-color: #7d9eb1;
--secondary-hover: #6a8ca1;
--text-color: #333;
--light-text: #666;
--lighter-text: #999;
--border-color: #e0e0e0;
--light-bg: #f5f7fa;
--container-bg: #fff;
--success-color: #66bb6a;
--warning-color: #ffa726;
--error-color: #ef5350;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: 'Roboto', 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
line-height: 1.6;
color: var(--text-color);
background-color: var(--light-bg);
padding: 0;
margin: 0;
}
.container {
width: 100%;
max-width: 1200px;
margin: 0 auto;
padding: 0 20px;
}
/* Navigation */
nav.nav-extended {
background-color: var(--primary-color);
margin-bottom: 20px;
}
nav .brand-logo {
font-size: 1.6rem;
font-weight: 500;
padding-left: 10px;
}
nav .brand-logo i {
margin-right: 8px;
}
nav ul li a {
font-weight: 500;
transition: background-color 0.3s;
}
nav ul li a:hover {
background-color: rgba(255,255,255,0.1);
}
.sidenav {
width: 280px;
}
.sidenav .user-view {
padding: 20px 16px 12px;
}
.sidenav .user-view .name {
font-size: 1.4rem;
font-weight: 500;
margin-top: 8px;
color: var(--primary-color);
}
.sidenav li > a {
display: flex;
align-items: center;
font-weight: 500;
}
.sidenav li > a > i {
margin-right: 16px;
color: var(--primary-color);
}
/* Main content */
main {
padding: 2rem 0;
}
.content-card {
background-color: var(--container-bg);
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.05);
padding: 2rem;
margin-bottom: 2rem;
}
/* Typography */
h1, h2, h3, h4, h5, h6 {
margin-bottom: 1rem;
line-height: 1.2;
color: var(--text-color);
}
h1 {
font-size: 2.2rem;
text-align: center;
margin-bottom: 1.5rem;
}
h2 {
font-size: 1.8rem;
margin-top: 1.5rem;
}
h3 {
font-size: 1.5rem;
margin-top: 1.2rem;
}
p {
margin-bottom: 1rem;
}
/* Forms */
.form-group {
margin-bottom: 1.5rem;
}
label {
display: block;
margin-bottom: 0.5rem;
font-weight: 600;
}
input[type="text"],
input[type="email"],
input[type="tel"],
input[type="number"],
textarea,
select {
width: 100%;
padding: 1.2rem;
border: none;
border-radius: 6px;
font-size: 2rem;
transition: all 0.3s;
margin-bottom: 1.2rem;
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
background-color: white;
}
input[type="text"]:focus,
input[type="email"]:focus,
input[type="tel"]:focus,
input[type="number"]:focus,
textarea:focus,
select:focus {
border-color: var(--primary-color);
outline: none;
box-shadow: 0 2px 8px rgba(93,138,168,0.2);
}
.required:after {
content: " *";
color: var(--error-color);
}
/* Buttons */
.button, button {
display: inline-block;
background-color: var(--primary-color);
color: white;
padding: 0.9rem 1.8rem;
border: none;
border-radius: 4px;
font-size: 1.1rem;
cursor: pointer;
text-decoration: none;
transition: all 0.3s;
text-transform: uppercase;
letter-spacing: 0.5px;
font-weight: 500;
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
width: 100%;
text-align: center;
}
.button:hover, button:hover {
background-color: var(--primary-hover);
box-shadow: 0 4px 8px rgba(0,0,0,0.3);
transform: translateY(-2px);
}
.button-secondary {
background-color: var(--secondary-color);
}
.button-secondary:hover {
background-color: var(--secondary-hover);
}
/* Tables */
table {
width: 100%;
border-collapse: collapse;
margin-bottom: 1.5rem;
}
th, td {
padding: 0.75rem;
text-align: left;
border-bottom: 1px solid var(--border-color);
}
th {
background-color: var(--light-bg);
font-weight: 600;
}
tr:hover {
background-color: rgba(0,0,0,0.02);
}
/* Lists */
ul, ol {
margin-bottom: 1rem;
padding-left: 1.5rem;
}
li {
margin-bottom: 0.5rem;
}
/* Utilities */
.text-center {
text-align: center;
}
.mt-1 { margin-top: 0.5rem; }
.mt-2 { margin-top: 1rem; }
.mt-3 { margin-top: 1.5rem; }
.mt-4 { margin-top: 2rem; }
.mb-1 { margin-bottom: 0.5rem; }
.mb-2 { margin-bottom: 1rem; }
.mb-3 { margin-bottom: 1.5rem; }
.mb-4 { margin-bottom: 2rem; }
/* Footer */
footer {
background-color: var(--text-color);
color: white;
padding: 2rem 0;
margin-top: 2rem;
}
footer a {
color: white;
text-decoration: none;
}
footer a:hover {
text-decoration: underline;
}
/* Responsive design */
@media (max-width: 768px) {
.menu-toggle {
display: block;
}
nav ul {
display: none;
position: absolute;
top: 60px;
left: 0;
right: 0;
flex-direction: column;
background-color: var(--primary-color);
padding: 1rem;
box-shadow: 0 2px 5px rgba(0,0,0,0.1);
}
nav ul.show {
display: flex;
}
nav ul li {
margin: 0.5rem 0;
}
.content-card {
padding: 1.5rem;
}
h1 {
font-size: 1.8rem;
}
h2 {
font-size: 1.5rem;
}
h3 {
font-size: 1.3rem;
}
}
@media (max-width: 480px) {
.container {
padding: 0 15px;
}
.content-card {
padding: 1rem;
}
h1 {
font-size: 1.6rem;
}
.button, button {
width: 100%;
text-align: center;
margin-bottom: 0.5rem;
}
}
"""
}
script(src = "https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/js/materialize.min.js") {}
script {
unsafe {
+"""
document.addEventListener('DOMContentLoaded', function() {
// Mobile menu toggle
const menuToggle = document.querySelector('.menu-toggle');
const navMenu = document.querySelector('nav ul');
if (menuToggle && navMenu) {
menuToggle.addEventListener('click', function() {
navMenu.classList.toggle('show');
});
}
// Initialize Materialize components
M.AutoInit();
// Enhance form elements
const inputs = document.querySelectorAll('input, textarea, select');
inputs.forEach(input => {
input.classList.add('browser-default');
});
});
"""
}
}
}
body {
if (showNavbar) {
nav(classes = "nav-extended z-depth-1") {
div("nav-wrapper") {
div("container") {
a(href = "/", classes = "brand-logo") {
i("material-icons left") { +"sports_handball" }
+"Meldestelle Portal"
}
a(href = "#", classes = "sidenav-trigger") {
attributes["data-target"] = "mobile-nav"
i("material-icons") { +"menu" }
}
ul(classes = "right hide-on-med-and-down") {
li {
a(href = "/") {
i("material-icons left") { +"home" }
+"Home"
}
}
if (showAdminLink) {
li {
a(href = "/admin/tournaments") {
i("material-icons left") { +"event" }
+"Turnierverwaltung"
}
}
}
}
}
}
}
// Mobile sidenav
ul(classes = "sidenav") {
attributes["id"] = "mobile-nav"
li {
div("user-view") {
div("background blue-grey lighten-4") {
style = "height: 80px;"
}
span("name") { +"Meldestelle Portal" }
}
}
li {
a(href = "/") {
i("material-icons") { +"home" }
+"Home"
}
}
if (showAdminLink) {
li {
a(href = "/admin/tournaments") {
i("material-icons") { +"event" }
+"Turnierverwaltung"
}
}
}
}
}
main {
div("container") {
div("content-card") {
content()
}
}
}
footer {
div("container") {
div("text-center") {
p { +"© ${java.time.Year.now().value} Meldestelle Portal. Alle Rechte vorbehalten." }
p {
+"Entwickelt von "
a(href = "#") { +"mocode" }
}
}
}
}
}
}
}
@@ -0,0 +1,392 @@
package at.mocode.views
import at.mocode.model.Turnier
import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.html.*
import kotlinx.html.*
/**
* View class for generating HTML for the tournament registration forms.
*/
class NennungView {
private val layoutTemplate = LayoutTemplate()
/**
* Generates the HTML response for the tournament registration form.
* @param call The ApplicationCall to respond to
* @param turnier The tournament to display the registration form for
*/
suspend fun renderNennungForm(call: ApplicationCall, turnier: Turnier) {
call.respondHtml(HttpStatusCode.OK) {
layoutTemplate.apply {
applyLayout(
title = "Online-Nennen - ${turnier.name}",
showNavbar = false,
showAdminLink = false
) {
h1 { +"Online-Nennen" }
// Tournament description
div(classes = "tournament-info text-center mb-4") {
h2 { +turnier.name }
p { +turnier.datum }
p { +"Turnier-Nr.: ${turnier.number}" }
}
form(action = "/nennung/${turnier.number}/submit", method = FormMethod.post, classes = "registration-form") {
div(classes = "form-section mb-4") {
h3 { +"Teilnehmer-Informationen" }
// Participant information list
div(classes = "competitions-list") {
// Rider information
div(classes = "competition-item") {
div(classes = "participant-details") {
label(classes = "required") { +"Reiter-Name" }
input(type = InputType.text, name = "riderName") {
attributes["required"] = "required"
attributes["placeholder"] = "Vor- und Nachname"
}
}
}
// Horse information
div(classes = "competition-item") {
div(classes = "participant-details") {
label(classes = "required") { +"Kopf-Nr./Pferd" }
input(type = InputType.text, name = "horseName") {
attributes["required"] = "required"
attributes["placeholder"] = "Name des Pferdes"
}
}
}
// Contact information
div(classes = "competition-item") {
div(classes = "participant-details") {
div(classes = "form-row") {
div(classes = "form-group form-group-half") {
label { +"E-Mail" }
input(type = InputType.email, name = "email") {
attributes["placeholder"] = "ihre@email.com"
}
}
}
}
}
// Contact information
div(classes = "competition-item") {
div(classes = "participant-details") {
div(classes = "form-row") {
div(classes = "form-group form-group-half") {
label { +"Telefon-Nr." }
input(type = InputType.tel, name = "phone") {
attributes["placeholder"] = "Ihre Telefonnummer"
}
}
}
}
}
}
p(classes = "form-hint") { +"Bitte geben Sie mindestens eine Kontaktmöglichkeit an (E-Mail oder Telefon)." }
}
// Competitions list
div(classes = "form-section mb-4") {
h3 { +"Bewerbe" }
if (turnier.bewerbe.isEmpty()) {
p { +"Keine Bewerbe verfügbar." }
} else {
div(classes = "competitions-list") {
turnier.bewerbe.forEach { bewerb ->
div(classes = "competition-item") {
label {
input(type = InputType.checkBox, name = "selectedEvents") {
attributes["value"] = bewerb.nummer.toString()
}
span(classes = "competition-details") {
strong { +"${bewerb.nummer}. ${bewerb.titel}" }
+" - ${bewerb.klasse}"
if (bewerb.task != null) {
+" - ${bewerb.task}"
}
}
}
}
}
}
}
}
// Comments
div(classes = "form-section mb-4") {
h3 { +"Zusätzliche Informationen" }
div(classes = "form-group") {
label { +"Wünsche/Bemerkungen" }
textArea {
attributes["rows"] = "4"
attributes["name"] = "comments"
attributes["placeholder"] = "Ihre Wünsche oder Bemerkungen zur Nennung..."
}
}
}
// Submit button
div(classes = "form-actions text-center mt-4") {
button(type = ButtonType.submit, classes = "button") {
+"Jetzt Nennen"
}
}
}
// Additional styles specific to the registration form
style {
+"""
.tournament-info {
margin-bottom: 2rem;
}
.tournament-info h2 {
color: var(--primary-color);
}
.form-section {
background-color: white;
border-radius: 8px;
padding: 2rem;
margin-bottom: 2rem;
box-shadow: 0 2px 10px rgba(0,0,0,0.08);
border-left: 4px solid var(--primary-color);
}
.form-section h3 {
border-bottom: 1px solid var(--border-color);
padding-bottom: 0.8rem;
margin-bottom: 1.5rem;
text-align: left;
color: var(--primary-color);
font-size: 1.4rem;
}
.form-row {
display: flex;
flex-direction: column;
width: 100%;
margin: 0;
}
@media (min-width: 768px) {
.form-row {
flex-direction: row;
gap: 20px;
}
.form-group-half {
flex: 1;
}
}
.form-group {
width: 100%;
margin: 0 0 1.5rem 0;
}
.form-group-half {
flex: 0 0 100%;
margin: 0 0 1.5rem 0;
max-width: 100%;
}
.form-hint {
font-size: 0.9rem;
color: var(--light-text);
margin-top: 0.5rem;
text-align: center;
}
.competitions-list {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 15px;
width: 100%;
margin: 0;
}
.competition-item {
padding: 18px;
border-radius: 8px;
transition: all 0.3s;
width: 100%;
text-align: left;
border: 1px solid var(--border-color);
background-color: white;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.competition-item:hover {
background-color: rgba(0,0,0,0.02);
box-shadow: 0 3px 8px rgba(0,0,0,0.12);
}
.competition-item label {
display: flex;
align-items: center;
justify-content: flex-start;
cursor: pointer;
}
.competition-item input[type="checkbox"] {
margin-right: 15px;
transform: scale(1.2);
}
.competition-details {
text-align: left;
}
/* Participant information styling */
.participant-details {
width: 100%;
}
.participant-details label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
}
.participant-details input {
width: 100%;
}
@media (max-width: 768px) {
/* Mobile styles already handled by the responsive layout */
}
"""
}
}
}
}
}
/**
* Generates the HTML response for the confirmation page after successful registration.
* @param call The ApplicationCall to respond to
* @param turnier The tournament the registration was for
* @param riderName The name of the rider
* @param horseName The name of the horse
*/
suspend fun renderConfirmationPage(call: ApplicationCall, turnier: Turnier, riderName: String, horseName: String) {
call.respondHtml(HttpStatusCode.OK) {
layoutTemplate.apply {
applyLayout(
title = "Nennung bestätigt - ${turnier.name}",
showNavbar = false,
showAdminLink = false
) {
h1 { +"Nennung bestätigt" }
div(classes = "confirmation-box") {
div(classes = "confirmation-icon") {
/* Icon removed as per request */
}
h2 { +"Vielen Dank für Ihre Nennung!" }
div(classes = "confirmation-details") {
p {
+"Ihre Nennung für "
strong { +turnier.name }
+" wurde erfolgreich übermittelt."
}
div(classes = "detail-item") {
span(classes = "detail-label") { +"Reiter:" }
span(classes = "detail-value") { +riderName }
}
div(classes = "detail-item") {
span(classes = "detail-label") { +"Pferd:" }
span(classes = "detail-value") { +horseName }
}
}
p(classes = "confirmation-message") {
+"Sie erhalten in Kürze eine Bestätigung per E-Mail."
}
div(classes = "confirmation-actions") {
a(href = "/nennung/${turnier.number}", classes = "button") {
+"Weitere Nennung abgeben"
}
}
}
// Additional styles specific to the confirmation page
style {
+"""
.confirmation-box {
background-color: var(--light-bg);
border-radius: 8px;
padding: 2rem;
text-align: center;
max-width: 600px;
margin: 0 auto;
box-shadow: 0 4px 15px rgba(0,0,0,0.05);
}
.confirmation-icon {
font-size: 4rem;
color: var(--success-color);
margin-bottom: 1rem;
}
.confirmation-details {
margin: 1.5rem 0;
padding: 1rem;
background-color: var(--container-bg);
border-radius: 8px;
text-align: left;
}
.detail-item {
display: flex;
margin-bottom: 0.5rem;
padding: 0.5rem 0;
border-bottom: 1px solid var(--border-color);
}
.detail-item:last-child {
border-bottom: none;
}
.detail-label {
font-weight: bold;
width: 100px;
}
.detail-value {
flex: 1;
}
.confirmation-message {
margin: 1.5rem 0;
color: var(--light-text);
}
.confirmation-actions {
margin-top: 1.5rem;
}
"""
}
}
}
}
}
}
@@ -14,11 +14,9 @@ class ApplicationTest {
fun testRootRouteShowsTournamentList() { fun testRootRouteShowsTournamentList() {
// Erstelle ein Beispiel-Turnier, das in der Datenbank sein würde // Erstelle ein Beispiel-Turnier, das in der Datenbank sein würde
val mockTurnier = Turnier( val mockTurnier = Turnier(
id = "dummy-01",
name = "Erstes DB Turnier", name = "Erstes DB Turnier",
datum = "19.04.2025", datum = "19.04.2025",
logoUrl = null, number = 1
ausschreibungUrl = "/pdfs/ausschreibung_dummy.pdf"
) )
// Erstelle eine Liste von Turnieren, wie sie aus der Datenbank kommen würde // Erstelle eine Liste von Turnieren, wie sie aus der Datenbank kommen würde
@@ -45,13 +43,7 @@ class ApplicationTest {
strong { +turnier.name } strong { +turnier.name }
+" (${turnier.datum})" +" (${turnier.datum})"
+" " +" "
if (turnier.ausschreibungUrl != null) { a(href = "/nennung/${turnier.number}") {
a(href = turnier.ausschreibungUrl, target = "_blank") {
button { +"Ausschreibung" }
}
+" "
}
a(href = "/nennung/${turnier.id}") {
button { +"Online Nennen" } button { +"Online Nennen" }
} }
} }
@@ -82,7 +74,7 @@ class ApplicationTest {
htmlContent.contains("(19.04.2025)"), htmlContent.contains("(19.04.2025)"),
"Dummy tournament date missing or incorrect" "Dummy tournament date missing or incorrect"
) )
assertTrue(htmlContent.contains("/nennung/dummy-01"), "Link to dummy tournament '/nennung/dummy-01' missing") assertTrue(htmlContent.contains("/nennung/1"), "Link to dummy tournament '/nennung/1' missing")
assertFalse( assertFalse(
htmlContent.contains("Keine Turniere in der Datenbank gefunden."), htmlContent.contains("Keine Turniere in der Datenbank gefunden."),
"'No tournaments' message should not be present if dummy was inserted" "'No tournaments' message should not be present if dummy was inserted"
@@ -1,16 +1,23 @@
package at.mocode package at.mocode
import at.mocode.model.Bewerb
import at.mocode.model.Turnier import at.mocode.model.Turnier
import at.mocode.tables.BewerbeTable
import at.mocode.tables.TurniereTable import at.mocode.tables.TurniereTable
import `import org`.jetbrains.exposed.sql.selectAll
import io.ktor.http.* import io.ktor.http.*
import io.ktor.server.application.* import io.ktor.server.application.*
import io.ktor.server.html.* import io.ktor.server.html.*
import io.ktor.server.routing.* import io.ktor.server.routing.*
import kotlinx.html.* import kotlinx.html.*
import org.jetbrains.exposed.sql.Database import org.jetbrains.exposed.sql.Database
import org.jetbrains.exposed.sql.Op
import org.jetbrains.exposed.sql.SchemaUtils import org.jetbrains.exposed.sql.SchemaUtils
import org.jetbrains.exposed.sql.SqlExpressionBuilder
import org.jetbrains.exposed.sql.insert import org.jetbrains.exposed.sql.insert
import org.jetbrains.exposed.sql.selectAll import org.jetbrains.exposed.sql.selectAll
import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.selectAll
import org.jetbrains.exposed.sql.transactions.transaction import org.jetbrains.exposed.sql.transactions.transaction
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
@@ -19,24 +26,40 @@ import org.slf4j.LoggerFactory
*/ */
fun configureTestDatabase() { fun configureTestDatabase() {
val log = LoggerFactory.getLogger("TestDatabaseInitialization") val log = LoggerFactory.getLogger("TestDatabaseInitialization")
log.info("Initializing in-memory H2 database for testing...") log.info("Initializing in-memory SQLite database for testing...")
// Verbinde mit einer In-Memory-H2-Datenbank // Verbinde mit einer In-Memory-SQLite-Datenbank
Database.connect("jdbc:h2:mem:test;DB_CLOSE_DELAY=-1", driver = "org.h2.Driver") Database.connect("jdbc:sqlite::memory:", driver = "org.sqlite.JDBC")
// Initialisiere das Datenbankschema // Initialisiere das Datenbankschema
transaction { transaction {
log.info("Creating test database schema...") log.info("Creating test database schema...")
SchemaUtils.create(TurniereTable) SchemaUtils.create(TurniereTable, BewerbeTable)
// Füge ein Test-Turnier hinzu // Füge ein Test-Turnier hinzu
log.info("Inserting test tournament data...") log.info("Inserting test tournament data...")
val turnierNumber = 1
TurniereTable.insert { TurniereTable.insert {
it[id] = "dummy-01" it[TurniereTable.number] = turnierNumber
it[name] = "Erstes DB Turnier" it[TurniereTable.name] = "CSN-C Edelhof April 2025"
it[datum] = "19.04.2025" it[TurniereTable.datum] = "14.04.2025 - 15.04.2025"
it[logoUrl] = null }
it[ausschreibungUrl] = "/pdfs/ausschreibung_dummy.pdf"
// Füge Test-Bewerbe hinzu
BewerbeTable.insert {
it[BewerbeTable.nummer] = 1
it[BewerbeTable.titel] = "Stilspringprüfung"
it[BewerbeTable.klasse] = "60 cm"
it[BewerbeTable.task] = null
it[BewerbeTable.turnierNumber] = turnierNumber
}
BewerbeTable.insert {
it[BewerbeTable.nummer] = 2
it[BewerbeTable.titel] = "Dressurprüfung"
it[BewerbeTable.klasse] = "Kl. A"
it[BewerbeTable.task] = "DRA 1"
it[BewerbeTable.turnierNumber] = turnierNumber
} }
log.info("Test database initialized successfully!") log.info("Test database initialized successfully!")
@@ -57,15 +80,30 @@ fun Application.testModule() {
// Lese Daten aus der Test-Datenbank // Lese Daten aus der Test-Datenbank
val turniereFromDb = transaction { val turniereFromDb = transaction {
TurniereTable.selectAll().map { row -> // Get all tournaments
val turniere = TurniereTable.selectAll().map { row ->
Turnier( Turnier(
id = row[TurniereTable.id],
name = row[TurniereTable.name], name = row[TurniereTable.name],
datum = row[TurniereTable.datum], datum = row[TurniereTable.datum],
logoUrl = row[TurniereTable.logoUrl], number = row[TurniereTable.number]
ausschreibungUrl = row[TurniereTable.ausschreibungUrl]
) )
} }
// For each tournament, get its competitions
turniere.forEach { turnier ->
val bewerbeList = BewerbeTable.selectAll().where { BewerbeTable.turnierNumber eq turnier.number }
.map { row ->
Bewerb(
nummer = row[BewerbeTable.nummer],
titel = row[BewerbeTable.titel],
klasse = row[BewerbeTable.klasse],
task = row[BewerbeTable.task]
)
}
turnier.bewerbe = bewerbeList
}
turniere
} }
// HTML-Antwort generieren (wie in Application.kt) // HTML-Antwort generieren (wie in Application.kt)
@@ -88,13 +126,25 @@ fun Application.testModule() {
strong { +turnier.name } strong { +turnier.name }
+" (${turnier.datum})" +" (${turnier.datum})"
+" " +" "
if (turnier.ausschreibungUrl != null) { div {
a(href = turnier.ausschreibungUrl, target = "_blank") { +"Bewerbe: "
button { +"Ausschreibung" } if (turnier.bewerbe.isEmpty()) {
+"Keine"
} else {
ul {
turnier.bewerbe.forEach { bewerb ->
li {
+"${bewerb.nummer}. ${bewerb.titel} - ${bewerb.klasse}"
if (bewerb.task != null) {
+" (${bewerb.task})"
}
}
}
}
} }
+" "
} }
a(href = "/nennung/${turnier.id}") { +" "
a(href = "/nennung/${turnier.number}") {
button { +"Online Nennen" } button { +"Online Nennen" }
} }
} }
@@ -0,0 +1,54 @@
package at.mocode.email
import at.mocode.config.EmailConfig
import org.slf4j.LoggerFactory
import kotlin.test.Test
import kotlin.test.assertTrue
/**
* Tests for the EmailService class.
* This test verifies that the email configuration is valid and that test emails can be sent.
*/
class EmailServiceTest {
private val log = LoggerFactory.getLogger(EmailServiceTest::class.java)
@Test
fun testEmailConfigurationIsValid() {
// Check if the email configuration is valid
val isValid = EmailConfig.isValid()
if (!isValid) {
log.warn("Email configuration is not valid: ${EmailConfig.getValidationErrors()}")
log.warn("Make sure the .env file contains the correct email configuration")
log.warn("SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASSWORD, RECIPIENT_EMAIL, and SMTP_SENDER_EMAIL must be set")
}
// We don't assert here because the test environment might not have valid email configuration
log.info("Email configuration valid: $isValid")
}
@Test
fun testSendTestEmail() {
// Only run this test if the email configuration is valid
if (!EmailConfig.isValid()) {
log.warn("Skipping test because email configuration is not valid")
return
}
// Get the EmailService instance
val emailService = EmailService.getInstance()
// Send a test email with debug enabled
val result = emailService.sendTestEmail(debug = true)
// Log the result
if (result) {
log.info("Test email sent successfully")
} else {
log.error("Failed to send test email")
}
// Assert that the email was sent successfully
assertTrue(result, "Test email should be sent successfully")
}
}
@@ -0,0 +1,22 @@
package at.mocode.model
import kotlinx.serialization.Serializable
/**
* Represents a competition (Bewerb) within a tournament.
* A competition has specific details like number, title, class, and optional task.
*/
@Serializable
data class Bewerb(
/** Competition number, e.g. 1, 2, etc. */
val nummer: Int,
/** Title of the competition, e.g. "Stilspringprüfung" or "Dressurprüfung" */
val titel: String,
/** Class/level of the competition, e.g. "60 cm" or "Kl. A" */
val klasse: String = "",
/** Optional task identifier, e.g. "DRA 1" */
val task: String? = null
)
@@ -4,13 +4,24 @@ import kotlinx.serialization.Serializable
@Serializable @Serializable
data class Nennung( data class Nennung(
// Wir brauchen die Turnier-ID, um die Nennung zuzuordnen /** Name of the rider */
val turnierId: String, val riderName: String,
// Einfache Felder für den Start
val riderName: String = "", // Standardwerte für leeres Formular /** Name of the horse */
val horseName: String = "", val horseName: String,
val email: String = "",
val comments: String? = null /** Email address for contact */
// Hier kommen später Felder hinzu: Verein, Lizenznr., Tel, val email: String,
// und vor allem: die Auswahl der Prüfungen!
/** Phone number for contact */
val phone: String,
/** List of selected event numbers */
val selectedEvents: List<String>,
/** Additional comments or wishes */
val comments: String,
/** The tournament this registration is for */
val turnier: Turnier
) )
@@ -1,13 +1,18 @@
package at.mocode.model package at.mocode.model
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@Serializable @Serializable
data class Turnier( data class Turnier(
val id: String, // Eine eindeutige ID für das Turnier (z.B. eine UUID als String) /** The name of the tournament, e.g. "CSN-C NEU CSNP-C NEU NEUMARKT/M., OÖ" */
val name: String, // Der Name, z.B. "CDN-C Edelhof April 2025" val name: String,
val datum: String, // Das Datum oder der Zeitraum, erstmal als Text, z.B. "14.04.2025 - 15.04.2025"
val logoUrl: String? = null, // Optional: Link zum Logo des Veranstalters /** The date of the tournament as a formatted string, e.g. "7.JUNI 2025" */
val ausschreibungUrl: String? = null // Optional: Link zum Ausschreibung-PDF val datum: String,
// Hier können später viele weitere Felder hinzukommen:
// Ort, Veranstalter, Status (geplant, läuft, beendet), Disziplinen etc. /** Unique identifier for the tournament */
val number: Int,
/** List of competitions (Bewerbe) associated with this tournament */
var bewerbe: List<Bewerb> = emptyList()
) )