From ef59fa35b12f278d2839ee6f92373de1cc28c628 Mon Sep 17 00:00:00 2001 From: stefan Date: Thu, 5 Jun 2025 12:57:07 +0200 Subject: [PATCH] erste Version Online-Nennen --- data/meldestelle.db | Bin 0 -> 32768 bytes meldestelle.db | 0 server/build.gradle.kts | 9 +- .../src/main/kotlin/at/mocode/Application.kt | 101 +--- .../at/mocode/config/DependencyInjection.kt | 44 ++ .../kotlin/at/mocode/config/EmailConfig.kt | 107 ++++ .../kotlin/at/mocode/email/EmailService.kt | 298 ++++++++++ .../src/main/kotlin/at/mocode/model/Bewerb.kt | 22 + .../main/kotlin/at/mocode/model/Nennung.kt | 29 +- .../main/kotlin/at/mocode/model/Turnier.kt | 18 +- .../main/kotlin/at/mocode/plugins/Database.kt | 79 +-- .../at/mocode/repository/NennungRepository.kt | 80 +++ .../at/mocode/repository/TurnierRepository.kt | 246 ++++++++ .../kotlin/at/mocode/routes/AdminRoutes.kt | 162 +++++ .../kotlin/at/mocode/routes/HomeRoutes.kt | 37 ++ .../kotlin/at/mocode/routes/NennungRoutes.kt | 119 ++++ .../main/kotlin/at/mocode/tables/Tables.kt | 146 ++++- .../main/kotlin/at/mocode/views/AdminView.kt | 561 ++++++++++++++++++ .../main/kotlin/at/mocode/views/HomeView.kt | 135 +++++ .../kotlin/at/mocode/views/LayoutTemplate.kt | 477 +++++++++++++++ .../kotlin/at/mocode/views/NennungView.kt | 392 ++++++++++++ .../test/kotlin/at/mocode/ApplicationTest.kt | 14 +- .../src/test/kotlin/at/mocode/TestDatabase.kt | 86 ++- .../at/mocode/email/EmailServiceTest.kt | 54 ++ .../kotlin/at/mocode/model/Bewerb.kt | 22 + .../kotlin/at/mocode/model/Nennung.kt | 31 +- .../kotlin/at/mocode/model/Turnier.kt | 19 +- 27 files changed, 3081 insertions(+), 207 deletions(-) create mode 100644 data/meldestelle.db create mode 100644 meldestelle.db create mode 100644 server/src/main/kotlin/at/mocode/config/DependencyInjection.kt create mode 100644 server/src/main/kotlin/at/mocode/config/EmailConfig.kt create mode 100644 server/src/main/kotlin/at/mocode/email/EmailService.kt create mode 100644 server/src/main/kotlin/at/mocode/model/Bewerb.kt create mode 100644 server/src/main/kotlin/at/mocode/repository/NennungRepository.kt create mode 100644 server/src/main/kotlin/at/mocode/repository/TurnierRepository.kt create mode 100644 server/src/main/kotlin/at/mocode/routes/AdminRoutes.kt create mode 100644 server/src/main/kotlin/at/mocode/routes/HomeRoutes.kt create mode 100644 server/src/main/kotlin/at/mocode/routes/NennungRoutes.kt create mode 100644 server/src/main/kotlin/at/mocode/views/AdminView.kt create mode 100644 server/src/main/kotlin/at/mocode/views/HomeView.kt create mode 100644 server/src/main/kotlin/at/mocode/views/LayoutTemplate.kt create mode 100644 server/src/main/kotlin/at/mocode/views/NennungView.kt create mode 100644 server/src/test/kotlin/at/mocode/email/EmailServiceTest.kt create mode 100644 shared/src/commonMain/kotlin/at/mocode/model/Bewerb.kt diff --git a/data/meldestelle.db b/data/meldestelle.db new file mode 100644 index 0000000000000000000000000000000000000000..13b965f3c9732337b9d23be82e654e62cfeb4f8c GIT binary patch literal 32768 zcmeI5&u<$=6vy|6W5o5@&=^Ipx3s2_(320ZwrbR6?i{NE~=;@7h_{u3B-RsoqM~&g}cn zdv88(*3nw4$?D}~!C*AMt(UR}o#75~JkMRAl;b!re7fOdA6?LJ*&R6Z``VqFUM{44 zC6T{4Pfv^^Gvr(UPf}hw+ItPUcpw2JfCP{L5mDOc$w`6`XArI!~| zFmg#wr6(Js>S~%=PSey{GC4_W0W(^qq%xhp9#i74#+32!^mK5aCcd0nO)D`7L-Si1 zX9A6GA(zR3_l0FeUc8v9G0oGC64M?RkY30!J)>nytU)0XjqazgxviJk zBNeQaEfhNq-`U*On!Z~IuWy%1X5ndCz5jOv1?E z?RQ+Bz`y|i;Y7`+%C0kAW!9<7;lsN4xaEeam0-a;ff|r;$NFs*v*mKf0vg%!R-^Lu z;?{02C+C)U&H`F9_}|L+w&)2=4Dq-4f|g@9>{Vc`9=q$Zps%GCFR#fkpM-pc+D3b& z!R>2bqRmxwtB>~th9E$tR!m!Ap9M!b?W@i44tD#$>L%%kxnM>b_*?GVT-K8x8d^I%{rzv@jLTklprdXwv)whh(OQA`+y!+W) z=r#Byf<~qz(K!Lejj7dmqA@PfaopJuZ48{@UEcHO>fz)ZD{gM*X>3P_U-C@1;n~pi zY{+cUDH@L2ogSEzNbYmwA99~Oc%liS7zrQ&B!C2v01`j~NB{{S0VIF~kN^^RDhaqn zfmip$+NmmtqSuSh|4(J1FexN}1dsp{Kmter2_OL^fCP{L5@Za%o`epyHpO=1?zLY+aHl>%P0pA1P_rA}4w|sBGAUu!& z5y=gRP!v9__J@7~SB<_|O+j?(p# zx;HWa!kGXk^sX9(;=>|S^Wt&S8EASsIh&4}y3u2%F9_;w=WDtiaO`;y&4H+UwVqL) z={eTS%aWZ8fNZ{>6FhQ3D`qSCmel5J5l5agBSL2GHWBCR0Z;XV<~(S+R<^bGXlLQ* zQPUY@=4zukI}Ny~x_9b`YA}W59c+(cJO7%1`mO&3DPI{ z5;`lFE4oc9nT)8_lLV}Dq+jT@tU80e1)7?Kk_@ZW6Mj%d!ADOmR;#0xGir5Evch;< ztg+ZEgz;Hny4!|{C6t&Mrq>GNX%{B80+PWVkent^vxVKeoBGY&yBn-n-|=8dtp>bc z9Rq9ELP0MZR4L?gOasGIHyGwm91&b?GcR;k0VIF~kN^@u0!RP}AOR$R1dsp{IH&|9 zK^*2A+lQJv`$=AV!_vc|7_@EHo~&XoFAnh5CXIvj{r^7Y$ZzCp^65cEfO#VUB!C2v z01`j~NB{{S0VIF~kN^@mfCNUxZth5bP&(#a%HD{DBQpjo8y7ar|H=`9|FChrqLtS- z#{^3xJVK;ahxocoYVWK5{C|%lf0IARy#vS!Q$_+v00|%gB!C2v01`j~NB{{S0VIF~ YngraUi{ptHaSP^Ih;;ET&PyWy0&) { EngineMain.main(args) } +/** + * Application module configuration. + */ fun Application.module() { - - // Als Erstes die Datenbank konfigurieren: + // Configure database first configureDatabase() - // Danach deine anderen Konfigurationen (Routing etc.): - routing { - get("/") { - // Logger holen (optional, aber nützlich) - 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 - } // 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("/") - } + // Configure routes + configureHomeRoutes() + configureNennungRoutes() + configureAdminRoutes() } diff --git a/server/src/main/kotlin/at/mocode/config/DependencyInjection.kt b/server/src/main/kotlin/at/mocode/config/DependencyInjection.kt new file mode 100644 index 00000000..6d07b300 --- /dev/null +++ b/server/src/main/kotlin/at/mocode/config/DependencyInjection.kt @@ -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() + } +} diff --git a/server/src/main/kotlin/at/mocode/config/EmailConfig.kt b/server/src/main/kotlin/at/mocode/config/EmailConfig.kt new file mode 100644 index 00000000..a12e85a4 --- /dev/null +++ b/server/src/main/kotlin/at/mocode/config/EmailConfig.kt @@ -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() + + 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(", ") + } +} diff --git a/server/src/main/kotlin/at/mocode/email/EmailService.kt b/server/src/main/kotlin/at/mocode/email/EmailService.kt new file mode 100644 index 00000000..7641ecc4 --- /dev/null +++ b/server/src/main/kotlin/at/mocode/email/EmailService.kt @@ -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(""" + + +

Dies ist eine Test-Email vom Meldestelle System

+

Wenn Sie diese Email erhalten haben, funktioniert die Email-Konfiguration korrekt.

+

SMTP-Konfiguration:

+
    +
  • Host: $smtpHost
  • +
  • Port: $smtpPort
  • +
  • Benutzername: $smtpUsername
  • +
+ + + """.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("") + + // Add tournament information if available + nennung.turnier?.let { turnier -> + sb.append("

${turnier.name}

") + } + + sb.append("

Reiter: ${nennung.riderName}

") + sb.append("

Pferd: ${nennung.horseName}

") + sb.append("

E-Mail: ${nennung.email}

") + sb.append("

Telefon: ${nennung.phone}

") + + sb.append("

Bewerbe:

") + sb.append("
    ") + nennung.selectedEvents.forEach { event -> + sb.append("
  • $event
  • ") + } + sb.append("
") + + if (nennung.comments.isNotBlank()) { + sb.append("

Bemerkungen:

") + sb.append("

${nennung.comments.replace("\n", "
")}

") + } + + sb.append("") + return sb.toString() + } + } +} diff --git a/server/src/main/kotlin/at/mocode/model/Bewerb.kt b/server/src/main/kotlin/at/mocode/model/Bewerb.kt new file mode 100644 index 00000000..3d38288a --- /dev/null +++ b/server/src/main/kotlin/at/mocode/model/Bewerb.kt @@ -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 +) diff --git a/server/src/main/kotlin/at/mocode/model/Nennung.kt b/server/src/main/kotlin/at/mocode/model/Nennung.kt index 0082c2cf..b030e366 100644 --- a/server/src/main/kotlin/at/mocode/model/Nennung.kt +++ b/server/src/main/kotlin/at/mocode/model/Nennung.kt @@ -4,13 +4,24 @@ import kotlinx.serialization.Serializable @Serializable data class Nennung( - // Wir brauchen die Turnier-ID, um die Nennung zuzuordnen - val turnierId: String, - // Einfache Felder für den Start - val riderName: String = "", // Standardwerte für leeres Formular - val horseName: String = "", - val email: String = "", - val comments: String? = null - // Hier kommen später Felder hinzu: Verein, Lizenznr., Tel, - // und vor allem: die Auswahl der Prüfungen! + /** Name of the rider */ + val riderName: String, + + /** Name of the horse */ + val horseName: String, + + /** Email address for contact */ + val email: String, + + /** Phone number for contact */ + val phone: String, + + /** List of selected event numbers */ + val selectedEvents: List, + + /** Additional comments or wishes */ + val comments: String, + + /** The tournament this registration is for */ + val turnier: Turnier ) diff --git a/server/src/main/kotlin/at/mocode/model/Turnier.kt b/server/src/main/kotlin/at/mocode/model/Turnier.kt index 5e703109..23013f10 100644 --- a/server/src/main/kotlin/at/mocode/model/Turnier.kt +++ b/server/src/main/kotlin/at/mocode/model/Turnier.kt @@ -4,11 +4,15 @@ import kotlinx.serialization.Serializable @Serializable data class Turnier( - val id: String, // Eine eindeutige ID für das Turnier (z.B. eine UUID als String) - val name: String, // Der Name, z.B. "CDN-C Edelhof April 2025" - 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 - val ausschreibungUrl: String? = null // Optional: Link zur Ausschreibungs-PDF - // Hier können später viele weitere Felder hinzukommen: - // Ort, Veranstalter, Status (geplant, läuft, beendet), Disziplinen etc. + /** The name of the tournament, e.g. "CSN-C NEU CSNP-C NEU NEUMARKT/M., OÖ" */ + val name: String, + + /** The date of the tournament as a formatted string, e.g. "7.JUNI 2025" */ + val datum: String, + + /** Unique identifier for the tournament */ + val number: Int, + + /** List of competitions (Bewerbe) associated with this tournament */ + var bewerbe: List = emptyList() ) diff --git a/server/src/main/kotlin/at/mocode/plugins/Database.kt b/server/src/main/kotlin/at/mocode/plugins/Database.kt index ba3feea4..fb64169d 100644 --- a/server/src/main/kotlin/at/mocode/plugins/Database.kt +++ b/server/src/main/kotlin/at/mocode/plugins/Database.kt @@ -4,52 +4,67 @@ import com.zaxxer.hikari.HikariConfig import com.zaxxer.hikari.HikariDataSource import org.jetbrains.exposed.sql.Database import org.slf4j.LoggerFactory - import org.jetbrains.exposed.sql.SchemaUtils import org.jetbrains.exposed.sql.transactions.transaction 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() { 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 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 { - // H2 im PostgreSQL-Kompatibilitätsmodus starten, kann helfen - Database.connect("jdbc:h2:mem:test;DB_CLOSE_DELAY=-1;MODE=PostgreSQL", driver = "org.h2.Driver") - log.info("Connected to H2 (test) successfully.") + Database.connect("jdbc:sqlite::memory:", driver = "org.sqlite.JDBC") + log.info("Connected to SQLite in-memory (test) successfully.") connectionSuccessful = true } catch (e: Exception) { - log.error("Failed to connect to H2 (test)!", e) - throw e // Fehler weiterwerfen, Test soll fehlschlagen + log.error("Failed to connect to SQLite (test)!", e) + throw e // Rethrow error, test should fail } } else { - // Prüfen, ob wir in IDEA laufen (keine Docker Umgebungsvariablen gesetzt) - // wir prüfen nur eine Variable, das reicht meistens + // Check if we're running in IDEA (no Docker environment variables set) + // We only check one variable, that's usually enough val dbHostFromEnv = System.getenv("DB_HOST") val isIdeaEnvironment = (dbHostFromEnv == null) 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 { - Database.connect("jdbc:h2:mem:dev;DB_CLOSE_DELAY=-1;MODE=PostgreSQL", driver = "org.h2.Driver") - log.info("Connected to H2 (dev) successfully.") + Database.connect("jdbc:sqlite:data/meldestelle.db", driver = "org.sqlite.JDBC") + log.info("Connected to SQLite file database (dev) successfully.") connectionSuccessful = true } catch (e: Exception) { - log.error("Failed to connect to H2 (dev)!", e) - // Hier vielleicht nicht werfen, damit App in IDE trotzdem startet? Oder doch? → Aktuell wirft es. + log.error("Failed to connect to SQLite (dev)!", e) + // Maybe don't throw here so the app starts in IDE anyway? Currently it throws. throw e } } else { - // Normale Docker/Produktionsumgebung -> PostgreSQL verwenden + // Normal Docker/Production environment -> use PostgreSQL log.info("Production/Docker environment detected, connecting to PostgreSQL...") try { - // Lese Konfiguration direkt aus Umgebungsvariablen - val dbHost = dbHostFromEnv // Sicherer Fallback + // Read configuration directly from environment variables + val dbHost = dbHostFromEnv // Safe fallback val dbPort = System.getenv("DB_PORT") ?: "5432" 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") @@ -74,34 +89,24 @@ fun configureDatabase() { connectionSuccessful = true } catch (e: Exception) { 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) --- - // Führe dies nur aus, wenn *irgendeine* DB-Verbindung erfolgreich war - transaction { // Führe Schema-Operationen in einer Transaktion aus + // --- Schema Initialization (NOW CENTRALIZED) --- + // Only execute this if *any* DB connection was successful + transaction { // Execute schema operations in a transaction log.info("Initializing/Verifying database schema...") try { - // Erstellt die Tabelle(n), falls sie noch nicht existieren - SchemaUtils.create(TurniereTable) - // Füge hier später weitere Tabellen hinzu: - // SchemaUtils.create(TurniereTable, NennungenTable, ...) + // Create the table(s) if they don't exist yet + SchemaUtils.create(TurniereTable, BewerbeTable, NennungenTable, NennungEventsTable) log.info("Database schema initialized successfully (tables created/verified).") } catch (e: Exception) { log.error("Failed to initialize database schema!", e) - // Hier könntest du entscheiden, ob ein Fehler beim Schema kritisch ist - // throw e // Auskommentiert: App startet evtl. trotzdem, auch wenn Schema fehlt/falsch ist + // Here you could decide if a schema error is critical + // 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 - // } - // ------------------------------------ } diff --git a/server/src/main/kotlin/at/mocode/repository/NennungRepository.kt b/server/src/main/kotlin/at/mocode/repository/NennungRepository.kt new file mode 100644 index 00000000..a5222325 --- /dev/null +++ b/server/src/main/kotlin/at/mocode/repository/NennungRepository.kt @@ -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, + 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 + ) + } +} diff --git a/server/src/main/kotlin/at/mocode/repository/TurnierRepository.kt b/server/src/main/kotlin/at/mocode/repository/TurnierRepository.kt new file mode 100644 index 00000000..f7ff4e46 --- /dev/null +++ b/server/src/main/kotlin/at/mocode/repository/TurnierRepository.kt @@ -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): 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): 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 = 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 + } +} diff --git a/server/src/main/kotlin/at/mocode/routes/AdminRoutes.kt b/server/src/main/kotlin/at/mocode/routes/AdminRoutes.kt new file mode 100644 index 00000000..2f2f7097 --- /dev/null +++ b/server/src/main/kotlin/at/mocode/routes/AdminRoutes.kt @@ -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() + 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() + 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() + } +} diff --git a/server/src/main/kotlin/at/mocode/routes/HomeRoutes.kt b/server/src/main/kotlin/at/mocode/routes/HomeRoutes.kt new file mode 100644 index 00000000..8a31dc97 --- /dev/null +++ b/server/src/main/kotlin/at/mocode/routes/HomeRoutes.kt @@ -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() + } +} diff --git a/server/src/main/kotlin/at/mocode/routes/NennungRoutes.kt b/server/src/main/kotlin/at/mocode/routes/NennungRoutes.kt new file mode 100644 index 00000000..ef44f274 --- /dev/null +++ b/server/src/main/kotlin/at/mocode/routes/NennungRoutes.kt @@ -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() + } +} diff --git a/server/src/main/kotlin/at/mocode/tables/Tables.kt b/server/src/main/kotlin/at/mocode/tables/Tables.kt index d22205f0..db3e2d2d 100644 --- a/server/src/main/kotlin/at/mocode/tables/Tables.kt +++ b/server/src/main/kotlin/at/mocode/tables/Tables.kt @@ -3,30 +3,142 @@ package at.mocode.tables import org.jetbrains.exposed.sql.Table 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 = integer("number").uniqueIndex() - // Spaltendefinitionen - wir mappen die Felder unserer data class Turnier - // wir wählen hier passende SQL-Datentypen aus. - - // 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 = varchar("id", 36).uniqueIndex() - - // name: Ein Textfeld, max. 255 Zeichen + /** + * Name of the tournament, max 255 characters. + */ val name: Column = varchar("name", 255) - // datum: Vorerst einfacher Text, max. 100 Zeichen + /** + * Date of the tournament as text, max 100 characters. + */ val datum: Column = varchar("datum", 100) - // logoUrl: Textfeld, max. 500 Zeichen, kann NULL sein (.nullable()) - val logoUrl: Column = varchar("logo_url", 500).nullable() + // Define the 'number' column as the primary key for this table + override val primaryKey = PrimaryKey(number) +} - // ausschreibungUrl: Textfeld, max. 500 Zeichen, kann NULL sein - val ausschreibungUrl: Column = 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 = integer("id").autoIncrement() - // Definiert die Spalte 'id' als Primärschlüssel für diese Tabelle + /** + * Number of the competition. + */ + val nummer: Column = integer("nummer") + + /** + * Title of the competition. + */ + val titel: Column = varchar("titel", 255) + + /** + * Class/level of the competition. + */ + val klasse: Column = varchar("klasse", 100) + + /** + * Optional task identifier. + */ + val task: Column = varchar("task", 100).nullable() + + /** + * Foreign key to the tournament table. + */ + val turnierNumber: Column = 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) } -// 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 = integer("id").autoIncrement() + + /** + * Name of the rider. + */ + val riderName: Column = varchar("rider_name", 255) + + /** + * Name of the horse. + */ + val horseName: Column = varchar("horse_name", 255) + + /** + * Email address for contact. + */ + val email: Column = varchar("email", 255) + + /** + * Phone number for contact. + */ + val phone: Column = varchar("phone", 100) + + /** + * Additional comments or wishes. + */ + val comments: Column = text("comments") + + /** + * Foreign key to the tournament table. + */ + val turnierNumber: Column = 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 = integer("id").autoIncrement() + + /** + * Foreign key to the registration table. + */ + val nennungId: Column = integer("nennung_id") + + /** + * Number of the selected competition. + */ + val eventNumber: Column = 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) +} diff --git a/server/src/main/kotlin/at/mocode/views/AdminView.kt b/server/src/main/kotlin/at/mocode/views/AdminView.kt new file mode 100644 index 00000000..6dfccb67 --- /dev/null +++ b/server/src/main/kotlin/at/mocode/views/AdminView.kt @@ -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) { + 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 = '

Bewerb ' + bewerbCounter + '

' + + '
' + + '
' + + '' + + '' + + '
' + + '
' + + '' + + '' + + '
' + + '
' + + '' + + '' + + '
' + + '
' + + '
' + + '' + + '' + + '
' + + ''; + + 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 = '

Neuer Bewerb

' + + '
' + + '
' + + '' + + '' + + '
' + + '
' + + '' + + '' + + '
' + + '
' + + '' + + '' + + '
' + + '
' + + '
' + + '' + + '' + + '
' + + ''; + + container.appendChild(bewerbDiv); + } + + function removeBewerbField(id) { + const bewerbDiv = document.getElementById('bewerb-' + id); + bewerbDiv.remove(); + } + """ + } + } + } + } + } + } +} diff --git a/server/src/main/kotlin/at/mocode/views/HomeView.kt b/server/src/main/kotlin/at/mocode/views/HomeView.kt new file mode 100644 index 00000000..03d2d91c --- /dev/null +++ b/server/src/main/kotlin/at/mocode/views/HomeView.kt @@ -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) { + 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; + } + """ + } + } + } + } + } +} diff --git a/server/src/main/kotlin/at/mocode/views/LayoutTemplate.kt b/server/src/main/kotlin/at/mocode/views/LayoutTemplate.kt new file mode 100644 index 00000000..0451350a --- /dev/null +++ b/server/src/main/kotlin/at/mocode/views/LayoutTemplate.kt @@ -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" } + } + } + } + } + } + } +} diff --git a/server/src/main/kotlin/at/mocode/views/NennungView.kt b/server/src/main/kotlin/at/mocode/views/NennungView.kt new file mode 100644 index 00000000..76bb5b51 --- /dev/null +++ b/server/src/main/kotlin/at/mocode/views/NennungView.kt @@ -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; + } + """ + } + } + } + } + } +} diff --git a/server/src/test/kotlin/at/mocode/ApplicationTest.kt b/server/src/test/kotlin/at/mocode/ApplicationTest.kt index efebaac2..cd622627 100644 --- a/server/src/test/kotlin/at/mocode/ApplicationTest.kt +++ b/server/src/test/kotlin/at/mocode/ApplicationTest.kt @@ -14,11 +14,9 @@ class ApplicationTest { fun testRootRouteShowsTournamentList() { // Erstelle ein Beispiel-Turnier, das in der Datenbank sein würde val mockTurnier = Turnier( - id = "dummy-01", name = "Erstes DB Turnier", datum = "19.04.2025", - logoUrl = null, - ausschreibungUrl = "/pdfs/ausschreibung_dummy.pdf" + number = 1 ) // Erstelle eine Liste von Turnieren, wie sie aus der Datenbank kommen würde @@ -45,13 +43,7 @@ class ApplicationTest { strong { +turnier.name } +" (${turnier.datum})" +" " - if (turnier.ausschreibungUrl != null) { - a(href = turnier.ausschreibungUrl, target = "_blank") { - button { +"Ausschreibung" } - } - +" " - } - a(href = "/nennung/${turnier.id}") { + a(href = "/nennung/${turnier.number}") { button { +"Online Nennen" } } } @@ -82,7 +74,7 @@ class ApplicationTest { htmlContent.contains("(19.04.2025)"), "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( htmlContent.contains("Keine Turniere in der Datenbank gefunden."), "'No tournaments' message should not be present if dummy was inserted" diff --git a/server/src/test/kotlin/at/mocode/TestDatabase.kt b/server/src/test/kotlin/at/mocode/TestDatabase.kt index 6f238d59..21b0eb01 100644 --- a/server/src/test/kotlin/at/mocode/TestDatabase.kt +++ b/server/src/test/kotlin/at/mocode/TestDatabase.kt @@ -1,16 +1,23 @@ package at.mocode +import at.mocode.model.Bewerb import at.mocode.model.Turnier +import at.mocode.tables.BewerbeTable import at.mocode.tables.TurniereTable +import `import org`.jetbrains.exposed.sql.selectAll import io.ktor.http.* import io.ktor.server.application.* import io.ktor.server.html.* import io.ktor.server.routing.* import kotlinx.html.* import org.jetbrains.exposed.sql.Database +import org.jetbrains.exposed.sql.Op import org.jetbrains.exposed.sql.SchemaUtils +import org.jetbrains.exposed.sql.SqlExpressionBuilder import org.jetbrains.exposed.sql.insert 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.slf4j.LoggerFactory @@ -19,24 +26,40 @@ import org.slf4j.LoggerFactory */ fun configureTestDatabase() { 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 - Database.connect("jdbc:h2:mem:test;DB_CLOSE_DELAY=-1", driver = "org.h2.Driver") + // Verbinde mit einer In-Memory-SQLite-Datenbank + Database.connect("jdbc:sqlite::memory:", driver = "org.sqlite.JDBC") // Initialisiere das Datenbankschema transaction { log.info("Creating test database schema...") - SchemaUtils.create(TurniereTable) + SchemaUtils.create(TurniereTable, BewerbeTable) // Füge ein Test-Turnier hinzu log.info("Inserting test tournament data...") + val turnierNumber = 1 TurniereTable.insert { - it[id] = "dummy-01" - it[name] = "Erstes DB Turnier" - it[datum] = "19.04.2025" - it[logoUrl] = null - it[ausschreibungUrl] = "/pdfs/ausschreibung_dummy.pdf" + it[TurniereTable.number] = turnierNumber + it[TurniereTable.name] = "CSN-C Edelhof April 2025" + it[TurniereTable.datum] = "14.04.2025 - 15.04.2025" + } + + // 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!") @@ -57,15 +80,30 @@ fun Application.testModule() { // Lese Daten aus der Test-Datenbank val turniereFromDb = transaction { - TurniereTable.selectAll().map { row -> + // Get all tournaments + val turniere = TurniereTable.selectAll().map { row -> Turnier( - id = row[TurniereTable.id], name = row[TurniereTable.name], datum = row[TurniereTable.datum], - logoUrl = row[TurniereTable.logoUrl], - ausschreibungUrl = row[TurniereTable.ausschreibungUrl] + 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 } // HTML-Antwort generieren (wie in Application.kt) @@ -88,13 +126,25 @@ fun Application.testModule() { strong { +turnier.name } +" (${turnier.datum})" +" " - if (turnier.ausschreibungUrl != null) { - a(href = turnier.ausschreibungUrl, target = "_blank") { - button { +"Ausschreibung" } + div { + +"Bewerbe: " + 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" } } } diff --git a/server/src/test/kotlin/at/mocode/email/EmailServiceTest.kt b/server/src/test/kotlin/at/mocode/email/EmailServiceTest.kt new file mode 100644 index 00000000..107421c9 --- /dev/null +++ b/server/src/test/kotlin/at/mocode/email/EmailServiceTest.kt @@ -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") + } +} diff --git a/shared/src/commonMain/kotlin/at/mocode/model/Bewerb.kt b/shared/src/commonMain/kotlin/at/mocode/model/Bewerb.kt new file mode 100644 index 00000000..ee25964b --- /dev/null +++ b/shared/src/commonMain/kotlin/at/mocode/model/Bewerb.kt @@ -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 +) \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/at/mocode/model/Nennung.kt b/shared/src/commonMain/kotlin/at/mocode/model/Nennung.kt index 600956cd..b030e366 100644 --- a/shared/src/commonMain/kotlin/at/mocode/model/Nennung.kt +++ b/shared/src/commonMain/kotlin/at/mocode/model/Nennung.kt @@ -4,13 +4,24 @@ import kotlinx.serialization.Serializable @Serializable data class Nennung( - // Wir brauchen die Turnier-ID, um die Nennung zuzuordnen - val turnierId: String, - // Einfache Felder für den Start - val riderName: String = "", // Standardwerte für leeres Formular - val horseName: String = "", - val email: String = "", - val comments: String? = null - // Hier kommen später Felder hinzu: Verein, Lizenznr., Tel, - // und vor allem: die Auswahl der Prüfungen! -) \ No newline at end of file + /** Name of the rider */ + val riderName: String, + + /** Name of the horse */ + val horseName: String, + + /** Email address for contact */ + val email: String, + + /** Phone number for contact */ + val phone: String, + + /** List of selected event numbers */ + val selectedEvents: List, + + /** Additional comments or wishes */ + val comments: String, + + /** The tournament this registration is for */ + val turnier: Turnier +) diff --git a/shared/src/commonMain/kotlin/at/mocode/model/Turnier.kt b/shared/src/commonMain/kotlin/at/mocode/model/Turnier.kt index 16c256aa..23013f10 100644 --- a/shared/src/commonMain/kotlin/at/mocode/model/Turnier.kt +++ b/shared/src/commonMain/kotlin/at/mocode/model/Turnier.kt @@ -1,13 +1,18 @@ package at.mocode.model import kotlinx.serialization.Serializable + @Serializable data class Turnier( - val id: String, // Eine eindeutige ID für das Turnier (z.B. eine UUID als String) - val name: String, // Der Name, z.B. "CDN-C Edelhof April 2025" - 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 - val ausschreibungUrl: String? = null // Optional: Link zum Ausschreibung-PDF - // Hier können später viele weitere Felder hinzukommen: - // Ort, Veranstalter, Status (geplant, läuft, beendet), Disziplinen etc. + /** The name of the tournament, e.g. "CSN-C NEU CSNP-C NEU NEUMARKT/M., OÖ" */ + val name: String, + + /** The date of the tournament as a formatted string, e.g. "7.JUNI 2025" */ + val datum: String, + + /** Unique identifier for the tournament */ + val number: Int, + + /** List of competitions (Bewerbe) associated with this tournament */ + var bewerbe: List = emptyList() )