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

@@ -0,0 +1,23 @@
import { DataTypes } from 'sequelize';
import sequelize from '../database.js';
const NotificationEvent = sequelize.define('NotificationEvent', {
id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true },
clubId: { type: DataTypes.INTEGER, allowNull: true, field: 'club_id' },
type: { type: DataTypes.STRING(80), allowNull: false },
priority: { type: DataTypes.ENUM('low', 'normal', 'high', 'critical'), allowNull: false, defaultValue: 'normal' },
resourceType: { type: DataTypes.STRING(80), allowNull: true, field: 'resource_type' },
resourceId: { type: DataTypes.STRING(80), allowNull: true, field: 'resource_id' },
title: { type: DataTypes.STRING(255), allowNull: false },
body: { type: DataTypes.TEXT, allowNull: true },
route: { type: DataTypes.STRING(500), allowNull: true },
payload: { type: DataTypes.JSON, allowNull: true },
dedupeKey: { type: DataTypes.STRING(255), allowNull: true, unique: true, field: 'dedupe_key' },
}, {
tableName: 'notification_events',
underscored: true,
timestamps: true,
indexes: [{ fields: ['club_id', 'created_at'] }],
});
export default NotificationEvent;

View File

@@ -0,0 +1,20 @@
import { DataTypes } from 'sequelize';
import sequelize from '../database.js';
const NotificationRecipient = sequelize.define('NotificationRecipient', {
id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true },
eventId: { type: DataTypes.BIGINT, allowNull: false, field: 'event_id' },
userId: { type: DataTypes.INTEGER, allowNull: false, field: 'user_id' },
readAt: { type: DataTypes.DATE, allowNull: true, field: 'read_at' },
archivedAt: { type: DataTypes.DATE, allowNull: true, field: 'archived_at' },
}, {
tableName: 'notification_recipients',
underscored: true,
timestamps: true,
indexes: [
{ unique: true, fields: ['event_id', 'user_id'] },
{ fields: ['user_id', 'read_at', 'created_at'] },
],
});
export default NotificationRecipient;

View File

@@ -0,0 +1,18 @@
import { DataTypes } from 'sequelize';
import sequelize from '../database.js';
const TournamentSuggestion = sequelize.define('TournamentSuggestion', {
id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true },
clubId: { type: DataTypes.INTEGER, allowNull: false, field: 'club_id' },
federation: { type: DataTypes.STRING(32), allowNull: false },
title: { type: DataTypes.STRING(255), allowNull: false },
eventDate: { type: DataTypes.STRING(32), allowNull: true, field: 'event_date' },
organizer: { type: DataTypes.STRING(255), allowNull: true },
location: { type: DataTypes.STRING(255), allowNull: true },
sourceUrl: { type: DataTypes.STRING(1000), allowNull: false, field: 'source_url' },
sourceFingerprint: { type: DataTypes.STRING(128), allowNull: false, unique: true, field: 'source_fingerprint' },
status: { type: DataTypes.ENUM('new', 'reviewed', 'dismissed'), allowNull: false, defaultValue: 'new' },
lastSeenAt: { type: DataTypes.DATE, allowNull: false, field: 'last_seen_at' },
}, { tableName: 'tournament_suggestions', underscored: true, timestamps: true, indexes: [{ fields: ['club_id', 'status', 'event_date'] }] });
export default TournamentSuggestion;

View File

@@ -95,6 +95,9 @@ import ClubDistributionGroup from './ClubDistributionGroup.js';
import ClubDistributionGroupMember from './ClubDistributionGroupMember.js';
import MemberProfileChangeRequest from './MemberProfileChangeRequest.js';
import MemberEventResponse from './MemberEventResponse.js';
import NotificationEvent from './NotificationEvent.js';
import NotificationRecipient from './NotificationRecipient.js';
import TournamentSuggestion from './TournamentSuggestion.js';
// Official tournaments relations
OfficialTournament.hasMany(OfficialCompetition, { foreignKey: 'tournamentId', as: 'competitions' });
OfficialCompetition.belongsTo(OfficialTournament, { foreignKey: 'tournamentId', as: 'tournament' });
@@ -112,6 +115,15 @@ Log.belongsTo(User, { foreignKey: 'userId' });
User.belongsToMany(Club, { through: UserClub, foreignKey: 'userId' });
Club.belongsToMany(User, { through: UserClub, foreignKey: 'clubId' });
NotificationEvent.hasMany(NotificationRecipient, { foreignKey: 'eventId', as: 'recipients' });
NotificationRecipient.belongsTo(NotificationEvent, { foreignKey: 'eventId', as: 'event' });
User.hasMany(NotificationRecipient, { foreignKey: 'userId', as: 'notificationRecipients' });
NotificationRecipient.belongsTo(User, { foreignKey: 'userId', as: 'user' });
Club.hasMany(NotificationEvent, { foreignKey: 'clubId', as: 'notificationEvents' });
NotificationEvent.belongsTo(Club, { foreignKey: 'clubId', as: 'club' });
Club.hasMany(TournamentSuggestion, { foreignKey: 'clubId', as: 'tournamentSuggestions' });
TournamentSuggestion.belongsTo(Club, { foreignKey: 'clubId', as: 'club' });
DiaryDate.belongsTo(Club, { foreignKey: 'clubId' });
Club.hasMany(DiaryDate, { foreignKey: 'clubId' });
@@ -706,5 +718,8 @@ export {
ClubDistributionGroup,
MemberProfileChangeRequest,
MemberEventResponse,
NotificationEvent,
NotificationRecipient,
TournamentSuggestion,
ClubDistributionGroupMember,
};