Compare commits
28 Commits
530e544542
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b69130c2b2 | ||
|
|
77aabef4a9 | ||
|
|
4b699de853 | ||
|
|
e3cb7282bc | ||
|
|
e537839e28 | ||
|
|
44d441811c | ||
|
|
da1efa5a74 | ||
| c7a306e8fa | |||
|
|
14cd5f04d5 | ||
|
|
7e533fae49 | ||
|
|
8393f154e5 | ||
|
|
c956869e8a | ||
|
|
4eabb3b766 | ||
|
|
146dedd9b4 | ||
|
|
6076194497 | ||
|
|
9cde1ab78b | ||
|
|
f5facaa811 | ||
|
|
78015298ec | ||
|
|
b4e1c50ea3 | ||
|
|
5da11d2e4d | ||
|
|
e8a50e55ca | ||
| 45de2a576c | |||
| e1ad5f7205 | |||
| 14341b7a63 | |||
| 803481ca8e | |||
| 46a8d5a77a | |||
| 96c1d0260b | |||
| 6e00a1b829 |
@@ -146,7 +146,7 @@ jobs:
|
||||
-o BatchMode=yes \
|
||||
-p "${{ vars.PROD_PORT }}" \
|
||||
"${{ 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:
|
||||
needs: analyze
|
||||
@@ -177,4 +177,4 @@ jobs:
|
||||
-o BatchMode=yes \
|
||||
-p "${{ vars.PROD_PORT }}" \
|
||||
"${{ 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
7
.gitleaks.toml
Normal 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
3
.gitleaksignore
Normal 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
|
||||
@@ -7,6 +7,10 @@ plugins {
|
||||
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")
|
||||
.orElse("https://harheimertc.tsschulz.de/")
|
||||
.get()
|
||||
@@ -84,6 +88,13 @@ android {
|
||||
versionName = androidVersionName
|
||||
}
|
||||
|
||||
lint {
|
||||
disable += setOf(
|
||||
"AutoboxingStateCreation",
|
||||
"MutableCollectionMutableState",
|
||||
)
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
create("release") {
|
||||
if (hasReleaseSigning) {
|
||||
@@ -253,6 +264,9 @@ dependencies {
|
||||
// Crash reporting
|
||||
implementation("io.sentry:sentry-android:8.42.0")
|
||||
|
||||
// Push notifications
|
||||
implementation("com.google.firebase:firebase-messaging:25.0.2")
|
||||
|
||||
// Room
|
||||
implementation("androidx.room:room-runtime: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.datastore:datastore-preferences:1.0.0")
|
||||
implementation("androidx.security:security-crypto:1.1.0-alpha06")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.7.3")
|
||||
|
||||
// Testing (skeleton)
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
|
||||
1
android-app/app/google-services.json
Symbolic link
1
android-app/app/google-services.json
Symbolic link
@@ -0,0 +1 @@
|
||||
../../google-services.json
|
||||
Binary file not shown.
@@ -2,6 +2,7 @@
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<application
|
||||
android:name=".HarheimerApplication"
|
||||
@@ -10,12 +11,20 @@
|
||||
android:theme="@style/Theme.HarheimerTC"
|
||||
android:usesCleartextTraffic="${usesCleartextTraffic}">
|
||||
<activity android:name="de.harheimertc.MainActivity"
|
||||
android:exported="true">
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<service
|
||||
android:name=".notifications.HarheimerMessagingService"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="com.google.firebase.MESSAGING_EVENT" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.files"
|
||||
|
||||
@@ -6,6 +6,7 @@ import coil.ImageLoaderFactory
|
||||
import coil.disk.DiskCache
|
||||
import coil.memory.MemoryCache
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
import de.harheimertc.notifications.HarheimerNotifications
|
||||
import io.sentry.Sentry
|
||||
import okhttp3.OkHttpClient
|
||||
import javax.inject.Inject
|
||||
@@ -19,6 +20,7 @@ class HarheimerApplication : Application(), ImageLoaderFactory {
|
||||
override fun onCreate() {
|
||||
Log.d("HILT", "HarheimerApplication.onCreate called")
|
||||
super.onCreate()
|
||||
HarheimerNotifications.createChannels(this)
|
||||
if (BuildConfig.SENTRY_DSN.isNotBlank()) {
|
||||
Sentry.init { options ->
|
||||
options.dsn = BuildConfig.SENTRY_DSN
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
package de.harheimertc
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import de.harheimertc.ui.navigation.NavGraph
|
||||
import de.harheimertc.ui.navigation.Destinations
|
||||
import de.harheimertc.notifications.HarheimerNotifications
|
||||
import de.harheimertc.ui.theme.HarheimerTheme
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import de.harheimertc.ui.navigation.NavigationViewModel
|
||||
@@ -17,22 +24,67 @@ import androidx.compose.ui.platform.LocalContext
|
||||
|
||||
@AndroidEntryPoint
|
||||
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?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
requestNotificationPermissionIfNeeded()
|
||||
notificationRoute.value = extractNotificationRoute(intent)
|
||||
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
|
||||
fun App() {
|
||||
fun App(
|
||||
notificationRoute: String? = null,
|
||||
onNotificationRouteConsumed: () -> Unit = {},
|
||||
) {
|
||||
HarheimerTheme {
|
||||
val navController = rememberNavController()
|
||||
val ctx = LocalContext.current
|
||||
val activity = ctx as? ComponentActivity
|
||||
Log.i("HILT_FACTORY", "defaultViewModelProviderFactory=${activity?.defaultViewModelProviderFactory?.javaClass?.name}")
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import okhttp3.RequestBody
|
||||
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 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(
|
||||
val datum: String = "",
|
||||
val uhrzeit: String? = null,
|
||||
@@ -231,7 +232,7 @@ data class ProfileVisibilityDto(
|
||||
val showEmail: Boolean = true,
|
||||
val showPhone: Boolean = true,
|
||||
val showAddress: Boolean = false,
|
||||
val showBirthday: Boolean = true,
|
||||
val showBirthday: Boolean = false,
|
||||
)
|
||||
data class ProfileUserDto(
|
||||
val id: String? = null,
|
||||
@@ -257,6 +258,30 @@ data class ProfileUpdateRequest(
|
||||
val currentPassword: 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(
|
||||
val name: String = "",
|
||||
val dayMonth: String = "",
|
||||
@@ -304,6 +329,7 @@ data class MemberDto(
|
||||
val editable: Boolean = false,
|
||||
val isMannschaftsspieler: Boolean = false,
|
||||
val hasHallKey: Boolean = false,
|
||||
val showBirthday: Boolean = false,
|
||||
val loginRoles: List<String> = emptyList(),
|
||||
)
|
||||
data class MembersResponse(
|
||||
@@ -566,6 +592,21 @@ interface ApiService {
|
||||
@GET("/api/termine")
|
||||
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")
|
||||
suspend fun spielplan(@Query("season") season: String? = null): Response<SpielplanResponse>
|
||||
|
||||
@@ -660,6 +701,15 @@ interface ApiService {
|
||||
@retrofit2.http.PUT("/api/profile")
|
||||
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")
|
||||
suspend fun birthdays(): Response<BirthdaysResponse>
|
||||
|
||||
@@ -680,6 +730,7 @@ interface ApiService {
|
||||
val notes: String? = null,
|
||||
val isMannschaftsspieler: Boolean = false,
|
||||
val hasHallKey: Boolean = false,
|
||||
val showBirthday: Boolean = false,
|
||||
)
|
||||
|
||||
data class BulkImportRequest(val members: List<Map<String, String>>)
|
||||
|
||||
@@ -19,7 +19,7 @@ import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class ConnectivityMonitor @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
@param:ApplicationContext private val context: Context,
|
||||
) {
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
private val _online = MutableStateFlow(hasInternetAccess())
|
||||
@@ -46,4 +46,4 @@ class ConnectivityMonitor @Inject constructor(
|
||||
val capabilities = manager.getNetworkCapabilities(network) ?: return false
|
||||
return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,8 @@ import de.harheimertc.data.PasswordResetDiagnosticsResponse
|
||||
import de.harheimertc.data.SaveCsvRequest
|
||||
import de.harheimertc.data.SaveCsvResponse
|
||||
import de.harheimertc.data.SecureOfflineCache
|
||||
import de.harheimertc.data.SpielplanResponse
|
||||
import de.harheimertc.data.TerminDto
|
||||
import javax.inject.Inject
|
||||
|
||||
class CmsRepository @Inject constructor(
|
||||
@@ -83,6 +85,86 @@ class CmsRepository @Inject constructor(
|
||||
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> =
|
||||
fetchEncryptedFallback(
|
||||
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>> =
|
||||
csv.lineSequence().filter(String::isNotBlank).map(::parseCsvLine).toList()
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -416,11 +416,13 @@ private fun menuSection(route: String?): MenuSection? = when (route) {
|
||||
Destinations.Qttr.route,
|
||||
Destinations.MemberNews.route,
|
||||
Destinations.Profile.route,
|
||||
Destinations.NotificationSettings.route,
|
||||
Destinations.MemberApi.route -> MenuSection.INTERN
|
||||
|
||||
Destinations.CmsStartseite.route,
|
||||
Destinations.CmsInhalte.route,
|
||||
Destinations.CmsVereinsmeisterschaften.route,
|
||||
Destinations.CmsNews.route,
|
||||
Destinations.CmsSportbetrieb.route,
|
||||
Destinations.CmsMitgliederverwaltung.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("News", Destinations.MemberNews.route))
|
||||
add(MenuTarget("Mein Profil", Destinations.Profile.route))
|
||||
add(MenuTarget("Benachrichtigungen", Destinations.NotificationSettings.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("Inhalte", Destinations.CmsInhalte.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("Mitgliederverwaltung", Destinations.CmsMitgliederverwaltung.route))
|
||||
add(MenuTarget("Einstellungen", Destinations.CmsEinstellungen.route))
|
||||
|
||||
@@ -10,8 +10,12 @@ sealed class Destinations(val route: String) {
|
||||
object Links : Destinations("verein/links")
|
||||
object Impressum : Destinations("impressum")
|
||||
object Mannschaften : Destinations("mannschaften")
|
||||
object MannschaftDetail : Destinations("mannschaften/{slug}") {
|
||||
fun create(slug: String): String = "mannschaften/$slug"
|
||||
object MannschaftDetail : Destinations("mannschaften/{slug}?season={season}") {
|
||||
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}") {
|
||||
fun create(slug: String): String = "mannschaft/$slug"
|
||||
@@ -39,10 +43,12 @@ sealed class Destinations(val route: String) {
|
||||
object Qttr : Destinations("intern/qttr")
|
||||
object MemberNews : Destinations("intern/news")
|
||||
object Profile : Destinations("intern/profil")
|
||||
object NotificationSettings : Destinations("intern/benachrichtigungen")
|
||||
object MemberApi : Destinations("intern/api")
|
||||
object CmsStartseite : Destinations("cms/startseite")
|
||||
object CmsInhalte : Destinations("cms/inhalte")
|
||||
object CmsVereinsmeisterschaften : Destinations("cms/vereinsmeisterschaften")
|
||||
object CmsNews : Destinations("cms/news")
|
||||
object CmsSportbetrieb : Destinations("cms/sportbetrieb")
|
||||
object CmsMitgliederverwaltung : Destinations("cms/mitgliederverwaltung")
|
||||
object CmsNewsletter : Destinations("cms/newsletter")
|
||||
|
||||
@@ -17,6 +17,7 @@ import androidx.navigation.NavHostController
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.navArgument
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -32,7 +33,9 @@ fun NavGraph(
|
||||
val backStackEntry = navController.currentBackStackEntryAsState().value
|
||||
val route = backStackEntry?.destination?.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
|
||||
val navigationState by navigationViewModel.state.collectAsState()
|
||||
LaunchedEffect(currentRoute) {
|
||||
@@ -136,9 +139,13 @@ fun NavGraph(
|
||||
composable("mannschaften/jugend") {
|
||||
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(
|
||||
slug = entry.arguments?.getString("slug").orEmpty(),
|
||||
season = entry.arguments?.getString("season"),
|
||||
navController = navController,
|
||||
showBackNavigation = !persistentNavigation,
|
||||
)
|
||||
@@ -146,6 +153,7 @@ fun NavGraph(
|
||||
composable(Destinations.MannschaftLegacyDetail.route) { entry ->
|
||||
de.harheimertc.ui.screens.mannschaften.MannschaftDetailScreen(
|
||||
slug = entry.arguments?.getString("slug").orEmpty(),
|
||||
season = null,
|
||||
navController = navController,
|
||||
showBackNavigation = !persistentNavigation,
|
||||
)
|
||||
@@ -305,6 +313,13 @@ fun NavGraph(
|
||||
showBackNavigation = !persistentNavigation,
|
||||
)
|
||||
}
|
||||
composable(Destinations.NotificationSettings.route) {
|
||||
de.harheimertc.ui.screens.notifications.NotificationSettingsScreen(
|
||||
navController = navController,
|
||||
showBackNavigation = !persistentNavigation,
|
||||
navigationState = navigationState,
|
||||
)
|
||||
}
|
||||
composable(Destinations.MemberApi.route) {
|
||||
de.harheimertc.ui.screens.memberarea.MemberApiScreen(
|
||||
navController = navController,
|
||||
@@ -320,6 +335,9 @@ fun NavGraph(
|
||||
composable(Destinations.CmsVereinsmeisterschaften.route) {
|
||||
de.harheimertc.ui.screens.cms.CmsVereinsmeisterschaftenScreen(navController, !persistentNavigation)
|
||||
}
|
||||
composable(Destinations.CmsNews.route) {
|
||||
de.harheimertc.ui.screens.cms.CmsNewsScreen(navController, !persistentNavigation)
|
||||
}
|
||||
composable(Destinations.CmsSportbetrieb.route) {
|
||||
de.harheimertc.ui.screens.cms.CmsSportbetriebScreen(navController, !persistentNavigation)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import de.harheimertc.repositories.GalleryRepository
|
||||
import de.harheimertc.repositories.LoginRepository
|
||||
import de.harheimertc.repositories.Mannschaft
|
||||
import de.harheimertc.repositories.MannschaftenRepository
|
||||
import de.harheimertc.repositories.PushTokenRepository
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -37,6 +38,7 @@ class NavigationViewModel @Inject constructor(
|
||||
private val loginRepository: LoginRepository,
|
||||
private val authRepository: AuthRepository,
|
||||
private val connectivityMonitor: ConnectivityMonitor,
|
||||
private val pushTokenRepository: PushTokenRepository,
|
||||
) : ViewModel() {
|
||||
private val _state = MutableStateFlow(NavigationUiState())
|
||||
val state: StateFlow<NavigationUiState> = _state
|
||||
@@ -61,13 +63,15 @@ class NavigationViewModel @Inject constructor(
|
||||
val auth = async { loginRepository.status().getOrDefault(de.harheimertc.data.AuthStatusResponse()) }
|
||||
val status = auth.await()
|
||||
val hasStoredSession = !authRepository.getToken().isNullOrBlank()
|
||||
val loggedIn = hasStoredSession || status.isLoggedIn
|
||||
_state.value = NavigationUiState(
|
||||
teams = teams.await(),
|
||||
hasGalleryImages = gallery.await(),
|
||||
loggedIn = hasStoredSession || status.isLoggedIn,
|
||||
loggedIn = loggedIn,
|
||||
roles = status.navigationRoles(),
|
||||
connectionNote = null,
|
||||
)
|
||||
if (loggedIn) registerPushToken()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,11 +79,19 @@ class NavigationViewModel @Inject constructor(
|
||||
viewModelScope.launch {
|
||||
val status = loginRepository.status().getOrDefault(de.harheimertc.data.AuthStatusResponse())
|
||||
val hasStoredSession = !authRepository.getToken().isNullOrBlank()
|
||||
val loggedIn = hasStoredSession || status.isLoggedIn
|
||||
_state.value = _state.value.copy(
|
||||
loggedIn = hasStoredSession || status.isLoggedIn,
|
||||
loggedIn = loggedIn,
|
||||
roles = status.navigationRoles(),
|
||||
connectionNote = _state.value.connectionNote,
|
||||
)
|
||||
if (loggedIn) registerPushToken()
|
||||
}
|
||||
}
|
||||
|
||||
private fun registerPushToken() {
|
||||
viewModelScope.launch {
|
||||
pushTokenRepository.registerCurrentDevice()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package de.harheimertc.ui.screens.cms
|
||||
|
||||
import android.content.Intent
|
||||
import android.app.DatePickerDialog
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
@@ -36,6 +38,8 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.navigation.NavController
|
||||
@@ -48,10 +52,12 @@ import de.harheimertc.data.NewsletterGroupDto
|
||||
import de.harheimertc.data.PasswordResetMatchingUserDto
|
||||
import de.harheimertc.data.PasswordResetAttemptDto
|
||||
import de.harheimertc.data.PasswordResetStepDto
|
||||
import de.harheimertc.data.TerminDto
|
||||
import de.harheimertc.ui.components.FormMessages
|
||||
import de.harheimertc.ui.components.LoadingState
|
||||
import de.harheimertc.ui.components.NativeRichTextEditor
|
||||
import de.harheimertc.ui.navigation.Destinations
|
||||
import de.harheimertc.repositories.CmsMannschaftRow
|
||||
import de.harheimertc.repositories.MeisterschaftResult
|
||||
import de.harheimertc.ui.theme.Accent100
|
||||
import de.harheimertc.ui.theme.Accent500
|
||||
@@ -61,6 +67,7 @@ import de.harheimertc.ui.theme.Primary100
|
||||
import de.harheimertc.ui.theme.Primary600
|
||||
import java.time.OffsetDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.Calendar
|
||||
import java.util.Locale
|
||||
|
||||
@Composable
|
||||
@@ -433,116 +440,362 @@ fun CmsVereinsmeisterschaftenScreen(navController: NavController, showBackNaviga
|
||||
@Composable
|
||||
fun CmsSportbetriebScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
val config = state.config
|
||||
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>() }
|
||||
val context = LocalContext.current
|
||||
var activeTab by remember { mutableStateOf("termine") }
|
||||
val mannschaften = remember { mutableStateListOf<CmsMannschaftRow>() }
|
||||
var spielplanCsv by remember { mutableStateOf("") }
|
||||
var spielplanEditorOpen by remember { mutableStateOf(false) }
|
||||
var terminDialogOpen by remember { mutableStateOf(false) }
|
||||
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) {
|
||||
config?.let {
|
||||
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)
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.loadSportbetrieb()
|
||||
}
|
||||
|
||||
CmsPage(navController, showBackNavigation, "Sportbetrieb", "Trainingszeiten, Trainingsort und Trainer pflegen") {
|
||||
when {
|
||||
state.loading || config == null -> item { LoadingState("Sportbetriebsdaten werden geladen...") }
|
||||
else -> {
|
||||
item {
|
||||
Button(
|
||||
onClick = {
|
||||
viewModel.saveConfig(
|
||||
config.copy(
|
||||
training = config.training.copy(
|
||||
ort = config.training.ort.copy(
|
||||
name = ortName,
|
||||
strasse = ortStrasse,
|
||||
plz = ortPlz,
|
||||
ort = ortOrt,
|
||||
),
|
||||
zeiten = trainingTimes.toList(),
|
||||
),
|
||||
trainer = trainers.toList(),
|
||||
),
|
||||
)
|
||||
},
|
||||
enabled = !state.saving,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
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) }
|
||||
LaunchedEffect(state.sportMannschaften) {
|
||||
mannschaften.clear()
|
||||
mannschaften.addAll(state.sportMannschaften)
|
||||
}
|
||||
|
||||
LaunchedEffect(state.sportSpielplanHeaders, state.sportSpielplanRows) {
|
||||
spielplanCsv = sportSpielplanCsvText(state.sportSpielplanHeaders, state.sportSpielplanRows)
|
||||
}
|
||||
|
||||
fun openTerminDialog(termin: TerminDto?) {
|
||||
editingTermin = termin
|
||||
terminDatum = termin?.datum.orEmpty()
|
||||
terminUhrzeit = termin?.uhrzeit.orEmpty()
|
||||
terminTitel = termin?.titel.orEmpty()
|
||||
terminBeschreibung = termin?.beschreibung.orEmpty()
|
||||
terminKategorie = termin?.kategorie ?: "Sonstiges"
|
||||
terminDialogOpen = true
|
||||
}
|
||||
|
||||
fun openDatePicker() {
|
||||
val calendar = Calendar.getInstance()
|
||||
runCatching {
|
||||
val parts = terminDatum.split("-")
|
||||
if (parts.size == 3) {
|
||||
calendar.set(parts[0].toInt(), parts[1].toInt() - 1, parts[2].toInt())
|
||||
}
|
||||
}
|
||||
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
|
||||
@@ -623,7 +876,7 @@ fun CmsNewsletterScreen(
|
||||
onEdit = { nl ->
|
||||
editingNewsletter = nl
|
||||
nlTitle = nl.title
|
||||
nlContent = nl.title ?: ""
|
||||
nlContent = nl.title
|
||||
nlType = "subscription"
|
||||
nlTargetGroup = ""
|
||||
nlSendToExternal = true
|
||||
@@ -753,6 +1006,12 @@ fun CmsEinstellungenScreen(navController: NavController, showBackNavigation: Boo
|
||||
var websiteVorname by remember { mutableStateOf("") }
|
||||
var websiteNachname 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) {
|
||||
config?.let {
|
||||
@@ -764,6 +1023,14 @@ fun CmsEinstellungenScreen(navController: NavController, showBackNavigation: Boo
|
||||
websiteVorname = it.website.verantwortlicher.vorname
|
||||
websiteNachname = it.website.verantwortlicher.nachname
|
||||
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,
|
||||
),
|
||||
),
|
||||
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())
|
||||
}
|
||||
}
|
||||
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 {
|
||||
DataCard("Systemstatus") {
|
||||
InfoRow("Mitgliedschaftstarife", config.mitgliedschaft.size.toString())
|
||||
@@ -950,6 +1284,7 @@ private fun CmsSummaryGrid(navController: NavController, state: CmsUiState) {
|
||||
val cards = listOf(
|
||||
Triple("Startseite", "Öffentliche Startseite", Destinations.CmsStartseite.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("Mitgliederverwaltung", "${state.users.size} Benutzer", Destinations.CmsMitgliederverwaltung.route),
|
||||
Triple("Kontaktanfragen", "${state.contactRequests.size} Anfragen", Destinations.CmsContactRequests.route),
|
||||
|
||||
@@ -13,6 +13,10 @@ import de.harheimertc.data.PasswordResetMatchingUserDto
|
||||
import de.harheimertc.data.PasswordResetAttemptDto
|
||||
import de.harheimertc.data.NewsDto
|
||||
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.MeisterschaftResult
|
||||
import kotlinx.coroutines.async
|
||||
@@ -39,6 +43,16 @@ data class CmsUiState(
|
||||
val passwordResetFailedOnly: Boolean = true,
|
||||
val news: List<NewsDto> = 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
|
||||
@@ -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) {
|
||||
viewModelScope.launch {
|
||||
_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 -> ""
|
||||
}
|
||||
|
||||
@@ -90,7 +90,7 @@ class HomeViewModel @Inject constructor(
|
||||
loading = false,
|
||||
heroImageUrl = data.heroImageUrl,
|
||||
termine = data.termine
|
||||
.filter { it.asDateTime()?.isBefore(LocalDateTime.now()) != true }
|
||||
.filter { it.asDateTime()?.toLocalDate()?.isBefore(LocalDate.now()) != true }
|
||||
.sortedBy { it.asDateTime() }
|
||||
.take(3),
|
||||
spiele = data.spiele
|
||||
|
||||
@@ -59,7 +59,7 @@ data class RegisterFormState(
|
||||
val birthDate: String = "",
|
||||
val password: String = "",
|
||||
val passwordRepeat: String = "",
|
||||
val showBirthday: Boolean = true,
|
||||
val showBirthday: Boolean = false,
|
||||
)
|
||||
|
||||
data class RegisterUiState(
|
||||
|
||||
@@ -85,7 +85,7 @@ fun MannschaftenScreen(
|
||||
state.error != null -> item { ErrorPanel(state.error.orEmpty(), viewModel::load) }
|
||||
state.teams.isEmpty() -> item { Text("Keine Mannschaftsdaten geladen", color = Accent500) }
|
||||
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 {
|
||||
@@ -161,13 +161,14 @@ private fun TeamCard(team: Mannschaft, onOpen: () -> Unit) {
|
||||
@Composable
|
||||
fun MannschaftDetailScreen(
|
||||
slug: String,
|
||||
season: String?,
|
||||
navController: NavController,
|
||||
showBackNavigation: Boolean,
|
||||
viewModel: MannschaftDetailViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
var selectedTab by rememberSaveable(slug) { mutableStateOf(DetailTab.Matches) }
|
||||
LaunchedEffect(slug) { viewModel.load(slug) }
|
||||
var selectedTab by rememberSaveable(slug, season) { mutableStateOf(DetailTab.Matches) }
|
||||
LaunchedEffect(slug, season) { viewModel.load(slug, season) }
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize().background(Color(0xFFFAFAFA)),
|
||||
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) } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,35 +121,38 @@ class MannschaftDetailViewModel @Inject constructor(
|
||||
) : ViewModel() {
|
||||
private val _state = MutableStateFlow(MannschaftDetailUiState())
|
||||
val state: StateFlow<MannschaftDetailUiState> = _state
|
||||
private var loadedSlug: String? = null
|
||||
private var loadedKey: String? = null
|
||||
|
||||
fun load(slug: String) {
|
||||
if (loadedSlug == slug) return
|
||||
loadedSlug = slug
|
||||
fun load(slug: String, season: String? = null) {
|
||||
val selectedSeason = season?.takeIf { it.isNotBlank() }
|
||||
val key = "$slug|${selectedSeason.orEmpty()}"
|
||||
if (loadedKey == key) return
|
||||
loadedKey = key
|
||||
viewModelScope.launch {
|
||||
_state.value = MannschaftDetailUiState(loading = true)
|
||||
val team = mannschaftenRepository.fetchMannschaften().getOrDefault(emptyList()).find { it.slug == slug }
|
||||
_state.value = MannschaftDetailUiState(loading = true, season = selectedSeason)
|
||||
val team = mannschaftenRepository.fetchMannschaften(selectedSeason).getOrDefault(emptyList()).find { it.slug == slug }
|
||||
if (team == null) {
|
||||
_state.value = MannschaftDetailUiState(loading = false, matchesError = "Mannschaft nicht gefunden.")
|
||||
return@launch
|
||||
}
|
||||
spielplanRepository.fetchSpielplan()
|
||||
spielplanRepository.fetchSpielplan(selectedSeason)
|
||||
.onSuccess { plan ->
|
||||
_state.value = MannschaftDetailUiState(
|
||||
loading = false,
|
||||
team = team,
|
||||
matches = plan.data.filter { matchesTeam(it, team.mannschaft) },
|
||||
season = plan.season,
|
||||
season = plan.season ?: selectedSeason,
|
||||
)
|
||||
if (team.informationenLink.isNotBlank()) {
|
||||
loadTable(team, plan.season)
|
||||
loadTable(team, plan.season ?: selectedSeason)
|
||||
}
|
||||
}
|
||||
.onFailure {
|
||||
_state.value = MannschaftDetailUiState(
|
||||
loading = false,
|
||||
team = team,
|
||||
matchesError = "Der aktuelle Spielplan konnte nicht geladen werden.",
|
||||
season = selectedSeason,
|
||||
matchesError = "Der Spielplan konnte nicht geladen werden.",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,6 +102,12 @@ private fun MemberAreaCardGrid(navController: NavController) {
|
||||
marker = "P",
|
||||
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(
|
||||
title = "Mitglieder",
|
||||
description = "Kontaktdaten der Vereinsmitglieder",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
@@ -24,7 +24,7 @@ data class ProfileFormState(
|
||||
val showEmail: Boolean = true,
|
||||
val showPhone: Boolean = true,
|
||||
val showAddress: Boolean = false,
|
||||
val showBirthday: Boolean = true,
|
||||
val showBirthday: Boolean = false,
|
||||
val currentPassword: String = "",
|
||||
val newPassword: String = "",
|
||||
val confirmPassword: String = "",
|
||||
|
||||
@@ -2,11 +2,13 @@ package de.harheimertc.ui.screens.cms
|
||||
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
@@ -27,6 +29,12 @@ class CmsViewModelTest {
|
||||
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
|
||||
fun load_populatesState() = runTest {
|
||||
val repo = mockk<de.harheimertc.repositories.CmsRepository>()
|
||||
@@ -37,11 +45,11 @@ class CmsViewModelTest {
|
||||
coEvery { repo.contactRequests() } returns Result.success(emptyList())
|
||||
coEvery { repo.newsletters() } returns Result.success(de.harheimertc.data.NewsletterListResponse())
|
||||
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.vereinsmeisterschaften() } returns Result.success(emptyList())
|
||||
|
||||
val vm = CmsViewModel(repo)
|
||||
val vm = viewModel(repo)
|
||||
// advance init launched coroutine
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
|
||||
@@ -66,7 +74,7 @@ class CmsViewModelTest {
|
||||
coEvery { repo.vereinsmeisterschaften() } returns Result.success(emptyList())
|
||||
coEvery { repo.saveConfig(any()) } returns Result.success(cfg)
|
||||
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
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
@@ -95,7 +103,7 @@ class CmsViewModelTest {
|
||||
coEvery { repo.vereinsmeisterschaften() } returns Result.success(emptyList())
|
||||
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()
|
||||
|
||||
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.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()
|
||||
|
||||
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.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()
|
||||
|
||||
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"))
|
||||
|
||||
val vm = CmsViewModel(repo)
|
||||
val vm = viewModel(repo)
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
|
||||
vm.resendInvite("10")
|
||||
|
||||
@@ -6,6 +6,7 @@ plugins {
|
||||
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.plugin.compose") version "2.3.21" apply false
|
||||
id("com.google.gms.google-services") version "4.4.4" apply false
|
||||
}
|
||||
|
||||
buildscript {
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -8,8 +8,8 @@ LOCAL_API_BASE_URL=https://harheimertc.tsschulz.de/
|
||||
PRODUCTION_API_BASE_URL=https://harheimertc.de/
|
||||
|
||||
# Android app versioning for Play Store uploads
|
||||
ANDROID_VERSION_CODE=24
|
||||
ANDROID_VERSION_NAME=0.9.19
|
||||
ANDROID_VERSION_CODE=26
|
||||
ANDROID_VERSION_NAME=0.9.21
|
||||
|
||||
# Temporary hotfix: disable R8 minification for release to avoid Retrofit generic signature stripping.
|
||||
RELEASE_MINIFY_ENABLED=false
|
||||
|
||||
@@ -78,53 +78,62 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- News Modal -->
|
||||
<div
|
||||
v-if="selectedNews"
|
||||
class="fixed inset-0 z-50 bg-black bg-opacity-50 flex items-center justify-center p-4"
|
||||
@click.self="closeNewsModal"
|
||||
>
|
||||
<div class="bg-white rounded-xl shadow-2xl max-w-4xl w-full max-h-[90vh] flex flex-col">
|
||||
<!-- Modal Header -->
|
||||
<div class="flex items-center justify-between p-6 border-b border-gray-200">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center text-sm text-gray-500 mb-2">
|
||||
<Calendar
|
||||
:size="16"
|
||||
class="mr-2"
|
||||
/>
|
||||
{{ formatDate(selectedNews.created) }}
|
||||
</div>
|
||||
<h2 class="text-2xl font-display font-bold text-gray-900">
|
||||
{{ selectedNews.title }}
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
class="ml-4 p-2 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
@click="closeNewsModal"
|
||||
>
|
||||
<X :size="24" />
|
||||
</button>
|
||||
</div>
|
||||
<Teleport to="body">
|
||||
<Transition name="news-modal">
|
||||
<div
|
||||
v-if="selectedNews"
|
||||
class="fixed inset-0 z-[100] flex items-center justify-center bg-slate-950/65 px-4 py-6 sm:px-6"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
:aria-labelledby="modalTitleId"
|
||||
@click.self="closeNewsModal"
|
||||
>
|
||||
<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">
|
||||
<header class="flex items-start gap-4 border-b border-gray-200 bg-white px-5 py-4 sm:px-7 sm:py-6">
|
||||
<div class="min-w-0 flex-1">
|
||||
<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" />
|
||||
<time :datetime="selectedNews.created">
|
||||
{{ formatDate(selectedNews.created) }}
|
||||
</time>
|
||||
</div>
|
||||
<h2
|
||||
:id="modalTitleId"
|
||||
class="text-2xl sm:text-3xl font-display font-bold leading-tight text-gray-950 break-words"
|
||||
>
|
||||
{{ selectedNews.title }}
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
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="p-6 overflow-y-auto flex-1">
|
||||
<div class="prose max-w-none text-gray-700 whitespace-pre-wrap">
|
||||
{{ selectedNews.content }}
|
||||
</div>
|
||||
<div class="overflow-y-auto px-5 py-5 sm:px-7 sm:py-6">
|
||||
<div class="news-modal-content text-base leading-7 text-gray-800 sm:text-lg sm:leading-8">
|
||||
{{ selectedNews.content }}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { Calendar, X } from 'lucide-vue-next'
|
||||
|
||||
const news = ref([])
|
||||
const selectedNews = ref(null)
|
||||
const isLoading = ref(true)
|
||||
const modalTitleId = 'public-news-modal-title'
|
||||
|
||||
const loadNews = async () => {
|
||||
try {
|
||||
@@ -164,19 +173,30 @@ const getGridClass = () => {
|
||||
|
||||
const openNewsModal = (item) => {
|
||||
selectedNews.value = item
|
||||
// Verhindere Scrollen im Hintergrund
|
||||
document.body.style.overflow = 'hidden'
|
||||
document.addEventListener('keydown', handleModalKeydown)
|
||||
}
|
||||
|
||||
const closeNewsModal = () => {
|
||||
selectedNews.value = null
|
||||
// Erlaube Scrollen wieder
|
||||
document.body.style.overflow = ''
|
||||
document.removeEventListener('keydown', handleModalKeydown)
|
||||
}
|
||||
|
||||
const handleModalKeydown = (event) => {
|
||||
if (event.key === 'Escape') {
|
||||
closeNewsModal()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadNews()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.body.style.overflow = ''
|
||||
document.removeEventListener('keydown', handleModalKeydown)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -186,5 +206,20 @@ onMounted(() => {
|
||||
-webkit-box-orient: vertical;
|
||||
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>
|
||||
|
||||
|
||||
@@ -550,6 +550,25 @@
|
||||
</label>
|
||||
</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
|
||||
v-if="errorMessage"
|
||||
class="flex items-center p-3 rounded-md bg-red-50 text-red-700 text-sm"
|
||||
@@ -846,7 +865,8 @@ const formData = ref({
|
||||
address: '',
|
||||
notes: '',
|
||||
isMannschaftsspieler: false,
|
||||
hasHallKey: false
|
||||
hasHallKey: false,
|
||||
showBirthday: false
|
||||
})
|
||||
|
||||
const canEdit = computed(() => {
|
||||
@@ -861,6 +881,10 @@ const isBirthdateRequired = computed(() => {
|
||||
return !editingMember.value || Boolean(editingMember.value?.geburtsdatum)
|
||||
})
|
||||
|
||||
const canDisableBirthdayVisibility = computed(() => {
|
||||
return editingMember.value?.showBirthday === true
|
||||
})
|
||||
|
||||
const filteredMembers = computed(() => {
|
||||
if (!filterHasHallKey.value) return members.value
|
||||
return members.value.filter(member => member.hasHallKey)
|
||||
@@ -880,7 +904,7 @@ const loadMembers = async () => {
|
||||
|
||||
const openAddModal = () => {
|
||||
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
|
||||
errorMessage.value = ''
|
||||
}
|
||||
@@ -896,7 +920,8 @@ const openEditModal = (member) => {
|
||||
address: member.address || '',
|
||||
notes: member.notes || '',
|
||||
isMannschaftsspieler: member.isMannschaftsspieler === true,
|
||||
hasHallKey: member.hasHallKey === true
|
||||
hasHallKey: member.hasHallKey === true,
|
||||
showBirthday: member.showBirthday === true
|
||||
}
|
||||
showModal.value = true
|
||||
errorMessage.value = ''
|
||||
@@ -914,7 +939,14 @@ const saveMember = async () => {
|
||||
try {
|
||||
await $fetch('/api/members', {
|
||||
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()
|
||||
await loadMembers()
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
<p class="text-sm font-medium text-green-800">
|
||||
{{ currentFile.name }}
|
||||
</p><p class="text-xs text-green-600">
|
||||
{{ currentFile.size }} bytes
|
||||
{{ currentFileLabel }}
|
||||
</p>
|
||||
</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()) }
|
||||
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
|
||||
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!'
|
||||
setTimeout(() => { isProcessing.value = false; showUploadModal.value = false }, 1000)
|
||||
} 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 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 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 selectAllColumns = () => { selectedColumns.value = selectedColumns.value.map(() => true) }
|
||||
const deselectAllColumns = () => { selectedColumns.value = selectedColumns.value.map(() => false) }
|
||||
@@ -415,7 +420,7 @@ onMounted(() => {
|
||||
csvHeaders.value = result.headers
|
||||
csvData.value = result.data.map(row => csvHeaders.value.map(header => row[header] || ''))
|
||||
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 */ }
|
||||
})()
|
||||
})
|
||||
|
||||
@@ -279,9 +279,25 @@ use_project_node
|
||||
ensure_node_version
|
||||
install_dependencies_if_needed
|
||||
|
||||
# 4. Remove old build (but keep data!)
|
||||
# 4. Stop running apps before replacing build artifacts
|
||||
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
|
||||
if [ -d ".output" ]; then
|
||||
echo " Removing .output directory..."
|
||||
|
||||
@@ -285,9 +285,19 @@ use_project_node
|
||||
ensure_node_version
|
||||
install_dependencies_if_needed
|
||||
|
||||
# 4. Remove old build (but keep data!)
|
||||
# 4. Stop running app before replacing build artifacts
|
||||
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
|
||||
if [ -d ".output" ]; then
|
||||
echo " Removing .output directory..."
|
||||
|
||||
67
google-services.json
Normal file
67
google-services.json
Normal 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"
|
||||
}
|
||||
@@ -7,6 +7,8 @@ try {
|
||||
}
|
||||
|
||||
// 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) {
|
||||
return {
|
||||
NODE_ENV: 'production',
|
||||
@@ -35,7 +37,10 @@ function createEnv(port) {
|
||||
WEBAUTHN_ORIGIN: process.env.WEBAUTHN_ORIGIN,
|
||||
WEBAUTHN_RP_ID: process.env.WEBAUTHN_RP_ID,
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ try {
|
||||
}
|
||||
|
||||
// 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) {
|
||||
return {
|
||||
NODE_ENV: process.env.NODE_ENV || 'development',
|
||||
@@ -37,7 +39,10 @@ function createEnv(port) {
|
||||
WEBAUTHN_ORIGIN: process.env.WEBAUTHN_ORIGIN,
|
||||
WEBAUTHN_RP_ID: process.env.WEBAUTHN_RP_ID,
|
||||
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
847
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "harheimertc-website",
|
||||
"version": "1.8.0",
|
||||
"version": "1.8.1",
|
||||
"description": "Moderne Webseite für den Harheimer Tischtennis Club",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
@@ -46,7 +46,6 @@
|
||||
"pinia": "^3.0.3",
|
||||
"quill": "^2.0.2",
|
||||
"sharp": "^0.34.5",
|
||||
|
||||
"vue": "^3.5.22"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -61,10 +60,11 @@
|
||||
"postcss": "^8.5.12",
|
||||
"supertest": "^7.1.0",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"vitest": "^4.0.16",
|
||||
"vitest": "^4.1.8",
|
||||
"vue-eslint-parser": "^10.2.0"
|
||||
},
|
||||
"overrides": {
|
||||
"@peculiar/x509": "1.13.0"
|
||||
"@peculiar/x509": "1.13.0",
|
||||
"esbuild": "0.28.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -365,7 +365,7 @@ const visibility = ref({
|
||||
showEmail: true,
|
||||
showPhone: true,
|
||||
showAddress: false,
|
||||
showBirthday: true
|
||||
showBirthday: false
|
||||
})
|
||||
|
||||
const passwordData = ref({
|
||||
@@ -568,4 +568,3 @@ useHead({
|
||||
title: 'Mein Profil - Harheimer TC',
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -173,4 +173,4 @@ function formatDate(value) {
|
||||
useHead({
|
||||
title: 'QTTR-Werte - Harheimer TC'
|
||||
})
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -20,8 +20,10 @@ const FILES = {
|
||||
function getDataPath(filename) {
|
||||
const cwd = process.cwd()
|
||||
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)
|
||||
}
|
||||
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
|
||||
return path.join(cwd, 'server/data', filename)
|
||||
}
|
||||
|
||||
|
||||
52
scripts/verify-no-public-writes.js
Normal file
52
scripts/verify-no-public-writes.js
Normal 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.')
|
||||
@@ -1,6 +1,5 @@
|
||||
import { verifyRegistrationResponse } from '@simplewebauthn/server'
|
||||
import crypto from 'crypto'
|
||||
import nodemailer from 'nodemailer'
|
||||
import { hashPassword, readUsers, writeUsers } from '../../utils/auth.js'
|
||||
import { getWebAuthnConfig } from '../../utils/webauthn-config.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 { assertPasswordNotPwned } from '../../utils/hibp.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
|
||||
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 })
|
||||
|
||||
// Send notification emails (same behavior as password registration)
|
||||
// Send notification emails through the same central recipient logic as password registration.
|
||||
try {
|
||||
const smtpUser = process.env.SMTP_USER
|
||||
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>
|
||||
`
|
||||
})
|
||||
}
|
||||
await sendRegistrationNotification({ name, email, phone })
|
||||
} catch (emailError) {
|
||||
console.error('E-Mail-Versand fehlgeschlagen:', emailError)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { readUsers, writeUsers, hashPassword } from '../../utils/auth.js'
|
||||
import { sendRegistrationNotification } from '../../utils/email-service.js'
|
||||
import { assertPasswordNotPwned } from '../../utils/hibp.js'
|
||||
import { sendNewUserRegistrationPush } from '../../utils/push-notifications.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
@@ -48,7 +49,7 @@ export default defineEventHandler(async (event) => {
|
||||
phone: phone || '',
|
||||
geburtsdatum,
|
||||
visibility: {
|
||||
showBirthday: visibility?.showBirthday !== undefined ? Boolean(visibility.showBirthday) : true
|
||||
showBirthday: visibility?.showBirthday !== undefined ? Boolean(visibility.showBirthday) : false
|
||||
},
|
||||
role: 'mitglied',
|
||||
active: false, // Requires admin approval
|
||||
@@ -59,6 +60,10 @@ export default defineEventHandler(async (event) => {
|
||||
users.push(newUser)
|
||||
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
|
||||
try {
|
||||
await sendRegistrationNotification({ name, email, phone })
|
||||
@@ -75,4 +80,3 @@ export default defineEventHandler(async (event) => {
|
||||
throw error
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -72,14 +72,14 @@ export default defineEventHandler(async (event) => {
|
||||
: true
|
||||
if (!isAccepted) continue
|
||||
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' })
|
||||
}
|
||||
|
||||
for (const u of registeredUsers) {
|
||||
if (!u.active || isHiddenUser(u)) continue
|
||||
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' })
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import nodemailer from 'nodemailer'
|
||||
import { promises as fs } from 'fs'
|
||||
import path from 'path'
|
||||
import { createContactRequest } from '../utils/contact-requests.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
|
||||
// filename is always a hardcoded constant ('config.json'), never user input
|
||||
@@ -23,17 +23,39 @@ async function loadConfig() {
|
||||
}
|
||||
}
|
||||
|
||||
async function collectRecipients(config) {
|
||||
const isProduction = process.env.NODE_ENV === 'production' && process.env.APP_ENV !== 'test'
|
||||
function envFlagEnabled(value) {
|
||||
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']
|
||||
}
|
||||
|
||||
const recipients = []
|
||||
|
||||
// Vorstand
|
||||
if (config?.vorstand && typeof config.vorstand === 'object') {
|
||||
// Vorstand: prefer active login users with the board role.
|
||||
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)) {
|
||||
if (member?.email && typeof member.email === 'string' && member.email.trim()) {
|
||||
recipients.push(member.email.trim())
|
||||
@@ -72,10 +94,7 @@ async function collectRecipients(config) {
|
||||
if (config?.website?.verantwortlicher?.email) {
|
||||
return [config.website.verantwortlicher.email]
|
||||
}
|
||||
if (process.env.SMTP_USER) {
|
||||
return [process.env.SMTP_USER]
|
||||
}
|
||||
return ['j.dichmann@gmx.de']
|
||||
throw new Error('Keine E-Mail-Empfänger in config.json konfiguriert.')
|
||||
}
|
||||
|
||||
function createTransporter() {
|
||||
@@ -111,13 +130,17 @@ export default defineEventHandler(async (event) => {
|
||||
}
|
||||
|
||||
// Anfrage immer speichern, auch wenn E-Mail-Versand fehlschlägt.
|
||||
await createContactRequest({
|
||||
const contactRequest = {
|
||||
name: String(body.name).trim(),
|
||||
email: String(body.email).trim(),
|
||||
phone: body.phone ? String(body.phone).trim() : '',
|
||||
subject: String(body.subject).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 recipients = await collectRecipients(config)
|
||||
|
||||
@@ -64,7 +64,8 @@ export default defineEventHandler(async (event) => {
|
||||
showEmail: vis.showEmail === undefined ? true : Boolean(vis.showEmail),
|
||||
showPhone: vis.showPhone === undefined ? true : Boolean(vis.showPhone),
|
||||
// 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({
|
||||
@@ -163,7 +164,8 @@ export default defineEventHandler(async (event) => {
|
||||
mergedMembers[matchedManualIndex].visibility = {
|
||||
showEmail: user.visibility.showEmail === undefined ? Boolean(vis.showEmail) : Boolean(user.visibility.showEmail),
|
||||
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 {
|
||||
@@ -186,7 +188,8 @@ export default defineEventHandler(async (event) => {
|
||||
visibility: {
|
||||
showEmail: userVis.showEmail === undefined ? true : Boolean(userVis.showEmail),
|
||||
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(', ')}`,
|
||||
source: 'login',
|
||||
@@ -226,7 +229,7 @@ export default defineEventHandler(async (event) => {
|
||||
const emailVisible = (isPrivilegedViewer || (isViewerAuthenticated && showEmail))
|
||||
const phoneVisible = (isPrivilegedViewer || (isViewerAuthenticated && showPhone))
|
||||
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 {
|
||||
id: member.id,
|
||||
@@ -246,7 +249,7 @@ export default defineEventHandler(async (event) => {
|
||||
showEmail: visibility.showEmail === undefined ? true : Boolean(visibility.showEmail),
|
||||
showPhone: visibility.showPhone === undefined ? true : Boolean(visibility.showPhone),
|
||||
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
|
||||
email: emailVisible ? member.email : undefined,
|
||||
phone: phoneVisible ? member.phone : undefined,
|
||||
|
||||
@@ -1,5 +1,22 @@
|
||||
import { getUserFromToken, hasAnyRole } from '../utils/auth.js'
|
||||
import { saveMember } from '../utils/members.js'
|
||||
import { getUserFromToken, hasAnyRole, readUsers, writeUsers, normalizeUserEmail } from '../utils/auth.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) => {
|
||||
try {
|
||||
@@ -39,7 +56,7 @@ export default defineEventHandler(async (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) {
|
||||
throw createError({
|
||||
@@ -56,6 +73,23 @@ export default defineEventHandler(async (event) => {
|
||||
}
|
||||
|
||||
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({
|
||||
id: id || undefined,
|
||||
firstName,
|
||||
@@ -67,9 +101,21 @@ export default defineEventHandler(async (event) => {
|
||||
notes: notes || '',
|
||||
isMannschaftsspieler: isMannschaftsspieler === true || isMannschaftsspieler === 'true',
|
||||
hasHallKey: hasHallKey === true || hasHallKey === 'true' || hasHallenschluessel === true || hasHallenschluessel === 'true',
|
||||
visibility: {
|
||||
...(visibility && typeof visibility === 'object' ? visibility : {}),
|
||||
showBirthday: nextShowBirthday
|
||||
},
|
||||
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 {
|
||||
success: true,
|
||||
message: 'Mitglied erfolgreich gespeichert.'
|
||||
@@ -98,4 +144,3 @@ export default defineEventHandler(async (event) => {
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { verifyToken, getUserById, hasAnyRole } from '../utils/auth.js'
|
||||
import { saveNews } from '../utils/news.js'
|
||||
import { sendNewNewsPush } from '../utils/push-notifications.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
@@ -41,7 +42,7 @@ export default defineEventHandler(async (event) => {
|
||||
})
|
||||
}
|
||||
|
||||
await saveNews({
|
||||
const newsEntry = {
|
||||
id: id || undefined,
|
||||
title,
|
||||
content,
|
||||
@@ -49,7 +50,17 @@ export default defineEventHandler(async (event) => {
|
||||
expiresAt: expiresAt || undefined,
|
||||
isHidden: isHidden || false,
|
||||
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 {
|
||||
success: true,
|
||||
|
||||
@@ -27,7 +27,7 @@ export default defineEventHandler(async (event) => {
|
||||
email: user.email,
|
||||
phone: user.phone || '',
|
||||
geburtsdatum: user.geburtsdatum || '',
|
||||
visibility: Object.assign({ showBirthday: true }, (user.visibility || {}))
|
||||
visibility: Object.assign({ showBirthday: false }, (user.visibility || {}))
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
24
server/api/profile/notifications.get.js
Normal file
24
server/api/profile/notifications.get.js
Normal 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)
|
||||
}
|
||||
})
|
||||
34
server/api/profile/notifications.put.js
Normal file
34
server/api/profile/notifications.put.js
Normal 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
|
||||
}
|
||||
})
|
||||
29
server/api/profile/push-token.post.js
Normal file
29
server/api/profile/push-token.post.js
Normal 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.' }
|
||||
})
|
||||
@@ -3,7 +3,7 @@ import { deleteTermin } from '../utils/termine.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
const token = getCookie(event, 'auth_token')
|
||||
const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '')
|
||||
|
||||
if (!token) {
|
||||
throw createError({
|
||||
@@ -58,4 +58,3 @@ export default defineEventHandler(async (event) => {
|
||||
throw error
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { readTermine } from '../utils/termine.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
const token = getCookie(event, 'auth_token')
|
||||
const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '')
|
||||
|
||||
if (!token) {
|
||||
throw createError({
|
||||
@@ -42,4 +42,3 @@ export default defineEventHandler(async (event) => {
|
||||
throw error
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { verifyToken, getUserById, hasAnyRole } from '../utils/auth.js'
|
||||
import { saveTermin } from '../utils/termine.js'
|
||||
import { sendNewEventPush } from '../utils/push-notifications.js'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
const token = getCookie(event, 'auth_token')
|
||||
const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '')
|
||||
|
||||
if (!token) {
|
||||
throw createError({
|
||||
@@ -41,13 +42,17 @@ export default defineEventHandler(async (event) => {
|
||||
})
|
||||
}
|
||||
|
||||
await saveTermin({
|
||||
const termin = {
|
||||
datum,
|
||||
uhrzeit: uhrzeit || '',
|
||||
titel,
|
||||
beschreibung: beschreibung || '',
|
||||
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 {
|
||||
success: true,
|
||||
@@ -58,4 +63,3 @@ export default defineEventHandler(async (event) => {
|
||||
throw error
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
38
server/plugins/notification-scheduler.js
Normal file
38
server/plugins/notification-scheduler.js
Normal 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
|
||||
})
|
||||
})
|
||||
@@ -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.')
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -2,6 +2,7 @@ import { promises as fs } from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
const DEFAULT_MAX_BACKUPS = Number.parseInt(process.env.DATA_FILE_BACKUP_MAX || '40', 10)
|
||||
let backupSequence = 0
|
||||
|
||||
function getProjectRoot() {
|
||||
const cwd = process.cwd()
|
||||
@@ -30,8 +31,24 @@ function sanitizeFileKey(filePath) {
|
||||
}
|
||||
|
||||
function buildBackupName(date = new Date()) {
|
||||
const sequence = (backupSequence++).toString(36).padStart(6, '0')
|
||||
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) {
|
||||
@@ -55,10 +72,11 @@ async function rotateOldBackups(backupDir, maxBackups) {
|
||||
}
|
||||
|
||||
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) {
|
||||
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
|
||||
const resolvedPath = path.resolve(filePath)
|
||||
return path.join(getBackupRoot(), sanitizeFileKey(resolvedPath))
|
||||
}
|
||||
@@ -77,6 +95,7 @@ export async function writeDataFileWithRotation(filePath, content, {
|
||||
encoding = 'utf-8',
|
||||
maxBackups = DEFAULT_MAX_BACKUPS
|
||||
} = {}) {
|
||||
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
|
||||
const resolvedPath = path.resolve(filePath)
|
||||
await ensureDirectory(path.dirname(resolvedPath))
|
||||
|
||||
@@ -100,7 +119,7 @@ export async function writeDataFileWithRotation(filePath, content, {
|
||||
if (existingContent !== null) {
|
||||
const backupDir = getBackupDirectoryForDataFile(resolvedPath)
|
||||
await ensureDirectory(backupDir)
|
||||
backupPath = path.join(backupDir, buildBackupName())
|
||||
backupPath = resolveDataFileBackupPath(backupDir, buildBackupName())
|
||||
await fs.copyFile(resolvedPath, backupPath)
|
||||
await rotateOldBackups(backupDir, maxBackups)
|
||||
}
|
||||
@@ -116,9 +135,10 @@ export async function writeDataFileWithRotation(filePath, content, {
|
||||
}
|
||||
|
||||
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 backupDir = getBackupDirectoryForDataFile(resolvedPath)
|
||||
const sourceBackupPath = path.join(backupDir, backupName)
|
||||
const sourceBackupPath = resolveDataFileBackupPath(backupDir, backupName)
|
||||
const backupContent = await fs.readFile(sourceBackupPath, 'utf-8')
|
||||
|
||||
return writeDataFileWithRotation(resolvedPath, backupContent, options)
|
||||
|
||||
@@ -7,6 +7,7 @@ import nodemailer from 'nodemailer'
|
||||
import fs from 'fs/promises'
|
||||
import path from 'path'
|
||||
import { getServerDataPath } from './paths.js'
|
||||
import { isHiddenUser, migrateUserRoles, readUsers } from './auth.js'
|
||||
|
||||
/**
|
||||
* 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
|
||||
* @param {Object} data - Form data
|
||||
* @param {Object} config - Configuration
|
||||
* @returns {Array<string>} Email addresses
|
||||
*/
|
||||
function getEmailRecipients(data, config) {
|
||||
const isProduction = process.env.NODE_ENV === 'production' && process.env.APP_ENV !== 'test'
|
||||
|
||||
if (!isProduction) {
|
||||
async function collectBoardUserRecipients() {
|
||||
try {
|
||||
const users = await readUsers()
|
||||
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']
|
||||
}
|
||||
|
||||
const recipients = []
|
||||
const recipients = await collectBoardUserRecipients()
|
||||
|
||||
// Config uses a 'vorstand' object with nested roles; collect all emails
|
||||
if (config.vorstand && typeof config.vorstand === 'object') {
|
||||
// Fallback for legacy installations where Vorstand members are only configured in config.json.
|
||||
if (recipients.length === 0 && config.vorstand && typeof config.vorstand === 'object') {
|
||||
Object.values(config.vorstand).forEach((member) => {
|
||||
if (member && member.email && typeof member.email === 'string' && 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)
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -69,11 +92,11 @@ function getEmailRecipients(data, config) {
|
||||
if (config.website && config.website.verantwortlicher && config.website.verantwortlicher.email) {
|
||||
recipients.push(config.website.verantwortlicher.email)
|
||||
} 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) {
|
||||
try {
|
||||
const config = await loadConfig()
|
||||
const recipients = getEmailRecipients(data, config)
|
||||
const recipients = await getEmailRecipients(data, config)
|
||||
|
||||
// Create transporter
|
||||
const transporter = createTransporter()
|
||||
@@ -167,7 +190,7 @@ Das ausgefüllte Formular ist als Anhang verfügbar.`
|
||||
export async function sendRegistrationNotification(data) {
|
||||
try {
|
||||
const config = await loadConfig()
|
||||
const recipients = getEmailRecipients(data, config)
|
||||
const recipients = await getEmailRecipients(data, config)
|
||||
|
||||
// Create transporter
|
||||
const transporter = createTransporter()
|
||||
|
||||
451
server/utils/notification-scheduler.js
Normal file
451
server/utils/notification-scheduler.js
Normal 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 }
|
||||
}
|
||||
55
server/utils/notification-settings.js
Normal file
55
server/utils/notification-settings.js
Normal 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 || {})
|
||||
})
|
||||
}
|
||||
273
server/utils/push-notifications.js
Normal file
273
server/utils/push-notifications.js
Normal 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'
|
||||
})
|
||||
}
|
||||
@@ -35,6 +35,9 @@ import configGetHandler from '../server/api/config.get.js'
|
||||
import configPutHandler from '../server/api/config.put.js'
|
||||
import profileGetHandler from '../server/api/profile.get.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 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', () => {
|
||||
it('verlangt Authentifizierung', async () => {
|
||||
const event = createEvent()
|
||||
|
||||
@@ -4,6 +4,7 @@ import path from 'path'
|
||||
import { promises as fs } from 'fs'
|
||||
import {
|
||||
getBackupDirectoryForDataFile,
|
||||
resolveDataFileBackupPath,
|
||||
listDataFileBackups,
|
||||
restoreDataFileBackup,
|
||||
writeDataFileWithRotation
|
||||
@@ -60,7 +61,7 @@ describe('Data file rotation utility', () => {
|
||||
expect(backups.length).toBe(1)
|
||||
|
||||
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')
|
||||
|
||||
const currentContent = await fs.readFile(dataFile, 'utf-8')
|
||||
@@ -91,7 +92,7 @@ describe('Data file rotation utility', () => {
|
||||
|
||||
const backupDir = getBackupDirectoryForDataFile(dataFile)
|
||||
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']))
|
||||
@@ -109,7 +110,7 @@ describe('Data file rotation utility', () => {
|
||||
const resolved = await Promise.all(
|
||||
beforeRestoreBackups.map(async (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')
|
||||
@@ -124,7 +125,7 @@ describe('Data file rotation utility', () => {
|
||||
|
||||
const afterRestoreBackups = await listDataFileBackups(dataFile)
|
||||
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')
|
||||
|
||||
116
tests/email-service.spec.ts
Normal file
116
tests/email-service.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
@@ -51,12 +51,13 @@ import membersGetHandler from '../server/api/members.get.js'
|
||||
import membersPostHandler from '../server/api/members.post.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 toggleMannschaftsspielerHandler from '../server/api/members/toggle-mannschaftsspieler.post.js'
|
||||
|
||||
describe('Members API Endpoints', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
authUtils.readUsers.mockResolvedValue([])
|
||||
memberUtils.readMembers.mockResolvedValue([])
|
||||
})
|
||||
|
||||
describe('GET /api/members', () => {
|
||||
@@ -100,6 +101,38 @@ describe('Members API Endpoints', () => {
|
||||
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 () => {
|
||||
const event = createEvent({ cookies: { auth_token: 'token' } })
|
||||
@@ -159,6 +192,8 @@ describe('Members API Endpoints', () => {
|
||||
const event = createEvent({ cookies: { auth_token: 'token' } })
|
||||
mockSuccessReadBody(baseBody)
|
||||
authUtils.getUserFromToken.mockResolvedValue({ id: '2', role: 'admin' })
|
||||
authUtils.readUsers.mockResolvedValue([])
|
||||
memberUtils.readMembers.mockResolvedValue([])
|
||||
memberUtils.saveMember.mockResolvedValue(true)
|
||||
|
||||
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 () => {
|
||||
const event = createEvent({ cookies: { auth_token: 'token' } })
|
||||
mockSuccessReadBody(baseBody)
|
||||
@@ -187,6 +292,7 @@ describe('Members API Endpoints', () => {
|
||||
email: 'lisa@example.com'
|
||||
})
|
||||
authUtils.getUserFromToken.mockResolvedValue({ id: '3', role: 'vorstand' })
|
||||
authUtils.readUsers.mockResolvedValue([])
|
||||
memberUtils.saveMember.mockResolvedValue(true)
|
||||
|
||||
const response = await membersPostHandler(event)
|
||||
|
||||
@@ -17,8 +17,13 @@ vi.mock('../server/utils/news.js', () => ({
|
||||
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 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 newsPostHandler from '../server/api/news.post.js'
|
||||
@@ -111,6 +116,29 @@ describe('News API Endpoints', () => {
|
||||
expect(newsUtils.saveNews).toHaveBeenCalledWith(
|
||||
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 () => {
|
||||
|
||||
191
tests/notification-scheduler.spec.ts
Normal file
191
tests/notification-scheduler.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
@@ -16,8 +16,25 @@ vi.mock('../server/utils/news.js', () => ({
|
||||
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 newsUtils = await import('../server/utils/news.js')
|
||||
const authUtils = await import('../server/utils/auth.js')
|
||||
|
||||
import contactHandler from '../server/api/contact.post.js'
|
||||
import galerieHandler from '../server/api/galerie.get.js'
|
||||
@@ -29,14 +46,17 @@ describe('Öffentliche API-Endpunkte', () => {
|
||||
afterEach(() => {
|
||||
delete process.env.NODE_ENV
|
||||
delete process.env.APP_ENV
|
||||
delete process.env.DEBUG
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
// Setze SMTP-Credentials für Tests
|
||||
process.env.SMTP_USER = 'test@example.com'
|
||||
process.env.SMTP_PASS = 'test-password'
|
||||
authUtils.readUsers.mockResolvedValue([])
|
||||
vi.restoreAllMocks()
|
||||
vi.clearAllMocks()
|
||||
authUtils.readUsers.mockResolvedValue([])
|
||||
})
|
||||
|
||||
describe('POST /api/contact', () => {
|
||||
@@ -78,6 +98,53 @@ describe('Öffentliche API-Endpunkte', () => {
|
||||
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', () => {
|
||||
|
||||
Reference in New Issue
Block a user