9 Commits

Author SHA1 Message Date
Torsten Schulz (local)
1fc28a6438 Verbessere die Benutzeroberfläche der CMS-Seiten durch Anpassungen an der Navigation und dem myTischtennis-Formular. Optimiere die Anzeige der Vereinsränge in der QTTR-Tabelle.
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 5m51s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 3m14s
2026-09-24 11:43:18 +02:00
Torsten Schulz (local)
d3e6fbf49b Füge Sortierfunktion für QTTR und TTR zur Rangliste hinzu und aktualisiere die Anzeige der Vereinsränge
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 5m37s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 3m5s
2026-09-24 11:26:49 +02:00
Torsten Schulz (local)
a9ce55699e Füge .osv-scanner.toml Datei hinzu, um die CI-Scanner-Konfiguration zu unterstützen.
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 6m31s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 3m36s
2026-09-24 11:00:36 +02:00
Torsten Schulz (local)
3996e8d68c Aktualisiere die Version von devalue auf 5.9.2 und entferne die .osv-scanner.toml Datei.
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 5m29s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
2026-09-24 10:43:54 +02:00
Torsten Schulz (local)
1a7ba583d8 Aktualisiere die Version auf 1.8.10 und füge Vereinsrang (QTTR / TTR) zur QTTR-Tabelle hinzu. Implementiere die Logik zur Berechnung und Sortierung der Vereinsränge in den API- und Import-Skripten.
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 5m18s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
2026-09-23 16:00:06 +02:00
Torsten Schulz (local)
645e4b9655 Füge .aab-Dateien zum .gitignore hinzu und aktualisiere die Android-Version auf 0.10.0
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 3m42s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m19s
2026-09-11 15:15:17 +02:00
Torsten Schulz (local)
93b76d4446 Entferne nicht mehr benötigte AAB-Binärdateien für Instant, Local und Production Builds.
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-11 15:15:11 +02:00
Torsten Schulz (local)
2842c89bef Füge Unterstützung für QTTR-Listenaktualisierungen hinzu: Implementiere Push-Benachrichtigung für aktualisierte QTTR-Listen und verbessere die Importlogik.
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 4m6s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m41s
2026-09-11 15:07:30 +02:00
Torsten Schulz (local)
dc08ae98ea Füge Unterstützung für die Installation-ID in Push-Token-Registrierung hinzu: Aktualisiere API- und Datenbank-Logik zur Speicherung der Installation-ID.
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 3m36s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m25s
2026-09-09 14:04:41 +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

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,6 +13,7 @@
<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
@@ -21,6 +22,31 @@
{{ data?.title || 'Andro-Rangliste' }} · {{ data?.rowCount || 0 }} Einträge {{ data?.title || 'Andro-Rangliste' }} · {{ data?.rowCount || 0 }} Einträge
</p> </p>
</div> </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 class="text-sm text-gray-500"> <div class="text-sm text-gray-500">
Aktualisiert: {{ formatDate(data?.importedAt) }} Aktualisiert: {{ formatDate(data?.importedAt) }}
</div> </div>
@@ -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') {

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