diff --git a/backend/controllers/friendlyMatchInvitationController.js b/backend/controllers/friendlyMatchInvitationController.js new file mode 100644 index 00000000..299fdfd6 --- /dev/null +++ b/backend/controllers/friendlyMatchInvitationController.js @@ -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.' }); + } +}; diff --git a/backend/controllers/friendlyMatchSharedController.js b/backend/controllers/friendlyMatchSharedController.js new file mode 100644 index 00000000..205e4cfa --- /dev/null +++ b/backend/controllers/friendlyMatchSharedController.js @@ -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.' }); + } +}; diff --git a/backend/migrations/20260530_create_friendly_match_shared_and_invitation.sql b/backend/migrations/20260530_create_friendly_match_shared_and_invitation.sql new file mode 100644 index 00000000..cfd86912 --- /dev/null +++ b/backend/migrations/20260530_create_friendly_match_shared_and_invitation.sql @@ -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`; diff --git a/backend/models/FriendlyMatchInvitation.js b/backend/models/FriendlyMatchInvitation.js new file mode 100644 index 00000000..e2296f9f --- /dev/null +++ b/backend/models/FriendlyMatchInvitation.js @@ -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; diff --git a/backend/models/FriendlyMatchShared.js b/backend/models/FriendlyMatchShared.js new file mode 100644 index 00000000..2c98860b --- /dev/null +++ b/backend/models/FriendlyMatchShared.js @@ -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; diff --git a/backend/models/index.js b/backend/models/index.js index 72411639..4ce0deda 100644 --- a/backend/models/index.js +++ b/backend/models/index.js @@ -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, diff --git a/backend/routes/friendlyMatchInvitationRoutes.js b/backend/routes/friendlyMatchInvitationRoutes.js new file mode 100644 index 00000000..42e4c754 --- /dev/null +++ b/backend/routes/friendlyMatchInvitationRoutes.js @@ -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; diff --git a/backend/routes/friendlyMatchSharedRoutes.js b/backend/routes/friendlyMatchSharedRoutes.js new file mode 100644 index 00000000..51556334 --- /dev/null +++ b/backend/routes/friendlyMatchSharedRoutes.js @@ -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; diff --git a/backend/server.js b/backend/server.js index c7683790..ca9e040f 100644 --- a/backend/server.js +++ b/backend/server.js @@ -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); diff --git a/backend/services/emailService.js b/backend/services/emailService.js index 6da72e49..4a231cfb 100644 --- a/backend/services/emailService.js +++ b/backend/services/emailService.js @@ -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 + ? `

Nachricht:
${String(message).replace(//g, '>')}

` + : ''; + + const mailOptions = { + from: process.env.EMAIL_USER, + to: recipientList.join(','), + subject: `Freundschaftsspiel-Einladung: ${fromClubName} -> ${toClubName}`, + html: ` +
+

Neue Freundschaftsspiel-Einladung

+

Der Verein ${fromClubName} hat euren Verein zu einem Freundschaftsspiel eingeladen.

+ + ${messageHtml} +

Bitte in der App annehmen oder ablehnen:

+

+ + Zur Einladung + +

+
+ `, + }; + + await transporter.sendMail(mailOptions); +}; + +export { sendActivationEmail, sendPasswordResetEmail, sendFriendlyMatchInvitationEmail }; diff --git a/backend/services/friendlyMatchSharedService.js b/backend/services/friendlyMatchSharedService.js new file mode 100644 index 00000000..396a8877 --- /dev/null +++ b/backend/services/friendlyMatchSharedService.js @@ -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(); diff --git a/backend/services/socketService.js b/backend/services/socketService.js index 943e74e3..ee2efb0e 100644 --- a/backend/services/socketService.js +++ b/backend/services/socketService.js @@ -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 }); diff --git a/docs/friendly-match-shared-concept-plan.md b/docs/friendly-match-shared-concept-plan.md new file mode 100644 index 00000000..49758a37 --- /dev/null +++ b/docs/friendly-match-shared-concept-plan.md @@ -0,0 +1,153 @@ +# Konzeptplan: Vereinsuebergreifende Freundschaftsspiele + +## Ziel +- Ein gemeinsames Freundschaftsspiel-Objekt fuer zwei Vereine (statt zwei isolierter Eintraege). +- Auffindbarkeit bestehender Spiele ueber Name, Datum und Startzeit. +- Einladungsprozess (anbieten, annehmen, ablehnen). +- Live-Synchronisierung zwischen beiden Vereinen per Socket. + +## Produktumfang (MVP) +- [x] Vereinsuebergreifendes Shared-Match-Datenmodell einfuehren. +- [x] Match-Finder fuer Name, Datum, Startzeit implementieren. +- [x] Einladung erstellen (Zielverein aus Vereinsliste + Freitext). +- [x] Einladung im System speichern. +- [x] E-Mail-Benachrichtigung an hinterlegte Personen des Zielvereins versenden. +- [x] Einladung annehmen: Shared-Match fuer beide Vereine erstellen. +- [x] Einladung ablehnen: Einladung loeschen. +- [x] Socket-Sync fuer beide Vereine bei Aenderungen am Shared-Match. + +## Datenmodell +### Neue Tabellen +- [x] `friendly_match_shared` + - [x] `id` + - [x] `home_club_id` + - [x] `guest_club_id` + - [x] `date` + - [x] `start_time` + - [x] `match_name` (oder Home/Gast-Namenstruktur) + - [x] `location_*` Felder + - [x] `match_system`, `singles_count`, `doubles_count`, `winning_sets` + - [x] `home_participants`, `guest_participants` + - [x] `result_details`, `home_match_points`, `guest_match_points` + - [x] `is_completed` + - [x] `created_by_user_id` + - [x] `created_from_invitation_id` + - [x] `created_at`, `updated_at` +- [x] `friendly_match_invitation` + - [x] `id` + - [x] `from_club_id` + - [x] `to_club_id` + - [x] `proposed_date` + - [x] `proposed_start_time` + - [x] `proposed_match_name` + - [x] `message` (Freitext) + - [x] `status` (`pending`, `accepted`) + - [x] `created_by_user_id` + - [x] `created_at`, `updated_at` + +### Indizes und Constraints +- [x] Index auf `friendly_match_shared (home_club_id, date, start_time)`. +- [x] Index auf `friendly_match_shared (guest_club_id, date, start_time)`. +- [x] Index auf `friendly_match_invitation (to_club_id, status, proposed_date)`. +- [x] Verhindern, dass ein Verein sich selbst einlaedt (`from_club_id != to_club_id`). + +## Backend API +### Finder +- [x] `GET /api/friendly-matches/find` + - [x] Query-Parameter: `clubId`, `name`, `date`, `startTime`. + - [x] Exakt-Matches und Kandidaten mit `confidence` zurueckgeben. + +### Einladungen +- [x] `POST /api/friendly-match-invitations/:clubId` + - [x] Payload: `toClubId`, `date`, `startTime`, `matchName`, `message`. +- [x] `GET /api/friendly-match-invitations/:clubId/incoming` +- [x] `GET /api/friendly-match-invitations/:clubId/outgoing` +- [x] `POST /api/friendly-match-invitations/:clubId/:invitationId/accept` +- [x] `POST /api/friendly-match-invitations/:clubId/:invitationId/decline` + +### Shared Matches +- [x] `GET /api/friendly-matches/shared/:clubId` +- [x] `PUT /api/friendly-matches/shared/:clubId/:matchId` +- [x] `PATCH /api/friendly-matches/shared/:clubId/:matchId/players` +- [x] Optional: `DELETE /api/friendly-matches/shared/:clubId/:matchId` + +## Rechte und Zugriff +- [x] Zugriff pruefen: User muss in beteiligtem Verein freigeschaltet sein. +- [x] Schreibrechte auf Shared-Match nur mit `schedule.write`. +- [x] Einladung annehmen/ablehnen nur fuer `to_club_id`. + +## E-Mail-Versand +- [x] Empfaengerermittlung: `user_club` (approved=true) + `user.email` fuer Zielverein. +- [x] Neue Mailfunktion in `emailService` fuer Einladung. +- [x] Inhalt: einladender Verein, Matchdaten, Freitext, Aktion (annehmen/ablehnen in App). +- [x] Fehlerbehandlung: Einladung bleibt erhalten, falls Mailversand fehlschlaegt (mit Log). + +## Matching-Logik (Name/Datum/Startzeit) +- [x] Normalisierung von Namen (trim, lowercase, Sonderzeichenbereinigung). +- [x] Datum exakt matchen. +- [x] Startzeit exakt matchen (MVP). +- [x] Teamnamen in beide Richtungen pruefen (home/guest vertauscht). +- [x] `confidence`-Stufen definieren (`exact`, `high`, `medium`). + +## Socket-Sync +- [x] Neue Socket-Events definieren: + - [x] `friendly:invitation:created` + - [x] `friendly:invitation:accepted` + - [x] `friendly:invitation:declined` + - [x] `friendly:shared:match:updated` + - [x] `friendly:shared:match:deleted` +- [x] Bei Shared-Match-Aenderung an beide Club-Raeume senden (`club-`, `club-`). +- [x] Frontend und Mobile auf neue Events subscriben. + +## Frontend (Web) +- [x] Bereich "Einladungen" (Eingehend/Ausgehend) in Freundschaftsspielen. +- [ ] Dialog "Verein einladen": Zielverein aus bestehender Vereinsliste + Freitext. +- [x] Buttons fuer annehmen/ablehnen in eingehenden Einladungen. +- [ ] Finder-UI fuer Name/Datum/Startzeit. +- [x] Shared-Match-Kennzeichnung in Liste und Detaildialog. +- [x] Socket-Event-Handling fuer Echtzeitupdates beider Vereine. + +## Mobile (Android + Shared) +- [x] API-Endpoints in `MatchesApi` erweitern. +- [x] State-Manager fuer Einladungen und Shared-Matches erweitern. +- [ ] UI fuer Einladungen (eingehend/ausgehend) einbauen. +- [ ] Finder-UI fuer Name/Datum/Startzeit einbauen. +- [ ] Shared-Match-Bearbeitung fuer beide Vereine synchronisieren. +- [x] Socket-Events fuer Friendly-Shared-Flow verarbeiten. + +## Migration und Abwaertskompatibilitaet +- [ ] Bestehende `friendly_match`-Eintraege unveraendert weiter lesbar halten. +- [ ] Schrittweise Migration/Koexistenzstrategie dokumentieren. +- [ ] Optionales Mapping alter Einzel-Eintraege auf Shared-Matches definieren. + +## Tests +### Backend +- [ ] Unit-Tests fuer Match-Finder. +- [ ] Unit-Tests fuer Einladung (create/accept/decline). +- [ ] Integrations-Tests fuer Shared-Match-CRUD. +- [ ] Berechtigungstests (falscher Verein, fehlende Rechte). + +### Frontend/Mobile +- [ ] UI-Tests fuer Einladung erstellen/anzeigen/entscheiden. +- [ ] UI-Tests fuer Finder. +- [ ] Realtime-Tests mit zwei gleichzeitig eingeloggten Vereinen. + +### End-to-End +- [ ] Verein A laedt Verein B ein. +- [ ] E-Mail wird versendet. +- [ ] Verein B nimmt an, Shared-Match entsteht. +- [ ] Aenderung in Verein A erscheint sofort in Verein B und umgekehrt. +- [ ] Ablehnung loescht Einladung vollstaendig. + +## Rollout in Phasen +- [ ] Phase 1: Datenmodell + Read-APIs. +- [ ] Phase 2: Einladung + E-Mail. +- [ ] Phase 3: Accept/Decline + Shared-Match-Erzeugung. +- [ ] Phase 4: Shared-Match-Edit + Socket-Sync. +- [ ] Phase 5: Finder + UX-Polish + E2E-Hardening. + +## Offene Entscheidungen +- [ ] Mehrfache parallele Einladungen fuer gleiche Daten erlauben oder verhindern? +- [ ] Match-Name als einzelnes Feld oder Home/Gast getrennt fuehren? +- [ ] Konfliktstrategie bei gleichzeitiger Bearbeitung (Last-Write-Wins vs. Versionierung)? +- [ ] Ablehnung immer hard delete oder auditierbar speichern? diff --git a/frontend/src/services/socketService.js b/frontend/src/services/socketService.js index cbe71437..2dbfcb19 100644 --- a/frontend/src/services/socketService.js +++ b/frontend/src/services/socketService.js @@ -320,6 +320,36 @@ export const onMatchReportSubmitted = (callback) => { } }; +export const onFriendlyInvitationCreated = (callback) => { + if (socket) { + socket.on('friendly:invitation:created', callback); + } +}; + +export const onFriendlyInvitationAccepted = (callback) => { + if (socket) { + socket.on('friendly:invitation:accepted', callback); + } +}; + +export const onFriendlyInvitationDeclined = (callback) => { + if (socket) { + socket.on('friendly:invitation:declined', callback); + } +}; + +export const onFriendlySharedMatchUpdated = (callback) => { + if (socket) { + socket.on('friendly:shared:match:updated', callback); + } +}; + +export const onFriendlySharedMatchDeleted = (callback) => { + if (socket) { + socket.on('friendly:shared:match:deleted', callback); + } +}; + // Event-Listener entfernen export const offParticipantAdded = (callback) => { if (socket) { @@ -423,3 +453,33 @@ export const offMatchReportSubmitted = (callback) => { } }; +export const offFriendlyInvitationCreated = (callback) => { + if (socket) { + socket.off('friendly:invitation:created', callback); + } +}; + +export const offFriendlyInvitationAccepted = (callback) => { + if (socket) { + socket.off('friendly:invitation:accepted', callback); + } +}; + +export const offFriendlyInvitationDeclined = (callback) => { + if (socket) { + socket.off('friendly:invitation:declined', callback); + } +}; + +export const offFriendlySharedMatchUpdated = (callback) => { + if (socket) { + socket.off('friendly:shared:match:updated', callback); + } +}; + +export const offFriendlySharedMatchDeleted = (callback) => { + if (socket) { + socket.off('friendly:shared:match:deleted', callback); + } +}; + diff --git a/frontend/src/views/ScheduleView.vue b/frontend/src/views/ScheduleView.vue index d45e347e..8db991da 100644 --- a/frontend/src/views/ScheduleView.vue +++ b/frontend/src/views/ScheduleView.vue @@ -43,6 +43,43 @@ @update:active-tab="activeTab = $event" >