Implement cross-club friendly match concept with invitations and shared matches
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 49s

- Added controllers for handling friendly match invitations and shared matches.
- Created migration scripts for `friendly_match_invitation` and `friendly_match_shared` tables.
- Developed models for `FriendlyMatchInvitation` and `FriendlyMatchShared`.
- Established routes for managing invitations and shared matches.
- Implemented services for business logic related to invitations and shared matches.
- Documented the concept plan for the new feature including API endpoints and data models.
This commit is contained in:
Torsten Schulz (local)
2026-05-30 17:50:35 +02:00
parent 359527eb5b
commit 0ff67dae80
21 changed files with 1795 additions and 17 deletions

View File

@@ -0,0 +1,69 @@
import friendlyMatchSharedService from '../services/friendlyMatchSharedService.js';
import {
emitFriendlyInvitationAccepted,
emitFriendlyInvitationCreated,
emitFriendlyInvitationDeclined,
emitFriendlySharedMatchUpdated,
} from '../services/socketService.js';
function userTokenFrom(req) {
const authHeader = req.headers.authorization;
if (authHeader && authHeader.startsWith('Bearer ')) {
return authHeader.slice(7);
}
return req.headers.authcode || authHeader;
}
export const createFriendlyMatchInvitation = async (req, res) => {
try {
const invitation = await friendlyMatchSharedService.createInvitation(userTokenFrom(req), req.params.clubId, req.body);
emitFriendlyInvitationCreated(invitation.fromClubId, invitation.toClubId, invitation);
res.status(201).json(invitation);
} catch (error) {
console.error('[createFriendlyMatchInvitation] Error:', error);
res.status(error.statusCode || 500).json({ error: error.message || 'Einladung konnte nicht erstellt werden.' });
}
};
export const listIncomingFriendlyMatchInvitations = async (req, res) => {
try {
const items = await friendlyMatchSharedService.listIncomingInvitations(userTokenFrom(req), req.params.clubId);
res.status(200).json(items);
} catch (error) {
console.error('[listIncomingFriendlyMatchInvitations] Error:', error);
res.status(error.statusCode || 500).json({ error: error.message || 'Eingehende Einladungen konnten nicht geladen werden.' });
}
};
export const listOutgoingFriendlyMatchInvitations = async (req, res) => {
try {
const items = await friendlyMatchSharedService.listOutgoingInvitations(userTokenFrom(req), req.params.clubId);
res.status(200).json(items);
} catch (error) {
console.error('[listOutgoingFriendlyMatchInvitations] Error:', error);
res.status(error.statusCode || 500).json({ error: error.message || 'Ausgehende Einladungen konnten nicht geladen werden.' });
}
};
export const acceptFriendlyMatchInvitation = async (req, res) => {
try {
const result = await friendlyMatchSharedService.acceptInvitation(userTokenFrom(req), req.params.clubId, req.params.invitationId);
emitFriendlyInvitationAccepted(result.invitation.fromClubId, result.invitation.toClubId, result.invitation);
emitFriendlySharedMatchUpdated(result.sharedMatch.homeClubId, result.sharedMatch.guestClubId, result.sharedMatch);
res.status(200).json(result);
} catch (error) {
console.error('[acceptFriendlyMatchInvitation] Error:', error);
res.status(error.statusCode || 500).json({ error: error.message || 'Einladung konnte nicht angenommen werden.' });
}
};
export const declineFriendlyMatchInvitation = async (req, res) => {
try {
const invitation = await friendlyMatchSharedService.declineInvitation(userTokenFrom(req), req.params.clubId, req.params.invitationId);
emitFriendlyInvitationDeclined(invitation.fromClubId, invitation.toClubId, invitation.id);
res.status(200).json({ success: true, id: invitation.id });
} catch (error) {
console.error('[declineFriendlyMatchInvitation] Error:', error);
res.status(error.statusCode || 500).json({ error: error.message || 'Einladung konnte nicht abgelehnt werden.' });
}
};

View File

@@ -0,0 +1,86 @@
import friendlyMatchSharedService from '../services/friendlyMatchSharedService.js';
import {
emitFriendlySharedMatchDeleted,
emitFriendlySharedMatchUpdated,
} from '../services/socketService.js';
function userTokenFrom(req) {
const authHeader = req.headers.authorization;
if (authHeader && authHeader.startsWith('Bearer ')) {
return authHeader.slice(7);
}
return req.headers.authcode || authHeader;
}
export const findSharedFriendlyMatches = async (req, res) => {
try {
const { clubId, name, date, startTime } = req.query;
const matches = await friendlyMatchSharedService.findByNameDateStartTime(userTokenFrom(req), clubId, {
name,
date,
startTime,
});
res.status(200).json(matches);
} catch (error) {
console.error('[findSharedFriendlyMatches] Error:', error);
res.status(error.statusCode || 500).json({ error: error.message || 'Suche nach Freundschaftsspielen fehlgeschlagen.' });
}
};
export const listSharedFriendlyMatches = async (req, res) => {
try {
const data = await friendlyMatchSharedService.listShared(userTokenFrom(req), req.params.clubId);
res.status(200).json(data);
} catch (error) {
console.error('[listSharedFriendlyMatches] Error:', error);
res.status(error.statusCode || 500).json({ error: error.message || 'Gemeinsame Freundschaftsspiele konnten nicht geladen werden.' });
}
};
export const updateSharedFriendlyMatch = async (req, res) => {
try {
const match = await friendlyMatchSharedService.updateShared(
userTokenFrom(req),
req.params.clubId,
req.params.matchId,
req.body,
);
emitFriendlySharedMatchUpdated(match.homeClubId, match.guestClubId, match);
res.status(200).json(match);
} catch (error) {
console.error('[updateSharedFriendlyMatch] Error:', error);
res.status(error.statusCode || 500).json({ error: error.message || 'Gemeinsames Freundschaftsspiel konnte nicht gespeichert werden.' });
}
};
export const updateSharedFriendlyMatchPlayers = async (req, res) => {
try {
const match = await friendlyMatchSharedService.updateSharedPlayers(
userTokenFrom(req),
req.params.clubId,
req.params.matchId,
req.body,
);
emitFriendlySharedMatchUpdated(match.homeClubId, match.guestClubId, match);
res.status(200).json({ message: 'Teilnehmer gespeichert', data: match });
} catch (error) {
console.error('[updateSharedFriendlyMatchPlayers] Error:', error);
res.status(error.statusCode || 500).json({ error: error.message || 'Teilnehmer konnten nicht gespeichert werden.' });
}
};
export const deleteSharedFriendlyMatch = async (req, res) => {
try {
const match = await friendlyMatchSharedService.getSharedById(
userTokenFrom(req),
req.params.clubId,
req.params.matchId,
);
const result = await friendlyMatchSharedService.removeShared(userTokenFrom(req), req.params.clubId, req.params.matchId);
emitFriendlySharedMatchDeleted(match.homeClubId, match.guestClubId, Number(req.params.matchId));
res.status(200).json(result);
} catch (error) {
console.error('[deleteSharedFriendlyMatch] Error:', error);
res.status(error.statusCode || 500).json({ error: error.message || 'Gemeinsames Freundschaftsspiel konnte nicht geloescht werden.' });
}
};

View File

@@ -0,0 +1,84 @@
-- Manual migration for cross-club friendly match concept
-- Created: 2026-05-30
-- 1) Invitation table
CREATE TABLE IF NOT EXISTS `friendly_match_invitation` (
`id` INT NOT NULL AUTO_INCREMENT,
`from_club_id` INT NOT NULL,
`to_club_id` INT NOT NULL,
`proposed_date` DATE NOT NULL,
`proposed_start_time` TIME NULL,
`proposed_match_name` VARCHAR(255) NOT NULL,
`message` TEXT NULL,
`status` VARCHAR(32) NOT NULL DEFAULT 'pending',
`created_by_user_id` INT NULL,
`accepted_by_user_id` INT NULL,
`accepted_at` DATETIME NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
CONSTRAINT `chk_friendly_match_invitation_clubs_different`
CHECK (`from_club_id` <> `to_club_id`),
CONSTRAINT `fk_friendly_match_invitation_from_club`
FOREIGN KEY (`from_club_id`) REFERENCES `clubs` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_friendly_match_invitation_to_club`
FOREIGN KEY (`to_club_id`) REFERENCES `clubs` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_friendly_match_invitation_created_by`
FOREIGN KEY (`created_by_user_id`) REFERENCES `user` (`id`) ON DELETE SET NULL,
CONSTRAINT `fk_friendly_match_invitation_accepted_by`
FOREIGN KEY (`accepted_by_user_id`) REFERENCES `user` (`id`) ON DELETE SET NULL,
KEY `idx_friendly_match_invitation_to_status_date` (`to_club_id`, `status`, `proposed_date`),
KEY `idx_friendly_match_invitation_from_status_date` (`from_club_id`, `status`, `proposed_date`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 2) Shared match table
CREATE TABLE IF NOT EXISTS `friendly_match_shared` (
`id` INT NOT NULL AUTO_INCREMENT,
`home_club_id` INT NOT NULL,
`guest_club_id` INT NOT NULL,
`date` DATE NOT NULL,
`start_time` TIME NULL,
`match_name` VARCHAR(255) NULL,
`home_team_name` VARCHAR(255) NOT NULL,
`guest_team_name` VARCHAR(255) NOT NULL,
`location_name` VARCHAR(255) NULL,
`location_address` VARCHAR(255) NULL,
`location_city` VARCHAR(255) NULL,
`location_zip` VARCHAR(32) NULL,
`match_system` VARCHAR(120) NOT NULL DEFAULT 'Braunschweiger System',
`singles_count` INT NOT NULL DEFAULT 12,
`doubles_count` INT NOT NULL DEFAULT 4,
`winning_sets` INT NOT NULL DEFAULT 3,
`home_match_points` INT NOT NULL DEFAULT 0,
`guest_match_points` INT NOT NULL DEFAULT 0,
`is_completed` TINYINT(1) NOT NULL DEFAULT 0,
`home_participants` JSON NULL,
`guest_participants` JSON NULL,
`result_details` JSON NULL,
`players_ready` JSON NULL,
`players_planned` JSON NULL,
`players_played` JSON NULL,
`status` VARCHAR(32) NOT NULL DEFAULT 'active',
`created_by_user_id` INT NULL,
`created_from_invitation_id` INT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
CONSTRAINT `chk_friendly_match_shared_clubs_different`
CHECK (`home_club_id` <> `guest_club_id`),
CONSTRAINT `fk_friendly_match_shared_home_club`
FOREIGN KEY (`home_club_id`) REFERENCES `clubs` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_friendly_match_shared_guest_club`
FOREIGN KEY (`guest_club_id`) REFERENCES `clubs` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_friendly_match_shared_created_by`
FOREIGN KEY (`created_by_user_id`) REFERENCES `user` (`id`) ON DELETE SET NULL,
CONSTRAINT `fk_friendly_match_shared_from_invitation`
FOREIGN KEY (`created_from_invitation_id`) REFERENCES `friendly_match_invitation` (`id`) ON DELETE SET NULL,
KEY `idx_friendly_match_shared_home_date_time` (`home_club_id`, `date`, `start_time`),
KEY `idx_friendly_match_shared_guest_date_time` (`guest_club_id`, `date`, `start_time`),
KEY `idx_friendly_match_shared_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Optional rollback statements (manual use):
-- DROP TABLE IF EXISTS `friendly_match_shared`;
-- DROP TABLE IF EXISTS `friendly_match_invitation`;

View File

@@ -0,0 +1,96 @@
import { DataTypes } from 'sequelize';
import sequelize from '../database.js';
import Club from './Club.js';
import User from './User.js';
const FriendlyMatchInvitation = sequelize.define('FriendlyMatchInvitation', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true,
allowNull: false,
},
fromClubId: {
type: DataTypes.INTEGER,
allowNull: false,
references: {
model: Club,
key: 'id',
},
onDelete: 'CASCADE',
field: 'from_club_id',
},
toClubId: {
type: DataTypes.INTEGER,
allowNull: false,
references: {
model: Club,
key: 'id',
},
onDelete: 'CASCADE',
field: 'to_club_id',
},
proposedDate: {
type: DataTypes.DATEONLY,
allowNull: false,
field: 'proposed_date',
},
proposedStartTime: {
type: DataTypes.TIME,
allowNull: true,
field: 'proposed_start_time',
},
proposedMatchName: {
type: DataTypes.STRING(255),
allowNull: false,
field: 'proposed_match_name',
},
message: {
type: DataTypes.TEXT,
allowNull: true,
},
status: {
type: DataTypes.STRING(32),
allowNull: false,
defaultValue: 'pending',
},
createdByUserId: {
type: DataTypes.INTEGER,
allowNull: true,
references: {
model: User,
key: 'id',
},
onDelete: 'SET NULL',
field: 'created_by_user_id',
},
acceptedByUserId: {
type: DataTypes.INTEGER,
allowNull: true,
references: {
model: User,
key: 'id',
},
onDelete: 'SET NULL',
field: 'accepted_by_user_id',
},
acceptedAt: {
type: DataTypes.DATE,
allowNull: true,
field: 'accepted_at',
},
}, {
tableName: 'friendly_match_invitation',
underscored: true,
timestamps: true,
indexes: [
{
fields: ['to_club_id', 'status', 'proposed_date'],
},
{
fields: ['from_club_id', 'status', 'proposed_date'],
},
],
});
export default FriendlyMatchInvitation;

View File

@@ -0,0 +1,186 @@
import { DataTypes } from 'sequelize';
import sequelize from '../database.js';
import Club from './Club.js';
import User from './User.js';
const FriendlyMatchShared = sequelize.define('FriendlyMatchShared', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true,
allowNull: false,
},
homeClubId: {
type: DataTypes.INTEGER,
allowNull: false,
references: {
model: Club,
key: 'id',
},
onDelete: 'CASCADE',
field: 'home_club_id',
},
guestClubId: {
type: DataTypes.INTEGER,
allowNull: false,
references: {
model: Club,
key: 'id',
},
onDelete: 'CASCADE',
field: 'guest_club_id',
},
date: {
type: DataTypes.DATEONLY,
allowNull: false,
},
startTime: {
type: DataTypes.TIME,
allowNull: true,
field: 'start_time',
},
matchName: {
type: DataTypes.STRING(255),
allowNull: true,
field: 'match_name',
},
homeTeamName: {
type: DataTypes.STRING(255),
allowNull: false,
field: 'home_team_name',
},
guestTeamName: {
type: DataTypes.STRING(255),
allowNull: false,
field: 'guest_team_name',
},
locationName: {
type: DataTypes.STRING(255),
allowNull: true,
field: 'location_name',
},
locationAddress: {
type: DataTypes.STRING(255),
allowNull: true,
field: 'location_address',
},
locationCity: {
type: DataTypes.STRING(255),
allowNull: true,
field: 'location_city',
},
locationZip: {
type: DataTypes.STRING(32),
allowNull: true,
field: 'location_zip',
},
matchSystem: {
type: DataTypes.STRING(120),
allowNull: false,
defaultValue: 'Braunschweiger System',
field: 'match_system',
},
singlesCount: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 12,
field: 'singles_count',
},
doublesCount: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 4,
field: 'doubles_count',
},
winningSets: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 3,
field: 'winning_sets',
},
homeMatchPoints: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0,
field: 'home_match_points',
},
guestMatchPoints: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0,
field: 'guest_match_points',
},
isCompleted: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
field: 'is_completed',
},
homeParticipants: {
type: DataTypes.JSON,
allowNull: true,
field: 'home_participants',
},
guestParticipants: {
type: DataTypes.JSON,
allowNull: true,
field: 'guest_participants',
},
resultDetails: {
type: DataTypes.JSON,
allowNull: true,
field: 'result_details',
},
playersReady: {
type: DataTypes.JSON,
allowNull: true,
field: 'players_ready',
},
playersPlanned: {
type: DataTypes.JSON,
allowNull: true,
field: 'players_planned',
},
playersPlayed: {
type: DataTypes.JSON,
allowNull: true,
field: 'players_played',
},
status: {
type: DataTypes.STRING(32),
allowNull: false,
defaultValue: 'active',
},
createdByUserId: {
type: DataTypes.INTEGER,
allowNull: true,
references: {
model: User,
key: 'id',
},
onDelete: 'SET NULL',
field: 'created_by_user_id',
},
createdFromInvitationId: {
type: DataTypes.INTEGER,
allowNull: true,
field: 'created_from_invitation_id',
},
}, {
tableName: 'friendly_match_shared',
underscored: true,
timestamps: true,
indexes: [
{
fields: ['home_club_id', 'date', 'start_time'],
},
{
fields: ['guest_club_id', 'date', 'start_time'],
},
{
fields: ['status'],
},
],
});
export default FriendlyMatchShared;

View File

@@ -58,6 +58,8 @@ import BillingDocument from './BillingDocument.js';
import BillingDocumentValue from './BillingDocumentValue.js';
import BillingUserSetting from './BillingUserSetting.js';
import FriendlyMatch from './FriendlyMatch.js';
import FriendlyMatchShared from './FriendlyMatchShared.js';
import FriendlyMatchInvitation from './FriendlyMatchInvitation.js';
import MemberTtrHistory from './MemberTtrHistory.js';
import MemberPlayInterest from './MemberPlayInterest.js';
import ClickTtAccount from './ClickTtAccount.js';
@@ -410,6 +412,37 @@ ClubDisabledPresetGroup.belongsTo(Club, { foreignKey: 'clubId', as: 'club' });
TrainingGroup.hasMany(TrainingTime, { foreignKey: 'trainingGroupId', as: 'trainingTimes' });
TrainingTime.belongsTo(TrainingGroup, { foreignKey: 'trainingGroupId', as: 'trainingGroup' });
// Friendly shared matches
FriendlyMatchShared.belongsTo(Club, { foreignKey: 'homeClubId', as: 'homeClub' });
FriendlyMatchShared.belongsTo(Club, { foreignKey: 'guestClubId', as: 'guestClub' });
Club.hasMany(FriendlyMatchShared, { foreignKey: 'homeClubId', as: 'homeSharedFriendlyMatches' });
Club.hasMany(FriendlyMatchShared, { foreignKey: 'guestClubId', as: 'guestSharedFriendlyMatches' });
FriendlyMatchShared.belongsTo(User, { foreignKey: 'createdByUserId', as: 'createdByUser' });
User.hasMany(FriendlyMatchShared, { foreignKey: 'createdByUserId', as: 'createdSharedFriendlyMatches' });
// Friendly invitations
FriendlyMatchInvitation.belongsTo(Club, { foreignKey: 'fromClubId', as: 'fromClub' });
FriendlyMatchInvitation.belongsTo(Club, { foreignKey: 'toClubId', as: 'toClub' });
Club.hasMany(FriendlyMatchInvitation, { foreignKey: 'fromClubId', as: 'sentFriendlyMatchInvitations' });
Club.hasMany(FriendlyMatchInvitation, { foreignKey: 'toClubId', as: 'receivedFriendlyMatchInvitations' });
FriendlyMatchInvitation.belongsTo(User, { foreignKey: 'createdByUserId', as: 'createdByUser' });
FriendlyMatchInvitation.belongsTo(User, { foreignKey: 'acceptedByUserId', as: 'acceptedByUser' });
User.hasMany(FriendlyMatchInvitation, { foreignKey: 'createdByUserId', as: 'createdFriendlyMatchInvitations' });
User.hasMany(FriendlyMatchInvitation, { foreignKey: 'acceptedByUserId', as: 'acceptedFriendlyMatchInvitations' });
FriendlyMatchShared.belongsTo(FriendlyMatchInvitation, {
foreignKey: 'createdFromInvitationId',
as: 'sourceInvitation',
constraints: false,
});
FriendlyMatchInvitation.hasOne(FriendlyMatchShared, {
foreignKey: 'createdFromInvitationId',
as: 'createdSharedMatch',
constraints: false,
});
export {
User,
Log,
@@ -468,6 +501,8 @@ export {
BillingDocumentValue,
BillingUserSetting,
FriendlyMatch,
FriendlyMatchShared,
FriendlyMatchInvitation,
MemberTtrHistory,
MemberPlayInterest,
ClickTtAccount,

View File

@@ -0,0 +1,20 @@
import express from 'express';
import {
acceptFriendlyMatchInvitation,
createFriendlyMatchInvitation,
declineFriendlyMatchInvitation,
listIncomingFriendlyMatchInvitations,
listOutgoingFriendlyMatchInvitations,
} from '../controllers/friendlyMatchInvitationController.js';
import { authenticate } from '../middleware/authMiddleware.js';
import { authorize } from '../middleware/authorizationMiddleware.js';
const router = express.Router();
router.post('/:clubId', authenticate, authorize('schedule', 'write'), createFriendlyMatchInvitation);
router.get('/:clubId/incoming', authenticate, authorize('schedule', 'read'), listIncomingFriendlyMatchInvitations);
router.get('/:clubId/outgoing', authenticate, authorize('schedule', 'read'), listOutgoingFriendlyMatchInvitations);
router.post('/:clubId/:invitationId/accept', authenticate, authorize('schedule', 'write'), acceptFriendlyMatchInvitation);
router.post('/:clubId/:invitationId/decline', authenticate, authorize('schedule', 'write'), declineFriendlyMatchInvitation);
export default router;

View File

@@ -0,0 +1,20 @@
import express from 'express';
import {
deleteSharedFriendlyMatch,
findSharedFriendlyMatches,
listSharedFriendlyMatches,
updateSharedFriendlyMatch,
updateSharedFriendlyMatchPlayers,
} from '../controllers/friendlyMatchSharedController.js';
import { authenticate } from '../middleware/authMiddleware.js';
import { authorize } from '../middleware/authorizationMiddleware.js';
const router = express.Router();
router.get('/find', authenticate, authorize('schedule', 'read'), findSharedFriendlyMatches);
router.get('/shared/:clubId', authenticate, authorize('schedule', 'read'), listSharedFriendlyMatches);
router.put('/shared/:clubId/:matchId', authenticate, authorize('schedule', 'write'), updateSharedFriendlyMatch);
router.patch('/shared/:clubId/:matchId/players', authenticate, authorize('schedule', 'write'), updateSharedFriendlyMatchPlayers);
router.delete('/shared/:clubId/:matchId', authenticate, authorize('schedule', 'write'), deleteSharedFriendlyMatch);
export default router;

View File

@@ -15,6 +15,7 @@ import {
GroupActivity, Tournament, TournamentGroup, TournamentMatch, TournamentResult,
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
} from './models/index.js';
import authRoutes from './routes/authRoutes.js';
@@ -61,6 +62,8 @@ import memberOrderRoutes from './routes/memberOrderRoutes.js';
import memberGroupPhotoRoutes from './routes/memberGroupPhotoRoutes.js';
import billingRoutes from './routes/billingRoutes.js';
import friendlyMatchRoutes from './routes/friendlyMatchRoutes.js';
import friendlyMatchSharedRoutes from './routes/friendlyMatchSharedRoutes.js';
import friendlyMatchInvitationRoutes from './routes/friendlyMatchInvitationRoutes.js';
import calendarRoutes from './routes/calendarRoutes.js';
import calendarEventRoutes from './routes/calendarEventRoutes.js';
import schedulerService from './services/schedulerService.js';
@@ -356,7 +359,9 @@ app.use('/api/training-cancellations', trainingCancellationRoutes);
app.use('/api/member-orders', memberOrderRoutes);
app.use('/api/member-group-photos', memberGroupPhotoRoutes);
app.use('/api/billing', billingRoutes);
app.use('/api/friendly-matches', friendlyMatchSharedRoutes);
app.use('/api/friendly-matches', friendlyMatchRoutes);
app.use('/api/friendly-match-invitations', friendlyMatchInvitationRoutes);
app.use('/api/calendar', calendarRoutes);
app.use('/api/calendar-events', calendarEventRoutes);
@@ -613,6 +618,8 @@ app.use((err, req, res, next) => {
await safeSync(BillingDocumentValue);
await safeSync(BillingUserSetting);
await safeSync(FriendlyMatch);
await safeSync(FriendlyMatchInvitation);
await safeSync(FriendlyMatchShared);
await safeSync(ClubTeam);
await safeSync(TrainingCancellation);
await safeSync(CalendarEvent);

View File

@@ -50,4 +50,49 @@ const sendPasswordResetEmail = async (email, resetToken) => {
await transporter.sendMail(mailOptions);
};
export { sendActivationEmail, sendPasswordResetEmail };
const sendFriendlyMatchInvitationEmail = async ({
toEmails,
fromClubName,
toClubName,
proposedDate,
proposedStartTime,
proposedMatchName,
message,
}) => {
const recipientList = Array.isArray(toEmails) ? toEmails.filter(Boolean) : [toEmails].filter(Boolean);
if (!recipientList.length) return;
const appUrl = process.env.BASE_URL || process.env.PUBLIC_SITE_URL || 'https://tt-tagebuch.de';
const timeLabel = proposedStartTime ? ` um ${proposedStartTime}` : '';
const messageHtml = message
? `<p style="margin-top: 12px;"><strong>Nachricht:</strong><br>${String(message).replace(/</g, '&lt;').replace(/>/g, '&gt;')}</p>`
: '';
const mailOptions = {
from: process.env.EMAIL_USER,
to: recipientList.join(','),
subject: `Freundschaftsspiel-Einladung: ${fromClubName} -> ${toClubName}`,
html: `
<div style="font-family: Arial, sans-serif; max-width: 700px; margin: 0 auto;">
<h2 style="color: #1f2937;">Neue Freundschaftsspiel-Einladung</h2>
<p>Der Verein <strong>${fromClubName}</strong> hat euren Verein zu einem Freundschaftsspiel eingeladen.</p>
<ul>
<li><strong>Match:</strong> ${proposedMatchName}</li>
<li><strong>Datum:</strong> ${proposedDate}${timeLabel}</li>
</ul>
${messageHtml}
<p style="margin-top: 16px;">Bitte in der App annehmen oder ablehnen:</p>
<p>
<a href="${appUrl}/friendly-matches"
style="background-color:#2563eb;color:#fff;padding:10px 16px;text-decoration:none;border-radius:6px;display:inline-block;">
Zur Einladung
</a>
</p>
</div>
`,
};
await transporter.sendMail(mailOptions);
};
export { sendActivationEmail, sendPasswordResetEmail, sendFriendlyMatchInvitationEmail };

View File

@@ -0,0 +1,429 @@
import { Op } from 'sequelize';
import FriendlyMatchShared from '../models/FriendlyMatchShared.js';
import FriendlyMatchInvitation from '../models/FriendlyMatchInvitation.js';
import UserClub from '../models/UserClub.js';
import User from '../models/User.js';
import Club from '../models/Club.js';
import HttpError from '../exceptions/HttpError.js';
import { checkAccess, getUserByToken } from '../utils/userUtils.js';
import { sendFriendlyMatchInvitationEmail } from './emailService.js';
function cleanString(value, fallback = '') {
const text = String(value ?? '').trim();
return text || fallback;
}
function cleanOptionalString(value) {
const text = String(value ?? '').trim();
return text || null;
}
function normalizeArrayValue(value) {
if (Array.isArray(value)) return value;
if (typeof value === 'string') {
try {
const parsed = JSON.parse(value);
return Array.isArray(parsed) ? parsed : [];
} catch (error) {
return [];
}
}
return [];
}
function normalizeIdList(list) {
if (typeof list === 'string') {
try {
list = JSON.parse(list);
} catch (error) {
return null;
}
}
if (!Array.isArray(list)) return null;
const seen = new Set();
const result = [];
for (const value of list) {
const id = Number.parseInt(value, 10);
if (!Number.isInteger(id) || seen.has(id)) continue;
seen.add(id);
result.push(id);
}
return result;
}
function normalizeTextForSearch(value) {
return String(value ?? '')
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-zA-Z0-9\s]/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.toLowerCase();
}
function isClubInvolved(clubId, match) {
const id = Number.parseInt(clubId, 10);
return Number(match.homeClubId) === id || Number(match.guestClubId) === id;
}
function toSharedScheduleRow(match) {
return {
id: match.id,
friendlyMatchId: match.id,
isFriendly: true,
isSharedFriendly: true,
date: match.date,
time: match.startTime,
homeClubId: match.homeClubId,
guestClubId: match.guestClubId,
homeTeam: { name: match.homeTeamName },
guestTeam: { name: match.guestTeamName },
location: {
name: match.locationName || 'N/A',
address: match.locationAddress || '',
city: match.locationCity || '',
zip: match.locationZip || '',
},
leagueDetails: { name: 'Freundschaftsspiel (Vereinsuebergreifend)' },
homeMatchPoints: match.homeMatchPoints || 0,
guestMatchPoints: match.guestMatchPoints || 0,
isCompleted: match.isCompleted || false,
matchSystem: match.matchSystem,
singlesCount: match.singlesCount,
doublesCount: match.doublesCount,
winningSets: match.winningSets,
homeParticipants: normalizeArrayValue(match.homeParticipants),
guestParticipants: normalizeArrayValue(match.guestParticipants),
resultDetails: normalizeArrayValue(match.resultDetails),
playersReady: normalizeArrayValue(match.playersReady),
playersPlanned: normalizeArrayValue(match.playersPlanned),
playersPlayed: normalizeArrayValue(match.playersPlayed),
status: match.status,
matchName: match.matchName,
createdFromInvitationId: match.createdFromInvitationId,
};
}
function toInvitationDto(invitation) {
return {
id: invitation.id,
fromClubId: invitation.fromClubId,
toClubId: invitation.toClubId,
proposedDate: invitation.proposedDate,
proposedStartTime: invitation.proposedStartTime,
proposedMatchName: invitation.proposedMatchName,
message: invitation.message,
status: invitation.status,
createdByUserId: invitation.createdByUserId,
acceptedByUserId: invitation.acceptedByUserId,
acceptedAt: invitation.acceptedAt,
createdAt: invitation.createdAt,
updatedAt: invitation.updatedAt,
};
}
class FriendlyMatchSharedService {
async findByNameDateStartTime(userToken, clubId, query = {}) {
await checkAccess(userToken, clubId);
const nameNorm = normalizeTextForSearch(query.name);
const date = cleanOptionalString(query.date);
const startTime = cleanOptionalString(query.startTime);
const where = {
[Op.or]: [{ homeClubId: clubId }, { guestClubId: clubId }],
};
if (date) where.date = date;
if (startTime) where.startTime = startTime;
const matches = await FriendlyMatchShared.findAll({
where,
order: [['date', 'ASC'], ['startTime', 'ASC'], ['id', 'ASC']],
});
const out = matches
.map((match) => {
const row = toSharedScheduleRow(match);
const combined = normalizeTextForSearch([
row.matchName,
row.homeTeam?.name,
row.guestTeam?.name,
`${row.homeTeam?.name || ''} ${row.guestTeam?.name || ''}`,
`${row.guestTeam?.name || ''} ${row.homeTeam?.name || ''}`,
].join(' '));
let confidence = 'medium';
if (nameNorm && combined === nameNorm && date && startTime) confidence = 'exact';
else if (nameNorm && combined.includes(nameNorm) && date && startTime) confidence = 'high';
else if (nameNorm && !combined.includes(nameNorm)) return null;
return {
...row,
confidence,
matchedBy: {
name: Boolean(nameNorm),
date: Boolean(date),
startTime: Boolean(startTime),
},
};
})
.filter(Boolean);
return out;
}
async listShared(userToken, clubId) {
await checkAccess(userToken, clubId);
const matches = await FriendlyMatchShared.findAll({
where: {
[Op.or]: [{ homeClubId: clubId }, { guestClubId: clubId }],
},
order: [['date', 'ASC'], ['startTime', 'ASC'], ['id', 'ASC']],
});
return matches.map(toSharedScheduleRow);
}
async getSharedById(userToken, clubId, matchId) {
await checkAccess(userToken, clubId);
const match = await FriendlyMatchShared.findByPk(matchId);
if (!match || !isClubInvolved(clubId, match)) {
throw new HttpError('Gemeinsames Freundschaftsspiel nicht gefunden.', 404);
}
return toSharedScheduleRow(match);
}
async updateShared(userToken, clubId, matchId, payload = {}) {
await checkAccess(userToken, clubId);
const match = await FriendlyMatchShared.findByPk(matchId);
if (!match || !isClubInvolved(clubId, match)) {
throw new HttpError('Gemeinsames Freundschaftsspiel nicht gefunden.', 404);
}
const updates = {};
for (const field of ['date', 'startTime', 'matchName', 'homeTeamName', 'guestTeamName', 'locationName', 'locationAddress', 'locationCity', 'locationZip', 'matchSystem', 'status']) {
if (Object.prototype.hasOwnProperty.call(payload, field)) {
updates[field] = ['date', 'homeTeamName', 'guestTeamName', 'matchSystem', 'status'].includes(field)
? cleanString(payload[field])
: cleanOptionalString(payload[field]);
}
}
for (const field of ['singlesCount', 'doublesCount', 'winningSets', 'homeMatchPoints', 'guestMatchPoints']) {
if (Object.prototype.hasOwnProperty.call(payload, field)) {
updates[field] = Number.parseInt(payload[field], 10) || 0;
}
}
if (Object.prototype.hasOwnProperty.call(payload, 'isCompleted')) {
updates.isCompleted = Boolean(payload.isCompleted);
}
if (Object.prototype.hasOwnProperty.call(payload, 'homeParticipants')) {
updates.homeParticipants = Array.isArray(payload.homeParticipants) ? payload.homeParticipants : [];
}
if (Object.prototype.hasOwnProperty.call(payload, 'guestParticipants')) {
updates.guestParticipants = Array.isArray(payload.guestParticipants) ? payload.guestParticipants : [];
}
if (Object.prototype.hasOwnProperty.call(payload, 'resultDetails')) {
updates.resultDetails = Array.isArray(payload.resultDetails) ? payload.resultDetails : [];
}
await match.update(updates);
return toSharedScheduleRow(match);
}
async updateSharedPlayers(userToken, clubId, matchId, payload = {}) {
await checkAccess(userToken, clubId);
const match = await FriendlyMatchShared.findByPk(matchId);
if (!match || !isClubInvolved(clubId, match)) {
throw new HttpError('Gemeinsames Freundschaftsspiel nicht gefunden.', 404);
}
const ready = normalizeIdList(payload.playersReady);
const planned = normalizeIdList(payload.playersPlanned);
const played = normalizeIdList(payload.playersPlayed);
await match.update({
playersReady: ready ?? (match.playersReady || []),
playersPlanned: planned ?? (match.playersPlanned || []),
playersPlayed: played ?? (match.playersPlayed || []),
});
return toSharedScheduleRow(match);
}
async removeShared(userToken, clubId, matchId) {
await checkAccess(userToken, clubId);
const match = await FriendlyMatchShared.findByPk(matchId);
if (!match || !isClubInvolved(clubId, match)) {
throw new HttpError('Gemeinsames Freundschaftsspiel nicht gefunden.', 404);
}
await match.destroy();
return { success: true, id: Number(matchId) };
}
async createInvitation(userToken, fromClubId, payload = {}) {
await checkAccess(userToken, fromClubId);
const toClubId = Number.parseInt(payload.toClubId, 10);
if (!Number.isInteger(toClubId)) {
throw new HttpError('Zielverein fehlt oder ist ungueltig.', 400);
}
if (Number(toClubId) === Number(fromClubId)) {
throw new HttpError('Ein Verein kann sich nicht selbst einladen.', 400);
}
const proposedDate = cleanString(payload.date);
const proposedMatchName = cleanString(payload.matchName);
if (!proposedDate || !proposedMatchName) {
throw new HttpError('Datum und Matchname sind Pflichtfelder.', 400);
}
const user = await getUserByToken(userToken);
const invitation = await FriendlyMatchInvitation.create({
fromClubId,
toClubId,
proposedDate,
proposedStartTime: cleanOptionalString(payload.startTime),
proposedMatchName,
message: cleanOptionalString(payload.message),
status: 'pending',
createdByUserId: user?.id || null,
});
this._sendInvitationEmails(invitation).catch((error) => {
console.error('[friendly-match-invitation] email send failed:', error?.message || error);
});
return toInvitationDto(invitation);
}
async listIncomingInvitations(userToken, clubId) {
await checkAccess(userToken, clubId);
const items = await FriendlyMatchInvitation.findAll({
where: { toClubId: clubId },
order: [['createdAt', 'DESC']],
});
return items.map(toInvitationDto);
}
async listOutgoingInvitations(userToken, clubId) {
await checkAccess(userToken, clubId);
const items = await FriendlyMatchInvitation.findAll({
where: { fromClubId: clubId },
order: [['createdAt', 'DESC']],
});
return items.map(toInvitationDto);
}
async acceptInvitation(userToken, clubId, invitationId) {
await checkAccess(userToken, clubId);
const invitation = await FriendlyMatchInvitation.findOne({
where: { id: invitationId, toClubId: clubId, status: 'pending' },
});
if (!invitation) {
throw new HttpError('Einladung nicht gefunden.', 404);
}
const user = await getUserByToken(userToken);
const fromClub = await Club.findByPk(invitation.fromClubId, { attributes: ['id', 'name'] });
const toClub = await Club.findByPk(invitation.toClubId, { attributes: ['id', 'name'] });
const shared = await FriendlyMatchShared.create({
homeClubId: invitation.fromClubId,
guestClubId: invitation.toClubId,
date: invitation.proposedDate,
startTime: invitation.proposedStartTime,
matchName: invitation.proposedMatchName,
homeTeamName: fromClub?.name || 'Heim',
guestTeamName: toClub?.name || 'Gast',
matchSystem: 'Braunschweiger System',
singlesCount: 12,
doublesCount: 4,
winningSets: 3,
homeMatchPoints: 0,
guestMatchPoints: 0,
isCompleted: false,
homeParticipants: [],
guestParticipants: [],
resultDetails: [],
playersReady: [],
playersPlanned: [],
playersPlayed: [],
status: 'active',
createdByUserId: user?.id || null,
createdFromInvitationId: invitation.id,
});
await invitation.update({
status: 'accepted',
acceptedByUserId: user?.id || null,
acceptedAt: new Date(),
});
return {
invitation: toInvitationDto(invitation),
sharedMatch: toSharedScheduleRow(shared),
};
}
async declineInvitation(userToken, clubId, invitationId) {
await checkAccess(userToken, clubId);
const invitation = await FriendlyMatchInvitation.findOne({
where: { id: invitationId, toClubId: clubId, status: 'pending' },
});
if (!invitation) {
throw new HttpError('Einladung nicht gefunden.', 404);
}
const dto = toInvitationDto(invitation);
await invitation.destroy();
return dto;
}
async _sendInvitationEmails(invitation) {
const [fromClub, toClub] = await Promise.all([
Club.findByPk(invitation.fromClubId, { attributes: ['id', 'name'] }),
Club.findByPk(invitation.toClubId, { attributes: ['id', 'name'] }),
]);
if (!toClub) return;
const recipients = await UserClub.findAll({
where: {
clubId: invitation.toClubId,
approved: true,
},
include: [
{
model: User,
as: 'user',
attributes: ['id', 'email'],
},
],
});
const targetEmails = recipients
.map((row) => row?.user?.email)
.filter((email) => typeof email === 'string' && email.trim().length > 3);
if (!targetEmails.length) return;
await sendFriendlyMatchInvitationEmail({
toEmails: targetEmails,
fromClubName: fromClub?.name || `Verein ${invitation.fromClubId}`,
toClubName: toClub?.name || `Verein ${invitation.toClubId}`,
proposedDate: invitation.proposedDate,
proposedStartTime: invitation.proposedStartTime,
proposedMatchName: invitation.proposedMatchName,
message: invitation.message,
});
}
}
export default new FriendlyMatchSharedService();

View File

@@ -230,6 +230,43 @@ export const emitScheduleMatchUpdated = (clubId, matchId, match = null) => {
emitToClub(clubId, 'schedule:match:updated', { clubId, matchId, match });
};
export const emitFriendlyInvitationCreated = (fromClubId, toClubId, invitation) => {
emitToClub(fromClubId, 'friendly:invitation:created', { invitation });
if (String(fromClubId) !== String(toClubId)) {
emitToClub(toClubId, 'friendly:invitation:created', { invitation });
}
};
export const emitFriendlyInvitationAccepted = (fromClubId, toClubId, invitation) => {
emitToClub(fromClubId, 'friendly:invitation:accepted', { invitation });
if (String(fromClubId) !== String(toClubId)) {
emitToClub(toClubId, 'friendly:invitation:accepted', { invitation });
}
};
export const emitFriendlyInvitationDeclined = (fromClubId, toClubId, invitationId) => {
const payload = { invitationId };
emitToClub(fromClubId, 'friendly:invitation:declined', payload);
if (String(fromClubId) !== String(toClubId)) {
emitToClub(toClubId, 'friendly:invitation:declined', payload);
}
};
export const emitFriendlySharedMatchUpdated = (homeClubId, guestClubId, match) => {
emitToClub(homeClubId, 'friendly:shared:match:updated', { match });
if (String(homeClubId) !== String(guestClubId)) {
emitToClub(guestClubId, 'friendly:shared:match:updated', { match });
}
};
export const emitFriendlySharedMatchDeleted = (homeClubId, guestClubId, matchId) => {
const payload = { matchId };
emitToClub(homeClubId, 'friendly:shared:match:deleted', payload);
if (String(homeClubId) !== String(guestClubId)) {
emitToClub(guestClubId, 'friendly:shared:match:deleted', payload);
}
};
// Event wenn Spielbericht (nuscore) abgesendet wurde matchData = vollständiges Objekt für andere Clients
export const emitMatchReportSubmitted = (clubId, matchCode, matchData = null) => {
emitToClub(clubId, 'schedule:match-report:submitted', { clubId, matchCode, matchData });