feat(tournament): add sourceUrl field and implement repair logic for tournaments
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 16m19s

This commit is contained in:
Torsten Schulz (local)
2026-09-04 13:12:01 +02:00
parent c16883f381
commit fe152d537d
4 changed files with 91 additions and 2 deletions

View File

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

View File

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

View File

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

View File

@@ -544,6 +544,22 @@
</div>
</div>
</div>
<div v-else-if="parsed && parsed.parsedData" class="workspace-detail calendar-entry-detail">
<div class="selected-tournament-strip">
<div class="selected-tournament-main">
<span class="selected-tournament-label">Aktives Turnier</span>
<strong>{{ parsed.parsedData.title || '' }}</strong>
</div>
<div class="selected-tournament-meta">
<span>{{ parsed.parsedData.termin || '' }}</span>
<span v-if="calendarEntryLocationLabel">{{ calendarEntryLocationLabel }}</span>
</div>
</div>
<div class="calendar-entry-empty">
<h3>Kalendereintrag geöffnet</h3>
<p>Für dieses Turnier sind noch keine Konkurrenzen hinterlegt. Importiere eine Ausschreibung als PDF, sobald du Teilnehmer oder Ergebnisse verwalten möchtest.</p>
</div>
</div>
<div v-else class="workspace-detail empty-workspace">
<div class="empty-workspace-card">
<h3>Kein aktives Turnier ausgewählt</h3>
@@ -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; }