feat: add tournament suggestions job and notification system
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 56s

- Implemented a scheduled job for fetching tournament suggestions in the scheduler service.
- Added a new service for handling tournament suggestions, including fetching and updating suggestions.
- Created notification system with models, controllers, and services for managing user notifications.
- Introduced a notification bell component in the frontend to display unread notifications.
- Added a notifications view for users to see all notifications and mark them as read.
- Updated API client and socket service to handle notifications and authentication.
- Created database migrations for notifications and tournament suggestions.
This commit is contained in:
Torsten Schulz (local)
2026-08-14 11:14:07 +02:00
parent 351fe588c1
commit bc9e7cfe3c
26 changed files with 643 additions and 13 deletions

View File

@@ -7,13 +7,19 @@
<span>{{ appBrand }}</span>
</router-link>
</h1>
<div v-if="isAuthenticated" class="user-menu">
<div v-if="isAuthenticated" class="header-user-actions">
<NotificationBell :club-id="currentClub" />
<div class="user-menu">
<button @click="toggleUserDropdown" class="user-info">
<span class="user-icon">👤</span>
<span class="user-email">{{ username }}</span>
<span class="dropdown-arrow"></span>
</button>
<div v-if="userDropdownOpen" class="user-dropdown">
<router-link to="/notifications" class="dropdown-item" @click="userDropdownOpen = false">
<span class="dropdown-icon">🔔</span>
Posteingang
</router-link>
<button type="button" class="dropdown-item" @click="openUserMenuDialog('MyTischtennisAccount', $t('navigation.myTischtennisAccount'))">
<span class="dropdown-icon">🔗</span>
{{ $t('navigation.myTischtennisAccount') }}
@@ -53,6 +59,7 @@
{{ $t('navigation.logout') }}
</button>
</div>
</div>
</div>
</header>
@@ -187,6 +194,7 @@ import logoUrl from './assets/logo.png';
import InfoDialog from './components/InfoDialog.vue';
import ConfirmDialog from './components/ConfirmDialog.vue';
import BaseDialog from './components/BaseDialog.vue';
import NotificationBell from './components/NotificationBell.vue';
import { buildInfoConfig, buildConfirmConfig } from './utils/dialogUtils.js';
import { FULL_APP_PRODUCTS, SIDEBAR_NAVIGATION } from './config/products.js';
@@ -196,6 +204,7 @@ export default {
components: {
DialogManager,
BaseDialog,
NotificationBell,
InfoDialog,
ConfirmDialog,
},
@@ -593,6 +602,12 @@ export default {
/* Schriftgröße bleibt wie in der main.scss definiert */
}
.header-user-actions {
display: flex;
align-items: center;
gap: 0.35rem;
}
.user-menu {
position: relative;
}

View File

@@ -2,7 +2,9 @@ import axios from 'axios';
import store from './store';
export const backendBaseUrl = import.meta.env.VITE_BACKEND
|| (import.meta.env.DEV ? 'http://localhost:3005' : window.location.origin);
// Lokal immer über den Vite-Proxy gehen. Damit sind Browser, API und
// Socket.IO für den Browser derselbe Ursprung und ein CORS-Preflight entfällt.
|| (import.meta.env.DEV ? '' : window.location.origin);
const apiClient = axios.create({
baseURL: `${backendBaseUrl}/api`,

View File

@@ -0,0 +1,75 @@
<template>
<div class="notification-menu" ref="root">
<button type="button" class="notification-bell" aria-label="Posteingang öffnen" @click="toggle">
<span aria-hidden="true">🔔</span>
<span v-if="unreadCount" class="notification-badge">{{ unreadLabel }}</span>
</button>
<section v-if="open" class="notification-popover" aria-label="Posteingang">
<header><strong>Posteingang</strong><button v-if="unreadCount" type="button" @click="markAllRead">Alles gelesen</button></header>
<p v-if="loading" class="notification-empty">Lädt </p>
<p v-else-if="!items.length" class="notification-empty">Keine neuen Benachrichtigungen.</p>
<button v-for="item in items" :key="item.id" type="button" :class="['notification-item', { unread: !item.readAt }]" @click="openItem(item)">
<span class="priority-dot" :class="item.priority"></span>
<span><strong>{{ item.title }}</strong><small>{{ item.body }}</small></span>
</button>
<button type="button" class="all-notifications" @click="openInbox">Alle anzeigen</button>
</section>
</div>
</template>
<script>
import apiClient from '../apiClient.js';
import { connectSocket, offNotificationCreated, onNotificationCreated } from '../services/socketService.js';
export default {
name: 'NotificationBell',
props: { clubId: { type: [Number, String], default: null } },
data: () => ({ open: false, items: [], unreadCount: 0, loading: false, refreshTimer: null }),
computed: { unreadLabel() { return this.unreadCount > 99 ? '99+' : this.unreadCount; } },
watch: { clubId() { this.refresh(); } },
mounted() {
connectSocket(this.clubId || null);
onNotificationCreated(this.handleNotification);
this.refresh();
this.refreshTimer = window.setInterval(() => this.refreshCount(), 60000);
document.addEventListener('visibilitychange', this.handleVisibility);
},
beforeUnmount() {
offNotificationCreated(this.handleNotification);
window.clearInterval(this.refreshTimer);
document.removeEventListener('visibilitychange', this.handleVisibility);
},
methods: {
async refresh() {
await Promise.all([this.refreshCount(), this.open ? this.loadItems() : Promise.resolve()]);
},
async refreshCount() {
try { this.unreadCount = (await apiClient.get('/notifications/unread-count')).data.count || 0; } catch (_error) { /* session handling happens in apiClient */ }
},
async loadItems() {
this.loading = true;
try { this.items = (await apiClient.get('/notifications', { params: { limit: 6 } })).data || []; } finally { this.loading = false; }
},
async toggle() { this.open = !this.open; if (this.open) await this.loadItems(); },
handleNotification() { this.refreshCount(); if (this.open) this.loadItems(); },
handleVisibility() { if (!document.hidden) this.refresh(); },
async markAllRead() { await apiClient.post('/notifications/read-all'); await this.refresh(); },
async openItem(item) {
if (!item.readAt) await apiClient.post(`/notifications/${item.id}/read`);
this.open = false;
await this.refreshCount();
this.$router.push(item.route || '/notifications');
},
openInbox() { this.open = false; this.$router.push('/notifications'); },
},
};
</script>
<style scoped>
.notification-menu { position: relative; }
.notification-bell { position: relative; border: 0; background: transparent; font-size: 1.25rem; cursor: pointer; padding: .45rem; }
.notification-badge { position: absolute; top: 0; right: -.15rem; min-width: 1.15rem; padding: .05rem .25rem; border-radius: 1rem; background: #c72c41; color: #fff; font: 700 .7rem/1.1 sans-serif; }
.notification-popover { position: absolute; right: 0; top: calc(100% + .35rem); z-index: 30; box-sizing: border-box; width: min(390px, calc(100vw - 1.5rem)); max-height: 70vh; overflow-x: hidden; overflow-y: auto; padding: .75rem; border: 1px solid #d8dee8; border-radius: .7rem; background: #fff; box-shadow: 0 12px 30px rgba(15, 23, 42, .18); }
.notification-popover header { display: flex; justify-content: space-between; align-items: center; margin-bottom: .5rem; }.notification-popover header button,.all-notifications { border: 0; background: none; color: #185fa5; cursor: pointer; font: inherit; }
.notification-item { display: flex; gap: .55rem; box-sizing: border-box; width: 100%; padding: .6rem; border: 0; border-radius: .45rem; background: transparent; text-align: left; cursor: pointer; }.notification-item > span:last-child { min-width: 0; overflow-wrap: anywhere; }.notification-item:hover,.notification-item.unread { background: #eef6ff; }.notification-item strong,.notification-item small { display: block; }.notification-item small { margin-top: .18rem; color: #52606d; }.priority-dot { width: .5rem; height: .5rem; flex: 0 0 auto; margin-top: .35rem; border-radius: 50%; background: #5c7080; }.priority-dot.high,.priority-dot.critical { background: #c72c41; }.notification-empty { color: #667085; }.all-notifications { box-sizing: border-box; width: 100%; padding-top: .65rem; text-align: center; }
</style>

View File

@@ -50,6 +50,7 @@ const ClubConceptModuleView = () => import('./views/ClubConceptModuleView.vue');
const Impressum = () => import('./views/Impressum.vue');
const Datenschutz = () => import('./views/Datenschutz.vue');
const KontoLoeschen = () => import('./views/KontoLoeschen.vue');
const NotificationsView = () => import('./views/NotificationsView.vue');
function withMeta(meta = {}) {
return meta;
@@ -195,6 +196,7 @@ const routes = [
{ path: '/personal-settings', name: 'personal-settings', component: PersonalSettings, meta: withMeta({ products: allProducts }) },
{ path: '/my-club', name: 'member-home', component: MemberHomeView, meta: withMeta({ products: clubOnly }) },
{ path: '/orders', name: 'orders', component: OrdersView, meta: withMeta({ products: allProducts }) },
{ path: '/notifications', name: 'notifications', component: NotificationsView, meta: withMeta({ products: allProducts }) },
{ path: '/billing', name: 'billing', component: BillingView, meta: withMeta({ products: trainerOnly }) },
{ path: '/club-requests', name: 'club-requests', component: ClubRequestsView, meta: withMeta({ products: clubOnly, permission: ['requests', 'read'] }) },
{ path: '/club-tasks', name: 'club-tasks', component: ClubTasksView, meta: withMeta({ products: clubOnly, permission: ['tasks', 'read'] }) },

View File

@@ -1,5 +1,6 @@
import { io } from 'socket.io-client';
import { backendBaseUrl } from '../apiClient.js';
import store from '../store.js';
let socket = null;
let isReloading = false;
@@ -69,7 +70,7 @@ export const connectSocket = (clubId) => {
// Entwicklung: Socket.IO läuft auf demselben Port wie der HTTP-Server (3005)
// Oder auf HTTPS-Port 3051, falls SSL-Zertifikate vorhanden sind
// Versuche zuerst HTTP, dann HTTPS
socketUrl = backendBaseUrl;
socketUrl = backendBaseUrl || window.location.origin;
// Falls der Server auf HTTPS-Port 3051 läuft, verwende diesen
// (wird automatisch auf HTTP zurückfallen, wenn HTTPS nicht verfügbar ist)
}
@@ -91,6 +92,7 @@ export const connectSocket = (clubId) => {
rejectUnauthorized: false, // Für selbst-signierte Zertifikate (nur Entwicklung)
// Verbesserte Cookie-Handling
withCredentials: true,
auth: { token: store.getters.token },
// Auto-Connect
autoConnect: true,
// Erzwinge Upgrade-Versuch nach erfolgreicher Polling-Verbindung
@@ -203,6 +205,14 @@ export const getSocket = () => {
return socket;
};
export const onNotificationCreated = (callback) => {
if (socket) socket.on('notification:created', callback);
};
export const offNotificationCreated = (callback) => {
if (socket) socket.off('notification:created', callback);
};
// Event-Listener registrieren
export const onParticipantAdded = (callback) => {
if (socket) {
@@ -482,4 +492,3 @@ export const offFriendlySharedMatchDeleted = (callback) => {
socket.off('friendly:shared:match:deleted', callback);
}
};

View File

@@ -0,0 +1,11 @@
<template>
<section class="notifications-page"><div class="page-heading"><div><h2>Posteingang</h2><p>Wichtige Informationen und Aufgaben für dich.</p></div><button v-if="items.some(item => !item.readAt)" type="button" class="btn-secondary" @click="markAllRead">Alles gelesen</button></div>
<p v-if="loading">Posteingang wird geladen …</p><p v-else-if="!items.length" class="empty">Keine Benachrichtigungen vorhanden.</p>
<article v-for="item in items" :key="item.id" :class="['entry', { unread: !item.readAt }]" @click="openItem(item)"><span :class="['dot', item.priority]"></span><div><h3>{{ item.title }}</h3><p v-if="item.body">{{ item.body }}</p><small>{{ formatDate(item.createdAt) }}</small></div></article>
</section>
</template>
<script>
import apiClient from '../apiClient.js';
export default { name: 'NotificationsView', data: () => ({ items: [], loading: true }), async mounted() { await this.load(); }, methods: { async load() { this.loading = true; try { this.items = (await apiClient.get('/notifications', { params: { limit: 100 } })).data || []; } finally { this.loading = false; } }, async markAllRead() { await apiClient.post('/notifications/read-all'); await this.load(); }, async openItem(item) { if (!item.readAt) await apiClient.post(`/notifications/${item.id}/read`); if (item.route) this.$router.push(item.route); else await this.load(); }, formatDate(value) { return value ? new Intl.DateTimeFormat('de-DE', { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(value)) : ''; } } };
</script>
<style scoped>.notifications-page{max-width:850px}.page-heading{display:flex;justify-content:space-between;align-items:flex-start;gap:1rem;margin-bottom:1rem}.page-heading h2{margin:0}.page-heading p,.entry p,.entry small,.empty{color:#667085}.entry{display:flex;gap:.8rem;margin:.55rem 0;padding:1rem;border:1px solid #d8dee8;border-radius:.6rem;cursor:pointer}.entry.unread{background:#eef6ff;border-color:#a8c9ed}.entry h3{margin:0}.entry p{margin:.3rem 0}.dot{width:.6rem;height:.6rem;flex:0 0 auto;margin-top:.4rem;border-radius:50%;background:#718096}.dot.high,.dot.critical{background:#c72c41}</style>

View File

@@ -1,6 +1,15 @@
<template>
<div class="official-tournaments">
<div class="workspace-admin">
<div class="admin-panel tournament-suggestions-panel">
<div class="panel-header"><h3>Turniervorschläge</h3><p>Turniere aus dem myTischtennis-Kalender prüfen und danach manuell einpflegen.</p></div>
<div class="panel-toolbar"><button class="btn-primary" :disabled="fetchingSuggestions" @click="fetchSuggestions">{{ fetchingSuggestions ? 'Abruf läuft ' : 'Jetzt abrufen' }}</button><span class="toolbar-meta">{{ newSuggestions.length }} neu</span></div>
<p v-if="suggestionError" class="suggestion-error">{{ suggestionError }}</p>
<ul v-if="newSuggestions.length" class="event-list suggestion-list">
<li v-for="suggestion in newSuggestions" :key="suggestion.id" class="event-item"><div class="suggestion-main"><strong>{{ suggestion.title }}</strong><span>{{ suggestion.eventDate || 'Termin offen' }}<template v-if="suggestion.location"> · {{ suggestion.location }}</template></span></div><a class="btn-secondary" :href="suggestion.sourceUrl" target="_blank" rel="noopener">Öffnen</a><button class="btn-secondary" @click="updateSuggestionStatus(suggestion, 'reviewed')">Geprüft</button><button class="btn-secondary" @click="updateSuggestionStatus(suggestion, 'dismissed')">Ausblenden</button></li>
</ul>
<p v-else class="empty-state compact">Keine neuen Vorschläge. Jetzt abrufen eignet sich zum Testen.</p>
</div>
<div class="admin-panel">
<div class="panel-header">
<h3>Turnier importieren</h3>
@@ -576,6 +585,7 @@ export default {
editingTournamentId: null,
editingTitle: '',
autoRegistering: false,
suggestions: [], fetchingSuggestions: false, suggestionError: '',
};
},
computed: {
@@ -634,6 +644,7 @@ export default {
return timeB - timeA;
});
},
newSuggestions() { return (this.suggestions || []).filter((suggestion) => suggestion.status === 'new'); },
historySummaryText() {
const rows = this.clubParticipationRows();
if (this.loadingClubParticipations) return 'Turnierbeteiligungen werden geladen';
@@ -1379,6 +1390,24 @@ export default {
// Fehler wird nicht angezeigt, damit die Seite trotzdem funktioniert
}
},
async loadSuggestions() {
try { const response = await apiClient.get(`/official-tournaments/${this.currentClub}/suggestions`); this.suggestions = Array.isArray(response.data) ? response.data : []; }
catch (_error) { this.suggestions = []; }
},
async fetchSuggestions() {
this.fetchingSuggestions = true; this.suggestionError = '';
try {
const response = await apiClient.post(`/official-tournaments/${this.currentClub}/suggestions/fetch`);
await this.loadSuggestions();
const { scanned = 0, newCount = 0, federation = '' } = response.data || {};
await this.showInfo('Turnierkalender aktualisiert', `${newCount} neue Vorschläge aus ${federation} (${scanned} geprüft).`, '', 'success');
} catch (error) { this.suggestionError = getSafeErrorMessage(error, 'Der Turnierkalender konnte nicht abgerufen werden.'); }
finally { this.fetchingSuggestions = false; }
},
async updateSuggestionStatus(suggestion, status) {
try { await apiClient.patch(`/official-tournaments/${this.currentClub}/suggestions/${suggestion.id}`, { status }); await this.loadSuggestions(); }
catch (error) { this.suggestionError = getSafeErrorMessage(error, 'Der Vorschlag konnte nicht aktualisiert werden.'); }
},
buildParticipationMap(entries) {
const map = {};
for (const e of entries) {
@@ -1890,6 +1919,7 @@ export default {
},
async mounted() {
await this.loadList();
await this.loadSuggestions();
await this.loadClubParticipations();
}
};
@@ -1912,6 +1942,7 @@ export default {
.event-item { display: flex; align-items: center; gap: 0.4rem; padding: .3rem .4rem; border-radius: 8px; }
.event-item.selected { background: #eef4ff; border: 1px solid #d1defd; }
.event-item.is-past { color: #8c96a5; }
.suggestion-main { display: flex; flex: 1 1 auto; min-width: 0; flex-direction: column; }.suggestion-main span { color: #64748b; font-size: .85rem; }.suggestion-error { color: #b42318; font-size: .9rem; }
.event-item.is-past .event-title,
.event-item.is-past .event-date { color: #8c96a5; }
.event-title { flex: 1; }