dev #42
@@ -72,8 +72,8 @@ object HarheimerNotifications {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun destinationRoute(data: Map<String, String>): String = when (data["type"]) {
|
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", "news_expiring" -> Destinations.Termine.route
|
"event", "events_today", "events_tomorrow" -> Destinations.Termine.route
|
||||||
"team_matches" -> Destinations.Spielplan.route
|
"team_matches" -> Destinations.Spielplan.route
|
||||||
"birthdays" -> Destinations.MemberArea.route
|
"birthdays" -> Destinations.MemberArea.route
|
||||||
"contact_request" -> Destinations.CmsContactRequests.route
|
"contact_request" -> Destinations.CmsContactRequests.route
|
||||||
|
|||||||
@@ -422,6 +422,7 @@ private fun menuSection(route: String?): MenuSection? = when (route) {
|
|||||||
Destinations.CmsStartseite.route,
|
Destinations.CmsStartseite.route,
|
||||||
Destinations.CmsInhalte.route,
|
Destinations.CmsInhalte.route,
|
||||||
Destinations.CmsVereinsmeisterschaften.route,
|
Destinations.CmsVereinsmeisterschaften.route,
|
||||||
|
Destinations.CmsNews.route,
|
||||||
Destinations.CmsSportbetrieb.route,
|
Destinations.CmsSportbetrieb.route,
|
||||||
Destinations.CmsMitgliederverwaltung.route,
|
Destinations.CmsMitgliederverwaltung.route,
|
||||||
Destinations.CmsNewsletter.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("Startseite", Destinations.CmsStartseite.route))
|
||||||
add(MenuTarget("Inhalte", Destinations.CmsInhalte.route))
|
add(MenuTarget("Inhalte", Destinations.CmsInhalte.route))
|
||||||
add(MenuTarget("Vereinsmeisterschaften", Destinations.CmsVereinsmeisterschaften.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("Sportbetrieb", Destinations.CmsSportbetrieb.route))
|
||||||
add(MenuTarget("Mitgliederverwaltung", Destinations.CmsMitgliederverwaltung.route))
|
add(MenuTarget("Mitgliederverwaltung", Destinations.CmsMitgliederverwaltung.route))
|
||||||
add(MenuTarget("Einstellungen", Destinations.CmsEinstellungen.route))
|
add(MenuTarget("Einstellungen", Destinations.CmsEinstellungen.route))
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ sealed class Destinations(val route: String) {
|
|||||||
object CmsStartseite : Destinations("cms/startseite")
|
object CmsStartseite : Destinations("cms/startseite")
|
||||||
object CmsInhalte : Destinations("cms/inhalte")
|
object CmsInhalte : Destinations("cms/inhalte")
|
||||||
object CmsVereinsmeisterschaften : Destinations("cms/vereinsmeisterschaften")
|
object CmsVereinsmeisterschaften : Destinations("cms/vereinsmeisterschaften")
|
||||||
|
object CmsNews : Destinations("cms/news")
|
||||||
object CmsSportbetrieb : Destinations("cms/sportbetrieb")
|
object CmsSportbetrieb : Destinations("cms/sportbetrieb")
|
||||||
object CmsMitgliederverwaltung : Destinations("cms/mitgliederverwaltung")
|
object CmsMitgliederverwaltung : Destinations("cms/mitgliederverwaltung")
|
||||||
object CmsNewsletter : Destinations("cms/newsletter")
|
object CmsNewsletter : Destinations("cms/newsletter")
|
||||||
|
|||||||
@@ -335,6 +335,9 @@ fun NavGraph(
|
|||||||
composable(Destinations.CmsVereinsmeisterschaften.route) {
|
composable(Destinations.CmsVereinsmeisterschaften.route) {
|
||||||
de.harheimertc.ui.screens.cms.CmsVereinsmeisterschaftenScreen(navController, !persistentNavigation)
|
de.harheimertc.ui.screens.cms.CmsVereinsmeisterschaftenScreen(navController, !persistentNavigation)
|
||||||
}
|
}
|
||||||
|
composable(Destinations.CmsNews.route) {
|
||||||
|
de.harheimertc.ui.screens.cms.CmsNewsScreen(navController, !persistentNavigation)
|
||||||
|
}
|
||||||
composable(Destinations.CmsSportbetrieb.route) {
|
composable(Destinations.CmsSportbetrieb.route) {
|
||||||
de.harheimertc.ui.screens.cms.CmsSportbetriebScreen(navController, !persistentNavigation)
|
de.harheimertc.ui.screens.cms.CmsSportbetriebScreen(navController, !persistentNavigation)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -432,114 +432,53 @@ fun CmsVereinsmeisterschaftenScreen(navController: NavController, showBackNaviga
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun CmsSportbetriebScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) {
|
fun CmsSportbetriebScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) {
|
||||||
val state by viewModel.state.collectAsState()
|
var activeTab by remember { mutableStateOf("termine") }
|
||||||
val config = state.config
|
val tabs = listOf(
|
||||||
var ortName by remember { mutableStateOf("") }
|
"termine" to "Termine",
|
||||||
var ortStrasse by remember { mutableStateOf("") }
|
"mannschaften" to "Mannschaften",
|
||||||
var ortPlz by remember { mutableStateOf("") }
|
"spielplaene" to "Spielpläne",
|
||||||
var ortOrt by remember { mutableStateOf("") }
|
)
|
||||||
val trainingTimes = remember { mutableStateListOf<de.harheimertc.data.TrainingTimeDto>() }
|
|
||||||
val trainers = remember { mutableStateListOf<de.harheimertc.data.TrainerDto>() }
|
|
||||||
|
|
||||||
LaunchedEffect(config) {
|
CmsPage(navController, showBackNavigation, "Sportbetrieb", "Termine, Mannschaften und Spielpläne pflegen") {
|
||||||
config?.let {
|
item {
|
||||||
ortName = it.training.ort.name
|
Row(
|
||||||
ortStrasse = it.training.ort.strasse
|
modifier = Modifier.fillMaxWidth(),
|
||||||
ortPlz = it.training.ort.plz
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
ortOrt = it.training.ort.ort
|
) {
|
||||||
trainingTimes.clear()
|
tabs.forEach { (id, label) ->
|
||||||
trainingTimes.addAll(it.training.zeiten)
|
if (activeTab == id) {
|
||||||
trainers.clear()
|
Button(onClick = { activeTab = id }, modifier = Modifier.weight(1f)) { Text(label) }
|
||||||
trainers.addAll(it.trainer)
|
} else {
|
||||||
|
OutlinedButton(onClick = { activeTab = id }, modifier = Modifier.weight(1f)) { Text(label) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
CmsPage(navController, showBackNavigation, "Sportbetrieb", "Trainingszeiten, Trainingsort und Trainer pflegen") {
|
when (activeTab) {
|
||||||
when {
|
"termine" -> item {
|
||||||
state.loading || config == null -> item { LoadingState("Sportbetriebsdaten werden geladen...") }
|
DataCard("Termine verwalten") {
|
||||||
else -> {
|
Text("Termine werden im CMS gepflegt und erscheinen anschließend auf der Terminseite.", color = Accent700)
|
||||||
item {
|
Button(onClick = { navController.navigate(Destinations.Termine.route) }, modifier = Modifier.fillMaxWidth()) {
|
||||||
Button(
|
Text("Termine anzeigen")
|
||||||
onClick = {
|
|
||||||
viewModel.saveConfig(
|
|
||||||
config.copy(
|
|
||||||
training = config.training.copy(
|
|
||||||
ort = config.training.ort.copy(
|
|
||||||
name = ortName,
|
|
||||||
strasse = ortStrasse,
|
|
||||||
plz = ortPlz,
|
|
||||||
ort = ortOrt,
|
|
||||||
),
|
|
||||||
zeiten = trainingTimes.toList(),
|
|
||||||
),
|
|
||||||
trainer = trainers.toList(),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
enabled = !state.saving,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
) {
|
|
||||||
Text(if (state.saving) "Speichert..." else "Speichern")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
item {
|
}
|
||||||
DataCard("Trainingsort") {
|
"mannschaften" -> item {
|
||||||
OutlinedTextField(value = ortName, onValueChange = { ortName = it }, label = { Text("Name") }, modifier = Modifier.fillMaxWidth())
|
DataCard("Mannschaften verwalten") {
|
||||||
OutlinedTextField(value = ortStrasse, onValueChange = { ortStrasse = it }, label = { Text("Straße") }, modifier = Modifier.fillMaxWidth())
|
Text("Mannschaftsdaten und Saisons entsprechen dem CMS-Bereich der Web-Oberfläche.", color = Accent700)
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) {
|
Button(onClick = { navController.navigate(Destinations.Mannschaften.route) }, modifier = Modifier.fillMaxWidth()) {
|
||||||
OutlinedTextField(value = ortPlz, onValueChange = { ortPlz = it }, label = { Text("PLZ") }, modifier = Modifier.weight(1f))
|
Text("Mannschaften öffnen")
|
||||||
OutlinedTextField(value = ortOrt, onValueChange = { ortOrt = it }, label = { Text("Ort") }, modifier = Modifier.weight(1f))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
item {
|
}
|
||||||
DataCard("Trainingszeiten") {
|
"spielplaene" -> item {
|
||||||
trainingTimes.forEachIndexed { index, zeit ->
|
DataCard("Spielpläne") {
|
||||||
TrainingTimeEditorCard(
|
Text("Spielpläne und Ergebnisse werden aus den importierten Spielplandaten angezeigt.", color = Accent700)
|
||||||
zeit = zeit,
|
Button(onClick = { navController.navigate(Destinations.Spielplan.route) }, modifier = Modifier.fillMaxWidth()) {
|
||||||
onChange = { updated -> trainingTimes[index] = updated },
|
Text("Spielpläne öffnen")
|
||||||
onRemove = { trainingTimes.removeAt(index) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
trainingTimes.add(
|
|
||||||
de.harheimertc.data.TrainingTimeDto(
|
|
||||||
id = "training-${System.currentTimeMillis()}",
|
|
||||||
tag = "Montag",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
) {
|
|
||||||
Text("Trainingszeit hinzufügen")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
item {
|
|
||||||
DataCard("Trainer") {
|
|
||||||
trainers.forEachIndexed { index, trainer ->
|
|
||||||
TrainerEditorCard(
|
|
||||||
trainer = trainer,
|
|
||||||
onChange = { updated -> trainers[index] = updated },
|
|
||||||
onRemove = { trainers.removeAt(index) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
trainers.add(
|
|
||||||
de.harheimertc.data.TrainerDto(
|
|
||||||
id = "trainer-${System.currentTimeMillis()}",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
) {
|
|
||||||
Text("Trainer hinzufügen")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
item { FormMessages(state.error, state.message) }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -950,6 +889,7 @@ private fun CmsSummaryGrid(navController: NavController, state: CmsUiState) {
|
|||||||
val cards = listOf(
|
val cards = listOf(
|
||||||
Triple("Startseite", "Öffentliche Startseite", Destinations.CmsStartseite.route),
|
Triple("Startseite", "Öffentliche Startseite", Destinations.CmsStartseite.route),
|
||||||
Triple("Inhalte", "Vereinsseiten", Destinations.CmsInhalte.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("Sportbetrieb", "Training und Sportdaten", Destinations.CmsSportbetrieb.route),
|
||||||
Triple("Mitgliederverwaltung", "${state.users.size} Benutzer", Destinations.CmsMitgliederverwaltung.route),
|
Triple("Mitgliederverwaltung", "${state.users.size} Benutzer", Destinations.CmsMitgliederverwaltung.route),
|
||||||
Triple("Kontaktanfragen", "${state.contactRequests.size} Anfragen", Destinations.CmsContactRequests.route),
|
Triple("Kontaktanfragen", "${state.contactRequests.size} Anfragen", Destinations.CmsContactRequests.route),
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ fun NotificationSettingsScreen(
|
|||||||
ToggleRow("Punktspiele der eigenen Mannschaft", state.settings.ownTeamMatches) {
|
ToggleRow("Punktspiele der eigenen Mannschaft", state.settings.ownTeamMatches) {
|
||||||
viewModel.update(state.settings.copy(ownTeamMatches = it))
|
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) {
|
ToggleRow("Punktspiele aller Mannschaften", state.settings.allTeamMatches) {
|
||||||
viewModel.update(state.settings.copy(allTeamMatches = it))
|
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
|
@Composable
|
||||||
private fun TimeSelection(selectedTime: String, onSelectTime: (String) -> Unit) {
|
private fun TimeSelection(selectedTime: String, onSelectTime: (String) -> Unit) {
|
||||||
Row(
|
Row(
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import de.harheimertc.repositories.Mannschaft
|
import de.harheimertc.repositories.Mannschaft
|
||||||
|
import de.harheimertc.repositories.LoginRepository
|
||||||
import de.harheimertc.repositories.MannschaftenRepository
|
import de.harheimertc.repositories.MannschaftenRepository
|
||||||
import de.harheimertc.repositories.NotificationPreferences
|
import de.harheimertc.repositories.NotificationPreferences
|
||||||
import de.harheimertc.repositories.NotificationPreferencesRepository
|
import de.harheimertc.repositories.NotificationPreferencesRepository
|
||||||
@@ -16,6 +17,8 @@ data class NotificationSettingsUiState(
|
|||||||
val loading: Boolean = true,
|
val loading: Boolean = true,
|
||||||
val settings: NotificationPreferences = NotificationPreferences(),
|
val settings: NotificationPreferences = NotificationPreferences(),
|
||||||
val teams: List<Mannschaft> = emptyList(),
|
val teams: List<Mannschaft> = emptyList(),
|
||||||
|
val ownTeams: List<Mannschaft> = emptyList(),
|
||||||
|
val currentUserName: String = "",
|
||||||
val seasons: List<String> = emptyList(),
|
val seasons: List<String> = emptyList(),
|
||||||
val error: String? = null,
|
val error: String? = null,
|
||||||
val saveError: String? = null,
|
val saveError: String? = null,
|
||||||
@@ -25,6 +28,7 @@ data class NotificationSettingsUiState(
|
|||||||
class NotificationSettingsViewModel @Inject constructor(
|
class NotificationSettingsViewModel @Inject constructor(
|
||||||
private val preferencesRepository: NotificationPreferencesRepository,
|
private val preferencesRepository: NotificationPreferencesRepository,
|
||||||
private val mannschaftenRepository: MannschaftenRepository,
|
private val mannschaftenRepository: MannschaftenRepository,
|
||||||
|
private val loginRepository: LoginRepository,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
private val _state = MutableStateFlow(NotificationSettingsUiState())
|
private val _state = MutableStateFlow(NotificationSettingsUiState())
|
||||||
val state: StateFlow<NotificationSettingsUiState> = _state
|
val state: StateFlow<NotificationSettingsUiState> = _state
|
||||||
@@ -37,12 +41,14 @@ class NotificationSettingsViewModel @Inject constructor(
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_state.value = _state.value.copy(loading = true, error = null, saveError = null)
|
_state.value = _state.value.copy(loading = true, error = null, saveError = null)
|
||||||
val storedSettings = preferencesRepository.loadRemote().getOrElse { preferencesRepository.loadLocal() }
|
val storedSettings = preferencesRepository.loadRemote().getOrElse { preferencesRepository.loadLocal() }
|
||||||
|
val authStatus = loginRepository.status().getOrNull()
|
||||||
|
val currentUserName = authStatus?.user?.name.orEmpty()
|
||||||
val seasonsResponse = mannschaftenRepository.fetchSeasons().getOrNull()
|
val seasonsResponse = mannschaftenRepository.fetchSeasons().getOrNull()
|
||||||
val seasons = seasonsResponse?.seasons.orEmpty()
|
val seasons = seasonsResponse?.seasons.orEmpty()
|
||||||
val selectedSeason = storedSettings.selectedTeamSeason
|
val selectedSeason = storedSettings.selectedTeamSeason
|
||||||
?: seasonsResponse?.defaultSeason?.takeIf { it.isNotBlank() }
|
?: seasonsResponse?.defaultSeason?.takeIf { it.isNotBlank() }
|
||||||
?: seasons.firstOrNull()
|
?: 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
|
val current = _state.value.settings
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_state.value = _state.value.copy(loading = true, error = null, saveError = null)
|
_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))
|
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)
|
mannschaftenRepository.fetchMannschaften(settings.selectedTeamSeason)
|
||||||
.onSuccess { teams ->
|
.onSuccess { teams ->
|
||||||
val knownSlugs = teams.map { it.slug }.toSet()
|
val knownSlugs = teams.map { it.slug }.toSet()
|
||||||
@@ -89,6 +95,8 @@ class NotificationSettingsViewModel @Inject constructor(
|
|||||||
loading = false,
|
loading = false,
|
||||||
settings = nextSettings,
|
settings = nextSettings,
|
||||||
teams = teams,
|
teams = teams,
|
||||||
|
ownTeams = ownTeamsForUser(currentUserName, teams),
|
||||||
|
currentUserName = currentUserName,
|
||||||
seasons = seasons,
|
seasons = seasons,
|
||||||
saveError = saveError,
|
saveError = saveError,
|
||||||
)
|
)
|
||||||
@@ -101,6 +109,7 @@ class NotificationSettingsViewModel @Inject constructor(
|
|||||||
_state.value = NotificationSettingsUiState(
|
_state.value = NotificationSettingsUiState(
|
||||||
loading = false,
|
loading = false,
|
||||||
settings = settings,
|
settings = settings,
|
||||||
|
currentUserName = currentUserName,
|
||||||
seasons = seasons,
|
seasons = seasons,
|
||||||
error = error.message ?: "Mannschaften konnten nicht geladen werden.",
|
error = error.message ?: "Mannschaften konnten nicht geladen werden.",
|
||||||
saveError = saveError,
|
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()
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { promises as fs } from 'fs'
|
|||||||
import path from 'path'
|
import path from 'path'
|
||||||
|
|
||||||
const DEFAULT_MAX_BACKUPS = Number.parseInt(process.env.DATA_FILE_BACKUP_MAX || '40', 10)
|
const DEFAULT_MAX_BACKUPS = Number.parseInt(process.env.DATA_FILE_BACKUP_MAX || '40', 10)
|
||||||
|
let backupSequence = 0
|
||||||
|
|
||||||
function getProjectRoot() {
|
function getProjectRoot() {
|
||||||
const cwd = process.cwd()
|
const cwd = process.cwd()
|
||||||
@@ -30,8 +31,9 @@ function sanitizeFileKey(filePath) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildBackupName(date = new Date()) {
|
function buildBackupName(date = new Date()) {
|
||||||
|
const sequence = (backupSequence++).toString(36).padStart(6, '0')
|
||||||
const randomSuffix = Math.random().toString(36).slice(2, 8)
|
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) {
|
export function resolveDataFileBackupPath(backupDir, backupName) {
|
||||||
|
|||||||
@@ -158,6 +158,7 @@ function matchesOn(rows, dateKey) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function matchSummary(matches, fallback) {
|
function matchSummary(matches, fallback) {
|
||||||
|
if (!matches.length) return fallback
|
||||||
if (matches.length === 1) {
|
if (matches.length === 1) {
|
||||||
const teams = matchTeams(matches[0].row).join(' - ')
|
const teams = matchTeams(matches[0].row).join(' - ')
|
||||||
const when = DATE_TIME_FORMATTER.format(matches[0].date)
|
const when = DATE_TIME_FORMATTER.format(matches[0].date)
|
||||||
@@ -184,7 +185,7 @@ async function readTeamMembers(season) {
|
|||||||
const rows = []
|
const rows = []
|
||||||
for (const line of lines.slice(1)) {
|
for (const line of lines.slice(1)) {
|
||||||
const values = parseCsvLine(line)
|
const values = parseCsvLine(line)
|
||||||
rows.push({ team: values[0] || '', players: values[7] || '' })
|
rows.push({ team: values[0] || '', captain: values[6] || '', players: values[7] || '' })
|
||||||
}
|
}
|
||||||
return rows
|
return rows
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -217,26 +218,52 @@ function parseCsvLine(line) {
|
|||||||
return values
|
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) {
|
function ownTeamSlugsForUser(user, teamRows) {
|
||||||
const name = normalizeText(userDisplayName(user))
|
const name = userDisplayName(user)
|
||||||
if (!name) return []
|
if (!normalizeText(name)) return []
|
||||||
return teamRows
|
return teamRows
|
||||||
.filter(row => normalizeText(row.players).includes(name))
|
.filter(row => personNameMatches(row.captain, name) ||
|
||||||
|
String(row.players || '').replace(/\r?\n/g, ';').split(/[;,]+/).some(player => personNameMatches(player, name)))
|
||||||
.map(row => slugify(row.team))
|
.map(row => slugify(row.team))
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
}
|
}
|
||||||
|
|
||||||
function userSelectedMatch(user, settings, matches) {
|
function selectedMatchesForUser(_user, settings, matches) {
|
||||||
const selected = new Set(settings.selectedTeamSlugs.map(slugify))
|
const selected = new Set((settings.selectedTeamSlugs || []).map(slugify))
|
||||||
if (!selected.size) return false
|
if (selected.size === 0) return []
|
||||||
return matches.some(match => teamSlugsForMatch(match).some(slug => selected.has(slug)))
|
return matches.filter(match => teamSlugsForMatch(match).some(slug => selected.has(slug)))
|
||||||
}
|
}
|
||||||
|
|
||||||
function userOwnTeamMatch(user, settings, matches, teamRows) {
|
function ownMatchesForUser(user, settings, matches, teamRows) {
|
||||||
if (!settings.ownTeamMatches) return false
|
if (settings.ownTeamMatches === false) return []
|
||||||
const ownSlugs = new Set(ownTeamSlugsForUser(user, teamRows))
|
const ownSlugs = new Set(ownTeamSlugsForUser(user, teamRows))
|
||||||
if (!ownSlugs.size) return false
|
if (ownSlugs.size === 0) return []
|
||||||
return matches.some(match => teamSlugsForMatch(match).some(slug => ownSlugs.has(slug)))
|
return matches.filter(match => teamSlugsForMatch(match).some(slug => ownSlugs.has(slug)))
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
function parseBirthday(value) {
|
||||||
@@ -288,17 +315,17 @@ export async function runNotificationSchedulerTick(now = new Date()) {
|
|||||||
|
|
||||||
let state = pruneState(await readState(), dateKey)
|
let state = pruneState(await readState(), dateKey)
|
||||||
const tomorrowKey = berlinDateKey(addDays(now, 1))
|
const tomorrowKey = berlinDateKey(addDays(now, 1))
|
||||||
const [termine, news, season] = await Promise.all([readTermine(), readNews(), getDefaultSpielplanSeason()])
|
const [termine, news, defaultSeason] = await Promise.all([readTermine(), readNews(), getDefaultSpielplanSeason()])
|
||||||
const [spielplan, teamRows] = await Promise.all([readSpielplanData({ season }), readTeamMembers(season)])
|
|
||||||
const todayTermine = eventsOn(termine, dateKey)
|
const todayTermine = eventsOn(termine, dateKey)
|
||||||
const tomorrowTermine = eventsOn(termine, tomorrowKey)
|
const tomorrowTermine = eventsOn(termine, tomorrowKey)
|
||||||
const expiringNewsToday = expiringNewsOn(news, dateKey)
|
const expiringNewsToday = expiringNewsOn(news, dateKey)
|
||||||
const todayEvents = [...todayTermine, ...expiringNewsToday]
|
const todayEvents = todayTermine
|
||||||
const tomorrowEvents = tomorrowTermine
|
const tomorrowEvents = tomorrowTermine
|
||||||
const todayMatches = matchesOn(spielplan.data || [], dateKey)
|
const seasonsForMatches = [...new Set(dueUsers
|
||||||
const tomorrowMatches = matchesOn(spielplan.data || [], tomorrowKey)
|
.map(user => notificationSeasonForSettings(notificationSettingsForUser(user), defaultSeason))
|
||||||
|
.filter(Boolean))]
|
||||||
|
const matchContexts = await loadMatchContextForSeasons(seasonsForMatches, dateKey, tomorrowKey)
|
||||||
const todaysBirthdays = await birthdaysOn(dateKey)
|
const todaysBirthdays = await birthdaysOn(dateKey)
|
||||||
const allMatches = [...todayMatches, ...tomorrowMatches]
|
|
||||||
const results = {}
|
const results = {}
|
||||||
|
|
||||||
results.eventsToday = await sendIfDue(state, dateKey, time, 'eventsToday', todayEvents.length > 0, () => sendPushToUsers({
|
results.eventsToday = await sendIfDue(state, dateKey, time, 'eventsToday', todayEvents.length > 0, () => sendPushToUsers({
|
||||||
@@ -325,29 +352,48 @@ export async function runNotificationSchedulerTick(now = new Date()) {
|
|||||||
failureLabel: 'FCM Termine-morgen-Push'
|
failureLabel: 'FCM Termine-morgen-Push'
|
||||||
}))
|
}))
|
||||||
|
|
||||||
results.allTeamMatches = await sendIfDue(state, dateKey, time, 'allTeamMatches', allMatches.length > 0, () => sendPushToUsers({
|
const allResults = []
|
||||||
title: 'Punktspiele',
|
const selectedResults = []
|
||||||
body: matchSummary(allMatches, 'Es stehen Punktspiele an.'),
|
const ownResults = []
|
||||||
data: { type: 'team_matches', date: dateKey },
|
for (const [season, context] of Object.entries(matchContexts)) {
|
||||||
predicate: (user, settings) => settings.notificationTime === time && settings.allTeamMatches,
|
allResults.push(await sendIfDue(state, dateKey, time, 'allTeamMatches:' + season, context.allMatches.length > 0, () => sendPushToUsers({
|
||||||
failureLabel: 'FCM Punktspiele-Push'
|
title: 'Punktspiele',
|
||||||
}))
|
body: matchSummary(context.allMatches, 'Es stehen Punktspiele an.'),
|
||||||
|
data: { type: 'team_matches', date: dateKey, season },
|
||||||
|
predicate: (user, settings) => settings.notificationTime === time &&
|
||||||
|
notificationSeasonForSettings(settings, defaultSeason) === season &&
|
||||||
|
settings.allTeamMatches,
|
||||||
|
failureLabel: 'FCM Punktspiele-Push'
|
||||||
|
})))
|
||||||
|
|
||||||
results.selectedTeamMatches = await sendIfDue(state, dateKey, time, 'selectedTeamMatches', allMatches.length > 0, () => sendPushToUsers({
|
selectedResults.push(await sendIfDue(state, dateKey, time, 'selectedTeamMatches:' + season, context.allMatches.length > 0, () => sendPushToUsers({
|
||||||
title: 'Punktspiele deiner Auswahl',
|
title: 'Punktspiele deiner Auswahl',
|
||||||
body: 'Für eine abonnierte Mannschaft stehen Punktspiele an.',
|
body: 'Für eine abonnierte Mannschaft steht ein Punktspiel an.',
|
||||||
data: { type: 'team_matches', date: dateKey },
|
bodyForUser: (user, settings) => matchSummary(selectedMatchesForUser(user, settings, context.allMatches), 'Für eine abonnierte Mannschaft steht ein Punktspiel an.'),
|
||||||
predicate: (user, settings) => settings.notificationTime === time && !settings.allTeamMatches && userSelectedMatch(user, settings, allMatches),
|
data: { type: 'team_matches', date: dateKey, season },
|
||||||
failureLabel: 'FCM Mannschaftsauswahl-Push'
|
predicate: (user, settings) => settings.notificationTime === time &&
|
||||||
}))
|
notificationSeasonForSettings(settings, defaultSeason) === season &&
|
||||||
|
settings.allTeamMatches === false &&
|
||||||
|
selectedMatchesForUser(user, settings, context.allMatches).length > 0,
|
||||||
|
failureLabel: 'FCM Mannschaftsauswahl-Push'
|
||||||
|
})))
|
||||||
|
|
||||||
results.ownTeamMatches = await sendIfDue(state, dateKey, time, 'ownTeamMatches', allMatches.length > 0, () => sendPushToUsers({
|
ownResults.push(await sendIfDue(state, dateKey, time, 'ownTeamMatches:' + season, context.allMatches.length > 0, () => sendPushToUsers({
|
||||||
title: 'Punktspiel deiner Mannschaft',
|
title: 'Punktspiel deiner Mannschaft',
|
||||||
body: 'Für deine Mannschaft steht ein Punktspiel an.',
|
body: 'Für deine Mannschaft steht ein Punktspiel an.',
|
||||||
data: { type: 'team_matches', date: dateKey },
|
bodyForUser: (user, settings) => matchSummary(ownMatchesForUser(user, settings, context.allMatches, context.teamRows), 'Für deine Mannschaft steht ein Punktspiel an.'),
|
||||||
predicate: (user, settings) => settings.notificationTime === time && !settings.allTeamMatches && !userSelectedMatch(user, settings, allMatches) && userOwnTeamMatch(user, settings, allMatches, teamRows),
|
data: { type: 'team_matches', date: dateKey, season },
|
||||||
failureLabel: 'FCM eigene-Mannschaft-Push'
|
predicate: (user, settings) => settings.notificationTime === time &&
|
||||||
}))
|
notificationSeasonForSettings(settings, defaultSeason) === season &&
|
||||||
|
settings.allTeamMatches === false &&
|
||||||
|
selectedMatchesForUser(user, settings, context.allMatches).length === 0 &&
|
||||||
|
ownMatchesForUser(user, settings, context.allMatches, context.teamRows).length > 0,
|
||||||
|
failureLabel: 'FCM eigene-Mannschaft-Push'
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
results.allTeamMatches = allResults.some(Boolean)
|
||||||
|
results.selectedTeamMatches = selectedResults.some(Boolean)
|
||||||
|
results.ownTeamMatches = ownResults.some(Boolean)
|
||||||
|
|
||||||
results.birthdays = await sendIfDue(state, dateKey, time, 'birthdays', todaysBirthdays.length > 0, () => sendPushToUsers({
|
results.birthdays = await sendIfDue(state, dateKey, time, 'birthdays', todaysBirthdays.length > 0, () => sendPushToUsers({
|
||||||
title: 'Geburtstage heute',
|
title: 'Geburtstage heute',
|
||||||
|
|||||||
@@ -146,9 +146,9 @@ function isVorstandUser(user) {
|
|||||||
return roles.includes('admin') || roles.includes('vorstand')
|
return roles.includes('admin') || roles.includes('vorstand')
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function sendPushToUsers({ title, body, data = {}, predicate, failureLabel = 'FCM-Push' }) {
|
export async function sendPushToUsers({ title, body, data = {}, predicate, bodyForUser, dataForUser, failureLabel = 'FCM-Push' }) {
|
||||||
const serviceAccount = await readServiceAccount()
|
const serviceAccount = await readServiceAccount()
|
||||||
if (!serviceAccount) {
|
if (serviceAccount == null) {
|
||||||
console.warn('FCM nicht konfiguriert: FCM_SERVICE_ACCOUNT_JSON oder GOOGLE_APPLICATION_CREDENTIALS fehlt.')
|
console.warn('FCM nicht konfiguriert: FCM_SERVICE_ACCOUNT_JSON oder GOOGLE_APPLICATION_CREDENTIALS fehlt.')
|
||||||
return { sent: 0, failed: 0, removed: 0, recipients: 0, tokenCount: 0, skipped: true }
|
return { sent: 0, failed: 0, removed: 0, recipients: 0, tokenCount: 0, skipped: true }
|
||||||
}
|
}
|
||||||
@@ -160,30 +160,34 @@ export async function sendPushToUsers({ title, body, data = {}, predicate, failu
|
|||||||
let recipients = 0
|
let recipients = 0
|
||||||
let tokenCount = 0
|
let tokenCount = 0
|
||||||
let changed = false
|
let changed = false
|
||||||
const payload = {
|
const baseData = Object.fromEntries(Object.entries(data).map(([key, value]) => [key, String(value ?? '')]))
|
||||||
...Object.fromEntries(Object.entries(data).map(([key, value]) => [key, String(value ?? '')])),
|
|
||||||
title: String(title || 'Harheimer TC'),
|
|
||||||
body: String(body || '').slice(0, 240),
|
|
||||||
notificationId: String(data.notificationId || notificationIdFor(`${data.type || 'push'}:${title}:${body}`))
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const user of users) {
|
for (const user of users) {
|
||||||
if (isHiddenUser(user)) continue
|
if (isHiddenUser(user)) continue
|
||||||
const settings = notificationSettingsForUser(user)
|
const settings = notificationSettingsForUser(user)
|
||||||
if (predicate && !predicate(user, settings)) 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
|
recipients += 1
|
||||||
const tokens = pushTokensForUser(user)
|
const tokens = pushTokensForUser(user)
|
||||||
tokenCount += tokens.length
|
tokenCount += tokens.length
|
||||||
const validTokens = []
|
const validTokens = []
|
||||||
for (const entry of tokens) {
|
for (const entry of tokens) {
|
||||||
try {
|
try {
|
||||||
await sendFcmMessage({ serviceAccount, accessToken, token: entry.token, title, body, data: payload })
|
await sendFcmMessage({ serviceAccount, accessToken, token: entry.token, title, body: userBody, data: payload })
|
||||||
sent += 1
|
sent += 1
|
||||||
validTokens.push(entry)
|
validTokens.push(entry)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
failed += 1
|
failed += 1
|
||||||
console.error('FCM Push fehlgeschlagen:', { failureLabel, message: error.message })
|
console.error('FCM Push fehlgeschlagen:', { failureLabel, message: error.message })
|
||||||
if (!/UNREGISTERED|NOT_FOUND|INVALID_ARGUMENT/.test(String(error.message))) {
|
if (/UNREGISTERED|NOT_FOUND|INVALID_ARGUMENT/.test(String(error.message)) === false) {
|
||||||
validTokens.push(entry)
|
validTokens.push(entry)
|
||||||
} else {
|
} else {
|
||||||
removed += 1
|
removed += 1
|
||||||
@@ -191,7 +195,7 @@ export async function sendPushToUsers({ title, body, data = {}, predicate, failu
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (validTokens.length !== tokens.length) {
|
if (validTokens.length < tokens.length) {
|
||||||
user.pushTokens = validTokens
|
user.pushTokens = validTokens
|
||||||
changed = true
|
changed = true
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user