3 Commits

Author SHA1 Message Date
4705d411d5 Merge pull request 'dev' (#52) from dev into main
All checks were successful
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Successful in 2m53s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #52
2026-09-08 15:07:08 +02:00
Torsten Schulz (local)
65e7563e79 Aktualisiere Versionsnummer auf 1.8.9 in package.json
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 4m52s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 3m3s
2026-09-08 14:57:30 +02:00
Torsten Schulz (local)
9bc9501219 Füge Unterstützung für TTR-Änderungen hinzu: Erweitere Benachrichtigungseinstellungen und implementiere Push-Benachrichtigungen für TTR-Wertänderungen.
Some checks failed
Code Analysis and Production Deploy / deploy-production (push) Has been cancelled
Code Analysis and Production Deploy / deploy-test (push) Has been cancelled
Code Analysis and Production Deploy / analyze (push) Has been cancelled
2026-09-08 14:54:20 +02:00
13 changed files with 118 additions and 23 deletions

View File

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

View File

@@ -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<String> = 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,

View File

@@ -472,7 +472,7 @@ private fun submenu(section: MenuSection?, state: NavigationUiState): List<MenuT
MenuSection.INTERN -> 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))

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "harheimertc-website",
"version": "1.8.8",
"version": "1.8.9",
"description": "Moderne Webseite für den Harheimer Tischtennis Club",
"private": true,
"type": "module",

View File

@@ -27,6 +27,7 @@
<div class="flex gap-3 flex-wrap">
<button :disabled="busy" class="rounded-lg bg-primary-600 px-4 py-2 font-semibold text-white disabled:opacity-60">Zugang speichern</button>
<button type="button" :disabled="busy || !status.configured" class="rounded-lg border border-primary-600 px-4 py-2 font-semibold text-primary-700 disabled:opacity-60" @click="runImport">Jetzt abrufen</button>
<button v-if="authStore.hasRole('admin')" type="button" :disabled="busy" class="rounded-lg border border-teal-600 px-4 py-2 font-semibold text-teal-700 disabled:opacity-60" @click="sendTestPush">Push-Test an mich senden</button>
</div>
</form>
</div>
@@ -35,14 +36,17 @@
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { useAuthStore } from '~/stores/auth'
definePageMeta({ middleware: 'auth' })
useHead({ title: 'myTischtennis-Verbindung CMS' })
const form = reactive({ email: '', password: '', association: 'HeTTV', clubId: '43030' })
const authStore = useAuthStore()
const status = ref({ configured: false })
const busy = ref(false); const message = ref(''); const error = ref(false)
const formattedLastImport = computed(() => status.value.lastSuccessfulImportAt ? new Intl.DateTimeFormat('de-DE', { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(status.value.lastSuccessfulImportAt)) : 'noch nie')
async function load() { status.value = await $fetch('/api/cms/mytischtennis') }
async function save() { busy.value = true; message.value = ''; error.value = false; try { status.value = await $fetch('/api/cms/mytischtennis', { method: 'PUT', body: form }); form.password = ''; message.value = 'Zugang verschlüsselt gespeichert.' } catch { error.value = true; message.value = 'Zugang konnte nicht gespeichert werden.' } finally { busy.value = false } }
async function runImport() { busy.value = true; message.value = ''; error.value = false; try { const result = await $fetch('/api/cms/mytischtennis/import', { method: 'POST' }); message.value = `${result.rowCount} Werte wurden aktualisiert.`; await load() } catch (caught) { error.value = true; message.value = caught?.data?.message || 'Abruf fehlgeschlagen. Details stehen im Serverlog.'; await load().catch(() => {}) } finally { busy.value = false } }
async function sendTestPush() { busy.value = true; message.value = ''; error.value = false; try { const result = await $fetch('/api/cms/notifications/test', { method: 'POST' }); message.value = result.sent > 0 ? 'Push-Test wurde an deine registrierten Android-Geräte gesendet.' : 'Kein registriertes Android-Gerät gefunden. Bitte App einmal öffnen und Benachrichtigungen erlauben.' } catch (caught) { error.value = true; message.value = caught?.data?.statusMessage || caught?.data?.message || 'Push-Test konnte nicht gesendet werden.' } finally { busy.value = false } }
onMounted(load)
</script>

View File

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

View File

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

View File

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

View File

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