Erweitert den MatchReportApiDialog um neue Funktionen zur Verwaltung von Spielberichten. Implementiert eine verbesserte Logik zur Berechnung der Gesamtpunkte und Sätze sowie zur Validierung von Eingaben. Fügt visuelle Hinweise für den Abschlussstatus und Warnungen bei fehlerhaften Eingaben hinzu. Optimiert die Benutzeroberfläche mit neuen CSS-Stilen für eine bessere Benutzererfahrung.

This commit is contained in:
Torsten Schulz (local)
2025-11-12 13:40:55 +01:00
524 changed files with 55207 additions and 17236 deletions

View File

@@ -0,0 +1,314 @@
import ApiLog from '../models/ApiLog.js';
import { Op } from 'sequelize';
class ApiLogService {
/**
* Log an API request/response
*/
async logRequest(options) {
try {
const {
userId = null,
method,
path,
statusCode = null,
requestBody = null,
responseBody = null,
executionTime = null,
errorMessage = null,
ipAddress = null,
userAgent = null,
logType = 'api_request',
schedulerJobType = null
} = options;
// Truncate long fields (raise limits to fit typical API JSON bodies)
const truncate = (str, maxLen = 64000) => {
if (!str) return null;
const strVal = typeof str === 'string' ? str : JSON.stringify(str);
return strVal.length > maxLen ? strVal.substring(0, maxLen) + '... (truncated)' : strVal;
};
await ApiLog.create({
userId,
method,
path,
statusCode,
requestBody: truncate(requestBody, 64000),
responseBody: truncate(responseBody, 64000),
executionTime,
errorMessage: truncate(errorMessage, 5000),
ipAddress,
userAgent,
logType,
schedulerJobType
});
} catch (error) {
console.error('Error logging API request:', error);
// Don't throw - logging failures shouldn't break the main operation
}
}
/**
* Log scheduler execution
*/
async logSchedulerExecution(jobType, success, message, executionTime = null, errorMessage = null) {
try {
// Allow optional counts object in message by accepting message as string or object
const truncate = (str, maxLen = 10000) => {
if (!str) return null;
const strVal = typeof str === 'string' ? str : JSON.stringify(str);
return strVal.length > maxLen ? strVal.substring(0, maxLen) + '... (truncated)' : strVal;
};
// If message is an object with details, keep it; otherwise wrap into an object
let responseObj = null;
try {
if (typeof message === 'object' && message !== null) {
responseObj = message;
} else {
responseObj = { message };
}
} catch (e) {
responseObj = { message: String(message) };
}
// If executionTime or errorMessage present, add to response object for visibility
if (executionTime !== null) responseObj.executionTime = executionTime;
if (errorMessage) responseObj.errorMessage = errorMessage;
await ApiLog.create({
userId: null,
method: 'SCHEDULER',
path: `/scheduler/${jobType}`,
statusCode: success ? 200 : 500,
responseBody: truncate(JSON.stringify(responseObj)),
executionTime,
errorMessage: truncate(errorMessage, 5000),
logType: 'scheduler',
schedulerJobType: jobType
});
} catch (error) {
console.error('Error logging scheduler execution:', error);
}
}
/**
* Get logs with filters
*/
async getLogs(options = {}) {
try {
const {
userId = null,
logType = null,
method = null,
path = null,
statusCode = null,
startDate = null,
endDate = null,
limit = 100,
offset = 0
} = options;
const where = {};
if (userId) {
where.userId = userId;
}
if (logType) {
where.logType = logType;
}
if (method) {
where.method = method;
}
if (path) {
// Handle "NOT:" prefix for excluding paths (e.g., "NOT:/mytischtennis")
if (path.startsWith('NOT:')) {
const excludePath = path.substring(4);
where.path = { [Op.not]: { [Op.like]: `%${excludePath}%` } };
} else {
where.path = { [Op.like]: `%${path}%` };
}
}
if (statusCode !== null) {
where.statusCode = statusCode;
}
if (startDate || endDate) {
where.createdAt = {};
if (startDate) {
where.createdAt[Op.gte] = new Date(startDate);
}
if (endDate) {
where.createdAt[Op.lte] = new Date(endDate);
}
}
const logs = await ApiLog.findAndCountAll({
where,
order: [['createdAt', 'DESC']],
limit: parseInt(limit),
offset: parseInt(offset),
attributes: [
'id', 'userId', 'method', 'path', 'statusCode',
'executionTime', 'errorMessage', 'ipAddress', 'logType',
'schedulerJobType', 'createdAt'
]
});
return {
logs: logs.rows,
total: logs.count,
limit: parseInt(limit),
offset: parseInt(offset)
};
} catch (error) {
console.error('Error getting logs:', error);
throw error;
}
}
/**
* Get a single log by ID
*/
async getLogById(logId) {
try {
const log = await ApiLog.findByPk(logId);
return log;
} catch (error) {
console.error('Error getting log by ID:', error);
throw error;
}
}
/**
* Get last execution info for scheduler jobs
*/
async getLastSchedulerExecutions(clubId = null) {
try {
const jobTypes = ['rating_updates', 'match_results'];
const results = {};
for (const jobType of jobTypes) {
const lastExecution = await ApiLog.findOne({
where: {
logType: 'scheduler',
schedulerJobType: jobType
},
order: [['createdAt', 'DESC']],
attributes: ['id', 'createdAt', 'statusCode', 'responseBody', 'executionTime', 'errorMessage']
});
if (lastExecution) {
let parsedResponse = null;
let updatedCount = null;
let fetchedCount = null;
let teamDetails = [];
// Parse responseBody to extract counts
try {
if (lastExecution.responseBody) {
parsedResponse = JSON.parse(lastExecution.responseBody);
// Extract counts from response
if (parsedResponse.totalUpdated !== undefined) {
updatedCount = parsedResponse.totalUpdated;
} else if (parsedResponse.updatedCount !== undefined) {
updatedCount = parsedResponse.updatedCount;
}
if (parsedResponse.totalFetched !== undefined) {
fetchedCount = parsedResponse.totalFetched;
} else if (parsedResponse.fetchedCount !== undefined) {
fetchedCount = parsedResponse.fetchedCount;
}
}
} catch (e) {
// Ignore parsing errors
}
// For match_results, try to get team-specific details from API logs
if (jobType === 'match_results') {
try {
// Find all API logs created around the same time as the scheduler execution
// (within 5 minutes before and after)
const timeWindowStart = new Date(lastExecution.createdAt);
timeWindowStart.setMinutes(timeWindowStart.getMinutes() - 5);
const timeWindowEnd = new Date(lastExecution.createdAt);
timeWindowEnd.setMinutes(timeWindowEnd.getMinutes() + 5);
const teamLogs = await ApiLog.findAll({
where: {
logType: 'api_request',
schedulerJobType: 'mytischtennis_fetch',
createdAt: {
[Op.between]: [timeWindowStart, timeWindowEnd]
}
},
order: [['createdAt', 'DESC']],
attributes: ['id', 'requestBody', 'statusCode', 'createdAt']
});
// Extract team information from requestBody
const teamMap = new Map();
for (const log of teamLogs) {
try {
if (log.requestBody) {
const requestData = JSON.parse(log.requestBody);
if (requestData.clubTeamId && requestData.teamName) {
const teamId = requestData.clubTeamId;
if (!teamMap.has(teamId)) {
teamMap.set(teamId, {
clubTeamId: teamId,
teamName: requestData.teamName,
success: log.statusCode === 200,
lastRun: log.createdAt
});
}
}
}
} catch (e) {
// Ignore parsing errors for individual logs
}
}
teamDetails = Array.from(teamMap.values());
} catch (e) {
console.error('Error extracting team details:', e);
}
}
results[jobType] = {
lastRun: lastExecution.createdAt,
success: lastExecution.statusCode === 200,
executionTime: lastExecution.executionTime,
updatedCount: updatedCount,
fetchedCount: fetchedCount,
errorMessage: lastExecution.errorMessage,
teamDetails: teamDetails
};
} else {
results[jobType] = {
lastRun: null,
success: null,
executionTime: null,
updatedCount: null,
fetchedCount: null,
errorMessage: null,
teamDetails: []
};
}
}
return results;
} catch (error) {
console.error('Error getting last scheduler executions:', error);
throw error;
}
}
}
export default new ApiLogService();

View File

@@ -13,13 +13,24 @@ const register = async (email, password) => {
return user;
} catch (error) {
devLog(error);
return null;
if (error.name === 'SequelizeUniqueConstraintError') {
const err = new Error('E-Mail-Adresse wird bereits verwendet');
err.status = 409;
throw err;
}
const err = new Error('Registrierung fehlgeschlagen');
err.status = 400;
throw err;
}
};
const activateUser = async (activationCode) => {
const user = await User.findOne({ where: { activationCode } });
if (!user) throw new Error('Invalid activation code');
if (!user) {
const err = new Error('Aktivierungscode ungültig');
err.status = 404;
throw err;
}
user.isActive = true;
user.activationCode = null;
await user.save();
@@ -28,11 +39,21 @@ const activateUser = async (activationCode) => {
const login = async (email, password) => {
if (!email || !password) {
throw { status: 400, message: 'Email und Passwort sind erforderlich.' };
const err = new Error('Email und Passwort sind erforderlich.');
err.status = 400;
throw err;
}
const user = await User.findOne({ where: { email } });
if (!user || !(await bcrypt.compare(password, user.password))) {
throw { status: 401, message: 'Ungültige Anmeldedaten' };
const validPassword = user && await bcrypt.compare(password, user.password);
if (!validPassword) {
const err = new Error('Ungültige Anmeldedaten');
err.status = 401;
throw err;
}
if (!user.isActive) {
const err = new Error('Account wurde noch nicht aktiviert');
err.status = 403;
throw err;
}
const token = jwt.sign({ userId: user.id }, process.env.JWT_SECRET, { expiresIn: '3h' });
await UserToken.create({
@@ -45,7 +66,9 @@ const login = async (email, password) => {
const logout = async (token) => {
if (!token) {
throw { status: 400, message: 'Token fehlt' };
const err = new Error('Token fehlt');
err.status = 400;
throw err;
}
await UserToken.destroy({ where: { token } });
return { message: 'Logout erfolgreich' };

View File

@@ -0,0 +1,956 @@
import myTischtennisService from './myTischtennisService.js';
import myTischtennisFetchLogService from './myTischtennisFetchLogService.js';
import apiLogService from './apiLogService.js';
import myTischtennisClient from '../clients/myTischtennisClient.js';
import MyTischtennis from '../models/MyTischtennis.js';
import ClubTeam from '../models/ClubTeam.js';
import League from '../models/League.js';
import Season from '../models/Season.js';
import Member from '../models/Member.js';
import Match from '../models/Match.js';
import Team from '../models/Team.js';
import { Op } from 'sequelize';
import { devLog } from '../utils/logger.js';
class AutoFetchMatchResultsService {
/**
* Execute automatic match results fetching for all users with enabled auto-updates
*/
async executeAutomaticFetch() {
devLog('Starting automatic match results fetch...');
try {
// Find all users with auto-updates enabled
const accounts = await MyTischtennis.findAll({
where: {
autoUpdateRatings: true, // Nutze das gleiche Flag
savePassword: true // Must have saved password
},
attributes: ['id', 'userId', 'email', 'savePassword', 'encryptedPassword', 'accessToken', 'expiresAt', 'cookie']
});
devLog(`Found ${accounts.length} accounts with auto-updates enabled for match results`);
if (accounts.length === 0) {
devLog('No accounts found with auto-updates enabled');
return;
}
// Process each account and collect summaries
const summaries = [];
for (const account of accounts) {
const summary = await this.processAccount(account);
summaries.push({ userId: account.userId, ...summary });
}
devLog('Automatic match results fetch completed');
// Return overall summary including per-account counts
const totalFetched = summaries.reduce((acc, s) => acc + (s.fetchedCount || 0), 0);
return { success: true, totalFetched, summaries };
} catch (error) {
console.error('Error in automatic match results fetch:', error);
throw error;
}
}
/**
* Process a single account for match results fetching
*/
async processAccount(account) {
const startTime = Date.now();
let success = false;
let message = '';
let errorDetails = null;
let fetchedCount = 0;
try {
devLog(`Processing match results for account ${account.email} (User ID: ${account.userId})`);
// Check if session is still valid
if (!account.accessToken || !account.expiresAt || account.expiresAt < Date.now() / 1000) {
devLog(`Session expired for ${account.email}, attempting re-login`);
// Try to re-login with stored password
const password = account.getPassword();
if (!password) {
throw new Error('No stored password available for re-login');
}
const loginResult = await myTischtennisClient.login(account.email, password);
if (!loginResult.success) {
throw new Error(`Re-login failed: ${loginResult.error}`);
}
// Update session data
account.accessToken = loginResult.accessToken;
account.refreshToken = loginResult.refreshToken;
account.expiresAt = loginResult.expiresAt;
account.cookie = loginResult.cookie;
account.savePassword = true; // ensure flag persists when saving
await account.save();
devLog(`Successfully re-logged in for ${account.email}`);
}
// Perform match results fetch
const fetchResult = await this.fetchMatchResults(account);
fetchedCount = fetchResult.fetchedCount || 0;
success = true;
message = `Successfully fetched ${fetchedCount} match results`;
devLog(`Fetched ${fetchedCount} match results for ${account.email}`);
} catch (error) {
success = false;
message = 'Match results fetch failed';
errorDetails = error.message;
console.error(`Error fetching match results for ${account.email}:`, error);
}
const executionTime = Date.now() - startTime;
// Log the attempt to MyTischtennisFetchLog
await myTischtennisFetchLogService.logFetch(
account.userId,
'match_results',
success,
message,
{
errorDetails,
recordsProcessed: fetchedCount,
executionTime,
isAutomatic: true
}
);
devLog(`Match results fetch for ${account.email}: ${success ? 'SUCCESS' : 'FAILED'} (${executionTime}ms)`);
// Return a summary for scheduler
return { success, message, fetchedCount, errorDetails, executionTime };
}
/**
* Fetch match results for a specific account
*/
async fetchMatchResults(account) {
devLog(`Fetching match results for ${account.email}`);
let totalFetched = 0;
try {
// Get all teams for this user's clubs that have myTischtennis IDs configured
const teams = await ClubTeam.findAll({
where: {
myTischtennisTeamId: {
[Op.ne]: null
}
},
include: [
{
model: League,
as: 'league',
where: {
myTischtennisGroupId: {
[Op.ne]: null
},
association: {
[Op.ne]: null
}
},
include: [
{
model: Season,
as: 'season'
}
]
}
]
});
devLog(`Found ${teams.length} teams with myTischtennis configuration`);
// Fetch results for each team
for (const team of teams) {
try {
const result = await this.fetchTeamResults(account, team);
totalFetched += result.fetchedCount;
} catch (error) {
console.error(`Error fetching results for team ${team.name}:`, error);
}
}
return {
success: true,
fetchedCount: totalFetched
};
} catch (error) {
console.error('Error in fetchMatchResults:', error);
throw error;
}
}
/**
* Fetch results for a specific team
*/
async fetchTeamResults(account, team) {
// Validate required data
if (!team.league) {
throw new Error('Team league is required');
}
if (!team.league.season || !team.league.season.season) {
throw new Error('Team league season is required');
}
const league = team.league;
const season = league.season;
// Build the myTischtennis URL
// Convert full season (e.g. "2025/2026") to short format (e.g. "25/26") for API
const seasonFull = season.season; // e.g. "2025/2026"
const seasonParts = seasonFull.split('/');
const seasonShort = seasonParts.length === 2
? `${seasonParts[0].slice(-2)}/${seasonParts[1].slice(-2)}`
: seasonFull;
const seasonStr = seasonShort.replace('/', '--'); // e.g. "25/26" -> "25--26"
const teamnameEncoded = encodeURIComponent(team.name.replace(/\s/g, '_'));
devLog(`=== FETCH TEAM RESULTS ===`);
devLog(`Team name (from ClubTeam): ${team.name}`);
devLog(`Team name encoded: ${teamnameEncoded}`);
devLog(`MyTischtennis Team ID: ${team.myTischtennisTeamId}`);
let totalProcessed = 0;
try {
// 1. Fetch player statistics (Spielerbilanzen)
const playerStatsUrl = `https://www.mytischtennis.de/click-tt/${league.association}/${seasonStr}/ligen/${league.groupname}/gruppe/${league.myTischtennisGroupId}/mannschaft/${team.myTischtennisTeamId}/${teamnameEncoded}/spielerbilanzen/gesamt?_data=routes%2Fclick-tt%2B%2F%24association%2B%2F%24season%2B%2F%24type%2B%2F%28%24groupname%29.gruppe.%24urlid_.mannschaft.%24teamid.%24teamname%2B%2Fspielerbilanzen.%24filter`;
devLog(`Fetching player stats from: ${playerStatsUrl}`);
const fetchStartTime = Date.now();
let playerStatsResponse = null;
let fetchExecutionTime = 0;
let responseStatus = 0;
let responseBodyData = null;
let fetchError = null;
try {
playerStatsResponse = await fetch(playerStatsUrl, {
headers: {
'Cookie': account.cookie || '',
'Authorization': `Bearer ${account.accessToken}`,
'Accept': 'application/json'
}
});
fetchExecutionTime = Date.now() - fetchStartTime;
responseStatus = playerStatsResponse.status;
if (playerStatsResponse.ok) {
const playerStatsData = await playerStatsResponse.json();
responseBodyData = playerStatsData;
// Avoid dumping full JSON to console; use devLog for compact info
devLog(`Received player stats for team ${team.name} - balancesheet entries: ${Array.isArray(playerStatsData.data?.balancesheet) ? playerStatsData.data.balancesheet.length : 0}`);
const playerCount = await this.processTeamData(team, playerStatsData);
totalProcessed += playerCount;
devLog(`Processed ${playerCount} player statistics`);
} else {
// Read error response body
try {
responseBodyData = await playerStatsResponse.text();
} catch (e) {
responseBodyData = `Error reading response: ${e.message}`;
}
}
} catch (error) {
fetchExecutionTime = Date.now() - fetchStartTime;
fetchError = error.message || String(error);
responseStatus = 0;
console.error('Error fetching from myTischtennis:', error);
}
// Log external request to myTischtennis - IMMER, auch bei Fehlern
try {
await apiLogService.logRequest({
userId: account.userId,
method: 'GET',
path: playerStatsUrl.replace('https://www.mytischtennis.de', ''), // Relative path for consistency
statusCode: responseStatus || (fetchError ? 0 : 200),
requestBody: JSON.stringify({
url: playerStatsUrl,
myTischtennisTeamId: team.myTischtennisTeamId,
clubTeamId: team.id,
teamName: team.name,
association: league.association,
groupname: league.groupname,
groupId: league.myTischtennisGroupId
}),
responseBody: responseBodyData ? (typeof responseBodyData === 'string' ? responseBodyData.substring(0, 10000) : JSON.stringify(responseBodyData).substring(0, 10000)) : null,
executionTime: fetchExecutionTime,
errorMessage: fetchError || (playerStatsResponse && !playerStatsResponse.ok ? `myTischtennis API returned ${responseStatus}` : null),
logType: 'api_request',
schedulerJobType: 'mytischtennis_fetch'
});
} catch (logError) {
console.error('Error logging myTischtennis request:', logError);
// Don't throw - logging failures shouldn't break the main operation
}
// Re-throw error if fetch failed
if (fetchError) {
throw new Error(`Failed to fetch from myTischtennis: ${fetchError}`);
}
// Note: Match results are already included in the player stats response above
// in tableData.meetings_excerpt.meetings, so we don't need a separate call
// Also fetch and update league table data for this team
if (account.userId) {
try {
const tableStartTime = Date.now();
const tableResult = await this.fetchAndUpdateLeagueTable(account.userId, team.leagueId);
const tableExecutionTime = Date.now() - tableStartTime;
devLog(`✓ League table updated for league ${team.leagueId}`);
// Log league table fetch
await myTischtennisFetchLogService.logFetch(
account.userId,
'league_table',
true,
`League table updated successfully`,
{
recordsProcessed: tableResult?.teamsUpdated || 0,
executionTime: tableExecutionTime,
isAutomatic: true,
leagueId: team.leagueId
}
);
} catch (error) {
console.error(`Error updating league table for league ${team.leagueId}:`, error);
// Log failed table fetch
await myTischtennisFetchLogService.logFetch(
account.userId,
'league_table',
false,
'League table update failed',
{
errorDetails: error.message,
isAutomatic: true,
leagueId: team.leagueId
}
);
}
} else {
devLog(`Skipping league table update - no userId available`);
}
return {
success: true,
fetchedCount: totalProcessed
};
} catch (error) {
console.error(`Error fetching team results for ${team.name}:`, error);
throw error;
}
}
/**
* Process and store team data from myTischtennis
*/
async processTeamData(team, data) {
// TODO: Implement data processing and storage
// This would typically involve:
// 1. Extract player statistics from data.data.balancesheet
// 2. Match players with local Member records (by player_id or name)
// 3. Update or create match statistics
// 4. Store historical data for tracking changes
devLog(`Processing data for team ${team.name}`);
if (!data.data || !data.data.balancesheet || !Array.isArray(data.data.balancesheet)) {
devLog('No balancesheet data found');
return 0;
}
let processedCount = 0;
for (const teamData of data.data.balancesheet) {
// Process single player statistics
if (teamData.single_player_statistics) {
for (const playerStat of teamData.single_player_statistics) {
devLog(`Player: ${playerStat.player_firstname} ${playerStat.player_lastname} (ID: ${playerStat.player_id})`);
devLog(` Points won: ${playerStat.points_won}, Points lost: ${playerStat.points_lost}`);
// Try to match player with local Member
const member = await this.matchPlayer(
playerStat.player_id,
playerStat.player_firstname,
playerStat.player_lastname
);
if (member) {
devLog(` Matched with local member: ${member.firstName} ${member.lastName} (ID: ${member.id})`);
// Update player statistics (TTR/QTTR would be fetched from different endpoint)
// For now, we just ensure the myTischtennis ID is stored
if (!member.myTischtennisPlayerId) {
member.myTischtennisPlayerId = playerStat.player_id;
await member.save();
devLog(` Updated myTischtennis Player ID for ${member.firstName} ${member.lastName}`);
}
} else {
devLog(` No local member found for ${playerStat.player_firstname} ${playerStat.player_lastname}`);
}
processedCount++;
}
}
// Process double player statistics
if (teamData.double_player_statistics) {
for (const doubleStat of teamData.double_player_statistics) {
devLog(`Double: ${doubleStat.firstname_player_1} ${doubleStat.lastname_player_1} / ${doubleStat.firstname_player_2} ${doubleStat.lastname_player_2}`);
devLog(` Points won: ${doubleStat.points_won}, Points lost: ${doubleStat.points_lost}`);
// TODO: Store double statistics
processedCount++;
}
}
}
// Also process meetings from the player stats response
if (data.data && data.data.balancesheet && data.data.balancesheet[0]) {
const teamData = data.data.balancesheet[0];
// Check for meetings_excerpt in the tableData section
if (data.tableData && data.tableData.meetings_excerpt && data.tableData.meetings_excerpt.meetings) {
devLog('Found meetings_excerpt in tableData, processing matches...');
const meetingsProcessed = await this.processMatchResults(team, { data: data.tableData });
devLog(`Processed ${meetingsProcessed} matches from player stats response`);
}
}
return processedCount;
}
/**
* Process match results from schedule/table data
*/
async processMatchResults(team, data) {
devLog(`Processing match results for team ${team.name}`);
// Handle different response structures from different endpoints
const meetingsExcerpt = data.data?.meetings_excerpt || data.tableData?.meetings_excerpt;
if (!meetingsExcerpt) {
devLog('No meetings_excerpt data found in response');
return 0;
}
let processedCount = 0;
// Handle both response structures:
// 1. With meetings property: meetings_excerpt.meetings (array of date objects)
// 2. Direct array: meetings_excerpt (array of date objects)
const meetings = meetingsExcerpt.meetings || meetingsExcerpt;
if (!Array.isArray(meetings) || meetings.length === 0) {
devLog('No meetings array found or empty');
return 0;
}
devLog(`Found ${meetings.length} items in meetings array`);
// Check if meetings is an array of date objects or an array of match objects
const firstItem = meetings[0];
const isDateGrouped = firstItem && typeof firstItem === 'object' && !firstItem.meeting_id;
if (isDateGrouped) {
// Format 1: Array of date objects (Spielerbilanzen, Spielplan)
devLog('Processing date-grouped meetings...');
for (const dateGroup of meetings) {
for (const [date, matchList] of Object.entries(dateGroup)) {
for (const match of matchList) {
devLog(`Match: ${match.team_home} vs ${match.team_away}`);
devLog(` Date: ${match.date}`);
devLog(` Status: ${match.state} (${match.is_meeting_complete ? 'complete' : 'incomplete'})`);
devLog(` Result: ${match.matches_won}:${match.matches_lost}`);
devLog(` Meeting ID: ${match.meeting_id}`);
try {
await this.storeMatchResult(team, match, false);
processedCount++;
} catch (error) {
console.error(`Error storing match result for meeting ${match.meeting_id}:`, error);
}
}
}
}
} else {
// Format 2: Flat array of match objects (Tabelle)
devLog('Processing flat meetings array...');
for (const match of meetings) {
devLog(`Match: ${match.team_home} vs ${match.team_away}`);
devLog(` Date: ${match.date}`);
devLog(` Status: ${match.state} (${match.is_meeting_complete ? 'complete' : 'incomplete'})`);
devLog(` Result: ${match.matches_won}:${match.matches_lost}`);
devLog(` Meeting ID: ${match.meeting_id}`);
try {
await this.storeMatchResult(team, match, false);
processedCount++;
} catch (error) {
console.error(`Error storing match result for meeting ${match.meeting_id}:`, error);
}
}
}
devLog(`Processed ${processedCount} matches in league ${team.leagueId}`);
return processedCount;
}
/**
* Store or update match result in database
*/
async storeMatchResult(ourClubTeam, matchData, isHomeTeam) {
// Parse match points from myTischtennis data
// matchData.matches_won/lost are ALWAYS from the perspective of team_home in myTischtennis
// So we need to assign them correctly based on whether WE are home or guest
const mtHomePoints = parseInt(matchData.matches_won) || 0;
const mtGuestPoints = parseInt(matchData.matches_lost) || 0;
// If matchData has team_home and team_away, we can determine our role
// But isHomeTeam parameter tells us if WE (ourClubTeam) are playing at home
const homeMatchPoints = mtHomePoints;
const guestMatchPoints = mtGuestPoints;
devLog(`Match points from myTischtennis: ${mtHomePoints}:${mtGuestPoints} (from team_home perspective)`);
// Find existing match by meeting ID OR by date and team names
devLog(`Searching for existing match with meeting ID: ${matchData.meeting_id}`);
let match = await Match.findOne({
where: { myTischtennisMeetingId: matchData.meeting_id }
});
if (match) {
devLog(`Found match by meeting ID: ${match.id}`);
}
// If not found by meeting ID, try to find by date and teams
if (!match) {
devLog(`No match found by meeting ID, searching by date and teams...`);
const matchDate = new Date(matchData.date);
const startOfDay = new Date(matchDate.setHours(0, 0, 0, 0));
const endOfDay = new Date(matchDate.setHours(23, 59, 59, 999));
devLog(`Searching matches on ${matchData.date} in league ${ourClubTeam.leagueId}`);
const potentialMatches = await Match.findAll({
where: {
date: {
[Op.between]: [startOfDay, endOfDay]
},
leagueId: ourClubTeam.leagueId
},
include: [
{ model: Team, as: 'homeTeam' },
{ model: Team, as: 'guestTeam' }
]
});
devLog(`Found ${potentialMatches.length} potential matches on this date`);
// Find by team names
for (const m of potentialMatches) {
devLog(` Checking match ${m.id}: ${m.homeTeam?.name} vs ${m.guestTeam?.name}`);
devLog(` Against: ${matchData.team_home} vs ${matchData.team_away}`);
const homeNameMatch = m.homeTeam?.name === matchData.team_home ||
m.homeTeam?.name.includes(matchData.team_home) ||
matchData.team_home.includes(m.homeTeam?.name);
const guestNameMatch = m.guestTeam?.name === matchData.team_away ||
m.guestTeam?.name.includes(matchData.team_away) ||
matchData.team_away.includes(m.guestTeam?.name);
devLog(` Home match: ${homeNameMatch}, Guest match: ${guestNameMatch}`);
if (homeNameMatch && guestNameMatch) {
match = m;
devLog(` ✓ Found existing match by date and teams: ${match.id}`);
break;
}
}
if (!match) {
devLog(`No existing match found, will create new one`);
}
}
if (match) {
// Update existing match
// IMPORTANT: Check if the teams are in the same order as in myTischtennis
// Load the match with team associations to compare
const matchWithTeams = await Match.findByPk(match.id, {
include: [
{ model: Team, as: 'homeTeam' },
{ model: Team, as: 'guestTeam' }
]
});
// Compare team names to determine if we need to swap points
const dbHomeTeamName = matchWithTeams.homeTeam?.name || '';
const dbGuestTeamName = matchWithTeams.guestTeam?.name || '';
const mtHomeTeamName = matchData.team_home;
const mtGuestTeamName = matchData.team_away;
// Check if teams are in the same order
const teamsMatch = (
dbHomeTeamName === mtHomeTeamName ||
dbHomeTeamName.includes(mtHomeTeamName) ||
mtHomeTeamName.includes(dbHomeTeamName)
);
let finalHomePoints, finalGuestPoints;
if (teamsMatch) {
// Teams are in same order
finalHomePoints = homeMatchPoints;
finalGuestPoints = guestMatchPoints;
devLog(`Teams in same order: ${dbHomeTeamName} = ${mtHomeTeamName}`);
} else {
// Teams are swapped - need to swap points!
finalHomePoints = guestMatchPoints;
finalGuestPoints = homeMatchPoints;
devLog(`Teams are SWAPPED! DB: ${dbHomeTeamName} vs ${dbGuestTeamName}, MyTT: ${mtHomeTeamName} vs ${mtGuestTeamName}`);
devLog(`Swapping points: ${homeMatchPoints}:${guestMatchPoints}${finalHomePoints}:${finalGuestPoints}`);
}
const updateData = {
homeMatchPoints: finalHomePoints,
guestMatchPoints: finalGuestPoints,
isCompleted: matchData.is_meeting_complete,
pdfUrl: matchData.pdf_url,
myTischtennisMeetingId: matchData.meeting_id // Store meeting ID for future updates
};
await match.update(updateData);
devLog(`Updated existing match ${match.id} (Meeting ${matchData.meeting_id}): ${finalHomePoints}:${finalGuestPoints} (${matchData.is_meeting_complete ? 'complete' : 'incomplete'})`);
} else {
// Create new match
devLog(`Creating new match for meeting ${matchData.meeting_id}`);
try {
// Find or create home and guest teams based on myTischtennis team IDs
const homeTeam = await this.findOrCreateTeam(
matchData.team_home,
matchData.team_home_id,
ourClubTeam
);
const guestTeam = await this.findOrCreateTeam(
matchData.team_away,
matchData.team_away_id,
ourClubTeam
);
// Extract time from date
const matchDate = new Date(matchData.date);
const time = `${String(matchDate.getHours()).padStart(2, '0')}:${String(matchDate.getMinutes()).padStart(2, '0')}:00`;
// Create match (points are already correctly set from matchData)
match = await Match.create({
date: matchData.date,
time: time,
locationId: null, // Location is not provided by myTischtennis
homeTeamId: homeTeam.id,
guestTeamId: guestTeam.id,
leagueId: ourClubTeam.leagueId,
clubId: ourClubTeam.clubId,
myTischtennisMeetingId: matchData.meeting_id,
homeMatchPoints: homeMatchPoints,
guestMatchPoints: guestMatchPoints,
isCompleted: matchData.is_meeting_complete,
pdfUrl: matchData.pdf_url
});
devLog(`Created new match ${match.id}: ${matchData.team_home} vs ${matchData.team_away} (${homeMatchPoints}:${guestMatchPoints}, ${matchData.is_meeting_complete ? 'complete' : 'incomplete'})`);
} catch (error) {
console.error(`Error creating match for meeting ${matchData.meeting_id}:`, error);
devLog(` Home: ${matchData.team_home} (myTT ID: ${matchData.team_home_id})`);
devLog(` Guest: ${matchData.team_away} (myTT ID: ${matchData.team_away_id})`);
}
}
return match;
}
/**
* Find or create a Team in the team table
* All teams (own and opponents) are stored in the team table
*/
async findOrCreateTeam(teamName, myTischtennisTeamId, ourClubTeam) {
devLog(`Finding team: ${teamName} (myTT ID: ${myTischtennisTeamId})`);
// Search in team table for all teams in this league
const allTeamsInLeague = await Team.findAll({
where: {
leagueId: ourClubTeam.leagueId,
seasonId: ourClubTeam.seasonId
}
});
devLog(` Searching in ${allTeamsInLeague.length} teams in league ${ourClubTeam.leagueId}`);
// Try exact match first
let team = allTeamsInLeague.find(t => t.name === teamName);
if (team) {
devLog(` ✓ Found team by exact name: ${team.name} (ID: ${team.id})`);
return team;
}
// If not found, try fuzzy match
team = allTeamsInLeague.find(t =>
t.name.includes(teamName) ||
teamName.includes(t.name)
);
if (team) {
devLog(` ✓ Found team by fuzzy match: ${team.name} (ID: ${team.id})`);
return team;
}
// Team not found - create it
team = await Team.create({
name: teamName,
clubId: ourClubTeam.clubId,
leagueId: ourClubTeam.leagueId,
seasonId: ourClubTeam.seasonId
});
devLog(` ✓ Created new team: ${team.name} (ID: ${team.id})`);
return team;
}
/**
* Match a myTischtennis player with a local Member
*/
async matchPlayer(playerId, firstName, lastName) {
// First, try to find by myTischtennis Player ID
if (playerId) {
const member = await Member.findOne({
where: { myTischtennisPlayerId: playerId }
});
if (member) {
return member;
}
}
// If not found, try to match by name (fuzzy matching)
// Note: Since names are encrypted, we need to get all members and decrypt
// This is not efficient for large databases, but works for now
const allMembers = await Member.findAll();
for (const member of allMembers) {
const memberFirstName = member.firstName?.toLowerCase().trim();
const memberLastName = member.lastName?.toLowerCase().trim();
const searchFirstName = firstName?.toLowerCase().trim();
const searchLastName = lastName?.toLowerCase().trim();
if (memberFirstName === searchFirstName && memberLastName === searchLastName) {
return member;
}
}
return null;
}
/**
* Get all accounts with auto-fetch enabled (for manual execution)
*/
async getAutoFetchAccounts() {
return await MyTischtennis.findAll({
where: {
autoUpdateRatings: true
},
attributes: ['userId', 'email', 'autoUpdateRatings']
});
}
/**
* Fetch and update league table data from MyTischtennis
* @param {number} userId - User ID
* @param {number} leagueId - League ID
*/
async fetchAndUpdateLeagueTable(userId, leagueId) {
try {
devLog(`Fetching league table for user ${userId}, league ${leagueId}`);
// Get user's MyTischtennis account
const myTischtennisAccount = await MyTischtennis.findOne({
where: { userId }
});
if (!myTischtennisAccount) {
throw new Error('MyTischtennis account not found');
}
// Get league info
const league = await League.findByPk(leagueId, {
include: [{ model: Season, as: 'season' }]
});
if (!league) {
throw new Error('League not found');
}
// Login to MyTischtennis if needed
let session = await myTischtennisService.getSession(userId);
if (!session || !session.isValid) {
if (!myTischtennisAccount.savePassword) {
throw new Error('MyTischtennis account not connected or session expired');
}
devLog('Session expired, re-logging in...');
await myTischtennisService.verifyLogin(userId);
session = await myTischtennisService.getSession(userId);
}
// Convert full season (e.g. "2025/2026") to short format (e.g. "25/26") and then to URL format (e.g. "25--26")
const seasonFull = league.season.season; // e.g. "2025/2026"
const seasonParts = seasonFull.split('/');
const seasonShort = seasonParts.length === 2
? `${seasonParts[0].slice(-2)}/${seasonParts[1].slice(-2)}`
: seasonFull;
const seasonStr = seasonShort.replace('/', '--'); // e.g. "25/26" -> "25--26"
// Fetch table data from MyTischtennis
const tableUrl = `https://www.mytischtennis.de/click-tt/${league.association}/${seasonStr}/ligen/${league.groupname}/gruppe/${league.myTischtennisGroupId}/tabelle/gesamt?_data=routes%2Fclick-tt%2B%2F%24association%2B%2F%24season%2B%2F%24type%2B%2F%24groupname.gruppe.%24urlid%2B%2Ftabelle.%24filter`;
devLog(`[fetchAndUpdateLeagueTable] Fetching table from URL: ${tableUrl}`);
const response = await myTischtennisClient.authenticatedRequest(tableUrl, session.cookie, {
method: 'GET'
});
if (!response.success) {
throw new Error(`Failed to fetch table data: ${response.error}`);
}
const tableData = await this.parseTableData(JSON.stringify(response.data), leagueId);
// Update teams with table data
const teamsUpdated = await this.updateTeamsWithTableData(tableData, leagueId);
devLog(`✓ Updated league table for league ${leagueId} with ${tableData.length} teams`);
return {
teamsUpdated,
totalTeams: tableData.length
};
} catch (error) {
console.error(`Error fetching league table for league ${leagueId}:`, error);
throw error;
}
}
/**
* Parse table data from MyTischtennis response
* @param {string} jsonResponse - JSON response from MyTischtennis
* @param {number} leagueId - League ID
* @returns {Array} Parsed table data
*/
async parseTableData(jsonResponse, leagueId) {
devLog('Parsing table data from MyTischtennis response...');
try {
const data = JSON.parse(jsonResponse);
if (!data.data || !data.data.league_table) {
devLog('No league table data found in response');
return [];
}
const leagueTable = data.data.league_table;
const parsedData = [];
for (const teamData of leagueTable) {
parsedData.push({
teamName: teamData.team_name,
matchesPlayed: teamData.matches_won + teamData.matches_lost,
matchesWon: teamData.matches_won,
matchesLost: teamData.matches_lost,
matchesTied: teamData.meetings_tie || 0, // Unentschiedene Begegnungen
setsWon: teamData.sets_won,
setsLost: teamData.sets_lost,
pointsWon: teamData.games_won, // MyTischtennis uses "games" for Ballpunkte
pointsLost: teamData.games_lost,
tablePointsWon: teamData.points_won, // Liga-Tabellenpunkte gewonnen
tablePointsLost: teamData.points_lost, // Liga-Tabellenpunkte verloren
tableRank: teamData.table_rank
});
}
devLog(`Parsed ${parsedData.length} teams from MyTischtennis table data`);
return parsedData;
} catch (error) {
console.error('Error parsing MyTischtennis table data:', error);
return [];
}
}
/**
* Update teams with table data
* @param {Array} tableData - Parsed table data
* @param {number} leagueId - League ID
*/
async updateTeamsWithTableData(tableData, leagueId) {
let updatedCount = 0;
for (const teamData of tableData) {
try {
// Find team by name in this league
const team = await Team.findOne({
where: {
leagueId: leagueId,
name: { [Op.like]: `%${teamData.teamName}%` }
}
});
if (team) {
await team.update({
matchesPlayed: teamData.matchesPlayed || 0,
matchesWon: teamData.matchesWon || 0,
matchesLost: teamData.matchesLost || 0,
matchesTied: teamData.matchesTied || 0,
setsWon: teamData.setsWon || 0,
setsLost: teamData.setsLost || 0,
pointsWon: teamData.pointsWon || 0,
pointsLost: teamData.pointsLost || 0,
tablePoints: (teamData.tablePointsWon || 0), // Legacy field (keep for compatibility)
tablePointsWon: teamData.tablePointsWon || 0,
tablePointsLost: teamData.tablePointsLost || 0
});
updatedCount++;
devLog(` ✓ Updated team ${team.name} with table data`);
} else {
devLog(` ⚠ Team not found: ${teamData.teamName}`);
}
} catch (error) {
console.error(`Error updating team ${teamData.teamName}:`, error);
}
}
return updatedCount;
}
}
export default new AutoFetchMatchResultsService();

View File

@@ -0,0 +1,184 @@
import myTischtennisService from './myTischtennisService.js';
import myTischtennisFetchLogService from './myTischtennisFetchLogService.js';
import memberService from './memberService.js';
import myTischtennisClient from '../clients/myTischtennisClient.js';
import MyTischtennis from '../models/MyTischtennis.js';
import { devLog } from '../utils/logger.js';
import UserClub from '../models/UserClub.js';
class AutoUpdateRatingsService {
/**
* Execute automatic rating updates for all users with enabled auto-updates
*/
async executeAutomaticUpdates() {
devLog('Starting automatic rating updates...');
try {
// Find all users with auto-updates enabled
const accounts = await MyTischtennis.findAll({
where: {
autoUpdateRatings: true,
savePassword: true // Must have saved password
},
attributes: ['id', 'userId', 'email', 'savePassword', 'encryptedPassword', 'accessToken', 'expiresAt', 'cookie']
});
devLog(`Found ${accounts.length} accounts with auto-updates enabled`);
if (accounts.length === 0) {
devLog('No accounts found with auto-updates enabled');
return;
}
// Process each account and collect summaries
const summaries = [];
for (const account of accounts) {
const summary = await this.processAccount(account);
summaries.push({ userId: account.userId, ...summary });
}
devLog('Automatic rating updates completed');
const totalUpdated = summaries.reduce((acc, s) => acc + (s.updatedCount || 0), 0);
return { success: true, totalUpdated, summaries };
} catch (error) {
console.error('Error in automatic rating updates:', error);
}
}
/**
* Process a single account for rating updates
*/
async processAccount(account) {
const startTime = Date.now();
let success = false;
let message = '';
let errorDetails = null;
let updatedCount = 0;
try {
devLog(`Processing account ${account.email} (User ID: ${account.userId})`);
// Check if session is still valid
if (!account.accessToken || !account.expiresAt || account.expiresAt < Date.now() / 1000) {
devLog(`Session expired for ${account.email}, attempting re-login`);
// Try to re-login with stored password
const password = account.getPassword();
if (!password) {
throw new Error('No stored password available for re-login');
}
const loginResult = await myTischtennisClient.login(account.email, password);
if (!loginResult.success) {
throw new Error(`Re-login failed: ${loginResult.error}`);
}
// Update session data
account.accessToken = loginResult.accessToken;
account.refreshToken = loginResult.refreshToken;
account.expiresAt = loginResult.expiresAt;
account.cookie = loginResult.cookie;
account.savePassword = true; // Ensure flag persists when saving limited attributes
await account.save();
devLog(`Successfully re-logged in for ${account.email}`);
}
// Perform rating update
const updateResult = await this.updateRatings(account);
updatedCount = updateResult.updatedCount || 0;
success = true;
message = `Successfully updated ${updatedCount} ratings`;
devLog(`Updated ${updatedCount} ratings for ${account.email}`);
} catch (error) {
success = false;
message = 'Update failed';
errorDetails = error.message;
console.error(`Error updating ratings for ${account.email}:`, error);
}
const executionTime = Date.now() - startTime;
// Log the attempt to MyTischtennisFetchLog
await myTischtennisFetchLogService.logFetch(
account.userId,
'ratings',
success,
message,
{
errorDetails,
recordsProcessed: updatedCount,
executionTime,
isAutomatic: true
}
);
// Also log to update history (for backwards compatibility)
await myTischtennisService.logUpdateAttempt(
account.userId,
success,
message,
errorDetails,
updatedCount,
executionTime
);
// Return summary for scheduler
return { success, message, updatedCount, errorDetails, executionTime };
}
/**
* Update ratings for a specific account
*/
async updateRatings(account) {
devLog(`Updating ratings for ${account.email}`);
try {
// Ermittle einen freigeschalteten Vereinszugang für den Benutzer
const userClub = await UserClub.findOne({
where: {
userId: account.userId,
approved: true
},
order: [['createdAt', 'ASC']],
attributes: ['clubId']
});
if (!userClub) {
throw new Error('Kein freigeschalteter Vereinszugang gefunden');
}
const clubId = userClub.clubId;
// Verwende den Service-Aufruf, der mit userId/clubId arbeitet
const result = await memberService.updateRatingsFromMyTischtennisByUserId(
account.userId,
clubId
);
return {
success: true,
updatedCount: result.updatedCount || 0
};
} catch (error) {
devLog(`Error updating ratings: ${error.message}`);
throw error;
}
}
/**
* Get all accounts with auto-updates enabled (for manual execution)
*/
async getAutoUpdateAccounts() {
return await MyTischtennis.findAll({
where: {
autoUpdateRatings: true
},
attributes: ['userId', 'email', 'autoUpdateRatings', 'lastUpdateRatings']
});
}
}
export default new AutoUpdateRatingsService();

View File

@@ -4,6 +4,7 @@ import User from '../models/User.js';
import Member from '../models/Member.js';
import { Op, fn, where, col } from 'sequelize';
import { checkAccess } from '../utils/userUtils.js';
import permissionService from './permissionService.js';
class ClubService {
async getAllClubs() {
@@ -20,8 +21,15 @@ class ClubService {
return await Club.create({ name: clubName });
}
async addUserToClub(userId, clubId) {
return await UserClub.create({ userId: userId, clubId: clubId, approved: true });
async addUserToClub(userId, clubId, isOwner = false) {
const userClub = await UserClub.create({
userId: userId,
clubId: clubId,
approved: true,
isOwner: isOwner,
role: isOwner ? 'admin' : 'member'
});
return userClub;
}
async getUserClubAccess(userId, clubId) {

View File

@@ -35,6 +35,7 @@ class ClubTeamService {
clubId: clubTeam.clubId,
leagueId: clubTeam.leagueId,
seasonId: clubTeam.seasonId,
myTischtennisTeamId: clubTeam.myTischtennisTeamId,
createdAt: clubTeam.createdAt,
updatedAt: clubTeam.updatedAt,
league: { name: 'Unbekannt' },
@@ -43,7 +44,9 @@ class ClubTeamService {
// Lade Liga-Daten
if (clubTeam.leagueId) {
const league = await League.findByPk(clubTeam.leagueId, { attributes: ['name'] });
const league = await League.findByPk(clubTeam.leagueId, {
attributes: ['id', 'name', 'myTischtennisGroupId', 'association', 'groupname']
});
if (league) enrichedTeam.league = league;
}

View File

@@ -208,6 +208,17 @@ class DiaryDateActivityService {
// Auch für GroupActivities
if (activityData.groupActivities && activityData.groupActivities.length > 0) {
const seenGroupActivityIds = new Set();
activityData.groupActivities = activityData.groupActivities.filter(groupActivity => {
if (!groupActivity || groupActivity.id === undefined || groupActivity.id === null) {
return false;
}
if (seenGroupActivityIds.has(groupActivity.id)) {
return false;
}
seenGroupActivityIds.add(groupActivity.id);
return true;
});
for (const groupActivity of activityData.groupActivities) {
if (groupActivity.groupPredefinedActivity) {
// Hole die erste verfügbare Image-ID direkt aus der Datenbank
@@ -231,23 +242,44 @@ class DiaryDateActivityService {
return activitiesWithImages;
}
async addGroupActivity(userToken, clubId, diaryDateId, groupId, activity) {
async addGroupActivity(userToken, clubId, diaryDateId, groupId, activity, timeblockId = null) {
await checkAccess(userToken, clubId);
const diaryDateActivity = await DiaryDateActivity.findOne({
where: {
diaryDateId,
isTimeblock: true,
},
order: [['order_id', 'DESC']],
limit: 1
});
let diaryDateActivity;
if (timeblockId) {
// Verwende die spezifische Zeitblock-ID, falls angegeben
diaryDateActivity = await DiaryDateActivity.findOne({
where: {
id: timeblockId,
diaryDateId,
isTimeblock: true,
}
});
} else {
// Fallback: Verwende den letzten Zeitblock
diaryDateActivity = await DiaryDateActivity.findOne({
where: {
diaryDateId,
isTimeblock: true,
},
order: [['order_id', 'DESC']],
limit: 1
});
}
if (!diaryDateActivity) {
console.error('[DiaryDateActivityService::addGroupActivity] Activity not found');
throw new Error('Activity not found');
}
const group = await Group.findByPk(groupId);
if (!group || group.diaryDateId !== diaryDateActivity.diaryDateId) {
if (!group) {
console.error('[DiaryDateActivityService::addGroupActivity] Group not found:', groupId);
throw new Error('Group not found');
}
if (group.diaryDateId !== diaryDateActivity.diaryDateId) {
console.error('[DiaryDateActivityService::addGroupActivity] Group and date don\'t fit');
console.error('Group diaryDateId:', group.diaryDateId, 'Activity diaryDateId:', diaryDateActivity.diaryDateId);
throw new Error('Group isn\'t related to date');
}
const [predefinedActivity, created] = await PredefinedActivity.findOrCreate({
@@ -264,6 +296,15 @@ class DiaryDateActivityService {
devLog(activityData);
return await GroupActivity.create(activityData);
}
async deleteGroupActivity(userToken, clubId, groupActivityId) {
await checkAccess(userToken, clubId);
const groupActivity = await GroupActivity.findByPk(groupActivityId);
if (!groupActivity) {
throw new Error('Group activity not found');
}
return await groupActivity.destroy();
}
}
export default new DiaryDateActivityService();

View File

@@ -87,8 +87,8 @@ class DiaryService {
tag = await DiaryTag.create({ name: tagName });
}
const diaryDate = await DiaryDate.findByPk(diaryDateId);
await diaryDate.addTag(tag);
return await diaryDate.getTags();
await diaryDate.addDiaryTag(tag);
return await diaryDate.getDiaryTags();
}
async addTagToDiaryDate(userToken, clubId, diaryDateId, tagId) {

View File

@@ -7,6 +7,8 @@ import Season from '../models/Season.js';
import Location from '../models/Location.js';
import League from '../models/League.js';
import Team from '../models/Team.js';
import ClubTeam from '../models/ClubTeam.js';
import Club from '../models/Club.js';
import SeasonService from './seasonService.js';
import { checkAccess } from '../utils/userUtils.js';
import { Op } from 'sequelize';
@@ -14,6 +16,46 @@ import { Op } from 'sequelize';
import { devLog } from '../utils/logger.js';
class MatchService {
/**
* Format team name with age class suffix
* @param {string} teamName - Base team name (e.g. "Harheimer TC")
* @param {string} ageClass - Age class (e.g. "Jugend 11", "Senioren", "Frauen", "Erwachsene")
* @returns {string} Formatted team name (e.g. "Harheimer TC (J11)")
*/
formatTeamNameWithAgeClass(teamName, ageClass) {
if (!ageClass || ageClass.trim() === '' || ageClass === 'Erwachsene') {
return teamName;
}
// Parse age class
const ageClassLower = ageClass.toLowerCase().trim();
// Senioren = S
if (ageClassLower.includes('senioren')) {
return `${teamName} (S)`;
}
// Frauen = F
if (ageClassLower.includes('frauen')) {
return `${teamName} (F)`;
}
// Jugend XX = JXX
const jugendMatch = ageClass.match(/jugend\s+(\d+)/i);
if (jugendMatch) {
return `${teamName} (J${jugendMatch[1]})`;
}
// Mädchen XX = MXX
const maedchenMatch = ageClass.match(/m[aä]dchen\s+(\d+)/i);
if (maedchenMatch) {
return `${teamName} (M${maedchenMatch[1]})`;
}
// Default: return as is
return teamName;
}
generateSeasonString(date = new Date()) {
const currentYear = date.getFullYear();
let seasonStartYear;
@@ -47,8 +89,20 @@ class MatchService {
seasonId: season.id,
},
});
const homeTeamId = await this.getOrCreateTeamId(row['HeimMannschaft'], clubId);
const guestTeamId = await this.getOrCreateTeamId(row['GastMannschaft'], clubId);
const homeTeamId = await this.getOrCreateTeamId(
row['HeimMannschaft'],
row['HeimMannschaftAltersklasse'],
clubId,
league.id,
season.id
);
const guestTeamId = await this.getOrCreateTeamId(
row['GastMannschaft'],
row['GastMannschaftAltersklasse'],
clubId,
league.id,
season.id
);
const [location] = await Location.findOrCreate({
where: {
name: row['HalleName'],
@@ -90,15 +144,24 @@ class MatchService {
}
}
async getOrCreateTeamId(teamName, clubId) {
async getOrCreateTeamId(teamName, ageClass, clubId, leagueId, seasonId) {
// Format team name with age class
const formattedTeamName = this.formatTeamNameWithAgeClass(teamName, ageClass);
devLog(`Team: "${teamName}" + "${ageClass}" -> "${formattedTeamName}"`);
const [team] = await Team.findOrCreate({
where: {
name: teamName,
clubId: clubId
name: formattedTeamName,
clubId: clubId,
leagueId: leagueId,
seasonId: seasonId
},
defaults: {
name: teamName,
clubId: clubId
name: formattedTeamName,
clubId: clubId,
leagueId: leagueId,
seasonId: seasonId
}
});
return team.id;
@@ -174,6 +237,13 @@ class MatchService {
code: match.code,
homePin: match.homePin,
guestPin: match.guestPin,
homeMatchPoints: match.homeMatchPoints || 0,
guestMatchPoints: match.guestMatchPoints || 0,
isCompleted: match.isCompleted || false,
pdfUrl: match.pdfUrl,
playersReady: match.playersReady || [],
playersPlanned: match.playersPlanned || [],
playersPlayed: match.playersPlayed || [],
homeTeam: { name: 'Unbekannt' },
guestTeam: { name: 'Unbekannt' },
location: { name: 'Unbekannt', address: '', city: '', zip: '' },
@@ -213,13 +283,54 @@ class MatchService {
if (!season) {
throw new Error('Season not found');
}
const matches = await Match.findAll({
// Get club name from database
const club = await Club.findByPk(clubId, { attributes: ['name'] });
if (!club) {
throw new Error('Club not found');
}
const clubName = club.name;
// Find all club teams in this league
const clubTeams = await ClubTeam.findAll({
where: {
clubId: clubId,
leagueId: leagueId
}
},
attributes: ['id', 'name']
});
// Find all Team entries that contain our club name
const ownTeams = await Team.findAll({
where: {
name: {
[Op.like]: `${clubName}%`
},
leagueId: leagueId
},
attributes: ['id', 'name']
});
const ownTeamIds = ownTeams.map(t => t.id);
// Load matches
let matches;
if (ownTeamIds.length > 0) {
// Load only matches where one of our teams is involved
matches = await Match.findAll({
where: {
leagueId: leagueId,
[Op.or]: [
{ homeTeamId: { [Op.in]: ownTeamIds } },
{ guestTeamId: { [Op.in]: ownTeamIds } }
]
}
});
} else {
// No own teams found - show nothing
matches = [];
}
// Lade Team- und Location-Daten manuell
const enrichedMatches = [];
for (const match of matches) {
@@ -234,6 +345,13 @@ class MatchService {
code: match.code,
homePin: match.homePin,
guestPin: match.guestPin,
homeMatchPoints: match.homeMatchPoints || 0,
guestMatchPoints: match.guestMatchPoints || 0,
isCompleted: match.isCompleted || false,
pdfUrl: match.pdfUrl,
playersReady: match.playersReady || [],
playersPlanned: match.playersPlanned || [],
playersPlayed: match.playersPlayed || [],
homeTeam: { name: 'Unbekannt' },
guestTeam: { name: 'Unbekannt' },
location: { name: 'Unbekannt', address: '', city: '', zip: '' },
@@ -264,6 +382,165 @@ class MatchService {
return enrichedMatches;
}
/**
* Get league table for a specific league
* @param {string} userToken - User authentication token
* @param {string} clubId - Club ID
* @param {string} leagueId - League ID
* @returns {Array} League table data
*/
async getLeagueTable(userToken, clubId, leagueId) {
await checkAccess(userToken, clubId);
try {
// Get all teams in this league
const teams = await Team.findAll({
where: {
leagueId: leagueId
},
attributes: [
'id', 'name', 'matchesPlayed', 'matchesWon', 'matchesLost', 'matchesTied',
'setsWon', 'setsLost', 'pointsWon', 'pointsLost', 'tablePoints', 'tablePointsWon', 'tablePointsLost'
],
order: [
['tablePointsWon', 'DESC'], // Highest table points first
['matchesWon', 'DESC'], // Then by matches won
['setsWon', 'DESC'] // Then by sets won
]
});
// Format table data
const tableData = teams.map(team => {
return {
teamId: team.id,
teamName: team.name,
setsWon: team.setsWon,
setsLost: team.setsLost,
matchPoints: team.matchesWon + ':' + team.matchesLost,
tablePoints: team.tablePointsWon + ':' + team.tablePointsLost, // Tabellenpunkte (points_won:points_lost)
pointRatio: team.pointsWon + ':' + team.pointsLost // Ballpunkte (games_won:games_lost)
};
});
return tableData;
} catch (error) {
console.error('Error getting league table:', error);
throw new Error('Failed to get league table');
}
}
async updateMatchPlayers(userToken, matchId, playersReady, playersPlanned, playersPlayed) {
// Find the match and verify access
const match = await Match.findByPk(matchId, {
include: [
{ model: Club, as: 'club' }
]
});
if (!match) {
throw new HttpError('Match not found', 404);
}
await checkAccess(userToken, match.clubId);
// Update player arrays
await match.update({
playersReady: playersReady || [],
playersPlanned: playersPlanned || [],
playersPlayed: playersPlayed || []
});
return {
id: match.id,
playersReady: match.playersReady,
playersPlanned: match.playersPlanned,
playersPlayed: match.playersPlayed
};
}
async getPlayerMatchStats(userToken, clubId, leagueId, seasonId) {
await checkAccess(userToken, clubId);
// Get all matches for this league/season
const matches = await Match.findAll({
where: {
clubId: clubId,
leagueId: leagueId
},
attributes: ['id', 'date', 'playersPlayed']
});
// Get all members
const Member = (await import('../models/Member.js')).default;
const members = await Member.findAll({
where: { clubId: clubId, active: true },
attributes: ['id', 'firstName', 'lastName']
});
// Calculate stats
const stats = {};
const now = new Date();
// Saison startet am 1. Juli
const seasonStart = new Date();
seasonStart.setMonth(6, 1); // 1. Juli
seasonStart.setHours(0, 0, 0, 0);
if (seasonStart > now) {
seasonStart.setFullYear(seasonStart.getFullYear() - 1);
}
// Vorrunde: 1. Juli bis 31. Dezember
const firstHalfEnd = new Date(seasonStart.getFullYear(), 11, 31, 23, 59, 59, 999); // 31. Dezember
// Rückrunde startet am 1. Januar (im Jahr nach Saisonstart)
const secondHalfStart = new Date(seasonStart.getFullYear() + 1, 0, 1, 0, 0, 0, 0); // 1. Januar
for (const member of members) {
stats[member.id] = {
memberId: member.id,
firstName: member.firstName,
lastName: member.lastName,
totalSeason: 0,
totalFirstHalf: 0,
totalSecondHalf: 0
};
}
for (const match of matches) {
// Parse playersPlayed if it's a JSON string
let playersPlayed = match.playersPlayed;
if (typeof playersPlayed === 'string') {
try {
playersPlayed = JSON.parse(playersPlayed);
} catch (e) {
continue;
}
}
if (!playersPlayed || !Array.isArray(playersPlayed) || playersPlayed.length === 0) {
continue;
}
const matchDate = new Date(match.date);
const isInSeason = matchDate >= seasonStart;
const isInFirstHalf = matchDate >= seasonStart && matchDate <= firstHalfEnd;
const isInSecondHalf = matchDate >= secondHalfStart;
for (const playerId of playersPlayed) {
if (stats[playerId]) {
if (isInSeason) stats[playerId].totalSeason++;
if (isInFirstHalf) stats[playerId].totalFirstHalf++;
if (isInSecondHalf) stats[playerId].totalSecondHalf++;
}
}
}
// Convert to array, filter out players with 0 matches, and sort by totalSeason descending
return Object.values(stats)
.filter(player => player.totalSeason > 0)
.sort((a, b) => b.totalSeason - a.totalSeason);
}
}
export default new MatchService();

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,227 @@
import MemberTransferConfig from '../models/MemberTransferConfig.js';
import { checkAccess } from '../utils/userUtils.js';
import { devLog } from '../utils/logger.js';
class MemberTransferConfigService {
/**
* Ruft die Konfiguration für einen Verein ab
*/
async getConfig(userToken, clubId) {
try {
await checkAccess(userToken, clubId);
const config = await MemberTransferConfig.findOne({
where: { clubId }
});
if (!config) {
return {
status: 404,
response: {
success: false,
message: 'Keine Konfiguration gefunden'
}
};
}
// Login-Credentials entschlüsseln
const loginCredentials = config.getLoginCredentials();
return {
status: 200,
response: {
success: true,
config: {
id: config.id,
clubId: config.clubId,
server: config.server,
loginEndpoint: config.loginEndpoint,
loginFormat: config.loginFormat,
loginCredentials: loginCredentials || {},
transferEndpoint: config.transferEndpoint,
transferMethod: config.transferMethod,
transferFormat: config.transferFormat,
transferTemplate: config.transferTemplate,
useBulkMode: config.useBulkMode,
bulkWrapperTemplate: config.bulkWrapperTemplate
}
}
};
} catch (error) {
devLog('[getConfig] Error:', error);
return {
status: 500,
response: {
success: false,
error: 'Fehler beim Laden der Konfiguration: ' + error.message
}
};
}
}
/**
* Speichert oder aktualisiert die Konfiguration
*/
async saveConfig(userToken, clubId, configData) {
try {
await checkAccess(userToken, clubId);
// Validierung
if (!configData.server) {
return {
status: 400,
response: {
success: false,
error: 'Server-Basis-URL ist erforderlich'
}
};
}
if (!configData.transferEndpoint) {
return {
status: 400,
response: {
success: false,
error: 'Übertragungs-Endpoint ist erforderlich'
}
};
}
if (!configData.transferTemplate) {
return {
status: 400,
response: {
success: false,
error: 'Übertragungs-Template ist erforderlich'
}
};
}
// Prüfen, ob bereits eine Konfiguration existiert
let config = await MemberTransferConfig.findOne({
where: { clubId }
});
const configToSave = {
clubId: clubId,
server: configData.server,
loginEndpoint: configData.loginEndpoint || null,
loginFormat: configData.loginFormat || 'json',
transferEndpoint: configData.transferEndpoint,
transferMethod: configData.transferMethod || 'POST',
transferFormat: configData.transferFormat || 'json',
transferTemplate: configData.transferTemplate,
useBulkMode: configData.useBulkMode || false,
bulkWrapperTemplate: configData.bulkWrapperTemplate || null
};
if (config) {
// Update
config.server = configToSave.server;
config.loginEndpoint = configToSave.loginEndpoint;
config.loginFormat = configToSave.loginFormat;
config.transferEndpoint = configToSave.transferEndpoint;
config.transferMethod = configToSave.transferMethod;
config.transferFormat = configToSave.transferFormat;
config.transferTemplate = configToSave.transferTemplate;
config.useBulkMode = configToSave.useBulkMode;
config.bulkWrapperTemplate = configToSave.bulkWrapperTemplate;
// Login-Credentials setzen (nur wenn vorhanden)
if (configData.loginCredentials) {
config.setLoginCredentials(configData.loginCredentials);
} else {
// Wenn keine Credentials übergeben wurden, aber welche vorhanden sind, behalten
// Nur löschen, wenn explizit null/undefined übergeben wurde
if (configData.loginCredentials === null || configData.loginCredentials === undefined) {
config.encryptedLoginCredentials = null;
}
}
await config.save();
} else {
// Create
config = await MemberTransferConfig.create(configToSave);
// Login-Credentials setzen (nur wenn vorhanden)
if (configData.loginCredentials) {
config.setLoginCredentials(configData.loginCredentials);
await config.save();
}
}
return {
status: 200,
response: {
success: true,
message: 'Konfiguration erfolgreich gespeichert',
config: {
id: config.id,
clubId: config.clubId,
server: config.server,
loginEndpoint: config.loginEndpoint,
loginFormat: config.loginFormat,
transferEndpoint: config.transferEndpoint,
transferMethod: config.transferMethod,
transferFormat: config.transferFormat,
transferTemplate: config.transferTemplate,
useBulkMode: config.useBulkMode,
bulkWrapperTemplate: config.bulkWrapperTemplate
}
}
};
} catch (error) {
devLog('[saveConfig] Error:', error);
return {
status: 500,
response: {
success: false,
error: 'Fehler beim Speichern der Konfiguration: ' + error.message
}
};
}
}
/**
* Löscht die Konfiguration
*/
async deleteConfig(userToken, clubId) {
try {
await checkAccess(userToken, clubId);
const deleted = await MemberTransferConfig.destroy({
where: { clubId }
});
if (deleted === 0) {
return {
status: 404,
response: {
success: false,
message: 'Keine Konfiguration gefunden'
}
};
}
return {
status: 200,
response: {
success: true,
message: 'Konfiguration erfolgreich gelöscht'
}
};
} catch (error) {
devLog('[deleteConfig] Error:', error);
return {
status: 500,
response: {
success: false,
error: 'Fehler beim Löschen der Konfiguration: ' + error.message
}
};
}
}
}
export default new MemberTransferConfigService();

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,129 @@
import MyTischtennisFetchLog from '../models/MyTischtennisFetchLog.js';
import { devLog } from '../utils/logger.js';
import { Op } from 'sequelize';
import sequelize from '../database.js';
class MyTischtennisFetchLogService {
/**
* Log a fetch attempt
*/
async logFetch(userId, fetchType, success, message, options = {}) {
try {
await MyTischtennisFetchLog.create({
userId,
fetchType,
success,
message,
errorDetails: options.errorDetails || null,
recordsProcessed: options.recordsProcessed || 0,
executionTime: options.executionTime || null,
isAutomatic: options.isAutomatic || false
});
devLog(`[FetchLog] ${fetchType} - ${success ? 'SUCCESS' : 'FAILED'} - User ${userId}`);
} catch (error) {
console.error('Error logging fetch:', error);
// Don't throw - logging failures shouldn't break the main operation
}
}
/**
* Get fetch logs for a user
*/
async getFetchLogs(userId, options = {}) {
try {
const where = { userId };
if (options.fetchType) {
where.fetchType = options.fetchType;
}
if (options.success !== undefined) {
where.success = options.success;
}
const logs = await MyTischtennisFetchLog.findAll({
where,
order: [['createdAt', 'DESC']],
limit: options.limit || 50,
attributes: [
'id', 'fetchType', 'success', 'message', 'errorDetails',
'recordsProcessed', 'executionTime', 'isAutomatic', 'createdAt'
]
});
return logs;
} catch (error) {
console.error('Error getting fetch logs:', error);
throw error;
}
}
/**
* Get latest successful fetch for each type
*/
async getLatestSuccessfulFetches(userId) {
try {
const fetchTypes = ['ratings', 'match_results', 'league_table'];
const results = {};
for (const fetchType of fetchTypes) {
const latestFetch = await MyTischtennisFetchLog.findOne({
where: {
userId,
fetchType,
success: true
},
order: [['createdAt', 'DESC']],
attributes: ['createdAt', 'recordsProcessed', 'executionTime']
});
results[fetchType] = latestFetch ? {
lastFetch: latestFetch.createdAt,
recordsProcessed: latestFetch.recordsProcessed,
executionTime: latestFetch.executionTime
} : null;
}
return results;
} catch (error) {
console.error('Error getting latest successful fetches:', error);
throw error;
}
}
/**
* Get fetch statistics
*/
async getFetchStatistics(userId, days = 30) {
try {
const since = new Date();
since.setDate(since.getDate() - days);
const stats = await MyTischtennisFetchLog.findAll({
where: {
userId,
createdAt: {
[Op.gte]: since
}
},
attributes: [
'fetchType',
[sequelize.fn('COUNT', sequelize.col('id')), 'totalFetches'],
[sequelize.fn('SUM', sequelize.literal('CASE WHEN success = true THEN 1 ELSE 0 END')), 'successfulFetches'],
[sequelize.fn('SUM', sequelize.col('records_processed')), 'totalRecordsProcessed'],
[sequelize.fn('AVG', sequelize.col('execution_time')), 'avgExecutionTime']
],
group: ['fetchType']
});
return stats;
} catch (error) {
console.error('Error getting fetch statistics:', error);
throw error;
}
}
}
export default new MyTischtennisFetchLogService();

View File

@@ -1,4 +1,5 @@
import MyTischtennis from '../models/MyTischtennis.js';
import MyTischtennisUpdateHistory from '../models/MyTischtennisUpdateHistory.js';
import User from '../models/User.js';
import myTischtennisClient from '../clients/myTischtennisClient.js';
import HttpError from '../exceptions/HttpError.js';
@@ -11,7 +12,7 @@ class MyTischtennisService {
async getAccount(userId) {
const account = await MyTischtennis.findOne({
where: { userId },
attributes: ['id', 'email', 'savePassword', 'lastLoginAttempt', 'lastLoginSuccess', 'expiresAt', 'userData', 'clubId', 'clubName', 'fedNickname', 'createdAt', 'updatedAt']
attributes: ['id', 'userId', 'email', 'savePassword', 'autoUpdateRatings', 'lastLoginAttempt', 'lastLoginSuccess', 'lastUpdateRatings', 'expiresAt', 'userData', 'clubId', 'clubName', 'fedNickname', 'createdAt', 'updatedAt']
});
return account;
}
@@ -19,11 +20,11 @@ class MyTischtennisService {
/**
* Create or update myTischtennis account
*/
async upsertAccount(userId, email, password, savePassword, userPassword) {
async upsertAccount(userId, email, password, savePassword, autoUpdateRatings, userPassword) {
// Verify user's app password
const user = await User.findByPk(userId);
if (!user) {
throw new HttpError(404, 'Benutzer nicht gefunden');
throw new HttpError('Benutzer nicht gefunden', 404);
}
let loginResult = null;
@@ -32,13 +33,13 @@ class MyTischtennisService {
if (password) {
const isValidPassword = await user.validatePassword(userPassword);
if (!isValidPassword) {
throw new HttpError(401, 'Ungültiges Passwort');
throw new HttpError('Ungültiges Passwort', 401);
}
// Login-Versuch bei myTischtennis
loginResult = await myTischtennisClient.login(email, password);
if (!loginResult.success) {
throw new HttpError(401, loginResult.error || 'myTischtennis-Login fehlgeschlagen. Bitte überprüfen Sie Ihre Zugangsdaten.');
throw new HttpError(loginResult.error || 'myTischtennis-Login fehlgeschlagen. Bitte überprüfen Sie Ihre Zugangsdaten.', 401);
}
}
@@ -51,6 +52,7 @@ class MyTischtennisService {
// Update existing
account.email = email;
account.savePassword = savePassword;
account.autoUpdateRatings = autoUpdateRatings;
if (password && savePassword) {
account.setPassword(password);
@@ -88,6 +90,7 @@ class MyTischtennisService {
userId,
email,
savePassword,
autoUpdateRatings,
lastLoginAttempt: password ? now : null,
lastLoginSuccess: loginResult?.success ? now : null
};
@@ -104,6 +107,7 @@ class MyTischtennisService {
if (profileResult.success) {
accountData.clubId = profileResult.clubId;
accountData.clubName = profileResult.clubName;
accountData.fedNickname = profileResult.fedNickname;
}
}
@@ -119,8 +123,10 @@ class MyTischtennisService {
id: account.id,
email: account.email,
savePassword: account.savePassword,
autoUpdateRatings: account.autoUpdateRatings,
lastLoginAttempt: account.lastLoginAttempt,
lastLoginSuccess: account.lastLoginSuccess,
lastUpdateRatings: account.lastUpdateRatings,
expiresAt: account.expiresAt
};
}
@@ -142,22 +148,52 @@ class MyTischtennisService {
const account = await MyTischtennis.findOne({ where: { userId } });
if (!account) {
throw new HttpError(404, 'Kein myTischtennis-Account verknüpft');
throw new HttpError('Kein myTischtennis-Account verknüpft', 404);
}
let password = providedPassword;
// Wenn kein Passwort übergeben wurde, versuche gespeichertes Passwort zu verwenden
if (!password) {
if (!account.savePassword || !account.encryptedPassword) {
throw new HttpError(400, 'Kein Passwort gespeichert. Bitte geben Sie Ihr Passwort ein.');
}
password = account.getPassword();
}
// Login-Versuch
const now = new Date();
account.lastLoginAttempt = now;
let password = providedPassword;
const hasStoredPassword = !!(account.savePassword && account.encryptedPassword);
const hasValidSession = !!(account.accessToken && account.cookie && account.expiresAt && account.expiresAt > Date.now() / 1000);
// Wenn kein Passwort angegeben wurde, entweder gespeichertes Passwort oder bestehende Session verwenden
if (!password) {
if (hasStoredPassword) {
password = account.getPassword();
} else if (hasValidSession) {
// Prüfe, ob bestehende Session noch gültig ist
const profileResult = await myTischtennisClient.getUserProfile(account.cookie);
if (profileResult.success) {
account.lastLoginSuccess = now;
account.clubId = profileResult.clubId || account.clubId;
account.clubName = profileResult.clubName || account.clubName;
account.fedNickname = profileResult.fedNickname || account.fedNickname;
await account.save();
return {
success: true,
accessToken: account.accessToken,
refreshToken: account.refreshToken,
expiresAt: account.expiresAt,
user: account.userData,
clubId: account.clubId,
clubName: account.clubName
};
}
// Session ungültig -> Session-Daten zurücksetzen und speichern
account.accessToken = null;
account.refreshToken = null;
account.cookie = null;
account.expiresAt = null;
await account.save();
throw new HttpError('Kein Passwort gespeichert und Session abgelaufen. Bitte Passwort eingeben.', 400);
} else {
throw new HttpError('Kein Passwort gespeichert. Bitte geben Sie Ihr Passwort ein.', 400);
}
}
// Login-Versuch mit Passwort
const loginResult = await myTischtennisClient.login(account.email, password);
if (loginResult.success) {
@@ -172,9 +208,9 @@ class MyTischtennisService {
const profileResult = await myTischtennisClient.getUserProfile(loginResult.cookie);
if (profileResult.success) {
account.clubId = profileResult.clubId;
account.clubName = profileResult.clubName;
account.fedNickname = profileResult.fedNickname;
account.clubId = profileResult.clubId || account.clubId;
account.clubName = profileResult.clubName || account.clubName;
account.fedNickname = profileResult.fedNickname || account.fedNickname;
} else {
console.error('[myTischtennisService] - Failed to get profile:', profileResult.error);
}
@@ -192,7 +228,7 @@ class MyTischtennisService {
};
} else {
await account.save(); // Save lastLoginAttempt
throw new HttpError(401, loginResult.error || 'myTischtennis-Login fehlgeschlagen');
throw new HttpError(loginResult.error || 'myTischtennis-Login fehlgeschlagen', 401);
}
}
@@ -206,7 +242,7 @@ class MyTischtennisService {
exists: !!account,
hasEmail: !!account?.email,
hasPassword: !!(account?.savePassword && account?.encryptedPassword),
hasValidSession: !!account?.accessToken && account?.expiresAt > Date.now() / 1000,
hasValidSession: !!(account?.accessToken && account?.cookie && account?.expiresAt && account?.expiresAt > Date.now() / 1000),
needsConfiguration: !account || !account.email,
needsPassword: !!account && (!account.savePassword || !account.encryptedPassword)
};
@@ -219,12 +255,12 @@ class MyTischtennisService {
const account = await MyTischtennis.findOne({ where: { userId } });
if (!account) {
throw new HttpError(404, 'Kein myTischtennis-Account verknüpft');
throw new HttpError('Kein myTischtennis-Account verknüpft', 404);
}
// Check if session is valid
if (!account.accessToken || !account.expiresAt || account.expiresAt < Date.now() / 1000) {
throw new HttpError(401, 'Session abgelaufen. Bitte erneut einloggen.');
throw new HttpError('Session abgelaufen. Bitte erneut einloggen.', 401);
}
return {
@@ -235,6 +271,53 @@ class MyTischtennisService {
userData: account.userData
};
}
/**
* Get update ratings history for user
*/
async getUpdateHistory(userId) {
const history = await MyTischtennisUpdateHistory.findAll({
where: { userId },
order: [['createdAt', 'DESC']],
limit: 50 // Letzte 50 Einträge
});
return history.map(entry => ({
id: entry.id,
success: entry.success,
message: entry.message,
errorDetails: entry.errorDetails,
updatedCount: entry.updatedCount,
executionTime: entry.executionTime,
createdAt: entry.createdAt
}));
}
/**
* Log update ratings attempt
*/
async logUpdateAttempt(userId, success, message, errorDetails = null, updatedCount = 0, executionTime = null) {
try {
await MyTischtennisUpdateHistory.create({
userId,
success,
message,
errorDetails,
updatedCount,
executionTime
});
// Update lastUpdateRatings in main table
if (success) {
await MyTischtennis.update(
{ lastUpdateRatings: new Date() },
{ where: { userId } }
);
}
} catch (error) {
console.error('Error logging update attempt:', error);
}
}
}
export default new MyTischtennisService();

View File

@@ -0,0 +1,297 @@
import { devLog } from '../utils/logger.js';
class MyTischtennisUrlParserService {
/**
* Parse myTischtennis URL and extract configuration data
*
* Example URL:
* https://www.mytischtennis.de/click-tt/HeTTV/25--26/ligen/1.Kreisklasse/gruppe/504417/mannschaft/2995094/Harheimer_TC_(J11)/spielerbilanzen/gesamt
*
* @param {string} url - The myTischtennis URL
* @returns {Object} Parsed configuration data
*/
parseUrl(url) {
try {
// Remove trailing slash if present
url = url.trim().replace(/\/$/, '');
// Try different URL patterns
// Pattern 1: Team URL with mannschaft/{teamId}/{teamname}
// /click-tt/{association}/{season}/{type}/{groupname}/gruppe/{groupId}/mannschaft/{teamId}/{teamname}/...
const teamPattern = /\/click-tt\/([^\/]+)\/([^\/]+)\/([^\/]+)\/([^\/]+)\/gruppe\/([^\/]+)\/mannschaft\/([^\/]+)\/([^\/]+)/;
// Pattern 2: Table/Group URL without team info
// /click-tt/{association}/{season}/{type}/{groupname}/gruppe/{groupId}/tabelle/gesamt
const tablePattern = /\/click-tt\/([^\/]+)\/([^\/]+)\/([^\/]+)\/([^\/]+)\/gruppe\/([^\/]+)\/tabelle\/([^\/]+)/;
let match = url.match(teamPattern);
let isTeamUrl = true;
if (!match) {
match = url.match(tablePattern);
isTeamUrl = false;
}
if (!match) {
throw new Error('URL format not recognized. Expected formats:\n' +
'- Team URL: /click-tt/{association}/{season}/{type}/{groupname}/gruppe/{groupId}/mannschaft/{teamId}/{teamname}/...\n' +
'- Table URL: /click-tt/{association}/{season}/{type}/{groupname}/gruppe/{groupId}/tabelle/gesamt');
}
const [
,
association,
seasonRaw,
type,
groupnameEncoded,
groupId,
...rest
] = match;
// Decode and process values
const seasonShort = seasonRaw.replace('--', '/'); // "25--26" -> "25/26"
const season = this.convertToFullSeason(seasonShort); // "25/26" -> "2025/2026"
const groupnameDecoded = this.decodeGroupName(groupnameEncoded);
const result = {
association,
season,
seasonShort, // Für API-Calls
type,
groupname: groupnameDecoded, // Dekodierte Version für Anzeige
groupnameOriginal: groupnameEncoded, // Originale URL-kodierte Version
groupId,
originalUrl: url,
urlType: isTeamUrl ? 'team' : 'table'
};
if (isTeamUrl) {
// Team URL: extract team info
const [teamId, teamnameEncoded] = rest;
const teamname = decodeURIComponent(teamnameEncoded).replace(/_/g, ' '); // "Harheimer_TC_(J11)" -> "Harheimer TC (J11)"
result.teamId = teamId;
result.teamname = teamname;
} else {
// Table URL: no team info
result.teamId = null;
result.teamname = null;
}
devLog('Parsed myTischtennis URL:', result);
return result;
} catch (error) {
console.error('Error parsing myTischtennis URL:', error);
throw error;
}
}
/**
* Decode group name from URL-encoded format
* "2._Kreisklasse_Gr._2" -> "2. Kreisklasse Gr. 2"
*/
decodeGroupName(encodedName) {
// First decode URI components
let decoded = decodeURIComponent(encodedName);
// Replace underscores with spaces
decoded = decoded.replace(/_/g, ' ');
// Clean up multiple spaces
decoded = decoded.replace(/\s+/g, ' ');
return decoded.trim();
}
/**
* Convert short season format to full format
* "25/26" -> "2025/2026"
* "24/25" -> "2024/2025"
*/
convertToFullSeason(seasonShort) {
const parts = seasonShort.split('/');
if (parts.length !== 2) {
return seasonShort;
}
const year1 = parseInt(parts[0]);
const year2 = parseInt(parts[1]);
// Determine century based on year1
// If year1 < 50, assume 20xx, otherwise 19xx
const century1 = year1 < 50 ? 2000 : 1900;
const century2 = year2 < 50 ? 2000 : 1900;
const fullYear1 = century1 + year1;
const fullYear2 = century2 + year2;
return `${fullYear1}/${fullYear2}`;
}
/**
* Convert full season format to short format
* "2025/2026" -> "25/26"
* "2024/2025" -> "24/25"
*/
convertToShortSeason(seasonFull) {
const parts = seasonFull.split('/');
if (parts.length !== 2) {
return seasonFull;
}
const year1 = parseInt(parts[0]);
const year2 = parseInt(parts[1]);
const shortYear1 = String(year1).slice(-2);
const shortYear2 = String(year2).slice(-2);
return `${shortYear1}/${shortYear2}`;
}
/**
* Fetch additional team data from myTischtennis
*
* @param {Object} parsedUrl - Parsed URL data from parseUrl()
* @param {string} cookie - Authentication cookie
* @param {string} accessToken - Access token
* @returns {Object} Additional team data
*/
async fetchTeamData(parsedUrl, cookie, accessToken) {
try {
const { association, seasonShort, type, groupname, groupId, teamId, teamname } = parsedUrl;
const seasonStr = seasonShort.replace('/', '--');
const teamnameEncoded = encodeURIComponent(teamname.replace(/\s/g, '_'));
// Build the API URL
const apiUrl = `https://www.mytischtennis.de/click-tt/${association}/${seasonStr}/${type}/${encodeURIComponent(groupname)}/gruppe/${groupId}/mannschaft/${teamId}/${teamnameEncoded}/spielerbilanzen/gesamt?_data=routes%2Fclick-tt%2B%2F%24association%2B%2F%24season%2B%2F%24type%2B%2F%28%24groupname%29.gruppe.%24urlid_.mannschaft.%24teamid.%24teamname%2B%2Fspielerbilanzen.%24filter`;
devLog(`Fetching team data from: ${apiUrl}`);
const response = await fetch(apiUrl, {
headers: {
'Cookie': cookie || '',
'Authorization': `Bearer ${accessToken}`,
'Accept': 'application/json',
'User-Agent': 'Mozilla/5.0'
}
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
// Extract additional information
const teamData = {
clubId: null,
clubName: null,
teamName: null,
leagueName: null,
leagueShortName: null,
region: null,
tableRank: null,
matchesWon: null,
matchesLost: null
};
if (data.data && data.data.head_infos) {
const headInfos = data.data.head_infos;
teamData.clubId = data.data.balancesheet?.[0]?.club_id || null;
teamData.clubName = headInfos.club_name;
teamData.teamName = headInfos.team_name;
teamData.leagueName = headInfos.league_name;
teamData.region = headInfos.region;
teamData.tableRank = headInfos.team_table_rank;
teamData.matchesWon = headInfos.team_matches_won;
teamData.matchesLost = headInfos.team_matches_lost;
}
devLog('Fetched team data:', teamData);
return {
...parsedUrl,
...teamData,
fullData: data
};
} catch (error) {
console.error('Error fetching team data:', error);
throw error;
}
}
/**
* Complete configuration from URL
* Combines URL parsing and data fetching
*
* @param {string} url - The myTischtennis URL
* @param {string} cookie - Authentication cookie (optional)
* @param {string} accessToken - Access token (optional)
* @returns {Object} Complete configuration data
*/
async getCompleteConfig(url, cookie = null, accessToken = null) {
const parsedUrl = this.parseUrl(url);
if (cookie && accessToken) {
return await this.fetchTeamData(parsedUrl, cookie, accessToken);
}
return parsedUrl;
}
/**
* Validate if URL is a valid myTischtennis team URL
*
* @param {string} url - The URL to validate
* @returns {boolean} True if valid
*/
isValidTeamUrl(url) {
try {
this.parseUrl(url);
return true;
} catch {
return false;
}
}
isValidUrl(url) {
return this.isValidTeamUrl(url); // Alias for backward compatibility
}
/**
* Build myTischtennis URL from components
*
* @param {Object} config - Configuration object
* @returns {string} The constructed URL
*/
buildUrl(config) {
const {
association,
season,
type = 'ligen',
groupname,
groupId,
teamId,
teamname,
urlType = 'team'
} = config;
// Convert full season to short format for URL
const seasonShort = this.convertToShortSeason(season);
const seasonStr = seasonShort.replace('/', '--');
const groupnameEncoded = encodeURIComponent(groupname);
if (urlType === 'table' || !teamId || !teamname) {
// Build table URL
return `https://www.mytischtennis.de/click-tt/${association}/${seasonStr}/${type}/${groupnameEncoded}/gruppe/${groupId}/tabelle/gesamt`;
} else {
// Build team URL
const teamnameEncoded = encodeURIComponent(teamname.replace(/\s/g, '_'));
return `https://www.mytischtennis.de/click-tt/${association}/${seasonStr}/${type}/${groupnameEncoded}/gruppe/${groupId}/mannschaft/${teamId}/${teamnameEncoded}/spielerbilanzen/gesamt`;
}
}
}
export default new MyTischtennisUrlParserService();

View File

@@ -32,19 +32,28 @@ class PDFParserService {
// Bestimme Dateityp basierend auf Dateiendung
const fileExtension = path.extname(filePath).toLowerCase();
let fileContent;
let extractedLines = null;
let lineEntries = null;
if (fileExtension === '.pdf') {
// Echte PDF-Parsing
const pdfBuffer = fs.readFileSync(filePath);
const pdfData = await pdfParse(pdfBuffer);
fileContent = pdfData.text;
try {
const { text, lines, entries } = await this.extractPdfTextWithLayout(filePath);
fileContent = text;
extractedLines = lines;
lineEntries = entries;
} catch (layoutError) {
console.error('[PDFParserService.parsePDF] - Layout extraction failed, falling back to pdf-parse:', layoutError);
const pdfBuffer = fs.readFileSync(filePath);
const pdfData = await pdfParse(pdfBuffer);
fileContent = pdfData.text;
}
} else {
// Fallback für TXT-Dateien (für Tests)
fileContent = fs.readFileSync(filePath, 'utf8');
}
// Parse den Text nach Spiel-Daten
const parsedData = this.extractMatchData(fileContent, clubId);
const parsedData = this.extractMatchData(fileContent, clubId, extractedLines, lineEntries);
return parsedData;
@@ -60,7 +69,7 @@ class PDFParserService {
* @param {number} clubId - ID des Vereins
* @returns {Object} Geparste Daten mit Matches und Metadaten
*/
static extractMatchData(text, clubId) {
static extractMatchData(text, clubId, providedLines = null, providedLineEntries = null) {
const matches = [];
const errors = [];
const metadata = {
@@ -71,21 +80,33 @@ class PDFParserService {
try {
// Teile Text in Zeilen auf
const lines = text.split('\n').map(line => line.trim()).filter(line => line.length > 0);
const linesSource = providedLines && Array.isArray(providedLines) ? providedLines : text.split('\n');
const lines = [];
const filteredLineEntries = [];
linesSource.forEach((line, idx) => {
const trimmed = typeof line === 'string' ? line.trim() : '';
if (trimmed.length > 0) {
lines.push(trimmed);
if (providedLineEntries && Array.isArray(providedLineEntries) && providedLineEntries[idx]) {
filteredLineEntries.push(providedLineEntries[idx]);
}
}
});
metadata.totalLines = lines.length;
// Verschiedene Parsing-Strategien je nach PDF-Format
const strategies = [
{ name: 'Standard Format', fn: this.parseStandardFormat },
{ name: 'Table Format', fn: this.parseTableFormat },
{ name: 'List Format', fn: this.parseListFormat }
{ name: 'Standard Format', fn: (lns, club, entries) => PDFParserService.parseStandardFormat(lns, club, entries) },
{ name: 'Table Format', fn: (lns, club, entries) => PDFParserService.parseTableFormat(lns, club, entries) },
{ name: 'List Format', fn: (lns, club, entries) => PDFParserService.parseListFormat(lns, club, entries) }
];
for (const strategy of strategies) {
try {
const result = strategy.fn(lines, clubId);
const result = strategy.fn(lines, clubId, filteredLineEntries.length === lines.length ? filteredLineEntries : null);
if (result.matches.length > 0) {
matches.push(...result.matches);
@@ -124,12 +145,29 @@ class PDFParserService {
* @param {number} clubId - ID des Vereins
* @returns {Object} Geparste Matches
*/
static parseStandardFormat(lines, clubId) {
static parseStandardFormat(lines, clubId, lineEntries = null) {
const matches = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const lineDetail = Array.isArray(lineEntries) ? lineEntries[i] : null;
const columnSegments = lineDetail ? this.segmentLineByPositions(lineDetail) : null;
let homeFromColumns = null;
let guestFromColumns = null;
let codeFromColumns = null;
if (columnSegments && columnSegments.length >= 3) {
homeFromColumns = columnSegments[1]?.trim() || null;
guestFromColumns = columnSegments[2]?.trim() || null;
const lastSegment = columnSegments[columnSegments.length - 1];
if (lastSegment) {
const candidateCode = lastSegment.replace(/\s+/g, '').trim();
if (/^[A-Z0-9]{12}$/.test(candidateCode)) {
codeFromColumns = candidateCode;
}
}
}
// Suche nach Datum-Pattern (dd.mm.yyyy oder dd/mm/yyyy)
const dateMatch = line.match(/(\d{1,2})[./](\d{1,2})[./](\d{4})/);
@@ -148,16 +186,21 @@ class PDFParserService {
const [, day, month, year] = dateMatch;
const date = new Date(`${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`);
// Suche nach Zeit-Pattern direkt nach dem Datum (hh:mm) - Format: Wt.dd.mm.yyyyhh:MM
const timeMatch = line.match(/(\d{1,2})[./](\d{1,2})[./](\d{4})(\d{1,2}):(\d{2})/);
// Suche nach Zeit-Pattern (hh:mm) - kann direkt nach Datum oder mit Leerzeichen sein
const timeMatch = line.match(/(\d{1,2}):(\d{2})/);
let time = null;
if (timeMatch) {
time = `${timeMatch[4].padStart(2, '0')}:${timeMatch[5]}`;
time = `${timeMatch[1].padStart(2, '0')}:${timeMatch[2]}`;
}
// Entferne Datum und Zeit vom Anfang der Zeile
const cleanLine = line.replace(/^[A-Za-z]{2}\.(\d{1,2})[./](\d{1,2})[./](\d{4})(\d{1,2}):(\d{2})\s*/, '');
// Entferne Datum (mit optionalem Wochentag) und Zeit vom Anfang der Zeile
// Format: "Sa. 06.09.2025 10:00" oder "06.09.2025 10:00"
const cleanLine = line
.replace(/^[A-Za-z]{2,3}\.\s*/, '') // Entferne Wochentag (z.B. "Sa. ", "Mo. ", "Fre. ")
.replace(/^\d{1,2}[./]\d{1,2}[./]\d{4}/, '') // Entferne Datum
.replace(/^\s*\d{1,2}:\d{2}/, '') // Entferne Zeit
.trim();
// Entferne Nummerierung am Anfang (z.B. "(1)")
const cleanLine2 = cleanLine.replace(/^\(\d+\)/, '');
@@ -166,7 +209,7 @@ class PDFParserService {
const cleanLine3 = cleanLine2.replace(/\([^)]*\)/g, '');
// Suche nach Code (12 Zeichen) oder PIN (4 Ziffern) am Ende
const codeMatch = cleanLine3.match(/([A-Z0-9]{12})$/);
let codeMatch = cleanLine3.match(/([A-Z0-9]{12})$/);
const pinMatch = cleanLine3.match(/(\d{4})$/);
let code = null;
@@ -183,22 +226,35 @@ class PDFParserService {
const pin = pinMatch[1];
teamsPart = cleanLine3.substring(0, cleanLine3.length - pin.length).trim();
// PIN gehört zu dem Team, das direkt vor der PIN steht
// Analysiere die Position der PIN in der ursprünglichen Zeile
const pinIndex = cleanLine3.lastIndexOf(pin);
const teamsPartIndex = cleanLine3.indexOf(teamsPart);
// Die PIN gehört immer zu "Harheimer TC"
// Prüfe, ob "Harheimer TC" am Anfang oder am Ende steht
const harheimerIndex = teamsPart.indexOf('Harheimer TC');
// Wenn PIN direkt nach dem Teams-Part steht, gehört sie zur Heimmannschaft
// Wenn PIN zwischen den Teams steht, gehört sie zur Gastmannschaft
if (pinIndex === teamsPartIndex + teamsPart.length) {
// PIN steht direkt nach den Teams -> Heimmannschaft
homePin = pin;
if (harheimerIndex >= 0) {
// "Harheimer TC" gefunden
let beforeHarheimer = teamsPart.substring(0, harheimerIndex).trim();
// Entferne führende Spielnummer (z.B. "1", "2", etc.)
beforeHarheimer = beforeHarheimer.replace(/^\d+/, '').trim();
if (beforeHarheimer && beforeHarheimer.length > 0) {
// Es gibt einen Team-Namen vor "Harheimer TC" → Harheimer ist Gastteam → guestPin
guestPin = pin;
} else {
// "Harheimer TC" steht am Anfang (nur Spielnummer davor) → Harheimer ist Heimteam → homePin
homePin = pin;
}
} else {
// PIN steht zwischen den Teams -> Gastmannschaft
// "Harheimer TC" nicht gefunden → Standardlogik: PIN gehört zum Gastteam
guestPin = pin;
}
}
if (!code && codeFromColumns) {
code = codeFromColumns;
teamsPart = teamsPart.replace(new RegExp(`${code}$`), '').trim();
}
if (code || pinMatch) {
@@ -249,15 +305,92 @@ class PDFParserService {
} else {
// Fallback: Versuche mit einzelnen Leerzeichen zu trennen
// Strategie 1: Suche nach "Harheimer TC" als Heimteam
// Strategie 1: Suche nach "Harheimer TC" als Heimteam oder Gastteam
if (teamsPart.includes('Harheimer TC')) {
const harheimerIndex = teamsPart.indexOf('Harheimer TC');
homeTeamName = 'Harheimer TC';
guestTeamName = teamsPart.substring(harheimerIndex + 'Harheimer TC'.length).trim();
let beforeHarheimer = teamsPart.substring(0, harheimerIndex).trim();
let afterHarheimer = teamsPart.substring(harheimerIndex + 'Harheimer TC'.length).trim();
// Entferne Klammern aus Gastteam
beforeHarheimer = beforeHarheimer
.replace(/^\(\d+\)/, '')
.replace(/^\d+/, '')
.trim();
afterHarheimer = afterHarheimer
.replace(/^\(\d+\)/, '')
.replace(/^\d+/, '')
.trim();
const romanNumeralCandidates = ['XII', 'XI', 'X', 'IX', 'VIII', 'VII', 'VI', 'V', 'IV', 'III', 'II', 'I'];
const matchLeadingRoman = (token) => {
if (!token) {
return null;
}
const normalizedToken = token.trim();
for (const candidate of romanNumeralCandidates) {
if (normalizedToken.startsWith(candidate)) {
const nextChar = normalizedToken.charAt(candidate.length);
if (!nextChar || /\s|[A-ZÄÖÜẞ]/.test(nextChar)) {
const remainder = normalizedToken.slice(candidate.length).trimStart();
return { roman: candidate, remainder };
}
}
}
return null;
};
const extractLeadingRomanFromTokens = (tokenList) => {
const tokensCopy = Array.isArray(tokenList) ? [...tokenList] : [];
if (tokensCopy.length === 0) {
return { roman: null, tokens: tokensCopy };
}
const firstToken = tokensCopy[0];
const match = matchLeadingRoman(firstToken);
if (match) {
const { roman, remainder } = match;
if (remainder) {
tokensCopy[0] = remainder;
} else {
tokensCopy.shift();
}
return { roman, tokens: tokensCopy };
}
return { roman: null, tokens: tokensCopy };
};
if (!beforeHarheimer && afterHarheimer) {
const tokens = afterHarheimer.split(/\s+/).filter(Boolean);
const { roman: homeRoman, tokens: guestTokens } = extractLeadingRomanFromTokens(tokens);
const homeSuffix = homeRoman ? ` ${homeRoman}` : '';
homeTeamName = `Harheimer TC${homeSuffix}`;
guestTeamName = guestTokens.join(' ').trim();
} else if (beforeHarheimer && !afterHarheimer) {
// "Harheimer TC" ist Gastteam ohne weitere Tokens
homeTeamName = beforeHarheimer.replace(/\([^)]*\)/g, '').trim();
guestTeamName = 'Harheimer TC';
} else if (beforeHarheimer && afterHarheimer) {
// "Harheimer TC" steht in der Mitte → Harheimer ist Gast, Tokens nach Harheimer gehören zu ihm
homeTeamName = beforeHarheimer.replace(/\([^)]*\)/g, '').trim();
const tokens = afterHarheimer.split(/\s+/).filter(Boolean);
const { roman: guestRoman, tokens: remainingTokens } = extractLeadingRomanFromTokens(tokens);
const guestSuffix = guestRoman ? ` ${guestRoman}` : '';
guestTeamName = `Harheimer TC${guestSuffix}`;
if (remainingTokens.length > 0) {
const trailingText = remainingTokens.join(' ').trim();
if (trailingText) {
guestTeamName = `${guestTeamName} ${trailingText}`.trim();
}
}
} else {
// Nur "Harheimer TC" ohne weitere Kontexte → überspringen
continue;
}
homeTeamName = homeTeamName.replace(/\([^)]*\)/g, '').trim();
guestTeamName = guestTeamName.replace(/\([^)]*\)/g, '').trim();
} else {
// Strategie 2: Suche nach Großbuchstaben am Anfang des zweiten Teams
const teamSplitMatch = teamsPart.match(/^([A-Za-z0-9\s\-\.]+?)\s+([A-Z][A-Za-z0-9\s\-\.]+)$/);
@@ -272,17 +405,14 @@ class PDFParserService {
}
}
if (homeFromColumns) {
homeTeamName = homeFromColumns;
}
if (guestFromColumns) {
guestTeamName = guestFromColumns;
}
if (homeTeamName && guestTeamName) {
let debugInfo;
if (code) {
debugInfo = `code: "${code}"`;
} else if (homePin && guestPin) {
debugInfo = `homePin: "${homePin}", guestPin: "${guestPin}"`;
} else if (homePin) {
debugInfo = `homePin: "${homePin}"`;
} else if (guestPin) {
debugInfo = `guestPin: "${guestPin}"`;
}
matches.push({
date: date,
@@ -306,13 +436,59 @@ class PDFParserService {
return { matches };
}
static segmentLineByPositions(lineDetail) {
if (!lineDetail || !Array.isArray(lineDetail.items)) {
return null;
}
const intraWordGapThreshold = 1.5;
const columnGapThreshold = 12;
const segments = [];
let currentSegment = '';
let previousItem = null;
lineDetail.items.forEach((item) => {
if (!item || typeof item.text !== 'string') {
return;
}
const text = item.text;
if (!text || text.trim().length === 0) {
return;
}
if (previousItem) {
const previousEnd = previousItem.x + previousItem.width;
const gap = item.x - previousEnd;
if (gap > columnGapThreshold) {
if (currentSegment.trim().length > 0) {
segments.push(currentSegment.trim());
}
currentSegment = '';
} else if (gap > intraWordGapThreshold) {
currentSegment += ' ';
}
}
currentSegment += text;
previousItem = item;
});
if (currentSegment.trim().length > 0) {
segments.push(currentSegment.trim());
}
return segments.length > 0 ? segments : null;
}
/**
* Tabellen-Format Parser
* @param {Array} lines - Textzeilen
* @param {number} clubId - ID des Vereins
* @returns {Object} Geparste Matches
*/
static parseTableFormat(lines, clubId) {
static parseTableFormat(lines, clubId, lineEntries = null) {
const matches = [];
// Suche nach Tabellen-Header
@@ -376,7 +552,7 @@ class PDFParserService {
* @param {number} clubId - ID des Vereins
* @returns {Object} Geparste Matches
*/
static parseListFormat(lines, clubId) {
static parseListFormat(lines, clubId, lineEntries = null) {
const matches = [];
for (let i = 0; i < lines.length; i++) {
@@ -507,13 +683,10 @@ class PDFParserService {
const matchingMatch = existingMatches.find(match => {
if (!match.guestTeam) return false;
const guestTeamName = match.guestTeam.name.toLowerCase();
const searchGuestName = matchData.guestTeamName.toLowerCase();
// Exakte Übereinstimmung oder Teilstring-Match
return guestTeamName === searchGuestName ||
guestTeamName.includes(searchGuestName) ||
searchGuestName.includes(guestTeamName);
const guestTeamName = match.guestTeam.name;
const searchGuestName = matchData.guestTeamName;
return PDFParserService.namesRoughlyMatch(guestTeamName, searchGuestName);
});
if (matchingMatch) {
@@ -554,40 +727,38 @@ class PDFParserService {
} else {
// Fallback: Versuche Teams direkt zu finden
const homeTeam = await Team.findOne({
let homeTeam = await Team.findOne({
where: {
name: matchData.homeTeamName,
clubId: matchData.clubId
}
});
const guestTeam = await Team.findOne({
let guestTeam = await Team.findOne({
where: {
name: matchData.guestTeamName,
clubId: matchData.clubId
}
});
// Debug: Zeige alle verfügbaren Teams für diesen Club
// If exact match failed, try fuzzy matching
if (!homeTeam || !guestTeam) {
const allTeams = await Team.findAll({
where: { clubId: matchData.clubId },
attributes: ['id', 'name']
});
// Versuche Fuzzy-Matching für Team-Namen
const homeTeamFuzzy = allTeams.find(t =>
t.name.toLowerCase().includes(matchData.homeTeamName.toLowerCase()) ||
matchData.homeTeamName.toLowerCase().includes(t.name.toLowerCase())
);
const guestTeamFuzzy = allTeams.find(t =>
t.name.toLowerCase().includes(matchData.guestTeamName.toLowerCase()) ||
matchData.guestTeamName.toLowerCase().includes(t.name.toLowerCase())
);
if (homeTeamFuzzy) {
// Fuzzy-Matching für Team-Namen
if (!homeTeam) {
homeTeam = allTeams.find(t =>
PDFParserService.namesRoughlyMatch(t.name, matchData.homeTeamName)
);
}
if (guestTeamFuzzy) {
if (!guestTeam) {
guestTeam = allTeams.find(t =>
PDFParserService.namesRoughlyMatch(t.name, matchData.guestTeamName)
);
}
}
@@ -633,6 +804,150 @@ class PDFParserService {
throw error;
}
}
static async extractPdfTextWithLayout(filePath) {
const { default: pdfjsLib } = await import('pdfjs-dist/legacy/build/pdf.js');
const pdfData = new Uint8Array(fs.readFileSync(filePath));
const loadingTask = pdfjsLib.getDocument({ data: pdfData, disableWorker: true });
const pdf = await loadingTask.promise;
const lineEntries = [];
const lineTolerance = 2; // Toleranz für Zeilenhöhe
const spaceGapThreshold = 1.5; // Mindestabstand, um ein Leerzeichen einzufügen
for (let pageNumber = 1; pageNumber <= pdf.numPages; pageNumber++) {
const page = await pdf.getPage(pageNumber);
const textContent = await page.getTextContent({ normalizeWhitespace: false });
const pageLines = [];
textContent.items.forEach((item) => {
if (!item || typeof item.str !== 'string') {
return;
}
const text = item.str;
if (!text || text.trim().length === 0) {
return;
}
const [scaleX, , , , x, y] = item.transform;
const width = (item.width || 0) * (scaleX || 1);
let targetLine = pageLines.find((line) => Math.abs(line.y - y) < lineTolerance);
if (!targetLine) {
targetLine = { y, items: [] };
pageLines.push(targetLine);
}
targetLine.items.push({
text,
x,
y,
width
});
});
// Sortiere Zeilen von oben nach unten
pageLines.sort((a, b) => b.y - a.y);
pageLines.forEach((line) => {
// Sortiere Zeichen von links nach rechts
line.items.sort((a, b) => a.x - b.x);
let lineText = '';
let previousItem = null;
line.items.forEach((item) => {
if (previousItem) {
const previousEnd = previousItem.x + previousItem.width;
const gap = item.x - previousEnd;
if (gap > spaceGapThreshold) {
lineText += ' ';
}
}
lineText += item.text;
previousItem = item;
});
const normalized = lineText.trim();
if (normalized.length > 0) {
lineEntries.push({
text: normalized,
items: line.items.map((item) => ({
text: item.text,
x: item.x,
y: item.y,
width: item.width
}))
});
}
});
}
await pdf.destroy();
const lines = lineEntries.map((entry) => entry.text);
const text = lines.join('\n');
return { text, lines, entries: lineEntries };
}
static normalizeTeamName(name) {
if (!name || typeof name !== 'string') return '';
return name
.toLowerCase()
.replace(/\u2026/g, '...')
.replace(/\s+/g, ' ')
.trim();
}
static matchWithEllipsis(pattern, target) {
const normalizedPattern = PDFParserService.normalizeTeamName(pattern);
const normalizedTarget = PDFParserService.normalizeTeamName(target);
if (!normalizedPattern.includes('...')) {
return normalizedTarget.includes(normalizedPattern);
}
const segments = normalizedPattern.split('...').map(segment => segment.trim()).filter(Boolean);
if (segments.length === 0) {
return true;
}
let currentIndex = 0;
for (const segment of segments) {
const foundIndex = normalizedTarget.indexOf(segment, currentIndex);
if (foundIndex === -1) {
return false;
}
currentIndex = foundIndex + segment.length;
}
return true;
}
static namesRoughlyMatch(nameA, nameB) {
const normalizedA = PDFParserService.normalizeTeamName(nameA);
const normalizedB = PDFParserService.normalizeTeamName(nameB);
if (!normalizedA || !normalizedB) {
return false;
}
if (normalizedA === normalizedB) {
return true;
}
if (normalizedA.includes('...') || normalizedB.includes('...')) {
if (PDFParserService.matchWithEllipsis(normalizedA, normalizedB)) {
return true;
}
if (PDFParserService.matchWithEllipsis(normalizedB, normalizedA)) {
return true;
}
}
return normalizedA.includes(normalizedB) || normalizedB.includes(normalizedA);
}
}
export default PDFParserService;

View File

@@ -0,0 +1,393 @@
import UserClub from '../models/UserClub.js';
import Club from '../models/Club.js';
import User from '../models/User.js';
/**
* Permission Service
* Handles all permission-related logic
*/
// Default permissions for each role
const ROLE_PERMISSIONS = {
admin: {
diary: { read: true, write: true, delete: true },
members: { read: true, write: true, delete: true },
teams: { read: true, write: true, delete: true },
schedule: { read: true, write: true, delete: true },
tournaments: { read: true, write: true, delete: true },
statistics: { read: true, write: true },
settings: { read: true, write: true },
permissions: { read: true, write: true }, // Can manage other users' permissions
approvals: { read: true, write: true },
mytischtennis_admin: { read: true, write: true },
predefined_activities: { read: true, write: true, delete: true }
},
trainer: {
diary: { read: true, write: true, delete: true },
members: { read: true, write: true, delete: false },
teams: { read: true, write: true, delete: false },
schedule: { read: true, write: false, delete: false },
tournaments: { read: true, write: true, delete: false },
statistics: { read: true, write: false },
settings: { read: false, write: false },
permissions: { read: false, write: false },
approvals: { read: false, write: false },
mytischtennis_admin: { read: false, write: false },
predefined_activities: { read: true, write: true, delete: true }
},
team_manager: {
diary: { read: false, write: false, delete: false },
members: { read: true, write: false, delete: false },
teams: { read: true, write: true, delete: false },
schedule: { read: true, write: true, delete: false },
tournaments: { read: true, write: false, delete: false },
statistics: { read: true, write: false },
settings: { read: false, write: false },
permissions: { read: false, write: false },
approvals: { read: false, write: false },
mytischtennis_admin: { read: false, write: false },
predefined_activities: { read: false, write: false, delete: false }
},
tournament_manager: {
diary: { read: false, write: false, delete: false },
members: { read: true, write: false, delete: false },
teams: { read: false, write: false, delete: false },
schedule: { read: false, write: false, delete: false },
tournaments: { read: true, write: true, delete: false },
statistics: { read: true, write: false },
settings: { read: false, write: false },
permissions: { read: false, write: false },
approvals: { read: false, write: false },
mytischtennis_admin: { read: false, write: false },
predefined_activities: { read: false, write: false, delete: false }
},
member: {
diary: { read: false, write: false, delete: false },
members: { read: false, write: false, delete: false },
teams: { read: false, write: false, delete: false },
schedule: { read: false, write: false, delete: false },
tournaments: { read: false, write: false, delete: false },
statistics: { read: true, write: false },
settings: { read: false, write: false },
permissions: { read: false, write: false },
approvals: { read: false, write: false },
mytischtennis_admin: { read: false, write: false },
predefined_activities: { read: false, write: false, delete: false }
}
};
class PermissionService {
/**
* Get user's permissions for a specific club
*/
async getUserClubPermissions(userId, clubId) {
const userClub = await UserClub.findOne({
where: {
userId,
clubId,
approved: true
}
});
if (!userClub) {
return null;
}
// If user is owner, they have full admin rights
if (userClub.isOwner) {
return {
role: 'admin',
isOwner: true,
permissions: ROLE_PERMISSIONS.admin
};
}
// Get role from database, fallback to 'member' if null/undefined
const role = userClub.role || 'member';
// Get role-based permissions
const rolePermissions = ROLE_PERMISSIONS[role] || ROLE_PERMISSIONS.member;
// Merge with custom permissions if any
const customPermissions = userClub.permissions || {};
const mergedPermissions = this.mergePermissions(rolePermissions, customPermissions);
return {
role: role,
isOwner: false,
permissions: mergedPermissions
};
}
/**
* Check if user has specific permission
*/
async hasPermission(userId, clubId, resource, action) {
const userPermissions = await this.getUserClubPermissions(userId, clubId);
if (!userPermissions) {
return false;
}
// Owner always has permission
if (userPermissions.isOwner) {
return true;
}
// MyTischtennis settings are accessible to all approved members
if (resource === 'mytischtennis') {
return true;
}
const resourcePermissions = userPermissions.permissions[resource];
if (!resourcePermissions) {
return false;
}
return resourcePermissions[action] === true;
}
/**
* Set user role in club
*/
async setUserRole(userId, clubId, role, updatedByUserId) {
// Check if updater has permission
const canManagePermissions = await this.hasPermission(updatedByUserId, clubId, 'permissions', 'write');
if (!canManagePermissions) {
throw new Error('Keine Berechtigung zum Ändern von Rollen');
}
// Check if target user is owner
const targetUserClub = await UserClub.findOne({
where: { userId, clubId }
});
if (!targetUserClub) {
throw new Error('Benutzer ist kein Mitglied dieses Clubs');
}
if (targetUserClub.isOwner) {
throw new Error('Die Rolle des Club-Erstellers kann nicht geändert werden');
}
// Validate role
if (!ROLE_PERMISSIONS[role]) {
throw new Error('Ungültige Rolle');
}
await targetUserClub.update({ role });
return {
success: true,
message: 'Rolle erfolgreich aktualisiert'
};
}
/**
* Set custom permissions for user
*/
async setCustomPermissions(userId, clubId, customPermissions, updatedByUserId) {
// Check if updater has permission
const canManagePermissions = await this.hasPermission(updatedByUserId, clubId, 'permissions', 'write');
if (!canManagePermissions) {
throw new Error('Keine Berechtigung zum Ändern von Berechtigungen');
}
// Check if target user is owner
const targetUserClub = await UserClub.findOne({
where: { userId, clubId }
});
if (!targetUserClub) {
throw new Error('Benutzer ist kein Mitglied dieses Clubs');
}
if (targetUserClub.isOwner) {
throw new Error('Die Berechtigungen des Club-Erstellers können nicht geändert werden');
}
await targetUserClub.update({ permissions: customPermissions });
return {
success: true,
message: 'Berechtigungen erfolgreich aktualisiert'
};
}
/**
* Set user status (activate/deactivate)
*/
async setUserStatus(userId, clubId, approved, updatedByUserId) {
// Check if updater has permission
const canManagePermissions = await this.hasPermission(updatedByUserId, clubId, 'permissions', 'write');
if (!canManagePermissions) {
throw new Error('Keine Berechtigung zum Ändern des Status');
}
// Check if target user is owner
const targetUserClub = await UserClub.findOne({
where: { userId, clubId }
});
if (!targetUserClub) {
throw new Error('Benutzer ist kein Mitglied dieses Clubs');
}
if (targetUserClub.isOwner) {
throw new Error('Der Status des Club-Erstellers kann nicht geändert werden');
}
await targetUserClub.update({ approved });
return {
success: true,
message: approved ? 'Benutzer erfolgreich aktiviert' : 'Benutzer erfolgreich deaktiviert'
};
}
/**
* Get all club members with their permissions
*/
async getClubMembersWithPermissions(clubId, requestingUserId) {
// Check if requester has permission to read permissions
const canReadPermissions = await this.hasPermission(requestingUserId, clubId, 'permissions', 'read');
if (!canReadPermissions) {
throw new Error('Keine Berechtigung zum Anzeigen von Berechtigungen');
}
const userClubs = await UserClub.findAll({
where: {
clubId
},
include: [{
model: User,
as: 'user',
attributes: ['id', 'email']
}]
});
return userClubs.map(uc => {
// Parse permissions JSON string to object
let parsedPermissions = null;
if (uc.permissions) {
try {
parsedPermissions = typeof uc.permissions === 'string'
? JSON.parse(uc.permissions)
: uc.permissions;
} catch (err) {
console.error('Error parsing permissions JSON:', err);
parsedPermissions = null;
}
}
return {
userId: uc.userId,
user: uc.user,
role: uc.role,
isOwner: uc.isOwner,
approved: uc.approved,
permissions: parsedPermissions,
effectivePermissions: this.getEffectivePermissions(uc)
};
});
}
/**
* Get effective permissions (role + custom)
*/
getEffectivePermissions(userClub) {
if (userClub.isOwner) {
return ROLE_PERMISSIONS.admin;
}
const rolePermissions = ROLE_PERMISSIONS[userClub.role] || ROLE_PERMISSIONS.member;
// Parse permissions JSON string to object
let customPermissions = {};
if (userClub.permissions) {
try {
customPermissions = typeof userClub.permissions === 'string'
? JSON.parse(userClub.permissions)
: userClub.permissions;
} catch (err) {
console.error('Error parsing permissions JSON in getEffectivePermissions:', err);
customPermissions = {};
}
}
return this.mergePermissions(rolePermissions, customPermissions);
}
/**
* Merge role permissions with custom permissions
*/
mergePermissions(rolePermissions, customPermissions) {
const merged = { ...rolePermissions };
for (const resource in customPermissions) {
if (!merged[resource]) {
merged[resource] = {};
}
merged[resource] = {
...merged[resource],
...customPermissions[resource]
};
}
return merged;
}
/**
* Mark user as club owner (used when creating a club)
*/
async setClubOwner(userId, clubId) {
const userClub = await UserClub.findOne({
where: { userId, clubId }
});
if (!userClub) {
throw new Error('UserClub relationship not found');
}
await userClub.update({
isOwner: true,
role: 'admin',
approved: true
});
}
/**
* Get all available roles
*/
getAvailableRoles() {
return [
{ value: 'admin', label: 'Administrator', description: 'Vollzugriff auf alle Funktionen' },
{ value: 'trainer', label: 'Trainer', description: 'Kann Trainingseinheiten, Mitglieder und Teams verwalten' },
{ value: 'team_manager', label: 'Mannschaftsführer', description: 'Kann Teams und Spielpläne verwalten' },
{ value: 'tournament_manager', label: 'Turnierleiter', description: 'Kann Turniere verwalten' },
{ value: 'member', label: 'Mitglied', description: 'Kann nur Trainings-Statistiken ansehen' }
];
}
/**
* Get permission structure for frontend
*/
getPermissionStructure() {
return {
diary: { label: 'Trainingstagebuch', actions: ['read', 'write', 'delete'] },
members: { label: 'Mitglieder', actions: ['read', 'write', 'delete'] },
teams: { label: 'Teams', actions: ['read', 'write', 'delete'] },
schedule: { label: 'Spielpläne', actions: ['read', 'write', 'delete'] },
tournaments: { label: 'Turniere', actions: ['read', 'write', 'delete'] },
statistics: { label: 'Statistiken', actions: ['read', 'write'] },
settings: { label: 'Einstellungen', actions: ['read', 'write'] },
permissions: { label: 'Berechtigungsverwaltung', actions: ['read', 'write'] },
approvals: { label: 'Freigaben (Mitgliedsanträge)', actions: ['read', 'write'] },
mytischtennis_admin: { label: 'MyTischtennis Admin', actions: ['read', 'write'] },
predefined_activities: { label: 'Vordefinierte Aktivitäten', actions: ['read', 'write', 'delete'] }
};
}
}
export default new PermissionService();

View File

@@ -58,20 +58,40 @@ class PredefinedActivityService {
if (!q || q.length < 2) {
return [];
}
return await PredefinedActivity.findAll({
// Intelligente Suche: Teile den Query in einzelne Begriffe auf
const searchTerms = q.split(/\s+/).filter(term => term.length > 0);
if (searchTerms.length === 0) {
return [];
}
// Hole alle Aktivitäten mit Kürzeln
const allActivities = await PredefinedActivity.findAll({
where: {
[Op.or]: [
{ name: { [Op.like]: `%${q}%` } },
{ code: { [Op.like]: `%${q}%` } },
],
code: { [Op.ne]: null } // Nur Aktivitäten mit Kürzel
},
order: [
[sequelize.literal('code IS NULL'), 'ASC'],
['code', 'ASC'],
['name', 'ASC'],
],
limit: Math.min(parseInt(limit || 20, 10), 50),
limit: 1000 // Höhere Grenze für Filterung
});
// Filtere die Ergebnisse, um nur die zu finden, die ALLE Begriffe enthalten
const filteredResults = allActivities.filter(activity => {
const code = (activity.code || '').toLowerCase();
// Prüfe, ob alle Suchbegriffe im Kürzel enthalten sind
return searchTerms.every(term => {
const normalizedTerm = term.toLowerCase();
return code.includes(normalizedTerm);
});
});
// Begrenze die Ergebnisse
return filteredResults.slice(0, Math.min(parseInt(limit || 20, 10), 50));
}
async mergeActivities(sourceId, targetId) {

View File

@@ -0,0 +1,194 @@
import cron from 'node-cron';
import autoUpdateRatingsService from './autoUpdateRatingsService.js';
import autoFetchMatchResultsService from './autoFetchMatchResultsService.js';
import apiLogService from './apiLogService.js';
import { devLog } from '../utils/logger.js';
class SchedulerService {
constructor() {
this.jobs = new Map();
this.isRunning = false;
}
/**
* Start the scheduler
*/
start() {
if (this.isRunning) {
devLog('Scheduler is already running');
return;
}
devLog('Starting scheduler service...');
// Schedule automatic rating updates at 6:00 AM daily
const ratingUpdateJob = cron.schedule('0 6 * * *', async () => {
const startTime = Date.now();
devLog(`[${new Date().toISOString()}] CRON: Executing scheduled rating updates...`);
let errorMessage = null;
try {
// Let the service return details including counts if available
const result = await autoUpdateRatingsService.executeAutomaticUpdates();
const executionTime = Date.now() - startTime;
// result may include updatedCount or a summary object
const messageObj = result && typeof result === 'object' ? result : { message: 'Rating updates completed successfully' };
// Log to ApiLog with rich details
await apiLogService.logSchedulerExecution('rating_updates', true, messageObj, executionTime, null);
devLog('Scheduled rating updates completed successfully');
} catch (error) {
const executionTime = Date.now() - startTime;
errorMessage = error.message;
console.error(`[${new Date().toISOString()}] CRON ERROR in scheduled rating updates:`, error);
console.error('Stack trace:', error.stack);
// Log to ApiLog
await apiLogService.logSchedulerExecution('rating_updates', false, { message: 'Rating updates failed' }, executionTime, errorMessage);
}
}, {
scheduled: false, // Don't start automatically
timezone: 'Europe/Berlin'
});
this.jobs.set('ratingUpdates', ratingUpdateJob);
ratingUpdateJob.start();
devLog('Rating update job scheduled and started');
// Schedule automatic match results fetching at 6:30 AM daily
const matchResultsJob = cron.schedule('30 6 * * *', async () => {
const startTime = Date.now();
devLog(`[${new Date().toISOString()}] CRON: Executing scheduled match results fetch...`);
let errorMessage = null;
try {
// Execute and capture returned summary (should include counts)
const result = await autoFetchMatchResultsService.executeAutomaticFetch();
const executionTime = Date.now() - startTime;
const messageObj = result && typeof result === 'object' ? result : { message: 'Match results fetch completed successfully' };
// Log to ApiLog with rich details (including counts if present)
await apiLogService.logSchedulerExecution('match_results', true, messageObj, executionTime, null);
devLog('Scheduled match results fetch completed successfully');
} catch (error) {
const executionTime = Date.now() - startTime;
errorMessage = error.message;
console.error(`[${new Date().toISOString()}] CRON ERROR in scheduled match results fetch:`, error);
console.error('Stack trace:', error.stack);
// Log to ApiLog
await apiLogService.logSchedulerExecution('match_results', false, { message: 'Match results fetch failed' }, executionTime, errorMessage);
}
}, {
scheduled: false, // Don't start automatically
timezone: 'Europe/Berlin'
});
this.jobs.set('matchResults', matchResultsJob);
matchResultsJob.start();
devLog('Match results fetch job scheduled and started');
this.isRunning = true;
const now = new Date();
const tomorrow6AM = new Date(now);
tomorrow6AM.setDate(tomorrow6AM.getDate() + 1);
tomorrow6AM.setHours(6, 0, 0, 0);
const tomorrow630AM = new Date(now);
tomorrow630AM.setDate(tomorrow630AM.getDate() + 1);
tomorrow630AM.setHours(6, 30, 0, 0);
devLog('[Scheduler] ===== SCHEDULER SERVICE STARTED =====');
devLog(`[Scheduler] Server time: ${now.toISOString()}`);
devLog(`[Scheduler] Timezone: Europe/Berlin`);
devLog(`[Scheduler] Rating updates: Next execution at ${tomorrow6AM.toISOString()} (6:00 AM Berlin time)`);
devLog(`[Scheduler] Match results fetch: Next execution at ${tomorrow630AM.toISOString()} (6:30 AM Berlin time)`);
devLog('[Scheduler] =====================================');
devLog('Scheduler service started successfully');
devLog('Rating updates scheduled for 6:00 AM daily (Europe/Berlin timezone)');
devLog('Match results fetch scheduled for 6:30 AM daily (Europe/Berlin timezone)');
}
/**
* Stop the scheduler
*/
stop() {
if (!this.isRunning) {
devLog('Scheduler is not running');
return;
}
devLog('Stopping scheduler service...');
for (const [name, job] of this.jobs) {
job.stop();
devLog(`Stopped job: ${name}`);
}
this.jobs.clear();
this.isRunning = false;
devLog('Scheduler service stopped');
}
/**
* Get scheduler status
*/
getStatus() {
return {
isRunning: this.isRunning,
jobs: Array.from(this.jobs.keys()),
timezone: 'Europe/Berlin'
};
}
/**
* Manually trigger rating updates (for testing)
*/
async triggerRatingUpdates() {
devLog('Manually triggering rating updates...');
try {
await autoUpdateRatingsService.executeAutomaticUpdates();
return { success: true, message: 'Rating updates completed successfully' };
} catch (error) {
console.error('Error in manual rating updates:', error);
return { success: false, message: error.message };
}
}
/**
* Manually trigger match results fetch (for testing)
*/
async triggerMatchResultsFetch() {
devLog('Manually triggering match results fetch...');
try {
await autoFetchMatchResultsService.executeAutomaticFetch();
return { success: true, message: 'Match results fetch completed successfully' };
} catch (error) {
console.error('Error in manual match results fetch:', error);
return { success: false, message: error.message };
}
}
/**
* Get next scheduled execution time for rating updates
*/
getNextRatingUpdateTime() {
const job = this.jobs.get('ratingUpdates');
if (!job || !this.isRunning) {
return null;
}
// Get next execution time (this is a simplified approach)
const now = new Date();
const tomorrow = new Date(now);
tomorrow.setDate(tomorrow.getDate() + 1);
tomorrow.setHours(6, 0, 0, 0);
return tomorrow;
}
}
export default new SchedulerService();

View File

@@ -39,7 +39,16 @@ class TeamDocumentService {
const filePath = path.join(uploadDir, uniqueFileName);
// Verschiebe die Datei vom temporären Verzeichnis zum finalen Speicherort
fs.renameSync(file.path, filePath);
try {
fs.renameSync(file.path, filePath);
} catch (error) {
if (error.code === 'EXDEV') {
fs.copyFileSync(file.path, filePath);
fs.unlinkSync(file.path);
} else {
throw error;
}
}
// Lösche alte Dokumente des gleichen Typs für dieses Team
await this.deleteDocumentsByType(clubTeamId, documentType);

View File

@@ -207,7 +207,6 @@ class TournamentService {
const gm = await TournamentMember.findAll({ where: { groupId: g.id } });
if (gm.length < 2) {
console.warn(`Gruppe ${g.id} hat nur ${gm.length} Teilnehmer - keine Matches erstellt`);
continue;
}
@@ -227,8 +226,6 @@ class TournamentService {
player2Id: p2Id,
groupRound: roundIndex + 1
});
} else {
console.warn(`Spieler gehören nicht zur gleichen Gruppe: ${p1Id} (${p1?.groupId}) vs ${p2Id} (${p2?.groupId}) in Gruppe ${g.id}`);
}
}
}