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:
314
backend/services/apiLogService.js
Normal file
314
backend/services/apiLogService.js
Normal 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();
|
||||
|
||||
@@ -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' };
|
||||
|
||||
956
backend/services/autoFetchMatchResultsService.js
Normal file
956
backend/services/autoFetchMatchResultsService.js
Normal 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();
|
||||
|
||||
184
backend/services/autoUpdateRatingsService.js
Normal file
184
backend/services/autoUpdateRatingsService.js
Normal 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();
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
227
backend/services/memberTransferConfigService.js
Normal file
227
backend/services/memberTransferConfigService.js
Normal 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();
|
||||
|
||||
1011
backend/services/memberTransferService.js
Normal file
1011
backend/services/memberTransferService.js
Normal file
File diff suppressed because it is too large
Load Diff
129
backend/services/myTischtennisFetchLogService.js
Normal file
129
backend/services/myTischtennisFetchLogService.js
Normal 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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
297
backend/services/myTischtennisUrlParserService.js
Normal file
297
backend/services/myTischtennisUrlParserService.js
Normal 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();
|
||||
@@ -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;
|
||||
|
||||
393
backend/services/permissionService.js
Normal file
393
backend/services/permissionService.js
Normal 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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
194
backend/services/schedulerService.js
Normal file
194
backend/services/schedulerService.js
Normal 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();
|
||||
@@ -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);
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user