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

@@ -1,5 +1,6 @@
import { ClubRequest, ClubRequestNote } from '../models/index.js';
import { getSafeErrorMessage } from '../utils/errorUtils.js';
import notificationService from '../services/notificationService.js';
const TERMINAL_REQUEST_STATUSES = new Set(['converted', 'rejected', 'archived']);
@@ -95,6 +96,17 @@ export const createClubRequest = async (req, res) => {
});
const created = await loadRequestOrThrow(clubId, request.id);
await notificationService.notifyClubPermission(clubId, 'requests', 'read', {
type: 'club_request.created',
priority: 'high',
resourceType: 'club_request',
resourceId: String(request.id),
title: 'Neue Vereinsanfrage',
body: payload.subject || `${payload.requestType === 'trial_training' ? 'Probetraining' : 'Anfrage'} eingegangen`,
route: `/club-requests?requestId=${request.id}`,
payload: { requestId: request.id, clubId: Number(clubId) },
dedupeKey: `club-request:${request.id}:created`,
});
res.status(201).json({ request: created });
} catch (error) {
console.error('[createClubRequest] - Error:', error);

View File

@@ -5,6 +5,7 @@ import {
emitFriendlyInvitationDeclined,
emitFriendlySharedMatchUpdated,
} from '../services/socketService.js';
import notificationService from '../services/notificationService.js';
function userTokenFrom(req) {
const authHeader = req.headers.authorization;
@@ -17,6 +18,17 @@ function userTokenFrom(req) {
export const createFriendlyMatchInvitation = async (req, res) => {
try {
const invitation = await friendlyMatchSharedService.createInvitation(userTokenFrom(req), req.params.clubId, req.body);
await notificationService.notifyClubPermission(invitation.toClubId, 'schedule', 'read', {
type: 'friendly_match.invitation_received',
priority: 'high',
resourceType: 'friendly_match_invitation',
resourceId: String(invitation.id),
title: 'Neue Anfrage für ein Freundschaftsspiel',
body: `${invitation.fromClub?.name || 'Ein Verein'} wartet auf eine Rückmeldung.`,
route: '/friendly-matches',
payload: { invitationId: invitation.id, clubId: invitation.toClubId },
dedupeKey: `friendly-invitation:${invitation.id}:received`,
});
emitFriendlyInvitationCreated(invitation.fromClubId, invitation.toClubId, invitation);
res.status(201).json(invitation);
} catch (error) {
@@ -48,6 +60,13 @@ export const listOutgoingFriendlyMatchInvitations = async (req, res) => {
export const acceptFriendlyMatchInvitation = async (req, res) => {
try {
const result = await friendlyMatchSharedService.acceptInvitation(userTokenFrom(req), req.params.clubId, req.params.invitationId);
await notificationService.notifyClubPermission(result.invitation.fromClubId, 'schedule', 'read', {
type: 'friendly_match.invitation_accepted', priority: 'normal', resourceType: 'friendly_match_invitation',
resourceId: String(result.invitation.id), title: 'Freundschaftsspiel angenommen',
body: 'Die Anfrage wurde angenommen.', route: '/friendly-matches',
payload: { invitationId: result.invitation.id, clubId: result.invitation.fromClubId },
dedupeKey: `friendly-invitation:${result.invitation.id}:accepted`,
});
emitFriendlyInvitationAccepted(result.invitation.fromClubId, result.invitation.toClubId, result.invitation);
emitFriendlySharedMatchUpdated(result.sharedMatch.homeClubId, result.sharedMatch.guestClubId, result.sharedMatch);
res.status(200).json(result);
@@ -60,6 +79,13 @@ export const acceptFriendlyMatchInvitation = async (req, res) => {
export const declineFriendlyMatchInvitation = async (req, res) => {
try {
const invitation = await friendlyMatchSharedService.declineInvitation(userTokenFrom(req), req.params.clubId, req.params.invitationId);
await notificationService.notifyClubPermission(invitation.fromClubId, 'schedule', 'read', {
type: 'friendly_match.invitation_declined', priority: 'normal', resourceType: 'friendly_match_invitation',
resourceId: String(invitation.id), title: 'Freundschaftsspiel abgelehnt',
body: 'Die Anfrage wurde abgelehnt.', route: '/friendly-matches',
payload: { invitationId: invitation.id, clubId: invitation.fromClubId },
dedupeKey: `friendly-invitation:${invitation.id}:declined`,
});
emitFriendlyInvitationDeclined(invitation.fromClubId, invitation.toClubId, invitation.id);
res.status(200).json({ success: true, id: invitation.id });
} catch (error) {

View File

@@ -0,0 +1,44 @@
import notificationService from '../services/notificationService.js';
const serialize = (recipient) => {
const event = recipient.event?.toJSON?.() || recipient.event || {};
return {
recipientId: String(recipient.id),
id: String(recipient.id),
eventId: String(event.id || recipient.eventId),
readAt: recipient.readAt,
createdAt: event.createdAt || recipient.createdAt,
type: event.type,
priority: event.priority,
title: event.title,
body: event.body,
route: event.route,
payload: event.payload,
clubId: event.clubId,
};
};
export const listNotifications = async (req, res) => {
try {
const items = await notificationService.listForUser(req.user.id, req.query);
res.json(items.map(serialize));
} catch (error) { res.status(500).json({ error: 'Posteingang konnte nicht geladen werden.' }); }
};
export const unreadCount = async (req, res) => {
try { res.json({ count: await notificationService.unreadCount(req.user.id, req.query.clubId) }); }
catch (error) { res.status(500).json({ error: 'Zähler konnte nicht geladen werden.' }); }
};
export const markRead = async (req, res) => {
try {
const item = await notificationService.markRead(req.user.id, req.params.recipientId);
if (!item) return res.status(404).json({ error: 'Benachrichtigung nicht gefunden.' });
return res.json({ success: true });
} catch (error) { return res.status(500).json({ error: 'Benachrichtigung konnte nicht aktualisiert werden.' }); }
};
export const markAllRead = async (req, res) => {
try { await notificationService.markAllRead(req.user.id, req.body?.clubId); return res.json({ success: true }); }
catch (error) { return res.status(500).json({ error: 'Posteingang konnte nicht aktualisiert werden.' }); }
};

View File

@@ -0,0 +1,14 @@
import tournamentSuggestionService from '../services/tournamentSuggestionService.js';
export const listTournamentSuggestions = async (req, res) => {
try { res.json(await tournamentSuggestionService.list(req.params.clubId)); }
catch (_error) { res.status(500).json({ error: 'Turniervorschläge konnten nicht geladen werden.' }); }
};
export const fetchTournamentSuggestions = async (req, res) => {
try { res.json(await tournamentSuggestionService.fetchForClub(req.params.clubId)); }
catch (error) { res.status(502).json({ error: error.message || 'Turnierkalender konnte nicht abgerufen werden.' }); }
};
export const updateTournamentSuggestion = async (req, res) => {
try { res.json(await tournamentSuggestionService.updateStatus(req.params.clubId, req.params.id, req.body?.status)); }
catch (error) { res.status(error.message?.includes('nicht gefunden') ? 404 : 400).json({ error: error.message || 'Turniervorschlag konnte nicht aktualisiert werden.' }); }
};

View File

@@ -0,0 +1,33 @@
CREATE TABLE IF NOT EXISTS notification_events (
id BIGINT NOT NULL AUTO_INCREMENT,
club_id INT NULL,
type VARCHAR(80) NOT NULL,
priority ENUM('low', 'normal', 'high', 'critical') NOT NULL DEFAULT 'normal',
resource_type VARCHAR(80) NULL,
resource_id VARCHAR(80) NULL,
title VARCHAR(255) NOT NULL,
body TEXT NULL,
route VARCHAR(500) NULL,
payload JSON NULL,
dedupe_key VARCHAR(255) NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY uq_notification_events_dedupe_key (dedupe_key),
KEY idx_notification_events_club_created (club_id, created_at)
);
CREATE TABLE IF NOT EXISTS notification_recipients (
id BIGINT NOT NULL AUTO_INCREMENT,
event_id BIGINT NOT NULL,
user_id INT NOT NULL,
read_at DATETIME NULL,
archived_at DATETIME NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY uq_notification_recipients_event_user (event_id, user_id),
KEY idx_notification_recipients_user_read_created (user_id, read_at, created_at),
CONSTRAINT fk_notification_recipients_event FOREIGN KEY (event_id) REFERENCES notification_events(id) ON DELETE CASCADE,
CONSTRAINT fk_notification_recipients_user FOREIGN KEY (user_id) REFERENCES user(id) ON DELETE CASCADE
);

View File

@@ -0,0 +1,19 @@
CREATE TABLE IF NOT EXISTS tournament_suggestions (
id BIGINT NOT NULL AUTO_INCREMENT,
club_id INT NOT NULL,
federation VARCHAR(32) NOT NULL,
title VARCHAR(255) NOT NULL,
event_date VARCHAR(32) NULL,
organizer VARCHAR(255) NULL,
location VARCHAR(255) NULL,
source_url VARCHAR(1000) NOT NULL,
source_fingerprint VARCHAR(128) NOT NULL,
status ENUM('new', 'reviewed', 'dismissed') NOT NULL DEFAULT 'new',
last_seen_at DATETIME NOT NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY uq_tournament_suggestions_source_fingerprint (source_fingerprint),
KEY idx_tournament_suggestions_club_status_date (club_id, status, event_date),
CONSTRAINT fk_tournament_suggestions_club FOREIGN KEY (club_id) REFERENCES clubs(id) ON DELETE CASCADE
);

View File

@@ -0,0 +1,23 @@
import { DataTypes } from 'sequelize';
import sequelize from '../database.js';
const NotificationEvent = sequelize.define('NotificationEvent', {
id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true },
clubId: { type: DataTypes.INTEGER, allowNull: true, field: 'club_id' },
type: { type: DataTypes.STRING(80), allowNull: false },
priority: { type: DataTypes.ENUM('low', 'normal', 'high', 'critical'), allowNull: false, defaultValue: 'normal' },
resourceType: { type: DataTypes.STRING(80), allowNull: true, field: 'resource_type' },
resourceId: { type: DataTypes.STRING(80), allowNull: true, field: 'resource_id' },
title: { type: DataTypes.STRING(255), allowNull: false },
body: { type: DataTypes.TEXT, allowNull: true },
route: { type: DataTypes.STRING(500), allowNull: true },
payload: { type: DataTypes.JSON, allowNull: true },
dedupeKey: { type: DataTypes.STRING(255), allowNull: true, unique: true, field: 'dedupe_key' },
}, {
tableName: 'notification_events',
underscored: true,
timestamps: true,
indexes: [{ fields: ['club_id', 'created_at'] }],
});
export default NotificationEvent;

View File

@@ -0,0 +1,20 @@
import { DataTypes } from 'sequelize';
import sequelize from '../database.js';
const NotificationRecipient = sequelize.define('NotificationRecipient', {
id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true },
eventId: { type: DataTypes.BIGINT, allowNull: false, field: 'event_id' },
userId: { type: DataTypes.INTEGER, allowNull: false, field: 'user_id' },
readAt: { type: DataTypes.DATE, allowNull: true, field: 'read_at' },
archivedAt: { type: DataTypes.DATE, allowNull: true, field: 'archived_at' },
}, {
tableName: 'notification_recipients',
underscored: true,
timestamps: true,
indexes: [
{ unique: true, fields: ['event_id', 'user_id'] },
{ fields: ['user_id', 'read_at', 'created_at'] },
],
});
export default NotificationRecipient;

View File

@@ -0,0 +1,18 @@
import { DataTypes } from 'sequelize';
import sequelize from '../database.js';
const TournamentSuggestion = sequelize.define('TournamentSuggestion', {
id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true },
clubId: { type: DataTypes.INTEGER, allowNull: false, field: 'club_id' },
federation: { type: DataTypes.STRING(32), allowNull: false },
title: { type: DataTypes.STRING(255), allowNull: false },
eventDate: { type: DataTypes.STRING(32), allowNull: true, field: 'event_date' },
organizer: { type: DataTypes.STRING(255), allowNull: true },
location: { type: DataTypes.STRING(255), allowNull: true },
sourceUrl: { type: DataTypes.STRING(1000), allowNull: false, field: 'source_url' },
sourceFingerprint: { type: DataTypes.STRING(128), allowNull: false, unique: true, field: 'source_fingerprint' },
status: { type: DataTypes.ENUM('new', 'reviewed', 'dismissed'), allowNull: false, defaultValue: 'new' },
lastSeenAt: { type: DataTypes.DATE, allowNull: false, field: 'last_seen_at' },
}, { tableName: 'tournament_suggestions', underscored: true, timestamps: true, indexes: [{ fields: ['club_id', 'status', 'event_date'] }] });
export default TournamentSuggestion;

View File

@@ -95,6 +95,9 @@ import ClubDistributionGroup from './ClubDistributionGroup.js';
import ClubDistributionGroupMember from './ClubDistributionGroupMember.js';
import MemberProfileChangeRequest from './MemberProfileChangeRequest.js';
import MemberEventResponse from './MemberEventResponse.js';
import NotificationEvent from './NotificationEvent.js';
import NotificationRecipient from './NotificationRecipient.js';
import TournamentSuggestion from './TournamentSuggestion.js';
// Official tournaments relations
OfficialTournament.hasMany(OfficialCompetition, { foreignKey: 'tournamentId', as: 'competitions' });
OfficialCompetition.belongsTo(OfficialTournament, { foreignKey: 'tournamentId', as: 'tournament' });
@@ -112,6 +115,15 @@ Log.belongsTo(User, { foreignKey: 'userId' });
User.belongsToMany(Club, { through: UserClub, foreignKey: 'userId' });
Club.belongsToMany(User, { through: UserClub, foreignKey: 'clubId' });
NotificationEvent.hasMany(NotificationRecipient, { foreignKey: 'eventId', as: 'recipients' });
NotificationRecipient.belongsTo(NotificationEvent, { foreignKey: 'eventId', as: 'event' });
User.hasMany(NotificationRecipient, { foreignKey: 'userId', as: 'notificationRecipients' });
NotificationRecipient.belongsTo(User, { foreignKey: 'userId', as: 'user' });
Club.hasMany(NotificationEvent, { foreignKey: 'clubId', as: 'notificationEvents' });
NotificationEvent.belongsTo(Club, { foreignKey: 'clubId', as: 'club' });
Club.hasMany(TournamentSuggestion, { foreignKey: 'clubId', as: 'tournamentSuggestions' });
TournamentSuggestion.belongsTo(Club, { foreignKey: 'clubId', as: 'club' });
DiaryDate.belongsTo(Club, { foreignKey: 'clubId' });
Club.hasMany(DiaryDate, { foreignKey: 'clubId' });
@@ -706,5 +718,8 @@ export {
ClubDistributionGroup,
MemberProfileChangeRequest,
MemberEventResponse,
NotificationEvent,
NotificationRecipient,
TournamentSuggestion,
ClubDistributionGroupMember,
};

View File

@@ -0,0 +1,11 @@
import express from 'express';
import { authenticate } from '../middleware/authMiddleware.js';
import { listNotifications, markAllRead, markRead, unreadCount } from '../controllers/notificationController.js';
const router = express.Router();
router.use(authenticate);
router.get('/', listNotifications);
router.get('/unread-count', unreadCount);
router.post('/read-all', markAllRead);
router.post('/:recipientId/read', markRead);
export default router;

View File

@@ -12,6 +12,7 @@ import {
updateParticipantStatus,
autoRegisterOfficialTournamentParticipants
} from '../controllers/officialTournamentController.js';
import { fetchTournamentSuggestions, listTournamentSuggestions, updateTournamentSuggestion } from '../controllers/tournamentSuggestionController.js';
const router = express.Router();
const upload = multer({ storage: multer.memoryStorage() });
@@ -19,6 +20,9 @@ const upload = multer({ storage: multer.memoryStorage() });
router.use(authenticate);
router.get('/:clubId', listOfficialTournaments);
router.get('/:clubId/suggestions', listTournamentSuggestions);
router.post('/:clubId/suggestions/fetch', fetchTournamentSuggestions);
router.patch('/:clubId/suggestions/:id', updateTournamentSuggestion);
router.get('/:clubId/participations/summary', listClubParticipations);
router.post('/:clubId/upload', upload.single('pdf'), uploadTournamentPdf);
router.get('/:clubId/:id', getParsedTournament);
@@ -29,4 +33,3 @@ router.post('/:clubId/:id/status', updateParticipantStatus);
router.post('/:clubId/:id/auto-register', autoRegisterOfficialTournamentParticipants);
export default router;

View File

@@ -16,7 +16,7 @@ import {
TournamentMember, Accident, UserToken, OfficialTournament, OfficialCompetition, OfficialCompetitionMember, MyTischtennis, ClickTtAccount, MyTischtennisUpdateHistory, MyTischtennisFetchLog, ApiLog, MemberTransferConfig, MemberContact, MemberTtrHistory, MemberPlayInterest,
MemberOrder, MemberOrderHistory, MemberGroupPhoto, BillingTemplate, BillingTemplateField, BillingRun, BillingDocument, BillingDocumentValue, BillingUserSetting, FriendlyMatch, TrainingCancellation
, FriendlyMatchShared, FriendlyMatchInvitation
, CalendarEvent, ClubVenue, ClubRequest, ClubRequestNote, ClubSepaMandate, ClubPaymentClaim, ClubAccount, ClubAccountTransaction, ClubInvoiceParty, ClubInvoice, ClubInvoiceItem, ClubRole, ClubUserRole, ClubCommunicationThread, ClubCommunicationMessage, ClubCommunicationRecipient, ClubCommunicationDeliveryLog, ClubCommunicationTemplate, ClubDistributionGroup, ClubDistributionGroupMember, MemberProfileChangeRequest, MemberEventResponse
, CalendarEvent, ClubVenue, ClubRequest, ClubRequestNote, ClubSepaMandate, ClubPaymentClaim, ClubAccount, ClubAccountTransaction, ClubInvoiceParty, ClubInvoice, ClubInvoiceItem, ClubRole, ClubUserRole, ClubCommunicationThread, ClubCommunicationMessage, ClubCommunicationRecipient, ClubCommunicationDeliveryLog, ClubCommunicationTemplate, ClubDistributionGroup, ClubDistributionGroupMember, MemberProfileChangeRequest, MemberEventResponse, NotificationEvent, NotificationRecipient, TournamentSuggestion
} from './models/index.js';
import authRoutes from './routes/authRoutes.js';
import clubRoutes from './routes/clubRoutes.js';
@@ -78,6 +78,7 @@ import clubInvoiceRoutes from './routes/clubInvoiceRoutes.js';
import clubPaymentClaimRoutes from './routes/clubPaymentClaimRoutes.js';
import clubCommunicationRoutes from './routes/clubCommunicationRoutes.js';
import clubDocumentRoutes from './routes/clubDocumentRoutes.js';
import notificationRoutes from './routes/notificationRoutes.js';
import schedulerService from './services/schedulerService.js';
import { requestLoggingMiddleware } from './middleware/requestLoggingMiddleware.js';
import HttpError from './exceptions/HttpError.js';
@@ -263,13 +264,17 @@ function stripJsonLdByType(html, schemaType) {
app.set('trust proxy', true);
// CORS-Konfiguration - Socket.IO hat seine eigene CORS-Konfiguration
app.use(cors({
// CORS-Konfiguration. Der explizite OPTIONS-Handler ist für lokale Vite-Hosts
// wichtig: POST/PUT/PATCH mit authcode lösen einen Browser-Preflight aus.
const corsOptions = {
origin: true,
credentials: true,
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'authcode', 'userid']
}));
allowedHeaders: ['Content-Type', 'Authorization', 'authcode', 'userid'],
optionsSuccessStatus: 204,
};
app.options('*', cors(corsOptions));
app.use(cors(corsOptions));
app.use('/api/clicktt/proxy', express.raw({
type: ['multipart/form-data', 'application/octet-stream'],
@@ -379,6 +384,7 @@ app.use('/api/calendar', calendarRoutes);
app.use('/api/calendar-events', calendarEventRoutes);
app.use('/api/mobile-feedback', mobileFeedbackRoutes);
app.use('/api/club-requests', clubRequestRoutes);
app.use('/api/notifications', notificationRoutes);
app.use('/api/club-dashboard', clubDashboardRoutes);
app.use('/api/club-tasks', clubTaskRoutes);
app.use('/api/club-statistics', clubStatisticsRoutes);
@@ -664,6 +670,9 @@ app.use((err, req, res, next) => {
await safeSync(CalendarEvent);
await safeSync(MemberProfileChangeRequest);
await safeSync(MemberEventResponse);
await safeSync(NotificationEvent);
await safeSync(NotificationRecipient);
await safeSync(TournamentSuggestion);
await safeSync(TeamDocument);
// Foreign Keys wieder aktivieren

View File

@@ -15,6 +15,7 @@ import sharp from 'sharp';
import { devLog } from '../utils/logger.js';
import { standardizePhoneNumber } from '../utils/phoneUtils.js';
import notificationService from './notificationService.js';
class MemberService {
normalizeSepaMandatePayload(payload = {}) {
const normalizeText = (value, maxLength = null) => {
@@ -786,6 +787,23 @@ class MemberService {
member.myTischtennisHistoryPlayerId = historyPlayerId;
}
await member.save();
const ratingChanged = member.ttr !== oldTtr || member.qttr !== oldQttr;
if (ratingChanged) {
const changes = [];
if (member.ttr !== oldTtr) changes.push(`TTR ${oldTtr ?? ''}${member.ttr ?? ''}`);
if (member.qttr !== oldQttr) changes.push(`QTTR ${oldQttr ?? ''}${member.qttr ?? ''}`);
await notificationService.notifyClubPermission(clubId, 'schedule', 'read', {
type: 'member.rating_changed',
priority: 'normal',
resourceType: 'member',
resourceId: String(member.id),
title: `(Q)TTR geändert: ${firstName} ${lastName}`,
body: changes.join(', '),
route: '/members',
payload: { memberId: member.id, clubId: Number(clubId), oldTtr, newTtr: member.ttr, oldQttr, newQttr: member.qttr },
dedupeKey: `member-rating:${clubId}:${member.id}:${member.ttr ?? 'none'}:${member.qttr ?? 'none'}`,
});
}
updated++;
matched.push({
name: `${firstName} ${lastName}`,

View File

@@ -0,0 +1,84 @@
import { Op } from 'sequelize';
import NotificationEvent from '../models/NotificationEvent.js';
import NotificationRecipient from '../models/NotificationRecipient.js';
import UserClub from '../models/UserClub.js';
import permissionService from './permissionService.js';
import { emitToUser } from './socketService.js';
const uniqueIds = (values) => [...new Set((values || []).map(Number).filter(Number.isInteger))];
class NotificationService {
async create({ recipients, ...eventData }) {
const userIds = uniqueIds(recipients);
if (!userIds.length) return null;
let event;
if (eventData.dedupeKey) {
[event] = await NotificationEvent.findOrCreate({
where: { dedupeKey: eventData.dedupeKey },
defaults: eventData,
});
} else {
event = await NotificationEvent.create(eventData);
}
const createdRecipientIds = [];
for (const userId of userIds) {
const [, created] = await NotificationRecipient.findOrCreate({
where: { eventId: event.id, userId },
defaults: { eventId: event.id, userId },
});
if (created) createdRecipientIds.push(userId);
}
for (const userId of createdRecipientIds) {
emitToUser(userId, 'notification:created', { eventId: String(event.id) });
}
return event;
}
async notifyClubPermission(clubId, resource, action, eventData) {
const memberships = await UserClub.findAll({ where: { clubId, approved: true }, attributes: ['userId'] });
const allowed = [];
for (const membership of memberships) {
if (await permissionService.hasPermission(membership.userId, Number(clubId), resource, action)) {
allowed.push(membership.userId);
}
}
return this.create({ ...eventData, clubId: Number(clubId), recipients: allowed });
}
async listForUser(userId, { clubId, unreadOnly = false, limit = 30 } = {}) {
const where = { userId, archivedAt: null };
if (unreadOnly) where.readAt = null;
const eventWhere = clubId ? { clubId: Number(clubId) } : undefined;
return NotificationRecipient.findAll({
where,
include: [{ model: NotificationEvent, as: 'event', where: eventWhere, required: true }],
order: [[{ model: NotificationEvent, as: 'event' }, 'createdAt', 'DESC']],
limit: Math.min(Math.max(Number(limit) || 30, 1), 100),
});
}
async unreadCount(userId, clubId = null) {
const include = clubId ? [{ model: NotificationEvent, as: 'event', where: { clubId: Number(clubId) }, required: true }] : [];
return NotificationRecipient.count({ where: { userId, readAt: null, archivedAt: null }, include });
}
async markRead(userId, recipientId) {
const recipient = await NotificationRecipient.findOne({ where: { id: recipientId, userId, archivedAt: null } });
if (!recipient) return null;
if (!recipient.readAt) await recipient.update({ readAt: new Date() });
return recipient;
}
async markAllRead(userId, clubId = null) {
if (!clubId) return NotificationRecipient.update({ readAt: new Date() }, { where: { userId, readAt: null, archivedAt: null } });
const entries = await this.listForUser(userId, { clubId, unreadOnly: true, limit: 100 });
const ids = entries.map((entry) => entry.id);
if (!ids.length) return [0];
return NotificationRecipient.update({ readAt: new Date() }, { where: { id: { [Op.in]: ids } } });
}
}
export default new NotificationService();

View File

@@ -3,6 +3,7 @@ import autoUpdateRatingsService from './autoUpdateRatingsService.js';
import autoFetchMatchResultsService from './autoFetchMatchResultsService.js';
import apiLogService from './apiLogService.js';
import { devLog } from '../utils/logger.js';
import tournamentSuggestionService from './tournamentSuggestionService.js';
class SchedulerService {
constructor() {
@@ -38,6 +39,20 @@ class SchedulerService {
}
}
async runTournamentSuggestionsJob(isAutomatic = true) {
const startTime = Date.now();
try {
const result = await tournamentSuggestionService.fetchAllClubs();
const executionTime = Date.now() - startTime;
await apiLogService.logSchedulerExecution('tournament_suggestions', true, result, executionTime, null);
return { success: true, result, executionTime, isAutomatic };
} catch (error) {
const executionTime = Date.now() - startTime;
await apiLogService.logSchedulerExecution('tournament_suggestions', false, { success: false }, executionTime, error?.message || String(error));
return { success: false, error: error?.message || String(error), executionTime, isAutomatic };
}
}
/**
* Start the scheduler
*/
@@ -65,8 +80,14 @@ class SchedulerService {
timezone: 'Europe/Berlin'
});
const tournamentSuggestionsJob = cron.schedule('15 2 * * *', async () => {
devLog('[Scheduler] Running tournament suggestions job...');
await this.runTournamentSuggestionsJob(true);
}, { timezone: 'Europe/Berlin' });
this.jobs.set('ratingUpdates', ratingUpdateJob);
this.jobs.set('matchResults', matchResultsJob);
this.jobs.set('tournamentSuggestions', tournamentSuggestionsJob);
this.isRunning = true;
const now = new Date();

View File

@@ -1,4 +1,7 @@
import { Server } from 'socket.io';
import jwt from 'jsonwebtoken';
import UserToken from '../models/UserToken.js';
import UserClub from '../models/UserClub.js';
let io = null;
@@ -46,6 +49,20 @@ export const initializeSocketIO = (httpServer) => {
perMessageDeflate: false, // Deaktiviert für bessere Kompatibilität
maxHttpBufferSize: 1e6 // 1MB
});
io.use(async (socket, next) => {
try {
const token = socket.handshake.auth?.token || socket.handshake.headers?.authcode;
if (!token) return next(new Error('Unauthorized'));
const decoded = jwt.verify(token, process.env.JWT_SECRET);
const tokenRecord = await UserToken.findOne({ where: { token } });
if (!tokenRecord || tokenRecord.expiresAt < new Date()) return next(new Error('Unauthorized'));
socket.userId = Number(decoded.userId);
return next();
} catch (_error) {
return next(new Error('Unauthorized'));
}
});
// Verbesserte WebSocket-Upgrade-Logging
io.engine.on('upgrade', (req, socket, head) => {
@@ -106,6 +123,7 @@ export const initializeSocketIO = (httpServer) => {
console.log(`✅ Socket.IO Client verbunden: ${socket.id}`);
console.log(` Transport: ${socket.conn.transport.name}`);
console.log(` Origin: ${socket.handshake.headers.origin || 'unknown'}`);
socket.join(`user-${socket.userId}`);
// Logge Transport-Upgrades
socket.conn.on('upgrade', () => {
@@ -117,7 +135,9 @@ export const initializeSocketIO = (httpServer) => {
});
// Client tritt einem Club-Raum bei
socket.on('join-club', (clubId) => {
socket.on('join-club', async (clubId) => {
const membership = await UserClub.findOne({ where: { userId: socket.userId, clubId: Number(clubId), approved: true } });
if (!membership) return;
const room = `club-${clubId}`;
socket.join(room);
console.log(` Client ${socket.id} tritt Raum bei: ${room}`);
@@ -160,6 +180,11 @@ export const emitToClub = (clubId, event, data) => {
io.to(room).emit(event, data);
};
export const emitToUser = (userId, event, data) => {
if (!io) return;
io.to(`user-${userId}`).emit(event, data);
};
// Events für Diary-Änderungen
export const emitParticipantAdded = (clubId, dateId, participant) => {
emitToClub(clubId, 'participant:added', { dateId, participant });
@@ -271,4 +296,3 @@ export const emitFriendlySharedMatchDeleted = (homeClubId, guestClubId, matchId)
export const emitMatchReportSubmitted = (clubId, matchCode, matchData = null) => {
emitToClub(clubId, 'schedule:match-report:submitted', { clubId, matchCode, matchData });
};

View File

@@ -0,0 +1,80 @@
import axios from 'axios';
import crypto from 'crypto';
import Club from '../models/Club.js';
import TournamentSuggestion from '../models/TournamentSuggestion.js';
import notificationService from './notificationService.js';
const MY_TT_BASE_URL = 'https://www.mytischtennis.de';
const cleanText = (value = '') => String(value).replace(/<[^>]*>/g, ' ').replace(/&nbsp;/gi, ' ').replace(/&amp;/gi, '&').replace(/\s+/g, ' ').trim();
const absoluteUrl = (href) => href.startsWith('http') ? href : `${MY_TT_BASE_URL}${href.startsWith('/') ? '' : '/'}${href}`;
function parseCalendar(html, federation) {
const items = [];
const anchors = [...String(html).matchAll(/<a\b[^>]*href=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/gi)];
for (const anchor of anchors) {
const href = anchor[1];
const title = cleanText(anchor[2]);
if (!/\/turnier\/\d+/.test(href) || !title || title.length < 3) continue;
const position = anchor.index || 0;
const context = cleanText(String(html).slice(Math.max(0, position - 250), position + 500));
const date = context.match(/\b\d{2}\.\d{2}\.20\d{2}\b/)?.[0] || null;
const location = context.match(/\d{2}\.\d{2}\.20\d{2}(?:,\s*\d{1,2}:\d{2})?\s+([^\d]{3,80}?)(?=\s+(?:Turnierserie|weiterführendes|abgesagt|\d{2}\.\d{2}|$))/i)?.[1]?.trim() || null;
const followingHtml = String(html).slice(position + anchor[0].length, position + anchor[0].length + 300);
const organizer = cleanText(followingHtml.match(/<span[^>]*>([^<]{3,255})<\/span>/i)?.[1] || '');
items.push({ title: title.slice(0, 255), eventDate: date, organizer: organizer?.slice(0, 255) || null, location: location?.slice(0, 255) || null, sourceUrl: absoluteUrl(href), federation });
}
return [...new Map(items.map((item) => [`${item.title}|${item.eventDate || ''}|${item.sourceUrl}`, item])).values()].slice(0, 150);
}
class TournamentSuggestionService {
async fetchForClub(clubId) {
const club = await Club.findByPk(clubId);
if (!club) throw new Error('Verein nicht gefunden.');
const federation = String(club.myTischtennisFedNickname || 'HeTTV').trim();
const calendarUrl = `${MY_TT_BASE_URL}/click-tt/${encodeURIComponent(federation)}/turnierkalender`;
const response = await axios.get(calendarUrl, { timeout: 30000, headers: { 'User-Agent': 'Trainingstagebuch/1.0 (+turnierkalender)' } });
const parsed = parseCalendar(response.data, federation);
if (!parsed.length) throw new Error('Im Turnierkalender konnten keine Turniere erkannt werden.');
let newCount = 0;
for (const item of parsed) {
const sourceFingerprint = crypto.createHash('sha256').update(`${clubId}|${item.sourceUrl}|${item.title}|${item.eventDate || ''}`).digest('hex');
const [suggestion, created] = await TournamentSuggestion.findOrCreate({
where: { sourceFingerprint },
defaults: { clubId, ...item, sourceFingerprint, lastSeenAt: new Date() },
});
if (!created) await suggestion.update({ lastSeenAt: new Date(), location: item.location, title: item.title });
if (created) newCount += 1;
}
if (newCount) {
await notificationService.notifyClubPermission(clubId, 'tournaments', 'read', {
type: 'tournament_suggestions.available', priority: 'normal', resourceType: 'tournament_suggestion',
title: `${newCount} neue Turniervorschläge`, body: `Der ${federation}-Turnierkalender wurde aktualisiert.`,
route: '/tournament-participations', payload: { clubId: Number(clubId), federation },
dedupeKey: `tournament-suggestions:${clubId}:${new Date().toISOString().slice(0, 10)}`,
});
}
return { federation, scanned: parsed.length, newCount };
}
async fetchAllClubs() {
const clubs = await Club.findAll({ attributes: ['id'] });
const results = [];
for (const club of clubs) {
try { results.push({ clubId: club.id, ...(await this.fetchForClub(club.id)) }); }
catch (error) { results.push({ clubId: club.id, error: error.message }); }
}
return results;
}
list(clubId) { return TournamentSuggestion.findAll({ where: { clubId }, order: [['status', 'ASC'], ['eventDate', 'ASC'], ['createdAt', 'DESC']] }); }
async updateStatus(clubId, id, status) {
if (!['reviewed', 'dismissed', 'new'].includes(status)) throw new Error('Ungültiger Status.');
const suggestion = await TournamentSuggestion.findOne({ where: { id, clubId } });
if (!suggestion) throw new Error('Turniervorschlag nicht gefunden.');
await suggestion.update({ status });
return suggestion;
}
}
export default new TournamentSuggestionService();

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

View File

@@ -43,6 +43,17 @@ export default defineConfig({
hmr: {
protocol: 'ws',
port: 5000,
}
},
proxy: {
'/api': {
target: 'http://localhost:3005',
changeOrigin: true,
},
'/socket.io': {
target: 'http://localhost:3005',
changeOrigin: true,
ws: true,
},
},
},
});