feat: add tournament suggestions job and notification system
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 56s
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 56s
- Implemented a scheduled job for fetching tournament suggestions in the scheduler service. - Added a new service for handling tournament suggestions, including fetching and updating suggestions. - Created notification system with models, controllers, and services for managing user notifications. - Introduced a notification bell component in the frontend to display unread notifications. - Added a notifications view for users to see all notifications and mark them as read. - Updated API client and socket service to handle notifications and authentication. - Created database migrations for notifications and tournament suggestions.
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
44
backend/controllers/notificationController.js
Normal file
44
backend/controllers/notificationController.js
Normal 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.' }); }
|
||||
};
|
||||
14
backend/controllers/tournamentSuggestionController.js
Normal file
14
backend/controllers/tournamentSuggestionController.js
Normal 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.' }); }
|
||||
};
|
||||
33
backend/migrations/20260814_create_notifications.sql
Normal file
33
backend/migrations/20260814_create_notifications.sql
Normal 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
|
||||
);
|
||||
@@ -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
|
||||
);
|
||||
23
backend/models/NotificationEvent.js
Normal file
23
backend/models/NotificationEvent.js
Normal 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;
|
||||
20
backend/models/NotificationRecipient.js
Normal file
20
backend/models/NotificationRecipient.js
Normal 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;
|
||||
18
backend/models/TournamentSuggestion.js
Normal file
18
backend/models/TournamentSuggestion.js
Normal 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;
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
11
backend/routes/notificationRoutes.js
Normal file
11
backend/routes/notificationRoutes.js
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -15,6 +15,7 @@ import sharp from 'sharp';
|
||||
|
||||
import { devLog } from '../utils/logger.js';
|
||||
import { standardizePhoneNumber } from '../utils/phoneUtils.js';
|
||||
import notificationService from './notificationService.js';
|
||||
class MemberService {
|
||||
normalizeSepaMandatePayload(payload = {}) {
|
||||
const normalizeText = (value, maxLength = null) => {
|
||||
@@ -786,6 +787,23 @@ class MemberService {
|
||||
member.myTischtennisHistoryPlayerId = historyPlayerId;
|
||||
}
|
||||
await member.save();
|
||||
const ratingChanged = member.ttr !== oldTtr || member.qttr !== oldQttr;
|
||||
if (ratingChanged) {
|
||||
const changes = [];
|
||||
if (member.ttr !== oldTtr) changes.push(`TTR ${oldTtr ?? '–'} → ${member.ttr ?? '–'}`);
|
||||
if (member.qttr !== oldQttr) changes.push(`QTTR ${oldQttr ?? '–'} → ${member.qttr ?? '–'}`);
|
||||
await notificationService.notifyClubPermission(clubId, 'schedule', 'read', {
|
||||
type: 'member.rating_changed',
|
||||
priority: 'normal',
|
||||
resourceType: 'member',
|
||||
resourceId: String(member.id),
|
||||
title: `(Q)TTR geändert: ${firstName} ${lastName}`,
|
||||
body: changes.join(', '),
|
||||
route: '/members',
|
||||
payload: { memberId: member.id, clubId: Number(clubId), oldTtr, newTtr: member.ttr, oldQttr, newQttr: member.qttr },
|
||||
dedupeKey: `member-rating:${clubId}:${member.id}:${member.ttr ?? 'none'}:${member.qttr ?? 'none'}`,
|
||||
});
|
||||
}
|
||||
updated++;
|
||||
matched.push({
|
||||
name: `${firstName} ${lastName}`,
|
||||
|
||||
84
backend/services/notificationService.js
Normal file
84
backend/services/notificationService.js
Normal file
@@ -0,0 +1,84 @@
|
||||
import { Op } from 'sequelize';
|
||||
import NotificationEvent from '../models/NotificationEvent.js';
|
||||
import NotificationRecipient from '../models/NotificationRecipient.js';
|
||||
import UserClub from '../models/UserClub.js';
|
||||
import permissionService from './permissionService.js';
|
||||
import { emitToUser } from './socketService.js';
|
||||
|
||||
const uniqueIds = (values) => [...new Set((values || []).map(Number).filter(Number.isInteger))];
|
||||
|
||||
class NotificationService {
|
||||
async create({ recipients, ...eventData }) {
|
||||
const userIds = uniqueIds(recipients);
|
||||
if (!userIds.length) return null;
|
||||
|
||||
let event;
|
||||
if (eventData.dedupeKey) {
|
||||
[event] = await NotificationEvent.findOrCreate({
|
||||
where: { dedupeKey: eventData.dedupeKey },
|
||||
defaults: eventData,
|
||||
});
|
||||
} else {
|
||||
event = await NotificationEvent.create(eventData);
|
||||
}
|
||||
|
||||
const createdRecipientIds = [];
|
||||
for (const userId of userIds) {
|
||||
const [, created] = await NotificationRecipient.findOrCreate({
|
||||
where: { eventId: event.id, userId },
|
||||
defaults: { eventId: event.id, userId },
|
||||
});
|
||||
if (created) createdRecipientIds.push(userId);
|
||||
}
|
||||
|
||||
for (const userId of createdRecipientIds) {
|
||||
emitToUser(userId, 'notification:created', { eventId: String(event.id) });
|
||||
}
|
||||
return event;
|
||||
}
|
||||
|
||||
async notifyClubPermission(clubId, resource, action, eventData) {
|
||||
const memberships = await UserClub.findAll({ where: { clubId, approved: true }, attributes: ['userId'] });
|
||||
const allowed = [];
|
||||
for (const membership of memberships) {
|
||||
if (await permissionService.hasPermission(membership.userId, Number(clubId), resource, action)) {
|
||||
allowed.push(membership.userId);
|
||||
}
|
||||
}
|
||||
return this.create({ ...eventData, clubId: Number(clubId), recipients: allowed });
|
||||
}
|
||||
|
||||
async listForUser(userId, { clubId, unreadOnly = false, limit = 30 } = {}) {
|
||||
const where = { userId, archivedAt: null };
|
||||
if (unreadOnly) where.readAt = null;
|
||||
const eventWhere = clubId ? { clubId: Number(clubId) } : undefined;
|
||||
return NotificationRecipient.findAll({
|
||||
where,
|
||||
include: [{ model: NotificationEvent, as: 'event', where: eventWhere, required: true }],
|
||||
order: [[{ model: NotificationEvent, as: 'event' }, 'createdAt', 'DESC']],
|
||||
limit: Math.min(Math.max(Number(limit) || 30, 1), 100),
|
||||
});
|
||||
}
|
||||
|
||||
async unreadCount(userId, clubId = null) {
|
||||
const include = clubId ? [{ model: NotificationEvent, as: 'event', where: { clubId: Number(clubId) }, required: true }] : [];
|
||||
return NotificationRecipient.count({ where: { userId, readAt: null, archivedAt: null }, include });
|
||||
}
|
||||
|
||||
async markRead(userId, recipientId) {
|
||||
const recipient = await NotificationRecipient.findOne({ where: { id: recipientId, userId, archivedAt: null } });
|
||||
if (!recipient) return null;
|
||||
if (!recipient.readAt) await recipient.update({ readAt: new Date() });
|
||||
return recipient;
|
||||
}
|
||||
|
||||
async markAllRead(userId, clubId = null) {
|
||||
if (!clubId) return NotificationRecipient.update({ readAt: new Date() }, { where: { userId, readAt: null, archivedAt: null } });
|
||||
const entries = await this.listForUser(userId, { clubId, unreadOnly: true, limit: 100 });
|
||||
const ids = entries.map((entry) => entry.id);
|
||||
if (!ids.length) return [0];
|
||||
return NotificationRecipient.update({ readAt: new Date() }, { where: { id: { [Op.in]: ids } } });
|
||||
}
|
||||
}
|
||||
|
||||
export default new NotificationService();
|
||||
@@ -3,6 +3,7 @@ import autoUpdateRatingsService from './autoUpdateRatingsService.js';
|
||||
import autoFetchMatchResultsService from './autoFetchMatchResultsService.js';
|
||||
import apiLogService from './apiLogService.js';
|
||||
import { devLog } from '../utils/logger.js';
|
||||
import tournamentSuggestionService from './tournamentSuggestionService.js';
|
||||
|
||||
class SchedulerService {
|
||||
constructor() {
|
||||
@@ -38,6 +39,20 @@ class SchedulerService {
|
||||
}
|
||||
}
|
||||
|
||||
async runTournamentSuggestionsJob(isAutomatic = true) {
|
||||
const startTime = Date.now();
|
||||
try {
|
||||
const result = await tournamentSuggestionService.fetchAllClubs();
|
||||
const executionTime = Date.now() - startTime;
|
||||
await apiLogService.logSchedulerExecution('tournament_suggestions', true, result, executionTime, null);
|
||||
return { success: true, result, executionTime, isAutomatic };
|
||||
} catch (error) {
|
||||
const executionTime = Date.now() - startTime;
|
||||
await apiLogService.logSchedulerExecution('tournament_suggestions', false, { success: false }, executionTime, error?.message || String(error));
|
||||
return { success: false, error: error?.message || String(error), executionTime, isAutomatic };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the scheduler
|
||||
*/
|
||||
@@ -65,8 +80,14 @@ class SchedulerService {
|
||||
timezone: 'Europe/Berlin'
|
||||
});
|
||||
|
||||
const tournamentSuggestionsJob = cron.schedule('15 2 * * *', async () => {
|
||||
devLog('[Scheduler] Running tournament suggestions job...');
|
||||
await this.runTournamentSuggestionsJob(true);
|
||||
}, { timezone: 'Europe/Berlin' });
|
||||
|
||||
this.jobs.set('ratingUpdates', ratingUpdateJob);
|
||||
this.jobs.set('matchResults', matchResultsJob);
|
||||
this.jobs.set('tournamentSuggestions', tournamentSuggestionsJob);
|
||||
|
||||
this.isRunning = true;
|
||||
const now = new Date();
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { Server } from 'socket.io';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import UserToken from '../models/UserToken.js';
|
||||
import UserClub from '../models/UserClub.js';
|
||||
|
||||
let io = null;
|
||||
|
||||
@@ -46,6 +49,20 @@ export const initializeSocketIO = (httpServer) => {
|
||||
perMessageDeflate: false, // Deaktiviert für bessere Kompatibilität
|
||||
maxHttpBufferSize: 1e6 // 1MB
|
||||
});
|
||||
|
||||
io.use(async (socket, next) => {
|
||||
try {
|
||||
const token = socket.handshake.auth?.token || socket.handshake.headers?.authcode;
|
||||
if (!token) return next(new Error('Unauthorized'));
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
const tokenRecord = await UserToken.findOne({ where: { token } });
|
||||
if (!tokenRecord || tokenRecord.expiresAt < new Date()) return next(new Error('Unauthorized'));
|
||||
socket.userId = Number(decoded.userId);
|
||||
return next();
|
||||
} catch (_error) {
|
||||
return next(new Error('Unauthorized'));
|
||||
}
|
||||
});
|
||||
|
||||
// Verbesserte WebSocket-Upgrade-Logging
|
||||
io.engine.on('upgrade', (req, socket, head) => {
|
||||
@@ -106,6 +123,7 @@ export const initializeSocketIO = (httpServer) => {
|
||||
console.log(`✅ Socket.IO Client verbunden: ${socket.id}`);
|
||||
console.log(` Transport: ${socket.conn.transport.name}`);
|
||||
console.log(` Origin: ${socket.handshake.headers.origin || 'unknown'}`);
|
||||
socket.join(`user-${socket.userId}`);
|
||||
|
||||
// Logge Transport-Upgrades
|
||||
socket.conn.on('upgrade', () => {
|
||||
@@ -117,7 +135,9 @@ export const initializeSocketIO = (httpServer) => {
|
||||
});
|
||||
|
||||
// Client tritt einem Club-Raum bei
|
||||
socket.on('join-club', (clubId) => {
|
||||
socket.on('join-club', async (clubId) => {
|
||||
const membership = await UserClub.findOne({ where: { userId: socket.userId, clubId: Number(clubId), approved: true } });
|
||||
if (!membership) return;
|
||||
const room = `club-${clubId}`;
|
||||
socket.join(room);
|
||||
console.log(` Client ${socket.id} tritt Raum bei: ${room}`);
|
||||
@@ -160,6 +180,11 @@ export const emitToClub = (clubId, event, data) => {
|
||||
io.to(room).emit(event, data);
|
||||
};
|
||||
|
||||
export const emitToUser = (userId, event, data) => {
|
||||
if (!io) return;
|
||||
io.to(`user-${userId}`).emit(event, data);
|
||||
};
|
||||
|
||||
// Events für Diary-Änderungen
|
||||
export const emitParticipantAdded = (clubId, dateId, participant) => {
|
||||
emitToClub(clubId, 'participant:added', { dateId, participant });
|
||||
@@ -271,4 +296,3 @@ export const emitFriendlySharedMatchDeleted = (homeClubId, guestClubId, matchId)
|
||||
export const emitMatchReportSubmitted = (clubId, matchCode, matchData = null) => {
|
||||
emitToClub(clubId, 'schedule:match-report:submitted', { clubId, matchCode, matchData });
|
||||
};
|
||||
|
||||
|
||||
80
backend/services/tournamentSuggestionService.js
Normal file
80
backend/services/tournamentSuggestionService.js
Normal file
@@ -0,0 +1,80 @@
|
||||
import axios from 'axios';
|
||||
import crypto from 'crypto';
|
||||
import Club from '../models/Club.js';
|
||||
import TournamentSuggestion from '../models/TournamentSuggestion.js';
|
||||
import notificationService from './notificationService.js';
|
||||
|
||||
const MY_TT_BASE_URL = 'https://www.mytischtennis.de';
|
||||
const cleanText = (value = '') => String(value).replace(/<[^>]*>/g, ' ').replace(/ /gi, ' ').replace(/&/gi, '&').replace(/\s+/g, ' ').trim();
|
||||
const absoluteUrl = (href) => href.startsWith('http') ? href : `${MY_TT_BASE_URL}${href.startsWith('/') ? '' : '/'}${href}`;
|
||||
|
||||
function parseCalendar(html, federation) {
|
||||
const items = [];
|
||||
const anchors = [...String(html).matchAll(/<a\b[^>]*href=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/gi)];
|
||||
for (const anchor of anchors) {
|
||||
const href = anchor[1];
|
||||
const title = cleanText(anchor[2]);
|
||||
if (!/\/turnier\/\d+/.test(href) || !title || title.length < 3) continue;
|
||||
const position = anchor.index || 0;
|
||||
const context = cleanText(String(html).slice(Math.max(0, position - 250), position + 500));
|
||||
const date = context.match(/\b\d{2}\.\d{2}\.20\d{2}\b/)?.[0] || null;
|
||||
const location = context.match(/\d{2}\.\d{2}\.20\d{2}(?:,\s*\d{1,2}:\d{2})?\s+([^\d]{3,80}?)(?=\s+(?:Turnierserie|weiterführendes|abgesagt|\d{2}\.\d{2}|$))/i)?.[1]?.trim() || null;
|
||||
const followingHtml = String(html).slice(position + anchor[0].length, position + anchor[0].length + 300);
|
||||
const organizer = cleanText(followingHtml.match(/<span[^>]*>([^<]{3,255})<\/span>/i)?.[1] || '');
|
||||
items.push({ title: title.slice(0, 255), eventDate: date, organizer: organizer?.slice(0, 255) || null, location: location?.slice(0, 255) || null, sourceUrl: absoluteUrl(href), federation });
|
||||
}
|
||||
return [...new Map(items.map((item) => [`${item.title}|${item.eventDate || ''}|${item.sourceUrl}`, item])).values()].slice(0, 150);
|
||||
}
|
||||
|
||||
class TournamentSuggestionService {
|
||||
async fetchForClub(clubId) {
|
||||
const club = await Club.findByPk(clubId);
|
||||
if (!club) throw new Error('Verein nicht gefunden.');
|
||||
const federation = String(club.myTischtennisFedNickname || 'HeTTV').trim();
|
||||
const calendarUrl = `${MY_TT_BASE_URL}/click-tt/${encodeURIComponent(federation)}/turnierkalender`;
|
||||
const response = await axios.get(calendarUrl, { timeout: 30000, headers: { 'User-Agent': 'Trainingstagebuch/1.0 (+turnierkalender)' } });
|
||||
const parsed = parseCalendar(response.data, federation);
|
||||
if (!parsed.length) throw new Error('Im Turnierkalender konnten keine Turniere erkannt werden.');
|
||||
|
||||
let newCount = 0;
|
||||
for (const item of parsed) {
|
||||
const sourceFingerprint = crypto.createHash('sha256').update(`${clubId}|${item.sourceUrl}|${item.title}|${item.eventDate || ''}`).digest('hex');
|
||||
const [suggestion, created] = await TournamentSuggestion.findOrCreate({
|
||||
where: { sourceFingerprint },
|
||||
defaults: { clubId, ...item, sourceFingerprint, lastSeenAt: new Date() },
|
||||
});
|
||||
if (!created) await suggestion.update({ lastSeenAt: new Date(), location: item.location, title: item.title });
|
||||
if (created) newCount += 1;
|
||||
}
|
||||
if (newCount) {
|
||||
await notificationService.notifyClubPermission(clubId, 'tournaments', 'read', {
|
||||
type: 'tournament_suggestions.available', priority: 'normal', resourceType: 'tournament_suggestion',
|
||||
title: `${newCount} neue Turniervorschläge`, body: `Der ${federation}-Turnierkalender wurde aktualisiert.`,
|
||||
route: '/tournament-participations', payload: { clubId: Number(clubId), federation },
|
||||
dedupeKey: `tournament-suggestions:${clubId}:${new Date().toISOString().slice(0, 10)}`,
|
||||
});
|
||||
}
|
||||
return { federation, scanned: parsed.length, newCount };
|
||||
}
|
||||
|
||||
async fetchAllClubs() {
|
||||
const clubs = await Club.findAll({ attributes: ['id'] });
|
||||
const results = [];
|
||||
for (const club of clubs) {
|
||||
try { results.push({ clubId: club.id, ...(await this.fetchForClub(club.id)) }); }
|
||||
catch (error) { results.push({ clubId: club.id, error: error.message }); }
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
list(clubId) { return TournamentSuggestion.findAll({ where: { clubId }, order: [['status', 'ASC'], ['eventDate', 'ASC'], ['createdAt', 'DESC']] }); }
|
||||
async updateStatus(clubId, id, status) {
|
||||
if (!['reviewed', 'dismissed', 'new'].includes(status)) throw new Error('Ungültiger Status.');
|
||||
const suggestion = await TournamentSuggestion.findOne({ where: { id, clubId } });
|
||||
if (!suggestion) throw new Error('Turniervorschlag nicht gefunden.');
|
||||
await suggestion.update({ status });
|
||||
return suggestion;
|
||||
}
|
||||
}
|
||||
|
||||
export default new TournamentSuggestionService();
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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`,
|
||||
|
||||
75
frontend/src/components/NotificationBell.vue
Normal file
75
frontend/src/components/NotificationBell.vue
Normal 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>
|
||||
@@ -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'] }) },
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
11
frontend/src/views/NotificationsView.vue
Normal file
11
frontend/src/views/NotificationsView.vue
Normal 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>
|
||||
@@ -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; }
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user