diff --git a/android-app/app/build.gradle.kts b/android-app/app/build.gradle.kts index 357aed4..5844f7a 100644 --- a/android-app/app/build.gradle.kts +++ b/android-app/app/build.gradle.kts @@ -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() @@ -253,6 +257,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 +269,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") diff --git a/android-app/app/production/release/app-production-release.aab b/android-app/app/production/release/app-production-release.aab index 3447f1c..85a22bc 100644 Binary files a/android-app/app/production/release/app-production-release.aab and b/android-app/app/production/release/app-production-release.aab differ diff --git a/android-app/app/src/main/AndroidManifest.xml b/android-app/app/src/main/AndroidManifest.xml index d176523..5fc3c8c 100644 --- a/android-app/app/src/main/AndroidManifest.xml +++ b/android-app/app/src/main/AndroidManifest.xml @@ -2,6 +2,7 @@ + + + + + + options.dsn = BuildConfig.SENTRY_DSN diff --git a/android-app/app/src/main/java/de/harheimertc/MainActivity.kt b/android-app/app/src/main/java/de/harheimertc/MainActivity.kt index 1a839cc..2921440 100644 --- a/android-app/app/src/main/java/de/harheimertc/MainActivity.kt +++ b/android-app/app/src/main/java/de/harheimertc/MainActivity.kt @@ -1,13 +1,17 @@ package de.harheimertc +import android.Manifest +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.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,12 +21,25 @@ import androidx.compose.ui.platform.LocalContext @AndroidEntryPoint class MainActivity : ComponentActivity() { + private val notificationPermissionLauncher = registerForActivityResult( + ActivityResultContracts.RequestPermission(), + ) { granted -> + Log.i("NOTIFICATIONS", "POST_NOTIFICATIONS granted=$granted") + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + requestNotificationPermissionIfNeeded() setContent { App() } } + + private fun requestNotificationPermissionIfNeeded() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && !HarheimerNotifications.hasNotificationPermission(this)) { + notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + } + } } @Composable diff --git a/android-app/app/src/main/java/de/harheimertc/data/ApiService.kt b/android-app/app/src/main/java/de/harheimertc/data/ApiService.kt index a971bdc..bb920b6 100644 --- a/android-app/app/src/main/java/de/harheimertc/data/ApiService.kt +++ b/android-app/app/src/main/java/de/harheimertc/data/ApiService.kt @@ -257,6 +257,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 = 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 = "", @@ -660,6 +684,15 @@ interface ApiService { @retrofit2.http.PUT("/api/profile") suspend fun updateProfile(@Body request: ProfileUpdateRequest): Response + @GET("/api/profile/notifications") + suspend fun notificationSettings(): Response + + @retrofit2.http.PUT("/api/profile/notifications") + suspend fun updateNotificationSettings(@Body request: NotificationSettingsDto): Response + + @POST("/api/profile/push-token") + suspend fun registerPushToken(@Body request: PushTokenRequest): Response + @GET("/api/birthdays") suspend fun birthdays(): Response diff --git a/android-app/app/src/main/java/de/harheimertc/notifications/HarheimerMessagingService.kt b/android-app/app/src/main/java/de/harheimertc/notifications/HarheimerMessagingService.kt new file mode 100644 index 0000000..0e3cf50 --- /dev/null +++ b/android-app/app/src/main/java/de/harheimertc/notifications/HarheimerMessagingService.kt @@ -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) + Log.d("HarheimerMessaging", "Push message received type=${message.data["type"]}, shown=$shown") + } +} diff --git a/android-app/app/src/main/java/de/harheimertc/notifications/HarheimerNotifications.kt b/android-app/app/src/main/java/de/harheimertc/notifications/HarheimerNotifications.kt new file mode 100644 index 0000000..f8aec64 --- /dev/null +++ b/android-app/app/src/main/java/de/harheimertc/notifications/HarheimerNotifications.kt @@ -0,0 +1,51 @@ +package de.harheimertc.notifications + +import android.Manifest +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +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.R + +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, + ): 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) + .setAutoCancel(true) + .build() + NotificationManagerCompat.from(context).notify(notificationId, notification) + return true + } +} diff --git a/android-app/app/src/main/java/de/harheimertc/repositories/NotificationPreferencesRepository.kt b/android-app/app/src/main/java/de/harheimertc/repositories/NotificationPreferencesRepository.kt new file mode 100644 index 0000000..04f1ae6 --- /dev/null +++ b/android-app/app/src/main/java/de/harheimertc/repositories/NotificationPreferencesRepository.kt @@ -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 = 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 = 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 { + 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, +) diff --git a/android-app/app/src/main/java/de/harheimertc/repositories/PushTokenRepository.kt b/android-app/app/src/main/java/de/harheimertc/repositories/PushTokenRepository.kt new file mode 100644 index 0000000..ed1ef33 --- /dev/null +++ b/android-app/app/src/main/java/de/harheimertc/repositories/PushTokenRepository.kt @@ -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 = runCatching { + val token = FirebaseMessaging.getInstance().token.await() + registerToken(token).getOrThrow() + } + + suspend fun registerToken(token: String): Result = 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) + } +} diff --git a/android-app/app/src/main/java/de/harheimertc/ui/components/AppNavigationHeader.kt b/android-app/app/src/main/java/de/harheimertc/ui/components/AppNavigationHeader.kt index d7f4cb9..9994519 100644 --- a/android-app/app/src/main/java/de/harheimertc/ui/components/AppNavigationHeader.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/components/AppNavigationHeader.kt @@ -416,6 +416,7 @@ 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, @@ -473,6 +474,7 @@ private fun submenu(section: MenuSection?, state: NavigationUiState): List = _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() } } diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/memberarea/MemberAreaScreen.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/memberarea/MemberAreaScreen.kt index 6cece96..1712b0d 100644 --- a/android-app/app/src/main/java/de/harheimertc/ui/screens/memberarea/MemberAreaScreen.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/memberarea/MemberAreaScreen.kt @@ -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", diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/notifications/NotificationSettingsScreen.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/notifications/NotificationSettingsScreen.kt new file mode 100644 index 0000000..ba9e96d --- /dev/null +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/notifications/NotificationSettingsScreen.kt @@ -0,0 +1,276 @@ +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)) + } + Text("Die eigene Mannschaft wird später aus den Mannschaftsdefinitionen der aktuellen Saison ermittelt.", color = Accent700) + 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 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, + seasons: List, + 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) + } + } + } + } + } +} diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/notifications/NotificationSettingsViewModel.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/notifications/NotificationSettingsViewModel.kt new file mode 100644 index 0000000..8cd156c --- /dev/null +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/notifications/NotificationSettingsViewModel.kt @@ -0,0 +1,110 @@ +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.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 = emptyList(), + val seasons: List = emptyList(), + val error: String? = null, + val saveError: String? = null, +) + +@HiltViewModel +class NotificationSettingsViewModel @Inject constructor( + private val preferencesRepository: NotificationPreferencesRepository, + private val mannschaftenRepository: MannschaftenRepository, +) : ViewModel() { + private val _state = MutableStateFlow(NotificationSettingsUiState()) + val state: StateFlow = _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 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) + } + } + + 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, 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, 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, + 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, + seasons = seasons, + error = error.message ?: "Mannschaften konnten nicht geladen werden.", + saveError = saveError, + ) + } + } +} diff --git a/android-app/build.gradle.kts b/android-app/build.gradle.kts index 4672809..4c1affa 100644 --- a/android-app/build.gradle.kts +++ b/android-app/build.gradle.kts @@ -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 { diff --git a/components/PublicNews.vue b/components/PublicNews.vue index 2b89552..8041a86 100644 --- a/components/PublicNews.vue +++ b/components/PublicNews.vue @@ -78,53 +78,62 @@ - -
-
- -
-
-
- - {{ formatDate(selectedNews.created) }} -
-

- {{ selectedNews.title }} -

-
- -
+ + + -
-
+ + diff --git a/package.json b/package.json index dc86fdc..fb7b88f 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/server/api/news.post.js b/server/api/news.post.js index 9c32d16..f2fddc6 100644 --- a/server/api/news.post.js +++ b/server/api/news.post.js @@ -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,13 @@ export default defineEventHandler(async (event) => { expiresAt: expiresAt || undefined, isHidden: isHidden || false, author: user.name - }) + } + await saveNews(newsEntry) + if (!id && !newsEntry.isHidden) { + sendNewNewsPush(newsEntry).catch(error => { + console.error('News-Push konnte nicht gesendet werden:', error) + }) + } return { success: true, diff --git a/server/api/profile/notifications.get.js b/server/api/profile/notifications.get.js new file mode 100644 index 0000000..8aeff9f --- /dev/null +++ b/server/api/profile/notifications.get.js @@ -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) + } +}) diff --git a/server/api/profile/notifications.put.js b/server/api/profile/notifications.put.js new file mode 100644 index 0000000..0e4fd40 --- /dev/null +++ b/server/api/profile/notifications.put.js @@ -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 + } +}) diff --git a/server/api/profile/push-token.post.js b/server/api/profile/push-token.post.js new file mode 100644 index 0000000..fe8c57d --- /dev/null +++ b/server/api/profile/push-token.post.js @@ -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.' } +}) diff --git a/server/utils/notification-settings.js b/server/utils/notification-settings.js new file mode 100644 index 0000000..6d98feb --- /dev/null +++ b/server/utils/notification-settings.js @@ -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 || {}) + }) +} diff --git a/server/utils/push-notifications.js b/server/utils/push-notifications.js new file mode 100644 index 0000000..39bb0d4 --- /dev/null +++ b/server/utils/push-notifications.js @@ -0,0 +1,165 @@ +import crypto from 'crypto' +import { promises as fs } from 'fs' +import { readUsers, writeUsers, isHiddenUser } 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 +} + +async function readServiceAccount() { + if (process.env.FCM_SERVICE_ACCOUNT_JSON) { + return JSON.parse(process.env.FCM_SERVICE_ACCOUNT_JSON) + } + if (process.env.GOOGLE_APPLICATION_CREDENTIALS) { + const raw = await fs.readFile(process.env.GOOGLE_APPLICATION_CREDENTIALS, 'utf8') + return JSON.parse(raw) + } + 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}`) + } +} + +export async function sendNewNewsPush(news) { + const serviceAccount = await readServiceAccount() + if (!serviceAccount) { + console.warn('FCM nicht konfiguriert: FCM_SERVICE_ACCOUNT_JSON oder GOOGLE_APPLICATION_CREDENTIALS fehlt.') + return { sent: 0, skipped: true } + } + const accessToken = await getAccessToken(serviceAccount) + const users = await readUsers() + let sent = 0 + let changed = false + const title = 'Neue News' + const body = String(news.title || 'Neue Nachricht vom Harheimer TC').slice(0, 120) + const data = { + type: 'news', + newsId: String(news.id || ''), + title, + body, + notificationId: String(news.id || Date.now()).split('').reduce((acc, char) => acc + char.charCodeAt(0), 0).toString() + } + + for (const user of users) { + if (isHiddenUser(user)) continue + const settings = notificationSettingsForUser(user) + if (!settings.newNews) continue + const tokens = pushTokensForUser(user) + const validTokens = [] + for (const entry of tokens) { + try { + await sendFcmMessage({ serviceAccount, accessToken, token: entry.token, title, body, data }) + sent += 1 + validTokens.push(entry) + } catch (error) { + console.error('FCM News-Push fehlgeschlagen:', error.message) + if (!/UNREGISTERED|NOT_FOUND|INVALID_ARGUMENT/.test(String(error.message))) { + validTokens.push(entry) + } else { + changed = true + } + } + } + if (validTokens.length !== tokens.length) { + user.pushTokens = validTokens + changed = true + } + } + if (changed) await writeUsers(users) + return { sent, skipped: false } +} diff --git a/tests/config-profile-endpoints.spec.ts b/tests/config-profile-endpoints.spec.ts index 57dc6fd..3d3e9bb 100644 --- a/tests/config-profile-endpoints.spec.ts +++ b/tests/config-profile-endpoints.spec.ts @@ -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() diff --git a/tests/news-endpoints.spec.ts b/tests/news-endpoints.spec.ts index 67216e0..7f8b8ff 100644 --- a/tests/news-endpoints.spec.ts +++ b/tests/news-endpoints.spec.ts @@ -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 () => {