19 Commits

Author SHA1 Message Date
42398dad21 Merge pull request 'dev' (#46) from dev into main
All checks were successful
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Successful in 2m47s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #46
2026-07-17 14:00:12 +02:00
Torsten Schulz (local)
281c25b05d feat: aktualisiere die Version auf 1.8.4 und verbessere die Erkennung von Jugendmannschaften in den Filterfunktionen
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 8m54s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m41s
2026-07-17 13:46:25 +02:00
Torsten Schulz (local)
1beb5c2eee feat: verbessere die Auswahl von Spielplan-Team-IDs durch Checkboxen und aktualisiere die Logik zur Verarbeitung von Team-Kriterien
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 7m10s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m43s
2026-07-17 12:05:22 +02:00
Torsten Schulz (local)
461f2de8dc feat: unterstütze mehrere Spielplan-Team-IDs und verbessere die Zuordnung importierter Mannschaften
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 7m17s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m46s
2026-07-17 11:49:55 +02:00
Torsten Schulz (local)
75a7c409cd feat: füge Auswahl für Spielplan-Team-ID hinzu und lade Teams von der API
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 6m55s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m40s
2026-07-17 11:20:15 +02:00
Torsten Schulz (local)
751c302036 feat: füge Importfunktion für Spielpläne von myTischtennis hinzu und verbessere den Ladevorgang
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 6m55s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m43s
2026-07-17 11:03:46 +02:00
Torsten Schulz (local)
f0e1ad39b0 Verbessere die Altersklassenerkennung in der Funktion sideMatchesJugendTeam durch Verwendung eines regulären Ausdrucks zur genauen Übereinstimmung
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 7m4s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m55s
2026-07-17 10:33:46 +02:00
Torsten Schulz (local)
1dc84023d0 feat: füge Unterstützung für importierte Spielplan-Teams hinzu und verbessere Teamfilter-Logik
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 6m37s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
2026-07-17 09:29:34 +02:00
Torsten Schulz (local)
30465c7833 Verbessere Codequalität und Sicherheit durch Refactoring, Hinzufügen von ESLint-Regeln und Aktualisierung der OSV-Scanner-Konfiguration
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 6m45s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m57s
2026-07-17 09:08:42 +02:00
Torsten Schulz (local)
d260f00756 Verbessere Fehlerbehandlung in der Verschlüsselung und PDF-Generierung durch Hinzufügen von Ursacheninformationen
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 6m34s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
2026-07-17 08:52:16 +02:00
Torsten Schulz (local)
f0ac4b7e56 Verbessere Fehlerbehandlung beim Laden von PDF-Vorlagen und füge Ursacheninformationen hinzu
Some checks failed
Code Analysis and Production Deploy / deploy-production (push) Has been cancelled
Code Analysis and Production Deploy / deploy-test (push) Has been cancelled
Code Analysis and Production Deploy / analyze (push) Has been cancelled
2026-07-17 08:50:47 +02:00
Torsten Schulz (local)
bd45beadd5 feat(cms): add endpoint for manual spielplan import with league tables handling
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 3m47s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
2026-07-17 08:41:11 +02:00
Torsten Schulz (local)
f060c9771f Füge Unterstützung für Spielplan-Team-ID in mehreren Komponenten hinzu und aktualisiere CSV-Export
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 7m2s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m15s
2026-07-17 08:29:34 +02:00
d563d81584 Merge pull request 'Füge Funktion zur Umwandlung von Saison-Slugs in Labels hinzu und aktualisiere PDF-Daten mit saisonalen Informationen' (#45) from dev into main
All checks were successful
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Successful in 1m59s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #45
2026-07-15 11:38:20 +02:00
Torsten Schulz (local)
0ab71166aa Refactor Spielplan-Filterlogik in separate Utility-Funktionen und verbessere die Filterung im PDF-Generierungsprozess 2026-07-15 11:30:06 +02:00
380d0a8332 Merge pull request 'dev' (#44) from dev into main
All checks were successful
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Successful in 2m41s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #44
2026-07-15 11:12:16 +02:00
301bd7acbd Merge pull request 'dev' (#43) from dev into main
All checks were successful
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Successful in 2m44s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #43
2026-06-23 23:50:21 +02:00
05e25aa3d1 Merge pull request 'dev' (#42) from dev into main
All checks were successful
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Successful in 2m15s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #42
2026-06-16 14:09:23 +02:00
ecae88af78 Merge pull request 'dev' (#41) from dev into main
All checks were successful
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Successful in 3m0s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #41
2026-06-10 16:49:07 +02:00
42 changed files with 2141 additions and 6397 deletions

View File

@@ -116,7 +116,7 @@ jobs:
chmod +x osv-scanner chmod +x osv-scanner
./osv-scanner --version ./osv-scanner --version
test -f ./package-lock.json test -f ./package-lock.json
./osv-scanner --lockfile ./package-lock.json ./osv-scanner scan -L ./package-lock.json --config ./.osv-scanner.toml
deploy-production: deploy-production:
runs-on: ubuntu-latest runs-on: ubuntu-latest

4
.osv-scanner.toml Normal file
View File

@@ -0,0 +1,4 @@
[[IgnoredVulns]]
id = "GHSA-v3m3-f69x-jf25"
ignoreUntil = 2026-12-31
reason = "Temporary exception: Quill 2.0.3 is required by the current RichTextEditor implementation, and OSV currently reports no fixed version. Track upstream fix and remove this ignore once a patched release is available."

View File

@@ -652,6 +652,9 @@ interface ApiService {
@POST("/api/cms/save-csv") @POST("/api/cms/save-csv")
suspend fun saveCsv(@Body request: SaveCsvRequest): Response<SaveCsvResponse> suspend fun saveCsv(@Body request: SaveCsvRequest): Response<SaveCsvResponse>
@POST("/api/cms/import-spielplan")
suspend fun importSpielplan(): Response<AuthMessageResponse>
@POST("/api/membership/generate-pdf") @POST("/api/membership/generate-pdf")
suspend fun generateMembershipPdf(@Body request: MembershipRequest): Response<MembershipResponse> suspend fun generateMembershipPdf(@Body request: MembershipRequest): Response<MembershipResponse>

View File

@@ -125,6 +125,7 @@ class CmsRepository @Inject constructor(
spieler = values[7], spieler = values[7],
informationenLink = values[8], informationenLink = values[8],
letzteAktualisierung = values[9], letzteAktualisierung = values[9],
spielplanMannschaftId = values.getOrElse(10) { "" },
) )
} }
} }
@@ -146,6 +147,12 @@ class CmsRepository @Inject constructor(
response.body() ?: SaveCsvResponse(success = false, message = "Leere Antwort") response.body() ?: SaveCsvResponse(success = false, message = "Leere Antwort")
} }
suspend fun importSpielplan(): Result<de.harheimertc.data.AuthMessageResponse> = runCatching {
val response = api.importSpielplan()
if (!response.isSuccessful) error("Spielplan konnte nicht importiert werden.")
response.body() ?: de.harheimertc.data.AuthMessageResponse(success = false, message = "Leere Antwort")
}
suspend fun spielplan(season: String? = null): Result<SpielplanResponse> = runCatching { suspend fun spielplan(season: String? = null): Result<SpielplanResponse> = runCatching {
val response = api.spielplan(season) val response = api.spielplan(season)
if (!response.isSuccessful) error("Spielplan konnte nicht geladen werden.") if (!response.isSuccessful) error("Spielplan konnte nicht geladen werden.")
@@ -381,6 +388,7 @@ data class CmsMannschaftRow(
val spieler: String = "", val spieler: String = "",
val informationenLink: String = "", val informationenLink: String = "",
val letzteAktualisierung: String = "", val letzteAktualisierung: String = "",
val spielplanMannschaftId: String = "",
) )
private fun List<CmsMannschaftRow>.toMannschaftenCsv(): String { private fun List<CmsMannschaftRow>.toMannschaftenCsv(): String {
@@ -395,6 +403,7 @@ private fun List<CmsMannschaftRow>.toMannschaftenCsv(): String {
"Spieler", "Spieler",
"Weitere Informationen Link", "Weitere Informationen Link",
"Letzte Aktualisierung", "Letzte Aktualisierung",
"Spielplan Mannschaft ID",
).toCsvRow() ).toCsvRow()
val rows = map { row -> val rows = map { row ->
listOf( listOf(
@@ -408,6 +417,7 @@ private fun List<CmsMannschaftRow>.toMannschaftenCsv(): String {
row.spieler, row.spieler,
row.informationenLink, row.informationenLink,
row.letzteAktualisierung, row.letzteAktualisierung,
row.spielplanMannschaftId,
).toCsvRow() ).toCsvRow()
} }
return listOf(header).plus(rows).joinToString("\n") return listOf(header).plus(rows).joinToString("\n")

View File

@@ -458,6 +458,9 @@ fun CmsSportbetriebScreen(navController: NavController, showBackNavigation: Bool
"mannschaften" to "Mannschaften", "mannschaften" to "Mannschaften",
"spielplaene" to "Spielpläne", "spielplaene" to "Spielpläne",
) )
val spielplanTeamOptions = remember(state.sportSpielplanHeaders, state.sportSpielplanRows) {
importedSpielplanTeams(state.sportSpielplanHeaders, state.sportSpielplanRows)
}
val terminKategorien = listOf("Training", "Punktspiel", "Turnier", "Veranstaltung", "Sonstiges") val terminKategorien = listOf("Training", "Punktspiel", "Turnier", "Veranstaltung", "Sonstiges")
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
@@ -606,6 +609,7 @@ fun CmsSportbetriebScreen(navController: NavController, showBackNavigation: Bool
items(mannschaften.size) { index -> items(mannschaften.size) { index ->
MannschaftEditorCard( MannschaftEditorCard(
row = mannschaften[index], row = mannschaften[index],
spielplanTeams = spielplanTeamOptions,
onChange = { updated -> mannschaften[index] = updated }, onChange = { updated -> mannschaften[index] = updated },
onRemove = { mannschaften.removeAt(index) }, onRemove = { mannschaften.removeAt(index) },
) )
@@ -619,9 +623,19 @@ fun CmsSportbetriebScreen(navController: NavController, showBackNavigation: Bool
InfoRow("Datei", fileName) InfoRow("Datei", fileName)
InfoRow("Saison", seasonLabel) InfoRow("Saison", seasonLabel)
InfoRow("Einträge", state.sportSpielplanRows.size.toString()) InfoRow("Einträge", state.sportSpielplanRows.size.toString())
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
Button(
onClick = { viewModel.importSportSpielplan() },
enabled = !state.sportSaving,
modifier = Modifier.weight(1f),
) {
Text(if (state.sportSaving) "Importiert..." else "Von myTischtennis importieren")
}
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
OutlinedButton( OutlinedButton(
onClick = { viewModel.loadSportbetrieb() }, onClick = { viewModel.loadSportbetrieb() },
enabled = !state.sportSaving,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) { ) {
Text("Neu laden") Text("Neu laden")
@@ -732,11 +746,32 @@ fun CmsSportbetriebScreen(navController: NavController, showBackNavigation: Bool
@Composable @Composable
private fun MannschaftEditorCard( private fun MannschaftEditorCard(
row: CmsMannschaftRow, row: CmsMannschaftRow,
spielplanTeams: List<ImportedSpielplanTeam>,
onChange: (CmsMannschaftRow) -> Unit, onChange: (CmsMannschaftRow) -> Unit,
onRemove: () -> Unit, onRemove: () -> Unit,
) { ) {
var teamPickerOpen by remember(row.mannschaft, spielplanTeams) { mutableStateOf(false) }
DataCard(row.mannschaft.ifBlank { "Mannschaft" }) { DataCard(row.mannschaft.ifBlank { "Mannschaft" }) {
OutlinedTextField(value = row.mannschaft, onValueChange = { onChange(row.copy(mannschaft = it)) }, label = { Text("Mannschaft") }, modifier = Modifier.fillMaxWidth()) OutlinedTextField(value = row.mannschaft, onValueChange = { onChange(row.copy(mannschaft = it)) }, label = { Text("Mannschaft") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(value = row.spielplanMannschaftId, onValueChange = { onChange(row.copy(spielplanMannschaftId = it)) }, label = { Text("Spielplan-Team-ID") }, supportingText = { Text("Eindeutige myTischtennis-Team-ID") }, modifier = Modifier.fillMaxWidth())
if (spielplanTeams.isNotEmpty()) {
Box(modifier = Modifier.fillMaxWidth()) {
OutlinedButton(onClick = { teamPickerOpen = true }, modifier = Modifier.fillMaxWidth()) {
Text("Aus importierten Teams auswählen")
}
DropdownMenu(expanded = teamPickerOpen, onDismissRequest = { teamPickerOpen = false }) {
spielplanTeams.forEach { team ->
DropdownMenuItem(
text = { Text(team.label) },
onClick = {
onChange(row.copy(spielplanMannschaftId = team.id))
teamPickerOpen = false
},
)
}
}
}
}
OutlinedTextField(value = row.liga, onValueChange = { onChange(row.copy(liga = it)) }, label = { Text("Liga") }, 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.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.telefon, onValueChange = { onChange(row.copy(telefon = it)) }, label = { Text("Telefon") }, modifier = Modifier.fillMaxWidth())
@@ -752,6 +787,35 @@ private fun MannschaftEditorCard(
} }
} }
private data class ImportedSpielplanTeam(val id: String, val label: String)
private fun importedSpielplanTeams(headers: List<String>, rows: List<List<String>>): List<ImportedSpielplanTeam> {
fun index(name: String) = headers.indexOf(name)
fun value(row: List<String>, column: String): String {
val columnIndex = index(column)
return row.getOrElse(columnIndex) { "" }.trim()
}
val teams = linkedMapOf<String, ImportedSpielplanTeam>()
listOf("Heim", "Gast").forEach { side ->
rows.forEach { row ->
val isHarheimer = value(row, "${side}VereinNr") == "43030" || value(row, "${side}VereinName") == "Harheimer TC"
val id = value(row, "${side}MannschaftId")
if (!isHarheimer || id.isBlank()) return@forEach
val teamName = value(row, "${side}Mannschaft")
val ageClass = value(row, "${side}MannschaftAltersklasse")
val teamNumber = value(row, "${side}MannschaftNr")
val label = listOf(teamName, ageClass, teamNumber.takeIf { it.isNotBlank() }?.let { "Nr. $it" })
.filterNotNull()
.filter { it.isNotBlank() }
.joinToString(" · ")
teams.putIfAbsent(id, ImportedSpielplanTeam(id, "$label [$id]"))
}
}
return teams.values.sortedBy { it.label }
}
private fun sportSpielplanCsvText(headers: List<String>, rows: List<List<String>>): String { private fun sportSpielplanCsvText(headers: List<String>, rows: List<List<String>>): String {
if (headers.isEmpty()) return "" if (headers.isEmpty()) return ""
return listOf(headers).plus(rows).joinToString("\n") { row -> row.joinToString(";") { it.csvCell(";") } } return listOf(headers).plus(rows).joinToString("\n") { row -> row.joinToString(";") { it.csvCell(";") } }

View File

@@ -322,6 +322,33 @@ class CmsViewModel @Inject constructor(
} }
} }
fun importSportSpielplan() {
viewModelScope.launch {
_state.value = _state.value.copy(sportSaving = true, error = null, message = null)
repository.importSpielplan()
.onSuccess { response ->
if (!response.success) {
_state.value = _state.value.copy(
sportSaving = false,
error = response.message ?: "Spielplan konnte nicht importiert werden.",
)
return@onSuccess
}
_state.value = _state.value.copy(
sportSaving = false,
message = response.message ?: "Spielplan aktualisiert.",
)
loadSportbetrieb()
}
.onFailure { err ->
_state.value = _state.value.copy(
sportSaving = false,
error = ErrorMapper.mapError(err) ?: "Spielplan konnte nicht importiert werden.",
)
}
}
}
fun saveSportSpielplan(headers: List<String>, rows: List<List<String>>) { fun saveSportSpielplan(headers: List<String>, rows: List<List<String>>) {
viewModelScope.launch { viewModelScope.launch {
_state.value = _state.value.copy(sportSaving = true, error = null, message = null) _state.value = _state.value.copy(sportSaving = true, error = null, message = null)

View File

@@ -151,11 +151,6 @@ const showConfirmModal = (title, message, action) => {
showConfirm.value = true showConfirm.value = true
} }
const closeSuccess = () => {
showSuccessToast.value = false
if (toastTimeout) { clearTimeout(toastTimeout); toastTimeout = null }
}
const closeError = () => { const closeError = () => {
showError.value = false showError.value = false
} }

View File

@@ -202,6 +202,31 @@
:disabled="isSaving" :disabled="isSaving"
> >
</div> </div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Spielplan-Team-IDs</label>
<div class="max-h-48 overflow-y-auto rounded-lg border border-gray-300 divide-y divide-gray-100">
<label
v-for="team in spielplanTeams"
:key="team.id"
class="flex items-start gap-3 px-4 py-3 cursor-pointer hover:bg-gray-50"
>
<input
:checked="selectedSpielplanTeamIds.includes(team.id)"
type="checkbox"
class="mt-0.5 h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
:disabled="isSaving"
@change="toggleSpielplanTeamId(team.id, $event.target.checked)"
>
<span class="text-sm text-gray-800">{{ team.label }}</span>
</label>
<p v-if="!spielplanTeams.length" class="px-4 py-3 text-sm text-gray-500">
Noch keine importierten Spielplan-Teams verfügbar.
</p>
</div>
<p class="mt-1 text-xs text-gray-500">
Alle passenden Einträge auswählen (z. B. Liga und Pokal). Die Auswahl wird beim Speichern beibehalten.
</p>
</div>
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-2">Liga *</label> <label class="block text-sm font-medium text-gray-700 mb-2">Liga *</label>
<input <input
@@ -411,13 +436,14 @@ const isLoading = ref(true)
const isSaving = ref(false) const isSaving = ref(false)
const isCreatingSeason = ref(false) const isCreatingSeason = ref(false)
const mannschaften = ref([]) const mannschaften = ref([])
const spielplanTeams = ref([])
const seasons = ref([]) const seasons = ref([])
const selectedSeason = ref('') const selectedSeason = ref('')
const showModal = ref(false) const showModal = ref(false)
const errorMessage = ref('') const errorMessage = ref('')
const isEditing = ref(false) const isEditing = ref(false)
const editingIndex = ref(-1) const editingIndex = ref(-1)
const formData = ref({ mannschaft: '', liga: '', staffelleiter: '', telefon: '', heimspieltag: '', spielsystem: '', mannschaftsfuehrer: '', spielerListe: [], weitere_informationen_link: '', letzte_aktualisierung: '' }) const formData = ref({ mannschaft: '', spielplan_mannschaft_id: '', liga: '', staffelleiter: '', telefon: '', heimspieltag: '', spielsystem: '', mannschaftsfuehrer: '', spielerListe: [], weitere_informationen_link: '', letzte_aktualisierung: '' })
const moveTargetBySpielerId = ref({}) const moveTargetBySpielerId = ref({})
const initialMoveTargetBySpielerId = ref({}) const initialMoveTargetBySpielerId = ref({})
const pendingSpielerNamesByTeamIndex = ref({}) const pendingSpielerNamesByTeamIndex = ref({})
@@ -427,6 +453,13 @@ function newSpielerItem(name = '') { return { id: `${Date.now()}-${Math.random()
function parseSpielerString(s) { if (!s) return []; return String(s).split(';').map(x => x.trim()).filter(Boolean).map(name => newSpielerItem(name)) } function parseSpielerString(s) { if (!s) return []; return String(s).split(';').map(x => x.trim()).filter(Boolean).map(name => newSpielerItem(name)) }
function serializeSpielerList(list) { return (list || []).map(s => (s?.name || '').trim()).filter(Boolean).join('; ') } function serializeSpielerList(list) { return (list || []).map(s => (s?.name || '').trim()).filter(Boolean).join('; ') }
function serializeSpielerNames(names) { return (names || []).map(s => String(s || '').trim()).filter(Boolean).join('; ') } function serializeSpielerNames(names) { return (names || []).map(s => String(s || '').trim()).filter(Boolean).join('; ') }
const selectedSpielplanTeamIds = computed(() => String(formData.value.spielplan_mannschaft_id || '').split(',').map(id => id.trim()).filter(Boolean))
const toggleSpielplanTeamId = (id, checked) => {
const ids = new Set(selectedSpielplanTeamIds.value)
if (checked) ids.add(id)
else ids.delete(id)
formData.value.spielplan_mannschaft_id = [...ids].join(',')
}
async function fetchCsvText(url) { async function fetchCsvText(url) {
const attempt = async () => { const r = await fetch(`${url}${url.includes('?') ? '&' : '?'}_t=${Date.now()}`, { cache: 'no-store' }); if (!r.ok) throw new Error(`HTTP ${r.status}`); return await r.text() } const attempt = async () => { const r = await fetch(`${url}${url.includes('?') ? '&' : '?'}_t=${Date.now()}`, { cache: 'no-store' }); if (!r.ok) throw new Error(`HTTP ${r.status}`); return await r.text() }
@@ -498,20 +531,41 @@ const loadMannschaften = async () => {
for (let i = 0; i < line.length; i++) { const char = line[i]; if (char === '"') { inQuotes = !inQuotes } else if (char === ',' && !inQuotes) { values.push(current.trim()); current = '' } else { current += char } } for (let i = 0; i < line.length; i++) { const char = line[i]; if (char === '"') { inQuotes = !inQuotes } else if (char === ',' && !inQuotes) { values.push(current.trim()); current = '' } else { current += char } }
values.push(current.trim()) values.push(current.trim())
if (values.length < 10) return null if (values.length < 10) return null
return { mannschaft: values[0]?.trim() || '', liga: values[1]?.trim() || '', staffelleiter: values[2]?.trim() || '', telefon: values[3]?.trim() || '', heimspieltag: values[4]?.trim() || '', spielsystem: values[5]?.trim() || '', mannschaftsfuehrer: values[6]?.trim() || '', spieler: values[7]?.trim() || '', weitere_informationen_link: values[8]?.trim() || '', letzte_aktualisierung: values[9]?.trim() || '' } return { mannschaft: values[0]?.trim() || '', liga: values[1]?.trim() || '', staffelleiter: values[2]?.trim() || '', telefon: values[3]?.trim() || '', heimspieltag: values[4]?.trim() || '', spielsystem: values[5]?.trim() || '', mannschaftsfuehrer: values[6]?.trim() || '', spieler: values[7]?.trim() || '', weitere_informationen_link: values[8]?.trim() || '', letzte_aktualisierung: values[9]?.trim() || '', spielplan_mannschaft_id: values[10]?.trim() || '' }
}).filter(m => m !== null && m.mannschaft !== '') }).filter(m => m !== null && m.mannschaft !== '')
} catch (error) { console.error('Fehler beim Laden:', error); errorMessage.value = 'Fehler beim Laden der Mannschaften'; throw error } finally { isLoading.value = false } } catch (error) { console.error('Fehler beim Laden:', error); errorMessage.value = 'Fehler beim Laden der Mannschaften'; throw error } finally { isLoading.value = false }
} }
const loadSpielplanTeams = async () => {
try {
const response = await fetch('/api/spielplan', { cache: 'no-store' })
const result = await response.json()
if (!result.success || !Array.isArray(result.data)) return
const teams = new Map()
for (const row of result.data) {
for (const side of ['Heim', 'Gast']) {
const isHarheimer = String(row[`${side}VereinNr`] || '') === '43030' || String(row[`${side}VereinName`] || '') === 'Harheimer TC'
const id = String(row[`${side}MannschaftId`] || '').trim()
if (!isHarheimer || !id || teams.has(id)) continue
const label = [row[`${side}Mannschaft`], row[`${side}MannschaftAltersklasse`], row.Liga || row.Staffel, row[`${side}MannschaftNr`] ? `Nr. ${row[`${side}MannschaftNr`]}` : ''].filter(Boolean).join(' · ')
teams.set(id, { id, label: `${label} [${id}]` })
}
}
spielplanTeams.value = [...teams.values()].sort((a, b) => a.label.localeCompare(b.label, 'de'))
} catch (error) {
console.error('Spielplan-Teams konnten nicht geladen werden:', error)
}
}
const onSeasonChange = async () => { const onSeasonChange = async () => {
await loadMannschaften().catch(() => {}) await loadMannschaften().catch(() => {})
} }
const getSpielerListe = (m) => { if (!m.spieler) return []; return m.spieler.split(';').map(s => s.trim()).filter(s => s !== '') } const getSpielerListe = (m) => { if (!m.spieler) return []; return m.spieler.split(';').map(s => s.trim()).filter(s => s !== '') }
const openAddModal = () => { formData.value = { mannschaft: '', liga: '', staffelleiter: '', telefon: '', heimspieltag: '', spielsystem: '', mannschaftsfuehrer: '', spielerListe: [], weitere_informationen_link: '', letzte_aktualisierung: nowIsoDate() }; showModal.value = true; errorMessage.value = ''; isEditing.value = false; editingIndex.value = -1; resetSpielerDraftState() } const openAddModal = () => { formData.value = { mannschaft: '', spielplan_mannschaft_id: '', liga: '', staffelleiter: '', telefon: '', heimspieltag: '', spielsystem: '', mannschaftsfuehrer: '', spielerListe: [], weitere_informationen_link: '', letzte_aktualisierung: nowIsoDate() }; showModal.value = true; errorMessage.value = ''; isEditing.value = false; editingIndex.value = -1; resetSpielerDraftState() }
const closeModal = () => { showModal.value = false; errorMessage.value = ''; isEditing.value = false; editingIndex.value = -1; resetSpielerDraftState() } const closeModal = () => { showModal.value = false; errorMessage.value = ''; isEditing.value = false; editingIndex.value = -1; resetSpielerDraftState() }
const openEditModal = (mannschaft, index) => { const openEditModal = (mannschaft, index) => {
formData.value = { mannschaft: mannschaft.mannschaft || '', liga: mannschaft.liga || '', staffelleiter: mannschaft.staffelleiter || '', telefon: mannschaft.telefon || '', heimspieltag: mannschaft.heimspieltag || '', spielsystem: mannschaft.spielsystem || '', mannschaftsfuehrer: mannschaft.mannschaftsfuehrer || '', spielerListe: parseSpielerString(mannschaft.spieler || ''), weitere_informationen_link: mannschaft.weitere_informationen_link || '', letzte_aktualisierung: mannschaft.letzte_aktualisierung || nowIsoDate() } formData.value = { mannschaft: mannschaft.mannschaft || '', spielplan_mannschaft_id: mannschaft.spielplan_mannschaft_id || '', liga: mannschaft.liga || '', staffelleiter: mannschaft.staffelleiter || '', telefon: mannschaft.telefon || '', heimspieltag: mannschaft.heimspieltag || '', spielsystem: mannschaft.spielsystem || '', mannschaftsfuehrer: mannschaft.mannschaftsfuehrer || '', spielerListe: parseSpielerString(mannschaft.spieler || ''), weitere_informationen_link: mannschaft.weitere_informationen_link || '', letzte_aktualisierung: mannschaft.letzte_aktualisierung || nowIsoDate() }
isEditing.value = true; editingIndex.value = index; showModal.value = true; errorMessage.value = ''; resetSpielerDraftState() isEditing.value = true; editingIndex.value = index; showModal.value = true; errorMessage.value = ''; resetSpielerDraftState()
const currentTeam = (formData.value.mannschaft || '').trim() const currentTeam = (formData.value.mannschaft || '').trim()
for (const s of formData.value.spielerListe) { for (const s of formData.value.spielerListe) {
@@ -569,7 +623,7 @@ const saveMannschaft = async () => {
try { try {
if (!applySelectedSpielerTransfers()) return if (!applySelectedSpielerTransfers()) return
const spielerString = serializeSpielerList(formData.value.spielerListe) const spielerString = serializeSpielerList(formData.value.spielerListe)
const updated = { mannschaft: formData.value.mannschaft || '', liga: formData.value.liga || '', staffelleiter: formData.value.staffelleiter || '', telefon: formData.value.telefon || '', heimspieltag: formData.value.heimspieltag || '', spielsystem: formData.value.spielsystem || '', mannschaftsfuehrer: formData.value.mannschaftsfuehrer || '', spieler: spielerString, weitere_informationen_link: formData.value.weitere_informationen_link || '', letzte_aktualisierung: formData.value.letzte_aktualisierung || nowIsoDate() } const updated = { mannschaft: formData.value.mannschaft || '', spielplan_mannschaft_id: formData.value.spielplan_mannschaft_id || '', liga: formData.value.liga || '', staffelleiter: formData.value.staffelleiter || '', telefon: formData.value.telefon || '', heimspieltag: formData.value.heimspieltag || '', spielsystem: formData.value.spielsystem || '', mannschaftsfuehrer: formData.value.mannschaftsfuehrer || '', spieler: spielerString, weitere_informationen_link: formData.value.weitere_informationen_link || '', letzte_aktualisierung: formData.value.letzte_aktualisierung || nowIsoDate() }
if (isEditing.value && editingIndex.value >= 0) { mannschaften.value[editingIndex.value] = { ...updated } } else { mannschaften.value.push({ ...updated }) } if (isEditing.value && editingIndex.value >= 0) { mannschaften.value[editingIndex.value] = { ...updated } } else { mannschaften.value.push({ ...updated }) }
const touchedTeamIndexes = Object.keys(pendingSpielerNamesByTeamIndex.value) const touchedTeamIndexes = Object.keys(pendingSpielerNamesByTeamIndex.value)
if (touchedTeamIndexes.length > 0) { const ts = nowIsoDate(); for (const idxStr of touchedTeamIndexes) { const idx = Number(idxStr); if (!Number.isFinite(idx)) continue; const existing = mannschaften.value[idx]; if (!existing) continue; mannschaften.value[idx] = { ...existing, spieler: serializeSpielerNames(pendingSpielerNamesByTeamIndex.value[idx]), letzte_aktualisierung: ts } } } if (touchedTeamIndexes.length > 0) { const ts = nowIsoDate(); for (const idxStr of touchedTeamIndexes) { const idx = Number(idxStr); if (!Number.isFinite(idx)) continue; const existing = mannschaften.value[idx]; if (!existing) continue; mannschaften.value[idx] = { ...existing, spieler: serializeSpielerNames(pendingSpielerNamesByTeamIndex.value[idx]), letzte_aktualisierung: ts } } }
@@ -579,10 +633,10 @@ const saveMannschaft = async () => {
} }
const saveCSV = async () => { const saveCSV = async () => {
const header = 'Mannschaft,Liga,Staffelleiter,Telefon,Heimspieltag,Spielsystem,Mannschaftsführer,Spieler,Weitere Informationen Link,Letzte Aktualisierung' const header = 'Mannschaft,Liga,Staffelleiter,Telefon,Heimspieltag,Spielsystem,Mannschaftsführer,Spieler,Weitere Informationen Link,Letzte Aktualisierung,Spielplan Mannschaft ID'
const rows = mannschaften.value.map(m => { const rows = mannschaften.value.map(m => {
const esc = (v) => { if (!v) return ''; const s = String(v); if (s.includes(',') || s.includes('"') || s.includes('\n')) return `"${s.replace(/"/g, '""')}"`; return s } const esc = (v) => { if (!v) return ''; const s = String(v); if (s.includes(',') || s.includes('"') || s.includes('\n')) return `"${s.replace(/"/g, '""')}"`; return s }
return [esc(m.mannschaft), esc(m.liga), esc(m.staffelleiter), esc(m.telefon), esc(m.heimspieltag), esc(m.spielsystem), esc(m.mannschaftsfuehrer), esc(m.spieler), esc(m.weitere_informationen_link), esc(m.letzte_aktualisierung)].join(',') return [esc(m.mannschaft), esc(m.liga), esc(m.staffelleiter), esc(m.telefon), esc(m.heimspieltag), esc(m.spielsystem), esc(m.mannschaftsfuehrer), esc(m.spieler), esc(m.weitere_informationen_link), esc(m.letzte_aktualisierung), esc(m.spielplan_mannschaft_id)].join(',')
}) })
await $fetch('/api/cms/save-csv', { await $fetch('/api/cms/save-csv', {
method: 'POST', method: 'POST',
@@ -639,10 +693,10 @@ const createNextSeason = async () => {
isCreatingSeason.value = true isCreatingSeason.value = true
errorMessage.value = '' errorMessage.value = ''
try { try {
const header = 'Mannschaft,Liga,Staffelleiter,Telefon,Heimspieltag,Spielsystem,Mannschaftsführer,Spieler,Weitere Informationen Link,Letzte Aktualisierung' const header = 'Mannschaft,Liga,Staffelleiter,Telefon,Heimspieltag,Spielsystem,Mannschaftsführer,Spieler,Weitere Informationen Link,Letzte Aktualisierung,Spielplan Mannschaft ID'
const rows = mannschaften.value.map(m => { const rows = mannschaften.value.map(m => {
const esc = (v) => { if (!v) return ''; const s = String(v); if (s.includes(',') || s.includes('"') || s.includes('\n')) return `"${s.replace(/"/g, '""')}"`; return s } const esc = (v) => { if (!v) return ''; const s = String(v); if (s.includes(',') || s.includes('"') || s.includes('\n')) return `"${s.replace(/"/g, '""')}"`; return s }
return [esc(m.mannschaft), esc(m.liga), esc(m.staffelleiter), esc(m.telefon), esc(m.heimspieltag), esc(m.spielsystem), esc(m.mannschaftsfuehrer), esc(m.spieler), esc(m.weitere_informationen_link), esc(m.letzte_aktualisierung || nowIsoDate())].join(',') return [esc(m.mannschaft), esc(m.liga), esc(m.staffelleiter), esc(m.telefon), esc(m.heimspieltag), esc(m.spielsystem), esc(m.mannschaftsfuehrer), esc(m.spieler), esc(m.weitere_informationen_link), esc(m.letzte_aktualisierung || nowIsoDate()), esc(m.spielplan_mannschaft_id)].join(',')
}) })
await $fetch('/api/cms/save-csv', { await $fetch('/api/cms/save-csv', {
@@ -678,7 +732,7 @@ const confirmDelete = (mannschaft, index) => {
onMounted(async () => { onMounted(async () => {
await loadSeasons() await loadSeasons()
await loadMannschaften().catch(() => {}) await Promise.all([loadMannschaften().catch(() => {}), loadSpielplanTeams()])
}) })
// Expose load function to parent components // Expose load function to parent components

View File

@@ -6,6 +6,13 @@
Spielpläne bearbeiten Spielpläne bearbeiten
</h2> </h2>
<div class="space-x-3"> <div class="space-x-3">
<button
:disabled="isImporting"
class="inline-flex items-center px-3 py-1.5 sm:px-4 sm:py-2 rounded-lg bg-blue-600 text-white hover:bg-blue-700 text-sm sm:text-base disabled:bg-blue-300"
@click="importFromMyTischtennis"
>
{{ isImporting ? 'Importiert...' : 'Von myTischtennis importieren' }}
</button>
<button <button
class="inline-flex items-center px-3 py-1.5 sm:px-4 sm:py-2 rounded-lg bg-green-600 text-white hover:bg-green-700 text-sm sm:text-base" class="inline-flex items-center px-3 py-1.5 sm:px-4 sm:py-2 rounded-lg bg-green-600 text-white hover:bg-green-700 text-sm sm:text-base"
@click="showUploadModal = true" @click="showUploadModal = true"
@@ -343,6 +350,7 @@ const fileInput = ref(null)
const modalFileInput = ref(null) const modalFileInput = ref(null)
const showUploadModal = ref(false) const showUploadModal = ref(false)
const isProcessing = ref(false) const isProcessing = ref(false)
const isImporting = ref(false)
const processingMessage = ref('') const processingMessage = ref('')
const isDragOver = ref(false) const isDragOver = ref(false)
const currentFile = ref(null) const currentFile = ref(null)
@@ -410,18 +418,37 @@ const save = async () => {
} catch (error) { console.error('Fehler:', error); alert('Fehler beim Speichern!') } } catch (error) { console.error('Fehler:', error); alert('Fehler beim Speichern!') }
} }
const loadCurrentSpielplan = async () => {
try {
const response = await fetch('/api/spielplan')
if (!response.ok) return
const result = await response.json()
if (!result.success || !Array.isArray(result.headers) || !Array.isArray(result.data)) return
csvHeaders.value = result.headers
csvData.value = result.data.map(row => csvHeaders.value.map(header => row[header] || ''))
selectedColumns.value = new Array(csvHeaders.value.length).fill(true)
currentFile.value = { name: result.season ? `spielplan-${result.season}.json` : 'spielplan.csv', entries: csvData.value.length, lastModified: null }
} catch { /* ignore */ }
}
const importFromMyTischtennis = async () => {
if (!confirm('Spielplan jetzt direkt von myTischtennis aktualisieren?')) return
isImporting.value = true
try {
const response = await fetch('/api/cms/import-spielplan', { method: 'POST' })
const result = await response.json()
if (!response.ok || !result.success) throw new Error(result.statusMessage || result.message || 'Import fehlgeschlagen')
await loadCurrentSpielplan()
alert(result.message || 'Spielplan erfolgreich aktualisiert.')
} catch (error) {
console.error('Spielplan-Import fehlgeschlagen:', error)
alert(`Fehler beim Import: ${error.message || 'Unbekannter Fehler'}`)
} finally {
isImporting.value = false
}
}
const closeUploadModal = () => { showUploadModal.value = false; selectedFile.value = null; if (modalFileInput.value) modalFileInput.value.value = '' } const closeUploadModal = () => { showUploadModal.value = false; selectedFile.value = null; if (modalFileInput.value) modalFileInput.value.value = '' }
onMounted(() => { onMounted(loadCurrentSpielplan)
(async () => {
try {
const response = await fetch('/api/spielplan'); if (!response.ok) return
const result = await response.json(); if (!result.success || !Array.isArray(result.headers) || !Array.isArray(result.data)) return
csvHeaders.value = result.headers
csvData.value = result.data.map(row => csvHeaders.value.map(header => row[header] || ''))
selectedColumns.value = new Array(csvHeaders.value.length).fill(true)
currentFile.value = { name: result.season ? `spielplan-${result.season}.json` : 'spielplan.csv', entries: csvData.value.length, lastModified: null }
} catch { /* ignore */ }
})()
})
</script> </script>

View File

@@ -25,14 +25,19 @@ export default [
'useHead': 'readonly', 'useHead': 'readonly',
'useFetch': 'readonly', 'useFetch': 'readonly',
'definePageMeta': 'readonly', 'definePageMeta': 'readonly',
'defineNuxtPlugin': 'readonly',
'defineNitroPlugin': 'readonly',
'defineNuxtRouteMiddleware': 'readonly', 'defineNuxtRouteMiddleware': 'readonly',
'defineEventHandler': 'readonly', 'defineEventHandler': 'readonly',
'readBody': 'readonly', 'readBody': 'readonly',
'getMethod': 'readonly',
'getCookie': 'readonly', 'getCookie': 'readonly',
'setCookie': 'readonly', 'setCookie': 'readonly',
'deleteCookie': 'readonly', 'deleteCookie': 'readonly',
'getHeader': 'readonly', 'getHeader': 'readonly',
'getRequestURL': 'readonly',
'setHeader': 'readonly', 'setHeader': 'readonly',
'setResponseStatus': 'readonly',
'getRouterParam': 'readonly', 'getRouterParam': 'readonly',
'getQuery': 'readonly', 'getQuery': 'readonly',
'sendStream': 'readonly', 'sendStream': 'readonly',
@@ -66,8 +71,9 @@ export default [
'vue/multi-word-component-names': 'off', 'vue/multi-word-component-names': 'off',
'vue/no-v-html': 'warn', 'vue/no-v-html': 'warn',
'no-unused-vars': ['warn', { 'no-unused-vars': ['warn', {
argsIgnorePattern: '^_', argsIgnorePattern: '^_|^event$',
varsIgnorePattern: '^_' varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_|^e$|^err$|^error$'
}], }],
'vue/no-unused-vars': ['warn', { 'vue/no-unused-vars': ['warn', {
ignorePattern: '^_' ignorePattern: '^_'
@@ -97,6 +103,18 @@ export default [
'tests/**', 'tests/**',
'scripts/**' 'scripts/**'
] ]
},
{
files: [
'pages/cms/newsletter.vue',
'pages/verein/geschichte.vue',
'pages/verein/satzung.vue',
'pages/verein/tt-regeln.vue',
'pages/verein/ueber-uns.vue'
],
rules: {
'vue/no-v-html': 'off'
}
} }
] ]

7405
package-lock.json generated Executable file → Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "harheimertc-website", "name": "harheimertc-website",
"version": "1.8.2", "version": "1.8.4",
"description": "Moderne Webseite für den Harheimer Tischtennis Club", "description": "Moderne Webseite für den Harheimer Tischtennis Club",
"private": true, "private": true,
"type": "module", "type": "module",
@@ -49,11 +49,13 @@
"vue": "^3.5.22" "vue": "^3.5.22"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1",
"@nuxtjs/tailwindcss": "^6.11.0", "@nuxtjs/tailwindcss": "^6.11.0",
"@types/dompurify": "^3.0.5", "@types/dompurify": "^3.0.5",
"autoprefixer": "^10.4.0", "autoprefixer": "^10.4.0",
"commander": "^13.1.0", "commander": "^13.1.0",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"eslint": "^10.7.0",
"eslint-plugin-vue": "^10.6.2", "eslint-plugin-vue": "^10.6.2",
"globals": "^16.5.0", "globals": "^16.5.0",
"lucide-vue-next": "^0.344.0", "lucide-vue-next": "^0.344.0",

View File

@@ -167,6 +167,7 @@
</span> </span>
</div> </div>
<!-- nosemgrep: javascript.vue.security.audit.xss.templates.avoid-v-html --> <!-- nosemgrep: javascript.vue.security.audit.xss.templates.avoid-v-html -->
<!-- eslint-disable-next-line vue/no-v-html -->
<div <div
class="text-sm text-gray-600 prose prose-sm max-w-none mb-3" class="text-sm text-gray-600 prose prose-sm max-w-none mb-3"
v-html="useSanitizeHtml(post.content.substring(0, 200) + (post.content.length > 200 ? '...' : ''))" v-html="useSanitizeHtml(post.content.substring(0, 200) + (post.content.length > 200 ? '...' : ''))"

View File

@@ -362,7 +362,7 @@
<script setup> <script setup>
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { Users } from 'lucide-vue-next' import { rowMatchesTeamFilter } from '../../utils/spielplan-filter.js'
const route = useRoute() const route = useRoute()
@@ -473,6 +473,7 @@ const loadMannschaften = async () => {
spieler: values[7].trim(), spieler: values[7].trim(),
weitere_informationen_link: values[8].trim(), weitere_informationen_link: values[8].trim(),
letzte_aktualisierung: values[9].trim(), letzte_aktualisierung: values[9].trim(),
spielplan_mannschaft_id: values[10] ? values[10].trim() : '',
slug: values[0].trim().toLowerCase().replace(/\s+/g, '-') slug: values[0].trim().toLowerCase().replace(/\s+/g, '-')
} }
}).filter(mannschaft => mannschaft !== null) }).filter(mannschaft => mannschaft !== null)
@@ -557,45 +558,16 @@ const isExactHarheimTeam = (teamName, variant) => {
} }
const isSpielForMannschaft = (row, cmsMannschaft) => { const isSpielForMannschaft = (row, cmsMannschaft) => {
const variants = getTeamVariants(cmsMannschaft) const configuredTeamIds = String(mannschaft.value?.spielplan_mannschaft_id || '')
if (!variants.length) return false .split(',')
.map(id => id.trim())
const heimMannschaft = (row.HeimMannschaft || '').toLowerCase() .filter(Boolean)
const gastMannschaft = (row.GastMannschaft || '').toLowerCase() if (configuredTeamIds.length) {
const heimAltersklasse = (row.HeimMannschaftAltersklasse || '').toLowerCase() return configuredTeamIds.includes(String(row.HeimMannschaftId || '').trim()) ||
const gastAltersklasse = (row.GastMannschaftAltersklasse || '').toLowerCase() configuredTeamIds.includes(String(row.GastMannschaftId || '').trim())
const isHarheimerHeim = heimMannschaft.includes('harheimer tc')
const isHarheimerGast = gastMannschaft.includes('harheimer tc')
if (!isHarheimerHeim && !isHarheimerGast) return false
const mannschaftMatch = variants.some((variant) => {
if (isHarheimerHeim && isExactHarheimTeam(heimMannschaft, variant)) return true
if (isHarheimerGast && isExactHarheimTeam(gastMannschaft, variant)) return true
return false
})
if (!mannschaftMatch) return false
if (cmsMannschaft.startsWith('Erwachsene')) {
const isErwachsenenHeim = isHarheimerHeim &&
heimAltersklasse.includes('erwachsene') &&
!heimAltersklasse.includes('jugend')
const isErwachsenenGast = isHarheimerGast &&
gastAltersklasse.includes('erwachsene') &&
!gastAltersklasse.includes('jugend')
return isErwachsenenHeim || isErwachsenenGast
} }
if (cmsMannschaft === 'Jugendmannschaft') { return rowMatchesTeamFilter(row, cmsMannschaft)
const isJugendHeim = isHarheimerHeim &&
(heimAltersklasse.includes('jugend') || heimMannschaft.includes('jugend'))
const isJugendGast = isHarheimerGast &&
(gastAltersklasse.includes('jugend') || gastMannschaft.includes('jugend'))
return isJugendHeim || isJugendGast
}
return true
} }
const parseTerminTimestamp = (row) => { const parseTerminTimestamp = (row) => {

View File

@@ -360,6 +360,7 @@
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { filterSpielplanRows, toApiTeamParam } from '../../utils/spielplan-filter.js'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@@ -525,183 +526,16 @@ const filterData = () => {
return return
} }
let saisonFiltered = spielplanData.value filteredData.value = filterSpielplanRows(spielplanData.value, {
team: selectedFilter.value,
// Dann nach Wettbewerb filtern wettbewerb: selectedWettbewerb.value
let wettbewerbFiltered = saisonFiltered })
if (selectedWettbewerb.value === 'punktrunde') {
wettbewerbFiltered = saisonFiltered.filter(row => {
const runde = (row.Runde || '').toLowerCase()
const staffel = (row.Staffel || '').toLowerCase()
const liga = (row.Liga || '').toLowerCase()
const isPokal = runde.includes('pokal') || staffel.includes('pokal') || liga.includes('pokal')
return !isPokal
})
} else if (selectedWettbewerb.value === 'pokal') {
wettbewerbFiltered = saisonFiltered.filter(row => {
const runde = (row.Runde || '').toLowerCase()
const staffel = (row.Staffel || '').toLowerCase()
const liga = (row.Liga || '').toLowerCase()
return runde.includes('pokal') || staffel.includes('pokal') || liga.includes('pokal')
})
}
// "alle" zeigt alle Spiele ohne weitere Filterung
// Dann nach Mannschaft filtern
if (selectedFilter.value === 'all') {
filteredData.value = wettbewerbFiltered
} else if (selectedFilter.value === 'erwachsene') {
filteredData.value = wettbewerbFiltered.filter(row => {
const heimMannschaft = (row.HeimMannschaft || '').toLowerCase()
const gastMannschaft = (row.GastMannschaft || '').toLowerCase()
const heimAltersklasse = (row.HeimMannschaftAltersklasse || '').toLowerCase()
const gastAltersklasse = (row.GastMannschaftAltersklasse || '').toLowerCase()
// Prüfe ob eine der Mannschaften Harheimer TC ist
const isHarheimerHeim = heimMannschaft.includes('harheimer tc')
const isHarheimerGast = gastMannschaft.includes('harheimer tc')
if (!isHarheimerHeim && !isHarheimerGast) {
return false // Kein Harheimer TC Spiel
}
// Filtere nach Erwachsenen-Mannschaften (NICHT Jugend)
const isErwachsenenHeim = isHarheimerHeim &&
heimAltersklasse.includes('erwachsene') &&
!heimAltersklasse.includes('jugend')
const isErwachsenenGast = isHarheimerGast &&
gastAltersklasse.includes('erwachsene') &&
!gastAltersklasse.includes('jugend')
return isErwachsenenHeim || isErwachsenenGast
})
} else if (selectedFilter.value === 'nachwuchs') {
filteredData.value = wettbewerbFiltered.filter(row => {
const heimMannschaft = (row.HeimMannschaft || '').toLowerCase()
const gastMannschaft = (row.GastMannschaft || '').toLowerCase()
const heimAltersklasse = (row.HeimMannschaftAltersklasse || '').toLowerCase()
const gastAltersklasse = (row.GastMannschaftAltersklasse || '').toLowerCase()
// Prüfe ob eine der Mannschaften Harheimer TC ist
const isHarheimerHeim = heimMannschaft.includes('harheimer tc')
const isHarheimerGast = gastMannschaft.includes('harheimer tc')
if (!isHarheimerHeim && !isHarheimerGast) {
return false // Kein Harheimer TC Spiel
}
// Filtere nach Jugend-Mannschaften (NUR Jugend)
const isJugendHeim = isHarheimerHeim &&
(heimAltersklasse.includes('jugend') || heimMannschaft.includes('jugend'))
const isJugendGast = isHarheimerGast &&
(gastAltersklasse.includes('jugend') || gastMannschaft.includes('jugend'))
return isJugendHeim || isJugendGast
})
} else {
// Spezifische Mannschaft - Mapping zwischen CMS-Mannschaften und CSV-Daten
filteredData.value = wettbewerbFiltered.filter(row => {
const heimMannschaft = (row.HeimMannschaft || '').toLowerCase()
const gastMannschaft = (row.GastMannschaft || '').toLowerCase()
const heimAltersklasse = (row.HeimMannschaftAltersklasse || '').toLowerCase()
const gastAltersklasse = (row.GastMannschaftAltersklasse || '').toLowerCase()
// Prüfe ob eine der Mannschaften Harheimer TC ist
const isHarheimerHeim = heimMannschaft.includes('harheimer tc')
const isHarheimerGast = gastMannschaft.includes('harheimer tc')
if (!isHarheimerHeim && !isHarheimerGast) {
return false // Kein Harheimer TC Spiel
}
const cmsMannschaft = selectedFilter.value
// Mapping zwischen CMS-Mannschaften und CSV-Daten
const mannschaftMapping = {
'Erwachsene 1': ['harheimer tc'], // Nur ohne römische Zahl
'Erwachsene 2': ['harheimer tc ii'],
'Erwachsene 3': ['harheimer tc iii'],
'Erwachsene 4': ['harheimer tc iv'],
'Erwachsene 5': ['harheimer tc v'],
'Jugendmannschaft': ['harheimer tc'] // Jugend hat keine römische Zahl
}
const csvVariants = mannschaftMapping[cmsMannschaft] || []
// Prüfe Mannschafts-Zuordnung UND Altersklasse
const mannschaftMatch = csvVariants.some(variant => {
// Strikte Übereinstimmung: Prüfe exakte Mannschaftsnamen
if (isHarheimerHeim) {
// Für "harheimer tc" (Erwachsene 1): Nur wenn KEINE römische Zahl folgt
if (variant === 'harheimer tc') {
return heimMannschaft === 'harheimer tc' ||
heimMannschaft.startsWith('harheimer tc ') &&
!heimMannschaft.match(/harheimer tc\s+[ivx]+/i)
}
// Für andere Mannschaften: Exakte Übereinstimmung
return heimMannschaft === variant || heimMannschaft.startsWith(variant + ' ')
}
if (isHarheimerGast) {
// Für "harheimer tc" (Erwachsene 1): Nur wenn KEINE römische Zahl folgt
if (variant === 'harheimer tc') {
return gastMannschaft === 'harheimer tc' ||
gastMannschaft.startsWith('harheimer tc ') &&
!gastMannschaft.match(/harheimer tc\s+[ivx]+/i)
}
// Für andere Mannschaften: Exakte Übereinstimmung
return gastMannschaft === variant || gastMannschaft.startsWith(variant + ' ')
}
return false
})
if (!mannschaftMatch) {
return false
}
// Zusätzliche Altersklassen-Prüfung für spezifische Mannschaften
if (cmsMannschaft.startsWith('Erwachsene')) {
// Erwachsenen-Mannschaften: MUSS Erwachsene sein, DARF NICHT Jugend sein
const isErwachsenenHeim = isHarheimerHeim &&
heimAltersklasse.includes('erwachsene') &&
!heimAltersklasse.includes('jugend')
const isErwachsenenGast = isHarheimerGast &&
gastAltersklasse.includes('erwachsene') &&
!gastAltersklasse.includes('jugend')
return isErwachsenenHeim || isErwachsenenGast
} else if (cmsMannschaft === 'Jugendmannschaft') {
// Jugend-Mannschaft: MUSS Jugend sein
const isJugendHeim = isHarheimerHeim &&
(heimAltersklasse.includes('jugend') || heimMannschaft.includes('jugend'))
const isJugendGast = isHarheimerGast &&
(gastAltersklasse.includes('jugend') || gastMannschaft.includes('jugend'))
return isJugendHeim || isJugendGast
}
return true // Fallback für unbekannte Mannschaften
})
}
} }
const downloadPDF = () => { const downloadPDF = () => {
if (!filteredData.value || filteredData.value.length === 0) return if (!filteredData.value || filteredData.value.length === 0) return
// Bestimme den Team-Parameter basierend auf dem Filter const teamParam = toApiTeamParam(selectedFilter.value)
let teamParam = ''
if (selectedFilter.value === 'all') {
teamParam = 'all'
} else if (selectedFilter.value === 'erwachsene') {
teamParam = 'erwachsene'
} else if (selectedFilter.value === 'nachwuchs') {
teamParam = 'nachwuchs'
} else {
// Für einzelne Mannschaften: Konvertiere Namen
teamParam = selectedFilter.value.replace(/\s+/g, '_').toLowerCase()
}
// Erstelle Download-URL für dynamische PDF-Generierung // Erstelle Download-URL für dynamische PDF-Generierung
const params = new URLSearchParams({ const params = new URLSearchParams({

View File

@@ -124,11 +124,9 @@
</template> </template>
<script setup> <script setup>
import { User, Users, Newspaper, Check, Calendar } from 'lucide-vue-next' import { User, Users, Newspaper, Calendar } from 'lucide-vue-next'
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
const authStore = useAuthStore()
const birthdays = ref([]) const birthdays = ref([])
const loadingBirthdays = ref(true) const loadingBirthdays = ref(true)

View File

@@ -1000,11 +1000,6 @@ const canEdit = computed(() => {
return authStore.hasAnyRole('admin', 'vorstand') return authStore.hasAnyRole('admin', 'vorstand')
}) })
const canViewContactData = computed(() => {
// Explicitly check for 'vorstand' role only
return authStore.hasRole('vorstand')
})
const isBirthdateRequired = computed(() => { const isBirthdateRequired = computed(() => {
return !editingMember.value || Boolean(editingMember.value?.geburtsdatum) return !editingMember.value || Boolean(editingMember.value?.geburtsdatum)
}) })

View File

@@ -33,13 +33,22 @@
</div> </div>
</div> </div>
<div v-if="pending" class="py-12 text-center text-gray-500"> <div
v-if="pending"
class="py-12 text-center text-gray-500"
>
Lade QTTR-Werte... Lade QTTR-Werte...
</div> </div>
<div v-else-if="error" class="rounded-lg border border-red-200 bg-red-50 p-4 text-red-800"> <div
v-else-if="error"
class="rounded-lg border border-red-200 bg-red-50 p-4 text-red-800"
>
{{ error.statusMessage || error.message || 'QTTR-Werte konnten nicht geladen werden.' }} {{ error.statusMessage || error.message || 'QTTR-Werte konnten nicht geladen werden.' }}
</div> </div>
<div v-else class="overflow-x-auto"> <div
v-else
class="overflow-x-auto"
>
<table class="min-w-full divide-y divide-gray-200"> <table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50"> <thead class="bg-gray-50">
<tr> <tr>

View File

@@ -162,8 +162,6 @@ const selectedGroup = computed(() => {
}) })
const isLoggedIn = computed(() => authStore.isLoggedIn) const isLoggedIn = computed(() => authStore.isLoggedIn)
const userEmail = computed(() => authStore.user?.email || '')
const userName = computed(() => authStore.user?.name || '')
async function loadGroups() { async function loadGroups() {
try { try {

View File

@@ -11,13 +11,24 @@
</div> </div>
<div class="bg-white rounded-xl shadow-lg p-8"> <div class="bg-white rounded-xl shadow-lg p-8">
<form class="space-y-6" @submit.prevent="handleSubmit"> <form
<div v-if="!token" class="bg-red-50 border border-red-200 rounded-lg p-4"> class="space-y-6"
<p class="text-sm text-red-800">Der Reset-Link ist unvollständig. Fordern Sie bitte einen neuen Link an.</p> @submit.prevent="handleSubmit"
>
<div
v-if="!token"
class="bg-red-50 border border-red-200 rounded-lg p-4"
>
<p class="text-sm text-red-800">
Der Reset-Link ist unvollständig. Fordern Sie bitte einen neuen Link an.
</p>
</div> </div>
<div> <div>
<label for="password" class="block text-sm font-medium text-gray-700 mb-2"> <label
for="password"
class="block text-sm font-medium text-gray-700 mb-2"
>
Neues Passwort Neues Passwort
</label> </label>
<input <input
@@ -32,7 +43,10 @@
</div> </div>
<div> <div>
<label for="passwordRepeat" class="block text-sm font-medium text-gray-700 mb-2"> <label
for="passwordRepeat"
class="block text-sm font-medium text-gray-700 mb-2"
>
Neues Passwort wiederholen Neues Passwort wiederholen
</label> </label>
<input <input
@@ -46,13 +60,24 @@
> >
</div> </div>
<div v-if="errorMessage" class="bg-red-50 border border-red-200 rounded-lg p-4"> <div
<p class="text-sm text-red-800">{{ errorMessage }}</p> v-if="errorMessage"
class="bg-red-50 border border-red-200 rounded-lg p-4"
>
<p class="text-sm text-red-800">
{{ errorMessage }}
</p>
</div> </div>
<div v-if="successMessage" class="bg-green-50 border border-green-200 rounded-lg p-4"> <div
v-if="successMessage"
class="bg-green-50 border border-green-200 rounded-lg p-4"
>
<p class="text-sm text-green-800 flex items-center"> <p class="text-sm text-green-800 flex items-center">
<Check :size="18" class="mr-2" /> <Check
:size="18"
class="mr-2"
/>
{{ successMessage }} {{ successMessage }}
</p> </p>
</div> </div>
@@ -62,12 +87,19 @@
:disabled="isLoading || !token || Boolean(successMessage)" :disabled="isLoading || !token || Boolean(successMessage)"
class="w-full px-6 py-3 bg-primary-600 hover:bg-primary-700 disabled:bg-gray-400 text-white font-semibold rounded-lg transition-colors flex items-center justify-center" class="w-full px-6 py-3 bg-primary-600 hover:bg-primary-700 disabled:bg-gray-400 text-white font-semibold rounded-lg transition-colors flex items-center justify-center"
> >
<Loader2 v-if="isLoading" :size="20" class="mr-2 animate-spin" /> <Loader2
v-if="isLoading"
:size="20"
class="mr-2 animate-spin"
/>
<span>{{ isLoading ? 'Wird gespeichert...' : 'Passwort speichern' }}</span> <span>{{ isLoading ? 'Wird gespeichert...' : 'Passwort speichern' }}</span>
</button> </button>
<div class="text-center"> <div class="text-center">
<NuxtLink to="/login" class="text-sm text-primary-600 hover:text-primary-700 font-medium"> <NuxtLink
to="/login"
class="text-sm text-primary-600 hover:text-primary-700 font-medium"
>
Zurück zum Login Zurück zum Login
</NuxtLink> </NuxtLink>
</div> </div>

View File

@@ -394,7 +394,6 @@ const formData = ref({
const isLoading = ref(false) const isLoading = ref(false)
const errorMessage = ref('') const errorMessage = ref('')
const successMessage = ref('') const successMessage = ref('')
const usePasskey = ref(false)
const isPasskeySupported = ref(false) const isPasskeySupported = ref(false)
const passkeySupportReason = ref('') const passkeySupportReason = ref('')
const setPasswordForPasskey = ref(true) const setPasswordForPasskey = ref(true)
@@ -424,7 +423,6 @@ const handleFormSubmit = (event) => {
// console.log('[DEBUG] Calling handleRegister...') // console.log('[DEBUG] Calling handleRegister...')
handleRegister() handleRegister()
} }
const showDebugInfo = ref(false)
const debugChallenge = ref('') const debugChallenge = ref('')
const debugRpId = ref('') const debugRpId = ref('')
const debugRegistrationId = ref('') const debugRegistrationId = ref('')
@@ -697,8 +695,6 @@ const handleRegisterWithPasskey = async () => {
debugSmartphoneUrl.value = `${window.location.origin}/passkey-register-cross-device?registrationId=${pre.registrationId}` debugSmartphoneUrl.value = `${window.location.origin}/passkey-register-cross-device?registrationId=${pre.registrationId}`
} }
showDebugInfo.value = true
console.log('[DEBUG] QR-Code Info (for Cross-Device):', { console.log('[DEBUG] QR-Code Info (for Cross-Device):', {
challenge: pre.options?.challenge, challenge: pre.options?.challenge,
challengeLength: pre.options?.challenge?.length, challengeLength: pre.options?.challenge?.length,

View File

@@ -5,6 +5,7 @@
Geschichte Geschichte
</h1> </h1>
<!-- nosemgrep: javascript.vue.security.audit.xss.templates.avoid-v-html --> <!-- nosemgrep: javascript.vue.security.audit.xss.templates.avoid-v-html -->
<!-- eslint-disable-next-line vue/no-v-html -->
<div <div
class="prose prose-lg max-w-none" class="prose prose-lg max-w-none"
v-html="content" v-html="content"

View File

@@ -6,6 +6,7 @@
</h1> </h1>
<!-- nosemgrep: javascript.vue.security.audit.xss.templates.avoid-v-html --> <!-- nosemgrep: javascript.vue.security.audit.xss.templates.avoid-v-html -->
<!-- eslint-disable-next-line vue/no-v-html -->
<div <div
class="prose prose-lg max-w-none mb-8" class="prose prose-lg max-w-none mb-8"
v-html="content" v-html="content"

View File

@@ -5,6 +5,7 @@
TT-Regeln TT-Regeln
</h1> </h1>
<!-- nosemgrep: javascript.vue.security.audit.xss.templates.avoid-v-html --> <!-- nosemgrep: javascript.vue.security.audit.xss.templates.avoid-v-html -->
<!-- eslint-disable-next-line vue/no-v-html -->
<div <div
class="prose prose-lg max-w-none" class="prose prose-lg max-w-none"
v-html="content" v-html="content"

View File

@@ -5,6 +5,7 @@
Über uns Über uns
</h1> </h1>
<!-- nosemgrep: javascript.vue.security.audit.xss.templates.avoid-v-html --> <!-- nosemgrep: javascript.vue.security.audit.xss.templates.avoid-v-html -->
<!-- eslint-disable-next-line vue/no-v-html -->
<div <div
class="prose prose-lg max-w-none" class="prose prose-lg max-w-none"
v-html="content" v-html="content"

View File

@@ -9,9 +9,6 @@ import { getClientIp } from '../../../utils/rate-limit.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'))
const getRequestURL = globalThis.getRequestURL ?? ((e) => {
try { return new URL(e?.req?.url, 'http://localhost') } catch { return { href: String(e?.req?.url || ''), pathname: String(e?.req?.url || '').split('?')[0] || '' } }
})
function findUserByCredentialId(users, credentialId) { function findUserByCredentialId(users, credentialId) {
const cid = String(credentialId || '') const cid = String(credentialId || '')

View File

@@ -0,0 +1,54 @@
import { getUserFromToken, hasAnyRole } from '../../utils/auth.js'
import { importSpielplan } from '../../utils/spielplan-import.js'
import { importLeagueTables } from '../../utils/spielklassen-tables-import.js'
import { publishImportedSpielplan } from '../../utils/spielplan-publish.js'
import { synchronizeMannschaftenSpielplanIds } from '../../utils/mannschaften-spielplan-ids.js'
import { error as loggerError, info as loggerInfo } from '../../utils/logger.js'
export default defineEventHandler(async (event) => {
let token = getCookie(event, 'auth_token')
if (!token) {
const authorization = getHeader(event, 'authorization')
if (authorization?.startsWith('Bearer ')) token = authorization.substring(7).trim()
}
const user = token ? await getUserFromToken(token) : null
if (!user) {
throw createError({ statusCode: 401, statusMessage: 'Nicht authentifiziert' })
}
if (!hasAnyRole(user, 'admin', 'vorstand')) {
throw createError({ statusCode: 403, statusMessage: 'Keine Berechtigung' })
}
try {
const imported = await importSpielplan()
const published = await publishImportedSpielplan({ inputPath: imported.jsonFile })
const mapping = await synchronizeMannschaftenSpielplanIds(published.seasonSlug)
let tableMessage = ''
try {
const tables = await importLeagueTables()
tableMessage = ` Tabellen: ${tables.importedCount}/${tables.teamCount}.`
} catch (error) {
loggerError('[cms] Tabellen-Import fehlgeschlagen:', { error })
tableMessage = ' Spielplan wurde aktualisiert; Tabellen konnten nicht aktualisiert werden.'
}
loggerInfo('[cms] Spielplan manuell importiert', {
userId: user.id,
season: published.seasonSlug,
matchCount: imported.matchCount
})
return {
success: true,
message: `Spielplan ${published.seasonSlug} mit ${imported.matchCount} Spielen aktualisiert. ${mapping.updatedTeams} Mannschaften automatisch zugeordnet.${tableMessage}`,
season: published.seasonSlug,
matchCount: imported.matchCount,
mappedTeams: mapping.updatedTeams
}
} catch (error) {
loggerError('[cms] Manueller Spielplan-Import fehlgeschlagen:', { error })
throw createError({ statusCode: 502, statusMessage: 'Spielplan konnte nicht von myTischtennis importiert werden' })
}
})

View File

@@ -371,7 +371,10 @@ export default defineEventHandler(async (event) => {
arrayBuffer = await res.arrayBuffer() arrayBuffer = await res.arrayBuffer()
} }
} catch (templateLoadError) { } catch (templateLoadError) {
throw new Error('Template-Laden fehlgeschlagen: ' + templateLoadError.message) throw new Error(
'Template-Laden fehlgeschlagen: ' + (templateLoadError?.message || String(templateLoadError)),
{ cause: templateLoadError }
)
} }
const pdfDoc = await PDFDocument.load(arrayBuffer) const pdfDoc = await PDFDocument.load(arrayBuffer)
@@ -610,7 +613,6 @@ export default defineEventHandler(async (event) => {
// E-Mail senden via zentralen Service (pass full path) // E-Mail senden via zentralen Service (pass full path)
emailResult = await sendMembershipEmailUtil(data, finalPdfPath) emailResult = await sendMembershipEmailUtil(data, finalPdfPath)
// Antragsdaten verschlüsselt speichern // Antragsdaten verschlüsselt speichern
const encryptionKey = process.env.ENCRYPTION_KEY || 'local_development_encryption_key_change_in_production'
const encryptedData = JSON.stringify(data) const encryptedData = JSON.stringify(data)
// 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 generated from timestamp, not user input, path traversal prevented // filename is generated from timestamp, not user input, path traversal prevented
@@ -671,7 +673,6 @@ export default defineEventHandler(async (event) => {
emailResult = await sendMembershipEmailUtil(data, finalPdfPath) emailResult = await sendMembershipEmailUtil(data, finalPdfPath)
// Antragsdaten verschlüsselt speichern // Antragsdaten verschlüsselt speichern
const encryptionKey = process.env.ENCRYPTION_KEY || 'local_development_encryption_key_change_in_production'
const encryptedData = JSON.stringify(data) const encryptedData = JSON.stringify(data)
// 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 generated from timestamp, not user input, path traversal prevented // filename is generated from timestamp, not user input, path traversal prevented

View File

@@ -1,6 +1,7 @@
import fs from 'fs/promises' import fs from 'fs/promises'
import path from 'path' import path from 'path'
import { getUserFromToken, hasAnyRole } from '../../../../../utils/auth.js' import { getUserFromToken, hasAnyRole } from '../../../../../utils/auth.js'
import { decryptObject } from '../../../../../utils/encryption.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 (e.g., 'newsletter-posts.json'), never user input // filename is always a hardcoded constant (e.g., 'newsletter-posts.json'), never user input

View File

@@ -2,6 +2,7 @@ import fs from 'fs/promises'
import path from 'path' import path from 'path'
import { readSpielplanData, validateSeasonSlug } from '../../utils/spielplan-data.js' import { readSpielplanData, validateSeasonSlug } from '../../utils/spielplan-data.js'
import { info as loggerInfo, error as loggerError } from '../../utils/logger.js' import { info as loggerInfo, error as loggerError } from '../../utils/logger.js'
import { filterSpielplanRows } from '../../../utils/spielplan-filter.js'
function seasonSlugToLabel(slug) { function seasonSlugToLabel(slug) {
const match = String(slug || '').match(/^(\d{2})--(\d{2})$/) const match = String(slug || '').match(/^(\d{2})--(\d{2})$/)
@@ -35,116 +36,8 @@ export default defineEventHandler(async (event) => {
const dataRows = spielplan.data const dataRows = spielplan.data
// Filtere Daten basierend auf Team
let filteredData = dataRows
if (team !== 'all') {
filteredData = dataRows.filter(row => {
const heimMannschaft = (row.HeimMannschaft || '').toLowerCase()
const gastMannschaft = (row.GastMannschaft || '').toLowerCase()
const heimAltersklasse = (row.HeimMannschaftAltersklasse || '').toLowerCase()
const gastAltersklasse = (row.GastMannschaftAltersklasse || '').toLowerCase()
// Prüfe ob eine der Mannschaften Harheimer TC ist
const isHarheimerHeim = heimMannschaft.includes('harheimer tc')
const isHarheimerGast = gastMannschaft.includes('harheimer tc')
if (!isHarheimerHeim && !isHarheimerGast) {
return false // Kein Harheimer TC Spiel
}
// Mapping zwischen Team-Namen und CSV-Daten
const mannschaftMapping = {
'erwachsene': ['harheimer tc'], // Alle Erwachsenen-Mannschaften
'nachwuchs': ['harheimer tc'], // Alle Jugend-Mannschaften
'erwachsene_1': ['harheimer tc'], // Nur ohne römische Zahl
'erwachsene_2': ['harheimer tc ii'],
'erwachsene_3': ['harheimer tc iii'],
'erwachsene_4': ['harheimer tc iv'],
'erwachsene_5': ['harheimer tc v'],
'jugendmannschaft': ['harheimer tc'] // Jugend hat keine römische Zahl
}
const csvVariants = mannschaftMapping[team] || []
if (csvVariants.length === 0) {
return false
}
// Prüfe Mannschafts-Zuordnung UND Altersklasse
const mannschaftMatch = csvVariants.some(variant => {
// Strikte Übereinstimmung: Prüfe exakte Mannschaftsnamen
if (isHarheimerHeim) {
// Für "harheimer tc" (Erwachsene 1): Nur wenn KEINE römische Zahl folgt
if (variant === 'harheimer tc') {
return heimMannschaft === 'harheimer tc' ||
heimMannschaft.startsWith('harheimer tc ') &&
!heimMannschaft.match(/harheimer tc\s+[ivx]+/i)
}
// Für andere Mannschaften: Exakte Übereinstimmung
return heimMannschaft === variant || heimMannschaft.startsWith(variant + ' ')
}
if (isHarheimerGast) {
// Für "harheimer tc" (Erwachsene 1): Nur wenn KEINE römische Zahl folgt
if (variant === 'harheimer tc') {
return gastMannschaft === 'harheimer tc' ||
gastMannschaft.startsWith('harheimer tc ') &&
!gastMannschaft.match(/harheimer tc\s+[ivx]+/i)
}
// Für andere Mannschaften: Exakte Übereinstimmung
return gastMannschaft === variant || gastMannschaft.startsWith(variant + ' ')
}
return false
})
if (!mannschaftMatch) {
return false
}
// Zusätzliche Altersklassen-Prüfung für spezifische Mannschaften
if (team.startsWith('erwachsene')) {
// Erwachsenen-Mannschaften: MUSS Erwachsene sein, DARF NICHT Jugend sein
const isErwachsenenHeim = isHarheimerHeim &&
heimAltersklasse.includes('erwachsene') &&
!heimAltersklasse.includes('jugend')
const isErwachsenenGast = isHarheimerGast &&
gastAltersklasse.includes('erwachsene') &&
!gastAltersklasse.includes('jugend')
return isErwachsenenHeim || isErwachsenenGast
} else if (team === 'jugendmannschaft' || team === 'nachwuchs') {
// Jugend-Mannschaft: MUSS Jugend sein
const isJugendHeim = isHarheimerHeim &&
(heimAltersklasse.includes('jugend') || heimMannschaft.includes('jugend'))
const isJugendGast = isHarheimerGast &&
(gastAltersklasse.includes('jugend') || gastMannschaft.includes('jugend'))
return isJugendHeim || isJugendGast
}
return true // Fallback für unbekannte Mannschaften
})
}
// Filtere nach Wettbewerb (Standard: Punktrunde)
// Muss zur Frontend-Logik passen: Punktrunde = alles, was kein Pokal ist.
const wettbewerb = String(query.wettbewerb || 'punktrunde').toLowerCase().trim() const wettbewerb = String(query.wettbewerb || 'punktrunde').toLowerCase().trim()
if (wettbewerb !== 'alle') { const filteredData = filterSpielplanRows(dataRows, { team, wettbewerb })
filteredData = filteredData.filter(row => {
const runde = (row.Runde || '').toLowerCase()
const staffel = (row.Staffel || '').toLowerCase()
const liga = (row.Liga || '').toLowerCase()
const isPokal = runde.includes('pokal') || staffel.includes('pokal') || liga.includes('pokal')
if (wettbewerb === 'punktrunde') {
return !isPokal
} else if (wettbewerb === 'pokal') {
return isPokal
}
return true
})
}
// Sammle Halle-Informationen für die jeweilige Mannschaft // Sammle Halle-Informationen für die jeweilige Mannschaft
const hallenMap = new Map() const hallenMap = new Map()

View File

@@ -1,5 +1,4 @@
import { promises as fs } from 'fs' import { promises as fs } from 'fs'
import path from 'path'
import { getCurrentSeasonSlug, validateSeasonSlug } from '../../utils/spielplan-data.js' import { getCurrentSeasonSlug, validateSeasonSlug } from '../../utils/spielplan-data.js'
import { getServerDataPath } from '../../utils/paths.js' import { getServerDataPath } from '../../utils/paths.js'
import { error as loggerError } from '../../utils/logger.js' import { error as loggerError } from '../../utils/logger.js'

View File

@@ -2,6 +2,7 @@ import { importSpielplan } from '../utils/spielplan-import.js'
import { importLeagueTables } from '../utils/spielklassen-tables-import.js' import { importLeagueTables } from '../utils/spielklassen-tables-import.js'
import { importQttrValues } from '../utils/qttr-import.js' import { importQttrValues } from '../utils/qttr-import.js'
import { publishImportedSpielplan } from '../utils/spielplan-publish.js' import { publishImportedSpielplan } from '../utils/spielplan-publish.js'
import { synchronizeMannschaftenSpielplanIds } from '../utils/mannschaften-spielplan-ids.js'
import { info as loggerInfo, error as loggerError } from '../utils/logger.js' import { info as loggerInfo, error as loggerError } from '../utils/logger.js'
import { cleanupPasswordResetLogs } from '../utils/password-reset-log.js' import { cleanupPasswordResetLogs } from '../utils/password-reset-log.js'
@@ -115,9 +116,11 @@ function createSpielplanJob(skipSpielplanImport) {
loggerInfo(`[spielplan-import] ${reason}: ${spielplan.matchCount} Spiele importiert`, { range: `${spielplan.source.season.dateStart} - ${spielplan.source.season.dateEnd}` }) loggerInfo(`[spielplan-import] ${reason}: ${spielplan.matchCount} Spiele importiert`, { range: `${spielplan.source.season.dateStart} - ${spielplan.source.season.dateEnd}` })
const published = await publishImportedSpielplan({ inputPath: spielplan.jsonFile }) const published = await publishImportedSpielplan({ inputPath: spielplan.jsonFile })
const mapping = await synchronizeMannschaftenSpielplanIds(published.seasonSlug)
loggerInfo(`[spielplan-import] ${reason}: Spielplan publiziert`, { loggerInfo(`[spielplan-import] ${reason}: Spielplan publiziert`, {
season: published.seasonSlug, season: published.seasonSlug,
internalPath: published.internalSeasonPath internalPath: published.internalSeasonPath,
mappedTeams: mapping.updatedTeams
}) })
try { try {

View File

@@ -82,7 +82,7 @@ function decryptLegacyCBC(encryptedData, password) {
return decrypted.toString('utf8') return decrypted.toString('utf8')
} catch (error) { } catch (error) {
// Re-throw mit mehr Kontext // Re-throw mit mehr Kontext
throw new Error(`Legacy CBC Entschlüsselung fehlgeschlagen: ${error.message}`) throw new Error(`Legacy CBC Entschlüsselung fehlgeschlagen: ${error.message}`, { cause: error })
} }
} }
@@ -121,7 +121,7 @@ function decryptV2GCM(encryptedData, password) {
return decrypted.toString('utf8') return decrypted.toString('utf8')
} catch (error) { } catch (error) {
// Re-throw mit mehr Kontext // Re-throw mit mehr Kontext
throw new Error(`GCM v2 Entschlüsselung fehlgeschlagen: ${error.message}`) throw new Error(`GCM v2 Entschlüsselung fehlgeschlagen: ${error.message}`, { cause: error })
} }
} }
@@ -133,7 +133,7 @@ export function encrypt(text, password) {
return encryptV2GCM(text, password) return encryptV2GCM(text, password)
} catch (error) { } catch (error) {
console.error('Verschlüsselungsfehler:', error) console.error('Verschlüsselungsfehler:', error)
throw new Error('Fehler beim Verschlüsseln der Daten') throw new Error('Fehler beim Verschlüsseln der Daten', { cause: error })
} }
} }
@@ -163,7 +163,7 @@ export function decrypt(encryptedData, password) {
if (error.message.includes('Entschlüsselung fehlgeschlagen')) { if (error.message.includes('Entschlüsselung fehlgeschlagen')) {
throw error throw error
} }
throw new Error(`Fehler beim Entschlüsseln der Daten: ${error.message}`) throw new Error(`Fehler beim Entschlüsseln der Daten: ${error.message}`, { cause: error })
} }
} }

View File

@@ -0,0 +1,150 @@
import { promises as fs } from 'fs'
import { getServerDataPath } from './paths.js'
import { readSpielplanData } from './spielplan-data.js'
const CLUB_ID = '43030'
function parseCsvRows(content) {
const rows = []
let row = []
let value = ''
let quoted = false
for (let index = 0; index < content.length; index += 1) {
const char = content[index]
if (char === '"') {
if (quoted && content[index + 1] === '"') {
value += '"'
index += 1
} else {
quoted = !quoted
}
} else if (char === ',' && !quoted) {
row.push(value)
value = ''
} else if ((char === '\n' || char === '\r') && !quoted) {
if (char === '\r' && content[index + 1] === '\n') index += 1
row.push(value)
if (row.some(cell => cell !== '')) rows.push(row)
row = []
value = ''
} else {
value += char
}
}
row.push(value)
if (row.some(cell => cell !== '')) rows.push(row)
return rows
}
function csvCell(value) {
const text = String(value ?? '')
return /[",\n\r]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text
}
function romanToNumber(value) {
const roman = String(value || '').toUpperCase()
const values = { I: 1, V: 5, X: 10 }
let result = 0
for (let index = 0; index < roman.length; index += 1) {
const current = values[roman[index]] || 0
const next = values[roman[index + 1]] || 0
result += current < next ? -current : current
}
return result || null
}
function getCmsTeamCriteria(name, league) {
const teamName = String(name || '').trim()
const youth = teamName.match(/^jugend(?:mannschaft)?\s+j(\d{1,2})(?:\s+([ivx]+|\d+))?\b/i)
if (youth) {
const rawNumber = youth[2]
return {
ageClass: `Jugend ${youth[1]}`,
teamNumber: rawNumber ? (Number(rawNumber) || romanToNumber(rawNumber)) : null
}
}
// In bestehenden CMS-Daten steht die Altersklasse häufig nur in der Liga,
// z. B. Mannschaft "Jugend I" mit Liga "Jugend 13 (J13)".
const ageMatch = `${teamName} ${String(league || '')}`.match(/\b(?:jugend(?:mannschaft)?\s*|j\s*\(?)(\d{1,2})\b/i)
if (ageMatch) {
const ordinal = teamName.match(/^jugend(?:mannschaft)?\s+([ivx]+|\d+)\b/i)?.[1]
return {
ageClass: `Jugend ${ageMatch[1]}`,
teamNumber: ordinal ? (Number(ordinal) || romanToNumber(ordinal)) : null
}
}
const adult = teamName.match(/^erwachsene\s+(\d+)\b/i)
if (adult) return { ageClass: 'Erwachsene', teamNumber: Number(adult[1]) }
return null
}
function collectImportedTeams(rows) {
const teams = new Map()
for (const row of rows) {
for (const side of ['Heim', 'Gast']) {
if (String(row[`${side}VereinNr`] || '') !== CLUB_ID) continue
const id = String(row[`${side}MannschaftId`] || '').trim()
if (!id) continue
teams.set(id, {
id,
ageClass: String(row[`${side}MannschaftAltersklasse`] || '').trim(),
teamNumber: Number(row[`${side}MannschaftNr`]) || null
})
}
}
return [...teams.values()]
}
/**
* Adds every league and cup team ID matching a CMS team to its CSV row.
* A manually chosen ID is retained if no automatic match can be derived.
*/
export async function synchronizeMannschaftenSpielplanIds(seasonSlug) {
const filePath = getServerDataPath('public-data', `mannschaften_${seasonSlug}.csv`)
let content
try {
content = await fs.readFile(filePath, 'utf8')
} catch (error) {
if (error?.code === 'ENOENT') return { updatedTeams: 0, skipped: true }
throw error
}
const parsed = parseCsvRows(content)
if (parsed.length < 2) return { updatedTeams: 0 }
const header = parsed[0]
let idColumn = header.indexOf('Spielplan Mannschaft ID')
if (idColumn < 0) {
header.push('Spielplan Mannschaft ID')
idColumn = header.length - 1
}
const spielplan = await readSpielplanData({ season: seasonSlug })
const importedTeams = collectImportedTeams(spielplan.data)
let updatedTeams = 0
for (const row of parsed.slice(1)) {
const criteria = getCmsTeamCriteria(row[0], row[1])
if (!criteria) continue
const ids = importedTeams
.filter(team => team.ageClass === criteria.ageClass && (!criteria.teamNumber || team.teamNumber === criteria.teamNumber))
.map(team => team.id)
.sort((a, b) => Number(a) - Number(b))
if (!ids.length) continue
const nextValue = ids.join(',')
if (row[idColumn] === nextValue) continue
row[idColumn] = nextValue
updatedTeams += 1
}
if (updatedTeams) {
const output = `${parsed.map(row => header.map((_, index) => csvCell(row[index])).join(',')).join('\n')}\n`
const temporaryPath = `${filePath}.tmp-${process.pid}-${Date.now()}`
await fs.writeFile(temporaryPath, output, 'utf8')
await fs.rename(temporaryPath, filePath)
}
return { updatedTeams, importedTeamCount: importedTeams.length }
}

View File

@@ -1,7 +1,7 @@
import { promises as fs } from 'fs' import { promises as fs } from 'fs'
import path from 'path' import path from 'path'
import { randomUUID } from 'crypto' import { randomUUID } from 'crypto'
import { encrypt, decrypt, encryptObject, decryptObject } from './encryption.js' import { encryptObject, decryptObject } from './encryption.js'
import { writeDataFileWithRotation } from './data-file-rotation.js' import { writeDataFileWithRotation } from './data-file-rotation.js'
// Handle both dev and production paths // Handle both dev and production paths

View File

@@ -106,7 +106,7 @@ export async function fillFormFields(pdfDoc, form, data) {
try { try {
const helveticaFont = await pdfDoc.embedFont(StandardFonts.Helvetica) const helveticaFont = await pdfDoc.embedFont(StandardFonts.Helvetica)
form.updateFieldAppearances(helveticaFont) form.updateFieldAppearances(helveticaFont)
} catch (_error) { } catch (error) {
console.warn('Could not update field appearances:', error.message) console.warn('Could not update field appearances:', error.message)
} }
} }
@@ -123,7 +123,7 @@ export async function fillPdfForm(pdfDoc, form, data) {
// Check if PLZ/Ort field on page 1 is empty and fix it // Check if PLZ/Ort field on page 1 is empty and fix it
await fixPLZOrtField(pdfDoc, data) await fixPLZOrtField(pdfDoc, data)
} catch (_error) { } catch (error) {
console.warn('Form filling failed, using fallback:', error.message) console.warn('Form filling failed, using fallback:', error.message)
await fillFormFieldsPositionally(pdfDoc, data) await fillFormFieldsPositionally(pdfDoc, data)
} }
@@ -136,7 +136,6 @@ export async function fillPdfForm(pdfDoc, form, data) {
*/ */
async function fixPLZOrtField(pdfDoc, data) { async function fixPLZOrtField(pdfDoc, data) {
try { try {
const pages = pdfDoc.getPages()
await pdfDoc.embedFont(StandardFonts.Helvetica) await pdfDoc.embedFont(StandardFonts.Helvetica)
// Draw PLZ/Ort at the correct position on page 1 // Draw PLZ/Ort at the correct position on page 1
@@ -160,7 +159,7 @@ async function fixPLZOrtField(pdfDoc, data) {
} }
} }
} catch (_error) { } catch (error) {
console.warn('Could not fix PLZ/Ort field:', error.message) console.warn('Could not fix PLZ/Ort field:', error.message)
} }
} }
@@ -221,7 +220,7 @@ async function fillFormFieldsPositionally(pdfDoc, data) {
firstPage.drawText('X', { x: 116, y: -8, size: 12, font: helveticaFont }) firstPage.drawText('X', { x: 116, y: -8, size: 12, font: helveticaFont })
} }
} catch (_error) { } catch (error) {
console.error('Positional filling failed:', error.message) console.error('Positional filling failed:', error.message)
} }
} }

View File

@@ -54,7 +54,7 @@ export class PDFGeneratorService {
const pdfBytes = await pdfDoc.save() const pdfBytes = await pdfDoc.save()
return new PDFGenerationResult(true, Buffer.from(pdfBytes), filename) return new PDFGenerationResult(true, Buffer.from(pdfBytes), filename)
} catch (_error) { } catch (error) {
console.error('Template PDF generation failed:', error.message) console.error('Template PDF generation failed:', error.message)
return new PDFGenerationResult(false, null, null, error.message) return new PDFGenerationResult(false, null, null, error.message)
} }
@@ -73,8 +73,8 @@ export class PDFGeneratorService {
try { try {
await fs.access(this.fallbackTemplatePath) await fs.access(this.fallbackTemplatePath)
return this.fallbackTemplatePath return this.fallbackTemplatePath
} catch (_fallbackError) { } catch (fallbackError) {
throw new Error('No PDF template found') throw new Error('No PDF template found', { cause: fallbackError })
} }
} }
} }
@@ -84,7 +84,7 @@ export class PDFGeneratorService {
* @param {Object} data - Form data * @param {Object} data - Form data
* @returns {string} Filename * @returns {string} Filename
*/ */
generateFilename(data) { generateFilename(_data) {
const timestamp = Date.now() const timestamp = Date.now()
return `beitrittserklärung_${timestamp}.pdf` return `beitrittserklärung_${timestamp}.pdf`
} }

View File

@@ -57,16 +57,6 @@ function toNumberOrNull(value) {
return Number.isNaN(numberValue) ? null : numberValue return Number.isNaN(numberValue) ? null : numberValue
} }
function normalizeName(value) {
return String(value || '')
.trim()
.toLowerCase()
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/\s+/g, ' ')
.replace(/['`]/g, '')
}
function normalizeGender(value) { function normalizeGender(value) {
const normalized = String(value || '').trim().toLowerCase() const normalized = String(value || '').trim().toLowerCase()
if (normalized === 'm' || normalized === 'männlich') return 'männlich' if (normalized === 'm' || normalized === 'männlich') return 'männlich'

View File

@@ -1,7 +1,7 @@
import { promises as fs } from 'fs' import { promises as fs } from 'fs'
import path from 'path' import path from 'path'
import { getProjectPath, getServerDataPath } from './paths.js' import { getProjectPath, getServerDataPath } from './paths.js'
import { error as loggerError, info as loggerInfo } from './logger.js' import { error as loggerError } from './logger.js'
const SPIELPLAN_HEADERS = [ const SPIELPLAN_HEADERS = [
'Termin', 'Termin',
@@ -25,12 +25,14 @@ const SPIELPLAN_HEADERS = [
'HeimVereinName', 'HeimVereinName',
'HeimMannschaftAltersklasse', 'HeimMannschaftAltersklasse',
'HeimMannschaftNr', 'HeimMannschaftNr',
'HeimMannschaftId',
'HeimMannschaft', 'HeimMannschaft',
'GastVereinVerband', 'GastVereinVerband',
'GastVereinNr', 'GastVereinNr',
'GastVereinName', 'GastVereinName',
'GastMannschaftAltersklasse', 'GastMannschaftAltersklasse',
'GastMannschaftNr', 'GastMannschaftNr',
'GastMannschaftId',
'GastMannschaft', 'GastMannschaft',
'SpieleHeim', 'SpieleHeim',
'SpieleGast' 'SpieleGast'
@@ -250,12 +252,14 @@ export function convertImportedSpielplanToJson(imported) {
HeimVereinName: homeIsHarheim ? CLUB_NAME : '', HeimVereinName: homeIsHarheim ? CLUB_NAME : '',
HeimMannschaftAltersklasse: inferAgeClass(match, match.teamHome), HeimMannschaftAltersklasse: inferAgeClass(match, match.teamHome),
HeimMannschaftNr: inferTeamNumber(match.teamHome), HeimMannschaftNr: inferTeamNumber(match.teamHome),
HeimMannschaftId: match.teamHomeId || '',
HeimMannschaft: match.teamHome || '', HeimMannschaft: match.teamHome || '',
GastVereinVerband: match.leagueOrgShortName || imported.source?.association || '', GastVereinVerband: match.leagueOrgShortName || imported.source?.association || '',
GastVereinNr: match.teamAwayClubId || '', GastVereinNr: match.teamAwayClubId || '',
GastVereinName: awayIsHarheim ? CLUB_NAME : '', GastVereinName: awayIsHarheim ? CLUB_NAME : '',
GastMannschaftAltersklasse: inferAgeClass(match, match.teamAway), GastMannschaftAltersklasse: inferAgeClass(match, match.teamAway),
GastMannschaftNr: inferTeamNumber(match.teamAway), GastMannschaftNr: inferTeamNumber(match.teamAway),
GastMannschaftId: match.teamAwayId || '',
GastMannschaft: match.teamAway || '', GastMannschaft: match.teamAway || '',
SpieleHeim: result.home, SpieleHeim: result.home,
SpieleGast: result.away SpieleGast: result.away
@@ -444,7 +448,7 @@ export async function listSpielplanSeasons() {
const bySlug = new Map() const bySlug = new Map()
for (const directory of directories) { for (const directory of directories) {
let entries = [] let entries
try { try {
entries = await fs.readdir(directory) entries = await fs.readdir(directory)
} catch (error) { } catch (error) {

View File

@@ -12,10 +12,6 @@ const OUTPUT_DIR = getServerDataPath('spielplan-import')
const JSON_FILE = path.join(OUTPUT_DIR, 'harheimer_tc_spielplan.json') const JSON_FILE = path.join(OUTPUT_DIR, 'harheimer_tc_spielplan.json')
const HTML_FILE = path.join(OUTPUT_DIR, 'harheimer_tc_spielplan.html') const HTML_FILE = path.join(OUTPUT_DIR, 'harheimer_tc_spielplan.html')
function pad2(value) {
return String(value).padStart(2, '0')
}
export function getSpieljahrForDate(date = new Date()) { export function getSpieljahrForDate(date = new Date()) {
const year = date.getFullYear() const year = date.getFullYear()
const startYear = date.getMonth() >= 6 ? year : year - 1 const startYear = date.getMonth() >= 6 ? year : year - 1

View File

@@ -1,5 +1,4 @@
import { promises as fs } from 'fs' import { promises as fs } from 'fs'
import path from 'path'
import { randomUUID } from 'crypto' import { randomUUID } from 'crypto'
import { writeDataFileWithRotation } from './data-file-rotation.js' import { writeDataFileWithRotation } from './data-file-rotation.js'

170
utils/spielplan-filter.js Normal file
View File

@@ -0,0 +1,170 @@
function normalizeText(value) {
return String(value || '').trim().toLowerCase().replace(/\s+/g, ' ')
}
function normalizeTeamFilterKey(value) {
const normalized = normalizeText(value).replace(/\s+/g, '_')
if (!normalized) return ''
if (normalized === 'all' || normalized === 'erwachsene' || normalized === 'nachwuchs') return normalized
if (normalized === 'jugendmannschaft') return 'jugendmannschaft'
const erwachseneMatch = normalized.match(/^erwachsene_(\d+)$/)
if (erwachseneMatch) {
return `erwachsene_${erwachseneMatch[1]}`
}
return normalized
}
const MANNSCHAFT_MAPPING = {
erwachsene_1: ['harheimer tc'],
erwachsene_2: ['harheimer tc ii'],
erwachsene_3: ['harheimer tc iii'],
erwachsene_4: ['harheimer tc iv'],
erwachsene_5: ['harheimer tc v'],
jugendmannschaft: ['harheimer tc']
}
function matchMappedVariant(mannschaft, variant) {
if (variant === 'harheimer tc') {
return mannschaft === 'harheimer tc' ||
(mannschaft.startsWith('harheimer tc ') && !mannschaft.match(/harheimer tc\s+[ivx]+/i))
}
return mannschaft === variant || mannschaft.startsWith(`${variant} `)
}
function isErwachsenenTeam(teamKey) {
return teamKey === 'erwachsene' || teamKey.startsWith('erwachsene_')
}
function romanToNumber(value) {
const roman = String(value || '').toUpperCase()
if (!/^[IVX]+$/.test(roman)) return null
const values = { I: 1, V: 5, X: 10 }
let total = 0
for (let index = 0; index < roman.length; index += 1) {
const current = values[roman[index]] || 0
const next = values[roman[index + 1]] || 0
total += current < next ? -current : current
}
return total || null
}
function parseJugendTeamKey(teamKey) {
const match = String(teamKey || '').match(/^jugend(?:mannschaft)?_?j?(\d{1,2})(?:_([ivx]+|\d+))?$/i)
if (!match) return null
const teamNumber = match[2]
? (Number(match[2]) || romanToNumber(match[2]))
: null
return { age: match[1], teamNumber: teamNumber ? String(teamNumber) : '' }
}
function sideMatchesJugendTeam({ isHarheimer, ageClass, teamNumber }, team) {
if (!isHarheimer) return false
const ageMatch = String(ageClass || '').match(/(?:jugend\s*|j)(\d{1,2})\b/i)
if (!ageMatch || ageMatch[1] !== team.age) return false
return !team.teamNumber || String(teamNumber || '').trim() === team.teamNumber
}
export function rowMatchesWettbewerbFilter(row, rawWettbewerb) {
const wettbewerb = normalizeText(rawWettbewerb || 'punktrunde')
const runde = normalizeText(row?.Runde)
const staffel = normalizeText(row?.Staffel)
const liga = normalizeText(row?.Liga)
const isPokal = runde.includes('pokal') || staffel.includes('pokal') || liga.includes('pokal')
if (wettbewerb === 'alle') return true
if (wettbewerb === 'pokal') return isPokal
return !isPokal
}
export function rowMatchesTeamFilter(row, rawTeam) {
const teamKey = normalizeTeamFilterKey(rawTeam)
if (!teamKey || teamKey === 'all') return true
const heimMannschaft = normalizeText(row?.HeimMannschaft)
const gastMannschaft = normalizeText(row?.GastMannschaft)
const heimAltersklasse = normalizeText(row?.HeimMannschaftAltersklasse)
const gastAltersklasse = normalizeText(row?.GastMannschaftAltersklasse)
const isHarheimerHeim = heimMannschaft.includes('harheimer tc')
const isHarheimerGast = gastMannschaft.includes('harheimer tc')
if (!isHarheimerHeim && !isHarheimerGast) {
return false
}
if (teamKey === 'erwachsene') {
const isErwachsenenHeim = isHarheimerHeim && heimAltersklasse.includes('erwachsene') && !heimAltersklasse.includes('jugend')
const isErwachsenenGast = isHarheimerGast && gastAltersklasse.includes('erwachsene') && !gastAltersklasse.includes('jugend')
return isErwachsenenHeim || isErwachsenenGast
}
if (teamKey === 'nachwuchs' || teamKey === 'jugendmannschaft') {
const isJugendHeim = isHarheimerHeim && (heimAltersklasse.includes('jugend') || heimMannschaft.includes('jugend'))
const isJugendGast = isHarheimerGast && (gastAltersklasse.includes('jugend') || gastMannschaft.includes('jugend'))
return isJugendHeim || isJugendGast
}
const jugendTeam = parseJugendTeamKey(teamKey)
if (jugendTeam) {
return sideMatchesJugendTeam({
isHarheimer: isHarheimerHeim,
ageClass: heimAltersklasse,
teamNumber: row?.HeimMannschaftNr
}, jugendTeam) || sideMatchesJugendTeam({
isHarheimer: isHarheimerGast,
ageClass: gastAltersklasse,
teamNumber: row?.GastMannschaftNr
}, jugendTeam)
}
const csvVariants = MANNSCHAFT_MAPPING[teamKey] || []
if (csvVariants.length === 0) {
return false
}
const mannschaftMatch = csvVariants.some((variant) => {
if (isHarheimerHeim && matchMappedVariant(heimMannschaft, variant)) return true
if (isHarheimerGast && matchMappedVariant(gastMannschaft, variant)) return true
return false
})
if (!mannschaftMatch) {
return false
}
if (isErwachsenenTeam(teamKey)) {
const isErwachsenenHeim = isHarheimerHeim && heimAltersklasse.includes('erwachsene') && !heimAltersklasse.includes('jugend')
const isErwachsenenGast = isHarheimerGast && gastAltersklasse.includes('erwachsene') && !gastAltersklasse.includes('jugend')
return isErwachsenenHeim || isErwachsenenGast
}
return true
}
export function filterSpielplanRows(rows, options = {}) {
const sourceRows = Array.isArray(rows) ? rows : []
const team = options.team || 'all'
const wettbewerb = options.wettbewerb || 'punktrunde'
return sourceRows.filter((row) => rowMatchesTeamFilter(row, team) && rowMatchesWettbewerbFilter(row, wettbewerb))
}
export function toApiTeamParam(filterValue) {
const teamKey = normalizeTeamFilterKey(filterValue)
if (teamKey === 'all' || teamKey === 'erwachsene' || teamKey === 'nachwuchs' || teamKey === 'jugendmannschaft') {
return teamKey
}
if (teamKey.match(/^erwachsene_\d+$/)) {
return teamKey
}
return String(filterValue || '').trim().replace(/\s+/g, '_').toLowerCase()
}