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.' }); }
|
||||
};
|
||||
Reference in New Issue
Block a user