Refactor backend to enhance MyTischtennis integration. Update package.json to change main entry point to server.js. Modify server.js to improve scheduler service logging. Add new fields to ClubTeam, League, Match, and Member models for MyTischtennis data. Update routes to include new MyTischtennis URL parsing and configuration endpoints. Enhance services for fetching team data and scheduling match results. Improve frontend components for MyTischtennis URL configuration and display match results with scores.
This commit is contained in:
657
backend/services/autoFetchMatchResultsService.js
Normal file
657
backend/services/autoFetchMatchResultsService.js
Normal file
@@ -0,0 +1,657 @@
|
||||
import myTischtennisService from './myTischtennisService.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', '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
|
||||
for (const account of accounts) {
|
||||
await this.processAccount(account);
|
||||
}
|
||||
|
||||
devLog('Automatic match results fetch completed');
|
||||
} catch (error) {
|
||||
console.error('Error in automatic match results fetch:', 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;
|
||||
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;
|
||||
|
||||
// TODO: Log the attempt to a dedicated match results history table
|
||||
devLog(`Match results fetch for ${account.email}: ${success ? 'SUCCESS' : 'FAILED'} (${executionTime}ms)`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
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 playerStatsResponse = await fetch(playerStatsUrl, {
|
||||
headers: {
|
||||
'Cookie': account.cookie || '',
|
||||
'Authorization': `Bearer ${account.accessToken}`,
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (playerStatsResponse.ok) {
|
||||
const playerStatsData = await playerStatsResponse.json();
|
||||
|
||||
// Log complete response for debugging
|
||||
console.log('=== PLAYER STATS RESPONSE START ===');
|
||||
console.log(JSON.stringify(playerStatsData, null, 2));
|
||||
console.log('=== PLAYER STATS RESPONSE END ===');
|
||||
|
||||
const playerCount = await this.processTeamData(team, playerStatsData);
|
||||
totalProcessed += playerCount;
|
||||
devLog(`Processed ${playerCount} player statistics`);
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
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']
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default new AutoFetchMatchResultsService();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,10 @@ 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,
|
||||
homeTeam: { name: 'Unbekannt' },
|
||||
guestTeam: { name: 'Unbekannt' },
|
||||
location: { name: 'Unbekannt', address: '', city: '', zip: '' },
|
||||
@@ -213,13 +280,61 @@ 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;
|
||||
|
||||
devLog(`Filtering matches for club: ${clubName}`);
|
||||
|
||||
// Find all club teams in this league
|
||||
const clubTeams = await ClubTeam.findAll({
|
||||
where: {
|
||||
clubId: clubId,
|
||||
leagueId: leagueId
|
||||
}
|
||||
},
|
||||
attributes: ['id', 'name']
|
||||
});
|
||||
|
||||
devLog(`Club teams in league ${leagueId}: ${clubTeams.map(ct => ct.name).join(', ')}`);
|
||||
|
||||
// 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);
|
||||
devLog(`Own team IDs in this league: ${ownTeamIds.join(', ')} (${ownTeams.map(t => t.name).join(', ')})`);
|
||||
|
||||
// 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 } }
|
||||
]
|
||||
}
|
||||
});
|
||||
devLog(`Found ${matches.length} matches for our teams`);
|
||||
} else {
|
||||
// No own teams found - show nothing
|
||||
devLog('No own teams found in this league, showing no matches');
|
||||
matches = [];
|
||||
}
|
||||
|
||||
// Lade Team- und Location-Daten manuell
|
||||
const enrichedMatches = [];
|
||||
for (const match of matches) {
|
||||
@@ -234,6 +349,10 @@ 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,
|
||||
homeTeam: { name: 'Unbekannt' },
|
||||
guestTeam: { name: 'Unbekannt' },
|
||||
location: { name: 'Unbekannt', address: '', city: '', zip: '' },
|
||||
|
||||
@@ -159,7 +159,7 @@ class MemberService {
|
||||
|
||||
// Versuche automatischen Login mit gespeicherten Credentials
|
||||
try {
|
||||
const loginResult = await myTischtennisService.verifyLogin(user.id);
|
||||
await myTischtennisService.verifyLogin(user.id);
|
||||
const freshSession = await myTischtennisService.getSession(user.id);
|
||||
session = {
|
||||
cookie: freshSession.cookie,
|
||||
|
||||
245
backend/services/myTischtennisUrlParserService.js
Normal file
245
backend/services/myTischtennisUrlParserService.js
Normal file
@@ -0,0 +1,245 @@
|
||||
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(/\/$/, '');
|
||||
|
||||
// Extract parts using regex
|
||||
// Pattern: /click-tt/{association}/{season}/{type}/{groupname}/gruppe/{groupId}/mannschaft/{teamId}/{teamname}/...
|
||||
const pattern = /\/click-tt\/([^\/]+)\/([^\/]+)\/([^\/]+)\/([^\/]+)\/gruppe\/([^\/]+)\/mannschaft\/([^\/]+)\/([^\/]+)/;
|
||||
|
||||
const match = url.match(pattern);
|
||||
|
||||
if (!match) {
|
||||
throw new Error('URL format not recognized. Expected format: /click-tt/{association}/{season}/{type}/{groupname}/gruppe/{groupId}/mannschaft/{teamId}/{teamname}/...');
|
||||
}
|
||||
|
||||
const [
|
||||
,
|
||||
association,
|
||||
seasonRaw,
|
||||
type,
|
||||
groupnameEncoded,
|
||||
groupId,
|
||||
teamId,
|
||||
teamnameEncoded
|
||||
] = match;
|
||||
|
||||
// Decode and process values
|
||||
const seasonShort = seasonRaw.replace('--', '/'); // "25--26" -> "25/26"
|
||||
const season = this.convertToFullSeason(seasonShort); // "25/26" -> "2025/2026"
|
||||
const groupname = decodeURIComponent(groupnameEncoded);
|
||||
const teamname = decodeURIComponent(teamnameEncoded).replace(/_/g, ' '); // "Harheimer_TC_(J11)" -> "Harheimer TC (J11)"
|
||||
|
||||
const result = {
|
||||
association,
|
||||
season,
|
||||
seasonShort, // Für API-Calls
|
||||
type,
|
||||
groupname,
|
||||
groupId,
|
||||
teamId,
|
||||
teamname,
|
||||
originalUrl: url
|
||||
};
|
||||
|
||||
devLog('Parsed myTischtennis URL:', result);
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Error parsing myTischtennis URL:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
} = config;
|
||||
|
||||
// Convert full season to short format for URL
|
||||
const seasonShort = this.convertToShortSeason(season);
|
||||
const seasonStr = seasonShort.replace('/', '--');
|
||||
const teamnameEncoded = encodeURIComponent(teamname.replace(/\s/g, '_'));
|
||||
const groupnameEncoded = encodeURIComponent(groupname);
|
||||
|
||||
return `https://www.mytischtennis.de/click-tt/${association}/${seasonStr}/${type}/${groupnameEncoded}/gruppe/${groupId}/mannschaft/${teamId}/${teamnameEncoded}/spielerbilanzen/gesamt`;
|
||||
}
|
||||
}
|
||||
|
||||
export default new MyTischtennisUrlParserService();
|
||||
@@ -88,11 +88,21 @@ class PDFParserService {
|
||||
const result = strategy.fn(lines, clubId);
|
||||
|
||||
if (result.matches.length > 0) {
|
||||
console.log(`[PDF Parser] Using strategy: ${strategy.name}, found ${result.matches.length} matches`);
|
||||
if (result.matches.length > 0) {
|
||||
console.log(`[PDF Parser] First match sample:`, {
|
||||
homeTeamName: result.matches[0].homeTeamName,
|
||||
guestTeamName: result.matches[0].guestTeamName,
|
||||
date: result.matches[0].date,
|
||||
rawLine: result.matches[0].rawLine
|
||||
});
|
||||
}
|
||||
matches.push(...result.matches);
|
||||
metadata.parsedMatches += result.matches.length;
|
||||
break; // Erste erfolgreiche Strategie verwenden
|
||||
}
|
||||
} catch (strategyError) {
|
||||
console.log(`[PDF Parser] Strategy ${strategy.name} failed:`, strategyError.message);
|
||||
errors.push(`Strategy ${strategy.name} failed: ${strategyError.message}`);
|
||||
}
|
||||
}
|
||||
@@ -148,16 +158,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+\)/, '');
|
||||
@@ -183,18 +198,26 @@ 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;
|
||||
}
|
||||
}
|
||||
@@ -249,14 +272,41 @@ 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();
|
||||
|
||||
// Entferne Klammern aus Gastteam
|
||||
guestTeamName = guestTeamName.replace(/\([^)]*\)/g, '').trim();
|
||||
// Prüfe, ob "Harheimer TC" am Anfang oder am Ende steht
|
||||
let beforeHarheimer = teamsPart.substring(0, harheimerIndex).trim();
|
||||
let afterHarheimer = teamsPart.substring(harheimerIndex + 'Harheimer TC'.length).trim();
|
||||
|
||||
// Entferne Spielnummern aus beiden Teilen
|
||||
beforeHarheimer = beforeHarheimer.replace(/^\d+/, '').trim();
|
||||
afterHarheimer = afterHarheimer.replace(/^\d+/, '').trim();
|
||||
|
||||
if (beforeHarheimer && !afterHarheimer) {
|
||||
// "Harheimer TC" ist am Ende → Harheimer ist Gastteam
|
||||
guestTeamName = 'Harheimer TC';
|
||||
homeTeamName = beforeHarheimer
|
||||
.replace(/\([^)]*\)/g, '') // Entferne Klammern
|
||||
.trim();
|
||||
} else if (!beforeHarheimer && afterHarheimer) {
|
||||
// "Harheimer TC" ist am Anfang → Harheimer ist Heimteam
|
||||
homeTeamName = 'Harheimer TC';
|
||||
guestTeamName = afterHarheimer
|
||||
.replace(/\([^)]*\)/g, '') // Entferne Klammern
|
||||
.trim();
|
||||
} else if (beforeHarheimer && afterHarheimer) {
|
||||
// "Harheimer TC" ist in der Mitte → verwende Position als Hinweis
|
||||
// Normalerweise: Heimteam zuerst, dann Gastteam
|
||||
homeTeamName = beforeHarheimer
|
||||
.replace(/\([^)]*\)/g, '') // Entferne Klammern
|
||||
.trim();
|
||||
guestTeamName = 'Harheimer TC';
|
||||
} else {
|
||||
// Nur "Harheimer TC" ohne andere Teams → ungültig
|
||||
continue;
|
||||
}
|
||||
|
||||
} else {
|
||||
// Strategie 2: Suche nach Großbuchstaben am Anfang des zweiten Teams
|
||||
@@ -284,6 +334,8 @@ class PDFParserService {
|
||||
debugInfo = `guestPin: "${guestPin}"`;
|
||||
}
|
||||
|
||||
console.log(`[PDF Parser] Parsed match: ${homeTeamName} vs ${guestTeamName}, ${debugInfo}`);
|
||||
|
||||
matches.push({
|
||||
date: date,
|
||||
time: time,
|
||||
@@ -554,40 +606,49 @@ 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']
|
||||
});
|
||||
console.log(`[PDF Parser] Available teams in club: ${allTeams.map(t => t.name).join(', ')}`);
|
||||
|
||||
// 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 =>
|
||||
t.name.toLowerCase().includes(matchData.homeTeamName.toLowerCase()) ||
|
||||
matchData.homeTeamName.toLowerCase().includes(t.name.toLowerCase())
|
||||
);
|
||||
|
||||
if (homeTeam) {
|
||||
console.log(`[PDF Parser] Found home team via fuzzy match: "${matchData.homeTeamName}" → "${homeTeam.name}"`);
|
||||
}
|
||||
}
|
||||
if (guestTeamFuzzy) {
|
||||
|
||||
if (!guestTeam) {
|
||||
guestTeam = allTeams.find(t =>
|
||||
t.name.toLowerCase().includes(matchData.guestTeamName.toLowerCase()) ||
|
||||
matchData.guestTeamName.toLowerCase().includes(t.name.toLowerCase())
|
||||
);
|
||||
|
||||
if (guestTeam) {
|
||||
console.log(`[PDF Parser] Found guest team via fuzzy match: "${matchData.guestTeamName}" → "${guestTeam.name}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import cron from 'node-cron';
|
||||
import autoUpdateRatingsService from './autoUpdateRatingsService.js';
|
||||
import autoFetchMatchResultsService from './autoFetchMatchResultsService.js';
|
||||
import { devLog } from '../utils/logger.js';
|
||||
|
||||
class SchedulerService {
|
||||
@@ -35,9 +36,26 @@ class SchedulerService {
|
||||
this.jobs.set('ratingUpdates', ratingUpdateJob);
|
||||
ratingUpdateJob.start();
|
||||
|
||||
// Schedule automatic match results fetching at 6:30 AM daily
|
||||
const matchResultsJob = cron.schedule('30 6 * * *', async () => {
|
||||
devLog('Executing scheduled match results fetch...');
|
||||
try {
|
||||
await autoFetchMatchResultsService.executeAutomaticFetch();
|
||||
} catch (error) {
|
||||
console.error('Error in scheduled match results fetch:', error);
|
||||
}
|
||||
}, {
|
||||
scheduled: false, // Don't start automatically
|
||||
timezone: 'Europe/Berlin'
|
||||
});
|
||||
|
||||
this.jobs.set('matchResults', matchResultsJob);
|
||||
matchResultsJob.start();
|
||||
|
||||
this.isRunning = true;
|
||||
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)');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -86,6 +104,20 @@ class SchedulerService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user