13 Commits

Author SHA1 Message Date
7ac2fd39da Merge pull request 'dev' (#53) 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 2m32s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #53
2026-09-09 13:51:00 +02:00
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
64bd7d56e5 Merge pull request 'dev' (#51) 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 2m58s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #51
2026-09-05 02:28:02 +02:00
446ec752b4 Merge pull request 'Aktualisiere Footer-Stile und verbessere Zugänglichkeit; erhöhe die Version auf 1.8.8' (#50) 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 3m7s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #50
2026-08-27 13:24:17 +02:00
aa3d065294 Merge pull request 'dev' (#49) 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 3m1s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #49
2026-08-26 14:42:08 +02:00
ffc4b66601 Merge pull request 'dev' (#48) 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 3m3s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #48
2026-08-26 11:38:32 +02:00
f1fc5eafc3 Merge pull request 'dev' (#47) 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 3m57s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #47
2026-08-12 15:25:19 +02:00
42398dad21 Merge pull request 'dev' (#46) 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 2m47s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #46
2026-07-17 14:00:12 +02:00
d563d81584 Merge pull request 'Füge Funktion zur Umwandlung von Saison-Slugs in Labels hinzu und aktualisiere PDF-Daten mit saisonalen Informationen' (#45) 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 1m59s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #45
2026-07-15 11:38:20 +02:00
380d0a8332 Merge pull request 'dev' (#44) 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 2m41s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #44
2026-07-15 11:12:16 +02:00
301bd7acbd Merge pull request 'dev' (#43) 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 2m44s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #43
2026-06-23 23:50:21 +02:00
05e25aa3d1 Merge pull request 'dev' (#42) 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 2m15s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #42
2026-06-16 14:09:23 +02:00
ecae88af78 Merge pull request 'dev' (#41) 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 3m0s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #41
2026-06-10 16:49:07 +02:00
21 changed files with 103 additions and 318 deletions

2
.gitignore vendored
View File

@@ -94,8 +94,6 @@ 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 +1,4 @@
# Intentionally empty: retained because the CI scanner is invoked with --config. [[IgnoredVulns]]
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."

Binary file not shown.

View File

@@ -283,7 +283,6 @@ 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.registerCurrentDevice() pushTokenRepository.registerToken(token)
} }
} }

View File

@@ -81,7 +81,6 @@ 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,7 +1,6 @@
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
@@ -16,18 +15,16 @@ 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()
val installationId = FirebaseInstallations.getInstance().id.await() registerToken(token).getOrThrow()
registerToken(token, installationId).getOrThrow()
} }
suspend fun registerToken(token: String, installationId: String? = null): Result<Unit> = runCatching { suspend fun registerToken(token: String): 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=36 ANDROID_VERSION_CODE=35
ANDROID_VERSION_NAME=0.10.0 ANDROID_VERSION_NAME=0.9.30
# 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="/mitgliederbereich/mitglieder" 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" 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"
> >
Mitgliederliste Newsletter
</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> </NuxtLink>
</template>
<template v-if="canAccessContactRequests">
<div class="h-3 w-px bg-primary-700" /> <div class="h-3 w-px bg-primary-700" />
<NuxtLink <NuxtLink
to="/mitgliederbereich/api" 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" 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"
> >
API-Dokumentation Kontaktanfragen
</NuxtLink> </NuxtLink>
<template v-if="canAccessNewsletter"> </template>
<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.2", "version": "5.9.0",
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.9.2.tgz", "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.9.0.tgz",
"integrity": "sha512-po4PAY5c53tw5XMocSnf8A/5OHhbbUftpr93aEN6BBoAdntUmK7vu7wOATqvt7cXO7m1Cl4gMVn6p7n6n4mj0w==", "integrity": "sha512-RWrqdArjvPbsATEhOPUo6Wndc/iWnkWKlhIrdlF3zMMYo/c3CVtoaVAyLtWxz5h8nSlkHzxnzV2uLydPXmtF+A==",
"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.10", "version": "1.8.9",
"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,7 +68,6 @@
}, },
"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,18 +208,11 @@
> >
<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 <RefreshCw :size="24" class="text-teal-600 group-hover:text-white" />
:size="24"
class="text-teal-600 group-hover:text-white"
/>
</div> </div>
<h2 class="ml-4 text-xl font-semibold text-gray-900"> <h2 class="ml-4 text-xl font-semibold text-gray-900">myTischtennis</h2>
myTischtennis
</h2>
</div> </div>
<p class="text-gray-600"> <p class="text-gray-600">Vereinszugang und TTR-Synchronisierung</p>
Vereinszugang und TTR-Synchronisierung
</p>
</NuxtLink> </NuxtLink>
<!-- Benutzerverwaltung (Admin ODER Vorstand) --> <!-- Benutzerverwaltung (Admin ODER Vorstand) -->

View File

@@ -1,94 +1,33 @@
<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"> <h1 class="text-3xl font-display font-bold text-gray-900">myTischtennis-Verbindung</h1>
myTischtennis-Verbindung <p class="mt-2 text-gray-600">Vereinszugang für den geschützten Import aktueller TTR- und QTTR-Werte.</p>
</h1> <form class="mt-8 bg-white rounded-xl shadow-lg p-6 space-y-5" @submit.prevent="save">
<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 <input v-model.trim="form.email" type="email" required autocomplete="username" class="w-full rounded-lg border border-gray-300 px-3 py-2">
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 <input v-model="form.password" type="password" required autocomplete="current-password" class="w-full rounded-lg border border-gray-300 px-3 py-2">
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 <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>
v-model.trim="form.association" <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>
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 <p v-if="status.configured" class="text-sm text-gray-600">Hinterlegt: {{ status.email }} · letzter erfolgreicher Abruf: {{ formattedLastImport }}</p>
v-if="status.configured" <p v-if="status.lastImportError" class="rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-800">
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 <p v-if="message" class="text-sm" :class="error ? 'text-red-700' : 'text-green-700'">{{ message }}</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 <button :disabled="busy" class="rounded-lg bg-primary-600 px-4 py-2 font-semibold text-white disabled:opacity-60">Zugang speichern</button>
:disabled="busy" <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>
class="rounded-lg bg-primary-600 px-4 py-2 font-semibold text-white disabled:opacity-60" <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>
>
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,39 +13,13 @@
<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 class="flex flex-wrap items-end gap-x-4 gap-y-3"> <div>
<div> <h2 class="text-xl font-semibold text-gray-900">
<h2 class="text-xl font-semibold text-gray-900"> Harheimer TC Rangliste
Harheimer TC Rangliste </h2>
</h2> <p class="text-sm text-gray-600">
<p class="text-sm text-gray-600"> {{ data?.title || 'Andro-Rangliste' }} · {{ data?.rowCount || 0 }} Einträge
{{ data?.title || 'Andro-Rangliste' }} · {{ data?.rowCount || 0 }} Einträge </p>
</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) }}
@@ -72,7 +46,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">
Vereinsrang<br>(QTTR / TTR) Rang
</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
@@ -90,14 +64,12 @@
</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 sortedRows" v-for="row in data?.rows || []"
:key="row.playerNumber || row.playerName" :key="`${row.rank}-${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">
<span :title="clubRankTitle(row)"> {{ row.rank ?? '–' }}
{{ 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)]">
@@ -123,7 +95,7 @@
</template> </template>
<script setup> <script setup>
import { computed, ref } from 'vue' import { computed } from 'vue'
const authStore = useAuthStore() const authStore = useAuthStore()
@@ -137,34 +109,6 @@ 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, ' ')
@@ -189,14 +133,6 @@ 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,26 +37,6 @@ 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)) {
@@ -93,16 +73,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: rankedRows rows: Array.isArray(payload.rows)
? 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') {

View File

@@ -22,8 +22,7 @@ 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,21 +85,16 @@ function pushTokensForUser(user) {
: [] : []
} }
export function upsertPushToken(user, { token, platform = 'android', appVersion = null, installationId = null }) { export function upsertPushToken(user, { token, platform = 'android', appVersion = 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 : []
// A Firebase token can rotate for the same app installation. Retain tokens const next = tokens.filter(entry => entry?.token !== normalizedToken)
// 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
}) })
@@ -334,19 +329,6 @@ 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, sendQttrListUpdatedPush } from './push-notifications.js' import { sendOwnTtrChangePush } 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,26 +67,6 @@ 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])
} }
@@ -210,7 +190,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 = addClubRanks(rows.map((cells) => deriveQttrFields(headers, cells))) const parsedRows = 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(),
@@ -263,11 +243,7 @@ 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,
@@ -286,21 +262,6 @@ 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', installationId: 'firebase-installation-id' }) mockSuccessReadBody({ token: 'fcm-token', platform: 'android', appVersion: '1.0+1' })
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', installationId: 'firebase-installation-id' })] pushTokens: [expect.objectContaining({ token: 'fcm-token', platform: 'android', appVersion: '1.0+1' })]
}) })
]) ])
}) })