From 9bc9501219ac935defadb7df03c136a8c029592f Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Tue, 8 Sep 2026 14:54:20 +0200 Subject: [PATCH] =?UTF-8?q?F=C3=BCge=20Unterst=C3=BCtzung=20f=C3=BCr=20TTR?= =?UTF-8?q?-=C3=84nderungen=20hinzu:=20Erweitere=20Benachrichtigungseinste?= =?UTF-8?q?llungen=20und=20implementiere=20Push-Benachrichtigungen=20f?= =?UTF-8?q?=C3=BCr=20TTR-Wert=C3=A4nderungen.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/de/harheimertc/data/ApiService.kt | 2 + .../NotificationPreferencesRepository.kt | 6 ++ .../ui/components/AppNavigationHeader.kt | 2 +- .../memberarea/MemberAreaDetailScreens.kt | 23 +++---- .../ui/screens/memberarea/MemberAreaScreen.kt | 6 +- .../NotificationSettingsScreen.kt | 3 + android-app/gradle.properties | 4 +- pages/cms/mytischtennis.vue | 4 ++ server/api/cms/notifications/test.post.js | 12 ++++ server/utils/notification-settings.js | 2 + server/utils/push-notifications.js | 61 +++++++++++++++++++ server/utils/qttr-import.js | 14 +++++ 12 files changed, 117 insertions(+), 22 deletions(-) create mode 100644 server/api/cms/notifications/test.post.js diff --git a/android-app/app/src/main/java/de/harheimertc/data/ApiService.kt b/android-app/app/src/main/java/de/harheimertc/data/ApiService.kt index ce19e2a..e5f82a3 100755 --- a/android-app/app/src/main/java/de/harheimertc/data/ApiService.kt +++ b/android-app/app/src/main/java/de/harheimertc/data/ApiService.kt @@ -269,6 +269,7 @@ data class NotificationSettingsDto( val birthdays: Boolean = false, val newContactRequest: Boolean = false, val newUserRegistration: Boolean = false, + val ownTtrChanges: Boolean = false, val selectedTeamSlugs: List = emptyList(), val selectedTeamSeason: String? = null, val notificationTime: String = "09:00", @@ -302,6 +303,7 @@ data class QttrRowDto( val playerName: String = "", val clubName: String = "", val currentQttr: Int? = null, + val currentTtr: Int? = null, val previousQttr: Int? = null, val birthdate: String? = null, ) diff --git a/android-app/app/src/main/java/de/harheimertc/repositories/NotificationPreferencesRepository.kt b/android-app/app/src/main/java/de/harheimertc/repositories/NotificationPreferencesRepository.kt index 04f1ae6..e2de8ee 100755 --- a/android-app/app/src/main/java/de/harheimertc/repositories/NotificationPreferencesRepository.kt +++ b/android-app/app/src/main/java/de/harheimertc/repositories/NotificationPreferencesRepository.kt @@ -19,6 +19,7 @@ data class NotificationPreferences( val birthdays: Boolean = false, val newContactRequest: Boolean = false, val newUserRegistration: Boolean = false, + val ownTtrChanges: Boolean = false, val selectedTeamSlugs: Set = emptySet(), val selectedTeamSeason: String? = null, val notificationTime: String = DEFAULT_NOTIFICATION_TIME, @@ -43,6 +44,7 @@ class NotificationPreferencesRepository @Inject constructor( birthdays = preferences.getBoolean(KEY_BIRTHDAYS, false), newContactRequest = preferences.getBoolean(KEY_NEW_CONTACT_REQUEST, false), newUserRegistration = preferences.getBoolean(KEY_NEW_USER_REGISTRATION, false), + ownTtrChanges = preferences.getBoolean(KEY_OWN_TTR_CHANGES, false), selectedTeamSlugs = preferences.getStringSet(KEY_SELECTED_TEAM_SLUGS, emptySet()).orEmpty(), selectedTeamSeason = preferences.getString(KEY_SELECTED_TEAM_SEASON, null)?.takeIf { it.isNotBlank() }, notificationTime = preferences.getString(KEY_NOTIFICATION_TIME, DEFAULT_NOTIFICATION_TIME) ?: DEFAULT_NOTIFICATION_TIME, @@ -69,6 +71,7 @@ class NotificationPreferencesRepository @Inject constructor( .putBoolean(KEY_BIRTHDAYS, settings.birthdays) .putBoolean(KEY_NEW_CONTACT_REQUEST, settings.newContactRequest) .putBoolean(KEY_NEW_USER_REGISTRATION, settings.newUserRegistration) + .putBoolean(KEY_OWN_TTR_CHANGES, settings.ownTtrChanges) .putStringSet(KEY_SELECTED_TEAM_SLUGS, settings.selectedTeamSlugs) .putString(KEY_SELECTED_TEAM_SEASON, settings.selectedTeamSeason) .putString(KEY_NOTIFICATION_TIME, settings.notificationTime) @@ -98,6 +101,7 @@ class NotificationPreferencesRepository @Inject constructor( const val KEY_BIRTHDAYS = "birthdays" const val KEY_NEW_CONTACT_REQUEST = "new_contact_request" const val KEY_NEW_USER_REGISTRATION = "new_user_registration" + const val KEY_OWN_TTR_CHANGES = "own_ttr_changes" const val KEY_SELECTED_TEAM_SLUGS = "selected_team_slugs" const val KEY_SELECTED_TEAM_SEASON = "selected_team_season" const val KEY_NOTIFICATION_TIME = "notification_time" @@ -114,6 +118,7 @@ private fun NotificationSettingsDto.toPreferences(): NotificationPreferences = N birthdays = birthdays, newContactRequest = newContactRequest, newUserRegistration = newUserRegistration, + ownTtrChanges = ownTtrChanges, selectedTeamSlugs = selectedTeamSlugs.toSet(), selectedTeamSeason = selectedTeamSeason, notificationTime = notificationTime, @@ -129,6 +134,7 @@ private fun NotificationPreferences.toDto(): NotificationSettingsDto = Notificat birthdays = birthdays, newContactRequest = newContactRequest, newUserRegistration = newUserRegistration, + ownTtrChanges = ownTtrChanges, selectedTeamSlugs = selectedTeamSlugs.toList(), selectedTeamSeason = selectedTeamSeason, notificationTime = notificationTime, 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 edfbf2d..9c76bb8 100755 --- 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 @@ -472,7 +472,7 @@ private fun submenu(section: MenuSection?, state: NavigationUiState): List buildList { add(MenuTarget("Übersicht", Destinations.MemberArea.route)) add(MenuTarget("Mitgliederliste", Destinations.Members.route)) - add(MenuTarget("QTTR", Destinations.Qttr.route)) + add(MenuTarget("TTR / QTTR", Destinations.Qttr.route)) add(MenuTarget("News", Destinations.MemberNews.route)) add(MenuTarget("Mein Profil", Destinations.Profile.route)) add(MenuTarget("Benachrichtigungen", Destinations.NotificationSettings.route)) diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/memberarea/MemberAreaDetailScreens.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/memberarea/MemberAreaDetailScreens.kt index 7aa215f..09df63e 100755 --- a/android-app/app/src/main/java/de/harheimertc/ui/screens/memberarea/MemberAreaDetailScreens.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/memberarea/MemberAreaDetailScreens.kt @@ -28,7 +28,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalUriHandler import android.util.Log import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight @@ -248,18 +247,7 @@ fun QttrScreen( viewModel: QttrViewModel = hiltViewModel(), ) { val state by viewModel.state.collectAsState() - val uriHandler = LocalUriHandler.current - val externalUrl = "https://www.mytischtennis.de/rankings/andro-rangliste?continent=all&country=Deutschland&all-players=on&as=DE.WE.R4.07&di=DE.WE.R4.07.04&area=DE.WE.R4.07.04.43&clubnr-search=Harheimer+TC&clubnr=43030&fednickname=HeTTV&gender=all¤t-ranking=yes&ttr-range=100%3B3000&birth-range=1926%3B2021" - - MemberAreaPage(navController, showBackNavigation, "QTTR-Werte", "Aus technischen Gründen sind nur die QTTR-Werte verfügbar.") { - item { - Surface(color = Primary100, shape = RoundedCornerShape(12.dp)) { - Column(Modifier.fillMaxWidth().padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text("Für TTR bitte die myTischtennis-Rangliste verwenden.", color = Primary900) - TextButton(onClick = { uriHandler.openUri(externalUrl) }) { Text("myTischtennis öffnen") } - } - } - } + MemberAreaPage(navController, showBackNavigation, "TTR- und QTTR-Werte", "Aktuelle Werte der Vereinsmitglieder aus myTischtennis.") { item { Surface(color = Color.White, shape = RoundedCornerShape(14.dp), shadowElevation = 3.dp) { Column(Modifier.fillMaxWidth().padding(18.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { @@ -269,9 +257,9 @@ fun QttrScreen( } } when { - state.loading -> item { LoadingState("QTTR-Werte werden geladen...") } + state.loading -> item { LoadingState("TTR- und QTTR-Werte werden geladen...") } state.error != null -> item { ErrorCard(state.error.orEmpty(), viewModel::load) } - state.rows.isEmpty() -> item { Text("Keine QTTR-Werte gefunden.", color = Accent700) } + state.rows.isEmpty() -> item { Text("Keine TTR- oder QTTR-Werte gefunden.", color = Accent700) } else -> items(state.rows.size) { index -> QttrRowCard(state.rows[index], isOwnRow(state.rows[index].playerName, state.currentUserName)) } } } @@ -388,7 +376,10 @@ private fun QttrRowCard(row: QttrRowDto, highlighted: Boolean) { ) Text(row.clubName.ifBlank { "Harheimer TC" }, color = qttrNameColor(row.gender, isMinor(row.birthdate)).copy(alpha = 0.88f)) } - Text(row.currentQttr?.toString() ?: "-", color = Primary600, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + Column(horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text("TTR ${row.currentTtr?.toString() ?: "-"}", color = Accent700, style = MaterialTheme.typography.labelLarge) + Text("QTTR ${row.currentQttr?.toString() ?: "-"}", color = Primary600, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + } } } } diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/memberarea/MemberAreaScreen.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/memberarea/MemberAreaScreen.kt index 1712b0d..00e4c34 100755 --- a/android-app/app/src/main/java/de/harheimertc/ui/screens/memberarea/MemberAreaScreen.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/memberarea/MemberAreaScreen.kt @@ -121,9 +121,9 @@ private fun MemberAreaCardGrid(navController: NavController) { onClick = { navController.navigate(Destinations.MemberNews.route) }, ) MemberAreaCard( - title = "QTTR", - description = "Aktuelle QTTR-Werte der Vereinsmitglieder", - marker = "Q", + title = "TTR / QTTR", + description = "Aktuelle TTR- und QTTR-Werte der Vereinsmitglieder", + marker = "T", onClick = { navController.navigate(Destinations.Qttr.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 f2b9c6b..1a85207 100755 --- 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 @@ -162,6 +162,9 @@ fun NotificationSettingsScreen( ToggleRow("Geburtstage", state.settings.birthdays) { viewModel.update(state.settings.copy(birthdays = it)) } + ToggleRow("Änderung meines TTR-Werts", state.settings.ownTtrChanges) { + viewModel.update(state.settings.copy(ownTtrChanges = it)) + } } } diff --git a/android-app/gradle.properties b/android-app/gradle.properties index df9ba17..2fa50ec 100755 --- a/android-app/gradle.properties +++ b/android-app/gradle.properties @@ -8,8 +8,8 @@ LOCAL_API_BASE_URL=https://harheimertc.tsschulz.de/ PRODUCTION_API_BASE_URL=https://harheimertc.de/ # Android app versioning for Play Store uploads -ANDROID_VERSION_CODE=32 -ANDROID_VERSION_NAME=0.9.27 +ANDROID_VERSION_CODE=33 +ANDROID_VERSION_NAME=0.9.28 # Temporary hotfix: disable R8 minification for release to avoid Retrofit generic signature stripping. RELEASE_MINIFY_ENABLED=false diff --git a/pages/cms/mytischtennis.vue b/pages/cms/mytischtennis.vue index 514ba7b..d5c363b 100644 --- a/pages/cms/mytischtennis.vue +++ b/pages/cms/mytischtennis.vue @@ -27,6 +27,7 @@
+
@@ -35,14 +36,17 @@ diff --git a/server/api/cms/notifications/test.post.js b/server/api/cms/notifications/test.post.js new file mode 100644 index 0000000..9fdb391 --- /dev/null +++ b/server/api/cms/notifications/test.post.js @@ -0,0 +1,12 @@ +import { getUserFromToken, hasRole } from '../../../utils/auth.js' +import { sendTestPushToUser } from '../../../utils/push-notifications.js' + +export default defineEventHandler(async (event) => { + const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '') + const user = token ? await getUserFromToken(token) : null + if (!user) throw createError({ statusCode: 401, statusMessage: 'Nicht authentifiziert' }) + if (!hasRole(user, 'admin')) throw createError({ statusCode: 403, statusMessage: 'Nur Administratoren dürfen einen Push-Test auslösen.' }) + + const result = await sendTestPushToUser(user.id) + return { success: true, ...result } +}) diff --git a/server/utils/notification-settings.js b/server/utils/notification-settings.js index 6d98feb..3e4f39b 100755 --- a/server/utils/notification-settings.js +++ b/server/utils/notification-settings.js @@ -8,6 +8,7 @@ export const DEFAULT_NOTIFICATION_SETTINGS = Object.freeze({ birthdays: false, newContactRequest: false, newUserRegistration: false, + ownTtrChanges: false, selectedTeamSlugs: [], selectedTeamSeason: null, notificationTime: '09:00' @@ -41,6 +42,7 @@ export function sanitizeNotificationSettings(input = {}) { birthdays: coerceBoolean(input.birthdays), newContactRequest: coerceBoolean(input.newContactRequest), newUserRegistration: coerceBoolean(input.newUserRegistration), + ownTtrChanges: coerceBoolean(input.ownTtrChanges), selectedTeamSlugs: [...new Set(selectedTeamSlugs)], selectedTeamSeason, notificationTime diff --git a/server/utils/push-notifications.js b/server/utils/push-notifications.js index 8511f7c..168c145 100755 --- a/server/utils/push-notifications.js +++ b/server/utils/push-notifications.js @@ -150,6 +150,26 @@ function isVorstandUser(user) { return roles.includes('admin') || roles.includes('vorstand') } +function normalizePersonName(value) { + return String(value || '') + .trim() + .toLowerCase() + .normalize('NFKD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/[’'`]/g, '') + .replace(/\s+/g, ' ') +} + +function userMatchesPlayer(user, playerName) { + const player = normalizePersonName(playerName) + if (!player) return false + const candidates = [ + user?.name, + `${user?.firstName || ''} ${user?.lastName || ''}`.trim() + ].map(normalizePersonName).filter(Boolean) + return candidates.includes(player) +} + export async function sendPushToUsers({ title, body, data = {}, predicate, bodyForUser, dataForUser, failureLabel = 'FCM-Push' }) { const serviceAccount = await readServiceAccount() if (serviceAccount == null) { @@ -275,3 +295,44 @@ export async function sendNewUserRegistrationPush(registration) { failureLabel: 'FCM Registrierungs-Push' }) } + +export async function sendOwnTtrChangePush(changes = []) { + const byPlayer = new Map( + changes + .filter(change => change?.playerName && Number.isFinite(change?.previousTtr) && Number.isFinite(change?.currentTtr)) + .map(change => [normalizePersonName(change.playerName), change]) + ) + if (!byPlayer.size) return { sent: 0, failed: 0, removed: 0, recipients: 0, tokenCount: 0, skipped: false } + + return sendPushToUsers({ + title: 'TTR-Wert geändert', + data: { type: 'ttr_change' }, + predicate: (user, settings) => settings.ownTtrChanges && [...byPlayer.values()].some(change => userMatchesPlayer(user, change.playerName)), + bodyForUser: (user) => { + const change = [...byPlayer.values()].find(entry => userMatchesPlayer(user, entry.playerName)) + return `Dein TTR-Wert hat sich von ${change.previousTtr} auf ${change.currentTtr} geändert.` + }, + dataForUser: (user) => { + const change = [...byPlayer.values()].find(entry => userMatchesPlayer(user, entry.playerName)) + return { + previousTtr: change.previousTtr, + currentTtr: change.currentTtr, + notificationId: notificationIdFor(`ttr:${change.playerName}:${change.previousTtr}:${change.currentTtr}`) + } + }, + failureLabel: 'FCM TTR-Änderungs-Push' + }) +} + +export async function sendTestPushToUser(userId) { + return sendPushToUsers({ + title: 'Harheimer TC: Push-Test', + body: 'Diese Test-Benachrichtigung wurde erfolgreich vom CMS ausgelöst.', + data: { + type: 'test', + notificationId: notificationIdFor(`test:${userId}:${Date.now()}`) + }, + predicate: user => user?.id === userId, + failureLabel: 'FCM Test-Push' + }) +} diff --git a/server/utils/qttr-import.js b/server/utils/qttr-import.js index 7cdaad2..92a0d19 100755 --- a/server/utils/qttr-import.js +++ b/server/utils/qttr-import.js @@ -1,5 +1,6 @@ import { promises as fs } from 'fs' import { getServerDataPath } from './paths.js' +import { sendOwnTtrChangePush } from './push-notifications.js' import { fetchClubRankings } from './mytischtennis-client.js' import { readMyTischtennisConnection, saveMyTischtennisConnection } from './mytischtennis-connection.js' @@ -215,6 +216,9 @@ export async function importQttrValues(options = {}) { } async function importAuthenticatedTtrValues(connection) { + const previousPayload = await fs.readFile(OUTPUT_FILE, 'utf8') + .then(JSON.parse) + .catch(() => ({ rows: [] })) const [ttrEntries, qttrEntries] = await Promise.all([ fetchClubRankings(connection, 'yes'), fetchClubRankings(connection, 'no') @@ -248,6 +252,16 @@ async function importAuthenticatedTtrValues(connection) { } await fs.mkdir(getServerDataPath(), { recursive: true }) await fs.writeFile(OUTPUT_FILE, `${JSON.stringify(payload, null, 2)}\n`, 'utf8') + const previousTtrByPlayer = new Map((previousPayload.rows || []).map(row => [ + String(row?.playerName || '').trim().toLowerCase(), toNumberOrNull(row?.currentTtr) + ])) + const changes = rows.flatMap(row => { + const previousTtr = previousTtrByPlayer.get(String(row.playerName || '').trim().toLowerCase()) + return previousTtr != null && row.currentTtr != null && previousTtr !== row.currentTtr + ? [{ playerName: row.playerName, previousTtr, currentTtr: row.currentTtr }] + : [] + }) + await sendOwnTtrChangePush(changes) await saveMyTischtennisConnection({ ...connection, lastSuccessfulImportAt: importedAt, lastImportError: null }) return { outputFile: OUTPUT_FILE, tableCount: 1, ...payload } }