Compare commits
61 Commits
redesign
...
552068da89
| Author | SHA1 | Date | |
|---|---|---|---|
| 552068da89 | |||
|
|
c550fe8581 | ||
|
|
472bec4519 | ||
|
|
10a37180de | ||
|
|
4ecc2cc90c | ||
|
|
7a991a50c7 | ||
| 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)
|
- name: Semgrep (SAST)
|
||||||
run: semgrep --config p/default --error .
|
run: semgrep --config p/default --error .
|
||||||
|
|
||||||
- name: npm audit (high+)
|
- name: npm audit (critical)
|
||||||
run: npm audit --audit-level=high
|
run: npm audit --audit-level=critical
|
||||||
|
|
||||||
- name: OSV-Scanner (SCA)
|
- name: OSV-Scanner (SCA)
|
||||||
run: |
|
run: |
|
||||||
|
cd "$GITHUB_WORKSPACE"
|
||||||
curl -L -o osv-scanner https://github.com/google/osv-scanner/releases/latest/download/osv-scanner_linux_amd64
|
curl -L -o osv-scanner https://github.com/google/osv-scanner/releases/latest/download/osv-scanner_linux_amd64
|
||||||
chmod +x osv-scanner
|
chmod +x osv-scanner
|
||||||
./osv-scanner --version
|
./osv-scanner --version
|
||||||
test -f ./package-lock.json
|
test -f "$GITHUB_WORKSPACE/package-lock.json"
|
||||||
./osv-scanner scan -L ./package-lock.json --config ./.osv-scanner.toml
|
./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:
|
deploy-production:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -94,6 +94,8 @@ dist
|
|||||||
/android-app/**/build/
|
/android-app/**/build/
|
||||||
/android-app/local.properties
|
/android-app/local.properties
|
||||||
/android-app/gradle-local.properties
|
/android-app/gradle-local.properties
|
||||||
|
# Android Play Store / release artifacts are generated locally or in CI.
|
||||||
|
/android-app/**/*.aab
|
||||||
# JVM Heap-Dumps sind lokale Diagnoseartefakte und dürfen nie ins Repository.
|
# JVM Heap-Dumps sind lokale Diagnoseartefakte und dürfen nie ins Repository.
|
||||||
*.hprof
|
*.hprof
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,2 @@
|
|||||||
[[IgnoredVulns]]
|
# Retained because the CI scanner is invoked with --config. Findings are
|
||||||
id = "GHSA-v3m3-f69x-jf25"
|
# evaluated transparently by scripts/check-osv-severity.mjs.
|
||||||
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."
|
|
||||||
|
|||||||
32
PLAYSTORE_REVIEW.md
Normal file
32
PLAYSTORE_REVIEW.md
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# Google Play: Zugang für die App-Prüfung
|
||||||
|
|
||||||
|
In der Play Console unter **Richtlinien und Programme → App-Inhalte → App-Zugriff / Anmeldedaten** angeben, dass einige Funktionen eine Anmeldung erfordern. Eine neue Anleitung mit Titel `Harheimer TC review account` anlegen.
|
||||||
|
|
||||||
|
## Konto auf dem Backend der eingereichten App einrichten
|
||||||
|
|
||||||
|
Nach Deployment dieser Änderungen im Projektverzeichnis des entsprechenden Servers ausführen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node scripts/setup-playstore-review.js
|
||||||
|
```
|
||||||
|
|
||||||
|
Das Skript liest die dortige `.env` einschließlich `ENCRYPTION_KEY`, sichert die bisherige Benutzerdatei und erstellt ein aktives, ausgeblendetes Mitgliedskonto. Standardadresse: `playstore-review@harheimertc.invalid`. Dies ist bewusst kein echtes Postfach. Das zufällig erzeugte Passwort wird einmal ausgegeben und muss sicher abgelegt werden. Bei erneutem Aufruf bleibt es unverändert. Optional können `PLAYSTORE_REVIEW_EMAIL` und `PLAYSTORE_REVIEW_PASSWORD` gesetzt werden; ein explizites Passwort aktualisiert das bestehende Review-Konto. Kein reguläres Konto wird umgewandelt.
|
||||||
|
|
||||||
|
In der Play Console die ausgegebene E-Mail-Adresse und das Passwort eintragen. Zusätzliche Anweisungen auf Englisch:
|
||||||
|
|
||||||
|
> Open the app and go to Login. Sign in with the email address and password provided above. This account is already activated and provides access to the member features. No registration, email verification, one-time code or approval is required. Please stay signed in while testing forms. Outgoing emails triggered by this account are suppressed by the server. When a form asks for an email address, use the review account email provided above, including when testing while signed out.
|
||||||
|
|
||||||
|
Die Zugangsdaten dauerhaft gültig halten. Das Konto ist ein Mitglied, kein CMS-Administrator.
|
||||||
|
|
||||||
|
## Versandsperre
|
||||||
|
|
||||||
|
Alle SMTP-Versandstellen verwenden dieselbe serverseitige Sperre:
|
||||||
|
|
||||||
|
- Für authentifizierte Konten mit `accountType: playstore_review` oder der konfigurierten `PLAYSTORE_REVIEW_EMAIL` wird kein SMTP-Versand ausgeführt, auch bei abweichender Formularadresse.
|
||||||
|
- Mails an die Review-Adresse sowie Kontaktmails mit dieser Antwortadresse werden auch ohne Anmeldung unterdrückt. Registrierung und Mitgliedsanträge prüfen zusätzlich die Formularadresse. Gemischte Empfängerlisten mit einem Review-Konto werden vollständig unterdrückt.
|
||||||
|
- `MAIL_DELIVERY_DISABLED=true` unterdrückt sämtliche SMTP-Mails einer Installation. Die PM2-Testkonfiguration setzt dies standardmäßig; Produktion bleibt ohne diese Einstellung aktiv. Nach Konfigurationsänderungen den Backend-Prozess mit aktualisierter Umgebung neu starten.
|
||||||
|
- `DEBUG=true` allein verhindert keinen Mailversand.
|
||||||
|
|
||||||
|
Die Versandsperre ist keine isolierte Demo-Datenbank: Formulare können weiterhin Daten speichern und Push-Benachrichtigungen auslösen. Ohne Anmeldung und mit einer fremden Formularadresse lässt sich eine Anfrage keinem Review-Konto zuordnen. Für vollständig mailfreie Tests daher eine separate Testinstanz mit `MAIL_DELIVERY_DISABLED=true` verwenden und sicherstellen, dass der eingereichte App-Build dieses Backend nutzt.
|
||||||
|
|
||||||
|
Google-Anforderungen: https://support.google.com/googleplay/android-developer/answer/15748846
|
||||||
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 dagger.hilt.android.AndroidEntryPoint
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.compose.ui.platform.LocalContext
|
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
|
@AndroidEntryPoint
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
|
@Inject
|
||||||
|
lateinit var authRepository: AuthRepository
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var pushTokenRepository: PushTokenRepository
|
||||||
|
|
||||||
private val notificationRoute = mutableStateOf<String?>(null)
|
private val notificationRoute = mutableStateOf<String?>(null)
|
||||||
|
|
||||||
private val notificationPermissionLauncher = registerForActivityResult(
|
private val notificationPermissionLauncher = registerForActivityResult(
|
||||||
@@ -50,6 +61,23 @@ class MainActivity : ComponentActivity() {
|
|||||||
notificationRoute.value = extractNotificationRoute(intent)
|
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? =
|
private fun extractNotificationRoute(intent: Intent?): String? =
|
||||||
intent?.getStringExtra(EXTRA_NOTIFICATION_ROUTE)?.takeIf { it.isNotBlank() }
|
intent?.getStringExtra(EXTRA_NOTIFICATION_ROUTE)?.takeIf { it.isNotBlank() }
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,22 @@ data class SpielplanResponse(
|
|||||||
val season: String? = null,
|
val season: String? = null,
|
||||||
val seasons: List<SeasonDto> = emptyList(),
|
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 SeasonDto(val slug: String = "", val label: String = "")
|
||||||
data class MannschaftenSeasonsResponse(
|
data class MannschaftenSeasonsResponse(
|
||||||
val success: Boolean = false,
|
val success: Boolean = false,
|
||||||
@@ -269,6 +285,10 @@ data class NotificationSettingsDto(
|
|||||||
val birthdays: Boolean = false,
|
val birthdays: Boolean = false,
|
||||||
val newContactRequest: Boolean = false,
|
val newContactRequest: Boolean = false,
|
||||||
val newUserRegistration: 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 selectedTeamSlugs: List<String> = emptyList(),
|
||||||
val selectedTeamSeason: String? = null,
|
val selectedTeamSeason: String? = null,
|
||||||
val notificationTime: String = "09:00",
|
val notificationTime: String = "09:00",
|
||||||
@@ -282,6 +302,7 @@ data class PushTokenRequest(
|
|||||||
val token: String,
|
val token: String,
|
||||||
val platform: String = "android",
|
val platform: String = "android",
|
||||||
val appVersion: String? = null,
|
val appVersion: String? = null,
|
||||||
|
val installationId: String? = null,
|
||||||
)
|
)
|
||||||
data class BirthdayDto(
|
data class BirthdayDto(
|
||||||
val name: String = "",
|
val name: String = "",
|
||||||
@@ -302,6 +323,7 @@ data class QttrRowDto(
|
|||||||
val playerName: String = "",
|
val playerName: String = "",
|
||||||
val clubName: String = "",
|
val clubName: String = "",
|
||||||
val currentQttr: Int? = null,
|
val currentQttr: Int? = null,
|
||||||
|
val currentTtr: Int? = null,
|
||||||
val previousQttr: Int? = null,
|
val previousQttr: Int? = null,
|
||||||
val birthdate: String? = null,
|
val birthdate: String? = null,
|
||||||
)
|
)
|
||||||
@@ -723,6 +745,9 @@ interface ApiService {
|
|||||||
@GET("/api/mitgliederbereich/qttr")
|
@GET("/api/mitgliederbereich/qttr")
|
||||||
suspend fun qttrValues(): Response<QttrValuesResponse>
|
suspend fun qttrValues(): Response<QttrValuesResponse>
|
||||||
|
|
||||||
|
@GET("/api/mitgliederbereich/live-ticker")
|
||||||
|
suspend fun liveTicker(): Response<LiveTickerResponse>
|
||||||
|
|
||||||
@GET("/api/members")
|
@GET("/api/members")
|
||||||
suspend fun members(): Response<MembersResponse>
|
suspend fun members(): Response<MembersResponse>
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ class HarheimerMessagingService : FirebaseMessagingService() {
|
|||||||
override fun onNewToken(token: String) {
|
override fun onNewToken(token: String) {
|
||||||
super.onNewToken(token)
|
super.onNewToken(token)
|
||||||
serviceScope.launch {
|
serviceScope.launch {
|
||||||
pushTokenRepository.registerToken(token)
|
pushTokenRepository.registerCurrentDevice()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -81,6 +81,8 @@ object HarheimerNotifications {
|
|||||||
"news", "news_expiring" -> Destinations.MemberNews.route
|
"news", "news_expiring" -> Destinations.MemberNews.route
|
||||||
"event", "events_today", "events_tomorrow" -> Destinations.Termine.route
|
"event", "events_today", "events_tomorrow" -> Destinations.Termine.route
|
||||||
"team_matches" -> Destinations.Spielplan.route
|
"team_matches" -> Destinations.Spielplan.route
|
||||||
|
"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
|
"birthdays" -> Destinations.MemberArea.route
|
||||||
"contact_request" -> Destinations.CmsContactRequests.route
|
"contact_request" -> Destinations.CmsContactRequests.route
|
||||||
"user_registration" -> Destinations.CmsBenutzer.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 birthdays: Boolean = false,
|
||||||
val newContactRequest: Boolean = false,
|
val newContactRequest: Boolean = false,
|
||||||
val newUserRegistration: 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 selectedTeamSlugs: Set<String> = emptySet(),
|
||||||
val selectedTeamSeason: String? = null,
|
val selectedTeamSeason: String? = null,
|
||||||
val notificationTime: String = DEFAULT_NOTIFICATION_TIME,
|
val notificationTime: String = DEFAULT_NOTIFICATION_TIME,
|
||||||
@@ -43,6 +47,10 @@ class NotificationPreferencesRepository @Inject constructor(
|
|||||||
birthdays = preferences.getBoolean(KEY_BIRTHDAYS, false),
|
birthdays = preferences.getBoolean(KEY_BIRTHDAYS, false),
|
||||||
newContactRequest = preferences.getBoolean(KEY_NEW_CONTACT_REQUEST, false),
|
newContactRequest = preferences.getBoolean(KEY_NEW_CONTACT_REQUEST, false),
|
||||||
newUserRegistration = preferences.getBoolean(KEY_NEW_USER_REGISTRATION, 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(),
|
selectedTeamSlugs = preferences.getStringSet(KEY_SELECTED_TEAM_SLUGS, emptySet()).orEmpty(),
|
||||||
selectedTeamSeason = preferences.getString(KEY_SELECTED_TEAM_SEASON, null)?.takeIf { it.isNotBlank() },
|
selectedTeamSeason = preferences.getString(KEY_SELECTED_TEAM_SEASON, null)?.takeIf { it.isNotBlank() },
|
||||||
notificationTime = preferences.getString(KEY_NOTIFICATION_TIME, DEFAULT_NOTIFICATION_TIME) ?: DEFAULT_NOTIFICATION_TIME,
|
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_BIRTHDAYS, settings.birthdays)
|
||||||
.putBoolean(KEY_NEW_CONTACT_REQUEST, settings.newContactRequest)
|
.putBoolean(KEY_NEW_CONTACT_REQUEST, settings.newContactRequest)
|
||||||
.putBoolean(KEY_NEW_USER_REGISTRATION, settings.newUserRegistration)
|
.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)
|
.putStringSet(KEY_SELECTED_TEAM_SLUGS, settings.selectedTeamSlugs)
|
||||||
.putString(KEY_SELECTED_TEAM_SEASON, settings.selectedTeamSeason)
|
.putString(KEY_SELECTED_TEAM_SEASON, settings.selectedTeamSeason)
|
||||||
.putString(KEY_NOTIFICATION_TIME, settings.notificationTime)
|
.putString(KEY_NOTIFICATION_TIME, settings.notificationTime)
|
||||||
@@ -76,13 +88,12 @@ class NotificationPreferencesRepository @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun saveRemote(settings: NotificationPreferences): Result<NotificationPreferences> {
|
suspend fun saveRemote(settings: NotificationPreferences): Result<NotificationPreferences> {
|
||||||
saveLocal(settings)
|
|
||||||
return runCatching {
|
return runCatching {
|
||||||
retryOnNetworkFailure {
|
retryOnNetworkFailure {
|
||||||
val response = api.updateNotificationSettings(settings.toDto())
|
val response = api.updateNotificationSettings(settings.toDto())
|
||||||
if (!response.isSuccessful) error("Benachrichtigungseinstellungen konnten nicht gespeichert werden.")
|
if (!response.isSuccessful) error("Benachrichtigungseinstellungen konnten nicht gespeichert werden.")
|
||||||
val saved = response.body()?.settings?.toPreferences() ?: error("Leere Antwort")
|
val saved = response.body()?.settings?.toPreferences() ?: error("Leere Antwort")
|
||||||
saveLocal(saved)
|
requireLiveNotificationSettingsSaved(settings, saved)
|
||||||
saved
|
saved
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -98,12 +109,25 @@ class NotificationPreferencesRepository @Inject constructor(
|
|||||||
const val KEY_BIRTHDAYS = "birthdays"
|
const val KEY_BIRTHDAYS = "birthdays"
|
||||||
const val KEY_NEW_CONTACT_REQUEST = "new_contact_request"
|
const val KEY_NEW_CONTACT_REQUEST = "new_contact_request"
|
||||||
const val KEY_NEW_USER_REGISTRATION = "new_user_registration"
|
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_SLUGS = "selected_team_slugs"
|
||||||
const val KEY_SELECTED_TEAM_SEASON = "selected_team_season"
|
const val KEY_SELECTED_TEAM_SEASON = "selected_team_season"
|
||||||
const val KEY_NOTIFICATION_TIME = "notification_time"
|
const val KEY_NOTIFICATION_TIME = "notification_time"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal fun requireLiveNotificationSettingsSaved(requested: NotificationPreferences, saved: NotificationPreferences) {
|
||||||
|
if (requested.liveTickerStarts != saved.liveTickerStarts ||
|
||||||
|
requested.liveTickerScoreChanges != saved.liveTickerScoreChanges ||
|
||||||
|
requested.liveTickerFinished != saved.liveTickerFinished
|
||||||
|
) {
|
||||||
|
error("Der Server hat die Live-Benachrichtigungen nicht übernommen. Bitte das Backend aktualisieren und erneut versuchen.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun NotificationSettingsDto.toPreferences(): NotificationPreferences = NotificationPreferences(
|
private fun NotificationSettingsDto.toPreferences(): NotificationPreferences = NotificationPreferences(
|
||||||
newNews = newNews,
|
newNews = newNews,
|
||||||
newEvents = newEvents,
|
newEvents = newEvents,
|
||||||
@@ -114,6 +138,10 @@ private fun NotificationSettingsDto.toPreferences(): NotificationPreferences = N
|
|||||||
birthdays = birthdays,
|
birthdays = birthdays,
|
||||||
newContactRequest = newContactRequest,
|
newContactRequest = newContactRequest,
|
||||||
newUserRegistration = newUserRegistration,
|
newUserRegistration = newUserRegistration,
|
||||||
|
ownTtrChanges = ownTtrChanges,
|
||||||
|
liveTickerStarts = liveTickerStarts,
|
||||||
|
liveTickerScoreChanges = liveTickerScoreChanges,
|
||||||
|
liveTickerFinished = liveTickerFinished,
|
||||||
selectedTeamSlugs = selectedTeamSlugs.toSet(),
|
selectedTeamSlugs = selectedTeamSlugs.toSet(),
|
||||||
selectedTeamSeason = selectedTeamSeason,
|
selectedTeamSeason = selectedTeamSeason,
|
||||||
notificationTime = notificationTime,
|
notificationTime = notificationTime,
|
||||||
@@ -129,6 +157,10 @@ private fun NotificationPreferences.toDto(): NotificationSettingsDto = Notificat
|
|||||||
birthdays = birthdays,
|
birthdays = birthdays,
|
||||||
newContactRequest = newContactRequest,
|
newContactRequest = newContactRequest,
|
||||||
newUserRegistration = newUserRegistration,
|
newUserRegistration = newUserRegistration,
|
||||||
|
ownTtrChanges = ownTtrChanges,
|
||||||
|
liveTickerStarts = liveTickerStarts,
|
||||||
|
liveTickerScoreChanges = liveTickerScoreChanges,
|
||||||
|
liveTickerFinished = liveTickerFinished,
|
||||||
selectedTeamSlugs = selectedTeamSlugs.toList(),
|
selectedTeamSlugs = selectedTeamSlugs.toList(),
|
||||||
selectedTeamSeason = selectedTeamSeason,
|
selectedTeamSeason = selectedTeamSeason,
|
||||||
notificationTime = notificationTime,
|
notificationTime = notificationTime,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package de.harheimertc.repositories
|
package de.harheimertc.repositories
|
||||||
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import com.google.firebase.installations.FirebaseInstallations
|
||||||
import com.google.firebase.messaging.FirebaseMessaging
|
import com.google.firebase.messaging.FirebaseMessaging
|
||||||
import de.harheimertc.BuildConfig
|
import de.harheimertc.BuildConfig
|
||||||
import de.harheimertc.data.ApiService
|
import de.harheimertc.data.ApiService
|
||||||
@@ -15,19 +16,24 @@ class PushTokenRepository @Inject constructor(
|
|||||||
) {
|
) {
|
||||||
suspend fun registerCurrentDevice(): Result<Unit> = runCatching {
|
suspend fun registerCurrentDevice(): Result<Unit> = runCatching {
|
||||||
val token = FirebaseMessaging.getInstance().token.await()
|
val token = FirebaseMessaging.getInstance().token.await()
|
||||||
registerToken(token).getOrThrow()
|
val installationId = FirebaseInstallations.getInstance().id.await()
|
||||||
|
registerToken(token, installationId).getOrThrow()
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun registerToken(token: String): Result<Unit> = runCatching {
|
suspend fun registerToken(token: String, installationId: String? = null): Result<Unit> = runCatching {
|
||||||
if (token.isBlank()) return@runCatching
|
if (token.isBlank()) return@runCatching
|
||||||
retryOnNetworkFailure {
|
retryOnNetworkFailure {
|
||||||
val response = api.registerPushToken(
|
val response = api.registerPushToken(
|
||||||
PushTokenRequest(
|
PushTokenRequest(
|
||||||
token = token,
|
token = token,
|
||||||
appVersion = "${BuildConfig.VERSION_NAME}+${BuildConfig.VERSION_CODE}",
|
appVersion = "${BuildConfig.VERSION_NAME}+${BuildConfig.VERSION_CODE}",
|
||||||
|
installationId = installationId,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
if (!response.isSuccessful) 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 ->
|
}.onFailure { error ->
|
||||||
Log.w("PushTokenRepository", "Push-Token Registrierung fehlgeschlagen", error)
|
Log.w("PushTokenRepository", "Push-Token Registrierung fehlgeschlagen", error)
|
||||||
|
|||||||
@@ -80,8 +80,8 @@ private fun CompactNavigation(
|
|||||||
navigationState: NavigationUiState = NavigationUiState(),
|
navigationState: NavigationUiState = NavigationUiState(),
|
||||||
) {
|
) {
|
||||||
val routeSection = menuSection(selectedRoute)
|
val routeSection = menuSection(selectedRoute)
|
||||||
val sectionOverride = androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf<MenuSection?>(null) }
|
val sectionOverride = androidx.compose.runtime.remember(selectedRoute) { androidx.compose.runtime.mutableStateOf<MenuSection?>(null) }
|
||||||
val section = routeSection ?: sectionOverride.value
|
val section = sectionOverride.value ?: routeSection
|
||||||
val subItems = submenu(section, navigationState)
|
val subItems = submenu(section, navigationState)
|
||||||
|
|
||||||
val mainScroll = rememberScrollState()
|
val mainScroll = rememberScrollState()
|
||||||
@@ -472,7 +472,7 @@ private fun submenu(section: MenuSection?, state: NavigationUiState): List<MenuT
|
|||||||
MenuSection.INTERN -> buildList {
|
MenuSection.INTERN -> buildList {
|
||||||
add(MenuTarget("Übersicht", Destinations.MemberArea.route))
|
add(MenuTarget("Übersicht", Destinations.MemberArea.route))
|
||||||
add(MenuTarget("Mitgliederliste", Destinations.Members.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("News", Destinations.MemberNews.route))
|
||||||
add(MenuTarget("Mein Profil", Destinations.Profile.route))
|
add(MenuTarget("Mein Profil", Destinations.Profile.route))
|
||||||
add(MenuTarget("Benachrichtigungen", Destinations.NotificationSettings.route))
|
add(MenuTarget("Benachrichtigungen", Destinations.NotificationSettings.route))
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
package de.harheimertc.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.Animatable
|
||||||
|
import androidx.compose.animation.core.LinearEasing
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
|
import androidx.compose.foundation.layout.wrapContentWidth
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
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.runtime.withFrameNanos
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.draw.clipToBounds
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.layout.onSizeChanged
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||||
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
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
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
|
||||||
|
@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
|
||||||
|
|
||||||
|
var paused by remember { mutableStateOf(false) }
|
||||||
|
Surface(color = Color(0xFFEAF8F8), modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
TextButton(
|
||||||
|
onClick = { paused = !paused },
|
||||||
|
modifier = Modifier.semantics {
|
||||||
|
contentDescription = if (paused) "Laufschrift fortsetzen" else "Laufschrift anhalten"
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text(if (paused) "▶" else "Ⅱ", color = Color(0xFF334155))
|
||||||
|
}
|
||||||
|
BoxWithConstraints(modifier = Modifier.weight(1f).clipToBounds()) {
|
||||||
|
val viewportWidth = maxWidth
|
||||||
|
var groupWidth by remember { mutableStateOf(0) }
|
||||||
|
val offset = remember { Animatable(0f) }
|
||||||
|
val pixelsPerSecond = with(LocalDensity.current) { 36.dp.toPx() }
|
||||||
|
LaunchedEffect(groupWidth, pixelsPerSecond, paused) {
|
||||||
|
if (groupWidth <= 0 || paused) return@LaunchedEffect
|
||||||
|
offset.snapTo(offset.value % groupWidth)
|
||||||
|
while (isActive) {
|
||||||
|
val duration = ((groupWidth - offset.value) / pixelsPerSecond * 1000).toInt().coerceAtLeast(1)
|
||||||
|
offset.animateTo(groupWidth.toFloat(), tween(duration, easing = LinearEasing))
|
||||||
|
offset.snapTo(0f)
|
||||||
|
// Also yield when system animations are disabled.
|
||||||
|
withFrameNanos { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.wrapContentWidth(Alignment.Start, unbounded = true)
|
||||||
|
.graphicsLayer { translationX = -offset.value },
|
||||||
|
) {
|
||||||
|
repeat(2) { copy ->
|
||||||
|
LiveTickerGroup(
|
||||||
|
matches = matches,
|
||||||
|
modifier = Modifier
|
||||||
|
.widthIn(min = viewportWidth)
|
||||||
|
.then(if (copy == 0) Modifier.onSizeChanged { groupWidth = it.width } else Modifier.clearAndSetSemantics {}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LiveTickerGroup(matches: List<LiveTickerMatchDto>, modifier: Modifier = Modifier) {
|
||||||
|
Row(
|
||||||
|
modifier = modifier.padding(start = 16.dp, end = 32.dp, top = 9.dp, bottom = 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.Column
|
|||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.systemBarsPadding
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
@@ -25,6 +26,7 @@ import androidx.hilt.navigation.compose.hiltViewModel
|
|||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import de.harheimertc.ui.components.AppNavigationHeader
|
import de.harheimertc.ui.components.AppNavigationHeader
|
||||||
|
import de.harheimertc.ui.components.LiveTickerBanner
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun NavGraph(
|
fun NavGraph(
|
||||||
@@ -44,8 +46,13 @@ fun NavGraph(
|
|||||||
LaunchedEffect(currentRoute) {
|
LaunchedEffect(currentRoute) {
|
||||||
navigationViewModel.refreshSession()
|
navigationViewModel.refreshSession()
|
||||||
}
|
}
|
||||||
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
|
LaunchedEffect(navigationState.loggedIn) {
|
||||||
val persistentNavigation = maxWidth >= 600.dp
|
navigationViewModel.setLiveTickerActive(navigationState.loggedIn)
|
||||||
|
}
|
||||||
|
BoxWithConstraints(modifier = Modifier.fillMaxSize().systemBarsPadding()) {
|
||||||
|
val tabletNavigation = maxWidth >= 600.dp
|
||||||
|
// Signed-in phones share the same menu and ticker above every screen.
|
||||||
|
val persistentNavigation = tabletNavigation || navigationState.loggedIn
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
navigationState.connectionNote?.let { message ->
|
navigationState.connectionNote?.let { message ->
|
||||||
Surface(color = Color(0xFFFFF4E5), modifier = Modifier.fillMaxWidth()) {
|
Surface(color = Color(0xFFFFF4E5), modifier = Modifier.fillMaxWidth()) {
|
||||||
@@ -68,10 +75,16 @@ fun NavGraph(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
webTabletNavigation = true,
|
webTabletNavigation = tabletNavigation,
|
||||||
navigationState = navigationState,
|
navigationState = navigationState,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (navigationState.loggedIn) {
|
||||||
|
LiveTickerBanner(
|
||||||
|
matches = navigationState.liveTickerMatches,
|
||||||
|
loaded = navigationState.liveTickerLoaded,
|
||||||
|
)
|
||||||
|
}
|
||||||
NavHost(
|
NavHost(
|
||||||
navController = navController,
|
navController = navController,
|
||||||
startDestination = startDestination,
|
startDestination = startDestination,
|
||||||
|
|||||||
@@ -4,15 +4,20 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import de.harheimertc.data.ConnectivityMonitor
|
import de.harheimertc.data.ConnectivityMonitor
|
||||||
|
import de.harheimertc.data.LiveTickerMatchDto
|
||||||
import de.harheimertc.repositories.AuthRepository
|
import de.harheimertc.repositories.AuthRepository
|
||||||
import de.harheimertc.repositories.GalleryRepository
|
import de.harheimertc.repositories.GalleryRepository
|
||||||
import de.harheimertc.repositories.LoginRepository
|
import de.harheimertc.repositories.LoginRepository
|
||||||
|
import de.harheimertc.repositories.LiveTickerRepository
|
||||||
import de.harheimertc.repositories.Mannschaft
|
import de.harheimertc.repositories.Mannschaft
|
||||||
import de.harheimertc.repositories.MannschaftenRepository
|
import de.harheimertc.repositories.MannschaftenRepository
|
||||||
import de.harheimertc.repositories.PushTokenRepository
|
import de.harheimertc.repositories.PushTokenRepository
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@@ -22,6 +27,8 @@ data class NavigationUiState(
|
|||||||
val loggedIn: Boolean = false,
|
val loggedIn: Boolean = false,
|
||||||
val roles: Set<String> = emptySet(),
|
val roles: Set<String> = emptySet(),
|
||||||
val connectionNote: String? = null,
|
val connectionNote: String? = null,
|
||||||
|
val liveTickerMatches: List<LiveTickerMatchDto> = emptyList(),
|
||||||
|
val liveTickerLoaded: Boolean = false,
|
||||||
) {
|
) {
|
||||||
val isAdmin: Boolean get() = "admin" in roles
|
val isAdmin: Boolean get() = "admin" in roles
|
||||||
val canAccessFullCms: Boolean get() = roles.any { it in setOf("admin", "vorstand") }
|
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 authRepository: AuthRepository,
|
||||||
private val connectivityMonitor: ConnectivityMonitor,
|
private val connectivityMonitor: ConnectivityMonitor,
|
||||||
private val pushTokenRepository: PushTokenRepository,
|
private val pushTokenRepository: PushTokenRepository,
|
||||||
|
private val liveTickerRepository: LiveTickerRepository,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
private val _state = MutableStateFlow(NavigationUiState())
|
private val _state = MutableStateFlow(NavigationUiState())
|
||||||
val state: StateFlow<NavigationUiState> = _state
|
val state: StateFlow<NavigationUiState> = _state
|
||||||
|
private var tickerJob: Job? = null
|
||||||
|
private var tickerActive = false
|
||||||
|
|
||||||
init {
|
init {
|
||||||
loadNavigationData()
|
loadNavigationData()
|
||||||
@@ -70,6 +80,8 @@ class NavigationViewModel @Inject constructor(
|
|||||||
loggedIn = loggedIn,
|
loggedIn = loggedIn,
|
||||||
roles = status.navigationRoles(),
|
roles = status.navigationRoles(),
|
||||||
connectionNote = null,
|
connectionNote = null,
|
||||||
|
liveTickerMatches = _state.value.liveTickerMatches,
|
||||||
|
liveTickerLoaded = _state.value.liveTickerLoaded,
|
||||||
)
|
)
|
||||||
if (loggedIn) registerPushToken()
|
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() {
|
private fun registerPushToken() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
pushTokenRepository.registerCurrentDevice()
|
pushTokenRepository.registerCurrentDevice()
|
||||||
@@ -96,6 +126,7 @@ class NavigationViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun logout(onComplete: () -> Unit = {}) {
|
fun logout(onComplete: () -> Unit = {}) {
|
||||||
|
setLiveTickerActive(false)
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
loginRepository.logout()
|
loginRepository.logout()
|
||||||
_state.value = _state.value.copy(
|
_state.value = _state.value.copy(
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import androidx.lifecycle.viewModelScope
|
|||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import de.harheimertc.repositories.LoginRepository
|
import de.harheimertc.repositories.LoginRepository
|
||||||
import de.harheimertc.repositories.PasskeyRepository
|
import de.harheimertc.repositories.PasskeyRepository
|
||||||
|
import de.harheimertc.repositories.PushTokenRepository
|
||||||
import de.harheimertc.ui.components.isValidEmail
|
import de.harheimertc.ui.components.isValidEmail
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -29,6 +30,7 @@ data class LoginUiState(
|
|||||||
class LoginViewModel @Inject constructor(
|
class LoginViewModel @Inject constructor(
|
||||||
private val repository: LoginRepository,
|
private val repository: LoginRepository,
|
||||||
private val passkeyRepository: PasskeyRepository,
|
private val passkeyRepository: PasskeyRepository,
|
||||||
|
private val pushTokenRepository: PushTokenRepository,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
private val _state = MutableStateFlow(LoginUiState())
|
private val _state = MutableStateFlow(LoginUiState())
|
||||||
val state: StateFlow<LoginUiState> = _state
|
val state: StateFlow<LoginUiState> = _state
|
||||||
@@ -70,6 +72,7 @@ class LoginViewModel @Inject constructor(
|
|||||||
_state.value = current.copy(loading = true, error = null, message = null)
|
_state.value = current.copy(loading = true, error = null, message = null)
|
||||||
repository.login(current.email, current.password)
|
repository.login(current.email, current.password)
|
||||||
.onSuccess { response ->
|
.onSuccess { response ->
|
||||||
|
viewModelScope.launch { pushTokenRepository.registerCurrentDevice() }
|
||||||
_state.value = current.copy(
|
_state.value = current.copy(
|
||||||
password = "",
|
password = "",
|
||||||
loading = false,
|
loading = false,
|
||||||
@@ -92,6 +95,7 @@ class LoginViewModel @Inject constructor(
|
|||||||
_state.value = current.copy(loading = true, error = null, message = null)
|
_state.value = current.copy(loading = true, error = null, message = null)
|
||||||
passkeyRepository.login(context, current.email)
|
passkeyRepository.login(context, current.email)
|
||||||
.onSuccess { response ->
|
.onSuccess { response ->
|
||||||
|
viewModelScope.launch { pushTokenRepository.registerCurrentDevice() }
|
||||||
_state.value = current.copy(
|
_state.value = current.copy(
|
||||||
password = "",
|
password = "",
|
||||||
loading = false,
|
loading = false,
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.LocalUriHandler
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
@@ -248,18 +247,7 @@ fun QttrScreen(
|
|||||||
viewModel: QttrViewModel = hiltViewModel(),
|
viewModel: QttrViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val state by viewModel.state.collectAsState()
|
val state by viewModel.state.collectAsState()
|
||||||
val uriHandler = LocalUriHandler.current
|
MemberAreaPage(navController, showBackNavigation, "TTR- und QTTR-Werte", "Aktuelle Werte der Vereinsmitglieder aus myTischtennis.") {
|
||||||
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") }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
item {
|
item {
|
||||||
Surface(color = Color.White, shape = RoundedCornerShape(14.dp), shadowElevation = 3.dp) {
|
Surface(color = Color.White, shape = RoundedCornerShape(14.dp), shadowElevation = 3.dp) {
|
||||||
Column(Modifier.fillMaxWidth().padding(18.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
Column(Modifier.fillMaxWidth().padding(18.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
@@ -269,9 +257,9 @@ fun QttrScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
when {
|
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.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)) }
|
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.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) },
|
onClick = { navController.navigate(Destinations.MemberNews.route) },
|
||||||
)
|
)
|
||||||
MemberAreaCard(
|
MemberAreaCard(
|
||||||
title = "QTTR",
|
title = "TTR / QTTR",
|
||||||
description = "Aktuelle QTTR-Werte der Vereinsmitglieder",
|
description = "Aktuelle TTR- und QTTR-Werte der Vereinsmitglieder",
|
||||||
marker = "Q",
|
marker = "T",
|
||||||
onClick = { navController.navigate(Destinations.Qttr.route) },
|
onClick = { navController.navigate(Destinations.Qttr.route) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.navigation.NavController
|
import androidx.navigation.NavController
|
||||||
import de.harheimertc.notifications.HarheimerNotifications
|
import de.harheimertc.notifications.HarheimerNotifications
|
||||||
|
import de.harheimertc.BuildConfig
|
||||||
import de.harheimertc.repositories.Mannschaft
|
import de.harheimertc.repositories.Mannschaft
|
||||||
import de.harheimertc.repositories.NotificationPreferences
|
import de.harheimertc.repositories.NotificationPreferences
|
||||||
import de.harheimertc.ui.components.LoadingState
|
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) {
|
if (state.loading) {
|
||||||
item { LoadingState("Benachrichtigungseinstellungen werden geladen...") }
|
item { LoadingState("Benachrichtigungseinstellungen werden geladen...") }
|
||||||
} else {
|
} else {
|
||||||
@@ -146,6 +164,15 @@ fun NotificationSettingsScreen(
|
|||||||
ToggleRow("Punktspiele aller Mannschaften", state.settings.allTeamMatches) {
|
ToggleRow("Punktspiele aller Mannschaften", state.settings.allTeamMatches) {
|
||||||
viewModel.update(state.settings.copy(allTeamMatches = it))
|
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(
|
TeamSelection(
|
||||||
teams = state.teams,
|
teams = state.teams,
|
||||||
seasons = state.seasons,
|
seasons = state.seasons,
|
||||||
@@ -162,6 +189,9 @@ fun NotificationSettingsScreen(
|
|||||||
ToggleRow("Geburtstage", state.settings.birthdays) {
|
ToggleRow("Geburtstage", state.settings.birthdays) {
|
||||||
viewModel.update(state.settings.copy(birthdays = it))
|
viewModel.update(state.settings.copy(birthdays = it))
|
||||||
}
|
}
|
||||||
|
ToggleRow("Änderung meines TTR-Werts", state.settings.ownTtrChanges) {
|
||||||
|
viewModel.update(state.settings.copy(ownTtrChanges = it))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,7 +210,10 @@ fun NotificationSettingsScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
state.saveError?.let { message ->
|
state.saveError?.let { message ->
|
||||||
item { Text(message, color = MaterialTheme.colorScheme.error) }
|
item {
|
||||||
|
Text(message, color = MaterialTheme.colorScheme.error)
|
||||||
|
TextButton(onClick = { viewModel.update(state.settings) }) { Text("Erneut speichern") }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
state.error?.let { message ->
|
state.error?.let { message ->
|
||||||
|
|||||||
@@ -8,9 +8,12 @@ import de.harheimertc.repositories.LoginRepository
|
|||||||
import de.harheimertc.repositories.MannschaftenRepository
|
import de.harheimertc.repositories.MannschaftenRepository
|
||||||
import de.harheimertc.repositories.NotificationPreferences
|
import de.harheimertc.repositories.NotificationPreferences
|
||||||
import de.harheimertc.repositories.NotificationPreferencesRepository
|
import de.harheimertc.repositories.NotificationPreferencesRepository
|
||||||
|
import de.harheimertc.repositories.PushTokenRepository
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
data class NotificationSettingsUiState(
|
data class NotificationSettingsUiState(
|
||||||
@@ -22,6 +25,9 @@ data class NotificationSettingsUiState(
|
|||||||
val seasons: List<String> = emptyList(),
|
val seasons: List<String> = emptyList(),
|
||||||
val error: String? = null,
|
val error: String? = null,
|
||||||
val saveError: String? = null,
|
val saveError: String? = null,
|
||||||
|
val deviceRegistrationMessage: String? = null,
|
||||||
|
val deviceRegistrationError: Boolean = false,
|
||||||
|
val deviceRegistrationInProgress: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
@@ -29,9 +35,12 @@ class NotificationSettingsViewModel @Inject constructor(
|
|||||||
private val preferencesRepository: NotificationPreferencesRepository,
|
private val preferencesRepository: NotificationPreferencesRepository,
|
||||||
private val mannschaftenRepository: MannschaftenRepository,
|
private val mannschaftenRepository: MannschaftenRepository,
|
||||||
private val loginRepository: LoginRepository,
|
private val loginRepository: LoginRepository,
|
||||||
|
private val pushTokenRepository: PushTokenRepository,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
private val _state = MutableStateFlow(NotificationSettingsUiState())
|
private val _state = MutableStateFlow(NotificationSettingsUiState())
|
||||||
val state: StateFlow<NotificationSettingsUiState> = _state
|
val state: StateFlow<NotificationSettingsUiState> = _state
|
||||||
|
private val saveMutex = Mutex()
|
||||||
|
private var settingsRevision = 0L
|
||||||
|
|
||||||
init {
|
init {
|
||||||
load()
|
load()
|
||||||
@@ -61,16 +70,23 @@ class NotificationSettingsViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun update(settings: NotificationPreferences) {
|
fun update(settings: NotificationPreferences) {
|
||||||
|
val revision = ++settingsRevision
|
||||||
preferencesRepository.saveLocal(settings)
|
preferencesRepository.saveLocal(settings)
|
||||||
_state.value = _state.value.copy(settings = settings, saveError = null)
|
_state.value = _state.value.copy(settings = settings, saveError = null)
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
preferencesRepository.saveRemote(settings)
|
saveMutex.withLock {
|
||||||
.onSuccess { saved -> _state.value = _state.value.copy(settings = saved, saveError = null) }
|
if (revision != settingsRevision) return@withLock
|
||||||
.onFailure { error ->
|
val result = preferencesRepository.saveRemote(settings)
|
||||||
|
if (revision != settingsRevision) return@withLock
|
||||||
|
result.onSuccess { saved ->
|
||||||
|
preferencesRepository.saveLocal(saved)
|
||||||
|
_state.value = _state.value.copy(settings = saved, saveError = null)
|
||||||
|
}.onFailure { error ->
|
||||||
_state.value = _state.value.copy(saveError = error.message ?: "Benachrichtigungseinstellungen konnten nicht gespeichert werden.")
|
_state.value = _state.value.copy(saveError = error.message ?: "Benachrichtigungseinstellungen konnten nicht gespeichert werden.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun toggleTeam(slug: String, selected: Boolean) {
|
fun toggleTeam(slug: String, selected: Boolean) {
|
||||||
val current = _state.value.settings
|
val current = _state.value.settings
|
||||||
@@ -82,6 +98,30 @@ class NotificationSettingsViewModel @Inject constructor(
|
|||||||
update(current.copy(selectedTeamSlugs = nextTeams))
|
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) {
|
private suspend fun loadTeams(settings: NotificationPreferences, seasons: List<String>, currentUserName: String, syncRemote: Boolean = false) {
|
||||||
mannschaftenRepository.fetchMannschaften(settings.selectedTeamSeason)
|
mannschaftenRepository.fetchMannschaften(settings.selectedTeamSeason)
|
||||||
.onSuccess { teams ->
|
.onSuccess { teams ->
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ class LoginViewModelTest {
|
|||||||
val repo = mockk<de.harheimertc.repositories.LoginRepository>()
|
val repo = mockk<de.harheimertc.repositories.LoginRepository>()
|
||||||
val passkeyRepo = mockk<de.harheimertc.repositories.PasskeyRepository>()
|
val passkeyRepo = mockk<de.harheimertc.repositories.PasskeyRepository>()
|
||||||
coEvery { repo.status() } returns Result.success(de.harheimertc.data.AuthStatusResponse())
|
coEvery { repo.status() } returns Result.success(de.harheimertc.data.AuthStatusResponse())
|
||||||
val vm = LoginViewModel(repo, passkeyRepo)
|
val vm = LoginViewModel(repo, passkeyRepo, mockk(relaxed = true))
|
||||||
|
|
||||||
vm.setEmail("invalid-email")
|
vm.setEmail("invalid-email")
|
||||||
vm.setPassword("")
|
vm.setPassword("")
|
||||||
@@ -57,7 +57,7 @@ class LoginViewModelTest {
|
|||||||
val passkeyRepo = mockk<de.harheimertc.repositories.PasskeyRepository>()
|
val passkeyRepo = mockk<de.harheimertc.repositories.PasskeyRepository>()
|
||||||
coEvery { repo.login(any(), any()) } returns Result.success(loginResp)
|
coEvery { repo.login(any(), any()) } returns Result.success(loginResp)
|
||||||
coEvery { repo.status() } returns Result.success(de.harheimertc.data.AuthStatusResponse())
|
coEvery { repo.status() } returns Result.success(de.harheimertc.data.AuthStatusResponse())
|
||||||
val vm = LoginViewModel(repo, passkeyRepo)
|
val vm = LoginViewModel(repo, passkeyRepo, mockk(relaxed = true))
|
||||||
|
|
||||||
vm.setEmail("max@ex.de")
|
vm.setEmail("max@ex.de")
|
||||||
vm.setPassword("secret")
|
vm.setPassword("secret")
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package de.harheimertc.ui.screens.notifications
|
||||||
|
|
||||||
|
import de.harheimertc.data.AuthStatusResponse
|
||||||
|
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 de.harheimertc.repositories.requireLiveNotificationSettingsSaved
|
||||||
|
import io.mockk.coEvery
|
||||||
|
import io.mockk.every
|
||||||
|
import io.mockk.mockk
|
||||||
|
import io.mockk.verify
|
||||||
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||||
|
import kotlinx.coroutines.test.resetMain
|
||||||
|
import kotlinx.coroutines.test.runCurrent
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import kotlinx.coroutines.test.setMain
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Assert.*
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class NotificationSettingsViewModelTest {
|
||||||
|
private val dispatcher = StandardTestDispatcher()
|
||||||
|
private val preferences = mockk<NotificationPreferencesRepository>(relaxed = true)
|
||||||
|
private val teams = mockk<MannschaftenRepository>()
|
||||||
|
private val login = mockk<LoginRepository>()
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
Dispatchers.setMain(dispatcher)
|
||||||
|
every { preferences.loadLocal() } returns NotificationPreferences()
|
||||||
|
coEvery { preferences.loadRemote() } returns Result.success(NotificationPreferences())
|
||||||
|
coEvery { login.status() } returns Result.success(AuthStatusResponse())
|
||||||
|
coEvery { teams.fetchSeasons() } returns Result.failure(IllegalStateException("offline"))
|
||||||
|
coEvery { teams.fetchMannschaften(any()) } returns Result.success(emptyList())
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() {
|
||||||
|
Dispatchers.resetMain()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun viewModel() = NotificationSettingsViewModel(preferences, teams, login, mockk<PushTokenRepository>())
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun olderResponseCannotUndoNewerSwitches() = runTest(dispatcher) {
|
||||||
|
val firstResponse = CompletableDeferred<Result<NotificationPreferences>>()
|
||||||
|
val first = NotificationPreferences(liveTickerStarts = true)
|
||||||
|
val latest = first.copy(liveTickerScoreChanges = true, liveTickerFinished = true)
|
||||||
|
coEvery { preferences.saveRemote(first) } coAnswers { firstResponse.await() }
|
||||||
|
coEvery { preferences.saveRemote(latest) } returns Result.success(latest)
|
||||||
|
val vm = viewModel()
|
||||||
|
runCurrent()
|
||||||
|
vm.update(first)
|
||||||
|
runCurrent()
|
||||||
|
vm.update(latest)
|
||||||
|
runCurrent()
|
||||||
|
firstResponse.complete(Result.success(first))
|
||||||
|
runCurrent()
|
||||||
|
assertEquals(latest, vm.state.value.settings)
|
||||||
|
verify(exactly = 1) { preferences.saveLocal(first) }
|
||||||
|
verify(atLeast = 1) { preferences.saveLocal(latest) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun rejectedLiveSettingsStayVisibleWithSaveError() = runTest(dispatcher) {
|
||||||
|
val requested = NotificationPreferences(liveTickerFinished = true)
|
||||||
|
coEvery { preferences.saveRemote(requested) } returns Result.failure(IllegalStateException("Backend aktualisieren"))
|
||||||
|
val vm = viewModel()
|
||||||
|
runCurrent()
|
||||||
|
vm.update(requested)
|
||||||
|
runCurrent()
|
||||||
|
assertTrue(vm.state.value.settings.liveTickerFinished)
|
||||||
|
assertEquals("Backend aktualisieren", vm.state.value.saveError)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun serverMustConfirmEachLivePreference() {
|
||||||
|
val defaults = NotificationPreferences()
|
||||||
|
for (requested in listOf(
|
||||||
|
defaults.copy(liveTickerStarts = true),
|
||||||
|
defaults.copy(liveTickerScoreChanges = true),
|
||||||
|
defaults.copy(liveTickerFinished = true),
|
||||||
|
)) {
|
||||||
|
val error = runCatching { requireLiveNotificationSettingsSaved(requested, defaults) }.exceptionOrNull()
|
||||||
|
assertTrue(error?.message.orEmpty().contains("Server"))
|
||||||
|
requireLiveNotificationSettingsSaved(requested, requested)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,8 +8,8 @@ LOCAL_API_BASE_URL=https://harheimertc.tsschulz.de/
|
|||||||
PRODUCTION_API_BASE_URL=https://harheimertc.de/
|
PRODUCTION_API_BASE_URL=https://harheimertc.de/
|
||||||
|
|
||||||
# Android app versioning for Play Store uploads
|
# Android app versioning for Play Store uploads
|
||||||
ANDROID_VERSION_CODE=32
|
ANDROID_VERSION_CODE=40
|
||||||
ANDROID_VERSION_NAME=0.9.27
|
ANDROID_VERSION_NAME=0.11.2
|
||||||
|
|
||||||
# Temporary hotfix: disable R8 minification for release to avoid Retrofit generic signature stripping.
|
# Temporary hotfix: disable R8 minification for release to avoid Retrofit generic signature stripping.
|
||||||
RELEASE_MINIFY_ENABLED=false
|
RELEASE_MINIFY_ENABLED=false
|
||||||
|
|||||||
6
app.vue
6
app.vue
@@ -1,7 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="h-screen flex flex-col overflow-hidden">
|
<div class="h-screen flex flex-col overflow-hidden pt-20">
|
||||||
<Navigation />
|
<Navigation />
|
||||||
<main class="flex-1 overflow-y-auto pt-20 pb-20 sm:pb-0">
|
<LiveTickerBanner />
|
||||||
|
<main class="min-h-0 flex-1 overflow-y-auto pb-20 sm:pb-0">
|
||||||
<NuxtPage />
|
<NuxtPage />
|
||||||
</main>
|
</main>
|
||||||
<Footer />
|
<Footer />
|
||||||
@@ -11,6 +12,7 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import Navigation from '~/components/Navigation.vue'
|
import Navigation from '~/components/Navigation.vue'
|
||||||
|
import LiveTickerBanner from '~/components/LiveTickerBanner.vue'
|
||||||
import Footer from '~/components/Footer.vue'
|
import Footer from '~/components/Footer.vue'
|
||||||
import ModalDialog from '~/components/ModalDialog.vue'
|
import ModalDialog from '~/components/ModalDialog.vue'
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
191
components/LiveTickerBanner.vue
Normal file
191
components/LiveTickerBanner.vue
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
<template>
|
||||||
|
<section
|
||||||
|
v-if="authStore.isLoggedIn && matches.length"
|
||||||
|
class="relative z-40 w-full shrink-0 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 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>
|
||||||
|
<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="paused"
|
||||||
|
:aria-label="paused ? 'Laufschrift fortsetzen' : 'Laufschrift anhalten'"
|
||||||
|
@click="paused = !paused"
|
||||||
|
>
|
||||||
|
{{ paused ? '▶' : 'Ⅱ' }}
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
class="ticker-viewport min-w-0 flex-1"
|
||||||
|
:class="{ 'ticker-paused': paused }"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref="tickerTrack"
|
||||||
|
class="ticker-track"
|
||||||
|
:style="{ '--ticker-duration': `${tickerDuration}s` }"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="copy in 2"
|
||||||
|
:key="copy"
|
||||||
|
class="ticker-group"
|
||||||
|
:aria-hidden="copy === 2 ? 'true' : undefined"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
|
import { useAuthStore } from '~/stores/auth'
|
||||||
|
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const matches = ref([])
|
||||||
|
const soundEnabled = ref(false)
|
||||||
|
const paused = ref(false)
|
||||||
|
const tickerTrack = ref(null)
|
||||||
|
const tickerDuration = ref(30)
|
||||||
|
let timer
|
||||||
|
let audioContext
|
||||||
|
let tickerLoaded = false
|
||||||
|
let previousStates = new Map()
|
||||||
|
|
||||||
|
// Two equal copies let the track wrap without a visible jump. ResizeObserver
|
||||||
|
// keeps the speed at 36 px/s when results, fonts or the viewport change.
|
||||||
|
watch(tickerTrack, (track, _previous, onCleanup) => {
|
||||||
|
if (!track) return
|
||||||
|
const observer = new ResizeObserver(() => {
|
||||||
|
tickerDuration.value = Math.max(1, track.getBoundingClientRect().width / 2 / 36)
|
||||||
|
})
|
||||||
|
observer.observe(track)
|
||||||
|
onCleanup(() => observer.disconnect())
|
||||||
|
}, { flush: 'post' })
|
||||||
|
|
||||||
|
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 (!authStore.isLoggedIn) {
|
||||||
|
matches.value = []
|
||||||
|
tickerLoaded = false
|
||||||
|
previousStates = new Map()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const nextMatches = (await $fetch('/api/mitgliederbereich/live-ticker')).matches || []
|
||||||
|
if (!authStore.isLoggedIn) return
|
||||||
|
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(() => authStore.isLoggedIn, load)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.ticker-viewport {
|
||||||
|
container-type: inline-size;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticker-track {
|
||||||
|
display: flex;
|
||||||
|
width: max-content;
|
||||||
|
animation: ticker-scroll var(--ticker-duration) linear infinite;
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticker-group {
|
||||||
|
display: flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2rem;
|
||||||
|
min-width: 100cqw;
|
||||||
|
padding-right: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticker-paused .ticker-track {
|
||||||
|
animation-play-state: paused;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: hover) {
|
||||||
|
.ticker-viewport:hover .ticker-track { animation-play-state: paused; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ticker-scroll {
|
||||||
|
to { transform: translateX(-50%); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.ticker-viewport { overflow-x: auto; }
|
||||||
|
.ticker-track { animation: none; will-change: auto; }
|
||||||
|
.ticker-group[aria-hidden="true"] { display: none; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -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 class="hidden min-w-0 flex-1 lg:flex items-center h-6 border-t border-primary-700/20">
|
||||||
<div
|
<div
|
||||||
v-if="currentSubmenu"
|
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 -->
|
<!-- Newsletter Submenu -->
|
||||||
<template v-if="currentSubmenu === 'newsletter'">
|
<template v-if="currentSubmenu === 'newsletter'">
|
||||||
@@ -290,6 +290,7 @@
|
|||||||
|
|
||||||
<!-- Intern Submenu -->
|
<!-- Intern Submenu -->
|
||||||
<template v-if="currentSubmenu === 'intern'">
|
<template v-if="currentSubmenu === 'intern'">
|
||||||
|
<div class="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto whitespace-nowrap">
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
to="/mitgliederbereich"
|
to="/mitgliederbereich"
|
||||||
class="px-2.5 py-1 text-xs font-semibold text-white hover:bg-primary-700/50 rounded transition-all"
|
class="px-2.5 py-1 text-xs font-semibold text-white hover:bg-primary-700/50 rounded transition-all"
|
||||||
@@ -310,7 +311,7 @@
|
|||||||
class="px-2.5 py-1 text-xs text-gray-300 hover:text-white hover:bg-primary-700/50 rounded transition-all"
|
class="px-2.5 py-1 text-xs text-gray-300 hover:text-white hover:bg-primary-700/50 rounded transition-all"
|
||||||
active-class="text-white bg-primary-600"
|
active-class="text-white bg-primary-600"
|
||||||
>
|
>
|
||||||
QTTR
|
TTR / QTTR
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
to="/mitgliederbereich/news"
|
to="/mitgliederbereich/news"
|
||||||
@@ -354,9 +355,10 @@
|
|||||||
Kontaktanfragen
|
Kontaktanfragen
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</template>
|
</template>
|
||||||
|
</div>
|
||||||
<template v-if="isAdmin">
|
<template v-if="isAdmin">
|
||||||
<div class="h-3 w-px bg-primary-700" />
|
<div class="h-3 w-px shrink-0 bg-primary-700" />
|
||||||
<div class="relative inline-block">
|
<div class="relative shrink-0 inline-block">
|
||||||
<button
|
<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="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' : ''"
|
:class="route.path.startsWith('/cms') ? 'text-white bg-primary-600' : ''"
|
||||||
@@ -441,6 +443,13 @@
|
|||||||
>
|
>
|
||||||
Einstellungen
|
Einstellungen
|
||||||
</NuxtLink>
|
</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
|
<NuxtLink
|
||||||
to="/cms/benutzer"
|
to="/cms/benutzer"
|
||||||
class="block px-4 py-2 text-sm text-gray-300 hover:bg-primary-600 hover:text-white transition-colors"
|
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"
|
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"
|
@click="isMobileMenuOpen = false"
|
||||||
>
|
>
|
||||||
QTTR
|
TTR / QTTR
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
to="/mitgliederbereich/news"
|
to="/mitgliederbereich/news"
|
||||||
@@ -858,6 +867,13 @@
|
|||||||
>
|
>
|
||||||
Einstellungen
|
Einstellungen
|
||||||
</NuxtLink>
|
</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
|
<NuxtLink
|
||||||
v-if="canManageUsers"
|
v-if="canManageUsers"
|
||||||
to="/cms/benutzer"
|
to="/cms/benutzer"
|
||||||
|
|||||||
@@ -56,14 +56,17 @@ has_tracked_files_under() {
|
|||||||
|
|
||||||
install_dependencies() {
|
install_dependencies() {
|
||||||
if [ -f "package-lock.json" ]; then
|
if [ -f "package-lock.json" ]; then
|
||||||
echo " Running: npm ci"
|
echo " Running: npm ci --no-audit"
|
||||||
if ! npm ci; then
|
# 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..."
|
echo " WARNING: npm ci fehlgeschlagen (Lockfile ggf. nicht synchron). Fallback auf npm install..."
|
||||||
npm install
|
npm install --no-audit --fund=false
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
echo " WARNING: package-lock.json fehlt. Führe npm install aus..."
|
echo " WARNING: package-lock.json fehlt. Führe npm install aus..."
|
||||||
npm install
|
npm install --no-audit --fund=false
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,6 +105,18 @@ install_dependencies_if_needed() {
|
|||||||
printf '%s\n' "$current_lock_hash" > "$lock_hash_file"
|
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() {
|
use_project_node() {
|
||||||
export NVM_DIR="${NVM_DIR:-$HOME/.nvm}"
|
export NVM_DIR="${NVM_DIR:-$HOME/.nvm}"
|
||||||
if [ -s "$NVM_DIR/nvm.sh" ]; then
|
if [ -s "$NVM_DIR/nvm.sh" ]; then
|
||||||
@@ -122,13 +137,8 @@ ensure_node_version() {
|
|||||||
|
|
||||||
local node_version
|
local node_version
|
||||||
node_version="$(node -p 'process.versions.node')"
|
node_version="$(node -p 'process.versions.node')"
|
||||||
if ! node -e 'const [major, minor] = process.versions.node.split(".").map(Number); process.exit(major > 22 || (major === 22 && minor >= 12) ? 0 : 1)' >/dev/null 2>&1; then
|
# Dieselbe Laufzeitprüfung wie beim Build und im Nitro-Server.
|
||||||
echo "ERROR: Node.js >= 22.12.0 wird benötigt, aktuell ist $node_version aktiv."
|
node "$SCRIPT_DIR/scripts/require-safe-node.cjs"
|
||||||
echo "Bitte Node 22 installieren/aktivieren, z.B.:"
|
|
||||||
echo " nvm install 22"
|
|
||||||
echo " nvm alias default 22"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo " Node.js $node_version"
|
echo " Node.js $node_version"
|
||||||
}
|
}
|
||||||
@@ -278,6 +288,7 @@ echo "3. Installing dependencies..."
|
|||||||
use_project_node
|
use_project_node
|
||||||
ensure_node_version
|
ensure_node_version
|
||||||
install_dependencies_if_needed
|
install_dependencies_if_needed
|
||||||
|
install_playwright_browser
|
||||||
|
|
||||||
# 4. Stop running apps before replacing build artifacts
|
# 4. Stop running apps before replacing build artifacts
|
||||||
echo ""
|
echo ""
|
||||||
@@ -528,29 +539,11 @@ if ! command -v pm2 &> /dev/null; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Funktion zum Starten/Neustarten einer PM2-Instanz
|
# Gemeinsamer Start mit der ausgewählten Node-Laufzeit und HTTP-Prüfung.
|
||||||
|
# shellcheck source=scripts/deploy-runtime.sh
|
||||||
|
source "$SCRIPT_DIR/scripts/deploy-runtime.sh"
|
||||||
restart_pm2_instance() {
|
restart_pm2_instance() {
|
||||||
local instance_name=$1
|
deploy_restart_pm2 "$1" "$SCRIPT_DIR/harheimertc.config.cjs"
|
||||||
if ! pm2 describe "$instance_name" &> /dev/null; then
|
|
||||||
echo " WARNING: PM2-Prozess '$instance_name' existiert nicht."
|
|
||||||
echo " Versuche, den Prozess zu starten..."
|
|
||||||
if pm2 start harheimertc.config.cjs --only "$instance_name" --update-env; then
|
|
||||||
echo " ✓ PM2-Prozess '$instance_name' gestartet"
|
|
||||||
return 0
|
|
||||||
else
|
|
||||||
echo " ERROR: Konnte PM2-Prozess '$instance_name' nicht starten."
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
echo " Restarting $instance_name with --update-env..."
|
|
||||||
if pm2 restart "$instance_name" --update-env; then
|
|
||||||
echo " ✓ PM2-Prozess '$instance_name' neu gestartet"
|
|
||||||
return 0
|
|
||||||
else
|
|
||||||
echo " ERROR: PM2-Restart für '$instance_name' fehlgeschlagen!"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Starte/Neustarte die Produktionsinstanz
|
# Starte/Neustarte die Produktionsinstanz
|
||||||
@@ -560,23 +553,16 @@ if ! restart_pm2_instance "harheimertc"; then
|
|||||||
INSTANCE_ERRORS=$((INSTANCE_ERRORS + 1))
|
INSTANCE_ERRORS=$((INSTANCE_ERRORS + 1))
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Prüfe, ob der Prozess läuft
|
if ! deploy_wait_for_http "harheimertc" "http://127.0.0.1:3100/"; then
|
||||||
sleep 2
|
|
||||||
echo ""
|
|
||||||
echo " Checking PM2 instances status..."
|
|
||||||
|
|
||||||
if pm2 describe harheimertc | grep -q "online"; then
|
|
||||||
echo " ✓ PM2-Prozess 'harheimertc' läuft (online)"
|
|
||||||
else
|
|
||||||
echo " WARNING: PM2-Prozess 'harheimertc' ist nicht online. Prüfe Logs: pm2 logs harheimertc"
|
|
||||||
INSTANCE_ERRORS=$((INSTANCE_ERRORS + 1))
|
INSTANCE_ERRORS=$((INSTANCE_ERRORS + 1))
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ "$INSTANCE_ERRORS" -gt 0 ]; then
|
if [ "$INSTANCE_ERRORS" -gt 0 ]; then
|
||||||
echo ""
|
echo ""
|
||||||
echo "WARNING: Einige PM2-Instanzen haben Probleme. Bitte manuell prüfen:"
|
echo "ERROR: Produktionsinstanz konnte nicht erfolgreich gestartet werden. Bitte manuell prüfen:"
|
||||||
echo " pm2 status"
|
echo " pm2 status"
|
||||||
echo " pm2 logs harheimertc"
|
echo " pm2 logs harheimertc"
|
||||||
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
|
|||||||
@@ -69,14 +69,17 @@ has_tracked_files_under() {
|
|||||||
|
|
||||||
install_dependencies() {
|
install_dependencies() {
|
||||||
if [ -f "package-lock.json" ]; then
|
if [ -f "package-lock.json" ]; then
|
||||||
echo " Running: npm ci"
|
echo " Running: npm ci --no-audit"
|
||||||
if ! npm ci; then
|
# 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..."
|
echo " WARNING: npm ci fehlgeschlagen (Lockfile ggf. nicht synchron). Fallback auf npm install..."
|
||||||
npm install
|
npm install --no-audit --fund=false
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
echo " WARNING: package-lock.json fehlt. Führe npm install aus..."
|
echo " WARNING: package-lock.json fehlt. Führe npm install aus..."
|
||||||
npm install
|
npm install --no-audit --fund=false
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,6 +118,18 @@ install_dependencies_if_needed() {
|
|||||||
printf '%s\n' "$current_lock_hash" > "$lock_hash_file"
|
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() {
|
use_project_node() {
|
||||||
export NVM_DIR="${NVM_DIR:-$HOME/.nvm}"
|
export NVM_DIR="${NVM_DIR:-$HOME/.nvm}"
|
||||||
if [ -s "$NVM_DIR/nvm.sh" ]; then
|
if [ -s "$NVM_DIR/nvm.sh" ]; then
|
||||||
@@ -135,13 +150,8 @@ ensure_node_version() {
|
|||||||
|
|
||||||
local node_version
|
local node_version
|
||||||
node_version="$(node -p 'process.versions.node')"
|
node_version="$(node -p 'process.versions.node')"
|
||||||
if ! node -e 'const [major, minor] = process.versions.node.split(".").map(Number); process.exit(major > 22 || (major === 22 && minor >= 12) ? 0 : 1)' >/dev/null 2>&1; then
|
# Dieselbe Laufzeitprüfung wie beim Build und im Nitro-Server.
|
||||||
echo "ERROR: Node.js >= 22.12.0 wird benötigt, aktuell ist $node_version aktiv."
|
node "$SCRIPT_DIR/scripts/require-safe-node.cjs"
|
||||||
echo "Bitte Node 22 installieren/aktivieren, z.B.:"
|
|
||||||
echo " nvm install 22"
|
|
||||||
echo " nvm alias default 22"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo " Node.js $node_version"
|
echo " Node.js $node_version"
|
||||||
}
|
}
|
||||||
@@ -284,6 +294,7 @@ echo "3. Installing dependencies..."
|
|||||||
use_project_node
|
use_project_node
|
||||||
ensure_node_version
|
ensure_node_version
|
||||||
install_dependencies_if_needed
|
install_dependencies_if_needed
|
||||||
|
install_playwright_browser
|
||||||
|
|
||||||
# 4. Stop running app before replacing build artifacts
|
# 4. Stop running app before replacing build artifacts
|
||||||
echo ""
|
echo ""
|
||||||
@@ -529,39 +540,11 @@ if ! command -v pm2 &> /dev/null; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Funktion zum Starten/Neustarten einer PM2-Instanz
|
# Gemeinsamer Start mit der ausgewählten Node-Laufzeit und HTTP-Prüfung.
|
||||||
|
# shellcheck source=scripts/deploy-runtime.sh
|
||||||
|
source "$SCRIPT_DIR/scripts/deploy-runtime.sh"
|
||||||
restart_pm2_instance() {
|
restart_pm2_instance() {
|
||||||
local instance_name=$1
|
deploy_restart_pm2 "$1" "$SCRIPT_DIR/harheimertc.test.config.cjs"
|
||||||
local config_file="$SCRIPT_DIR/harheimertc.test.config.cjs"
|
|
||||||
|
|
||||||
# Prüfe, ob die Konfigurationsdatei existiert
|
|
||||||
if [ ! -f "$config_file" ]; then
|
|
||||||
echo " ERROR: PM2-Konfigurationsdatei nicht gefunden: $config_file"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if ! pm2 describe "$instance_name" &> /dev/null; then
|
|
||||||
echo " WARNING: PM2-Prozess '$instance_name' existiert nicht."
|
|
||||||
echo " Versuche, den Prozess zu starten..."
|
|
||||||
echo " Verwende Konfiguration: $config_file"
|
|
||||||
if pm2 start "$config_file" --update-env; then
|
|
||||||
echo " ✓ PM2-Prozess '$instance_name' gestartet"
|
|
||||||
return 0
|
|
||||||
else
|
|
||||||
echo " ERROR: Konnte PM2-Prozess '$instance_name' nicht starten."
|
|
||||||
echo " Bitte manuell prüfen: pm2 logs $instance_name"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
echo " Restarting $instance_name with --update-env..."
|
|
||||||
if pm2 restart "$instance_name" --update-env; then
|
|
||||||
echo " ✓ PM2-Prozess '$instance_name' neu gestartet"
|
|
||||||
return 0
|
|
||||||
else
|
|
||||||
echo " ERROR: PM2-Restart für '$instance_name' fehlgeschlagen!"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Starte/Neustarte Test-Instanz
|
# Starte/Neustarte Test-Instanz
|
||||||
@@ -573,23 +556,7 @@ if ! restart_pm2_instance "harheimertc.test"; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Prüfe, ob der Prozess läuft
|
deploy_wait_for_http "harheimertc.test" "http://127.0.0.1:3102/"
|
||||||
sleep 2
|
|
||||||
echo ""
|
|
||||||
echo " Checking PM2 instance status..."
|
|
||||||
|
|
||||||
if pm2 describe harheimertc.test | grep -q "online"; then
|
|
||||||
echo " ✓ PM2-Prozess 'harheimertc.test' läuft (online)"
|
|
||||||
else
|
|
||||||
echo " ERROR: PM2-Prozess 'harheimertc.test' ist nicht online. Prüfe Logs: pm2 logs harheimertc.test"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if ! curl --fail --silent --show-error --max-time 15 http://127.0.0.1:3102/ >/dev/null; then
|
|
||||||
echo "ERROR: Test-Instanz antwortet nicht auf Port 3102."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo " ✓ HTTP-Healthcheck auf Port 3102 erfolgreich"
|
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "=== Test-Instanz Deployment completed successfully! ==="
|
echo "=== Test-Instanz Deployment completed successfully! ==="
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ git pull origin main
|
|||||||
|
|
||||||
# Dependencies installieren
|
# Dependencies installieren
|
||||||
echo "📦 Installing dependencies..."
|
echo "📦 Installing dependencies..."
|
||||||
npm install
|
npm install --no-audit --fund=false
|
||||||
|
|
||||||
# Website bauen (Static Generation)
|
# Website bauen (Static Generation)
|
||||||
echo "🔨 Building website..."
|
echo "🔨 Building website..."
|
||||||
|
|||||||
@@ -14,6 +14,11 @@ SMTP_PORT=587
|
|||||||
SMTP_USER=tsschulz@tsschulz.de
|
SMTP_USER=tsschulz@tsschulz.de
|
||||||
SMTP_PASS=your_app_password_here
|
SMTP_PASS=your_app_password_here
|
||||||
|
|
||||||
|
# Unterdrückt ALLE SMTP-Mails dieser Installation (für lokale Tests empfohlen).
|
||||||
|
MAIL_DELIVERY_DISABLED=true
|
||||||
|
# Optional: Review-Adresse; Einrichtung siehe PLAYSTORE_REVIEW.md
|
||||||
|
PLAYSTORE_REVIEW_EMAIL=playstore-review@harheimertc.invalid
|
||||||
|
|
||||||
# Alternative für GMX:
|
# Alternative für GMX:
|
||||||
# SMTP_HOST=mail.gmx.net
|
# SMTP_HOST=mail.gmx.net
|
||||||
# SMTP_PORT=587
|
# SMTP_PORT=587
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ function createEnv(port) {
|
|||||||
// Secrets/Config (loaded from .env above, if present)
|
// Secrets/Config (loaded from .env above, if present)
|
||||||
ENCRYPTION_KEY: process.env.ENCRYPTION_KEY,
|
ENCRYPTION_KEY: process.env.ENCRYPTION_KEY,
|
||||||
JWT_SECRET: process.env.JWT_SECRET,
|
JWT_SECRET: process.env.JWT_SECRET,
|
||||||
|
MAIL_DELIVERY_DISABLED: process.env.MAIL_DELIVERY_DISABLED,
|
||||||
|
PLAYSTORE_REVIEW_EMAIL: process.env.PLAYSTORE_REVIEW_EMAIL,
|
||||||
|
HIDDEN_USER_EMAILS: process.env.HIDDEN_USER_EMAILS,
|
||||||
SMTP_HOST: process.env.SMTP_HOST,
|
SMTP_HOST: process.env.SMTP_HOST,
|
||||||
SMTP_PORT: process.env.SMTP_PORT,
|
SMTP_PORT: process.env.SMTP_PORT,
|
||||||
SMTP_USER: process.env.SMTP_USER,
|
SMTP_USER: process.env.SMTP_USER,
|
||||||
@@ -49,8 +52,9 @@ module.exports = {
|
|||||||
{
|
{
|
||||||
name: 'harheimertc',
|
name: 'harheimertc',
|
||||||
// Nuxt 4 production build: direkt den Node-Server starten (kein "preview mode")
|
// Nuxt 4 production build: direkt den Node-Server starten (kein "preview mode")
|
||||||
script: 'node',
|
script: '.output/server/index.mjs',
|
||||||
args: '.output/server/index.mjs',
|
// Der PM2-Daemon kann noch unter einer älteren Node-Version laufen.
|
||||||
|
interpreter: process.env.DEPLOY_NODE_BINARY || process.execPath,
|
||||||
cwd: '/var/www/harheimertc',
|
cwd: '/var/www/harheimertc',
|
||||||
instances: 1,
|
instances: 1,
|
||||||
autorestart: true,
|
autorestart: true,
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ function createEnv(port) {
|
|||||||
// Secrets/Config (loaded from .env above, if present)
|
// Secrets/Config (loaded from .env above, if present)
|
||||||
ENCRYPTION_KEY: process.env.ENCRYPTION_KEY,
|
ENCRYPTION_KEY: process.env.ENCRYPTION_KEY,
|
||||||
JWT_SECRET: process.env.JWT_SECRET,
|
JWT_SECRET: process.env.JWT_SECRET,
|
||||||
|
MAIL_DELIVERY_DISABLED: process.env.MAIL_DELIVERY_DISABLED || 'true',
|
||||||
|
PLAYSTORE_REVIEW_EMAIL: process.env.PLAYSTORE_REVIEW_EMAIL,
|
||||||
|
HIDDEN_USER_EMAILS: process.env.HIDDEN_USER_EMAILS,
|
||||||
SMTP_HOST: process.env.SMTP_HOST,
|
SMTP_HOST: process.env.SMTP_HOST,
|
||||||
SMTP_PORT: process.env.SMTP_PORT,
|
SMTP_PORT: process.env.SMTP_PORT,
|
||||||
SMTP_USER: process.env.SMTP_USER,
|
SMTP_USER: process.env.SMTP_USER,
|
||||||
@@ -51,8 +54,9 @@ module.exports = {
|
|||||||
{
|
{
|
||||||
name: 'harheimertc.test',
|
name: 'harheimertc.test',
|
||||||
// Nuxt 4 production build: direkt den Node-Server starten (kein "preview mode")
|
// Nuxt 4 production build: direkt den Node-Server starten (kein "preview mode")
|
||||||
script: 'node',
|
script: '.output/server/index.mjs',
|
||||||
args: '.output/server/index.mjs',
|
// Der PM2-Daemon kann noch unter einer älteren Node-Version laufen.
|
||||||
|
interpreter: process.env.DEPLOY_NODE_BINARY || process.execPath,
|
||||||
cwd: '/var/www/harheimertc.test',
|
cwd: '/var/www/harheimertc.test',
|
||||||
instances: 1,
|
instances: 1,
|
||||||
autorestart: true,
|
autorestart: true,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import './scripts/require-safe-node.cjs';
|
||||||
// https://nuxt.com/docs/api/configuration/nuxt-config
|
// https://nuxt.com/docs/api/configuration/nuxt-config
|
||||||
export default defineNuxtConfig({
|
export default defineNuxtConfig({
|
||||||
devtools: { enabled: process.env.NODE_ENV !== 'production' },
|
devtools: { enabled: process.env.NODE_ENV !== 'production' },
|
||||||
|
|||||||
5666
package-lock.json
generated
5666
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
16
package.json
16
package.json
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "harheimertc-website",
|
"name": "harheimertc-website",
|
||||||
"version": "1.8.8",
|
"version": "1.9.2",
|
||||||
"description": "Moderne Webseite für den Harheimer Tischtennis Club",
|
"description": "Moderne Webseite für den Harheimer Tischtennis Club",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22.12.0",
|
"node": "^22.23.4 || ^24.22.1 || ^26.12.1",
|
||||||
"npm": "10.9.7"
|
"npm": "10.9.7"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -38,12 +38,13 @@
|
|||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"dompurify": "^3.3.1",
|
"dompurify": "^3.3.1",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"multer": "^2.0.2",
|
"multer": "^2.4.0",
|
||||||
"nodemailer": "^9.0.1",
|
"nodemailer": "^10.0.2",
|
||||||
"nuxt": "^4.5.1",
|
"nuxt": "^4.5.1",
|
||||||
"pdf-lib": "^1.17.1",
|
"pdf-lib": "^1.17.1",
|
||||||
"pdf-parse": "^2.4.5",
|
"pdf-parse": "^2.4.5",
|
||||||
"pinia": "^3.0.3",
|
"pinia": "^3.0.3",
|
||||||
|
"playwright": "^1.62.1",
|
||||||
"quill": "2.0.2",
|
"quill": "2.0.2",
|
||||||
"sharp": "^0.35.3",
|
"sharp": "^0.35.3",
|
||||||
"vue": "^3.5.22"
|
"vue": "^3.5.22"
|
||||||
@@ -62,11 +63,14 @@
|
|||||||
"postcss": "^8.5.12",
|
"postcss": "^8.5.12",
|
||||||
"supertest": "^7.1.0",
|
"supertest": "^7.1.0",
|
||||||
"tailwindcss": "^3.4.0",
|
"tailwindcss": "^3.4.0",
|
||||||
"vitest": "^4.1.8",
|
"vitest": "^4.1.12",
|
||||||
"vue-eslint-parser": "^10.2.0"
|
"vue-eslint-parser": "^10.2.0"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"@peculiar/x509": "1.13.0",
|
"@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>
|
</p>
|
||||||
</NuxtLink>
|
</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) -->
|
<!-- Benutzerverwaltung (Admin ODER Vorstand) -->
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
v-if="authStore.hasAnyRole('admin', 'vorstand')"
|
v-if="authStore.hasAnyRole('admin', 'vorstand')"
|
||||||
@@ -249,7 +270,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<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'
|
import { ref, onMounted } from 'vue'
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
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,23 +3,17 @@
|
|||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 space-y-8">
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 space-y-8">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-4xl sm:text-5xl font-display font-bold text-gray-900 mb-4">
|
<h1 class="text-4xl sm:text-5xl font-display font-bold text-gray-900 mb-4">
|
||||||
QTTR-Werte
|
TTR- und QTTR-Werte
|
||||||
</h1>
|
</h1>
|
||||||
<div class="w-24 h-1 bg-primary-600 mb-6" />
|
<div class="w-24 h-1 bg-primary-600 mb-6" />
|
||||||
<p class="text-lg text-gray-700 max-w-3xl">
|
<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
|
Aktuelle TTR- und QTTR-Werte des Harheimer TC. Der Abruf erfolgt über die geschützte Vereinsverbindung.
|
||||||
<a
|
|
||||||
:href="externalUrl"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="text-primary-600 hover:text-primary-800 underline"
|
|
||||||
>myTischtennis</a>
|
|
||||||
wechseln.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="bg-white rounded-xl shadow-lg border border-gray-100 p-6">
|
<div class="bg-white rounded-xl shadow-lg border border-gray-100 p-6">
|
||||||
<div class="flex flex-wrap items-center gap-4 justify-between mb-4">
|
<div class="flex flex-wrap items-center gap-4 justify-between mb-4">
|
||||||
|
<div class="flex flex-wrap items-end gap-x-4 gap-y-3">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-xl font-semibold text-gray-900">
|
<h2 class="text-xl font-semibold text-gray-900">
|
||||||
Harheimer TC Rangliste
|
Harheimer TC Rangliste
|
||||||
@@ -28,6 +22,31 @@
|
|||||||
{{ data?.title || 'Andro-Rangliste' }} · {{ data?.rowCount || 0 }} Einträge
|
{{ data?.title || 'Andro-Rangliste' }} · {{ data?.rowCount || 0 }} Einträge
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
class="inline-flex rounded-md border border-gray-200 bg-gray-50 p-0.5 shadow-sm"
|
||||||
|
role="group"
|
||||||
|
aria-label="Rangliste sortieren nach"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded px-3 py-1.5 text-sm font-semibold transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-600 focus-visible:ring-offset-2"
|
||||||
|
:class="sortBy === 'qttr' ? 'bg-primary-600 text-white shadow-sm' : 'text-gray-700 hover:bg-white hover:text-primary-700'"
|
||||||
|
:aria-pressed="sortBy === 'qttr'"
|
||||||
|
@click="sortBy = 'qttr'"
|
||||||
|
>
|
||||||
|
QTTR
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded px-3 py-1.5 text-sm font-semibold transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-600 focus-visible:ring-offset-2"
|
||||||
|
:class="sortBy === 'ttr' ? 'bg-primary-600 text-white shadow-sm' : 'text-gray-700 hover:bg-white hover:text-primary-700'"
|
||||||
|
:aria-pressed="sortBy === 'ttr'"
|
||||||
|
@click="sortBy = 'ttr'"
|
||||||
|
>
|
||||||
|
TTR
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="text-sm text-gray-500">
|
<div class="text-sm text-gray-500">
|
||||||
Aktualisiert: {{ formatDate(data?.importedAt) }}
|
Aktualisiert: {{ formatDate(data?.importedAt) }}
|
||||||
</div>
|
</div>
|
||||||
@@ -37,13 +56,13 @@
|
|||||||
v-if="pending"
|
v-if="pending"
|
||||||
class="py-12 text-center text-gray-500"
|
class="py-12 text-center text-gray-500"
|
||||||
>
|
>
|
||||||
Lade QTTR-Werte...
|
Lade TTR- und QTTR-Werte...
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-else-if="error"
|
v-else-if="error"
|
||||||
class="rounded-lg border border-red-200 bg-red-50 p-4 text-red-800"
|
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>
|
||||||
<div
|
<div
|
||||||
v-else
|
v-else
|
||||||
@@ -53,7 +72,7 @@
|
|||||||
<thead class="bg-gray-50">
|
<thead class="bg-gray-50">
|
||||||
<tr>
|
<tr>
|
||||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500">
|
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500">
|
||||||
Rang
|
Vereinsrang<br>(QTTR / TTR)
|
||||||
</th>
|
</th>
|
||||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500">
|
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500">
|
||||||
Spieler
|
Spieler
|
||||||
@@ -61,6 +80,9 @@
|
|||||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500">
|
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500">
|
||||||
Verein
|
Verein
|
||||||
</th>
|
</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">
|
<th class="px-4 py-3 text-right text-xs font-semibold uppercase tracking-wider text-gray-500">
|
||||||
QTTR
|
QTTR
|
||||||
</th>
|
</th>
|
||||||
@@ -68,12 +90,14 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-gray-100 bg-white">
|
<tbody class="divide-y divide-gray-100 bg-white">
|
||||||
<tr
|
<tr
|
||||||
v-for="row in data?.rows || []"
|
v-for="row in sortedRows"
|
||||||
:key="`${row.rank}-${row.playerNumber || row.playerName}`"
|
:key="row.playerNumber || row.playerName"
|
||||||
:class="isOwnRow(row.playerName) ? 'bg-primary-100' : ''"
|
:class="isOwnRow(row.playerName) ? 'bg-primary-100' : ''"
|
||||||
>
|
>
|
||||||
<td class="px-4 py-3 text-sm text-gray-600">
|
<td class="px-4 py-3 text-sm text-gray-600">
|
||||||
{{ row.rank ?? '–' }}
|
<span :title="clubRankTitle(row)">
|
||||||
|
{{ formatClubRank(row) }}
|
||||||
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3">
|
<td class="px-4 py-3">
|
||||||
<div :class="['font-medium', getPlayerNameClass(row)]">
|
<div :class="['font-medium', getPlayerNameClass(row)]">
|
||||||
@@ -83,6 +107,9 @@
|
|||||||
<td class="px-4 py-3 text-sm text-gray-700">
|
<td class="px-4 py-3 text-sm text-gray-700">
|
||||||
{{ row.clubName || 'Harheimer TC' }}
|
{{ row.clubName || 'Harheimer TC' }}
|
||||||
</td>
|
</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">
|
<td class="px-4 py-3 text-right text-lg font-semibold text-gray-900 tabular-nums">
|
||||||
{{ row.currentQttr ?? '–' }}
|
{{ row.currentQttr ?? '–' }}
|
||||||
</td>
|
</td>
|
||||||
@@ -96,10 +123,9 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
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({
|
definePageMeta({
|
||||||
middleware: 'auth',
|
middleware: 'auth',
|
||||||
@@ -111,6 +137,34 @@ await authStore.checkAuth()
|
|||||||
const { data, pending, error } = await useFetch('/api/mitgliederbereich/qttr')
|
const { data, pending, error } = await useFetch('/api/mitgliederbereich/qttr')
|
||||||
|
|
||||||
const currentUserName = computed(() => authStore.user?.name?.trim() || '')
|
const currentUserName = computed(() => authStore.user?.name?.trim() || '')
|
||||||
|
const sortBy = ref('qttr')
|
||||||
|
|
||||||
|
const sortedRows = computed(() => {
|
||||||
|
const rows = [...(data.value?.rows || [])]
|
||||||
|
const byName = (a, b) => String(a.playerName || '').localeCompare(String(b.playerName || ''), 'de')
|
||||||
|
const byRating = (key) => (a, b) => b[key] - a[key] || byName(a, b)
|
||||||
|
|
||||||
|
if (sortBy.value === 'ttr') {
|
||||||
|
return rows.sort((a, b) => {
|
||||||
|
const aMissing = a.currentTtr == null
|
||||||
|
const bMissing = b.currentTtr == null
|
||||||
|
if (aMissing !== bMissing) return aMissing ? 1 : -1
|
||||||
|
return aMissing ? byName(a, b) : byRating('currentTtr')(a, b)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows.sort((a, b) => {
|
||||||
|
const aMissing = a.currentQttr == null
|
||||||
|
const bMissing = b.currentQttr == null
|
||||||
|
if (aMissing !== bMissing) return aMissing ? 1 : -1
|
||||||
|
if (!aMissing) return byRating('currentQttr')(a, b)
|
||||||
|
|
||||||
|
const aMissingTtr = a.currentTtr == null
|
||||||
|
const bMissingTtr = b.currentTtr == null
|
||||||
|
if (aMissingTtr !== bMissingTtr) return aMissingTtr ? 1 : -1
|
||||||
|
return aMissingTtr ? byName(a, b) : byRating('currentTtr')(a, b)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
function normalizeName(value) {
|
function normalizeName(value) {
|
||||||
return String(value || '').trim().toLowerCase().replace(/\s+/g, ' ')
|
return String(value || '').trim().toLowerCase().replace(/\s+/g, ' ')
|
||||||
@@ -126,7 +180,7 @@ function isMaleGender(value) {
|
|||||||
|
|
||||||
function isFemaleGender(value) {
|
function isFemaleGender(value) {
|
||||||
const gender = normalizeName(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) {
|
function isOwnRow(playerName) {
|
||||||
@@ -135,6 +189,14 @@ function isOwnRow(playerName) {
|
|||||||
return normalizeName(playerName) === current
|
return normalizeName(playerName) === current
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatClubRank(row) {
|
||||||
|
return `${row.clubQttrRank ?? '–'} (${row.clubTtrRank ?? '–'})`
|
||||||
|
}
|
||||||
|
|
||||||
|
function clubRankTitle(row) {
|
||||||
|
return `QTTR-Platzierung im Verein: ${row.clubQttrRank ?? 'nicht verfügbar'}; TTR-Platzierung im Verein: ${row.clubTtrRank ?? 'nicht verfügbar'}`
|
||||||
|
}
|
||||||
|
|
||||||
function getPlayerNameClass(row) {
|
function getPlayerNameClass(row) {
|
||||||
const minor = isMinor(row.birthdate)
|
const minor = isMinor(row.birthdate)
|
||||||
if (minor && isMaleGender(row.gender)) return 'text-blue-400'
|
if (minor && isMaleGender(row.gender)) return 'text-blue-400'
|
||||||
@@ -180,6 +242,6 @@ function formatDate(value) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
useHead({
|
useHead({
|
||||||
title: 'QTTR-Werte - Harheimer TC'
|
title: 'TTR- und QTTR-Werte - Harheimer TC'
|
||||||
})
|
})
|
||||||
</script>
|
</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
|
||||||
|
}
|
||||||
45
scripts/deploy-runtime.sh
Normal file
45
scripts/deploy-runtime.sh
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Sourced by the test and production deployment scripts.
|
||||||
|
|
||||||
|
deploy_restart_pm2() {
|
||||||
|
local instance_name="$1"
|
||||||
|
local config_file="$2"
|
||||||
|
local node_binary
|
||||||
|
|
||||||
|
if [ ! -f "$config_file" ]; then
|
||||||
|
echo "ERROR: PM2-Konfigurationsdatei nicht gefunden: $config_file"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
node_binary="$(node -p 'process.execPath')" || return 1
|
||||||
|
echo " Starting/restarting $instance_name using Node: $node_binary"
|
||||||
|
# PM2 retains the old script path even when restarting via a config file.
|
||||||
|
# Recreate only this app so legacy `script: node` entries migrate as well.
|
||||||
|
if pm2 describe "$instance_name" >/dev/null 2>&1; then
|
||||||
|
pm2 delete "$instance_name" || return 1
|
||||||
|
fi
|
||||||
|
DEPLOY_NODE_BINARY="$node_binary" pm2 start "$config_file" \
|
||||||
|
--only "$instance_name" --update-env
|
||||||
|
}
|
||||||
|
|
||||||
|
deploy_wait_for_http() {
|
||||||
|
local instance_name="$1"
|
||||||
|
local url="$2"
|
||||||
|
local attempt
|
||||||
|
|
||||||
|
echo " Waiting for $instance_name: $url"
|
||||||
|
for ((attempt = 1; attempt <= 30; attempt++)); do
|
||||||
|
if curl --fail --silent --max-time 2 "$url" >/dev/null; then
|
||||||
|
echo " ✓ HTTP-Healthcheck erfolgreich: $url"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
if [ "$attempt" -lt 30 ]; then
|
||||||
|
sleep 2
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "ERROR: $instance_name antwortet nicht erfolgreich auf $url"
|
||||||
|
pm2 describe "$instance_name" || true
|
||||||
|
pm2 logs "$instance_name" --nostream --lines 30 || true
|
||||||
|
return 1
|
||||||
|
}
|
||||||
22
scripts/require-safe-node.cjs
Normal file
22
scripts/require-safe-node.cjs
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
// CVE-2026-111497: Node's bundled Brotli decoder must include the security fix.
|
||||||
|
// Check Node versions: patched releases can still report Brotli 1.2.0.
|
||||||
|
// https://nodejs.org/en/blog/vulnerability/october-2026-security-releases
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
function requireSafeNode() {
|
||||||
|
const [major, minor, patch] = process.versions.node.split('.').map(Number);
|
||||||
|
const minimum = { 22: [23, 4], 24: [22, 1], 26: [12, 1] }[major];
|
||||||
|
const patched = minimum && (minor > minimum[0] ||
|
||||||
|
(minor === minimum[0] && patch >= minimum[1]));
|
||||||
|
|
||||||
|
if (!patched) {
|
||||||
|
throw new Error(
|
||||||
|
`CVE-2026-111497: Node.js ${process.versions.node} is not an approved patched runtime. ` +
|
||||||
|
'Use Node.js 22.23.4+, 24.22.1+ or 26.12.1+ within those release lines. ' +
|
||||||
|
'Recommended: nvm install && nvm use (see .nvmrc). Restart all server/PM2 processes after upgrading.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
requireSafeNode();
|
||||||
|
module.exports = requireSafeNode;
|
||||||
44
scripts/setup-playstore-review.js
Normal file
44
scripts/setup-playstore-review.js
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import 'dotenv/config'
|
||||||
|
import { promises as fs } from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import crypto from 'node:crypto'
|
||||||
|
import { decryptObject } from '../server/utils/encryption.js'
|
||||||
|
import { hashPassword, writeUsers } from '../server/utils/auth.js'
|
||||||
|
|
||||||
|
// Run in the target installation's root, with its encryption key loaded.
|
||||||
|
const email = String(process.env.PLAYSTORE_REVIEW_EMAIL || 'playstore-review@harheimertc.invalid').trim().toLowerCase()
|
||||||
|
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) throw new Error('Ungültige PLAYSTORE_REVIEW_EMAIL')
|
||||||
|
if (!process.env.ENCRYPTION_KEY) throw new Error('ENCRYPTION_KEY der Zielinstallation fehlt')
|
||||||
|
const root = process.cwd().endsWith('.output') ? path.resolve('..') : process.cwd()
|
||||||
|
const file = path.join(root, 'server/data/users.json')
|
||||||
|
// Fail closed: never replace an unreadable user database with an empty list.
|
||||||
|
const raw = await fs.readFile(file, 'utf8')
|
||||||
|
let users
|
||||||
|
try { users = JSON.parse(raw) } catch { users = decryptObject(raw, process.env.ENCRYPTION_KEY) }
|
||||||
|
if (!Array.isArray(users)) throw new Error('Ungültiges Format der Benutzerdaten')
|
||||||
|
let user = users.find(candidate => String(candidate.email || '').trim().toLowerCase() === email)
|
||||||
|
if (user && user.accountType !== 'playstore_review') throw new Error('E-Mail wird bereits von einem regulären Konto verwendet')
|
||||||
|
if (user && !process.env.PLAYSTORE_REVIEW_PASSWORD) {
|
||||||
|
console.log(`Review-Konto existiert bereits: ${email}. Passwort bleibt unverändert.`)
|
||||||
|
} else {
|
||||||
|
const password = process.env.PLAYSTORE_REVIEW_PASSWORD || crypto.randomBytes(24).toString('base64url')
|
||||||
|
if (password.length < 16) throw new Error('Review-Passwort muss mindestens 16 Zeichen haben')
|
||||||
|
if (!user) {
|
||||||
|
user = { id: crypto.randomUUID(), email, created: new Date().toISOString() }
|
||||||
|
users.push(user)
|
||||||
|
}
|
||||||
|
user.name = 'Google Play Review'
|
||||||
|
user.password = await hashPassword(password)
|
||||||
|
user.roles = ['mitglied']
|
||||||
|
user.active = true
|
||||||
|
user.hidden = true
|
||||||
|
user.accountType = 'playstore_review'
|
||||||
|
user.visibility = { showBirthday: false }
|
||||||
|
delete user.role
|
||||||
|
await fs.copyFile(file, `${file}.before-playstore-${Date.now()}`)
|
||||||
|
if (!await writeUsers(users)) throw new Error('Review-Konto konnte nicht gespeichert werden')
|
||||||
|
console.log(`E-Mail: ${email}`)
|
||||||
|
if (!process.env.PLAYSTORE_REVIEW_PASSWORD) console.log(`Passwort (sicher ablegen): ${password}`)
|
||||||
|
else console.log('Passwort: aus PLAYSTORE_REVIEW_PASSWORD gesetzt')
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import nodemailer from 'nodemailer'
|
import { createMailTransport } from '../../../../utils/mail-transport.js'
|
||||||
import { readUsers, writeUsers } from '../../../../utils/auth.js'
|
import { readUsers, writeUsers } from '../../../../utils/auth.js'
|
||||||
import { assertRateLimit, getClientIp, registerRateLimitFailure, registerRateLimitSuccess } from '../../../../utils/rate-limit.js'
|
import { assertRateLimit, getClientIp, registerRateLimitFailure, registerRateLimitSuccess } from '../../../../utils/rate-limit.js'
|
||||||
import { generateRecoveryToken, hashRecoveryToken, pruneRecoveryTokens } from '../../../../utils/passkey-recovery.js'
|
import { generateRecoveryToken, hashRecoveryToken, pruneRecoveryTokens } from '../../../../utils/passkey-recovery.js'
|
||||||
@@ -70,7 +70,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
const smtpPass = process.env.SMTP_PASS
|
const smtpPass = process.env.SMTP_PASS
|
||||||
|
|
||||||
if (smtpUser && smtpPass) {
|
if (smtpUser && smtpPass) {
|
||||||
const transporter = nodemailer.createTransport({
|
const transporter = createMailTransport(event, {
|
||||||
host: process.env.SMTP_HOST || 'smtp.gmail.com',
|
host: process.env.SMTP_HOST || 'smtp.gmail.com',
|
||||||
port: process.env.SMTP_PORT || 587,
|
port: process.env.SMTP_PORT || 587,
|
||||||
secure: false,
|
secure: false,
|
||||||
|
|||||||
@@ -262,7 +262,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
|
|
||||||
// Send notification emails through the same central recipient logic as password registration.
|
// Send notification emails through the same central recipient logic as password registration.
|
||||||
try {
|
try {
|
||||||
await sendRegistrationNotification({ name, email, phone })
|
await sendRegistrationNotification({ name, email, phone }, event)
|
||||||
} catch (emailError) {
|
} catch (emailError) {
|
||||||
console.error('E-Mail-Versand fehlgeschlagen:', emailError)
|
console.error('E-Mail-Versand fehlgeschlagen:', emailError)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
|
|
||||||
// Send notification to Vorstand/admin via central email service
|
// Send notification to Vorstand/admin via central email service
|
||||||
try {
|
try {
|
||||||
await sendRegistrationNotification({ name, email, phone })
|
await sendRegistrationNotification({ name, email, phone }, event)
|
||||||
} catch (emailError) {
|
} catch (emailError) {
|
||||||
console.error('Registrierungs-Benachrichtigung fehlgeschlagen:', emailError)
|
console.error('Registrierungs-Benachrichtigung fehlgeschlagen:', emailError)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { readUsers, writeUsers } from '../../utils/auth.js'
|
import { readUsers, writeUsers } from '../../utils/auth.js'
|
||||||
import nodemailer from 'nodemailer'
|
import { createMailTransport } from '../../utils/mail-transport.js'
|
||||||
import crypto from 'crypto'
|
import crypto from 'crypto'
|
||||||
import { assertRateLimit, getClientIp, registerRateLimitFailure, registerRateLimitSuccess } from '../../utils/rate-limit.js'
|
import { assertRateLimit, getClientIp, registerRateLimitFailure, registerRateLimitSuccess } from '../../utils/rate-limit.js'
|
||||||
import { writeAuditLog } from '../../utils/audit-log.js'
|
import { writeAuditLog } from '../../utils/audit-log.js'
|
||||||
@@ -152,7 +152,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
}
|
}
|
||||||
await logStep('token_storage', 'completed', { userId: user.id })
|
await logStep('token_storage', 'completed', { userId: user.id })
|
||||||
|
|
||||||
const transporter = nodemailer.createTransport({
|
const transporter = createMailTransport(event, {
|
||||||
host: process.env.SMTP_HOST || 'smtp.gmail.com',
|
host: process.env.SMTP_HOST || 'smtp.gmail.com',
|
||||||
port: process.env.SMTP_PORT || 587,
|
port: process.env.SMTP_PORT || 587,
|
||||||
secure: process.env.SMTP_SECURE === 'true',
|
secure: process.env.SMTP_SECURE === 'true',
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import nodemailer from 'nodemailer'
|
import { createMailTransport } from '../../../../utils/mail-transport.js'
|
||||||
import { getUserFromToken, hasAnyRole } from '../../../../utils/auth.js'
|
import { getUserFromToken, hasAnyRole } from '../../../../utils/auth.js'
|
||||||
import { addContactReply, readContactRequests } from '../../../../utils/contact-requests.js'
|
import { addContactReply, readContactRequests } from '../../../../utils/contact-requests.js'
|
||||||
|
|
||||||
function createTransporter() {
|
function createTransporter(event) {
|
||||||
const smtpUser = process.env.SMTP_USER
|
const smtpUser = process.env.SMTP_USER
|
||||||
const smtpPass = process.env.SMTP_PASS || process.env.EMAIL_PASSWORD
|
const smtpPass = process.env.SMTP_PASS || process.env.EMAIL_PASSWORD
|
||||||
if (!smtpUser || !smtpPass) return null
|
if (!smtpUser || !smtpPass) return null
|
||||||
|
|
||||||
return nodemailer.createTransport({
|
return createMailTransport(event, {
|
||||||
host: process.env.SMTP_HOST || 'smtp.gmail.com',
|
host: process.env.SMTP_HOST || 'smtp.gmail.com',
|
||||||
port: Number(process.env.SMTP_PORT || 587),
|
port: Number(process.env.SMTP_PORT || 587),
|
||||||
secure: process.env.SMTP_SECURE === 'true',
|
secure: process.env.SMTP_SECURE === 'true',
|
||||||
@@ -43,7 +43,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
throw createError({ statusCode: 404, statusMessage: 'Anfrage nicht gefunden' })
|
throw createError({ statusCode: 404, statusMessage: 'Anfrage nicht gefunden' })
|
||||||
}
|
}
|
||||||
|
|
||||||
const transporter = createTransporter()
|
const transporter = createTransporter(event)
|
||||||
if (!transporter) {
|
if (!transporter) {
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 500,
|
statusCode: 500,
|
||||||
|
|||||||
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.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { getUserFromToken, readUsers, writeUsers, hasAnyRole, migrateUserRoles } from '../../../utils/auth.js'
|
import { getUserFromToken, readUsers, writeUsers, hasAnyRole, migrateUserRoles } from '../../../utils/auth.js'
|
||||||
import nodemailer from 'nodemailer'
|
import { createMailTransport } from '../../../utils/mail-transport.js'
|
||||||
import { writeAuditLog } from '../../../utils/audit-log.js'
|
import { writeAuditLog } from '../../../utils/audit-log.js'
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
@@ -58,7 +58,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
console.warn(`SMTP_USER=${smtpUser ? 'gesetzt' : 'FEHLT'}, SMTP_PASS=${smtpPass ? 'gesetzt' : 'FEHLT'}`)
|
console.warn(`SMTP_USER=${smtpUser ? 'gesetzt' : 'FEHLT'}, SMTP_PASS=${smtpPass ? 'gesetzt' : 'FEHLT'}`)
|
||||||
// Continue without sending email
|
// Continue without sending email
|
||||||
} else {
|
} else {
|
||||||
const transporter = nodemailer.createTransport({
|
const transporter = createMailTransport(event, {
|
||||||
host: process.env.SMTP_HOST || 'smtp.gmail.com',
|
host: process.env.SMTP_HOST || 'smtp.gmail.com',
|
||||||
port: process.env.SMTP_PORT || 587,
|
port: process.env.SMTP_PORT || 587,
|
||||||
secure: false,
|
secure: false,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import nodemailer from 'nodemailer'
|
import { createMailTransport } from '../utils/mail-transport.js'
|
||||||
import { promises as fs } from 'fs'
|
import { promises as fs } from 'fs'
|
||||||
import { createContactRequest } from '../utils/contact-requests.js'
|
import { createContactRequest } from '../utils/contact-requests.js'
|
||||||
import { readUsers, migrateUserRoles, isHiddenUser } from '../utils/auth.js'
|
import { readUsers, migrateUserRoles, isHiddenUser } from '../utils/auth.js'
|
||||||
@@ -97,12 +97,12 @@ async function collectRecipients(config) {
|
|||||||
throw new Error('Keine E-Mail-Empfänger in config.json konfiguriert.')
|
throw new Error('Keine E-Mail-Empfänger in config.json konfiguriert.')
|
||||||
}
|
}
|
||||||
|
|
||||||
function createTransporter() {
|
function createTransporter(event) {
|
||||||
const smtpUser = process.env.SMTP_USER
|
const smtpUser = process.env.SMTP_USER
|
||||||
const smtpPass = process.env.SMTP_PASS || process.env.EMAIL_PASSWORD
|
const smtpPass = process.env.SMTP_PASS || process.env.EMAIL_PASSWORD
|
||||||
if (!smtpUser || !smtpPass) return null
|
if (!smtpUser || !smtpPass) return null
|
||||||
|
|
||||||
return nodemailer.createTransport({
|
return createMailTransport(event, {
|
||||||
host: process.env.SMTP_HOST || 'smtp.gmail.com',
|
host: process.env.SMTP_HOST || 'smtp.gmail.com',
|
||||||
port: Number(process.env.SMTP_PORT || 587),
|
port: Number(process.env.SMTP_PORT || 587),
|
||||||
secure: process.env.SMTP_SECURE === 'true',
|
secure: process.env.SMTP_SECURE === 'true',
|
||||||
@@ -144,7 +144,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
|
|
||||||
const config = await loadConfig()
|
const config = await loadConfig()
|
||||||
const recipients = await collectRecipients(config)
|
const recipients = await collectRecipients(config)
|
||||||
const transporter = createTransporter()
|
const transporter = createTransporter(event)
|
||||||
|
|
||||||
if (!transporter) {
|
if (!transporter) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -626,7 +626,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
let emailResult
|
let emailResult
|
||||||
if (usedTemplate) {
|
if (usedTemplate) {
|
||||||
// E-Mail senden via zentralen Service (pass full path)
|
// E-Mail senden via zentralen Service (pass full path)
|
||||||
emailResult = await sendMembershipEmailUtil(data, finalPdfPath)
|
emailResult = await sendMembershipEmailUtil(data, finalPdfPath, event)
|
||||||
// Antragsdaten verschlüsselt speichern
|
// Antragsdaten verschlüsselt speichern
|
||||||
const encryptedData = JSON.stringify(data)
|
const encryptedData = JSON.stringify(data)
|
||||||
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
|
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
|
||||||
@@ -687,7 +687,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
await fs.copyFile(pdfPath, finalPdfPath)
|
await fs.copyFile(pdfPath, finalPdfPath)
|
||||||
|
|
||||||
// E-Mail senden via zentralen Service (pass full path)
|
// E-Mail senden via zentralen Service (pass full path)
|
||||||
emailResult = await sendMembershipEmailUtil(data, finalPdfPath)
|
emailResult = await sendMembershipEmailUtil(data, finalPdfPath, event)
|
||||||
|
|
||||||
// Antragsdaten verschlüsselt speichern
|
// Antragsdaten verschlüsselt speichern
|
||||||
const encryptedData = JSON.stringify(data)
|
const encryptedData = JSON.stringify(data)
|
||||||
@@ -720,7 +720,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
const fallbackFilename = await generateSimplePDF(data, filename, event)
|
const fallbackFilename = await generateSimplePDF(data, filename, event)
|
||||||
|
|
||||||
// E-Mail senden (Fallback)
|
// E-Mail senden (Fallback)
|
||||||
const emailResult = await sendMembershipEmailUtil(data, path.join(uploadsDir, `${filename}.txt`))
|
const emailResult = await sendMembershipEmailUtil(data, path.join(uploadsDir, `${filename}.txt`), event)
|
||||||
|
|
||||||
console.log('LaTeX nicht verfügbar, verwende Fallback-Lösung')
|
console.log('LaTeX nicht verfügbar, verwende Fallback-Lösung')
|
||||||
console.log('E-Mail würde gesendet werden an:', emailResult.recipients || [])
|
console.log('E-Mail würde gesendet werden an:', emailResult.recipients || [])
|
||||||
|
|||||||
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
|
return lookup
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function addClubRanks(rows) {
|
||||||
|
const addRankFor = (valueKey, rankKey) => {
|
||||||
|
rows
|
||||||
|
.filter((row) => row[valueKey] != null)
|
||||||
|
.sort((a, b) => b[valueKey] - a[valueKey] || String(a.playerName || '').localeCompare(String(b.playerName || ''), 'de'))
|
||||||
|
.forEach((row, index) => {
|
||||||
|
row[rankKey] = index + 1
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
addRankFor('currentQttr', 'clubQttrRank')
|
||||||
|
addRankFor('currentTtr', 'clubTtrRank')
|
||||||
|
|
||||||
|
return rows.sort((a, b) =>
|
||||||
|
(a.clubQttrRank ?? Number.POSITIVE_INFINITY) - (b.clubQttrRank ?? Number.POSITIVE_INFINITY)
|
||||||
|
|| (a.clubTtrRank ?? Number.POSITIVE_INFINITY) - (b.clubTtrRank ?? Number.POSITIVE_INFINITY)
|
||||||
|
|| String(a.playerName || '').localeCompare(String(b.playerName || ''), 'de')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '')
|
const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '')
|
||||||
if (!token || !verifyToken(token)) {
|
if (!token || !verifyToken(token)) {
|
||||||
@@ -73,16 +93,16 @@ export default defineEventHandler(async (event) => {
|
|||||||
].flatMap(entry => [entry?.name, `${entry?.firstName || ''} ${entry?.lastName || ''}`.trim()]).map(normalizeName).filter(Boolean))
|
].flatMap(entry => [entry?.name, `${entry?.firstName || ''} ${entry?.lastName || ''}`.trim()]).map(normalizeName).filter(Boolean))
|
||||||
const birthdateLookup = buildBirthdateLookup([...visibleManualMembers, ...visibleUsers])
|
const birthdateLookup = buildBirthdateLookup([...visibleManualMembers, ...visibleUsers])
|
||||||
|
|
||||||
|
const rankedRows = addClubRanks(Array.isArray(payload.rows) ? payload.rows.map(row => ({ ...row })) : [])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...payload,
|
...payload,
|
||||||
rows: Array.isArray(payload.rows)
|
rows: rankedRows
|
||||||
? payload.rows
|
|
||||||
.filter(row => !hiddenNames.has(normalizeName(row.playerName)))
|
.filter(row => !hiddenNames.has(normalizeName(row.playerName)))
|
||||||
.map((row) => ({
|
.map((row) => ({
|
||||||
...row,
|
...row,
|
||||||
birthdate: birthdateLookup.get(normalizeName(row.playerName)) || row.birthdate || ''
|
birthdate: birthdateLookup.get(normalizeName(row.playerName)) || row.birthdate || ''
|
||||||
}))
|
}))
|
||||||
: []
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error?.code === 'ENOENT') {
|
if (error?.code === 'ENOENT') {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import fs from 'fs/promises'
|
|||||||
import path from 'path'
|
import path from 'path'
|
||||||
import { getUserFromToken, hasAnyRole } from '../../../utils/auth.js'
|
import { getUserFromToken, hasAnyRole } from '../../../utils/auth.js'
|
||||||
import { getRecipientsByGroup, getNewsletterSubscribers, generateUnsubscribeToken } from '../../../utils/newsletter.js'
|
import { getRecipientsByGroup, getNewsletterSubscribers, generateUnsubscribeToken } from '../../../utils/newsletter.js'
|
||||||
import nodemailer from 'nodemailer'
|
import { createMailTransport } from '../../../utils/mail-transport.js'
|
||||||
|
|
||||||
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
|
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
|
||||||
// filename is always a hardcoded constant (e.g., 'newsletter.json'), never user input
|
// filename is always a hardcoded constant (e.g., 'newsletter.json'), never user input
|
||||||
@@ -178,7 +178,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const transporter = nodemailer.createTransport({
|
const transporter = createMailTransport(event, {
|
||||||
host: process.env.SMTP_HOST || 'smtp.gmail.com',
|
host: process.env.SMTP_HOST || 'smtp.gmail.com',
|
||||||
port: process.env.SMTP_PORT || 587,
|
port: process.env.SMTP_PORT || 587,
|
||||||
secure: false,
|
secure: false,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import path from 'path'
|
|||||||
import { getUserFromToken, hasAnyRole } from '../../../../../utils/auth.js'
|
import { getUserFromToken, hasAnyRole } from '../../../../../utils/auth.js'
|
||||||
import { randomUUID } from 'crypto'
|
import { randomUUID } from 'crypto'
|
||||||
import { getRecipientsByGroup, getNewsletterSubscribers, generateUnsubscribeToken } from '../../../../../utils/newsletter.js'
|
import { getRecipientsByGroup, getNewsletterSubscribers, generateUnsubscribeToken } from '../../../../../utils/newsletter.js'
|
||||||
import nodemailer from 'nodemailer'
|
import { createMailTransport } from '../../../../../utils/mail-transport.js'
|
||||||
|
|
||||||
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
|
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
|
||||||
// filename is always a hardcoded constant (e.g., 'newsletter-posts.json'), never user input
|
// filename is always a hardcoded constant (e.g., 'newsletter-posts.json'), never user input
|
||||||
@@ -222,7 +222,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const transporter = nodemailer.createTransport({
|
const transporter = createMailTransport(event, {
|
||||||
host: process.env.SMTP_HOST || 'smtp.gmail.com',
|
host: process.env.SMTP_HOST || 'smtp.gmail.com',
|
||||||
port: process.env.SMTP_PORT || 587,
|
port: process.env.SMTP_PORT || 587,
|
||||||
secure: false,
|
secure: false,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { getUserFromToken, hasAnyRole } from '../../../../../utils/auth.js'
|
import { getUserFromToken, hasAnyRole } from '../../../../../utils/auth.js'
|
||||||
import { readSubscribers, writeSubscribers } from '../../../../../utils/newsletter.js'
|
import { readSubscribers, writeSubscribers } from '../../../../../utils/newsletter.js'
|
||||||
import { randomUUID } from 'crypto'
|
import { randomUUID } from 'crypto'
|
||||||
import nodemailer from 'nodemailer'
|
import { createMailTransport } from '../../../../../utils/mail-transport.js'
|
||||||
import crypto from 'crypto'
|
import crypto from 'crypto'
|
||||||
import fs from 'fs/promises'
|
import fs from 'fs/promises'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
@@ -98,7 +98,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
// Bestätigungsmail erneut senden mit individueller Nachricht
|
// Bestätigungsmail erneut senden mit individueller Nachricht
|
||||||
await sendConfirmationEmail(existing.email, existing.name || name, existing.confirmationToken, group.name, customMessage, user.name)
|
await sendConfirmationEmail(event, existing.email, existing.name || name, existing.confirmationToken, group.name, customMessage, user.name)
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Eine Bestätigungsmail wurde an die E-Mail-Adresse gesendet'
|
message: 'Eine Bestätigungsmail wurde an die E-Mail-Adresse gesendet'
|
||||||
@@ -124,7 +124,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
// Bestätigungsmail erneut senden mit individueller Nachricht
|
// Bestätigungsmail erneut senden mit individueller Nachricht
|
||||||
await sendConfirmationEmail(existingEmail.email, existingEmail.name || name, existingEmail.confirmationToken, group.name, customMessage, user.name)
|
await sendConfirmationEmail(event, existingEmail.email, existingEmail.name || name, existingEmail.confirmationToken, group.name, customMessage, user.name)
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Eine Bestätigungsmail wurde an die E-Mail-Adresse gesendet'
|
message: 'Eine Bestätigungsmail wurde an die E-Mail-Adresse gesendet'
|
||||||
@@ -150,7 +150,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Bestätigungsmail senden mit individueller Nachricht
|
// Bestätigungsmail senden mit individueller Nachricht
|
||||||
await sendConfirmationEmail(existingEmail.email, existingEmail.name, existingEmail.confirmationToken, group.name, customMessage, user.name)
|
await sendConfirmationEmail(event, existingEmail.email, existingEmail.name, existingEmail.confirmationToken, group.name, customMessage, user.name)
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Eine Bestätigungsmail wurde an die E-Mail-Adresse gesendet'
|
message: 'Eine Bestätigungsmail wurde an die E-Mail-Adresse gesendet'
|
||||||
@@ -178,7 +178,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
await writeSubscribers(subscribers)
|
await writeSubscribers(subscribers)
|
||||||
|
|
||||||
// Bestätigungsmail senden mit individueller Nachricht
|
// Bestätigungsmail senden mit individueller Nachricht
|
||||||
await sendConfirmationEmail(email, name, confirmationToken, group.name, customMessage, user.name)
|
await sendConfirmationEmail(event, email, name, confirmationToken, group.name, customMessage, user.name)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
@@ -196,7 +196,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
async function sendConfirmationEmail(email, name, token, groupName, customMessage = null, inviterName = null) {
|
async function sendConfirmationEmail(event, email, name, token, groupName, customMessage = null, inviterName = null) {
|
||||||
const smtpUser = process.env.SMTP_USER
|
const smtpUser = process.env.SMTP_USER
|
||||||
const smtpPass = process.env.SMTP_PASS
|
const smtpPass = process.env.SMTP_PASS
|
||||||
|
|
||||||
@@ -208,7 +208,7 @@ async function sendConfirmationEmail(email, name, token, groupName, customMessag
|
|||||||
const baseUrl = process.env.NUXT_PUBLIC_BASE_URL || 'http://localhost:3100'
|
const baseUrl = process.env.NUXT_PUBLIC_BASE_URL || 'http://localhost:3100'
|
||||||
const confirmationUrl = `${baseUrl}/newsletter/confirm?token=${token}`
|
const confirmationUrl = `${baseUrl}/newsletter/confirm?token=${token}`
|
||||||
|
|
||||||
const transporter = nodemailer.createTransport({
|
const transporter = createMailTransport(event, {
|
||||||
host: process.env.SMTP_HOST || 'smtp.gmail.com',
|
host: process.env.SMTP_HOST || 'smtp.gmail.com',
|
||||||
port: process.env.SMTP_PORT || 587,
|
port: process.env.SMTP_PORT || 587,
|
||||||
secure: false,
|
secure: false,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { readSubscribers, writeSubscribers } from '../../utils/newsletter.js'
|
import { readSubscribers, writeSubscribers } from '../../utils/newsletter.js'
|
||||||
import { randomUUID } from 'crypto'
|
import { randomUUID } from 'crypto'
|
||||||
import nodemailer from 'nodemailer'
|
import { createMailTransport } from '../../utils/mail-transport.js'
|
||||||
import crypto from 'crypto'
|
import crypto from 'crypto'
|
||||||
import fs from 'fs/promises'
|
import fs from 'fs/promises'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
@@ -129,7 +129,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
// Bestätigungsmail erneut senden
|
// Bestätigungsmail erneut senden
|
||||||
await sendConfirmationEmail(existing.email, existing.name || name, existing.confirmationToken, group.name)
|
await sendConfirmationEmail(event, existing.email, existing.name || name, existing.confirmationToken, group.name)
|
||||||
registerRateLimitSuccess(event, { name: 'newsletter:subscribe:email', keyParts: [emailKey] })
|
registerRateLimitSuccess(event, { name: 'newsletter:subscribe:email', keyParts: [emailKey] })
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
@@ -158,7 +158,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
// Bestätigungsmail erneut senden
|
// Bestätigungsmail erneut senden
|
||||||
await sendConfirmationEmail(existingEmail.email, existingEmail.name || name, existingEmail.confirmationToken, group.name)
|
await sendConfirmationEmail(event, existingEmail.email, existingEmail.name || name, existingEmail.confirmationToken, group.name)
|
||||||
registerRateLimitSuccess(event, { name: 'newsletter:subscribe:email', keyParts: [emailKey] })
|
registerRateLimitSuccess(event, { name: 'newsletter:subscribe:email', keyParts: [emailKey] })
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
@@ -186,7 +186,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Bestätigungsmail senden
|
// Bestätigungsmail senden
|
||||||
await sendConfirmationEmail(existingEmail.email, existingEmail.name, existingEmail.confirmationToken, group.name)
|
await sendConfirmationEmail(event, existingEmail.email, existingEmail.name, existingEmail.confirmationToken, group.name)
|
||||||
registerRateLimitSuccess(event, { name: 'newsletter:subscribe:email', keyParts: [emailKey] })
|
registerRateLimitSuccess(event, { name: 'newsletter:subscribe:email', keyParts: [emailKey] })
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
@@ -215,7 +215,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
await writeSubscribers(subscribers)
|
await writeSubscribers(subscribers)
|
||||||
|
|
||||||
// Bestätigungsmail senden
|
// Bestätigungsmail senden
|
||||||
await sendConfirmationEmail(email, name, confirmationToken, group.name)
|
await sendConfirmationEmail(event, email, name, confirmationToken, group.name)
|
||||||
|
|
||||||
registerRateLimitSuccess(event, { name: 'newsletter:subscribe:email', keyParts: [emailKey] })
|
registerRateLimitSuccess(event, { name: 'newsletter:subscribe:email', keyParts: [emailKey] })
|
||||||
return {
|
return {
|
||||||
@@ -234,7 +234,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
async function sendConfirmationEmail(email, name, token, groupName) {
|
async function sendConfirmationEmail(event, email, name, token, groupName) {
|
||||||
const smtpUser = process.env.SMTP_USER
|
const smtpUser = process.env.SMTP_USER
|
||||||
const smtpPass = process.env.SMTP_PASS
|
const smtpPass = process.env.SMTP_PASS
|
||||||
|
|
||||||
@@ -246,7 +246,7 @@ async function sendConfirmationEmail(email, name, token, groupName) {
|
|||||||
const baseUrl = process.env.NUXT_PUBLIC_BASE_URL || 'http://localhost:3100'
|
const baseUrl = process.env.NUXT_PUBLIC_BASE_URL || 'http://localhost:3100'
|
||||||
const confirmationUrl = `${baseUrl}/newsletter/confirm?token=${token}`
|
const confirmationUrl = `${baseUrl}/newsletter/confirm?token=${token}`
|
||||||
|
|
||||||
const transporter = nodemailer.createTransport({
|
const transporter = createMailTransport(event, {
|
||||||
host: process.env.SMTP_HOST || 'smtp.gmail.com',
|
host: process.env.SMTP_HOST || 'smtp.gmail.com',
|
||||||
port: process.env.SMTP_PORT || 587,
|
port: process.env.SMTP_PORT || 587,
|
||||||
secure: false,
|
secure: false,
|
||||||
|
|||||||
@@ -22,7 +22,8 @@ export default defineEventHandler(async (event) => {
|
|||||||
upsertPushToken(users[userIndex], {
|
upsertPushToken(users[userIndex], {
|
||||||
token: body.token,
|
token: body.token,
|
||||||
platform: body.platform || 'android',
|
platform: body.platform || 'android',
|
||||||
appVersion: body.appVersion || null
|
appVersion: body.appVersion || null,
|
||||||
|
installationId: body.installationId || null
|
||||||
})
|
})
|
||||||
await writeUsers(users)
|
await writeUsers(users)
|
||||||
return { success: true, message: 'Push-Token gespeichert.' }
|
return { success: true, message: 'Push-Token gespeichert.' }
|
||||||
|
|||||||
@@ -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) => {
|
export default defineEventHandler(async (event) => {
|
||||||
try {
|
try {
|
||||||
const cwd = process.cwd()
|
const cwd = process.cwd()
|
||||||
@@ -50,7 +118,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
setHeader(event, 'Pragma', 'no-cache')
|
setHeader(event, 'Pragma', 'no-cache')
|
||||||
setHeader(event, 'Expires', '0')
|
setHeader(event, 'Expires', '0')
|
||||||
|
|
||||||
return csv
|
return sortChampionshipCsv(csv)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error?.statusCode) throw error
|
if (error?.statusCode) throw error
|
||||||
console.error('Fehler beim Laden der Vereinsmeisterschaften:', 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))
|
||||||
|
})
|
||||||
6
server/plugins/require-safe-node.js
Normal file
6
server/plugins/require-safe-node.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import requireSafeNode from '../../scripts/require-safe-node.cjs';
|
||||||
|
|
||||||
|
// An explicit call keeps the guard in Nitro's production bundle.
|
||||||
|
requireSafeNode();
|
||||||
|
|
||||||
|
export default defineNitroPlugin(() => {});
|
||||||
@@ -141,7 +141,7 @@ function createQttrJob() {
|
|||||||
return {
|
return {
|
||||||
run: async (reason) => {
|
run: async (reason) => {
|
||||||
const qttr = await importQttrValues()
|
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,
|
outputFile: qttr.outputFile,
|
||||||
tableCount: qttr.tableCount
|
tableCount: qttr.tableCount
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
* Clean Code: Single Responsibility Principle
|
* Clean Code: Single Responsibility Principle
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import nodemailer from 'nodemailer'
|
import { createMailTransport, shouldSuppressMail } from './mail-transport.js'
|
||||||
import fs from 'fs/promises'
|
import fs from 'fs/promises'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import { getServerDataPath } from './paths.js'
|
import { getServerDataPath } from './paths.js'
|
||||||
@@ -103,7 +103,7 @@ async function getEmailRecipients(data, config) {
|
|||||||
* Creates email transporter
|
* Creates email transporter
|
||||||
* @returns {Object} Nodemailer transporter
|
* @returns {Object} Nodemailer transporter
|
||||||
*/
|
*/
|
||||||
function createTransporter() {
|
function createTransporter(event) {
|
||||||
const smtpUser = process.env.SMTP_USER
|
const smtpUser = process.env.SMTP_USER
|
||||||
const smtpPass = process.env.SMTP_PASS
|
const smtpPass = process.env.SMTP_PASS
|
||||||
|
|
||||||
@@ -114,7 +114,7 @@ function createTransporter() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nodemailer.createTransport({
|
return createMailTransport(event, {
|
||||||
host: process.env.SMTP_HOST || 'localhost',
|
host: process.env.SMTP_HOST || 'localhost',
|
||||||
port: parseInt(process.env.SMTP_PORT) || 587,
|
port: parseInt(process.env.SMTP_PORT) || 587,
|
||||||
secure: process.env.SMTP_SECURE === 'true',
|
secure: process.env.SMTP_SECURE === 'true',
|
||||||
@@ -131,13 +131,14 @@ function createTransporter() {
|
|||||||
* @param {string} pdfPath - Path to generated PDF
|
* @param {string} pdfPath - Path to generated PDF
|
||||||
* @returns {Promise<Object>} Email result
|
* @returns {Promise<Object>} Email result
|
||||||
*/
|
*/
|
||||||
export async function sendMembershipEmail(data, pdfPath) {
|
export async function sendMembershipEmail(data, pdfPath, event) {
|
||||||
try {
|
try {
|
||||||
|
if (await shouldSuppressMail(event, [data.email])) return { success: true, suppressed: true, recipients: [], message: 'Testmodus: E-Mail-Versand unterdrückt.' }
|
||||||
const config = await loadConfig()
|
const config = await loadConfig()
|
||||||
const recipients = await getEmailRecipients(data, config)
|
const recipients = await getEmailRecipients(data, config)
|
||||||
|
|
||||||
// Create transporter
|
// Create transporter
|
||||||
const transporter = createTransporter()
|
const transporter = createTransporter(event)
|
||||||
|
|
||||||
// Email content
|
// Email content
|
||||||
const subject = `Neuer Mitgliedschaftsantrag - ${data.vorname} ${data.nachname}`
|
const subject = `Neuer Mitgliedschaftsantrag - ${data.vorname} ${data.nachname}`
|
||||||
@@ -187,13 +188,14 @@ Das ausgefüllte Formular ist als Anhang verfügbar.`
|
|||||||
* Sends a simple registration notification to Vorstand/admin and a confirmation to user.
|
* Sends a simple registration notification to Vorstand/admin and a confirmation to user.
|
||||||
* @param {Object} data - { name, email, phone }
|
* @param {Object} data - { name, email, phone }
|
||||||
*/
|
*/
|
||||||
export async function sendRegistrationNotification(data) {
|
export async function sendRegistrationNotification(data, event) {
|
||||||
try {
|
try {
|
||||||
|
if (await shouldSuppressMail(event, [data.email])) return { success: true, suppressed: true, recipients: [], message: 'Testmodus: E-Mail-Versand unterdrückt.' }
|
||||||
const config = await loadConfig()
|
const config = await loadConfig()
|
||||||
const recipients = await getEmailRecipients(data, config)
|
const recipients = await getEmailRecipients(data, config)
|
||||||
|
|
||||||
// Create transporter
|
// Create transporter
|
||||||
const transporter = createTransporter()
|
const transporter = createTransporter(event)
|
||||||
|
|
||||||
// Notify Vorstand/admin
|
// Notify Vorstand/admin
|
||||||
const adminSubject = 'Neue Registrierung - Harheimer TC'
|
const adminSubject = 'Neue Registrierung - Harheimer TC'
|
||||||
|
|||||||
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() }
|
||||||
|
}
|
||||||
50
server/utils/mail-transport.js
Normal file
50
server/utils/mail-transport.js
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import nodemailer from 'nodemailer'
|
||||||
|
import * as auth from './auth.js'
|
||||||
|
|
||||||
|
const normalize = value => String(value || '').trim().toLowerCase()
|
||||||
|
const enabled = value => ['1', 'true', 'yes', 'on'].includes(normalize(value))
|
||||||
|
|
||||||
|
export function isReviewUser(user) {
|
||||||
|
if (!user) return false
|
||||||
|
const email = normalize(user.email)
|
||||||
|
return normalize(user.accountType) === 'playstore_review' ||
|
||||||
|
Boolean(email && email === normalize(process.env.PLAYSTORE_REVIEW_EMAIL))
|
||||||
|
}
|
||||||
|
|
||||||
|
function addresses(values) {
|
||||||
|
return values.flat(Infinity).filter(Boolean).flatMap(value => {
|
||||||
|
if (typeof value === 'object') return [normalize(value.address)]
|
||||||
|
return String(value).split(',').map(part => normalize(part.match(/<([^>]+)>/)?.[1] || part))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function shouldSuppressMail(event, emails = []) {
|
||||||
|
if (enabled(process.env.MAIL_DELIVERY_DISABLED)) return true
|
||||||
|
|
||||||
|
// Use the authenticated account, never a client-provided demo flag.
|
||||||
|
if (event) {
|
||||||
|
const token = auth.getTokenFromEvent(event)
|
||||||
|
if (token && isReviewUser(await auth.getUserFromToken(token))) return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const targets = addresses(emails)
|
||||||
|
if (!targets.length) return false
|
||||||
|
if (targets.includes(normalize(process.env.PLAYSTORE_REVIEW_EMAIL))) return true
|
||||||
|
const users = await auth.readUsers()
|
||||||
|
return users.some(user => isReviewUser(user) && targets.includes(normalize(user.email)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// All SMTP entry points go through this gate, including newsletter batches.
|
||||||
|
// Create the real transport lazily so suppressed mail cannot open a connection.
|
||||||
|
export function createMailTransport(event, options) {
|
||||||
|
let transport
|
||||||
|
return {
|
||||||
|
async sendMail(mail) {
|
||||||
|
if (await shouldSuppressMail(event, [mail.to, mail.cc, mail.bcc, mail.replyTo])) {
|
||||||
|
return { suppressed: true, messageId: null, accepted: [], rejected: [] }
|
||||||
|
}
|
||||||
|
transport ||= nodemailer.createTransport(options)
|
||||||
|
return transport.sendMail(mail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
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,
|
birthdays: false,
|
||||||
newContactRequest: false,
|
newContactRequest: false,
|
||||||
newUserRegistration: false,
|
newUserRegistration: false,
|
||||||
|
ownTtrChanges: false,
|
||||||
|
liveTickerStarts: false,
|
||||||
|
liveTickerScoreChanges: false,
|
||||||
|
liveTickerFinished: false,
|
||||||
selectedTeamSlugs: [],
|
selectedTeamSlugs: [],
|
||||||
selectedTeamSeason: null,
|
selectedTeamSeason: null,
|
||||||
notificationTime: '09:00'
|
notificationTime: '09:00'
|
||||||
@@ -41,6 +45,10 @@ export function sanitizeNotificationSettings(input = {}) {
|
|||||||
birthdays: coerceBoolean(input.birthdays),
|
birthdays: coerceBoolean(input.birthdays),
|
||||||
newContactRequest: coerceBoolean(input.newContactRequest),
|
newContactRequest: coerceBoolean(input.newContactRequest),
|
||||||
newUserRegistration: coerceBoolean(input.newUserRegistration),
|
newUserRegistration: coerceBoolean(input.newUserRegistration),
|
||||||
|
ownTtrChanges: coerceBoolean(input.ownTtrChanges),
|
||||||
|
liveTickerStarts: coerceBoolean(input.liveTickerStarts),
|
||||||
|
liveTickerScoreChanges: coerceBoolean(input.liveTickerScoreChanges),
|
||||||
|
liveTickerFinished: coerceBoolean(input.liveTickerFinished),
|
||||||
selectedTeamSlugs: [...new Set(selectedTeamSlugs)],
|
selectedTeamSlugs: [...new Set(selectedTeamSlugs)],
|
||||||
selectedTeamSeason,
|
selectedTeamSeason,
|
||||||
notificationTime
|
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()
|
const normalizedToken = String(token || '').trim()
|
||||||
if (!normalizedToken) return user
|
if (!normalizedToken) return user
|
||||||
|
const normalizedInstallationId = String(installationId || '').trim().slice(0, 200) || null
|
||||||
const now = new Date().toISOString()
|
const now = new Date().toISOString()
|
||||||
const tokens = Array.isArray(user.pushTokens) ? user.pushTokens : []
|
const tokens = Array.isArray(user.pushTokens) ? user.pushTokens : []
|
||||||
const next = tokens.filter(entry => entry?.token !== normalizedToken)
|
// A Firebase token can rotate for the same app installation. Retain tokens
|
||||||
|
// from other devices, but replace the previous token of this installation.
|
||||||
|
const next = tokens.filter(entry => entry?.token !== normalizedToken &&
|
||||||
|
(!normalizedInstallationId || entry?.installationId !== normalizedInstallationId))
|
||||||
next.push({
|
next.push({
|
||||||
token: normalizedToken,
|
token: normalizedToken,
|
||||||
platform: String(platform || 'android').slice(0, 30),
|
platform: String(platform || 'android').slice(0, 30),
|
||||||
appVersion: appVersion ? String(appVersion).slice(0, 80) : null,
|
appVersion: appVersion ? String(appVersion).slice(0, 80) : null,
|
||||||
|
installationId: normalizedInstallationId,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
createdAt: tokens.find(entry => entry?.token === normalizedToken)?.createdAt || now
|
createdAt: tokens.find(entry => entry?.token === normalizedToken)?.createdAt || now
|
||||||
})
|
})
|
||||||
@@ -102,6 +107,11 @@ export function upsertPushToken(user, { token, platform = 'android', appVersion
|
|||||||
return user
|
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 = {} }) {
|
async function sendFcmMessage({ serviceAccount, accessToken, token, data = {} }) {
|
||||||
const projectId = projectIdFromServiceAccount(serviceAccount)
|
const projectId = projectIdFromServiceAccount(serviceAccount)
|
||||||
if (!projectId) throw new Error('FCM project_id fehlt.')
|
if (!projectId) throw new Error('FCM project_id fehlt.')
|
||||||
@@ -150,6 +160,26 @@ function isVorstandUser(user) {
|
|||||||
return roles.includes('admin') || roles.includes('vorstand')
|
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' }) {
|
export async function sendPushToUsers({ title, body, data = {}, predicate, bodyForUser, dataForUser, failureLabel = 'FCM-Push' }) {
|
||||||
const serviceAccount = await readServiceAccount()
|
const serviceAccount = await readServiceAccount()
|
||||||
if (serviceAccount == null) {
|
if (serviceAccount == null) {
|
||||||
@@ -275,3 +305,112 @@ export async function sendNewUserRegistrationPush(registration) {
|
|||||||
failureLabel: 'FCM Registrierungs-Push'
|
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 { promises as fs } from 'fs'
|
||||||
import { getServerDataPath } from './paths.js'
|
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 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')
|
const OUTPUT_FILE = getServerDataPath('qttr-values.json')
|
||||||
@@ -59,11 +62,31 @@ function toNumberOrNull(value) {
|
|||||||
|
|
||||||
function normalizeGender(value) {
|
function normalizeGender(value) {
|
||||||
const normalized = String(value || '').trim().toLowerCase()
|
const normalized = String(value || '').trim().toLowerCase()
|
||||||
if (normalized === 'm' || normalized === 'männlich') return 'männlich'
|
if (['m', 'männlich', 'maennlich', 'male', 'man'].includes(normalized)) return 'männlich'
|
||||||
if (normalized === 'w' || normalized === 'weiblich') return 'weiblich'
|
if (['w', 'weiblich', 'female', 'woman', 'f'].includes(normalized)) return 'weiblich'
|
||||||
return normalized || null
|
return normalized || null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function addClubRanks(rows) {
|
||||||
|
const addRankFor = (valueKey, rankKey) => {
|
||||||
|
rows
|
||||||
|
.filter((row) => row[valueKey] != null)
|
||||||
|
.sort((a, b) => b[valueKey] - a[valueKey] || String(a.playerName || '').localeCompare(String(b.playerName || ''), 'de'))
|
||||||
|
.forEach((row, index) => {
|
||||||
|
row[rankKey] = index + 1
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
addRankFor('currentQttr', 'clubQttrRank')
|
||||||
|
addRankFor('currentTtr', 'clubTtrRank')
|
||||||
|
|
||||||
|
return rows.sort((a, b) =>
|
||||||
|
(a.clubQttrRank ?? Number.POSITIVE_INFINITY) - (b.clubQttrRank ?? Number.POSITIVE_INFINITY)
|
||||||
|
|| (a.clubTtrRank ?? Number.POSITIVE_INFINITY) - (b.clubTtrRank ?? Number.POSITIVE_INFINITY)
|
||||||
|
|| String(a.playerName || '').localeCompare(String(b.playerName || ''), 'de')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function extractTableBlocks(html) {
|
function extractTableBlocks(html) {
|
||||||
return [...String(html || '').matchAll(/<table\b[^>]*>[\s\S]*?<\/table>/gi)].map((match) => match[0])
|
return [...String(html || '').matchAll(/<table\b[^>]*>[\s\S]*?<\/table>/gi)].map((match) => match[0])
|
||||||
}
|
}
|
||||||
@@ -160,6 +183,9 @@ function deriveQttrFields(headers, cells) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function importQttrValues(options = {}) {
|
export async function importQttrValues(options = {}) {
|
||||||
|
const connection = options.connection ?? await readMyTischtennisConnection()
|
||||||
|
if (connection) return importAuthenticatedTtrValues(connection)
|
||||||
|
|
||||||
const url = options.url || QTTR_URL
|
const url = options.url || QTTR_URL
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
headers: {
|
headers: {
|
||||||
@@ -184,7 +210,7 @@ export async function importQttrValues(options = {}) {
|
|||||||
throw new Error('QTTR-Tabelle ist leer oder unvollständig')
|
throw new Error('QTTR-Tabelle ist leer oder unvollständig')
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsedRows = rows.map((cells) => deriveQttrFields(headers, cells))
|
const parsedRows = addClubRanks(rows.map((cells) => deriveQttrFields(headers, cells)))
|
||||||
const payload = {
|
const payload = {
|
||||||
format: 'harheimertc.qttr.v1',
|
format: 'harheimertc.qttr.v1',
|
||||||
importedAt: new Date().toISOString(),
|
importedAt: new Date().toISOString(),
|
||||||
@@ -208,3 +234,78 @@ export async function importQttrValues(options = {}) {
|
|||||||
...payload
|
...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',
|
'Staffel',
|
||||||
'Runde',
|
'Runde',
|
||||||
'BegegnungNr',
|
'BegegnungNr',
|
||||||
|
'MeetingId',
|
||||||
|
'HasNuLivePushData',
|
||||||
'HalleNr',
|
'HalleNr',
|
||||||
'HalleName',
|
'HalleName',
|
||||||
'HalleStrasse',
|
'HalleStrasse',
|
||||||
@@ -242,6 +244,8 @@ export function convertImportedSpielplanToJson(imported) {
|
|||||||
Staffel: match.leagueShortName || '',
|
Staffel: match.leagueShortName || '',
|
||||||
Runde: match.roundName || '',
|
Runde: match.roundName || '',
|
||||||
BegegnungNr: match.meetingNumber || match.meetingId || '',
|
BegegnungNr: match.meetingNumber || match.meetingId || '',
|
||||||
|
MeetingId: match.meetingId || '',
|
||||||
|
HasNuLivePushData: match.hasNuLivePushData === true ? 'true' : 'false',
|
||||||
HalleNr: match.hallNumber || '',
|
HalleNr: match.hallNumber || '',
|
||||||
HalleName: match.location?.label || '',
|
HalleName: match.location?.label || '',
|
||||||
HalleStrasse: match.location?.street || '',
|
HalleStrasse: match.location?.street || '',
|
||||||
|
|||||||
@@ -111,6 +111,7 @@ function normalizeMatch(day, match) {
|
|||||||
formattedDay: match.formattedDay ?? null,
|
formattedDay: match.formattedDay ?? null,
|
||||||
formattedTime: match.formattedTime ?? null,
|
formattedTime: match.formattedTime ?? null,
|
||||||
state: match.state ?? null,
|
state: match.state ?? null,
|
||||||
|
hasNuLivePushData: match.has_nu_live_push_data === true,
|
||||||
meetingId: match.meeting_id ?? null,
|
meetingId: match.meeting_id ?? null,
|
||||||
meetingNumber: match.meeting_number ?? null,
|
meetingNumber: match.meeting_number ?? null,
|
||||||
leagueId: match.league_id ?? null,
|
leagueId: match.league_id ?? null,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import crypto from 'crypto'
|
|||||||
|
|
||||||
vi.mock('../server/utils/auth.js', () => {
|
vi.mock('../server/utils/auth.js', () => {
|
||||||
return {
|
return {
|
||||||
|
getTokenFromEvent: vi.fn(() => undefined),
|
||||||
readUsers: vi.fn(),
|
readUsers: vi.fn(),
|
||||||
writeUsers: vi.fn(),
|
writeUsers: vi.fn(),
|
||||||
verifyPassword: vi.fn(),
|
verifyPassword: vi.fn(),
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|||||||
import { createEvent, mockSuccessReadBody } from './setup'
|
import { createEvent, mockSuccessReadBody } from './setup'
|
||||||
|
|
||||||
vi.mock('../server/utils/auth.js', () => ({
|
vi.mock('../server/utils/auth.js', () => ({
|
||||||
|
getTokenFromEvent: vi.fn(() => undefined),
|
||||||
getUserFromToken: vi.fn(),
|
getUserFromToken: vi.fn(),
|
||||||
readUsers: vi.fn(),
|
readUsers: vi.fn(),
|
||||||
writeUsers: vi.fn(),
|
writeUsers: vi.fn(),
|
||||||
|
|||||||
@@ -175,6 +175,9 @@ describe('Config & Profil Endpoints', () => {
|
|||||||
id: '1',
|
id: '1',
|
||||||
notificationSettings: {
|
notificationSettings: {
|
||||||
eventsToday: true,
|
eventsToday: true,
|
||||||
|
liveTickerStarts: true,
|
||||||
|
liveTickerScoreChanges: true,
|
||||||
|
liveTickerFinished: true,
|
||||||
selectedTeamSlugs: ['herren-1', 'herren-1', ''],
|
selectedTeamSlugs: ['herren-1', 'herren-1', ''],
|
||||||
selectedTeamSeason: '2025/2026',
|
selectedTeamSeason: '2025/2026',
|
||||||
notificationTime: '07:30'
|
notificationTime: '07:30'
|
||||||
@@ -185,6 +188,9 @@ describe('Config & Profil Endpoints', () => {
|
|||||||
|
|
||||||
expect(result.success).toBe(true)
|
expect(result.success).toBe(true)
|
||||||
expect(result.settings.eventsToday).toBe(true)
|
expect(result.settings.eventsToday).toBe(true)
|
||||||
|
expect(result.settings.liveTickerStarts).toBe(true)
|
||||||
|
expect(result.settings.liveTickerScoreChanges).toBe(true)
|
||||||
|
expect(result.settings.liveTickerFinished).toBe(true)
|
||||||
expect(result.settings.newEvents).toBe(false)
|
expect(result.settings.newEvents).toBe(false)
|
||||||
expect(result.settings.selectedTeamSlugs).toEqual(['herren-1'])
|
expect(result.settings.selectedTeamSlugs).toEqual(['herren-1'])
|
||||||
expect(result.settings.notificationTime).toBe('07:30')
|
expect(result.settings.notificationTime).toBe('07:30')
|
||||||
@@ -202,6 +208,9 @@ describe('Config & Profil Endpoints', () => {
|
|||||||
const event = createEvent({ headers: { authorization: 'Bearer android-token' } })
|
const event = createEvent({ headers: { authorization: 'Bearer android-token' } })
|
||||||
mockSuccessReadBody({
|
mockSuccessReadBody({
|
||||||
newEvents: true,
|
newEvents: true,
|
||||||
|
liveTickerStarts: true,
|
||||||
|
liveTickerScoreChanges: true,
|
||||||
|
liveTickerFinished: true,
|
||||||
eventsToday: 'true',
|
eventsToday: 'true',
|
||||||
birthdays: true,
|
birthdays: true,
|
||||||
selectedTeamSlugs: ['herren-1', 'herren-1', ' jugend '],
|
selectedTeamSlugs: ['herren-1', 'herren-1', ' jugend '],
|
||||||
@@ -218,6 +227,9 @@ describe('Config & Profil Endpoints', () => {
|
|||||||
|
|
||||||
expect(result.success).toBe(true)
|
expect(result.success).toBe(true)
|
||||||
expect(result.settings.newEvents).toBe(true)
|
expect(result.settings.newEvents).toBe(true)
|
||||||
|
expect(result.settings.liveTickerStarts).toBe(true)
|
||||||
|
expect(result.settings.liveTickerScoreChanges).toBe(true)
|
||||||
|
expect(result.settings.liveTickerFinished).toBe(true)
|
||||||
expect(result.settings.eventsToday).toBe(false)
|
expect(result.settings.eventsToday).toBe(false)
|
||||||
expect(result.settings.birthdays).toBe(true)
|
expect(result.settings.birthdays).toBe(true)
|
||||||
expect(result.settings.selectedTeamSlugs).toEqual(['herren-1', 'jugend'])
|
expect(result.settings.selectedTeamSlugs).toEqual(['herren-1', 'jugend'])
|
||||||
@@ -227,6 +239,9 @@ describe('Config & Profil Endpoints', () => {
|
|||||||
id: '1',
|
id: '1',
|
||||||
notificationSettings: expect.objectContaining({
|
notificationSettings: expect.objectContaining({
|
||||||
newEvents: true,
|
newEvents: true,
|
||||||
|
liveTickerStarts: true,
|
||||||
|
liveTickerScoreChanges: true,
|
||||||
|
liveTickerFinished: true,
|
||||||
birthdays: true,
|
birthdays: true,
|
||||||
selectedTeamSeason: '2026/2027'
|
selectedTeamSeason: '2026/2027'
|
||||||
})
|
})
|
||||||
@@ -247,7 +262,7 @@ describe('Config & Profil Endpoints', () => {
|
|||||||
|
|
||||||
it('speichert Android-Push-Token am Benutzer', async () => {
|
it('speichert Android-Push-Token am Benutzer', async () => {
|
||||||
const event = createEvent({ headers: { authorization: 'Bearer android-token' } })
|
const event = createEvent({ headers: { authorization: 'Bearer android-token' } })
|
||||||
mockSuccessReadBody({ token: 'fcm-token', platform: 'android', appVersion: '1.0+1' })
|
mockSuccessReadBody({ token: 'fcm-token', platform: 'android', appVersion: '1.0+1', installationId: 'firebase-installation-id' })
|
||||||
const users = [{ id: '1', email: 'max@test.de', roles: ['mitglied'] }]
|
const users = [{ id: '1', email: 'max@test.de', roles: ['mitglied'] }]
|
||||||
authUtils.verifyToken.mockReturnValue({ id: '1' })
|
authUtils.verifyToken.mockReturnValue({ id: '1' })
|
||||||
authUtils.getUserFromToken.mockResolvedValue(users[0])
|
authUtils.getUserFromToken.mockResolvedValue(users[0])
|
||||||
@@ -260,7 +275,7 @@ describe('Config & Profil Endpoints', () => {
|
|||||||
expect(authUtils.writeUsers).toHaveBeenCalledWith([
|
expect(authUtils.writeUsers).toHaveBeenCalledWith([
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
id: '1',
|
id: '1',
|
||||||
pushTokens: [expect.objectContaining({ token: 'fcm-token', platform: 'android', appVersion: '1.0+1' })]
|
pushTokens: [expect.objectContaining({ token: 'fcm-token', platform: 'android', appVersion: '1.0+1', installationId: 'firebase-installation-id' })]
|
||||||
})
|
})
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|||||||
58
tests/deploy-runtime.spec.ts
Normal file
58
tests/deploy-runtime.spec.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { spawnSync } from 'node:child_process'
|
||||||
|
import path from 'node:path'
|
||||||
|
|
||||||
|
function runHelper(script: string) {
|
||||||
|
return spawnSync('bash', ['-c', 'set -euo pipefail; source "$DEPLOY_HELPER"; ' + script], {
|
||||||
|
encoding: 'utf8',
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
DEPLOY_HELPER: path.resolve('scripts/deploy-runtime.sh'),
|
||||||
|
DEPLOY_CONFIG: path.resolve('harheimertc.test.config.cjs')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Deployment runtime', () => {
|
||||||
|
it('reloads the selected app configuration with an absolute Node runtime', () => {
|
||||||
|
const result = runHelper(`
|
||||||
|
pm2() {
|
||||||
|
if [ "$1" = describe ]; then return 0; fi
|
||||||
|
printf 'runtime=%s\\n' "\${DEPLOY_NODE_BINARY:-}"
|
||||||
|
printf '%s\\n' "$@"
|
||||||
|
}
|
||||||
|
deploy_restart_pm2 harheimertc.test "$DEPLOY_CONFIG"
|
||||||
|
`)
|
||||||
|
expect(result.status).toBe(0)
|
||||||
|
expect(result.stdout).toContain('runtime=/')
|
||||||
|
expect(result.stdout).toContain('delete\nharheimertc.test')
|
||||||
|
expect(result.stdout).toContain('start\n' + path.resolve('harheimertc.test.config.cjs'))
|
||||||
|
expect(result.stdout).toContain('--only\nharheimertc.test\n--update-env')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('waits for HTTP readiness after initial connection failures', () => {
|
||||||
|
const result = runHelper(`
|
||||||
|
attempts=0
|
||||||
|
curl() { attempts=$((attempts + 1)); [ "$attempts" -ge 3 ]; }
|
||||||
|
sleep() { :; }
|
||||||
|
pm2() { echo 'Unexpected diagnostics'; return 1; }
|
||||||
|
deploy_wait_for_http harheimertc.test http://127.0.0.1:3102/
|
||||||
|
[ "$attempts" -eq 3 ]
|
||||||
|
`)
|
||||||
|
expect(result.status).toBe(0)
|
||||||
|
expect(result.stdout).toContain('HTTP-Healthcheck erfolgreich')
|
||||||
|
expect(result.stdout).not.toContain('Unexpected diagnostics')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fails with PM2 diagnostics when the server never becomes ready', () => {
|
||||||
|
const result = runHelper(`
|
||||||
|
curl() { return 1; }
|
||||||
|
sleep() { :; }
|
||||||
|
pm2() { printf '%s\\n' "$*"; }
|
||||||
|
deploy_wait_for_http harheimertc.test http://127.0.0.1:3102/
|
||||||
|
`)
|
||||||
|
expect(result.status).toBe(1)
|
||||||
|
expect(result.stdout).toContain('describe harheimertc.test')
|
||||||
|
expect(result.stdout).toContain('logs harheimertc.test --nostream --lines 30')
|
||||||
|
})
|
||||||
|
})
|
||||||
69
tests/mail-transport.spec.ts
Normal file
69
tests/mail-transport.spec.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { createEvent } from './setup'
|
||||||
|
|
||||||
|
vi.mock('nodemailer', () => ({
|
||||||
|
default: { createTransport: vi.fn(() => ({ sendMail: vi.fn().mockResolvedValue({ messageId: 'real' }) })) }
|
||||||
|
}))
|
||||||
|
vi.mock('../server/utils/auth.js', () => ({
|
||||||
|
readUsers: vi.fn().mockResolvedValue([]),
|
||||||
|
getTokenFromEvent: vi.fn(),
|
||||||
|
getUserFromToken: vi.fn()
|
||||||
|
}))
|
||||||
|
|
||||||
|
import nodemailer from 'nodemailer'
|
||||||
|
import * as auth from '../server/utils/auth.js'
|
||||||
|
import { createMailTransport } from '../server/utils/mail-transport.js'
|
||||||
|
import { sendMembershipEmail, sendRegistrationNotification } from '../server/utils/email-service.js'
|
||||||
|
|
||||||
|
describe('Mail delivery gate', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
vi.stubEnv('MAIL_DELIVERY_DISABLED', '')
|
||||||
|
vi.stubEnv('PLAYSTORE_REVIEW_EMAIL', '')
|
||||||
|
auth.readUsers.mockResolvedValue([])
|
||||||
|
auth.getTokenFromEvent.mockReturnValue(undefined)
|
||||||
|
})
|
||||||
|
afterEach(() => vi.unstubAllEnvs())
|
||||||
|
|
||||||
|
it('does not create an SMTP transport when all delivery is disabled', async () => {
|
||||||
|
vi.stubEnv('MAIL_DELIVERY_DISABLED', 'TRUE')
|
||||||
|
expect(await createMailTransport(createEvent(), {}).sendMail({ to: 'board@example.com' }))
|
||||||
|
.toMatchObject({ suppressed: true, accepted: [] })
|
||||||
|
expect(nodemailer.createTransport).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('suppresses board mail for an authenticated review user with another form address', async () => {
|
||||||
|
auth.getTokenFromEvent.mockReturnValue('verified-by-auth')
|
||||||
|
auth.getUserFromToken.mockResolvedValue({ accountType: 'playstore_review' })
|
||||||
|
await createMailTransport(createEvent(), {}).sendMail({ to: 'board@example.com', replyTo: 'other@example.com' })
|
||||||
|
expect(auth.getUserFromToken).toHaveBeenCalledWith('verified-by-auth')
|
||||||
|
expect(nodemailer.createTransport).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('suppresses signed-out contact mail using the configured review reply address', async () => {
|
||||||
|
vi.stubEnv('PLAYSTORE_REVIEW_EMAIL', 'review@example.com')
|
||||||
|
await createMailTransport(undefined, {}).sendMail({ to: 'board@example.com', replyTo: 'Review <REVIEW@example.com>' })
|
||||||
|
expect(nodemailer.createTransport).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('checks stored review accounts in cc and bcc as well', async () => {
|
||||||
|
auth.readUsers.mockResolvedValue([{ email: 'review@example.com', accountType: 'playstore_review' }])
|
||||||
|
await createMailTransport(undefined, {}).sendMail({ to: 'member@example.com', bcc: [{ address: 'review@example.com' }] })
|
||||||
|
expect(nodemailer.createTransport).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('suppresses membership and registration notifications before SMTP/config loading', async () => {
|
||||||
|
vi.stubEnv('PLAYSTORE_REVIEW_EMAIL', 'review@example.com')
|
||||||
|
expect(await sendMembershipEmail({ email: 'review@example.com' }, '/unused.pdf')).toMatchObject({ success: true, suppressed: true })
|
||||||
|
expect(await sendRegistrationNotification({ email: 'review@example.com' })).toMatchObject({ success: true, suppressed: true })
|
||||||
|
expect(nodemailer.createTransport).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('delivers ordinary member mail and does not trust a client demo flag', async () => {
|
||||||
|
const event = createEvent({ body: { demo: true, accountType: 'playstore_review' } })
|
||||||
|
const mail = { to: 'member@example.com', text: 'Hello' }
|
||||||
|
expect(await createMailTransport(event, { host: 'smtp.example.com' }).sendMail(mail)).toEqual({ messageId: 'real' })
|
||||||
|
expect(nodemailer.createTransport).toHaveBeenCalledWith({ host: 'smtp.example.com' })
|
||||||
|
expect(nodemailer.createTransport.mock.results[0].value.sendMail).toHaveBeenCalledWith(mail)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -17,6 +17,7 @@ vi.mock('../server/utils/news.js', () => ({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('../server/utils/auth.js', () => ({
|
vi.mock('../server/utils/auth.js', () => ({
|
||||||
|
getTokenFromEvent: vi.fn(() => undefined),
|
||||||
readUsers: vi.fn(),
|
readUsers: vi.fn(),
|
||||||
migrateUserRoles: vi.fn((user) => {
|
migrateUserRoles: vi.fn((user) => {
|
||||||
if (!user) return user
|
if (!user) return user
|
||||||
|
|||||||
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('Max Muster')
|
||||||
expect(result).toContain('2024')
|
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', () => {
|
describe('GET /api/birthdays', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user