dev #42

Merged
admin merged 7 commits from dev into main 2026-06-16 14:09:24 +02:00
10 changed files with 197 additions and 157 deletions
Showing only changes of commit e537839e28 - Show all commits

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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