From fe152d537d72b5a3882197f4e1bc4626b20589db Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Fri, 4 Sep 2026 13:12:01 +0200 Subject: [PATCH] feat(tournament): add sourceUrl field and implement repair logic for tournaments --- backend/models/OfficialTournament.js | 2 +- backend/server.js | 6 ++ backend/services/officialTournamentService.js | 61 ++++++++++++++++++- frontend/src/views/OfficialTournaments.vue | 24 ++++++++ 4 files changed, 91 insertions(+), 2 deletions(-) diff --git a/backend/models/OfficialTournament.js b/backend/models/OfficialTournament.js index 567d6d08..a636c6b7 100755 --- a/backend/models/OfficialTournament.js +++ b/backend/models/OfficialTournament.js @@ -12,6 +12,7 @@ const OfficialTournament = sequelize.define('OfficialTournament', { competitionTypes: { type: DataTypes.TEXT, allowNull: true }, // JSON.stringify(Array) registrationDeadlines: { type: DataTypes.TEXT, allowNull: true }, // JSON.stringify(Array) entryFees: { type: DataTypes.TEXT, allowNull: true }, // JSON.stringify(Object) - Teilnahmegebühren pro Spielklasse + sourceUrl: { type: DataTypes.STRING(1000), allowNull: true, field: 'source_url' }, }, { tableName: 'official_tournaments', timestamps: true, @@ -20,4 +21,3 @@ const OfficialTournament = sequelize.define('OfficialTournament', { export default OfficialTournament; - diff --git a/backend/server.js b/backend/server.js index 534a4932..a237961e 100755 --- a/backend/server.js +++ b/backend/server.js @@ -568,6 +568,12 @@ app.use((err, req, res, next) => { await renameColumnIfExists('official_tournaments', 'austragungsorte', 'venues', 'TEXT NULL'); await renameColumnIfExists('official_tournaments', 'konkurrenztypen', 'competition_types', 'TEXT NULL'); await renameColumnIfExists('official_tournaments', 'meldeschluesse', 'registration_deadlines', 'TEXT NULL'); + try { + const [sourceUrlColumn] = await sequelize.query("SHOW COLUMNS FROM `official_tournaments` LIKE 'source_url'"); + if (sourceUrlColumn.length === 0) await sequelize.query('ALTER TABLE `official_tournaments` ADD COLUMN `source_url` VARCHAR(1000) NULL'); + } catch (error) { + console.error('[migration] Failed to add official_tournaments.source_url:', error.message); + } const isDev = process.env.STAGE === 'dev'; diff --git a/backend/services/officialTournamentService.js b/backend/services/officialTournamentService.js index 0573cbd3..494073a3 100755 --- a/backend/services/officialTournamentService.js +++ b/backend/services/officialTournamentService.js @@ -3,6 +3,7 @@ const require = createRequire(import.meta.url); const pdfParse = require('pdf-parse/lib/pdf-parse.js'); import { Op } from 'sequelize'; +import axios from 'axios'; import OfficialTournament from '../models/OfficialTournament.js'; import OfficialCompetition from '../models/OfficialCompetition.js'; import OfficialCompetitionMember from '../models/OfficialCompetitionMember.js'; @@ -12,6 +13,55 @@ import Member from '../models/Member.js'; import OfficialTournamentParserService from './officialTournamentParserService.js'; class OfficialTournamentService { + extractJsonArray(html, propertyName) { + const content = String(html); + const propertyIndex = content.indexOf(`"${propertyName}":[`); + if (propertyIndex < 0) return []; + const start = content.indexOf('[', propertyIndex); + let depth = 0; let quoted = false; let escaped = false; + for (let index = start; index < content.length; index += 1) { + const character = content[index]; + if (quoted) { + if (escaped) escaped = false; + else if (character === '\\') escaped = true; + else if (character === '"') quoted = false; + } else if (character === '"') quoted = true; + else if (character === '[') depth += 1; + else if (character === ']' && --depth === 0) { + try { return JSON.parse(content.slice(start, index + 1)); } + catch (_error) { return []; } + } + } + return []; + } + + async repairTournamentFromSource(clubId, tournament) { + let sourceUrl = tournament.sourceUrl; + if (!sourceUrl) { + const suggestion = await TournamentSuggestion.findOne({ where: { clubId, title: tournament.title, eventDate: tournament.eventDate } }); + sourceUrl = suggestion?.sourceUrl || null; + if (sourceUrl) await tournament.update({ sourceUrl }); + } + if (!sourceUrl) return 0; + + const source = new URL(sourceUrl); + if (!['www.mytischtennis.de', 'mytischtennis.de'].includes(source.hostname)) return 0; + const response = await axios.get(sourceUrl, { timeout: 30000, headers: { 'User-Agent': 'Trainingstagebuch/1.0 (+turnierdetails)' } }); + const competitions = this.extractJsonArray(response.data, 'competitions') + .filter((competition) => competition?.name) + .map((competition) => ({ + ageClassCompetition: String(competition.name).slice(0, 255), + cutoffDate: competition.fed_rank_remarks ? String(competition.fed_rank_remarks).slice(0, 255) : null, + })); + if (!competitions.length) return 0; + + const existing = await OfficialCompetition.findAll({ where: { tournamentId: tournament.id }, attributes: ['ageClassCompetition'] }); + const existingNames = new Set(existing.map((competition) => competition.ageClassCompetition)); + const missing = competitions.filter((competition) => !existingNames.has(competition.ageClassCompetition)); + if (missing.length) await OfficialCompetition.bulkCreate(missing.map((competition) => ({ tournamentId: tournament.id, ...competition }))); + return missing.length; + } + async saveTournamentSuggestion(clubId, suggestionId) { const suggestion = await TournamentSuggestion.findOne({ where: { id: suggestionId, clubId } }); if (!suggestion) return null; @@ -31,6 +81,7 @@ class OfficialTournamentService { competitionTypes: JSON.stringify([]), registrationDeadlines: JSON.stringify([]), entryFees: JSON.stringify({}), + sourceUrl: suggestion.sourceUrl, }); } await suggestion.update({ status: 'reviewed' }); @@ -121,7 +172,15 @@ class OfficialTournamentService { const t = await OfficialTournament.findOne({ where: { id, clubId } }); if (!t) return null; - const comps = await OfficialCompetition.findAll({ where: { tournamentId: id } }); + let comps = await OfficialCompetition.findAll({ where: { tournamentId: id } }); + if (!comps.length) { + try { + await this.repairTournamentFromSource(clubId, t); + comps = await OfficialCompetition.findAll({ where: { tournamentId: id } }); + } catch (error) { + console.warn(`[officialTournament] Automatic repair failed for ${id}:`, error.message); + } + } const entries = await OfficialCompetitionMember.findAll({ where: { tournamentId: id } }); const competitions = comps.map((c) => { const j = c.toJSON(); diff --git a/frontend/src/views/OfficialTournaments.vue b/frontend/src/views/OfficialTournaments.vue index ff1713e6..0064f15d 100755 --- a/frontend/src/views/OfficialTournaments.vue +++ b/frontend/src/views/OfficialTournaments.vue @@ -544,6 +544,22 @@ +
+
+
+ Aktives Turnier + {{ parsed.parsedData.title || '–' }} +
+
+ {{ parsed.parsedData.termin || '–' }} + {{ calendarEntryLocationLabel }} +
+
+
+

Kalendereintrag geöffnet

+

Für dieses Turnier sind noch keine Konkurrenzen hinterlegt. Importiere eine Ausschreibung als PDF, sobald du Teilnehmer oder Ergebnisse verwalten möchtest.

+
+

Kein aktives Turnier ausgewählt

@@ -995,6 +1011,9 @@ export default { const placeLine = places.find((entry) => /^PLZ\s*\/\s*Ort:/i.test(entry || '')); return placeLine ? placeLine.replace(/^PLZ\s*\/\s*Ort:/i, '').trim() : ''; }, + calendarEntryLocationLabel() { + return (this.parsed?.parsedData?.austragungsorte || []).filter(Boolean).join(' · '); + }, registrationDeadlineAt() { const deadlines = [ ...(this.parsed?.parsedData?.meldeschluesse || []), @@ -1648,6 +1667,7 @@ export default { const response = await apiClient.post(`/official-tournaments/${this.currentClub}/suggestions/${suggestion.id}/save`); await Promise.all([this.loadSuggestions(), this.loadList()]); this.uploadedId = String(response.data.id); + await this.reload(); this.workspaceTab = 'saved'; } catch (error) { this.suggestionError = getSafeErrorMessage(error, 'Der Vorschlag konnte nicht gespeichert werden.'); } }, @@ -2229,6 +2249,10 @@ export default { .empty-workspace-card { max-width: 540px; text-align: center; color: #516074; } .empty-workspace-card h3 { margin: 0 0 .45rem; color: #17366d; } .empty-workspace-card p { margin: 0; } +.calendar-entry-detail { min-height: 220px; } +.calendar-entry-empty { padding: 2.5rem 1rem; text-align: center; color: #516074; } +.calendar-entry-empty h3 { margin: 0 0 .45rem; color: #17366d; } +.calendar-entry-empty p { max-width: 640px; margin: 0 auto; } .overview-header { display: flex; justify-content: space-between; gap: 1rem; align-items: flex-start; flex-wrap: wrap; } .overview-main { display: flex; flex-direction: column; gap: .9rem; flex: 1; min-width: min(100%, 520px); } .workflow-badges { display: flex; flex-wrap: wrap; gap: .45rem; }