feat: add tournament suggestions job and notification system
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 56s
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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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`,
|
||||
|
||||
75
frontend/src/components/NotificationBell.vue
Normal file
75
frontend/src/components/NotificationBell.vue
Normal 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>
|
||||
@@ -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'] }) },
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
11
frontend/src/views/NotificationsView.vue
Normal file
11
frontend/src/views/NotificationsView.vue
Normal 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>
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user