dev #54

Merged
torsten merged 9 commits from dev into main 2026-09-24 11:59:02 +02:00
21 changed files with 319 additions and 104 deletions

2
.gitignore vendored
View File

@@ -94,6 +94,8 @@ dist
/android-app/**/build/ /android-app/**/build/
/android-app/local.properties /android-app/local.properties
/android-app/gradle-local.properties /android-app/gradle-local.properties
# Android Play Store / release artifacts are generated locally or in CI.
/android-app/**/*.aab
# JVM Heap-Dumps sind lokale Diagnoseartefakte und dürfen nie ins Repository. # JVM Heap-Dumps sind lokale Diagnoseartefakte und dürfen nie ins Repository.
*.hprof *.hprof

View File

@@ -1,4 +1 @@
[[IgnoredVulns]] # Intentionally empty: retained because the CI scanner is invoked with --config.
id = "GHSA-v3m3-f69x-jf25"
ignoreUntil = 2026-12-31
reason = "Temporary exception: Quill 2.0.3 is required by the current RichTextEditor implementation, and OSV currently reports no fixed version. Track upstream fix and remove this ignore once a patched release is available."

View File

@@ -283,6 +283,7 @@ data class PushTokenRequest(
val token: String, val token: String,
val platform: String = "android", val platform: String = "android",
val appVersion: String? = null, val appVersion: String? = null,
val installationId: String? = null,
) )
data class BirthdayDto( data class BirthdayDto(
val name: String = "", val name: String = "",

View File

@@ -21,7 +21,7 @@ class HarheimerMessagingService : FirebaseMessagingService() {
override fun onNewToken(token: String) { override fun onNewToken(token: String) {
super.onNewToken(token) super.onNewToken(token)
serviceScope.launch { serviceScope.launch {
pushTokenRepository.registerToken(token) pushTokenRepository.registerCurrentDevice()
} }
} }

View File

@@ -81,6 +81,7 @@ object HarheimerNotifications {
"news", "news_expiring" -> Destinations.MemberNews.route "news", "news_expiring" -> Destinations.MemberNews.route
"event", "events_today", "events_tomorrow" -> Destinations.Termine.route "event", "events_today", "events_tomorrow" -> Destinations.Termine.route
"team_matches" -> Destinations.Spielplan.route "team_matches" -> Destinations.Spielplan.route
"qttr_list_updated", "ttr_change" -> Destinations.Qttr.route
"birthdays" -> Destinations.MemberArea.route "birthdays" -> Destinations.MemberArea.route
"contact_request" -> Destinations.CmsContactRequests.route "contact_request" -> Destinations.CmsContactRequests.route
"user_registration" -> Destinations.CmsBenutzer.route "user_registration" -> Destinations.CmsBenutzer.route

View File

@@ -1,6 +1,7 @@
package de.harheimertc.repositories package de.harheimertc.repositories
import android.util.Log import android.util.Log
import com.google.firebase.installations.FirebaseInstallations
import com.google.firebase.messaging.FirebaseMessaging import com.google.firebase.messaging.FirebaseMessaging
import de.harheimertc.BuildConfig import de.harheimertc.BuildConfig
import de.harheimertc.data.ApiService import de.harheimertc.data.ApiService
@@ -15,16 +16,18 @@ class PushTokenRepository @Inject constructor(
) { ) {
suspend fun registerCurrentDevice(): Result<Unit> = runCatching { suspend fun registerCurrentDevice(): Result<Unit> = runCatching {
val token = FirebaseMessaging.getInstance().token.await() val token = FirebaseMessaging.getInstance().token.await()
registerToken(token).getOrThrow() val installationId = FirebaseInstallations.getInstance().id.await()
registerToken(token, installationId).getOrThrow()
} }
suspend fun registerToken(token: String): Result<Unit> = runCatching { suspend fun registerToken(token: String, installationId: String? = null): Result<Unit> = runCatching {
if (token.isBlank()) return@runCatching if (token.isBlank()) return@runCatching
retryOnNetworkFailure { retryOnNetworkFailure {
val response = api.registerPushToken( val response = api.registerPushToken(
PushTokenRequest( PushTokenRequest(
token = token, token = token,
appVersion = "${BuildConfig.VERSION_NAME}+${BuildConfig.VERSION_CODE}", appVersion = "${BuildConfig.VERSION_NAME}+${BuildConfig.VERSION_CODE}",
installationId = installationId,
), ),
) )
if (!response.isSuccessful) { if (!response.isSuccessful) {

View File

@@ -8,8 +8,8 @@ LOCAL_API_BASE_URL=https://harheimertc.tsschulz.de/
PRODUCTION_API_BASE_URL=https://harheimertc.de/ PRODUCTION_API_BASE_URL=https://harheimertc.de/
# Android app versioning for Play Store uploads # Android app versioning for Play Store uploads
ANDROID_VERSION_CODE=35 ANDROID_VERSION_CODE=36
ANDROID_VERSION_NAME=0.9.30 ANDROID_VERSION_NAME=0.10.0
# Temporary hotfix: disable R8 minification for release to avoid Retrofit generic signature stripping. # Temporary hotfix: disable R8 minification for release to avoid Retrofit generic signature stripping.
RELEASE_MINIFY_ENABLED=false RELEASE_MINIFY_ENABLED=false

View File

@@ -292,69 +292,69 @@
<template v-if="currentSubmenu === 'intern'"> <template v-if="currentSubmenu === 'intern'">
<div class="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto whitespace-nowrap"> <div class="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto whitespace-nowrap">
<NuxtLink <NuxtLink
to="/mitgliederbereich" to="/mitgliederbereich"
class="px-2.5 py-1 text-xs font-semibold text-white hover:bg-primary-700/50 rounded transition-all" class="px-2.5 py-1 text-xs font-semibold text-white hover:bg-primary-700/50 rounded transition-all"
active-class="bg-primary-600" active-class="bg-primary-600"
> >
Übersicht Übersicht
</NuxtLink> </NuxtLink>
<div class="h-3 w-px bg-primary-700" />
<NuxtLink
to="/mitgliederbereich/mitglieder"
class="px-2.5 py-1 text-xs text-gray-300 hover:text-white hover:bg-primary-700/50 rounded transition-all"
active-class="text-white bg-primary-600"
>
Mitgliederliste
</NuxtLink>
<NuxtLink
to="/mitgliederbereich/qttr"
class="px-2.5 py-1 text-xs text-gray-300 hover:text-white hover:bg-primary-700/50 rounded transition-all"
active-class="text-white bg-primary-600"
>
TTR / QTTR
</NuxtLink>
<NuxtLink
to="/mitgliederbereich/news"
class="px-2.5 py-1 text-xs text-gray-300 hover:text-white hover:bg-primary-700/50 rounded transition-all"
active-class="text-white bg-primary-600"
>
News
</NuxtLink>
<NuxtLink
to="/mitgliederbereich/profil"
class="px-2.5 py-1 text-xs text-gray-300 hover:text-white hover:bg-primary-700/50 rounded transition-all"
active-class="text-white bg-primary-600"
>
Mein Profil
</NuxtLink>
<div class="h-3 w-px bg-primary-700" />
<NuxtLink
to="/mitgliederbereich/api"
class="px-2.5 py-1 text-xs text-gray-300 hover:text-white hover:bg-primary-700/50 rounded transition-all"
active-class="text-white bg-primary-600"
>
API-Dokumentation
</NuxtLink>
<template v-if="canAccessNewsletter">
<div class="h-3 w-px bg-primary-700" /> <div class="h-3 w-px bg-primary-700" />
<NuxtLink <NuxtLink
to="/cms/newsletter" to="/mitgliederbereich/mitglieder"
class="px-2.5 py-1 text-xs text-gray-300 hover:text-white hover:bg-primary-700/50 rounded transition-all" class="px-2.5 py-1 text-xs text-gray-300 hover:text-white hover:bg-primary-700/50 rounded transition-all"
active-class="text-white bg-primary-600" active-class="text-white bg-primary-600"
> >
Newsletter Mitgliederliste
</NuxtLink> </NuxtLink>
</template>
<template v-if="canAccessContactRequests">
<div class="h-3 w-px bg-primary-700" />
<NuxtLink <NuxtLink
to="/cms/kontaktanfragen" to="/mitgliederbereich/qttr"
class="px-2.5 py-1 text-xs text-gray-300 hover:text-white hover:bg-primary-700/50 rounded transition-all" class="px-2.5 py-1 text-xs text-gray-300 hover:text-white hover:bg-primary-700/50 rounded transition-all"
active-class="text-white bg-primary-600" active-class="text-white bg-primary-600"
> >
Kontaktanfragen TTR / QTTR
</NuxtLink> </NuxtLink>
</template> <NuxtLink
to="/mitgliederbereich/news"
class="px-2.5 py-1 text-xs text-gray-300 hover:text-white hover:bg-primary-700/50 rounded transition-all"
active-class="text-white bg-primary-600"
>
News
</NuxtLink>
<NuxtLink
to="/mitgliederbereich/profil"
class="px-2.5 py-1 text-xs text-gray-300 hover:text-white hover:bg-primary-700/50 rounded transition-all"
active-class="text-white bg-primary-600"
>
Mein Profil
</NuxtLink>
<div class="h-3 w-px bg-primary-700" />
<NuxtLink
to="/mitgliederbereich/api"
class="px-2.5 py-1 text-xs text-gray-300 hover:text-white hover:bg-primary-700/50 rounded transition-all"
active-class="text-white bg-primary-600"
>
API-Dokumentation
</NuxtLink>
<template v-if="canAccessNewsletter">
<div class="h-3 w-px bg-primary-700" />
<NuxtLink
to="/cms/newsletter"
class="px-2.5 py-1 text-xs text-gray-300 hover:text-white hover:bg-primary-700/50 rounded transition-all"
active-class="text-white bg-primary-600"
>
Newsletter
</NuxtLink>
</template>
<template v-if="canAccessContactRequests">
<div class="h-3 w-px bg-primary-700" />
<NuxtLink
to="/cms/kontaktanfragen"
class="px-2.5 py-1 text-xs text-gray-300 hover:text-white hover:bg-primary-700/50 rounded transition-all"
active-class="text-white bg-primary-600"
>
Kontaktanfragen
</NuxtLink>
</template>
</div> </div>
<template v-if="isAdmin"> <template v-if="isAdmin">
<div class="h-3 w-px shrink-0 bg-primary-700" /> <div class="h-3 w-px shrink-0 bg-primary-700" />

6
package-lock.json generated
View File

@@ -5252,9 +5252,9 @@
} }
}, },
"node_modules/devalue": { "node_modules/devalue": {
"version": "5.9.0", "version": "5.9.2",
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.9.0.tgz", "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.9.2.tgz",
"integrity": "sha512-RWrqdArjvPbsATEhOPUo6Wndc/iWnkWKlhIrdlF3zMMYo/c3CVtoaVAyLtWxz5h8nSlkHzxnzV2uLydPXmtF+A==", "integrity": "sha512-po4PAY5c53tw5XMocSnf8A/5OHhbbUftpr93aEN6BBoAdntUmK7vu7wOATqvt7cXO7m1Cl4gMVn6p7n6n4mj0w==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/dezalgo": { "node_modules/dezalgo": {

View File

@@ -1,6 +1,6 @@
{ {
"name": "harheimertc-website", "name": "harheimertc-website",
"version": "1.8.9", "version": "1.8.10",
"description": "Moderne Webseite für den Harheimer Tischtennis Club", "description": "Moderne Webseite für den Harheimer Tischtennis Club",
"private": true, "private": true,
"type": "module", "type": "module",
@@ -68,6 +68,7 @@
}, },
"overrides": { "overrides": {
"@peculiar/x509": "1.13.0", "@peculiar/x509": "1.13.0",
"devalue": "5.9.2",
"esbuild": "0.28.1", "esbuild": "0.28.1",
"qs": "6.16.0" "qs": "6.16.0"
} }

View File

@@ -208,11 +208,18 @@
> >
<div class="flex items-center mb-4"> <div class="flex items-center mb-4">
<div class="w-12 h-12 bg-teal-100 rounded-lg flex items-center justify-center group-hover:bg-teal-600 transition-colors"> <div class="w-12 h-12 bg-teal-100 rounded-lg flex items-center justify-center group-hover:bg-teal-600 transition-colors">
<RefreshCw :size="24" class="text-teal-600 group-hover:text-white" /> <RefreshCw
:size="24"
class="text-teal-600 group-hover:text-white"
/>
</div> </div>
<h2 class="ml-4 text-xl font-semibold text-gray-900">myTischtennis</h2> <h2 class="ml-4 text-xl font-semibold text-gray-900">
myTischtennis
</h2>
</div> </div>
<p class="text-gray-600">Vereinszugang und TTR-Synchronisierung</p> <p class="text-gray-600">
Vereinszugang und TTR-Synchronisierung
</p>
</NuxtLink> </NuxtLink>
<!-- Benutzerverwaltung (Admin ODER Vorstand) --> <!-- Benutzerverwaltung (Admin ODER Vorstand) -->

View File

@@ -1,33 +1,94 @@
<template> <template>
<div class="min-h-screen bg-gray-50 py-10"> <div class="min-h-screen bg-gray-50 py-10">
<div class="max-w-2xl mx-auto px-4"> <div class="max-w-2xl mx-auto px-4">
<h1 class="text-3xl font-display font-bold text-gray-900">myTischtennis-Verbindung</h1> <h1 class="text-3xl font-display font-bold text-gray-900">
<p class="mt-2 text-gray-600">Vereinszugang für den geschützten Import aktueller TTR- und QTTR-Werte.</p> myTischtennis-Verbindung
<form class="mt-8 bg-white rounded-xl shadow-lg p-6 space-y-5" @submit.prevent="save"> </h1>
<p class="mt-2 text-gray-600">
Vereinszugang für den geschützten Import aktueller TTR- und QTTR-Werte.
</p>
<form
class="mt-8 bg-white rounded-xl shadow-lg p-6 space-y-5"
@submit.prevent="save"
>
<div class="rounded-lg bg-amber-50 border border-amber-200 p-4 text-sm text-amber-900"> <div class="rounded-lg bg-amber-50 border border-amber-200 p-4 text-sm text-amber-900">
Das Passwort wird verschlüsselt auf dem Server gespeichert und nie wieder angezeigt. Zugriff haben ausschließlich Admin und Vorstand. Das Passwort wird verschlüsselt auf dem Server gespeichert und nie wieder angezeigt. Zugriff haben ausschließlich Admin und Vorstand.
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-1">myTischtennis-E-Mail</label> <label class="block text-sm font-medium text-gray-700 mb-1">myTischtennis-E-Mail</label>
<input v-model.trim="form.email" type="email" required autocomplete="username" class="w-full rounded-lg border border-gray-300 px-3 py-2"> <input
v-model.trim="form.email"
type="email"
required
autocomplete="username"
class="w-full rounded-lg border border-gray-300 px-3 py-2"
>
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-1">Passwort</label> <label class="block text-sm font-medium text-gray-700 mb-1">Passwort</label>
<input v-model="form.password" type="password" required autocomplete="current-password" class="w-full rounded-lg border border-gray-300 px-3 py-2"> <input
v-model="form.password"
type="password"
required
autocomplete="current-password"
class="w-full rounded-lg border border-gray-300 px-3 py-2"
>
</div> </div>
<div class="grid sm:grid-cols-2 gap-4"> <div class="grid sm:grid-cols-2 gap-4">
<label class="block text-sm font-medium text-gray-700">Verband<input v-model.trim="form.association" required class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2"></label> <label class="block text-sm font-medium text-gray-700">Verband<input
<label class="block text-sm font-medium text-gray-700">Vereinsnummer<input v-model.trim="form.clubId" required class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2"></label> v-model.trim="form.association"
required
class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2"
></label>
<label class="block text-sm font-medium text-gray-700">Vereinsnummer<input
v-model.trim="form.clubId"
required
class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2"
></label>
</div> </div>
<p v-if="status.configured" class="text-sm text-gray-600">Hinterlegt: {{ status.email }} · letzter erfolgreicher Abruf: {{ formattedLastImport }}</p> <p
<p v-if="status.lastImportError" class="rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-800"> v-if="status.configured"
class="text-sm text-gray-600"
>
Hinterlegt: {{ status.email }} · letzter erfolgreicher Abruf: {{ formattedLastImport }}
</p>
<p
v-if="status.lastImportError"
class="rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-800"
>
Letzter Abruf fehlgeschlagen: {{ status.lastImportError }} Letzter Abruf fehlgeschlagen: {{ status.lastImportError }}
</p> </p>
<p v-if="message" class="text-sm" :class="error ? 'text-red-700' : 'text-green-700'">{{ message }}</p> <p
v-if="message"
class="text-sm"
:class="error ? 'text-red-700' : 'text-green-700'"
>
{{ message }}
</p>
<div class="flex gap-3 flex-wrap"> <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
<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> :disabled="busy"
<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> 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> </div>
</form> </form>
</div> </div>

View File

@@ -13,13 +13,39 @@
<div class="bg-white rounded-xl shadow-lg border border-gray-100 p-6"> <div class="bg-white rounded-xl shadow-lg border border-gray-100 p-6">
<div class="flex flex-wrap items-center gap-4 justify-between mb-4"> <div class="flex flex-wrap items-center gap-4 justify-between mb-4">
<div> <div class="flex flex-wrap items-end gap-x-4 gap-y-3">
<h2 class="text-xl font-semibold text-gray-900"> <div>
Harheimer TC Rangliste <h2 class="text-xl font-semibold text-gray-900">
</h2> Harheimer TC Rangliste
<p class="text-sm text-gray-600"> </h2>
{{ data?.title || 'Andro-Rangliste' }} · {{ data?.rowCount || 0 }} Einträge <p class="text-sm text-gray-600">
</p> {{ data?.title || 'Andro-Rangliste' }} · {{ data?.rowCount || 0 }} Einträge
</p>
</div>
<div
class="inline-flex rounded-md border border-gray-200 bg-gray-50 p-0.5 shadow-sm"
role="group"
aria-label="Rangliste sortieren nach"
>
<button
type="button"
class="rounded px-3 py-1.5 text-sm font-semibold transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-600 focus-visible:ring-offset-2"
:class="sortBy === 'qttr' ? 'bg-primary-600 text-white shadow-sm' : 'text-gray-700 hover:bg-white hover:text-primary-700'"
:aria-pressed="sortBy === 'qttr'"
@click="sortBy = 'qttr'"
>
QTTR
</button>
<button
type="button"
class="rounded px-3 py-1.5 text-sm font-semibold transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-600 focus-visible:ring-offset-2"
:class="sortBy === 'ttr' ? 'bg-primary-600 text-white shadow-sm' : 'text-gray-700 hover:bg-white hover:text-primary-700'"
:aria-pressed="sortBy === 'ttr'"
@click="sortBy = 'ttr'"
>
TTR
</button>
</div>
</div> </div>
<div class="text-sm text-gray-500"> <div class="text-sm text-gray-500">
Aktualisiert: {{ formatDate(data?.importedAt) }} Aktualisiert: {{ formatDate(data?.importedAt) }}
@@ -46,7 +72,7 @@
<thead class="bg-gray-50"> <thead class="bg-gray-50">
<tr> <tr>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500"> <th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500">
Rang Vereinsrang<br>(QTTR / TTR)
</th> </th>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500"> <th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500">
Spieler Spieler
@@ -64,12 +90,14 @@
</thead> </thead>
<tbody class="divide-y divide-gray-100 bg-white"> <tbody class="divide-y divide-gray-100 bg-white">
<tr <tr
v-for="row in data?.rows || []" v-for="row in sortedRows"
:key="`${row.rank}-${row.playerNumber || row.playerName}`" :key="row.playerNumber || row.playerName"
:class="isOwnRow(row.playerName) ? 'bg-primary-100' : ''" :class="isOwnRow(row.playerName) ? 'bg-primary-100' : ''"
> >
<td class="px-4 py-3 text-sm text-gray-600"> <td class="px-4 py-3 text-sm text-gray-600">
{{ row.rank ?? '–' }} <span :title="clubRankTitle(row)">
{{ formatClubRank(row) }}
</span>
</td> </td>
<td class="px-4 py-3"> <td class="px-4 py-3">
<div :class="['font-medium', getPlayerNameClass(row)]"> <div :class="['font-medium', getPlayerNameClass(row)]">
@@ -95,7 +123,7 @@
</template> </template>
<script setup> <script setup>
import { computed } from 'vue' import { computed, ref } from 'vue'
const authStore = useAuthStore() const authStore = useAuthStore()
@@ -109,6 +137,34 @@ await authStore.checkAuth()
const { data, pending, error } = await useFetch('/api/mitgliederbereich/qttr') const { data, pending, error } = await useFetch('/api/mitgliederbereich/qttr')
const currentUserName = computed(() => authStore.user?.name?.trim() || '') const currentUserName = computed(() => authStore.user?.name?.trim() || '')
const sortBy = ref('qttr')
const sortedRows = computed(() => {
const rows = [...(data.value?.rows || [])]
const byName = (a, b) => String(a.playerName || '').localeCompare(String(b.playerName || ''), 'de')
const byRating = (key) => (a, b) => b[key] - a[key] || byName(a, b)
if (sortBy.value === 'ttr') {
return rows.sort((a, b) => {
const aMissing = a.currentTtr == null
const bMissing = b.currentTtr == null
if (aMissing !== bMissing) return aMissing ? 1 : -1
return aMissing ? byName(a, b) : byRating('currentTtr')(a, b)
})
}
return rows.sort((a, b) => {
const aMissing = a.currentQttr == null
const bMissing = b.currentQttr == null
if (aMissing !== bMissing) return aMissing ? 1 : -1
if (!aMissing) return byRating('currentQttr')(a, b)
const aMissingTtr = a.currentTtr == null
const bMissingTtr = b.currentTtr == null
if (aMissingTtr !== bMissingTtr) return aMissingTtr ? 1 : -1
return aMissingTtr ? byName(a, b) : byRating('currentTtr')(a, b)
})
})
function normalizeName(value) { function normalizeName(value) {
return String(value || '').trim().toLowerCase().replace(/\s+/g, ' ') return String(value || '').trim().toLowerCase().replace(/\s+/g, ' ')
@@ -133,6 +189,14 @@ function isOwnRow(playerName) {
return normalizeName(playerName) === current return normalizeName(playerName) === current
} }
function formatClubRank(row) {
return `${row.clubQttrRank ?? '–'} (${row.clubTtrRank ?? '–'})`
}
function clubRankTitle(row) {
return `QTTR-Platzierung im Verein: ${row.clubQttrRank ?? 'nicht verfügbar'}; TTR-Platzierung im Verein: ${row.clubTtrRank ?? 'nicht verfügbar'}`
}
function getPlayerNameClass(row) { function getPlayerNameClass(row) {
const minor = isMinor(row.birthdate) const minor = isMinor(row.birthdate)
if (minor && isMaleGender(row.gender)) return 'text-blue-400' if (minor && isMaleGender(row.gender)) return 'text-blue-400'

View File

@@ -37,6 +37,26 @@ function buildBirthdateLookup(entries) {
return lookup return lookup
} }
function addClubRanks(rows) {
const addRankFor = (valueKey, rankKey) => {
rows
.filter((row) => row[valueKey] != null)
.sort((a, b) => b[valueKey] - a[valueKey] || String(a.playerName || '').localeCompare(String(b.playerName || ''), 'de'))
.forEach((row, index) => {
row[rankKey] = index + 1
})
}
addRankFor('currentQttr', 'clubQttrRank')
addRankFor('currentTtr', 'clubTtrRank')
return rows.sort((a, b) =>
(a.clubQttrRank ?? Number.POSITIVE_INFINITY) - (b.clubQttrRank ?? Number.POSITIVE_INFINITY)
|| (a.clubTtrRank ?? Number.POSITIVE_INFINITY) - (b.clubTtrRank ?? Number.POSITIVE_INFINITY)
|| String(a.playerName || '').localeCompare(String(b.playerName || ''), 'de')
)
}
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '') const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '')
if (!token || !verifyToken(token)) { if (!token || !verifyToken(token)) {
@@ -73,16 +93,16 @@ export default defineEventHandler(async (event) => {
].flatMap(entry => [entry?.name, `${entry?.firstName || ''} ${entry?.lastName || ''}`.trim()]).map(normalizeName).filter(Boolean)) ].flatMap(entry => [entry?.name, `${entry?.firstName || ''} ${entry?.lastName || ''}`.trim()]).map(normalizeName).filter(Boolean))
const birthdateLookup = buildBirthdateLookup([...visibleManualMembers, ...visibleUsers]) const birthdateLookup = buildBirthdateLookup([...visibleManualMembers, ...visibleUsers])
const rankedRows = addClubRanks(Array.isArray(payload.rows) ? payload.rows.map(row => ({ ...row })) : [])
return { return {
...payload, ...payload,
rows: Array.isArray(payload.rows) rows: rankedRows
? payload.rows
.filter(row => !hiddenNames.has(normalizeName(row.playerName))) .filter(row => !hiddenNames.has(normalizeName(row.playerName)))
.map((row) => ({ .map((row) => ({
...row, ...row,
birthdate: birthdateLookup.get(normalizeName(row.playerName)) || row.birthdate || '' birthdate: birthdateLookup.get(normalizeName(row.playerName)) || row.birthdate || ''
})) }))
: []
} }
} catch (error) { } catch (error) {
if (error?.code === 'ENOENT') { if (error?.code === 'ENOENT') {
@@ -98,4 +118,4 @@ export default defineEventHandler(async (event) => {
message: 'Fehler beim Laden der QTTR-Werte.' message: 'Fehler beim Laden der QTTR-Werte.'
}) })
} }
}) })

View File

@@ -22,7 +22,8 @@ export default defineEventHandler(async (event) => {
upsertPushToken(users[userIndex], { upsertPushToken(users[userIndex], {
token: body.token, token: body.token,
platform: body.platform || 'android', platform: body.platform || 'android',
appVersion: body.appVersion || null appVersion: body.appVersion || null,
installationId: body.installationId || null
}) })
await writeUsers(users) await writeUsers(users)
return { success: true, message: 'Push-Token gespeichert.' } return { success: true, message: 'Push-Token gespeichert.' }

View File

@@ -85,16 +85,21 @@ function pushTokensForUser(user) {
: [] : []
} }
export function upsertPushToken(user, { token, platform = 'android', appVersion = null }) { export function upsertPushToken(user, { token, platform = 'android', appVersion = null, installationId = null }) {
const normalizedToken = String(token || '').trim() const normalizedToken = String(token || '').trim()
if (!normalizedToken) return user if (!normalizedToken) return user
const normalizedInstallationId = String(installationId || '').trim().slice(0, 200) || null
const now = new Date().toISOString() const now = new Date().toISOString()
const tokens = Array.isArray(user.pushTokens) ? user.pushTokens : [] const tokens = Array.isArray(user.pushTokens) ? user.pushTokens : []
const next = tokens.filter(entry => entry?.token !== normalizedToken) // A Firebase token can rotate for the same app installation. Retain tokens
// from other devices, but replace the previous token of this installation.
const next = tokens.filter(entry => entry?.token !== normalizedToken &&
(!normalizedInstallationId || entry?.installationId !== normalizedInstallationId))
next.push({ next.push({
token: normalizedToken, token: normalizedToken,
platform: String(platform || 'android').slice(0, 30), platform: String(platform || 'android').slice(0, 30),
appVersion: appVersion ? String(appVersion).slice(0, 80) : null, appVersion: appVersion ? String(appVersion).slice(0, 80) : null,
installationId: normalizedInstallationId,
updatedAt: now, updatedAt: now,
createdAt: tokens.find(entry => entry?.token === normalizedToken)?.createdAt || now createdAt: tokens.find(entry => entry?.token === normalizedToken)?.createdAt || now
}) })
@@ -329,6 +334,19 @@ export async function sendOwnTtrChangePush(changes = []) {
}) })
} }
export async function sendQttrListUpdatedPush({ importedAt, rowCount }) {
return sendPushToUsers({
title: 'QTTR-Liste aktualisiert',
body: `Die aktuelle TTR- und QTTR-Liste wurde aktualisiert (${rowCount} Einträge).`,
data: {
type: 'qttr_list_updated',
importedAt: importedAt || '',
notificationId: notificationIdFor(`qttr-list:${importedAt || Date.now()}`)
},
failureLabel: 'FCM QTTR-Listen-Push'
})
}
export async function sendTestPushToUser(userId) { export async function sendTestPushToUser(userId) {
return sendPushToUsers({ return sendPushToUsers({
title: 'Harheimer TC: Push-Test', title: 'Harheimer TC: Push-Test',

View File

@@ -1,6 +1,6 @@
import { promises as fs } from 'fs' import { promises as fs } from 'fs'
import { getServerDataPath } from './paths.js' import { getServerDataPath } from './paths.js'
import { sendOwnTtrChangePush } from './push-notifications.js' import { sendOwnTtrChangePush, sendQttrListUpdatedPush } from './push-notifications.js'
import { fetchClubRankings } from './mytischtennis-client.js' import { fetchClubRankings } from './mytischtennis-client.js'
import { readMyTischtennisConnection, saveMyTischtennisConnection } from './mytischtennis-connection.js' import { readMyTischtennisConnection, saveMyTischtennisConnection } from './mytischtennis-connection.js'
@@ -67,6 +67,26 @@ function normalizeGender(value) {
return normalized || null return normalized || null
} }
function addClubRanks(rows) {
const addRankFor = (valueKey, rankKey) => {
rows
.filter((row) => row[valueKey] != null)
.sort((a, b) => b[valueKey] - a[valueKey] || String(a.playerName || '').localeCompare(String(b.playerName || ''), 'de'))
.forEach((row, index) => {
row[rankKey] = index + 1
})
}
addRankFor('currentQttr', 'clubQttrRank')
addRankFor('currentTtr', 'clubTtrRank')
return rows.sort((a, b) =>
(a.clubQttrRank ?? Number.POSITIVE_INFINITY) - (b.clubQttrRank ?? Number.POSITIVE_INFINITY)
|| (a.clubTtrRank ?? Number.POSITIVE_INFINITY) - (b.clubTtrRank ?? Number.POSITIVE_INFINITY)
|| String(a.playerName || '').localeCompare(String(b.playerName || ''), 'de')
)
}
function extractTableBlocks(html) { function extractTableBlocks(html) {
return [...String(html || '').matchAll(/<table\b[^>]*>[\s\S]*?<\/table>/gi)].map((match) => match[0]) return [...String(html || '').matchAll(/<table\b[^>]*>[\s\S]*?<\/table>/gi)].map((match) => match[0])
} }
@@ -190,7 +210,7 @@ export async function importQttrValues(options = {}) {
throw new Error('QTTR-Tabelle ist leer oder unvollständig') throw new Error('QTTR-Tabelle ist leer oder unvollständig')
} }
const parsedRows = rows.map((cells) => deriveQttrFields(headers, cells)) const parsedRows = addClubRanks(rows.map((cells) => deriveQttrFields(headers, cells)))
const payload = { const payload = {
format: 'harheimertc.qttr.v1', format: 'harheimertc.qttr.v1',
importedAt: new Date().toISOString(), importedAt: new Date().toISOString(),
@@ -243,7 +263,11 @@ async function importAuthenticatedTtrValues(connection) {
}) })
}).filter(row => row.playerName && (row.currentTtr != null || row.currentQttr != null)) }).filter(row => row.playerName && (row.currentTtr != null || row.currentQttr != null))
addClubRanks(rows)
if (!rows.length) throw new Error('Keine verwertbaren TTR-Werte in der myTischtennis-Rangliste gefunden.') if (!rows.length) throw new Error('Keine verwertbaren TTR-Werte in der myTischtennis-Rangliste gefunden.')
const previousQttrSignature = qttrListSignature(previousPayload.rows)
const nextQttrSignature = qttrListSignature(rows)
const importedAt = new Date().toISOString() const importedAt = new Date().toISOString()
const payload = { const payload = {
format: 'harheimertc.qttr.v2', importedAt, format: 'harheimertc.qttr.v2', importedAt,
@@ -262,6 +286,21 @@ async function importAuthenticatedTtrValues(connection) {
: [] : []
}) })
await sendOwnTtrChangePush(changes) await sendOwnTtrChangePush(changes)
if (previousQttrSignature && previousQttrSignature !== nextQttrSignature) {
await sendQttrListUpdatedPush({ importedAt, rowCount: rows.length })
}
await saveMyTischtennisConnection({ ...connection, lastSuccessfulImportAt: importedAt, lastImportError: null }) await saveMyTischtennisConnection({ ...connection, lastSuccessfulImportAt: importedAt, lastImportError: null })
return { outputFile: OUTPUT_FILE, tableCount: 1, ...payload } return { outputFile: OUTPUT_FILE, tableCount: 1, ...payload }
} }
function qttrListSignature(rows) {
if (!Array.isArray(rows) || rows.length === 0) return ''
return rows
.map(row => [
String(row?.playerName || '').trim().toLocaleLowerCase('de-DE'),
row?.rank ?? '',
row?.currentQttr ?? ''
].join('|'))
.sort()
.join('\n')
}

View File

@@ -247,7 +247,7 @@ describe('Config & Profil Endpoints', () => {
it('speichert Android-Push-Token am Benutzer', async () => { it('speichert Android-Push-Token am Benutzer', async () => {
const event = createEvent({ headers: { authorization: 'Bearer android-token' } }) const event = createEvent({ headers: { authorization: 'Bearer android-token' } })
mockSuccessReadBody({ token: 'fcm-token', platform: 'android', appVersion: '1.0+1' }) mockSuccessReadBody({ token: 'fcm-token', platform: 'android', appVersion: '1.0+1', installationId: 'firebase-installation-id' })
const users = [{ id: '1', email: 'max@test.de', roles: ['mitglied'] }] const users = [{ id: '1', email: 'max@test.de', roles: ['mitglied'] }]
authUtils.verifyToken.mockReturnValue({ id: '1' }) authUtils.verifyToken.mockReturnValue({ id: '1' })
authUtils.getUserFromToken.mockResolvedValue(users[0]) authUtils.getUserFromToken.mockResolvedValue(users[0])
@@ -260,7 +260,7 @@ describe('Config & Profil Endpoints', () => {
expect(authUtils.writeUsers).toHaveBeenCalledWith([ expect(authUtils.writeUsers).toHaveBeenCalledWith([
expect.objectContaining({ expect.objectContaining({
id: '1', id: '1',
pushTokens: [expect.objectContaining({ token: 'fcm-token', platform: 'android', appVersion: '1.0+1' })] pushTokens: [expect.objectContaining({ token: 'fcm-token', platform: 'android', appVersion: '1.0+1', installationId: 'firebase-installation-id' })]
}) })
]) ])
}) })