28 Commits

Author SHA1 Message Date
Torsten Schulz (local)
b69130c2b2 Fixed semgrep error
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 5m56s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m36s
2026-06-14 21:29:53 +02:00
Torsten Schulz (local)
77aabef4a9 Benachrichtigungen erweitert
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 7m53s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Emails korrigiert
2026-06-14 01:05:19 +02:00
Torsten Schulz (local)
4b699de853 NPM fix
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 5m47s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m6s
2026-06-13 00:42:00 +02:00
Torsten Schulz (local)
e3cb7282bc Android implementation of sportbetrieb, 401-fix
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 7m29s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
2026-06-13 00:30:06 +02:00
Torsten Schulz (local)
e537839e28 Updated stuff
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 6m4s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m10s
2026-06-12 16:49:00 +02:00
Torsten Schulz (local)
44d441811c Fixed some problems
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 6m38s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m37s
2026-06-12 11:42:22 +02:00
Torsten Schulz (local)
da1efa5a74 Added notifications for actual news
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 5m59s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
2026-06-11 09:03:16 +02:00
c7a306e8fa Merge branch 'main' into dev
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 5m55s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 1m59s
2026-06-10 16:41:53 +02:00
Torsten Schulz (local)
14cd5f04d5 fixed deploy and navigation
Some checks failed
Code Analysis and Production Deploy / deploy-production (push) Has been cancelled
Code Analysis and Production Deploy / analyze (push) Has been cancelled
Code Analysis and Production Deploy / deploy-test (push) Has been cancelled
2026-06-10 16:36:50 +02:00
Torsten Schulz (local)
7e533fae49 robustere pfad-suche
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 5m47s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 1m55s
2026-06-10 16:05:15 +02:00
Torsten Schulz (local)
8393f154e5 logging update
Some checks failed
Code Analysis and Production Deploy / deploy-production (push) Has been cancelled
Code Analysis and Production Deploy / deploy-test (push) Has been cancelled
Code Analysis and Production Deploy / analyze (push) Has been cancelled
2026-06-10 16:03:37 +02:00
Torsten Schulz (local)
c956869e8a actualized ignore file
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 5m46s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 1m56s
2026-06-10 15:39:51 +02:00
Torsten Schulz (local)
4eabb3b766 gitleaks fix
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 4m28s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
2026-06-10 15:31:03 +02:00
Torsten Schulz (local)
146dedd9b4 config aktualisiert
Some checks failed
Code Analysis and Production Deploy / deploy-production (push) Has been cancelled
Code Analysis and Production Deploy / deploy-test (push) Has been cancelled
Code Analysis and Production Deploy / analyze (push) Has been cancelled
2026-06-10 15:27:53 +02:00
Torsten Schulz (local)
6076194497 added google service config
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 2m48s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
2026-06-10 15:11:11 +02:00
Torsten Schulz (local)
9cde1ab78b google push service config
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 5m47s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 1m59s
2026-06-10 15:02:22 +02:00
Torsten Schulz (local)
f5facaa811 FIX
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 3m0s
2026-06-10 14:39:17 +02:00
Torsten Schulz (local)
78015298ec package update
Some checks failed
Code Analysis and Production Deploy / analyze (push) Successful in 6m3s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Failing after 10s
2026-06-10 14:21:51 +02:00
Torsten Schulz (local)
b4e1c50ea3 semgrep problems fix
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 5m57s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
2026-06-10 13:55:50 +02:00
Torsten Schulz (local)
5da11d2e4d Fix in news, first android notification service
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 7m50s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
2026-06-10 13:47:33 +02:00
Torsten Schulz (local)
e8a50e55ca Fix Mannschaften 2026-06-10 08:03:44 +02:00
45de2a576c Merge pull request 'dev' (#40) 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 2m5s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #40
2026-05-31 15:14:03 +02:00
e1ad5f7205 Merge pull request 'feat: add robots.txt and sitemap.xml routes for SEO optimization' (#39) 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 2m11s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #39
2026-05-31 14:03:04 +02:00
14341b7a63 Merge pull request 'dev' (#38) 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 2m43s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #38
2026-05-30 23:54:07 +02:00
803481ca8e Merge pull request 'dev' (#37) from dev into main
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 7m15s
Code Analysis and Production Deploy / deploy-production (push) Successful in 2m24s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #37
2026-05-30 00:30:30 +02:00
46a8d5a77a Merge pull request 'dev' (#36) from dev into main
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 5m7s
Code Analysis and Production Deploy / deploy-production (push) Successful in 2m25s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #36
2026-05-28 08:56:09 +02:00
96c1d0260b Merge pull request 'chore(version): update version to 1.6.1' (#35) from dev into main
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 5m7s
Code Analysis and Production Deploy / deploy-production (push) Successful in 2m18s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #35
2026-05-27 20:45:54 +02:00
6e00a1b829 Merge pull request 'dev' (#34) from dev into main
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 33s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #34
2026-05-27 20:43:46 +02:00
78 changed files with 3920 additions and 1768 deletions

View File

@@ -146,7 +146,7 @@ jobs:
-o BatchMode=yes \ -o BatchMode=yes \
-p "${{ vars.PROD_PORT }}" \ -p "${{ vars.PROD_PORT }}" \
"${{ vars.PROD_USER }}@${{ vars.PROD_HOST }}" \ "${{ vars.PROD_USER }}@${{ vars.PROD_HOST }}" \
"bash -lc 'cd /var/www/harheimertc && git fetch origin main && git checkout -B main origin/main && git reset --hard origin/main && ./deploy-production.sh'" "bash -lc 'cd /var/www/harheimertc && git reset --hard HEAD && git fetch origin main && git checkout -B main origin/main && git reset --hard origin/main && ./deploy-production.sh'"
deploy-test: deploy-test:
needs: analyze needs: analyze
@@ -177,4 +177,4 @@ jobs:
-o BatchMode=yes \ -o BatchMode=yes \
-p "${{ vars.PROD_PORT }}" \ -p "${{ vars.PROD_PORT }}" \
"${{ vars.PROD_USER }}@${{ vars.PROD_HOST }}" \ "${{ vars.PROD_USER }}@${{ vars.PROD_HOST }}" \
"bash -lc 'cd /var/www/harheimertc.test && git fetch origin dev && git checkout -B dev origin/dev && git reset --hard origin/dev && ./deploy-test.sh'" "bash -lc 'cd /var/www/harheimertc.test && git reset --hard HEAD && git fetch origin dev && git checkout -B dev origin/dev && git reset --hard origin/dev && ./deploy-test.sh'"

7
.gitleaks.toml Normal file
View File

@@ -0,0 +1,7 @@
[[allowlists]]
description = "generated/imported non-secret data"
paths = [
'''server/data/spielplan-import/harheimer_tc_spielplan\.(html|json)$''',
'''android-app/app/build/.*''',
'''android-app/\.idea/planningMode\.xml$''',
]

3
.gitleaksignore Normal file
View File

@@ -0,0 +1,3 @@
google-services.json:gcp-api-key:18
google-services.json:gcp-api-key:37
google-services.json:gcp-api-key:56

View File

@@ -7,6 +7,10 @@ plugins {
id("com.google.dagger.hilt.android") id("com.google.dagger.hilt.android")
} }
if (file("google-services.json").exists()) {
apply(plugin = "com.google.gms.google-services")
}
val localApiBaseUrl = providers.gradleProperty("LOCAL_API_BASE_URL") val localApiBaseUrl = providers.gradleProperty("LOCAL_API_BASE_URL")
.orElse("https://harheimertc.tsschulz.de/") .orElse("https://harheimertc.tsschulz.de/")
.get() .get()
@@ -84,6 +88,13 @@ android {
versionName = androidVersionName versionName = androidVersionName
} }
lint {
disable += setOf(
"AutoboxingStateCreation",
"MutableCollectionMutableState",
)
}
signingConfigs { signingConfigs {
create("release") { create("release") {
if (hasReleaseSigning) { if (hasReleaseSigning) {
@@ -253,6 +264,9 @@ dependencies {
// Crash reporting // Crash reporting
implementation("io.sentry:sentry-android:8.42.0") implementation("io.sentry:sentry-android:8.42.0")
// Push notifications
implementation("com.google.firebase:firebase-messaging:25.0.2")
// Room // Room
implementation("androidx.room:room-runtime:2.6.1") implementation("androidx.room:room-runtime:2.6.1")
ksp("androidx.room:room-compiler:2.6.1") ksp("androidx.room:room-compiler:2.6.1")
@@ -262,6 +276,7 @@ dependencies {
implementation("androidx.work:work-runtime-ktx:2.8.1") implementation("androidx.work:work-runtime-ktx:2.8.1")
implementation("androidx.datastore:datastore-preferences:1.0.0") implementation("androidx.datastore:datastore-preferences:1.0.0")
implementation("androidx.security:security-crypto:1.1.0-alpha06") implementation("androidx.security:security-crypto:1.1.0-alpha06")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.7.3")
// Testing (skeleton) // Testing (skeleton)
testImplementation("junit:junit:4.13.2") testImplementation("junit:junit:4.13.2")

View File

@@ -0,0 +1 @@
../../google-services.json

View File

@@ -2,6 +2,7 @@
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application <application
android:name=".HarheimerApplication" android:name=".HarheimerApplication"
@@ -10,12 +11,20 @@
android:theme="@style/Theme.HarheimerTC" android:theme="@style/Theme.HarheimerTC"
android:usesCleartextTraffic="${usesCleartextTraffic}"> android:usesCleartextTraffic="${usesCleartextTraffic}">
<activity android:name="de.harheimertc.MainActivity" <activity android:name="de.harheimertc.MainActivity"
android:exported="true"> android:exported="true"
android:launchMode="singleTop">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
</activity> </activity>
<service
android:name=".notifications.HarheimerMessagingService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
<provider <provider
android:name="androidx.core.content.FileProvider" android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.files" android:authorities="${applicationId}.files"

View File

@@ -6,6 +6,7 @@ import coil.ImageLoaderFactory
import coil.disk.DiskCache import coil.disk.DiskCache
import coil.memory.MemoryCache import coil.memory.MemoryCache
import dagger.hilt.android.HiltAndroidApp import dagger.hilt.android.HiltAndroidApp
import de.harheimertc.notifications.HarheimerNotifications
import io.sentry.Sentry import io.sentry.Sentry
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import javax.inject.Inject import javax.inject.Inject
@@ -19,6 +20,7 @@ class HarheimerApplication : Application(), ImageLoaderFactory {
override fun onCreate() { override fun onCreate() {
Log.d("HILT", "HarheimerApplication.onCreate called") Log.d("HILT", "HarheimerApplication.onCreate called")
super.onCreate() super.onCreate()
HarheimerNotifications.createChannels(this)
if (BuildConfig.SENTRY_DSN.isNotBlank()) { if (BuildConfig.SENTRY_DSN.isNotBlank()) {
Sentry.init { options -> Sentry.init { options ->
options.dsn = BuildConfig.SENTRY_DSN options.dsn = BuildConfig.SENTRY_DSN

View File

@@ -1,13 +1,20 @@
package de.harheimertc package de.harheimertc
import android.Manifest
import android.content.Intent
import android.os.Build
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import de.harheimertc.ui.navigation.NavGraph import de.harheimertc.ui.navigation.NavGraph
import de.harheimertc.ui.navigation.Destinations import de.harheimertc.ui.navigation.Destinations
import de.harheimertc.notifications.HarheimerNotifications
import de.harheimertc.ui.theme.HarheimerTheme import de.harheimertc.ui.theme.HarheimerTheme
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import de.harheimertc.ui.navigation.NavigationViewModel import de.harheimertc.ui.navigation.NavigationViewModel
@@ -17,22 +24,67 @@ import androidx.compose.ui.platform.LocalContext
@AndroidEntryPoint @AndroidEntryPoint
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private val notificationRoute = mutableStateOf<String?>(null)
private val notificationPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission(),
) { granted ->
Log.i("NOTIFICATIONS", "POST_NOTIFICATIONS granted=$granted")
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
requestNotificationPermissionIfNeeded()
notificationRoute.value = extractNotificationRoute(intent)
setContent { setContent {
App() App(
notificationRoute = notificationRoute.value,
onNotificationRouteConsumed = { notificationRoute.value = null },
)
} }
} }
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
notificationRoute.value = extractNotificationRoute(intent)
}
private fun extractNotificationRoute(intent: Intent?): String? =
intent?.getStringExtra(EXTRA_NOTIFICATION_ROUTE)?.takeIf { it.isNotBlank() }
private fun requestNotificationPermissionIfNeeded() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && !HarheimerNotifications.hasNotificationPermission(this)) {
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
companion object {
const val EXTRA_NOTIFICATION_TYPE = "de.harheimertc.extra.NOTIFICATION_TYPE"
const val EXTRA_NOTIFICATION_ROUTE = "de.harheimertc.extra.NOTIFICATION_ROUTE"
const val EXTRA_NEWS_ID = "de.harheimertc.extra.NEWS_ID"
}
} }
@Composable @Composable
fun App() { fun App(
notificationRoute: String? = null,
onNotificationRouteConsumed: () -> Unit = {},
) {
HarheimerTheme { HarheimerTheme {
val navController = rememberNavController() val navController = rememberNavController()
val ctx = LocalContext.current val ctx = LocalContext.current
val activity = ctx as? ComponentActivity val activity = ctx as? ComponentActivity
Log.i("HILT_FACTORY", "defaultViewModelProviderFactory=${activity?.defaultViewModelProviderFactory?.javaClass?.name}") Log.i("HILT_FACTORY", "defaultViewModelProviderFactory=${activity?.defaultViewModelProviderFactory?.javaClass?.name}")
val navigationViewModel: NavigationViewModel = hiltViewModel() val navigationViewModel: NavigationViewModel = hiltViewModel()
LaunchedEffect(notificationRoute) {
val route = notificationRoute?.takeIf { it.isNotBlank() } ?: return@LaunchedEffect
navController.navigate(route) {
launchSingleTop = true
popUpTo(Destinations.Home.route)
}
onNotificationRouteConsumed()
}
NavGraph(navController = navController, navigationViewModelParam = navigationViewModel) NavGraph(navController = navController, navigationViewModelParam = navigationViewModel)
} }
} }

View File

@@ -21,6 +21,7 @@ import okhttp3.RequestBody
data class ContactRequest(val name: String, val email: String, val message: String) data class ContactRequest(val name: String, val email: String, val message: String)
data class ContactResponse(val ok: Boolean, val id: String? = null, val message: String? = null) data class ContactResponse(val ok: Boolean, val id: String? = null, val message: String? = null)
data class TermineResponse(val success: Boolean = true, val termine: List<TerminDto> = emptyList()) data class TermineResponse(val success: Boolean = true, val termine: List<TerminDto> = emptyList())
data class TermineManageResponse(val success: Boolean = true, val termine: List<TerminDto> = emptyList())
data class TerminDto( data class TerminDto(
val datum: String = "", val datum: String = "",
val uhrzeit: String? = null, val uhrzeit: String? = null,
@@ -231,7 +232,7 @@ data class ProfileVisibilityDto(
val showEmail: Boolean = true, val showEmail: Boolean = true,
val showPhone: Boolean = true, val showPhone: Boolean = true,
val showAddress: Boolean = false, val showAddress: Boolean = false,
val showBirthday: Boolean = true, val showBirthday: Boolean = false,
) )
data class ProfileUserDto( data class ProfileUserDto(
val id: String? = null, val id: String? = null,
@@ -257,6 +258,30 @@ data class ProfileUpdateRequest(
val currentPassword: String? = null, val currentPassword: String? = null,
val newPassword: String? = null, val newPassword: String? = null,
) )
data class NotificationSettingsDto(
val newNews: Boolean = false,
val newEvents: Boolean = false,
val eventsToday: Boolean = false,
val eventsTomorrow: Boolean = false,
val ownTeamMatches: Boolean = false,
val allTeamMatches: Boolean = false,
val birthdays: Boolean = false,
val newContactRequest: Boolean = false,
val newUserRegistration: Boolean = false,
val selectedTeamSlugs: List<String> = emptyList(),
val selectedTeamSeason: String? = null,
val notificationTime: String = "09:00",
)
data class NotificationSettingsResponse(
val success: Boolean = false,
val message: String? = null,
val settings: NotificationSettingsDto = NotificationSettingsDto(),
)
data class PushTokenRequest(
val token: String,
val platform: String = "android",
val appVersion: String? = null,
)
data class BirthdayDto( data class BirthdayDto(
val name: String = "", val name: String = "",
val dayMonth: String = "", val dayMonth: String = "",
@@ -304,6 +329,7 @@ data class MemberDto(
val editable: Boolean = false, val editable: Boolean = false,
val isMannschaftsspieler: Boolean = false, val isMannschaftsspieler: Boolean = false,
val hasHallKey: Boolean = false, val hasHallKey: Boolean = false,
val showBirthday: Boolean = false,
val loginRoles: List<String> = emptyList(), val loginRoles: List<String> = emptyList(),
) )
data class MembersResponse( data class MembersResponse(
@@ -566,6 +592,21 @@ interface ApiService {
@GET("/api/termine") @GET("/api/termine")
suspend fun termine(): Response<TermineResponse> suspend fun termine(): Response<TermineResponse>
@GET("/api/termine-manage")
suspend fun termineManage(): Response<TermineManageResponse>
@POST("/api/termine-manage")
suspend fun saveTermin(@Body request: TerminDto): Response<AuthMessageResponse>
@DELETE("/api/termine-manage")
suspend fun deleteTermin(
@Query("datum") datum: String,
@Query("uhrzeit") uhrzeit: String = "",
@Query("titel") titel: String,
@Query("beschreibung") beschreibung: String = "",
@Query("kategorie") kategorie: String = "Sonstiges",
): Response<AuthMessageResponse>
@GET("/api/spielplan") @GET("/api/spielplan")
suspend fun spielplan(@Query("season") season: String? = null): Response<SpielplanResponse> suspend fun spielplan(@Query("season") season: String? = null): Response<SpielplanResponse>
@@ -660,6 +701,15 @@ interface ApiService {
@retrofit2.http.PUT("/api/profile") @retrofit2.http.PUT("/api/profile")
suspend fun updateProfile(@Body request: ProfileUpdateRequest): Response<ProfileResponse> suspend fun updateProfile(@Body request: ProfileUpdateRequest): Response<ProfileResponse>
@GET("/api/profile/notifications")
suspend fun notificationSettings(): Response<NotificationSettingsResponse>
@retrofit2.http.PUT("/api/profile/notifications")
suspend fun updateNotificationSettings(@Body request: NotificationSettingsDto): Response<NotificationSettingsResponse>
@POST("/api/profile/push-token")
suspend fun registerPushToken(@Body request: PushTokenRequest): Response<AuthMessageResponse>
@GET("/api/birthdays") @GET("/api/birthdays")
suspend fun birthdays(): Response<BirthdaysResponse> suspend fun birthdays(): Response<BirthdaysResponse>
@@ -680,6 +730,7 @@ interface ApiService {
val notes: String? = null, val notes: String? = null,
val isMannschaftsspieler: Boolean = false, val isMannschaftsspieler: Boolean = false,
val hasHallKey: Boolean = false, val hasHallKey: Boolean = false,
val showBirthday: Boolean = false,
) )
data class BulkImportRequest(val members: List<Map<String, String>>) data class BulkImportRequest(val members: List<Map<String, String>>)

View File

@@ -19,7 +19,7 @@ import javax.inject.Singleton
@Singleton @Singleton
class ConnectivityMonitor @Inject constructor( class ConnectivityMonitor @Inject constructor(
@ApplicationContext private val context: Context, @param:ApplicationContext private val context: Context,
) { ) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val _online = MutableStateFlow(hasInternetAccess()) private val _online = MutableStateFlow(hasInternetAccess())
@@ -46,4 +46,4 @@ class ConnectivityMonitor @Inject constructor(
val capabilities = manager.getNetworkCapabilities(network) ?: return false val capabilities = manager.getNetworkCapabilities(network) ?: return false
return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
} }
} }

View File

@@ -0,0 +1,43 @@
package de.harheimertc.notifications
import android.util.Log
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
import dagger.hilt.android.AndroidEntryPoint
import de.harheimertc.repositories.PushTokenRepository
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import javax.inject.Inject
@AndroidEntryPoint
class HarheimerMessagingService : FirebaseMessagingService() {
@Inject
lateinit var pushTokenRepository: PushTokenRepository
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
override fun onNewToken(token: String) {
super.onNewToken(token)
serviceScope.launch {
pushTokenRepository.registerToken(token)
}
}
override fun onMessageReceived(message: RemoteMessage) {
super.onMessageReceived(message)
val title = message.notification?.title
?: message.data["title"]
?: "Harheimer TC"
val body = message.notification?.body
?: message.data["body"]
?: message.data["message"]
?: return
val notificationId = message.data["notificationId"]?.toIntOrNull()
?: message.messageId?.hashCode()
?: System.currentTimeMillis().toInt()
val shown = HarheimerNotifications.showBasicNotification(this, notificationId, title, body, message.data)
Log.d("HarheimerMessaging", "Push message received type=${message.data["type"]}, shown=$shown")
}
}

View File

@@ -0,0 +1,87 @@
package de.harheimertc.notifications
import android.Manifest
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import de.harheimertc.MainActivity
import de.harheimertc.R
import de.harheimertc.ui.navigation.Destinations
object HarheimerNotifications {
const val DEFAULT_CHANNEL_ID = "harheimer_tc_updates"
fun createChannels(context: Context) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val channel = NotificationChannel(
DEFAULT_CHANNEL_ID,
"Harheimer TC",
NotificationManager.IMPORTANCE_DEFAULT,
).apply {
description = "Benachrichtigungen des Harheimer TC"
}
context.getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
}
fun hasNotificationPermission(context: Context): Boolean =
Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED
fun showBasicNotification(
context: Context,
notificationId: Int,
title: String,
message: String,
data: Map<String, String> = emptyMap(),
): Boolean {
if (!hasNotificationPermission(context)) return false
val notification = NotificationCompat.Builder(context, DEFAULT_CHANNEL_ID)
.setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle(title)
.setContentText(message)
.setStyle(NotificationCompat.BigTextStyle().bigText(message))
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setContentIntent(createContentIntent(context, notificationId, data))
.setAutoCancel(true)
.build()
return try {
NotificationManagerCompat.from(context).notify(notificationId, notification)
true
} catch (_: SecurityException) {
false
}
}
private fun createContentIntent(context: Context, notificationId: Int, payload: Map<String, String>): PendingIntent {
val route = destinationRoute(payload)
val intent = Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
putExtra(MainActivity.EXTRA_NOTIFICATION_TYPE, payload["type"])
putExtra(MainActivity.EXTRA_NOTIFICATION_ROUTE, route)
payload["newsId"]?.let { putExtra(MainActivity.EXTRA_NEWS_ID, it) }
}
return PendingIntent.getActivity(
context,
notificationId,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
}
private fun destinationRoute(data: Map<String, String>): String = when (data["type"]) {
"news", "news_expiring" -> Destinations.MemberNews.route
"event", "events_today", "events_tomorrow" -> Destinations.Termine.route
"team_matches" -> Destinations.Spielplan.route
"birthdays" -> Destinations.MemberArea.route
"contact_request" -> Destinations.CmsContactRequests.route
"user_registration" -> Destinations.CmsBenutzer.route
else -> Destinations.Home.route
}
}

View File

@@ -10,6 +10,8 @@ import de.harheimertc.data.PasswordResetDiagnosticsResponse
import de.harheimertc.data.SaveCsvRequest import de.harheimertc.data.SaveCsvRequest
import de.harheimertc.data.SaveCsvResponse import de.harheimertc.data.SaveCsvResponse
import de.harheimertc.data.SecureOfflineCache import de.harheimertc.data.SecureOfflineCache
import de.harheimertc.data.SpielplanResponse
import de.harheimertc.data.TerminDto
import javax.inject.Inject import javax.inject.Inject
class CmsRepository @Inject constructor( class CmsRepository @Inject constructor(
@@ -83,6 +85,86 @@ class CmsRepository @Inject constructor(
response.body() ?: SaveCsvResponse(success = false, message = "Leere Antwort") response.body() ?: SaveCsvResponse(success = false, message = "Leere Antwort")
} }
suspend fun managedTermine(): Result<List<TerminDto>> = runCatching {
val response = api.termineManage()
if (!response.isSuccessful) error("Termine konnten nicht geladen werden.")
response.body()?.termine.orEmpty()
}
suspend fun saveTermin(request: TerminDto): Result<de.harheimertc.data.AuthMessageResponse> = runCatching {
val response = api.saveTermin(request)
if (!response.isSuccessful) error("Termin konnte nicht gespeichert werden.")
response.body() ?: de.harheimertc.data.AuthMessageResponse(success = false, message = "Leere Antwort")
}
suspend fun deleteTermin(request: TerminDto): Result<de.harheimertc.data.AuthMessageResponse> = runCatching {
val response = api.deleteTermin(
datum = request.datum,
uhrzeit = request.uhrzeit.orEmpty(),
titel = request.titel,
beschreibung = request.beschreibung.orEmpty(),
kategorie = request.kategorie ?: "Sonstiges",
)
if (!response.isSuccessful) error("Termin konnte nicht gelöscht werden.")
response.body() ?: de.harheimertc.data.AuthMessageResponse(success = false, message = "Leere Antwort")
}
suspend fun mannschaften(season: String? = null): Result<List<CmsMannschaftRow>> = runCatching {
val response = api.mannschaften(season)
if (!response.isSuccessful) error("Mannschaften konnten nicht geladen werden.")
parseCsv(response.body()?.string().orEmpty()).drop(1).mapNotNull { values ->
if (values.size < 10 || values[0].isBlank()) return@mapNotNull null
CmsMannschaftRow(
mannschaft = values[0],
liga = values[1],
staffelleiter = values[2],
telefon = values[3],
heimspieltag = values[4],
spielsystem = values[5],
mannschaftsfuehrer = values[6],
spieler = values[7],
informationenLink = values[8],
letzteAktualisierung = values[9],
)
}
}
suspend fun mannschaftenSeasons(): Result<de.harheimertc.data.MannschaftenSeasonsResponse> = runCatching {
val response = api.mannschaftenSeasons()
if (!response.isSuccessful) error("Saisons konnten nicht geladen werden.")
response.body() ?: de.harheimertc.data.MannschaftenSeasonsResponse()
}
suspend fun saveMannschaften(season: String?, rows: List<CmsMannschaftRow>): Result<SaveCsvResponse> = runCatching {
val response = api.saveCsv(
SaveCsvRequest(
filename = season?.takeIf { it.isNotBlank() }?.let { "mannschaften_$it.csv" } ?: "mannschaften.csv",
content = rows.toMannschaftenCsv(),
),
)
if (!response.isSuccessful) error("Mannschaften konnten nicht gespeichert werden.")
response.body() ?: SaveCsvResponse(success = false, message = "Leere Antwort")
}
suspend fun spielplan(season: String? = null): Result<SpielplanResponse> = runCatching {
val response = api.spielplan(season)
if (!response.isSuccessful) error("Spielplan konnte nicht geladen werden.")
val body = response.body() ?: error("Leere Antwort")
if (!body.success) error(body.message ?: "Spielplan konnte nicht geladen werden.")
body
}
suspend fun saveSpielplan(headers: List<String>, rows: List<List<String>>): Result<SaveCsvResponse> = runCatching {
val response = api.saveCsv(
SaveCsvRequest(
filename = "spielplan.csv",
content = listOf(headers).plus(rows).joinToString("\n") { row -> row.toCsvRow(";") },
),
)
if (!response.isSuccessful) error("Spielplan konnte nicht gespeichert werden.")
response.body() ?: SaveCsvResponse(success = false, message = "Leere Antwort")
}
suspend fun users(): Result<CmsUsersResponse> = suspend fun users(): Result<CmsUsersResponse> =
fetchEncryptedFallback( fetchEncryptedFallback(
load = { load = {
@@ -288,6 +370,58 @@ class CmsRepository @Inject constructor(
} }
} }
data class CmsMannschaftRow(
val mannschaft: String = "",
val liga: String = "",
val staffelleiter: String = "",
val telefon: String = "",
val heimspieltag: String = "",
val spielsystem: String = "",
val mannschaftsfuehrer: String = "",
val spieler: String = "",
val informationenLink: String = "",
val letzteAktualisierung: String = "",
)
private fun List<CmsMannschaftRow>.toMannschaftenCsv(): String {
val header = listOf(
"Mannschaft",
"Liga",
"Staffelleiter",
"Telefon",
"Heimspieltag",
"Spielsystem",
"Mannschaftsführer",
"Spieler",
"Weitere Informationen Link",
"Letzte Aktualisierung",
).toCsvRow()
val rows = map { row ->
listOf(
row.mannschaft,
row.liga,
row.staffelleiter,
row.telefon,
row.heimspieltag,
row.spielsystem,
row.mannschaftsfuehrer,
row.spieler,
row.informationenLink,
row.letzteAktualisierung,
).toCsvRow()
}
return listOf(header).plus(rows).joinToString("\n")
}
private fun List<String>.toCsvRow(delimiter: String = ","): String =
joinToString(delimiter) { value -> value.csvEscape(delimiter) }
private fun String.csvEscape(delimiter: String): String {
val needsQuotes = contains(delimiter) || contains('"') || contains('\n') || contains('\r')
val escaped = replace("\"", "\"\"")
return if (needsQuotes) "\"$escaped\"" else escaped
}
private fun parseCsv(csv: String): List<List<String>> = private fun parseCsv(csv: String): List<List<String>> =
csv.lineSequence().filter(String::isNotBlank).map(::parseCsvLine).toList() csv.lineSequence().filter(String::isNotBlank).map(::parseCsvLine).toList()

View File

@@ -0,0 +1,135 @@
package de.harheimertc.repositories
import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
import de.harheimertc.data.ApiService
import de.harheimertc.data.NotificationSettingsDto
import javax.inject.Inject
import javax.inject.Singleton
private const val DEFAULT_NOTIFICATION_TIME = "09:00"
data class NotificationPreferences(
val newNews: Boolean = false,
val newEvents: Boolean = false,
val eventsToday: Boolean = false,
val eventsTomorrow: Boolean = false,
val ownTeamMatches: Boolean = false,
val allTeamMatches: Boolean = false,
val birthdays: Boolean = false,
val newContactRequest: Boolean = false,
val newUserRegistration: Boolean = false,
val selectedTeamSlugs: Set<String> = emptySet(),
val selectedTeamSeason: String? = null,
val notificationTime: String = DEFAULT_NOTIFICATION_TIME,
)
@Singleton
class NotificationPreferencesRepository @Inject constructor(
@param:ApplicationContext private val context: Context,
private val api: ApiService,
) {
private val preferences by lazy {
context.getSharedPreferences("harheimertc_notification_preferences", Context.MODE_PRIVATE)
}
fun loadLocal(): NotificationPreferences = NotificationPreferences(
newNews = preferences.getBoolean(KEY_NEW_NEWS, false),
newEvents = preferences.getBoolean(KEY_NEW_EVENTS, false),
eventsToday = preferences.getBoolean(KEY_EVENTS_TODAY, false),
eventsTomorrow = preferences.getBoolean(KEY_EVENTS_TOMORROW, false),
ownTeamMatches = preferences.getBoolean(KEY_OWN_TEAM_MATCHES, false),
allTeamMatches = preferences.getBoolean(KEY_ALL_TEAM_MATCHES, false),
birthdays = preferences.getBoolean(KEY_BIRTHDAYS, false),
newContactRequest = preferences.getBoolean(KEY_NEW_CONTACT_REQUEST, false),
newUserRegistration = preferences.getBoolean(KEY_NEW_USER_REGISTRATION, 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,
)
suspend fun loadRemote(): Result<NotificationPreferences> = runCatching {
retryOnNetworkFailure {
val response = api.notificationSettings()
if (!response.isSuccessful) error("Benachrichtigungseinstellungen konnten nicht geladen werden.")
val settings = response.body()?.settings?.toPreferences() ?: error("Leere Antwort")
saveLocal(settings)
settings
}
}
fun saveLocal(settings: NotificationPreferences) {
preferences.edit()
.putBoolean(KEY_NEW_NEWS, settings.newNews)
.putBoolean(KEY_NEW_EVENTS, settings.newEvents)
.putBoolean(KEY_EVENTS_TODAY, settings.eventsToday)
.putBoolean(KEY_EVENTS_TOMORROW, settings.eventsTomorrow)
.putBoolean(KEY_OWN_TEAM_MATCHES, settings.ownTeamMatches)
.putBoolean(KEY_ALL_TEAM_MATCHES, settings.allTeamMatches)
.putBoolean(KEY_BIRTHDAYS, settings.birthdays)
.putBoolean(KEY_NEW_CONTACT_REQUEST, settings.newContactRequest)
.putBoolean(KEY_NEW_USER_REGISTRATION, settings.newUserRegistration)
.putStringSet(KEY_SELECTED_TEAM_SLUGS, settings.selectedTeamSlugs)
.putString(KEY_SELECTED_TEAM_SEASON, settings.selectedTeamSeason)
.putString(KEY_NOTIFICATION_TIME, settings.notificationTime)
.apply()
}
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)
saved
}
}
}
private companion object {
const val KEY_NEW_NEWS = "new_news"
const val KEY_NEW_EVENTS = "new_events"
const val KEY_EVENTS_TODAY = "events_today"
const val KEY_EVENTS_TOMORROW = "events_tomorrow"
const val KEY_OWN_TEAM_MATCHES = "own_team_matches"
const val KEY_ALL_TEAM_MATCHES = "all_team_matches"
const val KEY_BIRTHDAYS = "birthdays"
const val KEY_NEW_CONTACT_REQUEST = "new_contact_request"
const val KEY_NEW_USER_REGISTRATION = "new_user_registration"
const val KEY_SELECTED_TEAM_SLUGS = "selected_team_slugs"
const val KEY_SELECTED_TEAM_SEASON = "selected_team_season"
const val KEY_NOTIFICATION_TIME = "notification_time"
}
}
private fun NotificationSettingsDto.toPreferences(): NotificationPreferences = NotificationPreferences(
newNews = newNews,
newEvents = newEvents,
eventsToday = eventsToday,
eventsTomorrow = eventsTomorrow,
ownTeamMatches = ownTeamMatches,
allTeamMatches = allTeamMatches,
birthdays = birthdays,
newContactRequest = newContactRequest,
newUserRegistration = newUserRegistration,
selectedTeamSlugs = selectedTeamSlugs.toSet(),
selectedTeamSeason = selectedTeamSeason,
notificationTime = notificationTime,
)
private fun NotificationPreferences.toDto(): NotificationSettingsDto = NotificationSettingsDto(
newNews = newNews,
newEvents = newEvents,
eventsToday = eventsToday,
eventsTomorrow = eventsTomorrow,
ownTeamMatches = ownTeamMatches,
allTeamMatches = allTeamMatches,
birthdays = birthdays,
newContactRequest = newContactRequest,
newUserRegistration = newUserRegistration,
selectedTeamSlugs = selectedTeamSlugs.toList(),
selectedTeamSeason = selectedTeamSeason,
notificationTime = notificationTime,
)

View File

@@ -0,0 +1,35 @@
package de.harheimertc.repositories
import android.util.Log
import com.google.firebase.messaging.FirebaseMessaging
import de.harheimertc.BuildConfig
import de.harheimertc.data.ApiService
import de.harheimertc.data.PushTokenRequest
import kotlinx.coroutines.tasks.await
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class PushTokenRepository @Inject constructor(
private val api: ApiService,
) {
suspend fun registerCurrentDevice(): Result<Unit> = runCatching {
val token = FirebaseMessaging.getInstance().token.await()
registerToken(token).getOrThrow()
}
suspend fun registerToken(token: String): Result<Unit> = runCatching {
if (token.isBlank()) return@runCatching
retryOnNetworkFailure {
val response = api.registerPushToken(
PushTokenRequest(
token = token,
appVersion = "${BuildConfig.VERSION_NAME}+${BuildConfig.VERSION_CODE}",
),
)
if (!response.isSuccessful) error("Push-Token konnte nicht registriert werden.")
}
}.onFailure { error ->
Log.w("PushTokenRepository", "Push-Token Registrierung fehlgeschlagen", error)
}
}

View File

@@ -416,11 +416,13 @@ private fun menuSection(route: String?): MenuSection? = when (route) {
Destinations.Qttr.route, Destinations.Qttr.route,
Destinations.MemberNews.route, Destinations.MemberNews.route,
Destinations.Profile.route, Destinations.Profile.route,
Destinations.NotificationSettings.route,
Destinations.MemberApi.route -> MenuSection.INTERN Destinations.MemberApi.route -> MenuSection.INTERN
Destinations.CmsStartseite.route, Destinations.CmsStartseite.route,
Destinations.CmsInhalte.route, Destinations.CmsInhalte.route,
Destinations.CmsVereinsmeisterschaften.route, Destinations.CmsVereinsmeisterschaften.route,
Destinations.CmsNews.route,
Destinations.CmsSportbetrieb.route, Destinations.CmsSportbetrieb.route,
Destinations.CmsMitgliederverwaltung.route, Destinations.CmsMitgliederverwaltung.route,
Destinations.CmsNewsletter.route, Destinations.CmsNewsletter.route,
@@ -473,6 +475,7 @@ private fun submenu(section: MenuSection?, state: NavigationUiState): List<MenuT
add(MenuTarget("QTTR", Destinations.Qttr.route)) add(MenuTarget("QTTR", Destinations.Qttr.route))
add(MenuTarget("News", Destinations.MemberNews.route)) add(MenuTarget("News", Destinations.MemberNews.route))
add(MenuTarget("Mein Profil", Destinations.Profile.route)) add(MenuTarget("Mein Profil", Destinations.Profile.route))
add(MenuTarget("Benachrichtigungen", Destinations.NotificationSettings.route))
add(MenuTarget("API-Dokumentation", Destinations.MemberApi.route)) add(MenuTarget("API-Dokumentation", Destinations.MemberApi.route))
} }
@@ -482,7 +485,7 @@ private fun submenu(section: MenuSection?, state: NavigationUiState): List<MenuT
add(MenuTarget("Startseite", Destinations.CmsStartseite.route)) add(MenuTarget("Startseite", Destinations.CmsStartseite.route))
add(MenuTarget("Inhalte", Destinations.CmsInhalte.route)) add(MenuTarget("Inhalte", Destinations.CmsInhalte.route))
add(MenuTarget("Vereinsmeisterschaften", Destinations.CmsVereinsmeisterschaften.route)) add(MenuTarget("Vereinsmeisterschaften", Destinations.CmsVereinsmeisterschaften.route))
add(MenuTarget("News", Destinations.MemberNews.route)) add(MenuTarget("News", Destinations.CmsNews.route))
add(MenuTarget("Sportbetrieb", Destinations.CmsSportbetrieb.route)) add(MenuTarget("Sportbetrieb", Destinations.CmsSportbetrieb.route))
add(MenuTarget("Mitgliederverwaltung", Destinations.CmsMitgliederverwaltung.route)) add(MenuTarget("Mitgliederverwaltung", Destinations.CmsMitgliederverwaltung.route))
add(MenuTarget("Einstellungen", Destinations.CmsEinstellungen.route)) add(MenuTarget("Einstellungen", Destinations.CmsEinstellungen.route))

View File

@@ -10,8 +10,12 @@ sealed class Destinations(val route: String) {
object Links : Destinations("verein/links") object Links : Destinations("verein/links")
object Impressum : Destinations("impressum") object Impressum : Destinations("impressum")
object Mannschaften : Destinations("mannschaften") object Mannschaften : Destinations("mannschaften")
object MannschaftDetail : Destinations("mannschaften/{slug}") { object MannschaftDetail : Destinations("mannschaften/{slug}?season={season}") {
fun create(slug: String): String = "mannschaften/$slug" fun create(slug: String, season: String? = null): String {
val encodedSlug = android.net.Uri.encode(slug)
val selectedSeason = season?.takeIf { it.isNotBlank() } ?: return "mannschaften/$encodedSlug"
return "mannschaften/$encodedSlug?season=${android.net.Uri.encode(selectedSeason)}"
}
} }
object MannschaftLegacyDetail : Destinations("mannschaft/{slug}") { object MannschaftLegacyDetail : Destinations("mannschaft/{slug}") {
fun create(slug: String): String = "mannschaft/$slug" fun create(slug: String): String = "mannschaft/$slug"
@@ -39,10 +43,12 @@ sealed class Destinations(val route: String) {
object Qttr : Destinations("intern/qttr") object Qttr : Destinations("intern/qttr")
object MemberNews : Destinations("intern/news") object MemberNews : Destinations("intern/news")
object Profile : Destinations("intern/profil") object Profile : Destinations("intern/profil")
object NotificationSettings : Destinations("intern/benachrichtigungen")
object MemberApi : Destinations("intern/api") object MemberApi : Destinations("intern/api")
object CmsStartseite : Destinations("cms/startseite") object CmsStartseite : Destinations("cms/startseite")
object CmsInhalte : Destinations("cms/inhalte") object CmsInhalte : Destinations("cms/inhalte")
object CmsVereinsmeisterschaften : Destinations("cms/vereinsmeisterschaften") object CmsVereinsmeisterschaften : Destinations("cms/vereinsmeisterschaften")
object CmsNews : Destinations("cms/news")
object CmsSportbetrieb : Destinations("cms/sportbetrieb") object CmsSportbetrieb : Destinations("cms/sportbetrieb")
object CmsMitgliederverwaltung : Destinations("cms/mitgliederverwaltung") object CmsMitgliederverwaltung : Destinations("cms/mitgliederverwaltung")
object CmsNewsletter : Destinations("cms/newsletter") object CmsNewsletter : Destinations("cms/newsletter")

View File

@@ -17,6 +17,7 @@ import androidx.navigation.NavHostController
import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.navArgument
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -32,7 +33,9 @@ fun NavGraph(
val backStackEntry = navController.currentBackStackEntryAsState().value val backStackEntry = navController.currentBackStackEntryAsState().value
val route = backStackEntry?.destination?.route val route = backStackEntry?.destination?.route
val currentRoute = if (route == Destinations.MannschaftDetail.route) { val currentRoute = if (route == Destinations.MannschaftDetail.route) {
backStackEntry.arguments?.getString("slug")?.let(Destinations.MannschaftDetail::create) backStackEntry.arguments?.getString("slug")?.let { slug ->
Destinations.MannschaftDetail.create(slug, backStackEntry.arguments?.getString("season"))
}
} else route } else route
val navigationState by navigationViewModel.state.collectAsState() val navigationState by navigationViewModel.state.collectAsState()
LaunchedEffect(currentRoute) { LaunchedEffect(currentRoute) {
@@ -136,9 +139,13 @@ fun NavGraph(
composable("mannschaften/jugend") { composable("mannschaften/jugend") {
de.harheimertc.ui.screens.mannschaften.MannschaftenScreen(navController, !persistentNavigation) de.harheimertc.ui.screens.mannschaften.MannschaftenScreen(navController, !persistentNavigation)
} }
composable(Destinations.MannschaftDetail.route) { entry -> composable(
route = Destinations.MannschaftDetail.route,
arguments = listOf(navArgument("season") { nullable = true; defaultValue = null }),
) { entry ->
de.harheimertc.ui.screens.mannschaften.MannschaftDetailScreen( de.harheimertc.ui.screens.mannschaften.MannschaftDetailScreen(
slug = entry.arguments?.getString("slug").orEmpty(), slug = entry.arguments?.getString("slug").orEmpty(),
season = entry.arguments?.getString("season"),
navController = navController, navController = navController,
showBackNavigation = !persistentNavigation, showBackNavigation = !persistentNavigation,
) )
@@ -146,6 +153,7 @@ fun NavGraph(
composable(Destinations.MannschaftLegacyDetail.route) { entry -> composable(Destinations.MannschaftLegacyDetail.route) { entry ->
de.harheimertc.ui.screens.mannschaften.MannschaftDetailScreen( de.harheimertc.ui.screens.mannschaften.MannschaftDetailScreen(
slug = entry.arguments?.getString("slug").orEmpty(), slug = entry.arguments?.getString("slug").orEmpty(),
season = null,
navController = navController, navController = navController,
showBackNavigation = !persistentNavigation, showBackNavigation = !persistentNavigation,
) )
@@ -305,6 +313,13 @@ fun NavGraph(
showBackNavigation = !persistentNavigation, showBackNavigation = !persistentNavigation,
) )
} }
composable(Destinations.NotificationSettings.route) {
de.harheimertc.ui.screens.notifications.NotificationSettingsScreen(
navController = navController,
showBackNavigation = !persistentNavigation,
navigationState = navigationState,
)
}
composable(Destinations.MemberApi.route) { composable(Destinations.MemberApi.route) {
de.harheimertc.ui.screens.memberarea.MemberApiScreen( de.harheimertc.ui.screens.memberarea.MemberApiScreen(
navController = navController, navController = navController,
@@ -320,6 +335,9 @@ fun NavGraph(
composable(Destinations.CmsVereinsmeisterschaften.route) { composable(Destinations.CmsVereinsmeisterschaften.route) {
de.harheimertc.ui.screens.cms.CmsVereinsmeisterschaftenScreen(navController, !persistentNavigation) de.harheimertc.ui.screens.cms.CmsVereinsmeisterschaftenScreen(navController, !persistentNavigation)
} }
composable(Destinations.CmsNews.route) {
de.harheimertc.ui.screens.cms.CmsNewsScreen(navController, !persistentNavigation)
}
composable(Destinations.CmsSportbetrieb.route) { composable(Destinations.CmsSportbetrieb.route) {
de.harheimertc.ui.screens.cms.CmsSportbetriebScreen(navController, !persistentNavigation) de.harheimertc.ui.screens.cms.CmsSportbetriebScreen(navController, !persistentNavigation)
} }

View File

@@ -9,6 +9,7 @@ import de.harheimertc.repositories.GalleryRepository
import de.harheimertc.repositories.LoginRepository import de.harheimertc.repositories.LoginRepository
import de.harheimertc.repositories.Mannschaft import de.harheimertc.repositories.Mannschaft
import de.harheimertc.repositories.MannschaftenRepository import de.harheimertc.repositories.MannschaftenRepository
import de.harheimertc.repositories.PushTokenRepository
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -37,6 +38,7 @@ class NavigationViewModel @Inject constructor(
private val loginRepository: LoginRepository, private val loginRepository: LoginRepository,
private val authRepository: AuthRepository, private val authRepository: AuthRepository,
private val connectivityMonitor: ConnectivityMonitor, private val connectivityMonitor: ConnectivityMonitor,
private val pushTokenRepository: PushTokenRepository,
) : ViewModel() { ) : ViewModel() {
private val _state = MutableStateFlow(NavigationUiState()) private val _state = MutableStateFlow(NavigationUiState())
val state: StateFlow<NavigationUiState> = _state val state: StateFlow<NavigationUiState> = _state
@@ -61,13 +63,15 @@ class NavigationViewModel @Inject constructor(
val auth = async { loginRepository.status().getOrDefault(de.harheimertc.data.AuthStatusResponse()) } val auth = async { loginRepository.status().getOrDefault(de.harheimertc.data.AuthStatusResponse()) }
val status = auth.await() val status = auth.await()
val hasStoredSession = !authRepository.getToken().isNullOrBlank() val hasStoredSession = !authRepository.getToken().isNullOrBlank()
val loggedIn = hasStoredSession || status.isLoggedIn
_state.value = NavigationUiState( _state.value = NavigationUiState(
teams = teams.await(), teams = teams.await(),
hasGalleryImages = gallery.await(), hasGalleryImages = gallery.await(),
loggedIn = hasStoredSession || status.isLoggedIn, loggedIn = loggedIn,
roles = status.navigationRoles(), roles = status.navigationRoles(),
connectionNote = null, connectionNote = null,
) )
if (loggedIn) registerPushToken()
} }
} }
@@ -75,11 +79,19 @@ class NavigationViewModel @Inject constructor(
viewModelScope.launch { viewModelScope.launch {
val status = loginRepository.status().getOrDefault(de.harheimertc.data.AuthStatusResponse()) val status = loginRepository.status().getOrDefault(de.harheimertc.data.AuthStatusResponse())
val hasStoredSession = !authRepository.getToken().isNullOrBlank() val hasStoredSession = !authRepository.getToken().isNullOrBlank()
val loggedIn = hasStoredSession || status.isLoggedIn
_state.value = _state.value.copy( _state.value = _state.value.copy(
loggedIn = hasStoredSession || status.isLoggedIn, loggedIn = loggedIn,
roles = status.navigationRoles(), roles = status.navigationRoles(),
connectionNote = _state.value.connectionNote, connectionNote = _state.value.connectionNote,
) )
if (loggedIn) registerPushToken()
}
}
private fun registerPushToken() {
viewModelScope.launch {
pushTokenRepository.registerCurrentDevice()
} }
} }

View File

@@ -1,8 +1,10 @@
package de.harheimertc.ui.screens.cms package de.harheimertc.ui.screens.cms
import android.content.Intent import android.content.Intent
import android.app.DatePickerDialog
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@@ -36,6 +38,8 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Checkbox import androidx.compose.material3.Checkbox
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.navigation.NavController import androidx.navigation.NavController
@@ -48,10 +52,12 @@ import de.harheimertc.data.NewsletterGroupDto
import de.harheimertc.data.PasswordResetMatchingUserDto import de.harheimertc.data.PasswordResetMatchingUserDto
import de.harheimertc.data.PasswordResetAttemptDto import de.harheimertc.data.PasswordResetAttemptDto
import de.harheimertc.data.PasswordResetStepDto import de.harheimertc.data.PasswordResetStepDto
import de.harheimertc.data.TerminDto
import de.harheimertc.ui.components.FormMessages import de.harheimertc.ui.components.FormMessages
import de.harheimertc.ui.components.LoadingState import de.harheimertc.ui.components.LoadingState
import de.harheimertc.ui.components.NativeRichTextEditor import de.harheimertc.ui.components.NativeRichTextEditor
import de.harheimertc.ui.navigation.Destinations import de.harheimertc.ui.navigation.Destinations
import de.harheimertc.repositories.CmsMannschaftRow
import de.harheimertc.repositories.MeisterschaftResult import de.harheimertc.repositories.MeisterschaftResult
import de.harheimertc.ui.theme.Accent100 import de.harheimertc.ui.theme.Accent100
import de.harheimertc.ui.theme.Accent500 import de.harheimertc.ui.theme.Accent500
@@ -61,6 +67,7 @@ import de.harheimertc.ui.theme.Primary100
import de.harheimertc.ui.theme.Primary600 import de.harheimertc.ui.theme.Primary600
import java.time.OffsetDateTime import java.time.OffsetDateTime
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import java.util.Calendar
import java.util.Locale import java.util.Locale
@Composable @Composable
@@ -433,116 +440,362 @@ fun CmsVereinsmeisterschaftenScreen(navController: NavController, showBackNaviga
@Composable @Composable
fun CmsSportbetriebScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) { fun CmsSportbetriebScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) {
val state by viewModel.state.collectAsState() val state by viewModel.state.collectAsState()
val config = state.config val context = LocalContext.current
var ortName by remember { mutableStateOf("") } var activeTab by remember { mutableStateOf("termine") }
var ortStrasse by remember { mutableStateOf("") } val mannschaften = remember { mutableStateListOf<CmsMannschaftRow>() }
var ortPlz by remember { mutableStateOf("") } var spielplanCsv by remember { mutableStateOf("") }
var ortOrt by remember { mutableStateOf("") } var spielplanEditorOpen by remember { mutableStateOf(false) }
val trainingTimes = remember { mutableStateListOf<de.harheimertc.data.TrainingTimeDto>() } var terminDialogOpen by remember { mutableStateOf(false) }
val trainers = remember { mutableStateListOf<de.harheimertc.data.TrainerDto>() } var editingTermin by remember { mutableStateOf<TerminDto?>(null) }
var terminDatum by remember { mutableStateOf("") }
var terminUhrzeit by remember { mutableStateOf("") }
var terminTitel by remember { mutableStateOf("") }
var terminBeschreibung by remember { mutableStateOf("") }
var terminKategorie by remember { mutableStateOf("Sonstiges") }
var terminKategorieOpen by remember { mutableStateOf(false) }
val tabs = listOf(
"termine" to "Termine",
"mannschaften" to "Mannschaften",
"spielplaene" to "Spielpläne",
)
val terminKategorien = listOf("Training", "Punktspiel", "Turnier", "Veranstaltung", "Sonstiges")
LaunchedEffect(config) { LaunchedEffect(Unit) {
config?.let { viewModel.loadSportbetrieb()
ortName = it.training.ort.name
ortStrasse = it.training.ort.strasse
ortPlz = it.training.ort.plz
ortOrt = it.training.ort.ort
trainingTimes.clear()
trainingTimes.addAll(it.training.zeiten)
trainers.clear()
trainers.addAll(it.trainer)
}
} }
CmsPage(navController, showBackNavigation, "Sportbetrieb", "Trainingszeiten, Trainingsort und Trainer pflegen") { LaunchedEffect(state.sportMannschaften) {
when { mannschaften.clear()
state.loading || config == null -> item { LoadingState("Sportbetriebsdaten werden geladen...") } mannschaften.addAll(state.sportMannschaften)
else -> { }
item {
Button( LaunchedEffect(state.sportSpielplanHeaders, state.sportSpielplanRows) {
onClick = { spielplanCsv = sportSpielplanCsvText(state.sportSpielplanHeaders, state.sportSpielplanRows)
viewModel.saveConfig( }
config.copy(
training = config.training.copy( fun openTerminDialog(termin: TerminDto?) {
ort = config.training.ort.copy( editingTermin = termin
name = ortName, terminDatum = termin?.datum.orEmpty()
strasse = ortStrasse, terminUhrzeit = termin?.uhrzeit.orEmpty()
plz = ortPlz, terminTitel = termin?.titel.orEmpty()
ort = ortOrt, terminBeschreibung = termin?.beschreibung.orEmpty()
), terminKategorie = termin?.kategorie ?: "Sonstiges"
zeiten = trainingTimes.toList(), terminDialogOpen = true
), }
trainer = trainers.toList(),
), fun openDatePicker() {
) val calendar = Calendar.getInstance()
}, runCatching {
enabled = !state.saving, val parts = terminDatum.split("-")
modifier = Modifier.fillMaxWidth(), if (parts.size == 3) {
) { calendar.set(parts[0].toInt(), parts[1].toInt() - 1, parts[2].toInt())
Text(if (state.saving) "Speichert..." else "Speichern")
}
}
item {
DataCard("Trainingsort") {
OutlinedTextField(value = ortName, onValueChange = { ortName = it }, label = { Text("Name") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(value = ortStrasse, onValueChange = { ortStrasse = it }, label = { Text("Straße") }, modifier = Modifier.fillMaxWidth())
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) {
OutlinedTextField(value = ortPlz, onValueChange = { ortPlz = it }, label = { Text("PLZ") }, modifier = Modifier.weight(1f))
OutlinedTextField(value = ortOrt, onValueChange = { ortOrt = it }, label = { Text("Ort") }, modifier = Modifier.weight(1f))
}
}
}
item {
DataCard("Trainingszeiten") {
trainingTimes.forEachIndexed { index, zeit ->
TrainingTimeEditorCard(
zeit = zeit,
onChange = { updated -> trainingTimes[index] = updated },
onRemove = { trainingTimes.removeAt(index) },
)
}
Button(
onClick = {
trainingTimes.add(
de.harheimertc.data.TrainingTimeDto(
id = "training-${System.currentTimeMillis()}",
tag = "Montag",
),
)
},
modifier = Modifier.fillMaxWidth(),
) {
Text("Trainingszeit hinzufügen")
}
}
}
item {
DataCard("Trainer") {
trainers.forEachIndexed { index, trainer ->
TrainerEditorCard(
trainer = trainer,
onChange = { updated -> trainers[index] = updated },
onRemove = { trainers.removeAt(index) },
)
}
Button(
onClick = {
trainers.add(
de.harheimertc.data.TrainerDto(
id = "trainer-${System.currentTimeMillis()}",
),
)
},
modifier = Modifier.fillMaxWidth(),
) {
Text("Trainer hinzufügen")
}
}
}
item { FormMessages(state.error, state.message) }
} }
} }
DatePickerDialog(
context,
{ _, year, month, day ->
terminDatum = "%04d-%02d-%02d".format(Locale.ROOT, year, month + 1, day)
},
calendar.get(Calendar.YEAR),
calendar.get(Calendar.MONTH),
calendar.get(Calendar.DAY_OF_MONTH),
).show()
} }
CmsPage(navController, showBackNavigation, "Sportbetrieb", "Termine, Mannschaften und Spielpläne") {
if (state.sportLoading) item { LoadingState("Sportbetriebsdaten werden geladen...") }
item {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
tabs.forEach { (id, label) ->
if (activeTab == id) {
Button(onClick = { activeTab = id }, modifier = Modifier.weight(1f)) { Text(label) }
} else {
OutlinedButton(onClick = { activeTab = id }, modifier = Modifier.weight(1f)) { Text(label) }
}
}
}
}
if (!state.sportLoading) {
when (activeTab) {
"termine" -> {
item {
Button(
onClick = { openTerminDialog(null) },
enabled = !state.sportSaving,
modifier = Modifier.fillMaxWidth(),
) {
Text("Termin hinzufügen")
}
}
if (state.sportTermine.isEmpty()) {
item { EmptyCard("Keine Termine gefunden.") }
}
items(state.sportTermine.size) { index ->
val termin = state.sportTermine[index]
DataCard(termin.titel.ifBlank { "Termin" }) {
InfoRow("Datum", listOf(termin.datum, termin.uhrzeit.orEmpty()).filter(String::isNotBlank).joinToString(" "))
InfoRow("Kategorie", termin.kategorie ?: "Sonstiges")
if (!termin.beschreibung.isNullOrBlank()) {
Text(termin.beschreibung, color = Accent700)
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
OutlinedButton(onClick = { openTerminDialog(termin) }, modifier = Modifier.weight(1f)) {
Text("Bearbeiten")
}
TextButton(
onClick = { viewModel.deleteSportTermin(termin) },
enabled = !state.sportSaving,
modifier = Modifier.weight(1f),
) {
Text("Löschen")
}
}
}
}
}
"mannschaften" -> {
item {
if (state.sportMannschaftenSeasons.isNotEmpty()) {
DataCard("Saison") {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
state.sportMannschaftenSeasons.forEach { season ->
if (season == state.sportMannschaftenSeason) {
Button(onClick = { }, modifier = Modifier.weight(1f)) { Text(season) }
} else {
OutlinedButton(
onClick = { viewModel.loadSportMannschaftenSeason(season) },
modifier = Modifier.weight(1f),
) {
Text(season)
}
}
}
}
}
}
}
item {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
Button(
onClick = {
mannschaften.add(CmsMannschaftRow(letzteAktualisierung = OffsetDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE)))
},
modifier = Modifier.weight(1f),
) {
Text("Hinzufügen")
}
Button(
onClick = { viewModel.saveSportMannschaften(state.sportMannschaftenSeason, mannschaften.toList()) },
enabled = !state.sportSaving,
modifier = Modifier.weight(1f),
) {
Text(if (state.sportSaving) "Speichert..." else "Speichern")
}
}
}
if (mannschaften.isEmpty()) {
item { EmptyCard("Keine Mannschaften gefunden.") }
}
items(mannschaften.size) { index ->
MannschaftEditorCard(
row = mannschaften[index],
onChange = { updated -> mannschaften[index] = updated },
onRemove = { mannschaften.removeAt(index) },
)
}
}
"spielplaene" -> {
item {
DataCard("Vereins-Spielplan (CSV)") {
val seasonLabel = state.sportSpielplanSeason.ifBlank { "aktuelle Saison" }
val fileName = state.sportSpielplanSeason.takeIf { it.isNotBlank() }?.let { "spielplan-$it.json" } ?: "spielplan.csv"
InfoRow("Datei", fileName)
InfoRow("Saison", seasonLabel)
InfoRow("Einträge", state.sportSpielplanRows.size.toString())
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
OutlinedButton(
onClick = { viewModel.loadSportbetrieb() },
modifier = Modifier.weight(1f),
) {
Text("Neu laden")
}
Button(
onClick = { spielplanEditorOpen = true },
modifier = Modifier.weight(1f),
) {
Text("CSV bearbeiten")
}
}
}
}
}
}
item { FormMessages(state.error, state.message) }
}
}
if (terminDialogOpen) {
AlertDialog(
onDismissRequest = { terminDialogOpen = false },
title = { Text(if (editingTermin == null) "Termin hinzufügen" else "Termin bearbeiten") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(onClick = { openDatePicker() }, modifier = Modifier.fillMaxWidth()) {
Text(terminDatum.ifBlank { "Datum auswählen" })
}
OutlinedTextField(value = terminUhrzeit, onValueChange = { terminUhrzeit = it }, label = { Text("Uhrzeit") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(value = terminTitel, onValueChange = { terminTitel = it }, label = { Text("Titel") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(value = terminBeschreibung, onValueChange = { terminBeschreibung = it }, label = { Text("Beschreibung") }, modifier = Modifier.fillMaxWidth(), minLines = 3)
Box(modifier = Modifier.fillMaxWidth()) {
OutlinedButton(onClick = { terminKategorieOpen = true }, modifier = Modifier.fillMaxWidth()) {
Text(terminKategorie.ifBlank { "Kategorie auswählen" })
}
DropdownMenu(expanded = terminKategorieOpen, onDismissRequest = { terminKategorieOpen = false }) {
terminKategorien.forEach { kategorie ->
DropdownMenuItem(
text = { Text(kategorie) },
onClick = {
terminKategorie = kategorie
terminKategorieOpen = false
},
)
}
}
}
}
},
confirmButton = {
Button(
onClick = {
viewModel.saveSportTermin(
editingTermin,
TerminDto(
datum = terminDatum,
uhrzeit = terminUhrzeit.takeIf { it.isNotBlank() },
titel = terminTitel,
beschreibung = terminBeschreibung.takeIf { it.isNotBlank() },
kategorie = terminKategorie.ifBlank { "Sonstiges" },
),
)
terminDialogOpen = false
},
enabled = !state.sportSaving && terminDatum.isNotBlank() && terminTitel.isNotBlank(),
) {
Text(if (state.sportSaving) "Speichert..." else "Speichern")
}
},
dismissButton = {
TextButton(onClick = { terminDialogOpen = false }) { Text("Abbrechen") }
},
)
}
if (spielplanEditorOpen) {
AlertDialog(
onDismissRequest = { spielplanEditorOpen = false },
title = { Text("Spielplan CSV bearbeiten") },
text = {
OutlinedTextField(
value = spielplanCsv,
onValueChange = { spielplanCsv = it },
label = { Text("CSV mit Semikolon") },
modifier = Modifier.fillMaxWidth(),
minLines = 12,
)
},
confirmButton = {
Button(
onClick = {
val (headers, rows) = parseSportCsvText(spielplanCsv)
viewModel.saveSportSpielplan(headers, rows)
spielplanEditorOpen = false
},
enabled = !state.sportSaving && spielplanCsv.isNotBlank(),
) {
Text(if (state.sportSaving) "Speichert..." else "Speichern")
}
},
dismissButton = {
TextButton(onClick = { spielplanEditorOpen = false }) { Text("Abbrechen") }
},
)
}
}
@Composable
private fun MannschaftEditorCard(
row: CmsMannschaftRow,
onChange: (CmsMannschaftRow) -> Unit,
onRemove: () -> Unit,
) {
DataCard(row.mannschaft.ifBlank { "Mannschaft" }) {
OutlinedTextField(value = row.mannschaft, onValueChange = { onChange(row.copy(mannschaft = it)) }, label = { Text("Mannschaft") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(value = row.liga, onValueChange = { onChange(row.copy(liga = it)) }, label = { Text("Liga") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(value = row.staffelleiter, onValueChange = { onChange(row.copy(staffelleiter = it)) }, label = { Text("Staffelleiter") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(value = row.telefon, onValueChange = { onChange(row.copy(telefon = it)) }, label = { Text("Telefon") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(value = row.heimspieltag, onValueChange = { onChange(row.copy(heimspieltag = it)) }, label = { Text("Heimspieltag") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(value = row.spielsystem, onValueChange = { onChange(row.copy(spielsystem = it)) }, label = { Text("Spielsystem") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(value = row.mannschaftsfuehrer, onValueChange = { onChange(row.copy(mannschaftsfuehrer = it)) }, label = { Text("Mannschaftsführer") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(value = row.spieler, onValueChange = { onChange(row.copy(spieler = it)) }, label = { Text("Spieler") }, modifier = Modifier.fillMaxWidth(), minLines = 2)
OutlinedTextField(value = row.informationenLink, onValueChange = { onChange(row.copy(informationenLink = it)) }, label = { Text("Weitere Informationen Link") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(value = row.letzteAktualisierung, onValueChange = { onChange(row.copy(letzteAktualisierung = it)) }, label = { Text("Letzte Aktualisierung") }, modifier = Modifier.fillMaxWidth())
TextButton(onClick = onRemove, modifier = Modifier.fillMaxWidth()) {
Text("Entfernen")
}
}
}
private fun sportSpielplanCsvText(headers: List<String>, rows: List<List<String>>): String {
if (headers.isEmpty()) return ""
return listOf(headers).plus(rows).joinToString("\n") { row -> row.joinToString(";") { it.csvCell(";") } }
}
private fun parseSportCsvText(text: String): Pair<List<String>, List<List<String>>> {
val lines = text.lineSequence().filter { it.isNotBlank() }.toList()
if (lines.isEmpty()) return emptyList<String>() to emptyList()
val headers = parseDelimitedLine(lines.first(), ';')
val rows = lines.drop(1).map { parseDelimitedLine(it, ';') }
return headers to rows
}
private fun parseDelimitedLine(line: String, delimiter: Char): List<String> {
val values = mutableListOf<String>()
val value = StringBuilder()
var quoted = false
var index = 0
while (index < line.length) {
when (val char = line[index]) {
'"' -> {
if (quoted && index + 1 < line.length && line[index + 1] == '"') {
value.append('"')
index++
} else {
quoted = !quoted
}
}
delimiter -> if (quoted) value.append(char) else {
values += value.toString()
value.clear()
}
else -> value.append(char)
}
index++
}
values += value.toString()
return values
}
private fun String.csvCell(delimiter: String): String {
val needsQuotes = contains(delimiter) || contains('"') || contains('\n') || contains('\r')
val escaped = replace("\"", "\"\"")
return if (needsQuotes) "\"$escaped\"" else escaped
} }
@Composable @Composable
@@ -623,7 +876,7 @@ fun CmsNewsletterScreen(
onEdit = { nl -> onEdit = { nl ->
editingNewsletter = nl editingNewsletter = nl
nlTitle = nl.title nlTitle = nl.title
nlContent = nl.title ?: "" nlContent = nl.title
nlType = "subscription" nlType = "subscription"
nlTargetGroup = "" nlTargetGroup = ""
nlSendToExternal = true nlSendToExternal = true
@@ -753,6 +1006,12 @@ fun CmsEinstellungenScreen(navController: NavController, showBackNavigation: Boo
var websiteVorname by remember { mutableStateOf("") } var websiteVorname by remember { mutableStateOf("") }
var websiteNachname by remember { mutableStateOf("") } var websiteNachname by remember { mutableStateOf("") }
var websiteEmail by remember { mutableStateOf("") } var websiteEmail by remember { mutableStateOf("") }
var ortName by remember { mutableStateOf("") }
var ortStrasse by remember { mutableStateOf("") }
var ortPlz by remember { mutableStateOf("") }
var ortOrt by remember { mutableStateOf("") }
val trainingTimes = remember { mutableStateListOf<de.harheimertc.data.TrainingTimeDto>() }
val trainers = remember { mutableStateListOf<de.harheimertc.data.TrainerDto>() }
LaunchedEffect(config) { LaunchedEffect(config) {
config?.let { config?.let {
@@ -764,6 +1023,14 @@ fun CmsEinstellungenScreen(navController: NavController, showBackNavigation: Boo
websiteVorname = it.website.verantwortlicher.vorname websiteVorname = it.website.verantwortlicher.vorname
websiteNachname = it.website.verantwortlicher.nachname websiteNachname = it.website.verantwortlicher.nachname
websiteEmail = it.website.verantwortlicher.email websiteEmail = it.website.verantwortlicher.email
ortName = it.training.ort.name
ortStrasse = it.training.ort.strasse
ortPlz = it.training.ort.plz
ortOrt = it.training.ort.ort
trainingTimes.clear()
trainingTimes.addAll(it.training.zeiten)
trainers.clear()
trainers.addAll(it.trainer)
} }
} }
@@ -790,6 +1057,16 @@ fun CmsEinstellungenScreen(navController: NavController, showBackNavigation: Boo
email = websiteEmail, email = websiteEmail,
), ),
), ),
training = config.training.copy(
ort = config.training.ort.copy(
name = ortName,
strasse = ortStrasse,
plz = ortPlz,
ort = ortOrt,
),
zeiten = trainingTimes.toList(),
),
trainer = trainers.toList(),
), ),
) )
}, },
@@ -822,6 +1099,63 @@ fun CmsEinstellungenScreen(navController: NavController, showBackNavigation: Boo
OutlinedTextField(value = websiteEmail, onValueChange = { websiteEmail = it }, label = { Text("E-Mail") }, modifier = Modifier.fillMaxWidth()) OutlinedTextField(value = websiteEmail, onValueChange = { websiteEmail = it }, label = { Text("E-Mail") }, modifier = Modifier.fillMaxWidth())
} }
} }
item {
DataCard("Trainingsort") {
OutlinedTextField(value = ortName, onValueChange = { ortName = it }, label = { Text("Name") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(value = ortStrasse, onValueChange = { ortStrasse = it }, label = { Text("Straße") }, modifier = Modifier.fillMaxWidth())
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) {
OutlinedTextField(value = ortPlz, onValueChange = { ortPlz = it }, label = { Text("PLZ") }, modifier = Modifier.weight(1f))
OutlinedTextField(value = ortOrt, onValueChange = { ortOrt = it }, label = { Text("Ort") }, modifier = Modifier.weight(1f))
}
}
}
item {
DataCard("Trainingszeiten") {
trainingTimes.forEachIndexed { index, zeit ->
TrainingTimeEditorCard(
zeit = zeit,
onChange = { updated -> trainingTimes[index] = updated },
onRemove = { trainingTimes.removeAt(index) },
)
}
Button(
onClick = {
trainingTimes.add(
de.harheimertc.data.TrainingTimeDto(
id = "training-${System.currentTimeMillis()}",
tag = "Montag",
),
)
},
modifier = Modifier.fillMaxWidth(),
) {
Text("Trainingszeit hinzufügen")
}
}
}
item {
DataCard("Trainer") {
trainers.forEachIndexed { index, trainer ->
TrainerEditorCard(
trainer = trainer,
onChange = { updated -> trainers[index] = updated },
onRemove = { trainers.removeAt(index) },
)
}
Button(
onClick = {
trainers.add(
de.harheimertc.data.TrainerDto(
id = "trainer-${System.currentTimeMillis()}",
),
)
},
modifier = Modifier.fillMaxWidth(),
) {
Text("Trainer hinzufügen")
}
}
}
item { item {
DataCard("Systemstatus") { DataCard("Systemstatus") {
InfoRow("Mitgliedschaftstarife", config.mitgliedschaft.size.toString()) InfoRow("Mitgliedschaftstarife", config.mitgliedschaft.size.toString())
@@ -950,6 +1284,7 @@ private fun CmsSummaryGrid(navController: NavController, state: CmsUiState) {
val cards = listOf( val cards = listOf(
Triple("Startseite", "Öffentliche Startseite", Destinations.CmsStartseite.route), Triple("Startseite", "Öffentliche Startseite", Destinations.CmsStartseite.route),
Triple("Inhalte", "Vereinsseiten", Destinations.CmsInhalte.route), Triple("Inhalte", "Vereinsseiten", Destinations.CmsInhalte.route),
Triple("News", "Interne und öffentliche News", Destinations.CmsNews.route),
Triple("Sportbetrieb", "Training und Sportdaten", Destinations.CmsSportbetrieb.route), Triple("Sportbetrieb", "Training und Sportdaten", Destinations.CmsSportbetrieb.route),
Triple("Mitgliederverwaltung", "${state.users.size} Benutzer", Destinations.CmsMitgliederverwaltung.route), Triple("Mitgliederverwaltung", "${state.users.size} Benutzer", Destinations.CmsMitgliederverwaltung.route),
Triple("Kontaktanfragen", "${state.contactRequests.size} Anfragen", Destinations.CmsContactRequests.route), Triple("Kontaktanfragen", "${state.contactRequests.size} Anfragen", Destinations.CmsContactRequests.route),

View File

@@ -13,6 +13,10 @@ import de.harheimertc.data.PasswordResetMatchingUserDto
import de.harheimertc.data.PasswordResetAttemptDto import de.harheimertc.data.PasswordResetAttemptDto
import de.harheimertc.data.NewsDto import de.harheimertc.data.NewsDto
import de.harheimertc.data.NewsSaveRequest import de.harheimertc.data.NewsSaveRequest
import de.harheimertc.data.SeasonDto
import de.harheimertc.data.SpielDto
import de.harheimertc.data.TerminDto
import de.harheimertc.repositories.CmsMannschaftRow
import de.harheimertc.repositories.CmsRepository import de.harheimertc.repositories.CmsRepository
import de.harheimertc.repositories.MeisterschaftResult import de.harheimertc.repositories.MeisterschaftResult
import kotlinx.coroutines.async import kotlinx.coroutines.async
@@ -39,6 +43,16 @@ data class CmsUiState(
val passwordResetFailedOnly: Boolean = true, val passwordResetFailedOnly: Boolean = true,
val news: List<NewsDto> = emptyList(), val news: List<NewsDto> = emptyList(),
val meisterschaften: List<MeisterschaftResult> = emptyList(), val meisterschaften: List<MeisterschaftResult> = emptyList(),
val sportLoading: Boolean = false,
val sportSaving: Boolean = false,
val sportTermine: List<TerminDto> = emptyList(),
val sportMannschaften: List<CmsMannschaftRow> = emptyList(),
val sportMannschaftenSeasons: List<String> = emptyList(),
val sportMannschaftenSeason: String = "",
val sportSpielplanHeaders: List<String> = emptyList(),
val sportSpielplanRows: List<List<String>> = emptyList(),
val sportSpielplanSeason: String = "",
val sportSpielplanSeasons: List<SeasonDto> = emptyList(),
) )
@HiltViewModel @HiltViewModel
@@ -179,6 +193,156 @@ class CmsViewModel @Inject constructor(
} }
} }
fun loadSportbetrieb() {
viewModelScope.launch {
_state.value = _state.value.copy(sportLoading = true, error = null, message = null)
val termineRes = async { repository.managedTermine() }
val seasonsRes = async { repository.mannschaftenSeasons() }
val spielplanRes = async { repository.spielplan() }
val termineResult = termineRes.await()
val seasonsResult = seasonsRes.await()
val seasonInfo = seasonsResult.getOrNull()
val selectedSeason = _state.value.sportMannschaftenSeason.takeIf { it.isNotBlank() }
?: seasonInfo?.defaultSeason?.takeIf { it.isNotBlank() }
?: seasonInfo?.currentSeason?.takeIf { it.isNotBlank() }
?: seasonInfo?.seasons?.firstOrNull().orEmpty()
val mannschaftenResult = repository.mannschaften(selectedSeason.takeIf { it.isNotBlank() })
val spielplanResult = spielplanRes.await()
val errors = listOfNotNull(
ErrorMapper.mapError(termineResult.exceptionOrNull()),
ErrorMapper.mapError(seasonsResult.exceptionOrNull()),
ErrorMapper.mapError(mannschaftenResult.exceptionOrNull()),
ErrorMapper.mapError(spielplanResult.exceptionOrNull()),
)
val spielplan = spielplanResult.getOrNull()
val headers = spielplan?.headers.orEmpty()
_state.value = _state.value.copy(
sportLoading = false,
sportTermine = termineResult.getOrNull().orEmpty(),
sportMannschaften = mannschaftenResult.getOrNull().orEmpty(),
sportMannschaftenSeasons = seasonInfo?.seasons.orEmpty(),
sportMannschaftenSeason = selectedSeason,
sportSpielplanHeaders = headers,
sportSpielplanRows = spielplan?.data.orEmpty().map { row -> headers.map { header -> row.valueForHeader(header) } },
sportSpielplanSeason = spielplan?.season.orEmpty(),
sportSpielplanSeasons = spielplan?.seasons.orEmpty(),
error = errors.takeIf { it.isNotEmpty() }?.joinToString("; "),
)
}
}
fun loadSportMannschaftenSeason(season: String) {
viewModelScope.launch {
_state.value = _state.value.copy(sportLoading = true, error = null, message = null, sportMannschaftenSeason = season)
repository.mannschaften(season)
.onSuccess { rows ->
_state.value = _state.value.copy(sportLoading = false, sportMannschaften = rows)
}
.onFailure { err ->
_state.value = _state.value.copy(
sportLoading = false,
error = ErrorMapper.mapError(err) ?: "Mannschaften konnten nicht geladen werden.",
)
}
}
}
fun saveSportTermin(original: TerminDto?, termin: TerminDto) {
viewModelScope.launch {
_state.value = _state.value.copy(sportSaving = true, error = null, message = null)
if (original != null) {
repository.deleteTermin(original)
.onFailure { err ->
_state.value = _state.value.copy(
sportSaving = false,
error = ErrorMapper.mapError(err) ?: "Alter Termin konnte nicht ersetzt werden.",
)
return@launch
}
}
val saveResult = repository.saveTermin(termin)
saveResult
.onSuccess { response ->
val termine = repository.managedTermine().getOrDefault(_state.value.sportTermine)
_state.value = _state.value.copy(
sportSaving = false,
sportTermine = termine,
message = response.message ?: "Termin gespeichert.",
)
}
.onFailure { err ->
_state.value = _state.value.copy(
sportSaving = false,
error = ErrorMapper.mapError(err) ?: "Termin konnte nicht gespeichert werden.",
)
}
}
}
fun deleteSportTermin(termin: TerminDto) {
viewModelScope.launch {
_state.value = _state.value.copy(sportSaving = true, error = null, message = null)
repository.deleteTermin(termin)
.onSuccess { response ->
_state.value = _state.value.copy(
sportSaving = false,
sportTermine = _state.value.sportTermine.filterNot { it == termin },
message = response.message ?: "Termin gelöscht.",
)
}
.onFailure { err ->
_state.value = _state.value.copy(
sportSaving = false,
error = ErrorMapper.mapError(err) ?: "Termin konnte nicht gelöscht werden.",
)
}
}
}
fun saveSportMannschaften(season: String, rows: List<CmsMannschaftRow>) {
viewModelScope.launch {
_state.value = _state.value.copy(sportSaving = true, error = null, message = null)
repository.saveMannschaften(season.takeIf { it.isNotBlank() }, rows)
.onSuccess { response ->
_state.value = _state.value.copy(
sportSaving = false,
sportMannschaften = rows,
message = response.message ?: "Mannschaften gespeichert.",
)
}
.onFailure { err ->
_state.value = _state.value.copy(
sportSaving = false,
error = ErrorMapper.mapError(err) ?: "Mannschaften konnten nicht gespeichert werden.",
)
}
}
}
fun saveSportSpielplan(headers: List<String>, rows: List<List<String>>) {
viewModelScope.launch {
_state.value = _state.value.copy(sportSaving = true, error = null, message = null)
repository.saveSpielplan(headers, rows)
.onSuccess { response ->
_state.value = _state.value.copy(
sportSaving = false,
sportSpielplanHeaders = headers,
sportSpielplanRows = rows,
message = response.message ?: "Spielplan gespeichert.",
)
}
.onFailure { err ->
_state.value = _state.value.copy(
sportSaving = false,
error = ErrorMapper.mapError(err) ?: "Spielplan konnte nicht gespeichert werden.",
)
}
}
}
fun saveConfig(config: ConfigResponse) { fun saveConfig(config: ConfigResponse) {
viewModelScope.launch { viewModelScope.launch {
_state.value = _state.value.copy(saving = true, error = null, message = null) _state.value = _state.value.copy(saving = true, error = null, message = null)
@@ -462,3 +626,18 @@ class CmsViewModel @Inject constructor(
} }
} }
} }
private fun SpielDto.valueForHeader(header: String): String = when (header) {
"Termin" -> termin
"HeimMannschaft" -> heimMannschaft
"GastMannschaft" -> gastMannschaft
"HeimMannschaftAltersklasse" -> heimAltersklasse
"GastMannschaftAltersklasse" -> gastAltersklasse
"Altersklasse" -> altersklasse
"Liga" -> liga
"Staffel" -> staffel
"Runde" -> runde.orEmpty()
"SpieleHeim" -> spieleHeim
"SpieleGast" -> spieleGast
else -> ""
}

View File

@@ -90,7 +90,7 @@ class HomeViewModel @Inject constructor(
loading = false, loading = false,
heroImageUrl = data.heroImageUrl, heroImageUrl = data.heroImageUrl,
termine = data.termine termine = data.termine
.filter { it.asDateTime()?.isBefore(LocalDateTime.now()) != true } .filter { it.asDateTime()?.toLocalDate()?.isBefore(LocalDate.now()) != true }
.sortedBy { it.asDateTime() } .sortedBy { it.asDateTime() }
.take(3), .take(3),
spiele = data.spiele spiele = data.spiele

View File

@@ -59,7 +59,7 @@ data class RegisterFormState(
val birthDate: String = "", val birthDate: String = "",
val password: String = "", val password: String = "",
val passwordRepeat: String = "", val passwordRepeat: String = "",
val showBirthday: Boolean = true, val showBirthday: Boolean = false,
) )
data class RegisterUiState( data class RegisterUiState(

View File

@@ -85,7 +85,7 @@ fun MannschaftenScreen(
state.error != null -> item { ErrorPanel(state.error.orEmpty(), viewModel::load) } state.error != null -> item { ErrorPanel(state.error.orEmpty(), viewModel::load) }
state.teams.isEmpty() -> item { Text("Keine Mannschaftsdaten geladen", color = Accent500) } state.teams.isEmpty() -> item { Text("Keine Mannschaftsdaten geladen", color = Accent500) }
else -> items(state.teams) { team -> else -> items(state.teams) { team ->
TeamCard(team) { navController.navigate(Destinations.MannschaftDetail.create(team.slug)) } TeamCard(team) { navController.navigate(Destinations.MannschaftDetail.create(team.slug, state.selectedSeason)) }
} }
} }
item { item {
@@ -161,13 +161,14 @@ private fun TeamCard(team: Mannschaft, onOpen: () -> Unit) {
@Composable @Composable
fun MannschaftDetailScreen( fun MannschaftDetailScreen(
slug: String, slug: String,
season: String?,
navController: NavController, navController: NavController,
showBackNavigation: Boolean, showBackNavigation: Boolean,
viewModel: MannschaftDetailViewModel = hiltViewModel(), viewModel: MannschaftDetailViewModel = hiltViewModel(),
) { ) {
val state by viewModel.state.collectAsState() val state by viewModel.state.collectAsState()
var selectedTab by rememberSaveable(slug) { mutableStateOf(DetailTab.Matches) } var selectedTab by rememberSaveable(slug, season) { mutableStateOf(DetailTab.Matches) }
LaunchedEffect(slug) { viewModel.load(slug) } LaunchedEffect(slug, season) { viewModel.load(slug, season) }
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize().background(Color(0xFFFAFAFA)), modifier = Modifier.fillMaxSize().background(Color(0xFFFAFAFA)),
contentPadding = PaddingValues(horizontal = 18.dp, vertical = 22.dp), contentPadding = PaddingValues(horizontal = 18.dp, vertical = 22.dp),
@@ -229,7 +230,7 @@ fun MannschaftDetailScreen(
} }
} }
} }
} ?: item { ErrorPanel(state.matchesError ?: "Mannschaft nicht gefunden.") { viewModel.load(slug) } } } ?: item { ErrorPanel(state.matchesError ?: "Mannschaft nicht gefunden.") { viewModel.load(slug, season) } }
} }
} }
} }

View File

@@ -121,35 +121,38 @@ class MannschaftDetailViewModel @Inject constructor(
) : ViewModel() { ) : ViewModel() {
private val _state = MutableStateFlow(MannschaftDetailUiState()) private val _state = MutableStateFlow(MannschaftDetailUiState())
val state: StateFlow<MannschaftDetailUiState> = _state val state: StateFlow<MannschaftDetailUiState> = _state
private var loadedSlug: String? = null private var loadedKey: String? = null
fun load(slug: String) { fun load(slug: String, season: String? = null) {
if (loadedSlug == slug) return val selectedSeason = season?.takeIf { it.isNotBlank() }
loadedSlug = slug val key = "$slug|${selectedSeason.orEmpty()}"
if (loadedKey == key) return
loadedKey = key
viewModelScope.launch { viewModelScope.launch {
_state.value = MannschaftDetailUiState(loading = true) _state.value = MannschaftDetailUiState(loading = true, season = selectedSeason)
val team = mannschaftenRepository.fetchMannschaften().getOrDefault(emptyList()).find { it.slug == slug } val team = mannschaftenRepository.fetchMannschaften(selectedSeason).getOrDefault(emptyList()).find { it.slug == slug }
if (team == null) { if (team == null) {
_state.value = MannschaftDetailUiState(loading = false, matchesError = "Mannschaft nicht gefunden.") _state.value = MannschaftDetailUiState(loading = false, matchesError = "Mannschaft nicht gefunden.")
return@launch return@launch
} }
spielplanRepository.fetchSpielplan() spielplanRepository.fetchSpielplan(selectedSeason)
.onSuccess { plan -> .onSuccess { plan ->
_state.value = MannschaftDetailUiState( _state.value = MannschaftDetailUiState(
loading = false, loading = false,
team = team, team = team,
matches = plan.data.filter { matchesTeam(it, team.mannschaft) }, matches = plan.data.filter { matchesTeam(it, team.mannschaft) },
season = plan.season, season = plan.season ?: selectedSeason,
) )
if (team.informationenLink.isNotBlank()) { if (team.informationenLink.isNotBlank()) {
loadTable(team, plan.season) loadTable(team, plan.season ?: selectedSeason)
} }
} }
.onFailure { .onFailure {
_state.value = MannschaftDetailUiState( _state.value = MannschaftDetailUiState(
loading = false, loading = false,
team = team, team = team,
matchesError = "Der aktuelle Spielplan konnte nicht geladen werden.", season = selectedSeason,
matchesError = "Der Spielplan konnte nicht geladen werden.",
) )
} }
} }

View File

@@ -102,6 +102,12 @@ private fun MemberAreaCardGrid(navController: NavController) {
marker = "P", marker = "P",
onClick = { navController.navigate(Destinations.Profile.route) }, onClick = { navController.navigate(Destinations.Profile.route) },
) )
MemberAreaCard(
title = "Benachrichtigungen",
description = "Persönliche Hinweise im Android-System verwalten",
marker = "B",
onClick = { navController.navigate(Destinations.NotificationSettings.route) },
)
MemberAreaCard( MemberAreaCard(
title = "Mitglieder", title = "Mitglieder",
description = "Kontaktdaten der Vereinsmitglieder", description = "Kontaktdaten der Vereinsmitglieder",

View File

@@ -0,0 +1,286 @@
package de.harheimertc.ui.screens.notifications
import android.Manifest
import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavController
import de.harheimertc.notifications.HarheimerNotifications
import de.harheimertc.repositories.Mannschaft
import de.harheimertc.repositories.NotificationPreferences
import de.harheimertc.ui.components.LoadingState
import de.harheimertc.ui.navigation.NavigationUiState
import de.harheimertc.ui.theme.Accent500
import de.harheimertc.ui.theme.Accent700
import de.harheimertc.ui.theme.Accent900
import de.harheimertc.ui.theme.Primary100
import de.harheimertc.ui.theme.Primary600
private val notificationTimes = (6..22).flatMap { hour ->
listOf("%02d:00".format(hour), "%02d:30".format(hour))
}.dropLast(1)
@Composable
fun NotificationSettingsScreen(
navController: NavController,
showBackNavigation: Boolean,
navigationState: NavigationUiState,
viewModel: NotificationSettingsViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsState()
val context = LocalContext.current
var hasPermission by remember { mutableStateOf(HarheimerNotifications.hasNotificationPermission(context)) }
val permissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
hasPermission = granted || HarheimerNotifications.hasNotificationPermission(context)
}
val isBoard = "vorstand" in navigationState.roles
LazyColumn(
modifier = Modifier.fillMaxSize().background(Color(0xFFFAFAFA)),
contentPadding = PaddingValues(horizontal = 20.dp, vertical = 22.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
item {
if (showBackNavigation) {
TextButton(onClick = { navController.popBackStack() }) {
Text("< Intern", color = Primary600, fontWeight = FontWeight.SemiBold)
}
}
Text("Benachrichtigungen", style = MaterialTheme.typography.displayLarge, color = Accent900)
Text("Persönliche Android-Benachrichtigungen verwalten.", color = Accent500, modifier = Modifier.padding(top = 8.dp))
}
item {
NotificationCard("Android-Berechtigung") {
val permissionText = if (hasPermission) {
"Benachrichtigungen sind im Android-System erlaubt."
} else {
"Benachrichtigungen sind im Android-System noch nicht erlaubt."
}
Text(permissionText, color = if (hasPermission) Color(0xFF166534) else Accent700)
if (!hasPermission && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
Button(
onClick = { permissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) },
modifier = Modifier.fillMaxWidth(),
) {
Text("Berechtigung anfordern")
}
}
}
}
if (state.loading) {
item { LoadingState("Benachrichtigungseinstellungen werden geladen...") }
} else {
item {
NotificationCard("Benachrichtigungszeit") {
Text("Automatische Benachrichtigungen werden zu dieser Uhrzeit ausgelöst.", color = Accent700)
TimeSelection(state.settings.notificationTime) { selectedTime ->
viewModel.update(state.settings.copy(notificationTime = selectedTime))
}
}
}
item {
NotificationCard("News") {
ToggleRow("Neue News", state.settings.newNews) {
viewModel.update(state.settings.copy(newNews = it))
}
}
}
item {
NotificationCard("Termine") {
ToggleRow("Neue Termine", state.settings.newEvents) {
viewModel.update(state.settings.copy(newEvents = it))
}
ToggleRow("Termine von heute", state.settings.eventsToday) {
viewModel.update(state.settings.copy(eventsToday = it))
}
ToggleRow("Termine von morgen", state.settings.eventsTomorrow) {
viewModel.update(state.settings.copy(eventsTomorrow = it))
}
}
}
item {
NotificationCard("Punktspiele") {
ToggleRow("Punktspiele der eigenen Mannschaft", state.settings.ownTeamMatches) {
viewModel.update(state.settings.copy(ownTeamMatches = it))
}
OwnTeamInfo(state.ownTeams, state.currentUserName)
ToggleRow("Punktspiele aller Mannschaften", state.settings.allTeamMatches) {
viewModel.update(state.settings.copy(allTeamMatches = it))
}
TeamSelection(
teams = state.teams,
seasons = state.seasons,
selectedSeason = state.settings.selectedTeamSeason,
settings = state.settings,
onSelectSeason = viewModel::selectSeason,
onToggleTeam = viewModel::toggleTeam,
)
}
}
item {
NotificationCard("Mitglieder") {
ToggleRow("Geburtstage", state.settings.birthdays) {
viewModel.update(state.settings.copy(birthdays = it))
}
}
}
if (isBoard) {
item {
NotificationCard("Vorstand") {
ToggleRow("Neue Kontaktanfrage", state.settings.newContactRequest) {
viewModel.update(state.settings.copy(newContactRequest = it))
}
ToggleRow("Neue Benutzerregistrierung", state.settings.newUserRegistration) {
viewModel.update(state.settings.copy(newUserRegistration = it))
}
}
}
}
}
state.saveError?.let { message ->
item { Text(message, color = MaterialTheme.colorScheme.error) }
}
state.error?.let { message ->
item {
Text(message, color = MaterialTheme.colorScheme.error)
TextButton(onClick = viewModel::load) { Text("Erneut laden") }
}
}
}
}
@Composable
private fun NotificationCard(title: String, content: @Composable ColumnScope.() -> Unit) {
Surface(color = Color.White, shape = RoundedCornerShape(12.dp), shadowElevation = 2.dp) {
Column(Modifier.fillMaxWidth().padding(18.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(title, style = MaterialTheme.typography.titleLarge, color = Accent900)
content()
}
}
}
@Composable
private fun ToggleRow(label: String, checked: Boolean, onCheckedChange: (Boolean) -> Unit) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth(),
) {
Text(label, color = Accent900, modifier = Modifier.weight(1f))
Switch(checked = checked, onCheckedChange = onCheckedChange)
}
}
@Composable
private fun OwnTeamInfo(ownTeams: List<Mannschaft>, currentUserName: String) {
val text = when {
ownTeams.isNotEmpty() -> "Erkannte eigene Mannschaft: " + ownTeams.joinToString { it.mannschaft }
currentUserName.isBlank() -> "Eigene Mannschaft kann erst nach geladenem Profil ermittelt werden."
else -> "Keine eigene Mannschaft für " + currentUserName + " erkannt."
}
Text(text, color = Accent700)
}
@Composable
private fun TimeSelection(selectedTime: String, onSelectTime: (String) -> Unit) {
Row(
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
notificationTimes.forEach { time ->
if (time == selectedTime) {
Button(onClick = { onSelectTime(time) }) { Text(time) }
} else {
OutlinedButton(onClick = { onSelectTime(time) }) { Text(time) }
}
}
}
}
@Composable
private fun TeamSelection(
teams: List<Mannschaft>,
seasons: List<String>,
selectedSeason: String?,
settings: NotificationPreferences,
onSelectSeason: (String) -> Unit,
onToggleTeam: (String, Boolean) -> Unit,
) {
Surface(color = Primary100, shape = RoundedCornerShape(10.dp)) {
Column(Modifier.fillMaxWidth().padding(12.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text("Ausgewählte Mannschaften", color = Accent900, fontWeight = FontWeight.SemiBold)
Text("Zusätzlich einzelne Mannschaften abonnieren.", color = Accent700)
if (seasons.isNotEmpty()) {
Row(
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
seasons.forEach { season ->
val selected = season == selectedSeason
if (selected) {
Button(onClick = { onSelectSeason(season) }) { Text(season) }
} else {
OutlinedButton(onClick = { onSelectSeason(season) }) { Text(season) }
}
}
}
}
if (teams.isEmpty()) {
Text("Keine Mannschaften verfügbar.", color = Accent700)
} else {
teams.forEach { team ->
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
Checkbox(
checked = team.slug in settings.selectedTeamSlugs,
onCheckedChange = { onToggleTeam(team.slug, it) },
)
Text(team.mannschaft, color = Accent900)
}
}
}
}
}
}

View File

@@ -0,0 +1,143 @@
package de.harheimertc.ui.screens.notifications
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.harheimertc.repositories.Mannschaft
import de.harheimertc.repositories.LoginRepository
import de.harheimertc.repositories.MannschaftenRepository
import de.harheimertc.repositories.NotificationPreferences
import de.harheimertc.repositories.NotificationPreferencesRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
data class NotificationSettingsUiState(
val loading: Boolean = true,
val settings: NotificationPreferences = NotificationPreferences(),
val teams: List<Mannschaft> = emptyList(),
val ownTeams: List<Mannschaft> = emptyList(),
val currentUserName: String = "",
val seasons: List<String> = emptyList(),
val error: String? = null,
val saveError: String? = null,
)
@HiltViewModel
class NotificationSettingsViewModel @Inject constructor(
private val preferencesRepository: NotificationPreferencesRepository,
private val mannschaftenRepository: MannschaftenRepository,
private val loginRepository: LoginRepository,
) : ViewModel() {
private val _state = MutableStateFlow(NotificationSettingsUiState())
val state: StateFlow<NotificationSettingsUiState> = _state
init {
load()
}
fun load() {
viewModelScope.launch {
_state.value = _state.value.copy(loading = true, error = null, saveError = null)
val storedSettings = preferencesRepository.loadRemote().getOrElse { preferencesRepository.loadLocal() }
val authStatus = loginRepository.status().getOrNull()
val currentUserName = authStatus?.user?.name.orEmpty()
val seasonsResponse = mannschaftenRepository.fetchSeasons().getOrNull()
val seasons = seasonsResponse?.seasons.orEmpty()
val selectedSeason = storedSettings.selectedTeamSeason
?: seasonsResponse?.defaultSeason?.takeIf { it.isNotBlank() }
?: seasons.firstOrNull()
loadTeams(storedSettings.copy(selectedTeamSeason = selectedSeason), seasons, currentUserName)
}
}
fun selectSeason(season: String) {
val current = _state.value.settings
viewModelScope.launch {
_state.value = _state.value.copy(loading = true, error = null, saveError = null)
loadTeams(current.copy(selectedTeamSeason = season), _state.value.seasons, _state.value.currentUserName, syncRemote = true)
}
}
fun update(settings: NotificationPreferences) {
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 ->
_state.value = _state.value.copy(saveError = error.message ?: "Benachrichtigungseinstellungen konnten nicht gespeichert werden.")
}
}
}
fun toggleTeam(slug: String, selected: Boolean) {
val current = _state.value.settings
val nextTeams = if (selected) {
current.selectedTeamSlugs + slug
} else {
current.selectedTeamSlugs - slug
}
update(current.copy(selectedTeamSlugs = nextTeams))
}
private suspend fun loadTeams(settings: NotificationPreferences, seasons: List<String>, currentUserName: String, syncRemote: Boolean = false) {
mannschaftenRepository.fetchMannschaften(settings.selectedTeamSeason)
.onSuccess { teams ->
val knownSlugs = teams.map { it.slug }.toSet()
val nextSettings = settings.copy(selectedTeamSlugs = settings.selectedTeamSlugs.intersect(knownSlugs))
preferencesRepository.saveLocal(nextSettings)
val saveError = if (syncRemote) {
preferencesRepository.saveRemote(nextSettings).exceptionOrNull()?.message
} else null
_state.value = NotificationSettingsUiState(
loading = false,
settings = nextSettings,
teams = teams,
ownTeams = ownTeamsForUser(currentUserName, teams),
currentUserName = currentUserName,
seasons = seasons,
saveError = saveError,
)
}
.onFailure { error ->
preferencesRepository.saveLocal(settings)
val saveError = if (syncRemote) {
preferencesRepository.saveRemote(settings).exceptionOrNull()?.message
} else null
_state.value = NotificationSettingsUiState(
loading = false,
settings = settings,
currentUserName = currentUserName,
seasons = seasons,
error = error.message ?: "Mannschaften konnten nicht geladen werden.",
saveError = saveError,
)
}
}
}
private fun ownTeamsForUser(userName: String, teams: List<Mannschaft>): List<Mannschaft> {
if (normalizePersonName(userName).isBlank()) return emptyList()
return teams.filter { team ->
team.spieler.any { player -> personNameMatches(player, userName) } ||
personNameMatches(team.mannschaftsfuehrer, userName)
}
}
private fun personNameMatches(candidate: String, userName: String): Boolean {
val normalizedCandidate = normalizePersonName(candidate)
val normalizedUserName = normalizePersonName(userName)
if (normalizedCandidate.isBlank() || normalizedUserName.isBlank()) return false
if (normalizedCandidate == normalizedUserName) return true
val candidateParts = normalizedCandidate.split(" " ).filter { it.isNotBlank() }.toSet()
val userParts = normalizedUserName.split(" " ).filter { it.isNotBlank() }
return userParts.size >= 2 && userParts.all { it in candidateParts }
}
private fun normalizePersonName(value: String): String = value
.lowercase()
.replace(Regex("[^a-z0-9äöüß]+"), " ")
.trim()

View File

@@ -24,7 +24,7 @@ data class ProfileFormState(
val showEmail: Boolean = true, val showEmail: Boolean = true,
val showPhone: Boolean = true, val showPhone: Boolean = true,
val showAddress: Boolean = false, val showAddress: Boolean = false,
val showBirthday: Boolean = true, val showBirthday: Boolean = false,
val currentPassword: String = "", val currentPassword: String = "",
val newPassword: String = "", val newPassword: String = "",
val confirmPassword: String = "", val confirmPassword: String = "",

View File

@@ -2,11 +2,13 @@ package de.harheimertc.ui.screens.cms
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain import kotlinx.coroutines.test.setMain
import io.mockk.coEvery import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
import org.junit.After import org.junit.After
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
@@ -27,6 +29,12 @@ class CmsViewModelTest {
Dispatchers.resetMain() Dispatchers.resetMain()
} }
private fun viewModel(repo: de.harheimertc.repositories.CmsRepository): CmsViewModel {
val connectivity = mockk<de.harheimertc.data.ConnectivityMonitor>()
every { connectivity.online } returns MutableStateFlow(true)
return CmsViewModel(repo, connectivity)
}
@Test @Test
fun load_populatesState() = runTest { fun load_populatesState() = runTest {
val repo = mockk<de.harheimertc.repositories.CmsRepository>() val repo = mockk<de.harheimertc.repositories.CmsRepository>()
@@ -37,11 +45,11 @@ class CmsViewModelTest {
coEvery { repo.contactRequests() } returns Result.success(emptyList()) coEvery { repo.contactRequests() } returns Result.success(emptyList())
coEvery { repo.newsletters() } returns Result.success(de.harheimertc.data.NewsletterListResponse()) coEvery { repo.newsletters() } returns Result.success(de.harheimertc.data.NewsletterListResponse())
coEvery { repo.newsletterGroups() } returns Result.success(de.harheimertc.data.NewsletterGroupsResponse()) coEvery { repo.newsletterGroups() } returns Result.success(de.harheimertc.data.NewsletterGroupsResponse())
coEvery { repo.news() } returns Result.success(de.harheimertc.data.NewsResponse(success = true, news = listOf(de.harheimertc.data.NewsDto(id = 5, title = "T", content = "C")))) coEvery { repo.news() } returns Result.success(de.harheimertc.data.NewsResponse(success = true, news = listOf(de.harheimertc.data.NewsDto(id = "5", title = "T", content = "C"))))
coEvery { repo.passwordResetDiagnostics(any(), any()) } returns Result.success(de.harheimertc.data.PasswordResetDiagnosticsResponse()) coEvery { repo.passwordResetDiagnostics(any(), any()) } returns Result.success(de.harheimertc.data.PasswordResetDiagnosticsResponse())
coEvery { repo.vereinsmeisterschaften() } returns Result.success(emptyList()) coEvery { repo.vereinsmeisterschaften() } returns Result.success(emptyList())
val vm = CmsViewModel(repo) val vm = viewModel(repo)
// advance init launched coroutine // advance init launched coroutine
dispatcher.scheduler.advanceUntilIdle() dispatcher.scheduler.advanceUntilIdle()
@@ -66,7 +74,7 @@ class CmsViewModelTest {
coEvery { repo.vereinsmeisterschaften() } returns Result.success(emptyList()) coEvery { repo.vereinsmeisterschaften() } returns Result.success(emptyList())
coEvery { repo.saveConfig(any()) } returns Result.success(cfg) coEvery { repo.saveConfig(any()) } returns Result.success(cfg)
coEvery { repo.saveNews(any()) } returns Result.success(de.harheimertc.data.AuthMessageResponse(success = true, message = "ok")) coEvery { repo.saveNews(any()) } returns Result.success(de.harheimertc.data.AuthMessageResponse(success = true, message = "ok"))
val vm = CmsViewModel(repo) val vm = viewModel(repo)
// wait for init/load to finish before saving to avoid race // wait for init/load to finish before saving to avoid race
dispatcher.scheduler.advanceUntilIdle() dispatcher.scheduler.advanceUntilIdle()
@@ -95,7 +103,7 @@ class CmsViewModelTest {
coEvery { repo.vereinsmeisterschaften() } returns Result.success(emptyList()) coEvery { repo.vereinsmeisterschaften() } returns Result.success(emptyList())
coEvery { repo.saveNews(any()) } returns Result.success(de.harheimertc.data.AuthMessageResponse(success = true, message = "saved")) coEvery { repo.saveNews(any()) } returns Result.success(de.harheimertc.data.AuthMessageResponse(success = true, message = "saved"))
val vm = CmsViewModel(repo) val vm = viewModel(repo)
dispatcher.scheduler.advanceUntilIdle() dispatcher.scheduler.advanceUntilIdle()
vm.saveNews(de.harheimertc.data.NewsSaveRequest(id = null, title = "t", content = "c")) vm.saveNews(de.harheimertc.data.NewsSaveRequest(id = null, title = "t", content = "c"))
@@ -122,7 +130,7 @@ class CmsViewModelTest {
coEvery { repo.updateUserRoles(any(), any()) } returns Result.success(de.harheimertc.data.AuthMessageResponse(success = true, message = "roles updated")) coEvery { repo.updateUserRoles(any(), any()) } returns Result.success(de.harheimertc.data.AuthMessageResponse(success = true, message = "roles updated"))
coEvery { repo.users() } returns Result.success(de.harheimertc.data.CmsUsersResponse(listOf(de.harheimertc.data.CmsUserDto(id = "1", email = "u@e", name = "U", roles = listOf("admin", "vorstand"))))) coEvery { repo.users() } returns Result.success(de.harheimertc.data.CmsUsersResponse(listOf(de.harheimertc.data.CmsUserDto(id = "1", email = "u@e", name = "U", roles = listOf("admin", "vorstand")))))
val vm = CmsViewModel(repo) val vm = viewModel(repo)
dispatcher.scheduler.advanceUntilIdle() dispatcher.scheduler.advanceUntilIdle()
vm.updateUserRoles("1", listOf("admin", "vorstand")) vm.updateUserRoles("1", listOf("admin", "vorstand"))
@@ -150,7 +158,7 @@ class CmsViewModelTest {
coEvery { repo.updateUserActive(any(), any()) } returns Result.success(de.harheimertc.data.AuthMessageResponse(success = true, message = "user updated")) coEvery { repo.updateUserActive(any(), any()) } returns Result.success(de.harheimertc.data.AuthMessageResponse(success = true, message = "user updated"))
coEvery { repo.users() } returns Result.success(de.harheimertc.data.CmsUsersResponse(listOf(de.harheimertc.data.CmsUserDto(id = "2", email = "v@e", name = "V", active = false)))) coEvery { repo.users() } returns Result.success(de.harheimertc.data.CmsUsersResponse(listOf(de.harheimertc.data.CmsUserDto(id = "2", email = "v@e", name = "V", active = false))))
val vm = CmsViewModel(repo) val vm = viewModel(repo)
dispatcher.scheduler.advanceUntilIdle() dispatcher.scheduler.advanceUntilIdle()
vm.setUserActive("2", false) vm.setUserActive("2", false)
@@ -177,7 +185,7 @@ class CmsViewModelTest {
coEvery { repo.resendInvite(any()) } returns Result.success(de.harheimertc.data.AuthMessageResponse(success = true, message = "invite sent")) coEvery { repo.resendInvite(any()) } returns Result.success(de.harheimertc.data.AuthMessageResponse(success = true, message = "invite sent"))
val vm = CmsViewModel(repo) val vm = viewModel(repo)
dispatcher.scheduler.advanceUntilIdle() dispatcher.scheduler.advanceUntilIdle()
vm.resendInvite("10") vm.resendInvite("10")

View File

@@ -6,6 +6,7 @@ plugins {
id("com.google.devtools.ksp") version "2.3.7" apply false id("com.google.devtools.ksp") version "2.3.7" apply false
id("org.jetbrains.kotlin.kapt") version "2.3.21" apply false id("org.jetbrains.kotlin.kapt") version "2.3.21" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.3.21" apply false id("org.jetbrains.kotlin.plugin.compose") version "2.3.21" apply false
id("com.google.gms.google-services") version "4.4.4" apply false
} }
buildscript { buildscript {

File diff suppressed because one or more lines are too long

View File

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

View File

@@ -78,53 +78,62 @@
</div> </div>
</div> </div>
<!-- News Modal --> <Teleport to="body">
<div <Transition name="news-modal">
v-if="selectedNews" <div
class="fixed inset-0 z-50 bg-black bg-opacity-50 flex items-center justify-center p-4" v-if="selectedNews"
@click.self="closeNewsModal" class="fixed inset-0 z-[100] flex items-center justify-center bg-slate-950/65 px-4 py-6 sm:px-6"
> role="dialog"
<div class="bg-white rounded-xl shadow-2xl max-w-4xl w-full max-h-[90vh] flex flex-col"> aria-modal="true"
<!-- Modal Header --> :aria-labelledby="modalTitleId"
<div class="flex items-center justify-between p-6 border-b border-gray-200"> @click.self="closeNewsModal"
<div class="flex-1"> >
<div class="flex items-center text-sm text-gray-500 mb-2"> <article class="w-full max-w-3xl max-h-[min(44rem,calc(100vh-3rem))] overflow-hidden rounded-lg bg-white shadow-2xl ring-1 ring-black/10 flex flex-col">
<Calendar <header class="flex items-start gap-4 border-b border-gray-200 bg-white px-5 py-4 sm:px-7 sm:py-6">
:size="16" <div class="min-w-0 flex-1">
class="mr-2" <div class="mb-3 inline-flex items-center gap-2 rounded-full bg-primary-50 px-3 py-1 text-sm font-medium text-primary-800">
/> <Calendar :size="15" />
{{ formatDate(selectedNews.created) }} <time :datetime="selectedNews.created">
</div> {{ formatDate(selectedNews.created) }}
<h2 class="text-2xl font-display font-bold text-gray-900"> </time>
{{ selectedNews.title }} </div>
</h2> <h2
</div> :id="modalTitleId"
<button class="text-2xl sm:text-3xl font-display font-bold leading-tight text-gray-950 break-words"
class="ml-4 p-2 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg transition-colors" >
@click="closeNewsModal" {{ selectedNews.title }}
> </h2>
<X :size="24" /> </div>
</button> <button
</div> type="button"
class="shrink-0 rounded-md border border-gray-200 bg-white p-2 text-gray-500 shadow-sm transition-colors hover:bg-gray-50 hover:text-gray-900 focus:outline-none focus:ring-2 focus:ring-primary-500"
aria-label="News schließen"
@click="closeNewsModal"
>
<X :size="20" />
</button>
</header>
<!-- Modal Content (scrollable) --> <div class="overflow-y-auto px-5 py-5 sm:px-7 sm:py-6">
<div class="p-6 overflow-y-auto flex-1"> <div class="news-modal-content text-base leading-7 text-gray-800 sm:text-lg sm:leading-8">
<div class="prose max-w-none text-gray-700 whitespace-pre-wrap"> {{ selectedNews.content }}
{{ selectedNews.content }} </div>
</div> </div>
</article>
</div> </div>
</div> </Transition>
</div> </Teleport>
</section> </section>
</template> </template>
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, onMounted, onUnmounted } from 'vue'
import { Calendar, X } from 'lucide-vue-next' import { Calendar, X } from 'lucide-vue-next'
const news = ref([]) const news = ref([])
const selectedNews = ref(null) const selectedNews = ref(null)
const isLoading = ref(true) const isLoading = ref(true)
const modalTitleId = 'public-news-modal-title'
const loadNews = async () => { const loadNews = async () => {
try { try {
@@ -164,19 +173,30 @@ const getGridClass = () => {
const openNewsModal = (item) => { const openNewsModal = (item) => {
selectedNews.value = item selectedNews.value = item
// Verhindere Scrollen im Hintergrund
document.body.style.overflow = 'hidden' document.body.style.overflow = 'hidden'
document.addEventListener('keydown', handleModalKeydown)
} }
const closeNewsModal = () => { const closeNewsModal = () => {
selectedNews.value = null selectedNews.value = null
// Erlaube Scrollen wieder
document.body.style.overflow = '' document.body.style.overflow = ''
document.removeEventListener('keydown', handleModalKeydown)
}
const handleModalKeydown = (event) => {
if (event.key === 'Escape') {
closeNewsModal()
}
} }
onMounted(() => { onMounted(() => {
loadNews() loadNews()
}) })
onUnmounted(() => {
document.body.style.overflow = ''
document.removeEventListener('keydown', handleModalKeydown)
})
</script> </script>
<style scoped> <style scoped>
@@ -186,5 +206,20 @@ onMounted(() => {
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
overflow: hidden; overflow: hidden;
} }
.news-modal-content {
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.news-modal-enter-active,
.news-modal-leave-active {
transition: opacity 160ms ease;
}
.news-modal-enter-from,
.news-modal-leave-to {
opacity: 0;
}
</style> </style>

View File

@@ -550,6 +550,25 @@
</label> </label>
</div> </div>
<div class="flex items-center">
<input
id="showBirthday"
v-model="formData.showBirthday"
type="checkbox"
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
:disabled="isSaving || !canDisableBirthdayVisibility"
>
<label
for="showBirthday"
class="ml-2 block text-sm font-medium text-gray-700"
>
Geburtstag in Mitgliederliste und Benachrichtigungen anzeigen
</label>
</div>
<p class="-mt-3 text-xs text-gray-500">
Admins und Vorstand können die Sichtbarkeit nur ausschalten. Einschalten kann nur das Mitglied selbst im Profil.
</p>
<div <div
v-if="errorMessage" v-if="errorMessage"
class="flex items-center p-3 rounded-md bg-red-50 text-red-700 text-sm" class="flex items-center p-3 rounded-md bg-red-50 text-red-700 text-sm"
@@ -846,7 +865,8 @@ const formData = ref({
address: '', address: '',
notes: '', notes: '',
isMannschaftsspieler: false, isMannschaftsspieler: false,
hasHallKey: false hasHallKey: false,
showBirthday: false
}) })
const canEdit = computed(() => { const canEdit = computed(() => {
@@ -861,6 +881,10 @@ const isBirthdateRequired = computed(() => {
return !editingMember.value || Boolean(editingMember.value?.geburtsdatum) return !editingMember.value || Boolean(editingMember.value?.geburtsdatum)
}) })
const canDisableBirthdayVisibility = computed(() => {
return editingMember.value?.showBirthday === true
})
const filteredMembers = computed(() => { const filteredMembers = computed(() => {
if (!filterHasHallKey.value) return members.value if (!filterHasHallKey.value) return members.value
return members.value.filter(member => member.hasHallKey) return members.value.filter(member => member.hasHallKey)
@@ -880,7 +904,7 @@ const loadMembers = async () => {
const openAddModal = () => { const openAddModal = () => {
editingMember.value = null editingMember.value = null
formData.value = { firstName: '', lastName: '', geburtsdatum: '', email: '', phone: '', address: '', notes: '', isMannschaftsspieler: false, hasHallKey: false } formData.value = { firstName: '', lastName: '', geburtsdatum: '', email: '', phone: '', address: '', notes: '', isMannschaftsspieler: false, hasHallKey: false, showBirthday: false }
showModal.value = true showModal.value = true
errorMessage.value = '' errorMessage.value = ''
} }
@@ -896,7 +920,8 @@ const openEditModal = (member) => {
address: member.address || '', address: member.address || '',
notes: member.notes || '', notes: member.notes || '',
isMannschaftsspieler: member.isMannschaftsspieler === true, isMannschaftsspieler: member.isMannschaftsspieler === true,
hasHallKey: member.hasHallKey === true hasHallKey: member.hasHallKey === true,
showBirthday: member.showBirthday === true
} }
showModal.value = true showModal.value = true
errorMessage.value = '' errorMessage.value = ''
@@ -914,7 +939,14 @@ const saveMember = async () => {
try { try {
await $fetch('/api/members', { await $fetch('/api/members', {
method: 'POST', method: 'POST',
body: { id: editingMember.value?.id, ...formData.value } body: {
id: editingMember.value?.id,
...formData.value,
visibility: {
...(editingMember.value?.visibility || {}),
showBirthday: formData.value.showBirthday === true
}
}
}) })
closeModal() closeModal()
await loadMembers() await loadMembers()

View File

@@ -58,7 +58,7 @@
<p class="text-sm font-medium text-green-800"> <p class="text-sm font-medium text-green-800">
{{ currentFile.name }} {{ currentFile.name }}
</p><p class="text-xs text-green-600"> </p><p class="text-xs text-green-600">
{{ currentFile.size }} bytes {{ currentFileLabel }}
</p> </p>
</div> </div>
</div> </div>
@@ -368,7 +368,7 @@ const processFile = async (file) => {
const parseCSVLine = (line) => { const tabCount = (line.match(/\t/g) || []).length; const semicolonCount = (line.match(/;/g) || []).length; const delimiter = tabCount > semicolonCount ? '\t' : ';'; return line.split(delimiter).map(value => value.trim()) } const parseCSVLine = (line) => { const tabCount = (line.match(/\t/g) || []).length; const semicolonCount = (line.match(/;/g) || []).length; const delimiter = tabCount > semicolonCount ? '\t' : ';'; return line.split(delimiter).map(value => value.trim()) }
csvHeaders.value = parseCSVLine(lines[0]); csvData.value = lines.slice(1).map(line => parseCSVLine(line)) csvHeaders.value = parseCSVLine(lines[0]); csvData.value = lines.slice(1).map(line => parseCSVLine(line))
selectedColumns.value = new Array(csvHeaders.value.length).fill(true); columnsSelected.value = false selectedColumns.value = new Array(csvHeaders.value.length).fill(true); columnsSelected.value = false
currentFile.value = { name: file.name, size: file.size, lastModified: file.lastModified } currentFile.value = { name: file.name, size: file.size, entries: csvData.value.length, lastModified: file.lastModified }
processingMessage.value = 'Verarbeitung abgeschlossen!' processingMessage.value = 'Verarbeitung abgeschlossen!'
setTimeout(() => { isProcessing.value = false; showUploadModal.value = false }, 1000) setTimeout(() => { isProcessing.value = false; showUploadModal.value = false }, 1000)
} catch (error) { console.error('Fehler:', error); alert('Fehler: ' + error.message); isProcessing.value = false } } catch (error) { console.error('Fehler:', error); alert('Fehler: ' + error.message); isProcessing.value = false }
@@ -377,6 +377,11 @@ const processFile = async (file) => {
const processSelectedFile = () => { if (selectedFile.value) processFile(selectedFile.value) } const processSelectedFile = () => { if (selectedFile.value) processFile(selectedFile.value) }
const removeFile = () => { currentFile.value = null; csvData.value = []; csvHeaders.value = []; selectedColumns.value = []; columnsSelected.value = false; filteredCsvData.value = []; filteredCsvHeaders.value = []; if (fileInput.value) fileInput.value.value = '' } const removeFile = () => { currentFile.value = null; csvData.value = []; csvHeaders.value = []; selectedColumns.value = []; columnsSelected.value = false; filteredCsvData.value = []; filteredCsvHeaders.value = []; if (fileInput.value) fileInput.value.value = '' }
const selectedColumnsCount = computed(() => selectedColumns.value.filter(s => s).length) const selectedColumnsCount = computed(() => selectedColumns.value.filter(s => s).length)
const currentFileLabel = computed(() => {
if (!currentFile.value) return ''
if (typeof currentFile.value.entries === 'number') return `${currentFile.value.entries} Einträge`
return `${currentFile.value.size} bytes`
})
const getColumnPreview = (index) => { if (csvData.value.length === 0) return 'Keine Daten'; const sv = csvData.value.slice(0, 3).map(row => row[index]).filter(val => val && val.trim() !== ''); return sv.length > 0 ? `Beispiel: ${sv.join(', ')}` : 'Leere Spalte' } const getColumnPreview = (index) => { if (csvData.value.length === 0) return 'Keine Daten'; const sv = csvData.value.slice(0, 3).map(row => row[index]).filter(val => val && val.trim() !== ''); return sv.length > 0 ? `Beispiel: ${sv.join(', ')}` : 'Leere Spalte' }
const selectAllColumns = () => { selectedColumns.value = selectedColumns.value.map(() => true) } const selectAllColumns = () => { selectedColumns.value = selectedColumns.value.map(() => true) }
const deselectAllColumns = () => { selectedColumns.value = selectedColumns.value.map(() => false) } const deselectAllColumns = () => { selectedColumns.value = selectedColumns.value.map(() => false) }
@@ -415,7 +420,7 @@ onMounted(() => {
csvHeaders.value = result.headers csvHeaders.value = result.headers
csvData.value = result.data.map(row => csvHeaders.value.map(header => row[header] || '')) csvData.value = result.data.map(row => csvHeaders.value.map(header => row[header] || ''))
selectedColumns.value = new Array(csvHeaders.value.length).fill(true) selectedColumns.value = new Array(csvHeaders.value.length).fill(true)
currentFile.value = { name: result.season ? `spielplan-${result.season}.json` : 'spielplan.csv', size: csvData.value.length, lastModified: null } currentFile.value = { name: result.season ? `spielplan-${result.season}.json` : 'spielplan.csv', entries: csvData.value.length, lastModified: null }
} catch { /* ignore */ } } catch { /* ignore */ }
})() })()
}) })

View File

@@ -279,9 +279,25 @@ use_project_node
ensure_node_version ensure_node_version
install_dependencies_if_needed install_dependencies_if_needed
# 4. Remove old build (but keep data!) # 4. Stop running apps before replacing build artifacts
echo "" echo ""
echo "4. Cleaning build artifacts..." echo "4. Stopping PM2 before replacing build artifacts..."
if command -v pm2 >/dev/null 2>&1; then
for instance_name in harheimertc harheimertc-3102; do
if pm2 describe "$instance_name" >/dev/null 2>&1; then
pm2 stop "$instance_name" || true
echo "$instance_name gestoppt"
else
echo " PM2-Prozess $instance_name läuft nicht"
fi
done
else
echo " PM2 ist nicht verfügbar"
fi
# 5. Remove old build (but keep data!)
echo ""
echo "5. Cleaning build artifacts..."
# Sicherstellen, dass .output vollständig gelöscht wird # Sicherstellen, dass .output vollständig gelöscht wird
if [ -d ".output" ]; then if [ -d ".output" ]; then
echo " Removing .output directory..." echo " Removing .output directory..."

View File

@@ -285,9 +285,19 @@ use_project_node
ensure_node_version ensure_node_version
install_dependencies_if_needed install_dependencies_if_needed
# 4. Remove old build (but keep data!) # 4. Stop running app before replacing build artifacts
echo "" echo ""
echo "4. Cleaning build artifacts..." echo "4. Stopping PM2 before replacing build artifacts..."
if command -v pm2 >/dev/null 2>&1 && pm2 describe harheimertc.test >/dev/null 2>&1; then
pm2 stop harheimertc.test || true
echo " ✓ harheimertc.test gestoppt"
else
echo " PM2-Prozess harheimertc.test läuft nicht oder PM2 ist nicht verfügbar"
fi
# 5. Remove old build (but keep data!)
echo ""
echo "5. Cleaning build artifacts..."
# Sicherstellen, dass .output vollständig gelöscht wird # Sicherstellen, dass .output vollständig gelöscht wird
if [ -d ".output" ]; then if [ -d ".output" ]; then
echo " Removing .output directory..." echo " Removing .output directory..."

67
google-services.json Normal file
View File

@@ -0,0 +1,67 @@
{
"project_info": {
"project_number": "174247719758",
"project_id": "harheimer-tc",
"storage_bucket": "harheimer-tc.firebasestorage.app"
},
"client": [
{
"client_info": {
"mobilesdk_app_id": "1:174247719758:android:04240f5a6ecc06b8eba41f",
"android_client_info": {
"package_name": "de.harheimertc"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "AIzaSyBgBvGVEtaTmK6LCUwhJ5BUJJTq0FrN6yA"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
},
{
"client_info": {
"mobilesdk_app_id": "1:174247719758:android:852797b868a6413feba41f",
"android_client_info": {
"package_name": "de.harheimertc.local"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "AIzaSyBgBvGVEtaTmK6LCUwhJ5BUJJTq0FrN6yA"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
},
{
"client_info": {
"mobilesdk_app_id": "1:174247719758:android:0663d7ca236b5db2eba41f",
"android_client_info": {
"package_name": "de.harheimertc.test"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "AIzaSyBgBvGVEtaTmK6LCUwhJ5BUJJTq0FrN6yA"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
}
],
"configuration_version": "1"
}

View File

@@ -7,6 +7,8 @@ try {
} }
// Helper function to create env object // Helper function to create env object
const DEFAULT_GOOGLE_APPLICATION_CREDENTIALS = '/var/www/harheimertc/server/data/harheimer-tc-firebase-adminsdk-fbsvc-18b66a2971.json'
function createEnv(port) { function createEnv(port) {
return { return {
NODE_ENV: 'production', NODE_ENV: 'production',
@@ -35,7 +37,10 @@ function createEnv(port) {
WEBAUTHN_ORIGIN: process.env.WEBAUTHN_ORIGIN, WEBAUTHN_ORIGIN: process.env.WEBAUTHN_ORIGIN,
WEBAUTHN_RP_ID: process.env.WEBAUTHN_RP_ID, WEBAUTHN_RP_ID: process.env.WEBAUTHN_RP_ID,
WEBAUTHN_RP_NAME: process.env.WEBAUTHN_RP_NAME, WEBAUTHN_RP_NAME: process.env.WEBAUTHN_RP_NAME,
WEBAUTHN_REQUIRE_UV: process.env.WEBAUTHN_REQUIRE_UV WEBAUTHN_REQUIRE_UV: process.env.WEBAUTHN_REQUIRE_UV,
FCM_SERVICE_ACCOUNT_JSON: process.env.FCM_SERVICE_ACCOUNT_JSON,
GOOGLE_APPLICATION_CREDENTIALS: process.env.GOOGLE_APPLICATION_CREDENTIALS || DEFAULT_GOOGLE_APPLICATION_CREDENTIALS,
FCM_PROJECT_ID: process.env.FCM_PROJECT_ID
} }
} }

View File

@@ -8,6 +8,8 @@ try {
} }
// Helper function to create env object // Helper function to create env object
const DEFAULT_GOOGLE_APPLICATION_CREDENTIALS = '/var/www/harheimertc.test/server/data/harheimer-tc-firebase-adminsdk-fbsvc-18b66a2971.json'
function createEnv(port) { function createEnv(port) {
return { return {
NODE_ENV: process.env.NODE_ENV || 'development', NODE_ENV: process.env.NODE_ENV || 'development',
@@ -37,7 +39,10 @@ function createEnv(port) {
WEBAUTHN_ORIGIN: process.env.WEBAUTHN_ORIGIN, WEBAUTHN_ORIGIN: process.env.WEBAUTHN_ORIGIN,
WEBAUTHN_RP_ID: process.env.WEBAUTHN_RP_ID, WEBAUTHN_RP_ID: process.env.WEBAUTHN_RP_ID,
WEBAUTHN_RP_NAME: process.env.WEBAUTHN_RP_NAME, WEBAUTHN_RP_NAME: process.env.WEBAUTHN_RP_NAME,
WEBAUTHN_REQUIRE_UV: process.env.WEBAUTHN_REQUIRE_UV WEBAUTHN_REQUIRE_UV: process.env.WEBAUTHN_REQUIRE_UV,
FCM_SERVICE_ACCOUNT_JSON: process.env.FCM_SERVICE_ACCOUNT_JSON,
GOOGLE_APPLICATION_CREDENTIALS: process.env.GOOGLE_APPLICATION_CREDENTIALS || DEFAULT_GOOGLE_APPLICATION_CREDENTIALS,
FCM_PROJECT_ID: process.env.FCM_PROJECT_ID
} }
} }

847
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "harheimertc-website", "name": "harheimertc-website",
"version": "1.8.0", "version": "1.8.1",
"description": "Moderne Webseite für den Harheimer Tischtennis Club", "description": "Moderne Webseite für den Harheimer Tischtennis Club",
"private": true, "private": true,
"type": "module", "type": "module",
@@ -46,7 +46,6 @@
"pinia": "^3.0.3", "pinia": "^3.0.3",
"quill": "^2.0.2", "quill": "^2.0.2",
"sharp": "^0.34.5", "sharp": "^0.34.5",
"vue": "^3.5.22" "vue": "^3.5.22"
}, },
"devDependencies": { "devDependencies": {
@@ -61,10 +60,11 @@
"postcss": "^8.5.12", "postcss": "^8.5.12",
"supertest": "^7.1.0", "supertest": "^7.1.0",
"tailwindcss": "^3.4.0", "tailwindcss": "^3.4.0",
"vitest": "^4.0.16", "vitest": "^4.1.8",
"vue-eslint-parser": "^10.2.0" "vue-eslint-parser": "^10.2.0"
}, },
"overrides": { "overrides": {
"@peculiar/x509": "1.13.0" "@peculiar/x509": "1.13.0",
"esbuild": "0.28.1"
} }
} }

View File

@@ -365,7 +365,7 @@ const visibility = ref({
showEmail: true, showEmail: true,
showPhone: true, showPhone: true,
showAddress: false, showAddress: false,
showBirthday: true showBirthday: false
}) })
const passwordData = ref({ const passwordData = ref({
@@ -568,4 +568,3 @@ useHead({
title: 'Mein Profil - Harheimer TC', title: 'Mein Profil - Harheimer TC',
}) })
</script> </script>

View File

@@ -173,4 +173,4 @@ function formatDate(value) {
useHead({ useHead({
title: 'QTTR-Werte - Harheimer TC' title: 'QTTR-Werte - Harheimer TC'
}) })
</script> </script>

View File

@@ -20,8 +20,10 @@ const FILES = {
function getDataPath(filename) { function getDataPath(filename) {
const cwd = process.cwd() const cwd = process.cwd()
if (cwd.endsWith('.output')) { if (cwd.endsWith('.output')) {
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
return path.join(cwd, '../server/data', filename) return path.join(cwd, '../server/data', filename)
} }
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
return path.join(cwd, 'server/data', filename) return path.join(cwd, 'server/data', filename)
} }

View File

@@ -0,0 +1,52 @@
import fs from 'fs'
import path from 'path'
const repoRoot = process.cwd()
const scanRoots = ['server']
const sourceExtensions = new Set(['.js', '.mjs', '.ts'])
const publicWritePattern = /\b(writeFile|appendFile|copyFile|rename|mkdir)\s*\([^)]*(public[/\\](?:data|uploads)|['"`]public['"`]\s*,\s*['"`](?:data|uploads)['"`])/s
function childPath(dir, name) {
if (name !== path.basename(name) || name.includes('/') || name.includes('\\')) {
throw new Error(`Ungueltiger Dateiname beim Scannen: ${name}`)
}
return `${dir}${path.sep}${name}`
}
function walk(dir) {
const entries = fs.readdirSync(dir, { withFileTypes: true })
return entries.flatMap((entry) => {
const fullPath = childPath(dir, entry.name)
if (entry.isDirectory()) return walk(fullPath)
return [fullPath]
})
}
const findings = []
for (const root of scanRoots) {
const absoluteRoot = path.join(repoRoot, root)
if (!fs.existsSync(absoluteRoot)) continue
for (const filePath of walk(absoluteRoot)) {
if (!sourceExtensions.has(path.extname(filePath))) continue
const content = fs.readFileSync(filePath, 'utf8')
if (!publicWritePattern.test(content)) continue
const relativePath = path.relative(repoRoot, filePath)
findings.push(relativePath)
}
}
if (findings.length > 0) {
console.error('Runtime-Schreibzugriffe nach public/data oder public/uploads gefunden:')
for (const finding of findings) {
console.error(`- ${finding}`)
}
console.error('Bitte stattdessen server/data bzw. server/data/public-data verwenden.')
process.exit(1)
}
console.log('OK: keine serverseitigen Runtime-Schreibzugriffe nach public/data oder public/uploads gefunden.')

View File

@@ -1,6 +1,5 @@
import { verifyRegistrationResponse } from '@simplewebauthn/server' import { verifyRegistrationResponse } from '@simplewebauthn/server'
import crypto from 'crypto' import crypto from 'crypto'
import nodemailer from 'nodemailer'
import { hashPassword, readUsers, writeUsers } from '../../utils/auth.js' import { hashPassword, readUsers, writeUsers } from '../../utils/auth.js'
import { getWebAuthnConfig } from '../../utils/webauthn-config.js' import { getWebAuthnConfig } from '../../utils/webauthn-config.js'
import { consumePreRegistration } from '../../utils/webauthn-challenges.js' import { consumePreRegistration } from '../../utils/webauthn-challenges.js'
@@ -8,6 +7,7 @@ import { toBase64Url } from '../../utils/webauthn-encoding.js'
import { writeAuditLog } from '../../utils/audit-log.js' import { writeAuditLog } from '../../utils/audit-log.js'
import { assertPasswordNotPwned } from '../../utils/hibp.js' import { assertPasswordNotPwned } from '../../utils/hibp.js'
import { getClientIp } from '../../utils/rate-limit.js' import { getClientIp } from '../../utils/rate-limit.js'
import { sendRegistrationNotification } from '../../utils/email-service.js'
// Local fallback for Nitro globals when lint/run env doesn't provide them // Local fallback for Nitro globals when lint/run env doesn't provide them
const getMethod = globalThis.getMethod ?? ((e) => (e?.req?.method || e?.method || 'GET')) const getMethod = globalThis.getMethod ?? ((e) => (e?.req?.method || e?.method || 'GET'))
@@ -260,50 +260,9 @@ export default defineEventHandler(async (event) => {
await writeAuditLog('auth.passkey.prereg.success', { email, userId: newUser.id }) await writeAuditLog('auth.passkey.prereg.success', { email, userId: newUser.id })
// Send notification emails (same behavior as password registration) // Send notification emails through the same central recipient logic as password registration.
try { try {
const smtpUser = process.env.SMTP_USER await sendRegistrationNotification({ name, email, phone })
const smtpPass = process.env.SMTP_PASS
if (smtpUser && smtpPass) {
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST || 'smtp.gmail.com',
port: process.env.SMTP_PORT || 587,
secure: false,
auth: { user: smtpUser, pass: smtpPass }
})
await transporter.sendMail({
from: process.env.SMTP_FROM || 'noreply@harheimertc.de',
to: process.env.SMTP_ADMIN || 'j.dichmann@gmx.de',
subject: 'Neue Registrierung (Passkey) - Harheimer TC',
html: `
<h2>Neue Registrierung (Passkey)</h2>
<p>Ein neuer Benutzer hat sich registriert und wartet auf Freigabe:</p>
<ul>
<li><strong>Name:</strong> ${name}</li>
<li><strong>E-Mail:</strong> ${email}</li>
<li><strong>Telefon:</strong> ${phone || 'Nicht angegeben'}</li>
<li><strong>Login:</strong> Passkey${password ? ' + Passwort (Fallback)' : ' (ohne Passwort)'}</li>
</ul>
<p>Bitte prüfen Sie die Registrierung im CMS.</p>
`
})
await transporter.sendMail({
from: process.env.SMTP_FROM || 'noreply@harheimertc.de',
to: email,
subject: 'Registrierung erhalten - Harheimer TC',
html: `
<h2>Registrierung erhalten</h2>
<p>Hallo ${name},</p>
<p>vielen Dank für Ihre Registrierung beim Harheimer TC!</p>
<p>Ihre Anfrage wird vom Vorstand geprüft. Sie erhalten eine E-Mail, sobald Ihr Zugang freigeschaltet wurde.</p>
<br>
<p>Mit sportlichen Grüßen,<br>Ihr Harheimer TC</p>
`
})
}
} catch (emailError) { } catch (emailError) {
console.error('E-Mail-Versand fehlgeschlagen:', emailError) console.error('E-Mail-Versand fehlgeschlagen:', emailError)
} }

View File

@@ -1,6 +1,7 @@
import { readUsers, writeUsers, hashPassword } from '../../utils/auth.js' import { readUsers, writeUsers, hashPassword } from '../../utils/auth.js'
import { sendRegistrationNotification } from '../../utils/email-service.js' import { sendRegistrationNotification } from '../../utils/email-service.js'
import { assertPasswordNotPwned } from '../../utils/hibp.js' import { assertPasswordNotPwned } from '../../utils/hibp.js'
import { sendNewUserRegistrationPush } from '../../utils/push-notifications.js'
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
try { try {
@@ -48,7 +49,7 @@ export default defineEventHandler(async (event) => {
phone: phone || '', phone: phone || '',
geburtsdatum, geburtsdatum,
visibility: { visibility: {
showBirthday: visibility?.showBirthday !== undefined ? Boolean(visibility.showBirthday) : true showBirthday: visibility?.showBirthday !== undefined ? Boolean(visibility.showBirthday) : false
}, },
role: 'mitglied', role: 'mitglied',
active: false, // Requires admin approval active: false, // Requires admin approval
@@ -59,6 +60,10 @@ export default defineEventHandler(async (event) => {
users.push(newUser) users.push(newUser)
await writeUsers(users) await writeUsers(users)
sendNewUserRegistrationPush(newUser)
.then(result => console.info('Registrierungs-Push Ergebnis:', { userId: newUser.id, ...result }))
.catch(error => console.error('Registrierungs-Push fehlgeschlagen:', error))
// Send notification to Vorstand/admin via central email service // Send notification to Vorstand/admin via central email service
try { try {
await sendRegistrationNotification({ name, email, phone }) await sendRegistrationNotification({ name, email, phone })
@@ -75,4 +80,3 @@ export default defineEventHandler(async (event) => {
throw error throw error
} }
}) })

View File

@@ -72,14 +72,14 @@ export default defineEventHandler(async (event) => {
: true : true
if (!isAccepted) continue if (!isAccepted) continue
const vis = m.visibility || {} const vis = m.visibility || {}
const showBirthday = vis.showBirthday === undefined ? true : Boolean(vis.showBirthday) const showBirthday = vis.showBirthday === true
candidates.push({ name: `${m.firstName || ''} ${m.lastName || ''}`.trim(), geburtsdatum: m.geburtsdatum, visibility: { showBirthday }, source: 'manual' }) candidates.push({ name: `${m.firstName || ''} ${m.lastName || ''}`.trim(), geburtsdatum: m.geburtsdatum, visibility: { showBirthday }, source: 'manual' })
} }
for (const u of registeredUsers) { for (const u of registeredUsers) {
if (!u.active || isHiddenUser(u)) continue if (!u.active || isHiddenUser(u)) continue
const vis = u.visibility || {} const vis = u.visibility || {}
const showBirthday = vis.showBirthday === undefined ? true : Boolean(vis.showBirthday) const showBirthday = vis.showBirthday === true
candidates.push({ name: u.name, geburtsdatum: u.geburtsdatum, visibility: { showBirthday }, source: 'login' }) candidates.push({ name: u.name, geburtsdatum: u.geburtsdatum, visibility: { showBirthday }, source: 'login' })
} }

View File

@@ -1,8 +1,8 @@
import nodemailer from 'nodemailer' import nodemailer from 'nodemailer'
import { promises as fs } from 'fs' import { promises as fs } from 'fs'
import path from 'path'
import { createContactRequest } from '../utils/contact-requests.js' import { createContactRequest } from '../utils/contact-requests.js'
import { readUsers, migrateUserRoles, isHiddenUser } from '../utils/auth.js' import { readUsers, migrateUserRoles, isHiddenUser } from '../utils/auth.js'
import { sendNewContactRequestPush } from '../utils/push-notifications.js'
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
// filename is always a hardcoded constant ('config.json'), never user input // filename is always a hardcoded constant ('config.json'), never user input
@@ -23,17 +23,39 @@ async function loadConfig() {
} }
} }
async function collectRecipients(config) { function envFlagEnabled(value) {
const isProduction = process.env.NODE_ENV === 'production' && process.env.APP_ENV !== 'test' return ['1', 'true', 'yes', 'on'].includes(String(value || '').trim().toLowerCase())
}
if (!isProduction) { function shouldUseDeveloperRecipients() {
if (process.env.DEBUG !== undefined) return envFlagEnabled(process.env.DEBUG)
return process.env.NODE_ENV !== 'production' || process.env.APP_ENV === 'test'
}
async function collectRecipients(config) {
if (shouldUseDeveloperRecipients()) {
return ['tsschulz@tsschulz.de'] return ['tsschulz@tsschulz.de']
} }
const recipients = [] const recipients = []
// Vorstand // Vorstand: prefer active login users with the board role.
if (config?.vorstand && typeof config.vorstand === 'object') { try {
const users = await readUsers()
for (const rawUser of users) {
if (!rawUser || rawUser.active === false || isHiddenUser(rawUser)) continue
const user = migrateUserRoles({ ...rawUser })
const roles = Array.isArray(user.roles) ? user.roles : []
if (roles.includes('vorstand') && user.email && String(user.email).trim()) {
recipients.push(String(user.email).trim())
}
}
} catch (error) {
console.error('Fehler beim Laden der Vorstand-Empfänger aus Benutzerdaten:', error)
}
// Fallback: legacy config.json Vorstand object.
if (recipients.length === 0 && config?.vorstand && typeof config.vorstand === 'object') {
for (const member of Object.values(config.vorstand)) { for (const member of Object.values(config.vorstand)) {
if (member?.email && typeof member.email === 'string' && member.email.trim()) { if (member?.email && typeof member.email === 'string' && member.email.trim()) {
recipients.push(member.email.trim()) recipients.push(member.email.trim())
@@ -72,10 +94,7 @@ async function collectRecipients(config) {
if (config?.website?.verantwortlicher?.email) { if (config?.website?.verantwortlicher?.email) {
return [config.website.verantwortlicher.email] return [config.website.verantwortlicher.email]
} }
if (process.env.SMTP_USER) { throw new Error('Keine E-Mail-Empfänger in config.json konfiguriert.')
return [process.env.SMTP_USER]
}
return ['j.dichmann@gmx.de']
} }
function createTransporter() { function createTransporter() {
@@ -111,13 +130,17 @@ export default defineEventHandler(async (event) => {
} }
// Anfrage immer speichern, auch wenn E-Mail-Versand fehlschlägt. // Anfrage immer speichern, auch wenn E-Mail-Versand fehlschlägt.
await createContactRequest({ const contactRequest = {
name: String(body.name).trim(), name: String(body.name).trim(),
email: String(body.email).trim(), email: String(body.email).trim(),
phone: body.phone ? String(body.phone).trim() : '', phone: body.phone ? String(body.phone).trim() : '',
subject: String(body.subject).trim(), subject: String(body.subject).trim(),
message: String(body.message).trim() message: String(body.message).trim()
}) }
await createContactRequest(contactRequest)
sendNewContactRequestPush(contactRequest)
.then(result => console.info('Kontaktanfrage-Push Ergebnis:', { subject: contactRequest.subject, ...result }))
.catch(error => console.error('Kontaktanfrage-Push fehlgeschlagen:', error))
const config = await loadConfig() const config = await loadConfig()
const recipients = await collectRecipients(config) const recipients = await collectRecipients(config)

View File

@@ -64,7 +64,8 @@ export default defineEventHandler(async (event) => {
showEmail: vis.showEmail === undefined ? true : Boolean(vis.showEmail), showEmail: vis.showEmail === undefined ? true : Boolean(vis.showEmail),
showPhone: vis.showPhone === undefined ? true : Boolean(vis.showPhone), showPhone: vis.showPhone === undefined ? true : Boolean(vis.showPhone),
// Address remains private by default // Address remains private by default
showAddress: vis.showAddress === undefined ? false : Boolean(vis.showAddress) showAddress: vis.showAddress === undefined ? false : Boolean(vis.showAddress),
showBirthday: vis.showBirthday === true
} }
mergedMembers.push({ mergedMembers.push({
@@ -163,7 +164,8 @@ export default defineEventHandler(async (event) => {
mergedMembers[matchedManualIndex].visibility = { mergedMembers[matchedManualIndex].visibility = {
showEmail: user.visibility.showEmail === undefined ? Boolean(vis.showEmail) : Boolean(user.visibility.showEmail), showEmail: user.visibility.showEmail === undefined ? Boolean(vis.showEmail) : Boolean(user.visibility.showEmail),
showPhone: user.visibility.showPhone === undefined ? Boolean(vis.showPhone) : Boolean(user.visibility.showPhone), showPhone: user.visibility.showPhone === undefined ? Boolean(vis.showPhone) : Boolean(user.visibility.showPhone),
showAddress: user.visibility.showAddress === undefined ? Boolean(vis.showAddress) : Boolean(user.visibility.showAddress) showAddress: user.visibility.showAddress === undefined ? Boolean(vis.showAddress) : Boolean(user.visibility.showAddress),
showBirthday: user.visibility.showBirthday === undefined ? vis.showBirthday === true : user.visibility.showBirthday === true
} }
} }
} else { } else {
@@ -186,7 +188,8 @@ export default defineEventHandler(async (event) => {
visibility: { visibility: {
showEmail: userVis.showEmail === undefined ? true : Boolean(userVis.showEmail), showEmail: userVis.showEmail === undefined ? true : Boolean(userVis.showEmail),
showPhone: userVis.showPhone === undefined ? true : Boolean(userVis.showPhone), showPhone: userVis.showPhone === undefined ? true : Boolean(userVis.showPhone),
showAddress: userVis.showAddress === undefined ? false : Boolean(userVis.showAddress) showAddress: userVis.showAddress === undefined ? false : Boolean(userVis.showAddress),
showBirthday: userVis.showBirthday === true
}, },
notes: `Rolle(n): ${roles.join(', ')}`, notes: `Rolle(n): ${roles.join(', ')}`,
source: 'login', source: 'login',
@@ -226,7 +229,7 @@ export default defineEventHandler(async (event) => {
const emailVisible = (isPrivilegedViewer || (isViewerAuthenticated && showEmail)) const emailVisible = (isPrivilegedViewer || (isViewerAuthenticated && showEmail))
const phoneVisible = (isPrivilegedViewer || (isViewerAuthenticated && showPhone)) const phoneVisible = (isPrivilegedViewer || (isViewerAuthenticated && showPhone))
const addressVisible = (isPrivilegedViewer || (isViewerAuthenticated && showAddress)) const addressVisible = (isPrivilegedViewer || (isViewerAuthenticated && showAddress))
const birthdayVisible = (isPrivilegedViewer || (isViewerAuthenticated && (member.visibility && member.visibility.showBirthday !== undefined ? Boolean(member.visibility.showBirthday) : true))) const birthdayVisible = (isPrivilegedViewer || (isViewerAuthenticated && member.visibility?.showBirthday === true))
return { return {
id: member.id, id: member.id,
@@ -246,7 +249,7 @@ export default defineEventHandler(async (event) => {
showEmail: visibility.showEmail === undefined ? true : Boolean(visibility.showEmail), showEmail: visibility.showEmail === undefined ? true : Boolean(visibility.showEmail),
showPhone: visibility.showPhone === undefined ? true : Boolean(visibility.showPhone), showPhone: visibility.showPhone === undefined ? true : Boolean(visibility.showPhone),
showAddress: visibility.showAddress === undefined ? false : Boolean(visibility.showAddress), showAddress: visibility.showAddress === undefined ? false : Boolean(visibility.showAddress),
showBirthday: visibility.showBirthday === undefined ? true : Boolean(visibility.showBirthday), showBirthday: visibility.showBirthday === true,
// Privileged viewers (vorstand) always see contact fields // Privileged viewers (vorstand) always see contact fields
email: emailVisible ? member.email : undefined, email: emailVisible ? member.email : undefined,
phone: phoneVisible ? member.phone : undefined, phone: phoneVisible ? member.phone : undefined,

View File

@@ -1,5 +1,22 @@
import { getUserFromToken, hasAnyRole } from '../utils/auth.js' import { getUserFromToken, hasAnyRole, readUsers, writeUsers, normalizeUserEmail } from '../utils/auth.js'
import { saveMember } from '../utils/members.js' import { readMembers, saveMember } from '../utils/members.js'
function requestedBirthdayVisibility(body) {
return body?.visibility?.showBirthday ?? body?.showBirthday
}
function birthdayVisibilityIsTrue(value) {
return value === true || value === 'true'
}
function resolveAdminBirthdayVisibility({ requested, existingManualMember, existingUser }) {
if (requested === false || requested === 'false') return false
const existingValue = existingUser?.visibility?.showBirthday ?? existingManualMember?.visibility?.showBirthday
if (existingValue === true) return true
return false
}
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
try { try {
@@ -39,7 +56,7 @@ export default defineEventHandler(async (event) => {
} }
const body = await readBody(event) const body = await readBody(event)
const { id, firstName, lastName, geburtsdatum, email, phone, address, notes, isMannschaftsspieler, hasHallKey, hasHallenschluessel, active } = body const { id, firstName, lastName, geburtsdatum, email, phone, address, notes, isMannschaftsspieler, hasHallKey, hasHallenschluessel, active, visibility } = body
if (!firstName || !lastName) { if (!firstName || !lastName) {
throw createError({ throw createError({
@@ -56,6 +73,23 @@ export default defineEventHandler(async (event) => {
} }
try { try {
const [members, users] = await Promise.all([readMembers(), readUsers()])
const normalizedEmail = normalizeUserEmail(email)
const existingManualMember = members.find(member => {
if (id && member.id === id) return true
return normalizedEmail && normalizeUserEmail(member.email) === normalizedEmail
})
const userIndex = users.findIndex(user => {
if (id && user.id === id) return true
return normalizedEmail && normalizeUserEmail(user.email) === normalizedEmail
})
const existingUser = userIndex !== -1 ? users[userIndex] : null
const nextShowBirthday = resolveAdminBirthdayVisibility({
requested: requestedBirthdayVisibility(body),
existingManualMember,
existingUser
})
await saveMember({ await saveMember({
id: id || undefined, id: id || undefined,
firstName, firstName,
@@ -67,9 +101,21 @@ export default defineEventHandler(async (event) => {
notes: notes || '', notes: notes || '',
isMannschaftsspieler: isMannschaftsspieler === true || isMannschaftsspieler === 'true', isMannschaftsspieler: isMannschaftsspieler === true || isMannschaftsspieler === 'true',
hasHallKey: hasHallKey === true || hasHallKey === 'true' || hasHallenschluessel === true || hasHallenschluessel === 'true', hasHallKey: hasHallKey === true || hasHallKey === 'true' || hasHallenschluessel === true || hasHallenschluessel === 'true',
visibility: {
...(visibility && typeof visibility === 'object' ? visibility : {}),
showBirthday: nextShowBirthday
},
active: typeof active === 'boolean' ? active : true active: typeof active === 'boolean' ? active : true
}) })
if (userIndex !== -1 && (!birthdayVisibilityIsTrue(requestedBirthdayVisibility(body)) || existingUser?.visibility?.showBirthday === true)) {
users[userIndex].visibility = {
...(users[userIndex].visibility || {}),
showBirthday: nextShowBirthday
}
await writeUsers(users)
}
return { return {
success: true, success: true,
message: 'Mitglied erfolgreich gespeichert.' message: 'Mitglied erfolgreich gespeichert.'
@@ -98,4 +144,3 @@ export default defineEventHandler(async (event) => {
}) })
} }
}) })

View File

@@ -1,5 +1,6 @@
import { verifyToken, getUserById, hasAnyRole } from '../utils/auth.js' import { verifyToken, getUserById, hasAnyRole } from '../utils/auth.js'
import { saveNews } from '../utils/news.js' import { saveNews } from '../utils/news.js'
import { sendNewNewsPush } from '../utils/push-notifications.js'
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
try { try {
@@ -41,7 +42,7 @@ export default defineEventHandler(async (event) => {
}) })
} }
await saveNews({ const newsEntry = {
id: id || undefined, id: id || undefined,
title, title,
content, content,
@@ -49,7 +50,17 @@ export default defineEventHandler(async (event) => {
expiresAt: expiresAt || undefined, expiresAt: expiresAt || undefined,
isHidden: isHidden || false, isHidden: isHidden || false,
author: user.name author: user.name
}) }
await saveNews(newsEntry)
if (!id && !newsEntry.isHidden) {
sendNewNewsPush(newsEntry)
.then(result => {
console.info('News-Push Ergebnis:', { newsId: newsEntry.id, ...result })
})
.catch(error => {
console.error('News-Push konnte nicht gesendet werden:', error)
})
}
return { return {
success: true, success: true,

View File

@@ -27,7 +27,7 @@ export default defineEventHandler(async (event) => {
email: user.email, email: user.email,
phone: user.phone || '', phone: user.phone || '',
geburtsdatum: user.geburtsdatum || '', geburtsdatum: user.geburtsdatum || '',
visibility: Object.assign({ showBirthday: true }, (user.visibility || {})) visibility: Object.assign({ showBirthday: false }, (user.visibility || {}))
} }
} }
} catch (error) { } catch (error) {

View File

@@ -0,0 +1,24 @@
import { verifyToken, getUserFromToken } from '../../utils/auth.js'
import { notificationSettingsForUser } from '../../utils/notification-settings.js'
function tokenFromEvent(event) {
return getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '')
}
async function requireAuthenticatedUser(event) {
const token = tokenFromEvent(event)
if (!token) throw createError({ statusCode: 401, message: 'Nicht authentifiziert.' })
const decoded = verifyToken(token)
if (!decoded) throw createError({ statusCode: 401, message: 'Ungültiges Token.' })
const user = await getUserFromToken(token)
if (!user) throw createError({ statusCode: 401, message: 'Ungültige Sitzung.' })
return { token, decoded, user }
}
export default defineEventHandler(async (event) => {
const { user } = await requireAuthenticatedUser(event)
return {
success: true,
settings: notificationSettingsForUser(user)
}
})

View File

@@ -0,0 +1,34 @@
import { verifyToken, getUserFromToken, readUsers, writeUsers } from '../../utils/auth.js'
import { sanitizeNotificationSettings } from '../../utils/notification-settings.js'
function tokenFromEvent(event) {
return getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '')
}
async function requireAuthenticatedUser(event) {
const token = tokenFromEvent(event)
if (!token) throw createError({ statusCode: 401, message: 'Nicht authentifiziert.' })
const decoded = verifyToken(token)
if (!decoded) throw createError({ statusCode: 401, message: 'Ungültiges Token.' })
const user = await getUserFromToken(token)
if (!user) throw createError({ statusCode: 401, message: 'Ungültige Sitzung.' })
return { token, decoded, user }
}
export default defineEventHandler(async (event) => {
const { decoded } = await requireAuthenticatedUser(event)
const body = await readBody(event)
const settings = sanitizeNotificationSettings(body?.settings || body || {})
const users = await readUsers()
const userIndex = users.findIndex(user => user.id === decoded.id)
if (userIndex === -1) {
throw createError({ statusCode: 404, message: 'Benutzer nicht gefunden.' })
}
users[userIndex].notificationSettings = settings
await writeUsers(users)
return {
success: true,
message: 'Benachrichtigungseinstellungen gespeichert.',
settings
}
})

View File

@@ -0,0 +1,29 @@
import { verifyToken, getUserFromToken, readUsers, writeUsers } from '../../utils/auth.js'
import { upsertPushToken } from '../../utils/push-notifications.js'
function tokenFromEvent(event) {
return getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '')
}
export default defineEventHandler(async (event) => {
const token = tokenFromEvent(event)
if (!token) throw createError({ statusCode: 401, message: 'Nicht authentifiziert.' })
const decoded = verifyToken(token)
if (!decoded) throw createError({ statusCode: 401, message: 'Ungültiges Token.' })
const sessionUser = await getUserFromToken(token)
if (!sessionUser) throw createError({ statusCode: 401, message: 'Ungültige Sitzung.' })
const body = await readBody(event)
if (!body?.token || typeof body.token !== 'string') {
throw createError({ statusCode: 400, message: 'Push-Token fehlt.' })
}
const users = await readUsers()
const userIndex = users.findIndex(user => user.id === decoded.id)
if (userIndex === -1) throw createError({ statusCode: 404, message: 'Benutzer nicht gefunden.' })
upsertPushToken(users[userIndex], {
token: body.token,
platform: body.platform || 'android',
appVersion: body.appVersion || null
})
await writeUsers(users)
return { success: true, message: 'Push-Token gespeichert.' }
})

View File

@@ -3,7 +3,7 @@ import { deleteTermin } from '../utils/termine.js'
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
try { try {
const token = getCookie(event, 'auth_token') const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '')
if (!token) { if (!token) {
throw createError({ throw createError({
@@ -58,4 +58,3 @@ export default defineEventHandler(async (event) => {
throw error throw error
} }
}) })

View File

@@ -3,7 +3,7 @@ import { readTermine } from '../utils/termine.js'
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
try { try {
const token = getCookie(event, 'auth_token') const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '')
if (!token) { if (!token) {
throw createError({ throw createError({
@@ -42,4 +42,3 @@ export default defineEventHandler(async (event) => {
throw error throw error
} }
}) })

View File

@@ -1,9 +1,10 @@
import { verifyToken, getUserById, hasAnyRole } from '../utils/auth.js' import { verifyToken, getUserById, hasAnyRole } from '../utils/auth.js'
import { saveTermin } from '../utils/termine.js' import { saveTermin } from '../utils/termine.js'
import { sendNewEventPush } from '../utils/push-notifications.js'
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
try { try {
const token = getCookie(event, 'auth_token') const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '')
if (!token) { if (!token) {
throw createError({ throw createError({
@@ -41,13 +42,17 @@ export default defineEventHandler(async (event) => {
}) })
} }
await saveTermin({ const termin = {
datum, datum,
uhrzeit: uhrzeit || '', uhrzeit: uhrzeit || '',
titel, titel,
beschreibung: beschreibung || '', beschreibung: beschreibung || '',
kategorie: kategorie || 'Sonstiges' kategorie: kategorie || 'Sonstiges'
}) }
await saveTermin(termin)
sendNewEventPush(termin)
.then(result => console.info('Termin-Push Ergebnis:', { titel: termin.titel, ...result }))
.catch(error => console.error('Termin-Push fehlgeschlagen:', error))
return { return {
success: true, success: true,
@@ -58,4 +63,3 @@ export default defineEventHandler(async (event) => {
throw error throw error
} }
}) })

View File

@@ -0,0 +1,38 @@
import { runNotificationSchedulerTick } from '../utils/notification-scheduler.js'
import { info as loggerInfo, error as loggerError } from '../utils/logger.js'
const INTERVAL_MS = 60_000
let timer = null
let running = false
async function tick(reason = 'interval') {
if (running) return
running = true
try {
const result = await runNotificationSchedulerTick()
if (result?.dueUsers) {
loggerInfo('[notification-scheduler] Tick', { reason, ...result })
}
} catch (error) {
loggerError('[notification-scheduler] Tick fehlgeschlagen:', { error })
} finally {
running = false
}
}
export default defineNitroPlugin((nitroApp) => {
if (process.env.NOTIFICATION_SCHEDULER_DISABLED === 'true') {
loggerInfo('[notification-scheduler] Deaktiviert')
return
}
loggerInfo('[notification-scheduler] Gestartet')
timer = setInterval(() => tick(), INTERVAL_MS)
timer.unref?.()
tick('start')
nitroApp.hooks.hookOnce('close', () => {
if (timer) clearInterval(timer)
timer = null
})
})

View File

@@ -1,44 +0,0 @@
// Script: set-all-birthday-visible.js
// Setzt für alle Mitglieder das Flag visibility.showBirthday auf true
const fs = require('fs')
const path = require('path')
const membersPath = path.join(__dirname, 'data', 'members.json')
let raw
try {
raw = fs.readFileSync(membersPath, 'utf8')
} catch (e) {
console.error('Fehler beim Lesen von members.json:', e)
process.exit(1)
}
let members
try {
members = JSON.parse(raw)
} catch (e) {
console.error('Fehler beim Parsen von members.json:', e)
process.exit(1)
}
if (!Array.isArray(members)) {
console.error('members.json ist kein Array!')
process.exit(1)
}
let changed = 0
for (const m of members) {
if (!m.visibility) m.visibility = {}
if (m.visibility.showBirthday !== true) {
m.visibility.showBirthday = true
changed++
}
}
if (changed > 0) {
fs.writeFileSync(membersPath, JSON.stringify(members, null, 2), 'utf8')
console.log(`Flag für ${changed} Mitglieder gesetzt.`)
} else {
console.log('Alle Mitglieder hatten das Flag bereits gesetzt.')
}

View File

@@ -1,33 +0,0 @@
// Script: set-all-birthday-visible.mjs
// Setzt für alle Mitglieder das Flag visibility.showBirthday auf true (mit Entschlüsselung)
import { readMembers, writeMembers } from './utils/members.js';
import dotenv from 'dotenv';
import path from 'path';
import { fileURLToPath } from 'url';
dotenv.config({ path: path.resolve(process.cwd(), '.env') });
async function main() {
let members = await readMembers();
if (!Array.isArray(members)) {
console.error('members.json ist kein Array!')
process.exit(1)
}
let changed = 0;
for (const m of members) {
if (!m.visibility) m.visibility = {};
if (m.visibility.showBirthday !== true) {
m.visibility.showBirthday = true;
changed++;
}
}
if (changed > 0) {
await writeMembers(members);
console.log(`Flag für ${changed} Mitglieder gesetzt.`);
} else {
console.log('Alle Mitglieder hatten das Flag bereits gesetzt.');
}
}
main();

View File

@@ -1,72 +0,0 @@
// Script: set-all-visibility-flags.mjs
// Setzt für alle Mitglieder in allen relevanten Dateien alle visibility-Flags auf true (inkl. Entschlüsselung)
import { readMembers, writeMembers } from './utils/members.js';
import dotenv from 'dotenv';
import path from 'path';
import { fileURLToPath } from 'url';
import fs from 'fs/promises';
dotenv.config({ path: path.resolve(process.cwd(), '.env') });
const usersPath = path.resolve(process.cwd(), 'server/data/users.json');
async function updateVisibility(obj) {
let changed = 0;
if (Array.isArray(obj)) {
for (const m of obj) {
if (!m.visibility) m.visibility = {};
if (m.visibility.showEmail !== true) { m.visibility.showEmail = true; changed++; }
if (m.visibility.showPhone !== true) { m.visibility.showPhone = true; changed++; }
if (m.visibility.showAddress !== true) { m.visibility.showAddress = true; changed++; }
if (m.visibility.showBirthday !== true) { m.visibility.showBirthday = true; changed++; }
}
}
return changed;
}
async function updateUsersFile() {
let changed = 0;
try {
let raw = await fs.readFile(usersPath, 'utf8');
let users;
if (raw.trim().startsWith('v2:')) {
// encrypted, try to use decryptObject from encryption.js
const { decryptObject } = await import('./utils/encryption.js');
const key = process.env.ENCRYPTION_KEY || 'local_development_encryption_key_change_in_production';
users = decryptObject(raw, key);
} else {
users = JSON.parse(raw);
}
changed = await updateVisibility(users);
// write back (encrypted if vorher encrypted)
if (raw.trim().startsWith('v2:')) {
const { encryptObject } = await import('./utils/encryption.js');
const key = process.env.ENCRYPTION_KEY || 'local_development_encryption_key_change_in_production';
const encrypted = encryptObject(users, key);
await fs.writeFile(usersPath, encrypted, 'utf8');
} else {
await fs.writeFile(usersPath, JSON.stringify(users, null, 2), 'utf8');
}
return changed;
} catch (e) {
console.error('Fehler beim Bearbeiten von users.json:', e);
return 0;
}
}
async function main() {
let changedMembers = 0;
let changedUsers = 0;
// members.json (manuelle Mitglieder)
let members = await readMembers();
changedMembers = await updateVisibility(members);
if (changedMembers > 0) {
await writeMembers(members);
}
// users.json (Login-System)
changedUsers = await updateUsersFile();
console.log(`members.json: ${changedMembers} Änderungen, users.json: ${changedUsers} Änderungen`);
}
main();

View File

@@ -2,6 +2,7 @@ import { promises as fs } from 'fs'
import path from 'path' import path from 'path'
const DEFAULT_MAX_BACKUPS = Number.parseInt(process.env.DATA_FILE_BACKUP_MAX || '40', 10) const DEFAULT_MAX_BACKUPS = Number.parseInt(process.env.DATA_FILE_BACKUP_MAX || '40', 10)
let backupSequence = 0
function getProjectRoot() { function getProjectRoot() {
const cwd = process.cwd() const cwd = process.cwd()
@@ -30,8 +31,24 @@ function sanitizeFileKey(filePath) {
} }
function buildBackupName(date = new Date()) { function buildBackupName(date = new Date()) {
const sequence = (backupSequence++).toString(36).padStart(6, '0')
const randomSuffix = Math.random().toString(36).slice(2, 8) const randomSuffix = Math.random().toString(36).slice(2, 8)
return `${date.toISOString().replace(/[:.]/g, '-')}-${randomSuffix}.bak` return `${date.toISOString().replace(/[:.]/g, '-')}-${sequence}-${randomSuffix}.bak`
}
export function resolveDataFileBackupPath(backupDir, backupName) {
if (typeof backupName !== 'string' || !backupName.endsWith('.bak') || path.basename(backupName) !== backupName) {
throw new Error('Ungueltiger Backup-Dateiname')
}
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
const resolvedBackupDir = path.resolve(backupDir)
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
const resolvedBackupPath = path.resolve(resolvedBackupDir, backupName)
if (path.dirname(resolvedBackupPath) !== resolvedBackupDir) {
throw new Error('Backup-Datei liegt ausserhalb des Backup-Ordners')
}
return resolvedBackupPath
} }
async function ensureDirectory(dirPath) { async function ensureDirectory(dirPath) {
@@ -55,10 +72,11 @@ async function rotateOldBackups(backupDir, maxBackups) {
} }
const toDelete = backups.slice(0, overflowCount) const toDelete = backups.slice(0, overflowCount)
await Promise.all(toDelete.map((name) => fs.unlink(path.join(backupDir, name)).catch(() => {}))) await Promise.all(toDelete.map((name) => fs.unlink(resolveDataFileBackupPath(backupDir, name)).catch(() => {})))
} }
export function getBackupDirectoryForDataFile(filePath) { export function getBackupDirectoryForDataFile(filePath) {
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
const resolvedPath = path.resolve(filePath) const resolvedPath = path.resolve(filePath)
return path.join(getBackupRoot(), sanitizeFileKey(resolvedPath)) return path.join(getBackupRoot(), sanitizeFileKey(resolvedPath))
} }
@@ -77,6 +95,7 @@ export async function writeDataFileWithRotation(filePath, content, {
encoding = 'utf-8', encoding = 'utf-8',
maxBackups = DEFAULT_MAX_BACKUPS maxBackups = DEFAULT_MAX_BACKUPS
} = {}) { } = {}) {
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
const resolvedPath = path.resolve(filePath) const resolvedPath = path.resolve(filePath)
await ensureDirectory(path.dirname(resolvedPath)) await ensureDirectory(path.dirname(resolvedPath))
@@ -100,7 +119,7 @@ export async function writeDataFileWithRotation(filePath, content, {
if (existingContent !== null) { if (existingContent !== null) {
const backupDir = getBackupDirectoryForDataFile(resolvedPath) const backupDir = getBackupDirectoryForDataFile(resolvedPath)
await ensureDirectory(backupDir) await ensureDirectory(backupDir)
backupPath = path.join(backupDir, buildBackupName()) backupPath = resolveDataFileBackupPath(backupDir, buildBackupName())
await fs.copyFile(resolvedPath, backupPath) await fs.copyFile(resolvedPath, backupPath)
await rotateOldBackups(backupDir, maxBackups) await rotateOldBackups(backupDir, maxBackups)
} }
@@ -116,9 +135,10 @@ export async function writeDataFileWithRotation(filePath, content, {
} }
export async function restoreDataFileBackup(filePath, backupName, options = {}) { export async function restoreDataFileBackup(filePath, backupName, options = {}) {
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
const resolvedPath = path.resolve(filePath) const resolvedPath = path.resolve(filePath)
const backupDir = getBackupDirectoryForDataFile(resolvedPath) const backupDir = getBackupDirectoryForDataFile(resolvedPath)
const sourceBackupPath = path.join(backupDir, backupName) const sourceBackupPath = resolveDataFileBackupPath(backupDir, backupName)
const backupContent = await fs.readFile(sourceBackupPath, 'utf-8') const backupContent = await fs.readFile(sourceBackupPath, 'utf-8')
return writeDataFileWithRotation(resolvedPath, backupContent, options) return writeDataFileWithRotation(resolvedPath, backupContent, options)

View File

@@ -7,6 +7,7 @@ import nodemailer from 'nodemailer'
import fs from 'fs/promises' import fs from 'fs/promises'
import path from 'path' import path from 'path'
import { getServerDataPath } from './paths.js' import { getServerDataPath } from './paths.js'
import { isHiddenUser, migrateUserRoles, readUsers } from './auth.js'
/** /**
* Gets the correct data path for config files * Gets the correct data path for config files
@@ -34,23 +35,45 @@ async function loadConfig() {
} }
} }
function envFlagEnabled(value) {
return ['1', 'true', 'yes', 'on'].includes(String(value || '').trim().toLowerCase())
}
function shouldUseDeveloperRecipients() {
if (process.env.DEBUG !== undefined) return envFlagEnabled(process.env.DEBUG)
return process.env.NODE_ENV !== 'production' || process.env.APP_ENV === 'test'
}
/** /**
* Gets email recipients based on membership type and environment * Gets email recipients based on membership type and environment
* @param {Object} data - Form data * @param {Object} data - Form data
* @param {Object} config - Configuration * @param {Object} config - Configuration
* @returns {Array<string>} Email addresses * @returns {Array<string>} Email addresses
*/ */
function getEmailRecipients(data, config) { async function collectBoardUserRecipients() {
const isProduction = process.env.NODE_ENV === 'production' && process.env.APP_ENV !== 'test' try {
const users = await readUsers()
if (!isProduction) { return users
.filter(user => user && user.active !== false && !isHiddenUser(user))
.map(user => migrateUserRoles({ ...user }))
.filter(user => Array.isArray(user.roles) && user.roles.includes('vorstand'))
.map(user => String(user.email || '').trim())
.filter(Boolean)
} catch (error) {
console.error('Could not load board recipients from users.json:', error.message || error)
return []
}
}
async function getEmailRecipients(data, config) {
if (shouldUseDeveloperRecipients()) {
return ['tsschulz@tsschulz.de'] return ['tsschulz@tsschulz.de']
} }
const recipients = [] const recipients = await collectBoardUserRecipients()
// Config uses a 'vorstand' object with nested roles; collect all emails // Fallback for legacy installations where Vorstand members are only configured in config.json.
if (config.vorstand && typeof config.vorstand === 'object') { if (recipients.length === 0 && config.vorstand && typeof config.vorstand === 'object') {
Object.values(config.vorstand).forEach((member) => { Object.values(config.vorstand).forEach((member) => {
if (member && member.email && typeof member.email === 'string' && member.email.trim() !== '') { if (member && member.email && typeof member.email === 'string' && member.email.trim() !== '') {
recipients.push(member.email.trim()) recipients.push(member.email.trim())
@@ -59,7 +82,7 @@ function getEmailRecipients(data, config) {
} }
// For minors, also add first trainer email if configured (trainer is an array) // For minors, also add first trainer email if configured (trainer is an array)
if (!data.isVolljaehrig && Array.isArray(config.trainer) && config.trainer.length > 0 && config.trainer[0].email) { if (data.isVolljaehrig === false && Array.isArray(config.trainer) && config.trainer.length > 0 && config.trainer[0].email) {
recipients.push(config.trainer[0].email) recipients.push(config.trainer[0].email)
} }
@@ -69,11 +92,11 @@ function getEmailRecipients(data, config) {
if (config.website && config.website.verantwortlicher && config.website.verantwortlicher.email) { if (config.website && config.website.verantwortlicher && config.website.verantwortlicher.email) {
recipients.push(config.website.verantwortlicher.email) recipients.push(config.website.verantwortlicher.email)
} else { } else {
recipients.push('tsschulz@tsschulz.de') throw new Error('Keine E-Mail-Empfänger in config.json konfiguriert.')
} }
} }
return recipients return [...new Set(recipients)]
} }
/** /**
@@ -111,7 +134,7 @@ function createTransporter() {
export async function sendMembershipEmail(data, pdfPath) { export async function sendMembershipEmail(data, pdfPath) {
try { try {
const config = await loadConfig() const config = await loadConfig()
const recipients = getEmailRecipients(data, config) const recipients = await getEmailRecipients(data, config)
// Create transporter // Create transporter
const transporter = createTransporter() const transporter = createTransporter()
@@ -167,7 +190,7 @@ Das ausgefüllte Formular ist als Anhang verfügbar.`
export async function sendRegistrationNotification(data) { export async function sendRegistrationNotification(data) {
try { try {
const config = await loadConfig() const config = await loadConfig()
const recipients = getEmailRecipients(data, config) const recipients = await getEmailRecipients(data, config)
// Create transporter // Create transporter
const transporter = createTransporter() const transporter = createTransporter()

View File

@@ -0,0 +1,451 @@
import { promises as fs } from 'fs'
import path from 'path'
import { readUsers, isHiddenUser } from './auth.js'
import { readMembers } from './members.js'
import { readTermine } from './termine.js'
import { readNews } from './news.js'
import { getServerDataPath } from './paths.js'
import { getDefaultSpielplanSeason, readSpielplanData } from './spielplan-data.js'
import { notificationSettingsForUser } from './notification-settings.js'
import { sendPushToUsers } from './push-notifications.js'
import { info as loggerInfo, error as loggerError } from './logger.js'
const TIME_ZONE = 'Europe/Berlin'
const STATE_FILE = getServerDataPath('notification-scheduler-state.json')
const DATE_FORMATTER = new Intl.DateTimeFormat('en-CA', {
timeZone: TIME_ZONE,
year: 'numeric',
month: '2-digit',
day: '2-digit'
})
const TIME_FORMATTER = new Intl.DateTimeFormat('en-GB', {
timeZone: TIME_ZONE,
hour: '2-digit',
minute: '2-digit',
hour12: false
})
const DATE_TIME_FORMATTER = new Intl.DateTimeFormat('de-DE', {
timeZone: TIME_ZONE,
weekday: 'short',
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: false
})
function berlinDateKey(date = new Date()) {
return DATE_FORMATTER.format(date)
}
function berlinTimeKey(date = new Date()) {
return TIME_FORMATTER.format(date)
}
function addDays(date, days) {
const next = new Date(date)
next.setUTCDate(next.getUTCDate() + days)
return next
}
function normalizeText(value) {
return String(value || '')
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/['`]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, ' ')
.trim()
}
function slugify(value) {
return normalizeText(value).replace(/\s+/g, '-')
}
function userDisplayName(user) {
return String(user?.name || `${user?.firstName || ''} ${user?.lastName || ''}`.trim() || '').trim()
}
function hasTimedSettings(user) {
const settings = notificationSettingsForUser(user)
return settings.newNews || settings.eventsToday || settings.eventsTomorrow || settings.ownTeamMatches ||
settings.allTeamMatches || settings.selectedTeamSlugs.length > 0 || settings.birthdays
}
async function readState() {
try {
const parsed = JSON.parse(await fs.readFile(STATE_FILE, 'utf8'))
return parsed && typeof parsed === 'object' ? parsed : {}
} catch (error) {
if (error?.code !== 'ENOENT') loggerError('[notification-scheduler] Status konnte nicht gelesen werden:', { error })
return {}
}
}
async function writeState(state) {
await fs.mkdir(path.dirname(STATE_FILE), { recursive: true })
await fs.writeFile(STATE_FILE, `${JSON.stringify(state, null, 2)}\n`, 'utf8')
}
function pruneState(state, todayKey) {
const entries = Object.entries(state).filter(([key]) => key.startsWith(todayKey))
return Object.fromEntries(entries)
}
function runKey(dateKey, time, category) {
return `${dateKey}:${time}:${category}`
}
function parseTerminDate(termin) {
const rawDate = String(termin?.datum || '').trim()
if (!rawDate) return null
const time = String(termin?.uhrzeit || '00:00').trim() || '00:00'
if (/^\d{4}-\d{2}-\d{2}$/.test(rawDate)) return new Date(`${rawDate}T${time.padStart(5, '0')}:00+02:00`)
const german = rawDate.match(/^(\d{1,2})\.(\d{1,2})\.(\d{4})$/)
if (german) {
const [, day, month, year] = german
return new Date(`${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}T${time.padStart(5, '0')}:00+02:00`)
}
const parsed = new Date(rawDate)
return Number.isNaN(parsed.getTime()) ? null : parsed
}
function eventsOn(termine, dateKey) {
return termine
.map(termin => ({ termin, date: parseTerminDate(termin) }))
.filter(entry => entry.date && berlinDateKey(entry.date) === dateKey)
.map(entry => ({ title: entry.termin.titel, source: 'termin', item: entry.termin }))
}
function expiringNewsOn(news, dateKey) {
return news
.filter(item => !item?.isHidden && item?.expiresAt)
.map(item => ({ item, date: new Date(item.expiresAt) }))
.filter(entry => !Number.isNaN(entry.date.getTime()) && berlinDateKey(entry.date) === dateKey)
.map(entry => ({ title: entry.item.title, source: 'news', item: entry.item }))
}
function formatNewsExpirySummary(news, fallback) {
if (news.length === 1) return String(news[0].title || fallback).slice(0, 140)
return `${news.length} News laufen heute ab: ${news.slice(0, 3).map(item => item.title).filter(Boolean).join(', ')}`.slice(0, 140)
}
function formatEventSummary(events, fallback) {
if (events.length === 1) return String(events[0].title || fallback).slice(0, 140)
return `${events.length} Einträge: ${events.slice(0, 3).map(event => event.title).filter(Boolean).join(', ')}`.slice(0, 140)
}
function matchDate(row) {
const timestamp = Number(row?.Timestamp)
if (Number.isFinite(timestamp) && timestamp > 0) return new Date(timestamp * 1000)
const raw = String(row?.Termin || '').trim()
const match = raw.match(/^(\d{1,2})\.(\d{1,2})\.(\d{4})(?:\s+(\d{1,2}:\d{2}))?/)
if (!match) return null
const [, day, month, year, time = '00:00'] = match
const parsed = new Date(`${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}T${time}:00+02:00`)
return Number.isNaN(parsed.getTime()) ? null : parsed
}
function matchTeams(row) {
return [row?.HeimMannschaft, row?.GastMannschaft].map(value => String(value || '').trim()).filter(Boolean)
}
function matchesOn(rows, dateKey) {
return rows
.map(row => ({ row, date: matchDate(row) }))
.filter(entry => entry.date && berlinDateKey(entry.date) === dateKey)
}
function matchSummary(matches, fallback) {
if (!matches.length) return fallback
if (matches.length === 1) {
const teams = matchTeams(matches[0].row).join(' - ')
const when = DATE_TIME_FORMATTER.format(matches[0].date)
return `${when}: ${teams}`.slice(0, 140)
}
return `${matches.length} Punktspiele am ${dateKeyToGerman(berlinDateKey(matches[0]?.date || new Date()))}`
}
function dateKeyToGerman(dateKey) {
const [year, month, day] = String(dateKey).split('-')
return `${day}.${month}.${year}`
}
function matchIdentity(match) {
const row = match?.row || {}
const explicit = row.BegegnungNr || row.MeetingId || row.meeting_id || row.SpielNr
if (explicit) return `id:${explicit}`
return [
berlinDateKey(match?.date || matchDate(row) || new Date(0)),
String(row.Timestamp || ''),
...matchTeams(row).map(slugify)
].join('|')
}
function uniqueMatches(matches) {
const seen = new Set()
const unique = []
for (const match of matches) {
const identity = matchIdentity(match)
if (seen.has(identity)) continue
seen.add(identity)
unique.push(match)
}
return unique
}
function localTeamSlugForSide(row, side, teamRows) {
const clubName = normalizeText(row?.[`${side}VereinName`] || row?.[`${side}Mannschaft`] || '')
if (!clubName.includes('harheimer tc')) return []
const ageClass = String(row?.[`${side}MannschaftAltersklasse`] || row?.Altersklasse || '')
const number = String(row?.[`${side}MannschaftNr`] || '1').trim() || '1'
const base = /jugend/i.test(ageClass) ? 'Jugend' : 'Erwachsene'
const candidate = slugify(`${base} ${number}`)
const known = new Set(teamRows.map(row => slugify(row.team)).filter(Boolean))
if (!known.size || known.has(candidate)) return [candidate]
if (/jugend/i.test(ageClass)) {
return teamRows
.map(row => slugify(row.team))
.filter(slug => slug.startsWith('jugend'))
}
return []
}
function teamSlugsForMatch(match, teamRows = []) {
const row = match?.row || {}
return [...new Set([
...matchTeams(row).map(slugify),
...localTeamSlugForSide(row, 'Heim', teamRows),
...localTeamSlugForSide(row, 'Gast', teamRows)
].filter(Boolean))]
}
async function readTeamMembers(season) {
const fileNames = season ? [`mannschaften_${season}.csv`, 'mannschaften.csv'] : ['mannschaften.csv']
for (const fileName of fileNames) {
try {
const raw = await fs.readFile(getServerDataPath('public-data', fileName), 'utf8')
const lines = raw.split(/\r?\n/).filter(line => line.trim())
const rows = []
for (const line of lines.slice(1)) {
const values = parseCsvLine(line)
rows.push({ team: values[0] || '', captain: values[6] || '', players: values[7] || '' })
}
return rows
} catch (error) {
if (error?.code !== 'ENOENT') loggerError('[notification-scheduler] Mannschaften konnten nicht gelesen werden:', { fileName, error })
}
}
return []
}
function parseCsvLine(line) {
const values = []
let current = ''
let inQuotes = false
for (let index = 0; index < line.length; index += 1) {
const char = line[index]
const next = line[index + 1]
if (char === '"' && inQuotes && next === '"') {
current += '"'
index += 1
} else if (char === '"') {
inQuotes = !inQuotes
} else if (char === ',' && !inQuotes) {
values.push(current.trim())
current = ''
} else {
current += char
}
}
values.push(current.trim())
return values
}
function personNameMatches(candidate, userName) {
const normalizedCandidate = normalizeText(candidate)
const normalizedUserName = normalizeText(userName)
if (!normalizedCandidate || !normalizedUserName) return false
if (normalizedCandidate === normalizedUserName) return true
const candidateParts = new Set(normalizedCandidate.split(' ').filter(Boolean))
const userParts = normalizedUserName.split(' ').filter(Boolean)
return userParts.length >= 2 && userParts.every(part => candidateParts.has(part))
}
function ownTeamSlugsForUser(user, teamRows) {
const name = userDisplayName(user)
if (!normalizeText(name)) return []
return teamRows
.filter(row => personNameMatches(row.captain, name) ||
String(row.players || '').replace(/\r?\n/g, ';').split(/[;,]+/).some(player => personNameMatches(player, name)))
.map(row => slugify(row.team))
.filter(Boolean)
}
function selectedMatchesForUser(_user, settings, matches, teamRows = []) {
const selected = new Set((settings.selectedTeamSlugs || []).map(slugify))
if (selected.size === 0) return []
return matches.filter(match => teamSlugsForMatch(match, teamRows).some(slug => selected.has(slug)))
}
function ownMatchesForUser(user, settings, matches, teamRows) {
if (settings.ownTeamMatches === false) return []
const ownSlugs = new Set(ownTeamSlugsForUser(user, teamRows))
if (ownSlugs.size === 0) return []
return matches.filter(match => teamSlugsForMatch(match, teamRows).some(slug => ownSlugs.has(slug)))
}
function matchesForUser(user, settings, context) {
if (settings.allTeamMatches) return uniqueMatches(context.allMatches)
return uniqueMatches([
...selectedMatchesForUser(user, settings, context.allMatches, context.teamRows),
...ownMatchesForUser(user, settings, context.allMatches, context.teamRows)
])
}
function notificationSeasonForSettings(settings, fallbackSeason) {
return String(settings?.selectedTeamSeason || fallbackSeason || '').trim()
}
async function loadMatchContextForSeasons(seasons, dateKey, tomorrowKey) {
const entries = await Promise.all(seasons.map(async season => {
const [spielplan, teamRows] = await Promise.all([readSpielplanData({ season }), readTeamMembers(season)])
const todayMatches = matchesOn(spielplan.data || [], dateKey)
const tomorrowMatches = matchesOn(spielplan.data || [], tomorrowKey)
return [season, { spielplan, teamRows, todayMatches, tomorrowMatches, allMatches: [...todayMatches, ...tomorrowMatches] }]
}))
return Object.fromEntries(entries)
}
function parseBirthday(value) {
const raw = String(value || '').trim()
if (!raw) return null
const iso = raw.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/)
if (iso) return { month: Number(iso[2]), day: Number(iso[3]) }
const german = raw.match(/^(\d{1,2})\.(\d{1,2})(?:\.(\d{2,4}))?$/)
if (german) return { month: Number(german[2]), day: Number(german[1]) }
return null
}
function hasBirthdayNotificationConsent(person) {
return person?.visibility?.showBirthday === true || person?.showBirthday === true
}
function formatBirthdaySummary(names) {
const visibleNames = names.map(name => String(name || '').trim()).filter(Boolean)
if (visibleNames.length === 1) return `${visibleNames[0]} hat heute Geburtstag.`
return `Geburtstage heute: ${visibleNames.slice(0, 5).join(', ')}${visibleNames.length > 5 ? ` und ${visibleNames.length - 5} weitere` : ''}.`
}
async function birthdaysOn(dateKey) {
const [, month, day] = dateKey.split('-').map(Number)
const [manualMembers, users] = await Promise.all([readMembers(), readUsers()])
const people = []
for (const member of manualMembers) {
if (member?.active === false) continue
if (!hasBirthdayNotificationConsent(member)) continue
const birthday = parseBirthday(member.geburtsdatum || member.birthday)
if (birthday?.month === month && birthday?.day === day) {
people.push(String(member.name || `${member.firstName || ''} ${member.lastName || ''}`.trim()).trim())
}
}
for (const user of users) {
if (isHiddenUser(user) || user?.active === false) continue
if (!hasBirthdayNotificationConsent(user)) continue
const birthday = parseBirthday(user.geburtsdatum || user.birthday)
if (birthday?.month === month && birthday?.day === day) {
people.push(userDisplayName(user))
}
}
return [...new Set(people.filter(Boolean))].sort((a, b) => a.localeCompare(b, 'de'))
}
async function sendIfDue(state, dateKey, time, category, enabled, send, equivalentCategories = []) {
const key = runKey(dateKey, time, category)
const equivalentKeys = equivalentCategories.map(equivalentCategory => runKey(dateKey, time, equivalentCategory))
if (!enabled || state[key] || equivalentKeys.some(equivalentKey => state[equivalentKey])) return null
const result = await send()
state[key] = { at: new Date().toISOString(), result }
return result
}
export async function runNotificationSchedulerTick(now = new Date()) {
const dateKey = berlinDateKey(now)
const time = berlinTimeKey(now)
const users = (await readUsers()).filter(user => !isHiddenUser(user) && hasTimedSettings(user))
const dueUsers = users.filter(user => notificationSettingsForUser(user).notificationTime === time)
if (!dueUsers.length) return { dueUsers: 0, time, dateKey }
let state = pruneState(await readState(), dateKey)
const tomorrowKey = berlinDateKey(addDays(now, 1))
const [termine, news, defaultSeason] = await Promise.all([readTermine(), readNews(), getDefaultSpielplanSeason()])
const todayTermine = eventsOn(termine, dateKey)
const tomorrowTermine = eventsOn(termine, tomorrowKey)
const expiringNewsToday = expiringNewsOn(news, dateKey)
const todayEvents = todayTermine
const tomorrowEvents = tomorrowTermine
const seasonsForMatches = [...new Set(dueUsers
.map(user => notificationSeasonForSettings(notificationSettingsForUser(user), defaultSeason))
.filter(Boolean))]
const matchContexts = await loadMatchContextForSeasons(seasonsForMatches, dateKey, tomorrowKey)
const todaysBirthdays = await birthdaysOn(dateKey)
const results = {}
results.eventsToday = await sendIfDue(state, dateKey, time, 'eventsToday', todayEvents.length > 0, () => sendPushToUsers({
title: 'Termine heute',
body: formatEventSummary(todayEvents, 'Heute stehen Termine an.'),
data: { type: 'events_today', date: dateKey },
predicate: (user, settings) => settings.notificationTime === time && settings.eventsToday,
failureLabel: 'FCM Termine-heute-Push'
}))
results.expiringNews = await sendIfDue(state, dateKey, time, 'expiringNews', expiringNewsToday.length > 0, () => sendPushToUsers({
title: 'News laufen heute ab',
body: formatNewsExpirySummary(expiringNewsToday, 'Heute laufen News ab.'),
data: { type: 'news_expiring', date: dateKey },
predicate: (_user, settings) => settings.notificationTime === time && settings.newNews && !settings.eventsToday,
failureLabel: 'FCM News-Ablauf-Push'
}))
results.eventsTomorrow = await sendIfDue(state, dateKey, time, 'eventsTomorrow', tomorrowEvents.length > 0, () => sendPushToUsers({
title: 'Termine morgen',
body: formatEventSummary(tomorrowEvents, 'Morgen stehen Termine an.'),
data: { type: 'events_tomorrow', date: tomorrowKey },
predicate: (user, settings) => settings.notificationTime === time && settings.eventsTomorrow,
failureLabel: 'FCM Termine-morgen-Push'
}))
const teamMatchResults = []
for (const [season, context] of Object.entries(matchContexts)) {
teamMatchResults.push(await sendIfDue(state, dateKey, time, 'teamMatches:' + season, context.allMatches.length > 0, () => sendPushToUsers({
title: 'Punktspiele',
body: matchSummary(context.allMatches, 'Es stehen Punktspiele an.'),
data: { type: 'team_matches', date: dateKey, season },
bodyForUser: (user, settings) => matchSummary(matchesForUser(user, settings, context), 'Es stehen Punktspiele an.'),
predicate: (user, settings) => settings.notificationTime === time &&
notificationSeasonForSettings(settings, defaultSeason) === season &&
matchesForUser(user, settings, context).length > 0,
failureLabel: 'FCM Punktspiele-Push'
}), ['allTeamMatches:' + season, 'selectedTeamMatches:' + season, 'ownTeamMatches:' + season]))
}
results.teamMatches = teamMatchResults.some(Boolean)
results.allTeamMatches = results.teamMatches
results.selectedTeamMatches = results.teamMatches
results.ownTeamMatches = results.teamMatches
results.birthdays = await sendIfDue(state, dateKey, time, 'birthdays', todaysBirthdays.length > 0, () => sendPushToUsers({
title: 'Geburtstage heute',
body: formatBirthdaySummary(todaysBirthdays),
data: { type: 'birthdays', date: dateKey },
predicate: (_user, settings) => settings.notificationTime === time && settings.birthdays,
failureLabel: 'FCM Geburtstags-Push'
}))
await writeState(state)
loggerInfo('[notification-scheduler] Lauf abgeschlossen', { dateKey, time, dueUsers: dueUsers.length, results })
return { dateKey, time, dueUsers: dueUsers.length, results }
}

View File

@@ -0,0 +1,55 @@
export const DEFAULT_NOTIFICATION_SETTINGS = Object.freeze({
newNews: false,
newEvents: false,
eventsToday: false,
eventsTomorrow: false,
ownTeamMatches: false,
allTeamMatches: false,
birthdays: false,
newContactRequest: false,
newUserRegistration: false,
selectedTeamSlugs: [],
selectedTeamSeason: null,
notificationTime: '09:00'
})
function coerceBoolean(value) {
return value === true
}
export function sanitizeNotificationSettings(input = {}) {
const selectedTeamSlugs = Array.isArray(input.selectedTeamSlugs)
? input.selectedTeamSlugs
.map(value => String(value || '').trim())
.filter(Boolean)
.slice(0, 50)
: []
const selectedTeamSeason = typeof input.selectedTeamSeason === 'string' && input.selectedTeamSeason.trim()
? input.selectedTeamSeason.trim().slice(0, 30)
: null
const notificationTime = /^([01]\d|2[0-3]):[0-5]\d$/.test(String(input.notificationTime || ''))
? String(input.notificationTime)
: DEFAULT_NOTIFICATION_SETTINGS.notificationTime
return {
newNews: coerceBoolean(input.newNews),
newEvents: coerceBoolean(input.newEvents),
eventsToday: coerceBoolean(input.eventsToday),
eventsTomorrow: coerceBoolean(input.eventsTomorrow),
ownTeamMatches: coerceBoolean(input.ownTeamMatches),
allTeamMatches: coerceBoolean(input.allTeamMatches),
birthdays: coerceBoolean(input.birthdays),
newContactRequest: coerceBoolean(input.newContactRequest),
newUserRegistration: coerceBoolean(input.newUserRegistration),
selectedTeamSlugs: [...new Set(selectedTeamSlugs)],
selectedTeamSeason,
notificationTime
}
}
export function notificationSettingsForUser(user) {
return sanitizeNotificationSettings({
...DEFAULT_NOTIFICATION_SETTINGS,
...(user?.notificationSettings || user?.notifications || {})
})
}

View File

@@ -0,0 +1,273 @@
import crypto from 'crypto'
import { promises as fs } from 'fs'
import path from 'path'
import { readUsers, writeUsers, isHiddenUser, migrateUserRoles } from './auth.js'
import { notificationSettingsForUser } from './notification-settings.js'
const FCM_SCOPE = 'https://www.googleapis.com/auth/firebase.messaging'
const TOKEN_URL = 'https://oauth2.googleapis.com/token'
const tokenCache = { accessToken: null, expiresAt: 0 }
function base64Url(input) {
return Buffer.from(input).toString('base64url')
}
function projectIdFromServiceAccount(serviceAccount) {
return process.env.FCM_PROJECT_ID || serviceAccount.project_id
}
function serviceAccountCandidatePaths() {
const filename = 'harheimer-tc-firebase-adminsdk-fbsvc-18b66a2971.json'
const cwd = process.cwd()
const candidates = []
if (process.env.GOOGLE_APPLICATION_CREDENTIALS) candidates.push(process.env.GOOGLE_APPLICATION_CREDENTIALS)
candidates.push(path.join(cwd, 'server/data', filename))
candidates.push(path.join(cwd, '../server/data', filename))
return [...new Set(candidates)]
}
async function readServiceAccount() {
if (process.env.FCM_SERVICE_ACCOUNT_JSON) {
return JSON.parse(process.env.FCM_SERVICE_ACCOUNT_JSON)
}
for (const candidate of serviceAccountCandidatePaths()) {
try {
const raw = await fs.readFile(candidate, 'utf8')
return JSON.parse(raw)
} catch (error) {
if (error?.code !== 'ENOENT') {
console.warn(`FCM Service-Account konnte nicht gelesen werden (${candidate}): ${error.message}`)
}
}
}
return null
}
async function getAccessToken(serviceAccount) {
if (tokenCache.accessToken && tokenCache.expiresAt > Date.now() + 60_000) {
return tokenCache.accessToken
}
const now = Math.floor(Date.now() / 1000)
const assertion = [
base64Url(JSON.stringify({ alg: 'RS256', typ: 'JWT' })),
base64Url(JSON.stringify({
iss: serviceAccount.client_email,
scope: FCM_SCOPE,
aud: TOKEN_URL,
iat: now,
exp: now + 3600
}))
].join('.')
const signature = crypto
.createSign('RSA-SHA256')
.update(assertion)
.sign(serviceAccount.private_key, 'base64url')
const response = await fetch(TOKEN_URL, {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
assertion: `${assertion}.${signature}`
})
})
if (!response.ok) {
throw new Error(`FCM OAuth fehlgeschlagen: ${response.status}`)
}
const body = await response.json()
tokenCache.accessToken = body.access_token
tokenCache.expiresAt = Date.now() + Number(body.expires_in || 3600) * 1000
return tokenCache.accessToken
}
function pushTokensForUser(user) {
return Array.isArray(user.pushTokens)
? user.pushTokens.filter(entry => entry?.token && entry.platform === 'android')
: []
}
export function upsertPushToken(user, { token, platform = 'android', appVersion = null }) {
const normalizedToken = String(token || '').trim()
if (!normalizedToken) return user
const now = new Date().toISOString()
const tokens = Array.isArray(user.pushTokens) ? user.pushTokens : []
const next = tokens.filter(entry => entry?.token !== normalizedToken)
next.push({
token: normalizedToken,
platform: String(platform || 'android').slice(0, 30),
appVersion: appVersion ? String(appVersion).slice(0, 80) : null,
updatedAt: now,
createdAt: tokens.find(entry => entry?.token === normalizedToken)?.createdAt || now
})
user.pushTokens = next.slice(-20)
return user
}
async function sendFcmMessage({ serviceAccount, accessToken, token, title, body, data = {} }) {
const projectId = projectIdFromServiceAccount(serviceAccount)
if (!projectId) throw new Error('FCM project_id fehlt.')
const response = await fetch(`https://fcm.googleapis.com/v1/projects/${projectId}/messages:send`, {
method: 'POST',
headers: {
authorization: `Bearer ${accessToken}`,
'content-type': 'application/json'
},
body: JSON.stringify({
message: {
token,
notification: { title, body },
data,
android: {
priority: 'high',
notification: {
channel_id: 'harheimer_tc_updates',
click_action: 'OPEN_NEWS'
}
}
}
})
})
if (!response.ok) {
const text = await response.text().catch(() => '')
throw new Error(`FCM send fehlgeschlagen: ${response.status} ${text}`)
}
}
function isStaleFcmTokenError(error) {
return /UNREGISTERED|NOT_FOUND|INVALID_ARGUMENT/.test(String(error?.message || error || ''))
}
function notificationIdFor(value) {
return String(value || Date.now()).split('').reduce((acc, char) => acc + char.charCodeAt(0), 0).toString()
}
function userRoles(user) {
const migrated = migrateUserRoles({ ...(user || {}) })
return Array.isArray(migrated.roles) ? migrated.roles : []
}
function isVorstandUser(user) {
const roles = userRoles(user)
return roles.includes('admin') || roles.includes('vorstand')
}
export async function sendPushToUsers({ title, body, data = {}, predicate, bodyForUser, dataForUser, failureLabel = 'FCM-Push' }) {
const serviceAccount = await readServiceAccount()
if (serviceAccount == null) {
console.warn('FCM nicht konfiguriert: FCM_SERVICE_ACCOUNT_JSON oder GOOGLE_APPLICATION_CREDENTIALS fehlt.')
return { sent: 0, failed: 0, removed: 0, recipients: 0, tokenCount: 0, skipped: true }
}
const accessToken = await getAccessToken(serviceAccount)
const users = await readUsers()
let sent = 0
let failed = 0
let removed = 0
let recipients = 0
let tokenCount = 0
let changed = false
const baseData = Object.fromEntries(Object.entries(data).map(([key, value]) => [key, String(value ?? '')]))
for (const user of users) {
if (isHiddenUser(user)) continue
const settings = notificationSettingsForUser(user)
if (predicate && !predicate(user, settings)) continue
const userBody = String(bodyForUser ? bodyForUser(user, settings) : body || '').slice(0, 240)
const userData = dataForUser ? dataForUser(user, settings) : {}
const payload = {
...baseData,
...Object.fromEntries(Object.entries(userData || {}).map(([key, value]) => [key, String(value ?? '')])),
title: String(title || 'Harheimer TC'),
body: userBody,
notificationId: String((userData && userData.notificationId) || data.notificationId || notificationIdFor([data.type || 'push', title, userBody].join(':')))
}
recipients += 1
const tokens = pushTokensForUser(user)
tokenCount += tokens.length
const validTokens = []
for (const entry of tokens) {
try {
await sendFcmMessage({ serviceAccount, accessToken, token: entry.token, title, body: userBody, data: payload })
sent += 1
validTokens.push(entry)
} catch (error) {
if (isStaleFcmTokenError(error)) {
removed += 1
changed = true
console.warn('FCM Push-Token entfernt:', { failureLabel, reason: error.message })
} else {
failed += 1
console.error('FCM Push fehlgeschlagen:', { failureLabel, message: error.message })
validTokens.push(entry)
}
}
}
if (validTokens.length < tokens.length) {
user.pushTokens = validTokens
changed = true
}
}
if (changed) await writeUsers(users)
return { sent, failed, removed, recipients, tokenCount, skipped: false }
}
export async function sendNewNewsPush(news) {
const title = 'Neue News'
const body = String(news.title || 'Neue Nachricht vom Harheimer TC').slice(0, 120)
return sendPushToUsers({
title,
body,
data: {
type: 'news',
newsId: String(news.id || ''),
notificationId: notificationIdFor(news.id || Date.now())
},
predicate: (_user, settings) => settings.newNews,
failureLabel: 'FCM News-Push'
})
}
export async function sendNewEventPush(termin) {
const title = 'Neuer Termin'
const body = String(termin?.titel || 'Ein neuer Termin wurde eingetragen.').slice(0, 120)
return sendPushToUsers({
title,
body,
data: {
type: 'event',
date: termin?.datum || '',
notificationId: notificationIdFor(`event:${termin?.datum || ''}:${termin?.titel || ''}`)
},
predicate: (_user, settings) => settings.newEvents,
failureLabel: 'FCM Termin-Push'
})
}
export async function sendNewContactRequestPush(contactRequest) {
const title = 'Neue Kontaktanfrage'
const body = String(contactRequest?.subject || contactRequest?.name || 'Eine neue Kontaktanfrage ist eingegangen.').slice(0, 120)
return sendPushToUsers({
title,
body,
data: {
type: 'contact_request',
notificationId: notificationIdFor(`contact:${contactRequest?.email || ''}:${contactRequest?.subject || ''}:${Date.now()}`)
},
predicate: (user, settings) => isVorstandUser(user) && settings.newContactRequest,
failureLabel: 'FCM Kontaktanfrage-Push'
})
}
export async function sendNewUserRegistrationPush(registration) {
const title = 'Neue Benutzerregistrierung'
const body = String(registration?.name || registration?.email || 'Eine neue Registrierung wartet auf Freigabe.').slice(0, 120)
return sendPushToUsers({
title,
body,
data: {
type: 'user_registration',
userId: registration?.id || '',
notificationId: notificationIdFor(`registration:${registration?.id || registration?.email || Date.now()}`)
},
predicate: (user, settings) => isVorstandUser(user) && settings.newUserRegistration,
failureLabel: 'FCM Registrierungs-Push'
})
}

View File

@@ -35,6 +35,9 @@ import configGetHandler from '../server/api/config.get.js'
import configPutHandler from '../server/api/config.put.js' import configPutHandler from '../server/api/config.put.js'
import profileGetHandler from '../server/api/profile.get.js' import profileGetHandler from '../server/api/profile.get.js'
import profilePutHandler from '../server/api/profile.put.js' import profilePutHandler from '../server/api/profile.put.js'
import profileNotificationsGetHandler from '../server/api/profile/notifications.get.js'
import profileNotificationsPutHandler from '../server/api/profile/notifications.put.js'
import profilePushTokenHandler from '../server/api/profile/push-token.post.js'
const invalidCurrentPassword = ['invalid', 'test', 'pw'].join('-') const invalidCurrentPassword = ['invalid', 'test', 'pw'].join('-')
const validCurrentPassword = ['valid', 'test', 'pw'].join('-') const validCurrentPassword = ['valid', 'test', 'pw'].join('-')
@@ -157,6 +160,113 @@ describe('Config & Profil Endpoints', () => {
}) })
}) })
describe('GET /api/profile/notifications', () => {
it('verlangt Authentifizierung', async () => {
const event = createEvent()
await expect(profileNotificationsGetHandler(event)).rejects.toMatchObject({ statusCode: 401 })
})
it('liefert Defaults plus gespeicherte Benachrichtigungseinstellungen', async () => {
const event = createEvent({ headers: { authorization: 'Bearer android-token' } })
authUtils.verifyToken.mockReturnValue({ id: '1' })
authUtils.getUserFromToken.mockResolvedValue({
id: '1',
notificationSettings: {
eventsToday: true,
selectedTeamSlugs: ['herren-1', 'herren-1', ''],
selectedTeamSeason: '2025/2026',
notificationTime: '07:30'
}
})
const result = await profileNotificationsGetHandler(event)
expect(result.success).toBe(true)
expect(result.settings.eventsToday).toBe(true)
expect(result.settings.newEvents).toBe(false)
expect(result.settings.selectedTeamSlugs).toEqual(['herren-1'])
expect(result.settings.notificationTime).toBe('07:30')
})
})
describe('PUT /api/profile/notifications', () => {
it('verlangt Authentifizierung', async () => {
const event = createEvent({ body: { eventsToday: true } })
await expect(profileNotificationsPutHandler(event)).rejects.toMatchObject({ statusCode: 401 })
})
it('speichert sanitizte Benachrichtigungseinstellungen am Benutzer', async () => {
const event = createEvent({ headers: { authorization: 'Bearer android-token' } })
mockSuccessReadBody({
newEvents: true,
eventsToday: 'true',
birthdays: true,
selectedTeamSlugs: ['herren-1', 'herren-1', ' jugend '],
selectedTeamSeason: '2026/2027',
notificationTime: '25:99'
})
const users = [{ id: '1', email: 'max@test.de', roles: ['mitglied'] }]
authUtils.verifyToken.mockReturnValue({ id: '1' })
authUtils.getUserFromToken.mockResolvedValue(users[0])
authUtils.readUsers.mockResolvedValue(users)
authUtils.writeUsers.mockResolvedValue(true)
const result = await profileNotificationsPutHandler(event)
expect(result.success).toBe(true)
expect(result.settings.newEvents).toBe(true)
expect(result.settings.eventsToday).toBe(false)
expect(result.settings.birthdays).toBe(true)
expect(result.settings.selectedTeamSlugs).toEqual(['herren-1', 'jugend'])
expect(result.settings.notificationTime).toBe('09:00')
expect(authUtils.writeUsers).toHaveBeenCalledWith([
expect.objectContaining({
id: '1',
notificationSettings: expect.objectContaining({
newEvents: true,
birthdays: true,
selectedTeamSeason: '2026/2027'
})
})
])
})
})
describe('POST /api/profile/push-token', () => {
it('verlangt Authentifizierung', async () => {
const event = createEvent()
mockSuccessReadBody({ token: 'fcm-token' })
await expect(profilePushTokenHandler(event)).rejects.toMatchObject({ statusCode: 401 })
})
it('speichert Android-Push-Token am Benutzer', async () => {
const event = createEvent({ headers: { authorization: 'Bearer android-token' } })
mockSuccessReadBody({ token: 'fcm-token', platform: 'android', appVersion: '1.0+1' })
const users = [{ id: '1', email: 'max@test.de', roles: ['mitglied'] }]
authUtils.verifyToken.mockReturnValue({ id: '1' })
authUtils.getUserFromToken.mockResolvedValue(users[0])
authUtils.readUsers.mockResolvedValue(users)
authUtils.writeUsers.mockResolvedValue(true)
const result = await profilePushTokenHandler(event)
expect(result.success).toBe(true)
expect(authUtils.writeUsers).toHaveBeenCalledWith([
expect.objectContaining({
id: '1',
pushTokens: [expect.objectContaining({ token: 'fcm-token', platform: 'android', appVersion: '1.0+1' })]
})
])
})
})
describe('PUT /api/profile', () => { describe('PUT /api/profile', () => {
it('verlangt Authentifizierung', async () => { it('verlangt Authentifizierung', async () => {
const event = createEvent() const event = createEvent()

View File

@@ -4,6 +4,7 @@ import path from 'path'
import { promises as fs } from 'fs' import { promises as fs } from 'fs'
import { import {
getBackupDirectoryForDataFile, getBackupDirectoryForDataFile,
resolveDataFileBackupPath,
listDataFileBackups, listDataFileBackups,
restoreDataFileBackup, restoreDataFileBackup,
writeDataFileWithRotation writeDataFileWithRotation
@@ -60,7 +61,7 @@ describe('Data file rotation utility', () => {
expect(backups.length).toBe(1) expect(backups.length).toBe(1)
const backupDir = getBackupDirectoryForDataFile(dataFile) const backupDir = getBackupDirectoryForDataFile(dataFile)
const backupContent = await fs.readFile(path.join(backupDir, backups[0]), 'utf-8') const backupContent = await fs.readFile(resolveDataFileBackupPath(backupDir, backups[0]), 'utf-8')
expect(backupContent).toBe('v1') expect(backupContent).toBe('v1')
const currentContent = await fs.readFile(dataFile, 'utf-8') const currentContent = await fs.readFile(dataFile, 'utf-8')
@@ -91,7 +92,7 @@ describe('Data file rotation utility', () => {
const backupDir = getBackupDirectoryForDataFile(dataFile) const backupDir = getBackupDirectoryForDataFile(dataFile)
const backupContents = await Promise.all( const backupContents = await Promise.all(
backups.map((name) => fs.readFile(path.join(backupDir, name), 'utf-8')) backups.map((name) => fs.readFile(resolveDataFileBackupPath(backupDir, name), 'utf-8'))
) )
expect(backupContents).toEqual(expect.arrayContaining(['v2', 'v3'])) expect(backupContents).toEqual(expect.arrayContaining(['v2', 'v3']))
@@ -109,7 +110,7 @@ describe('Data file rotation utility', () => {
const resolved = await Promise.all( const resolved = await Promise.all(
beforeRestoreBackups.map(async (name) => ({ beforeRestoreBackups.map(async (name) => ({
name, name,
content: await fs.readFile(path.join(backupDir, name), 'utf-8') content: await fs.readFile(resolveDataFileBackupPath(backupDir, name), 'utf-8')
})) }))
) )
const v1Entry = resolved.find((entry) => entry.content === 'v1') const v1Entry = resolved.find((entry) => entry.content === 'v1')
@@ -124,7 +125,7 @@ describe('Data file rotation utility', () => {
const afterRestoreBackups = await listDataFileBackups(dataFile) const afterRestoreBackups = await listDataFileBackups(dataFile)
const afterContents = await Promise.all( const afterContents = await Promise.all(
afterRestoreBackups.map((name) => fs.readFile(path.join(backupDir, name), 'utf-8')) afterRestoreBackups.map((name) => fs.readFile(resolveDataFileBackupPath(backupDir, name), 'utf-8'))
) )
expect(afterContents).toContain('v3') expect(afterContents).toContain('v3')

116
tests/email-service.spec.ts Normal file
View File

@@ -0,0 +1,116 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import fs from 'fs/promises'
vi.mock('nodemailer', () => {
const sendMail = vi.fn().mockResolvedValue({ messageId: 'test-message' })
const createTransport = vi.fn(() => ({ sendMail }))
return {
default: { createTransport },
createTransport
}
})
vi.mock('../server/utils/auth.js', () => ({
readUsers: vi.fn(),
migrateUserRoles: vi.fn((user) => {
if (!user) return user
if (Array.isArray(user.roles)) return user
if (user.role) {
user.roles = [user.role]
delete user.role
} else {
user.roles = ['mitglied']
}
return user
}),
isHiddenUser: vi.fn(user => user?.hidden === true || user?.invisible === true || user?.isHidden === true || user?.systemAccount === true || user?.accountType === 'playstore_review')
}))
const nodemailer = await import('nodemailer')
const authUtils = await import('../server/utils/auth.js')
const emailService = await import('../server/utils/email-service.js')
describe('Email service recipients', () => {
beforeEach(() => {
vi.restoreAllMocks()
vi.clearAllMocks()
process.env.SMTP_USER = 'smtp@example.com'
process.env.SMTP_PASS = 'smtp-password'
authUtils.readUsers.mockResolvedValue([])
})
afterEach(() => {
delete process.env.SMTP_USER
delete process.env.SMTP_PASS
delete process.env.NODE_ENV
delete process.env.APP_ENV
delete process.env.DEBUG
})
it('sendet bei DEBUG=FALSE in production an Vorstand statt Entwickleradresse', async () => {
process.env.NODE_ENV = 'production'
process.env.APP_ENV = 'test'
process.env.DEBUG = 'FALSE'
vi.spyOn(fs, 'readFile').mockResolvedValue(JSON.stringify({
vorstand: {
vorsitzender: { email: 'vorstand@example.com' }
}
}))
await emailService.sendRegistrationNotification({
name: 'Max Muster',
email: 'max@example.com',
phone: '069123456'
})
const transporter = nodemailer.default.createTransport.mock.results[0].value
expect(transporter.sendMail).toHaveBeenCalledWith(expect.objectContaining({
to: 'vorstand@example.com'
}))
expect(transporter.sendMail.mock.calls[0][0].to).not.toContain('tsschulz@tsschulz.de')
})
it('bevorzugt aktive Vorstand-Benutzer vor config.json', async () => {
process.env.NODE_ENV = 'production'
process.env.DEBUG = 'FALSE'
authUtils.readUsers.mockResolvedValue([
{ id: '1', email: 'rolle-vorstand@example.com', roles: ['vorstand'], active: true },
{ id: '2', email: 'inaktiv@example.com', roles: ['vorstand'], active: false },
{ id: '3', email: 'mitglied@example.com', roles: ['mitglied'], active: true }
])
vi.spyOn(fs, 'readFile').mockResolvedValue(JSON.stringify({
vorstand: {
vorsitzender: { email: 'config-vorstand@example.com' }
}
}))
await emailService.sendRegistrationNotification({
name: 'Max Muster',
email: 'max@example.com'
})
const transporter = nodemailer.default.createTransport.mock.results[0].value
const to = transporter.sendMail.mock.calls[0][0].to
expect(to).toBe('rolle-vorstand@example.com')
expect(to).not.toContain('config-vorstand@example.com')
expect(to).not.toContain('inaktiv@example.com')
})
it('sendet nur bei explizitem DEBUG=true an die Entwickleradresse', async () => {
process.env.NODE_ENV = 'production'
process.env.DEBUG = 'true'
vi.spyOn(fs, 'readFile').mockResolvedValue(JSON.stringify({
vorstand: {
vorsitzender: { email: 'vorstand@example.com' }
}
}))
await emailService.sendRegistrationNotification({
name: 'Max Muster',
email: 'max@example.com'
})
const transporter = nodemailer.default.createTransport.mock.results[0].value
expect(transporter.sendMail.mock.calls[0][0].to).toBe('tsschulz@tsschulz.de')
})
})

View File

@@ -51,12 +51,13 @@ import membersGetHandler from '../server/api/members.get.js'
import membersPostHandler from '../server/api/members.post.js' import membersPostHandler from '../server/api/members.post.js'
import membersDeleteHandler from '../server/api/members.delete.js' import membersDeleteHandler from '../server/api/members.delete.js'
import membersBulkHandler from '../server/api/members/bulk.post.js' import membersBulkHandler from '../server/api/members/bulk.post.js'
import membersBulkHandler from '../server/api/members/bulk.post.js'
import toggleMannschaftsspielerHandler from '../server/api/members/toggle-mannschaftsspieler.post.js' import toggleMannschaftsspielerHandler from '../server/api/members/toggle-mannschaftsspieler.post.js'
describe('Members API Endpoints', () => { describe('Members API Endpoints', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
authUtils.readUsers.mockResolvedValue([])
memberUtils.readMembers.mockResolvedValue([])
}) })
describe('GET /api/members', () => { describe('GET /api/members', () => {
@@ -100,6 +101,38 @@ describe('Members API Endpoints', () => {
expect(response.members[0].name).toBe('Anna Muster') expect(response.members[0].name).toBe('Anna Muster')
}) })
it('liefert Geburtstags-Sichtbarkeit für Admin/Vorstand-Bearbeitung', async () => {
const event = createEvent({ cookies: { auth_token: 'token' } })
authUtils.verifyToken.mockReturnValue({ id: '1' })
memberUtils.readMembers.mockResolvedValue([
{ id: 'm1', firstName: 'Anna', lastName: 'Muster', geburtsdatum: '2000-01-01', visibility: { showBirthday: false } }
])
authUtils.readUsers.mockResolvedValue([])
authUtils.getUserFromToken.mockResolvedValue({ id: '1', role: 'admin' })
const response = await membersGetHandler(event)
expect(response.members).toHaveLength(1)
expect(response.members[0].showBirthday).toBe(false)
})
it('uebernimmt Geburtstags-Sichtbarkeit vom Login-Benutzer beim Merge', async () => {
const event = createEvent({ cookies: { auth_token: 'token' } })
authUtils.verifyToken.mockReturnValue({ id: '1' })
memberUtils.readMembers.mockResolvedValue([
{ id: 'm1', firstName: 'Anna', lastName: 'Muster', email: 'anna@club.de', geburtsdatum: '2000-01-01', visibility: { showBirthday: true } }
])
authUtils.readUsers.mockResolvedValue([
{ id: 'u1', name: 'Anna Muster', email: 'anna@club.de', active: true, visibility: { showBirthday: false } }
])
authUtils.getUserFromToken.mockResolvedValue({ id: '1', role: 'admin' })
const response = await membersGetHandler(event)
expect(response.members).toHaveLength(1)
expect(response.members[0].showBirthday).toBe(false)
})
it('blendet unsichtbare Playstore-Benutzer und passende manuelle Einträge aus', async () => { it('blendet unsichtbare Playstore-Benutzer und passende manuelle Einträge aus', async () => {
const event = createEvent({ cookies: { auth_token: 'token' } }) const event = createEvent({ cookies: { auth_token: 'token' } })
@@ -159,6 +192,8 @@ describe('Members API Endpoints', () => {
const event = createEvent({ cookies: { auth_token: 'token' } }) const event = createEvent({ cookies: { auth_token: 'token' } })
mockSuccessReadBody(baseBody) mockSuccessReadBody(baseBody)
authUtils.getUserFromToken.mockResolvedValue({ id: '2', role: 'admin' }) authUtils.getUserFromToken.mockResolvedValue({ id: '2', role: 'admin' })
authUtils.readUsers.mockResolvedValue([])
memberUtils.readMembers.mockResolvedValue([])
memberUtils.saveMember.mockResolvedValue(true) memberUtils.saveMember.mockResolvedValue(true)
const response = await membersPostHandler(event) const response = await membersPostHandler(event)
@@ -168,6 +203,76 @@ describe('Members API Endpoints', () => {
})) }))
}) })
it('speichert Geburtstags-Sichtbarkeit für manuelle Mitglieder', async () => {
const event = createEvent({ cookies: { auth_token: 'token' } })
mockSuccessReadBody({ ...baseBody, showBirthday: false })
authUtils.getUserFromToken.mockResolvedValue({ id: '2', role: 'admin' })
authUtils.readUsers.mockResolvedValue([])
memberUtils.readMembers.mockResolvedValue([
{ id: 'manual-1', firstName: 'Lisa', lastName: 'Beispiel', email: 'lisa@example.com', visibility: { showBirthday: true } }
])
memberUtils.saveMember.mockResolvedValue(true)
const response = await membersPostHandler(event)
expect(response.success).toBe(true)
expect(memberUtils.saveMember).toHaveBeenCalledWith(expect.objectContaining({
visibility: expect.objectContaining({ showBirthday: false })
}))
expect(authUtils.writeUsers).not.toHaveBeenCalled()
})
it('kann Geburtstags-Sichtbarkeit auch am Login-Benutzer ausschalten', async () => {
const event = createEvent({ cookies: { auth_token: 'token' } })
mockSuccessReadBody({
id: 'user-1',
...baseBody,
email: 'lisa@example.com',
visibility: { showBirthday: false }
})
authUtils.getUserFromToken.mockResolvedValue({ id: '2', role: 'vorstand' })
memberUtils.readMembers.mockResolvedValue([])
authUtils.readUsers.mockResolvedValue([
{ id: 'user-1', email: 'lisa@example.com', visibility: { showBirthday: true, showEmail: true } }
])
authUtils.writeUsers.mockResolvedValue(undefined)
memberUtils.saveMember.mockResolvedValue(true)
const response = await membersPostHandler(event)
expect(response.success).toBe(true)
expect(authUtils.writeUsers).toHaveBeenCalledWith([
expect.objectContaining({
id: 'user-1',
visibility: expect.objectContaining({ showBirthday: false, showEmail: true })
})
])
})
it('darf Geburtstags-Sichtbarkeit nicht für Login-Benutzer einschalten', async () => {
const event = createEvent({ cookies: { auth_token: 'token' } })
mockSuccessReadBody({
id: 'user-1',
...baseBody,
email: 'lisa@example.com',
visibility: { showBirthday: true }
})
authUtils.getUserFromToken.mockResolvedValue({ id: '2', role: 'vorstand' })
memberUtils.readMembers.mockResolvedValue([])
authUtils.readUsers.mockResolvedValue([
{ id: 'user-1', email: 'lisa@example.com', visibility: { showBirthday: false, showEmail: true } }
])
memberUtils.saveMember.mockResolvedValue(true)
const response = await membersPostHandler(event)
expect(response.success).toBe(true)
expect(memberUtils.saveMember).toHaveBeenCalledWith(expect.objectContaining({
visibility: expect.objectContaining({ showBirthday: false })
}))
expect(authUtils.writeUsers).not.toHaveBeenCalled()
})
it('erlaubt vorstand beim Speichern', async () => { it('erlaubt vorstand beim Speichern', async () => {
const event = createEvent({ cookies: { auth_token: 'token' } }) const event = createEvent({ cookies: { auth_token: 'token' } })
mockSuccessReadBody(baseBody) mockSuccessReadBody(baseBody)
@@ -187,6 +292,7 @@ describe('Members API Endpoints', () => {
email: 'lisa@example.com' email: 'lisa@example.com'
}) })
authUtils.getUserFromToken.mockResolvedValue({ id: '3', role: 'vorstand' }) authUtils.getUserFromToken.mockResolvedValue({ id: '3', role: 'vorstand' })
authUtils.readUsers.mockResolvedValue([])
memberUtils.saveMember.mockResolvedValue(true) memberUtils.saveMember.mockResolvedValue(true)
const response = await membersPostHandler(event) const response = await membersPostHandler(event)

View File

@@ -17,8 +17,13 @@ vi.mock('../server/utils/news.js', () => ({
deleteNews: vi.fn() deleteNews: vi.fn()
})) }))
vi.mock('../server/utils/push-notifications.js', () => ({
sendNewNewsPush: vi.fn().mockResolvedValue({ sent: 1, skipped: false })
}))
const authUtils = await import('../server/utils/auth.js') const authUtils = await import('../server/utils/auth.js')
const newsUtils = await import('../server/utils/news.js') const newsUtils = await import('../server/utils/news.js')
const pushUtils = await import('../server/utils/push-notifications.js')
import newsGetHandler from '../server/api/news.get.js' import newsGetHandler from '../server/api/news.get.js'
import newsPostHandler from '../server/api/news.post.js' import newsPostHandler from '../server/api/news.post.js'
@@ -111,6 +116,29 @@ describe('News API Endpoints', () => {
expect(newsUtils.saveNews).toHaveBeenCalledWith( expect(newsUtils.saveNews).toHaveBeenCalledWith(
expect.objectContaining({ title: 'Neue Info', content: 'Inhalt hier', isPublic: true }) expect.objectContaining({ title: 'Neue Info', content: 'Inhalt hier', isPublic: true })
) )
expect(pushUtils.sendNewNewsPush).toHaveBeenCalledWith(
expect.objectContaining({ title: 'Neue Info', content: 'Inhalt hier', isPublic: true })
)
})
it('sendet keinen Push bei News-Update', async () => {
const event = adminEvent()
newsUtils.saveNews.mockResolvedValue(undefined)
mockSuccessReadBody({ id: 'existing-news', title: 'Update', content: 'Inhalt' })
await newsPostHandler(event)
expect(pushUtils.sendNewNewsPush).not.toHaveBeenCalled()
})
it('sendet keinen Push bei versteckten News', async () => {
const event = adminEvent()
newsUtils.saveNews.mockResolvedValue(undefined)
mockSuccessReadBody({ title: 'Intern', content: 'Inhalt', isHidden: true })
await newsPostHandler(event)
expect(pushUtils.sendNewNewsPush).not.toHaveBeenCalled()
}) })
it('setzt autor auf den angemeldeten Benutzer', async () => { it('setzt autor auf den angemeldeten Benutzer', async () => {

View File

@@ -0,0 +1,191 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import fs from 'fs/promises'
vi.mock('../server/utils/auth.js', () => ({
readUsers: vi.fn(),
isHiddenUser: vi.fn(user => user?.hidden === true || user?.invisible === true || user?.isHidden === true || user?.systemAccount === true)
}))
vi.mock('../server/utils/members.js', () => ({
readMembers: vi.fn()
}))
vi.mock('../server/utils/termine.js', () => ({
readTermine: vi.fn().mockResolvedValue([])
}))
vi.mock('../server/utils/news.js', () => ({
readNews: vi.fn().mockResolvedValue([])
}))
vi.mock('../server/utils/spielplan-data.js', () => ({
getDefaultSpielplanSeason: vi.fn().mockResolvedValue('25--26'),
readSpielplanData: vi.fn().mockResolvedValue({ data: [] })
}))
vi.mock('../server/utils/push-notifications.js', () => ({
sendPushToUsers: vi.fn().mockResolvedValue({ sent: 1, failed: 0, removed: 0, recipients: 1, tokenCount: 1, skipped: false })
}))
vi.mock('../server/utils/logger.js', () => ({
error: vi.fn(),
info: vi.fn(),
warn: vi.fn()
}))
const authUtils = await import('../server/utils/auth.js')
const memberUtils = await import('../server/utils/members.js')
const pushUtils = await import('../server/utils/push-notifications.js')
const spielplanUtils = await import('../server/utils/spielplan-data.js')
const { runNotificationSchedulerTick } = await import('../server/utils/notification-scheduler.js')
const schedulerNow = new Date('2026-06-14T07:00:00.000Z')
const recipient = {
id: 'recipient',
name: 'Push Empfaenger',
active: true,
notificationSettings: {
birthdays: true,
notificationTime: '09:00'
}
}
describe('Notification Scheduler', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.spyOn(fs, 'readFile').mockImplementation(async (filePath) => {
if (String(filePath).includes('mannschaften_25--26.csv')) {
return [
'Mannschaft,Liga,Staffelleiter,Telefon,Heimspieltag,Spielsystem,Mannschaftsführer,Spieler,Weitere Informationen Link,Letzte Aktualisierung',
'Erwachsene 1,,,,,,Mannschaftsfuehrer,Max Spieler,,',
'Erwachsene 2,,,,,,Andere Person,Andere Spieler,,'
].join('\n')
}
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
})
vi.spyOn(fs, 'mkdir').mockResolvedValue(undefined)
vi.spyOn(fs, 'writeFile').mockResolvedValue(undefined)
memberUtils.readMembers.mockResolvedValue([])
authUtils.readUsers.mockResolvedValue([recipient])
spielplanUtils.getDefaultSpielplanSeason.mockResolvedValue('25--26')
spielplanUtils.readSpielplanData.mockResolvedValue({ data: [] })
})
it('sendet Geburtstags-Push nur fuer Mitglieder mit expliziter Geburtstagsfreigabe', async () => {
memberUtils.readMembers.mockResolvedValue([
{ firstName: 'Erlaubt', lastName: 'Person', active: true, geburtsdatum: '1990-06-14', visibility: { showBirthday: true } },
{ firstName: 'Privat', lastName: 'Person', active: true, geburtsdatum: '1990-06-14', visibility: { showBirthday: false } },
{ firstName: 'Unklar', lastName: 'Person', active: true, geburtsdatum: '1990-06-14' }
])
await runNotificationSchedulerTick(schedulerNow)
expect(pushUtils.sendPushToUsers).toHaveBeenCalledTimes(1)
expect(pushUtils.sendPushToUsers).toHaveBeenCalledWith(expect.objectContaining({
title: 'Geburtstage heute',
body: 'Erlaubt Person hat heute Geburtstag.',
data: { type: 'birthdays', date: '2026-06-14' }
}))
})
it('nennt bei mehreren Geburtstags-Pushes nur Namen und kein Alter', async () => {
memberUtils.readMembers.mockResolvedValue([
{ firstName: 'Anna', lastName: 'Beispiel', active: true, geburtsdatum: '1980-06-14', visibility: { showBirthday: true } },
{ firstName: 'Bert', lastName: 'Beispiel', active: true, geburtsdatum: '2010-06-14', visibility: { showBirthday: true } }
])
await runNotificationSchedulerTick(schedulerNow)
const payload = pushUtils.sendPushToUsers.mock.calls[0][0]
expect(payload.body).toBe('Geburtstage heute: Anna Beispiel, Bert Beispiel.')
expect(payload.body).not.toMatch(/\b\d+\b/)
expect(payload.body).not.toContain('Jahre')
})
it('sendet Punktspiel-Push nur einmal, wenn alle, eigene und ausgewaehlte Mannschaft dasselbe Spiel treffen', async () => {
const matchUser = {
id: 'match-user',
name: 'Max Spieler',
active: true,
notificationSettings: {
allTeamMatches: true,
ownTeamMatches: true,
selectedTeamSlugs: ['erwachsene-1'],
selectedTeamSeason: '25--26',
notificationTime: '09:00'
}
}
authUtils.readUsers.mockResolvedValue([matchUser])
spielplanUtils.readSpielplanData.mockResolvedValue({
data: [{
Termin: '14.06.2026 20:15',
BegegnungNr: 'spiel-1',
Altersklasse: 'Erwachsene',
HeimVereinName: 'Harheimer TC',
HeimMannschaftAltersklasse: 'Erwachsene',
HeimMannschaftNr: '1',
HeimMannschaft: 'Harheimer TC',
GastVereinName: 'Gastverein',
GastMannschaftAltersklasse: 'Erwachsene',
GastMannschaftNr: '1',
GastMannschaft: 'Gastverein'
}]
})
await runNotificationSchedulerTick(schedulerNow)
expect(pushUtils.sendPushToUsers).toHaveBeenCalledTimes(1)
const payload = pushUtils.sendPushToUsers.mock.calls[0][0]
expect(payload.title).toBe('Punktspiele')
expect(payload.predicate(matchUser, matchUser.notificationSettings)).toBe(true)
expect(payload.bodyForUser(matchUser, matchUser.notificationSettings)).toContain('Harheimer TC - Gastverein')
})
it('fasst eigene und ausgewaehlte Punktspiele in einer Benachrichtigung zusammen', async () => {
const matchUser = {
id: 'match-user',
name: 'Max Spieler',
active: true,
notificationSettings: {
allTeamMatches: false,
ownTeamMatches: true,
selectedTeamSlugs: ['erwachsene-2'],
selectedTeamSeason: '25--26',
notificationTime: '09:00'
}
}
authUtils.readUsers.mockResolvedValue([matchUser])
spielplanUtils.readSpielplanData.mockResolvedValue({
data: [
{
Termin: '14.06.2026 20:15',
BegegnungNr: 'spiel-1',
Altersklasse: 'Erwachsene',
HeimVereinName: 'Harheimer TC',
HeimMannschaftAltersklasse: 'Erwachsene',
HeimMannschaftNr: '1',
HeimMannschaft: 'Harheimer TC',
GastMannschaft: 'Gastverein'
},
{
Termin: '14.06.2026 20:30',
BegegnungNr: 'spiel-2',
Altersklasse: 'Erwachsene',
HeimMannschaft: 'Gastverein II',
GastVereinName: 'Harheimer TC',
GastMannschaftAltersklasse: 'Erwachsene',
GastMannschaftNr: '2',
GastMannschaft: 'Harheimer TC II'
}
]
})
await runNotificationSchedulerTick(schedulerNow)
expect(pushUtils.sendPushToUsers).toHaveBeenCalledTimes(1)
const payload = pushUtils.sendPushToUsers.mock.calls[0][0]
expect(payload.predicate(matchUser, matchUser.notificationSettings)).toBe(true)
expect(payload.bodyForUser(matchUser, matchUser.notificationSettings)).toBe('2 Punktspiele am 14.06.2026')
})
})

View File

@@ -16,8 +16,25 @@ vi.mock('../server/utils/news.js', () => ({
readNews: vi.fn() readNews: vi.fn()
})) }))
vi.mock('../server/utils/auth.js', () => ({
readUsers: vi.fn(),
migrateUserRoles: vi.fn((user) => {
if (!user) return user
if (Array.isArray(user.roles)) return user
if (user.role) {
user.roles = [user.role]
delete user.role
} else {
user.roles = ['mitglied']
}
return user
}),
isHiddenUser: vi.fn(user => user?.hidden === true || user?.invisible === true || user?.isHidden === true || user?.systemAccount === true || user?.accountType === 'playstore_review')
}))
const nodemailer = await import('nodemailer') const nodemailer = await import('nodemailer')
const newsUtils = await import('../server/utils/news.js') const newsUtils = await import('../server/utils/news.js')
const authUtils = await import('../server/utils/auth.js')
import contactHandler from '../server/api/contact.post.js' import contactHandler from '../server/api/contact.post.js'
import galerieHandler from '../server/api/galerie.get.js' import galerieHandler from '../server/api/galerie.get.js'
@@ -29,14 +46,17 @@ describe('Öffentliche API-Endpunkte', () => {
afterEach(() => { afterEach(() => {
delete process.env.NODE_ENV delete process.env.NODE_ENV
delete process.env.APP_ENV delete process.env.APP_ENV
delete process.env.DEBUG
}) })
beforeEach(() => { beforeEach(() => {
// Setze SMTP-Credentials für Tests // Setze SMTP-Credentials für Tests
process.env.SMTP_USER = 'test@example.com' process.env.SMTP_USER = 'test@example.com'
process.env.SMTP_PASS = 'test-password' process.env.SMTP_PASS = 'test-password'
authUtils.readUsers.mockResolvedValue([])
vi.restoreAllMocks() vi.restoreAllMocks()
vi.clearAllMocks() vi.clearAllMocks()
authUtils.readUsers.mockResolvedValue([])
}) })
describe('POST /api/contact', () => { describe('POST /api/contact', () => {
@@ -78,6 +98,53 @@ describe('Öffentliche API-Endpunkte', () => {
to: 'tsschulz@tsschulz.de' to: 'tsschulz@tsschulz.de'
})) }))
}) })
it('sendet bei DEBUG=FALSE an konfigurierte Empfänger', async () => {
process.env.NODE_ENV = 'production'
process.env.APP_ENV = 'test'
process.env.DEBUG = 'FALSE'
vi.spyOn(fs, 'readFile').mockResolvedValue(JSON.stringify({
vorstand: {
vorsitzender: { email: 'vorstand@example.com' }
}
}))
const event = createEvent()
mockSuccessReadBody({ name: 'Max', email: 'max@example.com', subject: 'Frage', message: 'Hallo' })
await contactHandler(event)
const transporter = nodemailer.default.createTransport.mock.results[0].value
const to = transporter.sendMail.mock.calls[0][0].to
expect(to).toContain('vorstand@example.com')
expect(to).not.toContain('tsschulz@tsschulz.de')
})
it('bevorzugt aktive Vorstand-Benutzer vor config.json', async () => {
process.env.NODE_ENV = 'production'
process.env.DEBUG = 'FALSE'
authUtils.readUsers.mockResolvedValue([
{ id: '1', email: 'rolle-vorstand@example.com', roles: ['vorstand'], active: true },
{ id: '2', email: 'hidden@example.com', roles: ['vorstand'], active: true, hidden: true },
{ id: '3', email: 'trainer@example.com', roles: ['trainer'], active: true }
])
vi.spyOn(fs, 'readFile').mockResolvedValue(JSON.stringify({
vorstand: {
vorsitzender: { email: 'config-vorstand@example.com' }
}
}))
const event = createEvent()
mockSuccessReadBody({ name: 'Max', email: 'max@example.com', subject: 'Frage', message: 'Hallo' })
await contactHandler(event)
const transporter = nodemailer.default.createTransport.mock.results[0].value
const to = transporter.sendMail.mock.calls[0][0].to
expect(to).toContain('rolle-vorstand@example.com')
expect(to).not.toContain('config-vorstand@example.com')
expect(to).not.toContain('hidden@example.com')
})
}) })
describe('GET /api/galerie', () => { describe('GET /api/galerie', () => {