diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 0000000..8e007e8 --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,7 @@ +[[allowlists]] +description = "generated/imported non-secret data" +paths = [ + '''server/data/spielplan-import/harheimer_tc_spielplan\.(html|json)$''', + '''android-app/app/build/.*''', + '''android-app/\.idea/planningMode\.xml$''', +] diff --git a/android-app/app/build.gradle.kts b/android-app/app/build.gradle.kts index 5844f7a..76c36e1 100644 --- a/android-app/app/build.gradle.kts +++ b/android-app/app/build.gradle.kts @@ -88,6 +88,13 @@ android { versionName = androidVersionName } + lint { + disable += setOf( + "AutoboxingStateCreation", + "MutableCollectionMutableState", + ) + } + signingConfigs { create("release") { if (hasReleaseSigning) { diff --git a/android-app/app/production/release/app-production-release.aab b/android-app/app/production/release/app-production-release.aab deleted file mode 100644 index 85a22bc..0000000 Binary files a/android-app/app/production/release/app-production-release.aab and /dev/null differ diff --git a/android-app/app/src/main/java/de/harheimertc/data/ApiService.kt b/android-app/app/src/main/java/de/harheimertc/data/ApiService.kt index bb920b6..b445344 100644 --- a/android-app/app/src/main/java/de/harheimertc/data/ApiService.kt +++ b/android-app/app/src/main/java/de/harheimertc/data/ApiService.kt @@ -21,6 +21,7 @@ import okhttp3.RequestBody data class ContactRequest(val name: String, val email: String, val message: String) data class ContactResponse(val ok: Boolean, val id: String? = null, val message: String? = null) data class TermineResponse(val success: Boolean = true, val termine: List = emptyList()) +data class TermineManageResponse(val success: Boolean = true, val termine: List = emptyList()) data class TerminDto( val datum: String = "", val uhrzeit: String? = null, @@ -231,7 +232,7 @@ data class ProfileVisibilityDto( val showEmail: Boolean = true, val showPhone: Boolean = true, val showAddress: Boolean = false, - val showBirthday: Boolean = true, + val showBirthday: Boolean = false, ) data class ProfileUserDto( val id: String? = null, @@ -328,6 +329,7 @@ data class MemberDto( val editable: Boolean = false, val isMannschaftsspieler: Boolean = false, val hasHallKey: Boolean = false, + val showBirthday: Boolean = false, val loginRoles: List = emptyList(), ) data class MembersResponse( @@ -590,6 +592,21 @@ interface ApiService { @GET("/api/termine") suspend fun termine(): Response + @GET("/api/termine-manage") + suspend fun termineManage(): Response + + @POST("/api/termine-manage") + suspend fun saveTermin(@Body request: TerminDto): Response + + @DELETE("/api/termine-manage") + suspend fun deleteTermin( + @Query("datum") datum: String, + @Query("uhrzeit") uhrzeit: String = "", + @Query("titel") titel: String, + @Query("beschreibung") beschreibung: String = "", + @Query("kategorie") kategorie: String = "Sonstiges", + ): Response + @GET("/api/spielplan") suspend fun spielplan(@Query("season") season: String? = null): Response @@ -713,6 +730,7 @@ interface ApiService { val notes: String? = null, val isMannschaftsspieler: Boolean = false, val hasHallKey: Boolean = false, + val showBirthday: Boolean = false, ) data class BulkImportRequest(val members: List>) diff --git a/android-app/app/src/main/java/de/harheimertc/data/ConnectivityMonitor.kt b/android-app/app/src/main/java/de/harheimertc/data/ConnectivityMonitor.kt index ee5652b..6cc4607 100644 --- a/android-app/app/src/main/java/de/harheimertc/data/ConnectivityMonitor.kt +++ b/android-app/app/src/main/java/de/harheimertc/data/ConnectivityMonitor.kt @@ -19,7 +19,7 @@ import javax.inject.Singleton @Singleton class ConnectivityMonitor @Inject constructor( - @ApplicationContext private val context: Context, + @param:ApplicationContext private val context: Context, ) { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val _online = MutableStateFlow(hasInternetAccess()) @@ -46,4 +46,4 @@ class ConnectivityMonitor @Inject constructor( val capabilities = manager.getNetworkCapabilities(network) ?: return false return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) } -} \ No newline at end of file +} diff --git a/android-app/app/src/main/java/de/harheimertc/notifications/HarheimerNotifications.kt b/android-app/app/src/main/java/de/harheimertc/notifications/HarheimerNotifications.kt index 848a4f9..e67462a 100644 --- a/android-app/app/src/main/java/de/harheimertc/notifications/HarheimerNotifications.kt +++ b/android-app/app/src/main/java/de/harheimertc/notifications/HarheimerNotifications.kt @@ -51,8 +51,12 @@ object HarheimerNotifications { .setContentIntent(createContentIntent(context, notificationId, data)) .setAutoCancel(true) .build() - NotificationManagerCompat.from(context).notify(notificationId, notification) - return true + return try { + NotificationManagerCompat.from(context).notify(notificationId, notification) + true + } catch (_: SecurityException) { + false + } } private fun createContentIntent(context: Context, notificationId: Int, payload: Map): PendingIntent { @@ -72,7 +76,12 @@ object HarheimerNotifications { } private fun destinationRoute(data: Map): String = when (data["type"]) { - "news" -> Destinations.MemberNews.route + "news", "news_expiring" -> Destinations.MemberNews.route + "event", "events_today", "events_tomorrow" -> Destinations.Termine.route + "team_matches" -> Destinations.Spielplan.route + "birthdays" -> Destinations.MemberArea.route + "contact_request" -> Destinations.CmsContactRequests.route + "user_registration" -> Destinations.CmsBenutzer.route else -> Destinations.Home.route } } diff --git a/android-app/app/src/main/java/de/harheimertc/repositories/CmsRepository.kt b/android-app/app/src/main/java/de/harheimertc/repositories/CmsRepository.kt index fdefde2..c6b06fe 100644 --- a/android-app/app/src/main/java/de/harheimertc/repositories/CmsRepository.kt +++ b/android-app/app/src/main/java/de/harheimertc/repositories/CmsRepository.kt @@ -10,6 +10,8 @@ import de.harheimertc.data.PasswordResetDiagnosticsResponse import de.harheimertc.data.SaveCsvRequest import de.harheimertc.data.SaveCsvResponse import de.harheimertc.data.SecureOfflineCache +import de.harheimertc.data.SpielplanResponse +import de.harheimertc.data.TerminDto import javax.inject.Inject class CmsRepository @Inject constructor( @@ -83,6 +85,86 @@ class CmsRepository @Inject constructor( response.body() ?: SaveCsvResponse(success = false, message = "Leere Antwort") } + suspend fun managedTermine(): Result> = runCatching { + val response = api.termineManage() + if (!response.isSuccessful) error("Termine konnten nicht geladen werden.") + response.body()?.termine.orEmpty() + } + + suspend fun saveTermin(request: TerminDto): Result = runCatching { + val response = api.saveTermin(request) + if (!response.isSuccessful) error("Termin konnte nicht gespeichert werden.") + response.body() ?: de.harheimertc.data.AuthMessageResponse(success = false, message = "Leere Antwort") + } + + suspend fun deleteTermin(request: TerminDto): Result = runCatching { + val response = api.deleteTermin( + datum = request.datum, + uhrzeit = request.uhrzeit.orEmpty(), + titel = request.titel, + beschreibung = request.beschreibung.orEmpty(), + kategorie = request.kategorie ?: "Sonstiges", + ) + if (!response.isSuccessful) error("Termin konnte nicht gelöscht werden.") + response.body() ?: de.harheimertc.data.AuthMessageResponse(success = false, message = "Leere Antwort") + } + + suspend fun mannschaften(season: String? = null): Result> = runCatching { + val response = api.mannschaften(season) + if (!response.isSuccessful) error("Mannschaften konnten nicht geladen werden.") + parseCsv(response.body()?.string().orEmpty()).drop(1).mapNotNull { values -> + if (values.size < 10 || values[0].isBlank()) return@mapNotNull null + CmsMannschaftRow( + mannschaft = values[0], + liga = values[1], + staffelleiter = values[2], + telefon = values[3], + heimspieltag = values[4], + spielsystem = values[5], + mannschaftsfuehrer = values[6], + spieler = values[7], + informationenLink = values[8], + letzteAktualisierung = values[9], + ) + } + } + + suspend fun mannschaftenSeasons(): Result = runCatching { + val response = api.mannschaftenSeasons() + if (!response.isSuccessful) error("Saisons konnten nicht geladen werden.") + response.body() ?: de.harheimertc.data.MannschaftenSeasonsResponse() + } + + suspend fun saveMannschaften(season: String?, rows: List): Result = runCatching { + val response = api.saveCsv( + SaveCsvRequest( + filename = season?.takeIf { it.isNotBlank() }?.let { "mannschaften_$it.csv" } ?: "mannschaften.csv", + content = rows.toMannschaftenCsv(), + ), + ) + if (!response.isSuccessful) error("Mannschaften konnten nicht gespeichert werden.") + response.body() ?: SaveCsvResponse(success = false, message = "Leere Antwort") + } + + suspend fun spielplan(season: String? = null): Result = runCatching { + val response = api.spielplan(season) + if (!response.isSuccessful) error("Spielplan konnte nicht geladen werden.") + val body = response.body() ?: error("Leere Antwort") + if (!body.success) error(body.message ?: "Spielplan konnte nicht geladen werden.") + body + } + + suspend fun saveSpielplan(headers: List, rows: List>): Result = runCatching { + val response = api.saveCsv( + SaveCsvRequest( + filename = "spielplan.csv", + content = listOf(headers).plus(rows).joinToString("\n") { row -> row.toCsvRow(";") }, + ), + ) + if (!response.isSuccessful) error("Spielplan konnte nicht gespeichert werden.") + response.body() ?: SaveCsvResponse(success = false, message = "Leere Antwort") + } + suspend fun users(): Result = fetchEncryptedFallback( load = { @@ -288,6 +370,58 @@ class CmsRepository @Inject constructor( } } +data class CmsMannschaftRow( + val mannschaft: String = "", + val liga: String = "", + val staffelleiter: String = "", + val telefon: String = "", + val heimspieltag: String = "", + val spielsystem: String = "", + val mannschaftsfuehrer: String = "", + val spieler: String = "", + val informationenLink: String = "", + val letzteAktualisierung: String = "", +) + +private fun List.toMannschaftenCsv(): String { + val header = listOf( + "Mannschaft", + "Liga", + "Staffelleiter", + "Telefon", + "Heimspieltag", + "Spielsystem", + "Mannschaftsführer", + "Spieler", + "Weitere Informationen Link", + "Letzte Aktualisierung", + ).toCsvRow() + val rows = map { row -> + listOf( + row.mannschaft, + row.liga, + row.staffelleiter, + row.telefon, + row.heimspieltag, + row.spielsystem, + row.mannschaftsfuehrer, + row.spieler, + row.informationenLink, + row.letzteAktualisierung, + ).toCsvRow() + } + return listOf(header).plus(rows).joinToString("\n") +} + +private fun List.toCsvRow(delimiter: String = ","): String = + joinToString(delimiter) { value -> value.csvEscape(delimiter) } + +private fun String.csvEscape(delimiter: String): String { + val needsQuotes = contains(delimiter) || contains('"') || contains('\n') || contains('\r') + val escaped = replace("\"", "\"\"") + return if (needsQuotes) "\"$escaped\"" else escaped +} + private fun parseCsv(csv: String): List> = csv.lineSequence().filter(String::isNotBlank).map(::parseCsvLine).toList() diff --git a/android-app/app/src/main/java/de/harheimertc/ui/components/AppNavigationHeader.kt b/android-app/app/src/main/java/de/harheimertc/ui/components/AppNavigationHeader.kt index 9994519..edfbf2d 100644 --- a/android-app/app/src/main/java/de/harheimertc/ui/components/AppNavigationHeader.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/components/AppNavigationHeader.kt @@ -422,6 +422,7 @@ private fun menuSection(route: String?): MenuSection? = when (route) { Destinations.CmsStartseite.route, Destinations.CmsInhalte.route, Destinations.CmsVereinsmeisterschaften.route, + Destinations.CmsNews.route, Destinations.CmsSportbetrieb.route, Destinations.CmsMitgliederverwaltung.route, Destinations.CmsNewsletter.route, @@ -484,7 +485,7 @@ private fun submenu(section: MenuSection?, state: NavigationUiState): List() } - val trainers = remember { mutableStateListOf() } + val context = LocalContext.current + var activeTab by remember { mutableStateOf("termine") } + val mannschaften = remember { mutableStateListOf() } + var spielplanCsv by remember { mutableStateOf("") } + var spielplanEditorOpen by remember { mutableStateOf(false) } + var terminDialogOpen by remember { mutableStateOf(false) } + var editingTermin by remember { mutableStateOf(null) } + var terminDatum by remember { mutableStateOf("") } + var terminUhrzeit by remember { mutableStateOf("") } + var terminTitel by remember { mutableStateOf("") } + var terminBeschreibung by remember { mutableStateOf("") } + var terminKategorie by remember { mutableStateOf("Sonstiges") } + var terminKategorieOpen by remember { mutableStateOf(false) } + val tabs = listOf( + "termine" to "Termine", + "mannschaften" to "Mannschaften", + "spielplaene" to "Spielpläne", + ) + val terminKategorien = listOf("Training", "Punktspiel", "Turnier", "Veranstaltung", "Sonstiges") - LaunchedEffect(config) { - config?.let { - ortName = it.training.ort.name - ortStrasse = it.training.ort.strasse - ortPlz = it.training.ort.plz - ortOrt = it.training.ort.ort - trainingTimes.clear() - trainingTimes.addAll(it.training.zeiten) - trainers.clear() - trainers.addAll(it.trainer) - } + LaunchedEffect(Unit) { + viewModel.loadSportbetrieb() } - CmsPage(navController, showBackNavigation, "Sportbetrieb", "Trainingszeiten, Trainingsort und Trainer pflegen") { - when { - state.loading || config == null -> item { LoadingState("Sportbetriebsdaten werden geladen...") } - else -> { - item { - Button( - onClick = { - viewModel.saveConfig( - config.copy( - training = config.training.copy( - ort = config.training.ort.copy( - name = ortName, - strasse = ortStrasse, - plz = ortPlz, - ort = ortOrt, - ), - zeiten = trainingTimes.toList(), - ), - trainer = trainers.toList(), - ), - ) - }, - enabled = !state.saving, - modifier = Modifier.fillMaxWidth(), - ) { - Text(if (state.saving) "Speichert..." else "Speichern") - } - } - item { - DataCard("Trainingsort") { - OutlinedTextField(value = ortName, onValueChange = { ortName = it }, label = { Text("Name") }, modifier = Modifier.fillMaxWidth()) - OutlinedTextField(value = ortStrasse, onValueChange = { ortStrasse = it }, label = { Text("Straße") }, modifier = Modifier.fillMaxWidth()) - Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) { - OutlinedTextField(value = ortPlz, onValueChange = { ortPlz = it }, label = { Text("PLZ") }, modifier = Modifier.weight(1f)) - OutlinedTextField(value = ortOrt, onValueChange = { ortOrt = it }, label = { Text("Ort") }, modifier = Modifier.weight(1f)) - } - } - } - item { - DataCard("Trainingszeiten") { - trainingTimes.forEachIndexed { index, zeit -> - TrainingTimeEditorCard( - zeit = zeit, - onChange = { updated -> trainingTimes[index] = updated }, - onRemove = { trainingTimes.removeAt(index) }, - ) - } - Button( - onClick = { - trainingTimes.add( - de.harheimertc.data.TrainingTimeDto( - id = "training-${System.currentTimeMillis()}", - tag = "Montag", - ), - ) - }, - modifier = Modifier.fillMaxWidth(), - ) { - Text("Trainingszeit hinzufügen") - } - } - } - item { - DataCard("Trainer") { - trainers.forEachIndexed { index, trainer -> - TrainerEditorCard( - trainer = trainer, - onChange = { updated -> trainers[index] = updated }, - onRemove = { trainers.removeAt(index) }, - ) - } - Button( - onClick = { - trainers.add( - de.harheimertc.data.TrainerDto( - id = "trainer-${System.currentTimeMillis()}", - ), - ) - }, - modifier = Modifier.fillMaxWidth(), - ) { - Text("Trainer hinzufügen") - } - } - } - item { FormMessages(state.error, state.message) } + LaunchedEffect(state.sportMannschaften) { + mannschaften.clear() + mannschaften.addAll(state.sportMannschaften) + } + + LaunchedEffect(state.sportSpielplanHeaders, state.sportSpielplanRows) { + spielplanCsv = sportSpielplanCsvText(state.sportSpielplanHeaders, state.sportSpielplanRows) + } + + fun openTerminDialog(termin: TerminDto?) { + editingTermin = termin + terminDatum = termin?.datum.orEmpty() + terminUhrzeit = termin?.uhrzeit.orEmpty() + terminTitel = termin?.titel.orEmpty() + terminBeschreibung = termin?.beschreibung.orEmpty() + terminKategorie = termin?.kategorie ?: "Sonstiges" + terminDialogOpen = true + } + + fun openDatePicker() { + val calendar = Calendar.getInstance() + runCatching { + val parts = terminDatum.split("-") + if (parts.size == 3) { + calendar.set(parts[0].toInt(), parts[1].toInt() - 1, parts[2].toInt()) } } + DatePickerDialog( + context, + { _, year, month, day -> + terminDatum = "%04d-%02d-%02d".format(Locale.ROOT, year, month + 1, day) + }, + calendar.get(Calendar.YEAR), + calendar.get(Calendar.MONTH), + calendar.get(Calendar.DAY_OF_MONTH), + ).show() } + + CmsPage(navController, showBackNavigation, "Sportbetrieb", "Termine, Mannschaften und Spielpläne") { + if (state.sportLoading) item { LoadingState("Sportbetriebsdaten werden geladen...") } + item { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + tabs.forEach { (id, label) -> + if (activeTab == id) { + Button(onClick = { activeTab = id }, modifier = Modifier.weight(1f)) { Text(label) } + } else { + OutlinedButton(onClick = { activeTab = id }, modifier = Modifier.weight(1f)) { Text(label) } + } + } + } + } + + if (!state.sportLoading) { + when (activeTab) { + "termine" -> { + item { + Button( + onClick = { openTerminDialog(null) }, + enabled = !state.sportSaving, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Termin hinzufügen") + } + } + if (state.sportTermine.isEmpty()) { + item { EmptyCard("Keine Termine gefunden.") } + } + items(state.sportTermine.size) { index -> + val termin = state.sportTermine[index] + DataCard(termin.titel.ifBlank { "Termin" }) { + InfoRow("Datum", listOf(termin.datum, termin.uhrzeit.orEmpty()).filter(String::isNotBlank).joinToString(" ")) + InfoRow("Kategorie", termin.kategorie ?: "Sonstiges") + if (!termin.beschreibung.isNullOrBlank()) { + Text(termin.beschreibung, color = Accent700) + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { + OutlinedButton(onClick = { openTerminDialog(termin) }, modifier = Modifier.weight(1f)) { + Text("Bearbeiten") + } + TextButton( + onClick = { viewModel.deleteSportTermin(termin) }, + enabled = !state.sportSaving, + modifier = Modifier.weight(1f), + ) { + Text("Löschen") + } + } + } + } + } + "mannschaften" -> { + item { + if (state.sportMannschaftenSeasons.isNotEmpty()) { + DataCard("Saison") { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + state.sportMannschaftenSeasons.forEach { season -> + if (season == state.sportMannschaftenSeason) { + Button(onClick = { }, modifier = Modifier.weight(1f)) { Text(season) } + } else { + OutlinedButton( + onClick = { viewModel.loadSportMannschaftenSeason(season) }, + modifier = Modifier.weight(1f), + ) { + Text(season) + } + } + } + } + } + } + } + item { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { + Button( + onClick = { + mannschaften.add(CmsMannschaftRow(letzteAktualisierung = OffsetDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE))) + }, + modifier = Modifier.weight(1f), + ) { + Text("Hinzufügen") + } + Button( + onClick = { viewModel.saveSportMannschaften(state.sportMannschaftenSeason, mannschaften.toList()) }, + enabled = !state.sportSaving, + modifier = Modifier.weight(1f), + ) { + Text(if (state.sportSaving) "Speichert..." else "Speichern") + } + } + } + if (mannschaften.isEmpty()) { + item { EmptyCard("Keine Mannschaften gefunden.") } + } + items(mannschaften.size) { index -> + MannschaftEditorCard( + row = mannschaften[index], + onChange = { updated -> mannschaften[index] = updated }, + onRemove = { mannschaften.removeAt(index) }, + ) + } + } + "spielplaene" -> { + item { + DataCard("Vereins-Spielplan (CSV)") { + val seasonLabel = state.sportSpielplanSeason.ifBlank { "aktuelle Saison" } + val fileName = state.sportSpielplanSeason.takeIf { it.isNotBlank() }?.let { "spielplan-$it.json" } ?: "spielplan.csv" + InfoRow("Datei", fileName) + InfoRow("Saison", seasonLabel) + InfoRow("Einträge", state.sportSpielplanRows.size.toString()) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { + OutlinedButton( + onClick = { viewModel.loadSportbetrieb() }, + modifier = Modifier.weight(1f), + ) { + Text("Neu laden") + } + Button( + onClick = { spielplanEditorOpen = true }, + modifier = Modifier.weight(1f), + ) { + Text("CSV bearbeiten") + } + } + } + } + } + } + item { FormMessages(state.error, state.message) } + } + } + + if (terminDialogOpen) { + AlertDialog( + onDismissRequest = { terminDialogOpen = false }, + title = { Text(if (editingTermin == null) "Termin hinzufügen" else "Termin bearbeiten") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton(onClick = { openDatePicker() }, modifier = Modifier.fillMaxWidth()) { + Text(terminDatum.ifBlank { "Datum auswählen" }) + } + OutlinedTextField(value = terminUhrzeit, onValueChange = { terminUhrzeit = it }, label = { Text("Uhrzeit") }, modifier = Modifier.fillMaxWidth()) + OutlinedTextField(value = terminTitel, onValueChange = { terminTitel = it }, label = { Text("Titel") }, modifier = Modifier.fillMaxWidth()) + OutlinedTextField(value = terminBeschreibung, onValueChange = { terminBeschreibung = it }, label = { Text("Beschreibung") }, modifier = Modifier.fillMaxWidth(), minLines = 3) + Box(modifier = Modifier.fillMaxWidth()) { + OutlinedButton(onClick = { terminKategorieOpen = true }, modifier = Modifier.fillMaxWidth()) { + Text(terminKategorie.ifBlank { "Kategorie auswählen" }) + } + DropdownMenu(expanded = terminKategorieOpen, onDismissRequest = { terminKategorieOpen = false }) { + terminKategorien.forEach { kategorie -> + DropdownMenuItem( + text = { Text(kategorie) }, + onClick = { + terminKategorie = kategorie + terminKategorieOpen = false + }, + ) + } + } + } + } + }, + confirmButton = { + Button( + onClick = { + viewModel.saveSportTermin( + editingTermin, + TerminDto( + datum = terminDatum, + uhrzeit = terminUhrzeit.takeIf { it.isNotBlank() }, + titel = terminTitel, + beschreibung = terminBeschreibung.takeIf { it.isNotBlank() }, + kategorie = terminKategorie.ifBlank { "Sonstiges" }, + ), + ) + terminDialogOpen = false + }, + enabled = !state.sportSaving && terminDatum.isNotBlank() && terminTitel.isNotBlank(), + ) { + Text(if (state.sportSaving) "Speichert..." else "Speichern") + } + }, + dismissButton = { + TextButton(onClick = { terminDialogOpen = false }) { Text("Abbrechen") } + }, + ) + } + + if (spielplanEditorOpen) { + AlertDialog( + onDismissRequest = { spielplanEditorOpen = false }, + title = { Text("Spielplan CSV bearbeiten") }, + text = { + OutlinedTextField( + value = spielplanCsv, + onValueChange = { spielplanCsv = it }, + label = { Text("CSV mit Semikolon") }, + modifier = Modifier.fillMaxWidth(), + minLines = 12, + ) + }, + confirmButton = { + Button( + onClick = { + val (headers, rows) = parseSportCsvText(spielplanCsv) + viewModel.saveSportSpielplan(headers, rows) + spielplanEditorOpen = false + }, + enabled = !state.sportSaving && spielplanCsv.isNotBlank(), + ) { + Text(if (state.sportSaving) "Speichert..." else "Speichern") + } + }, + dismissButton = { + TextButton(onClick = { spielplanEditorOpen = false }) { Text("Abbrechen") } + }, + ) + } +} + +@Composable +private fun MannschaftEditorCard( + row: CmsMannschaftRow, + onChange: (CmsMannschaftRow) -> Unit, + onRemove: () -> Unit, +) { + DataCard(row.mannschaft.ifBlank { "Mannschaft" }) { + OutlinedTextField(value = row.mannschaft, onValueChange = { onChange(row.copy(mannschaft = it)) }, label = { Text("Mannschaft") }, modifier = Modifier.fillMaxWidth()) + OutlinedTextField(value = row.liga, onValueChange = { onChange(row.copy(liga = it)) }, label = { Text("Liga") }, modifier = Modifier.fillMaxWidth()) + OutlinedTextField(value = row.staffelleiter, onValueChange = { onChange(row.copy(staffelleiter = it)) }, label = { Text("Staffelleiter") }, modifier = Modifier.fillMaxWidth()) + OutlinedTextField(value = row.telefon, onValueChange = { onChange(row.copy(telefon = it)) }, label = { Text("Telefon") }, modifier = Modifier.fillMaxWidth()) + OutlinedTextField(value = row.heimspieltag, onValueChange = { onChange(row.copy(heimspieltag = it)) }, label = { Text("Heimspieltag") }, modifier = Modifier.fillMaxWidth()) + OutlinedTextField(value = row.spielsystem, onValueChange = { onChange(row.copy(spielsystem = it)) }, label = { Text("Spielsystem") }, modifier = Modifier.fillMaxWidth()) + OutlinedTextField(value = row.mannschaftsfuehrer, onValueChange = { onChange(row.copy(mannschaftsfuehrer = it)) }, label = { Text("Mannschaftsführer") }, modifier = Modifier.fillMaxWidth()) + OutlinedTextField(value = row.spieler, onValueChange = { onChange(row.copy(spieler = it)) }, label = { Text("Spieler") }, modifier = Modifier.fillMaxWidth(), minLines = 2) + OutlinedTextField(value = row.informationenLink, onValueChange = { onChange(row.copy(informationenLink = it)) }, label = { Text("Weitere Informationen Link") }, modifier = Modifier.fillMaxWidth()) + OutlinedTextField(value = row.letzteAktualisierung, onValueChange = { onChange(row.copy(letzteAktualisierung = it)) }, label = { Text("Letzte Aktualisierung") }, modifier = Modifier.fillMaxWidth()) + TextButton(onClick = onRemove, modifier = Modifier.fillMaxWidth()) { + Text("Entfernen") + } + } +} + +private fun sportSpielplanCsvText(headers: List, rows: List>): String { + if (headers.isEmpty()) return "" + return listOf(headers).plus(rows).joinToString("\n") { row -> row.joinToString(";") { it.csvCell(";") } } +} + +private fun parseSportCsvText(text: String): Pair, List>> { + val lines = text.lineSequence().filter { it.isNotBlank() }.toList() + if (lines.isEmpty()) return emptyList() to emptyList() + val headers = parseDelimitedLine(lines.first(), ';') + val rows = lines.drop(1).map { parseDelimitedLine(it, ';') } + return headers to rows +} + +private fun parseDelimitedLine(line: String, delimiter: Char): List { + val values = mutableListOf() + val value = StringBuilder() + var quoted = false + var index = 0 + while (index < line.length) { + when (val char = line[index]) { + '"' -> { + if (quoted && index + 1 < line.length && line[index + 1] == '"') { + value.append('"') + index++ + } else { + quoted = !quoted + } + } + delimiter -> if (quoted) value.append(char) else { + values += value.toString() + value.clear() + } + else -> value.append(char) + } + index++ + } + values += value.toString() + return values +} + +private fun String.csvCell(delimiter: String): String { + val needsQuotes = contains(delimiter) || contains('"') || contains('\n') || contains('\r') + val escaped = replace("\"", "\"\"") + return if (needsQuotes) "\"$escaped\"" else escaped } @Composable @@ -623,7 +876,7 @@ fun CmsNewsletterScreen( onEdit = { nl -> editingNewsletter = nl nlTitle = nl.title - nlContent = nl.title ?: "" + nlContent = nl.title nlType = "subscription" nlTargetGroup = "" nlSendToExternal = true @@ -753,6 +1006,12 @@ fun CmsEinstellungenScreen(navController: NavController, showBackNavigation: Boo var websiteVorname by remember { mutableStateOf("") } var websiteNachname by remember { mutableStateOf("") } var websiteEmail by remember { mutableStateOf("") } + var ortName by remember { mutableStateOf("") } + var ortStrasse by remember { mutableStateOf("") } + var ortPlz by remember { mutableStateOf("") } + var ortOrt by remember { mutableStateOf("") } + val trainingTimes = remember { mutableStateListOf() } + val trainers = remember { mutableStateListOf() } LaunchedEffect(config) { config?.let { @@ -764,6 +1023,14 @@ fun CmsEinstellungenScreen(navController: NavController, showBackNavigation: Boo websiteVorname = it.website.verantwortlicher.vorname websiteNachname = it.website.verantwortlicher.nachname websiteEmail = it.website.verantwortlicher.email + ortName = it.training.ort.name + ortStrasse = it.training.ort.strasse + ortPlz = it.training.ort.plz + ortOrt = it.training.ort.ort + trainingTimes.clear() + trainingTimes.addAll(it.training.zeiten) + trainers.clear() + trainers.addAll(it.trainer) } } @@ -790,6 +1057,16 @@ fun CmsEinstellungenScreen(navController: NavController, showBackNavigation: Boo email = websiteEmail, ), ), + training = config.training.copy( + ort = config.training.ort.copy( + name = ortName, + strasse = ortStrasse, + plz = ortPlz, + ort = ortOrt, + ), + zeiten = trainingTimes.toList(), + ), + trainer = trainers.toList(), ), ) }, @@ -822,6 +1099,63 @@ fun CmsEinstellungenScreen(navController: NavController, showBackNavigation: Boo OutlinedTextField(value = websiteEmail, onValueChange = { websiteEmail = it }, label = { Text("E-Mail") }, modifier = Modifier.fillMaxWidth()) } } + item { + DataCard("Trainingsort") { + OutlinedTextField(value = ortName, onValueChange = { ortName = it }, label = { Text("Name") }, modifier = Modifier.fillMaxWidth()) + OutlinedTextField(value = ortStrasse, onValueChange = { ortStrasse = it }, label = { Text("Straße") }, modifier = Modifier.fillMaxWidth()) + Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) { + OutlinedTextField(value = ortPlz, onValueChange = { ortPlz = it }, label = { Text("PLZ") }, modifier = Modifier.weight(1f)) + OutlinedTextField(value = ortOrt, onValueChange = { ortOrt = it }, label = { Text("Ort") }, modifier = Modifier.weight(1f)) + } + } + } + item { + DataCard("Trainingszeiten") { + trainingTimes.forEachIndexed { index, zeit -> + TrainingTimeEditorCard( + zeit = zeit, + onChange = { updated -> trainingTimes[index] = updated }, + onRemove = { trainingTimes.removeAt(index) }, + ) + } + Button( + onClick = { + trainingTimes.add( + de.harheimertc.data.TrainingTimeDto( + id = "training-${System.currentTimeMillis()}", + tag = "Montag", + ), + ) + }, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Trainingszeit hinzufügen") + } + } + } + item { + DataCard("Trainer") { + trainers.forEachIndexed { index, trainer -> + TrainerEditorCard( + trainer = trainer, + onChange = { updated -> trainers[index] = updated }, + onRemove = { trainers.removeAt(index) }, + ) + } + Button( + onClick = { + trainers.add( + de.harheimertc.data.TrainerDto( + id = "trainer-${System.currentTimeMillis()}", + ), + ) + }, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Trainer hinzufügen") + } + } + } item { DataCard("Systemstatus") { InfoRow("Mitgliedschaftstarife", config.mitgliedschaft.size.toString()) @@ -950,6 +1284,7 @@ private fun CmsSummaryGrid(navController: NavController, state: CmsUiState) { val cards = listOf( Triple("Startseite", "Öffentliche Startseite", Destinations.CmsStartseite.route), Triple("Inhalte", "Vereinsseiten", Destinations.CmsInhalte.route), + Triple("News", "Interne und öffentliche News", Destinations.CmsNews.route), Triple("Sportbetrieb", "Training und Sportdaten", Destinations.CmsSportbetrieb.route), Triple("Mitgliederverwaltung", "${state.users.size} Benutzer", Destinations.CmsMitgliederverwaltung.route), Triple("Kontaktanfragen", "${state.contactRequests.size} Anfragen", Destinations.CmsContactRequests.route), diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/cms/CmsViewModels.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/cms/CmsViewModels.kt index 328e728..edb3e0f 100644 --- a/android-app/app/src/main/java/de/harheimertc/ui/screens/cms/CmsViewModels.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/cms/CmsViewModels.kt @@ -13,6 +13,10 @@ import de.harheimertc.data.PasswordResetMatchingUserDto import de.harheimertc.data.PasswordResetAttemptDto import de.harheimertc.data.NewsDto import de.harheimertc.data.NewsSaveRequest +import de.harheimertc.data.SeasonDto +import de.harheimertc.data.SpielDto +import de.harheimertc.data.TerminDto +import de.harheimertc.repositories.CmsMannschaftRow import de.harheimertc.repositories.CmsRepository import de.harheimertc.repositories.MeisterschaftResult import kotlinx.coroutines.async @@ -39,6 +43,16 @@ data class CmsUiState( val passwordResetFailedOnly: Boolean = true, val news: List = emptyList(), val meisterschaften: List = emptyList(), + val sportLoading: Boolean = false, + val sportSaving: Boolean = false, + val sportTermine: List = emptyList(), + val sportMannschaften: List = emptyList(), + val sportMannschaftenSeasons: List = emptyList(), + val sportMannschaftenSeason: String = "", + val sportSpielplanHeaders: List = emptyList(), + val sportSpielplanRows: List> = emptyList(), + val sportSpielplanSeason: String = "", + val sportSpielplanSeasons: List = emptyList(), ) @HiltViewModel @@ -179,6 +193,156 @@ class CmsViewModel @Inject constructor( } } + fun loadSportbetrieb() { + viewModelScope.launch { + _state.value = _state.value.copy(sportLoading = true, error = null, message = null) + + val termineRes = async { repository.managedTermine() } + val seasonsRes = async { repository.mannschaftenSeasons() } + val spielplanRes = async { repository.spielplan() } + + val termineResult = termineRes.await() + val seasonsResult = seasonsRes.await() + val seasonInfo = seasonsResult.getOrNull() + val selectedSeason = _state.value.sportMannschaftenSeason.takeIf { it.isNotBlank() } + ?: seasonInfo?.defaultSeason?.takeIf { it.isNotBlank() } + ?: seasonInfo?.currentSeason?.takeIf { it.isNotBlank() } + ?: seasonInfo?.seasons?.firstOrNull().orEmpty() + val mannschaftenResult = repository.mannschaften(selectedSeason.takeIf { it.isNotBlank() }) + val spielplanResult = spielplanRes.await() + val errors = listOfNotNull( + ErrorMapper.mapError(termineResult.exceptionOrNull()), + ErrorMapper.mapError(seasonsResult.exceptionOrNull()), + ErrorMapper.mapError(mannschaftenResult.exceptionOrNull()), + ErrorMapper.mapError(spielplanResult.exceptionOrNull()), + ) + val spielplan = spielplanResult.getOrNull() + val headers = spielplan?.headers.orEmpty() + + _state.value = _state.value.copy( + sportLoading = false, + sportTermine = termineResult.getOrNull().orEmpty(), + sportMannschaften = mannschaftenResult.getOrNull().orEmpty(), + sportMannschaftenSeasons = seasonInfo?.seasons.orEmpty(), + sportMannschaftenSeason = selectedSeason, + sportSpielplanHeaders = headers, + sportSpielplanRows = spielplan?.data.orEmpty().map { row -> headers.map { header -> row.valueForHeader(header) } }, + sportSpielplanSeason = spielplan?.season.orEmpty(), + sportSpielplanSeasons = spielplan?.seasons.orEmpty(), + error = errors.takeIf { it.isNotEmpty() }?.joinToString("; "), + ) + } + } + + fun loadSportMannschaftenSeason(season: String) { + viewModelScope.launch { + _state.value = _state.value.copy(sportLoading = true, error = null, message = null, sportMannschaftenSeason = season) + repository.mannschaften(season) + .onSuccess { rows -> + _state.value = _state.value.copy(sportLoading = false, sportMannschaften = rows) + } + .onFailure { err -> + _state.value = _state.value.copy( + sportLoading = false, + error = ErrorMapper.mapError(err) ?: "Mannschaften konnten nicht geladen werden.", + ) + } + } + } + + fun saveSportTermin(original: TerminDto?, termin: TerminDto) { + viewModelScope.launch { + _state.value = _state.value.copy(sportSaving = true, error = null, message = null) + if (original != null) { + repository.deleteTermin(original) + .onFailure { err -> + _state.value = _state.value.copy( + sportSaving = false, + error = ErrorMapper.mapError(err) ?: "Alter Termin konnte nicht ersetzt werden.", + ) + return@launch + } + } + val saveResult = repository.saveTermin(termin) + saveResult + .onSuccess { response -> + val termine = repository.managedTermine().getOrDefault(_state.value.sportTermine) + _state.value = _state.value.copy( + sportSaving = false, + sportTermine = termine, + message = response.message ?: "Termin gespeichert.", + ) + } + .onFailure { err -> + _state.value = _state.value.copy( + sportSaving = false, + error = ErrorMapper.mapError(err) ?: "Termin konnte nicht gespeichert werden.", + ) + } + } + } + + fun deleteSportTermin(termin: TerminDto) { + viewModelScope.launch { + _state.value = _state.value.copy(sportSaving = true, error = null, message = null) + repository.deleteTermin(termin) + .onSuccess { response -> + _state.value = _state.value.copy( + sportSaving = false, + sportTermine = _state.value.sportTermine.filterNot { it == termin }, + message = response.message ?: "Termin gelöscht.", + ) + } + .onFailure { err -> + _state.value = _state.value.copy( + sportSaving = false, + error = ErrorMapper.mapError(err) ?: "Termin konnte nicht gelöscht werden.", + ) + } + } + } + + fun saveSportMannschaften(season: String, rows: List) { + viewModelScope.launch { + _state.value = _state.value.copy(sportSaving = true, error = null, message = null) + repository.saveMannschaften(season.takeIf { it.isNotBlank() }, rows) + .onSuccess { response -> + _state.value = _state.value.copy( + sportSaving = false, + sportMannschaften = rows, + message = response.message ?: "Mannschaften gespeichert.", + ) + } + .onFailure { err -> + _state.value = _state.value.copy( + sportSaving = false, + error = ErrorMapper.mapError(err) ?: "Mannschaften konnten nicht gespeichert werden.", + ) + } + } + } + + fun saveSportSpielplan(headers: List, rows: List>) { + viewModelScope.launch { + _state.value = _state.value.copy(sportSaving = true, error = null, message = null) + repository.saveSpielplan(headers, rows) + .onSuccess { response -> + _state.value = _state.value.copy( + sportSaving = false, + sportSpielplanHeaders = headers, + sportSpielplanRows = rows, + message = response.message ?: "Spielplan gespeichert.", + ) + } + .onFailure { err -> + _state.value = _state.value.copy( + sportSaving = false, + error = ErrorMapper.mapError(err) ?: "Spielplan konnte nicht gespeichert werden.", + ) + } + } + } + fun saveConfig(config: ConfigResponse) { viewModelScope.launch { _state.value = _state.value.copy(saving = true, error = null, message = null) @@ -462,3 +626,18 @@ class CmsViewModel @Inject constructor( } } } + +private fun SpielDto.valueForHeader(header: String): String = when (header) { + "Termin" -> termin + "HeimMannschaft" -> heimMannschaft + "GastMannschaft" -> gastMannschaft + "HeimMannschaftAltersklasse" -> heimAltersklasse + "GastMannschaftAltersklasse" -> gastAltersklasse + "Altersklasse" -> altersklasse + "Liga" -> liga + "Staffel" -> staffel + "Runde" -> runde.orEmpty() + "SpieleHeim" -> spieleHeim + "SpieleGast" -> spieleGast + else -> "" +} diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/home/HomeViewModel.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/home/HomeViewModel.kt index 8a6279c..34f9fe7 100644 --- a/android-app/app/src/main/java/de/harheimertc/ui/screens/home/HomeViewModel.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/home/HomeViewModel.kt @@ -90,7 +90,7 @@ class HomeViewModel @Inject constructor( loading = false, heroImageUrl = data.heroImageUrl, termine = data.termine - .filter { it.asDateTime()?.isBefore(LocalDateTime.now()) != true } + .filter { it.asDateTime()?.toLocalDate()?.isBefore(LocalDate.now()) != true } .sortedBy { it.asDateTime() } .take(3), spiele = data.spiele diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/login/RegistrationViewModels.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/login/RegistrationViewModels.kt index 6097153..97b0141 100644 --- a/android-app/app/src/main/java/de/harheimertc/ui/screens/login/RegistrationViewModels.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/login/RegistrationViewModels.kt @@ -59,7 +59,7 @@ data class RegisterFormState( val birthDate: String = "", val password: String = "", val passwordRepeat: String = "", - val showBirthday: Boolean = true, + val showBirthday: Boolean = false, ) data class RegisterUiState( diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/notifications/NotificationSettingsScreen.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/notifications/NotificationSettingsScreen.kt index 7b1d443..f2b9c6b 100644 --- a/android-app/app/src/main/java/de/harheimertc/ui/screens/notifications/NotificationSettingsScreen.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/notifications/NotificationSettingsScreen.kt @@ -142,7 +142,7 @@ fun NotificationSettingsScreen( ToggleRow("Punktspiele der eigenen Mannschaft", state.settings.ownTeamMatches) { viewModel.update(state.settings.copy(ownTeamMatches = it)) } - Text("Die eigene Mannschaft wird aus dem Namen und der Mannschaftszusammensetzung ermittelt.", color = Accent700) + OwnTeamInfo(state.ownTeams, state.currentUserName) ToggleRow("Punktspiele aller Mannschaften", state.settings.allTeamMatches) { viewModel.update(state.settings.copy(allTeamMatches = it)) } @@ -214,6 +214,16 @@ private fun ToggleRow(label: String, checked: Boolean, onCheckedChange: (Boolean } } +@Composable +private fun OwnTeamInfo(ownTeams: List, currentUserName: String) { + val text = when { + ownTeams.isNotEmpty() -> "Erkannte eigene Mannschaft: " + ownTeams.joinToString { it.mannschaft } + currentUserName.isBlank() -> "Eigene Mannschaft kann erst nach geladenem Profil ermittelt werden." + else -> "Keine eigene Mannschaft für " + currentUserName + " erkannt." + } + Text(text, color = Accent700) +} + @Composable private fun TimeSelection(selectedTime: String, onSelectTime: (String) -> Unit) { Row( diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/notifications/NotificationSettingsViewModel.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/notifications/NotificationSettingsViewModel.kt index 8cd156c..7b6ce87 100644 --- a/android-app/app/src/main/java/de/harheimertc/ui/screens/notifications/NotificationSettingsViewModel.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/notifications/NotificationSettingsViewModel.kt @@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import de.harheimertc.repositories.Mannschaft +import de.harheimertc.repositories.LoginRepository import de.harheimertc.repositories.MannschaftenRepository import de.harheimertc.repositories.NotificationPreferences import de.harheimertc.repositories.NotificationPreferencesRepository @@ -16,6 +17,8 @@ data class NotificationSettingsUiState( val loading: Boolean = true, val settings: NotificationPreferences = NotificationPreferences(), val teams: List = emptyList(), + val ownTeams: List = emptyList(), + val currentUserName: String = "", val seasons: List = emptyList(), val error: String? = null, val saveError: String? = null, @@ -25,6 +28,7 @@ data class NotificationSettingsUiState( class NotificationSettingsViewModel @Inject constructor( private val preferencesRepository: NotificationPreferencesRepository, private val mannschaftenRepository: MannschaftenRepository, + private val loginRepository: LoginRepository, ) : ViewModel() { private val _state = MutableStateFlow(NotificationSettingsUiState()) val state: StateFlow = _state @@ -37,12 +41,14 @@ class NotificationSettingsViewModel @Inject constructor( viewModelScope.launch { _state.value = _state.value.copy(loading = true, error = null, saveError = null) val storedSettings = preferencesRepository.loadRemote().getOrElse { preferencesRepository.loadLocal() } + val authStatus = loginRepository.status().getOrNull() + val currentUserName = authStatus?.user?.name.orEmpty() val seasonsResponse = mannschaftenRepository.fetchSeasons().getOrNull() val seasons = seasonsResponse?.seasons.orEmpty() val selectedSeason = storedSettings.selectedTeamSeason ?: seasonsResponse?.defaultSeason?.takeIf { it.isNotBlank() } ?: seasons.firstOrNull() - loadTeams(storedSettings.copy(selectedTeamSeason = selectedSeason), seasons) + loadTeams(storedSettings.copy(selectedTeamSeason = selectedSeason), seasons, currentUserName) } } @@ -50,7 +56,7 @@ class NotificationSettingsViewModel @Inject constructor( val current = _state.value.settings viewModelScope.launch { _state.value = _state.value.copy(loading = true, error = null, saveError = null) - loadTeams(current.copy(selectedTeamSeason = season), _state.value.seasons, syncRemote = true) + loadTeams(current.copy(selectedTeamSeason = season), _state.value.seasons, _state.value.currentUserName, syncRemote = true) } } @@ -76,7 +82,7 @@ class NotificationSettingsViewModel @Inject constructor( update(current.copy(selectedTeamSlugs = nextTeams)) } - private suspend fun loadTeams(settings: NotificationPreferences, seasons: List, syncRemote: Boolean = false) { + private suspend fun loadTeams(settings: NotificationPreferences, seasons: List, currentUserName: String, syncRemote: Boolean = false) { mannschaftenRepository.fetchMannschaften(settings.selectedTeamSeason) .onSuccess { teams -> val knownSlugs = teams.map { it.slug }.toSet() @@ -89,6 +95,8 @@ class NotificationSettingsViewModel @Inject constructor( loading = false, settings = nextSettings, teams = teams, + ownTeams = ownTeamsForUser(currentUserName, teams), + currentUserName = currentUserName, seasons = seasons, saveError = saveError, ) @@ -101,6 +109,7 @@ class NotificationSettingsViewModel @Inject constructor( _state.value = NotificationSettingsUiState( loading = false, settings = settings, + currentUserName = currentUserName, seasons = seasons, error = error.message ?: "Mannschaften konnten nicht geladen werden.", saveError = saveError, @@ -108,3 +117,27 @@ class NotificationSettingsViewModel @Inject constructor( } } } + +private fun ownTeamsForUser(userName: String, teams: List): List { + if (normalizePersonName(userName).isBlank()) return emptyList() + return teams.filter { team -> + team.spieler.any { player -> personNameMatches(player, userName) } || + personNameMatches(team.mannschaftsfuehrer, userName) + } +} + +private fun personNameMatches(candidate: String, userName: String): Boolean { + val normalizedCandidate = normalizePersonName(candidate) + val normalizedUserName = normalizePersonName(userName) + if (normalizedCandidate.isBlank() || normalizedUserName.isBlank()) return false + if (normalizedCandidate == normalizedUserName) return true + + val candidateParts = normalizedCandidate.split(" " ).filter { it.isNotBlank() }.toSet() + val userParts = normalizedUserName.split(" " ).filter { it.isNotBlank() } + return userParts.size >= 2 && userParts.all { it in candidateParts } +} + +private fun normalizePersonName(value: String): String = value + .lowercase() + .replace(Regex("[^a-z0-9äöüß]+"), " ") + .trim() diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/profile/ProfileViewModel.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/profile/ProfileViewModel.kt index 8c1f4ba..28e273f 100644 --- a/android-app/app/src/main/java/de/harheimertc/ui/screens/profile/ProfileViewModel.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/profile/ProfileViewModel.kt @@ -24,7 +24,7 @@ data class ProfileFormState( val showEmail: Boolean = true, val showPhone: Boolean = true, val showAddress: Boolean = false, - val showBirthday: Boolean = true, + val showBirthday: Boolean = false, val currentPassword: String = "", val newPassword: String = "", val confirmPassword: String = "", diff --git a/android-app/app/src/test/java/de/harheimertc/ui/screens/cms/CmsViewModelTest.kt b/android-app/app/src/test/java/de/harheimertc/ui/screens/cms/CmsViewModelTest.kt index ddab0b2..d373564 100644 --- a/android-app/app/src/test/java/de/harheimertc/ui/screens/cms/CmsViewModelTest.kt +++ b/android-app/app/src/test/java/de/harheimertc/ui/screens/cms/CmsViewModelTest.kt @@ -2,11 +2,13 @@ package de.harheimertc.ui.screens.cms import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain import io.mockk.coEvery +import io.mockk.every import io.mockk.mockk import org.junit.After import org.junit.Assert.assertEquals @@ -27,6 +29,12 @@ class CmsViewModelTest { Dispatchers.resetMain() } + private fun viewModel(repo: de.harheimertc.repositories.CmsRepository): CmsViewModel { + val connectivity = mockk() + every { connectivity.online } returns MutableStateFlow(true) + return CmsViewModel(repo, connectivity) + } + @Test fun load_populatesState() = runTest { val repo = mockk() @@ -37,11 +45,11 @@ class CmsViewModelTest { coEvery { repo.contactRequests() } returns Result.success(emptyList()) coEvery { repo.newsletters() } returns Result.success(de.harheimertc.data.NewsletterListResponse()) coEvery { repo.newsletterGroups() } returns Result.success(de.harheimertc.data.NewsletterGroupsResponse()) - coEvery { repo.news() } returns Result.success(de.harheimertc.data.NewsResponse(success = true, news = listOf(de.harheimertc.data.NewsDto(id = 5, title = "T", content = "C")))) + coEvery { repo.news() } returns Result.success(de.harheimertc.data.NewsResponse(success = true, news = listOf(de.harheimertc.data.NewsDto(id = "5", title = "T", content = "C")))) coEvery { repo.passwordResetDiagnostics(any(), any()) } returns Result.success(de.harheimertc.data.PasswordResetDiagnosticsResponse()) coEvery { repo.vereinsmeisterschaften() } returns Result.success(emptyList()) - val vm = CmsViewModel(repo) + val vm = viewModel(repo) // advance init launched coroutine dispatcher.scheduler.advanceUntilIdle() @@ -66,7 +74,7 @@ class CmsViewModelTest { coEvery { repo.vereinsmeisterschaften() } returns Result.success(emptyList()) coEvery { repo.saveConfig(any()) } returns Result.success(cfg) coEvery { repo.saveNews(any()) } returns Result.success(de.harheimertc.data.AuthMessageResponse(success = true, message = "ok")) - val vm = CmsViewModel(repo) + val vm = viewModel(repo) // wait for init/load to finish before saving to avoid race dispatcher.scheduler.advanceUntilIdle() @@ -95,7 +103,7 @@ class CmsViewModelTest { coEvery { repo.vereinsmeisterschaften() } returns Result.success(emptyList()) coEvery { repo.saveNews(any()) } returns Result.success(de.harheimertc.data.AuthMessageResponse(success = true, message = "saved")) - val vm = CmsViewModel(repo) + val vm = viewModel(repo) dispatcher.scheduler.advanceUntilIdle() vm.saveNews(de.harheimertc.data.NewsSaveRequest(id = null, title = "t", content = "c")) @@ -122,7 +130,7 @@ class CmsViewModelTest { coEvery { repo.updateUserRoles(any(), any()) } returns Result.success(de.harheimertc.data.AuthMessageResponse(success = true, message = "roles updated")) coEvery { repo.users() } returns Result.success(de.harheimertc.data.CmsUsersResponse(listOf(de.harheimertc.data.CmsUserDto(id = "1", email = "u@e", name = "U", roles = listOf("admin", "vorstand"))))) - val vm = CmsViewModel(repo) + val vm = viewModel(repo) dispatcher.scheduler.advanceUntilIdle() vm.updateUserRoles("1", listOf("admin", "vorstand")) @@ -150,7 +158,7 @@ class CmsViewModelTest { coEvery { repo.updateUserActive(any(), any()) } returns Result.success(de.harheimertc.data.AuthMessageResponse(success = true, message = "user updated")) coEvery { repo.users() } returns Result.success(de.harheimertc.data.CmsUsersResponse(listOf(de.harheimertc.data.CmsUserDto(id = "2", email = "v@e", name = "V", active = false)))) - val vm = CmsViewModel(repo) + val vm = viewModel(repo) dispatcher.scheduler.advanceUntilIdle() vm.setUserActive("2", false) @@ -177,7 +185,7 @@ class CmsViewModelTest { coEvery { repo.resendInvite(any()) } returns Result.success(de.harheimertc.data.AuthMessageResponse(success = true, message = "invite sent")) - val vm = CmsViewModel(repo) + val vm = viewModel(repo) dispatcher.scheduler.advanceUntilIdle() vm.resendInvite("10") diff --git a/android-app/build/reports/problems/problems-report.html b/android-app/build/reports/problems/problems-report.html deleted file mode 100644 index 77cfa1f..0000000 --- a/android-app/build/reports/problems/problems-report.html +++ /dev/null @@ -1,666 +0,0 @@ - - - - - - - - - - - - - Gradle Configuration Cache - - - -
- -
- Loading... -
- - - - - - diff --git a/android-app/gradle.properties b/android-app/gradle.properties index 23c1fef..b63cad4 100644 --- a/android-app/gradle.properties +++ b/android-app/gradle.properties @@ -8,8 +8,8 @@ LOCAL_API_BASE_URL=https://harheimertc.tsschulz.de/ PRODUCTION_API_BASE_URL=https://harheimertc.de/ # Android app versioning for Play Store uploads -ANDROID_VERSION_CODE=25 -ANDROID_VERSION_NAME=0.9.20 +ANDROID_VERSION_CODE=26 +ANDROID_VERSION_NAME=0.9.21 # Temporary hotfix: disable R8 minification for release to avoid Retrofit generic signature stripping. RELEASE_MINIFY_ENABLED=false diff --git a/components/cms/CmsMitglieder.vue b/components/cms/CmsMitglieder.vue index fa59615..ced2de0 100644 --- a/components/cms/CmsMitglieder.vue +++ b/components/cms/CmsMitglieder.vue @@ -550,6 +550,25 @@ +
+ + +
+

+ Admins und Vorstand können die Sichtbarkeit nur ausschalten. Einschalten kann nur das Mitglied selbst im Profil. +

+
{ @@ -861,6 +881,10 @@ const isBirthdateRequired = computed(() => { return !editingMember.value || Boolean(editingMember.value?.geburtsdatum) }) +const canDisableBirthdayVisibility = computed(() => { + return editingMember.value?.showBirthday === true +}) + const filteredMembers = computed(() => { if (!filterHasHallKey.value) return members.value return members.value.filter(member => member.hasHallKey) @@ -880,7 +904,7 @@ const loadMembers = async () => { const openAddModal = () => { editingMember.value = null - formData.value = { firstName: '', lastName: '', geburtsdatum: '', email: '', phone: '', address: '', notes: '', isMannschaftsspieler: false, hasHallKey: false } + formData.value = { firstName: '', lastName: '', geburtsdatum: '', email: '', phone: '', address: '', notes: '', isMannschaftsspieler: false, hasHallKey: false, showBirthday: false } showModal.value = true errorMessage.value = '' } @@ -896,7 +920,8 @@ const openEditModal = (member) => { address: member.address || '', notes: member.notes || '', isMannschaftsspieler: member.isMannschaftsspieler === true, - hasHallKey: member.hasHallKey === true + hasHallKey: member.hasHallKey === true, + showBirthday: member.showBirthday === true } showModal.value = true errorMessage.value = '' @@ -914,7 +939,14 @@ const saveMember = async () => { try { await $fetch('/api/members', { method: 'POST', - body: { id: editingMember.value?.id, ...formData.value } + body: { + id: editingMember.value?.id, + ...formData.value, + visibility: { + ...(editingMember.value?.visibility || {}), + showBirthday: formData.value.showBirthday === true + } + } }) closeModal() await loadMembers() diff --git a/components/cms/CmsSpielplaene.vue b/components/cms/CmsSpielplaene.vue index 523fa3d..dca8d34 100644 --- a/components/cms/CmsSpielplaene.vue +++ b/components/cms/CmsSpielplaene.vue @@ -58,7 +58,7 @@

{{ currentFile.name }}

- {{ currentFile.size }} bytes + {{ currentFileLabel }}

@@ -368,7 +368,7 @@ const processFile = async (file) => { const parseCSVLine = (line) => { const tabCount = (line.match(/\t/g) || []).length; const semicolonCount = (line.match(/;/g) || []).length; const delimiter = tabCount > semicolonCount ? '\t' : ';'; return line.split(delimiter).map(value => value.trim()) } csvHeaders.value = parseCSVLine(lines[0]); csvData.value = lines.slice(1).map(line => parseCSVLine(line)) selectedColumns.value = new Array(csvHeaders.value.length).fill(true); columnsSelected.value = false - currentFile.value = { name: file.name, size: file.size, lastModified: file.lastModified } + currentFile.value = { name: file.name, size: file.size, entries: csvData.value.length, lastModified: file.lastModified } processingMessage.value = 'Verarbeitung abgeschlossen!' setTimeout(() => { isProcessing.value = false; showUploadModal.value = false }, 1000) } catch (error) { console.error('Fehler:', error); alert('Fehler: ' + error.message); isProcessing.value = false } @@ -377,6 +377,11 @@ const processFile = async (file) => { const processSelectedFile = () => { if (selectedFile.value) processFile(selectedFile.value) } const removeFile = () => { currentFile.value = null; csvData.value = []; csvHeaders.value = []; selectedColumns.value = []; columnsSelected.value = false; filteredCsvData.value = []; filteredCsvHeaders.value = []; if (fileInput.value) fileInput.value.value = '' } const selectedColumnsCount = computed(() => selectedColumns.value.filter(s => s).length) +const currentFileLabel = computed(() => { + if (!currentFile.value) return '' + if (typeof currentFile.value.entries === 'number') return `${currentFile.value.entries} Einträge` + return `${currentFile.value.size} bytes` +}) const getColumnPreview = (index) => { if (csvData.value.length === 0) return 'Keine Daten'; const sv = csvData.value.slice(0, 3).map(row => row[index]).filter(val => val && val.trim() !== ''); return sv.length > 0 ? `Beispiel: ${sv.join(', ')}` : 'Leere Spalte' } const selectAllColumns = () => { selectedColumns.value = selectedColumns.value.map(() => true) } const deselectAllColumns = () => { selectedColumns.value = selectedColumns.value.map(() => false) } @@ -415,7 +420,7 @@ onMounted(() => { csvHeaders.value = result.headers csvData.value = result.data.map(row => csvHeaders.value.map(header => row[header] || '')) selectedColumns.value = new Array(csvHeaders.value.length).fill(true) - currentFile.value = { name: result.season ? `spielplan-${result.season}.json` : 'spielplan.csv', size: csvData.value.length, lastModified: null } + currentFile.value = { name: result.season ? `spielplan-${result.season}.json` : 'spielplan.csv', entries: csvData.value.length, lastModified: null } } catch { /* ignore */ } })() }) diff --git a/package-lock.json b/package-lock.json index 21373c3..8b34873 100644 --- a/package-lock.json +++ b/package-lock.json @@ -669,9 +669,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", - "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", "cpu": [ "ppc64" ], @@ -685,9 +685,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", - "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", "cpu": [ "arm" ], @@ -701,9 +701,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", - "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", "cpu": [ "arm64" ], @@ -717,9 +717,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", - "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", "cpu": [ "x64" ], @@ -733,9 +733,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", - "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", "cpu": [ "arm64" ], @@ -749,9 +749,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", - "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", "cpu": [ "x64" ], @@ -765,9 +765,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", - "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", "cpu": [ "arm64" ], @@ -781,9 +781,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", - "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", "cpu": [ "x64" ], @@ -797,9 +797,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", - "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", "cpu": [ "arm" ], @@ -813,9 +813,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", - "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", "cpu": [ "arm64" ], @@ -829,9 +829,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", - "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", "cpu": [ "ia32" ], @@ -845,9 +845,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", - "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", "cpu": [ "loong64" ], @@ -861,9 +861,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", - "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", "cpu": [ "mips64el" ], @@ -877,9 +877,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", - "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", "cpu": [ "ppc64" ], @@ -893,9 +893,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", - "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", "cpu": [ "riscv64" ], @@ -909,9 +909,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", - "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", "cpu": [ "s390x" ], @@ -925,9 +925,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", - "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", "cpu": [ "x64" ], @@ -941,9 +941,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", - "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", "cpu": [ "arm64" ], @@ -957,9 +957,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", - "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", "cpu": [ "x64" ], @@ -973,9 +973,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", - "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", "cpu": [ "arm64" ], @@ -989,9 +989,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", - "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", "cpu": [ "x64" ], @@ -1005,9 +1005,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", - "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", "cpu": [ "arm64" ], @@ -1021,9 +1021,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", - "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", "cpu": [ "x64" ], @@ -1037,9 +1037,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", - "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", "cpu": [ "arm64" ], @@ -1053,9 +1053,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", - "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", "cpu": [ "ia32" ], @@ -1069,9 +1069,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", - "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", "cpu": [ "x64" ], @@ -7770,9 +7770,9 @@ } }, "node_modules/esbuild": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", - "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", "hasInstallScript": true, "license": "MIT", "bin": { @@ -7782,32 +7782,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.0", - "@esbuild/android-arm": "0.28.0", - "@esbuild/android-arm64": "0.28.0", - "@esbuild/android-x64": "0.28.0", - "@esbuild/darwin-arm64": "0.28.0", - "@esbuild/darwin-x64": "0.28.0", - "@esbuild/freebsd-arm64": "0.28.0", - "@esbuild/freebsd-x64": "0.28.0", - "@esbuild/linux-arm": "0.28.0", - "@esbuild/linux-arm64": "0.28.0", - "@esbuild/linux-ia32": "0.28.0", - "@esbuild/linux-loong64": "0.28.0", - "@esbuild/linux-mips64el": "0.28.0", - "@esbuild/linux-ppc64": "0.28.0", - "@esbuild/linux-riscv64": "0.28.0", - "@esbuild/linux-s390x": "0.28.0", - "@esbuild/linux-x64": "0.28.0", - "@esbuild/netbsd-arm64": "0.28.0", - "@esbuild/netbsd-x64": "0.28.0", - "@esbuild/openbsd-arm64": "0.28.0", - "@esbuild/openbsd-x64": "0.28.0", - "@esbuild/openharmony-arm64": "0.28.0", - "@esbuild/sunos-x64": "0.28.0", - "@esbuild/win32-arm64": "0.28.0", - "@esbuild/win32-ia32": "0.28.0", - "@esbuild/win32-x64": "0.28.0" + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" } }, "node_modules/escalade": { @@ -14703,463 +14703,6 @@ "vue": "^3.5.0" } }, - "node_modules/vite/node_modules/@esbuild/aix-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", - "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", - "cpu": [ - "ppc64" - ], - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/android-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", - "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/android-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", - "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/android-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", - "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/darwin-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", - "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/darwin-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", - "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", - "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/freebsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", - "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", - "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", - "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", - "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-loong64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", - "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", - "cpu": [ - "loong64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-mips64el": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", - "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", - "cpu": [ - "mips64el" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", - "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", - "cpu": [ - "ppc64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-riscv64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", - "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", - "cpu": [ - "riscv64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-s390x": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", - "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", - "cpu": [ - "s390x" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", - "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", - "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/netbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", - "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", - "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/openbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", - "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", - "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/sunos-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", - "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/win32-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", - "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/win32-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", - "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/win32-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", - "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/esbuild": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", - "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.7", - "@esbuild/android-arm": "0.27.7", - "@esbuild/android-arm64": "0.27.7", - "@esbuild/android-x64": "0.27.7", - "@esbuild/darwin-arm64": "0.27.7", - "@esbuild/darwin-x64": "0.27.7", - "@esbuild/freebsd-arm64": "0.27.7", - "@esbuild/freebsd-x64": "0.27.7", - "@esbuild/linux-arm": "0.27.7", - "@esbuild/linux-arm64": "0.27.7", - "@esbuild/linux-ia32": "0.27.7", - "@esbuild/linux-loong64": "0.27.7", - "@esbuild/linux-mips64el": "0.27.7", - "@esbuild/linux-ppc64": "0.27.7", - "@esbuild/linux-riscv64": "0.27.7", - "@esbuild/linux-s390x": "0.27.7", - "@esbuild/linux-x64": "0.27.7", - "@esbuild/netbsd-arm64": "0.27.7", - "@esbuild/netbsd-x64": "0.27.7", - "@esbuild/openbsd-arm64": "0.27.7", - "@esbuild/openbsd-x64": "0.27.7", - "@esbuild/openharmony-arm64": "0.27.7", - "@esbuild/sunos-x64": "0.27.7", - "@esbuild/win32-arm64": "0.27.7", - "@esbuild/win32-ia32": "0.27.7", - "@esbuild/win32-x64": "0.27.7" - } - }, "node_modules/vitest": { "version": "4.1.8", "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.8.tgz", diff --git a/package.json b/package.json index df76049..d287eed 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,7 @@ "vue-eslint-parser": "^10.2.0" }, "overrides": { - "@peculiar/x509": "1.13.0" + "@peculiar/x509": "1.13.0", + "esbuild": "0.28.1" } } diff --git a/pages/mitgliederbereich/profil.vue b/pages/mitgliederbereich/profil.vue index ad3e71e..9f2fc64 100644 --- a/pages/mitgliederbereich/profil.vue +++ b/pages/mitgliederbereich/profil.vue @@ -365,7 +365,7 @@ const visibility = ref({ showEmail: true, showPhone: true, showAddress: false, - showBirthday: true + showBirthday: false }) const passwordData = ref({ @@ -568,4 +568,3 @@ useHead({ title: 'Mein Profil - Harheimer TC', }) - diff --git a/pages/mitgliederbereich/qttr.vue b/pages/mitgliederbereich/qttr.vue index 53291e2..3369451 100644 --- a/pages/mitgliederbereich/qttr.vue +++ b/pages/mitgliederbereich/qttr.vue @@ -173,4 +173,4 @@ function formatDate(value) { useHead({ title: 'QTTR-Werte - Harheimer TC' }) - \ No newline at end of file + diff --git a/scripts/verify-no-public-writes.js b/scripts/verify-no-public-writes.js new file mode 100644 index 0000000..55cc78d --- /dev/null +++ b/scripts/verify-no-public-writes.js @@ -0,0 +1,52 @@ +import fs from 'fs' +import path from 'path' + +const repoRoot = process.cwd() +const scanRoots = ['server'] +const sourceExtensions = new Set(['.js', '.mjs', '.ts']) + +const publicWritePattern = /\b(writeFile|appendFile|copyFile|rename|mkdir)\s*\([^)]*(public[/\\](?:data|uploads)|['"`]public['"`]\s*,\s*['"`](?:data|uploads)['"`])/s + +function childPath(dir, name) { + if (name !== path.basename(name) || name.includes('/') || name.includes('\\')) { + throw new Error(`Ungueltiger Dateiname beim Scannen: ${name}`) + } + return `${dir}${path.sep}${name}` +} + +function walk(dir) { + const entries = fs.readdirSync(dir, { withFileTypes: true }) + return entries.flatMap((entry) => { + const fullPath = childPath(dir, entry.name) + if (entry.isDirectory()) return walk(fullPath) + return [fullPath] + }) +} + +const findings = [] + +for (const root of scanRoots) { + const absoluteRoot = path.join(repoRoot, root) + if (!fs.existsSync(absoluteRoot)) continue + + for (const filePath of walk(absoluteRoot)) { + if (!sourceExtensions.has(path.extname(filePath))) continue + + const content = fs.readFileSync(filePath, 'utf8') + if (!publicWritePattern.test(content)) continue + + const relativePath = path.relative(repoRoot, filePath) + findings.push(relativePath) + } +} + +if (findings.length > 0) { + console.error('Runtime-Schreibzugriffe nach public/data oder public/uploads gefunden:') + for (const finding of findings) { + console.error(`- ${finding}`) + } + console.error('Bitte stattdessen server/data bzw. server/data/public-data verwenden.') + process.exit(1) +} + +console.log('OK: keine serverseitigen Runtime-Schreibzugriffe nach public/data oder public/uploads gefunden.') diff --git a/server/api/auth/register-passkey.post.js b/server/api/auth/register-passkey.post.js index 9a2a606..36a5f95 100644 --- a/server/api/auth/register-passkey.post.js +++ b/server/api/auth/register-passkey.post.js @@ -1,6 +1,5 @@ import { verifyRegistrationResponse } from '@simplewebauthn/server' import crypto from 'crypto' -import nodemailer from 'nodemailer' import { hashPassword, readUsers, writeUsers } from '../../utils/auth.js' import { getWebAuthnConfig } from '../../utils/webauthn-config.js' import { consumePreRegistration } from '../../utils/webauthn-challenges.js' @@ -8,6 +7,7 @@ import { toBase64Url } from '../../utils/webauthn-encoding.js' import { writeAuditLog } from '../../utils/audit-log.js' import { assertPasswordNotPwned } from '../../utils/hibp.js' import { getClientIp } from '../../utils/rate-limit.js' +import { sendRegistrationNotification } from '../../utils/email-service.js' // Local fallback for Nitro globals when lint/run env doesn't provide them const getMethod = globalThis.getMethod ?? ((e) => (e?.req?.method || e?.method || 'GET')) @@ -260,50 +260,9 @@ export default defineEventHandler(async (event) => { await writeAuditLog('auth.passkey.prereg.success', { email, userId: newUser.id }) - // Send notification emails (same behavior as password registration) + // Send notification emails through the same central recipient logic as password registration. try { - const smtpUser = process.env.SMTP_USER - const smtpPass = process.env.SMTP_PASS - - if (smtpUser && smtpPass) { - const transporter = nodemailer.createTransport({ - host: process.env.SMTP_HOST || 'smtp.gmail.com', - port: process.env.SMTP_PORT || 587, - secure: false, - auth: { user: smtpUser, pass: smtpPass } - }) - - await transporter.sendMail({ - from: process.env.SMTP_FROM || 'noreply@harheimertc.de', - to: process.env.SMTP_ADMIN || 'j.dichmann@gmx.de', - subject: 'Neue Registrierung (Passkey) - Harheimer TC', - html: ` -

Neue Registrierung (Passkey)

-

Ein neuer Benutzer hat sich registriert und wartet auf Freigabe:

-
    -
  • Name: ${name}
  • -
  • E-Mail: ${email}
  • -
  • Telefon: ${phone || 'Nicht angegeben'}
  • -
  • Login: Passkey${password ? ' + Passwort (Fallback)' : ' (ohne Passwort)'}
  • -
-

Bitte prüfen Sie die Registrierung im CMS.

- ` - }) - - await transporter.sendMail({ - from: process.env.SMTP_FROM || 'noreply@harheimertc.de', - to: email, - subject: 'Registrierung erhalten - Harheimer TC', - html: ` -

Registrierung erhalten

-

Hallo ${name},

-

vielen Dank für Ihre Registrierung beim Harheimer TC!

-

Ihre Anfrage wird vom Vorstand geprüft. Sie erhalten eine E-Mail, sobald Ihr Zugang freigeschaltet wurde.

-
-

Mit sportlichen Grüßen,
Ihr Harheimer TC

- ` - }) - } + await sendRegistrationNotification({ name, email, phone }) } catch (emailError) { console.error('E-Mail-Versand fehlgeschlagen:', emailError) } diff --git a/server/api/auth/register.post.js b/server/api/auth/register.post.js index ae5ad44..1e2abc4 100644 --- a/server/api/auth/register.post.js +++ b/server/api/auth/register.post.js @@ -1,6 +1,7 @@ import { readUsers, writeUsers, hashPassword } from '../../utils/auth.js' import { sendRegistrationNotification } from '../../utils/email-service.js' import { assertPasswordNotPwned } from '../../utils/hibp.js' +import { sendNewUserRegistrationPush } from '../../utils/push-notifications.js' export default defineEventHandler(async (event) => { try { @@ -48,7 +49,7 @@ export default defineEventHandler(async (event) => { phone: phone || '', geburtsdatum, visibility: { - showBirthday: visibility?.showBirthday !== undefined ? Boolean(visibility.showBirthday) : true + showBirthday: visibility?.showBirthday !== undefined ? Boolean(visibility.showBirthday) : false }, role: 'mitglied', active: false, // Requires admin approval @@ -59,6 +60,10 @@ export default defineEventHandler(async (event) => { users.push(newUser) await writeUsers(users) + sendNewUserRegistrationPush(newUser) + .then(result => console.info('Registrierungs-Push Ergebnis:', { userId: newUser.id, ...result })) + .catch(error => console.error('Registrierungs-Push fehlgeschlagen:', error)) + // Send notification to Vorstand/admin via central email service try { await sendRegistrationNotification({ name, email, phone }) @@ -75,4 +80,3 @@ export default defineEventHandler(async (event) => { throw error } }) - diff --git a/server/api/birthdays.get.js b/server/api/birthdays.get.js index 2b7b45b..335461b 100644 --- a/server/api/birthdays.get.js +++ b/server/api/birthdays.get.js @@ -72,14 +72,14 @@ export default defineEventHandler(async (event) => { : true if (!isAccepted) continue const vis = m.visibility || {} - const showBirthday = vis.showBirthday === undefined ? true : Boolean(vis.showBirthday) + const showBirthday = vis.showBirthday === true candidates.push({ name: `${m.firstName || ''} ${m.lastName || ''}`.trim(), geburtsdatum: m.geburtsdatum, visibility: { showBirthday }, source: 'manual' }) } for (const u of registeredUsers) { if (!u.active || isHiddenUser(u)) continue const vis = u.visibility || {} - const showBirthday = vis.showBirthday === undefined ? true : Boolean(vis.showBirthday) + const showBirthday = vis.showBirthday === true candidates.push({ name: u.name, geburtsdatum: u.geburtsdatum, visibility: { showBirthday }, source: 'login' }) } diff --git a/server/api/contact.post.js b/server/api/contact.post.js index 7bf2307..69c34f9 100644 --- a/server/api/contact.post.js +++ b/server/api/contact.post.js @@ -1,8 +1,8 @@ import nodemailer from 'nodemailer' import { promises as fs } from 'fs' -import path from 'path' import { createContactRequest } from '../utils/contact-requests.js' import { readUsers, migrateUserRoles, isHiddenUser } from '../utils/auth.js' +import { sendNewContactRequestPush } from '../utils/push-notifications.js' // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal // filename is always a hardcoded constant ('config.json'), never user input @@ -23,17 +23,39 @@ async function loadConfig() { } } -async function collectRecipients(config) { - const isProduction = process.env.NODE_ENV === 'production' && process.env.APP_ENV !== 'test' +function envFlagEnabled(value) { + return ['1', 'true', 'yes', 'on'].includes(String(value || '').trim().toLowerCase()) +} - if (!isProduction) { +function shouldUseDeveloperRecipients() { + if (process.env.DEBUG !== undefined) return envFlagEnabled(process.env.DEBUG) + return process.env.NODE_ENV !== 'production' || process.env.APP_ENV === 'test' +} + +async function collectRecipients(config) { + if (shouldUseDeveloperRecipients()) { return ['tsschulz@tsschulz.de'] } const recipients = [] - // Vorstand - if (config?.vorstand && typeof config.vorstand === 'object') { + // Vorstand: prefer active login users with the board role. + try { + const users = await readUsers() + for (const rawUser of users) { + if (!rawUser || rawUser.active === false || isHiddenUser(rawUser)) continue + const user = migrateUserRoles({ ...rawUser }) + const roles = Array.isArray(user.roles) ? user.roles : [] + if (roles.includes('vorstand') && user.email && String(user.email).trim()) { + recipients.push(String(user.email).trim()) + } + } + } catch (error) { + console.error('Fehler beim Laden der Vorstand-Empfänger aus Benutzerdaten:', error) + } + + // Fallback: legacy config.json Vorstand object. + if (recipients.length === 0 && config?.vorstand && typeof config.vorstand === 'object') { for (const member of Object.values(config.vorstand)) { if (member?.email && typeof member.email === 'string' && member.email.trim()) { recipients.push(member.email.trim()) @@ -72,10 +94,7 @@ async function collectRecipients(config) { if (config?.website?.verantwortlicher?.email) { return [config.website.verantwortlicher.email] } - if (process.env.SMTP_USER) { - return [process.env.SMTP_USER] - } - return ['j.dichmann@gmx.de'] + throw new Error('Keine E-Mail-Empfänger in config.json konfiguriert.') } function createTransporter() { @@ -111,13 +130,17 @@ export default defineEventHandler(async (event) => { } // Anfrage immer speichern, auch wenn E-Mail-Versand fehlschlägt. - await createContactRequest({ + const contactRequest = { name: String(body.name).trim(), email: String(body.email).trim(), phone: body.phone ? String(body.phone).trim() : '', subject: String(body.subject).trim(), message: String(body.message).trim() - }) + } + await createContactRequest(contactRequest) + sendNewContactRequestPush(contactRequest) + .then(result => console.info('Kontaktanfrage-Push Ergebnis:', { subject: contactRequest.subject, ...result })) + .catch(error => console.error('Kontaktanfrage-Push fehlgeschlagen:', error)) const config = await loadConfig() const recipients = await collectRecipients(config) diff --git a/server/api/members.get.js b/server/api/members.get.js index e588122..4237c1c 100644 --- a/server/api/members.get.js +++ b/server/api/members.get.js @@ -64,7 +64,8 @@ export default defineEventHandler(async (event) => { showEmail: vis.showEmail === undefined ? true : Boolean(vis.showEmail), showPhone: vis.showPhone === undefined ? true : Boolean(vis.showPhone), // Address remains private by default - showAddress: vis.showAddress === undefined ? false : Boolean(vis.showAddress) + showAddress: vis.showAddress === undefined ? false : Boolean(vis.showAddress), + showBirthday: vis.showBirthday === true } mergedMembers.push({ @@ -163,7 +164,8 @@ export default defineEventHandler(async (event) => { mergedMembers[matchedManualIndex].visibility = { showEmail: user.visibility.showEmail === undefined ? Boolean(vis.showEmail) : Boolean(user.visibility.showEmail), showPhone: user.visibility.showPhone === undefined ? Boolean(vis.showPhone) : Boolean(user.visibility.showPhone), - showAddress: user.visibility.showAddress === undefined ? Boolean(vis.showAddress) : Boolean(user.visibility.showAddress) + showAddress: user.visibility.showAddress === undefined ? Boolean(vis.showAddress) : Boolean(user.visibility.showAddress), + showBirthday: user.visibility.showBirthday === undefined ? vis.showBirthday === true : user.visibility.showBirthday === true } } } else { @@ -186,7 +188,8 @@ export default defineEventHandler(async (event) => { visibility: { showEmail: userVis.showEmail === undefined ? true : Boolean(userVis.showEmail), showPhone: userVis.showPhone === undefined ? true : Boolean(userVis.showPhone), - showAddress: userVis.showAddress === undefined ? false : Boolean(userVis.showAddress) + showAddress: userVis.showAddress === undefined ? false : Boolean(userVis.showAddress), + showBirthday: userVis.showBirthday === true }, notes: `Rolle(n): ${roles.join(', ')}`, source: 'login', @@ -226,7 +229,7 @@ export default defineEventHandler(async (event) => { const emailVisible = (isPrivilegedViewer || (isViewerAuthenticated && showEmail)) const phoneVisible = (isPrivilegedViewer || (isViewerAuthenticated && showPhone)) const addressVisible = (isPrivilegedViewer || (isViewerAuthenticated && showAddress)) - const birthdayVisible = (isPrivilegedViewer || (isViewerAuthenticated && (member.visibility && member.visibility.showBirthday !== undefined ? Boolean(member.visibility.showBirthday) : true))) + const birthdayVisible = (isPrivilegedViewer || (isViewerAuthenticated && member.visibility?.showBirthday === true)) return { id: member.id, @@ -246,7 +249,7 @@ export default defineEventHandler(async (event) => { showEmail: visibility.showEmail === undefined ? true : Boolean(visibility.showEmail), showPhone: visibility.showPhone === undefined ? true : Boolean(visibility.showPhone), showAddress: visibility.showAddress === undefined ? false : Boolean(visibility.showAddress), - showBirthday: visibility.showBirthday === undefined ? true : Boolean(visibility.showBirthday), + showBirthday: visibility.showBirthday === true, // Privileged viewers (vorstand) always see contact fields email: emailVisible ? member.email : undefined, phone: phoneVisible ? member.phone : undefined, diff --git a/server/api/members.post.js b/server/api/members.post.js index e2b3737..408c487 100644 --- a/server/api/members.post.js +++ b/server/api/members.post.js @@ -1,5 +1,22 @@ -import { getUserFromToken, hasAnyRole } from '../utils/auth.js' -import { saveMember } from '../utils/members.js' +import { getUserFromToken, hasAnyRole, readUsers, writeUsers, normalizeUserEmail } from '../utils/auth.js' +import { readMembers, saveMember } from '../utils/members.js' + +function requestedBirthdayVisibility(body) { + return body?.visibility?.showBirthday ?? body?.showBirthday +} + +function birthdayVisibilityIsTrue(value) { + return value === true || value === 'true' +} + +function resolveAdminBirthdayVisibility({ requested, existingManualMember, existingUser }) { + if (requested === false || requested === 'false') return false + + const existingValue = existingUser?.visibility?.showBirthday ?? existingManualMember?.visibility?.showBirthday + if (existingValue === true) return true + + return false +} export default defineEventHandler(async (event) => { try { @@ -39,7 +56,7 @@ export default defineEventHandler(async (event) => { } const body = await readBody(event) - const { id, firstName, lastName, geburtsdatum, email, phone, address, notes, isMannschaftsspieler, hasHallKey, hasHallenschluessel, active } = body + const { id, firstName, lastName, geburtsdatum, email, phone, address, notes, isMannschaftsspieler, hasHallKey, hasHallenschluessel, active, visibility } = body if (!firstName || !lastName) { throw createError({ @@ -56,6 +73,23 @@ export default defineEventHandler(async (event) => { } try { + const [members, users] = await Promise.all([readMembers(), readUsers()]) + const normalizedEmail = normalizeUserEmail(email) + const existingManualMember = members.find(member => { + if (id && member.id === id) return true + return normalizedEmail && normalizeUserEmail(member.email) === normalizedEmail + }) + const userIndex = users.findIndex(user => { + if (id && user.id === id) return true + return normalizedEmail && normalizeUserEmail(user.email) === normalizedEmail + }) + const existingUser = userIndex !== -1 ? users[userIndex] : null + const nextShowBirthday = resolveAdminBirthdayVisibility({ + requested: requestedBirthdayVisibility(body), + existingManualMember, + existingUser + }) + await saveMember({ id: id || undefined, firstName, @@ -67,9 +101,21 @@ export default defineEventHandler(async (event) => { notes: notes || '', isMannschaftsspieler: isMannschaftsspieler === true || isMannschaftsspieler === 'true', hasHallKey: hasHallKey === true || hasHallKey === 'true' || hasHallenschluessel === true || hasHallenschluessel === 'true', + visibility: { + ...(visibility && typeof visibility === 'object' ? visibility : {}), + showBirthday: nextShowBirthday + }, active: typeof active === 'boolean' ? active : true }) + if (userIndex !== -1 && (!birthdayVisibilityIsTrue(requestedBirthdayVisibility(body)) || existingUser?.visibility?.showBirthday === true)) { + users[userIndex].visibility = { + ...(users[userIndex].visibility || {}), + showBirthday: nextShowBirthday + } + await writeUsers(users) + } + return { success: true, message: 'Mitglied erfolgreich gespeichert.' @@ -98,4 +144,3 @@ export default defineEventHandler(async (event) => { }) } }) - diff --git a/server/api/profile.get.js b/server/api/profile.get.js index c645677..a557b88 100644 --- a/server/api/profile.get.js +++ b/server/api/profile.get.js @@ -27,7 +27,7 @@ export default defineEventHandler(async (event) => { email: user.email, phone: user.phone || '', geburtsdatum: user.geburtsdatum || '', - visibility: Object.assign({ showBirthday: true }, (user.visibility || {})) + visibility: Object.assign({ showBirthday: false }, (user.visibility || {})) } } } catch (error) { diff --git a/server/api/termine-manage.delete.js b/server/api/termine-manage.delete.js index 05768a0..7fe4126 100644 --- a/server/api/termine-manage.delete.js +++ b/server/api/termine-manage.delete.js @@ -3,7 +3,7 @@ import { deleteTermin } from '../utils/termine.js' export default defineEventHandler(async (event) => { try { - const token = getCookie(event, 'auth_token') + const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '') if (!token) { throw createError({ @@ -58,4 +58,3 @@ export default defineEventHandler(async (event) => { throw error } }) - diff --git a/server/api/termine-manage.get.js b/server/api/termine-manage.get.js index f3d913b..7d37179 100644 --- a/server/api/termine-manage.get.js +++ b/server/api/termine-manage.get.js @@ -3,7 +3,7 @@ import { readTermine } from '../utils/termine.js' export default defineEventHandler(async (event) => { try { - const token = getCookie(event, 'auth_token') + const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '') if (!token) { throw createError({ @@ -42,4 +42,3 @@ export default defineEventHandler(async (event) => { throw error } }) - diff --git a/server/api/termine-manage.post.js b/server/api/termine-manage.post.js index 674f893..b1d0247 100644 --- a/server/api/termine-manage.post.js +++ b/server/api/termine-manage.post.js @@ -1,9 +1,10 @@ import { verifyToken, getUserById, hasAnyRole } from '../utils/auth.js' import { saveTermin } from '../utils/termine.js' +import { sendNewEventPush } from '../utils/push-notifications.js' export default defineEventHandler(async (event) => { try { - const token = getCookie(event, 'auth_token') + const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '') if (!token) { throw createError({ @@ -41,13 +42,17 @@ export default defineEventHandler(async (event) => { }) } - await saveTermin({ + const termin = { datum, uhrzeit: uhrzeit || '', titel, beschreibung: beschreibung || '', kategorie: kategorie || 'Sonstiges' - }) + } + await saveTermin(termin) + sendNewEventPush(termin) + .then(result => console.info('Termin-Push Ergebnis:', { titel: termin.titel, ...result })) + .catch(error => console.error('Termin-Push fehlgeschlagen:', error)) return { success: true, @@ -58,4 +63,3 @@ export default defineEventHandler(async (event) => { throw error } }) - diff --git a/server/plugins/notification-scheduler.js b/server/plugins/notification-scheduler.js new file mode 100644 index 0000000..04b70f2 --- /dev/null +++ b/server/plugins/notification-scheduler.js @@ -0,0 +1,38 @@ +import { runNotificationSchedulerTick } from '../utils/notification-scheduler.js' +import { info as loggerInfo, error as loggerError } from '../utils/logger.js' + +const INTERVAL_MS = 60_000 +let timer = null +let running = false + +async function tick(reason = 'interval') { + if (running) return + running = true + try { + const result = await runNotificationSchedulerTick() + if (result?.dueUsers) { + loggerInfo('[notification-scheduler] Tick', { reason, ...result }) + } + } catch (error) { + loggerError('[notification-scheduler] Tick fehlgeschlagen:', { error }) + } finally { + running = false + } +} + +export default defineNitroPlugin((nitroApp) => { + if (process.env.NOTIFICATION_SCHEDULER_DISABLED === 'true') { + loggerInfo('[notification-scheduler] Deaktiviert') + return + } + + loggerInfo('[notification-scheduler] Gestartet') + timer = setInterval(() => tick(), INTERVAL_MS) + timer.unref?.() + tick('start') + + nitroApp.hooks.hookOnce('close', () => { + if (timer) clearInterval(timer) + timer = null + }) +}) diff --git a/server/set-all-birthday-visible.js b/server/set-all-birthday-visible.js deleted file mode 100644 index 8ca8d42..0000000 --- a/server/set-all-birthday-visible.js +++ /dev/null @@ -1,44 +0,0 @@ -// Script: set-all-birthday-visible.js -// Setzt für alle Mitglieder das Flag visibility.showBirthday auf true - -const fs = require('fs') -const path = require('path') - -const membersPath = path.join(__dirname, 'data', 'members.json') - -let raw -try { - raw = fs.readFileSync(membersPath, 'utf8') -} catch (e) { - console.error('Fehler beim Lesen von members.json:', e) - process.exit(1) -} - -let members -try { - members = JSON.parse(raw) -} catch (e) { - console.error('Fehler beim Parsen von members.json:', e) - process.exit(1) -} - -if (!Array.isArray(members)) { - console.error('members.json ist kein Array!') - process.exit(1) -} - -let changed = 0 -for (const m of members) { - if (!m.visibility) m.visibility = {} - if (m.visibility.showBirthday !== true) { - m.visibility.showBirthday = true - changed++ - } -} - -if (changed > 0) { - fs.writeFileSync(membersPath, JSON.stringify(members, null, 2), 'utf8') - console.log(`Flag für ${changed} Mitglieder gesetzt.`) -} else { - console.log('Alle Mitglieder hatten das Flag bereits gesetzt.') -} diff --git a/server/set-all-birthday-visible.mjs b/server/set-all-birthday-visible.mjs deleted file mode 100644 index f07ac9f..0000000 --- a/server/set-all-birthday-visible.mjs +++ /dev/null @@ -1,33 +0,0 @@ -// Script: set-all-birthday-visible.mjs -// Setzt für alle Mitglieder das Flag visibility.showBirthday auf true (mit Entschlüsselung) - -import { readMembers, writeMembers } from './utils/members.js'; -import dotenv from 'dotenv'; -import path from 'path'; -import { fileURLToPath } from 'url'; - -dotenv.config({ path: path.resolve(process.cwd(), '.env') }); - -async function main() { - let members = await readMembers(); - if (!Array.isArray(members)) { - console.error('members.json ist kein Array!') - process.exit(1) - } - let changed = 0; - for (const m of members) { - if (!m.visibility) m.visibility = {}; - if (m.visibility.showBirthday !== true) { - m.visibility.showBirthday = true; - changed++; - } - } - if (changed > 0) { - await writeMembers(members); - console.log(`Flag für ${changed} Mitglieder gesetzt.`); - } else { - console.log('Alle Mitglieder hatten das Flag bereits gesetzt.'); - } -} - -main(); diff --git a/server/set-all-visibility-flags.mjs b/server/set-all-visibility-flags.mjs deleted file mode 100644 index 8f2428b..0000000 --- a/server/set-all-visibility-flags.mjs +++ /dev/null @@ -1,72 +0,0 @@ -// Script: set-all-visibility-flags.mjs -// Setzt für alle Mitglieder in allen relevanten Dateien alle visibility-Flags auf true (inkl. Entschlüsselung) - -import { readMembers, writeMembers } from './utils/members.js'; -import dotenv from 'dotenv'; -import path from 'path'; -import { fileURLToPath } from 'url'; -import fs from 'fs/promises'; - -dotenv.config({ path: path.resolve(process.cwd(), '.env') }); - -const usersPath = path.resolve(process.cwd(), 'server/data/users.json'); - -async function updateVisibility(obj) { - let changed = 0; - if (Array.isArray(obj)) { - for (const m of obj) { - if (!m.visibility) m.visibility = {}; - if (m.visibility.showEmail !== true) { m.visibility.showEmail = true; changed++; } - if (m.visibility.showPhone !== true) { m.visibility.showPhone = true; changed++; } - if (m.visibility.showAddress !== true) { m.visibility.showAddress = true; changed++; } - if (m.visibility.showBirthday !== true) { m.visibility.showBirthday = true; changed++; } - } - } - return changed; -} - -async function updateUsersFile() { - let changed = 0; - try { - let raw = await fs.readFile(usersPath, 'utf8'); - let users; - if (raw.trim().startsWith('v2:')) { - // encrypted, try to use decryptObject from encryption.js - const { decryptObject } = await import('./utils/encryption.js'); - const key = process.env.ENCRYPTION_KEY || 'local_development_encryption_key_change_in_production'; - users = decryptObject(raw, key); - } else { - users = JSON.parse(raw); - } - changed = await updateVisibility(users); - // write back (encrypted if vorher encrypted) - if (raw.trim().startsWith('v2:')) { - const { encryptObject } = await import('./utils/encryption.js'); - const key = process.env.ENCRYPTION_KEY || 'local_development_encryption_key_change_in_production'; - const encrypted = encryptObject(users, key); - await fs.writeFile(usersPath, encrypted, 'utf8'); - } else { - await fs.writeFile(usersPath, JSON.stringify(users, null, 2), 'utf8'); - } - return changed; - } catch (e) { - console.error('Fehler beim Bearbeiten von users.json:', e); - return 0; - } -} - -async function main() { - let changedMembers = 0; - let changedUsers = 0; - // members.json (manuelle Mitglieder) - let members = await readMembers(); - changedMembers = await updateVisibility(members); - if (changedMembers > 0) { - await writeMembers(members); - } - // users.json (Login-System) - changedUsers = await updateUsersFile(); - console.log(`members.json: ${changedMembers} Änderungen, users.json: ${changedUsers} Änderungen`); -} - -main(); diff --git a/server/utils/data-file-rotation.js b/server/utils/data-file-rotation.js index 2cc6fa0..8c4b8ce 100644 --- a/server/utils/data-file-rotation.js +++ b/server/utils/data-file-rotation.js @@ -2,6 +2,7 @@ import { promises as fs } from 'fs' import path from 'path' const DEFAULT_MAX_BACKUPS = Number.parseInt(process.env.DATA_FILE_BACKUP_MAX || '40', 10) +let backupSequence = 0 function getProjectRoot() { const cwd = process.cwd() @@ -30,8 +31,9 @@ function sanitizeFileKey(filePath) { } function buildBackupName(date = new Date()) { + const sequence = (backupSequence++).toString(36).padStart(6, '0') const randomSuffix = Math.random().toString(36).slice(2, 8) - return `${date.toISOString().replace(/[:.]/g, '-')}-${randomSuffix}.bak` + return `${date.toISOString().replace(/[:.]/g, '-')}-${sequence}-${randomSuffix}.bak` } export function resolveDataFileBackupPath(backupDir, backupName) { diff --git a/server/utils/email-service.js b/server/utils/email-service.js index bd68010..d0158bf 100644 --- a/server/utils/email-service.js +++ b/server/utils/email-service.js @@ -7,6 +7,7 @@ import nodemailer from 'nodemailer' import fs from 'fs/promises' import path from 'path' import { getServerDataPath } from './paths.js' +import { isHiddenUser, migrateUserRoles, readUsers } from './auth.js' /** * Gets the correct data path for config files @@ -34,23 +35,45 @@ async function loadConfig() { } } +function envFlagEnabled(value) { + return ['1', 'true', 'yes', 'on'].includes(String(value || '').trim().toLowerCase()) +} + +function shouldUseDeveloperRecipients() { + if (process.env.DEBUG !== undefined) return envFlagEnabled(process.env.DEBUG) + return process.env.NODE_ENV !== 'production' || process.env.APP_ENV === 'test' +} + /** * Gets email recipients based on membership type and environment * @param {Object} data - Form data * @param {Object} config - Configuration * @returns {Array} Email addresses */ -function getEmailRecipients(data, config) { - const isProduction = process.env.NODE_ENV === 'production' && process.env.APP_ENV !== 'test' - - if (!isProduction) { +async function collectBoardUserRecipients() { + try { + const users = await readUsers() + return users + .filter(user => user && user.active !== false && !isHiddenUser(user)) + .map(user => migrateUserRoles({ ...user })) + .filter(user => Array.isArray(user.roles) && user.roles.includes('vorstand')) + .map(user => String(user.email || '').trim()) + .filter(Boolean) + } catch (error) { + console.error('Could not load board recipients from users.json:', error.message || error) + return [] + } +} + +async function getEmailRecipients(data, config) { + if (shouldUseDeveloperRecipients()) { return ['tsschulz@tsschulz.de'] } - const recipients = [] + const recipients = await collectBoardUserRecipients() - // Config uses a 'vorstand' object with nested roles; collect all emails - if (config.vorstand && typeof config.vorstand === 'object') { + // Fallback for legacy installations where Vorstand members are only configured in config.json. + if (recipients.length === 0 && config.vorstand && typeof config.vorstand === 'object') { Object.values(config.vorstand).forEach((member) => { if (member && member.email && typeof member.email === 'string' && member.email.trim() !== '') { recipients.push(member.email.trim()) @@ -59,7 +82,7 @@ function getEmailRecipients(data, config) { } // For minors, also add first trainer email if configured (trainer is an array) - if (!data.isVolljaehrig && Array.isArray(config.trainer) && config.trainer.length > 0 && config.trainer[0].email) { + if (data.isVolljaehrig === false && Array.isArray(config.trainer) && config.trainer.length > 0 && config.trainer[0].email) { recipients.push(config.trainer[0].email) } @@ -69,11 +92,11 @@ function getEmailRecipients(data, config) { if (config.website && config.website.verantwortlicher && config.website.verantwortlicher.email) { recipients.push(config.website.verantwortlicher.email) } else { - recipients.push('tsschulz@tsschulz.de') + throw new Error('Keine E-Mail-Empfänger in config.json konfiguriert.') } } - return recipients + return [...new Set(recipients)] } /** @@ -111,7 +134,7 @@ function createTransporter() { export async function sendMembershipEmail(data, pdfPath) { try { const config = await loadConfig() - const recipients = getEmailRecipients(data, config) + const recipients = await getEmailRecipients(data, config) // Create transporter const transporter = createTransporter() @@ -167,7 +190,7 @@ Das ausgefüllte Formular ist als Anhang verfügbar.` export async function sendRegistrationNotification(data) { try { const config = await loadConfig() - const recipients = getEmailRecipients(data, config) + const recipients = await getEmailRecipients(data, config) // Create transporter const transporter = createTransporter() diff --git a/server/utils/notification-scheduler.js b/server/utils/notification-scheduler.js new file mode 100644 index 0000000..fa8d8e9 --- /dev/null +++ b/server/utils/notification-scheduler.js @@ -0,0 +1,451 @@ +import { promises as fs } from 'fs' +import path from 'path' +import { readUsers, isHiddenUser } from './auth.js' +import { readMembers } from './members.js' +import { readTermine } from './termine.js' +import { readNews } from './news.js' +import { getServerDataPath } from './paths.js' +import { getDefaultSpielplanSeason, readSpielplanData } from './spielplan-data.js' +import { notificationSettingsForUser } from './notification-settings.js' +import { sendPushToUsers } from './push-notifications.js' +import { info as loggerInfo, error as loggerError } from './logger.js' + +const TIME_ZONE = 'Europe/Berlin' +const STATE_FILE = getServerDataPath('notification-scheduler-state.json') +const DATE_FORMATTER = new Intl.DateTimeFormat('en-CA', { + timeZone: TIME_ZONE, + year: 'numeric', + month: '2-digit', + day: '2-digit' +}) +const TIME_FORMATTER = new Intl.DateTimeFormat('en-GB', { + timeZone: TIME_ZONE, + hour: '2-digit', + minute: '2-digit', + hour12: false +}) +const DATE_TIME_FORMATTER = new Intl.DateTimeFormat('de-DE', { + timeZone: TIME_ZONE, + weekday: 'short', + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + hour12: false +}) + +function berlinDateKey(date = new Date()) { + return DATE_FORMATTER.format(date) +} + +function berlinTimeKey(date = new Date()) { + return TIME_FORMATTER.format(date) +} + +function addDays(date, days) { + const next = new Date(date) + next.setUTCDate(next.getUTCDate() + days) + return next +} + +function normalizeText(value) { + return String(value || '') + .normalize('NFKD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/[’'`]/g, '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, ' ') + .trim() +} + +function slugify(value) { + return normalizeText(value).replace(/\s+/g, '-') +} + +function userDisplayName(user) { + return String(user?.name || `${user?.firstName || ''} ${user?.lastName || ''}`.trim() || '').trim() +} + +function hasTimedSettings(user) { + const settings = notificationSettingsForUser(user) + return settings.newNews || settings.eventsToday || settings.eventsTomorrow || settings.ownTeamMatches || + settings.allTeamMatches || settings.selectedTeamSlugs.length > 0 || settings.birthdays +} + +async function readState() { + try { + const parsed = JSON.parse(await fs.readFile(STATE_FILE, 'utf8')) + return parsed && typeof parsed === 'object' ? parsed : {} + } catch (error) { + if (error?.code !== 'ENOENT') loggerError('[notification-scheduler] Status konnte nicht gelesen werden:', { error }) + return {} + } +} + +async function writeState(state) { + await fs.mkdir(path.dirname(STATE_FILE), { recursive: true }) + await fs.writeFile(STATE_FILE, `${JSON.stringify(state, null, 2)}\n`, 'utf8') +} + +function pruneState(state, todayKey) { + const entries = Object.entries(state).filter(([key]) => key.startsWith(todayKey)) + return Object.fromEntries(entries) +} + +function runKey(dateKey, time, category) { + return `${dateKey}:${time}:${category}` +} + +function parseTerminDate(termin) { + const rawDate = String(termin?.datum || '').trim() + if (!rawDate) return null + const time = String(termin?.uhrzeit || '00:00').trim() || '00:00' + if (/^\d{4}-\d{2}-\d{2}$/.test(rawDate)) return new Date(`${rawDate}T${time.padStart(5, '0')}:00+02:00`) + const german = rawDate.match(/^(\d{1,2})\.(\d{1,2})\.(\d{4})$/) + if (german) { + const [, day, month, year] = german + return new Date(`${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}T${time.padStart(5, '0')}:00+02:00`) + } + const parsed = new Date(rawDate) + return Number.isNaN(parsed.getTime()) ? null : parsed +} + +function eventsOn(termine, dateKey) { + return termine + .map(termin => ({ termin, date: parseTerminDate(termin) })) + .filter(entry => entry.date && berlinDateKey(entry.date) === dateKey) + .map(entry => ({ title: entry.termin.titel, source: 'termin', item: entry.termin })) +} + +function expiringNewsOn(news, dateKey) { + return news + .filter(item => !item?.isHidden && item?.expiresAt) + .map(item => ({ item, date: new Date(item.expiresAt) })) + .filter(entry => !Number.isNaN(entry.date.getTime()) && berlinDateKey(entry.date) === dateKey) + .map(entry => ({ title: entry.item.title, source: 'news', item: entry.item })) +} + +function formatNewsExpirySummary(news, fallback) { + if (news.length === 1) return String(news[0].title || fallback).slice(0, 140) + return `${news.length} News laufen heute ab: ${news.slice(0, 3).map(item => item.title).filter(Boolean).join(', ')}`.slice(0, 140) +} + +function formatEventSummary(events, fallback) { + if (events.length === 1) return String(events[0].title || fallback).slice(0, 140) + return `${events.length} Einträge: ${events.slice(0, 3).map(event => event.title).filter(Boolean).join(', ')}`.slice(0, 140) +} + +function matchDate(row) { + const timestamp = Number(row?.Timestamp) + if (Number.isFinite(timestamp) && timestamp > 0) return new Date(timestamp * 1000) + const raw = String(row?.Termin || '').trim() + const match = raw.match(/^(\d{1,2})\.(\d{1,2})\.(\d{4})(?:\s+(\d{1,2}:\d{2}))?/) + if (!match) return null + const [, day, month, year, time = '00:00'] = match + const parsed = new Date(`${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}T${time}:00+02:00`) + return Number.isNaN(parsed.getTime()) ? null : parsed +} + +function matchTeams(row) { + return [row?.HeimMannschaft, row?.GastMannschaft].map(value => String(value || '').trim()).filter(Boolean) +} + +function matchesOn(rows, dateKey) { + return rows + .map(row => ({ row, date: matchDate(row) })) + .filter(entry => entry.date && berlinDateKey(entry.date) === dateKey) +} + +function matchSummary(matches, fallback) { + if (!matches.length) return fallback + if (matches.length === 1) { + const teams = matchTeams(matches[0].row).join(' - ') + const when = DATE_TIME_FORMATTER.format(matches[0].date) + return `${when}: ${teams}`.slice(0, 140) + } + return `${matches.length} Punktspiele am ${dateKeyToGerman(berlinDateKey(matches[0]?.date || new Date()))}` +} + +function dateKeyToGerman(dateKey) { + const [year, month, day] = String(dateKey).split('-') + return `${day}.${month}.${year}` +} + +function matchIdentity(match) { + const row = match?.row || {} + const explicit = row.BegegnungNr || row.MeetingId || row.meeting_id || row.SpielNr + if (explicit) return `id:${explicit}` + return [ + berlinDateKey(match?.date || matchDate(row) || new Date(0)), + String(row.Timestamp || ''), + ...matchTeams(row).map(slugify) + ].join('|') +} + +function uniqueMatches(matches) { + const seen = new Set() + const unique = [] + for (const match of matches) { + const identity = matchIdentity(match) + if (seen.has(identity)) continue + seen.add(identity) + unique.push(match) + } + return unique +} + +function localTeamSlugForSide(row, side, teamRows) { + const clubName = normalizeText(row?.[`${side}VereinName`] || row?.[`${side}Mannschaft`] || '') + if (!clubName.includes('harheimer tc')) return [] + + const ageClass = String(row?.[`${side}MannschaftAltersklasse`] || row?.Altersklasse || '') + const number = String(row?.[`${side}MannschaftNr`] || '1').trim() || '1' + const base = /jugend/i.test(ageClass) ? 'Jugend' : 'Erwachsene' + const candidate = slugify(`${base} ${number}`) + const known = new Set(teamRows.map(row => slugify(row.team)).filter(Boolean)) + + if (!known.size || known.has(candidate)) return [candidate] + if (/jugend/i.test(ageClass)) { + return teamRows + .map(row => slugify(row.team)) + .filter(slug => slug.startsWith('jugend')) + } + return [] +} + +function teamSlugsForMatch(match, teamRows = []) { + const row = match?.row || {} + return [...new Set([ + ...matchTeams(row).map(slugify), + ...localTeamSlugForSide(row, 'Heim', teamRows), + ...localTeamSlugForSide(row, 'Gast', teamRows) + ].filter(Boolean))] +} + +async function readTeamMembers(season) { + const fileNames = season ? [`mannschaften_${season}.csv`, 'mannschaften.csv'] : ['mannschaften.csv'] + for (const fileName of fileNames) { + try { + const raw = await fs.readFile(getServerDataPath('public-data', fileName), 'utf8') + const lines = raw.split(/\r?\n/).filter(line => line.trim()) + const rows = [] + for (const line of lines.slice(1)) { + const values = parseCsvLine(line) + rows.push({ team: values[0] || '', captain: values[6] || '', players: values[7] || '' }) + } + return rows + } catch (error) { + if (error?.code !== 'ENOENT') loggerError('[notification-scheduler] Mannschaften konnten nicht gelesen werden:', { fileName, error }) + } + } + return [] +} + +function parseCsvLine(line) { + const values = [] + let current = '' + let inQuotes = false + for (let index = 0; index < line.length; index += 1) { + const char = line[index] + const next = line[index + 1] + if (char === '"' && inQuotes && next === '"') { + current += '"' + index += 1 + } else if (char === '"') { + inQuotes = !inQuotes + } else if (char === ',' && !inQuotes) { + values.push(current.trim()) + current = '' + } else { + current += char + } + } + values.push(current.trim()) + return values +} + +function personNameMatches(candidate, userName) { + const normalizedCandidate = normalizeText(candidate) + const normalizedUserName = normalizeText(userName) + if (!normalizedCandidate || !normalizedUserName) return false + if (normalizedCandidate === normalizedUserName) return true + + const candidateParts = new Set(normalizedCandidate.split(' ').filter(Boolean)) + const userParts = normalizedUserName.split(' ').filter(Boolean) + return userParts.length >= 2 && userParts.every(part => candidateParts.has(part)) +} + +function ownTeamSlugsForUser(user, teamRows) { + const name = userDisplayName(user) + if (!normalizeText(name)) return [] + return teamRows + .filter(row => personNameMatches(row.captain, name) || + String(row.players || '').replace(/\r?\n/g, ';').split(/[;,]+/).some(player => personNameMatches(player, name))) + .map(row => slugify(row.team)) + .filter(Boolean) +} + +function selectedMatchesForUser(_user, settings, matches, teamRows = []) { + const selected = new Set((settings.selectedTeamSlugs || []).map(slugify)) + if (selected.size === 0) return [] + return matches.filter(match => teamSlugsForMatch(match, teamRows).some(slug => selected.has(slug))) +} + +function ownMatchesForUser(user, settings, matches, teamRows) { + if (settings.ownTeamMatches === false) return [] + const ownSlugs = new Set(ownTeamSlugsForUser(user, teamRows)) + if (ownSlugs.size === 0) return [] + return matches.filter(match => teamSlugsForMatch(match, teamRows).some(slug => ownSlugs.has(slug))) +} + +function matchesForUser(user, settings, context) { + if (settings.allTeamMatches) return uniqueMatches(context.allMatches) + return uniqueMatches([ + ...selectedMatchesForUser(user, settings, context.allMatches, context.teamRows), + ...ownMatchesForUser(user, settings, context.allMatches, context.teamRows) + ]) +} + +function notificationSeasonForSettings(settings, fallbackSeason) { + return String(settings?.selectedTeamSeason || fallbackSeason || '').trim() +} + +async function loadMatchContextForSeasons(seasons, dateKey, tomorrowKey) { + const entries = await Promise.all(seasons.map(async season => { + const [spielplan, teamRows] = await Promise.all([readSpielplanData({ season }), readTeamMembers(season)]) + const todayMatches = matchesOn(spielplan.data || [], dateKey) + const tomorrowMatches = matchesOn(spielplan.data || [], tomorrowKey) + return [season, { spielplan, teamRows, todayMatches, tomorrowMatches, allMatches: [...todayMatches, ...tomorrowMatches] }] + })) + return Object.fromEntries(entries) +} + +function parseBirthday(value) { + const raw = String(value || '').trim() + if (!raw) return null + const iso = raw.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/) + if (iso) return { month: Number(iso[2]), day: Number(iso[3]) } + const german = raw.match(/^(\d{1,2})\.(\d{1,2})(?:\.(\d{2,4}))?$/) + if (german) return { month: Number(german[2]), day: Number(german[1]) } + return null +} + +function hasBirthdayNotificationConsent(person) { + return person?.visibility?.showBirthday === true || person?.showBirthday === true +} + +function formatBirthdaySummary(names) { + const visibleNames = names.map(name => String(name || '').trim()).filter(Boolean) + if (visibleNames.length === 1) return `${visibleNames[0]} hat heute Geburtstag.` + return `Geburtstage heute: ${visibleNames.slice(0, 5).join(', ')}${visibleNames.length > 5 ? ` und ${visibleNames.length - 5} weitere` : ''}.` +} + +async function birthdaysOn(dateKey) { + const [, month, day] = dateKey.split('-').map(Number) + const [manualMembers, users] = await Promise.all([readMembers(), readUsers()]) + const people = [] + for (const member of manualMembers) { + if (member?.active === false) continue + if (!hasBirthdayNotificationConsent(member)) continue + const birthday = parseBirthday(member.geburtsdatum || member.birthday) + if (birthday?.month === month && birthday?.day === day) { + people.push(String(member.name || `${member.firstName || ''} ${member.lastName || ''}`.trim()).trim()) + } + } + for (const user of users) { + if (isHiddenUser(user) || user?.active === false) continue + if (!hasBirthdayNotificationConsent(user)) continue + const birthday = parseBirthday(user.geburtsdatum || user.birthday) + if (birthday?.month === month && birthday?.day === day) { + people.push(userDisplayName(user)) + } + } + return [...new Set(people.filter(Boolean))].sort((a, b) => a.localeCompare(b, 'de')) +} + +async function sendIfDue(state, dateKey, time, category, enabled, send, equivalentCategories = []) { + const key = runKey(dateKey, time, category) + const equivalentKeys = equivalentCategories.map(equivalentCategory => runKey(dateKey, time, equivalentCategory)) + if (!enabled || state[key] || equivalentKeys.some(equivalentKey => state[equivalentKey])) return null + const result = await send() + state[key] = { at: new Date().toISOString(), result } + return result +} + +export async function runNotificationSchedulerTick(now = new Date()) { + const dateKey = berlinDateKey(now) + const time = berlinTimeKey(now) + const users = (await readUsers()).filter(user => !isHiddenUser(user) && hasTimedSettings(user)) + const dueUsers = users.filter(user => notificationSettingsForUser(user).notificationTime === time) + if (!dueUsers.length) return { dueUsers: 0, time, dateKey } + + let state = pruneState(await readState(), dateKey) + const tomorrowKey = berlinDateKey(addDays(now, 1)) + const [termine, news, defaultSeason] = await Promise.all([readTermine(), readNews(), getDefaultSpielplanSeason()]) + const todayTermine = eventsOn(termine, dateKey) + const tomorrowTermine = eventsOn(termine, tomorrowKey) + const expiringNewsToday = expiringNewsOn(news, dateKey) + const todayEvents = todayTermine + const tomorrowEvents = tomorrowTermine + const seasonsForMatches = [...new Set(dueUsers + .map(user => notificationSeasonForSettings(notificationSettingsForUser(user), defaultSeason)) + .filter(Boolean))] + const matchContexts = await loadMatchContextForSeasons(seasonsForMatches, dateKey, tomorrowKey) + const todaysBirthdays = await birthdaysOn(dateKey) + const results = {} + + results.eventsToday = await sendIfDue(state, dateKey, time, 'eventsToday', todayEvents.length > 0, () => sendPushToUsers({ + title: 'Termine heute', + body: formatEventSummary(todayEvents, 'Heute stehen Termine an.'), + data: { type: 'events_today', date: dateKey }, + predicate: (user, settings) => settings.notificationTime === time && settings.eventsToday, + failureLabel: 'FCM Termine-heute-Push' + })) + + results.expiringNews = await sendIfDue(state, dateKey, time, 'expiringNews', expiringNewsToday.length > 0, () => sendPushToUsers({ + title: 'News laufen heute ab', + body: formatNewsExpirySummary(expiringNewsToday, 'Heute laufen News ab.'), + data: { type: 'news_expiring', date: dateKey }, + predicate: (_user, settings) => settings.notificationTime === time && settings.newNews && !settings.eventsToday, + failureLabel: 'FCM News-Ablauf-Push' + })) + + results.eventsTomorrow = await sendIfDue(state, dateKey, time, 'eventsTomorrow', tomorrowEvents.length > 0, () => sendPushToUsers({ + title: 'Termine morgen', + body: formatEventSummary(tomorrowEvents, 'Morgen stehen Termine an.'), + data: { type: 'events_tomorrow', date: tomorrowKey }, + predicate: (user, settings) => settings.notificationTime === time && settings.eventsTomorrow, + failureLabel: 'FCM Termine-morgen-Push' + })) + + const teamMatchResults = [] + for (const [season, context] of Object.entries(matchContexts)) { + teamMatchResults.push(await sendIfDue(state, dateKey, time, 'teamMatches:' + season, context.allMatches.length > 0, () => sendPushToUsers({ + title: 'Punktspiele', + body: matchSummary(context.allMatches, 'Es stehen Punktspiele an.'), + data: { type: 'team_matches', date: dateKey, season }, + bodyForUser: (user, settings) => matchSummary(matchesForUser(user, settings, context), 'Es stehen Punktspiele an.'), + predicate: (user, settings) => settings.notificationTime === time && + notificationSeasonForSettings(settings, defaultSeason) === season && + matchesForUser(user, settings, context).length > 0, + failureLabel: 'FCM Punktspiele-Push' + }), ['allTeamMatches:' + season, 'selectedTeamMatches:' + season, 'ownTeamMatches:' + season])) + } + results.teamMatches = teamMatchResults.some(Boolean) + results.allTeamMatches = results.teamMatches + results.selectedTeamMatches = results.teamMatches + results.ownTeamMatches = results.teamMatches + + results.birthdays = await sendIfDue(state, dateKey, time, 'birthdays', todaysBirthdays.length > 0, () => sendPushToUsers({ + title: 'Geburtstage heute', + body: formatBirthdaySummary(todaysBirthdays), + data: { type: 'birthdays', date: dateKey }, + predicate: (_user, settings) => settings.notificationTime === time && settings.birthdays, + failureLabel: 'FCM Geburtstags-Push' + })) + + await writeState(state) + loggerInfo('[notification-scheduler] Lauf abgeschlossen', { dateKey, time, dueUsers: dueUsers.length, results }) + return { dateKey, time, dueUsers: dueUsers.length, results } +} diff --git a/server/utils/push-notifications.js b/server/utils/push-notifications.js index a900563..ef6e62b 100644 --- a/server/utils/push-notifications.js +++ b/server/utils/push-notifications.js @@ -1,7 +1,7 @@ import crypto from 'crypto' import { promises as fs } from 'fs' import path from 'path' -import { readUsers, writeUsers, isHiddenUser } from './auth.js' +import { readUsers, writeUsers, isHiddenUser, migrateUserRoles } from './auth.js' import { notificationSettingsForUser } from './notification-settings.js' const FCM_SCOPE = 'https://www.googleapis.com/auth/firebase.messaging' @@ -132,11 +132,29 @@ async function sendFcmMessage({ serviceAccount, accessToken, token, title, body, } } -export async function sendNewNewsPush(news) { +function isStaleFcmTokenError(error) { + return /UNREGISTERED|NOT_FOUND|INVALID_ARGUMENT/.test(String(error?.message || error || '')) +} + +function notificationIdFor(value) { + return String(value || Date.now()).split('').reduce((acc, char) => acc + char.charCodeAt(0), 0).toString() +} + +function userRoles(user) { + const migrated = migrateUserRoles({ ...(user || {}) }) + return Array.isArray(migrated.roles) ? migrated.roles : [] +} + +function isVorstandUser(user) { + const roles = userRoles(user) + return roles.includes('admin') || roles.includes('vorstand') +} + +export async function sendPushToUsers({ title, body, data = {}, predicate, bodyForUser, dataForUser, failureLabel = 'FCM-Push' }) { const serviceAccount = await readServiceAccount() - if (!serviceAccount) { + if (serviceAccount == null) { console.warn('FCM nicht konfiguriert: FCM_SERVICE_ACCOUNT_JSON oder GOOGLE_APPLICATION_CREDENTIALS fehlt.') - return { sent: 0, skipped: true } + return { sent: 0, failed: 0, removed: 0, recipients: 0, tokenCount: 0, skipped: true } } const accessToken = await getAccessToken(serviceAccount) const users = await readUsers() @@ -146,41 +164,43 @@ export async function sendNewNewsPush(news) { let recipients = 0 let tokenCount = 0 let changed = false - const title = 'Neue News' - const body = String(news.title || 'Neue Nachricht vom Harheimer TC').slice(0, 120) - const data = { - type: 'news', - newsId: String(news.id || ''), - title, - body, - notificationId: String(news.id || Date.now()).split('').reduce((acc, char) => acc + char.charCodeAt(0), 0).toString() - } + const baseData = Object.fromEntries(Object.entries(data).map(([key, value]) => [key, String(value ?? '')])) for (const user of users) { if (isHiddenUser(user)) continue const settings = notificationSettingsForUser(user) - if (!settings.newNews) continue + if (predicate && !predicate(user, settings)) continue + const userBody = String(bodyForUser ? bodyForUser(user, settings) : body || '').slice(0, 240) + const userData = dataForUser ? dataForUser(user, settings) : {} + const payload = { + ...baseData, + ...Object.fromEntries(Object.entries(userData || {}).map(([key, value]) => [key, String(value ?? '')])), + title: String(title || 'Harheimer TC'), + body: userBody, + notificationId: String((userData && userData.notificationId) || data.notificationId || notificationIdFor([data.type || 'push', title, userBody].join(':'))) + } recipients += 1 const tokens = pushTokensForUser(user) tokenCount += tokens.length const validTokens = [] for (const entry of tokens) { try { - await sendFcmMessage({ serviceAccount, accessToken, token: entry.token, title, body, data }) + await sendFcmMessage({ serviceAccount, accessToken, token: entry.token, title, body: userBody, data: payload }) sent += 1 validTokens.push(entry) } catch (error) { - failed += 1 - console.error('FCM News-Push fehlgeschlagen:', error.message) - if (!/UNREGISTERED|NOT_FOUND|INVALID_ARGUMENT/.test(String(error.message))) { - validTokens.push(entry) - } else { + if (isStaleFcmTokenError(error)) { removed += 1 changed = true + console.warn('FCM Push-Token entfernt:', { failureLabel, reason: error.message }) + } else { + failed += 1 + console.error('FCM Push fehlgeschlagen:', { failureLabel, message: error.message }) + validTokens.push(entry) } } } - if (validTokens.length !== tokens.length) { + if (validTokens.length < tokens.length) { user.pushTokens = validTokens changed = true } @@ -188,3 +208,66 @@ export async function sendNewNewsPush(news) { if (changed) await writeUsers(users) return { sent, failed, removed, recipients, tokenCount, skipped: false } } + +export async function sendNewNewsPush(news) { + const title = 'Neue News' + const body = String(news.title || 'Neue Nachricht vom Harheimer TC').slice(0, 120) + return sendPushToUsers({ + title, + body, + data: { + type: 'news', + newsId: String(news.id || ''), + notificationId: notificationIdFor(news.id || Date.now()) + }, + predicate: (_user, settings) => settings.newNews, + failureLabel: 'FCM News-Push' + }) +} + +export async function sendNewEventPush(termin) { + const title = 'Neuer Termin' + const body = String(termin?.titel || 'Ein neuer Termin wurde eingetragen.').slice(0, 120) + return sendPushToUsers({ + title, + body, + data: { + type: 'event', + date: termin?.datum || '', + notificationId: notificationIdFor(`event:${termin?.datum || ''}:${termin?.titel || ''}`) + }, + predicate: (_user, settings) => settings.newEvents, + failureLabel: 'FCM Termin-Push' + }) +} + +export async function sendNewContactRequestPush(contactRequest) { + const title = 'Neue Kontaktanfrage' + const body = String(contactRequest?.subject || contactRequest?.name || 'Eine neue Kontaktanfrage ist eingegangen.').slice(0, 120) + return sendPushToUsers({ + title, + body, + data: { + type: 'contact_request', + notificationId: notificationIdFor(`contact:${contactRequest?.email || ''}:${contactRequest?.subject || ''}:${Date.now()}`) + }, + predicate: (user, settings) => isVorstandUser(user) && settings.newContactRequest, + failureLabel: 'FCM Kontaktanfrage-Push' + }) +} + +export async function sendNewUserRegistrationPush(registration) { + const title = 'Neue Benutzerregistrierung' + const body = String(registration?.name || registration?.email || 'Eine neue Registrierung wartet auf Freigabe.').slice(0, 120) + return sendPushToUsers({ + title, + body, + data: { + type: 'user_registration', + userId: registration?.id || '', + notificationId: notificationIdFor(`registration:${registration?.id || registration?.email || Date.now()}`) + }, + predicate: (user, settings) => isVorstandUser(user) && settings.newUserRegistration, + failureLabel: 'FCM Registrierungs-Push' + }) +} diff --git a/tests/email-service.spec.ts b/tests/email-service.spec.ts new file mode 100644 index 0000000..8c6219d --- /dev/null +++ b/tests/email-service.spec.ts @@ -0,0 +1,116 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import fs from 'fs/promises' + +vi.mock('nodemailer', () => { + const sendMail = vi.fn().mockResolvedValue({ messageId: 'test-message' }) + const createTransport = vi.fn(() => ({ sendMail })) + return { + default: { createTransport }, + createTransport + } +}) + +vi.mock('../server/utils/auth.js', () => ({ + readUsers: vi.fn(), + migrateUserRoles: vi.fn((user) => { + if (!user) return user + if (Array.isArray(user.roles)) return user + if (user.role) { + user.roles = [user.role] + delete user.role + } else { + user.roles = ['mitglied'] + } + return user + }), + isHiddenUser: vi.fn(user => user?.hidden === true || user?.invisible === true || user?.isHidden === true || user?.systemAccount === true || user?.accountType === 'playstore_review') +})) + +const nodemailer = await import('nodemailer') +const authUtils = await import('../server/utils/auth.js') +const emailService = await import('../server/utils/email-service.js') + +describe('Email service recipients', () => { + beforeEach(() => { + vi.restoreAllMocks() + vi.clearAllMocks() + process.env.SMTP_USER = 'smtp@example.com' + process.env.SMTP_PASS = 'smtp-password' + authUtils.readUsers.mockResolvedValue([]) + }) + + afterEach(() => { + delete process.env.SMTP_USER + delete process.env.SMTP_PASS + delete process.env.NODE_ENV + delete process.env.APP_ENV + delete process.env.DEBUG + }) + + it('sendet bei DEBUG=FALSE in production an Vorstand statt Entwickleradresse', async () => { + process.env.NODE_ENV = 'production' + process.env.APP_ENV = 'test' + process.env.DEBUG = 'FALSE' + vi.spyOn(fs, 'readFile').mockResolvedValue(JSON.stringify({ + vorstand: { + vorsitzender: { email: 'vorstand@example.com' } + } + })) + + await emailService.sendRegistrationNotification({ + name: 'Max Muster', + email: 'max@example.com', + phone: '069123456' + }) + + const transporter = nodemailer.default.createTransport.mock.results[0].value + expect(transporter.sendMail).toHaveBeenCalledWith(expect.objectContaining({ + to: 'vorstand@example.com' + })) + expect(transporter.sendMail.mock.calls[0][0].to).not.toContain('tsschulz@tsschulz.de') + }) + + it('bevorzugt aktive Vorstand-Benutzer vor config.json', async () => { + process.env.NODE_ENV = 'production' + process.env.DEBUG = 'FALSE' + authUtils.readUsers.mockResolvedValue([ + { id: '1', email: 'rolle-vorstand@example.com', roles: ['vorstand'], active: true }, + { id: '2', email: 'inaktiv@example.com', roles: ['vorstand'], active: false }, + { id: '3', email: 'mitglied@example.com', roles: ['mitglied'], active: true } + ]) + vi.spyOn(fs, 'readFile').mockResolvedValue(JSON.stringify({ + vorstand: { + vorsitzender: { email: 'config-vorstand@example.com' } + } + })) + + await emailService.sendRegistrationNotification({ + name: 'Max Muster', + email: 'max@example.com' + }) + + const transporter = nodemailer.default.createTransport.mock.results[0].value + const to = transporter.sendMail.mock.calls[0][0].to + expect(to).toBe('rolle-vorstand@example.com') + expect(to).not.toContain('config-vorstand@example.com') + expect(to).not.toContain('inaktiv@example.com') + }) + + it('sendet nur bei explizitem DEBUG=true an die Entwickleradresse', async () => { + process.env.NODE_ENV = 'production' + process.env.DEBUG = 'true' + vi.spyOn(fs, 'readFile').mockResolvedValue(JSON.stringify({ + vorstand: { + vorsitzender: { email: 'vorstand@example.com' } + } + })) + + await emailService.sendRegistrationNotification({ + name: 'Max Muster', + email: 'max@example.com' + }) + + const transporter = nodemailer.default.createTransport.mock.results[0].value + expect(transporter.sendMail.mock.calls[0][0].to).toBe('tsschulz@tsschulz.de') + }) +}) diff --git a/tests/members-endpoints.spec.ts b/tests/members-endpoints.spec.ts index 6ec1c9e..308a492 100644 --- a/tests/members-endpoints.spec.ts +++ b/tests/members-endpoints.spec.ts @@ -51,12 +51,13 @@ import membersGetHandler from '../server/api/members.get.js' import membersPostHandler from '../server/api/members.post.js' import membersDeleteHandler from '../server/api/members.delete.js' import membersBulkHandler from '../server/api/members/bulk.post.js' -import membersBulkHandler from '../server/api/members/bulk.post.js' import toggleMannschaftsspielerHandler from '../server/api/members/toggle-mannschaftsspieler.post.js' describe('Members API Endpoints', () => { beforeEach(() => { vi.clearAllMocks() + authUtils.readUsers.mockResolvedValue([]) + memberUtils.readMembers.mockResolvedValue([]) }) describe('GET /api/members', () => { @@ -100,6 +101,38 @@ describe('Members API Endpoints', () => { expect(response.members[0].name).toBe('Anna Muster') }) + it('liefert Geburtstags-Sichtbarkeit für Admin/Vorstand-Bearbeitung', async () => { + const event = createEvent({ cookies: { auth_token: 'token' } }) + authUtils.verifyToken.mockReturnValue({ id: '1' }) + memberUtils.readMembers.mockResolvedValue([ + { id: 'm1', firstName: 'Anna', lastName: 'Muster', geburtsdatum: '2000-01-01', visibility: { showBirthday: false } } + ]) + authUtils.readUsers.mockResolvedValue([]) + authUtils.getUserFromToken.mockResolvedValue({ id: '1', role: 'admin' }) + + const response = await membersGetHandler(event) + + expect(response.members).toHaveLength(1) + expect(response.members[0].showBirthday).toBe(false) + }) + + it('uebernimmt Geburtstags-Sichtbarkeit vom Login-Benutzer beim Merge', async () => { + const event = createEvent({ cookies: { auth_token: 'token' } }) + authUtils.verifyToken.mockReturnValue({ id: '1' }) + memberUtils.readMembers.mockResolvedValue([ + { id: 'm1', firstName: 'Anna', lastName: 'Muster', email: 'anna@club.de', geburtsdatum: '2000-01-01', visibility: { showBirthday: true } } + ]) + authUtils.readUsers.mockResolvedValue([ + { id: 'u1', name: 'Anna Muster', email: 'anna@club.de', active: true, visibility: { showBirthday: false } } + ]) + authUtils.getUserFromToken.mockResolvedValue({ id: '1', role: 'admin' }) + + const response = await membersGetHandler(event) + + expect(response.members).toHaveLength(1) + expect(response.members[0].showBirthday).toBe(false) + }) + it('blendet unsichtbare Playstore-Benutzer und passende manuelle Einträge aus', async () => { const event = createEvent({ cookies: { auth_token: 'token' } }) @@ -159,6 +192,8 @@ describe('Members API Endpoints', () => { const event = createEvent({ cookies: { auth_token: 'token' } }) mockSuccessReadBody(baseBody) authUtils.getUserFromToken.mockResolvedValue({ id: '2', role: 'admin' }) + authUtils.readUsers.mockResolvedValue([]) + memberUtils.readMembers.mockResolvedValue([]) memberUtils.saveMember.mockResolvedValue(true) const response = await membersPostHandler(event) @@ -168,6 +203,76 @@ describe('Members API Endpoints', () => { })) }) + it('speichert Geburtstags-Sichtbarkeit für manuelle Mitglieder', async () => { + const event = createEvent({ cookies: { auth_token: 'token' } }) + mockSuccessReadBody({ ...baseBody, showBirthday: false }) + authUtils.getUserFromToken.mockResolvedValue({ id: '2', role: 'admin' }) + authUtils.readUsers.mockResolvedValue([]) + memberUtils.readMembers.mockResolvedValue([ + { id: 'manual-1', firstName: 'Lisa', lastName: 'Beispiel', email: 'lisa@example.com', visibility: { showBirthday: true } } + ]) + memberUtils.saveMember.mockResolvedValue(true) + + const response = await membersPostHandler(event) + + expect(response.success).toBe(true) + expect(memberUtils.saveMember).toHaveBeenCalledWith(expect.objectContaining({ + visibility: expect.objectContaining({ showBirthday: false }) + })) + expect(authUtils.writeUsers).not.toHaveBeenCalled() + }) + + it('kann Geburtstags-Sichtbarkeit auch am Login-Benutzer ausschalten', async () => { + const event = createEvent({ cookies: { auth_token: 'token' } }) + mockSuccessReadBody({ + id: 'user-1', + ...baseBody, + email: 'lisa@example.com', + visibility: { showBirthday: false } + }) + authUtils.getUserFromToken.mockResolvedValue({ id: '2', role: 'vorstand' }) + memberUtils.readMembers.mockResolvedValue([]) + authUtils.readUsers.mockResolvedValue([ + { id: 'user-1', email: 'lisa@example.com', visibility: { showBirthday: true, showEmail: true } } + ]) + authUtils.writeUsers.mockResolvedValue(undefined) + memberUtils.saveMember.mockResolvedValue(true) + + const response = await membersPostHandler(event) + + expect(response.success).toBe(true) + expect(authUtils.writeUsers).toHaveBeenCalledWith([ + expect.objectContaining({ + id: 'user-1', + visibility: expect.objectContaining({ showBirthday: false, showEmail: true }) + }) + ]) + }) + + it('darf Geburtstags-Sichtbarkeit nicht für Login-Benutzer einschalten', async () => { + const event = createEvent({ cookies: { auth_token: 'token' } }) + mockSuccessReadBody({ + id: 'user-1', + ...baseBody, + email: 'lisa@example.com', + visibility: { showBirthday: true } + }) + authUtils.getUserFromToken.mockResolvedValue({ id: '2', role: 'vorstand' }) + memberUtils.readMembers.mockResolvedValue([]) + authUtils.readUsers.mockResolvedValue([ + { id: 'user-1', email: 'lisa@example.com', visibility: { showBirthday: false, showEmail: true } } + ]) + memberUtils.saveMember.mockResolvedValue(true) + + const response = await membersPostHandler(event) + + expect(response.success).toBe(true) + expect(memberUtils.saveMember).toHaveBeenCalledWith(expect.objectContaining({ + visibility: expect.objectContaining({ showBirthday: false }) + })) + expect(authUtils.writeUsers).not.toHaveBeenCalled() + }) + it('erlaubt vorstand beim Speichern', async () => { const event = createEvent({ cookies: { auth_token: 'token' } }) mockSuccessReadBody(baseBody) @@ -187,6 +292,7 @@ describe('Members API Endpoints', () => { email: 'lisa@example.com' }) authUtils.getUserFromToken.mockResolvedValue({ id: '3', role: 'vorstand' }) + authUtils.readUsers.mockResolvedValue([]) memberUtils.saveMember.mockResolvedValue(true) const response = await membersPostHandler(event) diff --git a/tests/notification-scheduler.spec.ts b/tests/notification-scheduler.spec.ts new file mode 100644 index 0000000..f69662a --- /dev/null +++ b/tests/notification-scheduler.spec.ts @@ -0,0 +1,191 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import fs from 'fs/promises' + +vi.mock('../server/utils/auth.js', () => ({ + readUsers: vi.fn(), + isHiddenUser: vi.fn(user => user?.hidden === true || user?.invisible === true || user?.isHidden === true || user?.systemAccount === true) +})) + +vi.mock('../server/utils/members.js', () => ({ + readMembers: vi.fn() +})) + +vi.mock('../server/utils/termine.js', () => ({ + readTermine: vi.fn().mockResolvedValue([]) +})) + +vi.mock('../server/utils/news.js', () => ({ + readNews: vi.fn().mockResolvedValue([]) +})) + +vi.mock('../server/utils/spielplan-data.js', () => ({ + getDefaultSpielplanSeason: vi.fn().mockResolvedValue('25--26'), + readSpielplanData: vi.fn().mockResolvedValue({ data: [] }) +})) + +vi.mock('../server/utils/push-notifications.js', () => ({ + sendPushToUsers: vi.fn().mockResolvedValue({ sent: 1, failed: 0, removed: 0, recipients: 1, tokenCount: 1, skipped: false }) +})) + +vi.mock('../server/utils/logger.js', () => ({ + error: vi.fn(), + info: vi.fn(), + warn: vi.fn() +})) + +const authUtils = await import('../server/utils/auth.js') +const memberUtils = await import('../server/utils/members.js') +const pushUtils = await import('../server/utils/push-notifications.js') +const spielplanUtils = await import('../server/utils/spielplan-data.js') + +const { runNotificationSchedulerTick } = await import('../server/utils/notification-scheduler.js') + +const schedulerNow = new Date('2026-06-14T07:00:00.000Z') +const recipient = { + id: 'recipient', + name: 'Push Empfaenger', + active: true, + notificationSettings: { + birthdays: true, + notificationTime: '09:00' + } +} + +describe('Notification Scheduler', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.spyOn(fs, 'readFile').mockImplementation(async (filePath) => { + if (String(filePath).includes('mannschaften_25--26.csv')) { + return [ + 'Mannschaft,Liga,Staffelleiter,Telefon,Heimspieltag,Spielsystem,Mannschaftsführer,Spieler,Weitere Informationen Link,Letzte Aktualisierung', + 'Erwachsene 1,,,,,,Mannschaftsfuehrer,Max Spieler,,', + 'Erwachsene 2,,,,,,Andere Person,Andere Spieler,,' + ].join('\n') + } + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + }) + vi.spyOn(fs, 'mkdir').mockResolvedValue(undefined) + vi.spyOn(fs, 'writeFile').mockResolvedValue(undefined) + memberUtils.readMembers.mockResolvedValue([]) + authUtils.readUsers.mockResolvedValue([recipient]) + spielplanUtils.getDefaultSpielplanSeason.mockResolvedValue('25--26') + spielplanUtils.readSpielplanData.mockResolvedValue({ data: [] }) + }) + + it('sendet Geburtstags-Push nur fuer Mitglieder mit expliziter Geburtstagsfreigabe', async () => { + memberUtils.readMembers.mockResolvedValue([ + { firstName: 'Erlaubt', lastName: 'Person', active: true, geburtsdatum: '1990-06-14', visibility: { showBirthday: true } }, + { firstName: 'Privat', lastName: 'Person', active: true, geburtsdatum: '1990-06-14', visibility: { showBirthday: false } }, + { firstName: 'Unklar', lastName: 'Person', active: true, geburtsdatum: '1990-06-14' } + ]) + + await runNotificationSchedulerTick(schedulerNow) + + expect(pushUtils.sendPushToUsers).toHaveBeenCalledTimes(1) + expect(pushUtils.sendPushToUsers).toHaveBeenCalledWith(expect.objectContaining({ + title: 'Geburtstage heute', + body: 'Erlaubt Person hat heute Geburtstag.', + data: { type: 'birthdays', date: '2026-06-14' } + })) + }) + + it('nennt bei mehreren Geburtstags-Pushes nur Namen und kein Alter', async () => { + memberUtils.readMembers.mockResolvedValue([ + { firstName: 'Anna', lastName: 'Beispiel', active: true, geburtsdatum: '1980-06-14', visibility: { showBirthday: true } }, + { firstName: 'Bert', lastName: 'Beispiel', active: true, geburtsdatum: '2010-06-14', visibility: { showBirthday: true } } + ]) + + await runNotificationSchedulerTick(schedulerNow) + + const payload = pushUtils.sendPushToUsers.mock.calls[0][0] + expect(payload.body).toBe('Geburtstage heute: Anna Beispiel, Bert Beispiel.') + expect(payload.body).not.toMatch(/\b\d+\b/) + expect(payload.body).not.toContain('Jahre') + }) + + it('sendet Punktspiel-Push nur einmal, wenn alle, eigene und ausgewaehlte Mannschaft dasselbe Spiel treffen', async () => { + const matchUser = { + id: 'match-user', + name: 'Max Spieler', + active: true, + notificationSettings: { + allTeamMatches: true, + ownTeamMatches: true, + selectedTeamSlugs: ['erwachsene-1'], + selectedTeamSeason: '25--26', + notificationTime: '09:00' + } + } + authUtils.readUsers.mockResolvedValue([matchUser]) + spielplanUtils.readSpielplanData.mockResolvedValue({ + data: [{ + Termin: '14.06.2026 20:15', + BegegnungNr: 'spiel-1', + Altersklasse: 'Erwachsene', + HeimVereinName: 'Harheimer TC', + HeimMannschaftAltersklasse: 'Erwachsene', + HeimMannschaftNr: '1', + HeimMannschaft: 'Harheimer TC', + GastVereinName: 'Gastverein', + GastMannschaftAltersklasse: 'Erwachsene', + GastMannschaftNr: '1', + GastMannschaft: 'Gastverein' + }] + }) + + await runNotificationSchedulerTick(schedulerNow) + + expect(pushUtils.sendPushToUsers).toHaveBeenCalledTimes(1) + const payload = pushUtils.sendPushToUsers.mock.calls[0][0] + expect(payload.title).toBe('Punktspiele') + expect(payload.predicate(matchUser, matchUser.notificationSettings)).toBe(true) + expect(payload.bodyForUser(matchUser, matchUser.notificationSettings)).toContain('Harheimer TC - Gastverein') + }) + + it('fasst eigene und ausgewaehlte Punktspiele in einer Benachrichtigung zusammen', async () => { + const matchUser = { + id: 'match-user', + name: 'Max Spieler', + active: true, + notificationSettings: { + allTeamMatches: false, + ownTeamMatches: true, + selectedTeamSlugs: ['erwachsene-2'], + selectedTeamSeason: '25--26', + notificationTime: '09:00' + } + } + authUtils.readUsers.mockResolvedValue([matchUser]) + spielplanUtils.readSpielplanData.mockResolvedValue({ + data: [ + { + Termin: '14.06.2026 20:15', + BegegnungNr: 'spiel-1', + Altersklasse: 'Erwachsene', + HeimVereinName: 'Harheimer TC', + HeimMannschaftAltersklasse: 'Erwachsene', + HeimMannschaftNr: '1', + HeimMannschaft: 'Harheimer TC', + GastMannschaft: 'Gastverein' + }, + { + Termin: '14.06.2026 20:30', + BegegnungNr: 'spiel-2', + Altersklasse: 'Erwachsene', + HeimMannschaft: 'Gastverein II', + GastVereinName: 'Harheimer TC', + GastMannschaftAltersklasse: 'Erwachsene', + GastMannschaftNr: '2', + GastMannschaft: 'Harheimer TC II' + } + ] + }) + + await runNotificationSchedulerTick(schedulerNow) + + expect(pushUtils.sendPushToUsers).toHaveBeenCalledTimes(1) + const payload = pushUtils.sendPushToUsers.mock.calls[0][0] + expect(payload.predicate(matchUser, matchUser.notificationSettings)).toBe(true) + expect(payload.bodyForUser(matchUser, matchUser.notificationSettings)).toBe('2 Punktspiele am 14.06.2026') + }) +}) diff --git a/tests/public-endpoints.spec.ts b/tests/public-endpoints.spec.ts index 60403bd..985a905 100644 --- a/tests/public-endpoints.spec.ts +++ b/tests/public-endpoints.spec.ts @@ -16,8 +16,25 @@ vi.mock('../server/utils/news.js', () => ({ readNews: vi.fn() })) +vi.mock('../server/utils/auth.js', () => ({ + readUsers: vi.fn(), + migrateUserRoles: vi.fn((user) => { + if (!user) return user + if (Array.isArray(user.roles)) return user + if (user.role) { + user.roles = [user.role] + delete user.role + } else { + user.roles = ['mitglied'] + } + return user + }), + isHiddenUser: vi.fn(user => user?.hidden === true || user?.invisible === true || user?.isHidden === true || user?.systemAccount === true || user?.accountType === 'playstore_review') +})) + const nodemailer = await import('nodemailer') const newsUtils = await import('../server/utils/news.js') +const authUtils = await import('../server/utils/auth.js') import contactHandler from '../server/api/contact.post.js' import galerieHandler from '../server/api/galerie.get.js' @@ -29,14 +46,17 @@ describe('Öffentliche API-Endpunkte', () => { afterEach(() => { delete process.env.NODE_ENV delete process.env.APP_ENV + delete process.env.DEBUG }) beforeEach(() => { // Setze SMTP-Credentials für Tests process.env.SMTP_USER = 'test@example.com' process.env.SMTP_PASS = 'test-password' + authUtils.readUsers.mockResolvedValue([]) vi.restoreAllMocks() vi.clearAllMocks() + authUtils.readUsers.mockResolvedValue([]) }) describe('POST /api/contact', () => { @@ -78,6 +98,53 @@ describe('Öffentliche API-Endpunkte', () => { to: 'tsschulz@tsschulz.de' })) }) + + it('sendet bei DEBUG=FALSE an konfigurierte Empfänger', async () => { + process.env.NODE_ENV = 'production' + process.env.APP_ENV = 'test' + process.env.DEBUG = 'FALSE' + vi.spyOn(fs, 'readFile').mockResolvedValue(JSON.stringify({ + vorstand: { + vorsitzender: { email: 'vorstand@example.com' } + } + })) + + const event = createEvent() + mockSuccessReadBody({ name: 'Max', email: 'max@example.com', subject: 'Frage', message: 'Hallo' }) + + await contactHandler(event) + + const transporter = nodemailer.default.createTransport.mock.results[0].value + const to = transporter.sendMail.mock.calls[0][0].to + expect(to).toContain('vorstand@example.com') + expect(to).not.toContain('tsschulz@tsschulz.de') + }) + + it('bevorzugt aktive Vorstand-Benutzer vor config.json', async () => { + process.env.NODE_ENV = 'production' + process.env.DEBUG = 'FALSE' + authUtils.readUsers.mockResolvedValue([ + { id: '1', email: 'rolle-vorstand@example.com', roles: ['vorstand'], active: true }, + { id: '2', email: 'hidden@example.com', roles: ['vorstand'], active: true, hidden: true }, + { id: '3', email: 'trainer@example.com', roles: ['trainer'], active: true } + ]) + vi.spyOn(fs, 'readFile').mockResolvedValue(JSON.stringify({ + vorstand: { + vorsitzender: { email: 'config-vorstand@example.com' } + } + })) + + const event = createEvent() + mockSuccessReadBody({ name: 'Max', email: 'max@example.com', subject: 'Frage', message: 'Hallo' }) + + await contactHandler(event) + + const transporter = nodemailer.default.createTransport.mock.results[0].value + const to = transporter.sendMail.mock.calls[0][0].to + expect(to).toContain('rolle-vorstand@example.com') + expect(to).not.toContain('config-vorstand@example.com') + expect(to).not.toContain('hidden@example.com') + }) }) describe('GET /api/galerie', () => {