feat: add tournament suggestions job and notification system
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 56s

- Implemented a scheduled job for fetching tournament suggestions in the scheduler service.
- Added a new service for handling tournament suggestions, including fetching and updating suggestions.
- Created notification system with models, controllers, and services for managing user notifications.
- Introduced a notification bell component in the frontend to display unread notifications.
- Added a notifications view for users to see all notifications and mark them as read.
- Updated API client and socket service to handle notifications and authentication.
- Created database migrations for notifications and tournament suggestions.
This commit is contained in:
Torsten Schulz (local)
2026-08-14 11:14:07 +02:00
parent 351fe588c1
commit bc9e7cfe3c
26 changed files with 643 additions and 13 deletions

View File

@@ -1,5 +1,6 @@
import { ClubRequest, ClubRequestNote } from '../models/index.js';
import { getSafeErrorMessage } from '../utils/errorUtils.js';
import notificationService from '../services/notificationService.js';
const TERMINAL_REQUEST_STATUSES = new Set(['converted', 'rejected', 'archived']);
@@ -95,6 +96,17 @@ export const createClubRequest = async (req, res) => {
});
const created = await loadRequestOrThrow(clubId, request.id);
await notificationService.notifyClubPermission(clubId, 'requests', 'read', {
type: 'club_request.created',
priority: 'high',
resourceType: 'club_request',
resourceId: String(request.id),
title: 'Neue Vereinsanfrage',
body: payload.subject || `${payload.requestType === 'trial_training' ? 'Probetraining' : 'Anfrage'} eingegangen`,
route: `/club-requests?requestId=${request.id}`,
payload: { requestId: request.id, clubId: Number(clubId) },
dedupeKey: `club-request:${request.id}:created`,
});
res.status(201).json({ request: created });
} catch (error) {
console.error('[createClubRequest] - Error:', error);

View File

@@ -5,6 +5,7 @@ import {
emitFriendlyInvitationDeclined,
emitFriendlySharedMatchUpdated,
} from '../services/socketService.js';
import notificationService from '../services/notificationService.js';
function userTokenFrom(req) {
const authHeader = req.headers.authorization;
@@ -17,6 +18,17 @@ function userTokenFrom(req) {
export const createFriendlyMatchInvitation = async (req, res) => {
try {
const invitation = await friendlyMatchSharedService.createInvitation(userTokenFrom(req), req.params.clubId, req.body);
await notificationService.notifyClubPermission(invitation.toClubId, 'schedule', 'read', {
type: 'friendly_match.invitation_received',
priority: 'high',
resourceType: 'friendly_match_invitation',
resourceId: String(invitation.id),
title: 'Neue Anfrage für ein Freundschaftsspiel',
body: `${invitation.fromClub?.name || 'Ein Verein'} wartet auf eine Rückmeldung.`,
route: '/friendly-matches',
payload: { invitationId: invitation.id, clubId: invitation.toClubId },
dedupeKey: `friendly-invitation:${invitation.id}:received`,
});
emitFriendlyInvitationCreated(invitation.fromClubId, invitation.toClubId, invitation);
res.status(201).json(invitation);
} catch (error) {
@@ -48,6 +60,13 @@ export const listOutgoingFriendlyMatchInvitations = async (req, res) => {
export const acceptFriendlyMatchInvitation = async (req, res) => {
try {
const result = await friendlyMatchSharedService.acceptInvitation(userTokenFrom(req), req.params.clubId, req.params.invitationId);
await notificationService.notifyClubPermission(result.invitation.fromClubId, 'schedule', 'read', {
type: 'friendly_match.invitation_accepted', priority: 'normal', resourceType: 'friendly_match_invitation',
resourceId: String(result.invitation.id), title: 'Freundschaftsspiel angenommen',
body: 'Die Anfrage wurde angenommen.', route: '/friendly-matches',
payload: { invitationId: result.invitation.id, clubId: result.invitation.fromClubId },
dedupeKey: `friendly-invitation:${result.invitation.id}:accepted`,
});
emitFriendlyInvitationAccepted(result.invitation.fromClubId, result.invitation.toClubId, result.invitation);
emitFriendlySharedMatchUpdated(result.sharedMatch.homeClubId, result.sharedMatch.guestClubId, result.sharedMatch);
res.status(200).json(result);
@@ -60,6 +79,13 @@ export const acceptFriendlyMatchInvitation = async (req, res) => {
export const declineFriendlyMatchInvitation = async (req, res) => {
try {
const invitation = await friendlyMatchSharedService.declineInvitation(userTokenFrom(req), req.params.clubId, req.params.invitationId);
await notificationService.notifyClubPermission(invitation.fromClubId, 'schedule', 'read', {
type: 'friendly_match.invitation_declined', priority: 'normal', resourceType: 'friendly_match_invitation',
resourceId: String(invitation.id), title: 'Freundschaftsspiel abgelehnt',
body: 'Die Anfrage wurde abgelehnt.', route: '/friendly-matches',
payload: { invitationId: invitation.id, clubId: invitation.fromClubId },
dedupeKey: `friendly-invitation:${invitation.id}:declined`,
});
emitFriendlyInvitationDeclined(invitation.fromClubId, invitation.toClubId, invitation.id);
res.status(200).json({ success: true, id: invitation.id });
} catch (error) {

View File

@@ -0,0 +1,44 @@
import notificationService from '../services/notificationService.js';
const serialize = (recipient) => {
const event = recipient.event?.toJSON?.() || recipient.event || {};
return {
recipientId: String(recipient.id),
id: String(recipient.id),
eventId: String(event.id || recipient.eventId),
readAt: recipient.readAt,
createdAt: event.createdAt || recipient.createdAt,
type: event.type,
priority: event.priority,
title: event.title,
body: event.body,
route: event.route,
payload: event.payload,
clubId: event.clubId,
};
};
export const listNotifications = async (req, res) => {
try {
const items = await notificationService.listForUser(req.user.id, req.query);
res.json(items.map(serialize));
} catch (error) { res.status(500).json({ error: 'Posteingang konnte nicht geladen werden.' }); }
};
export const unreadCount = async (req, res) => {
try { res.json({ count: await notificationService.unreadCount(req.user.id, req.query.clubId) }); }
catch (error) { res.status(500).json({ error: 'Zähler konnte nicht geladen werden.' }); }
};
export const markRead = async (req, res) => {
try {
const item = await notificationService.markRead(req.user.id, req.params.recipientId);
if (!item) return res.status(404).json({ error: 'Benachrichtigung nicht gefunden.' });
return res.json({ success: true });
} catch (error) { return res.status(500).json({ error: 'Benachrichtigung konnte nicht aktualisiert werden.' }); }
};
export const markAllRead = async (req, res) => {
try { await notificationService.markAllRead(req.user.id, req.body?.clubId); return res.json({ success: true }); }
catch (error) { return res.status(500).json({ error: 'Posteingang konnte nicht aktualisiert werden.' }); }
};

View File

@@ -0,0 +1,14 @@
import tournamentSuggestionService from '../services/tournamentSuggestionService.js';
export const listTournamentSuggestions = async (req, res) => {
try { res.json(await tournamentSuggestionService.list(req.params.clubId)); }
catch (_error) { res.status(500).json({ error: 'Turniervorschläge konnten nicht geladen werden.' }); }
};
export const fetchTournamentSuggestions = async (req, res) => {
try { res.json(await tournamentSuggestionService.fetchForClub(req.params.clubId)); }
catch (error) { res.status(502).json({ error: error.message || 'Turnierkalender konnte nicht abgerufen werden.' }); }
};
export const updateTournamentSuggestion = async (req, res) => {
try { res.json(await tournamentSuggestionService.updateStatus(req.params.clubId, req.params.id, req.body?.status)); }
catch (error) { res.status(error.message?.includes('nicht gefunden') ? 404 : 400).json({ error: error.message || 'Turniervorschlag konnte nicht aktualisiert werden.' }); }
};