diff --git a/backend/package.json b/backend/package.json index a69c74e0..35ec1f33 100755 --- a/backend/package.json +++ b/backend/package.json @@ -6,6 +6,7 @@ "scripts": { "postinstall": "cd ../frontend && npm install && npm run build", "dev": "nodemon server.js", + "db:migrate": "node ./scripts/runMigrations.js", "cleanup:usertoken": "node ./scripts/cleanupUserTokenKeys.js", "cleanup:indexes": "node ./scripts/cleanupAllIndexes.js" }, diff --git a/backend/scripts/runMigrations.js b/backend/scripts/runMigrations.js new file mode 100644 index 00000000..6b332ce1 --- /dev/null +++ b/backend/scripts/runMigrations.js @@ -0,0 +1,68 @@ +import sequelize from '../database.js'; + +const migrations = [ + { + id: '20260819_add_manual_match_fields', + async up() { + const [fixtureTypeColumns] = await sequelize.query("SHOW COLUMNS FROM `match` LIKE 'fixture_type'"); + if (fixtureTypeColumns.length === 0) { + await sequelize.query("ALTER TABLE `match` ADD COLUMN `fixture_type` VARCHAR(32) NOT NULL DEFAULT 'league'"); + } + + const [notesColumns] = await sequelize.query("SHOW COLUMNS FROM `match` LIKE 'notes'"); + if (notesColumns.length === 0) { + await sequelize.query('ALTER TABLE `match` ADD COLUMN `notes` TEXT NULL'); + } + }, + }, + { + id: '20260819_create_kaisertisch_tournaments', + async up() { + await sequelize.query(` + CREATE TABLE IF NOT EXISTS kaisertisch_tournaments ( + id INT AUTO_INCREMENT PRIMARY KEY, + club_id INT NOT NULL, + diary_date_id INT NOT NULL, + state JSON NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uniq_kaisertisch_tournament_club_date (club_id, diary_date_id), + CONSTRAINT fk_kaisertisch_tournament_club FOREIGN KEY (club_id) REFERENCES clubs(id) ON DELETE CASCADE, + CONSTRAINT fk_kaisertisch_tournament_date FOREIGN KEY (diary_date_id) REFERENCES diary_dates(id) ON DELETE CASCADE + ) + `); + }, + }, +]; + +async function runMigrations() { + await sequelize.authenticate(); + await sequelize.query(` + CREATE TABLE IF NOT EXISTS schema_migrations ( + id VARCHAR(191) PRIMARY KEY, + applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + `); + + const [appliedRows] = await sequelize.query('SELECT id FROM schema_migrations'); + const applied = new Set(appliedRows.map((row) => row.id)); + + for (const migration of migrations) { + if (applied.has(migration.id)) continue; + console.log(`[migration] Applying ${migration.id}`); + await migration.up(); + await sequelize.query('INSERT INTO schema_migrations (id) VALUES (:id)', { + replacements: { id: migration.id }, + }); + console.log(`[migration] Applied ${migration.id}`); + } +} + +runMigrations() + .catch((error) => { + console.error('[migration] Failed:', error); + process.exitCode = 1; + }) + .finally(async () => { + await sequelize.close(); + }); diff --git a/backend/services/matchService.js b/backend/services/matchService.js index 7dbbf6b0..24640896 100755 --- a/backend/services/matchService.js +++ b/backend/services/matchService.js @@ -51,6 +51,10 @@ class MatchService { const time = String(payload.time || '').trim(); const opponentName = String(payload.opponentName || '').trim(); const homeAway = payload.homeAway === 'away' ? 'away' : 'home'; + const fixtureType = String(payload.fixtureType || 'league').trim(); + if (!['league', 'cup'].includes(fixtureType)) { + throw new HttpError('Ungültiger Spieltyp', 400); + } if (!Number.isInteger(clubTeamId) || !/^\d{4}-\d{2}-\d{2}$/.test(date) || !/^\d{2}:\d{2}$/.test(time) || !opponentName) { throw new HttpError('Mannschaft, Gegner, Datum und Uhrzeit sind erforderlich', 400); } @@ -78,7 +82,7 @@ class MatchService { date: new Date(`${date}T${time}:00`), time, clubId: parsedClubId, leagueId: league.id, locationId, homeTeamId: homeAway === 'home' ? ownTeam.id : opponentTeam.id, guestTeamId: homeAway === 'home' ? opponentTeam.id : ownTeam.id, - fixtureType: 'cup', notes: String(payload.notes || '').trim() || null + fixtureType, notes: String(payload.notes || '').trim() || null }); return { id: match.id, clubId: match.clubId, match: await this.enrichMatch(match) }; } diff --git a/docs/manual_sql_migrations.md b/docs/manual_sql_migrations.md index 78657794..096fe255 100755 --- a/docs/manual_sql_migrations.md +++ b/docs/manual_sql_migrations.md @@ -2,6 +2,12 @@ Diese Datei sammelt SQL-Aenderungen, die auf Live/Test manuell eingespielt werden muessen, wenn das Produktions-Setup keine automatische Schema-Migration ausfuehrt. +## Ausführung per Node + +Neue, registrierte Migrationen können vor dem Neustart des Backends mit +`cd backend && npm run db:migrate` ausgeführt werden. Der Runner führt jede +Migration nur einmal aus und protokolliert sie in `schema_migrations`. + ## Pflege-Regel Ab jetzt gilt fuer jede Schemaaenderung: diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 09b700a9..e6af68f8 100755 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -1482,7 +1482,14 @@ "addMatch": "Spiel hinzufügen", "manualMatchDescription": "Manuell gepflegte Spiele erscheinen neben den importierten Click-TT-Terminen.", "fixtureType": "Spieltyp:", + "regularMatch": "Reguläres Spiel", "cupMatch": "Pokalspiel", + "friendlyMatch": "Freundschaftsspiel", + "fixtureTypeDescription": { + "league": "Reguläre und Pokalspiele werden im Spielplan gespeichert.", + "cup": "Reguläre und Pokalspiele werden im Spielplan gespeichert.", + "friendly": "Freundschaftsspiele werden separat verwaltet; die Angaben werden in das passende Formular übernommen." + }, "ownTeam": "Eigene Mannschaft", "selectTeam": "Mannschaft wählen", "opponent": "Gegner", @@ -1495,7 +1502,10 @@ "notes": "Notizen", "saving": "Speichert…", "manualMatchRequired": "Bitte Mannschaft, Gegner, Datum und Uhrzeit ausfüllen.", - "manualMatchSaved": "Pokalspiel wurde hinzugefügt.", + "manualMatchSaved": { + "league": "Reguläres Spiel wurde hinzugefügt.", + "cup": "Pokalspiel wurde hinzugefügt." + }, "manualMatchSaveFailed": "Pokalspiel konnte nicht gespeichert werden.", "galleryLoading": "Galerie wird geladen…", "gallery": "Mitglieder-Galerie", diff --git a/frontend/src/i18n/locales/en-US.json b/frontend/src/i18n/locales/en-US.json index bdf3bde7..c5a38e23 100755 --- a/frontend/src/i18n/locales/en-US.json +++ b/frontend/src/i18n/locales/en-US.json @@ -1459,7 +1459,14 @@ "addMatch": "Add match", "manualMatchDescription": "Manually maintained matches appear alongside imported Click-TT fixtures.", "fixtureType": "Fixture type:", + "regularMatch": "Regular match", "cupMatch": "Cup match", + "friendlyMatch": "Friendly match", + "fixtureTypeDescription": { + "league": "Regular and cup matches are stored in the schedule.", + "cup": "Regular and cup matches are stored in the schedule.", + "friendly": "Friendly matches are managed separately; the details are transferred to the appropriate form." + }, "ownTeam": "Own team", "selectTeam": "Select team", "opponent": "Opponent", @@ -1472,7 +1479,10 @@ "notes": "Notes", "saving": "Saving…", "manualMatchRequired": "Please fill in the team, opponent, date and time.", - "manualMatchSaved": "Cup match added.", + "manualMatchSaved": { + "league": "Regular match added.", + "cup": "Cup match added." + }, "manualMatchSaveFailed": "Cup match could not be saved.", "galleryLoading": "Loading gallery…", "gallery": "Member gallery", diff --git a/frontend/src/views/ScheduleView.vue b/frontend/src/views/ScheduleView.vue index d198e7b0..3b69e7e2 100755 --- a/frontend/src/views/ScheduleView.vue +++ b/frontend/src/views/ScheduleView.vue @@ -275,7 +275,13 @@

{{ $t('schedule.manualMatchDescription') }}

-
{{ $t('schedule.fixtureType') }} {{ $t('schedule.cupMatch') }}
+
+ {{ $t('schedule.fixtureType') }} + + + +
+

{{ $t(`schedule.fixtureTypeDescription.${manualMatchDialog.form.fixtureType}`) }}

@@ -888,7 +894,7 @@ export default { showImportModal: false, manualMatchDialog: { isOpen: false, saving: false, error: '', - form: { clubTeamId: '', opponentName: '', date: new Date().toISOString().slice(0, 10), time: '', homeAway: 'home', locationName: '', locationAddress: '', locationZip: '', locationCity: '', notes: '' } + form: { fixtureType: 'league', clubTeamId: '', opponentName: '', date: new Date().toISOString().slice(0, 10), time: '', homeAway: 'home', locationName: '', locationAddress: '', locationZip: '', locationCity: '', notes: '' } }, selectedFile: null, teams: [], @@ -984,7 +990,7 @@ export default { return (Array.isArray(matches) ? matches : []).filter((match) => !match?.isFriendly); }, resetManualMatchDialogForm() { - this.manualMatchDialog.form = { clubTeamId: this.selectedTeam?.id ? String(this.selectedTeam.id) : '', opponentName: '', date: new Date().toISOString().slice(0, 10), time: '', homeAway: 'home', locationName: '', locationAddress: '', locationZip: '', locationCity: '', notes: '' }; + this.manualMatchDialog.form = { fixtureType: 'league', clubTeamId: this.selectedTeam?.id ? String(this.selectedTeam.id) : '', opponentName: '', date: new Date().toISOString().slice(0, 10), time: '', homeAway: 'home', locationName: '', locationAddress: '', locationZip: '', locationCity: '', notes: '' }; this.manualMatchDialog.error = ''; }, openManualMatchDialog() { @@ -1005,11 +1011,33 @@ export default { this.manualMatchDialog.saving = true; this.manualMatchDialog.error = ''; try { + if (form.fixtureType === 'friendly') { + const ownTeam = this.teams.find((team) => String(team.id) === String(form.clubTeamId)); + if (!ownTeam?.name) { + this.manualMatchDialog.error = this.$t('schedule.manualMatchRequired'); + return; + } + const prefill = { + ...this.emptyFriendlyMatchForm(), + date: form.date, + time: form.time, + homeTeamName: form.homeAway === 'home' ? ownTeam.name : form.opponentName, + guestTeamName: form.homeAway === 'home' ? form.opponentName : ownTeam.name, + locationName: form.locationName, + locationAddress: form.locationAddress, + locationZip: form.locationZip, + locationCity: form.locationCity, + }; + this.manualMatchDialog.isOpen = false; + this.resetManualMatchDialogForm(); + await this.openFriendlyMatchDialog(null, prefill); + return; + } await apiClient.post(`/matches/${this.currentClub}`, { ...form, clubTeamId: Number(form.clubTeamId) }); this.manualMatchDialog.isOpen = false; this.resetManualMatchDialogForm(); await this.refreshScheduleData(); - this.showInfo(this.$t('messages.success'), this.$t('schedule.manualMatchSaved'), '', 'success'); + this.showInfo(this.$t('messages.success'), this.$t(`schedule.manualMatchSaved.${form.fixtureType}`), '', 'success'); } catch (error) { this.manualMatchDialog.error = getSafeErrorMessage(error, this.$t('schedule.manualMatchSaveFailed')); } finally { @@ -1730,7 +1758,7 @@ export default { this.friendlyMatchDialog.homeMembers = members; this.friendlyMatchDialog.guestMembers = []; }, - async openFriendlyMatchDialog(match = null) { + async openFriendlyMatchDialog(match = null, prefill = null) { await this.loadFriendlyMembers(match); this.friendlyMatchDialog.match = match?.isFriendly ? match : null; this.friendlyMatchDialog.editingId = match?.isFriendly ? match.id : null; @@ -1756,7 +1784,7 @@ export default { guestParticipants: [...this.parseFriendlyArray(match.guestParticipants)], resultDetails: [...this.parseFriendlyArray(match.resultDetails)] } - : this.emptyFriendlyMatchForm(); + : (prefill || this.emptyFriendlyMatchForm()); this.syncFriendlyEditDoubleRows(); await this.loadFriendlyVenues(match); const selectedVenue = this.findFriendlyVenueForForm(); @@ -3892,8 +3920,10 @@ li { .manual-match-form { display: flex; flex-direction: column; gap: 1rem; } .manual-match-intro { margin: 0; color: var(--text-muted, #6c757d); } -.manual-match-type { padding: .65rem .8rem; border-left: 3px solid var(--primary-color); background: var(--surface-muted, #f5f7f6); font-size: .92rem; } -.manual-match-type strong { margin-left: .4rem; } +.manual-match-type { display: flex; flex-wrap: wrap; gap: .6rem 1rem; margin: 0; padding: .65rem .8rem; border: 1px solid var(--border-color); border-left: 3px solid var(--primary-color); background: var(--surface-muted, #f5f7f6); font-size: .92rem; } +.manual-match-type legend { width: 100%; padding: 0 .2rem; font-size: .86rem; font-weight: 600; } +.manual-match-type label { font-weight: 600; cursor: pointer; } +.manual-match-type-hint { margin: -.35rem 0 0; color: var(--text-muted, #6c757d); font-size: .86rem; } .manual-match-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .85rem; } .manual-match-grid label, .manual-match-notes { display: grid; gap: .35rem; font-weight: 600; font-size: .9rem; } .manual-match-grid input, .manual-match-grid select, .manual-match-notes textarea { width: 100%; box-sizing: border-box; padding: .6rem .7rem; border: 1px solid var(--border-color); border-radius: 4px; background: var(--background-light, #fff); color: inherit; font: inherit; }