Freundschaftsspiele korrigiert
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 52s

This commit is contained in:
Torsten Schulz (local)
2026-06-06 12:42:17 +02:00
parent 5727404f88
commit 5194d4582f
22 changed files with 1527 additions and 177 deletions

View File

@@ -0,0 +1,52 @@
import clubVenueService from '../services/clubVenueService.js';
const handleError = (res, label, error) => {
if (error.message === 'noaccess') return res.status(403).json({ error: 'noaccess' });
if (error.statusCode || error.status) return res.status(error.statusCode || error.status).json({ error: error.message });
console.error(`[${label}] - error:`, error);
return res.status(500).json({ error: 'internalerror' });
};
export const listClubVenues = async (req, res) => {
try {
const { authcode: token } = req.headers;
const { clubId } = req.params;
const venues = await clubVenueService.list(token, clubId);
res.status(200).json(venues);
} catch (error) {
handleError(res, 'listClubVenues', error);
}
};
export const createClubVenue = async (req, res) => {
try {
const { authcode: token } = req.headers;
const { clubId } = req.params;
const venue = await clubVenueService.create(token, clubId, req.body);
res.status(201).json(venue);
} catch (error) {
handleError(res, 'createClubVenue', error);
}
};
export const updateClubVenue = async (req, res) => {
try {
const { authcode: token } = req.headers;
const { clubId, venueId } = req.params;
const venue = await clubVenueService.update(token, clubId, venueId, req.body);
res.status(200).json(venue);
} catch (error) {
handleError(res, 'updateClubVenue', error);
}
};
export const deleteClubVenue = async (req, res) => {
try {
const { authcode: token } = req.headers;
const { clubId, venueId } = req.params;
const result = await clubVenueService.delete(token, clubId, venueId);
res.status(200).json(result);
} catch (error) {
handleError(res, 'deleteClubVenue', error);
}
};

View File

@@ -37,6 +37,21 @@ export const listSharedFriendlyMatches = async (req, res) => {
}
};
export const getSharedFriendlyMatchMembers = async (req, res) => {
try {
const members = await friendlyMatchSharedService.membersForSide(
userTokenFrom(req),
req.params.clubId,
req.params.matchId,
req.params.side,
);
res.status(200).json(members);
} catch (error) {
console.error('[getSharedFriendlyMatchMembers] Error:', error);
res.status(error.statusCode || 500).json({ error: error.message || 'Mitglieder konnten nicht geladen werden.' });
}
};
export const updateSharedFriendlyMatch = async (req, res) => {
try {
const match = await friendlyMatchSharedService.updateShared(

View File

@@ -0,0 +1,15 @@
CREATE TABLE IF NOT EXISTS `club_venue` (
`id` INT NOT NULL AUTO_INCREMENT,
`club_id` INT NOT NULL,
`name` VARCHAR(255) NOT NULL,
`address` VARCHAR(255) NULL,
`zip` VARCHAR(32) NULL,
`city` VARCHAR(255) NULL,
`sort_order` INT NOT NULL DEFAULT 0,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_club_venue_club_sort` (`club_id`, `sort_order`),
KEY `idx_club_venue_club_name` (`club_id`, `name`),
CONSTRAINT `fk_club_venue_club` FOREIGN KEY (`club_id`) REFERENCES `clubs` (`id`) ON DELETE CASCADE
);

View File

@@ -0,0 +1,54 @@
import { DataTypes } from 'sequelize';
import sequelize from '../database.js';
import Club from './Club.js';
const ClubVenue = sequelize.define('ClubVenue', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true,
allowNull: false,
},
clubId: {
type: DataTypes.INTEGER,
allowNull: false,
references: {
model: Club,
key: 'id',
},
onDelete: 'CASCADE',
field: 'club_id',
},
name: {
type: DataTypes.STRING(255),
allowNull: false,
},
address: {
type: DataTypes.STRING(255),
allowNull: true,
},
zip: {
type: DataTypes.STRING(32),
allowNull: true,
},
city: {
type: DataTypes.STRING(255),
allowNull: true,
},
sortOrder: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0,
field: 'sort_order',
},
}, {
tableName: 'club_venue',
underscored: true,
timestamps: true,
indexes: [
{ fields: ['club_id', 'sort_order'] },
{ fields: ['club_id', 'name'] },
],
});
export default ClubVenue;

View File

@@ -69,6 +69,7 @@ import TrainingGroup from './TrainingGroup.js';
import MemberTrainingGroup from './MemberTrainingGroup.js';
import ClubDisabledPresetGroup from './ClubDisabledPresetGroup.js';
import TrainingTime from './TrainingTime.js';
import ClubVenue from './ClubVenue.js';
// Official tournaments relations
OfficialTournament.hasMany(OfficialCompetition, { foreignKey: 'tournamentId', as: 'competitions' });
OfficialCompetition.belongsTo(OfficialTournament, { foreignKey: 'tournamentId', as: 'tournament' });
@@ -443,6 +444,9 @@ FriendlyMatchInvitation.hasOne(FriendlyMatchShared, {
constraints: false,
});
Club.hasMany(ClubVenue, { foreignKey: 'clubId', as: 'venues' });
ClubVenue.belongsTo(Club, { foreignKey: 'clubId', as: 'club' });
export {
User,
Log,
@@ -512,4 +516,5 @@ export {
MemberTrainingGroup,
ClubDisabledPresetGroup,
TrainingTime,
ClubVenue,
};

View File

@@ -0,0 +1,18 @@
import express from 'express';
import { authenticate } from '../middleware/authMiddleware.js';
import { authorize } from '../middleware/authorizationMiddleware.js';
import {
createClubVenue,
deleteClubVenue,
listClubVenues,
updateClubVenue,
} from '../controllers/clubVenueController.js';
const router = express.Router();
router.get('/:clubId', authenticate, authorize('settings', 'read'), listClubVenues);
router.post('/:clubId', authenticate, authorize('settings', 'write'), createClubVenue);
router.put('/:clubId/:venueId', authenticate, authorize('settings', 'write'), updateClubVenue);
router.delete('/:clubId/:venueId', authenticate, authorize('settings', 'write'), deleteClubVenue);
export default router;

View File

@@ -2,6 +2,7 @@ import express from 'express';
import {
deleteSharedFriendlyMatch,
findSharedFriendlyMatches,
getSharedFriendlyMatchMembers,
listSharedFriendlyMatches,
updateSharedFriendlyMatch,
updateSharedFriendlyMatchPlayers,
@@ -13,6 +14,7 @@ const router = express.Router();
router.get('/find', authenticate, authorize('schedule', 'read'), findSharedFriendlyMatches);
router.get('/shared/:clubId', authenticate, authorize('schedule', 'read'), listSharedFriendlyMatches);
router.get('/shared/:clubId/:matchId/members/:side', authenticate, authorize('schedule', 'read'), getSharedFriendlyMatchMembers);
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);

View File

@@ -16,7 +16,7 @@ import {
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
, CalendarEvent, ClubVenue
} from './models/index.js';
import authRoutes from './routes/authRoutes.js';
import clubRoutes from './routes/clubRoutes.js';
@@ -57,6 +57,7 @@ import clickTtHttpPageRoutes from './routes/clickTtHttpPageRoutes.js';
import memberTransferConfigRoutes from './routes/memberTransferConfigRoutes.js';
import trainingGroupRoutes from './routes/trainingGroupRoutes.js';
import trainingTimeRoutes from './routes/trainingTimeRoutes.js';
import clubVenueRoutes from './routes/clubVenueRoutes.js';
import trainingCancellationRoutes from './routes/trainingCancellationRoutes.js';
import memberOrderRoutes from './routes/memberOrderRoutes.js';
import memberGroupPhotoRoutes from './routes/memberGroupPhotoRoutes.js';
@@ -355,6 +356,7 @@ app.use('/api/clicktt', clickTtHttpPageRoutes);
app.use('/api/member-transfer-config', memberTransferConfigRoutes);
app.use('/api/training-groups', trainingGroupRoutes);
app.use('/api/training-times', trainingTimeRoutes);
app.use('/api/club-venues', clubVenueRoutes);
app.use('/api/training-cancellations', trainingCancellationRoutes);
app.use('/api/member-orders', memberOrderRoutes);
app.use('/api/member-group-photos', memberGroupPhotoRoutes);
@@ -565,6 +567,7 @@ app.use((err, req, res, next) => {
await safeSync(User);
await safeSync(Club);
await safeSync(ClubVenue);
await safeSync(UserClub);
await safeSync(Log);
await safeSync(Member);

View File

@@ -0,0 +1,58 @@
import ClubVenue from '../models/ClubVenue.js';
import { checkAccess } from '../utils/userUtils.js';
import HttpError from '../exceptions/HttpError.js';
const clean = (value) => String(value || '').trim();
const cleanNullable = (value) => clean(value) || null;
class ClubVenueService {
async list(userToken, clubId) {
await checkAccess(userToken, clubId);
return ClubVenue.findAll({
where: { clubId },
order: [['sortOrder', 'ASC'], ['name', 'ASC'], ['id', 'ASC']],
});
}
async create(userToken, clubId, payload = {}) {
await checkAccess(userToken, clubId);
const name = clean(payload.name);
if (!name) throw new HttpError('Name ist erforderlich', 400);
const maxSortOrder = await ClubVenue.max('sortOrder', { where: { clubId } }) || 0;
return ClubVenue.create({
clubId,
name,
address: cleanNullable(payload.address),
zip: cleanNullable(payload.zip),
city: cleanNullable(payload.city),
sortOrder: Number.isFinite(Number(payload.sortOrder)) ? Number(payload.sortOrder) : maxSortOrder + 1,
});
}
async update(userToken, clubId, venueId, payload = {}) {
await checkAccess(userToken, clubId);
const venue = await ClubVenue.findOne({ where: { id: venueId, clubId } });
if (!venue) throw new HttpError('Spiellokal nicht gefunden', 404);
const updates = {};
if (payload.name !== undefined) {
const name = clean(payload.name);
if (!name) throw new HttpError('Name ist erforderlich', 400);
updates.name = name;
}
if (payload.address !== undefined) updates.address = cleanNullable(payload.address);
if (payload.zip !== undefined) updates.zip = cleanNullable(payload.zip);
if (payload.city !== undefined) updates.city = cleanNullable(payload.city);
if (payload.sortOrder !== undefined && Number.isFinite(Number(payload.sortOrder))) updates.sortOrder = Number(payload.sortOrder);
return venue.update(updates);
}
async delete(userToken, clubId, venueId) {
await checkAccess(userToken, clubId);
const venue = await ClubVenue.findOne({ where: { id: venueId, clubId } });
if (!venue) throw new HttpError('Spiellokal nicht gefunden', 404);
await venue.destroy();
return { success: true };
}
}
export default new ClubVenueService();

View File

@@ -71,6 +71,21 @@ function normalizeIdList(list) {
return result;
}
function isMatchLocked(match) {
const dateText = cleanString(match?.date);
if (!dateText) return false;
const [year, month, day] = dateText.split('-').map((value) => Number.parseInt(value, 10));
if (!year || !month || !day) return false;
const endsAt = new Date(year, month - 1, day, 23, 59, 59, 999);
return endsAt.getTime() <= Date.now();
}
function assertMatchEditable(match) {
if (isMatchLocked(match)) {
throw new HttpError('Der Termin ist verstrichen. Das Freundschaftsspiel ist nur noch sichtbar.', 409);
}
}
function toScheduleRow(match) {
return {
id: match.id,
@@ -100,6 +115,7 @@ function toScheduleRow(match) {
playersReady: normalizeArrayValue(match.playersReady),
playersPlanned: normalizeArrayValue(match.playersPlanned),
playersPlayed: normalizeArrayValue(match.playersPlayed),
isLocked: isMatchLocked(match),
};
}
@@ -153,6 +169,7 @@ class FriendlyMatchService {
await checkAccess(userToken, clubId);
const match = await FriendlyMatch.findOne({ where: { id: matchId, clubId } });
if (!match) throw new HttpError('Freundschaftsspiel nicht gefunden.', 404);
assertMatchEditable(match);
const updates = {};
for (const field of ['date', 'time', 'homeTeamName', 'guestTeamName', 'locationName', 'locationAddress', 'locationCity', 'locationZip', 'matchSystem']) {
@@ -189,6 +206,7 @@ class FriendlyMatchService {
await checkAccess(userToken, clubId);
const match = await FriendlyMatch.findOne({ where: { id: matchId, clubId } });
if (!match) throw new HttpError('Freundschaftsspiel nicht gefunden.', 404);
assertMatchEditable(match);
const ready = normalizeIdList(payload.playersReady);
const planned = normalizeIdList(payload.playersPlanned);

View File

@@ -4,6 +4,7 @@ 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 Member from '../models/Member.js';
import HttpError from '../exceptions/HttpError.js';
import { checkAccess, getUserByToken } from '../utils/userUtils.js';
import { sendFriendlyMatchInvitationEmail } from './emailService.js';
@@ -66,7 +67,41 @@ function isClubInvolved(clubId, match) {
return Number(match.homeClubId) === id || Number(match.guestClubId) === id;
}
function toSharedScheduleRow(match) {
function isSharedMatchLocked(match) {
const dateText = cleanString(match?.date);
if (!dateText) return false;
const [year, month, day] = dateText.split('-').map((value) => Number.parseInt(value, 10));
if (!year || !month || !day) return false;
const endsAt = new Date(year, month - 1, day, 23, 59, 59, 999);
return endsAt.getTime() <= Date.now();
}
function assertSharedMatchEditable(match) {
if (isSharedMatchLocked(match)) {
throw new HttpError('Der Termin ist verstrichen. Das Freundschaftsspiel ist nur noch sichtbar.', 409);
}
}
function canShowOpponentMembers(match) {
return String(match?.status || '') === 'active' && !isSharedMatchLocked(match);
}
function toSharedScheduleRow(match, viewerClubId = null) {
const hideOpponent = viewerClubId != null && !canShowOpponentMembers(match);
const viewerIsHome = Number(viewerClubId) === Number(match.homeClubId);
const rawHomeParticipants = normalizeArrayValue(match.homeParticipants);
const rawGuestParticipants = normalizeArrayValue(match.guestParticipants);
const rawResultDetails = normalizeArrayValue(match.resultDetails);
const homeParticipants = hideOpponent && !viewerIsHome ? [] : rawHomeParticipants;
const guestParticipants = hideOpponent && viewerIsHome ? [] : rawGuestParticipants;
const resultDetails = hideOpponent
? rawResultDetails.map((row) => ({
...row,
homeName: viewerIsHome ? row?.homeName : '',
guestName: viewerIsHome ? '' : row?.guestName,
}))
: rawResultDetails;
return {
id: match.id,
friendlyMatchId: match.id,
@@ -92,13 +127,14 @@ function toSharedScheduleRow(match) {
singlesCount: match.singlesCount,
doublesCount: match.doublesCount,
winningSets: match.winningSets,
homeParticipants: normalizeArrayValue(match.homeParticipants),
guestParticipants: normalizeArrayValue(match.guestParticipants),
resultDetails: normalizeArrayValue(match.resultDetails),
homeParticipants,
guestParticipants,
resultDetails,
playersReady: normalizeArrayValue(match.playersReady),
playersPlanned: normalizeArrayValue(match.playersPlanned),
playersPlayed: normalizeArrayValue(match.playersPlayed),
status: match.status,
isLocked: isSharedMatchLocked(match),
matchName: match.matchName,
createdFromInvitationId: match.createdFromInvitationId,
};
@@ -143,7 +179,7 @@ class FriendlyMatchSharedService {
const out = matches
.map((match) => {
const row = toSharedScheduleRow(match);
const row = toSharedScheduleRow(match, clubId);
const combined = normalizeTextForSearch([
row.matchName,
row.homeTeam?.name,
@@ -180,7 +216,7 @@ class FriendlyMatchSharedService {
},
order: [['date', 'ASC'], ['startTime', 'ASC'], ['id', 'ASC']],
});
return matches.map(toSharedScheduleRow);
return matches.map((match) => toSharedScheduleRow(match, clubId));
}
async getSharedById(userToken, clubId, matchId) {
@@ -189,7 +225,7 @@ class FriendlyMatchSharedService {
if (!match || !isClubInvolved(clubId, match)) {
throw new HttpError('Gemeinsames Freundschaftsspiel nicht gefunden.', 404);
}
return toSharedScheduleRow(match);
return toSharedScheduleRow(match, clubId);
}
async updateShared(userToken, clubId, matchId, payload = {}) {
@@ -199,8 +235,13 @@ class FriendlyMatchSharedService {
if (!match || !isClubInvolved(clubId, match)) {
throw new HttpError('Gemeinsames Freundschaftsspiel nicht gefunden.', 404);
}
assertSharedMatchEditable(match);
const updates = {};
if (Object.prototype.hasOwnProperty.call(payload, 'time') && !Object.prototype.hasOwnProperty.call(payload, 'startTime')) {
payload.startTime = payload.time;
}
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)
@@ -229,7 +270,28 @@ class FriendlyMatchSharedService {
}
await match.update(updates);
return toSharedScheduleRow(match);
return toSharedScheduleRow(match, clubId);
}
async membersForSide(userToken, clubId, matchId, side) {
await checkAccess(userToken, clubId);
const match = await FriendlyMatchShared.findByPk(matchId);
if (!match || !isClubInvolved(clubId, match)) {
throw new HttpError('Gemeinsames Freundschaftsspiel nicht gefunden.', 404);
}
const requestedSide = side === 'guest' ? 'guest' : 'home';
const targetClubId = requestedSide === 'guest' ? match.guestClubId : match.homeClubId;
const isOpponent = Number(targetClubId) !== Number(clubId);
if (isOpponent && !canShowOpponentMembers(match)) {
return [];
}
return Member.findAll({
where: { clubId: targetClubId, active: true },
attributes: ['id', 'firstName', 'lastName', 'gender', 'clubId'],
order: [['lastName', 'ASC'], ['firstName', 'ASC']],
});
}
async updateSharedPlayers(userToken, clubId, matchId, payload = {}) {
@@ -239,18 +301,34 @@ class FriendlyMatchSharedService {
if (!match || !isClubInvolved(clubId, match)) {
throw new HttpError('Gemeinsames Freundschaftsspiel nicht gefunden.', 404);
}
assertSharedMatchEditable(match);
const ready = normalizeIdList(payload.playersReady);
const planned = normalizeIdList(payload.playersPlanned);
const played = normalizeIdList(payload.playersPlayed);
const currentClubMembers = await Member.findAll({
where: { clubId },
attributes: ['id'],
});
const currentClubMemberIds = new Set(currentClubMembers.map((member) => Number(member.id)));
const mergeForCurrentClub = (existingValue, nextValue) => {
if (nextValue == null) return normalizeArrayValue(existingValue);
const existing = normalizeArrayValue(existingValue)
.map((id) => Number.parseInt(id, 10))
.filter((id) => Number.isInteger(id));
const preservedOtherClub = existing.filter((id) => !currentClubMemberIds.has(id));
const nextCurrentClub = nextValue.filter((id) => currentClubMemberIds.has(id));
return [...new Set([...preservedOtherClub, ...nextCurrentClub])];
};
await match.update({
playersReady: ready ?? (match.playersReady || []),
playersPlanned: planned ?? (match.playersPlanned || []),
playersPlayed: played ?? (match.playersPlayed || []),
playersReady: mergeForCurrentClub(match.playersReady, ready),
playersPlanned: mergeForCurrentClub(match.playersPlanned, planned),
playersPlayed: mergeForCurrentClub(match.playersPlayed, played),
});
return toSharedScheduleRow(match);
return toSharedScheduleRow(match, clubId);
}
async removeShared(userToken, clubId, matchId) {
@@ -367,7 +445,7 @@ class FriendlyMatchSharedService {
return {
invitation: toInvitationDto(invitation),
sharedMatch: toSharedScheduleRow(shared),
sharedMatch: toSharedScheduleRow(shared, clubId),
};
}