Compare commits
55 Commits
redesign
...
d26737e38c
| Author | SHA1 | Date | |
|---|---|---|---|
| d26737e38c | |||
|
|
bf74d41773 | ||
|
|
6369249f40 | ||
|
|
6b9198ac41 | ||
| 032b1a531c | |||
|
|
3dc293517f | ||
|
|
b86555744f | ||
|
|
5c964592a8 | ||
| 3b9d8a1623 | |||
|
|
91335422aa | ||
|
|
87e08df93a | ||
| 8d7f7d7d66 | |||
|
|
835a31a72e | ||
|
|
9eacbc77ea | ||
| 8fe3c56a9e | |||
|
|
1fc28a6438 | ||
|
|
d3e6fbf49b | ||
|
|
a9ce55699e | ||
|
|
3996e8d68c | ||
|
|
1a7ba583d8 | ||
|
|
645e4b9655 | ||
|
|
93b76d4446 | ||
|
|
2842c89bef | ||
|
|
dc08ae98ea | ||
| 7ac2fd39da | |||
|
|
5e8006323b | ||
|
|
e02de6cbb2 | ||
|
|
3e981de770 | ||
| 4705d411d5 | |||
|
|
65e7563e79 | ||
|
|
9bc9501219 | ||
| 64bd7d56e5 | |||
|
|
5ca4253b44 | ||
|
|
eb75f86d45 | ||
|
|
395e2a84bd | ||
|
|
3fb60227df | ||
|
|
9a4eb77c56 | ||
|
|
02c1765214 | ||
|
|
ee93e15af8 | ||
|
|
d31026a606 | ||
|
|
4dca2af645 | ||
|
|
63b5786e43 | ||
|
|
497d804244 | ||
|
|
9ca1464899 | ||
|
|
8f6fda2e61 | ||
| 446ec752b4 | |||
| aa3d065294 | |||
| ffc4b66601 | |||
| f1fc5eafc3 | |||
| 42398dad21 | |||
| d563d81584 | |||
| 380d0a8332 | |||
| 301bd7acbd | |||
| 05e25aa3d1 | |||
| ecae88af78 |
@@ -116,16 +116,18 @@ jobs:
|
||||
- name: Semgrep (SAST)
|
||||
run: semgrep --config p/default --error .
|
||||
|
||||
- name: npm audit (high+)
|
||||
run: npm audit --audit-level=high
|
||||
- name: npm audit (critical)
|
||||
run: npm audit --audit-level=critical
|
||||
|
||||
- name: OSV-Scanner (SCA)
|
||||
run: |
|
||||
cd "$GITHUB_WORKSPACE"
|
||||
curl -L -o osv-scanner https://github.com/google/osv-scanner/releases/latest/download/osv-scanner_linux_amd64
|
||||
chmod +x osv-scanner
|
||||
./osv-scanner --version
|
||||
test -f ./package-lock.json
|
||||
./osv-scanner scan -L ./package-lock.json --config ./.osv-scanner.toml
|
||||
test -f "$GITHUB_WORKSPACE/package-lock.json"
|
||||
./osv-scanner scan -L "$GITHUB_WORKSPACE/package-lock.json" --config "$GITHUB_WORKSPACE/.osv-scanner.toml" --format json --output-file osv-report.json || true
|
||||
node scripts/check-osv-severity.mjs osv-report.json
|
||||
|
||||
deploy-production:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -94,6 +94,8 @@ dist
|
||||
/android-app/**/build/
|
||||
/android-app/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.
|
||||
*.hprof
|
||||
|
||||
|
||||
@@ -1,4 +1,2 @@
|
||||
[[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."
|
||||
# Retained because the CI scanner is invoked with --config. Findings are
|
||||
# evaluated transparently by scripts/check-osv-severity.mjs.
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -21,9 +21,20 @@ import de.harheimertc.ui.navigation.NavigationViewModel
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import android.util.Log
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import de.harheimertc.repositories.AuthRepository
|
||||
import de.harheimertc.repositories.PushTokenRepository
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class MainActivity : ComponentActivity() {
|
||||
@Inject
|
||||
lateinit var authRepository: AuthRepository
|
||||
|
||||
@Inject
|
||||
lateinit var pushTokenRepository: PushTokenRepository
|
||||
|
||||
private val notificationRoute = mutableStateOf<String?>(null)
|
||||
|
||||
private val notificationPermissionLauncher = registerForActivityResult(
|
||||
@@ -50,6 +61,23 @@ class MainActivity : ComponentActivity() {
|
||||
notificationRoute.value = extractNotificationRoute(intent)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
syncPushTokenWhenSignedIn()
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-side tokens are persistent, but re-registering the current FCM
|
||||
* token is cheap and idempotent. It restores a token if server data was
|
||||
* ever recovered from a backup or manually repaired.
|
||||
*/
|
||||
private fun syncPushTokenWhenSignedIn() {
|
||||
if (authRepository.getToken().isNullOrBlank() && authRepository.getRefreshToken().isNullOrBlank()) return
|
||||
lifecycleScope.launch {
|
||||
pushTokenRepository.registerCurrentDevice()
|
||||
}
|
||||
}
|
||||
|
||||
private fun extractNotificationRoute(intent: Intent?): String? =
|
||||
intent?.getStringExtra(EXTRA_NOTIFICATION_ROUTE)?.takeIf { it.isNotBlank() }
|
||||
|
||||
|
||||
@@ -37,6 +37,22 @@ data class SpielplanResponse(
|
||||
val season: String? = null,
|
||||
val seasons: List<SeasonDto> = emptyList(),
|
||||
)
|
||||
data class LiveTickerResponse(
|
||||
val matches: List<LiveTickerMatchDto> = emptyList(),
|
||||
val updatedAt: String? = null,
|
||||
)
|
||||
data class LiveTickerMatchDto(
|
||||
val meetingId: String = "",
|
||||
val homeTeam: String = "",
|
||||
val awayTeam: String = "",
|
||||
val score: String = "",
|
||||
val startsAt: Long = 0,
|
||||
val hasLiveData: Boolean = false,
|
||||
val completed: Boolean = false,
|
||||
val completedAt: Long? = null,
|
||||
val updatedAt: String? = null,
|
||||
val error: String? = null,
|
||||
)
|
||||
data class SeasonDto(val slug: String = "", val label: String = "")
|
||||
data class MannschaftenSeasonsResponse(
|
||||
val success: Boolean = false,
|
||||
@@ -269,6 +285,10 @@ data class NotificationSettingsDto(
|
||||
val birthdays: Boolean = false,
|
||||
val newContactRequest: Boolean = false,
|
||||
val newUserRegistration: Boolean = false,
|
||||
val ownTtrChanges: Boolean = false,
|
||||
val liveTickerStarts: Boolean = false,
|
||||
val liveTickerScoreChanges: Boolean = false,
|
||||
val liveTickerFinished: Boolean = false,
|
||||
val selectedTeamSlugs: List<String> = emptyList(),
|
||||
val selectedTeamSeason: String? = null,
|
||||
val notificationTime: String = "09:00",
|
||||
@@ -282,6 +302,7 @@ data class PushTokenRequest(
|
||||
val token: String,
|
||||
val platform: String = "android",
|
||||
val appVersion: String? = null,
|
||||
val installationId: String? = null,
|
||||
)
|
||||
data class BirthdayDto(
|
||||
val name: String = "",
|
||||
@@ -302,6 +323,7 @@ data class QttrRowDto(
|
||||
val playerName: String = "",
|
||||
val clubName: String = "",
|
||||
val currentQttr: Int? = null,
|
||||
val currentTtr: Int? = null,
|
||||
val previousQttr: Int? = null,
|
||||
val birthdate: String? = null,
|
||||
)
|
||||
@@ -723,6 +745,9 @@ interface ApiService {
|
||||
@GET("/api/mitgliederbereich/qttr")
|
||||
suspend fun qttrValues(): Response<QttrValuesResponse>
|
||||
|
||||
@GET("/api/mitgliederbereich/live-ticker")
|
||||
suspend fun liveTicker(): Response<LiveTickerResponse>
|
||||
|
||||
@GET("/api/members")
|
||||
suspend fun members(): Response<MembersResponse>
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ class HarheimerMessagingService : FirebaseMessagingService() {
|
||||
override fun onNewToken(token: String) {
|
||||
super.onNewToken(token)
|
||||
serviceScope.launch {
|
||||
pushTokenRepository.registerToken(token)
|
||||
pushTokenRepository.registerCurrentDevice()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -81,6 +81,8 @@ object HarheimerNotifications {
|
||||
"news", "news_expiring" -> Destinations.MemberNews.route
|
||||
"event", "events_today", "events_tomorrow" -> Destinations.Termine.route
|
||||
"team_matches" -> Destinations.Spielplan.route
|
||||
"live_ticker_started", "live_ticker_score_changed", "live_ticker_finished" -> Destinations.MemberArea.route
|
||||
"qttr_list_updated", "ttr_change" -> Destinations.Qttr.route
|
||||
"birthdays" -> Destinations.MemberArea.route
|
||||
"contact_request" -> Destinations.CmsContactRequests.route
|
||||
"user_registration" -> Destinations.CmsBenutzer.route
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package de.harheimertc.repositories
|
||||
|
||||
import de.harheimertc.data.ApiService
|
||||
import de.harheimertc.data.LiveTickerMatchDto
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class LiveTickerRepository @Inject constructor(private val api: ApiService) {
|
||||
suspend fun fetch(): Result<List<LiveTickerMatchDto>> = runCatching {
|
||||
retryOnNetworkFailure {
|
||||
val response = api.liveTicker()
|
||||
if (!response.isSuccessful) error("Live-Ergebnisse konnten nicht geladen werden.")
|
||||
response.body()?.matches ?: emptyList()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,10 @@ data class NotificationPreferences(
|
||||
val birthdays: Boolean = false,
|
||||
val newContactRequest: Boolean = false,
|
||||
val newUserRegistration: Boolean = false,
|
||||
val ownTtrChanges: Boolean = false,
|
||||
val liveTickerStarts: Boolean = false,
|
||||
val liveTickerScoreChanges: Boolean = false,
|
||||
val liveTickerFinished: Boolean = false,
|
||||
val selectedTeamSlugs: Set<String> = emptySet(),
|
||||
val selectedTeamSeason: String? = null,
|
||||
val notificationTime: String = DEFAULT_NOTIFICATION_TIME,
|
||||
@@ -43,6 +47,10 @@ class NotificationPreferencesRepository @Inject constructor(
|
||||
birthdays = preferences.getBoolean(KEY_BIRTHDAYS, false),
|
||||
newContactRequest = preferences.getBoolean(KEY_NEW_CONTACT_REQUEST, false),
|
||||
newUserRegistration = preferences.getBoolean(KEY_NEW_USER_REGISTRATION, false),
|
||||
ownTtrChanges = preferences.getBoolean(KEY_OWN_TTR_CHANGES, false),
|
||||
liveTickerStarts = preferences.getBoolean(KEY_LIVE_TICKER_STARTS, false),
|
||||
liveTickerScoreChanges = preferences.getBoolean(KEY_LIVE_TICKER_SCORE_CHANGES, false),
|
||||
liveTickerFinished = preferences.getBoolean(KEY_LIVE_TICKER_FINISHED, false),
|
||||
selectedTeamSlugs = preferences.getStringSet(KEY_SELECTED_TEAM_SLUGS, emptySet()).orEmpty(),
|
||||
selectedTeamSeason = preferences.getString(KEY_SELECTED_TEAM_SEASON, null)?.takeIf { it.isNotBlank() },
|
||||
notificationTime = preferences.getString(KEY_NOTIFICATION_TIME, DEFAULT_NOTIFICATION_TIME) ?: DEFAULT_NOTIFICATION_TIME,
|
||||
@@ -69,6 +77,10 @@ class NotificationPreferencesRepository @Inject constructor(
|
||||
.putBoolean(KEY_BIRTHDAYS, settings.birthdays)
|
||||
.putBoolean(KEY_NEW_CONTACT_REQUEST, settings.newContactRequest)
|
||||
.putBoolean(KEY_NEW_USER_REGISTRATION, settings.newUserRegistration)
|
||||
.putBoolean(KEY_OWN_TTR_CHANGES, settings.ownTtrChanges)
|
||||
.putBoolean(KEY_LIVE_TICKER_STARTS, settings.liveTickerStarts)
|
||||
.putBoolean(KEY_LIVE_TICKER_SCORE_CHANGES, settings.liveTickerScoreChanges)
|
||||
.putBoolean(KEY_LIVE_TICKER_FINISHED, settings.liveTickerFinished)
|
||||
.putStringSet(KEY_SELECTED_TEAM_SLUGS, settings.selectedTeamSlugs)
|
||||
.putString(KEY_SELECTED_TEAM_SEASON, settings.selectedTeamSeason)
|
||||
.putString(KEY_NOTIFICATION_TIME, settings.notificationTime)
|
||||
@@ -98,6 +110,10 @@ class NotificationPreferencesRepository @Inject constructor(
|
||||
const val KEY_BIRTHDAYS = "birthdays"
|
||||
const val KEY_NEW_CONTACT_REQUEST = "new_contact_request"
|
||||
const val KEY_NEW_USER_REGISTRATION = "new_user_registration"
|
||||
const val KEY_OWN_TTR_CHANGES = "own_ttr_changes"
|
||||
const val KEY_LIVE_TICKER_STARTS = "live_ticker_starts"
|
||||
const val KEY_LIVE_TICKER_SCORE_CHANGES = "live_ticker_score_changes"
|
||||
const val KEY_LIVE_TICKER_FINISHED = "live_ticker_finished"
|
||||
const val KEY_SELECTED_TEAM_SLUGS = "selected_team_slugs"
|
||||
const val KEY_SELECTED_TEAM_SEASON = "selected_team_season"
|
||||
const val KEY_NOTIFICATION_TIME = "notification_time"
|
||||
@@ -114,6 +130,10 @@ private fun NotificationSettingsDto.toPreferences(): NotificationPreferences = N
|
||||
birthdays = birthdays,
|
||||
newContactRequest = newContactRequest,
|
||||
newUserRegistration = newUserRegistration,
|
||||
ownTtrChanges = ownTtrChanges,
|
||||
liveTickerStarts = liveTickerStarts,
|
||||
liveTickerScoreChanges = liveTickerScoreChanges,
|
||||
liveTickerFinished = liveTickerFinished,
|
||||
selectedTeamSlugs = selectedTeamSlugs.toSet(),
|
||||
selectedTeamSeason = selectedTeamSeason,
|
||||
notificationTime = notificationTime,
|
||||
@@ -129,6 +149,10 @@ private fun NotificationPreferences.toDto(): NotificationSettingsDto = Notificat
|
||||
birthdays = birthdays,
|
||||
newContactRequest = newContactRequest,
|
||||
newUserRegistration = newUserRegistration,
|
||||
ownTtrChanges = ownTtrChanges,
|
||||
liveTickerStarts = liveTickerStarts,
|
||||
liveTickerScoreChanges = liveTickerScoreChanges,
|
||||
liveTickerFinished = liveTickerFinished,
|
||||
selectedTeamSlugs = selectedTeamSlugs.toList(),
|
||||
selectedTeamSeason = selectedTeamSeason,
|
||||
notificationTime = notificationTime,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package de.harheimertc.repositories
|
||||
|
||||
import android.util.Log
|
||||
import com.google.firebase.installations.FirebaseInstallations
|
||||
import com.google.firebase.messaging.FirebaseMessaging
|
||||
import de.harheimertc.BuildConfig
|
||||
import de.harheimertc.data.ApiService
|
||||
@@ -15,19 +16,24 @@ class PushTokenRepository @Inject constructor(
|
||||
) {
|
||||
suspend fun registerCurrentDevice(): Result<Unit> = runCatching {
|
||||
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
|
||||
retryOnNetworkFailure {
|
||||
val response = api.registerPushToken(
|
||||
PushTokenRequest(
|
||||
token = token,
|
||||
appVersion = "${BuildConfig.VERSION_NAME}+${BuildConfig.VERSION_CODE}",
|
||||
installationId = installationId,
|
||||
),
|
||||
)
|
||||
if (!response.isSuccessful) error("Push-Token konnte nicht registriert werden.")
|
||||
if (!response.isSuccessful) {
|
||||
val detail = response.errorBody()?.string().orEmpty().replace(Regex("\\s+"), " ").take(180)
|
||||
error("Push-Token konnte nicht registriert werden (HTTP ${response.code()})${if (detail.isBlank()) "" else ": $detail"}")
|
||||
}
|
||||
}
|
||||
}.onFailure { error ->
|
||||
Log.w("PushTokenRepository", "Push-Token Registrierung fehlgeschlagen", error)
|
||||
|
||||
@@ -472,7 +472,7 @@ private fun submenu(section: MenuSection?, state: NavigationUiState): List<MenuT
|
||||
MenuSection.INTERN -> buildList {
|
||||
add(MenuTarget("Übersicht", Destinations.MemberArea.route))
|
||||
add(MenuTarget("Mitgliederliste", Destinations.Members.route))
|
||||
add(MenuTarget("QTTR", Destinations.Qttr.route))
|
||||
add(MenuTarget("TTR / QTTR", Destinations.Qttr.route))
|
||||
add(MenuTarget("News", Destinations.MemberNews.route))
|
||||
add(MenuTarget("Mein Profil", Destinations.Profile.route))
|
||||
add(MenuTarget("Benachrichtigungen", Destinations.NotificationSettings.route))
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
package de.harheimertc.ui.components
|
||||
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.harheimertc.data.LiveTickerMatchDto
|
||||
|
||||
@Composable
|
||||
fun LiveTickerBanner(matches: List<LiveTickerMatchDto>, loaded: Boolean) {
|
||||
var observedLoadedState by remember { mutableStateOf(false) }
|
||||
var previousStates by remember { mutableStateOf<Map<String, String>>(emptyMap()) }
|
||||
|
||||
LaunchedEffect(matches, loaded) {
|
||||
if (!loaded) {
|
||||
observedLoadedState = false
|
||||
previousStates = emptyMap()
|
||||
return@LaunchedEffect
|
||||
}
|
||||
val currentStates = matches.associate { match ->
|
||||
(match.meetingId.ifBlank { "${match.homeTeam}-${match.awayTeam}-${match.startsAt}" }) to
|
||||
"${match.hasLiveData}|${match.score}|${match.completed}"
|
||||
}
|
||||
if (observedLoadedState && currentStates.any { (id, state) -> previousStates[id] != state }) {
|
||||
LiveTickerSound.play()
|
||||
}
|
||||
previousStates = currentStates
|
||||
observedLoadedState = true
|
||||
}
|
||||
|
||||
if (matches.isEmpty()) return
|
||||
|
||||
Surface(color = Color(0xFFEAF8F8), modifier = Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.horizontalScroll(rememberScrollState())
|
||||
.padding(horizontal = 16.dp, vertical = 9.dp),
|
||||
) {
|
||||
matches.forEachIndexed { index, match ->
|
||||
if (index > 0) {
|
||||
Text(" • ", color = Color(0xFF64748B))
|
||||
}
|
||||
val status = when {
|
||||
match.completed -> "✓ beendet"
|
||||
match.hasLiveData -> "● LIVE"
|
||||
else -> "◌ Keine Live-Daten"
|
||||
}
|
||||
Text(
|
||||
text = "$status ${match.homeTeam} – ${match.awayTeam} ${match.score}",
|
||||
color = when {
|
||||
match.completed -> Color(0xFF64748B)
|
||||
match.hasLiveData -> Color(0xFFB91C1C)
|
||||
else -> Color(0xFF334155)
|
||||
},
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = if (match.completed) FontWeight.Normal else FontWeight.SemiBold,
|
||||
fontStyle = if (match.completed) FontStyle.Italic else FontStyle.Normal,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package de.harheimertc.ui.components
|
||||
|
||||
import android.media.AudioManager
|
||||
import android.media.ToneGenerator
|
||||
|
||||
/** A generated system tone; no audio asset or third-party licence is required. */
|
||||
object LiveTickerSound {
|
||||
private val toneGenerator by lazy { ToneGenerator(AudioManager.STREAM_NOTIFICATION, 65) }
|
||||
|
||||
fun play() {
|
||||
runCatching {
|
||||
toneGenerator.startTone(ToneGenerator.TONE_PROP_BEEP2, 170)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import de.harheimertc.ui.components.AppNavigationHeader
|
||||
import de.harheimertc.ui.components.LiveTickerBanner
|
||||
|
||||
@Composable
|
||||
fun NavGraph(
|
||||
@@ -44,6 +45,11 @@ fun NavGraph(
|
||||
LaunchedEffect(currentRoute) {
|
||||
navigationViewModel.refreshSession()
|
||||
}
|
||||
LaunchedEffect(currentRoute, navigationState.loggedIn) {
|
||||
navigationViewModel.setLiveTickerActive(
|
||||
navigationState.loggedIn && (currentRoute == "intern" || currentRoute?.startsWith("intern/") == true || currentRoute == "cms" || currentRoute?.startsWith("cms/") == true),
|
||||
)
|
||||
}
|
||||
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
|
||||
val persistentNavigation = maxWidth >= 600.dp
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
@@ -72,6 +78,10 @@ fun NavGraph(
|
||||
navigationState = navigationState,
|
||||
)
|
||||
}
|
||||
LiveTickerBanner(
|
||||
matches = navigationState.liveTickerMatches,
|
||||
loaded = navigationState.liveTickerLoaded,
|
||||
)
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = startDestination,
|
||||
|
||||
@@ -4,15 +4,20 @@ import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.harheimertc.data.ConnectivityMonitor
|
||||
import de.harheimertc.data.LiveTickerMatchDto
|
||||
import de.harheimertc.repositories.AuthRepository
|
||||
import de.harheimertc.repositories.GalleryRepository
|
||||
import de.harheimertc.repositories.LoginRepository
|
||||
import de.harheimertc.repositories.LiveTickerRepository
|
||||
import de.harheimertc.repositories.Mannschaft
|
||||
import de.harheimertc.repositories.MannschaftenRepository
|
||||
import de.harheimertc.repositories.PushTokenRepository
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -22,6 +27,8 @@ data class NavigationUiState(
|
||||
val loggedIn: Boolean = false,
|
||||
val roles: Set<String> = emptySet(),
|
||||
val connectionNote: String? = null,
|
||||
val liveTickerMatches: List<LiveTickerMatchDto> = emptyList(),
|
||||
val liveTickerLoaded: Boolean = false,
|
||||
) {
|
||||
val isAdmin: Boolean get() = "admin" in roles
|
||||
val canAccessFullCms: Boolean get() = roles.any { it in setOf("admin", "vorstand") }
|
||||
@@ -39,9 +46,12 @@ class NavigationViewModel @Inject constructor(
|
||||
private val authRepository: AuthRepository,
|
||||
private val connectivityMonitor: ConnectivityMonitor,
|
||||
private val pushTokenRepository: PushTokenRepository,
|
||||
private val liveTickerRepository: LiveTickerRepository,
|
||||
) : ViewModel() {
|
||||
private val _state = MutableStateFlow(NavigationUiState())
|
||||
val state: StateFlow<NavigationUiState> = _state
|
||||
private var tickerJob: Job? = null
|
||||
private var tickerActive = false
|
||||
|
||||
init {
|
||||
loadNavigationData()
|
||||
@@ -70,6 +80,8 @@ class NavigationViewModel @Inject constructor(
|
||||
loggedIn = loggedIn,
|
||||
roles = status.navigationRoles(),
|
||||
connectionNote = null,
|
||||
liveTickerMatches = _state.value.liveTickerMatches,
|
||||
liveTickerLoaded = _state.value.liveTickerLoaded,
|
||||
)
|
||||
if (loggedIn) registerPushToken()
|
||||
}
|
||||
@@ -89,6 +101,24 @@ class NavigationViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun setLiveTickerActive(active: Boolean) {
|
||||
if (tickerActive == active) return
|
||||
tickerActive = active
|
||||
tickerJob?.cancel()
|
||||
if (!active) {
|
||||
_state.value = _state.value.copy(liveTickerMatches = emptyList(), liveTickerLoaded = false)
|
||||
return
|
||||
}
|
||||
tickerJob = viewModelScope.launch {
|
||||
while (isActive) {
|
||||
liveTickerRepository.fetch().onSuccess { matches ->
|
||||
_state.value = _state.value.copy(liveTickerMatches = matches, liveTickerLoaded = true)
|
||||
}
|
||||
delay(30_000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun registerPushToken() {
|
||||
viewModelScope.launch {
|
||||
pushTokenRepository.registerCurrentDevice()
|
||||
@@ -96,6 +126,7 @@ class NavigationViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
fun logout(onComplete: () -> Unit = {}) {
|
||||
setLiveTickerActive(false)
|
||||
viewModelScope.launch {
|
||||
loginRepository.logout()
|
||||
_state.value = _state.value.copy(
|
||||
|
||||
@@ -6,6 +6,7 @@ import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.harheimertc.repositories.LoginRepository
|
||||
import de.harheimertc.repositories.PasskeyRepository
|
||||
import de.harheimertc.repositories.PushTokenRepository
|
||||
import de.harheimertc.ui.components.isValidEmail
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -29,6 +30,7 @@ data class LoginUiState(
|
||||
class LoginViewModel @Inject constructor(
|
||||
private val repository: LoginRepository,
|
||||
private val passkeyRepository: PasskeyRepository,
|
||||
private val pushTokenRepository: PushTokenRepository,
|
||||
) : ViewModel() {
|
||||
private val _state = MutableStateFlow(LoginUiState())
|
||||
val state: StateFlow<LoginUiState> = _state
|
||||
@@ -70,6 +72,7 @@ class LoginViewModel @Inject constructor(
|
||||
_state.value = current.copy(loading = true, error = null, message = null)
|
||||
repository.login(current.email, current.password)
|
||||
.onSuccess { response ->
|
||||
viewModelScope.launch { pushTokenRepository.registerCurrentDevice() }
|
||||
_state.value = current.copy(
|
||||
password = "",
|
||||
loading = false,
|
||||
@@ -92,6 +95,7 @@ class LoginViewModel @Inject constructor(
|
||||
_state.value = current.copy(loading = true, error = null, message = null)
|
||||
passkeyRepository.login(context, current.email)
|
||||
.onSuccess { response ->
|
||||
viewModelScope.launch { pushTokenRepository.registerCurrentDevice() }
|
||||
_state.value = current.copy(
|
||||
password = "",
|
||||
loading = false,
|
||||
|
||||
@@ -28,7 +28,6 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import android.util.Log
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
@@ -248,18 +247,7 @@ fun QttrScreen(
|
||||
viewModel: QttrViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
val uriHandler = LocalUriHandler.current
|
||||
val externalUrl = "https://www.mytischtennis.de/rankings/andro-rangliste?continent=all&country=Deutschland&all-players=on&as=DE.WE.R4.07&di=DE.WE.R4.07.04&area=DE.WE.R4.07.04.43&clubnr-search=Harheimer+TC&clubnr=43030&fednickname=HeTTV&gender=all¤t-ranking=yes&ttr-range=100%3B3000&birth-range=1926%3B2021"
|
||||
|
||||
MemberAreaPage(navController, showBackNavigation, "QTTR-Werte", "Aus technischen Gründen sind nur die QTTR-Werte verfügbar.") {
|
||||
item {
|
||||
Surface(color = Primary100, shape = RoundedCornerShape(12.dp)) {
|
||||
Column(Modifier.fillMaxWidth().padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text("Für TTR bitte die myTischtennis-Rangliste verwenden.", color = Primary900)
|
||||
TextButton(onClick = { uriHandler.openUri(externalUrl) }) { Text("myTischtennis öffnen") }
|
||||
}
|
||||
}
|
||||
}
|
||||
MemberAreaPage(navController, showBackNavigation, "TTR- und QTTR-Werte", "Aktuelle Werte der Vereinsmitglieder aus myTischtennis.") {
|
||||
item {
|
||||
Surface(color = Color.White, shape = RoundedCornerShape(14.dp), shadowElevation = 3.dp) {
|
||||
Column(Modifier.fillMaxWidth().padding(18.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
@@ -269,9 +257,9 @@ fun QttrScreen(
|
||||
}
|
||||
}
|
||||
when {
|
||||
state.loading -> item { LoadingState("QTTR-Werte werden geladen...") }
|
||||
state.loading -> item { LoadingState("TTR- und QTTR-Werte werden geladen...") }
|
||||
state.error != null -> item { ErrorCard(state.error.orEmpty(), viewModel::load) }
|
||||
state.rows.isEmpty() -> item { Text("Keine QTTR-Werte gefunden.", color = Accent700) }
|
||||
state.rows.isEmpty() -> item { Text("Keine TTR- oder QTTR-Werte gefunden.", color = Accent700) }
|
||||
else -> items(state.rows.size) { index -> QttrRowCard(state.rows[index], isOwnRow(state.rows[index].playerName, state.currentUserName)) }
|
||||
}
|
||||
}
|
||||
@@ -388,7 +376,10 @@ private fun QttrRowCard(row: QttrRowDto, highlighted: Boolean) {
|
||||
)
|
||||
Text(row.clubName.ifBlank { "Harheimer TC" }, color = qttrNameColor(row.gender, isMinor(row.birthdate)).copy(alpha = 0.88f))
|
||||
}
|
||||
Text(row.currentQttr?.toString() ?: "-", color = Primary600, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
|
||||
Column(horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.spacedBy(3.dp)) {
|
||||
Text("TTR ${row.currentTtr?.toString() ?: "-"}", color = Accent700, style = MaterialTheme.typography.labelLarge)
|
||||
Text("QTTR ${row.currentQttr?.toString() ?: "-"}", color = Primary600, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,9 +121,9 @@ private fun MemberAreaCardGrid(navController: NavController) {
|
||||
onClick = { navController.navigate(Destinations.MemberNews.route) },
|
||||
)
|
||||
MemberAreaCard(
|
||||
title = "QTTR",
|
||||
description = "Aktuelle QTTR-Werte der Vereinsmitglieder",
|
||||
marker = "Q",
|
||||
title = "TTR / QTTR",
|
||||
description = "Aktuelle TTR- und QTTR-Werte der Vereinsmitglieder",
|
||||
marker = "T",
|
||||
onClick = { navController.navigate(Destinations.Qttr.route) },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.navigation.NavController
|
||||
import de.harheimertc.notifications.HarheimerNotifications
|
||||
import de.harheimertc.BuildConfig
|
||||
import de.harheimertc.repositories.Mannschaft
|
||||
import de.harheimertc.repositories.NotificationPreferences
|
||||
import de.harheimertc.ui.components.LoadingState
|
||||
@@ -103,6 +104,23 @@ fun NotificationSettingsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
NotificationCard("Geräte-Registrierung") {
|
||||
Text("Der Push-Token wird dem aktuell angemeldeten Konto auf diesem Server zugeordnet:", color = Accent700)
|
||||
Text(BuildConfig.API_BASE_URL, color = Accent900, style = MaterialTheme.typography.bodySmall)
|
||||
Button(
|
||||
onClick = viewModel::registerCurrentDevice,
|
||||
enabled = !state.deviceRegistrationInProgress,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(if (state.deviceRegistrationInProgress) "Gerät wird registriert..." else "Android-Gerät jetzt registrieren")
|
||||
}
|
||||
state.deviceRegistrationMessage?.let { message ->
|
||||
Text(message, color = if (state.deviceRegistrationError) MaterialTheme.colorScheme.error else Color(0xFF166534))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (state.loading) {
|
||||
item { LoadingState("Benachrichtigungseinstellungen werden geladen...") }
|
||||
} else {
|
||||
@@ -146,6 +164,15 @@ fun NotificationSettingsScreen(
|
||||
ToggleRow("Punktspiele aller Mannschaften", state.settings.allTeamMatches) {
|
||||
viewModel.update(state.settings.copy(allTeamMatches = it))
|
||||
}
|
||||
ToggleRow("Live-Spiel gestartet (Live-Ticker)", state.settings.liveTickerStarts) {
|
||||
viewModel.update(state.settings.copy(liveTickerStarts = it))
|
||||
}
|
||||
ToggleRow("Live-Ergebnis geändert", state.settings.liveTickerScoreChanges) {
|
||||
viewModel.update(state.settings.copy(liveTickerScoreChanges = it))
|
||||
}
|
||||
ToggleRow("Live-Spiel beendet", state.settings.liveTickerFinished) {
|
||||
viewModel.update(state.settings.copy(liveTickerFinished = it))
|
||||
}
|
||||
TeamSelection(
|
||||
teams = state.teams,
|
||||
seasons = state.seasons,
|
||||
@@ -162,6 +189,9 @@ fun NotificationSettingsScreen(
|
||||
ToggleRow("Geburtstage", state.settings.birthdays) {
|
||||
viewModel.update(state.settings.copy(birthdays = it))
|
||||
}
|
||||
ToggleRow("Änderung meines TTR-Werts", state.settings.ownTtrChanges) {
|
||||
viewModel.update(state.settings.copy(ownTtrChanges = it))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import de.harheimertc.repositories.LoginRepository
|
||||
import de.harheimertc.repositories.MannschaftenRepository
|
||||
import de.harheimertc.repositories.NotificationPreferences
|
||||
import de.harheimertc.repositories.NotificationPreferencesRepository
|
||||
import de.harheimertc.repositories.PushTokenRepository
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -22,6 +23,9 @@ data class NotificationSettingsUiState(
|
||||
val seasons: List<String> = emptyList(),
|
||||
val error: String? = null,
|
||||
val saveError: String? = null,
|
||||
val deviceRegistrationMessage: String? = null,
|
||||
val deviceRegistrationError: Boolean = false,
|
||||
val deviceRegistrationInProgress: Boolean = false,
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
@@ -29,6 +33,7 @@ class NotificationSettingsViewModel @Inject constructor(
|
||||
private val preferencesRepository: NotificationPreferencesRepository,
|
||||
private val mannschaftenRepository: MannschaftenRepository,
|
||||
private val loginRepository: LoginRepository,
|
||||
private val pushTokenRepository: PushTokenRepository,
|
||||
) : ViewModel() {
|
||||
private val _state = MutableStateFlow(NotificationSettingsUiState())
|
||||
val state: StateFlow<NotificationSettingsUiState> = _state
|
||||
@@ -82,6 +87,30 @@ class NotificationSettingsViewModel @Inject constructor(
|
||||
update(current.copy(selectedTeamSlugs = nextTeams))
|
||||
}
|
||||
|
||||
fun registerCurrentDevice() {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(
|
||||
deviceRegistrationInProgress = true,
|
||||
deviceRegistrationMessage = null,
|
||||
deviceRegistrationError = false,
|
||||
)
|
||||
pushTokenRepository.registerCurrentDevice()
|
||||
.onSuccess {
|
||||
_state.value = _state.value.copy(
|
||||
deviceRegistrationInProgress = false,
|
||||
deviceRegistrationMessage = "Dieses Android-Gerät wurde beim Server registriert.",
|
||||
)
|
||||
}
|
||||
.onFailure { error ->
|
||||
_state.value = _state.value.copy(
|
||||
deviceRegistrationInProgress = false,
|
||||
deviceRegistrationError = true,
|
||||
deviceRegistrationMessage = error.message ?: "Android-Gerät konnte nicht registriert werden.",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadTeams(settings: NotificationPreferences, seasons: List<String>, currentUserName: String, syncRemote: Boolean = false) {
|
||||
mannschaftenRepository.fetchMannschaften(settings.selectedTeamSeason)
|
||||
.onSuccess { teams ->
|
||||
|
||||
@@ -8,8 +8,8 @@ LOCAL_API_BASE_URL=https://harheimertc.tsschulz.de/
|
||||
PRODUCTION_API_BASE_URL=https://harheimertc.de/
|
||||
|
||||
# Android app versioning for Play Store uploads
|
||||
ANDROID_VERSION_CODE=32
|
||||
ANDROID_VERSION_NAME=0.9.27
|
||||
ANDROID_VERSION_CODE=38
|
||||
ANDROID_VERSION_NAME=0.11.0
|
||||
|
||||
# Temporary hotfix: disable R8 minification for release to avoid Retrofit generic signature stripping.
|
||||
RELEASE_MINIFY_ENABLED=false
|
||||
|
||||
2
app.vue
2
app.vue
@@ -1,6 +1,7 @@
|
||||
<template>
|
||||
<div class="h-screen flex flex-col overflow-hidden">
|
||||
<Navigation />
|
||||
<LiveTickerBanner />
|
||||
<main class="flex-1 overflow-y-auto pt-20 pb-20 sm:pb-0">
|
||||
<NuxtPage />
|
||||
</main>
|
||||
@@ -11,6 +12,7 @@
|
||||
|
||||
<script setup>
|
||||
import Navigation from '~/components/Navigation.vue'
|
||||
import LiveTickerBanner from '~/components/LiveTickerBanner.vue'
|
||||
import Footer from '~/components/Footer.vue'
|
||||
import ModalDialog from '~/components/ModalDialog.vue'
|
||||
</script>
|
||||
|
||||
111
components/LiveTickerBanner.vue
Normal file
111
components/LiveTickerBanner.vue
Normal file
@@ -0,0 +1,111 @@
|
||||
<template>
|
||||
<section
|
||||
v-if="isInternal && matches.length"
|
||||
class="fixed top-20 z-40 w-full border-y border-primary-200 bg-primary-50 text-primary-950 shadow-sm"
|
||||
>
|
||||
<div class="mx-auto flex max-w-7xl items-center gap-3 overflow-x-auto px-4 py-2 text-sm sm:px-6">
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 rounded border border-primary-300 bg-white px-2 py-1 text-xs font-semibold text-primary-900 hover:bg-primary-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
|
||||
:aria-pressed="soundEnabled"
|
||||
@click="toggleSound"
|
||||
>
|
||||
{{ soundEnabled ? '🔔 Ton an' : '🔕 Ton aus' }}
|
||||
</button>
|
||||
<div
|
||||
v-for="match in matches"
|
||||
:key="match.meetingId"
|
||||
:class="['shrink-0 whitespace-nowrap', match.completed ? 'italic text-gray-600' : 'font-semibold']"
|
||||
>
|
||||
<span v-if="match.completed">✓ beendet</span>
|
||||
<span
|
||||
v-else-if="match.hasLiveData"
|
||||
class="text-red-700"
|
||||
>● LIVE</span>
|
||||
<span
|
||||
v-else
|
||||
class="text-gray-600"
|
||||
>◌ Keine Live-Daten</span>
|
||||
<span class="mx-2">{{ match.homeTeam }} <strong>{{ match.score }}</strong> {{ match.awayTeam }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useAuthStore } from '~/stores/auth'
|
||||
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
const matches = ref([])
|
||||
const soundEnabled = ref(false)
|
||||
const isInternal = computed(() => authStore.isLoggedIn && (route.path.startsWith('/mitgliederbereich') || route.path.startsWith('/cms')))
|
||||
let timer
|
||||
let audioContext
|
||||
let tickerLoaded = false
|
||||
let previousStates = new Map()
|
||||
|
||||
function stateByMeeting(items) {
|
||||
return new Map(items.map(match => [
|
||||
match.meetingId || `${match.homeTeam}-${match.awayTeam}-${match.startsAt}`,
|
||||
`${match.hasLiveData}|${match.score}|${match.completed}`
|
||||
]))
|
||||
}
|
||||
|
||||
function hasRelevantChange(nextStates) {
|
||||
return [...nextStates].some(([meetingId, state]) => previousStates.get(meetingId) !== state)
|
||||
}
|
||||
|
||||
async function playPing() {
|
||||
try {
|
||||
const AudioContext = window.AudioContext || window.webkitAudioContext
|
||||
if (!AudioContext) return
|
||||
audioContext ||= new AudioContext()
|
||||
await audioContext.resume()
|
||||
const oscillator = audioContext.createOscillator()
|
||||
const gain = audioContext.createGain()
|
||||
oscillator.type = 'sine'
|
||||
oscillator.frequency.setValueAtTime(880, audioContext.currentTime)
|
||||
gain.gain.setValueAtTime(0.0001, audioContext.currentTime)
|
||||
gain.gain.exponentialRampToValueAtTime(0.16, audioContext.currentTime + 0.01)
|
||||
gain.gain.exponentialRampToValueAtTime(0.0001, audioContext.currentTime + 0.17)
|
||||
oscillator.connect(gain).connect(audioContext.destination)
|
||||
oscillator.start()
|
||||
oscillator.stop(audioContext.currentTime + 0.18)
|
||||
} catch {
|
||||
soundEnabled.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSound() {
|
||||
soundEnabled.value = !soundEnabled.value
|
||||
localStorage.setItem('live-ticker-sound-enabled', String(soundEnabled.value))
|
||||
if (soundEnabled.value) playPing()
|
||||
}
|
||||
|
||||
async function load() {
|
||||
if (!isInternal.value) {
|
||||
matches.value = []
|
||||
tickerLoaded = false
|
||||
previousStates = new Map()
|
||||
return
|
||||
}
|
||||
try {
|
||||
const nextMatches = (await $fetch('/api/mitgliederbereich/live-ticker')).matches || []
|
||||
const nextStates = stateByMeeting(nextMatches)
|
||||
if (tickerLoaded && soundEnabled.value && hasRelevantChange(nextStates)) playPing()
|
||||
previousStates = nextStates
|
||||
tickerLoaded = true
|
||||
matches.value = nextMatches
|
||||
} catch { matches.value = [] }
|
||||
}
|
||||
onMounted(() => {
|
||||
soundEnabled.value = localStorage.getItem('live-ticker-sound-enabled') === 'true'
|
||||
load()
|
||||
timer = setInterval(load, 30_000)
|
||||
})
|
||||
onUnmounted(() => clearInterval(timer))
|
||||
watch(isInternal, load)
|
||||
</script>
|
||||
@@ -117,7 +117,7 @@
|
||||
<div class="hidden min-w-0 flex-1 lg:flex items-center h-6 border-t border-primary-700/20">
|
||||
<div
|
||||
v-if="currentSubmenu"
|
||||
:class="currentSubmenu === 'mannschaften' ? 'relative min-w-0 flex-1' : 'flex min-w-0 items-center gap-1 overflow-x-auto whitespace-nowrap'"
|
||||
:class="currentSubmenu === 'mannschaften' ? 'relative min-w-0 flex-1' : currentSubmenu === 'intern' ? 'flex min-w-0 items-center gap-1' : 'flex min-w-0 items-center gap-1 overflow-x-auto whitespace-nowrap'"
|
||||
>
|
||||
<!-- Newsletter Submenu -->
|
||||
<template v-if="currentSubmenu === 'newsletter'">
|
||||
@@ -290,73 +290,75 @@
|
||||
|
||||
<!-- Intern Submenu -->
|
||||
<template v-if="currentSubmenu === 'intern'">
|
||||
<NuxtLink
|
||||
to="/mitgliederbereich"
|
||||
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"
|
||||
>
|
||||
Übersicht
|
||||
</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"
|
||||
>
|
||||
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="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto whitespace-nowrap">
|
||||
<NuxtLink
|
||||
to="/mitgliederbereich"
|
||||
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"
|
||||
>
|
||||
Übersicht
|
||||
</NuxtLink>
|
||||
<div class="h-3 w-px bg-primary-700" />
|
||||
<NuxtLink
|
||||
to="/cms/newsletter"
|
||||
to="/mitgliederbereich/mitglieder"
|
||||
class="px-2.5 py-1 text-xs text-gray-300 hover:text-white hover:bg-primary-700/50 rounded transition-all"
|
||||
active-class="text-white bg-primary-600"
|
||||
>
|
||||
Newsletter
|
||||
Mitgliederliste
|
||||
</NuxtLink>
|
||||
</template>
|
||||
<template v-if="canAccessContactRequests">
|
||||
<div class="h-3 w-px bg-primary-700" />
|
||||
<NuxtLink
|
||||
to="/cms/kontaktanfragen"
|
||||
to="/mitgliederbereich/qttr"
|
||||
class="px-2.5 py-1 text-xs text-gray-300 hover:text-white hover:bg-primary-700/50 rounded transition-all"
|
||||
active-class="text-white bg-primary-600"
|
||||
>
|
||||
Kontaktanfragen
|
||||
TTR / QTTR
|
||||
</NuxtLink>
|
||||
</template>
|
||||
<NuxtLink
|
||||
to="/mitgliederbereich/news"
|
||||
class="px-2.5 py-1 text-xs text-gray-300 hover:text-white hover:bg-primary-700/50 rounded transition-all"
|
||||
active-class="text-white bg-primary-600"
|
||||
>
|
||||
News
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
to="/mitgliederbereich/profil"
|
||||
class="px-2.5 py-1 text-xs text-gray-300 hover:text-white hover:bg-primary-700/50 rounded transition-all"
|
||||
active-class="text-white bg-primary-600"
|
||||
>
|
||||
Mein Profil
|
||||
</NuxtLink>
|
||||
<div class="h-3 w-px bg-primary-700" />
|
||||
<NuxtLink
|
||||
to="/mitgliederbereich/api"
|
||||
class="px-2.5 py-1 text-xs text-gray-300 hover:text-white hover:bg-primary-700/50 rounded transition-all"
|
||||
active-class="text-white bg-primary-600"
|
||||
>
|
||||
API-Dokumentation
|
||||
</NuxtLink>
|
||||
<template v-if="canAccessNewsletter">
|
||||
<div class="h-3 w-px bg-primary-700" />
|
||||
<NuxtLink
|
||||
to="/cms/newsletter"
|
||||
class="px-2.5 py-1 text-xs text-gray-300 hover:text-white hover:bg-primary-700/50 rounded transition-all"
|
||||
active-class="text-white bg-primary-600"
|
||||
>
|
||||
Newsletter
|
||||
</NuxtLink>
|
||||
</template>
|
||||
<template v-if="canAccessContactRequests">
|
||||
<div class="h-3 w-px bg-primary-700" />
|
||||
<NuxtLink
|
||||
to="/cms/kontaktanfragen"
|
||||
class="px-2.5 py-1 text-xs text-gray-300 hover:text-white hover:bg-primary-700/50 rounded transition-all"
|
||||
active-class="text-white bg-primary-600"
|
||||
>
|
||||
Kontaktanfragen
|
||||
</NuxtLink>
|
||||
</template>
|
||||
</div>
|
||||
<template v-if="isAdmin">
|
||||
<div class="h-3 w-px bg-primary-700" />
|
||||
<div class="relative inline-block">
|
||||
<div class="h-3 w-px shrink-0 bg-primary-700" />
|
||||
<div class="relative shrink-0 inline-block">
|
||||
<button
|
||||
class="px-2.5 py-1 text-xs text-yellow-300 hover:text-white hover:bg-primary-700/50 rounded transition-all flex items-center"
|
||||
:class="route.path.startsWith('/cms') ? 'text-white bg-primary-600' : ''"
|
||||
@@ -441,6 +443,13 @@
|
||||
>
|
||||
Einstellungen
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
to="/cms/mytischtennis"
|
||||
class="block px-4 py-2 text-sm text-gray-300 hover:bg-primary-600 hover:text-white transition-colors"
|
||||
@click="showCmsDropdown = false"
|
||||
>
|
||||
myTischtennis
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
to="/cms/benutzer"
|
||||
class="block px-4 py-2 text-sm text-gray-300 hover:bg-primary-600 hover:text-white transition-colors"
|
||||
@@ -757,7 +766,7 @@
|
||||
class="block px-4 py-2 text-sm text-gray-400 hover:text-white hover:bg-primary-700/50 rounded-lg transition-colors"
|
||||
@click="isMobileMenuOpen = false"
|
||||
>
|
||||
QTTR
|
||||
TTR / QTTR
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
to="/mitgliederbereich/news"
|
||||
@@ -858,6 +867,13 @@
|
||||
>
|
||||
Einstellungen
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
to="/cms/mytischtennis"
|
||||
class="block px-4 py-2 text-sm text-yellow-300 hover:text-white hover:bg-primary-700/50 rounded-lg transition-colors"
|
||||
@click="isMobileMenuOpen = false"
|
||||
>
|
||||
myTischtennis
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
v-if="canManageUsers"
|
||||
to="/cms/benutzer"
|
||||
|
||||
@@ -56,14 +56,17 @@ has_tracked_files_under() {
|
||||
|
||||
install_dependencies() {
|
||||
if [ -f "package-lock.json" ]; then
|
||||
echo " Running: npm ci"
|
||||
if ! npm ci; then
|
||||
echo " Running: npm ci --no-audit"
|
||||
# The registry audit endpoint is independent of package installation and
|
||||
# can be temporarily unavailable (e.g. DNS EAI_AGAIN). Do not block a
|
||||
# production rollout on that external advisory lookup.
|
||||
if ! npm ci --no-audit --fund=false; then
|
||||
echo " WARNING: npm ci fehlgeschlagen (Lockfile ggf. nicht synchron). Fallback auf npm install..."
|
||||
npm install
|
||||
npm install --no-audit --fund=false
|
||||
fi
|
||||
else
|
||||
echo " WARNING: package-lock.json fehlt. Führe npm install aus..."
|
||||
npm install
|
||||
npm install --no-audit --fund=false
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -102,6 +105,18 @@ install_dependencies_if_needed() {
|
||||
printf '%s\n' "$current_lock_hash" > "$lock_hash_file"
|
||||
}
|
||||
|
||||
install_playwright_browser() {
|
||||
if [ ! -d "node_modules/playwright" ]; then
|
||||
echo " Playwright ist nicht installiert; Browser-Installation wird übersprungen"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Muss mit demselben Benutzer laufen, der später den PM2-Prozess startet;
|
||||
# Playwright legt den Browser im benutzerspezifischen Cache ab.
|
||||
echo " Installing/verifying Playwright Chromium browser..."
|
||||
npx playwright install chromium
|
||||
}
|
||||
|
||||
use_project_node() {
|
||||
export NVM_DIR="${NVM_DIR:-$HOME/.nvm}"
|
||||
if [ -s "$NVM_DIR/nvm.sh" ]; then
|
||||
@@ -278,6 +293,7 @@ echo "3. Installing dependencies..."
|
||||
use_project_node
|
||||
ensure_node_version
|
||||
install_dependencies_if_needed
|
||||
install_playwright_browser
|
||||
|
||||
# 4. Stop running apps before replacing build artifacts
|
||||
echo ""
|
||||
|
||||
@@ -69,14 +69,17 @@ has_tracked_files_under() {
|
||||
|
||||
install_dependencies() {
|
||||
if [ -f "package-lock.json" ]; then
|
||||
echo " Running: npm ci"
|
||||
if ! npm ci; then
|
||||
echo " Running: npm ci --no-audit"
|
||||
# The registry audit endpoint is independent of package installation and
|
||||
# can be temporarily unavailable (e.g. DNS EAI_AGAIN). Do not block a
|
||||
# test rollout on that external advisory lookup.
|
||||
if ! npm ci --no-audit --fund=false; then
|
||||
echo " WARNING: npm ci fehlgeschlagen (Lockfile ggf. nicht synchron). Fallback auf npm install..."
|
||||
npm install
|
||||
npm install --no-audit --fund=false
|
||||
fi
|
||||
else
|
||||
echo " WARNING: package-lock.json fehlt. Führe npm install aus..."
|
||||
npm install
|
||||
npm install --no-audit --fund=false
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -115,6 +118,18 @@ install_dependencies_if_needed() {
|
||||
printf '%s\n' "$current_lock_hash" > "$lock_hash_file"
|
||||
}
|
||||
|
||||
install_playwright_browser() {
|
||||
if [ ! -d "node_modules/playwright" ]; then
|
||||
echo " Playwright ist nicht installiert; Browser-Installation wird übersprungen"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Muss mit demselben Benutzer laufen, der später den PM2-Prozess startet;
|
||||
# Playwright legt den Browser im benutzerspezifischen Cache ab.
|
||||
echo " Installing/verifying Playwright Chromium browser..."
|
||||
npx playwright install chromium
|
||||
}
|
||||
|
||||
use_project_node() {
|
||||
export NVM_DIR="${NVM_DIR:-$HOME/.nvm}"
|
||||
if [ -s "$NVM_DIR/nvm.sh" ]; then
|
||||
@@ -284,6 +299,7 @@ echo "3. Installing dependencies..."
|
||||
use_project_node
|
||||
ensure_node_version
|
||||
install_dependencies_if_needed
|
||||
install_playwright_browser
|
||||
|
||||
# 4. Stop running app before replacing build artifacts
|
||||
echo ""
|
||||
|
||||
@@ -10,7 +10,7 @@ git pull origin main
|
||||
|
||||
# Dependencies installieren
|
||||
echo "📦 Installing dependencies..."
|
||||
npm install
|
||||
npm install --no-audit --fund=false
|
||||
|
||||
# Website bauen (Static Generation)
|
||||
echo "🔨 Building website..."
|
||||
|
||||
5107
package-lock.json
generated
5107
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
14
package.json
14
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "harheimertc-website",
|
||||
"version": "1.8.8",
|
||||
"version": "1.9.0",
|
||||
"description": "Moderne Webseite für den Harheimer Tischtennis Club",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
@@ -38,12 +38,13 @@
|
||||
"bcryptjs": "^2.4.3",
|
||||
"dompurify": "^3.3.1",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"multer": "^2.0.2",
|
||||
"nodemailer": "^9.0.1",
|
||||
"multer": "^2.4.0",
|
||||
"nodemailer": "^10.0.2",
|
||||
"nuxt": "^4.5.1",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pdf-parse": "^2.4.5",
|
||||
"pinia": "^3.0.3",
|
||||
"playwright": "^1.62.1",
|
||||
"quill": "2.0.2",
|
||||
"sharp": "^0.35.3",
|
||||
"vue": "^3.5.22"
|
||||
@@ -62,11 +63,14 @@
|
||||
"postcss": "^8.5.12",
|
||||
"supertest": "^7.1.0",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"vitest": "^4.1.8",
|
||||
"vitest": "^4.1.12",
|
||||
"vue-eslint-parser": "^10.2.0"
|
||||
},
|
||||
"overrides": {
|
||||
"@peculiar/x509": "1.13.0",
|
||||
"esbuild": "0.28.1"
|
||||
"devalue": "5.9.3",
|
||||
"esbuild": "0.28.1",
|
||||
"qs": "6.16.0",
|
||||
"undici": "8.10.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -201,6 +201,27 @@
|
||||
</p>
|
||||
</NuxtLink>
|
||||
|
||||
<NuxtLink
|
||||
v-if="authStore.hasAnyRole('admin', 'vorstand')"
|
||||
to="/cms/mytischtennis"
|
||||
class="bg-white p-6 rounded-xl shadow-lg border border-gray-100 hover:shadow-xl transition-all group"
|
||||
>
|
||||
<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">
|
||||
<RefreshCw
|
||||
:size="24"
|
||||
class="text-teal-600 group-hover:text-white"
|
||||
/>
|
||||
</div>
|
||||
<h2 class="ml-4 text-xl font-semibold text-gray-900">
|
||||
myTischtennis
|
||||
</h2>
|
||||
</div>
|
||||
<p class="text-gray-600">
|
||||
Vereinszugang und TTR-Synchronisierung
|
||||
</p>
|
||||
</NuxtLink>
|
||||
|
||||
<!-- Benutzerverwaltung (Admin ODER Vorstand) -->
|
||||
<NuxtLink
|
||||
v-if="authStore.hasAnyRole('admin', 'vorstand')"
|
||||
@@ -249,7 +270,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Newspaper, Calendar, Users, UserCog, Settings, Layout, Mail, ShieldAlert } from 'lucide-vue-next'
|
||||
import { Newspaper, Calendar, Users, UserCog, Settings, Layout, Mail, ShieldAlert, RefreshCw } from 'lucide-vue-next'
|
||||
import { ref, onMounted } from 'vue'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
113
pages/cms/mytischtennis.vue
Normal file
113
pages/cms/mytischtennis.vue
Normal file
@@ -0,0 +1,113 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-gray-50 py-10">
|
||||
<div class="max-w-2xl mx-auto px-4">
|
||||
<h1 class="text-3xl font-display font-bold text-gray-900">
|
||||
myTischtennis-Verbindung
|
||||
</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">
|
||||
Das Passwort wird verschlüsselt auf dem Server gespeichert und nie wieder angezeigt. Zugriff haben ausschließlich Admin und Vorstand.
|
||||
</div>
|
||||
<div>
|
||||
<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"
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<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"
|
||||
>
|
||||
</div>
|
||||
<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">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>
|
||||
<p
|
||||
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 }}
|
||||
</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">
|
||||
<button
|
||||
:disabled="busy"
|
||||
class="rounded-lg bg-primary-600 px-4 py-2 font-semibold text-white disabled:opacity-60"
|
||||
>
|
||||
Zugang speichern
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:disabled="busy || !status.configured"
|
||||
class="rounded-lg border border-primary-600 px-4 py-2 font-semibold text-primary-700 disabled:opacity-60"
|
||||
@click="runImport"
|
||||
>
|
||||
Jetzt abrufen
|
||||
</button>
|
||||
<button
|
||||
v-if="authStore.hasRole('admin')"
|
||||
type="button"
|
||||
:disabled="busy"
|
||||
class="rounded-lg border border-teal-600 px-4 py-2 font-semibold text-teal-700 disabled:opacity-60"
|
||||
@click="sendTestPush"
|
||||
>
|
||||
Push-Test an mich senden
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { useAuthStore } from '~/stores/auth'
|
||||
definePageMeta({ middleware: 'auth' })
|
||||
useHead({ title: 'myTischtennis-Verbindung – CMS' })
|
||||
const form = reactive({ email: '', password: '', association: 'HeTTV', clubId: '43030' })
|
||||
const authStore = useAuthStore()
|
||||
const status = ref({ configured: false })
|
||||
const busy = ref(false); const message = ref(''); const error = ref(false)
|
||||
const formattedLastImport = computed(() => status.value.lastSuccessfulImportAt ? new Intl.DateTimeFormat('de-DE', { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(status.value.lastSuccessfulImportAt)) : 'noch nie')
|
||||
async function load() { status.value = await $fetch('/api/cms/mytischtennis') }
|
||||
async function save() { busy.value = true; message.value = ''; error.value = false; try { status.value = await $fetch('/api/cms/mytischtennis', { method: 'PUT', body: form }); form.password = ''; message.value = 'Zugang verschlüsselt gespeichert.' } catch { error.value = true; message.value = 'Zugang konnte nicht gespeichert werden.' } finally { busy.value = false } }
|
||||
async function runImport() { busy.value = true; message.value = ''; error.value = false; try { const result = await $fetch('/api/cms/mytischtennis/import', { method: 'POST' }); message.value = `${result.rowCount} Werte wurden aktualisiert.`; await load() } catch (caught) { error.value = true; message.value = caught?.data?.message || 'Abruf fehlgeschlagen. Details stehen im Serverlog.'; await load().catch(() => {}) } finally { busy.value = false } }
|
||||
async function sendTestPush() { busy.value = true; message.value = ''; error.value = false; try { const result = await $fetch('/api/cms/notifications/test', { method: 'POST' }); if (result.registeredTokenCount === 0) { message.value = 'Kein Android-Token für dieses Konto gespeichert.'; error.value = true } else if (result.skipped) { message.value = `${result.registeredTokenCount} Android-Gerät(e) registriert, aber der FCM-Push-Dienst ist auf dem Server nicht konfiguriert.`; error.value = true } else if (result.deliveryError) { message.value = `${result.registeredTokenCount} Android-Gerät(e) registriert, aber: ${result.deliveryError}`; error.value = true } else if (result.sent > 0) { message.value = `Push-Test wurde an ${result.sent} registrierte(s) Android-Gerät(e) gesendet.` } else { message.value = `${result.registeredTokenCount} Android-Gerät(e) registriert, Zustellung fehlgeschlagen. Bitte Serverlog prüfen.`; error.value = true } } catch (caught) { error.value = true; message.value = caught?.data?.statusMessage || caught?.data?.message || 'Push-Test konnte nicht gesendet werden.' } finally { busy.value = false } }
|
||||
onMounted(load)
|
||||
</script>
|
||||
@@ -3,30 +3,49 @@
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 space-y-8">
|
||||
<div>
|
||||
<h1 class="text-4xl sm:text-5xl font-display font-bold text-gray-900 mb-4">
|
||||
QTTR-Werte
|
||||
TTR- und QTTR-Werte
|
||||
</h1>
|
||||
<div class="w-24 h-1 bg-primary-600 mb-6" />
|
||||
<p class="text-lg text-gray-700 max-w-3xl">
|
||||
Aus technischen Gründen sind nur die QTTR-Werte verfügbar. Für TTR bitte auf
|
||||
<a
|
||||
:href="externalUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-primary-600 hover:text-primary-800 underline"
|
||||
>myTischtennis</a>
|
||||
wechseln.
|
||||
Aktuelle TTR- und QTTR-Werte des Harheimer TC. Der Abruf erfolgt über die geschützte Vereinsverbindung.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<h2 class="text-xl font-semibold text-gray-900">
|
||||
Harheimer TC Rangliste
|
||||
</h2>
|
||||
<p class="text-sm text-gray-600">
|
||||
{{ data?.title || 'Andro-Rangliste' }} · {{ data?.rowCount || 0 }} Einträge
|
||||
</p>
|
||||
<div class="flex flex-wrap items-end gap-x-4 gap-y-3">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-gray-900">
|
||||
Harheimer TC Rangliste
|
||||
</h2>
|
||||
<p class="text-sm text-gray-600">
|
||||
{{ data?.title || 'Andro-Rangliste' }} · {{ data?.rowCount || 0 }} Einträge
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="inline-flex rounded-md border border-gray-200 bg-gray-50 p-0.5 shadow-sm"
|
||||
role="group"
|
||||
aria-label="Rangliste sortieren nach"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded px-3 py-1.5 text-sm font-semibold transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-600 focus-visible:ring-offset-2"
|
||||
:class="sortBy === 'qttr' ? 'bg-primary-600 text-white shadow-sm' : 'text-gray-700 hover:bg-white hover:text-primary-700'"
|
||||
:aria-pressed="sortBy === 'qttr'"
|
||||
@click="sortBy = 'qttr'"
|
||||
>
|
||||
QTTR
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded px-3 py-1.5 text-sm font-semibold transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-600 focus-visible:ring-offset-2"
|
||||
:class="sortBy === 'ttr' ? 'bg-primary-600 text-white shadow-sm' : 'text-gray-700 hover:bg-white hover:text-primary-700'"
|
||||
:aria-pressed="sortBy === 'ttr'"
|
||||
@click="sortBy = 'ttr'"
|
||||
>
|
||||
TTR
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-sm text-gray-500">
|
||||
Aktualisiert: {{ formatDate(data?.importedAt) }}
|
||||
@@ -37,13 +56,13 @@
|
||||
v-if="pending"
|
||||
class="py-12 text-center text-gray-500"
|
||||
>
|
||||
Lade QTTR-Werte...
|
||||
Lade TTR- und QTTR-Werte...
|
||||
</div>
|
||||
<div
|
||||
v-else-if="error"
|
||||
class="rounded-lg border border-red-200 bg-red-50 p-4 text-red-800"
|
||||
>
|
||||
{{ error.statusMessage || error.message || 'QTTR-Werte konnten nicht geladen werden.' }}
|
||||
{{ error.statusMessage || error.message || 'TTR- und QTTR-Werte konnten nicht geladen werden.' }}
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
@@ -53,7 +72,7 @@
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<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 class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500">
|
||||
Spieler
|
||||
@@ -61,6 +80,9 @@
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500">
|
||||
Verein
|
||||
</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-semibold uppercase tracking-wider text-gray-500">
|
||||
TTR
|
||||
</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-semibold uppercase tracking-wider text-gray-500">
|
||||
QTTR
|
||||
</th>
|
||||
@@ -68,12 +90,14 @@
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100 bg-white">
|
||||
<tr
|
||||
v-for="row in data?.rows || []"
|
||||
:key="`${row.rank}-${row.playerNumber || row.playerName}`"
|
||||
v-for="row in sortedRows"
|
||||
:key="row.playerNumber || row.playerName"
|
||||
:class="isOwnRow(row.playerName) ? 'bg-primary-100' : ''"
|
||||
>
|
||||
<td class="px-4 py-3 text-sm text-gray-600">
|
||||
{{ row.rank ?? '–' }}
|
||||
<span :title="clubRankTitle(row)">
|
||||
{{ formatClubRank(row) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<div :class="['font-medium', getPlayerNameClass(row)]">
|
||||
@@ -83,6 +107,9 @@
|
||||
<td class="px-4 py-3 text-sm text-gray-700">
|
||||
{{ row.clubName || 'Harheimer TC' }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right text-lg font-semibold text-gray-900 tabular-nums">
|
||||
{{ row.currentTtr ?? '–' }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right text-lg font-semibold text-gray-900 tabular-nums">
|
||||
{{ row.currentQttr ?? '–' }}
|
||||
</td>
|
||||
@@ -96,10 +123,9 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const externalUrl = 'https://www.mytischtennis.de/rankings/andro-rangliste?continent=all&country=Deutschland&all-players=on&as=DE.WE.R4.07&di=DE.WE.R4.07.04&area=DE.WE.R4.07.04.43&clubnr-search=Harheimer+TC&clubnr=43030&fednickname=HeTTV&gender=all¤t-ranking=yes&ttr-range=100%3B3000&birth-range=1926%3B2021'
|
||||
|
||||
definePageMeta({
|
||||
middleware: 'auth',
|
||||
@@ -111,6 +137,34 @@ await authStore.checkAuth()
|
||||
const { data, pending, error } = await useFetch('/api/mitgliederbereich/qttr')
|
||||
|
||||
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) {
|
||||
return String(value || '').trim().toLowerCase().replace(/\s+/g, ' ')
|
||||
@@ -126,7 +180,7 @@ function isMaleGender(value) {
|
||||
|
||||
function isFemaleGender(value) {
|
||||
const gender = normalizeName(value)
|
||||
return gender.startsWith('w') || gender.includes('weib') || gender.includes('frau')
|
||||
return gender.startsWith('w') || gender.includes('weib') || gender.includes('frau') || gender.includes('female')
|
||||
}
|
||||
|
||||
function isOwnRow(playerName) {
|
||||
@@ -135,6 +189,14 @@ function isOwnRow(playerName) {
|
||||
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) {
|
||||
const minor = isMinor(row.birthdate)
|
||||
if (minor && isMaleGender(row.gender)) return 'text-blue-400'
|
||||
@@ -180,6 +242,6 @@ function formatDate(value) {
|
||||
}
|
||||
|
||||
useHead({
|
||||
title: 'QTTR-Werte - Harheimer TC'
|
||||
title: 'TTR- und QTTR-Werte - Harheimer TC'
|
||||
})
|
||||
</script>
|
||||
|
||||
26
scripts/check-osv-severity.mjs
Normal file
26
scripts/check-osv-severity.mjs
Normal file
@@ -0,0 +1,26 @@
|
||||
import { readFile } from 'node:fs/promises'
|
||||
|
||||
const reportPath = process.argv[2]
|
||||
if (!reportPath) throw new Error('Pfad zum OSV-JSON-Report fehlt.')
|
||||
|
||||
const report = JSON.parse(await readFile(reportPath, 'utf8'))
|
||||
const findings = report.results.flatMap(result =>
|
||||
result.packages.flatMap(entry =>
|
||||
entry.groups.map(group => ({
|
||||
packageName: entry.package.name,
|
||||
packageVersion: entry.package.version,
|
||||
ids: group.ids.join(', '),
|
||||
severity: Number(group.max_severity) || 0
|
||||
}))
|
||||
)
|
||||
)
|
||||
|
||||
for (const finding of findings) {
|
||||
console.log(`OSV: ${finding.ids} in ${finding.packageName}@${finding.packageVersion} (CVSS ${finding.severity})`)
|
||||
}
|
||||
|
||||
const critical = findings.filter(finding => finding.severity >= 9)
|
||||
if (critical.length > 0) {
|
||||
console.error(`OSV-Scanner: ${critical.length} kritische(r) Befund(e) (CVSS >= 9.0).`)
|
||||
process.exitCode = 1
|
||||
}
|
||||
10
server/api/cms/mytischtennis.get.js
Normal file
10
server/api/cms/mytischtennis.get.js
Normal file
@@ -0,0 +1,10 @@
|
||||
import { getUserFromToken, hasAnyRole } from '../../utils/auth.js'
|
||||
import { publicConnectionStatus, readMyTischtennisConnection } from '../../utils/mytischtennis-connection.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '')
|
||||
const user = token ? await getUserFromToken(token) : null
|
||||
if (!user) throw createError({ statusCode: 401, statusMessage: 'Nicht authentifiziert' })
|
||||
if (!hasAnyRole(user, 'admin', 'vorstand')) throw createError({ statusCode: 403, statusMessage: 'Keine Berechtigung' })
|
||||
return publicConnectionStatus(await readMyTischtennisConnection())
|
||||
})
|
||||
17
server/api/cms/mytischtennis.put.js
Normal file
17
server/api/cms/mytischtennis.put.js
Normal file
@@ -0,0 +1,17 @@
|
||||
import { getUserFromToken, hasAnyRole } from '../../utils/auth.js'
|
||||
import { readMyTischtennisConnection, saveMyTischtennisConnection, publicConnectionStatus } from '../../utils/mytischtennis-connection.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '')
|
||||
const user = token ? await getUserFromToken(token) : null
|
||||
if (!user) throw createError({ statusCode: 401, statusMessage: 'Nicht authentifiziert' })
|
||||
if (!hasAnyRole(user, 'admin', 'vorstand')) throw createError({ statusCode: 403, statusMessage: 'Keine Berechtigung' })
|
||||
const body = await readBody(event)
|
||||
if (!/^\S+@\S+\.\S+$/.test(String(body.email || '')) || String(body.password || '').length < 1) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'E-Mail und Passwort sind erforderlich.' })
|
||||
}
|
||||
const previous = await readMyTischtennisConnection()
|
||||
const connection = { ...previous, email: body.email.trim(), password: body.password, association: String(body.association || 'HeTTV'), clubId: String(body.clubId || '43030') }
|
||||
await saveMyTischtennisConnection(connection)
|
||||
return publicConnectionStatus(connection)
|
||||
})
|
||||
24
server/api/cms/mytischtennis/import.post.js
Normal file
24
server/api/cms/mytischtennis/import.post.js
Normal file
@@ -0,0 +1,24 @@
|
||||
import { getUserFromToken, hasAnyRole } from '../../../utils/auth.js'
|
||||
import { importQttrValues } from '../../../utils/qttr-import.js'
|
||||
import { readMyTischtennisConnection, saveMyTischtennisConnection } from '../../../utils/mytischtennis-connection.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '')
|
||||
const user = token ? await getUserFromToken(token) : null
|
||||
if (!user) throw createError({ statusCode: 401, statusMessage: 'Nicht authentifiziert' })
|
||||
if (!hasAnyRole(user, 'admin', 'vorstand')) throw createError({ statusCode: 403, statusMessage: 'Keine Berechtigung' })
|
||||
const connection = await readMyTischtennisConnection()
|
||||
if (!connection) throw createError({ statusCode: 409, statusMessage: 'Keine myTischtennis-Verbindung eingerichtet.' })
|
||||
try {
|
||||
const result = await importQttrValues({ connection })
|
||||
return { success: true, rowCount: result.rowCount, importedAt: result.importedAt }
|
||||
} catch (error) {
|
||||
const fullDetail = String(error.stack || error.message || error)
|
||||
const detail = fullDetail.slice(0, 500)
|
||||
// Vollständig nur ins serverseitige PM2-Log schreiben; im CMS wird der Fehler
|
||||
// bewusst gekürzt gespeichert, damit Browser-/Systempfade nicht öffentlich werden.
|
||||
console.error('[mytischtennis] TTR-Abruf fehlgeschlagen:', fullDetail)
|
||||
await saveMyTischtennisConnection({ ...connection, lastImportError: detail })
|
||||
throw createError({ statusCode: 502, message: 'TTR-Abruf fehlgeschlagen. Details stehen im Serverlog.' })
|
||||
}
|
||||
})
|
||||
22
server/api/cms/notifications/test.post.js
Normal file
22
server/api/cms/notifications/test.post.js
Normal file
@@ -0,0 +1,22 @@
|
||||
import { getUserFromToken, hasRole } from '../../../utils/auth.js'
|
||||
import { androidPushTokenCountForUser, sendTestPushToUser } from '../../../utils/push-notifications.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '')
|
||||
const user = token ? await getUserFromToken(token) : null
|
||||
if (!user) throw createError({ statusCode: 401, statusMessage: 'Nicht authentifiziert' })
|
||||
if (!hasRole(user, 'admin')) throw createError({ statusCode: 403, statusMessage: 'Nur Administratoren dürfen einen Push-Test auslösen.' })
|
||||
|
||||
const registeredTokenCount = await androidPushTokenCountForUser(user.id)
|
||||
try {
|
||||
const result = await sendTestPushToUser(user.id)
|
||||
return { success: true, registeredTokenCount, ...result }
|
||||
} catch (error) {
|
||||
console.error('FCM Test-Push konnte nicht vorbereitet werden:', error)
|
||||
return {
|
||||
success: false,
|
||||
registeredTokenCount,
|
||||
deliveryError: 'Die FCM-Serververbindung konnte nicht aufgebaut werden. Details stehen im Serverlog.'
|
||||
}
|
||||
}
|
||||
})
|
||||
11
server/api/mitgliederbereich/live-ticker.get.js
Normal file
11
server/api/mitgliederbereich/live-ticker.get.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import { requireAuthenticatedUser } from '../../utils/auth.js'
|
||||
import { getLiveTicker, refreshLiveTicker } from '../../utils/live-ticker.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await requireAuthenticatedUser(event)
|
||||
try {
|
||||
return await refreshLiveTicker()
|
||||
} catch {
|
||||
return getLiveTicker()
|
||||
}
|
||||
})
|
||||
@@ -37,6 +37,26 @@ function buildBirthdateLookup(entries) {
|
||||
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) => {
|
||||
const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '')
|
||||
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))
|
||||
const birthdateLookup = buildBirthdateLookup([...visibleManualMembers, ...visibleUsers])
|
||||
|
||||
const rankedRows = addClubRanks(Array.isArray(payload.rows) ? payload.rows.map(row => ({ ...row })) : [])
|
||||
|
||||
return {
|
||||
...payload,
|
||||
rows: Array.isArray(payload.rows)
|
||||
? payload.rows
|
||||
rows: rankedRows
|
||||
.filter(row => !hiddenNames.has(normalizeName(row.playerName)))
|
||||
.map((row) => ({
|
||||
...row,
|
||||
birthdate: birthdateLookup.get(normalizeName(row.playerName)) || row.birthdate || ''
|
||||
}))
|
||||
: []
|
||||
}
|
||||
} catch (error) {
|
||||
if (error?.code === 'ENOENT') {
|
||||
@@ -98,4 +118,4 @@ export default defineEventHandler(async (event) => {
|
||||
message: 'Fehler beim Laden der QTTR-Werte.'
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -22,7 +22,8 @@ export default defineEventHandler(async (event) => {
|
||||
upsertPushToken(users[userIndex], {
|
||||
token: body.token,
|
||||
platform: body.platform || 'android',
|
||||
appVersion: body.appVersion || null
|
||||
appVersion: body.appVersion || null,
|
||||
installationId: body.installationId || null
|
||||
})
|
||||
await writeUsers(users)
|
||||
return { success: true, message: 'Push-Token gespeichert.' }
|
||||
|
||||
@@ -10,6 +10,74 @@ async function exists(filePath) {
|
||||
}
|
||||
}
|
||||
|
||||
function parseCsvLine(line) {
|
||||
const values = []
|
||||
let value = ''
|
||||
let quoted = false
|
||||
|
||||
for (let index = 0; index < line.length; index++) {
|
||||
const char = line[index]
|
||||
if (char === '"') {
|
||||
if (quoted && line[index + 1] === '"') {
|
||||
value += '"'
|
||||
index++
|
||||
} else {
|
||||
quoted = !quoted
|
||||
}
|
||||
} else if (char === ',' && !quoted) {
|
||||
values.push(value.trim())
|
||||
value = ''
|
||||
} else {
|
||||
value += char
|
||||
}
|
||||
}
|
||||
|
||||
values.push(value.trim())
|
||||
return values
|
||||
}
|
||||
|
||||
function categoryOrder(category) {
|
||||
const normalized = category.toLocaleLowerCase('de-DE')
|
||||
if (normalized.includes('einzel')) return 0
|
||||
if (normalized.includes('doppel')) return 1
|
||||
return 2
|
||||
}
|
||||
|
||||
function placementOrder(placement) {
|
||||
const match = String(placement).match(/\d+/)
|
||||
return match ? Number(match[0]) : Number.MAX_SAFE_INTEGER
|
||||
}
|
||||
|
||||
function sortChampionshipCsv(csv) {
|
||||
const newline = csv.includes('\r\n') ? '\r\n' : '\n'
|
||||
const lines = csv.split(/\r?\n/)
|
||||
const [header, ...dataLines] = lines
|
||||
const entries = dataLines
|
||||
.filter(line => line.trim() !== '')
|
||||
.map((line, index) => ({ line, values: parseCsvLine(line), index }))
|
||||
|
||||
entries.sort((a, b) => {
|
||||
const yearA = Number(a.values[0])
|
||||
const yearB = Number(b.values[0])
|
||||
if (Number.isFinite(yearA) && Number.isFinite(yearB) && yearA !== yearB) return yearB - yearA
|
||||
|
||||
const categoryA = a.values[1] || ''
|
||||
const categoryB = b.values[1] || ''
|
||||
const categoryDifference = categoryOrder(categoryA) - categoryOrder(categoryB)
|
||||
if (categoryDifference !== 0) return categoryDifference
|
||||
|
||||
const categoryNameDifference = categoryA.localeCompare(categoryB, 'de-DE')
|
||||
if (categoryNameDifference !== 0) return categoryNameDifference
|
||||
|
||||
const placementDifference = placementOrder(a.values[2]) - placementOrder(b.values[2])
|
||||
if (placementDifference !== 0) return placementDifference
|
||||
|
||||
return a.index - b.index
|
||||
})
|
||||
|
||||
return [header, ...entries.map(entry => entry.line)].join(newline)
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
const cwd = process.cwd()
|
||||
@@ -50,7 +118,7 @@ export default defineEventHandler(async (event) => {
|
||||
setHeader(event, 'Pragma', 'no-cache')
|
||||
setHeader(event, 'Expires', '0')
|
||||
|
||||
return csv
|
||||
return sortChampionshipCsv(csv)
|
||||
} catch (error) {
|
||||
if (error?.statusCode) throw error
|
||||
console.error('Fehler beim Laden der Vereinsmeisterschaften:', error)
|
||||
@@ -60,4 +128,3 @@ export default defineEventHandler(async (event) => {
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
10
server/plugins/live-ticker-scheduler.js
Normal file
10
server/plugins/live-ticker-scheduler.js
Normal file
@@ -0,0 +1,10 @@
|
||||
import { refreshLiveTicker } from '../utils/live-ticker.js'
|
||||
import { error as loggerError } from '../utils/logger.js'
|
||||
|
||||
export default defineNitroPlugin((nitroApp) => {
|
||||
const run = () => refreshLiveTicker().catch(error => loggerError('[live-ticker] Aktualisierung fehlgeschlagen', { error }))
|
||||
const timer = setInterval(run, 30_000)
|
||||
timer.unref?.()
|
||||
run()
|
||||
nitroApp.hooks.hookOnce('close', () => clearInterval(timer))
|
||||
})
|
||||
@@ -141,7 +141,7 @@ function createQttrJob() {
|
||||
return {
|
||||
run: async (reason) => {
|
||||
const qttr = await importQttrValues()
|
||||
loggerInfo(`[qttr-import] ${reason}: ${qttr.rowCount} QTTR-Werte importiert`, {
|
||||
loggerInfo(`[qttr-import] ${reason}: ${qttr.rowCount} TTR-/QTTR-Werte importiert`, {
|
||||
outputFile: qttr.outputFile,
|
||||
tableCount: qttr.tableCount
|
||||
})
|
||||
|
||||
172
server/utils/live-ticker.js
Normal file
172
server/utils/live-ticker.js
Normal file
@@ -0,0 +1,172 @@
|
||||
import { promises as fs } from 'fs'
|
||||
import path from 'path'
|
||||
import { getCurrentSeasonSlug, readSpielplanData } from './spielplan-data.js'
|
||||
import { getServerDataPath } from './paths.js'
|
||||
import { sendLiveTickerFinishedPush, sendLiveTickerScoreChangedPush, sendLiveTickerStartedPush } from './push-notifications.js'
|
||||
import { error as loggerError } from './logger.js'
|
||||
|
||||
const TICKER_WINDOW_BEFORE_MS = 30 * 60 * 1000
|
||||
const TICKER_WINDOW_AFTER_MS = 6 * 60 * 60 * 1000
|
||||
const FINISHED_VISIBLE_MS = 60 * 60 * 1000
|
||||
const LIVE_START_STATE_FILE = getServerDataPath('live-ticker-notifications.json')
|
||||
const LIVE_TICKER_STATE_FILE = getServerDataPath('live-ticker-observed-state.json')
|
||||
const LIVE_START_STATE_RETENTION_MS = 2 * 24 * 60 * 60 * 1000
|
||||
const cache = new Map()
|
||||
let liveStartNotifications = null
|
||||
let observedLiveTickerStates = null
|
||||
let refreshInFlight = null
|
||||
|
||||
function matchTime(row) {
|
||||
const timestamp = Number(row.Timestamp)
|
||||
return Number.isFinite(timestamp) ? timestamp * 1000 : NaN
|
||||
}
|
||||
|
||||
function score(data) {
|
||||
const home = data.is_completed ? data.matches_home : data.match?.reduce((sum, item) => sum + (Number(item.matches_home) || 0), 0)
|
||||
const away = data.is_completed ? data.matches_guest : data.match?.reduce((sum, item) => sum + (Number(item.matches_guest) || 0), 0)
|
||||
return `${home ?? 0}:${away ?? 0}`
|
||||
}
|
||||
|
||||
function completedTime(data, fallback) {
|
||||
const timestamp = Date.parse(data.end_date || data.entry_date || '')
|
||||
return Number.isFinite(timestamp) ? timestamp : fallback
|
||||
}
|
||||
|
||||
async function readLiveStartNotifications() {
|
||||
if (liveStartNotifications) return liveStartNotifications
|
||||
try {
|
||||
const parsed = JSON.parse(await fs.readFile(LIVE_START_STATE_FILE, 'utf8'))
|
||||
liveStartNotifications = parsed && typeof parsed === 'object' ? parsed : {}
|
||||
} catch (error) {
|
||||
if (error?.code !== 'ENOENT') loggerError('[live-ticker] Push-Status konnte nicht gelesen werden', { error })
|
||||
liveStartNotifications = {}
|
||||
}
|
||||
return liveStartNotifications
|
||||
}
|
||||
|
||||
async function saveLiveStartNotifications(nowMs) {
|
||||
const state = await readLiveStartNotifications()
|
||||
for (const [meetingId, notifiedAt] of Object.entries(state)) {
|
||||
if (nowMs - Date.parse(notifiedAt) > LIVE_START_STATE_RETENTION_MS) delete state[meetingId]
|
||||
}
|
||||
await fs.mkdir(path.dirname(LIVE_START_STATE_FILE), { recursive: true })
|
||||
await fs.writeFile(LIVE_START_STATE_FILE, `${JSON.stringify(state, null, 2)}\n`, 'utf8')
|
||||
}
|
||||
|
||||
async function notifyLiveStart(entry, nowMs) {
|
||||
const state = await readLiveStartNotifications()
|
||||
if (state[entry.meetingId]) return
|
||||
state[entry.meetingId] = new Date(nowMs).toISOString()
|
||||
try {
|
||||
await saveLiveStartNotifications(nowMs)
|
||||
await sendLiveTickerStartedPush(entry)
|
||||
} catch (error) {
|
||||
loggerError('[live-ticker] Start-Push konnte nicht gesendet werden', { meetingId: entry.meetingId, error })
|
||||
}
|
||||
}
|
||||
|
||||
async function readObservedLiveTickerStates() {
|
||||
if (observedLiveTickerStates) return observedLiveTickerStates
|
||||
try {
|
||||
const parsed = JSON.parse(await fs.readFile(LIVE_TICKER_STATE_FILE, 'utf8'))
|
||||
observedLiveTickerStates = parsed && typeof parsed === 'object' ? parsed : {}
|
||||
} catch (error) {
|
||||
if (error?.code !== 'ENOENT') loggerError('[live-ticker] Beobachtete Stände konnten nicht gelesen werden', { error })
|
||||
observedLiveTickerStates = {}
|
||||
}
|
||||
return observedLiveTickerStates
|
||||
}
|
||||
|
||||
async function saveObservedLiveTickerStates(nowMs) {
|
||||
const states = await readObservedLiveTickerStates()
|
||||
for (const [meetingId, state] of Object.entries(states)) {
|
||||
if (nowMs - Date.parse(state?.updatedAt || '') > LIVE_START_STATE_RETENTION_MS) delete states[meetingId]
|
||||
}
|
||||
await fs.mkdir(path.dirname(LIVE_TICKER_STATE_FILE), { recursive: true })
|
||||
await fs.writeFile(LIVE_TICKER_STATE_FILE, `${JSON.stringify(states, null, 2)}\n`, 'utf8')
|
||||
}
|
||||
|
||||
async function notifyLiveTickerChanges(entry, nowMs) {
|
||||
const states = await readObservedLiveTickerStates()
|
||||
const previous = states[entry.meetingId]
|
||||
const startedNow = entry.hasLiveData && !previous?.hasLiveData
|
||||
const finishedNow = entry.completed && previous && !previous.completed
|
||||
const scoreChanged = entry.hasLiveData && !entry.completed && previous?.hasLiveData && previous.score !== entry.score
|
||||
|
||||
states[entry.meetingId] = {
|
||||
hasLiveData: entry.hasLiveData,
|
||||
completed: entry.completed,
|
||||
score: entry.score,
|
||||
updatedAt: new Date(nowMs).toISOString()
|
||||
}
|
||||
try {
|
||||
await saveObservedLiveTickerStates(nowMs)
|
||||
if (startedNow) await notifyLiveStart(entry, nowMs)
|
||||
if (scoreChanged) await sendLiveTickerScoreChangedPush(entry)
|
||||
if (finishedNow) await sendLiveTickerFinishedPush(entry)
|
||||
} catch (error) {
|
||||
loggerError('[live-ticker] Live-Push konnte nicht gesendet werden', { meetingId: entry.meetingId, error })
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshLiveTickerOnce(now = new Date()) {
|
||||
const season = getCurrentSeasonSlug(now)
|
||||
const { data } = await readSpielplanData({ season })
|
||||
const nowMs = now.getTime()
|
||||
const candidates = data.filter((row) => {
|
||||
const startsAt = matchTime(row)
|
||||
return Number.isFinite(startsAt) && startsAt - TICKER_WINDOW_BEFORE_MS <= nowMs && nowMs <= startsAt + TICKER_WINDOW_AFTER_MS
|
||||
})
|
||||
|
||||
await Promise.all(candidates.map(async (row) => {
|
||||
const meetingId = String(row.MeetingId || row.BegegnungNr || '').trim()
|
||||
if (!/^\d{6,}$/.test(meetingId)) return
|
||||
try {
|
||||
const response = await fetch(`https://www.mytischtennis.de/api/meeting/${meetingId}/live`, {
|
||||
headers: { accept: 'application/json', 'accept-language': 'de-DE,de;q=0.9' },
|
||||
signal: AbortSignal.timeout(10_000)
|
||||
})
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
||||
const payload = await response.json()
|
||||
const report = payload?.data
|
||||
if (!report) throw new Error('Keine Spieldaten erhalten')
|
||||
const completedAt = report.is_completed ? completedTime(report, nowMs) : null
|
||||
const previous = cache.get(meetingId)
|
||||
const entry = {
|
||||
meetingId,
|
||||
homeTeam: report.team_home || row.HeimMannschaft,
|
||||
awayTeam: report.team_guest || row.GastMannschaft,
|
||||
score: score(report),
|
||||
startsAt: matchTime(row),
|
||||
hasLiveData: report.has_nu_live_push_data === true,
|
||||
completed: report.is_completed === true,
|
||||
completedAt: completedAt || previous?.completedAt || null,
|
||||
updatedAt: new Date(nowMs).toISOString()
|
||||
}
|
||||
cache.set(meetingId, entry)
|
||||
await notifyLiveTickerChanges(entry, nowMs)
|
||||
} catch (error) {
|
||||
const previous = cache.get(meetingId)
|
||||
if (previous) cache.set(meetingId, { ...previous, updatedAt: previous.updatedAt, error: 'Live-Daten derzeit nicht erreichbar' })
|
||||
}
|
||||
}))
|
||||
|
||||
for (const [meetingId, entry] of cache) {
|
||||
if (entry.completed && entry.completedAt && nowMs - entry.completedAt > FINISHED_VISIBLE_MS) cache.delete(meetingId)
|
||||
}
|
||||
return getLiveTicker(now)
|
||||
}
|
||||
|
||||
export function refreshLiveTicker(now = new Date()) {
|
||||
if (refreshInFlight) return refreshInFlight
|
||||
refreshInFlight = refreshLiveTickerOnce(now).finally(() => { refreshInFlight = null })
|
||||
return refreshInFlight
|
||||
}
|
||||
|
||||
export function getLiveTicker(now = new Date()) {
|
||||
const nowMs = now.getTime()
|
||||
const matches = [...cache.values()]
|
||||
.filter((entry) => !entry.completed || !entry.completedAt || nowMs - entry.completedAt <= FINISHED_VISIBLE_MS)
|
||||
.sort((a, b) => Number(b.hasLiveData && !b.completed) - Number(a.hasLiveData && !a.completed) || a.startsAt - b.startsAt)
|
||||
return { matches, updatedAt: new Date(nowMs).toISOString() }
|
||||
}
|
||||
170
server/utils/mytischtennis-client.js
Normal file
170
server/utils/mytischtennis-client.js
Normal file
@@ -0,0 +1,170 @@
|
||||
import { chromium } from 'playwright'
|
||||
|
||||
const BASE_URL = 'https://www.mytischtennis.de'
|
||||
const HEADERS = { 'accept-language': 'de-DE,de;q=0.9', 'user-agent': 'Harheimer-TC-Vereinsverwaltung/1.0' }
|
||||
|
||||
function findAuthCookie(headers) {
|
||||
const raw = headers.getSetCookie?.() || (headers.get('set-cookie') ? [headers.get('set-cookie')] : [])
|
||||
return raw.find(value => value.startsWith('sb-10-auth-token='))?.split(';')[0] || null
|
||||
}
|
||||
|
||||
function findEntries(value) {
|
||||
if (!value || typeof value !== 'object') return null
|
||||
if (Array.isArray(value.entries)) return value.entries
|
||||
for (const child of Object.values(value)) {
|
||||
const result = findEntries(child)
|
||||
if (result) return result
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async function loginDirect(email, password) {
|
||||
const page = await fetch(`${BASE_URL}/login?next=%2F`, { headers: HEADERS })
|
||||
const html = await page.text()
|
||||
if (/captcha/i.test(html)) throw new Error('CAPTCHA_REQUIRED')
|
||||
const xsrf = html.match(/name=["']xsrf["'][^>]*value=["']([^"']+)/i)?.[1]
|
||||
const form = new URLSearchParams({ email, password, intent: 'login' })
|
||||
if (xsrf) form.set('xsrf', xsrf)
|
||||
const result = await fetch(`${BASE_URL}/login?next=%2F&_data=routes%2F_auth%2B%2Flogin`, {
|
||||
method: 'POST', headers: { ...HEADERS, 'content-type': 'application/x-www-form-urlencoded' }, body: form, redirect: 'manual'
|
||||
})
|
||||
const cookie = findAuthCookie(result.headers)
|
||||
if (!cookie) throw new Error('Direkter myTischtennis-Login fehlgeschlagen.')
|
||||
return cookie
|
||||
}
|
||||
|
||||
async function loginWithBrowser(email, password) {
|
||||
const browser = await chromium.launch({
|
||||
headless: true,
|
||||
args: ['--no-sandbox', '--disable-dev-shm-usage']
|
||||
})
|
||||
try {
|
||||
const page = await browser.newPage()
|
||||
// Das myTT-CAPTCHA wird erst nach dem Laden der Seite erzeugt. Der Ablauf
|
||||
// entspricht der erprobten Integration im Trainingstagebuch.
|
||||
const acceptConsentDialog = async (waitMs = 0) => {
|
||||
if (waitMs) await page.waitForTimeout(waitMs)
|
||||
for (const selector of [
|
||||
'#onetrust-accept-btn-handler', 'button:has-text("Alle akzeptieren")',
|
||||
'button:has-text("Akzeptieren")', 'button:has-text("Einverstanden")',
|
||||
'button:has-text("Zustimmen")', '[data-testid="accept-button"]', '.cmp-accept-all', '.accept-all-btn'
|
||||
]) {
|
||||
try {
|
||||
const button = page.locator(selector).first()
|
||||
if (await button.count()) { await button.click({ timeout: 2_500 }); await page.waitForTimeout(800); return true }
|
||||
} catch { /* try next CMP selector */ }
|
||||
}
|
||||
return false
|
||||
}
|
||||
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded', timeout: 30_000 }).catch(() => {})
|
||||
if (!await acceptConsentDialog()) await acceptConsentDialog(2_500)
|
||||
await page.goto(`${BASE_URL}/login?next=%2F`, { waitUntil: 'domcontentloaded', timeout: 45_000 })
|
||||
if (!await acceptConsentDialog()) await acceptConsentDialog(1_500)
|
||||
await page.locator('input[name="email"]').fill(email)
|
||||
await page.locator('input[name="password"]').fill(password)
|
||||
|
||||
// Exakt wie in trainingstagebuch: Das Widget wird nach dem DOM-Load
|
||||
// nachgeladen; ein sofortiges count() würde es häufig übersehen.
|
||||
await page.waitForSelector('private-captcha', { timeout: 8_000 }).catch(() => {})
|
||||
const captchaHost = page.locator('private-captcha').first()
|
||||
const hasCaptcha = await captchaHost.count() > 0
|
||||
console.info('[mytischtennis] CAPTCHA widget detected:', hasCaptcha)
|
||||
if (hasCaptcha) {
|
||||
await page.waitForTimeout(1_200)
|
||||
// Ein echter Playwright-Klick erzeugt im Gegensatz zu element.click() ein
|
||||
// vertrauenswürdiges Pointer-Ereignis für das Widget.
|
||||
await captchaHost.click({ timeout: 5_000, force: true }).catch(error => {
|
||||
console.warn('[mytischtennis] CAPTCHA pointer click failed:', error.message)
|
||||
})
|
||||
await page.evaluate(() => {
|
||||
const host = document.querySelector('private-captcha')
|
||||
const checkbox = host?.shadowRoot?.querySelector('#pc-checkbox')
|
||||
if (!checkbox) return
|
||||
checkbox.click()
|
||||
checkbox.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
checkbox.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
})
|
||||
await page.waitForFunction(() => {
|
||||
const token = document.querySelector('input[name="captcha"]')?.value?.trim() || ''
|
||||
const clicked = document.querySelector('input[name="captcha_clicked"]')?.value?.toLowerCase() || ''
|
||||
return token.length > 80 && (clicked === 'true' || clicked === '1')
|
||||
}, { timeout: 32_000 }).catch(() => {})
|
||||
const captchaState = await page.evaluate(() => {
|
||||
const host = document.querySelector('private-captcha')
|
||||
const checkbox = host?.shadowRoot?.querySelector('#pc-checkbox')
|
||||
const token = document.querySelector('input[name="captcha"]')?.value?.trim() || ''
|
||||
return {
|
||||
tokenLength: token.length,
|
||||
clicked: document.querySelector('input[name="captcha_clicked"]')?.value || null,
|
||||
shadowRoot: Boolean(host?.shadowRoot),
|
||||
checkboxFound: Boolean(checkbox),
|
||||
checkboxChecked: Boolean(checkbox?.checked),
|
||||
widgetState: host?.getAttribute('data-state') || null
|
||||
}
|
||||
})
|
||||
console.info('[mytischtennis] CAPTCHA state after wait:', captchaState)
|
||||
const captchaReady = captchaState.tokenLength > 80
|
||||
if (!captchaReady) throw new Error('CAPTCHA konnte nicht automatisch gelöst werden.')
|
||||
await page.waitForTimeout(2_500)
|
||||
}
|
||||
|
||||
await page.evaluate(() => {
|
||||
const form = document.querySelector('form[action*="/login"]')
|
||||
if (!form) return
|
||||
let intent = form.querySelector('input[name="intent"]')
|
||||
if (!intent) {
|
||||
intent = document.createElement('input')
|
||||
intent.setAttribute('type', 'hidden')
|
||||
intent.setAttribute('name', 'intent')
|
||||
form.appendChild(intent)
|
||||
}
|
||||
intent.setAttribute('value', 'login')
|
||||
})
|
||||
const submit = page.locator('button[type="submit"][name="intent"][value="login"]').first()
|
||||
const genericSubmit = page.locator('button[type="submit"], input[type="submit"]').first()
|
||||
if (await submit.count()) await submit.click({ noWaitAfter: true })
|
||||
else if (await genericSubmit.count()) await genericSubmit.click({ noWaitAfter: true })
|
||||
else await page.locator('form').evaluate(form => form.requestSubmit())
|
||||
let cookie = null
|
||||
let cookieNames = []
|
||||
let loginPageText = ''
|
||||
for (let attempt = 0; attempt < 40; attempt += 1) {
|
||||
const cookies = await page.context().cookies()
|
||||
cookieNames = cookies.map(item => item.name)
|
||||
cookie = cookies.find(item => item.name === 'sb-10-auth-token' || /^sb-\d+-auth-token$/.test(item.name) || item.name.includes('auth-token'))
|
||||
if (cookie) break
|
||||
if (attempt % 4 === 0) {
|
||||
loginPageText = await page.locator('body').innerText().catch(() => '')
|
||||
}
|
||||
await page.waitForTimeout(500)
|
||||
}
|
||||
if (!cookie) {
|
||||
console.warn('[mytischtennis] Login after solved CAPTCHA did not create a session', {
|
||||
url: page.url(), cookieNames, pageText: loginPageText.slice(0, 1_000)
|
||||
})
|
||||
throw new Error('myTischtennis-Login nach gelöstem CAPTCHA fehlgeschlagen.')
|
||||
}
|
||||
return `${cookie.name}=${cookie.value}`
|
||||
} finally { await browser.close() }
|
||||
}
|
||||
|
||||
export async function fetchClubRankings(connection, currentRanking = 'yes') {
|
||||
let cookie
|
||||
try { cookie = await loginDirect(connection.email, connection.password) } catch (error) {
|
||||
if (error.message !== 'CAPTCHA_REQUIRED') throw error
|
||||
cookie = await loginWithBrowser(connection.email, connection.password)
|
||||
}
|
||||
const url = new URL('/rankings/andro-rangliste', BASE_URL)
|
||||
url.search = new URLSearchParams({ 'all-players': 'on', clubnr: connection.clubId, fednickname: connection.association, 'current-ranking': currentRanking, 'results-per-page': '100', page: '0', _data: 'routes/$' })
|
||||
console.info('[mytischtennis] Ranking request URL:', url.toString())
|
||||
const response = await fetch(url, { headers: { ...HEADERS, cookie, accept: 'application/json', referer: `${BASE_URL}/` } })
|
||||
if (!response.ok) throw new Error(`Ranglisten-Abruf fehlgeschlagen (HTTP ${response.status}).`)
|
||||
const entries = findEntries(await response.json())
|
||||
if (!entries) throw new Error('myTischtennis hat keine Ranglisten-Daten geliefert.')
|
||||
console.info('[mytischtennis] Ranking response:', {
|
||||
currentRanking,
|
||||
entryCount: entries.length,
|
||||
entryKeys: Object.keys(entries[0] || {})
|
||||
})
|
||||
return entries
|
||||
}
|
||||
44
server/utils/mytischtennis-connection.js
Normal file
44
server/utils/mytischtennis-connection.js
Normal file
@@ -0,0 +1,44 @@
|
||||
import { promises as fs } from 'fs'
|
||||
import { encryptObject, decryptObject } from './encryption.js'
|
||||
import { getServerDataPath } from './paths.js'
|
||||
|
||||
const FILE = getServerDataPath('mytischtennis-connection.json')
|
||||
|
||||
function encryptionKey() {
|
||||
const key = process.env.ENCRYPTION_KEY
|
||||
if (!key) throw new Error('ENCRYPTION_KEY ist für die myTischtennis-Verbindung erforderlich.')
|
||||
return key
|
||||
}
|
||||
|
||||
export async function readMyTischtennisConnection() {
|
||||
try {
|
||||
const stored = JSON.parse(await fs.readFile(FILE, 'utf8'))
|
||||
return decryptObject(stored.encrypted, encryptionKey())
|
||||
} catch (error) {
|
||||
if (error?.code === 'ENOENT') return null
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveMyTischtennisConnection(connection) {
|
||||
const payload = {
|
||||
version: 1,
|
||||
encrypted: encryptObject(connection, encryptionKey())
|
||||
}
|
||||
await fs.mkdir(getServerDataPath(), { recursive: true })
|
||||
await fs.writeFile(FILE, `${JSON.stringify(payload)}\n`, { encoding: 'utf8', mode: 0o600 })
|
||||
}
|
||||
|
||||
export function publicConnectionStatus(connection) {
|
||||
if (!connection) return { configured: false }
|
||||
const email = String(connection.email || '')
|
||||
const [name, domain] = email.split('@')
|
||||
return {
|
||||
configured: true,
|
||||
email: name ? `${name.slice(0, 2)}${'•'.repeat(Math.max(1, name.length - 2))}${domain ? `@${domain}` : ''}` : null,
|
||||
association: connection.association || null,
|
||||
clubId: connection.clubId || null,
|
||||
lastSuccessfulImportAt: connection.lastSuccessfulImportAt || null,
|
||||
lastImportError: connection.lastImportError || null
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,10 @@ export const DEFAULT_NOTIFICATION_SETTINGS = Object.freeze({
|
||||
birthdays: false,
|
||||
newContactRequest: false,
|
||||
newUserRegistration: false,
|
||||
ownTtrChanges: false,
|
||||
liveTickerStarts: false,
|
||||
liveTickerScoreChanges: false,
|
||||
liveTickerFinished: false,
|
||||
selectedTeamSlugs: [],
|
||||
selectedTeamSeason: null,
|
||||
notificationTime: '09:00'
|
||||
@@ -41,6 +45,10 @@ export function sanitizeNotificationSettings(input = {}) {
|
||||
birthdays: coerceBoolean(input.birthdays),
|
||||
newContactRequest: coerceBoolean(input.newContactRequest),
|
||||
newUserRegistration: coerceBoolean(input.newUserRegistration),
|
||||
ownTtrChanges: coerceBoolean(input.ownTtrChanges),
|
||||
liveTickerStarts: coerceBoolean(input.liveTickerStarts),
|
||||
liveTickerScoreChanges: coerceBoolean(input.liveTickerScoreChanges),
|
||||
liveTickerFinished: coerceBoolean(input.liveTickerFinished),
|
||||
selectedTeamSlugs: [...new Set(selectedTeamSlugs)],
|
||||
selectedTeamSeason,
|
||||
notificationTime
|
||||
|
||||
@@ -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()
|
||||
if (!normalizedToken) return user
|
||||
const normalizedInstallationId = String(installationId || '').trim().slice(0, 200) || null
|
||||
const now = new Date().toISOString()
|
||||
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({
|
||||
token: normalizedToken,
|
||||
platform: String(platform || 'android').slice(0, 30),
|
||||
appVersion: appVersion ? String(appVersion).slice(0, 80) : null,
|
||||
installationId: normalizedInstallationId,
|
||||
updatedAt: now,
|
||||
createdAt: tokens.find(entry => entry?.token === normalizedToken)?.createdAt || now
|
||||
})
|
||||
@@ -102,6 +107,11 @@ export function upsertPushToken(user, { token, platform = 'android', appVersion
|
||||
return user
|
||||
}
|
||||
|
||||
export async function androidPushTokenCountForUser(userId) {
|
||||
const user = (await readUsers()).find(entry => entry?.id === userId)
|
||||
return pushTokensForUser(user).length
|
||||
}
|
||||
|
||||
async function sendFcmMessage({ serviceAccount, accessToken, token, data = {} }) {
|
||||
const projectId = projectIdFromServiceAccount(serviceAccount)
|
||||
if (!projectId) throw new Error('FCM project_id fehlt.')
|
||||
@@ -150,6 +160,26 @@ function isVorstandUser(user) {
|
||||
return roles.includes('admin') || roles.includes('vorstand')
|
||||
}
|
||||
|
||||
function normalizePersonName(value) {
|
||||
return String(value || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.normalize('NFKD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/[’'`]/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
}
|
||||
|
||||
function userMatchesPlayer(user, playerName) {
|
||||
const player = normalizePersonName(playerName)
|
||||
if (!player) return false
|
||||
const candidates = [
|
||||
user?.name,
|
||||
`${user?.firstName || ''} ${user?.lastName || ''}`.trim()
|
||||
].map(normalizePersonName).filter(Boolean)
|
||||
return candidates.includes(player)
|
||||
}
|
||||
|
||||
export async function sendPushToUsers({ title, body, data = {}, predicate, bodyForUser, dataForUser, failureLabel = 'FCM-Push' }) {
|
||||
const serviceAccount = await readServiceAccount()
|
||||
if (serviceAccount == null) {
|
||||
@@ -275,3 +305,112 @@ export async function sendNewUserRegistrationPush(registration) {
|
||||
failureLabel: 'FCM Registrierungs-Push'
|
||||
})
|
||||
}
|
||||
|
||||
export async function sendOwnTtrChangePush(changes = []) {
|
||||
const byPlayer = new Map(
|
||||
changes
|
||||
.filter(change => change?.playerName && Number.isFinite(change?.previousTtr) && Number.isFinite(change?.currentTtr))
|
||||
.map(change => [normalizePersonName(change.playerName), change])
|
||||
)
|
||||
if (!byPlayer.size) return { sent: 0, failed: 0, removed: 0, recipients: 0, tokenCount: 0, skipped: false }
|
||||
|
||||
return sendPushToUsers({
|
||||
title: 'TTR-Wert geändert',
|
||||
data: { type: 'ttr_change' },
|
||||
predicate: (user, settings) => settings.ownTtrChanges && [...byPlayer.values()].some(change => userMatchesPlayer(user, change.playerName)),
|
||||
bodyForUser: (user) => {
|
||||
const change = [...byPlayer.values()].find(entry => userMatchesPlayer(user, entry.playerName))
|
||||
return `Dein TTR-Wert hat sich von ${change.previousTtr} auf ${change.currentTtr} geändert.`
|
||||
},
|
||||
dataForUser: (user) => {
|
||||
const change = [...byPlayer.values()].find(entry => userMatchesPlayer(user, entry.playerName))
|
||||
return {
|
||||
previousTtr: change.previousTtr,
|
||||
currentTtr: change.currentTtr,
|
||||
notificationId: notificationIdFor(`ttr:${change.playerName}:${change.previousTtr}:${change.currentTtr}`)
|
||||
}
|
||||
},
|
||||
failureLabel: 'FCM TTR-Änderungs-Push'
|
||||
})
|
||||
}
|
||||
|
||||
export async function sendQttrListUpdatedPush({ importedAt, rowCount }) {
|
||||
return sendPushToUsers({
|
||||
title: 'QTTR-Liste aktualisiert',
|
||||
body: `Die aktuellen QTTR-Werte wurden 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 sendLiveTickerStartedPush(match) {
|
||||
const homeTeam = String(match?.homeTeam || 'Heimmannschaft').trim()
|
||||
const awayTeam = String(match?.awayTeam || 'Gastmannschaft').trim()
|
||||
const meetingId = String(match?.meetingId || '').trim()
|
||||
return sendPushToUsers({
|
||||
title: 'Live-Spiel gestartet',
|
||||
body: `${homeTeam} – ${awayTeam}: Live-Ticker ist verfügbar.`,
|
||||
data: {
|
||||
type: 'live_ticker_started',
|
||||
meetingId,
|
||||
notificationId: notificationIdFor(`live-ticker-start:${meetingId}`)
|
||||
},
|
||||
predicate: (_user, settings) => settings.liveTickerStarts,
|
||||
failureLabel: 'FCM Live-Ticker-Start-Push'
|
||||
})
|
||||
}
|
||||
|
||||
export async function sendLiveTickerScoreChangedPush(match) {
|
||||
const homeTeam = String(match?.homeTeam || 'Heimmannschaft').trim()
|
||||
const awayTeam = String(match?.awayTeam || 'Gastmannschaft').trim()
|
||||
const score = String(match?.score || '–').trim()
|
||||
const meetingId = String(match?.meetingId || '').trim()
|
||||
return sendPushToUsers({
|
||||
title: 'Live-Ergebnis geändert',
|
||||
body: `${homeTeam} – ${awayTeam}: ${score}`,
|
||||
data: {
|
||||
type: 'live_ticker_score_changed',
|
||||
meetingId,
|
||||
score,
|
||||
notificationId: notificationIdFor(`live-ticker-score:${meetingId}:${score}`)
|
||||
},
|
||||
predicate: (_user, settings) => settings.liveTickerScoreChanges,
|
||||
failureLabel: 'FCM Live-Ticker-Ergebnis-Push'
|
||||
})
|
||||
}
|
||||
|
||||
export async function sendLiveTickerFinishedPush(match) {
|
||||
const homeTeam = String(match?.homeTeam || 'Heimmannschaft').trim()
|
||||
const awayTeam = String(match?.awayTeam || 'Gastmannschaft').trim()
|
||||
const score = String(match?.score || '–').trim()
|
||||
const meetingId = String(match?.meetingId || '').trim()
|
||||
return sendPushToUsers({
|
||||
title: 'Spiel beendet',
|
||||
body: `${homeTeam} – ${awayTeam}: ${score}`,
|
||||
data: {
|
||||
type: 'live_ticker_finished',
|
||||
meetingId,
|
||||
score,
|
||||
notificationId: notificationIdFor(`live-ticker-finished:${meetingId}`)
|
||||
},
|
||||
predicate: (_user, settings) => settings.liveTickerFinished,
|
||||
failureLabel: 'FCM Live-Ticker-Ende-Push'
|
||||
})
|
||||
}
|
||||
|
||||
export async function sendTestPushToUser(userId) {
|
||||
return sendPushToUsers({
|
||||
title: 'Harheimer TC: Push-Test',
|
||||
body: 'Diese Test-Benachrichtigung wurde erfolgreich vom CMS ausgelöst.',
|
||||
data: {
|
||||
type: 'test',
|
||||
notificationId: notificationIdFor(`test:${userId}:${Date.now()}`)
|
||||
},
|
||||
predicate: user => user?.id === userId,
|
||||
failureLabel: 'FCM Test-Push'
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { promises as fs } from 'fs'
|
||||
import { getServerDataPath } from './paths.js'
|
||||
import { sendOwnTtrChangePush, sendQttrListUpdatedPush } from './push-notifications.js'
|
||||
import { fetchClubRankings } from './mytischtennis-client.js'
|
||||
import { readMyTischtennisConnection, saveMyTischtennisConnection } from './mytischtennis-connection.js'
|
||||
|
||||
const QTTR_URL = 'https://www.mytischtennis.de/rankings/andro-rangliste?continent=all&country=Deutschland&all-players=on&as=DE.WE.R4.07&di=DE.WE.R4.07.04&area=DE.WE.R4.07.04.43&clubnr-search=Harheimer+TC&clubnr=43030&fednickname=HeTTV&gender=all¤t-ranking=no&ttr-range=100%3B3000&birth-range=1926%3B2021'
|
||||
const OUTPUT_FILE = getServerDataPath('qttr-values.json')
|
||||
@@ -59,11 +62,31 @@ function toNumberOrNull(value) {
|
||||
|
||||
function normalizeGender(value) {
|
||||
const normalized = String(value || '').trim().toLowerCase()
|
||||
if (normalized === 'm' || normalized === 'männlich') return 'männlich'
|
||||
if (normalized === 'w' || normalized === 'weiblich') return 'weiblich'
|
||||
if (['m', 'männlich', 'maennlich', 'male', 'man'].includes(normalized)) return 'männlich'
|
||||
if (['w', 'weiblich', 'female', 'woman', 'f'].includes(normalized)) return 'weiblich'
|
||||
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) {
|
||||
return [...String(html || '').matchAll(/<table\b[^>]*>[\s\S]*?<\/table>/gi)].map((match) => match[0])
|
||||
}
|
||||
@@ -160,6 +183,9 @@ function deriveQttrFields(headers, cells) {
|
||||
}
|
||||
|
||||
export async function importQttrValues(options = {}) {
|
||||
const connection = options.connection ?? await readMyTischtennisConnection()
|
||||
if (connection) return importAuthenticatedTtrValues(connection)
|
||||
|
||||
const url = options.url || QTTR_URL
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
@@ -184,7 +210,7 @@ export async function importQttrValues(options = {}) {
|
||||
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 = {
|
||||
format: 'harheimertc.qttr.v1',
|
||||
importedAt: new Date().toISOString(),
|
||||
@@ -207,4 +233,79 @@ export async function importQttrValues(options = {}) {
|
||||
rowCount: parsedRows.length,
|
||||
...payload
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function importAuthenticatedTtrValues(connection) {
|
||||
const previousPayload = await fs.readFile(OUTPUT_FILE, 'utf8')
|
||||
.then(JSON.parse)
|
||||
.catch(() => ({ rows: [] }))
|
||||
const [ttrEntries, qttrEntries] = await Promise.all([
|
||||
fetchClubRankings(connection, 'yes'),
|
||||
fetchClubRankings(connection, 'no')
|
||||
])
|
||||
const qttrByPlayer = new Map(qttrEntries.map(entry => [
|
||||
String(entry.personId || `${entry.firstname || ''}|${entry.lastname || ''}`).toLowerCase(), entry
|
||||
]))
|
||||
const rows = ttrEntries.map((entry, index) => {
|
||||
const key = String(entry.personId || `${entry.firstname || ''}|${entry.lastname || ''}`).toLowerCase()
|
||||
const qttrEntry = qttrByPlayer.get(key)
|
||||
return ({
|
||||
rank: toNumberOrNull(entry.rank ?? entry.ranking ?? index + 1),
|
||||
playerNumber: toNumberOrNull(entry.playernr ?? entry.player_number ?? entry.nuid),
|
||||
gender: normalizeGender(entry.gender ?? entry.sex ?? entry.genderCode ?? entry.gender_code),
|
||||
playerName: `${entry.firstname ?? entry.first_name ?? ''} ${entry.lastname ?? entry.last_name ?? ''}`.trim() || entry.name || null,
|
||||
clubName: entry.clubname ?? entry.club_name ?? 'Harheimer TC',
|
||||
currentQttr: toNumberOrNull(qttrEntry?.fedRank ?? qttrEntry?.qttr ?? entry.qttr),
|
||||
currentTtr: toNumberOrNull(entry.fedRank ?? entry.ttr ?? entry.current_ttr),
|
||||
previousQttr: null,
|
||||
valuesByHeader: entry,
|
||||
rawCells: []
|
||||
})
|
||||
}).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.')
|
||||
const previousQttrSignature = qttrListSignature(previousPayload.rows)
|
||||
const nextQttrSignature = qttrListSignature(rows)
|
||||
const importedAt = new Date().toISOString()
|
||||
const payload = {
|
||||
format: 'harheimertc.qttr.v2', importedAt,
|
||||
source: { type: 'mytischtennis-authenticated', association: connection.association, clubId: connection.clubId },
|
||||
title: 'Aktuelle myTischtennis-Rangliste', headerCount: 0, rowCount: rows.length, headers: [], rows
|
||||
}
|
||||
await fs.mkdir(getServerDataPath(), { recursive: true })
|
||||
await fs.writeFile(OUTPUT_FILE, `${JSON.stringify(payload, null, 2)}\n`, 'utf8')
|
||||
const previousTtrByPlayer = new Map((previousPayload.rows || []).map(row => [
|
||||
String(row?.playerName || '').trim().toLowerCase(), toNumberOrNull(row?.currentTtr)
|
||||
]))
|
||||
const changes = rows.flatMap(row => {
|
||||
const previousTtr = previousTtrByPlayer.get(String(row.playerName || '').trim().toLowerCase())
|
||||
return previousTtr != null && row.currentTtr != null && previousTtr !== row.currentTtr
|
||||
? [{ playerName: row.playerName, previousTtr, currentTtr: row.currentTtr }]
|
||||
: []
|
||||
})
|
||||
await sendOwnTtrChangePush(changes)
|
||||
if (previousQttrSignature && previousQttrSignature !== nextQttrSignature) {
|
||||
await sendQttrListUpdatedPush({
|
||||
importedAt,
|
||||
rowCount: rows.filter(row => row.currentQttr != null).length
|
||||
})
|
||||
}
|
||||
await saveMyTischtennisConnection({ ...connection, lastSuccessfulImportAt: importedAt, lastImportError: null })
|
||||
return { outputFile: OUTPUT_FILE, tableCount: 1, ...payload }
|
||||
}
|
||||
|
||||
export function qttrListSignature(rows) {
|
||||
if (!Array.isArray(rows) || rows.length === 0) return ''
|
||||
return rows
|
||||
// TTR-only players are refreshed daily. They must neither alter the
|
||||
// quarterly QTTR signature nor trigger a QTTR-list notification.
|
||||
.filter(row => row?.currentQttr != null)
|
||||
.map(row => [
|
||||
String(row?.playerNumber || row?.playerName || '').trim().toLocaleLowerCase('de-DE'),
|
||||
row?.currentQttr ?? ''
|
||||
].join('|'))
|
||||
.sort()
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@ const SPIELPLAN_HEADERS = [
|
||||
'Staffel',
|
||||
'Runde',
|
||||
'BegegnungNr',
|
||||
'MeetingId',
|
||||
'HasNuLivePushData',
|
||||
'HalleNr',
|
||||
'HalleName',
|
||||
'HalleStrasse',
|
||||
@@ -242,6 +244,8 @@ export function convertImportedSpielplanToJson(imported) {
|
||||
Staffel: match.leagueShortName || '',
|
||||
Runde: match.roundName || '',
|
||||
BegegnungNr: match.meetingNumber || match.meetingId || '',
|
||||
MeetingId: match.meetingId || '',
|
||||
HasNuLivePushData: match.hasNuLivePushData === true ? 'true' : 'false',
|
||||
HalleNr: match.hallNumber || '',
|
||||
HalleName: match.location?.label || '',
|
||||
HalleStrasse: match.location?.street || '',
|
||||
|
||||
@@ -111,6 +111,7 @@ function normalizeMatch(day, match) {
|
||||
formattedDay: match.formattedDay ?? null,
|
||||
formattedTime: match.formattedTime ?? null,
|
||||
state: match.state ?? null,
|
||||
hasNuLivePushData: match.has_nu_live_push_data === true,
|
||||
meetingId: match.meeting_id ?? null,
|
||||
meetingNumber: match.meeting_number ?? null,
|
||||
leagueId: match.league_id ?? null,
|
||||
|
||||
@@ -247,7 +247,7 @@ describe('Config & Profil Endpoints', () => {
|
||||
|
||||
it('speichert Android-Push-Token am Benutzer', async () => {
|
||||
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'] }]
|
||||
authUtils.verifyToken.mockReturnValue({ id: '1' })
|
||||
authUtils.getUserFromToken.mockResolvedValue(users[0])
|
||||
@@ -260,7 +260,7 @@ describe('Config & Profil Endpoints', () => {
|
||||
expect(authUtils.writeUsers).toHaveBeenCalledWith([
|
||||
expect.objectContaining({
|
||||
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' })]
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
23
tests/qttr-import.spec.ts
Normal file
23
tests/qttr-import.spec.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { qttrListSignature } from '../server/utils/qttr-import.js'
|
||||
|
||||
describe('QTTR import signature', () => {
|
||||
it('ignores TTR-only players so daily TTR changes cannot trigger a QTTR push', () => {
|
||||
const qttrRows = [
|
||||
{ playerNumber: 1, playerName: 'QTTR Spieler', currentQttr: 1500, currentTtr: 1512 }
|
||||
]
|
||||
const withChangedTtrOnlyPlayer = [
|
||||
...qttrRows,
|
||||
{ playerNumber: 2, playerName: 'Nur TTR', currentQttr: null, currentTtr: 1333 }
|
||||
]
|
||||
|
||||
expect(qttrListSignature(withChangedTtrOnlyPlayer)).toBe(qttrListSignature(qttrRows))
|
||||
})
|
||||
|
||||
it('changes when an actual QTTR value changes', () => {
|
||||
const previous = [{ playerNumber: 1, playerName: 'QTTR Spieler', currentQttr: 1500 }]
|
||||
const updated = [{ playerNumber: 1, playerName: 'QTTR Spieler', currentQttr: 1501 }]
|
||||
|
||||
expect(qttrListSignature(updated)).not.toBe(qttrListSignature(previous))
|
||||
})
|
||||
})
|
||||
@@ -166,6 +166,30 @@ describe('Spielplan, Mannschaften & öffentliche Endpoints', () => {
|
||||
expect(result).toContain('Max Muster')
|
||||
expect(result).toContain('2024')
|
||||
})
|
||||
|
||||
it('sortiert nach Jahr, Einzel/Doppel und Platzierung', async () => {
|
||||
vi.spyOn(fs, 'access').mockResolvedValue(undefined)
|
||||
vi.spyOn(fs, 'readFile').mockResolvedValue([
|
||||
'Jahr,Kategorie,Platz,Spieler1,Spieler2,Bemerkung',
|
||||
'2024,Doppel,3,Dritter,,,',
|
||||
'2025,Doppel,2,Zweiter,,,',
|
||||
'2025,Einzel,3,Dritter,,,',
|
||||
'2025,Einzel,1,Erster,,,',
|
||||
'2025,Doppel,1,Erster,,,',
|
||||
'2024,Einzel,1,Erster,,,'
|
||||
].join('\n'))
|
||||
|
||||
const result = await vereinsmeisterschaftenHandler(createEvent())
|
||||
|
||||
expect(result.split('\n').slice(1)).toEqual([
|
||||
'2025,Einzel,1,Erster,,,',
|
||||
'2025,Einzel,3,Dritter,,,',
|
||||
'2025,Doppel,1,Erster,,,',
|
||||
'2025,Doppel,2,Zweiter,,,',
|
||||
'2024,Einzel,1,Erster,,,',
|
||||
'2024,Doppel,3,Dritter,,,'
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET /api/birthdays', () => {
|
||||
|
||||
Reference in New Issue
Block a user