From c13f426b3d3da4d701787fa8d4f3064f7f23bf8b Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Tue, 10 Mar 2026 21:54:03 +0100 Subject: [PATCH] feat(tournaments): add update functionality for official tournaments - Implemented an API endpoint to update the title of official tournaments, including error handling for non-existent tournaments. - Enhanced the frontend to allow users to edit tournament titles directly, with input validation and feedback for successful updates or errors. - Updated the German localization file to include new strings for editing titles and error messages. --- .../officialTournamentController.js | 15 ++++ backend/routes/clickTtHttpPageRoutes.js | 44 ++++++++++++ backend/routes/officialTournamentRoutes.js | 3 +- backend/services/officialTournamentService.js | 8 +++ frontend/src/i18n/locales/de.json | 2 + frontend/src/views/OfficialTournaments.vue | 69 +++++++++++++++++-- 6 files changed, 136 insertions(+), 5 deletions(-) diff --git a/backend/controllers/officialTournamentController.js b/backend/controllers/officialTournamentController.js index bee2d1ee..f840c732 100644 --- a/backend/controllers/officialTournamentController.js +++ b/backend/controllers/officialTournamentController.js @@ -1,6 +1,21 @@ import { checkAccess } from '../utils/userUtils.js'; import officialTournamentService from '../services/officialTournamentService.js'; +export const updateOfficialTournament = async (req, res) => { + try { + const { authcode: userToken } = req.headers; + const { clubId, id } = req.params; + await checkAccess(userToken, clubId); + + const result = await officialTournamentService.updateOfficialTournament(clubId, id, req.body); + if (!result) return res.status(404).json({ error: 'not found' }); + res.status(200).json(result); + } catch (e) { + console.error('[updateOfficialTournament] Error:', e); + res.status(500).json({ error: 'Failed to update tournament' }); + } +}; + export const uploadTournamentPdf = async (req, res) => { try { const { authcode: userToken } = req.headers; diff --git a/backend/routes/clickTtHttpPageRoutes.js b/backend/routes/clickTtHttpPageRoutes.js index a4f5af15..2f686a89 100644 --- a/backend/routes/clickTtHttpPageRoutes.js +++ b/backend/routes/clickTtHttpPageRoutes.js @@ -10,9 +10,47 @@ import { authenticate } from '../middleware/authMiddleware.js'; const router = express.Router(); +/** Domains, deren Links durch den Proxy umgeleitet werden (für Folge-Logs) */ +const PROXY_DOMAINS = ['click-tt.de', 'httv.de']; + +/** + * Prüft, ob eine URL durch unseren Proxy umgeleitet werden soll + */ +function shouldProxyUrl(href) { + if (!href || typeof href !== 'string') return false; + const h = href.trim(); + if (h.startsWith('#') || h.startsWith('javascript:')) return false; + return PROXY_DOMAINS.some(d => h.includes(d)); +} + +/** + * Schreibt Links im HTML um, sodass Klicks im iframe über unseren Proxy laufen (Folge-Logs). + */ +function rewriteLinksInHtml(html, proxyBaseUrl, pageBaseUrl) { + if (!html || !proxyBaseUrl || !pageBaseUrl) return html; + try { + const base = new URL(pageBaseUrl); + return html.replace( + /]*?)href\s*=\s*["']([^"']+)["']([^>]*)>/gi, + (match, before, href, after) => { + let absoluteUrl = href; + if (href.startsWith('/') || !href.startsWith('http')) { + absoluteUrl = new URL(href, base.origin + base.pathname).href; + } + if (!shouldProxyUrl(absoluteUrl)) return match; + const proxyUrl = `${proxyBaseUrl}${proxyBaseUrl.includes('?') ? '&' : '?'}url=${encodeURIComponent(absoluteUrl)}`; + return ``; + } + ); + } catch { + return html; + } +} + /** * GET /api/clicktt/proxy * Proxy für iframe-Einbettung – liefert HTML direkt (ohne Auth, für iframe src). + * Links zu click-tt.de/httv.de werden umgeschrieben, damit Folge-Klicks geloggt werden. * Query: type (leaguePage|clubInfo|regionMeetings), association, championship, clubId * ODER: url (vollständige URL, nur click-tt.de/httv.de) */ @@ -66,6 +104,12 @@ router.get('/proxy', async (req, res, next) => { .replace(/]*http-equiv=["']x-frame-options["'][^>]*>/gi, '') .replace(/]*http-equiv=["']x-content-type-options["'][^>]*>/gi, ''); + // Links umschreiben: Klicks im iframe laufen über unseren Proxy → Folge-Logs + const protocol = req.protocol || 'http'; + const host = req.get('host') || 'localhost:3005'; + const proxyBase = `${protocol}://${host}/api/clicktt/proxy`; + html = rewriteLinksInHtml(html, proxyBase, targetUrl); + res.set({ 'Content-Type': 'text/html; charset=utf-8', 'Access-Control-Allow-Origin': '*', diff --git a/backend/routes/officialTournamentRoutes.js b/backend/routes/officialTournamentRoutes.js index 16d11085..a61a18aa 100644 --- a/backend/routes/officialTournamentRoutes.js +++ b/backend/routes/officialTournamentRoutes.js @@ -1,7 +1,7 @@ import express from 'express'; import multer from 'multer'; import { authenticate } from '../middleware/authMiddleware.js'; -import { uploadTournamentPdf, getParsedTournament, listOfficialTournaments, deleteOfficialTournament, upsertCompetitionMember, listClubParticipations, updateParticipantStatus } from '../controllers/officialTournamentController.js'; +import { uploadTournamentPdf, getParsedTournament, listOfficialTournaments, deleteOfficialTournament, updateOfficialTournament, upsertCompetitionMember, listClubParticipations, updateParticipantStatus } from '../controllers/officialTournamentController.js'; const router = express.Router(); const upload = multer({ storage: multer.memoryStorage() }); @@ -12,6 +12,7 @@ router.get('/:clubId', listOfficialTournaments); router.get('/:clubId/participations/summary', listClubParticipations); router.post('/:clubId/upload', upload.single('pdf'), uploadTournamentPdf); router.get('/:clubId/:id', getParsedTournament); +router.patch('/:clubId/:id', updateOfficialTournament); router.delete('/:clubId/:id', deleteOfficialTournament); router.post('/:clubId/:id/participation', upsertCompetitionMember); router.post('/:clubId/:id/status', updateParticipantStatus); diff --git a/backend/services/officialTournamentService.js b/backend/services/officialTournamentService.js index ed189e11..44f17ba2 100644 --- a/backend/services/officialTournamentService.js +++ b/backend/services/officialTournamentService.js @@ -295,6 +295,14 @@ class OfficialTournamentService { return out; } + async updateOfficialTournament(clubId, id, { title }) { + const t = await OfficialTournament.findOne({ where: { id, clubId } }); + if (!t) return null; + if (title !== undefined) t.title = title; + await t.save(); + return t.toJSON(); + } + async deleteOfficialTournament(clubId, id) { const t = await OfficialTournament.findOne({ where: { id, clubId } }); if (!t) return false; diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 0037d108..3babd444 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -1400,6 +1400,8 @@ "showParticipations": "Turnierbeteiligungen anzeigen", "savedEvents": "Gespeicherte Veranstaltungen", "tournament": "Turnier", + "editTitle": "Titel bearbeiten", + "updateTitleError": "Titel konnte nicht gespeichert werden.", "delete": "Löschen", "timeRange": "Zeitraum:", "last3Months": "Letzte 3 Monate", diff --git a/frontend/src/views/OfficialTournaments.vue b/frontend/src/views/OfficialTournaments.vue index 823b64b2..99f315f7 100644 --- a/frontend/src/views/OfficialTournaments.vue +++ b/frontend/src/views/OfficialTournaments.vue @@ -13,12 +13,35 @@

{{ $t('officialTournaments.savedEvents') }}

-
    -
  • - + @@ -419,6 +442,8 @@ export default { loadingClubParticipations: false, clubParticipationRowsData: [], participationRange: 'all', + editingTournamentId: null, + editingTitle: '', }; }, computed: { @@ -1237,6 +1262,35 @@ export default { return `${dd}.${mm}.${yyyy}`; }, + startTitleEdit(t) { + this.editingTournamentId = t.id; + this.editingTitle = t.title || ''; + this.$nextTick(() => { + const el = this.$refs.titleEditInput; + if (Array.isArray(el)) { + if (el[0]) el[0].focus(); + } else if (el) el.focus(); + }); + }, + cancelTitleEdit() { + this.editingTournamentId = null; + this.editingTitle = ''; + }, + async saveTournamentTitle(t) { + const newTitle = (this.editingTitle || '').trim(); + this.editingTournamentId = null; + this.editingTitle = ''; + if (newTitle === (t.title || '').trim()) return; + try { + await apiClient.patch(`/official-tournaments/${this.currentClub}/${t.id}`, { title: newTitle || null }); + t.title = newTitle || null; + if (String(this.uploadedId) === String(t.id) && this.parsed) { + this.parsed.parsedData = { ...this.parsed.parsedData, title: newTitle || this.parsed.parsedData.title }; + } + } catch (error) { + this.showInfo(this.$t('common.error'), this.$t('officialTournaments.updateTitleError'), getSafeErrorMessage(error), 'error'); + } + }, async removeTournament(t) { const confirmed = await this.showConfirm( 'Turnier löschen', @@ -1275,6 +1329,13 @@ export default {