20 Commits

Author SHA1 Message Date
552068da89 Merge pull request 'dev' (#59) from dev into main
All checks were successful
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Successful in 2m39s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #59
2026-10-29 22:24:52 +01:00
Torsten Schulz (local)
c550fe8581 fix: verbessere Benutzerzuweisung für Playstore-Review-Konto
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 5m48s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m42s
2026-10-29 22:13:46 +01:00
Torsten Schulz (local)
472bec4519 fix: show live ticker across app and website and suppress review emails
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 5m18s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
2026-10-29 21:49:51 +01:00
Torsten Schulz (local)
10a37180de Refactor deployment scripts to improve Node.js version handling and PM2 process management
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 7m36s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m55s
- Updated `ensure_node_version` function in `deploy-production.sh` and `deploy-test.sh` to use a common script for Node.js version validation.
- Simplified PM2 instance restart logic by introducing `deploy_restart_pm2` function in a new `deploy-runtime.sh` script.
- Replaced direct PM2 commands with the new function to handle instance restarts and HTTP readiness checks.
- Modified PM2 configuration files to set the script directly instead of using `node` as the interpreter, allowing for better compatibility with older Node.js versions.
- Added tests for the new deployment runtime functions in `deploy-runtime.spec.ts` to ensure correct behavior during deployment.
- Updated `package-lock.json` to include new dependencies and ensure compatibility with the latest changes.
2026-10-29 15:48:17 +01:00
Torsten Schulz (local)
4ecc2cc90c fix: animiere Live-Ticker und stabilisiere Benachrichtigungsschalter
Some checks failed
Code Analysis and Production Deploy / analyze (push) Successful in 7m55s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Failing after 2m19s
2026-10-29 13:52:05 +01:00
Torsten Schulz (local)
7a991a50c7 fix: sichere Brotli-Laufzeit ab und erhöhe Patchversionen
Some checks failed
Code Analysis and Production Deploy / analyze (push) Successful in 7m23s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Failing after 2m27s
2026-10-27 15:32:16 +01:00
d26737e38c Merge pull request 'dev' (#58) from dev into main
All checks were successful
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Successful in 3m6s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #58
2026-10-23 12:28:51 +02:00
Torsten Schulz (local)
bf74d41773 feat: erweitere Benachrichtigungseinstellungen um Live-Ticker-Optionen und implementiere Push-Benachrichtigungen für Live-Spielereignisse
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 6m32s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 3m5s
2026-10-23 00:25:07 +02:00
Torsten Schulz (local)
6369249f40 feat: implement LiveTicker functionality with sound notifications and UI updates
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 7m10s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 3m11s
2026-10-22 23:57:22 +02:00
Torsten Schulz (local)
6b9198ac41 feat: füge LiveTickerBanner-Komponente hinzu und erweitere Spielplan-Daten mit MeetingId und Live-Daten-Flag
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 6m28s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 3m17s
2026-10-22 23:41:33 +02:00
032b1a531c Merge pull request 'dev' (#57) from dev into main
All checks were successful
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Successful in 3m36s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #57
2026-10-22 23:21:33 +02:00
Torsten Schulz (local)
3dc293517f feat: erweitere OSV-Scanner mit JSON-Ausgabe und kritischen Befunden
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 7m40s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 3m17s
2026-10-22 23:14:28 +02:00
Torsten Schulz (local)
b86555744f chore: update version to 1.8.13 and dependencies; enhance CSV sorting for championships
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 6m56s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
2026-10-22 23:03:26 +02:00
Torsten Schulz (local)
5c964592a8 Aktualisiere die Android-Version auf 0.10.1 und den Versionscode auf 37 für den Play Store Upload.
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 8m18s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
2026-10-05 14:52:37 +02:00
3b9d8a1623 Merge pull request 'dev' (#56) from dev into main
All checks were successful
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Successful in 2m58s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #56
2026-09-30 14:18:00 +02:00
Torsten Schulz (local)
91335422aa Aktualisiere die Version auf 1.8.12 und aktualisiere die Abhängigkeiten für brace-expansion
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 5m35s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 3m4s
2026-09-30 14:01:05 +02:00
Torsten Schulz (local)
87e08df93a Aktualisiere die Version auf 1.8.12, passe die Protokollnachricht für den QTTR-Import an und füge Tests für die QTTR-Importsignatur hinzu.
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 5m15s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
2026-09-30 09:30:24 +02:00
8d7f7d7d66 Merge pull request 'dev' (#55) from dev into main
All checks were successful
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Successful in 3m9s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #55
2026-09-29 09:02:18 +02:00
Torsten Schulz (local)
835a31a72e Aktualisiere die Version von multer auf 2.4.0 und nodemailer auf 10.0.2. Füge undici in die Abhängigkeiten ein.
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 5m30s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 3m13s
2026-09-29 07:51:35 +02:00
Torsten Schulz (local)
9eacbc77ea Aktualisiere die Version auf 1.8.11 und passe die Push-Benachrichtigung für aktualisierte QTTR-Werte an. Ändere die Signatur der QTTR-Liste, um die Spieler-ID zu verwenden.
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 5m26s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
2026-09-29 07:36:14 +02:00
65 changed files with 4632 additions and 2623 deletions

View File

@@ -116,8 +116,8 @@ jobs:
- name: Semgrep (SAST)
run: semgrep --config p/default --error .
- name: npm audit (high+)
run: npm audit --audit-level=high
- name: npm audit (critical)
run: npm audit --audit-level=critical
- name: OSV-Scanner (SCA)
run: |
@@ -126,7 +126,8 @@ jobs:
chmod +x osv-scanner
./osv-scanner --version
test -f "$GITHUB_WORKSPACE/package-lock.json"
./osv-scanner scan -L "$GITHUB_WORKSPACE/package-lock.json" --config "$GITHUB_WORKSPACE/.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:
runs-on: ubuntu-latest

2
.nvmrc
View File

@@ -1 +1 @@
22
24.22.1

View File

@@ -1 +1,2 @@
# Intentionally empty: retained because the CI scanner is invoked with --config.
# Retained because the CI scanner is invoked with --config. Findings are
# evaluated transparently by scripts/check-osv-severity.mjs.

32
PLAYSTORE_REVIEW.md Normal file
View 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

View File

@@ -37,6 +37,22 @@ data class SpielplanResponse(
val season: String? = null,
val seasons: List<SeasonDto> = emptyList(),
)
data class LiveTickerResponse(
val matches: List<LiveTickerMatchDto> = emptyList(),
val updatedAt: String? = null,
)
data class LiveTickerMatchDto(
val meetingId: String = "",
val homeTeam: String = "",
val awayTeam: String = "",
val score: String = "",
val startsAt: Long = 0,
val hasLiveData: Boolean = false,
val completed: Boolean = false,
val completedAt: Long? = null,
val updatedAt: String? = null,
val error: String? = null,
)
data class SeasonDto(val slug: String = "", val label: String = "")
data class MannschaftenSeasonsResponse(
val success: Boolean = false,
@@ -270,6 +286,9 @@ data class NotificationSettingsDto(
val newContactRequest: Boolean = false,
val newUserRegistration: Boolean = false,
val ownTtrChanges: Boolean = false,
val liveTickerStarts: Boolean = false,
val liveTickerScoreChanges: Boolean = false,
val liveTickerFinished: Boolean = false,
val selectedTeamSlugs: List<String> = emptyList(),
val selectedTeamSeason: String? = null,
val notificationTime: String = "09:00",
@@ -726,6 +745,9 @@ interface ApiService {
@GET("/api/mitgliederbereich/qttr")
suspend fun qttrValues(): Response<QttrValuesResponse>
@GET("/api/mitgliederbereich/live-ticker")
suspend fun liveTicker(): Response<LiveTickerResponse>
@GET("/api/members")
suspend fun members(): Response<MembersResponse>

View File

@@ -81,6 +81,7 @@ object HarheimerNotifications {
"news", "news_expiring" -> Destinations.MemberNews.route
"event", "events_today", "events_tomorrow" -> Destinations.Termine.route
"team_matches" -> Destinations.Spielplan.route
"live_ticker_started", "live_ticker_score_changed", "live_ticker_finished" -> Destinations.MemberArea.route
"qttr_list_updated", "ttr_change" -> Destinations.Qttr.route
"birthdays" -> Destinations.MemberArea.route
"contact_request" -> Destinations.CmsContactRequests.route

View File

@@ -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()
}
}
}

View File

@@ -20,6 +20,9 @@ data class NotificationPreferences(
val newContactRequest: Boolean = false,
val newUserRegistration: Boolean = false,
val ownTtrChanges: Boolean = false,
val liveTickerStarts: Boolean = false,
val liveTickerScoreChanges: Boolean = false,
val liveTickerFinished: Boolean = false,
val selectedTeamSlugs: Set<String> = emptySet(),
val selectedTeamSeason: String? = null,
val notificationTime: String = DEFAULT_NOTIFICATION_TIME,
@@ -45,6 +48,9 @@ class NotificationPreferencesRepository @Inject constructor(
newContactRequest = preferences.getBoolean(KEY_NEW_CONTACT_REQUEST, false),
newUserRegistration = preferences.getBoolean(KEY_NEW_USER_REGISTRATION, false),
ownTtrChanges = preferences.getBoolean(KEY_OWN_TTR_CHANGES, false),
liveTickerStarts = preferences.getBoolean(KEY_LIVE_TICKER_STARTS, false),
liveTickerScoreChanges = preferences.getBoolean(KEY_LIVE_TICKER_SCORE_CHANGES, false),
liveTickerFinished = preferences.getBoolean(KEY_LIVE_TICKER_FINISHED, false),
selectedTeamSlugs = preferences.getStringSet(KEY_SELECTED_TEAM_SLUGS, emptySet()).orEmpty(),
selectedTeamSeason = preferences.getString(KEY_SELECTED_TEAM_SEASON, null)?.takeIf { it.isNotBlank() },
notificationTime = preferences.getString(KEY_NOTIFICATION_TIME, DEFAULT_NOTIFICATION_TIME) ?: DEFAULT_NOTIFICATION_TIME,
@@ -72,6 +78,9 @@ class NotificationPreferencesRepository @Inject constructor(
.putBoolean(KEY_NEW_CONTACT_REQUEST, settings.newContactRequest)
.putBoolean(KEY_NEW_USER_REGISTRATION, settings.newUserRegistration)
.putBoolean(KEY_OWN_TTR_CHANGES, settings.ownTtrChanges)
.putBoolean(KEY_LIVE_TICKER_STARTS, settings.liveTickerStarts)
.putBoolean(KEY_LIVE_TICKER_SCORE_CHANGES, settings.liveTickerScoreChanges)
.putBoolean(KEY_LIVE_TICKER_FINISHED, settings.liveTickerFinished)
.putStringSet(KEY_SELECTED_TEAM_SLUGS, settings.selectedTeamSlugs)
.putString(KEY_SELECTED_TEAM_SEASON, settings.selectedTeamSeason)
.putString(KEY_NOTIFICATION_TIME, settings.notificationTime)
@@ -79,13 +88,12 @@ class NotificationPreferencesRepository @Inject constructor(
}
suspend fun saveRemote(settings: NotificationPreferences): Result<NotificationPreferences> {
saveLocal(settings)
return runCatching {
retryOnNetworkFailure {
val response = api.updateNotificationSettings(settings.toDto())
if (!response.isSuccessful) error("Benachrichtigungseinstellungen konnten nicht gespeichert werden.")
val saved = response.body()?.settings?.toPreferences() ?: error("Leere Antwort")
saveLocal(saved)
requireLiveNotificationSettingsSaved(settings, saved)
saved
}
}
@@ -102,12 +110,24 @@ class NotificationPreferencesRepository @Inject constructor(
const val KEY_NEW_CONTACT_REQUEST = "new_contact_request"
const val KEY_NEW_USER_REGISTRATION = "new_user_registration"
const val KEY_OWN_TTR_CHANGES = "own_ttr_changes"
const val KEY_LIVE_TICKER_STARTS = "live_ticker_starts"
const val KEY_LIVE_TICKER_SCORE_CHANGES = "live_ticker_score_changes"
const val KEY_LIVE_TICKER_FINISHED = "live_ticker_finished"
const val KEY_SELECTED_TEAM_SLUGS = "selected_team_slugs"
const val KEY_SELECTED_TEAM_SEASON = "selected_team_season"
const val KEY_NOTIFICATION_TIME = "notification_time"
}
}
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(
newNews = newNews,
newEvents = newEvents,
@@ -119,6 +139,9 @@ private fun NotificationSettingsDto.toPreferences(): NotificationPreferences = N
newContactRequest = newContactRequest,
newUserRegistration = newUserRegistration,
ownTtrChanges = ownTtrChanges,
liveTickerStarts = liveTickerStarts,
liveTickerScoreChanges = liveTickerScoreChanges,
liveTickerFinished = liveTickerFinished,
selectedTeamSlugs = selectedTeamSlugs.toSet(),
selectedTeamSeason = selectedTeamSeason,
notificationTime = notificationTime,
@@ -135,6 +158,9 @@ private fun NotificationPreferences.toDto(): NotificationSettingsDto = Notificat
newContactRequest = newContactRequest,
newUserRegistration = newUserRegistration,
ownTtrChanges = ownTtrChanges,
liveTickerStarts = liveTickerStarts,
liveTickerScoreChanges = liveTickerScoreChanges,
liveTickerFinished = liveTickerFinished,
selectedTeamSlugs = selectedTeamSlugs.toList(),
selectedTeamSeason = selectedTeamSeason,
notificationTime = notificationTime,

View File

@@ -80,8 +80,8 @@ private fun CompactNavigation(
navigationState: NavigationUiState = NavigationUiState(),
) {
val routeSection = menuSection(selectedRoute)
val sectionOverride = androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf<MenuSection?>(null) }
val section = routeSection ?: sectionOverride.value
val sectionOverride = androidx.compose.runtime.remember(selectedRoute) { androidx.compose.runtime.mutableStateOf<MenuSection?>(null) }
val section = sectionOverride.value ?: routeSection
val subItems = submenu(section, navigationState)
val mainScroll = rememberScrollState()

View File

@@ -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,
)
}
}
}

View File

@@ -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)
}
}
}

View File

@@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.getValue
import de.harheimertc.ui.components.AppNavigationHeader
import de.harheimertc.ui.components.LiveTickerBanner
@Composable
fun NavGraph(
@@ -44,8 +46,13 @@ fun NavGraph(
LaunchedEffect(currentRoute) {
navigationViewModel.refreshSession()
}
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
val persistentNavigation = maxWidth >= 600.dp
LaunchedEffect(navigationState.loggedIn) {
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()) {
navigationState.connectionNote?.let { message ->
Surface(color = Color(0xFFFFF4E5), modifier = Modifier.fillMaxWidth()) {
@@ -68,10 +75,16 @@ fun NavGraph(
}
}
},
webTabletNavigation = true,
webTabletNavigation = tabletNavigation,
navigationState = navigationState,
)
}
if (navigationState.loggedIn) {
LiveTickerBanner(
matches = navigationState.liveTickerMatches,
loaded = navigationState.liveTickerLoaded,
)
}
NavHost(
navController = navController,
startDestination = startDestination,

View File

@@ -4,15 +4,20 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.harheimertc.data.ConnectivityMonitor
import de.harheimertc.data.LiveTickerMatchDto
import de.harheimertc.repositories.AuthRepository
import de.harheimertc.repositories.GalleryRepository
import de.harheimertc.repositories.LoginRepository
import de.harheimertc.repositories.LiveTickerRepository
import de.harheimertc.repositories.Mannschaft
import de.harheimertc.repositories.MannschaftenRepository
import de.harheimertc.repositories.PushTokenRepository
import kotlinx.coroutines.async
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import javax.inject.Inject
@@ -22,6 +27,8 @@ data class NavigationUiState(
val loggedIn: Boolean = false,
val roles: Set<String> = emptySet(),
val connectionNote: String? = null,
val liveTickerMatches: List<LiveTickerMatchDto> = emptyList(),
val liveTickerLoaded: Boolean = false,
) {
val isAdmin: Boolean get() = "admin" in roles
val canAccessFullCms: Boolean get() = roles.any { it in setOf("admin", "vorstand") }
@@ -39,9 +46,12 @@ class NavigationViewModel @Inject constructor(
private val authRepository: AuthRepository,
private val connectivityMonitor: ConnectivityMonitor,
private val pushTokenRepository: PushTokenRepository,
private val liveTickerRepository: LiveTickerRepository,
) : ViewModel() {
private val _state = MutableStateFlow(NavigationUiState())
val state: StateFlow<NavigationUiState> = _state
private var tickerJob: Job? = null
private var tickerActive = false
init {
loadNavigationData()
@@ -70,6 +80,8 @@ class NavigationViewModel @Inject constructor(
loggedIn = loggedIn,
roles = status.navigationRoles(),
connectionNote = null,
liveTickerMatches = _state.value.liveTickerMatches,
liveTickerLoaded = _state.value.liveTickerLoaded,
)
if (loggedIn) registerPushToken()
}
@@ -89,6 +101,24 @@ class NavigationViewModel @Inject constructor(
}
}
fun setLiveTickerActive(active: Boolean) {
if (tickerActive == active) return
tickerActive = active
tickerJob?.cancel()
if (!active) {
_state.value = _state.value.copy(liveTickerMatches = emptyList(), liveTickerLoaded = false)
return
}
tickerJob = viewModelScope.launch {
while (isActive) {
liveTickerRepository.fetch().onSuccess { matches ->
_state.value = _state.value.copy(liveTickerMatches = matches, liveTickerLoaded = true)
}
delay(30_000)
}
}
}
private fun registerPushToken() {
viewModelScope.launch {
pushTokenRepository.registerCurrentDevice()
@@ -96,6 +126,7 @@ class NavigationViewModel @Inject constructor(
}
fun logout(onComplete: () -> Unit = {}) {
setLiveTickerActive(false)
viewModelScope.launch {
loginRepository.logout()
_state.value = _state.value.copy(

View File

@@ -164,6 +164,15 @@ fun NotificationSettingsScreen(
ToggleRow("Punktspiele aller Mannschaften", state.settings.allTeamMatches) {
viewModel.update(state.settings.copy(allTeamMatches = it))
}
ToggleRow("Live-Spiel gestartet (Live-Ticker)", state.settings.liveTickerStarts) {
viewModel.update(state.settings.copy(liveTickerStarts = it))
}
ToggleRow("Live-Ergebnis geändert", state.settings.liveTickerScoreChanges) {
viewModel.update(state.settings.copy(liveTickerScoreChanges = it))
}
ToggleRow("Live-Spiel beendet", state.settings.liveTickerFinished) {
viewModel.update(state.settings.copy(liveTickerFinished = it))
}
TeamSelection(
teams = state.teams,
seasons = state.seasons,
@@ -201,7 +210,10 @@ fun NotificationSettingsScreen(
}
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 ->

View File

@@ -12,6 +12,8 @@ import de.harheimertc.repositories.PushTokenRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import javax.inject.Inject
data class NotificationSettingsUiState(
@@ -37,6 +39,8 @@ class NotificationSettingsViewModel @Inject constructor(
) : ViewModel() {
private val _state = MutableStateFlow(NotificationSettingsUiState())
val state: StateFlow<NotificationSettingsUiState> = _state
private val saveMutex = Mutex()
private var settingsRevision = 0L
init {
load()
@@ -66,14 +70,21 @@ class NotificationSettingsViewModel @Inject constructor(
}
fun update(settings: NotificationPreferences) {
val revision = ++settingsRevision
preferencesRepository.saveLocal(settings)
_state.value = _state.value.copy(settings = settings, saveError = null)
viewModelScope.launch {
preferencesRepository.saveRemote(settings)
.onSuccess { saved -> _state.value = _state.value.copy(settings = saved, saveError = null) }
.onFailure { error ->
saveMutex.withLock {
if (revision != settingsRevision) return@withLock
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.")
}
}
}
}

View File

@@ -38,7 +38,7 @@ class LoginViewModelTest {
val repo = mockk<de.harheimertc.repositories.LoginRepository>()
val passkeyRepo = mockk<de.harheimertc.repositories.PasskeyRepository>()
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.setPassword("")
@@ -57,7 +57,7 @@ class LoginViewModelTest {
val passkeyRepo = mockk<de.harheimertc.repositories.PasskeyRepository>()
coEvery { repo.login(any(), any()) } returns Result.success(loginResp)
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.setPassword("secret")

View File

@@ -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)
}
}
}

View File

@@ -8,8 +8,8 @@ LOCAL_API_BASE_URL=https://harheimertc.tsschulz.de/
PRODUCTION_API_BASE_URL=https://harheimertc.de/
# Android app versioning for Play Store uploads
ANDROID_VERSION_CODE=36
ANDROID_VERSION_NAME=0.10.0
ANDROID_VERSION_CODE=40
ANDROID_VERSION_NAME=0.11.2
# Temporary hotfix: disable R8 minification for release to avoid Retrofit generic signature stripping.
RELEASE_MINIFY_ENABLED=false

View File

@@ -1,7 +1,8 @@
<template>
<div class="h-screen flex flex-col overflow-hidden">
<div class="h-screen flex flex-col overflow-hidden pt-20">
<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 />
</main>
<Footer />
@@ -11,6 +12,7 @@
<script setup>
import Navigation from '~/components/Navigation.vue'
import LiveTickerBanner from '~/components/LiveTickerBanner.vue'
import Footer from '~/components/Footer.vue'
import ModalDialog from '~/components/ModalDialog.vue'
</script>

View 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>

View File

@@ -137,13 +137,8 @@ ensure_node_version() {
local node_version
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
echo "ERROR: Node.js >= 22.12.0 wird benötigt, aktuell ist $node_version aktiv."
echo "Bitte Node 22 installieren/aktivieren, z.B.:"
echo " nvm install 22"
echo " nvm alias default 22"
exit 1
fi
# Dieselbe Laufzeitprüfung wie beim Build und im Nitro-Server.
node "$SCRIPT_DIR/scripts/require-safe-node.cjs"
echo " Node.js $node_version"
}
@@ -544,29 +539,11 @@ if ! command -v pm2 &> /dev/null; then
exit 1
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() {
local instance_name=$1
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
deploy_restart_pm2 "$1" "$SCRIPT_DIR/harheimertc.config.cjs"
}
# Starte/Neustarte die Produktionsinstanz
@@ -576,23 +553,16 @@ if ! restart_pm2_instance "harheimertc"; then
INSTANCE_ERRORS=$((INSTANCE_ERRORS + 1))
fi
# Prüfe, ob der Prozess läuft
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"
if ! deploy_wait_for_http "harheimertc" "http://127.0.0.1:3100/"; then
INSTANCE_ERRORS=$((INSTANCE_ERRORS + 1))
fi
if [ "$INSTANCE_ERRORS" -gt 0 ]; then
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 logs harheimertc"
exit 1
fi
echo ""

View File

@@ -150,13 +150,8 @@ ensure_node_version() {
local node_version
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
echo "ERROR: Node.js >= 22.12.0 wird benötigt, aktuell ist $node_version aktiv."
echo "Bitte Node 22 installieren/aktivieren, z.B.:"
echo " nvm install 22"
echo " nvm alias default 22"
exit 1
fi
# Dieselbe Laufzeitprüfung wie beim Build und im Nitro-Server.
node "$SCRIPT_DIR/scripts/require-safe-node.cjs"
echo " Node.js $node_version"
}
@@ -545,39 +540,11 @@ if ! command -v pm2 &> /dev/null; then
exit 1
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() {
local instance_name=$1
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
deploy_restart_pm2 "$1" "$SCRIPT_DIR/harheimertc.test.config.cjs"
}
# Starte/Neustarte Test-Instanz
@@ -589,23 +556,7 @@ if ! restart_pm2_instance "harheimertc.test"; then
exit 1
fi
# Prüfe, ob der Prozess läuft
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"
deploy_wait_for_http "harheimertc.test" "http://127.0.0.1:3102/"
echo ""
echo "=== Test-Instanz Deployment completed successfully! ==="

View File

@@ -14,6 +14,11 @@ SMTP_PORT=587
SMTP_USER=tsschulz@tsschulz.de
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:
# SMTP_HOST=mail.gmx.net
# SMTP_PORT=587

View File

@@ -16,6 +16,9 @@ function createEnv(port) {
// Secrets/Config (loaded from .env above, if present)
ENCRYPTION_KEY: process.env.ENCRYPTION_KEY,
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_PORT: process.env.SMTP_PORT,
SMTP_USER: process.env.SMTP_USER,
@@ -49,8 +52,9 @@ module.exports = {
{
name: 'harheimertc',
// Nuxt 4 production build: direkt den Node-Server starten (kein "preview mode")
script: 'node',
args: '.output/server/index.mjs',
script: '.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',
instances: 1,
autorestart: true,

View File

@@ -18,6 +18,9 @@ function createEnv(port) {
// Secrets/Config (loaded from .env above, if present)
ENCRYPTION_KEY: process.env.ENCRYPTION_KEY,
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_PORT: process.env.SMTP_PORT,
SMTP_USER: process.env.SMTP_USER,
@@ -51,8 +54,9 @@ module.exports = {
{
name: 'harheimertc.test',
// Nuxt 4 production build: direkt den Node-Server starten (kein "preview mode")
script: 'node',
args: '.output/server/index.mjs',
script: '.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',
instances: 1,
autorestart: true,

View File

@@ -1,3 +1,4 @@
import './scripts/require-safe-node.cjs';
// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
devtools: { enabled: process.env.NODE_ENV !== 'production' },

5633
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,11 @@
{
"name": "harheimertc-website",
"version": "1.8.10",
"version": "1.9.2",
"description": "Moderne Webseite für den Harheimer Tischtennis Club",
"private": true,
"type": "module",
"engines": {
"node": ">=22.12.0",
"node": "^22.23.4 || ^24.22.1 || ^26.12.1",
"npm": "10.9.7"
},
"scripts": {
@@ -38,8 +38,8 @@
"bcryptjs": "^2.4.3",
"dompurify": "^3.3.1",
"jsonwebtoken": "^9.0.2",
"multer": "^2.0.2",
"nodemailer": "^9.0.1",
"multer": "^2.4.0",
"nodemailer": "^10.0.2",
"nuxt": "^4.5.1",
"pdf-lib": "^1.17.1",
"pdf-parse": "^2.4.5",
@@ -63,13 +63,14 @@
"postcss": "^8.5.12",
"supertest": "^7.1.0",
"tailwindcss": "^3.4.0",
"vitest": "^4.1.8",
"vitest": "^4.1.12",
"vue-eslint-parser": "^10.2.0"
},
"overrides": {
"@peculiar/x509": "1.13.0",
"devalue": "5.9.2",
"devalue": "5.9.3",
"esbuild": "0.28.1",
"qs": "6.16.0"
"qs": "6.16.0",
"undici": "8.10.2"
}
}

View 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
View 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
}

View 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;

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

View File

@@ -1,4 +1,4 @@
import nodemailer from 'nodemailer'
import { createMailTransport } from '../../../../utils/mail-transport.js'
import { readUsers, writeUsers } from '../../../../utils/auth.js'
import { assertRateLimit, getClientIp, registerRateLimitFailure, registerRateLimitSuccess } from '../../../../utils/rate-limit.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
if (smtpUser && smtpPass) {
const transporter = nodemailer.createTransport({
const transporter = createMailTransport(event, {
host: process.env.SMTP_HOST || 'smtp.gmail.com',
port: process.env.SMTP_PORT || 587,
secure: false,

View File

@@ -262,7 +262,7 @@ export default defineEventHandler(async (event) => {
// Send notification emails through the same central recipient logic as password registration.
try {
await sendRegistrationNotification({ name, email, phone })
await sendRegistrationNotification({ name, email, phone }, event)
} catch (emailError) {
console.error('E-Mail-Versand fehlgeschlagen:', emailError)
}

View File

@@ -66,7 +66,7 @@ export default defineEventHandler(async (event) => {
// Send notification to Vorstand/admin via central email service
try {
await sendRegistrationNotification({ name, email, phone })
await sendRegistrationNotification({ name, email, phone }, event)
} catch (emailError) {
console.error('Registrierungs-Benachrichtigung fehlgeschlagen:', emailError)
}

View File

@@ -1,5 +1,5 @@
import { readUsers, writeUsers } from '../../utils/auth.js'
import nodemailer from 'nodemailer'
import { createMailTransport } from '../../utils/mail-transport.js'
import crypto from 'crypto'
import { assertRateLimit, getClientIp, registerRateLimitFailure, registerRateLimitSuccess } from '../../utils/rate-limit.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 })
const transporter = nodemailer.createTransport({
const transporter = createMailTransport(event, {
host: process.env.SMTP_HOST || 'smtp.gmail.com',
port: process.env.SMTP_PORT || 587,
secure: process.env.SMTP_SECURE === 'true',

View File

@@ -1,13 +1,13 @@
import nodemailer from 'nodemailer'
import { createMailTransport } from '../../../../utils/mail-transport.js'
import { getUserFromToken, hasAnyRole } from '../../../../utils/auth.js'
import { addContactReply, readContactRequests } from '../../../../utils/contact-requests.js'
function createTransporter() {
function createTransporter(event) {
const smtpUser = process.env.SMTP_USER
const smtpPass = process.env.SMTP_PASS || process.env.EMAIL_PASSWORD
if (!smtpUser || !smtpPass) return null
return nodemailer.createTransport({
return createMailTransport(event, {
host: process.env.SMTP_HOST || 'smtp.gmail.com',
port: Number(process.env.SMTP_PORT || 587),
secure: process.env.SMTP_SECURE === 'true',
@@ -43,7 +43,7 @@ export default defineEventHandler(async (event) => {
throw createError({ statusCode: 404, statusMessage: 'Anfrage nicht gefunden' })
}
const transporter = createTransporter()
const transporter = createTransporter(event)
if (!transporter) {
throw createError({
statusCode: 500,

View File

@@ -1,5 +1,5 @@
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'
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'}`)
// Continue without sending email
} else {
const transporter = nodemailer.createTransport({
const transporter = createMailTransport(event, {
host: process.env.SMTP_HOST || 'smtp.gmail.com',
port: process.env.SMTP_PORT || 587,
secure: false,

View File

@@ -1,4 +1,4 @@
import nodemailer from 'nodemailer'
import { createMailTransport } from '../utils/mail-transport.js'
import { promises as fs } from 'fs'
import { createContactRequest } from '../utils/contact-requests.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.')
}
function createTransporter() {
function createTransporter(event) {
const smtpUser = process.env.SMTP_USER
const smtpPass = process.env.SMTP_PASS || process.env.EMAIL_PASSWORD
if (!smtpUser || !smtpPass) return null
return nodemailer.createTransport({
return createMailTransport(event, {
host: process.env.SMTP_HOST || 'smtp.gmail.com',
port: Number(process.env.SMTP_PORT || 587),
secure: process.env.SMTP_SECURE === 'true',
@@ -144,7 +144,7 @@ export default defineEventHandler(async (event) => {
const config = await loadConfig()
const recipients = await collectRecipients(config)
const transporter = createTransporter()
const transporter = createTransporter(event)
if (!transporter) {
return {

View File

@@ -626,7 +626,7 @@ export default defineEventHandler(async (event) => {
let emailResult
if (usedTemplate) {
// E-Mail senden via zentralen Service (pass full path)
emailResult = await sendMembershipEmailUtil(data, finalPdfPath)
emailResult = await sendMembershipEmailUtil(data, finalPdfPath, event)
// Antragsdaten verschlüsselt speichern
const encryptedData = JSON.stringify(data)
// 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)
// E-Mail senden via zentralen Service (pass full path)
emailResult = await sendMembershipEmailUtil(data, finalPdfPath)
emailResult = await sendMembershipEmailUtil(data, finalPdfPath, event)
// Antragsdaten verschlüsselt speichern
const encryptedData = JSON.stringify(data)
@@ -720,7 +720,7 @@ export default defineEventHandler(async (event) => {
const fallbackFilename = await generateSimplePDF(data, filename, event)
// 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('E-Mail würde gesendet werden an:', emailResult.recipients || [])

View 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()
}
})

View File

@@ -2,7 +2,7 @@ import fs from 'fs/promises'
import path from 'path'
import { getUserFromToken, hasAnyRole } from '../../../utils/auth.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
// 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',
port: process.env.SMTP_PORT || 587,
secure: false,

View File

@@ -3,7 +3,7 @@ import path from 'path'
import { getUserFromToken, hasAnyRole } from '../../../../../utils/auth.js'
import { randomUUID } from 'crypto'
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
// 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',
port: process.env.SMTP_PORT || 587,
secure: false,

View File

@@ -1,7 +1,7 @@
import { getUserFromToken, hasAnyRole } from '../../../../../utils/auth.js'
import { readSubscribers, writeSubscribers } from '../../../../../utils/newsletter.js'
import { randomUUID } from 'crypto'
import nodemailer from 'nodemailer'
import { createMailTransport } from '../../../../../utils/mail-transport.js'
import crypto from 'crypto'
import fs from 'fs/promises'
import path from 'path'
@@ -98,7 +98,7 @@ export default defineEventHandler(async (event) => {
})
} else {
// 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 {
success: true,
message: 'Eine Bestätigungsmail wurde an die E-Mail-Adresse gesendet'
@@ -124,7 +124,7 @@ export default defineEventHandler(async (event) => {
})
} else {
// 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 {
success: true,
message: 'Eine Bestätigungsmail wurde an die E-Mail-Adresse gesendet'
@@ -150,7 +150,7 @@ export default defineEventHandler(async (event) => {
}
} else {
// 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 {
success: true,
message: 'Eine Bestätigungsmail wurde an die E-Mail-Adresse gesendet'
@@ -178,7 +178,7 @@ export default defineEventHandler(async (event) => {
await writeSubscribers(subscribers)
// 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 {
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 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 confirmationUrl = `${baseUrl}/newsletter/confirm?token=${token}`
const transporter = nodemailer.createTransport({
const transporter = createMailTransport(event, {
host: process.env.SMTP_HOST || 'smtp.gmail.com',
port: process.env.SMTP_PORT || 587,
secure: false,

View File

@@ -1,6 +1,6 @@
import { readSubscribers, writeSubscribers } from '../../utils/newsletter.js'
import { randomUUID } from 'crypto'
import nodemailer from 'nodemailer'
import { createMailTransport } from '../../utils/mail-transport.js'
import crypto from 'crypto'
import fs from 'fs/promises'
import path from 'path'
@@ -129,7 +129,7 @@ export default defineEventHandler(async (event) => {
})
} else {
// 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] })
return {
success: true,
@@ -158,7 +158,7 @@ export default defineEventHandler(async (event) => {
})
} else {
// 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] })
return {
success: true,
@@ -186,7 +186,7 @@ export default defineEventHandler(async (event) => {
}
} else {
// 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] })
return {
success: true,
@@ -215,7 +215,7 @@ export default defineEventHandler(async (event) => {
await writeSubscribers(subscribers)
// 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] })
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 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 confirmationUrl = `${baseUrl}/newsletter/confirm?token=${token}`
const transporter = nodemailer.createTransport({
const transporter = createMailTransport(event, {
host: process.env.SMTP_HOST || 'smtp.gmail.com',
port: process.env.SMTP_PORT || 587,
secure: false,

View File

@@ -10,6 +10,74 @@ async function exists(filePath) {
}
}
function parseCsvLine(line) {
const values = []
let value = ''
let quoted = false
for (let index = 0; index < line.length; index++) {
const char = line[index]
if (char === '"') {
if (quoted && line[index + 1] === '"') {
value += '"'
index++
} else {
quoted = !quoted
}
} else if (char === ',' && !quoted) {
values.push(value.trim())
value = ''
} else {
value += char
}
}
values.push(value.trim())
return values
}
function categoryOrder(category) {
const normalized = category.toLocaleLowerCase('de-DE')
if (normalized.includes('einzel')) return 0
if (normalized.includes('doppel')) return 1
return 2
}
function placementOrder(placement) {
const match = String(placement).match(/\d+/)
return match ? Number(match[0]) : Number.MAX_SAFE_INTEGER
}
function sortChampionshipCsv(csv) {
const newline = csv.includes('\r\n') ? '\r\n' : '\n'
const lines = csv.split(/\r?\n/)
const [header, ...dataLines] = lines
const entries = dataLines
.filter(line => line.trim() !== '')
.map((line, index) => ({ line, values: parseCsvLine(line), index }))
entries.sort((a, b) => {
const yearA = Number(a.values[0])
const yearB = Number(b.values[0])
if (Number.isFinite(yearA) && Number.isFinite(yearB) && yearA !== yearB) return yearB - yearA
const categoryA = a.values[1] || ''
const categoryB = b.values[1] || ''
const categoryDifference = categoryOrder(categoryA) - categoryOrder(categoryB)
if (categoryDifference !== 0) return categoryDifference
const categoryNameDifference = categoryA.localeCompare(categoryB, 'de-DE')
if (categoryNameDifference !== 0) return categoryNameDifference
const placementDifference = placementOrder(a.values[2]) - placementOrder(b.values[2])
if (placementDifference !== 0) return placementDifference
return a.index - b.index
})
return [header, ...entries.map(entry => entry.line)].join(newline)
}
export default defineEventHandler(async (event) => {
try {
const cwd = process.cwd()
@@ -50,7 +118,7 @@ export default defineEventHandler(async (event) => {
setHeader(event, 'Pragma', 'no-cache')
setHeader(event, 'Expires', '0')
return csv
return sortChampionshipCsv(csv)
} catch (error) {
if (error?.statusCode) throw error
console.error('Fehler beim Laden der Vereinsmeisterschaften:', error)
@@ -60,4 +128,3 @@ export default defineEventHandler(async (event) => {
})
}
})

View 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))
})

View 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(() => {});

View File

@@ -141,7 +141,7 @@ function createQttrJob() {
return {
run: async (reason) => {
const qttr = await importQttrValues()
loggerInfo(`[qttr-import] ${reason}: ${qttr.rowCount} QTTR-Werte importiert`, {
loggerInfo(`[qttr-import] ${reason}: ${qttr.rowCount} TTR-/QTTR-Werte importiert`, {
outputFile: qttr.outputFile,
tableCount: qttr.tableCount
})

View File

@@ -3,7 +3,7 @@
* Clean Code: Single Responsibility Principle
*/
import nodemailer from 'nodemailer'
import { createMailTransport, shouldSuppressMail } from './mail-transport.js'
import fs from 'fs/promises'
import path from 'path'
import { getServerDataPath } from './paths.js'
@@ -103,7 +103,7 @@ async function getEmailRecipients(data, config) {
* Creates email transporter
* @returns {Object} Nodemailer transporter
*/
function createTransporter() {
function createTransporter(event) {
const smtpUser = process.env.SMTP_USER
const smtpPass = process.env.SMTP_PASS
@@ -114,7 +114,7 @@ function createTransporter() {
)
}
return nodemailer.createTransport({
return createMailTransport(event, {
host: process.env.SMTP_HOST || 'localhost',
port: parseInt(process.env.SMTP_PORT) || 587,
secure: process.env.SMTP_SECURE === 'true',
@@ -131,13 +131,14 @@ function createTransporter() {
* @param {string} pdfPath - Path to generated PDF
* @returns {Promise<Object>} Email result
*/
export async function sendMembershipEmail(data, pdfPath) {
export async function sendMembershipEmail(data, pdfPath, event) {
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 recipients = await getEmailRecipients(data, config)
// Create transporter
const transporter = createTransporter()
const transporter = createTransporter(event)
// Email content
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.
* @param {Object} data - { name, email, phone }
*/
export async function sendRegistrationNotification(data) {
export async function sendRegistrationNotification(data, event) {
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 recipients = await getEmailRecipients(data, config)
// Create transporter
const transporter = createTransporter()
const transporter = createTransporter(event)
// Notify Vorstand/admin
const adminSubject = 'Neue Registrierung - Harheimer TC'

172
server/utils/live-ticker.js Normal file
View 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() }
}

View 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)
}
}
}

View File

@@ -9,6 +9,9 @@ export const DEFAULT_NOTIFICATION_SETTINGS = Object.freeze({
newContactRequest: false,
newUserRegistration: false,
ownTtrChanges: false,
liveTickerStarts: false,
liveTickerScoreChanges: false,
liveTickerFinished: false,
selectedTeamSlugs: [],
selectedTeamSeason: null,
notificationTime: '09:00'
@@ -43,6 +46,9 @@ export function sanitizeNotificationSettings(input = {}) {
newContactRequest: coerceBoolean(input.newContactRequest),
newUserRegistration: coerceBoolean(input.newUserRegistration),
ownTtrChanges: coerceBoolean(input.ownTtrChanges),
liveTickerStarts: coerceBoolean(input.liveTickerStarts),
liveTickerScoreChanges: coerceBoolean(input.liveTickerScoreChanges),
liveTickerFinished: coerceBoolean(input.liveTickerFinished),
selectedTeamSlugs: [...new Set(selectedTeamSlugs)],
selectedTeamSeason,
notificationTime

View File

@@ -337,7 +337,7 @@ export async function sendOwnTtrChangePush(changes = []) {
export async function sendQttrListUpdatedPush({ importedAt, rowCount }) {
return sendPushToUsers({
title: 'QTTR-Liste aktualisiert',
body: `Die aktuelle TTR- und QTTR-Liste wurde aktualisiert (${rowCount} Einträge).`,
body: `Die aktuellen QTTR-Werte wurden aktualisiert (${rowCount} Einträge).`,
data: {
type: 'qttr_list_updated',
importedAt: importedAt || '',
@@ -347,6 +347,61 @@ export async function sendQttrListUpdatedPush({ importedAt, rowCount }) {
})
}
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',

View File

@@ -287,18 +287,23 @@ async function importAuthenticatedTtrValues(connection) {
})
await sendOwnTtrChangePush(changes)
if (previousQttrSignature && previousQttrSignature !== nextQttrSignature) {
await sendQttrListUpdatedPush({ importedAt, rowCount: rows.length })
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 }
}
function qttrListSignature(rows) {
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?.playerName || '').trim().toLocaleLowerCase('de-DE'),
row?.rank ?? '',
String(row?.playerNumber || row?.playerName || '').trim().toLocaleLowerCase('de-DE'),
row?.currentQttr ?? ''
].join('|'))
.sort()

View File

@@ -15,6 +15,8 @@ const SPIELPLAN_HEADERS = [
'Staffel',
'Runde',
'BegegnungNr',
'MeetingId',
'HasNuLivePushData',
'HalleNr',
'HalleName',
'HalleStrasse',
@@ -242,6 +244,8 @@ export function convertImportedSpielplanToJson(imported) {
Staffel: match.leagueShortName || '',
Runde: match.roundName || '',
BegegnungNr: match.meetingNumber || match.meetingId || '',
MeetingId: match.meetingId || '',
HasNuLivePushData: match.hasNuLivePushData === true ? 'true' : 'false',
HalleNr: match.hallNumber || '',
HalleName: match.location?.label || '',
HalleStrasse: match.location?.street || '',

View File

@@ -111,6 +111,7 @@ function normalizeMatch(day, match) {
formattedDay: match.formattedDay ?? null,
formattedTime: match.formattedTime ?? null,
state: match.state ?? null,
hasNuLivePushData: match.has_nu_live_push_data === true,
meetingId: match.meeting_id ?? null,
meetingNumber: match.meeting_number ?? null,
leagueId: match.league_id ?? null,

View File

@@ -5,6 +5,7 @@ import crypto from 'crypto'
vi.mock('../server/utils/auth.js', () => {
return {
getTokenFromEvent: vi.fn(() => undefined),
readUsers: vi.fn(),
writeUsers: vi.fn(),
verifyPassword: vi.fn(),

View File

@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createEvent, mockSuccessReadBody } from './setup'
vi.mock('../server/utils/auth.js', () => ({
getTokenFromEvent: vi.fn(() => undefined),
getUserFromToken: vi.fn(),
readUsers: vi.fn(),
writeUsers: vi.fn(),

View File

@@ -175,6 +175,9 @@ describe('Config & Profil Endpoints', () => {
id: '1',
notificationSettings: {
eventsToday: true,
liveTickerStarts: true,
liveTickerScoreChanges: true,
liveTickerFinished: true,
selectedTeamSlugs: ['herren-1', 'herren-1', ''],
selectedTeamSeason: '2025/2026',
notificationTime: '07:30'
@@ -185,6 +188,9 @@ describe('Config & Profil Endpoints', () => {
expect(result.success).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.selectedTeamSlugs).toEqual(['herren-1'])
expect(result.settings.notificationTime).toBe('07:30')
@@ -202,6 +208,9 @@ describe('Config & Profil Endpoints', () => {
const event = createEvent({ headers: { authorization: 'Bearer android-token' } })
mockSuccessReadBody({
newEvents: true,
liveTickerStarts: true,
liveTickerScoreChanges: true,
liveTickerFinished: true,
eventsToday: 'true',
birthdays: true,
selectedTeamSlugs: ['herren-1', 'herren-1', ' jugend '],
@@ -218,6 +227,9 @@ describe('Config & Profil Endpoints', () => {
expect(result.success).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.birthdays).toBe(true)
expect(result.settings.selectedTeamSlugs).toEqual(['herren-1', 'jugend'])
@@ -227,6 +239,9 @@ describe('Config & Profil Endpoints', () => {
id: '1',
notificationSettings: expect.objectContaining({
newEvents: true,
liveTickerStarts: true,
liveTickerScoreChanges: true,
liveTickerFinished: true,
birthdays: true,
selectedTeamSeason: '2026/2027'
})

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

View 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)
})
})

View File

@@ -17,6 +17,7 @@ vi.mock('../server/utils/news.js', () => ({
}))
vi.mock('../server/utils/auth.js', () => ({
getTokenFromEvent: vi.fn(() => undefined),
readUsers: vi.fn(),
migrateUserRoles: vi.fn((user) => {
if (!user) return user

23
tests/qttr-import.spec.ts Normal file
View 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))
})
})

View File

@@ -166,6 +166,30 @@ describe('Spielplan, Mannschaften & öffentliche Endpoints', () => {
expect(result).toContain('Max Muster')
expect(result).toContain('2024')
})
it('sortiert nach Jahr, Einzel/Doppel und Platzierung', async () => {
vi.spyOn(fs, 'access').mockResolvedValue(undefined)
vi.spyOn(fs, 'readFile').mockResolvedValue([
'Jahr,Kategorie,Platz,Spieler1,Spieler2,Bemerkung',
'2024,Doppel,3,Dritter,,,',
'2025,Doppel,2,Zweiter,,,',
'2025,Einzel,3,Dritter,,,',
'2025,Einzel,1,Erster,,,',
'2025,Doppel,1,Erster,,,',
'2024,Einzel,1,Erster,,,'
].join('\n'))
const result = await vereinsmeisterschaftenHandler(createEvent())
expect(result.split('\n').slice(1)).toEqual([
'2025,Einzel,1,Erster,,,',
'2025,Einzel,3,Dritter,,,',
'2025,Doppel,1,Erster,,,',
'2025,Doppel,2,Zweiter,,,',
'2024,Einzel,1,Erster,,,',
'2024,Doppel,3,Dritter,,,'
])
})
})
describe('GET /api/birthdays', () => {