Fix in news, first android notification service
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 7m50s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped

This commit is contained in:
Torsten Schulz (local)
2026-06-10 13:47:33 +02:00
parent e8a50e55ca
commit 5da11d2e4d
28 changed files with 1277 additions and 43 deletions

View File

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

View File

@@ -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"
@@ -16,6 +17,13 @@
<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"

View File

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

View File

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

View File

@@ -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<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 = "",
@@ -660,6 +684,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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -43,6 +43,7 @@ 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")

View File

@@ -313,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,

View File

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

View File

@@ -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",

View File

@@ -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<Mannschaft>,
seasons: List<String>,
selectedSeason: String?,
settings: NotificationPreferences,
onSelectSeason: (String) -> Unit,
onToggleTeam: (String, Boolean) -> Unit,
) {
Surface(color = Primary100, shape = RoundedCornerShape(10.dp)) {
Column(Modifier.fillMaxWidth().padding(12.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text("Ausgewählte Mannschaften", color = Accent900, fontWeight = FontWeight.SemiBold)
Text("Zusätzlich einzelne Mannschaften abonnieren.", color = Accent700)
if (seasons.isNotEmpty()) {
Row(
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
seasons.forEach { season ->
val selected = season == selectedSeason
if (selected) {
Button(onClick = { onSelectSeason(season) }) { Text(season) }
} else {
OutlinedButton(onClick = { onSelectSeason(season) }) { Text(season) }
}
}
}
}
if (teams.isEmpty()) {
Text("Keine Mannschaften verfügbar.", color = Accent700)
} else {
teams.forEach { team ->
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
Checkbox(
checked = team.slug in settings.selectedTeamSlugs,
onCheckedChange = { onToggleTeam(team.slug, it) },
)
Text(team.mannschaft, color = Accent900)
}
}
}
}
}
}

View File

@@ -0,0 +1,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<Mannschaft> = emptyList(),
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,
) : 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 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<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,
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,
)
}
}
}

View File

@@ -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 {

View File

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

View File

@@ -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",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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 }
}

View File

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

View File

@@ -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 () => {