Implement cross-club friendly match concept with invitations and shared matches
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 49s
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 49s
- Added controllers for handling friendly match invitations and shared matches. - Created migration scripts for `friendly_match_invitation` and `friendly_match_shared` tables. - Developed models for `FriendlyMatchInvitation` and `FriendlyMatchShared`. - Established routes for managing invitations and shared matches. - Implemented services for business logic related to invitations and shared matches. - Documented the concept plan for the new feature including API endpoints and data models.
This commit is contained in:
69
backend/controllers/friendlyMatchInvitationController.js
Normal file
69
backend/controllers/friendlyMatchInvitationController.js
Normal file
@@ -0,0 +1,69 @@
|
||||
import friendlyMatchSharedService from '../services/friendlyMatchSharedService.js';
|
||||
import {
|
||||
emitFriendlyInvitationAccepted,
|
||||
emitFriendlyInvitationCreated,
|
||||
emitFriendlyInvitationDeclined,
|
||||
emitFriendlySharedMatchUpdated,
|
||||
} from '../services/socketService.js';
|
||||
|
||||
function userTokenFrom(req) {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (authHeader && authHeader.startsWith('Bearer ')) {
|
||||
return authHeader.slice(7);
|
||||
}
|
||||
return req.headers.authcode || authHeader;
|
||||
}
|
||||
|
||||
export const createFriendlyMatchInvitation = async (req, res) => {
|
||||
try {
|
||||
const invitation = await friendlyMatchSharedService.createInvitation(userTokenFrom(req), req.params.clubId, req.body);
|
||||
emitFriendlyInvitationCreated(invitation.fromClubId, invitation.toClubId, invitation);
|
||||
res.status(201).json(invitation);
|
||||
} catch (error) {
|
||||
console.error('[createFriendlyMatchInvitation] Error:', error);
|
||||
res.status(error.statusCode || 500).json({ error: error.message || 'Einladung konnte nicht erstellt werden.' });
|
||||
}
|
||||
};
|
||||
|
||||
export const listIncomingFriendlyMatchInvitations = async (req, res) => {
|
||||
try {
|
||||
const items = await friendlyMatchSharedService.listIncomingInvitations(userTokenFrom(req), req.params.clubId);
|
||||
res.status(200).json(items);
|
||||
} catch (error) {
|
||||
console.error('[listIncomingFriendlyMatchInvitations] Error:', error);
|
||||
res.status(error.statusCode || 500).json({ error: error.message || 'Eingehende Einladungen konnten nicht geladen werden.' });
|
||||
}
|
||||
};
|
||||
|
||||
export const listOutgoingFriendlyMatchInvitations = async (req, res) => {
|
||||
try {
|
||||
const items = await friendlyMatchSharedService.listOutgoingInvitations(userTokenFrom(req), req.params.clubId);
|
||||
res.status(200).json(items);
|
||||
} catch (error) {
|
||||
console.error('[listOutgoingFriendlyMatchInvitations] Error:', error);
|
||||
res.status(error.statusCode || 500).json({ error: error.message || 'Ausgehende Einladungen konnten nicht geladen werden.' });
|
||||
}
|
||||
};
|
||||
|
||||
export const acceptFriendlyMatchInvitation = async (req, res) => {
|
||||
try {
|
||||
const result = await friendlyMatchSharedService.acceptInvitation(userTokenFrom(req), req.params.clubId, req.params.invitationId);
|
||||
emitFriendlyInvitationAccepted(result.invitation.fromClubId, result.invitation.toClubId, result.invitation);
|
||||
emitFriendlySharedMatchUpdated(result.sharedMatch.homeClubId, result.sharedMatch.guestClubId, result.sharedMatch);
|
||||
res.status(200).json(result);
|
||||
} catch (error) {
|
||||
console.error('[acceptFriendlyMatchInvitation] Error:', error);
|
||||
res.status(error.statusCode || 500).json({ error: error.message || 'Einladung konnte nicht angenommen werden.' });
|
||||
}
|
||||
};
|
||||
|
||||
export const declineFriendlyMatchInvitation = async (req, res) => {
|
||||
try {
|
||||
const invitation = await friendlyMatchSharedService.declineInvitation(userTokenFrom(req), req.params.clubId, req.params.invitationId);
|
||||
emitFriendlyInvitationDeclined(invitation.fromClubId, invitation.toClubId, invitation.id);
|
||||
res.status(200).json({ success: true, id: invitation.id });
|
||||
} catch (error) {
|
||||
console.error('[declineFriendlyMatchInvitation] Error:', error);
|
||||
res.status(error.statusCode || 500).json({ error: error.message || 'Einladung konnte nicht abgelehnt werden.' });
|
||||
}
|
||||
};
|
||||
86
backend/controllers/friendlyMatchSharedController.js
Normal file
86
backend/controllers/friendlyMatchSharedController.js
Normal file
@@ -0,0 +1,86 @@
|
||||
import friendlyMatchSharedService from '../services/friendlyMatchSharedService.js';
|
||||
import {
|
||||
emitFriendlySharedMatchDeleted,
|
||||
emitFriendlySharedMatchUpdated,
|
||||
} from '../services/socketService.js';
|
||||
|
||||
function userTokenFrom(req) {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (authHeader && authHeader.startsWith('Bearer ')) {
|
||||
return authHeader.slice(7);
|
||||
}
|
||||
return req.headers.authcode || authHeader;
|
||||
}
|
||||
|
||||
export const findSharedFriendlyMatches = async (req, res) => {
|
||||
try {
|
||||
const { clubId, name, date, startTime } = req.query;
|
||||
const matches = await friendlyMatchSharedService.findByNameDateStartTime(userTokenFrom(req), clubId, {
|
||||
name,
|
||||
date,
|
||||
startTime,
|
||||
});
|
||||
res.status(200).json(matches);
|
||||
} catch (error) {
|
||||
console.error('[findSharedFriendlyMatches] Error:', error);
|
||||
res.status(error.statusCode || 500).json({ error: error.message || 'Suche nach Freundschaftsspielen fehlgeschlagen.' });
|
||||
}
|
||||
};
|
||||
|
||||
export const listSharedFriendlyMatches = async (req, res) => {
|
||||
try {
|
||||
const data = await friendlyMatchSharedService.listShared(userTokenFrom(req), req.params.clubId);
|
||||
res.status(200).json(data);
|
||||
} catch (error) {
|
||||
console.error('[listSharedFriendlyMatches] Error:', error);
|
||||
res.status(error.statusCode || 500).json({ error: error.message || 'Gemeinsame Freundschaftsspiele konnten nicht geladen werden.' });
|
||||
}
|
||||
};
|
||||
|
||||
export const updateSharedFriendlyMatch = async (req, res) => {
|
||||
try {
|
||||
const match = await friendlyMatchSharedService.updateShared(
|
||||
userTokenFrom(req),
|
||||
req.params.clubId,
|
||||
req.params.matchId,
|
||||
req.body,
|
||||
);
|
||||
emitFriendlySharedMatchUpdated(match.homeClubId, match.guestClubId, match);
|
||||
res.status(200).json(match);
|
||||
} catch (error) {
|
||||
console.error('[updateSharedFriendlyMatch] Error:', error);
|
||||
res.status(error.statusCode || 500).json({ error: error.message || 'Gemeinsames Freundschaftsspiel konnte nicht gespeichert werden.' });
|
||||
}
|
||||
};
|
||||
|
||||
export const updateSharedFriendlyMatchPlayers = async (req, res) => {
|
||||
try {
|
||||
const match = await friendlyMatchSharedService.updateSharedPlayers(
|
||||
userTokenFrom(req),
|
||||
req.params.clubId,
|
||||
req.params.matchId,
|
||||
req.body,
|
||||
);
|
||||
emitFriendlySharedMatchUpdated(match.homeClubId, match.guestClubId, match);
|
||||
res.status(200).json({ message: 'Teilnehmer gespeichert', data: match });
|
||||
} catch (error) {
|
||||
console.error('[updateSharedFriendlyMatchPlayers] Error:', error);
|
||||
res.status(error.statusCode || 500).json({ error: error.message || 'Teilnehmer konnten nicht gespeichert werden.' });
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteSharedFriendlyMatch = async (req, res) => {
|
||||
try {
|
||||
const match = await friendlyMatchSharedService.getSharedById(
|
||||
userTokenFrom(req),
|
||||
req.params.clubId,
|
||||
req.params.matchId,
|
||||
);
|
||||
const result = await friendlyMatchSharedService.removeShared(userTokenFrom(req), req.params.clubId, req.params.matchId);
|
||||
emitFriendlySharedMatchDeleted(match.homeClubId, match.guestClubId, Number(req.params.matchId));
|
||||
res.status(200).json(result);
|
||||
} catch (error) {
|
||||
console.error('[deleteSharedFriendlyMatch] Error:', error);
|
||||
res.status(error.statusCode || 500).json({ error: error.message || 'Gemeinsames Freundschaftsspiel konnte nicht geloescht werden.' });
|
||||
}
|
||||
};
|
||||
@@ -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`;
|
||||
96
backend/models/FriendlyMatchInvitation.js
Normal file
96
backend/models/FriendlyMatchInvitation.js
Normal file
@@ -0,0 +1,96 @@
|
||||
import { DataTypes } from 'sequelize';
|
||||
import sequelize from '../database.js';
|
||||
import Club from './Club.js';
|
||||
import User from './User.js';
|
||||
|
||||
const FriendlyMatchInvitation = sequelize.define('FriendlyMatchInvitation', {
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
allowNull: false,
|
||||
},
|
||||
fromClubId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: Club,
|
||||
key: 'id',
|
||||
},
|
||||
onDelete: 'CASCADE',
|
||||
field: 'from_club_id',
|
||||
},
|
||||
toClubId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: Club,
|
||||
key: 'id',
|
||||
},
|
||||
onDelete: 'CASCADE',
|
||||
field: 'to_club_id',
|
||||
},
|
||||
proposedDate: {
|
||||
type: DataTypes.DATEONLY,
|
||||
allowNull: false,
|
||||
field: 'proposed_date',
|
||||
},
|
||||
proposedStartTime: {
|
||||
type: DataTypes.TIME,
|
||||
allowNull: true,
|
||||
field: 'proposed_start_time',
|
||||
},
|
||||
proposedMatchName: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: false,
|
||||
field: 'proposed_match_name',
|
||||
},
|
||||
message: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
},
|
||||
status: {
|
||||
type: DataTypes.STRING(32),
|
||||
allowNull: false,
|
||||
defaultValue: 'pending',
|
||||
},
|
||||
createdByUserId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
references: {
|
||||
model: User,
|
||||
key: 'id',
|
||||
},
|
||||
onDelete: 'SET NULL',
|
||||
field: 'created_by_user_id',
|
||||
},
|
||||
acceptedByUserId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
references: {
|
||||
model: User,
|
||||
key: 'id',
|
||||
},
|
||||
onDelete: 'SET NULL',
|
||||
field: 'accepted_by_user_id',
|
||||
},
|
||||
acceptedAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true,
|
||||
field: 'accepted_at',
|
||||
},
|
||||
}, {
|
||||
tableName: 'friendly_match_invitation',
|
||||
underscored: true,
|
||||
timestamps: true,
|
||||
indexes: [
|
||||
{
|
||||
fields: ['to_club_id', 'status', 'proposed_date'],
|
||||
},
|
||||
{
|
||||
fields: ['from_club_id', 'status', 'proposed_date'],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
export default FriendlyMatchInvitation;
|
||||
186
backend/models/FriendlyMatchShared.js
Normal file
186
backend/models/FriendlyMatchShared.js
Normal file
@@ -0,0 +1,186 @@
|
||||
import { DataTypes } from 'sequelize';
|
||||
import sequelize from '../database.js';
|
||||
import Club from './Club.js';
|
||||
import User from './User.js';
|
||||
|
||||
const FriendlyMatchShared = sequelize.define('FriendlyMatchShared', {
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
allowNull: false,
|
||||
},
|
||||
homeClubId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: Club,
|
||||
key: 'id',
|
||||
},
|
||||
onDelete: 'CASCADE',
|
||||
field: 'home_club_id',
|
||||
},
|
||||
guestClubId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: Club,
|
||||
key: 'id',
|
||||
},
|
||||
onDelete: 'CASCADE',
|
||||
field: 'guest_club_id',
|
||||
},
|
||||
date: {
|
||||
type: DataTypes.DATEONLY,
|
||||
allowNull: false,
|
||||
},
|
||||
startTime: {
|
||||
type: DataTypes.TIME,
|
||||
allowNull: true,
|
||||
field: 'start_time',
|
||||
},
|
||||
matchName: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: true,
|
||||
field: 'match_name',
|
||||
},
|
||||
homeTeamName: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: false,
|
||||
field: 'home_team_name',
|
||||
},
|
||||
guestTeamName: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: false,
|
||||
field: 'guest_team_name',
|
||||
},
|
||||
locationName: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: true,
|
||||
field: 'location_name',
|
||||
},
|
||||
locationAddress: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: true,
|
||||
field: 'location_address',
|
||||
},
|
||||
locationCity: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: true,
|
||||
field: 'location_city',
|
||||
},
|
||||
locationZip: {
|
||||
type: DataTypes.STRING(32),
|
||||
allowNull: true,
|
||||
field: 'location_zip',
|
||||
},
|
||||
matchSystem: {
|
||||
type: DataTypes.STRING(120),
|
||||
allowNull: false,
|
||||
defaultValue: 'Braunschweiger System',
|
||||
field: 'match_system',
|
||||
},
|
||||
singlesCount: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 12,
|
||||
field: 'singles_count',
|
||||
},
|
||||
doublesCount: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 4,
|
||||
field: 'doubles_count',
|
||||
},
|
||||
winningSets: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 3,
|
||||
field: 'winning_sets',
|
||||
},
|
||||
homeMatchPoints: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0,
|
||||
field: 'home_match_points',
|
||||
},
|
||||
guestMatchPoints: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0,
|
||||
field: 'guest_match_points',
|
||||
},
|
||||
isCompleted: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: false,
|
||||
field: 'is_completed',
|
||||
},
|
||||
homeParticipants: {
|
||||
type: DataTypes.JSON,
|
||||
allowNull: true,
|
||||
field: 'home_participants',
|
||||
},
|
||||
guestParticipants: {
|
||||
type: DataTypes.JSON,
|
||||
allowNull: true,
|
||||
field: 'guest_participants',
|
||||
},
|
||||
resultDetails: {
|
||||
type: DataTypes.JSON,
|
||||
allowNull: true,
|
||||
field: 'result_details',
|
||||
},
|
||||
playersReady: {
|
||||
type: DataTypes.JSON,
|
||||
allowNull: true,
|
||||
field: 'players_ready',
|
||||
},
|
||||
playersPlanned: {
|
||||
type: DataTypes.JSON,
|
||||
allowNull: true,
|
||||
field: 'players_planned',
|
||||
},
|
||||
playersPlayed: {
|
||||
type: DataTypes.JSON,
|
||||
allowNull: true,
|
||||
field: 'players_played',
|
||||
},
|
||||
status: {
|
||||
type: DataTypes.STRING(32),
|
||||
allowNull: false,
|
||||
defaultValue: 'active',
|
||||
},
|
||||
createdByUserId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
references: {
|
||||
model: User,
|
||||
key: 'id',
|
||||
},
|
||||
onDelete: 'SET NULL',
|
||||
field: 'created_by_user_id',
|
||||
},
|
||||
createdFromInvitationId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
field: 'created_from_invitation_id',
|
||||
},
|
||||
}, {
|
||||
tableName: 'friendly_match_shared',
|
||||
underscored: true,
|
||||
timestamps: true,
|
||||
indexes: [
|
||||
{
|
||||
fields: ['home_club_id', 'date', 'start_time'],
|
||||
},
|
||||
{
|
||||
fields: ['guest_club_id', 'date', 'start_time'],
|
||||
},
|
||||
{
|
||||
fields: ['status'],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
export default FriendlyMatchShared;
|
||||
@@ -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,
|
||||
|
||||
20
backend/routes/friendlyMatchInvitationRoutes.js
Normal file
20
backend/routes/friendlyMatchInvitationRoutes.js
Normal file
@@ -0,0 +1,20 @@
|
||||
import express from 'express';
|
||||
import {
|
||||
acceptFriendlyMatchInvitation,
|
||||
createFriendlyMatchInvitation,
|
||||
declineFriendlyMatchInvitation,
|
||||
listIncomingFriendlyMatchInvitations,
|
||||
listOutgoingFriendlyMatchInvitations,
|
||||
} from '../controllers/friendlyMatchInvitationController.js';
|
||||
import { authenticate } from '../middleware/authMiddleware.js';
|
||||
import { authorize } from '../middleware/authorizationMiddleware.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.post('/:clubId', authenticate, authorize('schedule', 'write'), createFriendlyMatchInvitation);
|
||||
router.get('/:clubId/incoming', authenticate, authorize('schedule', 'read'), listIncomingFriendlyMatchInvitations);
|
||||
router.get('/:clubId/outgoing', authenticate, authorize('schedule', 'read'), listOutgoingFriendlyMatchInvitations);
|
||||
router.post('/:clubId/:invitationId/accept', authenticate, authorize('schedule', 'write'), acceptFriendlyMatchInvitation);
|
||||
router.post('/:clubId/:invitationId/decline', authenticate, authorize('schedule', 'write'), declineFriendlyMatchInvitation);
|
||||
|
||||
export default router;
|
||||
20
backend/routes/friendlyMatchSharedRoutes.js
Normal file
20
backend/routes/friendlyMatchSharedRoutes.js
Normal file
@@ -0,0 +1,20 @@
|
||||
import express from 'express';
|
||||
import {
|
||||
deleteSharedFriendlyMatch,
|
||||
findSharedFriendlyMatches,
|
||||
listSharedFriendlyMatches,
|
||||
updateSharedFriendlyMatch,
|
||||
updateSharedFriendlyMatchPlayers,
|
||||
} from '../controllers/friendlyMatchSharedController.js';
|
||||
import { authenticate } from '../middleware/authMiddleware.js';
|
||||
import { authorize } from '../middleware/authorizationMiddleware.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/find', authenticate, authorize('schedule', 'read'), findSharedFriendlyMatches);
|
||||
router.get('/shared/:clubId', authenticate, authorize('schedule', 'read'), listSharedFriendlyMatches);
|
||||
router.put('/shared/:clubId/:matchId', authenticate, authorize('schedule', 'write'), updateSharedFriendlyMatch);
|
||||
router.patch('/shared/:clubId/:matchId/players', authenticate, authorize('schedule', 'write'), updateSharedFriendlyMatchPlayers);
|
||||
router.delete('/shared/:clubId/:matchId', authenticate, authorize('schedule', 'write'), deleteSharedFriendlyMatch);
|
||||
|
||||
export default router;
|
||||
@@ -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);
|
||||
|
||||
@@ -50,4 +50,49 @@ const sendPasswordResetEmail = async (email, resetToken) => {
|
||||
await transporter.sendMail(mailOptions);
|
||||
};
|
||||
|
||||
export { sendActivationEmail, sendPasswordResetEmail };
|
||||
const sendFriendlyMatchInvitationEmail = async ({
|
||||
toEmails,
|
||||
fromClubName,
|
||||
toClubName,
|
||||
proposedDate,
|
||||
proposedStartTime,
|
||||
proposedMatchName,
|
||||
message,
|
||||
}) => {
|
||||
const recipientList = Array.isArray(toEmails) ? toEmails.filter(Boolean) : [toEmails].filter(Boolean);
|
||||
if (!recipientList.length) return;
|
||||
|
||||
const appUrl = process.env.BASE_URL || process.env.PUBLIC_SITE_URL || 'https://tt-tagebuch.de';
|
||||
const timeLabel = proposedStartTime ? ` um ${proposedStartTime}` : '';
|
||||
const messageHtml = message
|
||||
? `<p style="margin-top: 12px;"><strong>Nachricht:</strong><br>${String(message).replace(/</g, '<').replace(/>/g, '>')}</p>`
|
||||
: '';
|
||||
|
||||
const mailOptions = {
|
||||
from: process.env.EMAIL_USER,
|
||||
to: recipientList.join(','),
|
||||
subject: `Freundschaftsspiel-Einladung: ${fromClubName} -> ${toClubName}`,
|
||||
html: `
|
||||
<div style="font-family: Arial, sans-serif; max-width: 700px; margin: 0 auto;">
|
||||
<h2 style="color: #1f2937;">Neue Freundschaftsspiel-Einladung</h2>
|
||||
<p>Der Verein <strong>${fromClubName}</strong> hat euren Verein zu einem Freundschaftsspiel eingeladen.</p>
|
||||
<ul>
|
||||
<li><strong>Match:</strong> ${proposedMatchName}</li>
|
||||
<li><strong>Datum:</strong> ${proposedDate}${timeLabel}</li>
|
||||
</ul>
|
||||
${messageHtml}
|
||||
<p style="margin-top: 16px;">Bitte in der App annehmen oder ablehnen:</p>
|
||||
<p>
|
||||
<a href="${appUrl}/friendly-matches"
|
||||
style="background-color:#2563eb;color:#fff;padding:10px 16px;text-decoration:none;border-radius:6px;display:inline-block;">
|
||||
Zur Einladung
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
`,
|
||||
};
|
||||
|
||||
await transporter.sendMail(mailOptions);
|
||||
};
|
||||
|
||||
export { sendActivationEmail, sendPasswordResetEmail, sendFriendlyMatchInvitationEmail };
|
||||
|
||||
429
backend/services/friendlyMatchSharedService.js
Normal file
429
backend/services/friendlyMatchSharedService.js
Normal file
@@ -0,0 +1,429 @@
|
||||
import { Op } from 'sequelize';
|
||||
import FriendlyMatchShared from '../models/FriendlyMatchShared.js';
|
||||
import FriendlyMatchInvitation from '../models/FriendlyMatchInvitation.js';
|
||||
import UserClub from '../models/UserClub.js';
|
||||
import User from '../models/User.js';
|
||||
import Club from '../models/Club.js';
|
||||
import HttpError from '../exceptions/HttpError.js';
|
||||
import { checkAccess, getUserByToken } from '../utils/userUtils.js';
|
||||
import { sendFriendlyMatchInvitationEmail } from './emailService.js';
|
||||
|
||||
function cleanString(value, fallback = '') {
|
||||
const text = String(value ?? '').trim();
|
||||
return text || fallback;
|
||||
}
|
||||
|
||||
function cleanOptionalString(value) {
|
||||
const text = String(value ?? '').trim();
|
||||
return text || null;
|
||||
}
|
||||
|
||||
function normalizeArrayValue(value) {
|
||||
if (Array.isArray(value)) return value;
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch (error) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function normalizeIdList(list) {
|
||||
if (typeof list === 'string') {
|
||||
try {
|
||||
list = JSON.parse(list);
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (!Array.isArray(list)) return null;
|
||||
const seen = new Set();
|
||||
const result = [];
|
||||
for (const value of list) {
|
||||
const id = Number.parseInt(value, 10);
|
||||
if (!Number.isInteger(id) || seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
result.push(id);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function normalizeTextForSearch(value) {
|
||||
return String(value ?? '')
|
||||
.normalize('NFKD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/[^a-zA-Z0-9\s]/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function isClubInvolved(clubId, match) {
|
||||
const id = Number.parseInt(clubId, 10);
|
||||
return Number(match.homeClubId) === id || Number(match.guestClubId) === id;
|
||||
}
|
||||
|
||||
function toSharedScheduleRow(match) {
|
||||
return {
|
||||
id: match.id,
|
||||
friendlyMatchId: match.id,
|
||||
isFriendly: true,
|
||||
isSharedFriendly: true,
|
||||
date: match.date,
|
||||
time: match.startTime,
|
||||
homeClubId: match.homeClubId,
|
||||
guestClubId: match.guestClubId,
|
||||
homeTeam: { name: match.homeTeamName },
|
||||
guestTeam: { name: match.guestTeamName },
|
||||
location: {
|
||||
name: match.locationName || 'N/A',
|
||||
address: match.locationAddress || '',
|
||||
city: match.locationCity || '',
|
||||
zip: match.locationZip || '',
|
||||
},
|
||||
leagueDetails: { name: 'Freundschaftsspiel (Vereinsuebergreifend)' },
|
||||
homeMatchPoints: match.homeMatchPoints || 0,
|
||||
guestMatchPoints: match.guestMatchPoints || 0,
|
||||
isCompleted: match.isCompleted || false,
|
||||
matchSystem: match.matchSystem,
|
||||
singlesCount: match.singlesCount,
|
||||
doublesCount: match.doublesCount,
|
||||
winningSets: match.winningSets,
|
||||
homeParticipants: normalizeArrayValue(match.homeParticipants),
|
||||
guestParticipants: normalizeArrayValue(match.guestParticipants),
|
||||
resultDetails: normalizeArrayValue(match.resultDetails),
|
||||
playersReady: normalizeArrayValue(match.playersReady),
|
||||
playersPlanned: normalizeArrayValue(match.playersPlanned),
|
||||
playersPlayed: normalizeArrayValue(match.playersPlayed),
|
||||
status: match.status,
|
||||
matchName: match.matchName,
|
||||
createdFromInvitationId: match.createdFromInvitationId,
|
||||
};
|
||||
}
|
||||
|
||||
function toInvitationDto(invitation) {
|
||||
return {
|
||||
id: invitation.id,
|
||||
fromClubId: invitation.fromClubId,
|
||||
toClubId: invitation.toClubId,
|
||||
proposedDate: invitation.proposedDate,
|
||||
proposedStartTime: invitation.proposedStartTime,
|
||||
proposedMatchName: invitation.proposedMatchName,
|
||||
message: invitation.message,
|
||||
status: invitation.status,
|
||||
createdByUserId: invitation.createdByUserId,
|
||||
acceptedByUserId: invitation.acceptedByUserId,
|
||||
acceptedAt: invitation.acceptedAt,
|
||||
createdAt: invitation.createdAt,
|
||||
updatedAt: invitation.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
class FriendlyMatchSharedService {
|
||||
async findByNameDateStartTime(userToken, clubId, query = {}) {
|
||||
await checkAccess(userToken, clubId);
|
||||
|
||||
const nameNorm = normalizeTextForSearch(query.name);
|
||||
const date = cleanOptionalString(query.date);
|
||||
const startTime = cleanOptionalString(query.startTime);
|
||||
|
||||
const where = {
|
||||
[Op.or]: [{ homeClubId: clubId }, { guestClubId: clubId }],
|
||||
};
|
||||
if (date) where.date = date;
|
||||
if (startTime) where.startTime = startTime;
|
||||
|
||||
const matches = await FriendlyMatchShared.findAll({
|
||||
where,
|
||||
order: [['date', 'ASC'], ['startTime', 'ASC'], ['id', 'ASC']],
|
||||
});
|
||||
|
||||
const out = matches
|
||||
.map((match) => {
|
||||
const row = toSharedScheduleRow(match);
|
||||
const combined = normalizeTextForSearch([
|
||||
row.matchName,
|
||||
row.homeTeam?.name,
|
||||
row.guestTeam?.name,
|
||||
`${row.homeTeam?.name || ''} ${row.guestTeam?.name || ''}`,
|
||||
`${row.guestTeam?.name || ''} ${row.homeTeam?.name || ''}`,
|
||||
].join(' '));
|
||||
|
||||
let confidence = 'medium';
|
||||
if (nameNorm && combined === nameNorm && date && startTime) confidence = 'exact';
|
||||
else if (nameNorm && combined.includes(nameNorm) && date && startTime) confidence = 'high';
|
||||
else if (nameNorm && !combined.includes(nameNorm)) return null;
|
||||
|
||||
return {
|
||||
...row,
|
||||
confidence,
|
||||
matchedBy: {
|
||||
name: Boolean(nameNorm),
|
||||
date: Boolean(date),
|
||||
startTime: Boolean(startTime),
|
||||
},
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
async listShared(userToken, clubId) {
|
||||
await checkAccess(userToken, clubId);
|
||||
const matches = await FriendlyMatchShared.findAll({
|
||||
where: {
|
||||
[Op.or]: [{ homeClubId: clubId }, { guestClubId: clubId }],
|
||||
},
|
||||
order: [['date', 'ASC'], ['startTime', 'ASC'], ['id', 'ASC']],
|
||||
});
|
||||
return matches.map(toSharedScheduleRow);
|
||||
}
|
||||
|
||||
async getSharedById(userToken, clubId, matchId) {
|
||||
await checkAccess(userToken, clubId);
|
||||
const match = await FriendlyMatchShared.findByPk(matchId);
|
||||
if (!match || !isClubInvolved(clubId, match)) {
|
||||
throw new HttpError('Gemeinsames Freundschaftsspiel nicht gefunden.', 404);
|
||||
}
|
||||
return toSharedScheduleRow(match);
|
||||
}
|
||||
|
||||
async updateShared(userToken, clubId, matchId, payload = {}) {
|
||||
await checkAccess(userToken, clubId);
|
||||
|
||||
const match = await FriendlyMatchShared.findByPk(matchId);
|
||||
if (!match || !isClubInvolved(clubId, match)) {
|
||||
throw new HttpError('Gemeinsames Freundschaftsspiel nicht gefunden.', 404);
|
||||
}
|
||||
|
||||
const updates = {};
|
||||
for (const field of ['date', 'startTime', 'matchName', 'homeTeamName', 'guestTeamName', 'locationName', 'locationAddress', 'locationCity', 'locationZip', 'matchSystem', 'status']) {
|
||||
if (Object.prototype.hasOwnProperty.call(payload, field)) {
|
||||
updates[field] = ['date', 'homeTeamName', 'guestTeamName', 'matchSystem', 'status'].includes(field)
|
||||
? cleanString(payload[field])
|
||||
: cleanOptionalString(payload[field]);
|
||||
}
|
||||
}
|
||||
|
||||
for (const field of ['singlesCount', 'doublesCount', 'winningSets', 'homeMatchPoints', 'guestMatchPoints']) {
|
||||
if (Object.prototype.hasOwnProperty.call(payload, field)) {
|
||||
updates[field] = Number.parseInt(payload[field], 10) || 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(payload, 'isCompleted')) {
|
||||
updates.isCompleted = Boolean(payload.isCompleted);
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(payload, 'homeParticipants')) {
|
||||
updates.homeParticipants = Array.isArray(payload.homeParticipants) ? payload.homeParticipants : [];
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(payload, 'guestParticipants')) {
|
||||
updates.guestParticipants = Array.isArray(payload.guestParticipants) ? payload.guestParticipants : [];
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(payload, 'resultDetails')) {
|
||||
updates.resultDetails = Array.isArray(payload.resultDetails) ? payload.resultDetails : [];
|
||||
}
|
||||
|
||||
await match.update(updates);
|
||||
return toSharedScheduleRow(match);
|
||||
}
|
||||
|
||||
async updateSharedPlayers(userToken, clubId, matchId, payload = {}) {
|
||||
await checkAccess(userToken, clubId);
|
||||
|
||||
const match = await FriendlyMatchShared.findByPk(matchId);
|
||||
if (!match || !isClubInvolved(clubId, match)) {
|
||||
throw new HttpError('Gemeinsames Freundschaftsspiel nicht gefunden.', 404);
|
||||
}
|
||||
|
||||
const ready = normalizeIdList(payload.playersReady);
|
||||
const planned = normalizeIdList(payload.playersPlanned);
|
||||
const played = normalizeIdList(payload.playersPlayed);
|
||||
|
||||
await match.update({
|
||||
playersReady: ready ?? (match.playersReady || []),
|
||||
playersPlanned: planned ?? (match.playersPlanned || []),
|
||||
playersPlayed: played ?? (match.playersPlayed || []),
|
||||
});
|
||||
|
||||
return toSharedScheduleRow(match);
|
||||
}
|
||||
|
||||
async removeShared(userToken, clubId, matchId) {
|
||||
await checkAccess(userToken, clubId);
|
||||
const match = await FriendlyMatchShared.findByPk(matchId);
|
||||
if (!match || !isClubInvolved(clubId, match)) {
|
||||
throw new HttpError('Gemeinsames Freundschaftsspiel nicht gefunden.', 404);
|
||||
}
|
||||
await match.destroy();
|
||||
return { success: true, id: Number(matchId) };
|
||||
}
|
||||
|
||||
async createInvitation(userToken, fromClubId, payload = {}) {
|
||||
await checkAccess(userToken, fromClubId);
|
||||
|
||||
const toClubId = Number.parseInt(payload.toClubId, 10);
|
||||
if (!Number.isInteger(toClubId)) {
|
||||
throw new HttpError('Zielverein fehlt oder ist ungueltig.', 400);
|
||||
}
|
||||
if (Number(toClubId) === Number(fromClubId)) {
|
||||
throw new HttpError('Ein Verein kann sich nicht selbst einladen.', 400);
|
||||
}
|
||||
|
||||
const proposedDate = cleanString(payload.date);
|
||||
const proposedMatchName = cleanString(payload.matchName);
|
||||
if (!proposedDate || !proposedMatchName) {
|
||||
throw new HttpError('Datum und Matchname sind Pflichtfelder.', 400);
|
||||
}
|
||||
|
||||
const user = await getUserByToken(userToken);
|
||||
|
||||
const invitation = await FriendlyMatchInvitation.create({
|
||||
fromClubId,
|
||||
toClubId,
|
||||
proposedDate,
|
||||
proposedStartTime: cleanOptionalString(payload.startTime),
|
||||
proposedMatchName,
|
||||
message: cleanOptionalString(payload.message),
|
||||
status: 'pending',
|
||||
createdByUserId: user?.id || null,
|
||||
});
|
||||
|
||||
this._sendInvitationEmails(invitation).catch((error) => {
|
||||
console.error('[friendly-match-invitation] email send failed:', error?.message || error);
|
||||
});
|
||||
|
||||
return toInvitationDto(invitation);
|
||||
}
|
||||
|
||||
async listIncomingInvitations(userToken, clubId) {
|
||||
await checkAccess(userToken, clubId);
|
||||
const items = await FriendlyMatchInvitation.findAll({
|
||||
where: { toClubId: clubId },
|
||||
order: [['createdAt', 'DESC']],
|
||||
});
|
||||
return items.map(toInvitationDto);
|
||||
}
|
||||
|
||||
async listOutgoingInvitations(userToken, clubId) {
|
||||
await checkAccess(userToken, clubId);
|
||||
const items = await FriendlyMatchInvitation.findAll({
|
||||
where: { fromClubId: clubId },
|
||||
order: [['createdAt', 'DESC']],
|
||||
});
|
||||
return items.map(toInvitationDto);
|
||||
}
|
||||
|
||||
async acceptInvitation(userToken, clubId, invitationId) {
|
||||
await checkAccess(userToken, clubId);
|
||||
|
||||
const invitation = await FriendlyMatchInvitation.findOne({
|
||||
where: { id: invitationId, toClubId: clubId, status: 'pending' },
|
||||
});
|
||||
if (!invitation) {
|
||||
throw new HttpError('Einladung nicht gefunden.', 404);
|
||||
}
|
||||
|
||||
const user = await getUserByToken(userToken);
|
||||
|
||||
const fromClub = await Club.findByPk(invitation.fromClubId, { attributes: ['id', 'name'] });
|
||||
const toClub = await Club.findByPk(invitation.toClubId, { attributes: ['id', 'name'] });
|
||||
|
||||
const shared = await FriendlyMatchShared.create({
|
||||
homeClubId: invitation.fromClubId,
|
||||
guestClubId: invitation.toClubId,
|
||||
date: invitation.proposedDate,
|
||||
startTime: invitation.proposedStartTime,
|
||||
matchName: invitation.proposedMatchName,
|
||||
homeTeamName: fromClub?.name || 'Heim',
|
||||
guestTeamName: toClub?.name || 'Gast',
|
||||
matchSystem: 'Braunschweiger System',
|
||||
singlesCount: 12,
|
||||
doublesCount: 4,
|
||||
winningSets: 3,
|
||||
homeMatchPoints: 0,
|
||||
guestMatchPoints: 0,
|
||||
isCompleted: false,
|
||||
homeParticipants: [],
|
||||
guestParticipants: [],
|
||||
resultDetails: [],
|
||||
playersReady: [],
|
||||
playersPlanned: [],
|
||||
playersPlayed: [],
|
||||
status: 'active',
|
||||
createdByUserId: user?.id || null,
|
||||
createdFromInvitationId: invitation.id,
|
||||
});
|
||||
|
||||
await invitation.update({
|
||||
status: 'accepted',
|
||||
acceptedByUserId: user?.id || null,
|
||||
acceptedAt: new Date(),
|
||||
});
|
||||
|
||||
return {
|
||||
invitation: toInvitationDto(invitation),
|
||||
sharedMatch: toSharedScheduleRow(shared),
|
||||
};
|
||||
}
|
||||
|
||||
async declineInvitation(userToken, clubId, invitationId) {
|
||||
await checkAccess(userToken, clubId);
|
||||
|
||||
const invitation = await FriendlyMatchInvitation.findOne({
|
||||
where: { id: invitationId, toClubId: clubId, status: 'pending' },
|
||||
});
|
||||
if (!invitation) {
|
||||
throw new HttpError('Einladung nicht gefunden.', 404);
|
||||
}
|
||||
|
||||
const dto = toInvitationDto(invitation);
|
||||
await invitation.destroy();
|
||||
return dto;
|
||||
}
|
||||
|
||||
async _sendInvitationEmails(invitation) {
|
||||
const [fromClub, toClub] = await Promise.all([
|
||||
Club.findByPk(invitation.fromClubId, { attributes: ['id', 'name'] }),
|
||||
Club.findByPk(invitation.toClubId, { attributes: ['id', 'name'] }),
|
||||
]);
|
||||
|
||||
if (!toClub) return;
|
||||
|
||||
const recipients = await UserClub.findAll({
|
||||
where: {
|
||||
clubId: invitation.toClubId,
|
||||
approved: true,
|
||||
},
|
||||
include: [
|
||||
{
|
||||
model: User,
|
||||
as: 'user',
|
||||
attributes: ['id', 'email'],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const targetEmails = recipients
|
||||
.map((row) => row?.user?.email)
|
||||
.filter((email) => typeof email === 'string' && email.trim().length > 3);
|
||||
|
||||
if (!targetEmails.length) return;
|
||||
|
||||
await sendFriendlyMatchInvitationEmail({
|
||||
toEmails: targetEmails,
|
||||
fromClubName: fromClub?.name || `Verein ${invitation.fromClubId}`,
|
||||
toClubName: toClub?.name || `Verein ${invitation.toClubId}`,
|
||||
proposedDate: invitation.proposedDate,
|
||||
proposedStartTime: invitation.proposedStartTime,
|
||||
proposedMatchName: invitation.proposedMatchName,
|
||||
message: invitation.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default new FriendlyMatchSharedService();
|
||||
@@ -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 });
|
||||
|
||||
153
docs/friendly-match-shared-concept-plan.md
Normal file
153
docs/friendly-match-shared-concept-plan.md
Normal file
@@ -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-<home>`, `club-<guest>`).
|
||||
- [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?
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -43,6 +43,43 @@
|
||||
@update:active-tab="activeTab = $event"
|
||||
>
|
||||
<template #schedule-panel>
|
||||
<div v-if="friendlyOnly" class="friendly-invitations-card">
|
||||
<div class="friendly-invitations-header">
|
||||
<strong>Vereinsuebergreifende Einladungen</strong>
|
||||
<button type="button" class="btn-secondary" @click="openFriendlyInvitationDialog">Verein einladen</button>
|
||||
</div>
|
||||
<div class="friendly-invitations-grid">
|
||||
<div>
|
||||
<h4>Eingehend ({{ incomingFriendlyInvitations.length }})</h4>
|
||||
<ul v-if="incomingFriendlyInvitations.length" class="friendly-invitation-list">
|
||||
<li v-for="invitation in incomingFriendlyInvitations" :key="`incoming-${invitation.id}`">
|
||||
<div class="friendly-invitation-main">
|
||||
<span><strong>{{ getClubNameById(invitation.fromClubId) }}</strong> · {{ invitation.proposedDate }} {{ invitation.proposedStartTime || '' }}</span>
|
||||
<small>{{ invitation.proposedMatchName }}</small>
|
||||
</div>
|
||||
<div class="friendly-invitation-actions">
|
||||
<button type="button" class="btn-save" @click="acceptFriendlyInvitation(invitation.id)">Annehmen</button>
|
||||
<button type="button" class="btn-cancel" @click="declineFriendlyInvitation(invitation.id)">Ablehnen</button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else class="friendly-invitation-empty">Keine eingehenden Einladungen.</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4>Ausgehend ({{ outgoingFriendlyInvitations.length }})</h4>
|
||||
<ul v-if="outgoingFriendlyInvitations.length" class="friendly-invitation-list">
|
||||
<li v-for="invitation in outgoingFriendlyInvitations" :key="`outgoing-${invitation.id}`">
|
||||
<div class="friendly-invitation-main">
|
||||
<span><strong>{{ getClubNameById(invitation.toClubId) }}</strong> · {{ invitation.proposedDate }} {{ invitation.proposedStartTime || '' }}</span>
|
||||
<small>{{ invitation.proposedMatchName }}</small>
|
||||
</div>
|
||||
<span class="friendly-invitation-status">{{ invitation.status || 'pending' }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else class="friendly-invitation-empty">Keine ausgehenden Einladungen.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedTeam" class="league-match-scope-card">
|
||||
<div class="league-match-scope-header">
|
||||
<strong>{{ $t('schedule.matchOverviewTitle') }}</strong>
|
||||
@@ -460,6 +497,46 @@
|
||||
</div>
|
||||
</BaseDialog>
|
||||
|
||||
<BaseDialog
|
||||
v-model="friendlyInvitationDialog.isOpen"
|
||||
title="Verein einladen"
|
||||
:max-width="560"
|
||||
@close="closeFriendlyInvitationDialog"
|
||||
>
|
||||
<div class="friendly-invitation-form">
|
||||
<div class="friendly-form-grid">
|
||||
<label>Zielverein
|
||||
<select v-model="friendlyInvitationDialog.form.toClubId">
|
||||
<option value="">Bitte Verein wählen</option>
|
||||
<option
|
||||
v-for="club in friendlyInvitationTargetClubs"
|
||||
:key="club.id"
|
||||
:value="club.id"
|
||||
>
|
||||
{{ club.name }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Datum
|
||||
<input v-model="friendlyInvitationDialog.form.date" type="date" />
|
||||
</label>
|
||||
<label>Startzeit
|
||||
<input v-model="friendlyInvitationDialog.form.startTime" type="time" />
|
||||
</label>
|
||||
<label>Matchname
|
||||
<input v-model="friendlyInvitationDialog.form.matchName" type="text" placeholder="z. B. Freundschaftsspiel Herren 1" />
|
||||
</label>
|
||||
</div>
|
||||
<label class="friendly-invitation-message">Nachricht (optional)
|
||||
<textarea v-model="friendlyInvitationDialog.form.message" rows="3" placeholder="Kurze Nachricht an den Zielverein"></textarea>
|
||||
</label>
|
||||
<div class="dialog-actions">
|
||||
<button type="button" class="btn-save" @click="saveFriendlyInvitation">Einladung senden</button>
|
||||
<button type="button" class="btn-cancel" @click="closeFriendlyInvitationDialog">{{ $t('schedule.cancel') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</BaseDialog>
|
||||
|
||||
<BaseDialog
|
||||
v-model="friendlyMatchDialog.isOpen"
|
||||
:title="friendlyMatchDialog.editingId ? 'Freundschaftsspiel bearbeiten' : 'Freundschaftsspiel anlegen'"
|
||||
@@ -531,7 +608,17 @@ import {
|
||||
onScheduleMatchUpdated,
|
||||
offScheduleMatchUpdated,
|
||||
onMatchReportSubmitted,
|
||||
offMatchReportSubmitted
|
||||
offMatchReportSubmitted,
|
||||
onFriendlyInvitationCreated,
|
||||
offFriendlyInvitationCreated,
|
||||
onFriendlyInvitationAccepted,
|
||||
offFriendlyInvitationAccepted,
|
||||
onFriendlyInvitationDeclined,
|
||||
offFriendlyInvitationDeclined,
|
||||
onFriendlySharedMatchUpdated,
|
||||
offFriendlySharedMatchUpdated,
|
||||
onFriendlySharedMatchDeleted,
|
||||
offFriendlySharedMatchDeleted
|
||||
} from '../services/socketService.js';
|
||||
export default {
|
||||
name: 'ScheduleView',
|
||||
@@ -606,6 +693,9 @@ export default {
|
||||
friendlyMatchesLabel() {
|
||||
return 'Freundschaftsspiele';
|
||||
},
|
||||
friendlyInvitationTargetClubs() {
|
||||
return (this.clubs || []).filter((club) => 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;
|
||||
|
||||
Binary file not shown.
@@ -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"
|
||||
|
||||
@@ -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<ScheduleMatchDto> {
|
||||
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<FriendlyMatchInvitationDto> {
|
||||
return client.http.get("/api/friendly-match-invitations/$clubId/incoming").body()
|
||||
}
|
||||
|
||||
suspend fun listOutgoingFriendlyInvitations(clubId: Int): List<FriendlyMatchInvitationDto> {
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<FriendlyResultRowDto> = 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,
|
||||
|
||||
@@ -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<ScheduleMatchDto> = emptyList(),
|
||||
val overallMatches: List<ScheduleMatchDto> = emptyList(),
|
||||
val friendlyMatches: List<ScheduleMatchDto> = emptyList(),
|
||||
val incomingFriendlyInvitations: List<FriendlyMatchInvitationDto> = emptyList(),
|
||||
val outgoingFriendlyInvitations: List<FriendlyMatchInvitationDto> = emptyList(),
|
||||
val leagueTable: List<LeagueTableRowDto> = 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 }) }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user