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

@@ -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();