Compare commits
9 Commits
7ac2fd39da
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1fc28a6438 | ||
|
|
d3e6fbf49b | ||
|
|
a9ce55699e | ||
|
|
3996e8d68c | ||
|
|
1a7ba583d8 | ||
|
|
645e4b9655 | ||
|
|
93b76d4446 | ||
|
|
2842c89bef | ||
|
|
dc08ae98ea |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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."
|
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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 = "",
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
6
package-lock.json
generated
@@ -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": {
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) -->
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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') {
|
||||||
|
|||||||
@@ -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.' }
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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')
|
||||||
|
}
|
||||||
|
|||||||
@@ -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' })]
|
||||||
})
|
})
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user