Merge pull request 'dev' (#42) from dev into main
Reviewed-on: #42
This commit was merged in pull request #42.
This commit is contained in:
7
.gitleaks.toml
Normal file
7
.gitleaks.toml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
[[allowlists]]
|
||||||
|
description = "generated/imported non-secret data"
|
||||||
|
paths = [
|
||||||
|
'''server/data/spielplan-import/harheimer_tc_spielplan\.(html|json)$''',
|
||||||
|
'''android-app/app/build/.*''',
|
||||||
|
'''android-app/\.idea/planningMode\.xml$''',
|
||||||
|
]
|
||||||
@@ -88,6 +88,13 @@ android {
|
|||||||
versionName = androidVersionName
|
versionName = androidVersionName
|
||||||
}
|
}
|
||||||
|
|
||||||
|
lint {
|
||||||
|
disable += setOf(
|
||||||
|
"AutoboxingStateCreation",
|
||||||
|
"MutableCollectionMutableState",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
create("release") {
|
create("release") {
|
||||||
if (hasReleaseSigning) {
|
if (hasReleaseSigning) {
|
||||||
|
|||||||
Binary file not shown.
@@ -21,6 +21,7 @@ import okhttp3.RequestBody
|
|||||||
data class ContactRequest(val name: String, val email: String, val message: String)
|
data class ContactRequest(val name: String, val email: String, val message: String)
|
||||||
data class ContactResponse(val ok: Boolean, val id: String? = null, val message: String? = null)
|
data class ContactResponse(val ok: Boolean, val id: String? = null, val message: String? = null)
|
||||||
data class TermineResponse(val success: Boolean = true, val termine: List<TerminDto> = emptyList())
|
data class TermineResponse(val success: Boolean = true, val termine: List<TerminDto> = emptyList())
|
||||||
|
data class TermineManageResponse(val success: Boolean = true, val termine: List<TerminDto> = emptyList())
|
||||||
data class TerminDto(
|
data class TerminDto(
|
||||||
val datum: String = "",
|
val datum: String = "",
|
||||||
val uhrzeit: String? = null,
|
val uhrzeit: String? = null,
|
||||||
@@ -231,7 +232,7 @@ data class ProfileVisibilityDto(
|
|||||||
val showEmail: Boolean = true,
|
val showEmail: Boolean = true,
|
||||||
val showPhone: Boolean = true,
|
val showPhone: Boolean = true,
|
||||||
val showAddress: Boolean = false,
|
val showAddress: Boolean = false,
|
||||||
val showBirthday: Boolean = true,
|
val showBirthday: Boolean = false,
|
||||||
)
|
)
|
||||||
data class ProfileUserDto(
|
data class ProfileUserDto(
|
||||||
val id: String? = null,
|
val id: String? = null,
|
||||||
@@ -328,6 +329,7 @@ data class MemberDto(
|
|||||||
val editable: Boolean = false,
|
val editable: Boolean = false,
|
||||||
val isMannschaftsspieler: Boolean = false,
|
val isMannschaftsspieler: Boolean = false,
|
||||||
val hasHallKey: Boolean = false,
|
val hasHallKey: Boolean = false,
|
||||||
|
val showBirthday: Boolean = false,
|
||||||
val loginRoles: List<String> = emptyList(),
|
val loginRoles: List<String> = emptyList(),
|
||||||
)
|
)
|
||||||
data class MembersResponse(
|
data class MembersResponse(
|
||||||
@@ -590,6 +592,21 @@ interface ApiService {
|
|||||||
@GET("/api/termine")
|
@GET("/api/termine")
|
||||||
suspend fun termine(): Response<TermineResponse>
|
suspend fun termine(): Response<TermineResponse>
|
||||||
|
|
||||||
|
@GET("/api/termine-manage")
|
||||||
|
suspend fun termineManage(): Response<TermineManageResponse>
|
||||||
|
|
||||||
|
@POST("/api/termine-manage")
|
||||||
|
suspend fun saveTermin(@Body request: TerminDto): Response<AuthMessageResponse>
|
||||||
|
|
||||||
|
@DELETE("/api/termine-manage")
|
||||||
|
suspend fun deleteTermin(
|
||||||
|
@Query("datum") datum: String,
|
||||||
|
@Query("uhrzeit") uhrzeit: String = "",
|
||||||
|
@Query("titel") titel: String,
|
||||||
|
@Query("beschreibung") beschreibung: String = "",
|
||||||
|
@Query("kategorie") kategorie: String = "Sonstiges",
|
||||||
|
): Response<AuthMessageResponse>
|
||||||
|
|
||||||
@GET("/api/spielplan")
|
@GET("/api/spielplan")
|
||||||
suspend fun spielplan(@Query("season") season: String? = null): Response<SpielplanResponse>
|
suspend fun spielplan(@Query("season") season: String? = null): Response<SpielplanResponse>
|
||||||
|
|
||||||
@@ -713,6 +730,7 @@ interface ApiService {
|
|||||||
val notes: String? = null,
|
val notes: String? = null,
|
||||||
val isMannschaftsspieler: Boolean = false,
|
val isMannschaftsspieler: Boolean = false,
|
||||||
val hasHallKey: Boolean = false,
|
val hasHallKey: Boolean = false,
|
||||||
|
val showBirthday: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class BulkImportRequest(val members: List<Map<String, String>>)
|
data class BulkImportRequest(val members: List<Map<String, String>>)
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import javax.inject.Singleton
|
|||||||
|
|
||||||
@Singleton
|
@Singleton
|
||||||
class ConnectivityMonitor @Inject constructor(
|
class ConnectivityMonitor @Inject constructor(
|
||||||
@ApplicationContext private val context: Context,
|
@param:ApplicationContext private val context: Context,
|
||||||
) {
|
) {
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
private val _online = MutableStateFlow(hasInternetAccess())
|
private val _online = MutableStateFlow(hasInternetAccess())
|
||||||
@@ -46,4 +46,4 @@ class ConnectivityMonitor @Inject constructor(
|
|||||||
val capabilities = manager.getNetworkCapabilities(network) ?: return false
|
val capabilities = manager.getNetworkCapabilities(network) ?: return false
|
||||||
return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,8 +51,12 @@ object HarheimerNotifications {
|
|||||||
.setContentIntent(createContentIntent(context, notificationId, data))
|
.setContentIntent(createContentIntent(context, notificationId, data))
|
||||||
.setAutoCancel(true)
|
.setAutoCancel(true)
|
||||||
.build()
|
.build()
|
||||||
NotificationManagerCompat.from(context).notify(notificationId, notification)
|
return try {
|
||||||
return true
|
NotificationManagerCompat.from(context).notify(notificationId, notification)
|
||||||
|
true
|
||||||
|
} catch (_: SecurityException) {
|
||||||
|
false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createContentIntent(context: Context, notificationId: Int, payload: Map<String, String>): PendingIntent {
|
private fun createContentIntent(context: Context, notificationId: Int, payload: Map<String, String>): PendingIntent {
|
||||||
@@ -72,7 +76,12 @@ object HarheimerNotifications {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun destinationRoute(data: Map<String, String>): String = when (data["type"]) {
|
private fun destinationRoute(data: Map<String, String>): String = when (data["type"]) {
|
||||||
"news" -> Destinations.MemberNews.route
|
"news", "news_expiring" -> Destinations.MemberNews.route
|
||||||
|
"event", "events_today", "events_tomorrow" -> Destinations.Termine.route
|
||||||
|
"team_matches" -> Destinations.Spielplan.route
|
||||||
|
"birthdays" -> Destinations.MemberArea.route
|
||||||
|
"contact_request" -> Destinations.CmsContactRequests.route
|
||||||
|
"user_registration" -> Destinations.CmsBenutzer.route
|
||||||
else -> Destinations.Home.route
|
else -> Destinations.Home.route
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import de.harheimertc.data.PasswordResetDiagnosticsResponse
|
|||||||
import de.harheimertc.data.SaveCsvRequest
|
import de.harheimertc.data.SaveCsvRequest
|
||||||
import de.harheimertc.data.SaveCsvResponse
|
import de.harheimertc.data.SaveCsvResponse
|
||||||
import de.harheimertc.data.SecureOfflineCache
|
import de.harheimertc.data.SecureOfflineCache
|
||||||
|
import de.harheimertc.data.SpielplanResponse
|
||||||
|
import de.harheimertc.data.TerminDto
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
class CmsRepository @Inject constructor(
|
class CmsRepository @Inject constructor(
|
||||||
@@ -83,6 +85,86 @@ class CmsRepository @Inject constructor(
|
|||||||
response.body() ?: SaveCsvResponse(success = false, message = "Leere Antwort")
|
response.body() ?: SaveCsvResponse(success = false, message = "Leere Antwort")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun managedTermine(): Result<List<TerminDto>> = runCatching {
|
||||||
|
val response = api.termineManage()
|
||||||
|
if (!response.isSuccessful) error("Termine konnten nicht geladen werden.")
|
||||||
|
response.body()?.termine.orEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun saveTermin(request: TerminDto): Result<de.harheimertc.data.AuthMessageResponse> = runCatching {
|
||||||
|
val response = api.saveTermin(request)
|
||||||
|
if (!response.isSuccessful) error("Termin konnte nicht gespeichert werden.")
|
||||||
|
response.body() ?: de.harheimertc.data.AuthMessageResponse(success = false, message = "Leere Antwort")
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun deleteTermin(request: TerminDto): Result<de.harheimertc.data.AuthMessageResponse> = runCatching {
|
||||||
|
val response = api.deleteTermin(
|
||||||
|
datum = request.datum,
|
||||||
|
uhrzeit = request.uhrzeit.orEmpty(),
|
||||||
|
titel = request.titel,
|
||||||
|
beschreibung = request.beschreibung.orEmpty(),
|
||||||
|
kategorie = request.kategorie ?: "Sonstiges",
|
||||||
|
)
|
||||||
|
if (!response.isSuccessful) error("Termin konnte nicht gelöscht werden.")
|
||||||
|
response.body() ?: de.harheimertc.data.AuthMessageResponse(success = false, message = "Leere Antwort")
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun mannschaften(season: String? = null): Result<List<CmsMannschaftRow>> = runCatching {
|
||||||
|
val response = api.mannschaften(season)
|
||||||
|
if (!response.isSuccessful) error("Mannschaften konnten nicht geladen werden.")
|
||||||
|
parseCsv(response.body()?.string().orEmpty()).drop(1).mapNotNull { values ->
|
||||||
|
if (values.size < 10 || values[0].isBlank()) return@mapNotNull null
|
||||||
|
CmsMannschaftRow(
|
||||||
|
mannschaft = values[0],
|
||||||
|
liga = values[1],
|
||||||
|
staffelleiter = values[2],
|
||||||
|
telefon = values[3],
|
||||||
|
heimspieltag = values[4],
|
||||||
|
spielsystem = values[5],
|
||||||
|
mannschaftsfuehrer = values[6],
|
||||||
|
spieler = values[7],
|
||||||
|
informationenLink = values[8],
|
||||||
|
letzteAktualisierung = values[9],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun mannschaftenSeasons(): Result<de.harheimertc.data.MannschaftenSeasonsResponse> = runCatching {
|
||||||
|
val response = api.mannschaftenSeasons()
|
||||||
|
if (!response.isSuccessful) error("Saisons konnten nicht geladen werden.")
|
||||||
|
response.body() ?: de.harheimertc.data.MannschaftenSeasonsResponse()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun saveMannschaften(season: String?, rows: List<CmsMannschaftRow>): Result<SaveCsvResponse> = runCatching {
|
||||||
|
val response = api.saveCsv(
|
||||||
|
SaveCsvRequest(
|
||||||
|
filename = season?.takeIf { it.isNotBlank() }?.let { "mannschaften_$it.csv" } ?: "mannschaften.csv",
|
||||||
|
content = rows.toMannschaftenCsv(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if (!response.isSuccessful) error("Mannschaften konnten nicht gespeichert werden.")
|
||||||
|
response.body() ?: SaveCsvResponse(success = false, message = "Leere Antwort")
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun spielplan(season: String? = null): Result<SpielplanResponse> = runCatching {
|
||||||
|
val response = api.spielplan(season)
|
||||||
|
if (!response.isSuccessful) error("Spielplan konnte nicht geladen werden.")
|
||||||
|
val body = response.body() ?: error("Leere Antwort")
|
||||||
|
if (!body.success) error(body.message ?: "Spielplan konnte nicht geladen werden.")
|
||||||
|
body
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun saveSpielplan(headers: List<String>, rows: List<List<String>>): Result<SaveCsvResponse> = runCatching {
|
||||||
|
val response = api.saveCsv(
|
||||||
|
SaveCsvRequest(
|
||||||
|
filename = "spielplan.csv",
|
||||||
|
content = listOf(headers).plus(rows).joinToString("\n") { row -> row.toCsvRow(";") },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if (!response.isSuccessful) error("Spielplan konnte nicht gespeichert werden.")
|
||||||
|
response.body() ?: SaveCsvResponse(success = false, message = "Leere Antwort")
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun users(): Result<CmsUsersResponse> =
|
suspend fun users(): Result<CmsUsersResponse> =
|
||||||
fetchEncryptedFallback(
|
fetchEncryptedFallback(
|
||||||
load = {
|
load = {
|
||||||
@@ -288,6 +370,58 @@ class CmsRepository @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
data class CmsMannschaftRow(
|
||||||
|
val mannschaft: String = "",
|
||||||
|
val liga: String = "",
|
||||||
|
val staffelleiter: String = "",
|
||||||
|
val telefon: String = "",
|
||||||
|
val heimspieltag: String = "",
|
||||||
|
val spielsystem: String = "",
|
||||||
|
val mannschaftsfuehrer: String = "",
|
||||||
|
val spieler: String = "",
|
||||||
|
val informationenLink: String = "",
|
||||||
|
val letzteAktualisierung: String = "",
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun List<CmsMannschaftRow>.toMannschaftenCsv(): String {
|
||||||
|
val header = listOf(
|
||||||
|
"Mannschaft",
|
||||||
|
"Liga",
|
||||||
|
"Staffelleiter",
|
||||||
|
"Telefon",
|
||||||
|
"Heimspieltag",
|
||||||
|
"Spielsystem",
|
||||||
|
"Mannschaftsführer",
|
||||||
|
"Spieler",
|
||||||
|
"Weitere Informationen Link",
|
||||||
|
"Letzte Aktualisierung",
|
||||||
|
).toCsvRow()
|
||||||
|
val rows = map { row ->
|
||||||
|
listOf(
|
||||||
|
row.mannschaft,
|
||||||
|
row.liga,
|
||||||
|
row.staffelleiter,
|
||||||
|
row.telefon,
|
||||||
|
row.heimspieltag,
|
||||||
|
row.spielsystem,
|
||||||
|
row.mannschaftsfuehrer,
|
||||||
|
row.spieler,
|
||||||
|
row.informationenLink,
|
||||||
|
row.letzteAktualisierung,
|
||||||
|
).toCsvRow()
|
||||||
|
}
|
||||||
|
return listOf(header).plus(rows).joinToString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun List<String>.toCsvRow(delimiter: String = ","): String =
|
||||||
|
joinToString(delimiter) { value -> value.csvEscape(delimiter) }
|
||||||
|
|
||||||
|
private fun String.csvEscape(delimiter: String): String {
|
||||||
|
val needsQuotes = contains(delimiter) || contains('"') || contains('\n') || contains('\r')
|
||||||
|
val escaped = replace("\"", "\"\"")
|
||||||
|
return if (needsQuotes) "\"$escaped\"" else escaped
|
||||||
|
}
|
||||||
|
|
||||||
private fun parseCsv(csv: String): List<List<String>> =
|
private fun parseCsv(csv: String): List<List<String>> =
|
||||||
csv.lineSequence().filter(String::isNotBlank).map(::parseCsvLine).toList()
|
csv.lineSequence().filter(String::isNotBlank).map(::parseCsvLine).toList()
|
||||||
|
|
||||||
|
|||||||
@@ -422,6 +422,7 @@ private fun menuSection(route: String?): MenuSection? = when (route) {
|
|||||||
Destinations.CmsStartseite.route,
|
Destinations.CmsStartseite.route,
|
||||||
Destinations.CmsInhalte.route,
|
Destinations.CmsInhalte.route,
|
||||||
Destinations.CmsVereinsmeisterschaften.route,
|
Destinations.CmsVereinsmeisterschaften.route,
|
||||||
|
Destinations.CmsNews.route,
|
||||||
Destinations.CmsSportbetrieb.route,
|
Destinations.CmsSportbetrieb.route,
|
||||||
Destinations.CmsMitgliederverwaltung.route,
|
Destinations.CmsMitgliederverwaltung.route,
|
||||||
Destinations.CmsNewsletter.route,
|
Destinations.CmsNewsletter.route,
|
||||||
@@ -484,7 +485,7 @@ private fun submenu(section: MenuSection?, state: NavigationUiState): List<MenuT
|
|||||||
add(MenuTarget("Startseite", Destinations.CmsStartseite.route))
|
add(MenuTarget("Startseite", Destinations.CmsStartseite.route))
|
||||||
add(MenuTarget("Inhalte", Destinations.CmsInhalte.route))
|
add(MenuTarget("Inhalte", Destinations.CmsInhalte.route))
|
||||||
add(MenuTarget("Vereinsmeisterschaften", Destinations.CmsVereinsmeisterschaften.route))
|
add(MenuTarget("Vereinsmeisterschaften", Destinations.CmsVereinsmeisterschaften.route))
|
||||||
add(MenuTarget("News", Destinations.MemberNews.route))
|
add(MenuTarget("News", Destinations.CmsNews.route))
|
||||||
add(MenuTarget("Sportbetrieb", Destinations.CmsSportbetrieb.route))
|
add(MenuTarget("Sportbetrieb", Destinations.CmsSportbetrieb.route))
|
||||||
add(MenuTarget("Mitgliederverwaltung", Destinations.CmsMitgliederverwaltung.route))
|
add(MenuTarget("Mitgliederverwaltung", Destinations.CmsMitgliederverwaltung.route))
|
||||||
add(MenuTarget("Einstellungen", Destinations.CmsEinstellungen.route))
|
add(MenuTarget("Einstellungen", Destinations.CmsEinstellungen.route))
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ sealed class Destinations(val route: String) {
|
|||||||
object CmsStartseite : Destinations("cms/startseite")
|
object CmsStartseite : Destinations("cms/startseite")
|
||||||
object CmsInhalte : Destinations("cms/inhalte")
|
object CmsInhalte : Destinations("cms/inhalte")
|
||||||
object CmsVereinsmeisterschaften : Destinations("cms/vereinsmeisterschaften")
|
object CmsVereinsmeisterschaften : Destinations("cms/vereinsmeisterschaften")
|
||||||
|
object CmsNews : Destinations("cms/news")
|
||||||
object CmsSportbetrieb : Destinations("cms/sportbetrieb")
|
object CmsSportbetrieb : Destinations("cms/sportbetrieb")
|
||||||
object CmsMitgliederverwaltung : Destinations("cms/mitgliederverwaltung")
|
object CmsMitgliederverwaltung : Destinations("cms/mitgliederverwaltung")
|
||||||
object CmsNewsletter : Destinations("cms/newsletter")
|
object CmsNewsletter : Destinations("cms/newsletter")
|
||||||
|
|||||||
@@ -335,6 +335,9 @@ fun NavGraph(
|
|||||||
composable(Destinations.CmsVereinsmeisterschaften.route) {
|
composable(Destinations.CmsVereinsmeisterschaften.route) {
|
||||||
de.harheimertc.ui.screens.cms.CmsVereinsmeisterschaftenScreen(navController, !persistentNavigation)
|
de.harheimertc.ui.screens.cms.CmsVereinsmeisterschaftenScreen(navController, !persistentNavigation)
|
||||||
}
|
}
|
||||||
|
composable(Destinations.CmsNews.route) {
|
||||||
|
de.harheimertc.ui.screens.cms.CmsNewsScreen(navController, !persistentNavigation)
|
||||||
|
}
|
||||||
composable(Destinations.CmsSportbetrieb.route) {
|
composable(Destinations.CmsSportbetrieb.route) {
|
||||||
de.harheimertc.ui.screens.cms.CmsSportbetriebScreen(navController, !persistentNavigation)
|
de.harheimertc.ui.screens.cms.CmsSportbetriebScreen(navController, !persistentNavigation)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
package de.harheimertc.ui.screens.cms
|
package de.harheimertc.ui.screens.cms
|
||||||
|
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.app.DatePickerDialog
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
@@ -36,6 +38,8 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Checkbox
|
import androidx.compose.material3.Checkbox
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.navigation.NavController
|
import androidx.navigation.NavController
|
||||||
@@ -48,10 +52,12 @@ import de.harheimertc.data.NewsletterGroupDto
|
|||||||
import de.harheimertc.data.PasswordResetMatchingUserDto
|
import de.harheimertc.data.PasswordResetMatchingUserDto
|
||||||
import de.harheimertc.data.PasswordResetAttemptDto
|
import de.harheimertc.data.PasswordResetAttemptDto
|
||||||
import de.harheimertc.data.PasswordResetStepDto
|
import de.harheimertc.data.PasswordResetStepDto
|
||||||
|
import de.harheimertc.data.TerminDto
|
||||||
import de.harheimertc.ui.components.FormMessages
|
import de.harheimertc.ui.components.FormMessages
|
||||||
import de.harheimertc.ui.components.LoadingState
|
import de.harheimertc.ui.components.LoadingState
|
||||||
import de.harheimertc.ui.components.NativeRichTextEditor
|
import de.harheimertc.ui.components.NativeRichTextEditor
|
||||||
import de.harheimertc.ui.navigation.Destinations
|
import de.harheimertc.ui.navigation.Destinations
|
||||||
|
import de.harheimertc.repositories.CmsMannschaftRow
|
||||||
import de.harheimertc.repositories.MeisterschaftResult
|
import de.harheimertc.repositories.MeisterschaftResult
|
||||||
import de.harheimertc.ui.theme.Accent100
|
import de.harheimertc.ui.theme.Accent100
|
||||||
import de.harheimertc.ui.theme.Accent500
|
import de.harheimertc.ui.theme.Accent500
|
||||||
@@ -61,6 +67,7 @@ import de.harheimertc.ui.theme.Primary100
|
|||||||
import de.harheimertc.ui.theme.Primary600
|
import de.harheimertc.ui.theme.Primary600
|
||||||
import java.time.OffsetDateTime
|
import java.time.OffsetDateTime
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.util.Calendar
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -433,116 +440,362 @@ fun CmsVereinsmeisterschaftenScreen(navController: NavController, showBackNaviga
|
|||||||
@Composable
|
@Composable
|
||||||
fun CmsSportbetriebScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) {
|
fun CmsSportbetriebScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) {
|
||||||
val state by viewModel.state.collectAsState()
|
val state by viewModel.state.collectAsState()
|
||||||
val config = state.config
|
val context = LocalContext.current
|
||||||
var ortName by remember { mutableStateOf("") }
|
var activeTab by remember { mutableStateOf("termine") }
|
||||||
var ortStrasse by remember { mutableStateOf("") }
|
val mannschaften = remember { mutableStateListOf<CmsMannschaftRow>() }
|
||||||
var ortPlz by remember { mutableStateOf("") }
|
var spielplanCsv by remember { mutableStateOf("") }
|
||||||
var ortOrt by remember { mutableStateOf("") }
|
var spielplanEditorOpen by remember { mutableStateOf(false) }
|
||||||
val trainingTimes = remember { mutableStateListOf<de.harheimertc.data.TrainingTimeDto>() }
|
var terminDialogOpen by remember { mutableStateOf(false) }
|
||||||
val trainers = remember { mutableStateListOf<de.harheimertc.data.TrainerDto>() }
|
var editingTermin by remember { mutableStateOf<TerminDto?>(null) }
|
||||||
|
var terminDatum by remember { mutableStateOf("") }
|
||||||
|
var terminUhrzeit by remember { mutableStateOf("") }
|
||||||
|
var terminTitel by remember { mutableStateOf("") }
|
||||||
|
var terminBeschreibung by remember { mutableStateOf("") }
|
||||||
|
var terminKategorie by remember { mutableStateOf("Sonstiges") }
|
||||||
|
var terminKategorieOpen by remember { mutableStateOf(false) }
|
||||||
|
val tabs = listOf(
|
||||||
|
"termine" to "Termine",
|
||||||
|
"mannschaften" to "Mannschaften",
|
||||||
|
"spielplaene" to "Spielpläne",
|
||||||
|
)
|
||||||
|
val terminKategorien = listOf("Training", "Punktspiel", "Turnier", "Veranstaltung", "Sonstiges")
|
||||||
|
|
||||||
LaunchedEffect(config) {
|
LaunchedEffect(Unit) {
|
||||||
config?.let {
|
viewModel.loadSportbetrieb()
|
||||||
ortName = it.training.ort.name
|
|
||||||
ortStrasse = it.training.ort.strasse
|
|
||||||
ortPlz = it.training.ort.plz
|
|
||||||
ortOrt = it.training.ort.ort
|
|
||||||
trainingTimes.clear()
|
|
||||||
trainingTimes.addAll(it.training.zeiten)
|
|
||||||
trainers.clear()
|
|
||||||
trainers.addAll(it.trainer)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
CmsPage(navController, showBackNavigation, "Sportbetrieb", "Trainingszeiten, Trainingsort und Trainer pflegen") {
|
LaunchedEffect(state.sportMannschaften) {
|
||||||
when {
|
mannschaften.clear()
|
||||||
state.loading || config == null -> item { LoadingState("Sportbetriebsdaten werden geladen...") }
|
mannschaften.addAll(state.sportMannschaften)
|
||||||
else -> {
|
}
|
||||||
item {
|
|
||||||
Button(
|
LaunchedEffect(state.sportSpielplanHeaders, state.sportSpielplanRows) {
|
||||||
onClick = {
|
spielplanCsv = sportSpielplanCsvText(state.sportSpielplanHeaders, state.sportSpielplanRows)
|
||||||
viewModel.saveConfig(
|
}
|
||||||
config.copy(
|
|
||||||
training = config.training.copy(
|
fun openTerminDialog(termin: TerminDto?) {
|
||||||
ort = config.training.ort.copy(
|
editingTermin = termin
|
||||||
name = ortName,
|
terminDatum = termin?.datum.orEmpty()
|
||||||
strasse = ortStrasse,
|
terminUhrzeit = termin?.uhrzeit.orEmpty()
|
||||||
plz = ortPlz,
|
terminTitel = termin?.titel.orEmpty()
|
||||||
ort = ortOrt,
|
terminBeschreibung = termin?.beschreibung.orEmpty()
|
||||||
),
|
terminKategorie = termin?.kategorie ?: "Sonstiges"
|
||||||
zeiten = trainingTimes.toList(),
|
terminDialogOpen = true
|
||||||
),
|
}
|
||||||
trainer = trainers.toList(),
|
|
||||||
),
|
fun openDatePicker() {
|
||||||
)
|
val calendar = Calendar.getInstance()
|
||||||
},
|
runCatching {
|
||||||
enabled = !state.saving,
|
val parts = terminDatum.split("-")
|
||||||
modifier = Modifier.fillMaxWidth(),
|
if (parts.size == 3) {
|
||||||
) {
|
calendar.set(parts[0].toInt(), parts[1].toInt() - 1, parts[2].toInt())
|
||||||
Text(if (state.saving) "Speichert..." else "Speichern")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
item {
|
|
||||||
DataCard("Trainingsort") {
|
|
||||||
OutlinedTextField(value = ortName, onValueChange = { ortName = it }, label = { Text("Name") }, modifier = Modifier.fillMaxWidth())
|
|
||||||
OutlinedTextField(value = ortStrasse, onValueChange = { ortStrasse = it }, label = { Text("Straße") }, modifier = Modifier.fillMaxWidth())
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) {
|
|
||||||
OutlinedTextField(value = ortPlz, onValueChange = { ortPlz = it }, label = { Text("PLZ") }, modifier = Modifier.weight(1f))
|
|
||||||
OutlinedTextField(value = ortOrt, onValueChange = { ortOrt = it }, label = { Text("Ort") }, modifier = Modifier.weight(1f))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
item {
|
|
||||||
DataCard("Trainingszeiten") {
|
|
||||||
trainingTimes.forEachIndexed { index, zeit ->
|
|
||||||
TrainingTimeEditorCard(
|
|
||||||
zeit = zeit,
|
|
||||||
onChange = { updated -> trainingTimes[index] = updated },
|
|
||||||
onRemove = { trainingTimes.removeAt(index) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
trainingTimes.add(
|
|
||||||
de.harheimertc.data.TrainingTimeDto(
|
|
||||||
id = "training-${System.currentTimeMillis()}",
|
|
||||||
tag = "Montag",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
) {
|
|
||||||
Text("Trainingszeit hinzufügen")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
item {
|
|
||||||
DataCard("Trainer") {
|
|
||||||
trainers.forEachIndexed { index, trainer ->
|
|
||||||
TrainerEditorCard(
|
|
||||||
trainer = trainer,
|
|
||||||
onChange = { updated -> trainers[index] = updated },
|
|
||||||
onRemove = { trainers.removeAt(index) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
trainers.add(
|
|
||||||
de.harheimertc.data.TrainerDto(
|
|
||||||
id = "trainer-${System.currentTimeMillis()}",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
) {
|
|
||||||
Text("Trainer hinzufügen")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
item { FormMessages(state.error, state.message) }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
DatePickerDialog(
|
||||||
|
context,
|
||||||
|
{ _, year, month, day ->
|
||||||
|
terminDatum = "%04d-%02d-%02d".format(Locale.ROOT, year, month + 1, day)
|
||||||
|
},
|
||||||
|
calendar.get(Calendar.YEAR),
|
||||||
|
calendar.get(Calendar.MONTH),
|
||||||
|
calendar.get(Calendar.DAY_OF_MONTH),
|
||||||
|
).show()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
CmsPage(navController, showBackNavigation, "Sportbetrieb", "Termine, Mannschaften und Spielpläne") {
|
||||||
|
if (state.sportLoading) item { LoadingState("Sportbetriebsdaten werden geladen...") }
|
||||||
|
item {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
tabs.forEach { (id, label) ->
|
||||||
|
if (activeTab == id) {
|
||||||
|
Button(onClick = { activeTab = id }, modifier = Modifier.weight(1f)) { Text(label) }
|
||||||
|
} else {
|
||||||
|
OutlinedButton(onClick = { activeTab = id }, modifier = Modifier.weight(1f)) { Text(label) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!state.sportLoading) {
|
||||||
|
when (activeTab) {
|
||||||
|
"termine" -> {
|
||||||
|
item {
|
||||||
|
Button(
|
||||||
|
onClick = { openTerminDialog(null) },
|
||||||
|
enabled = !state.sportSaving,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Text("Termin hinzufügen")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (state.sportTermine.isEmpty()) {
|
||||||
|
item { EmptyCard("Keine Termine gefunden.") }
|
||||||
|
}
|
||||||
|
items(state.sportTermine.size) { index ->
|
||||||
|
val termin = state.sportTermine[index]
|
||||||
|
DataCard(termin.titel.ifBlank { "Termin" }) {
|
||||||
|
InfoRow("Datum", listOf(termin.datum, termin.uhrzeit.orEmpty()).filter(String::isNotBlank).joinToString(" "))
|
||||||
|
InfoRow("Kategorie", termin.kategorie ?: "Sonstiges")
|
||||||
|
if (!termin.beschreibung.isNullOrBlank()) {
|
||||||
|
Text(termin.beschreibung, color = Accent700)
|
||||||
|
}
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
|
||||||
|
OutlinedButton(onClick = { openTerminDialog(termin) }, modifier = Modifier.weight(1f)) {
|
||||||
|
Text("Bearbeiten")
|
||||||
|
}
|
||||||
|
TextButton(
|
||||||
|
onClick = { viewModel.deleteSportTermin(termin) },
|
||||||
|
enabled = !state.sportSaving,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
) {
|
||||||
|
Text("Löschen")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"mannschaften" -> {
|
||||||
|
item {
|
||||||
|
if (state.sportMannschaftenSeasons.isNotEmpty()) {
|
||||||
|
DataCard("Saison") {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
state.sportMannschaftenSeasons.forEach { season ->
|
||||||
|
if (season == state.sportMannschaftenSeason) {
|
||||||
|
Button(onClick = { }, modifier = Modifier.weight(1f)) { Text(season) }
|
||||||
|
} else {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { viewModel.loadSportMannschaftenSeason(season) },
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
) {
|
||||||
|
Text(season)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
mannschaften.add(CmsMannschaftRow(letzteAktualisierung = OffsetDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE)))
|
||||||
|
},
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
) {
|
||||||
|
Text("Hinzufügen")
|
||||||
|
}
|
||||||
|
Button(
|
||||||
|
onClick = { viewModel.saveSportMannschaften(state.sportMannschaftenSeason, mannschaften.toList()) },
|
||||||
|
enabled = !state.sportSaving,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
) {
|
||||||
|
Text(if (state.sportSaving) "Speichert..." else "Speichern")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (mannschaften.isEmpty()) {
|
||||||
|
item { EmptyCard("Keine Mannschaften gefunden.") }
|
||||||
|
}
|
||||||
|
items(mannschaften.size) { index ->
|
||||||
|
MannschaftEditorCard(
|
||||||
|
row = mannschaften[index],
|
||||||
|
onChange = { updated -> mannschaften[index] = updated },
|
||||||
|
onRemove = { mannschaften.removeAt(index) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"spielplaene" -> {
|
||||||
|
item {
|
||||||
|
DataCard("Vereins-Spielplan (CSV)") {
|
||||||
|
val seasonLabel = state.sportSpielplanSeason.ifBlank { "aktuelle Saison" }
|
||||||
|
val fileName = state.sportSpielplanSeason.takeIf { it.isNotBlank() }?.let { "spielplan-$it.json" } ?: "spielplan.csv"
|
||||||
|
InfoRow("Datei", fileName)
|
||||||
|
InfoRow("Saison", seasonLabel)
|
||||||
|
InfoRow("Einträge", state.sportSpielplanRows.size.toString())
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { viewModel.loadSportbetrieb() },
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
) {
|
||||||
|
Text("Neu laden")
|
||||||
|
}
|
||||||
|
Button(
|
||||||
|
onClick = { spielplanEditorOpen = true },
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
) {
|
||||||
|
Text("CSV bearbeiten")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
item { FormMessages(state.error, state.message) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (terminDialogOpen) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { terminDialogOpen = false },
|
||||||
|
title = { Text(if (editingTermin == null) "Termin hinzufügen" else "Termin bearbeiten") },
|
||||||
|
text = {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
OutlinedButton(onClick = { openDatePicker() }, modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text(terminDatum.ifBlank { "Datum auswählen" })
|
||||||
|
}
|
||||||
|
OutlinedTextField(value = terminUhrzeit, onValueChange = { terminUhrzeit = it }, label = { Text("Uhrzeit") }, modifier = Modifier.fillMaxWidth())
|
||||||
|
OutlinedTextField(value = terminTitel, onValueChange = { terminTitel = it }, label = { Text("Titel") }, modifier = Modifier.fillMaxWidth())
|
||||||
|
OutlinedTextField(value = terminBeschreibung, onValueChange = { terminBeschreibung = it }, label = { Text("Beschreibung") }, modifier = Modifier.fillMaxWidth(), minLines = 3)
|
||||||
|
Box(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
OutlinedButton(onClick = { terminKategorieOpen = true }, modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text(terminKategorie.ifBlank { "Kategorie auswählen" })
|
||||||
|
}
|
||||||
|
DropdownMenu(expanded = terminKategorieOpen, onDismissRequest = { terminKategorieOpen = false }) {
|
||||||
|
terminKategorien.forEach { kategorie ->
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(kategorie) },
|
||||||
|
onClick = {
|
||||||
|
terminKategorie = kategorie
|
||||||
|
terminKategorieOpen = false
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
viewModel.saveSportTermin(
|
||||||
|
editingTermin,
|
||||||
|
TerminDto(
|
||||||
|
datum = terminDatum,
|
||||||
|
uhrzeit = terminUhrzeit.takeIf { it.isNotBlank() },
|
||||||
|
titel = terminTitel,
|
||||||
|
beschreibung = terminBeschreibung.takeIf { it.isNotBlank() },
|
||||||
|
kategorie = terminKategorie.ifBlank { "Sonstiges" },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
terminDialogOpen = false
|
||||||
|
},
|
||||||
|
enabled = !state.sportSaving && terminDatum.isNotBlank() && terminTitel.isNotBlank(),
|
||||||
|
) {
|
||||||
|
Text(if (state.sportSaving) "Speichert..." else "Speichern")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { terminDialogOpen = false }) { Text("Abbrechen") }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (spielplanEditorOpen) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { spielplanEditorOpen = false },
|
||||||
|
title = { Text("Spielplan CSV bearbeiten") },
|
||||||
|
text = {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = spielplanCsv,
|
||||||
|
onValueChange = { spielplanCsv = it },
|
||||||
|
label = { Text("CSV mit Semikolon") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
minLines = 12,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
val (headers, rows) = parseSportCsvText(spielplanCsv)
|
||||||
|
viewModel.saveSportSpielplan(headers, rows)
|
||||||
|
spielplanEditorOpen = false
|
||||||
|
},
|
||||||
|
enabled = !state.sportSaving && spielplanCsv.isNotBlank(),
|
||||||
|
) {
|
||||||
|
Text(if (state.sportSaving) "Speichert..." else "Speichern")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { spielplanEditorOpen = false }) { Text("Abbrechen") }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MannschaftEditorCard(
|
||||||
|
row: CmsMannschaftRow,
|
||||||
|
onChange: (CmsMannschaftRow) -> Unit,
|
||||||
|
onRemove: () -> Unit,
|
||||||
|
) {
|
||||||
|
DataCard(row.mannschaft.ifBlank { "Mannschaft" }) {
|
||||||
|
OutlinedTextField(value = row.mannschaft, onValueChange = { onChange(row.copy(mannschaft = it)) }, label = { Text("Mannschaft") }, modifier = Modifier.fillMaxWidth())
|
||||||
|
OutlinedTextField(value = row.liga, onValueChange = { onChange(row.copy(liga = it)) }, label = { Text("Liga") }, modifier = Modifier.fillMaxWidth())
|
||||||
|
OutlinedTextField(value = row.staffelleiter, onValueChange = { onChange(row.copy(staffelleiter = it)) }, label = { Text("Staffelleiter") }, modifier = Modifier.fillMaxWidth())
|
||||||
|
OutlinedTextField(value = row.telefon, onValueChange = { onChange(row.copy(telefon = it)) }, label = { Text("Telefon") }, modifier = Modifier.fillMaxWidth())
|
||||||
|
OutlinedTextField(value = row.heimspieltag, onValueChange = { onChange(row.copy(heimspieltag = it)) }, label = { Text("Heimspieltag") }, modifier = Modifier.fillMaxWidth())
|
||||||
|
OutlinedTextField(value = row.spielsystem, onValueChange = { onChange(row.copy(spielsystem = it)) }, label = { Text("Spielsystem") }, modifier = Modifier.fillMaxWidth())
|
||||||
|
OutlinedTextField(value = row.mannschaftsfuehrer, onValueChange = { onChange(row.copy(mannschaftsfuehrer = it)) }, label = { Text("Mannschaftsführer") }, modifier = Modifier.fillMaxWidth())
|
||||||
|
OutlinedTextField(value = row.spieler, onValueChange = { onChange(row.copy(spieler = it)) }, label = { Text("Spieler") }, modifier = Modifier.fillMaxWidth(), minLines = 2)
|
||||||
|
OutlinedTextField(value = row.informationenLink, onValueChange = { onChange(row.copy(informationenLink = it)) }, label = { Text("Weitere Informationen Link") }, modifier = Modifier.fillMaxWidth())
|
||||||
|
OutlinedTextField(value = row.letzteAktualisierung, onValueChange = { onChange(row.copy(letzteAktualisierung = it)) }, label = { Text("Letzte Aktualisierung") }, modifier = Modifier.fillMaxWidth())
|
||||||
|
TextButton(onClick = onRemove, modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text("Entfernen")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sportSpielplanCsvText(headers: List<String>, rows: List<List<String>>): String {
|
||||||
|
if (headers.isEmpty()) return ""
|
||||||
|
return listOf(headers).plus(rows).joinToString("\n") { row -> row.joinToString(";") { it.csvCell(";") } }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseSportCsvText(text: String): Pair<List<String>, List<List<String>>> {
|
||||||
|
val lines = text.lineSequence().filter { it.isNotBlank() }.toList()
|
||||||
|
if (lines.isEmpty()) return emptyList<String>() to emptyList()
|
||||||
|
val headers = parseDelimitedLine(lines.first(), ';')
|
||||||
|
val rows = lines.drop(1).map { parseDelimitedLine(it, ';') }
|
||||||
|
return headers to rows
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseDelimitedLine(line: String, delimiter: Char): List<String> {
|
||||||
|
val values = mutableListOf<String>()
|
||||||
|
val value = StringBuilder()
|
||||||
|
var quoted = false
|
||||||
|
var index = 0
|
||||||
|
while (index < line.length) {
|
||||||
|
when (val char = line[index]) {
|
||||||
|
'"' -> {
|
||||||
|
if (quoted && index + 1 < line.length && line[index + 1] == '"') {
|
||||||
|
value.append('"')
|
||||||
|
index++
|
||||||
|
} else {
|
||||||
|
quoted = !quoted
|
||||||
|
}
|
||||||
|
}
|
||||||
|
delimiter -> if (quoted) value.append(char) else {
|
||||||
|
values += value.toString()
|
||||||
|
value.clear()
|
||||||
|
}
|
||||||
|
else -> value.append(char)
|
||||||
|
}
|
||||||
|
index++
|
||||||
|
}
|
||||||
|
values += value.toString()
|
||||||
|
return values
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.csvCell(delimiter: String): String {
|
||||||
|
val needsQuotes = contains(delimiter) || contains('"') || contains('\n') || contains('\r')
|
||||||
|
val escaped = replace("\"", "\"\"")
|
||||||
|
return if (needsQuotes) "\"$escaped\"" else escaped
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -623,7 +876,7 @@ fun CmsNewsletterScreen(
|
|||||||
onEdit = { nl ->
|
onEdit = { nl ->
|
||||||
editingNewsletter = nl
|
editingNewsletter = nl
|
||||||
nlTitle = nl.title
|
nlTitle = nl.title
|
||||||
nlContent = nl.title ?: ""
|
nlContent = nl.title
|
||||||
nlType = "subscription"
|
nlType = "subscription"
|
||||||
nlTargetGroup = ""
|
nlTargetGroup = ""
|
||||||
nlSendToExternal = true
|
nlSendToExternal = true
|
||||||
@@ -753,6 +1006,12 @@ fun CmsEinstellungenScreen(navController: NavController, showBackNavigation: Boo
|
|||||||
var websiteVorname by remember { mutableStateOf("") }
|
var websiteVorname by remember { mutableStateOf("") }
|
||||||
var websiteNachname by remember { mutableStateOf("") }
|
var websiteNachname by remember { mutableStateOf("") }
|
||||||
var websiteEmail by remember { mutableStateOf("") }
|
var websiteEmail by remember { mutableStateOf("") }
|
||||||
|
var ortName by remember { mutableStateOf("") }
|
||||||
|
var ortStrasse by remember { mutableStateOf("") }
|
||||||
|
var ortPlz by remember { mutableStateOf("") }
|
||||||
|
var ortOrt by remember { mutableStateOf("") }
|
||||||
|
val trainingTimes = remember { mutableStateListOf<de.harheimertc.data.TrainingTimeDto>() }
|
||||||
|
val trainers = remember { mutableStateListOf<de.harheimertc.data.TrainerDto>() }
|
||||||
|
|
||||||
LaunchedEffect(config) {
|
LaunchedEffect(config) {
|
||||||
config?.let {
|
config?.let {
|
||||||
@@ -764,6 +1023,14 @@ fun CmsEinstellungenScreen(navController: NavController, showBackNavigation: Boo
|
|||||||
websiteVorname = it.website.verantwortlicher.vorname
|
websiteVorname = it.website.verantwortlicher.vorname
|
||||||
websiteNachname = it.website.verantwortlicher.nachname
|
websiteNachname = it.website.verantwortlicher.nachname
|
||||||
websiteEmail = it.website.verantwortlicher.email
|
websiteEmail = it.website.verantwortlicher.email
|
||||||
|
ortName = it.training.ort.name
|
||||||
|
ortStrasse = it.training.ort.strasse
|
||||||
|
ortPlz = it.training.ort.plz
|
||||||
|
ortOrt = it.training.ort.ort
|
||||||
|
trainingTimes.clear()
|
||||||
|
trainingTimes.addAll(it.training.zeiten)
|
||||||
|
trainers.clear()
|
||||||
|
trainers.addAll(it.trainer)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -790,6 +1057,16 @@ fun CmsEinstellungenScreen(navController: NavController, showBackNavigation: Boo
|
|||||||
email = websiteEmail,
|
email = websiteEmail,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
training = config.training.copy(
|
||||||
|
ort = config.training.ort.copy(
|
||||||
|
name = ortName,
|
||||||
|
strasse = ortStrasse,
|
||||||
|
plz = ortPlz,
|
||||||
|
ort = ortOrt,
|
||||||
|
),
|
||||||
|
zeiten = trainingTimes.toList(),
|
||||||
|
),
|
||||||
|
trainer = trainers.toList(),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -822,6 +1099,63 @@ fun CmsEinstellungenScreen(navController: NavController, showBackNavigation: Boo
|
|||||||
OutlinedTextField(value = websiteEmail, onValueChange = { websiteEmail = it }, label = { Text("E-Mail") }, modifier = Modifier.fillMaxWidth())
|
OutlinedTextField(value = websiteEmail, onValueChange = { websiteEmail = it }, label = { Text("E-Mail") }, modifier = Modifier.fillMaxWidth())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
item {
|
||||||
|
DataCard("Trainingsort") {
|
||||||
|
OutlinedTextField(value = ortName, onValueChange = { ortName = it }, label = { Text("Name") }, modifier = Modifier.fillMaxWidth())
|
||||||
|
OutlinedTextField(value = ortStrasse, onValueChange = { ortStrasse = it }, label = { Text("Straße") }, modifier = Modifier.fillMaxWidth())
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) {
|
||||||
|
OutlinedTextField(value = ortPlz, onValueChange = { ortPlz = it }, label = { Text("PLZ") }, modifier = Modifier.weight(1f))
|
||||||
|
OutlinedTextField(value = ortOrt, onValueChange = { ortOrt = it }, label = { Text("Ort") }, modifier = Modifier.weight(1f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
DataCard("Trainingszeiten") {
|
||||||
|
trainingTimes.forEachIndexed { index, zeit ->
|
||||||
|
TrainingTimeEditorCard(
|
||||||
|
zeit = zeit,
|
||||||
|
onChange = { updated -> trainingTimes[index] = updated },
|
||||||
|
onRemove = { trainingTimes.removeAt(index) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
trainingTimes.add(
|
||||||
|
de.harheimertc.data.TrainingTimeDto(
|
||||||
|
id = "training-${System.currentTimeMillis()}",
|
||||||
|
tag = "Montag",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Text("Trainingszeit hinzufügen")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
DataCard("Trainer") {
|
||||||
|
trainers.forEachIndexed { index, trainer ->
|
||||||
|
TrainerEditorCard(
|
||||||
|
trainer = trainer,
|
||||||
|
onChange = { updated -> trainers[index] = updated },
|
||||||
|
onRemove = { trainers.removeAt(index) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
trainers.add(
|
||||||
|
de.harheimertc.data.TrainerDto(
|
||||||
|
id = "trainer-${System.currentTimeMillis()}",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Text("Trainer hinzufügen")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
item {
|
item {
|
||||||
DataCard("Systemstatus") {
|
DataCard("Systemstatus") {
|
||||||
InfoRow("Mitgliedschaftstarife", config.mitgliedschaft.size.toString())
|
InfoRow("Mitgliedschaftstarife", config.mitgliedschaft.size.toString())
|
||||||
@@ -950,6 +1284,7 @@ private fun CmsSummaryGrid(navController: NavController, state: CmsUiState) {
|
|||||||
val cards = listOf(
|
val cards = listOf(
|
||||||
Triple("Startseite", "Öffentliche Startseite", Destinations.CmsStartseite.route),
|
Triple("Startseite", "Öffentliche Startseite", Destinations.CmsStartseite.route),
|
||||||
Triple("Inhalte", "Vereinsseiten", Destinations.CmsInhalte.route),
|
Triple("Inhalte", "Vereinsseiten", Destinations.CmsInhalte.route),
|
||||||
|
Triple("News", "Interne und öffentliche News", Destinations.CmsNews.route),
|
||||||
Triple("Sportbetrieb", "Training und Sportdaten", Destinations.CmsSportbetrieb.route),
|
Triple("Sportbetrieb", "Training und Sportdaten", Destinations.CmsSportbetrieb.route),
|
||||||
Triple("Mitgliederverwaltung", "${state.users.size} Benutzer", Destinations.CmsMitgliederverwaltung.route),
|
Triple("Mitgliederverwaltung", "${state.users.size} Benutzer", Destinations.CmsMitgliederverwaltung.route),
|
||||||
Triple("Kontaktanfragen", "${state.contactRequests.size} Anfragen", Destinations.CmsContactRequests.route),
|
Triple("Kontaktanfragen", "${state.contactRequests.size} Anfragen", Destinations.CmsContactRequests.route),
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ import de.harheimertc.data.PasswordResetMatchingUserDto
|
|||||||
import de.harheimertc.data.PasswordResetAttemptDto
|
import de.harheimertc.data.PasswordResetAttemptDto
|
||||||
import de.harheimertc.data.NewsDto
|
import de.harheimertc.data.NewsDto
|
||||||
import de.harheimertc.data.NewsSaveRequest
|
import de.harheimertc.data.NewsSaveRequest
|
||||||
|
import de.harheimertc.data.SeasonDto
|
||||||
|
import de.harheimertc.data.SpielDto
|
||||||
|
import de.harheimertc.data.TerminDto
|
||||||
|
import de.harheimertc.repositories.CmsMannschaftRow
|
||||||
import de.harheimertc.repositories.CmsRepository
|
import de.harheimertc.repositories.CmsRepository
|
||||||
import de.harheimertc.repositories.MeisterschaftResult
|
import de.harheimertc.repositories.MeisterschaftResult
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
@@ -39,6 +43,16 @@ data class CmsUiState(
|
|||||||
val passwordResetFailedOnly: Boolean = true,
|
val passwordResetFailedOnly: Boolean = true,
|
||||||
val news: List<NewsDto> = emptyList(),
|
val news: List<NewsDto> = emptyList(),
|
||||||
val meisterschaften: List<MeisterschaftResult> = emptyList(),
|
val meisterschaften: List<MeisterschaftResult> = emptyList(),
|
||||||
|
val sportLoading: Boolean = false,
|
||||||
|
val sportSaving: Boolean = false,
|
||||||
|
val sportTermine: List<TerminDto> = emptyList(),
|
||||||
|
val sportMannschaften: List<CmsMannschaftRow> = emptyList(),
|
||||||
|
val sportMannschaftenSeasons: List<String> = emptyList(),
|
||||||
|
val sportMannschaftenSeason: String = "",
|
||||||
|
val sportSpielplanHeaders: List<String> = emptyList(),
|
||||||
|
val sportSpielplanRows: List<List<String>> = emptyList(),
|
||||||
|
val sportSpielplanSeason: String = "",
|
||||||
|
val sportSpielplanSeasons: List<SeasonDto> = emptyList(),
|
||||||
)
|
)
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
@@ -179,6 +193,156 @@ class CmsViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun loadSportbetrieb() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.value = _state.value.copy(sportLoading = true, error = null, message = null)
|
||||||
|
|
||||||
|
val termineRes = async { repository.managedTermine() }
|
||||||
|
val seasonsRes = async { repository.mannschaftenSeasons() }
|
||||||
|
val spielplanRes = async { repository.spielplan() }
|
||||||
|
|
||||||
|
val termineResult = termineRes.await()
|
||||||
|
val seasonsResult = seasonsRes.await()
|
||||||
|
val seasonInfo = seasonsResult.getOrNull()
|
||||||
|
val selectedSeason = _state.value.sportMannschaftenSeason.takeIf { it.isNotBlank() }
|
||||||
|
?: seasonInfo?.defaultSeason?.takeIf { it.isNotBlank() }
|
||||||
|
?: seasonInfo?.currentSeason?.takeIf { it.isNotBlank() }
|
||||||
|
?: seasonInfo?.seasons?.firstOrNull().orEmpty()
|
||||||
|
val mannschaftenResult = repository.mannschaften(selectedSeason.takeIf { it.isNotBlank() })
|
||||||
|
val spielplanResult = spielplanRes.await()
|
||||||
|
val errors = listOfNotNull(
|
||||||
|
ErrorMapper.mapError(termineResult.exceptionOrNull()),
|
||||||
|
ErrorMapper.mapError(seasonsResult.exceptionOrNull()),
|
||||||
|
ErrorMapper.mapError(mannschaftenResult.exceptionOrNull()),
|
||||||
|
ErrorMapper.mapError(spielplanResult.exceptionOrNull()),
|
||||||
|
)
|
||||||
|
val spielplan = spielplanResult.getOrNull()
|
||||||
|
val headers = spielplan?.headers.orEmpty()
|
||||||
|
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
sportLoading = false,
|
||||||
|
sportTermine = termineResult.getOrNull().orEmpty(),
|
||||||
|
sportMannschaften = mannschaftenResult.getOrNull().orEmpty(),
|
||||||
|
sportMannschaftenSeasons = seasonInfo?.seasons.orEmpty(),
|
||||||
|
sportMannschaftenSeason = selectedSeason,
|
||||||
|
sportSpielplanHeaders = headers,
|
||||||
|
sportSpielplanRows = spielplan?.data.orEmpty().map { row -> headers.map { header -> row.valueForHeader(header) } },
|
||||||
|
sportSpielplanSeason = spielplan?.season.orEmpty(),
|
||||||
|
sportSpielplanSeasons = spielplan?.seasons.orEmpty(),
|
||||||
|
error = errors.takeIf { it.isNotEmpty() }?.joinToString("; "),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadSportMannschaftenSeason(season: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.value = _state.value.copy(sportLoading = true, error = null, message = null, sportMannschaftenSeason = season)
|
||||||
|
repository.mannschaften(season)
|
||||||
|
.onSuccess { rows ->
|
||||||
|
_state.value = _state.value.copy(sportLoading = false, sportMannschaften = rows)
|
||||||
|
}
|
||||||
|
.onFailure { err ->
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
sportLoading = false,
|
||||||
|
error = ErrorMapper.mapError(err) ?: "Mannschaften konnten nicht geladen werden.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveSportTermin(original: TerminDto?, termin: TerminDto) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.value = _state.value.copy(sportSaving = true, error = null, message = null)
|
||||||
|
if (original != null) {
|
||||||
|
repository.deleteTermin(original)
|
||||||
|
.onFailure { err ->
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
sportSaving = false,
|
||||||
|
error = ErrorMapper.mapError(err) ?: "Alter Termin konnte nicht ersetzt werden.",
|
||||||
|
)
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val saveResult = repository.saveTermin(termin)
|
||||||
|
saveResult
|
||||||
|
.onSuccess { response ->
|
||||||
|
val termine = repository.managedTermine().getOrDefault(_state.value.sportTermine)
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
sportSaving = false,
|
||||||
|
sportTermine = termine,
|
||||||
|
message = response.message ?: "Termin gespeichert.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.onFailure { err ->
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
sportSaving = false,
|
||||||
|
error = ErrorMapper.mapError(err) ?: "Termin konnte nicht gespeichert werden.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteSportTermin(termin: TerminDto) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.value = _state.value.copy(sportSaving = true, error = null, message = null)
|
||||||
|
repository.deleteTermin(termin)
|
||||||
|
.onSuccess { response ->
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
sportSaving = false,
|
||||||
|
sportTermine = _state.value.sportTermine.filterNot { it == termin },
|
||||||
|
message = response.message ?: "Termin gelöscht.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.onFailure { err ->
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
sportSaving = false,
|
||||||
|
error = ErrorMapper.mapError(err) ?: "Termin konnte nicht gelöscht werden.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveSportMannschaften(season: String, rows: List<CmsMannschaftRow>) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.value = _state.value.copy(sportSaving = true, error = null, message = null)
|
||||||
|
repository.saveMannschaften(season.takeIf { it.isNotBlank() }, rows)
|
||||||
|
.onSuccess { response ->
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
sportSaving = false,
|
||||||
|
sportMannschaften = rows,
|
||||||
|
message = response.message ?: "Mannschaften gespeichert.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.onFailure { err ->
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
sportSaving = false,
|
||||||
|
error = ErrorMapper.mapError(err) ?: "Mannschaften konnten nicht gespeichert werden.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveSportSpielplan(headers: List<String>, rows: List<List<String>>) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.value = _state.value.copy(sportSaving = true, error = null, message = null)
|
||||||
|
repository.saveSpielplan(headers, rows)
|
||||||
|
.onSuccess { response ->
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
sportSaving = false,
|
||||||
|
sportSpielplanHeaders = headers,
|
||||||
|
sportSpielplanRows = rows,
|
||||||
|
message = response.message ?: "Spielplan gespeichert.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.onFailure { err ->
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
sportSaving = false,
|
||||||
|
error = ErrorMapper.mapError(err) ?: "Spielplan konnte nicht gespeichert werden.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun saveConfig(config: ConfigResponse) {
|
fun saveConfig(config: ConfigResponse) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_state.value = _state.value.copy(saving = true, error = null, message = null)
|
_state.value = _state.value.copy(saving = true, error = null, message = null)
|
||||||
@@ -462,3 +626,18 @@ class CmsViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun SpielDto.valueForHeader(header: String): String = when (header) {
|
||||||
|
"Termin" -> termin
|
||||||
|
"HeimMannschaft" -> heimMannschaft
|
||||||
|
"GastMannschaft" -> gastMannschaft
|
||||||
|
"HeimMannschaftAltersklasse" -> heimAltersklasse
|
||||||
|
"GastMannschaftAltersklasse" -> gastAltersklasse
|
||||||
|
"Altersklasse" -> altersklasse
|
||||||
|
"Liga" -> liga
|
||||||
|
"Staffel" -> staffel
|
||||||
|
"Runde" -> runde.orEmpty()
|
||||||
|
"SpieleHeim" -> spieleHeim
|
||||||
|
"SpieleGast" -> spieleGast
|
||||||
|
else -> ""
|
||||||
|
}
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ class HomeViewModel @Inject constructor(
|
|||||||
loading = false,
|
loading = false,
|
||||||
heroImageUrl = data.heroImageUrl,
|
heroImageUrl = data.heroImageUrl,
|
||||||
termine = data.termine
|
termine = data.termine
|
||||||
.filter { it.asDateTime()?.isBefore(LocalDateTime.now()) != true }
|
.filter { it.asDateTime()?.toLocalDate()?.isBefore(LocalDate.now()) != true }
|
||||||
.sortedBy { it.asDateTime() }
|
.sortedBy { it.asDateTime() }
|
||||||
.take(3),
|
.take(3),
|
||||||
spiele = data.spiele
|
spiele = data.spiele
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ data class RegisterFormState(
|
|||||||
val birthDate: String = "",
|
val birthDate: String = "",
|
||||||
val password: String = "",
|
val password: String = "",
|
||||||
val passwordRepeat: String = "",
|
val passwordRepeat: String = "",
|
||||||
val showBirthday: Boolean = true,
|
val showBirthday: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class RegisterUiState(
|
data class RegisterUiState(
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ fun NotificationSettingsScreen(
|
|||||||
ToggleRow("Punktspiele der eigenen Mannschaft", state.settings.ownTeamMatches) {
|
ToggleRow("Punktspiele der eigenen Mannschaft", state.settings.ownTeamMatches) {
|
||||||
viewModel.update(state.settings.copy(ownTeamMatches = it))
|
viewModel.update(state.settings.copy(ownTeamMatches = it))
|
||||||
}
|
}
|
||||||
Text("Die eigene Mannschaft wird aus dem Namen und der Mannschaftszusammensetzung ermittelt.", color = Accent700)
|
OwnTeamInfo(state.ownTeams, state.currentUserName)
|
||||||
ToggleRow("Punktspiele aller Mannschaften", state.settings.allTeamMatches) {
|
ToggleRow("Punktspiele aller Mannschaften", state.settings.allTeamMatches) {
|
||||||
viewModel.update(state.settings.copy(allTeamMatches = it))
|
viewModel.update(state.settings.copy(allTeamMatches = it))
|
||||||
}
|
}
|
||||||
@@ -214,6 +214,16 @@ private fun ToggleRow(label: String, checked: Boolean, onCheckedChange: (Boolean
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun OwnTeamInfo(ownTeams: List<Mannschaft>, currentUserName: String) {
|
||||||
|
val text = when {
|
||||||
|
ownTeams.isNotEmpty() -> "Erkannte eigene Mannschaft: " + ownTeams.joinToString { it.mannschaft }
|
||||||
|
currentUserName.isBlank() -> "Eigene Mannschaft kann erst nach geladenem Profil ermittelt werden."
|
||||||
|
else -> "Keine eigene Mannschaft für " + currentUserName + " erkannt."
|
||||||
|
}
|
||||||
|
Text(text, color = Accent700)
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun TimeSelection(selectedTime: String, onSelectTime: (String) -> Unit) {
|
private fun TimeSelection(selectedTime: String, onSelectTime: (String) -> Unit) {
|
||||||
Row(
|
Row(
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import de.harheimertc.repositories.Mannschaft
|
import de.harheimertc.repositories.Mannschaft
|
||||||
|
import de.harheimertc.repositories.LoginRepository
|
||||||
import de.harheimertc.repositories.MannschaftenRepository
|
import de.harheimertc.repositories.MannschaftenRepository
|
||||||
import de.harheimertc.repositories.NotificationPreferences
|
import de.harheimertc.repositories.NotificationPreferences
|
||||||
import de.harheimertc.repositories.NotificationPreferencesRepository
|
import de.harheimertc.repositories.NotificationPreferencesRepository
|
||||||
@@ -16,6 +17,8 @@ data class NotificationSettingsUiState(
|
|||||||
val loading: Boolean = true,
|
val loading: Boolean = true,
|
||||||
val settings: NotificationPreferences = NotificationPreferences(),
|
val settings: NotificationPreferences = NotificationPreferences(),
|
||||||
val teams: List<Mannschaft> = emptyList(),
|
val teams: List<Mannschaft> = emptyList(),
|
||||||
|
val ownTeams: List<Mannschaft> = emptyList(),
|
||||||
|
val currentUserName: String = "",
|
||||||
val seasons: List<String> = emptyList(),
|
val seasons: List<String> = emptyList(),
|
||||||
val error: String? = null,
|
val error: String? = null,
|
||||||
val saveError: String? = null,
|
val saveError: String? = null,
|
||||||
@@ -25,6 +28,7 @@ data class NotificationSettingsUiState(
|
|||||||
class NotificationSettingsViewModel @Inject constructor(
|
class NotificationSettingsViewModel @Inject constructor(
|
||||||
private val preferencesRepository: NotificationPreferencesRepository,
|
private val preferencesRepository: NotificationPreferencesRepository,
|
||||||
private val mannschaftenRepository: MannschaftenRepository,
|
private val mannschaftenRepository: MannschaftenRepository,
|
||||||
|
private val loginRepository: LoginRepository,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
private val _state = MutableStateFlow(NotificationSettingsUiState())
|
private val _state = MutableStateFlow(NotificationSettingsUiState())
|
||||||
val state: StateFlow<NotificationSettingsUiState> = _state
|
val state: StateFlow<NotificationSettingsUiState> = _state
|
||||||
@@ -37,12 +41,14 @@ class NotificationSettingsViewModel @Inject constructor(
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_state.value = _state.value.copy(loading = true, error = null, saveError = null)
|
_state.value = _state.value.copy(loading = true, error = null, saveError = null)
|
||||||
val storedSettings = preferencesRepository.loadRemote().getOrElse { preferencesRepository.loadLocal() }
|
val storedSettings = preferencesRepository.loadRemote().getOrElse { preferencesRepository.loadLocal() }
|
||||||
|
val authStatus = loginRepository.status().getOrNull()
|
||||||
|
val currentUserName = authStatus?.user?.name.orEmpty()
|
||||||
val seasonsResponse = mannschaftenRepository.fetchSeasons().getOrNull()
|
val seasonsResponse = mannschaftenRepository.fetchSeasons().getOrNull()
|
||||||
val seasons = seasonsResponse?.seasons.orEmpty()
|
val seasons = seasonsResponse?.seasons.orEmpty()
|
||||||
val selectedSeason = storedSettings.selectedTeamSeason
|
val selectedSeason = storedSettings.selectedTeamSeason
|
||||||
?: seasonsResponse?.defaultSeason?.takeIf { it.isNotBlank() }
|
?: seasonsResponse?.defaultSeason?.takeIf { it.isNotBlank() }
|
||||||
?: seasons.firstOrNull()
|
?: seasons.firstOrNull()
|
||||||
loadTeams(storedSettings.copy(selectedTeamSeason = selectedSeason), seasons)
|
loadTeams(storedSettings.copy(selectedTeamSeason = selectedSeason), seasons, currentUserName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,7 +56,7 @@ class NotificationSettingsViewModel @Inject constructor(
|
|||||||
val current = _state.value.settings
|
val current = _state.value.settings
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_state.value = _state.value.copy(loading = true, error = null, saveError = null)
|
_state.value = _state.value.copy(loading = true, error = null, saveError = null)
|
||||||
loadTeams(current.copy(selectedTeamSeason = season), _state.value.seasons, syncRemote = true)
|
loadTeams(current.copy(selectedTeamSeason = season), _state.value.seasons, _state.value.currentUserName, syncRemote = true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,7 +82,7 @@ class NotificationSettingsViewModel @Inject constructor(
|
|||||||
update(current.copy(selectedTeamSlugs = nextTeams))
|
update(current.copy(selectedTeamSlugs = nextTeams))
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun loadTeams(settings: NotificationPreferences, seasons: List<String>, syncRemote: Boolean = false) {
|
private suspend fun loadTeams(settings: NotificationPreferences, seasons: List<String>, currentUserName: String, syncRemote: Boolean = false) {
|
||||||
mannschaftenRepository.fetchMannschaften(settings.selectedTeamSeason)
|
mannschaftenRepository.fetchMannschaften(settings.selectedTeamSeason)
|
||||||
.onSuccess { teams ->
|
.onSuccess { teams ->
|
||||||
val knownSlugs = teams.map { it.slug }.toSet()
|
val knownSlugs = teams.map { it.slug }.toSet()
|
||||||
@@ -89,6 +95,8 @@ class NotificationSettingsViewModel @Inject constructor(
|
|||||||
loading = false,
|
loading = false,
|
||||||
settings = nextSettings,
|
settings = nextSettings,
|
||||||
teams = teams,
|
teams = teams,
|
||||||
|
ownTeams = ownTeamsForUser(currentUserName, teams),
|
||||||
|
currentUserName = currentUserName,
|
||||||
seasons = seasons,
|
seasons = seasons,
|
||||||
saveError = saveError,
|
saveError = saveError,
|
||||||
)
|
)
|
||||||
@@ -101,6 +109,7 @@ class NotificationSettingsViewModel @Inject constructor(
|
|||||||
_state.value = NotificationSettingsUiState(
|
_state.value = NotificationSettingsUiState(
|
||||||
loading = false,
|
loading = false,
|
||||||
settings = settings,
|
settings = settings,
|
||||||
|
currentUserName = currentUserName,
|
||||||
seasons = seasons,
|
seasons = seasons,
|
||||||
error = error.message ?: "Mannschaften konnten nicht geladen werden.",
|
error = error.message ?: "Mannschaften konnten nicht geladen werden.",
|
||||||
saveError = saveError,
|
saveError = saveError,
|
||||||
@@ -108,3 +117,27 @@ class NotificationSettingsViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun ownTeamsForUser(userName: String, teams: List<Mannschaft>): List<Mannschaft> {
|
||||||
|
if (normalizePersonName(userName).isBlank()) return emptyList()
|
||||||
|
return teams.filter { team ->
|
||||||
|
team.spieler.any { player -> personNameMatches(player, userName) } ||
|
||||||
|
personNameMatches(team.mannschaftsfuehrer, userName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun personNameMatches(candidate: String, userName: String): Boolean {
|
||||||
|
val normalizedCandidate = normalizePersonName(candidate)
|
||||||
|
val normalizedUserName = normalizePersonName(userName)
|
||||||
|
if (normalizedCandidate.isBlank() || normalizedUserName.isBlank()) return false
|
||||||
|
if (normalizedCandidate == normalizedUserName) return true
|
||||||
|
|
||||||
|
val candidateParts = normalizedCandidate.split(" " ).filter { it.isNotBlank() }.toSet()
|
||||||
|
val userParts = normalizedUserName.split(" " ).filter { it.isNotBlank() }
|
||||||
|
return userParts.size >= 2 && userParts.all { it in candidateParts }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun normalizePersonName(value: String): String = value
|
||||||
|
.lowercase()
|
||||||
|
.replace(Regex("[^a-z0-9äöüß]+"), " ")
|
||||||
|
.trim()
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ data class ProfileFormState(
|
|||||||
val showEmail: Boolean = true,
|
val showEmail: Boolean = true,
|
||||||
val showPhone: Boolean = true,
|
val showPhone: Boolean = true,
|
||||||
val showAddress: Boolean = false,
|
val showAddress: Boolean = false,
|
||||||
val showBirthday: Boolean = true,
|
val showBirthday: Boolean = false,
|
||||||
val currentPassword: String = "",
|
val currentPassword: String = "",
|
||||||
val newPassword: String = "",
|
val newPassword: String = "",
|
||||||
val confirmPassword: String = "",
|
val confirmPassword: String = "",
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ package de.harheimertc.ui.screens.cms
|
|||||||
|
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||||
import kotlinx.coroutines.test.resetMain
|
import kotlinx.coroutines.test.resetMain
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import kotlinx.coroutines.test.setMain
|
import kotlinx.coroutines.test.setMain
|
||||||
import io.mockk.coEvery
|
import io.mockk.coEvery
|
||||||
|
import io.mockk.every
|
||||||
import io.mockk.mockk
|
import io.mockk.mockk
|
||||||
import org.junit.After
|
import org.junit.After
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
@@ -27,6 +29,12 @@ class CmsViewModelTest {
|
|||||||
Dispatchers.resetMain()
|
Dispatchers.resetMain()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun viewModel(repo: de.harheimertc.repositories.CmsRepository): CmsViewModel {
|
||||||
|
val connectivity = mockk<de.harheimertc.data.ConnectivityMonitor>()
|
||||||
|
every { connectivity.online } returns MutableStateFlow(true)
|
||||||
|
return CmsViewModel(repo, connectivity)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun load_populatesState() = runTest {
|
fun load_populatesState() = runTest {
|
||||||
val repo = mockk<de.harheimertc.repositories.CmsRepository>()
|
val repo = mockk<de.harheimertc.repositories.CmsRepository>()
|
||||||
@@ -37,11 +45,11 @@ class CmsViewModelTest {
|
|||||||
coEvery { repo.contactRequests() } returns Result.success(emptyList())
|
coEvery { repo.contactRequests() } returns Result.success(emptyList())
|
||||||
coEvery { repo.newsletters() } returns Result.success(de.harheimertc.data.NewsletterListResponse())
|
coEvery { repo.newsletters() } returns Result.success(de.harheimertc.data.NewsletterListResponse())
|
||||||
coEvery { repo.newsletterGroups() } returns Result.success(de.harheimertc.data.NewsletterGroupsResponse())
|
coEvery { repo.newsletterGroups() } returns Result.success(de.harheimertc.data.NewsletterGroupsResponse())
|
||||||
coEvery { repo.news() } returns Result.success(de.harheimertc.data.NewsResponse(success = true, news = listOf(de.harheimertc.data.NewsDto(id = 5, title = "T", content = "C"))))
|
coEvery { repo.news() } returns Result.success(de.harheimertc.data.NewsResponse(success = true, news = listOf(de.harheimertc.data.NewsDto(id = "5", title = "T", content = "C"))))
|
||||||
coEvery { repo.passwordResetDiagnostics(any(), any()) } returns Result.success(de.harheimertc.data.PasswordResetDiagnosticsResponse())
|
coEvery { repo.passwordResetDiagnostics(any(), any()) } returns Result.success(de.harheimertc.data.PasswordResetDiagnosticsResponse())
|
||||||
coEvery { repo.vereinsmeisterschaften() } returns Result.success(emptyList())
|
coEvery { repo.vereinsmeisterschaften() } returns Result.success(emptyList())
|
||||||
|
|
||||||
val vm = CmsViewModel(repo)
|
val vm = viewModel(repo)
|
||||||
// advance init launched coroutine
|
// advance init launched coroutine
|
||||||
dispatcher.scheduler.advanceUntilIdle()
|
dispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
@@ -66,7 +74,7 @@ class CmsViewModelTest {
|
|||||||
coEvery { repo.vereinsmeisterschaften() } returns Result.success(emptyList())
|
coEvery { repo.vereinsmeisterschaften() } returns Result.success(emptyList())
|
||||||
coEvery { repo.saveConfig(any()) } returns Result.success(cfg)
|
coEvery { repo.saveConfig(any()) } returns Result.success(cfg)
|
||||||
coEvery { repo.saveNews(any()) } returns Result.success(de.harheimertc.data.AuthMessageResponse(success = true, message = "ok"))
|
coEvery { repo.saveNews(any()) } returns Result.success(de.harheimertc.data.AuthMessageResponse(success = true, message = "ok"))
|
||||||
val vm = CmsViewModel(repo)
|
val vm = viewModel(repo)
|
||||||
|
|
||||||
// wait for init/load to finish before saving to avoid race
|
// wait for init/load to finish before saving to avoid race
|
||||||
dispatcher.scheduler.advanceUntilIdle()
|
dispatcher.scheduler.advanceUntilIdle()
|
||||||
@@ -95,7 +103,7 @@ class CmsViewModelTest {
|
|||||||
coEvery { repo.vereinsmeisterschaften() } returns Result.success(emptyList())
|
coEvery { repo.vereinsmeisterschaften() } returns Result.success(emptyList())
|
||||||
coEvery { repo.saveNews(any()) } returns Result.success(de.harheimertc.data.AuthMessageResponse(success = true, message = "saved"))
|
coEvery { repo.saveNews(any()) } returns Result.success(de.harheimertc.data.AuthMessageResponse(success = true, message = "saved"))
|
||||||
|
|
||||||
val vm = CmsViewModel(repo)
|
val vm = viewModel(repo)
|
||||||
dispatcher.scheduler.advanceUntilIdle()
|
dispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
vm.saveNews(de.harheimertc.data.NewsSaveRequest(id = null, title = "t", content = "c"))
|
vm.saveNews(de.harheimertc.data.NewsSaveRequest(id = null, title = "t", content = "c"))
|
||||||
@@ -122,7 +130,7 @@ class CmsViewModelTest {
|
|||||||
coEvery { repo.updateUserRoles(any(), any()) } returns Result.success(de.harheimertc.data.AuthMessageResponse(success = true, message = "roles updated"))
|
coEvery { repo.updateUserRoles(any(), any()) } returns Result.success(de.harheimertc.data.AuthMessageResponse(success = true, message = "roles updated"))
|
||||||
coEvery { repo.users() } returns Result.success(de.harheimertc.data.CmsUsersResponse(listOf(de.harheimertc.data.CmsUserDto(id = "1", email = "u@e", name = "U", roles = listOf("admin", "vorstand")))))
|
coEvery { repo.users() } returns Result.success(de.harheimertc.data.CmsUsersResponse(listOf(de.harheimertc.data.CmsUserDto(id = "1", email = "u@e", name = "U", roles = listOf("admin", "vorstand")))))
|
||||||
|
|
||||||
val vm = CmsViewModel(repo)
|
val vm = viewModel(repo)
|
||||||
dispatcher.scheduler.advanceUntilIdle()
|
dispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
vm.updateUserRoles("1", listOf("admin", "vorstand"))
|
vm.updateUserRoles("1", listOf("admin", "vorstand"))
|
||||||
@@ -150,7 +158,7 @@ class CmsViewModelTest {
|
|||||||
coEvery { repo.updateUserActive(any(), any()) } returns Result.success(de.harheimertc.data.AuthMessageResponse(success = true, message = "user updated"))
|
coEvery { repo.updateUserActive(any(), any()) } returns Result.success(de.harheimertc.data.AuthMessageResponse(success = true, message = "user updated"))
|
||||||
coEvery { repo.users() } returns Result.success(de.harheimertc.data.CmsUsersResponse(listOf(de.harheimertc.data.CmsUserDto(id = "2", email = "v@e", name = "V", active = false))))
|
coEvery { repo.users() } returns Result.success(de.harheimertc.data.CmsUsersResponse(listOf(de.harheimertc.data.CmsUserDto(id = "2", email = "v@e", name = "V", active = false))))
|
||||||
|
|
||||||
val vm = CmsViewModel(repo)
|
val vm = viewModel(repo)
|
||||||
dispatcher.scheduler.advanceUntilIdle()
|
dispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
vm.setUserActive("2", false)
|
vm.setUserActive("2", false)
|
||||||
@@ -177,7 +185,7 @@ class CmsViewModelTest {
|
|||||||
|
|
||||||
coEvery { repo.resendInvite(any()) } returns Result.success(de.harheimertc.data.AuthMessageResponse(success = true, message = "invite sent"))
|
coEvery { repo.resendInvite(any()) } returns Result.success(de.harheimertc.data.AuthMessageResponse(success = true, message = "invite sent"))
|
||||||
|
|
||||||
val vm = CmsViewModel(repo)
|
val vm = viewModel(repo)
|
||||||
dispatcher.scheduler.advanceUntilIdle()
|
dispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
vm.resendInvite("10")
|
vm.resendInvite("10")
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -8,8 +8,8 @@ LOCAL_API_BASE_URL=https://harheimertc.tsschulz.de/
|
|||||||
PRODUCTION_API_BASE_URL=https://harheimertc.de/
|
PRODUCTION_API_BASE_URL=https://harheimertc.de/
|
||||||
|
|
||||||
# Android app versioning for Play Store uploads
|
# Android app versioning for Play Store uploads
|
||||||
ANDROID_VERSION_CODE=25
|
ANDROID_VERSION_CODE=26
|
||||||
ANDROID_VERSION_NAME=0.9.20
|
ANDROID_VERSION_NAME=0.9.21
|
||||||
|
|
||||||
# Temporary hotfix: disable R8 minification for release to avoid Retrofit generic signature stripping.
|
# Temporary hotfix: disable R8 minification for release to avoid Retrofit generic signature stripping.
|
||||||
RELEASE_MINIFY_ENABLED=false
|
RELEASE_MINIFY_ENABLED=false
|
||||||
|
|||||||
@@ -550,6 +550,25 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center">
|
||||||
|
<input
|
||||||
|
id="showBirthday"
|
||||||
|
v-model="formData.showBirthday"
|
||||||
|
type="checkbox"
|
||||||
|
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
|
||||||
|
:disabled="isSaving || !canDisableBirthdayVisibility"
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
for="showBirthday"
|
||||||
|
class="ml-2 block text-sm font-medium text-gray-700"
|
||||||
|
>
|
||||||
|
Geburtstag in Mitgliederliste und Benachrichtigungen anzeigen
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p class="-mt-3 text-xs text-gray-500">
|
||||||
|
Admins und Vorstand können die Sichtbarkeit nur ausschalten. Einschalten kann nur das Mitglied selbst im Profil.
|
||||||
|
</p>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="errorMessage"
|
v-if="errorMessage"
|
||||||
class="flex items-center p-3 rounded-md bg-red-50 text-red-700 text-sm"
|
class="flex items-center p-3 rounded-md bg-red-50 text-red-700 text-sm"
|
||||||
@@ -846,7 +865,8 @@ const formData = ref({
|
|||||||
address: '',
|
address: '',
|
||||||
notes: '',
|
notes: '',
|
||||||
isMannschaftsspieler: false,
|
isMannschaftsspieler: false,
|
||||||
hasHallKey: false
|
hasHallKey: false,
|
||||||
|
showBirthday: false
|
||||||
})
|
})
|
||||||
|
|
||||||
const canEdit = computed(() => {
|
const canEdit = computed(() => {
|
||||||
@@ -861,6 +881,10 @@ const isBirthdateRequired = computed(() => {
|
|||||||
return !editingMember.value || Boolean(editingMember.value?.geburtsdatum)
|
return !editingMember.value || Boolean(editingMember.value?.geburtsdatum)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const canDisableBirthdayVisibility = computed(() => {
|
||||||
|
return editingMember.value?.showBirthday === true
|
||||||
|
})
|
||||||
|
|
||||||
const filteredMembers = computed(() => {
|
const filteredMembers = computed(() => {
|
||||||
if (!filterHasHallKey.value) return members.value
|
if (!filterHasHallKey.value) return members.value
|
||||||
return members.value.filter(member => member.hasHallKey)
|
return members.value.filter(member => member.hasHallKey)
|
||||||
@@ -880,7 +904,7 @@ const loadMembers = async () => {
|
|||||||
|
|
||||||
const openAddModal = () => {
|
const openAddModal = () => {
|
||||||
editingMember.value = null
|
editingMember.value = null
|
||||||
formData.value = { firstName: '', lastName: '', geburtsdatum: '', email: '', phone: '', address: '', notes: '', isMannschaftsspieler: false, hasHallKey: false }
|
formData.value = { firstName: '', lastName: '', geburtsdatum: '', email: '', phone: '', address: '', notes: '', isMannschaftsspieler: false, hasHallKey: false, showBirthday: false }
|
||||||
showModal.value = true
|
showModal.value = true
|
||||||
errorMessage.value = ''
|
errorMessage.value = ''
|
||||||
}
|
}
|
||||||
@@ -896,7 +920,8 @@ const openEditModal = (member) => {
|
|||||||
address: member.address || '',
|
address: member.address || '',
|
||||||
notes: member.notes || '',
|
notes: member.notes || '',
|
||||||
isMannschaftsspieler: member.isMannschaftsspieler === true,
|
isMannschaftsspieler: member.isMannschaftsspieler === true,
|
||||||
hasHallKey: member.hasHallKey === true
|
hasHallKey: member.hasHallKey === true,
|
||||||
|
showBirthday: member.showBirthday === true
|
||||||
}
|
}
|
||||||
showModal.value = true
|
showModal.value = true
|
||||||
errorMessage.value = ''
|
errorMessage.value = ''
|
||||||
@@ -914,7 +939,14 @@ const saveMember = async () => {
|
|||||||
try {
|
try {
|
||||||
await $fetch('/api/members', {
|
await $fetch('/api/members', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: { id: editingMember.value?.id, ...formData.value }
|
body: {
|
||||||
|
id: editingMember.value?.id,
|
||||||
|
...formData.value,
|
||||||
|
visibility: {
|
||||||
|
...(editingMember.value?.visibility || {}),
|
||||||
|
showBirthday: formData.value.showBirthday === true
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
closeModal()
|
closeModal()
|
||||||
await loadMembers()
|
await loadMembers()
|
||||||
|
|||||||
@@ -58,7 +58,7 @@
|
|||||||
<p class="text-sm font-medium text-green-800">
|
<p class="text-sm font-medium text-green-800">
|
||||||
{{ currentFile.name }}
|
{{ currentFile.name }}
|
||||||
</p><p class="text-xs text-green-600">
|
</p><p class="text-xs text-green-600">
|
||||||
{{ currentFile.size }} bytes
|
{{ currentFileLabel }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -368,7 +368,7 @@ const processFile = async (file) => {
|
|||||||
const parseCSVLine = (line) => { const tabCount = (line.match(/\t/g) || []).length; const semicolonCount = (line.match(/;/g) || []).length; const delimiter = tabCount > semicolonCount ? '\t' : ';'; return line.split(delimiter).map(value => value.trim()) }
|
const parseCSVLine = (line) => { const tabCount = (line.match(/\t/g) || []).length; const semicolonCount = (line.match(/;/g) || []).length; const delimiter = tabCount > semicolonCount ? '\t' : ';'; return line.split(delimiter).map(value => value.trim()) }
|
||||||
csvHeaders.value = parseCSVLine(lines[0]); csvData.value = lines.slice(1).map(line => parseCSVLine(line))
|
csvHeaders.value = parseCSVLine(lines[0]); csvData.value = lines.slice(1).map(line => parseCSVLine(line))
|
||||||
selectedColumns.value = new Array(csvHeaders.value.length).fill(true); columnsSelected.value = false
|
selectedColumns.value = new Array(csvHeaders.value.length).fill(true); columnsSelected.value = false
|
||||||
currentFile.value = { name: file.name, size: file.size, lastModified: file.lastModified }
|
currentFile.value = { name: file.name, size: file.size, entries: csvData.value.length, lastModified: file.lastModified }
|
||||||
processingMessage.value = 'Verarbeitung abgeschlossen!'
|
processingMessage.value = 'Verarbeitung abgeschlossen!'
|
||||||
setTimeout(() => { isProcessing.value = false; showUploadModal.value = false }, 1000)
|
setTimeout(() => { isProcessing.value = false; showUploadModal.value = false }, 1000)
|
||||||
} catch (error) { console.error('Fehler:', error); alert('Fehler: ' + error.message); isProcessing.value = false }
|
} catch (error) { console.error('Fehler:', error); alert('Fehler: ' + error.message); isProcessing.value = false }
|
||||||
@@ -377,6 +377,11 @@ const processFile = async (file) => {
|
|||||||
const processSelectedFile = () => { if (selectedFile.value) processFile(selectedFile.value) }
|
const processSelectedFile = () => { if (selectedFile.value) processFile(selectedFile.value) }
|
||||||
const removeFile = () => { currentFile.value = null; csvData.value = []; csvHeaders.value = []; selectedColumns.value = []; columnsSelected.value = false; filteredCsvData.value = []; filteredCsvHeaders.value = []; if (fileInput.value) fileInput.value.value = '' }
|
const removeFile = () => { currentFile.value = null; csvData.value = []; csvHeaders.value = []; selectedColumns.value = []; columnsSelected.value = false; filteredCsvData.value = []; filteredCsvHeaders.value = []; if (fileInput.value) fileInput.value.value = '' }
|
||||||
const selectedColumnsCount = computed(() => selectedColumns.value.filter(s => s).length)
|
const selectedColumnsCount = computed(() => selectedColumns.value.filter(s => s).length)
|
||||||
|
const currentFileLabel = computed(() => {
|
||||||
|
if (!currentFile.value) return ''
|
||||||
|
if (typeof currentFile.value.entries === 'number') return `${currentFile.value.entries} Einträge`
|
||||||
|
return `${currentFile.value.size} bytes`
|
||||||
|
})
|
||||||
const getColumnPreview = (index) => { if (csvData.value.length === 0) return 'Keine Daten'; const sv = csvData.value.slice(0, 3).map(row => row[index]).filter(val => val && val.trim() !== ''); return sv.length > 0 ? `Beispiel: ${sv.join(', ')}` : 'Leere Spalte' }
|
const getColumnPreview = (index) => { if (csvData.value.length === 0) return 'Keine Daten'; const sv = csvData.value.slice(0, 3).map(row => row[index]).filter(val => val && val.trim() !== ''); return sv.length > 0 ? `Beispiel: ${sv.join(', ')}` : 'Leere Spalte' }
|
||||||
const selectAllColumns = () => { selectedColumns.value = selectedColumns.value.map(() => true) }
|
const selectAllColumns = () => { selectedColumns.value = selectedColumns.value.map(() => true) }
|
||||||
const deselectAllColumns = () => { selectedColumns.value = selectedColumns.value.map(() => false) }
|
const deselectAllColumns = () => { selectedColumns.value = selectedColumns.value.map(() => false) }
|
||||||
@@ -415,7 +420,7 @@ onMounted(() => {
|
|||||||
csvHeaders.value = result.headers
|
csvHeaders.value = result.headers
|
||||||
csvData.value = result.data.map(row => csvHeaders.value.map(header => row[header] || ''))
|
csvData.value = result.data.map(row => csvHeaders.value.map(header => row[header] || ''))
|
||||||
selectedColumns.value = new Array(csvHeaders.value.length).fill(true)
|
selectedColumns.value = new Array(csvHeaders.value.length).fill(true)
|
||||||
currentFile.value = { name: result.season ? `spielplan-${result.season}.json` : 'spielplan.csv', size: csvData.value.length, lastModified: null }
|
currentFile.value = { name: result.season ? `spielplan-${result.season}.json` : 'spielplan.csv', entries: csvData.value.length, lastModified: null }
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
})()
|
})()
|
||||||
})
|
})
|
||||||
|
|||||||
671
package-lock.json
generated
671
package-lock.json
generated
@@ -669,9 +669,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/aix-ppc64": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
"version": "0.28.0",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
|
||||||
"integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==",
|
"integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
@@ -685,9 +685,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/android-arm": {
|
"node_modules/@esbuild/android-arm": {
|
||||||
"version": "0.28.0",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
|
||||||
"integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==",
|
"integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -701,9 +701,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/android-arm64": {
|
"node_modules/@esbuild/android-arm64": {
|
||||||
"version": "0.28.0",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
|
||||||
"integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==",
|
"integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -717,9 +717,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/android-x64": {
|
"node_modules/@esbuild/android-x64": {
|
||||||
"version": "0.28.0",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
|
||||||
"integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==",
|
"integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -733,9 +733,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/darwin-arm64": {
|
"node_modules/@esbuild/darwin-arm64": {
|
||||||
"version": "0.28.0",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
|
||||||
"integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==",
|
"integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -749,9 +749,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/darwin-x64": {
|
"node_modules/@esbuild/darwin-x64": {
|
||||||
"version": "0.28.0",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
|
||||||
"integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==",
|
"integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -765,9 +765,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/freebsd-arm64": {
|
"node_modules/@esbuild/freebsd-arm64": {
|
||||||
"version": "0.28.0",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
|
||||||
"integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==",
|
"integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -781,9 +781,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/freebsd-x64": {
|
"node_modules/@esbuild/freebsd-x64": {
|
||||||
"version": "0.28.0",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
|
||||||
"integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==",
|
"integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -797,9 +797,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-arm": {
|
"node_modules/@esbuild/linux-arm": {
|
||||||
"version": "0.28.0",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
|
||||||
"integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==",
|
"integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -813,9 +813,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-arm64": {
|
"node_modules/@esbuild/linux-arm64": {
|
||||||
"version": "0.28.0",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
|
||||||
"integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==",
|
"integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -829,9 +829,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-ia32": {
|
"node_modules/@esbuild/linux-ia32": {
|
||||||
"version": "0.28.0",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
|
||||||
"integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==",
|
"integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
@@ -845,9 +845,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-loong64": {
|
"node_modules/@esbuild/linux-loong64": {
|
||||||
"version": "0.28.0",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
|
||||||
"integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==",
|
"integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
@@ -861,9 +861,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-mips64el": {
|
"node_modules/@esbuild/linux-mips64el": {
|
||||||
"version": "0.28.0",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
|
||||||
"integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==",
|
"integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"mips64el"
|
"mips64el"
|
||||||
],
|
],
|
||||||
@@ -877,9 +877,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-ppc64": {
|
"node_modules/@esbuild/linux-ppc64": {
|
||||||
"version": "0.28.0",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
|
||||||
"integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==",
|
"integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
@@ -893,9 +893,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-riscv64": {
|
"node_modules/@esbuild/linux-riscv64": {
|
||||||
"version": "0.28.0",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
|
||||||
"integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==",
|
"integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
@@ -909,9 +909,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-s390x": {
|
"node_modules/@esbuild/linux-s390x": {
|
||||||
"version": "0.28.0",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
|
||||||
"integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==",
|
"integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
@@ -925,9 +925,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-x64": {
|
"node_modules/@esbuild/linux-x64": {
|
||||||
"version": "0.28.0",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
|
||||||
"integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==",
|
"integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -941,9 +941,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/netbsd-arm64": {
|
"node_modules/@esbuild/netbsd-arm64": {
|
||||||
"version": "0.28.0",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
|
||||||
"integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==",
|
"integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -957,9 +957,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/netbsd-x64": {
|
"node_modules/@esbuild/netbsd-x64": {
|
||||||
"version": "0.28.0",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
|
||||||
"integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==",
|
"integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -973,9 +973,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/openbsd-arm64": {
|
"node_modules/@esbuild/openbsd-arm64": {
|
||||||
"version": "0.28.0",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
|
||||||
"integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==",
|
"integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -989,9 +989,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/openbsd-x64": {
|
"node_modules/@esbuild/openbsd-x64": {
|
||||||
"version": "0.28.0",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
|
||||||
"integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==",
|
"integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1005,9 +1005,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/openharmony-arm64": {
|
"node_modules/@esbuild/openharmony-arm64": {
|
||||||
"version": "0.28.0",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
|
||||||
"integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==",
|
"integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1021,9 +1021,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/sunos-x64": {
|
"node_modules/@esbuild/sunos-x64": {
|
||||||
"version": "0.28.0",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
|
||||||
"integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==",
|
"integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1037,9 +1037,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/win32-arm64": {
|
"node_modules/@esbuild/win32-arm64": {
|
||||||
"version": "0.28.0",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
|
||||||
"integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==",
|
"integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1053,9 +1053,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/win32-ia32": {
|
"node_modules/@esbuild/win32-ia32": {
|
||||||
"version": "0.28.0",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
|
||||||
"integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==",
|
"integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
@@ -1069,9 +1069,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/win32-x64": {
|
"node_modules/@esbuild/win32-x64": {
|
||||||
"version": "0.28.0",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
|
||||||
"integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==",
|
"integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -7770,9 +7770,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/esbuild": {
|
"node_modules/esbuild": {
|
||||||
"version": "0.28.0",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz",
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
|
||||||
"integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==",
|
"integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -7782,32 +7782,32 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@esbuild/aix-ppc64": "0.28.0",
|
"@esbuild/aix-ppc64": "0.28.1",
|
||||||
"@esbuild/android-arm": "0.28.0",
|
"@esbuild/android-arm": "0.28.1",
|
||||||
"@esbuild/android-arm64": "0.28.0",
|
"@esbuild/android-arm64": "0.28.1",
|
||||||
"@esbuild/android-x64": "0.28.0",
|
"@esbuild/android-x64": "0.28.1",
|
||||||
"@esbuild/darwin-arm64": "0.28.0",
|
"@esbuild/darwin-arm64": "0.28.1",
|
||||||
"@esbuild/darwin-x64": "0.28.0",
|
"@esbuild/darwin-x64": "0.28.1",
|
||||||
"@esbuild/freebsd-arm64": "0.28.0",
|
"@esbuild/freebsd-arm64": "0.28.1",
|
||||||
"@esbuild/freebsd-x64": "0.28.0",
|
"@esbuild/freebsd-x64": "0.28.1",
|
||||||
"@esbuild/linux-arm": "0.28.0",
|
"@esbuild/linux-arm": "0.28.1",
|
||||||
"@esbuild/linux-arm64": "0.28.0",
|
"@esbuild/linux-arm64": "0.28.1",
|
||||||
"@esbuild/linux-ia32": "0.28.0",
|
"@esbuild/linux-ia32": "0.28.1",
|
||||||
"@esbuild/linux-loong64": "0.28.0",
|
"@esbuild/linux-loong64": "0.28.1",
|
||||||
"@esbuild/linux-mips64el": "0.28.0",
|
"@esbuild/linux-mips64el": "0.28.1",
|
||||||
"@esbuild/linux-ppc64": "0.28.0",
|
"@esbuild/linux-ppc64": "0.28.1",
|
||||||
"@esbuild/linux-riscv64": "0.28.0",
|
"@esbuild/linux-riscv64": "0.28.1",
|
||||||
"@esbuild/linux-s390x": "0.28.0",
|
"@esbuild/linux-s390x": "0.28.1",
|
||||||
"@esbuild/linux-x64": "0.28.0",
|
"@esbuild/linux-x64": "0.28.1",
|
||||||
"@esbuild/netbsd-arm64": "0.28.0",
|
"@esbuild/netbsd-arm64": "0.28.1",
|
||||||
"@esbuild/netbsd-x64": "0.28.0",
|
"@esbuild/netbsd-x64": "0.28.1",
|
||||||
"@esbuild/openbsd-arm64": "0.28.0",
|
"@esbuild/openbsd-arm64": "0.28.1",
|
||||||
"@esbuild/openbsd-x64": "0.28.0",
|
"@esbuild/openbsd-x64": "0.28.1",
|
||||||
"@esbuild/openharmony-arm64": "0.28.0",
|
"@esbuild/openharmony-arm64": "0.28.1",
|
||||||
"@esbuild/sunos-x64": "0.28.0",
|
"@esbuild/sunos-x64": "0.28.1",
|
||||||
"@esbuild/win32-arm64": "0.28.0",
|
"@esbuild/win32-arm64": "0.28.1",
|
||||||
"@esbuild/win32-ia32": "0.28.0",
|
"@esbuild/win32-ia32": "0.28.1",
|
||||||
"@esbuild/win32-x64": "0.28.0"
|
"@esbuild/win32-x64": "0.28.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/escalade": {
|
"node_modules/escalade": {
|
||||||
@@ -14703,463 +14703,6 @@
|
|||||||
"vue": "^3.5.0"
|
"vue": "^3.5.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vite/node_modules/@esbuild/aix-ppc64": {
|
|
||||||
"version": "0.27.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
|
|
||||||
"integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==",
|
|
||||||
"cpu": [
|
|
||||||
"ppc64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"aix"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vite/node_modules/@esbuild/android-arm": {
|
|
||||||
"version": "0.27.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz",
|
|
||||||
"integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==",
|
|
||||||
"cpu": [
|
|
||||||
"arm"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"android"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vite/node_modules/@esbuild/android-arm64": {
|
|
||||||
"version": "0.27.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz",
|
|
||||||
"integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"android"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vite/node_modules/@esbuild/android-x64": {
|
|
||||||
"version": "0.27.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz",
|
|
||||||
"integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"android"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vite/node_modules/@esbuild/darwin-arm64": {
|
|
||||||
"version": "0.27.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz",
|
|
||||||
"integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vite/node_modules/@esbuild/darwin-x64": {
|
|
||||||
"version": "0.27.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz",
|
|
||||||
"integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vite/node_modules/@esbuild/freebsd-arm64": {
|
|
||||||
"version": "0.27.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz",
|
|
||||||
"integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"freebsd"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vite/node_modules/@esbuild/freebsd-x64": {
|
|
||||||
"version": "0.27.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz",
|
|
||||||
"integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"freebsd"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vite/node_modules/@esbuild/linux-arm": {
|
|
||||||
"version": "0.27.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz",
|
|
||||||
"integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==",
|
|
||||||
"cpu": [
|
|
||||||
"arm"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vite/node_modules/@esbuild/linux-arm64": {
|
|
||||||
"version": "0.27.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz",
|
|
||||||
"integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vite/node_modules/@esbuild/linux-ia32": {
|
|
||||||
"version": "0.27.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz",
|
|
||||||
"integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==",
|
|
||||||
"cpu": [
|
|
||||||
"ia32"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vite/node_modules/@esbuild/linux-loong64": {
|
|
||||||
"version": "0.27.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz",
|
|
||||||
"integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==",
|
|
||||||
"cpu": [
|
|
||||||
"loong64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vite/node_modules/@esbuild/linux-mips64el": {
|
|
||||||
"version": "0.27.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz",
|
|
||||||
"integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==",
|
|
||||||
"cpu": [
|
|
||||||
"mips64el"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vite/node_modules/@esbuild/linux-ppc64": {
|
|
||||||
"version": "0.27.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz",
|
|
||||||
"integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==",
|
|
||||||
"cpu": [
|
|
||||||
"ppc64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vite/node_modules/@esbuild/linux-riscv64": {
|
|
||||||
"version": "0.27.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz",
|
|
||||||
"integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==",
|
|
||||||
"cpu": [
|
|
||||||
"riscv64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vite/node_modules/@esbuild/linux-s390x": {
|
|
||||||
"version": "0.27.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz",
|
|
||||||
"integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==",
|
|
||||||
"cpu": [
|
|
||||||
"s390x"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vite/node_modules/@esbuild/linux-x64": {
|
|
||||||
"version": "0.27.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz",
|
|
||||||
"integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vite/node_modules/@esbuild/netbsd-arm64": {
|
|
||||||
"version": "0.27.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz",
|
|
||||||
"integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"netbsd"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vite/node_modules/@esbuild/netbsd-x64": {
|
|
||||||
"version": "0.27.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz",
|
|
||||||
"integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"netbsd"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vite/node_modules/@esbuild/openbsd-arm64": {
|
|
||||||
"version": "0.27.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz",
|
|
||||||
"integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"openbsd"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vite/node_modules/@esbuild/openbsd-x64": {
|
|
||||||
"version": "0.27.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz",
|
|
||||||
"integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"openbsd"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vite/node_modules/@esbuild/openharmony-arm64": {
|
|
||||||
"version": "0.27.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz",
|
|
||||||
"integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"openharmony"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vite/node_modules/@esbuild/sunos-x64": {
|
|
||||||
"version": "0.27.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz",
|
|
||||||
"integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"sunos"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vite/node_modules/@esbuild/win32-arm64": {
|
|
||||||
"version": "0.27.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz",
|
|
||||||
"integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"win32"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vite/node_modules/@esbuild/win32-ia32": {
|
|
||||||
"version": "0.27.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz",
|
|
||||||
"integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==",
|
|
||||||
"cpu": [
|
|
||||||
"ia32"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"win32"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vite/node_modules/@esbuild/win32-x64": {
|
|
||||||
"version": "0.27.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz",
|
|
||||||
"integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"win32"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vite/node_modules/esbuild": {
|
|
||||||
"version": "0.27.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz",
|
|
||||||
"integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==",
|
|
||||||
"hasInstallScript": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"bin": {
|
|
||||||
"esbuild": "bin/esbuild"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"@esbuild/aix-ppc64": "0.27.7",
|
|
||||||
"@esbuild/android-arm": "0.27.7",
|
|
||||||
"@esbuild/android-arm64": "0.27.7",
|
|
||||||
"@esbuild/android-x64": "0.27.7",
|
|
||||||
"@esbuild/darwin-arm64": "0.27.7",
|
|
||||||
"@esbuild/darwin-x64": "0.27.7",
|
|
||||||
"@esbuild/freebsd-arm64": "0.27.7",
|
|
||||||
"@esbuild/freebsd-x64": "0.27.7",
|
|
||||||
"@esbuild/linux-arm": "0.27.7",
|
|
||||||
"@esbuild/linux-arm64": "0.27.7",
|
|
||||||
"@esbuild/linux-ia32": "0.27.7",
|
|
||||||
"@esbuild/linux-loong64": "0.27.7",
|
|
||||||
"@esbuild/linux-mips64el": "0.27.7",
|
|
||||||
"@esbuild/linux-ppc64": "0.27.7",
|
|
||||||
"@esbuild/linux-riscv64": "0.27.7",
|
|
||||||
"@esbuild/linux-s390x": "0.27.7",
|
|
||||||
"@esbuild/linux-x64": "0.27.7",
|
|
||||||
"@esbuild/netbsd-arm64": "0.27.7",
|
|
||||||
"@esbuild/netbsd-x64": "0.27.7",
|
|
||||||
"@esbuild/openbsd-arm64": "0.27.7",
|
|
||||||
"@esbuild/openbsd-x64": "0.27.7",
|
|
||||||
"@esbuild/openharmony-arm64": "0.27.7",
|
|
||||||
"@esbuild/sunos-x64": "0.27.7",
|
|
||||||
"@esbuild/win32-arm64": "0.27.7",
|
|
||||||
"@esbuild/win32-ia32": "0.27.7",
|
|
||||||
"@esbuild/win32-x64": "0.27.7"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vitest": {
|
"node_modules/vitest": {
|
||||||
"version": "4.1.8",
|
"version": "4.1.8",
|
||||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.8.tgz",
|
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.8.tgz",
|
||||||
|
|||||||
@@ -64,6 +64,7 @@
|
|||||||
"vue-eslint-parser": "^10.2.0"
|
"vue-eslint-parser": "^10.2.0"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"@peculiar/x509": "1.13.0"
|
"@peculiar/x509": "1.13.0",
|
||||||
|
"esbuild": "0.28.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -365,7 +365,7 @@ const visibility = ref({
|
|||||||
showEmail: true,
|
showEmail: true,
|
||||||
showPhone: true,
|
showPhone: true,
|
||||||
showAddress: false,
|
showAddress: false,
|
||||||
showBirthday: true
|
showBirthday: false
|
||||||
})
|
})
|
||||||
|
|
||||||
const passwordData = ref({
|
const passwordData = ref({
|
||||||
@@ -568,4 +568,3 @@ useHead({
|
|||||||
title: 'Mein Profil - Harheimer TC',
|
title: 'Mein Profil - Harheimer TC',
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -173,4 +173,4 @@ function formatDate(value) {
|
|||||||
useHead({
|
useHead({
|
||||||
title: 'QTTR-Werte - Harheimer TC'
|
title: 'QTTR-Werte - Harheimer TC'
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
52
scripts/verify-no-public-writes.js
Normal file
52
scripts/verify-no-public-writes.js
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import fs from 'fs'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
const repoRoot = process.cwd()
|
||||||
|
const scanRoots = ['server']
|
||||||
|
const sourceExtensions = new Set(['.js', '.mjs', '.ts'])
|
||||||
|
|
||||||
|
const publicWritePattern = /\b(writeFile|appendFile|copyFile|rename|mkdir)\s*\([^)]*(public[/\\](?:data|uploads)|['"`]public['"`]\s*,\s*['"`](?:data|uploads)['"`])/s
|
||||||
|
|
||||||
|
function childPath(dir, name) {
|
||||||
|
if (name !== path.basename(name) || name.includes('/') || name.includes('\\')) {
|
||||||
|
throw new Error(`Ungueltiger Dateiname beim Scannen: ${name}`)
|
||||||
|
}
|
||||||
|
return `${dir}${path.sep}${name}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function walk(dir) {
|
||||||
|
const entries = fs.readdirSync(dir, { withFileTypes: true })
|
||||||
|
return entries.flatMap((entry) => {
|
||||||
|
const fullPath = childPath(dir, entry.name)
|
||||||
|
if (entry.isDirectory()) return walk(fullPath)
|
||||||
|
return [fullPath]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const findings = []
|
||||||
|
|
||||||
|
for (const root of scanRoots) {
|
||||||
|
const absoluteRoot = path.join(repoRoot, root)
|
||||||
|
if (!fs.existsSync(absoluteRoot)) continue
|
||||||
|
|
||||||
|
for (const filePath of walk(absoluteRoot)) {
|
||||||
|
if (!sourceExtensions.has(path.extname(filePath))) continue
|
||||||
|
|
||||||
|
const content = fs.readFileSync(filePath, 'utf8')
|
||||||
|
if (!publicWritePattern.test(content)) continue
|
||||||
|
|
||||||
|
const relativePath = path.relative(repoRoot, filePath)
|
||||||
|
findings.push(relativePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (findings.length > 0) {
|
||||||
|
console.error('Runtime-Schreibzugriffe nach public/data oder public/uploads gefunden:')
|
||||||
|
for (const finding of findings) {
|
||||||
|
console.error(`- ${finding}`)
|
||||||
|
}
|
||||||
|
console.error('Bitte stattdessen server/data bzw. server/data/public-data verwenden.')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('OK: keine serverseitigen Runtime-Schreibzugriffe nach public/data oder public/uploads gefunden.')
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import { verifyRegistrationResponse } from '@simplewebauthn/server'
|
import { verifyRegistrationResponse } from '@simplewebauthn/server'
|
||||||
import crypto from 'crypto'
|
import crypto from 'crypto'
|
||||||
import nodemailer from 'nodemailer'
|
|
||||||
import { hashPassword, readUsers, writeUsers } from '../../utils/auth.js'
|
import { hashPassword, readUsers, writeUsers } from '../../utils/auth.js'
|
||||||
import { getWebAuthnConfig } from '../../utils/webauthn-config.js'
|
import { getWebAuthnConfig } from '../../utils/webauthn-config.js'
|
||||||
import { consumePreRegistration } from '../../utils/webauthn-challenges.js'
|
import { consumePreRegistration } from '../../utils/webauthn-challenges.js'
|
||||||
@@ -8,6 +7,7 @@ import { toBase64Url } from '../../utils/webauthn-encoding.js'
|
|||||||
import { writeAuditLog } from '../../utils/audit-log.js'
|
import { writeAuditLog } from '../../utils/audit-log.js'
|
||||||
import { assertPasswordNotPwned } from '../../utils/hibp.js'
|
import { assertPasswordNotPwned } from '../../utils/hibp.js'
|
||||||
import { getClientIp } from '../../utils/rate-limit.js'
|
import { getClientIp } from '../../utils/rate-limit.js'
|
||||||
|
import { sendRegistrationNotification } from '../../utils/email-service.js'
|
||||||
|
|
||||||
// Local fallback for Nitro globals when lint/run env doesn't provide them
|
// Local fallback for Nitro globals when lint/run env doesn't provide them
|
||||||
const getMethod = globalThis.getMethod ?? ((e) => (e?.req?.method || e?.method || 'GET'))
|
const getMethod = globalThis.getMethod ?? ((e) => (e?.req?.method || e?.method || 'GET'))
|
||||||
@@ -260,50 +260,9 @@ export default defineEventHandler(async (event) => {
|
|||||||
|
|
||||||
await writeAuditLog('auth.passkey.prereg.success', { email, userId: newUser.id })
|
await writeAuditLog('auth.passkey.prereg.success', { email, userId: newUser.id })
|
||||||
|
|
||||||
// Send notification emails (same behavior as password registration)
|
// Send notification emails through the same central recipient logic as password registration.
|
||||||
try {
|
try {
|
||||||
const smtpUser = process.env.SMTP_USER
|
await sendRegistrationNotification({ name, email, phone })
|
||||||
const smtpPass = process.env.SMTP_PASS
|
|
||||||
|
|
||||||
if (smtpUser && smtpPass) {
|
|
||||||
const transporter = nodemailer.createTransport({
|
|
||||||
host: process.env.SMTP_HOST || 'smtp.gmail.com',
|
|
||||||
port: process.env.SMTP_PORT || 587,
|
|
||||||
secure: false,
|
|
||||||
auth: { user: smtpUser, pass: smtpPass }
|
|
||||||
})
|
|
||||||
|
|
||||||
await transporter.sendMail({
|
|
||||||
from: process.env.SMTP_FROM || 'noreply@harheimertc.de',
|
|
||||||
to: process.env.SMTP_ADMIN || 'j.dichmann@gmx.de',
|
|
||||||
subject: 'Neue Registrierung (Passkey) - Harheimer TC',
|
|
||||||
html: `
|
|
||||||
<h2>Neue Registrierung (Passkey)</h2>
|
|
||||||
<p>Ein neuer Benutzer hat sich registriert und wartet auf Freigabe:</p>
|
|
||||||
<ul>
|
|
||||||
<li><strong>Name:</strong> ${name}</li>
|
|
||||||
<li><strong>E-Mail:</strong> ${email}</li>
|
|
||||||
<li><strong>Telefon:</strong> ${phone || 'Nicht angegeben'}</li>
|
|
||||||
<li><strong>Login:</strong> Passkey${password ? ' + Passwort (Fallback)' : ' (ohne Passwort)'}</li>
|
|
||||||
</ul>
|
|
||||||
<p>Bitte prüfen Sie die Registrierung im CMS.</p>
|
|
||||||
`
|
|
||||||
})
|
|
||||||
|
|
||||||
await transporter.sendMail({
|
|
||||||
from: process.env.SMTP_FROM || 'noreply@harheimertc.de',
|
|
||||||
to: email,
|
|
||||||
subject: 'Registrierung erhalten - Harheimer TC',
|
|
||||||
html: `
|
|
||||||
<h2>Registrierung erhalten</h2>
|
|
||||||
<p>Hallo ${name},</p>
|
|
||||||
<p>vielen Dank für Ihre Registrierung beim Harheimer TC!</p>
|
|
||||||
<p>Ihre Anfrage wird vom Vorstand geprüft. Sie erhalten eine E-Mail, sobald Ihr Zugang freigeschaltet wurde.</p>
|
|
||||||
<br>
|
|
||||||
<p>Mit sportlichen Grüßen,<br>Ihr Harheimer TC</p>
|
|
||||||
`
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} catch (emailError) {
|
} catch (emailError) {
|
||||||
console.error('E-Mail-Versand fehlgeschlagen:', emailError)
|
console.error('E-Mail-Versand fehlgeschlagen:', emailError)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { readUsers, writeUsers, hashPassword } from '../../utils/auth.js'
|
import { readUsers, writeUsers, hashPassword } from '../../utils/auth.js'
|
||||||
import { sendRegistrationNotification } from '../../utils/email-service.js'
|
import { sendRegistrationNotification } from '../../utils/email-service.js'
|
||||||
import { assertPasswordNotPwned } from '../../utils/hibp.js'
|
import { assertPasswordNotPwned } from '../../utils/hibp.js'
|
||||||
|
import { sendNewUserRegistrationPush } from '../../utils/push-notifications.js'
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
try {
|
try {
|
||||||
@@ -48,7 +49,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
phone: phone || '',
|
phone: phone || '',
|
||||||
geburtsdatum,
|
geburtsdatum,
|
||||||
visibility: {
|
visibility: {
|
||||||
showBirthday: visibility?.showBirthday !== undefined ? Boolean(visibility.showBirthday) : true
|
showBirthday: visibility?.showBirthday !== undefined ? Boolean(visibility.showBirthday) : false
|
||||||
},
|
},
|
||||||
role: 'mitglied',
|
role: 'mitglied',
|
||||||
active: false, // Requires admin approval
|
active: false, // Requires admin approval
|
||||||
@@ -59,6 +60,10 @@ export default defineEventHandler(async (event) => {
|
|||||||
users.push(newUser)
|
users.push(newUser)
|
||||||
await writeUsers(users)
|
await writeUsers(users)
|
||||||
|
|
||||||
|
sendNewUserRegistrationPush(newUser)
|
||||||
|
.then(result => console.info('Registrierungs-Push Ergebnis:', { userId: newUser.id, ...result }))
|
||||||
|
.catch(error => console.error('Registrierungs-Push fehlgeschlagen:', error))
|
||||||
|
|
||||||
// Send notification to Vorstand/admin via central email service
|
// Send notification to Vorstand/admin via central email service
|
||||||
try {
|
try {
|
||||||
await sendRegistrationNotification({ name, email, phone })
|
await sendRegistrationNotification({ name, email, phone })
|
||||||
@@ -75,4 +80,3 @@ export default defineEventHandler(async (event) => {
|
|||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -72,14 +72,14 @@ export default defineEventHandler(async (event) => {
|
|||||||
: true
|
: true
|
||||||
if (!isAccepted) continue
|
if (!isAccepted) continue
|
||||||
const vis = m.visibility || {}
|
const vis = m.visibility || {}
|
||||||
const showBirthday = vis.showBirthday === undefined ? true : Boolean(vis.showBirthday)
|
const showBirthday = vis.showBirthday === true
|
||||||
candidates.push({ name: `${m.firstName || ''} ${m.lastName || ''}`.trim(), geburtsdatum: m.geburtsdatum, visibility: { showBirthday }, source: 'manual' })
|
candidates.push({ name: `${m.firstName || ''} ${m.lastName || ''}`.trim(), geburtsdatum: m.geburtsdatum, visibility: { showBirthday }, source: 'manual' })
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const u of registeredUsers) {
|
for (const u of registeredUsers) {
|
||||||
if (!u.active || isHiddenUser(u)) continue
|
if (!u.active || isHiddenUser(u)) continue
|
||||||
const vis = u.visibility || {}
|
const vis = u.visibility || {}
|
||||||
const showBirthday = vis.showBirthday === undefined ? true : Boolean(vis.showBirthday)
|
const showBirthday = vis.showBirthday === true
|
||||||
candidates.push({ name: u.name, geburtsdatum: u.geburtsdatum, visibility: { showBirthday }, source: 'login' })
|
candidates.push({ name: u.name, geburtsdatum: u.geburtsdatum, visibility: { showBirthday }, source: 'login' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import nodemailer from 'nodemailer'
|
import nodemailer from 'nodemailer'
|
||||||
import { promises as fs } from 'fs'
|
import { promises as fs } from 'fs'
|
||||||
import path from 'path'
|
|
||||||
import { createContactRequest } from '../utils/contact-requests.js'
|
import { createContactRequest } from '../utils/contact-requests.js'
|
||||||
import { readUsers, migrateUserRoles, isHiddenUser } from '../utils/auth.js'
|
import { readUsers, migrateUserRoles, isHiddenUser } from '../utils/auth.js'
|
||||||
|
import { sendNewContactRequestPush } from '../utils/push-notifications.js'
|
||||||
|
|
||||||
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
|
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
|
||||||
// filename is always a hardcoded constant ('config.json'), never user input
|
// filename is always a hardcoded constant ('config.json'), never user input
|
||||||
@@ -23,17 +23,39 @@ async function loadConfig() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function collectRecipients(config) {
|
function envFlagEnabled(value) {
|
||||||
const isProduction = process.env.NODE_ENV === 'production' && process.env.APP_ENV !== 'test'
|
return ['1', 'true', 'yes', 'on'].includes(String(value || '').trim().toLowerCase())
|
||||||
|
}
|
||||||
|
|
||||||
if (!isProduction) {
|
function shouldUseDeveloperRecipients() {
|
||||||
|
if (process.env.DEBUG !== undefined) return envFlagEnabled(process.env.DEBUG)
|
||||||
|
return process.env.NODE_ENV !== 'production' || process.env.APP_ENV === 'test'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function collectRecipients(config) {
|
||||||
|
if (shouldUseDeveloperRecipients()) {
|
||||||
return ['tsschulz@tsschulz.de']
|
return ['tsschulz@tsschulz.de']
|
||||||
}
|
}
|
||||||
|
|
||||||
const recipients = []
|
const recipients = []
|
||||||
|
|
||||||
// Vorstand
|
// Vorstand: prefer active login users with the board role.
|
||||||
if (config?.vorstand && typeof config.vorstand === 'object') {
|
try {
|
||||||
|
const users = await readUsers()
|
||||||
|
for (const rawUser of users) {
|
||||||
|
if (!rawUser || rawUser.active === false || isHiddenUser(rawUser)) continue
|
||||||
|
const user = migrateUserRoles({ ...rawUser })
|
||||||
|
const roles = Array.isArray(user.roles) ? user.roles : []
|
||||||
|
if (roles.includes('vorstand') && user.email && String(user.email).trim()) {
|
||||||
|
recipients.push(String(user.email).trim())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fehler beim Laden der Vorstand-Empfänger aus Benutzerdaten:', error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: legacy config.json Vorstand object.
|
||||||
|
if (recipients.length === 0 && config?.vorstand && typeof config.vorstand === 'object') {
|
||||||
for (const member of Object.values(config.vorstand)) {
|
for (const member of Object.values(config.vorstand)) {
|
||||||
if (member?.email && typeof member.email === 'string' && member.email.trim()) {
|
if (member?.email && typeof member.email === 'string' && member.email.trim()) {
|
||||||
recipients.push(member.email.trim())
|
recipients.push(member.email.trim())
|
||||||
@@ -72,10 +94,7 @@ async function collectRecipients(config) {
|
|||||||
if (config?.website?.verantwortlicher?.email) {
|
if (config?.website?.verantwortlicher?.email) {
|
||||||
return [config.website.verantwortlicher.email]
|
return [config.website.verantwortlicher.email]
|
||||||
}
|
}
|
||||||
if (process.env.SMTP_USER) {
|
throw new Error('Keine E-Mail-Empfänger in config.json konfiguriert.')
|
||||||
return [process.env.SMTP_USER]
|
|
||||||
}
|
|
||||||
return ['j.dichmann@gmx.de']
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function createTransporter() {
|
function createTransporter() {
|
||||||
@@ -111,13 +130,17 @@ export default defineEventHandler(async (event) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Anfrage immer speichern, auch wenn E-Mail-Versand fehlschlägt.
|
// Anfrage immer speichern, auch wenn E-Mail-Versand fehlschlägt.
|
||||||
await createContactRequest({
|
const contactRequest = {
|
||||||
name: String(body.name).trim(),
|
name: String(body.name).trim(),
|
||||||
email: String(body.email).trim(),
|
email: String(body.email).trim(),
|
||||||
phone: body.phone ? String(body.phone).trim() : '',
|
phone: body.phone ? String(body.phone).trim() : '',
|
||||||
subject: String(body.subject).trim(),
|
subject: String(body.subject).trim(),
|
||||||
message: String(body.message).trim()
|
message: String(body.message).trim()
|
||||||
})
|
}
|
||||||
|
await createContactRequest(contactRequest)
|
||||||
|
sendNewContactRequestPush(contactRequest)
|
||||||
|
.then(result => console.info('Kontaktanfrage-Push Ergebnis:', { subject: contactRequest.subject, ...result }))
|
||||||
|
.catch(error => console.error('Kontaktanfrage-Push fehlgeschlagen:', error))
|
||||||
|
|
||||||
const config = await loadConfig()
|
const config = await loadConfig()
|
||||||
const recipients = await collectRecipients(config)
|
const recipients = await collectRecipients(config)
|
||||||
|
|||||||
@@ -64,7 +64,8 @@ export default defineEventHandler(async (event) => {
|
|||||||
showEmail: vis.showEmail === undefined ? true : Boolean(vis.showEmail),
|
showEmail: vis.showEmail === undefined ? true : Boolean(vis.showEmail),
|
||||||
showPhone: vis.showPhone === undefined ? true : Boolean(vis.showPhone),
|
showPhone: vis.showPhone === undefined ? true : Boolean(vis.showPhone),
|
||||||
// Address remains private by default
|
// Address remains private by default
|
||||||
showAddress: vis.showAddress === undefined ? false : Boolean(vis.showAddress)
|
showAddress: vis.showAddress === undefined ? false : Boolean(vis.showAddress),
|
||||||
|
showBirthday: vis.showBirthday === true
|
||||||
}
|
}
|
||||||
|
|
||||||
mergedMembers.push({
|
mergedMembers.push({
|
||||||
@@ -163,7 +164,8 @@ export default defineEventHandler(async (event) => {
|
|||||||
mergedMembers[matchedManualIndex].visibility = {
|
mergedMembers[matchedManualIndex].visibility = {
|
||||||
showEmail: user.visibility.showEmail === undefined ? Boolean(vis.showEmail) : Boolean(user.visibility.showEmail),
|
showEmail: user.visibility.showEmail === undefined ? Boolean(vis.showEmail) : Boolean(user.visibility.showEmail),
|
||||||
showPhone: user.visibility.showPhone === undefined ? Boolean(vis.showPhone) : Boolean(user.visibility.showPhone),
|
showPhone: user.visibility.showPhone === undefined ? Boolean(vis.showPhone) : Boolean(user.visibility.showPhone),
|
||||||
showAddress: user.visibility.showAddress === undefined ? Boolean(vis.showAddress) : Boolean(user.visibility.showAddress)
|
showAddress: user.visibility.showAddress === undefined ? Boolean(vis.showAddress) : Boolean(user.visibility.showAddress),
|
||||||
|
showBirthday: user.visibility.showBirthday === undefined ? vis.showBirthday === true : user.visibility.showBirthday === true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -186,7 +188,8 @@ export default defineEventHandler(async (event) => {
|
|||||||
visibility: {
|
visibility: {
|
||||||
showEmail: userVis.showEmail === undefined ? true : Boolean(userVis.showEmail),
|
showEmail: userVis.showEmail === undefined ? true : Boolean(userVis.showEmail),
|
||||||
showPhone: userVis.showPhone === undefined ? true : Boolean(userVis.showPhone),
|
showPhone: userVis.showPhone === undefined ? true : Boolean(userVis.showPhone),
|
||||||
showAddress: userVis.showAddress === undefined ? false : Boolean(userVis.showAddress)
|
showAddress: userVis.showAddress === undefined ? false : Boolean(userVis.showAddress),
|
||||||
|
showBirthday: userVis.showBirthday === true
|
||||||
},
|
},
|
||||||
notes: `Rolle(n): ${roles.join(', ')}`,
|
notes: `Rolle(n): ${roles.join(', ')}`,
|
||||||
source: 'login',
|
source: 'login',
|
||||||
@@ -226,7 +229,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
const emailVisible = (isPrivilegedViewer || (isViewerAuthenticated && showEmail))
|
const emailVisible = (isPrivilegedViewer || (isViewerAuthenticated && showEmail))
|
||||||
const phoneVisible = (isPrivilegedViewer || (isViewerAuthenticated && showPhone))
|
const phoneVisible = (isPrivilegedViewer || (isViewerAuthenticated && showPhone))
|
||||||
const addressVisible = (isPrivilegedViewer || (isViewerAuthenticated && showAddress))
|
const addressVisible = (isPrivilegedViewer || (isViewerAuthenticated && showAddress))
|
||||||
const birthdayVisible = (isPrivilegedViewer || (isViewerAuthenticated && (member.visibility && member.visibility.showBirthday !== undefined ? Boolean(member.visibility.showBirthday) : true)))
|
const birthdayVisible = (isPrivilegedViewer || (isViewerAuthenticated && member.visibility?.showBirthday === true))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: member.id,
|
id: member.id,
|
||||||
@@ -246,7 +249,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
showEmail: visibility.showEmail === undefined ? true : Boolean(visibility.showEmail),
|
showEmail: visibility.showEmail === undefined ? true : Boolean(visibility.showEmail),
|
||||||
showPhone: visibility.showPhone === undefined ? true : Boolean(visibility.showPhone),
|
showPhone: visibility.showPhone === undefined ? true : Boolean(visibility.showPhone),
|
||||||
showAddress: visibility.showAddress === undefined ? false : Boolean(visibility.showAddress),
|
showAddress: visibility.showAddress === undefined ? false : Boolean(visibility.showAddress),
|
||||||
showBirthday: visibility.showBirthday === undefined ? true : Boolean(visibility.showBirthday),
|
showBirthday: visibility.showBirthday === true,
|
||||||
// Privileged viewers (vorstand) always see contact fields
|
// Privileged viewers (vorstand) always see contact fields
|
||||||
email: emailVisible ? member.email : undefined,
|
email: emailVisible ? member.email : undefined,
|
||||||
phone: phoneVisible ? member.phone : undefined,
|
phone: phoneVisible ? member.phone : undefined,
|
||||||
|
|||||||
@@ -1,5 +1,22 @@
|
|||||||
import { getUserFromToken, hasAnyRole } from '../utils/auth.js'
|
import { getUserFromToken, hasAnyRole, readUsers, writeUsers, normalizeUserEmail } from '../utils/auth.js'
|
||||||
import { saveMember } from '../utils/members.js'
|
import { readMembers, saveMember } from '../utils/members.js'
|
||||||
|
|
||||||
|
function requestedBirthdayVisibility(body) {
|
||||||
|
return body?.visibility?.showBirthday ?? body?.showBirthday
|
||||||
|
}
|
||||||
|
|
||||||
|
function birthdayVisibilityIsTrue(value) {
|
||||||
|
return value === true || value === 'true'
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveAdminBirthdayVisibility({ requested, existingManualMember, existingUser }) {
|
||||||
|
if (requested === false || requested === 'false') return false
|
||||||
|
|
||||||
|
const existingValue = existingUser?.visibility?.showBirthday ?? existingManualMember?.visibility?.showBirthday
|
||||||
|
if (existingValue === true) return true
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
try {
|
try {
|
||||||
@@ -39,7 +56,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const body = await readBody(event)
|
const body = await readBody(event)
|
||||||
const { id, firstName, lastName, geburtsdatum, email, phone, address, notes, isMannschaftsspieler, hasHallKey, hasHallenschluessel, active } = body
|
const { id, firstName, lastName, geburtsdatum, email, phone, address, notes, isMannschaftsspieler, hasHallKey, hasHallenschluessel, active, visibility } = body
|
||||||
|
|
||||||
if (!firstName || !lastName) {
|
if (!firstName || !lastName) {
|
||||||
throw createError({
|
throw createError({
|
||||||
@@ -56,6 +73,23 @@ export default defineEventHandler(async (event) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const [members, users] = await Promise.all([readMembers(), readUsers()])
|
||||||
|
const normalizedEmail = normalizeUserEmail(email)
|
||||||
|
const existingManualMember = members.find(member => {
|
||||||
|
if (id && member.id === id) return true
|
||||||
|
return normalizedEmail && normalizeUserEmail(member.email) === normalizedEmail
|
||||||
|
})
|
||||||
|
const userIndex = users.findIndex(user => {
|
||||||
|
if (id && user.id === id) return true
|
||||||
|
return normalizedEmail && normalizeUserEmail(user.email) === normalizedEmail
|
||||||
|
})
|
||||||
|
const existingUser = userIndex !== -1 ? users[userIndex] : null
|
||||||
|
const nextShowBirthday = resolveAdminBirthdayVisibility({
|
||||||
|
requested: requestedBirthdayVisibility(body),
|
||||||
|
existingManualMember,
|
||||||
|
existingUser
|
||||||
|
})
|
||||||
|
|
||||||
await saveMember({
|
await saveMember({
|
||||||
id: id || undefined,
|
id: id || undefined,
|
||||||
firstName,
|
firstName,
|
||||||
@@ -67,9 +101,21 @@ export default defineEventHandler(async (event) => {
|
|||||||
notes: notes || '',
|
notes: notes || '',
|
||||||
isMannschaftsspieler: isMannschaftsspieler === true || isMannschaftsspieler === 'true',
|
isMannschaftsspieler: isMannschaftsspieler === true || isMannschaftsspieler === 'true',
|
||||||
hasHallKey: hasHallKey === true || hasHallKey === 'true' || hasHallenschluessel === true || hasHallenschluessel === 'true',
|
hasHallKey: hasHallKey === true || hasHallKey === 'true' || hasHallenschluessel === true || hasHallenschluessel === 'true',
|
||||||
|
visibility: {
|
||||||
|
...(visibility && typeof visibility === 'object' ? visibility : {}),
|
||||||
|
showBirthday: nextShowBirthday
|
||||||
|
},
|
||||||
active: typeof active === 'boolean' ? active : true
|
active: typeof active === 'boolean' ? active : true
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (userIndex !== -1 && (!birthdayVisibilityIsTrue(requestedBirthdayVisibility(body)) || existingUser?.visibility?.showBirthday === true)) {
|
||||||
|
users[userIndex].visibility = {
|
||||||
|
...(users[userIndex].visibility || {}),
|
||||||
|
showBirthday: nextShowBirthday
|
||||||
|
}
|
||||||
|
await writeUsers(users)
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Mitglied erfolgreich gespeichert.'
|
message: 'Mitglied erfolgreich gespeichert.'
|
||||||
@@ -98,4 +144,3 @@ export default defineEventHandler(async (event) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
email: user.email,
|
email: user.email,
|
||||||
phone: user.phone || '',
|
phone: user.phone || '',
|
||||||
geburtsdatum: user.geburtsdatum || '',
|
geburtsdatum: user.geburtsdatum || '',
|
||||||
visibility: Object.assign({ showBirthday: true }, (user.visibility || {}))
|
visibility: Object.assign({ showBirthday: false }, (user.visibility || {}))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { deleteTermin } from '../utils/termine.js'
|
|||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
try {
|
try {
|
||||||
const token = getCookie(event, 'auth_token')
|
const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '')
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
throw createError({
|
throw createError({
|
||||||
@@ -58,4 +58,3 @@ export default defineEventHandler(async (event) => {
|
|||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { readTermine } from '../utils/termine.js'
|
|||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
try {
|
try {
|
||||||
const token = getCookie(event, 'auth_token')
|
const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '')
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
throw createError({
|
throw createError({
|
||||||
@@ -42,4 +42,3 @@ export default defineEventHandler(async (event) => {
|
|||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { verifyToken, getUserById, hasAnyRole } from '../utils/auth.js'
|
import { verifyToken, getUserById, hasAnyRole } from '../utils/auth.js'
|
||||||
import { saveTermin } from '../utils/termine.js'
|
import { saveTermin } from '../utils/termine.js'
|
||||||
|
import { sendNewEventPush } from '../utils/push-notifications.js'
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
try {
|
try {
|
||||||
const token = getCookie(event, 'auth_token')
|
const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '')
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
throw createError({
|
throw createError({
|
||||||
@@ -41,13 +42,17 @@ export default defineEventHandler(async (event) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
await saveTermin({
|
const termin = {
|
||||||
datum,
|
datum,
|
||||||
uhrzeit: uhrzeit || '',
|
uhrzeit: uhrzeit || '',
|
||||||
titel,
|
titel,
|
||||||
beschreibung: beschreibung || '',
|
beschreibung: beschreibung || '',
|
||||||
kategorie: kategorie || 'Sonstiges'
|
kategorie: kategorie || 'Sonstiges'
|
||||||
})
|
}
|
||||||
|
await saveTermin(termin)
|
||||||
|
sendNewEventPush(termin)
|
||||||
|
.then(result => console.info('Termin-Push Ergebnis:', { titel: termin.titel, ...result }))
|
||||||
|
.catch(error => console.error('Termin-Push fehlgeschlagen:', error))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
@@ -58,4 +63,3 @@ export default defineEventHandler(async (event) => {
|
|||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
38
server/plugins/notification-scheduler.js
Normal file
38
server/plugins/notification-scheduler.js
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { runNotificationSchedulerTick } from '../utils/notification-scheduler.js'
|
||||||
|
import { info as loggerInfo, error as loggerError } from '../utils/logger.js'
|
||||||
|
|
||||||
|
const INTERVAL_MS = 60_000
|
||||||
|
let timer = null
|
||||||
|
let running = false
|
||||||
|
|
||||||
|
async function tick(reason = 'interval') {
|
||||||
|
if (running) return
|
||||||
|
running = true
|
||||||
|
try {
|
||||||
|
const result = await runNotificationSchedulerTick()
|
||||||
|
if (result?.dueUsers) {
|
||||||
|
loggerInfo('[notification-scheduler] Tick', { reason, ...result })
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
loggerError('[notification-scheduler] Tick fehlgeschlagen:', { error })
|
||||||
|
} finally {
|
||||||
|
running = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineNitroPlugin((nitroApp) => {
|
||||||
|
if (process.env.NOTIFICATION_SCHEDULER_DISABLED === 'true') {
|
||||||
|
loggerInfo('[notification-scheduler] Deaktiviert')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loggerInfo('[notification-scheduler] Gestartet')
|
||||||
|
timer = setInterval(() => tick(), INTERVAL_MS)
|
||||||
|
timer.unref?.()
|
||||||
|
tick('start')
|
||||||
|
|
||||||
|
nitroApp.hooks.hookOnce('close', () => {
|
||||||
|
if (timer) clearInterval(timer)
|
||||||
|
timer = null
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
// Script: set-all-birthday-visible.js
|
|
||||||
// Setzt für alle Mitglieder das Flag visibility.showBirthday auf true
|
|
||||||
|
|
||||||
const fs = require('fs')
|
|
||||||
const path = require('path')
|
|
||||||
|
|
||||||
const membersPath = path.join(__dirname, 'data', 'members.json')
|
|
||||||
|
|
||||||
let raw
|
|
||||||
try {
|
|
||||||
raw = fs.readFileSync(membersPath, 'utf8')
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Fehler beim Lesen von members.json:', e)
|
|
||||||
process.exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
let members
|
|
||||||
try {
|
|
||||||
members = JSON.parse(raw)
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Fehler beim Parsen von members.json:', e)
|
|
||||||
process.exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!Array.isArray(members)) {
|
|
||||||
console.error('members.json ist kein Array!')
|
|
||||||
process.exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
let changed = 0
|
|
||||||
for (const m of members) {
|
|
||||||
if (!m.visibility) m.visibility = {}
|
|
||||||
if (m.visibility.showBirthday !== true) {
|
|
||||||
m.visibility.showBirthday = true
|
|
||||||
changed++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (changed > 0) {
|
|
||||||
fs.writeFileSync(membersPath, JSON.stringify(members, null, 2), 'utf8')
|
|
||||||
console.log(`Flag für ${changed} Mitglieder gesetzt.`)
|
|
||||||
} else {
|
|
||||||
console.log('Alle Mitglieder hatten das Flag bereits gesetzt.')
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
// Script: set-all-birthday-visible.mjs
|
|
||||||
// Setzt für alle Mitglieder das Flag visibility.showBirthday auf true (mit Entschlüsselung)
|
|
||||||
|
|
||||||
import { readMembers, writeMembers } from './utils/members.js';
|
|
||||||
import dotenv from 'dotenv';
|
|
||||||
import path from 'path';
|
|
||||||
import { fileURLToPath } from 'url';
|
|
||||||
|
|
||||||
dotenv.config({ path: path.resolve(process.cwd(), '.env') });
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
let members = await readMembers();
|
|
||||||
if (!Array.isArray(members)) {
|
|
||||||
console.error('members.json ist kein Array!')
|
|
||||||
process.exit(1)
|
|
||||||
}
|
|
||||||
let changed = 0;
|
|
||||||
for (const m of members) {
|
|
||||||
if (!m.visibility) m.visibility = {};
|
|
||||||
if (m.visibility.showBirthday !== true) {
|
|
||||||
m.visibility.showBirthday = true;
|
|
||||||
changed++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (changed > 0) {
|
|
||||||
await writeMembers(members);
|
|
||||||
console.log(`Flag für ${changed} Mitglieder gesetzt.`);
|
|
||||||
} else {
|
|
||||||
console.log('Alle Mitglieder hatten das Flag bereits gesetzt.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
main();
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
// Script: set-all-visibility-flags.mjs
|
|
||||||
// Setzt für alle Mitglieder in allen relevanten Dateien alle visibility-Flags auf true (inkl. Entschlüsselung)
|
|
||||||
|
|
||||||
import { readMembers, writeMembers } from './utils/members.js';
|
|
||||||
import dotenv from 'dotenv';
|
|
||||||
import path from 'path';
|
|
||||||
import { fileURLToPath } from 'url';
|
|
||||||
import fs from 'fs/promises';
|
|
||||||
|
|
||||||
dotenv.config({ path: path.resolve(process.cwd(), '.env') });
|
|
||||||
|
|
||||||
const usersPath = path.resolve(process.cwd(), 'server/data/users.json');
|
|
||||||
|
|
||||||
async function updateVisibility(obj) {
|
|
||||||
let changed = 0;
|
|
||||||
if (Array.isArray(obj)) {
|
|
||||||
for (const m of obj) {
|
|
||||||
if (!m.visibility) m.visibility = {};
|
|
||||||
if (m.visibility.showEmail !== true) { m.visibility.showEmail = true; changed++; }
|
|
||||||
if (m.visibility.showPhone !== true) { m.visibility.showPhone = true; changed++; }
|
|
||||||
if (m.visibility.showAddress !== true) { m.visibility.showAddress = true; changed++; }
|
|
||||||
if (m.visibility.showBirthday !== true) { m.visibility.showBirthday = true; changed++; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return changed;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function updateUsersFile() {
|
|
||||||
let changed = 0;
|
|
||||||
try {
|
|
||||||
let raw = await fs.readFile(usersPath, 'utf8');
|
|
||||||
let users;
|
|
||||||
if (raw.trim().startsWith('v2:')) {
|
|
||||||
// encrypted, try to use decryptObject from encryption.js
|
|
||||||
const { decryptObject } = await import('./utils/encryption.js');
|
|
||||||
const key = process.env.ENCRYPTION_KEY || 'local_development_encryption_key_change_in_production';
|
|
||||||
users = decryptObject(raw, key);
|
|
||||||
} else {
|
|
||||||
users = JSON.parse(raw);
|
|
||||||
}
|
|
||||||
changed = await updateVisibility(users);
|
|
||||||
// write back (encrypted if vorher encrypted)
|
|
||||||
if (raw.trim().startsWith('v2:')) {
|
|
||||||
const { encryptObject } = await import('./utils/encryption.js');
|
|
||||||
const key = process.env.ENCRYPTION_KEY || 'local_development_encryption_key_change_in_production';
|
|
||||||
const encrypted = encryptObject(users, key);
|
|
||||||
await fs.writeFile(usersPath, encrypted, 'utf8');
|
|
||||||
} else {
|
|
||||||
await fs.writeFile(usersPath, JSON.stringify(users, null, 2), 'utf8');
|
|
||||||
}
|
|
||||||
return changed;
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Fehler beim Bearbeiten von users.json:', e);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
let changedMembers = 0;
|
|
||||||
let changedUsers = 0;
|
|
||||||
// members.json (manuelle Mitglieder)
|
|
||||||
let members = await readMembers();
|
|
||||||
changedMembers = await updateVisibility(members);
|
|
||||||
if (changedMembers > 0) {
|
|
||||||
await writeMembers(members);
|
|
||||||
}
|
|
||||||
// users.json (Login-System)
|
|
||||||
changedUsers = await updateUsersFile();
|
|
||||||
console.log(`members.json: ${changedMembers} Änderungen, users.json: ${changedUsers} Änderungen`);
|
|
||||||
}
|
|
||||||
|
|
||||||
main();
|
|
||||||
@@ -2,6 +2,7 @@ import { promises as fs } from 'fs'
|
|||||||
import path from 'path'
|
import path from 'path'
|
||||||
|
|
||||||
const DEFAULT_MAX_BACKUPS = Number.parseInt(process.env.DATA_FILE_BACKUP_MAX || '40', 10)
|
const DEFAULT_MAX_BACKUPS = Number.parseInt(process.env.DATA_FILE_BACKUP_MAX || '40', 10)
|
||||||
|
let backupSequence = 0
|
||||||
|
|
||||||
function getProjectRoot() {
|
function getProjectRoot() {
|
||||||
const cwd = process.cwd()
|
const cwd = process.cwd()
|
||||||
@@ -30,8 +31,9 @@ function sanitizeFileKey(filePath) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildBackupName(date = new Date()) {
|
function buildBackupName(date = new Date()) {
|
||||||
|
const sequence = (backupSequence++).toString(36).padStart(6, '0')
|
||||||
const randomSuffix = Math.random().toString(36).slice(2, 8)
|
const randomSuffix = Math.random().toString(36).slice(2, 8)
|
||||||
return `${date.toISOString().replace(/[:.]/g, '-')}-${randomSuffix}.bak`
|
return `${date.toISOString().replace(/[:.]/g, '-')}-${sequence}-${randomSuffix}.bak`
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveDataFileBackupPath(backupDir, backupName) {
|
export function resolveDataFileBackupPath(backupDir, backupName) {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import nodemailer from 'nodemailer'
|
|||||||
import fs from 'fs/promises'
|
import fs from 'fs/promises'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import { getServerDataPath } from './paths.js'
|
import { getServerDataPath } from './paths.js'
|
||||||
|
import { isHiddenUser, migrateUserRoles, readUsers } from './auth.js'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets the correct data path for config files
|
* Gets the correct data path for config files
|
||||||
@@ -34,23 +35,45 @@ async function loadConfig() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function envFlagEnabled(value) {
|
||||||
|
return ['1', 'true', 'yes', 'on'].includes(String(value || '').trim().toLowerCase())
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldUseDeveloperRecipients() {
|
||||||
|
if (process.env.DEBUG !== undefined) return envFlagEnabled(process.env.DEBUG)
|
||||||
|
return process.env.NODE_ENV !== 'production' || process.env.APP_ENV === 'test'
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets email recipients based on membership type and environment
|
* Gets email recipients based on membership type and environment
|
||||||
* @param {Object} data - Form data
|
* @param {Object} data - Form data
|
||||||
* @param {Object} config - Configuration
|
* @param {Object} config - Configuration
|
||||||
* @returns {Array<string>} Email addresses
|
* @returns {Array<string>} Email addresses
|
||||||
*/
|
*/
|
||||||
function getEmailRecipients(data, config) {
|
async function collectBoardUserRecipients() {
|
||||||
const isProduction = process.env.NODE_ENV === 'production' && process.env.APP_ENV !== 'test'
|
try {
|
||||||
|
const users = await readUsers()
|
||||||
if (!isProduction) {
|
return users
|
||||||
|
.filter(user => user && user.active !== false && !isHiddenUser(user))
|
||||||
|
.map(user => migrateUserRoles({ ...user }))
|
||||||
|
.filter(user => Array.isArray(user.roles) && user.roles.includes('vorstand'))
|
||||||
|
.map(user => String(user.email || '').trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Could not load board recipients from users.json:', error.message || error)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getEmailRecipients(data, config) {
|
||||||
|
if (shouldUseDeveloperRecipients()) {
|
||||||
return ['tsschulz@tsschulz.de']
|
return ['tsschulz@tsschulz.de']
|
||||||
}
|
}
|
||||||
|
|
||||||
const recipients = []
|
const recipients = await collectBoardUserRecipients()
|
||||||
|
|
||||||
// Config uses a 'vorstand' object with nested roles; collect all emails
|
// Fallback for legacy installations where Vorstand members are only configured in config.json.
|
||||||
if (config.vorstand && typeof config.vorstand === 'object') {
|
if (recipients.length === 0 && config.vorstand && typeof config.vorstand === 'object') {
|
||||||
Object.values(config.vorstand).forEach((member) => {
|
Object.values(config.vorstand).forEach((member) => {
|
||||||
if (member && member.email && typeof member.email === 'string' && member.email.trim() !== '') {
|
if (member && member.email && typeof member.email === 'string' && member.email.trim() !== '') {
|
||||||
recipients.push(member.email.trim())
|
recipients.push(member.email.trim())
|
||||||
@@ -59,7 +82,7 @@ function getEmailRecipients(data, config) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// For minors, also add first trainer email if configured (trainer is an array)
|
// For minors, also add first trainer email if configured (trainer is an array)
|
||||||
if (!data.isVolljaehrig && Array.isArray(config.trainer) && config.trainer.length > 0 && config.trainer[0].email) {
|
if (data.isVolljaehrig === false && Array.isArray(config.trainer) && config.trainer.length > 0 && config.trainer[0].email) {
|
||||||
recipients.push(config.trainer[0].email)
|
recipients.push(config.trainer[0].email)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,11 +92,11 @@ function getEmailRecipients(data, config) {
|
|||||||
if (config.website && config.website.verantwortlicher && config.website.verantwortlicher.email) {
|
if (config.website && config.website.verantwortlicher && config.website.verantwortlicher.email) {
|
||||||
recipients.push(config.website.verantwortlicher.email)
|
recipients.push(config.website.verantwortlicher.email)
|
||||||
} else {
|
} else {
|
||||||
recipients.push('tsschulz@tsschulz.de')
|
throw new Error('Keine E-Mail-Empfänger in config.json konfiguriert.')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return recipients
|
return [...new Set(recipients)]
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -111,7 +134,7 @@ function createTransporter() {
|
|||||||
export async function sendMembershipEmail(data, pdfPath) {
|
export async function sendMembershipEmail(data, pdfPath) {
|
||||||
try {
|
try {
|
||||||
const config = await loadConfig()
|
const config = await loadConfig()
|
||||||
const recipients = getEmailRecipients(data, config)
|
const recipients = await getEmailRecipients(data, config)
|
||||||
|
|
||||||
// Create transporter
|
// Create transporter
|
||||||
const transporter = createTransporter()
|
const transporter = createTransporter()
|
||||||
@@ -167,7 +190,7 @@ Das ausgefüllte Formular ist als Anhang verfügbar.`
|
|||||||
export async function sendRegistrationNotification(data) {
|
export async function sendRegistrationNotification(data) {
|
||||||
try {
|
try {
|
||||||
const config = await loadConfig()
|
const config = await loadConfig()
|
||||||
const recipients = getEmailRecipients(data, config)
|
const recipients = await getEmailRecipients(data, config)
|
||||||
|
|
||||||
// Create transporter
|
// Create transporter
|
||||||
const transporter = createTransporter()
|
const transporter = createTransporter()
|
||||||
|
|||||||
451
server/utils/notification-scheduler.js
Normal file
451
server/utils/notification-scheduler.js
Normal file
@@ -0,0 +1,451 @@
|
|||||||
|
import { promises as fs } from 'fs'
|
||||||
|
import path from 'path'
|
||||||
|
import { readUsers, isHiddenUser } from './auth.js'
|
||||||
|
import { readMembers } from './members.js'
|
||||||
|
import { readTermine } from './termine.js'
|
||||||
|
import { readNews } from './news.js'
|
||||||
|
import { getServerDataPath } from './paths.js'
|
||||||
|
import { getDefaultSpielplanSeason, readSpielplanData } from './spielplan-data.js'
|
||||||
|
import { notificationSettingsForUser } from './notification-settings.js'
|
||||||
|
import { sendPushToUsers } from './push-notifications.js'
|
||||||
|
import { info as loggerInfo, error as loggerError } from './logger.js'
|
||||||
|
|
||||||
|
const TIME_ZONE = 'Europe/Berlin'
|
||||||
|
const STATE_FILE = getServerDataPath('notification-scheduler-state.json')
|
||||||
|
const DATE_FORMATTER = new Intl.DateTimeFormat('en-CA', {
|
||||||
|
timeZone: TIME_ZONE,
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit'
|
||||||
|
})
|
||||||
|
const TIME_FORMATTER = new Intl.DateTimeFormat('en-GB', {
|
||||||
|
timeZone: TIME_ZONE,
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: false
|
||||||
|
})
|
||||||
|
const DATE_TIME_FORMATTER = new Intl.DateTimeFormat('de-DE', {
|
||||||
|
timeZone: TIME_ZONE,
|
||||||
|
weekday: 'short',
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: false
|
||||||
|
})
|
||||||
|
|
||||||
|
function berlinDateKey(date = new Date()) {
|
||||||
|
return DATE_FORMATTER.format(date)
|
||||||
|
}
|
||||||
|
|
||||||
|
function berlinTimeKey(date = new Date()) {
|
||||||
|
return TIME_FORMATTER.format(date)
|
||||||
|
}
|
||||||
|
|
||||||
|
function addDays(date, days) {
|
||||||
|
const next = new Date(date)
|
||||||
|
next.setUTCDate(next.getUTCDate() + days)
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeText(value) {
|
||||||
|
return String(value || '')
|
||||||
|
.normalize('NFKD')
|
||||||
|
.replace(/[\u0300-\u036f]/g, '')
|
||||||
|
.replace(/[’'`]/g, '')
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, ' ')
|
||||||
|
.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function slugify(value) {
|
||||||
|
return normalizeText(value).replace(/\s+/g, '-')
|
||||||
|
}
|
||||||
|
|
||||||
|
function userDisplayName(user) {
|
||||||
|
return String(user?.name || `${user?.firstName || ''} ${user?.lastName || ''}`.trim() || '').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasTimedSettings(user) {
|
||||||
|
const settings = notificationSettingsForUser(user)
|
||||||
|
return settings.newNews || settings.eventsToday || settings.eventsTomorrow || settings.ownTeamMatches ||
|
||||||
|
settings.allTeamMatches || settings.selectedTeamSlugs.length > 0 || settings.birthdays
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readState() {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(await fs.readFile(STATE_FILE, 'utf8'))
|
||||||
|
return parsed && typeof parsed === 'object' ? parsed : {}
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.code !== 'ENOENT') loggerError('[notification-scheduler] Status konnte nicht gelesen werden:', { error })
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeState(state) {
|
||||||
|
await fs.mkdir(path.dirname(STATE_FILE), { recursive: true })
|
||||||
|
await fs.writeFile(STATE_FILE, `${JSON.stringify(state, null, 2)}\n`, 'utf8')
|
||||||
|
}
|
||||||
|
|
||||||
|
function pruneState(state, todayKey) {
|
||||||
|
const entries = Object.entries(state).filter(([key]) => key.startsWith(todayKey))
|
||||||
|
return Object.fromEntries(entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
function runKey(dateKey, time, category) {
|
||||||
|
return `${dateKey}:${time}:${category}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTerminDate(termin) {
|
||||||
|
const rawDate = String(termin?.datum || '').trim()
|
||||||
|
if (!rawDate) return null
|
||||||
|
const time = String(termin?.uhrzeit || '00:00').trim() || '00:00'
|
||||||
|
if (/^\d{4}-\d{2}-\d{2}$/.test(rawDate)) return new Date(`${rawDate}T${time.padStart(5, '0')}:00+02:00`)
|
||||||
|
const german = rawDate.match(/^(\d{1,2})\.(\d{1,2})\.(\d{4})$/)
|
||||||
|
if (german) {
|
||||||
|
const [, day, month, year] = german
|
||||||
|
return new Date(`${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}T${time.padStart(5, '0')}:00+02:00`)
|
||||||
|
}
|
||||||
|
const parsed = new Date(rawDate)
|
||||||
|
return Number.isNaN(parsed.getTime()) ? null : parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventsOn(termine, dateKey) {
|
||||||
|
return termine
|
||||||
|
.map(termin => ({ termin, date: parseTerminDate(termin) }))
|
||||||
|
.filter(entry => entry.date && berlinDateKey(entry.date) === dateKey)
|
||||||
|
.map(entry => ({ title: entry.termin.titel, source: 'termin', item: entry.termin }))
|
||||||
|
}
|
||||||
|
|
||||||
|
function expiringNewsOn(news, dateKey) {
|
||||||
|
return news
|
||||||
|
.filter(item => !item?.isHidden && item?.expiresAt)
|
||||||
|
.map(item => ({ item, date: new Date(item.expiresAt) }))
|
||||||
|
.filter(entry => !Number.isNaN(entry.date.getTime()) && berlinDateKey(entry.date) === dateKey)
|
||||||
|
.map(entry => ({ title: entry.item.title, source: 'news', item: entry.item }))
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatNewsExpirySummary(news, fallback) {
|
||||||
|
if (news.length === 1) return String(news[0].title || fallback).slice(0, 140)
|
||||||
|
return `${news.length} News laufen heute ab: ${news.slice(0, 3).map(item => item.title).filter(Boolean).join(', ')}`.slice(0, 140)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatEventSummary(events, fallback) {
|
||||||
|
if (events.length === 1) return String(events[0].title || fallback).slice(0, 140)
|
||||||
|
return `${events.length} Einträge: ${events.slice(0, 3).map(event => event.title).filter(Boolean).join(', ')}`.slice(0, 140)
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchDate(row) {
|
||||||
|
const timestamp = Number(row?.Timestamp)
|
||||||
|
if (Number.isFinite(timestamp) && timestamp > 0) return new Date(timestamp * 1000)
|
||||||
|
const raw = String(row?.Termin || '').trim()
|
||||||
|
const match = raw.match(/^(\d{1,2})\.(\d{1,2})\.(\d{4})(?:\s+(\d{1,2}:\d{2}))?/)
|
||||||
|
if (!match) return null
|
||||||
|
const [, day, month, year, time = '00:00'] = match
|
||||||
|
const parsed = new Date(`${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}T${time}:00+02:00`)
|
||||||
|
return Number.isNaN(parsed.getTime()) ? null : parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchTeams(row) {
|
||||||
|
return [row?.HeimMannschaft, row?.GastMannschaft].map(value => String(value || '').trim()).filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesOn(rows, dateKey) {
|
||||||
|
return rows
|
||||||
|
.map(row => ({ row, date: matchDate(row) }))
|
||||||
|
.filter(entry => entry.date && berlinDateKey(entry.date) === dateKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchSummary(matches, fallback) {
|
||||||
|
if (!matches.length) return fallback
|
||||||
|
if (matches.length === 1) {
|
||||||
|
const teams = matchTeams(matches[0].row).join(' - ')
|
||||||
|
const when = DATE_TIME_FORMATTER.format(matches[0].date)
|
||||||
|
return `${when}: ${teams}`.slice(0, 140)
|
||||||
|
}
|
||||||
|
return `${matches.length} Punktspiele am ${dateKeyToGerman(berlinDateKey(matches[0]?.date || new Date()))}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function dateKeyToGerman(dateKey) {
|
||||||
|
const [year, month, day] = String(dateKey).split('-')
|
||||||
|
return `${day}.${month}.${year}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchIdentity(match) {
|
||||||
|
const row = match?.row || {}
|
||||||
|
const explicit = row.BegegnungNr || row.MeetingId || row.meeting_id || row.SpielNr
|
||||||
|
if (explicit) return `id:${explicit}`
|
||||||
|
return [
|
||||||
|
berlinDateKey(match?.date || matchDate(row) || new Date(0)),
|
||||||
|
String(row.Timestamp || ''),
|
||||||
|
...matchTeams(row).map(slugify)
|
||||||
|
].join('|')
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueMatches(matches) {
|
||||||
|
const seen = new Set()
|
||||||
|
const unique = []
|
||||||
|
for (const match of matches) {
|
||||||
|
const identity = matchIdentity(match)
|
||||||
|
if (seen.has(identity)) continue
|
||||||
|
seen.add(identity)
|
||||||
|
unique.push(match)
|
||||||
|
}
|
||||||
|
return unique
|
||||||
|
}
|
||||||
|
|
||||||
|
function localTeamSlugForSide(row, side, teamRows) {
|
||||||
|
const clubName = normalizeText(row?.[`${side}VereinName`] || row?.[`${side}Mannschaft`] || '')
|
||||||
|
if (!clubName.includes('harheimer tc')) return []
|
||||||
|
|
||||||
|
const ageClass = String(row?.[`${side}MannschaftAltersklasse`] || row?.Altersklasse || '')
|
||||||
|
const number = String(row?.[`${side}MannschaftNr`] || '1').trim() || '1'
|
||||||
|
const base = /jugend/i.test(ageClass) ? 'Jugend' : 'Erwachsene'
|
||||||
|
const candidate = slugify(`${base} ${number}`)
|
||||||
|
const known = new Set(teamRows.map(row => slugify(row.team)).filter(Boolean))
|
||||||
|
|
||||||
|
if (!known.size || known.has(candidate)) return [candidate]
|
||||||
|
if (/jugend/i.test(ageClass)) {
|
||||||
|
return teamRows
|
||||||
|
.map(row => slugify(row.team))
|
||||||
|
.filter(slug => slug.startsWith('jugend'))
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
function teamSlugsForMatch(match, teamRows = []) {
|
||||||
|
const row = match?.row || {}
|
||||||
|
return [...new Set([
|
||||||
|
...matchTeams(row).map(slugify),
|
||||||
|
...localTeamSlugForSide(row, 'Heim', teamRows),
|
||||||
|
...localTeamSlugForSide(row, 'Gast', teamRows)
|
||||||
|
].filter(Boolean))]
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readTeamMembers(season) {
|
||||||
|
const fileNames = season ? [`mannschaften_${season}.csv`, 'mannschaften.csv'] : ['mannschaften.csv']
|
||||||
|
for (const fileName of fileNames) {
|
||||||
|
try {
|
||||||
|
const raw = await fs.readFile(getServerDataPath('public-data', fileName), 'utf8')
|
||||||
|
const lines = raw.split(/\r?\n/).filter(line => line.trim())
|
||||||
|
const rows = []
|
||||||
|
for (const line of lines.slice(1)) {
|
||||||
|
const values = parseCsvLine(line)
|
||||||
|
rows.push({ team: values[0] || '', captain: values[6] || '', players: values[7] || '' })
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.code !== 'ENOENT') loggerError('[notification-scheduler] Mannschaften konnten nicht gelesen werden:', { fileName, error })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCsvLine(line) {
|
||||||
|
const values = []
|
||||||
|
let current = ''
|
||||||
|
let inQuotes = false
|
||||||
|
for (let index = 0; index < line.length; index += 1) {
|
||||||
|
const char = line[index]
|
||||||
|
const next = line[index + 1]
|
||||||
|
if (char === '"' && inQuotes && next === '"') {
|
||||||
|
current += '"'
|
||||||
|
index += 1
|
||||||
|
} else if (char === '"') {
|
||||||
|
inQuotes = !inQuotes
|
||||||
|
} else if (char === ',' && !inQuotes) {
|
||||||
|
values.push(current.trim())
|
||||||
|
current = ''
|
||||||
|
} else {
|
||||||
|
current += char
|
||||||
|
}
|
||||||
|
}
|
||||||
|
values.push(current.trim())
|
||||||
|
return values
|
||||||
|
}
|
||||||
|
|
||||||
|
function personNameMatches(candidate, userName) {
|
||||||
|
const normalizedCandidate = normalizeText(candidate)
|
||||||
|
const normalizedUserName = normalizeText(userName)
|
||||||
|
if (!normalizedCandidate || !normalizedUserName) return false
|
||||||
|
if (normalizedCandidate === normalizedUserName) return true
|
||||||
|
|
||||||
|
const candidateParts = new Set(normalizedCandidate.split(' ').filter(Boolean))
|
||||||
|
const userParts = normalizedUserName.split(' ').filter(Boolean)
|
||||||
|
return userParts.length >= 2 && userParts.every(part => candidateParts.has(part))
|
||||||
|
}
|
||||||
|
|
||||||
|
function ownTeamSlugsForUser(user, teamRows) {
|
||||||
|
const name = userDisplayName(user)
|
||||||
|
if (!normalizeText(name)) return []
|
||||||
|
return teamRows
|
||||||
|
.filter(row => personNameMatches(row.captain, name) ||
|
||||||
|
String(row.players || '').replace(/\r?\n/g, ';').split(/[;,]+/).some(player => personNameMatches(player, name)))
|
||||||
|
.map(row => slugify(row.team))
|
||||||
|
.filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectedMatchesForUser(_user, settings, matches, teamRows = []) {
|
||||||
|
const selected = new Set((settings.selectedTeamSlugs || []).map(slugify))
|
||||||
|
if (selected.size === 0) return []
|
||||||
|
return matches.filter(match => teamSlugsForMatch(match, teamRows).some(slug => selected.has(slug)))
|
||||||
|
}
|
||||||
|
|
||||||
|
function ownMatchesForUser(user, settings, matches, teamRows) {
|
||||||
|
if (settings.ownTeamMatches === false) return []
|
||||||
|
const ownSlugs = new Set(ownTeamSlugsForUser(user, teamRows))
|
||||||
|
if (ownSlugs.size === 0) return []
|
||||||
|
return matches.filter(match => teamSlugsForMatch(match, teamRows).some(slug => ownSlugs.has(slug)))
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesForUser(user, settings, context) {
|
||||||
|
if (settings.allTeamMatches) return uniqueMatches(context.allMatches)
|
||||||
|
return uniqueMatches([
|
||||||
|
...selectedMatchesForUser(user, settings, context.allMatches, context.teamRows),
|
||||||
|
...ownMatchesForUser(user, settings, context.allMatches, context.teamRows)
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
function notificationSeasonForSettings(settings, fallbackSeason) {
|
||||||
|
return String(settings?.selectedTeamSeason || fallbackSeason || '').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMatchContextForSeasons(seasons, dateKey, tomorrowKey) {
|
||||||
|
const entries = await Promise.all(seasons.map(async season => {
|
||||||
|
const [spielplan, teamRows] = await Promise.all([readSpielplanData({ season }), readTeamMembers(season)])
|
||||||
|
const todayMatches = matchesOn(spielplan.data || [], dateKey)
|
||||||
|
const tomorrowMatches = matchesOn(spielplan.data || [], tomorrowKey)
|
||||||
|
return [season, { spielplan, teamRows, todayMatches, tomorrowMatches, allMatches: [...todayMatches, ...tomorrowMatches] }]
|
||||||
|
}))
|
||||||
|
return Object.fromEntries(entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseBirthday(value) {
|
||||||
|
const raw = String(value || '').trim()
|
||||||
|
if (!raw) return null
|
||||||
|
const iso = raw.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/)
|
||||||
|
if (iso) return { month: Number(iso[2]), day: Number(iso[3]) }
|
||||||
|
const german = raw.match(/^(\d{1,2})\.(\d{1,2})(?:\.(\d{2,4}))?$/)
|
||||||
|
if (german) return { month: Number(german[2]), day: Number(german[1]) }
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasBirthdayNotificationConsent(person) {
|
||||||
|
return person?.visibility?.showBirthday === true || person?.showBirthday === true
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBirthdaySummary(names) {
|
||||||
|
const visibleNames = names.map(name => String(name || '').trim()).filter(Boolean)
|
||||||
|
if (visibleNames.length === 1) return `${visibleNames[0]} hat heute Geburtstag.`
|
||||||
|
return `Geburtstage heute: ${visibleNames.slice(0, 5).join(', ')}${visibleNames.length > 5 ? ` und ${visibleNames.length - 5} weitere` : ''}.`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function birthdaysOn(dateKey) {
|
||||||
|
const [, month, day] = dateKey.split('-').map(Number)
|
||||||
|
const [manualMembers, users] = await Promise.all([readMembers(), readUsers()])
|
||||||
|
const people = []
|
||||||
|
for (const member of manualMembers) {
|
||||||
|
if (member?.active === false) continue
|
||||||
|
if (!hasBirthdayNotificationConsent(member)) continue
|
||||||
|
const birthday = parseBirthday(member.geburtsdatum || member.birthday)
|
||||||
|
if (birthday?.month === month && birthday?.day === day) {
|
||||||
|
people.push(String(member.name || `${member.firstName || ''} ${member.lastName || ''}`.trim()).trim())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const user of users) {
|
||||||
|
if (isHiddenUser(user) || user?.active === false) continue
|
||||||
|
if (!hasBirthdayNotificationConsent(user)) continue
|
||||||
|
const birthday = parseBirthday(user.geburtsdatum || user.birthday)
|
||||||
|
if (birthday?.month === month && birthday?.day === day) {
|
||||||
|
people.push(userDisplayName(user))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...new Set(people.filter(Boolean))].sort((a, b) => a.localeCompare(b, 'de'))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendIfDue(state, dateKey, time, category, enabled, send, equivalentCategories = []) {
|
||||||
|
const key = runKey(dateKey, time, category)
|
||||||
|
const equivalentKeys = equivalentCategories.map(equivalentCategory => runKey(dateKey, time, equivalentCategory))
|
||||||
|
if (!enabled || state[key] || equivalentKeys.some(equivalentKey => state[equivalentKey])) return null
|
||||||
|
const result = await send()
|
||||||
|
state[key] = { at: new Date().toISOString(), result }
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runNotificationSchedulerTick(now = new Date()) {
|
||||||
|
const dateKey = berlinDateKey(now)
|
||||||
|
const time = berlinTimeKey(now)
|
||||||
|
const users = (await readUsers()).filter(user => !isHiddenUser(user) && hasTimedSettings(user))
|
||||||
|
const dueUsers = users.filter(user => notificationSettingsForUser(user).notificationTime === time)
|
||||||
|
if (!dueUsers.length) return { dueUsers: 0, time, dateKey }
|
||||||
|
|
||||||
|
let state = pruneState(await readState(), dateKey)
|
||||||
|
const tomorrowKey = berlinDateKey(addDays(now, 1))
|
||||||
|
const [termine, news, defaultSeason] = await Promise.all([readTermine(), readNews(), getDefaultSpielplanSeason()])
|
||||||
|
const todayTermine = eventsOn(termine, dateKey)
|
||||||
|
const tomorrowTermine = eventsOn(termine, tomorrowKey)
|
||||||
|
const expiringNewsToday = expiringNewsOn(news, dateKey)
|
||||||
|
const todayEvents = todayTermine
|
||||||
|
const tomorrowEvents = tomorrowTermine
|
||||||
|
const seasonsForMatches = [...new Set(dueUsers
|
||||||
|
.map(user => notificationSeasonForSettings(notificationSettingsForUser(user), defaultSeason))
|
||||||
|
.filter(Boolean))]
|
||||||
|
const matchContexts = await loadMatchContextForSeasons(seasonsForMatches, dateKey, tomorrowKey)
|
||||||
|
const todaysBirthdays = await birthdaysOn(dateKey)
|
||||||
|
const results = {}
|
||||||
|
|
||||||
|
results.eventsToday = await sendIfDue(state, dateKey, time, 'eventsToday', todayEvents.length > 0, () => sendPushToUsers({
|
||||||
|
title: 'Termine heute',
|
||||||
|
body: formatEventSummary(todayEvents, 'Heute stehen Termine an.'),
|
||||||
|
data: { type: 'events_today', date: dateKey },
|
||||||
|
predicate: (user, settings) => settings.notificationTime === time && settings.eventsToday,
|
||||||
|
failureLabel: 'FCM Termine-heute-Push'
|
||||||
|
}))
|
||||||
|
|
||||||
|
results.expiringNews = await sendIfDue(state, dateKey, time, 'expiringNews', expiringNewsToday.length > 0, () => sendPushToUsers({
|
||||||
|
title: 'News laufen heute ab',
|
||||||
|
body: formatNewsExpirySummary(expiringNewsToday, 'Heute laufen News ab.'),
|
||||||
|
data: { type: 'news_expiring', date: dateKey },
|
||||||
|
predicate: (_user, settings) => settings.notificationTime === time && settings.newNews && !settings.eventsToday,
|
||||||
|
failureLabel: 'FCM News-Ablauf-Push'
|
||||||
|
}))
|
||||||
|
|
||||||
|
results.eventsTomorrow = await sendIfDue(state, dateKey, time, 'eventsTomorrow', tomorrowEvents.length > 0, () => sendPushToUsers({
|
||||||
|
title: 'Termine morgen',
|
||||||
|
body: formatEventSummary(tomorrowEvents, 'Morgen stehen Termine an.'),
|
||||||
|
data: { type: 'events_tomorrow', date: tomorrowKey },
|
||||||
|
predicate: (user, settings) => settings.notificationTime === time && settings.eventsTomorrow,
|
||||||
|
failureLabel: 'FCM Termine-morgen-Push'
|
||||||
|
}))
|
||||||
|
|
||||||
|
const teamMatchResults = []
|
||||||
|
for (const [season, context] of Object.entries(matchContexts)) {
|
||||||
|
teamMatchResults.push(await sendIfDue(state, dateKey, time, 'teamMatches:' + season, context.allMatches.length > 0, () => sendPushToUsers({
|
||||||
|
title: 'Punktspiele',
|
||||||
|
body: matchSummary(context.allMatches, 'Es stehen Punktspiele an.'),
|
||||||
|
data: { type: 'team_matches', date: dateKey, season },
|
||||||
|
bodyForUser: (user, settings) => matchSummary(matchesForUser(user, settings, context), 'Es stehen Punktspiele an.'),
|
||||||
|
predicate: (user, settings) => settings.notificationTime === time &&
|
||||||
|
notificationSeasonForSettings(settings, defaultSeason) === season &&
|
||||||
|
matchesForUser(user, settings, context).length > 0,
|
||||||
|
failureLabel: 'FCM Punktspiele-Push'
|
||||||
|
}), ['allTeamMatches:' + season, 'selectedTeamMatches:' + season, 'ownTeamMatches:' + season]))
|
||||||
|
}
|
||||||
|
results.teamMatches = teamMatchResults.some(Boolean)
|
||||||
|
results.allTeamMatches = results.teamMatches
|
||||||
|
results.selectedTeamMatches = results.teamMatches
|
||||||
|
results.ownTeamMatches = results.teamMatches
|
||||||
|
|
||||||
|
results.birthdays = await sendIfDue(state, dateKey, time, 'birthdays', todaysBirthdays.length > 0, () => sendPushToUsers({
|
||||||
|
title: 'Geburtstage heute',
|
||||||
|
body: formatBirthdaySummary(todaysBirthdays),
|
||||||
|
data: { type: 'birthdays', date: dateKey },
|
||||||
|
predicate: (_user, settings) => settings.notificationTime === time && settings.birthdays,
|
||||||
|
failureLabel: 'FCM Geburtstags-Push'
|
||||||
|
}))
|
||||||
|
|
||||||
|
await writeState(state)
|
||||||
|
loggerInfo('[notification-scheduler] Lauf abgeschlossen', { dateKey, time, dueUsers: dueUsers.length, results })
|
||||||
|
return { dateKey, time, dueUsers: dueUsers.length, results }
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import crypto from 'crypto'
|
import crypto from 'crypto'
|
||||||
import { promises as fs } from 'fs'
|
import { promises as fs } from 'fs'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import { readUsers, writeUsers, isHiddenUser } from './auth.js'
|
import { readUsers, writeUsers, isHiddenUser, migrateUserRoles } from './auth.js'
|
||||||
import { notificationSettingsForUser } from './notification-settings.js'
|
import { notificationSettingsForUser } from './notification-settings.js'
|
||||||
|
|
||||||
const FCM_SCOPE = 'https://www.googleapis.com/auth/firebase.messaging'
|
const FCM_SCOPE = 'https://www.googleapis.com/auth/firebase.messaging'
|
||||||
@@ -132,11 +132,29 @@ async function sendFcmMessage({ serviceAccount, accessToken, token, title, body,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function sendNewNewsPush(news) {
|
function isStaleFcmTokenError(error) {
|
||||||
|
return /UNREGISTERED|NOT_FOUND|INVALID_ARGUMENT/.test(String(error?.message || error || ''))
|
||||||
|
}
|
||||||
|
|
||||||
|
function notificationIdFor(value) {
|
||||||
|
return String(value || Date.now()).split('').reduce((acc, char) => acc + char.charCodeAt(0), 0).toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
function userRoles(user) {
|
||||||
|
const migrated = migrateUserRoles({ ...(user || {}) })
|
||||||
|
return Array.isArray(migrated.roles) ? migrated.roles : []
|
||||||
|
}
|
||||||
|
|
||||||
|
function isVorstandUser(user) {
|
||||||
|
const roles = userRoles(user)
|
||||||
|
return roles.includes('admin') || roles.includes('vorstand')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendPushToUsers({ title, body, data = {}, predicate, bodyForUser, dataForUser, failureLabel = 'FCM-Push' }) {
|
||||||
const serviceAccount = await readServiceAccount()
|
const serviceAccount = await readServiceAccount()
|
||||||
if (!serviceAccount) {
|
if (serviceAccount == null) {
|
||||||
console.warn('FCM nicht konfiguriert: FCM_SERVICE_ACCOUNT_JSON oder GOOGLE_APPLICATION_CREDENTIALS fehlt.')
|
console.warn('FCM nicht konfiguriert: FCM_SERVICE_ACCOUNT_JSON oder GOOGLE_APPLICATION_CREDENTIALS fehlt.')
|
||||||
return { sent: 0, skipped: true }
|
return { sent: 0, failed: 0, removed: 0, recipients: 0, tokenCount: 0, skipped: true }
|
||||||
}
|
}
|
||||||
const accessToken = await getAccessToken(serviceAccount)
|
const accessToken = await getAccessToken(serviceAccount)
|
||||||
const users = await readUsers()
|
const users = await readUsers()
|
||||||
@@ -146,41 +164,43 @@ export async function sendNewNewsPush(news) {
|
|||||||
let recipients = 0
|
let recipients = 0
|
||||||
let tokenCount = 0
|
let tokenCount = 0
|
||||||
let changed = false
|
let changed = false
|
||||||
const title = 'Neue News'
|
const baseData = Object.fromEntries(Object.entries(data).map(([key, value]) => [key, String(value ?? '')]))
|
||||||
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) {
|
for (const user of users) {
|
||||||
if (isHiddenUser(user)) continue
|
if (isHiddenUser(user)) continue
|
||||||
const settings = notificationSettingsForUser(user)
|
const settings = notificationSettingsForUser(user)
|
||||||
if (!settings.newNews) continue
|
if (predicate && !predicate(user, settings)) continue
|
||||||
|
const userBody = String(bodyForUser ? bodyForUser(user, settings) : body || '').slice(0, 240)
|
||||||
|
const userData = dataForUser ? dataForUser(user, settings) : {}
|
||||||
|
const payload = {
|
||||||
|
...baseData,
|
||||||
|
...Object.fromEntries(Object.entries(userData || {}).map(([key, value]) => [key, String(value ?? '')])),
|
||||||
|
title: String(title || 'Harheimer TC'),
|
||||||
|
body: userBody,
|
||||||
|
notificationId: String((userData && userData.notificationId) || data.notificationId || notificationIdFor([data.type || 'push', title, userBody].join(':')))
|
||||||
|
}
|
||||||
recipients += 1
|
recipients += 1
|
||||||
const tokens = pushTokensForUser(user)
|
const tokens = pushTokensForUser(user)
|
||||||
tokenCount += tokens.length
|
tokenCount += tokens.length
|
||||||
const validTokens = []
|
const validTokens = []
|
||||||
for (const entry of tokens) {
|
for (const entry of tokens) {
|
||||||
try {
|
try {
|
||||||
await sendFcmMessage({ serviceAccount, accessToken, token: entry.token, title, body, data })
|
await sendFcmMessage({ serviceAccount, accessToken, token: entry.token, title, body: userBody, data: payload })
|
||||||
sent += 1
|
sent += 1
|
||||||
validTokens.push(entry)
|
validTokens.push(entry)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
failed += 1
|
if (isStaleFcmTokenError(error)) {
|
||||||
console.error('FCM News-Push fehlgeschlagen:', error.message)
|
|
||||||
if (!/UNREGISTERED|NOT_FOUND|INVALID_ARGUMENT/.test(String(error.message))) {
|
|
||||||
validTokens.push(entry)
|
|
||||||
} else {
|
|
||||||
removed += 1
|
removed += 1
|
||||||
changed = true
|
changed = true
|
||||||
|
console.warn('FCM Push-Token entfernt:', { failureLabel, reason: error.message })
|
||||||
|
} else {
|
||||||
|
failed += 1
|
||||||
|
console.error('FCM Push fehlgeschlagen:', { failureLabel, message: error.message })
|
||||||
|
validTokens.push(entry)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (validTokens.length !== tokens.length) {
|
if (validTokens.length < tokens.length) {
|
||||||
user.pushTokens = validTokens
|
user.pushTokens = validTokens
|
||||||
changed = true
|
changed = true
|
||||||
}
|
}
|
||||||
@@ -188,3 +208,66 @@ export async function sendNewNewsPush(news) {
|
|||||||
if (changed) await writeUsers(users)
|
if (changed) await writeUsers(users)
|
||||||
return { sent, failed, removed, recipients, tokenCount, skipped: false }
|
return { sent, failed, removed, recipients, tokenCount, skipped: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function sendNewNewsPush(news) {
|
||||||
|
const title = 'Neue News'
|
||||||
|
const body = String(news.title || 'Neue Nachricht vom Harheimer TC').slice(0, 120)
|
||||||
|
return sendPushToUsers({
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
data: {
|
||||||
|
type: 'news',
|
||||||
|
newsId: String(news.id || ''),
|
||||||
|
notificationId: notificationIdFor(news.id || Date.now())
|
||||||
|
},
|
||||||
|
predicate: (_user, settings) => settings.newNews,
|
||||||
|
failureLabel: 'FCM News-Push'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendNewEventPush(termin) {
|
||||||
|
const title = 'Neuer Termin'
|
||||||
|
const body = String(termin?.titel || 'Ein neuer Termin wurde eingetragen.').slice(0, 120)
|
||||||
|
return sendPushToUsers({
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
data: {
|
||||||
|
type: 'event',
|
||||||
|
date: termin?.datum || '',
|
||||||
|
notificationId: notificationIdFor(`event:${termin?.datum || ''}:${termin?.titel || ''}`)
|
||||||
|
},
|
||||||
|
predicate: (_user, settings) => settings.newEvents,
|
||||||
|
failureLabel: 'FCM Termin-Push'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendNewContactRequestPush(contactRequest) {
|
||||||
|
const title = 'Neue Kontaktanfrage'
|
||||||
|
const body = String(contactRequest?.subject || contactRequest?.name || 'Eine neue Kontaktanfrage ist eingegangen.').slice(0, 120)
|
||||||
|
return sendPushToUsers({
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
data: {
|
||||||
|
type: 'contact_request',
|
||||||
|
notificationId: notificationIdFor(`contact:${contactRequest?.email || ''}:${contactRequest?.subject || ''}:${Date.now()}`)
|
||||||
|
},
|
||||||
|
predicate: (user, settings) => isVorstandUser(user) && settings.newContactRequest,
|
||||||
|
failureLabel: 'FCM Kontaktanfrage-Push'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendNewUserRegistrationPush(registration) {
|
||||||
|
const title = 'Neue Benutzerregistrierung'
|
||||||
|
const body = String(registration?.name || registration?.email || 'Eine neue Registrierung wartet auf Freigabe.').slice(0, 120)
|
||||||
|
return sendPushToUsers({
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
data: {
|
||||||
|
type: 'user_registration',
|
||||||
|
userId: registration?.id || '',
|
||||||
|
notificationId: notificationIdFor(`registration:${registration?.id || registration?.email || Date.now()}`)
|
||||||
|
},
|
||||||
|
predicate: (user, settings) => isVorstandUser(user) && settings.newUserRegistration,
|
||||||
|
failureLabel: 'FCM Registrierungs-Push'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
116
tests/email-service.spec.ts
Normal file
116
tests/email-service.spec.ts
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import fs from 'fs/promises'
|
||||||
|
|
||||||
|
vi.mock('nodemailer', () => {
|
||||||
|
const sendMail = vi.fn().mockResolvedValue({ messageId: 'test-message' })
|
||||||
|
const createTransport = vi.fn(() => ({ sendMail }))
|
||||||
|
return {
|
||||||
|
default: { createTransport },
|
||||||
|
createTransport
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
vi.mock('../server/utils/auth.js', () => ({
|
||||||
|
readUsers: vi.fn(),
|
||||||
|
migrateUserRoles: vi.fn((user) => {
|
||||||
|
if (!user) return user
|
||||||
|
if (Array.isArray(user.roles)) return user
|
||||||
|
if (user.role) {
|
||||||
|
user.roles = [user.role]
|
||||||
|
delete user.role
|
||||||
|
} else {
|
||||||
|
user.roles = ['mitglied']
|
||||||
|
}
|
||||||
|
return user
|
||||||
|
}),
|
||||||
|
isHiddenUser: vi.fn(user => user?.hidden === true || user?.invisible === true || user?.isHidden === true || user?.systemAccount === true || user?.accountType === 'playstore_review')
|
||||||
|
}))
|
||||||
|
|
||||||
|
const nodemailer = await import('nodemailer')
|
||||||
|
const authUtils = await import('../server/utils/auth.js')
|
||||||
|
const emailService = await import('../server/utils/email-service.js')
|
||||||
|
|
||||||
|
describe('Email service recipients', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
vi.clearAllMocks()
|
||||||
|
process.env.SMTP_USER = 'smtp@example.com'
|
||||||
|
process.env.SMTP_PASS = 'smtp-password'
|
||||||
|
authUtils.readUsers.mockResolvedValue([])
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
delete process.env.SMTP_USER
|
||||||
|
delete process.env.SMTP_PASS
|
||||||
|
delete process.env.NODE_ENV
|
||||||
|
delete process.env.APP_ENV
|
||||||
|
delete process.env.DEBUG
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sendet bei DEBUG=FALSE in production an Vorstand statt Entwickleradresse', async () => {
|
||||||
|
process.env.NODE_ENV = 'production'
|
||||||
|
process.env.APP_ENV = 'test'
|
||||||
|
process.env.DEBUG = 'FALSE'
|
||||||
|
vi.spyOn(fs, 'readFile').mockResolvedValue(JSON.stringify({
|
||||||
|
vorstand: {
|
||||||
|
vorsitzender: { email: 'vorstand@example.com' }
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
await emailService.sendRegistrationNotification({
|
||||||
|
name: 'Max Muster',
|
||||||
|
email: 'max@example.com',
|
||||||
|
phone: '069123456'
|
||||||
|
})
|
||||||
|
|
||||||
|
const transporter = nodemailer.default.createTransport.mock.results[0].value
|
||||||
|
expect(transporter.sendMail).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
to: 'vorstand@example.com'
|
||||||
|
}))
|
||||||
|
expect(transporter.sendMail.mock.calls[0][0].to).not.toContain('tsschulz@tsschulz.de')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('bevorzugt aktive Vorstand-Benutzer vor config.json', async () => {
|
||||||
|
process.env.NODE_ENV = 'production'
|
||||||
|
process.env.DEBUG = 'FALSE'
|
||||||
|
authUtils.readUsers.mockResolvedValue([
|
||||||
|
{ id: '1', email: 'rolle-vorstand@example.com', roles: ['vorstand'], active: true },
|
||||||
|
{ id: '2', email: 'inaktiv@example.com', roles: ['vorstand'], active: false },
|
||||||
|
{ id: '3', email: 'mitglied@example.com', roles: ['mitglied'], active: true }
|
||||||
|
])
|
||||||
|
vi.spyOn(fs, 'readFile').mockResolvedValue(JSON.stringify({
|
||||||
|
vorstand: {
|
||||||
|
vorsitzender: { email: 'config-vorstand@example.com' }
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
await emailService.sendRegistrationNotification({
|
||||||
|
name: 'Max Muster',
|
||||||
|
email: 'max@example.com'
|
||||||
|
})
|
||||||
|
|
||||||
|
const transporter = nodemailer.default.createTransport.mock.results[0].value
|
||||||
|
const to = transporter.sendMail.mock.calls[0][0].to
|
||||||
|
expect(to).toBe('rolle-vorstand@example.com')
|
||||||
|
expect(to).not.toContain('config-vorstand@example.com')
|
||||||
|
expect(to).not.toContain('inaktiv@example.com')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sendet nur bei explizitem DEBUG=true an die Entwickleradresse', async () => {
|
||||||
|
process.env.NODE_ENV = 'production'
|
||||||
|
process.env.DEBUG = 'true'
|
||||||
|
vi.spyOn(fs, 'readFile').mockResolvedValue(JSON.stringify({
|
||||||
|
vorstand: {
|
||||||
|
vorsitzender: { email: 'vorstand@example.com' }
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
await emailService.sendRegistrationNotification({
|
||||||
|
name: 'Max Muster',
|
||||||
|
email: 'max@example.com'
|
||||||
|
})
|
||||||
|
|
||||||
|
const transporter = nodemailer.default.createTransport.mock.results[0].value
|
||||||
|
expect(transporter.sendMail.mock.calls[0][0].to).toBe('tsschulz@tsschulz.de')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -51,12 +51,13 @@ import membersGetHandler from '../server/api/members.get.js'
|
|||||||
import membersPostHandler from '../server/api/members.post.js'
|
import membersPostHandler from '../server/api/members.post.js'
|
||||||
import membersDeleteHandler from '../server/api/members.delete.js'
|
import membersDeleteHandler from '../server/api/members.delete.js'
|
||||||
import membersBulkHandler from '../server/api/members/bulk.post.js'
|
import membersBulkHandler from '../server/api/members/bulk.post.js'
|
||||||
import membersBulkHandler from '../server/api/members/bulk.post.js'
|
|
||||||
import toggleMannschaftsspielerHandler from '../server/api/members/toggle-mannschaftsspieler.post.js'
|
import toggleMannschaftsspielerHandler from '../server/api/members/toggle-mannschaftsspieler.post.js'
|
||||||
|
|
||||||
describe('Members API Endpoints', () => {
|
describe('Members API Endpoints', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
|
authUtils.readUsers.mockResolvedValue([])
|
||||||
|
memberUtils.readMembers.mockResolvedValue([])
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('GET /api/members', () => {
|
describe('GET /api/members', () => {
|
||||||
@@ -100,6 +101,38 @@ describe('Members API Endpoints', () => {
|
|||||||
expect(response.members[0].name).toBe('Anna Muster')
|
expect(response.members[0].name).toBe('Anna Muster')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('liefert Geburtstags-Sichtbarkeit für Admin/Vorstand-Bearbeitung', async () => {
|
||||||
|
const event = createEvent({ cookies: { auth_token: 'token' } })
|
||||||
|
authUtils.verifyToken.mockReturnValue({ id: '1' })
|
||||||
|
memberUtils.readMembers.mockResolvedValue([
|
||||||
|
{ id: 'm1', firstName: 'Anna', lastName: 'Muster', geburtsdatum: '2000-01-01', visibility: { showBirthday: false } }
|
||||||
|
])
|
||||||
|
authUtils.readUsers.mockResolvedValue([])
|
||||||
|
authUtils.getUserFromToken.mockResolvedValue({ id: '1', role: 'admin' })
|
||||||
|
|
||||||
|
const response = await membersGetHandler(event)
|
||||||
|
|
||||||
|
expect(response.members).toHaveLength(1)
|
||||||
|
expect(response.members[0].showBirthday).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uebernimmt Geburtstags-Sichtbarkeit vom Login-Benutzer beim Merge', async () => {
|
||||||
|
const event = createEvent({ cookies: { auth_token: 'token' } })
|
||||||
|
authUtils.verifyToken.mockReturnValue({ id: '1' })
|
||||||
|
memberUtils.readMembers.mockResolvedValue([
|
||||||
|
{ id: 'm1', firstName: 'Anna', lastName: 'Muster', email: 'anna@club.de', geburtsdatum: '2000-01-01', visibility: { showBirthday: true } }
|
||||||
|
])
|
||||||
|
authUtils.readUsers.mockResolvedValue([
|
||||||
|
{ id: 'u1', name: 'Anna Muster', email: 'anna@club.de', active: true, visibility: { showBirthday: false } }
|
||||||
|
])
|
||||||
|
authUtils.getUserFromToken.mockResolvedValue({ id: '1', role: 'admin' })
|
||||||
|
|
||||||
|
const response = await membersGetHandler(event)
|
||||||
|
|
||||||
|
expect(response.members).toHaveLength(1)
|
||||||
|
expect(response.members[0].showBirthday).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
it('blendet unsichtbare Playstore-Benutzer und passende manuelle Einträge aus', async () => {
|
it('blendet unsichtbare Playstore-Benutzer und passende manuelle Einträge aus', async () => {
|
||||||
const event = createEvent({ cookies: { auth_token: 'token' } })
|
const event = createEvent({ cookies: { auth_token: 'token' } })
|
||||||
@@ -159,6 +192,8 @@ describe('Members API Endpoints', () => {
|
|||||||
const event = createEvent({ cookies: { auth_token: 'token' } })
|
const event = createEvent({ cookies: { auth_token: 'token' } })
|
||||||
mockSuccessReadBody(baseBody)
|
mockSuccessReadBody(baseBody)
|
||||||
authUtils.getUserFromToken.mockResolvedValue({ id: '2', role: 'admin' })
|
authUtils.getUserFromToken.mockResolvedValue({ id: '2', role: 'admin' })
|
||||||
|
authUtils.readUsers.mockResolvedValue([])
|
||||||
|
memberUtils.readMembers.mockResolvedValue([])
|
||||||
memberUtils.saveMember.mockResolvedValue(true)
|
memberUtils.saveMember.mockResolvedValue(true)
|
||||||
|
|
||||||
const response = await membersPostHandler(event)
|
const response = await membersPostHandler(event)
|
||||||
@@ -168,6 +203,76 @@ describe('Members API Endpoints', () => {
|
|||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('speichert Geburtstags-Sichtbarkeit für manuelle Mitglieder', async () => {
|
||||||
|
const event = createEvent({ cookies: { auth_token: 'token' } })
|
||||||
|
mockSuccessReadBody({ ...baseBody, showBirthday: false })
|
||||||
|
authUtils.getUserFromToken.mockResolvedValue({ id: '2', role: 'admin' })
|
||||||
|
authUtils.readUsers.mockResolvedValue([])
|
||||||
|
memberUtils.readMembers.mockResolvedValue([
|
||||||
|
{ id: 'manual-1', firstName: 'Lisa', lastName: 'Beispiel', email: 'lisa@example.com', visibility: { showBirthday: true } }
|
||||||
|
])
|
||||||
|
memberUtils.saveMember.mockResolvedValue(true)
|
||||||
|
|
||||||
|
const response = await membersPostHandler(event)
|
||||||
|
|
||||||
|
expect(response.success).toBe(true)
|
||||||
|
expect(memberUtils.saveMember).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
visibility: expect.objectContaining({ showBirthday: false })
|
||||||
|
}))
|
||||||
|
expect(authUtils.writeUsers).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('kann Geburtstags-Sichtbarkeit auch am Login-Benutzer ausschalten', async () => {
|
||||||
|
const event = createEvent({ cookies: { auth_token: 'token' } })
|
||||||
|
mockSuccessReadBody({
|
||||||
|
id: 'user-1',
|
||||||
|
...baseBody,
|
||||||
|
email: 'lisa@example.com',
|
||||||
|
visibility: { showBirthday: false }
|
||||||
|
})
|
||||||
|
authUtils.getUserFromToken.mockResolvedValue({ id: '2', role: 'vorstand' })
|
||||||
|
memberUtils.readMembers.mockResolvedValue([])
|
||||||
|
authUtils.readUsers.mockResolvedValue([
|
||||||
|
{ id: 'user-1', email: 'lisa@example.com', visibility: { showBirthday: true, showEmail: true } }
|
||||||
|
])
|
||||||
|
authUtils.writeUsers.mockResolvedValue(undefined)
|
||||||
|
memberUtils.saveMember.mockResolvedValue(true)
|
||||||
|
|
||||||
|
const response = await membersPostHandler(event)
|
||||||
|
|
||||||
|
expect(response.success).toBe(true)
|
||||||
|
expect(authUtils.writeUsers).toHaveBeenCalledWith([
|
||||||
|
expect.objectContaining({
|
||||||
|
id: 'user-1',
|
||||||
|
visibility: expect.objectContaining({ showBirthday: false, showEmail: true })
|
||||||
|
})
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('darf Geburtstags-Sichtbarkeit nicht für Login-Benutzer einschalten', async () => {
|
||||||
|
const event = createEvent({ cookies: { auth_token: 'token' } })
|
||||||
|
mockSuccessReadBody({
|
||||||
|
id: 'user-1',
|
||||||
|
...baseBody,
|
||||||
|
email: 'lisa@example.com',
|
||||||
|
visibility: { showBirthday: true }
|
||||||
|
})
|
||||||
|
authUtils.getUserFromToken.mockResolvedValue({ id: '2', role: 'vorstand' })
|
||||||
|
memberUtils.readMembers.mockResolvedValue([])
|
||||||
|
authUtils.readUsers.mockResolvedValue([
|
||||||
|
{ id: 'user-1', email: 'lisa@example.com', visibility: { showBirthday: false, showEmail: true } }
|
||||||
|
])
|
||||||
|
memberUtils.saveMember.mockResolvedValue(true)
|
||||||
|
|
||||||
|
const response = await membersPostHandler(event)
|
||||||
|
|
||||||
|
expect(response.success).toBe(true)
|
||||||
|
expect(memberUtils.saveMember).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
visibility: expect.objectContaining({ showBirthday: false })
|
||||||
|
}))
|
||||||
|
expect(authUtils.writeUsers).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
it('erlaubt vorstand beim Speichern', async () => {
|
it('erlaubt vorstand beim Speichern', async () => {
|
||||||
const event = createEvent({ cookies: { auth_token: 'token' } })
|
const event = createEvent({ cookies: { auth_token: 'token' } })
|
||||||
mockSuccessReadBody(baseBody)
|
mockSuccessReadBody(baseBody)
|
||||||
@@ -187,6 +292,7 @@ describe('Members API Endpoints', () => {
|
|||||||
email: 'lisa@example.com'
|
email: 'lisa@example.com'
|
||||||
})
|
})
|
||||||
authUtils.getUserFromToken.mockResolvedValue({ id: '3', role: 'vorstand' })
|
authUtils.getUserFromToken.mockResolvedValue({ id: '3', role: 'vorstand' })
|
||||||
|
authUtils.readUsers.mockResolvedValue([])
|
||||||
memberUtils.saveMember.mockResolvedValue(true)
|
memberUtils.saveMember.mockResolvedValue(true)
|
||||||
|
|
||||||
const response = await membersPostHandler(event)
|
const response = await membersPostHandler(event)
|
||||||
|
|||||||
191
tests/notification-scheduler.spec.ts
Normal file
191
tests/notification-scheduler.spec.ts
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import fs from 'fs/promises'
|
||||||
|
|
||||||
|
vi.mock('../server/utils/auth.js', () => ({
|
||||||
|
readUsers: vi.fn(),
|
||||||
|
isHiddenUser: vi.fn(user => user?.hidden === true || user?.invisible === true || user?.isHidden === true || user?.systemAccount === true)
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../server/utils/members.js', () => ({
|
||||||
|
readMembers: vi.fn()
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../server/utils/termine.js', () => ({
|
||||||
|
readTermine: vi.fn().mockResolvedValue([])
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../server/utils/news.js', () => ({
|
||||||
|
readNews: vi.fn().mockResolvedValue([])
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../server/utils/spielplan-data.js', () => ({
|
||||||
|
getDefaultSpielplanSeason: vi.fn().mockResolvedValue('25--26'),
|
||||||
|
readSpielplanData: vi.fn().mockResolvedValue({ data: [] })
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../server/utils/push-notifications.js', () => ({
|
||||||
|
sendPushToUsers: vi.fn().mockResolvedValue({ sent: 1, failed: 0, removed: 0, recipients: 1, tokenCount: 1, skipped: false })
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../server/utils/logger.js', () => ({
|
||||||
|
error: vi.fn(),
|
||||||
|
info: vi.fn(),
|
||||||
|
warn: vi.fn()
|
||||||
|
}))
|
||||||
|
|
||||||
|
const authUtils = await import('../server/utils/auth.js')
|
||||||
|
const memberUtils = await import('../server/utils/members.js')
|
||||||
|
const pushUtils = await import('../server/utils/push-notifications.js')
|
||||||
|
const spielplanUtils = await import('../server/utils/spielplan-data.js')
|
||||||
|
|
||||||
|
const { runNotificationSchedulerTick } = await import('../server/utils/notification-scheduler.js')
|
||||||
|
|
||||||
|
const schedulerNow = new Date('2026-06-14T07:00:00.000Z')
|
||||||
|
const recipient = {
|
||||||
|
id: 'recipient',
|
||||||
|
name: 'Push Empfaenger',
|
||||||
|
active: true,
|
||||||
|
notificationSettings: {
|
||||||
|
birthdays: true,
|
||||||
|
notificationTime: '09:00'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Notification Scheduler', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
vi.spyOn(fs, 'readFile').mockImplementation(async (filePath) => {
|
||||||
|
if (String(filePath).includes('mannschaften_25--26.csv')) {
|
||||||
|
return [
|
||||||
|
'Mannschaft,Liga,Staffelleiter,Telefon,Heimspieltag,Spielsystem,Mannschaftsführer,Spieler,Weitere Informationen Link,Letzte Aktualisierung',
|
||||||
|
'Erwachsene 1,,,,,,Mannschaftsfuehrer,Max Spieler,,',
|
||||||
|
'Erwachsene 2,,,,,,Andere Person,Andere Spieler,,'
|
||||||
|
].join('\n')
|
||||||
|
}
|
||||||
|
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
|
||||||
|
})
|
||||||
|
vi.spyOn(fs, 'mkdir').mockResolvedValue(undefined)
|
||||||
|
vi.spyOn(fs, 'writeFile').mockResolvedValue(undefined)
|
||||||
|
memberUtils.readMembers.mockResolvedValue([])
|
||||||
|
authUtils.readUsers.mockResolvedValue([recipient])
|
||||||
|
spielplanUtils.getDefaultSpielplanSeason.mockResolvedValue('25--26')
|
||||||
|
spielplanUtils.readSpielplanData.mockResolvedValue({ data: [] })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sendet Geburtstags-Push nur fuer Mitglieder mit expliziter Geburtstagsfreigabe', async () => {
|
||||||
|
memberUtils.readMembers.mockResolvedValue([
|
||||||
|
{ firstName: 'Erlaubt', lastName: 'Person', active: true, geburtsdatum: '1990-06-14', visibility: { showBirthday: true } },
|
||||||
|
{ firstName: 'Privat', lastName: 'Person', active: true, geburtsdatum: '1990-06-14', visibility: { showBirthday: false } },
|
||||||
|
{ firstName: 'Unklar', lastName: 'Person', active: true, geburtsdatum: '1990-06-14' }
|
||||||
|
])
|
||||||
|
|
||||||
|
await runNotificationSchedulerTick(schedulerNow)
|
||||||
|
|
||||||
|
expect(pushUtils.sendPushToUsers).toHaveBeenCalledTimes(1)
|
||||||
|
expect(pushUtils.sendPushToUsers).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
title: 'Geburtstage heute',
|
||||||
|
body: 'Erlaubt Person hat heute Geburtstag.',
|
||||||
|
data: { type: 'birthdays', date: '2026-06-14' }
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('nennt bei mehreren Geburtstags-Pushes nur Namen und kein Alter', async () => {
|
||||||
|
memberUtils.readMembers.mockResolvedValue([
|
||||||
|
{ firstName: 'Anna', lastName: 'Beispiel', active: true, geburtsdatum: '1980-06-14', visibility: { showBirthday: true } },
|
||||||
|
{ firstName: 'Bert', lastName: 'Beispiel', active: true, geburtsdatum: '2010-06-14', visibility: { showBirthday: true } }
|
||||||
|
])
|
||||||
|
|
||||||
|
await runNotificationSchedulerTick(schedulerNow)
|
||||||
|
|
||||||
|
const payload = pushUtils.sendPushToUsers.mock.calls[0][0]
|
||||||
|
expect(payload.body).toBe('Geburtstage heute: Anna Beispiel, Bert Beispiel.')
|
||||||
|
expect(payload.body).not.toMatch(/\b\d+\b/)
|
||||||
|
expect(payload.body).not.toContain('Jahre')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sendet Punktspiel-Push nur einmal, wenn alle, eigene und ausgewaehlte Mannschaft dasselbe Spiel treffen', async () => {
|
||||||
|
const matchUser = {
|
||||||
|
id: 'match-user',
|
||||||
|
name: 'Max Spieler',
|
||||||
|
active: true,
|
||||||
|
notificationSettings: {
|
||||||
|
allTeamMatches: true,
|
||||||
|
ownTeamMatches: true,
|
||||||
|
selectedTeamSlugs: ['erwachsene-1'],
|
||||||
|
selectedTeamSeason: '25--26',
|
||||||
|
notificationTime: '09:00'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
authUtils.readUsers.mockResolvedValue([matchUser])
|
||||||
|
spielplanUtils.readSpielplanData.mockResolvedValue({
|
||||||
|
data: [{
|
||||||
|
Termin: '14.06.2026 20:15',
|
||||||
|
BegegnungNr: 'spiel-1',
|
||||||
|
Altersklasse: 'Erwachsene',
|
||||||
|
HeimVereinName: 'Harheimer TC',
|
||||||
|
HeimMannschaftAltersklasse: 'Erwachsene',
|
||||||
|
HeimMannschaftNr: '1',
|
||||||
|
HeimMannschaft: 'Harheimer TC',
|
||||||
|
GastVereinName: 'Gastverein',
|
||||||
|
GastMannschaftAltersklasse: 'Erwachsene',
|
||||||
|
GastMannschaftNr: '1',
|
||||||
|
GastMannschaft: 'Gastverein'
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
|
||||||
|
await runNotificationSchedulerTick(schedulerNow)
|
||||||
|
|
||||||
|
expect(pushUtils.sendPushToUsers).toHaveBeenCalledTimes(1)
|
||||||
|
const payload = pushUtils.sendPushToUsers.mock.calls[0][0]
|
||||||
|
expect(payload.title).toBe('Punktspiele')
|
||||||
|
expect(payload.predicate(matchUser, matchUser.notificationSettings)).toBe(true)
|
||||||
|
expect(payload.bodyForUser(matchUser, matchUser.notificationSettings)).toContain('Harheimer TC - Gastverein')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fasst eigene und ausgewaehlte Punktspiele in einer Benachrichtigung zusammen', async () => {
|
||||||
|
const matchUser = {
|
||||||
|
id: 'match-user',
|
||||||
|
name: 'Max Spieler',
|
||||||
|
active: true,
|
||||||
|
notificationSettings: {
|
||||||
|
allTeamMatches: false,
|
||||||
|
ownTeamMatches: true,
|
||||||
|
selectedTeamSlugs: ['erwachsene-2'],
|
||||||
|
selectedTeamSeason: '25--26',
|
||||||
|
notificationTime: '09:00'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
authUtils.readUsers.mockResolvedValue([matchUser])
|
||||||
|
spielplanUtils.readSpielplanData.mockResolvedValue({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
Termin: '14.06.2026 20:15',
|
||||||
|
BegegnungNr: 'spiel-1',
|
||||||
|
Altersklasse: 'Erwachsene',
|
||||||
|
HeimVereinName: 'Harheimer TC',
|
||||||
|
HeimMannschaftAltersklasse: 'Erwachsene',
|
||||||
|
HeimMannschaftNr: '1',
|
||||||
|
HeimMannschaft: 'Harheimer TC',
|
||||||
|
GastMannschaft: 'Gastverein'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Termin: '14.06.2026 20:30',
|
||||||
|
BegegnungNr: 'spiel-2',
|
||||||
|
Altersklasse: 'Erwachsene',
|
||||||
|
HeimMannschaft: 'Gastverein II',
|
||||||
|
GastVereinName: 'Harheimer TC',
|
||||||
|
GastMannschaftAltersklasse: 'Erwachsene',
|
||||||
|
GastMannschaftNr: '2',
|
||||||
|
GastMannschaft: 'Harheimer TC II'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
await runNotificationSchedulerTick(schedulerNow)
|
||||||
|
|
||||||
|
expect(pushUtils.sendPushToUsers).toHaveBeenCalledTimes(1)
|
||||||
|
const payload = pushUtils.sendPushToUsers.mock.calls[0][0]
|
||||||
|
expect(payload.predicate(matchUser, matchUser.notificationSettings)).toBe(true)
|
||||||
|
expect(payload.bodyForUser(matchUser, matchUser.notificationSettings)).toBe('2 Punktspiele am 14.06.2026')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -16,8 +16,25 @@ vi.mock('../server/utils/news.js', () => ({
|
|||||||
readNews: vi.fn()
|
readNews: vi.fn()
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
vi.mock('../server/utils/auth.js', () => ({
|
||||||
|
readUsers: vi.fn(),
|
||||||
|
migrateUserRoles: vi.fn((user) => {
|
||||||
|
if (!user) return user
|
||||||
|
if (Array.isArray(user.roles)) return user
|
||||||
|
if (user.role) {
|
||||||
|
user.roles = [user.role]
|
||||||
|
delete user.role
|
||||||
|
} else {
|
||||||
|
user.roles = ['mitglied']
|
||||||
|
}
|
||||||
|
return user
|
||||||
|
}),
|
||||||
|
isHiddenUser: vi.fn(user => user?.hidden === true || user?.invisible === true || user?.isHidden === true || user?.systemAccount === true || user?.accountType === 'playstore_review')
|
||||||
|
}))
|
||||||
|
|
||||||
const nodemailer = await import('nodemailer')
|
const nodemailer = await import('nodemailer')
|
||||||
const newsUtils = await import('../server/utils/news.js')
|
const newsUtils = await import('../server/utils/news.js')
|
||||||
|
const authUtils = await import('../server/utils/auth.js')
|
||||||
|
|
||||||
import contactHandler from '../server/api/contact.post.js'
|
import contactHandler from '../server/api/contact.post.js'
|
||||||
import galerieHandler from '../server/api/galerie.get.js'
|
import galerieHandler from '../server/api/galerie.get.js'
|
||||||
@@ -29,14 +46,17 @@ describe('Öffentliche API-Endpunkte', () => {
|
|||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
delete process.env.NODE_ENV
|
delete process.env.NODE_ENV
|
||||||
delete process.env.APP_ENV
|
delete process.env.APP_ENV
|
||||||
|
delete process.env.DEBUG
|
||||||
})
|
})
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// Setze SMTP-Credentials für Tests
|
// Setze SMTP-Credentials für Tests
|
||||||
process.env.SMTP_USER = 'test@example.com'
|
process.env.SMTP_USER = 'test@example.com'
|
||||||
process.env.SMTP_PASS = 'test-password'
|
process.env.SMTP_PASS = 'test-password'
|
||||||
|
authUtils.readUsers.mockResolvedValue([])
|
||||||
vi.restoreAllMocks()
|
vi.restoreAllMocks()
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
|
authUtils.readUsers.mockResolvedValue([])
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('POST /api/contact', () => {
|
describe('POST /api/contact', () => {
|
||||||
@@ -78,6 +98,53 @@ describe('Öffentliche API-Endpunkte', () => {
|
|||||||
to: 'tsschulz@tsschulz.de'
|
to: 'tsschulz@tsschulz.de'
|
||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('sendet bei DEBUG=FALSE an konfigurierte Empfänger', async () => {
|
||||||
|
process.env.NODE_ENV = 'production'
|
||||||
|
process.env.APP_ENV = 'test'
|
||||||
|
process.env.DEBUG = 'FALSE'
|
||||||
|
vi.spyOn(fs, 'readFile').mockResolvedValue(JSON.stringify({
|
||||||
|
vorstand: {
|
||||||
|
vorsitzender: { email: 'vorstand@example.com' }
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
const event = createEvent()
|
||||||
|
mockSuccessReadBody({ name: 'Max', email: 'max@example.com', subject: 'Frage', message: 'Hallo' })
|
||||||
|
|
||||||
|
await contactHandler(event)
|
||||||
|
|
||||||
|
const transporter = nodemailer.default.createTransport.mock.results[0].value
|
||||||
|
const to = transporter.sendMail.mock.calls[0][0].to
|
||||||
|
expect(to).toContain('vorstand@example.com')
|
||||||
|
expect(to).not.toContain('tsschulz@tsschulz.de')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('bevorzugt aktive Vorstand-Benutzer vor config.json', async () => {
|
||||||
|
process.env.NODE_ENV = 'production'
|
||||||
|
process.env.DEBUG = 'FALSE'
|
||||||
|
authUtils.readUsers.mockResolvedValue([
|
||||||
|
{ id: '1', email: 'rolle-vorstand@example.com', roles: ['vorstand'], active: true },
|
||||||
|
{ id: '2', email: 'hidden@example.com', roles: ['vorstand'], active: true, hidden: true },
|
||||||
|
{ id: '3', email: 'trainer@example.com', roles: ['trainer'], active: true }
|
||||||
|
])
|
||||||
|
vi.spyOn(fs, 'readFile').mockResolvedValue(JSON.stringify({
|
||||||
|
vorstand: {
|
||||||
|
vorsitzender: { email: 'config-vorstand@example.com' }
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
const event = createEvent()
|
||||||
|
mockSuccessReadBody({ name: 'Max', email: 'max@example.com', subject: 'Frage', message: 'Hallo' })
|
||||||
|
|
||||||
|
await contactHandler(event)
|
||||||
|
|
||||||
|
const transporter = nodemailer.default.createTransport.mock.results[0].value
|
||||||
|
const to = transporter.sendMail.mock.calls[0][0].to
|
||||||
|
expect(to).toContain('rolle-vorstand@example.com')
|
||||||
|
expect(to).not.toContain('config-vorstand@example.com')
|
||||||
|
expect(to).not.toContain('hidden@example.com')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('GET /api/galerie', () => {
|
describe('GET /api/galerie', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user