diff --git a/backend/controllers/clubRequestController.js b/backend/controllers/clubRequestController.js index 27fb6c6f..f8d4b663 100755 --- a/backend/controllers/clubRequestController.js +++ b/backend/controllers/clubRequestController.js @@ -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); diff --git a/backend/controllers/friendlyMatchInvitationController.js b/backend/controllers/friendlyMatchInvitationController.js index 299fdfd6..c1990236 100755 --- a/backend/controllers/friendlyMatchInvitationController.js +++ b/backend/controllers/friendlyMatchInvitationController.js @@ -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) { diff --git a/backend/controllers/notificationController.js b/backend/controllers/notificationController.js new file mode 100644 index 00000000..0d0a9376 --- /dev/null +++ b/backend/controllers/notificationController.js @@ -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.' }); } +}; diff --git a/backend/controllers/tournamentSuggestionController.js b/backend/controllers/tournamentSuggestionController.js new file mode 100644 index 00000000..6b0d4228 --- /dev/null +++ b/backend/controllers/tournamentSuggestionController.js @@ -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.' }); } +}; diff --git a/backend/migrations/20260814_create_notifications.sql b/backend/migrations/20260814_create_notifications.sql new file mode 100644 index 00000000..07b6e255 --- /dev/null +++ b/backend/migrations/20260814_create_notifications.sql @@ -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 +); diff --git a/backend/migrations/20260814_create_tournament_suggestions.sql b/backend/migrations/20260814_create_tournament_suggestions.sql new file mode 100644 index 00000000..2ebeae0c --- /dev/null +++ b/backend/migrations/20260814_create_tournament_suggestions.sql @@ -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 +); diff --git a/backend/models/NotificationEvent.js b/backend/models/NotificationEvent.js new file mode 100644 index 00000000..ca73609a --- /dev/null +++ b/backend/models/NotificationEvent.js @@ -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; diff --git a/backend/models/NotificationRecipient.js b/backend/models/NotificationRecipient.js new file mode 100644 index 00000000..ea168346 --- /dev/null +++ b/backend/models/NotificationRecipient.js @@ -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; diff --git a/backend/models/TournamentSuggestion.js b/backend/models/TournamentSuggestion.js new file mode 100644 index 00000000..07132fb7 --- /dev/null +++ b/backend/models/TournamentSuggestion.js @@ -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; diff --git a/backend/models/index.js b/backend/models/index.js index 5d5911bf..caa32d53 100755 --- a/backend/models/index.js +++ b/backend/models/index.js @@ -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, }; diff --git a/backend/routes/notificationRoutes.js b/backend/routes/notificationRoutes.js new file mode 100644 index 00000000..f7aa01fb --- /dev/null +++ b/backend/routes/notificationRoutes.js @@ -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; diff --git a/backend/routes/officialTournamentRoutes.js b/backend/routes/officialTournamentRoutes.js index 2177e8b5..b033f8bb 100755 --- a/backend/routes/officialTournamentRoutes.js +++ b/backend/routes/officialTournamentRoutes.js @@ -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; - diff --git a/backend/server.js b/backend/server.js index 09252c61..767818c0 100755 --- a/backend/server.js +++ b/backend/server.js @@ -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 diff --git a/backend/services/memberService.js b/backend/services/memberService.js index 6073a38b..ac613fea 100755 --- a/backend/services/memberService.js +++ b/backend/services/memberService.js @@ -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}`, diff --git a/backend/services/notificationService.js b/backend/services/notificationService.js new file mode 100644 index 00000000..3f5d09ec --- /dev/null +++ b/backend/services/notificationService.js @@ -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(); diff --git a/backend/services/schedulerService.js b/backend/services/schedulerService.js index 5d4b3462..45bcbd5b 100755 --- a/backend/services/schedulerService.js +++ b/backend/services/schedulerService.js @@ -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(); diff --git a/backend/services/socketService.js b/backend/services/socketService.js index ee2efb0e..690ff998 100755 --- a/backend/services/socketService.js +++ b/backend/services/socketService.js @@ -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 }); }; - diff --git a/backend/services/tournamentSuggestionService.js b/backend/services/tournamentSuggestionService.js new file mode 100644 index 00000000..8ea36ad7 --- /dev/null +++ b/backend/services/tournamentSuggestionService.js @@ -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(/]*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(/]*>([^<]{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(); diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 046834ba..fceb4e73 100755 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -7,13 +7,19 @@ {{ appBrand }} -
+ @@ -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; } diff --git a/frontend/src/apiClient.js b/frontend/src/apiClient.js index 830724be..c0f06d2e 100755 --- a/frontend/src/apiClient.js +++ b/frontend/src/apiClient.js @@ -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`, diff --git a/frontend/src/components/NotificationBell.vue b/frontend/src/components/NotificationBell.vue new file mode 100644 index 00000000..20371feb --- /dev/null +++ b/frontend/src/components/NotificationBell.vue @@ -0,0 +1,75 @@ + + + + + diff --git a/frontend/src/router.js b/frontend/src/router.js index 8beb6716..74aa8555 100755 --- a/frontend/src/router.js +++ b/frontend/src/router.js @@ -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'] }) }, diff --git a/frontend/src/services/socketService.js b/frontend/src/services/socketService.js index 2dbfcb19..1488ba92 100755 --- a/frontend/src/services/socketService.js +++ b/frontend/src/services/socketService.js @@ -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); } }; - diff --git a/frontend/src/views/NotificationsView.vue b/frontend/src/views/NotificationsView.vue new file mode 100644 index 00000000..9e114d35 --- /dev/null +++ b/frontend/src/views/NotificationsView.vue @@ -0,0 +1,11 @@ + + + diff --git a/frontend/src/views/OfficialTournaments.vue b/frontend/src/views/OfficialTournaments.vue index a36da559..3f7d3ff5 100755 --- a/frontend/src/views/OfficialTournaments.vue +++ b/frontend/src/views/OfficialTournaments.vue @@ -1,6 +1,15 @@