diff --git a/android-app/app/src/main/java/de/harheimertc/notifications/HarheimerNotifications.kt b/android-app/app/src/main/java/de/harheimertc/notifications/HarheimerNotifications.kt index 1866b0b..1878b37 100644 --- a/android-app/app/src/main/java/de/harheimertc/notifications/HarheimerNotifications.kt +++ b/android-app/app/src/main/java/de/harheimertc/notifications/HarheimerNotifications.kt @@ -72,8 +72,8 @@ object HarheimerNotifications { } private fun destinationRoute(data: Map): String = when (data["type"]) { - "news" -> Destinations.MemberNews.route - "event", "events_today", "events_tomorrow", "news_expiring" -> Destinations.Termine.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 diff --git a/android-app/app/src/main/java/de/harheimertc/ui/components/AppNavigationHeader.kt b/android-app/app/src/main/java/de/harheimertc/ui/components/AppNavigationHeader.kt index 9994519..edfbf2d 100644 --- a/android-app/app/src/main/java/de/harheimertc/ui/components/AppNavigationHeader.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/components/AppNavigationHeader.kt @@ -422,6 +422,7 @@ private fun menuSection(route: String?): MenuSection? = when (route) { Destinations.CmsStartseite.route, Destinations.CmsInhalte.route, Destinations.CmsVereinsmeisterschaften.route, + Destinations.CmsNews.route, Destinations.CmsSportbetrieb.route, Destinations.CmsMitgliederverwaltung.route, Destinations.CmsNewsletter.route, @@ -484,7 +485,7 @@ private fun submenu(section: MenuSection?, state: NavigationUiState): List() } - val trainers = remember { mutableStateListOf() } + var activeTab by remember { mutableStateOf("termine") } + val tabs = listOf( + "termine" to "Termine", + "mannschaften" to "Mannschaften", + "spielplaene" to "Spielpläne", + ) - 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) + CmsPage(navController, showBackNavigation, "Sportbetrieb", "Termine, Mannschaften und Spielpläne pflegen") { + 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 -> { - item { - Button( - onClick = { - viewModel.saveConfig( - config.copy( - training = config.training.copy( - ort = config.training.ort.copy( - name = ortName, - strasse = ortStrasse, - plz = ortPlz, - ort = ortOrt, - ), - zeiten = trainingTimes.toList(), - ), - trainer = trainers.toList(), - ), - ) - }, - enabled = !state.saving, - modifier = Modifier.fillMaxWidth(), - ) { - Text(if (state.saving) "Speichert..." else "Speichern") + when (activeTab) { + "termine" -> item { + DataCard("Termine verwalten") { + Text("Termine werden im CMS gepflegt und erscheinen anschließend auf der Terminseite.", color = Accent700) + Button(onClick = { navController.navigate(Destinations.Termine.route) }, modifier = Modifier.fillMaxWidth()) { + Text("Termine anzeigen") } } - 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)) - } + } + "mannschaften" -> item { + DataCard("Mannschaften verwalten") { + Text("Mannschaftsdaten und Saisons entsprechen dem CMS-Bereich der Web-Oberfläche.", color = Accent700) + Button(onClick = { navController.navigate(Destinations.Mannschaften.route) }, modifier = Modifier.fillMaxWidth()) { + Text("Mannschaften öffnen") } } - 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") - } + } + "spielplaene" -> item { + DataCard("Spielpläne") { + Text("Spielpläne und Ergebnisse werden aus den importierten Spielplandaten angezeigt.", color = Accent700) + Button(onClick = { navController.navigate(Destinations.Spielplan.route) }, modifier = Modifier.fillMaxWidth()) { + Text("Spielpläne öffnen") } } - 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( Triple("Startseite", "Öffentliche Startseite", Destinations.CmsStartseite.route), Triple("Inhalte", "Vereinsseiten", Destinations.CmsInhalte.route), + Triple("News", "Interne und öffentliche News", Destinations.CmsNews.route), Triple("Sportbetrieb", "Training und Sportdaten", Destinations.CmsSportbetrieb.route), Triple("Mitgliederverwaltung", "${state.users.size} Benutzer", Destinations.CmsMitgliederverwaltung.route), Triple("Kontaktanfragen", "${state.contactRequests.size} Anfragen", Destinations.CmsContactRequests.route), diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/notifications/NotificationSettingsScreen.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/notifications/NotificationSettingsScreen.kt index 7b1d443..f2b9c6b 100644 --- a/android-app/app/src/main/java/de/harheimertc/ui/screens/notifications/NotificationSettingsScreen.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/notifications/NotificationSettingsScreen.kt @@ -142,7 +142,7 @@ fun NotificationSettingsScreen( ToggleRow("Punktspiele der eigenen Mannschaft", state.settings.ownTeamMatches) { viewModel.update(state.settings.copy(ownTeamMatches = it)) } - Text("Die eigene Mannschaft wird aus dem Namen und der Mannschaftszusammensetzung ermittelt.", color = Accent700) + OwnTeamInfo(state.ownTeams, state.currentUserName) ToggleRow("Punktspiele aller Mannschaften", state.settings.allTeamMatches) { viewModel.update(state.settings.copy(allTeamMatches = it)) } @@ -214,6 +214,16 @@ private fun ToggleRow(label: String, checked: Boolean, onCheckedChange: (Boolean } } +@Composable +private fun OwnTeamInfo(ownTeams: List, currentUserName: String) { + val text = when { + ownTeams.isNotEmpty() -> "Erkannte eigene Mannschaft: " + ownTeams.joinToString { it.mannschaft } + currentUserName.isBlank() -> "Eigene Mannschaft kann erst nach geladenem Profil ermittelt werden." + else -> "Keine eigene Mannschaft für " + currentUserName + " erkannt." + } + Text(text, color = Accent700) +} + @Composable private fun TimeSelection(selectedTime: String, onSelectTime: (String) -> Unit) { Row( diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/notifications/NotificationSettingsViewModel.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/notifications/NotificationSettingsViewModel.kt index 8cd156c..7b6ce87 100644 --- a/android-app/app/src/main/java/de/harheimertc/ui/screens/notifications/NotificationSettingsViewModel.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/notifications/NotificationSettingsViewModel.kt @@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import de.harheimertc.repositories.Mannschaft +import de.harheimertc.repositories.LoginRepository import de.harheimertc.repositories.MannschaftenRepository import de.harheimertc.repositories.NotificationPreferences import de.harheimertc.repositories.NotificationPreferencesRepository @@ -16,6 +17,8 @@ data class NotificationSettingsUiState( val loading: Boolean = true, val settings: NotificationPreferences = NotificationPreferences(), val teams: List = emptyList(), + val ownTeams: List = emptyList(), + val currentUserName: String = "", val seasons: List = emptyList(), val error: String? = null, val saveError: String? = null, @@ -25,6 +28,7 @@ data class NotificationSettingsUiState( class NotificationSettingsViewModel @Inject constructor( private val preferencesRepository: NotificationPreferencesRepository, private val mannschaftenRepository: MannschaftenRepository, + private val loginRepository: LoginRepository, ) : ViewModel() { private val _state = MutableStateFlow(NotificationSettingsUiState()) val state: StateFlow = _state @@ -37,12 +41,14 @@ class NotificationSettingsViewModel @Inject constructor( viewModelScope.launch { _state.value = _state.value.copy(loading = true, error = null, saveError = null) val storedSettings = preferencesRepository.loadRemote().getOrElse { preferencesRepository.loadLocal() } + val authStatus = loginRepository.status().getOrNull() + val currentUserName = authStatus?.user?.name.orEmpty() val seasonsResponse = mannschaftenRepository.fetchSeasons().getOrNull() val seasons = seasonsResponse?.seasons.orEmpty() val selectedSeason = storedSettings.selectedTeamSeason ?: seasonsResponse?.defaultSeason?.takeIf { it.isNotBlank() } ?: seasons.firstOrNull() - loadTeams(storedSettings.copy(selectedTeamSeason = selectedSeason), seasons) + loadTeams(storedSettings.copy(selectedTeamSeason = selectedSeason), seasons, currentUserName) } } @@ -50,7 +56,7 @@ class NotificationSettingsViewModel @Inject constructor( val current = _state.value.settings viewModelScope.launch { _state.value = _state.value.copy(loading = true, error = null, saveError = null) - loadTeams(current.copy(selectedTeamSeason = season), _state.value.seasons, syncRemote = true) + loadTeams(current.copy(selectedTeamSeason = season), _state.value.seasons, _state.value.currentUserName, syncRemote = true) } } @@ -76,7 +82,7 @@ class NotificationSettingsViewModel @Inject constructor( update(current.copy(selectedTeamSlugs = nextTeams)) } - private suspend fun loadTeams(settings: NotificationPreferences, seasons: List, syncRemote: Boolean = false) { + private suspend fun loadTeams(settings: NotificationPreferences, seasons: List, currentUserName: String, syncRemote: Boolean = false) { mannschaftenRepository.fetchMannschaften(settings.selectedTeamSeason) .onSuccess { teams -> val knownSlugs = teams.map { it.slug }.toSet() @@ -89,6 +95,8 @@ class NotificationSettingsViewModel @Inject constructor( loading = false, settings = nextSettings, teams = teams, + ownTeams = ownTeamsForUser(currentUserName, teams), + currentUserName = currentUserName, seasons = seasons, saveError = saveError, ) @@ -101,6 +109,7 @@ class NotificationSettingsViewModel @Inject constructor( _state.value = NotificationSettingsUiState( loading = false, settings = settings, + currentUserName = currentUserName, seasons = seasons, error = error.message ?: "Mannschaften konnten nicht geladen werden.", saveError = saveError, @@ -108,3 +117,27 @@ class NotificationSettingsViewModel @Inject constructor( } } } + +private fun ownTeamsForUser(userName: String, teams: List): List { + if (normalizePersonName(userName).isBlank()) return emptyList() + return teams.filter { team -> + team.spieler.any { player -> personNameMatches(player, userName) } || + personNameMatches(team.mannschaftsfuehrer, userName) + } +} + +private fun personNameMatches(candidate: String, userName: String): Boolean { + val normalizedCandidate = normalizePersonName(candidate) + val normalizedUserName = normalizePersonName(userName) + if (normalizedCandidate.isBlank() || normalizedUserName.isBlank()) return false + if (normalizedCandidate == normalizedUserName) return true + + val candidateParts = normalizedCandidate.split(" " ).filter { it.isNotBlank() }.toSet() + val userParts = normalizedUserName.split(" " ).filter { it.isNotBlank() } + return userParts.size >= 2 && userParts.all { it in candidateParts } +} + +private fun normalizePersonName(value: String): String = value + .lowercase() + .replace(Regex("[^a-z0-9äöüß]+"), " ") + .trim() diff --git a/server/utils/data-file-rotation.js b/server/utils/data-file-rotation.js index 2cc6fa0..8c4b8ce 100644 --- a/server/utils/data-file-rotation.js +++ b/server/utils/data-file-rotation.js @@ -2,6 +2,7 @@ import { promises as fs } from 'fs' import path from 'path' const DEFAULT_MAX_BACKUPS = Number.parseInt(process.env.DATA_FILE_BACKUP_MAX || '40', 10) +let backupSequence = 0 function getProjectRoot() { const cwd = process.cwd() @@ -30,8 +31,9 @@ function sanitizeFileKey(filePath) { } function buildBackupName(date = new Date()) { + const sequence = (backupSequence++).toString(36).padStart(6, '0') const randomSuffix = Math.random().toString(36).slice(2, 8) - return `${date.toISOString().replace(/[:.]/g, '-')}-${randomSuffix}.bak` + return `${date.toISOString().replace(/[:.]/g, '-')}-${sequence}-${randomSuffix}.bak` } export function resolveDataFileBackupPath(backupDir, backupName) { diff --git a/server/utils/notification-scheduler.js b/server/utils/notification-scheduler.js index 04338d1..6a285e1 100644 --- a/server/utils/notification-scheduler.js +++ b/server/utils/notification-scheduler.js @@ -158,6 +158,7 @@ function matchesOn(rows, 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) @@ -184,7 +185,7 @@ async function readTeamMembers(season) { const rows = [] for (const line of lines.slice(1)) { 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 } catch (error) { @@ -217,26 +218,52 @@ function parseCsvLine(line) { 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 = normalizeText(userDisplayName(user)) - if (!name) return [] + const name = userDisplayName(user) + if (!normalizeText(name)) return [] 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)) .filter(Boolean) } -function userSelectedMatch(user, settings, matches) { - const selected = new Set(settings.selectedTeamSlugs.map(slugify)) - if (!selected.size) return false - return matches.some(match => teamSlugsForMatch(match).some(slug => selected.has(slug))) +function selectedMatchesForUser(_user, settings, matches) { + const selected = new Set((settings.selectedTeamSlugs || []).map(slugify)) + if (selected.size === 0) return [] + return matches.filter(match => teamSlugsForMatch(match).some(slug => selected.has(slug))) } -function userOwnTeamMatch(user, settings, matches, teamRows) { - if (!settings.ownTeamMatches) return false +function ownMatchesForUser(user, settings, matches, teamRows) { + if (settings.ownTeamMatches === false) return [] const ownSlugs = new Set(ownTeamSlugsForUser(user, teamRows)) - if (!ownSlugs.size) return false - return matches.some(match => teamSlugsForMatch(match).some(slug => ownSlugs.has(slug))) + if (ownSlugs.size === 0) return [] + 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) { @@ -288,17 +315,17 @@ export async function runNotificationSchedulerTick(now = new Date()) { let state = pruneState(await readState(), dateKey) const tomorrowKey = berlinDateKey(addDays(now, 1)) - const [termine, news, season] = await Promise.all([readTermine(), readNews(), getDefaultSpielplanSeason()]) - const [spielplan, teamRows] = await Promise.all([readSpielplanData({ season }), readTeamMembers(season)]) + 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, ...expiringNewsToday] + const todayEvents = todayTermine const tomorrowEvents = tomorrowTermine - const todayMatches = matchesOn(spielplan.data || [], dateKey) - const tomorrowMatches = matchesOn(spielplan.data || [], tomorrowKey) + 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 allMatches = [...todayMatches, ...tomorrowMatches] const results = {} 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' })) - results.allTeamMatches = await sendIfDue(state, dateKey, time, 'allTeamMatches', allMatches.length > 0, () => sendPushToUsers({ - title: 'Punktspiele', - body: matchSummary(allMatches, 'Es stehen Punktspiele an.'), - data: { type: 'team_matches', date: dateKey }, - predicate: (user, settings) => settings.notificationTime === time && settings.allTeamMatches, - failureLabel: 'FCM Punktspiele-Push' - })) + const allResults = [] + const selectedResults = [] + const ownResults = [] + for (const [season, context] of Object.entries(matchContexts)) { + allResults.push(await sendIfDue(state, dateKey, time, 'allTeamMatches:' + season, context.allMatches.length > 0, () => sendPushToUsers({ + 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({ - title: 'Punktspiele deiner Auswahl', - body: 'Für eine abonnierte Mannschaft stehen Punktspiele an.', - data: { type: 'team_matches', date: dateKey }, - predicate: (user, settings) => settings.notificationTime === time && !settings.allTeamMatches && userSelectedMatch(user, settings, allMatches), - failureLabel: 'FCM Mannschaftsauswahl-Push' - })) + selectedResults.push(await sendIfDue(state, dateKey, time, 'selectedTeamMatches:' + season, context.allMatches.length > 0, () => sendPushToUsers({ + title: 'Punktspiele deiner Auswahl', + body: 'Für eine abonnierte Mannschaft steht ein Punktspiel an.', + bodyForUser: (user, settings) => matchSummary(selectedMatchesForUser(user, settings, context.allMatches), 'Für eine abonnierte Mannschaft steht ein Punktspiel an.'), + data: { type: 'team_matches', date: dateKey, season }, + 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({ - title: 'Punktspiel deiner Mannschaft', - body: 'Für deine Mannschaft steht ein Punktspiel an.', - data: { type: 'team_matches', date: dateKey }, - predicate: (user, settings) => settings.notificationTime === time && !settings.allTeamMatches && !userSelectedMatch(user, settings, allMatches) && userOwnTeamMatch(user, settings, allMatches, teamRows), - failureLabel: 'FCM eigene-Mannschaft-Push' - })) + ownResults.push(await sendIfDue(state, dateKey, time, 'ownTeamMatches:' + season, context.allMatches.length > 0, () => sendPushToUsers({ + title: 'Punktspiel deiner Mannschaft', + body: 'Für deine Mannschaft steht ein Punktspiel an.', + bodyForUser: (user, settings) => matchSummary(ownMatchesForUser(user, settings, context.allMatches, context.teamRows), 'Für deine Mannschaft steht ein Punktspiel an.'), + data: { type: 'team_matches', date: dateKey, season }, + 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({ title: 'Geburtstage heute', diff --git a/server/utils/push-notifications.js b/server/utils/push-notifications.js index cfa63e4..970f279 100644 --- a/server/utils/push-notifications.js +++ b/server/utils/push-notifications.js @@ -146,9 +146,9 @@ function isVorstandUser(user) { 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() - if (!serviceAccount) { + if (serviceAccount == null) { 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 } } @@ -160,30 +160,34 @@ export async function sendPushToUsers({ title, body, data = {}, predicate, failu let recipients = 0 let tokenCount = 0 let changed = false - const payload = { - ...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}`)) - } + 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 (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: payload }) + await sendFcmMessage({ serviceAccount, accessToken, token: entry.token, title, body: userBody, data: payload }) sent += 1 validTokens.push(entry) } catch (error) { failed += 1 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) } else { 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 changed = true }