2 Commits

Author SHA1 Message Date
Torsten Schulz (local)
bc9e7cfe3c 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.
2026-08-14 11:14:07 +02:00
Torsten Schulz (local)
351fe588c1 feat: neues Benachrichtigungssystem Konzept hinzugefügt 2026-08-14 09:47:59 +02:00
27 changed files with 800 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

@@ -0,0 +1,157 @@
# Benachrichtigungssystem – Konzept
## Zielbild
Nutzer sollen relevante Vorgänge rechtzeitig sehen, ohne mit Meldungen überflutet zu werden. Jede Benachrichtigung ist einer konkreten Aufgabe oder Information zugeordnet und führt mit einem Klick direkt dorthin. Das System unterscheidet zwischen Vereinsverwaltung, Trainerbereich und Mitgliedern, respektiert Rollen und persönliche Einstellungen und bleibt auch dann zuverlässig, wenn jemand gerade nicht angemeldet ist.
Die zentrale Produktentscheidung lautet: **Der Posteingang in der Anwendung ist die verlässliche Quelle; Live-Hinweise, E-Mail und später Push sind nur zusätzliche Zustellwege.** Dadurch gehen Informationen nicht verloren, auch wenn ein Browser geschlossen ist oder ein Versand fehlschlägt.
## Nutzergruppen und Grundregeln
| Nutzergruppe | Erhält Benachrichtigungen zu | Darf nicht sehen |
| --- | --- | --- |
| Vereinsverwaltung | Vorgängen in ihren berechtigten Modulen, z. B. Anfragen, Freigaben, Aufgaben, Zahlungen | Inhalte ohne Modulberechtigung oder außerhalb ihres Vereins |
| Trainer | Eigene Trainings- und Mannschaftsprozesse, (Q)TTR-Änderungen relevanter Spieler sowie Freundschaftsspiel-Anfragen | Verwaltungs- oder Mitgliedsdaten außerhalb der eigenen Berechtigung bzw. Zuordnung |
| Mitglied | Eigenen Terminen, Nachrichten, Profilanfragen und Mitgliedschaftsthemen | Daten anderer Mitglieder, interne Verwaltungsnotizen |
| Systemadministrator | Technischen oder übergreifenden Systemereignissen | Vereinsinhalte ohne expliziten Support-/Berechtigungsprozess |
Empfänger werden immer auf dem Server bestimmt. Der Client erhält nur bereits autorisierte Einträge. Eine Rolle ist kein pauschaler Verteiler: Eine neue Probetrainingsanfrage geht beispielsweise an Nutzer mit `requests:read` oder eine künftig konfigurierbare zuständige Rolle, nicht automatisch an alle Vereinsnutzer.
## Ereigniskatalog für die erste Ausbaustufe
| Ereignis | Empfänger | Priorität | Standardkanal | Ziel |
| --- | --- | --- | --- | --- |
| Neue Kontakt-, Probe-, Mitgliedschafts- oder Sponsoringanfrage | Zuständige Nutzer für Anfragen | hoch | Posteingang, Live-Hinweis, E-Mail | Anfrage öffnen |
| Neue oder fällige Aufgabe | Zugewiesene Person; bei fehlender Zuweisung verantwortliche Rolle | normal / hoch bei Überfälligkeit | Posteingang, Live-Hinweis | Aufgabe öffnen |
| Profiländerung beantragt | Nutzer mit Berechtigung zur Mitgliederpflege | normal | Posteingang, Live-Hinweis | Änderungsanfrage prüfen |
| Profiländerung genehmigt/abgelehnt | betreffendes Mitglied | normal | Posteingang, Live-Hinweis | Mitgliederbereich |
| Neue direkte Vereinsnachricht | Empfänger-Mitglied | normal | Posteingang, Live-Hinweis, optional E-Mail | Nachricht öffnen |
| Termin-/Trainingsänderung oder -absage | betroffene, angemeldete Mitglieder | hoch bei Absage, sonst normal | Posteingang, Live-Hinweis, optional E-Mail | Termin öffnen |
| (Q)TTR eines relevanten Spielers hat sich geändert | Zugeordnete Trainer bzw. Trainer mit Lesezugriff auf den Spieler | normal | Posteingang, Live-Hinweis, optional Tagesbündelung | Spieler-/Mannschaftsprofil öffnen |
| Eingehende Anfrage für ein Freundschaftsspiel | Zuständige Trainer bzw. Nutzer mit `schedule:read` im Zielverein | hoch | Posteingang, Live-Hinweis, optional E-Mail | Freundschaftsspiel öffnen |
| Freundschaftsspiel angenommen, abgelehnt oder wesentlich geändert | Ersteller und zuständige Trainer beider Vereine | normal / hoch bei kurzfristiger Absage | Posteingang, Live-Hinweis | Freundschaftsspiel öffnen |
| Zahlung offen, Mahnstufe oder Zahlungseingang | Kasse bzw. betroffenes Mitglied, abhängig vom Vorgang | hoch bei Frist/Mahnung | Posteingang, optional E-Mail | Forderung öffnen |
| Rolle oder Zugang geändert | betroffener Nutzer | hoch | Posteingang, E-Mail | Einstellungen bzw. Verein |
| Wichtige Produkt-/Sicherheitsinformation | gezielte Nutzergruppe | kritisch | Posteingang, E-Mail; später Push | Informationsdetail |
Nicht als Benachrichtigung vorgesehen sind reine Lesesichtungen, normale Protokolländerungen und jede kleine Bearbeitung durch andere Personen. Diese gehören in die Historie/Aktivitätsprotokolle. Mehrere gleichartige Ereignisse werden zusammengefasst, etwa: „3 neue Probetrainingsanfragen“.
## Prioritäten und Zustellregeln
- **Kritisch:** Sicherheits- oder Zugangsereignisse. Nicht stumm schaltbar; zusätzlich E-Mail.
- **Hoch:** Frist, neue externe Anfrage, Absage oder überfällige Aufgabe. Sofort im Produkt; E-Mail standardmäßig an.
- **Normal:** Handlungsrelevante Information. Sofort im Produkt; E-Mail ist eine persönliche Einstellung.
- **Niedrig:** Reine Information. Nur im Posteingang, standardmäßig gebündelt oder deaktiviert.
## Sichtbarkeit in Hauptnavigation und Trainerbereich
Die Anwendung zeigt die Glocke mit dem globalen Zähler ungelesener Einträge **immer sichtbar im authentifizierten Hauptkopf**, unmittelbar neben dem Nutzer-Menü. Sie bleibt damit sichtbar, unabhängig davon, ob der Nutzer gerade die Vereins-, Trainer- oder Mitgliederansicht geöffnet hat. Auf kleinen Bildschirmen bleibt mindestens das Glocken-Icon mit Badge erhalten; der Text kann entfallen.
Ein Klick öffnet einen kompakten Auszug der neuesten Einträge; „Alle anzeigen“ führt zum persönlichen Posteingang. Dieser Posteingang wird nicht als separater, leerer „Trainer-Posteingang“ dupliziert: Er ist ein gemeinsamer persönlicher Eingang mit klaren Filtern bzw. Bereichen **Alle**, **Trainer**, **Verein** und **Mitglied**. Im Trainerprodukt ist der Filter **Trainer** voreingestellt und zusätzlich als eigener Navigationspunkt „Posteingang“ in der Hauptnavigation sichtbar. Die Glocke zählt aber stets alle für den angemeldeten Nutzer relevanten ungelesenen Einträge.
Ein neuer Live-Eintrag zeigt einen unaufdringlichen Toast, aber keinen blockierenden Dialog. Ton ist standardmäßig aus.
Ein Eintrag bleibt ungelesen, bis der Nutzer ihn bewusst öffnet oder einzeln/alle als gelesen markiert. Das Öffnen des Dropdowns allein markiert nichts als gelesen. Wird das Zieldokument archiviert oder die Berechtigung entzogen, bleibt der Eintrag als Hinweis erhalten, zeigt aber eine sichere, erklärende Zielseite.
## Fachliches Datenmodell
Die Benachrichtigung wird als immutable Ereignis plus nutzerspezifischer Zustellstatus modelliert. Dadurch kann ein Ereignis mehreren Personen zugestellt werden, ohne Inhalte zu duplizieren.
```text
notification_events
id, club_id?, type, priority, actor_user_id?,
resource_type, resource_id, title_key, body_key, payload,
dedupe_key?, created_at, expires_at?
notification_recipients
id, event_id, user_id, read_at?, archived_at?,
delivery_state, email_state, email_sent_at?, created_at
notification_preferences
user_id, club_id?, event_category,
in_app_enabled, email_enabled, push_enabled, digest_frequency,
quiet_hours_from?, quiet_hours_to?
```
`payload` enthält nur die für Darstellung und Navigation erforderlichen Daten, etwa `{ "clubId": 12, "requestId": 44, "route": "/club-requests?requestId=44" }`. Es enthält keine sensiblen Freitexte, Adressen oder internen Notizen. Titel und Texte werden über Übersetzungsschlüssel erzeugt; das erlaubt Spracheinstellungen und spätere Textanpassungen.
`dedupe_key` verhindert Doppelungen durch wiederholte Jobs oder parallele Requests, zum Beispiel `club:12:request:44:created`. Für Sammelmeldungen wird ein kurzer Zeitraum verwendet, etwa zehn Minuten je Verein, Typ und Empfängergruppe.
### Regel für (Q)TTR-Änderungen
Ein Import, Sync oder erneuter Abruf erzeugt **keine** Benachrichtigung allein deshalb, weil er gelaufen ist. Vor dem Speichern vergleicht der Server je Spieler die normalisierten bisherigen und neuen Werte (`ttr`, `qttr`) sowie den zugehörigen Stichtag/Stand. Nur wenn mindestens ein fachlicher Wert tatsächlich abweicht, wird genau ein Ereignis erzeugt, z. B. „Max Mustermann: QTTR 1.487 → 1.512“.
Für denselben Spieler und Stichtag verhindert eine eindeutige `dedupe_key` wie `club:12:member:44:qttr:2026-08-11` weitere Meldungen. Trifft ein korrigierter Wert für denselben Stichtag ein, wird der bestehende ungelesene Eintrag aktualisiert statt eine zweite Meldung zu erzeugen. Fehlt der alte Wert oder ist der neue Wert ungültig, wird kein Änderungsalarm versendet; die Änderung bleibt im Importprotokoll nachvollziehbar. Bei vielen Änderungen werden sie pro Trainer und Abruf gebündelt, etwa „12 (Q)TTR-Änderungen in deinen Mannschaften“.
Empfohlene Indizes: `(user_id, read_at, created_at DESC)` auf `notification_recipients`, `(club_id, created_at DESC)` auf `notification_events` und ein eindeutiger Index auf `dedupe_key`, soweit gesetzt.
## Technische Architektur
1. Eine fachliche Aktion wird erfolgreich in der Datenbank gespeichert, etwa eine neue Vereinsanfrage.
2. Im selben Datenbank-Commit erzeugt ein `NotificationService` das Ereignis und die berechtigten Empfänger. So existiert kein Hinweis auf einen Vorgang, der später zurückgerollt wird.
3. Nach dem Commit wird ein neutrales Echtzeitereignis an den persönlichen Nutzerraum gesendet, z. B. `notification:created` mit Empfänger-ID und Zähler. Bestehendes Socket.IO kann dafür erweitert werden.
4. Der Client lädt den Eintrag über die API nach und aktualisiert Glocke und Posteingang. Bei fehlender Socket-Verbindung wird beim App-Start, beim Tab-Fokus und periodisch moderat abgeglichen.
5. Ein Hintergrund-Worker verarbeitet E-Mail und später Web-Push anhand der Zustellpräferenzen. Wiederholversuche und Fehler landen in einem Zustellprotokoll.
Die bisherigen Club-Socket-Räume eignen sich für Aktualisierungen gemeinsamer Ansichten, sind aber für persönliche Hinweise nicht ausreichend sicher. Ergänzung: Socket-Authentifizierung mit dem bestehenden Zugangstoken und persönliche Räume wie `user:{userId}`; der Client darf keine frei wählbaren Nutzer- oder Clubräume betreten.
## API-Schnittstellen (Vorschlag)
| Methode | Endpunkt | Zweck |
| --- | --- | --- |
| `GET` | `/notifications?clubId=&cursor=&unreadOnly=` | Posteingang paginiert laden |
| `GET` | `/notifications/unread-count?clubId=` | Zähler für Glocke laden |
| `POST` | `/notifications/:recipientId/read` | Eintrag als gelesen markieren |
| `POST` | `/notifications/read-all` | Gefilterte Einträge als gelesen markieren |
| `POST` | `/notifications/:recipientId/archive` | Persönlich ausblenden/archivieren |
| `GET` / `PUT` | `/notification-preferences` | persönliche Kanal- und Kategorieeinstellungen |
Es gibt bewusst keinen allgemeinen Client-Endpunkt zum Erzeugen einer Benachrichtigung. Fachliche Endpunkte (Anfrage anlegen, Aufgabe zuweisen, Termin absagen) rufen den serverseitigen Dienst auf. Manuell verfasste Vereinskommunikation bleibt im Kommunikationsmodul und erzeugt für Empfänger ihren passenden Posteingangseintrag.
## Datenschutz, Sicherheit und Betrieb
- Berechtigungen werden beim Erzeugen **und** beim Abruf bzw. Öffnen der Zielressource geprüft.
- E-Mails enthalten möglichst keine vertraulichen Daten; sie nennen Anlass und Verein und verlinken nach Anmeldung auf das Ziel.
- Für E-Mail, Push und Telemetrie werden Einwilligung, Widerruf und Versandzeitpunkt protokolliert. Kritische Sicherheitsmails folgen der erforderlichen Rechtsgrundlage und sind nicht abwählbar.
- Standardaufbewahrung: gelesene/archivierte Einträge 180 Tage, ungelesene 365 Tage; kritische Sicherheitsereignisse gemäß Sicherheits- und Löschkonzept länger. Die Fristen müssen mit dem finalen Datenschutzkonzept abgestimmt werden.
- E-Mail-Versand ist asynchron, idempotent und hat begrenzte Wiederholversuche. Ein Versandfehler verändert nie den In-App-Status.
## Umsetzung in Stufen
### MVP
- Datenmodell, serverseitiger `NotificationService`, REST-API und Posteingang mit Glocke.
- Live-Aktualisierung über authentifizierte persönliche Socket-Räume plus Polling-Fallback.
- Globale Glocke mit Badge im authentifizierten Hauptkopf und sichtbarer Navigationspunkt „Posteingang“ im Trainerbereich.
- Ereignisse: neue Vereinsanfragen, zugewiesene/fällige Aufgaben, Profiländerungen, direkte Vereinsnachrichten, tatsächlich geänderte (Q)TTR-Werte sowie eingehende Freundschaftsspiel-Anfragen.
- Gelesen/ungelesen, Navigation zum Kontext und persönliche In-App-Einstellung.
### Ausbaustufe 2
- E-Mail-Worker mit Vorlagen, Zustellprotokoll, Bündelung und Ruhezeiten.
- Termin-/Trainingsänderungen, Finanzen, Rollen- und Zugangsmeldungen.
- Zuständigkeiten je Ereignistyp im Verein konfigurierbar.
### Ausbaustufe 3
- Web-Push/PWA, tägliche oder wöchentliche Zusammenfassungen, Eskalationen bei unbehandelten kritischen Vorgängen.
- Auswertung: Zustellung, Öffnung, Bearbeitungszeit – ausschließlich aggregiert und datensparsam.
## Akzeptanzkriterien für das MVP
- Eine neue Probetrainingsanfrage erscheint für alle zuständigen Nutzer innerhalb weniger Sekunden in Glocke und Posteingang, aber nicht bei Unberechtigten.
- Im Trainerbereich ist der Posteingang über Hauptnavigation und Glocke ohne Seitenwechsel sichtbar erreichbar; die Glocke zeigt auch dort zuverlässig den ungelesenen Zähler.
- Ein erneuter (Q)TTR-Import mit identischen Werten erzeugt keinen Eintrag. Ändert sich ein TTR- oder QTTR-Wert, sehen nur berechtigte bzw. zugeordnete Trainer eine nachvollziehbare Meldung mit Alt- und Neuwert.
- Eine eingehende Freundschaftsspiel-Anfrage wird im Zielverein den zuständigen Trainern zugestellt und führt direkt zur Entscheidung in „Freundschaftsspiele“.
- Ein Mitglied sieht die Entscheidung über seine Profiländerung und kann direkt zum passenden Bereich navigieren.
- Nach Ab- und erneutem Anmelden bleiben ungelesene Einträge und der Zähler korrekt erhalten.
- Bei unterbrochener Echtzeitverbindung werden neue Einträge beim nächsten Abgleich sichtbar.
- Ein Nutzer kann normale Kategorien abschalten; kritische Sicherheitsinformationen bleiben sichtbar.
- Ein Empfänger kann keine Benachrichtigung oder Zielressource eines anderen Vereins oder Nutzers über manipulierte IDs abrufen.
## Offene Produktentscheidungen
1. Welche Rollen sind pro Ereignistyp anfänglich zuständig (z. B. Anfragen: Vorstand, Mitgliederverwaltung oder frei konfigurierbar)?
2. Soll E-Mail bereits im MVP enthalten sein oder erst nach dem belastbaren In-App-Posteingang?
3. Müssen Mitglieder in der ersten Version individuelle Trainings-/Terminpräferenzen erhalten oder genügt „bei allen für mich relevanten Änderungen“?
4. Welche Aufbewahrungsfrist wird mit Datenschutz/Vertrag final festgelegt?

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