Merge pull request 'dev' (#42) from dev into main
All checks were successful
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Successful in 2m15s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped

Reviewed-on: #42
This commit was merged in pull request #42.
This commit is contained in:
2026-06-16 14:09:23 +02:00
49 changed files with 2306 additions and 1631 deletions

7
.gitleaks.toml Normal file
View File

@@ -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$''',
]

View File

@@ -88,6 +88,13 @@ android {
versionName = androidVersionName
}
lint {
disable += setOf(
"AutoboxingStateCreation",
"MutableCollectionMutableState",
)
}
signingConfigs {
create("release") {
if (hasReleaseSigning) {

View File

@@ -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<TerminDto> = emptyList())
data class TermineManageResponse(val success: Boolean = true, val termine: List<TerminDto> = 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<String> = emptyList(),
)
data class MembersResponse(
@@ -590,6 +592,21 @@ interface ApiService {
@GET("/api/termine")
suspend fun termine(): Response<TermineResponse>
@GET("/api/termine-manage")
suspend fun termineManage(): Response<TermineManageResponse>
@POST("/api/termine-manage")
suspend fun saveTermin(@Body request: TerminDto): Response<AuthMessageResponse>
@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<AuthMessageResponse>
@GET("/api/spielplan")
suspend fun spielplan(@Query("season") season: String? = null): Response<SpielplanResponse>
@@ -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<Map<String, String>>)

View File

@@ -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())

View File

@@ -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<String, String>): PendingIntent {
@@ -72,7 +76,12 @@ object HarheimerNotifications {
}
private fun destinationRoute(data: Map<String, String>): 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
}
}

View File

@@ -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<List<TerminDto>> = runCatching {
val response = api.termineManage()
if (!response.isSuccessful) error("Termine konnten nicht geladen werden.")
response.body()?.termine.orEmpty()
}
suspend fun saveTermin(request: TerminDto): Result<de.harheimertc.data.AuthMessageResponse> = 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<de.harheimertc.data.AuthMessageResponse> = 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<List<CmsMannschaftRow>> = 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<de.harheimertc.data.MannschaftenSeasonsResponse> = 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<CmsMannschaftRow>): Result<SaveCsvResponse> = 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<SpielplanResponse> = 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<String>, rows: List<List<String>>): Result<SaveCsvResponse> = 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<CmsUsersResponse> =
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<CmsMannschaftRow>.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<String>.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<List<String>> =
csv.lineSequence().filter(String::isNotBlank).map(::parseCsvLine).toList()

View File

@@ -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<MenuT
add(MenuTarget("Startseite", Destinations.CmsStartseite.route))
add(MenuTarget("Inhalte", Destinations.CmsInhalte.route))
add(MenuTarget("Vereinsmeisterschaften", Destinations.CmsVereinsmeisterschaften.route))
add(MenuTarget("News", Destinations.MemberNews.route))
add(MenuTarget("News", Destinations.CmsNews.route))
add(MenuTarget("Sportbetrieb", Destinations.CmsSportbetrieb.route))
add(MenuTarget("Mitgliederverwaltung", Destinations.CmsMitgliederverwaltung.route))
add(MenuTarget("Einstellungen", Destinations.CmsEinstellungen.route))

View File

@@ -48,6 +48,7 @@ sealed class Destinations(val route: String) {
object CmsStartseite : Destinations("cms/startseite")
object CmsInhalte : Destinations("cms/inhalte")
object CmsVereinsmeisterschaften : Destinations("cms/vereinsmeisterschaften")
object CmsNews : Destinations("cms/news")
object CmsSportbetrieb : Destinations("cms/sportbetrieb")
object CmsMitgliederverwaltung : Destinations("cms/mitgliederverwaltung")
object CmsNewsletter : Destinations("cms/newsletter")

View File

@@ -335,6 +335,9 @@ fun NavGraph(
composable(Destinations.CmsVereinsmeisterschaften.route) {
de.harheimertc.ui.screens.cms.CmsVereinsmeisterschaftenScreen(navController, !persistentNavigation)
}
composable(Destinations.CmsNews.route) {
de.harheimertc.ui.screens.cms.CmsNewsScreen(navController, !persistentNavigation)
}
composable(Destinations.CmsSportbetrieb.route) {
de.harheimertc.ui.screens.cms.CmsSportbetriebScreen(navController, !persistentNavigation)
}

View File

@@ -1,8 +1,10 @@
package de.harheimertc.ui.screens.cms
import android.content.Intent
import android.app.DatePickerDialog
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.Arrangement
@@ -36,6 +38,8 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Checkbox
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedButton
import androidx.navigation.NavController
@@ -48,10 +52,12 @@ import de.harheimertc.data.NewsletterGroupDto
import de.harheimertc.data.PasswordResetMatchingUserDto
import de.harheimertc.data.PasswordResetAttemptDto
import de.harheimertc.data.PasswordResetStepDto
import de.harheimertc.data.TerminDto
import de.harheimertc.ui.components.FormMessages
import de.harheimertc.ui.components.LoadingState
import de.harheimertc.ui.components.NativeRichTextEditor
import de.harheimertc.ui.navigation.Destinations
import de.harheimertc.repositories.CmsMannschaftRow
import de.harheimertc.repositories.MeisterschaftResult
import de.harheimertc.ui.theme.Accent100
import de.harheimertc.ui.theme.Accent500
@@ -61,6 +67,7 @@ import de.harheimertc.ui.theme.Primary100
import de.harheimertc.ui.theme.Primary600
import java.time.OffsetDateTime
import java.time.format.DateTimeFormatter
import java.util.Calendar
import java.util.Locale
@Composable
@@ -433,116 +440,362 @@ fun CmsVereinsmeisterschaftenScreen(navController: NavController, showBackNaviga
@Composable
fun CmsSportbetriebScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) {
val state by viewModel.state.collectAsState()
val config = state.config
var ortName by remember { mutableStateOf("") }
var ortStrasse by remember { mutableStateOf("") }
var ortPlz by remember { mutableStateOf("") }
var ortOrt by remember { mutableStateOf("") }
val trainingTimes = remember { mutableStateListOf<de.harheimertc.data.TrainingTimeDto>() }
val trainers = remember { mutableStateListOf<de.harheimertc.data.TrainerDto>() }
val context = LocalContext.current
var activeTab by remember { mutableStateOf("termine") }
val mannschaften = remember { mutableStateListOf<CmsMannschaftRow>() }
var spielplanCsv by remember { mutableStateOf("") }
var spielplanEditorOpen by remember { mutableStateOf(false) }
var terminDialogOpen by remember { mutableStateOf(false) }
var editingTermin by remember { mutableStateOf<TerminDto?>(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<String>, rows: List<List<String>>): String {
if (headers.isEmpty()) return ""
return listOf(headers).plus(rows).joinToString("\n") { row -> row.joinToString(";") { it.csvCell(";") } }
}
private fun parseSportCsvText(text: String): Pair<List<String>, List<List<String>>> {
val lines = text.lineSequence().filter { it.isNotBlank() }.toList()
if (lines.isEmpty()) return emptyList<String>() 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<String> {
val values = mutableListOf<String>()
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<de.harheimertc.data.TrainingTimeDto>() }
val trainers = remember { mutableStateListOf<de.harheimertc.data.TrainerDto>() }
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),

View File

@@ -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<NewsDto> = emptyList(),
val meisterschaften: List<MeisterschaftResult> = emptyList(),
val sportLoading: Boolean = false,
val sportSaving: Boolean = false,
val sportTermine: List<TerminDto> = emptyList(),
val sportMannschaften: List<CmsMannschaftRow> = emptyList(),
val sportMannschaftenSeasons: List<String> = emptyList(),
val sportMannschaftenSeason: String = "",
val sportSpielplanHeaders: List<String> = emptyList(),
val sportSpielplanRows: List<List<String>> = emptyList(),
val sportSpielplanSeason: String = "",
val sportSpielplanSeasons: List<SeasonDto> = 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<CmsMannschaftRow>) {
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<String>, rows: List<List<String>>) {
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 -> ""
}

View File

@@ -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

View File

@@ -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(

View File

@@ -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<Mannschaft>, 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(

View File

@@ -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<Mannschaft> = emptyList(),
val ownTeams: List<Mannschaft> = emptyList(),
val currentUserName: String = "",
val seasons: List<String> = 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<NotificationSettingsUiState> = _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<String>, syncRemote: Boolean = false) {
private suspend fun loadTeams(settings: NotificationPreferences, seasons: List<String>, 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<Mannschaft>): List<Mannschaft> {
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()

View File

@@ -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 = "",

View File

@@ -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<de.harheimertc.data.ConnectivityMonitor>()
every { connectivity.online } returns MutableStateFlow(true)
return CmsViewModel(repo, connectivity)
}
@Test
fun load_populatesState() = runTest {
val repo = mockk<de.harheimertc.repositories.CmsRepository>()
@@ -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")

File diff suppressed because one or more lines are too long

View File

@@ -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

View File

@@ -550,6 +550,25 @@
</label>
</div>
<div class="flex items-center">
<input
id="showBirthday"
v-model="formData.showBirthday"
type="checkbox"
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
:disabled="isSaving || !canDisableBirthdayVisibility"
>
<label
for="showBirthday"
class="ml-2 block text-sm font-medium text-gray-700"
>
Geburtstag in Mitgliederliste und Benachrichtigungen anzeigen
</label>
</div>
<p class="-mt-3 text-xs text-gray-500">
Admins und Vorstand können die Sichtbarkeit nur ausschalten. Einschalten kann nur das Mitglied selbst im Profil.
</p>
<div
v-if="errorMessage"
class="flex items-center p-3 rounded-md bg-red-50 text-red-700 text-sm"
@@ -846,7 +865,8 @@ const formData = ref({
address: '',
notes: '',
isMannschaftsspieler: false,
hasHallKey: false
hasHallKey: false,
showBirthday: false
})
const canEdit = computed(() => {
@@ -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()

View File

@@ -58,7 +58,7 @@
<p class="text-sm font-medium text-green-800">
{{ currentFile.name }}
</p><p class="text-xs text-green-600">
{{ currentFile.size }} bytes
{{ currentFileLabel }}
</p>
</div>
</div>
@@ -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 */ }
})()
})

671
package-lock.json generated
View File

@@ -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",

View File

@@ -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"
}
}

View File

@@ -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',
})
</script>

View File

@@ -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.')

View File

@@ -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: `
<h2>Neue Registrierung (Passkey)</h2>
<p>Ein neuer Benutzer hat sich registriert und wartet auf Freigabe:</p>
<ul>
<li><strong>Name:</strong> ${name}</li>
<li><strong>E-Mail:</strong> ${email}</li>
<li><strong>Telefon:</strong> ${phone || 'Nicht angegeben'}</li>
<li><strong>Login:</strong> Passkey${password ? ' + Passwort (Fallback)' : ' (ohne Passwort)'}</li>
</ul>
<p>Bitte prüfen Sie die Registrierung im CMS.</p>
`
})
await transporter.sendMail({
from: process.env.SMTP_FROM || 'noreply@harheimertc.de',
to: email,
subject: 'Registrierung erhalten - Harheimer TC',
html: `
<h2>Registrierung erhalten</h2>
<p>Hallo ${name},</p>
<p>vielen Dank für Ihre Registrierung beim Harheimer TC!</p>
<p>Ihre Anfrage wird vom Vorstand geprüft. Sie erhalten eine E-Mail, sobald Ihr Zugang freigeschaltet wurde.</p>
<br>
<p>Mit sportlichen Grüßen,<br>Ihr Harheimer TC</p>
`
})
}
await sendRegistrationNotification({ name, email, phone })
} catch (emailError) {
console.error('E-Mail-Versand fehlgeschlagen:', emailError)
}

View File

@@ -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
}
})

View File

@@ -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' })
}

View File

@@ -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)

View File

@@ -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,

View File

@@ -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) => {
})
}
})

View File

@@ -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) {

View File

@@ -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
}
})

View File

@@ -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
}
})

View File

@@ -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
}
})

View File

@@ -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
})
})

View File

@@ -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.')
}

View File

@@ -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();

View File

@@ -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();

View File

@@ -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) {

View File

@@ -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<string>} Email addresses
*/
function getEmailRecipients(data, config) {
const isProduction = process.env.NODE_ENV === 'production' && process.env.APP_ENV !== 'test'
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 []
}
}
if (!isProduction) {
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()

View File

@@ -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 }
}

View File

@@ -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'
})
}

116
tests/email-service.spec.ts Normal file
View File

@@ -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')
})
})

View File

@@ -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)

View File

@@ -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')
})
})

View File

@@ -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', () => {