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.
+
+ - Match: ${proposedMatchName}
+ - Datum: ${proposedDate}${timeLabel}
+
+ ${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"
>
+
+
+
+
+
Eingehend ({{ incomingFriendlyInvitations.length }})
+
+
Keine eingehenden Einladungen.
+
+
+
Ausgehend ({{ outgoingFriendlyInvitations.length }})
+
+
Keine ausgehenden Einladungen.
+
+
+
+
+
+
+
Number(club.id) !== Number(this.currentClub));
+ },
},
watch: {
currentClub: {
@@ -613,13 +703,24 @@ export default {
handler(newVal) {
offScheduleMatchUpdated(this.handleScheduleMatchUpdated);
offMatchReportSubmitted(this.handleMatchReportSubmitted);
+ offFriendlyInvitationCreated(this.handleFriendlyInvitationRealtime);
+ offFriendlyInvitationAccepted(this.handleFriendlyInvitationRealtime);
+ offFriendlyInvitationDeclined(this.handleFriendlyInvitationRealtime);
+ offFriendlySharedMatchUpdated(this.handleFriendlySharedMatchUpdatedRealtime);
+ offFriendlySharedMatchDeleted(this.handleFriendlySharedMatchDeletedRealtime);
disconnectSocket();
if (newVal) {
connectSocket(newVal);
onScheduleMatchUpdated(this.handleScheduleMatchUpdated);
onMatchReportSubmitted(this.handleMatchReportSubmitted);
+ onFriendlyInvitationCreated(this.handleFriendlyInvitationRealtime);
+ onFriendlyInvitationAccepted(this.handleFriendlyInvitationRealtime);
+ onFriendlyInvitationDeclined(this.handleFriendlyInvitationRealtime);
+ onFriendlySharedMatchUpdated(this.handleFriendlySharedMatchUpdatedRealtime);
+ onFriendlySharedMatchDeleted(this.handleFriendlySharedMatchDeletedRealtime);
if (this.friendlyOnly) {
this.loadFriendlyMatches();
+ this.loadFriendlyInvitations();
}
}
}
@@ -711,9 +812,110 @@ export default {
saving: false,
saveAgain: false
},
+ incomingFriendlyInvitations: [],
+ outgoingFriendlyInvitations: [],
+ friendlyInvitationDialog: {
+ isOpen: false,
+ form: {
+ toClubId: '',
+ date: new Date().toISOString().slice(0, 10),
+ startTime: '',
+ matchName: `Freundschaftsspiel ${new Date().toISOString().slice(0, 10)}`,
+ message: '',
+ }
+ },
};
},
methods: {
+ getClubNameById(clubId) {
+ const club = (this.clubs || []).find((item) => Number(item.id) === Number(clubId));
+ return club?.name || `Verein ${clubId}`;
+ },
+ resetFriendlyInvitationDialogForm() {
+ this.friendlyInvitationDialog.form = {
+ toClubId: '',
+ date: new Date().toISOString().slice(0, 10),
+ startTime: '',
+ matchName: `Freundschaftsspiel ${new Date().toISOString().slice(0, 10)}`,
+ message: '',
+ };
+ },
+ async openFriendlyInvitationDialog() {
+ if (!this.friendlyInvitationTargetClubs.length) {
+ await this.showInfo('Hinweis', 'Keine weiteren Vereine vorhanden.', '', 'info');
+ return;
+ }
+ this.resetFriendlyInvitationDialogForm();
+ this.friendlyInvitationDialog.isOpen = true;
+ },
+ closeFriendlyInvitationDialog() {
+ this.friendlyInvitationDialog.isOpen = false;
+ this.resetFriendlyInvitationDialogForm();
+ },
+ async saveFriendlyInvitation() {
+ const form = this.friendlyInvitationDialog.form;
+ const toClubId = Number.parseInt(form.toClubId, 10);
+ if (!Number.isInteger(toClubId)) {
+ await this.showInfo('Hinweis', 'Bitte einen Zielverein auswählen.', '', 'info');
+ return;
+ }
+ if (!String(form.date || '').trim()) {
+ await this.showInfo('Hinweis', 'Bitte ein Datum angeben.', '', 'info');
+ return;
+ }
+ if (!String(form.matchName || '').trim()) {
+ await this.showInfo('Hinweis', 'Bitte einen Matchnamen angeben.', '', 'info');
+ return;
+ }
+ try {
+ await apiClient.post(`/friendly-match-invitations/${this.currentClub}`, {
+ toClubId,
+ date: form.date,
+ startTime: String(form.startTime || '').trim() || null,
+ matchName: String(form.matchName || '').trim(),
+ message: String(form.message || '').trim() || null,
+ });
+ this.closeFriendlyInvitationDialog();
+ await this.loadFriendlyInvitations();
+ await this.showInfo('Erfolg', 'Einladung wurde versendet.', '', 'success');
+ } catch (error) {
+ await this.showInfo('Fehler', getSafeErrorMessage(error, 'Einladung konnte nicht gespeichert werden.'), '', 'error');
+ }
+ },
+ async loadFriendlyInvitations() {
+ try {
+ const [incomingResponse, outgoingResponse] = await Promise.all([
+ apiClient.get(`/friendly-match-invitations/${this.currentClub}/incoming`),
+ apiClient.get(`/friendly-match-invitations/${this.currentClub}/outgoing`),
+ ]);
+ const incoming = Array.isArray(incomingResponse.data) ? incomingResponse.data : [];
+ const outgoing = Array.isArray(outgoingResponse.data) ? outgoingResponse.data : [];
+ this.incomingFriendlyInvitations = incoming.filter((invitation) => String(invitation?.status || 'pending') === 'pending');
+ this.outgoingFriendlyInvitations = outgoing.filter((invitation) => String(invitation?.status || 'pending') === 'pending');
+ } catch (error) {
+ console.error('Error loading friendly invitations:', error);
+ }
+ },
+ async acceptFriendlyInvitation(invitationId) {
+ try {
+ await apiClient.post(`/friendly-match-invitations/${this.currentClub}/${invitationId}/accept`);
+ this.incomingFriendlyInvitations = this.incomingFriendlyInvitations.filter((invitation) => invitation.id !== invitationId);
+ this.outgoingFriendlyInvitations = this.outgoingFriendlyInvitations.filter((invitation) => invitation.id !== invitationId);
+ await Promise.all([this.loadFriendlyInvitations(), this.loadFriendlyMatches()]);
+ } catch (error) {
+ await this.showInfo('Fehler', getSafeErrorMessage(error, 'Einladung konnte nicht angenommen werden.'), '', 'error');
+ }
+ },
+ async declineFriendlyInvitation(invitationId) {
+ try {
+ await apiClient.post(`/friendly-match-invitations/${this.currentClub}/${invitationId}/decline`);
+ this.incomingFriendlyInvitations = this.incomingFriendlyInvitations.filter((invitation) => invitation.id !== invitationId);
+ this.outgoingFriendlyInvitations = this.outgoingFriendlyInvitations.filter((invitation) => invitation.id !== invitationId);
+ await this.loadFriendlyInvitations();
+ } catch (error) {
+ await this.showInfo('Fehler', getSafeErrorMessage(error, 'Einladung konnte nicht abgelehnt werden.'), '', 'error');
+ }
+ },
emptyFriendlyMatchForm() {
const today = new Date().toISOString().slice(0, 10);
return {
@@ -1025,7 +1227,7 @@ export default {
try {
const response = match.isFriendly
- ? await apiClient.patch(`/friendly-matches/${this.currentClub}/${match.id}/players`, {
+ ? await apiClient.patch(`${match.isSharedFriendly ? `/friendly-matches/shared/${this.currentClub}/${match.id}/players` : `/friendly-matches/${this.currentClub}/${match.id}/players`}`, {
playersReady,
playersPlanned,
playersPlayed
@@ -1438,7 +1640,7 @@ export default {
const score = this.calculateFriendlyResultScore(this.friendlyResultDialog.rows);
try {
this.friendlyResultDialog.saving = true;
- await apiClient.put(`/friendly-matches/${this.currentClub}/${match.id}`, {
+ await apiClient.put(`${match.isSharedFriendly ? `/friendly-matches/shared/${this.currentClub}/${match.id}` : `/friendly-matches/${this.currentClub}/${match.id}`}`, {
homeMatchPoints: score.home,
guestMatchPoints: score.guest,
isCompleted,
@@ -1472,7 +1674,12 @@ export default {
};
const id = this.friendlyMatchDialog.editingId;
if (id) {
- await apiClient.put(`/friendly-matches/${this.currentClub}/${id}`, payload);
+ const existingMatch = this.matches.find((item) => Number(item.id) === Number(id));
+ if (existingMatch?.isSharedFriendly) {
+ await apiClient.put(`/friendly-matches/shared/${this.currentClub}/${id}`, payload);
+ } else {
+ await apiClient.put(`/friendly-matches/${this.currentClub}/${id}`, payload);
+ }
} else {
await apiClient.post(`/friendly-matches/${this.currentClub}`, payload);
}
@@ -1504,7 +1711,7 @@ export default {
homeTeamName: match.homeTeam?.name || '',
guestTeamName: match.guestTeam?.name || ''
};
- await apiClient.put(`/friendly-matches/${this.currentClub}/${match.id}`, payload);
+ await apiClient.put(`${match.isSharedFriendly ? `/friendly-matches/shared/${this.currentClub}/${match.id}` : `/friendly-matches/${this.currentClub}/${match.id}`}`, payload);
} catch (error) {
console.error('toggleHomeAway error:', error);
// Revert optimistic change
@@ -1521,7 +1728,12 @@ export default {
const confirmed = await this.showConfirm('Freundschaftsspiel löschen', 'Soll dieses Freundschaftsspiel gelöscht werden?', '', 'warning');
if (!confirmed) return;
try {
- await apiClient.delete(`/friendly-matches/${this.currentClub}/${this.friendlyMatchDialog.editingId}`);
+ const target = this.matches.find((item) => Number(item.id) === Number(this.friendlyMatchDialog.editingId));
+ if (target?.isSharedFriendly) {
+ await apiClient.delete(`/friendly-matches/shared/${this.currentClub}/${this.friendlyMatchDialog.editingId}`);
+ } else {
+ await apiClient.delete(`/friendly-matches/${this.currentClub}/${this.friendlyMatchDialog.editingId}`);
+ }
this.closeFriendlyMatchDialog();
await this.loadFriendlyMatches();
} catch (error) {
@@ -1786,8 +1998,11 @@ export default {
this.activeTab = 'schedule';
this.leagueTable = [];
try {
- const response = await apiClient.get(`/friendly-matches/${this.currentClub}`);
- this.friendlyMatches = response.data || [];
+ const [localResponse, sharedResponse] = await Promise.all([
+ apiClient.get(`/friendly-matches/${this.currentClub}`),
+ apiClient.get(`/friendly-matches/shared/${this.currentClub}`),
+ ]);
+ this.friendlyMatches = [...(localResponse.data || []), ...(sharedResponse.data || [])];
this.matches = this.sortMatchesByDateTime(this.friendlyMatches);
} catch (error) {
this.showInfo(this.$t('messages.error'), getSafeErrorMessage(error, 'Freundschaftsspiele konnten nicht geladen werden.'), '', 'error');
@@ -2096,10 +2311,30 @@ export default {
}
this.refreshScheduleData();
},
+ handleFriendlyInvitationRealtime() {
+ if (!this.friendlyOnly) return;
+ this.loadFriendlyInvitations();
+ },
+ handleFriendlySharedMatchUpdatedRealtime(payload) {
+ if (!this.friendlyOnly || !payload?.match) return;
+ const idx = this.matches.findIndex((match) => match.id === payload.match.id);
+ if (idx !== -1) {
+ this.matches.splice(idx, 1, payload.match);
+ } else {
+ this.matches.push(payload.match);
+ }
+ this.friendlyMatches = [...this.matches];
+ this.matches = this.sortMatchesByDateTime(this.matches);
+ },
+ handleFriendlySharedMatchDeletedRealtime(payload) {
+ if (!this.friendlyOnly || payload?.matchId == null) return;
+ this.matches = this.matches.filter((match) => match.id !== payload.matchId);
+ this.friendlyMatches = [...this.matches];
+ },
},
async created() {
if (this.friendlyOnly) {
- await this.loadFriendlyMatches();
+ await Promise.all([this.loadFriendlyMatches(), this.loadFriendlyInvitations()]);
return;
}
this.loadTeams();
@@ -2107,6 +2342,11 @@ export default {
beforeUnmount() {
offScheduleMatchUpdated(this.handleScheduleMatchUpdated);
offMatchReportSubmitted(this.handleMatchReportSubmitted);
+ offFriendlyInvitationCreated(this.handleFriendlyInvitationRealtime);
+ offFriendlyInvitationAccepted(this.handleFriendlyInvitationRealtime);
+ offFriendlyInvitationDeclined(this.handleFriendlyInvitationRealtime);
+ offFriendlySharedMatchUpdated(this.handleFriendlySharedMatchUpdatedRealtime);
+ offFriendlySharedMatchDeleted(this.handleFriendlySharedMatchDeletedRealtime);
disconnectSocket();
},
};
@@ -2307,6 +2547,89 @@ td {
font-weight: 600;
}
+.friendly-invitations-card {
+ border: 1px solid #dbe3ea;
+ border-radius: 10px;
+ background: #f9fcff;
+ padding: 0.85rem;
+ margin-bottom: 1rem;
+}
+
+.friendly-invitations-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 0.75rem;
+}
+
+.friendly-invitations-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
+ gap: 1rem;
+ margin-top: 0.75rem;
+}
+
+.friendly-invitation-list {
+ list-style: none;
+ padding: 0;
+ margin: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 0.45rem;
+}
+
+.friendly-invitation-list li {
+ display: flex;
+ justify-content: space-between;
+ gap: 0.75rem;
+ padding: 0.55rem 0.6rem;
+ border: 1px solid #e5ecf2;
+ border-radius: 8px;
+ background: #fff;
+}
+
+.friendly-invitation-main {
+ display: flex;
+ flex-direction: column;
+ gap: 0.2rem;
+}
+
+.friendly-invitation-actions {
+ display: flex;
+ gap: 0.4rem;
+}
+
+.friendly-invitation-empty {
+ color: #64748b;
+ margin: 0.4rem 0 0;
+}
+
+.friendly-invitation-status {
+ align-self: center;
+ color: #475569;
+}
+
+.friendly-invitation-form {
+ display: flex;
+ flex-direction: column;
+ gap: 0.85rem;
+}
+
+.friendly-invitation-message {
+ display: flex;
+ flex-direction: column;
+ gap: 0.35rem;
+}
+
+.friendly-invitation-message textarea {
+ width: 100%;
+ box-sizing: border-box;
+ padding: 0.45rem 0.55rem;
+ border: 1px solid var(--border-color, #ddd);
+ border-radius: 6px;
+ resize: vertical;
+}
+
.modal {
display: flex;
justify-content: center;
diff --git a/mobile-app/composeApp/release/composeApp-release.aab b/mobile-app/composeApp/release/composeApp-release.aab
index 275fa63b..14fcfa24 100644
Binary files a/mobile-app/composeApp/release/composeApp-release.aab and b/mobile-app/composeApp/release/composeApp-release.aab differ
diff --git a/mobile-app/gradle/libs.versions.toml b/mobile-app/gradle/libs.versions.toml
index efacf522..e00511f4 100644
--- a/mobile-app/gradle/libs.versions.toml
+++ b/mobile-app/gradle/libs.versions.toml
@@ -1,7 +1,7 @@
[versions]
# composeApp (Play Store / „Über die App“-Build)
-appVersionCode = "18"
-appVersionName = "1.6.2"
+appVersionCode = "20"
+appVersionName = "1.7.0"
agp = "9.2.1"
android-compileSdk = "35"
android-minSdk = "24"
diff --git a/mobile-app/shared/src/commonMain/kotlin/de/tsschulz/tt_tagebuch/shared/api/MatchesApi.kt b/mobile-app/shared/src/commonMain/kotlin/de/tsschulz/tt_tagebuch/shared/api/MatchesApi.kt
index c42794ba..c65560a2 100644
--- a/mobile-app/shared/src/commonMain/kotlin/de/tsschulz/tt_tagebuch/shared/api/MatchesApi.kt
+++ b/mobile-app/shared/src/commonMain/kotlin/de/tsschulz/tt_tagebuch/shared/api/MatchesApi.kt
@@ -4,6 +4,8 @@ import de.tsschulz.tt_tagebuch.shared.api.http.AuthedHttpClient
import de.tsschulz.tt_tagebuch.shared.api.models.LeaguePlayerStatDto
import de.tsschulz.tt_tagebuch.shared.api.models.LeagueTableRowDto
import de.tsschulz.tt_tagebuch.shared.api.models.FriendlyMatchSaveBody
+import de.tsschulz.tt_tagebuch.shared.api.models.FriendlyMatchInvitationCreateBody
+import de.tsschulz.tt_tagebuch.shared.api.models.FriendlyMatchInvitationDto
import de.tsschulz.tt_tagebuch.shared.api.models.ScheduleMatchDto
import de.tsschulz.tt_tagebuch.shared.api.models.UpdateMatchPlayersBody
import io.ktor.client.call.body
@@ -50,6 +52,10 @@ class MatchesApi(
return client.http.get("/api/friendly-matches/$clubId").body()
}
+ suspend fun listSharedFriendlyMatches(clubId: Int): List {
+ return client.http.get("/api/friendly-matches/shared/$clubId").body()
+ }
+
suspend fun createFriendlyMatch(clubId: Int, body: FriendlyMatchSaveBody): ScheduleMatchDto {
return client.http.post("/api/friendly-matches/$clubId") {
setBody(body)
@@ -62,7 +68,45 @@ class MatchesApi(
}.body()
}
+ suspend fun updateSharedFriendlyMatch(clubId: Int, matchId: Int, body: FriendlyMatchSaveBody): ScheduleMatchDto {
+ return client.http.put("/api/friendly-matches/shared/$clubId/$matchId") {
+ setBody(body)
+ }.body()
+ }
+
+ suspend fun updateSharedFriendlyMatchPlayers(clubId: Int, matchId: Int, body: UpdateMatchPlayersBody) {
+ client.http.patch("/api/friendly-matches/shared/$clubId/$matchId/players") {
+ setBody(body)
+ }
+ }
+
suspend fun deleteFriendlyMatch(clubId: Int, matchId: Int) {
client.http.delete("/api/friendly-matches/$clubId/$matchId")
}
+
+ suspend fun deleteSharedFriendlyMatch(clubId: Int, matchId: Int) {
+ client.http.delete("/api/friendly-matches/shared/$clubId/$matchId")
+ }
+
+ suspend fun createFriendlyInvitation(clubId: Int, body: FriendlyMatchInvitationCreateBody): FriendlyMatchInvitationDto {
+ return client.http.post("/api/friendly-match-invitations/$clubId") {
+ setBody(body)
+ }.body()
+ }
+
+ suspend fun listIncomingFriendlyInvitations(clubId: Int): List {
+ return client.http.get("/api/friendly-match-invitations/$clubId/incoming").body()
+ }
+
+ suspend fun listOutgoingFriendlyInvitations(clubId: Int): List {
+ return client.http.get("/api/friendly-match-invitations/$clubId/outgoing").body()
+ }
+
+ suspend fun acceptFriendlyInvitation(clubId: Int, invitationId: Int): ScheduleMatchDto {
+ return client.http.post("/api/friendly-match-invitations/$clubId/$invitationId/accept").body()
+ }
+
+ suspend fun declineFriendlyInvitation(clubId: Int, invitationId: Int): FriendlyMatchInvitationDto {
+ return client.http.post("/api/friendly-match-invitations/$clubId/$invitationId/decline").body()
+ }
}
diff --git a/mobile-app/shared/src/commonMain/kotlin/de/tsschulz/tt_tagebuch/shared/api/SocketService.kt b/mobile-app/shared/src/commonMain/kotlin/de/tsschulz/tt_tagebuch/shared/api/SocketService.kt
index 22072c57..63d931ef 100644
--- a/mobile-app/shared/src/commonMain/kotlin/de/tsschulz/tt_tagebuch/shared/api/SocketService.kt
+++ b/mobile-app/shared/src/commonMain/kotlin/de/tsschulz/tt_tagebuch/shared/api/SocketService.kt
@@ -92,6 +92,11 @@ class SocketService(private val socketUrl: String) {
"tournament:changed",
"schedule:match:updated",
"schedule:match-report:submitted",
+ "friendly:invitation:created",
+ "friendly:invitation:accepted",
+ "friendly:invitation:declined",
+ "friendly:shared:match:updated",
+ "friendly:shared:match:deleted",
)
}
}
diff --git a/mobile-app/shared/src/commonMain/kotlin/de/tsschulz/tt_tagebuch/shared/api/models/Schedule.kt b/mobile-app/shared/src/commonMain/kotlin/de/tsschulz/tt_tagebuch/shared/api/models/Schedule.kt
index 36b6cfc3..0c2858b5 100644
--- a/mobile-app/shared/src/commonMain/kotlin/de/tsschulz/tt_tagebuch/shared/api/models/Schedule.kt
+++ b/mobile-app/shared/src/commonMain/kotlin/de/tsschulz/tt_tagebuch/shared/api/models/Schedule.kt
@@ -83,9 +83,13 @@ data class ScheduleLeagueDetailsDto(
data class ScheduleMatchDto(
val id: Int,
val friendlyMatchId: Int? = null,
+ val sharedMatchId: Int? = null,
val isFriendly: Boolean = false,
+ val isSharedFriendly: Boolean = false,
val date: String? = null,
val time: String? = null,
+ val homeClubId: Int? = null,
+ val guestClubId: Int? = null,
val homeTeamId: Int? = null,
val guestTeamId: Int? = null,
val locationId: Int? = null,
@@ -150,6 +154,30 @@ data class FriendlyMatchSaveBody(
val resultDetails: List = emptyList(),
)
+@Serializable
+data class FriendlyMatchInvitationDto(
+ val id: Int,
+ val fromClubId: Int,
+ val toClubId: Int,
+ val proposedDate: String,
+ val proposedStartTime: String? = null,
+ val proposedMatchName: String,
+ val message: String? = null,
+ val status: String = "pending",
+ val createdByUserId: Int? = null,
+ val acceptedByUserId: Int? = null,
+ val acceptedAt: String? = null,
+)
+
+@Serializable
+data class FriendlyMatchInvitationCreateBody(
+ val toClubId: Int,
+ val date: String,
+ val startTime: String? = null,
+ val matchName: String,
+ val message: String? = null,
+)
+
@Serializable
data class LeagueTableRowDto(
val teamId: Int,
diff --git a/mobile-app/shared/src/commonMain/kotlin/de/tsschulz/tt_tagebuch/shared/state/ScheduleManager.kt b/mobile-app/shared/src/commonMain/kotlin/de/tsschulz/tt_tagebuch/shared/state/ScheduleManager.kt
index 2ac3d6d3..8f745b84 100644
--- a/mobile-app/shared/src/commonMain/kotlin/de/tsschulz/tt_tagebuch/shared/state/ScheduleManager.kt
+++ b/mobile-app/shared/src/commonMain/kotlin/de/tsschulz/tt_tagebuch/shared/state/ScheduleManager.kt
@@ -4,6 +4,8 @@ import de.tsschulz.tt_tagebuch.shared.api.ClubTeamsApi
import de.tsschulz.tt_tagebuch.shared.api.MatchesApi
import de.tsschulz.tt_tagebuch.shared.api.ScheduleLogic
import de.tsschulz.tt_tagebuch.shared.api.models.ClubTeamDto
+import de.tsschulz.tt_tagebuch.shared.api.models.FriendlyMatchInvitationCreateBody
+import de.tsschulz.tt_tagebuch.shared.api.models.FriendlyMatchInvitationDto
import de.tsschulz.tt_tagebuch.shared.api.models.FriendlyMatchSaveBody
import de.tsschulz.tt_tagebuch.shared.api.models.LeagueTableRowDto
import de.tsschulz.tt_tagebuch.shared.api.models.ScheduleMatchDto
@@ -23,6 +25,8 @@ data class ScheduleState(
val allMatches: List = emptyList(),
val overallMatches: List = emptyList(),
val friendlyMatches: List = emptyList(),
+ val incomingFriendlyInvitations: List = emptyList(),
+ val outgoingFriendlyInvitations: List = emptyList(),
val leagueTable: List = emptyList(),
val matchScope: ScheduleMatchScope = ScheduleMatchScope.Own,
val otherTeamName: String = "",
@@ -78,7 +82,10 @@ class ScheduleManager(
}
ScheduleViewMode.Overall -> loadOverallSchedule(clubId)
ScheduleViewMode.Adult -> loadAdultSchedule(clubId)
- ScheduleViewMode.Friendly -> loadFriendlyMatches(clubId)
+ ScheduleViewMode.Friendly -> {
+ loadFriendlyMatches(clubId)
+ loadFriendlyInvitations(clubId)
+ }
}
}
@@ -248,8 +255,11 @@ class ScheduleManager(
)
}
try {
- val matches = matchesApi.listFriendlyMatches(clubId)
- _state.update { it.copy(friendlyMatches = matches, isLoading = false, error = null) }
+ val localMatches = matchesApi.listFriendlyMatches(clubId)
+ val sharedMatches = matchesApi.listSharedFriendlyMatches(clubId)
+ val combined = ScheduleLogic.sortMatches(localMatches + sharedMatches)
+ _state.update { it.copy(friendlyMatches = combined, isLoading = false, error = null) }
+ loadFriendlyInvitations(clubId)
} catch (t: Throwable) {
_state.update {
it.copy(
@@ -294,8 +304,44 @@ class ScheduleManager(
_state.update { it.copy(friendlyMatches = ScheduleLogic.sortMatches(it.friendlyMatches + saved)) }
}
+ suspend fun loadFriendlyInvitations(clubId: Int) {
+ try {
+ val incoming = matchesApi.listIncomingFriendlyInvitations(clubId)
+ val outgoing = matchesApi.listOutgoingFriendlyInvitations(clubId)
+ _state.update {
+ it.copy(
+ incomingFriendlyInvitations = incoming,
+ outgoingFriendlyInvitations = outgoing,
+ )
+ }
+ } catch (_: Throwable) {
+ // Invitations sind ein Zusatzbereich; Fehler sollen den Hauptscreen nicht blockieren.
+ }
+ }
+
+ suspend fun createFriendlyInvitation(clubId: Int, body: FriendlyMatchInvitationCreateBody) {
+ matchesApi.createFriendlyInvitation(clubId, body)
+ loadFriendlyInvitations(clubId)
+ }
+
+ suspend fun acceptFriendlyInvitation(clubId: Int, invitationId: Int) {
+ val sharedMatch = matchesApi.acceptFriendlyInvitation(clubId, invitationId)
+ _state.update { it.copy(friendlyMatches = ScheduleLogic.sortMatches(it.friendlyMatches + sharedMatch)) }
+ loadFriendlyInvitations(clubId)
+ }
+
+ suspend fun declineFriendlyInvitation(clubId: Int, invitationId: Int) {
+ matchesApi.declineFriendlyInvitation(clubId, invitationId)
+ loadFriendlyInvitations(clubId)
+ }
+
suspend fun updateFriendlyMatch(clubId: Int, matchId: Int, body: FriendlyMatchSaveBody) {
- val saved = matchesApi.updateFriendlyMatch(clubId, matchId, body)
+ val existing = _state.value.friendlyMatches.find { it.id == matchId }
+ val saved = if (existing?.isSharedFriendly == true) {
+ matchesApi.updateSharedFriendlyMatch(clubId, matchId, body)
+ } else {
+ matchesApi.updateFriendlyMatch(clubId, matchId, body)
+ }
_state.update {
it.copy(
friendlyMatches = ScheduleLogic.sortMatches(
@@ -306,7 +352,12 @@ class ScheduleManager(
}
suspend fun deleteFriendlyMatch(clubId: Int, matchId: Int) {
- matchesApi.deleteFriendlyMatch(clubId, matchId)
+ val existing = _state.value.friendlyMatches.find { it.id == matchId }
+ if (existing?.isSharedFriendly == true) {
+ matchesApi.deleteSharedFriendlyMatch(clubId, matchId)
+ } else {
+ matchesApi.deleteFriendlyMatch(clubId, matchId)
+ }
_state.update { it.copy(friendlyMatches = it.friendlyMatches.filterNot { match -> match.id == matchId }) }
}
}