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:
@@ -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}`,
|
||||
|
||||
84
backend/services/notificationService.js
Normal file
84
backend/services/notificationService.js
Normal 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();
|
||||
@@ -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();
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
|
||||
|
||||
80
backend/services/tournamentSuggestionService.js
Normal file
80
backend/services/tournamentSuggestionService.js
Normal 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(/ /gi, ' ').replace(/&/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();
|
||||
Reference in New Issue
Block a user