Merge pull request 'dev' (#42) from dev into main
Reviewed-on: #42
This commit was merged in pull request #42.
This commit is contained in:
7
.gitleaks.toml
Normal file
7
.gitleaks.toml
Normal 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$''',
|
||||
]
|
||||
@@ -88,6 +88,13 @@ android {
|
||||
versionName = androidVersionName
|
||||
}
|
||||
|
||||
lint {
|
||||
disable += setOf(
|
||||
"AutoboxingStateCreation",
|
||||
"MutableCollectionMutableState",
|
||||
)
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
create("release") {
|
||||
if (hasReleaseSigning) {
|
||||
|
||||
Binary file not shown.
@@ -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>>)
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -51,8 +51,12 @@ object HarheimerNotifications {
|
||||
.setContentIntent(createContentIntent(context, notificationId, data))
|
||||
.setAutoCancel(true)
|
||||
.build()
|
||||
return try {
|
||||
NotificationManagerCompat.from(context).notify(notificationId, notification)
|
||||
return true
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CmsPage(navController, showBackNavigation, "Sportbetrieb", "Trainingszeiten, Trainingsort und Trainer pflegen") {
|
||||
when {
|
||||
state.loading || config == null -> item { LoadingState("Sportbetriebsdaten werden geladen...") }
|
||||
else -> {
|
||||
if (!state.sportLoading) {
|
||||
when (activeTab) {
|
||||
"termine" -> {
|
||||
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,
|
||||
onClick = { openTerminDialog(null) },
|
||||
enabled = !state.sportSaving,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(if (state.saving) "Speichert..." else "Speichern")
|
||||
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 {
|
||||
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))
|
||||
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 {
|
||||
DataCard("Trainingszeiten") {
|
||||
trainingTimes.forEachIndexed { index, zeit ->
|
||||
TrainingTimeEditorCard(
|
||||
zeit = zeit,
|
||||
onChange = { updated -> trainingTimes[index] = updated },
|
||||
onRemove = { trainingTimes.removeAt(index) },
|
||||
)
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
|
||||
Button(
|
||||
onClick = {
|
||||
trainingTimes.add(
|
||||
de.harheimertc.data.TrainingTimeDto(
|
||||
id = "training-${System.currentTimeMillis()}",
|
||||
tag = "Montag",
|
||||
),
|
||||
)
|
||||
mannschaften.add(CmsMannschaftRow(letzteAktualisierung = OffsetDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE)))
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Text("Trainingszeit hinzufügen")
|
||||
}
|
||||
}
|
||||
}
|
||||
item {
|
||||
DataCard("Trainer") {
|
||||
trainers.forEachIndexed { index, trainer ->
|
||||
TrainerEditorCard(
|
||||
trainer = trainer,
|
||||
onChange = { updated -> trainers[index] = updated },
|
||||
onRemove = { trainers.removeAt(index) },
|
||||
)
|
||||
Text("Hinzufügen")
|
||||
}
|
||||
Button(
|
||||
onClick = {
|
||||
trainers.add(
|
||||
de.harheimertc.data.TrainerDto(
|
||||
id = "trainer-${System.currentTimeMillis()}",
|
||||
),
|
||||
)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
onClick = { viewModel.saveSportMannschaften(state.sportMannschaftenSeason, mannschaften.toList()) },
|
||||
enabled = !state.sportSaving,
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Text("Trainer hinzufügen")
|
||||
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),
|
||||
|
||||
@@ -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 -> ""
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 = "",
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
671
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
52
scripts/verify-no-public-writes.js
Normal file
52
scripts/verify-no-public-writes.js
Normal 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.')
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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' })
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => {
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
38
server/plugins/notification-scheduler.js
Normal file
38
server/plugins/notification-scheduler.js
Normal 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
|
||||
})
|
||||
})
|
||||
@@ -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.')
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
|
||||
451
server/utils/notification-scheduler.js
Normal file
451
server/utils/notification-scheduler.js
Normal 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 }
|
||||
}
|
||||
@@ -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
116
tests/email-service.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
191
tests/notification-scheduler.spec.ts
Normal file
191
tests/notification-scheduler.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user