Add MyTischtennis fetch log functionality and new endpoints

Enhance MyTischtennis integration by introducing fetch log capabilities. Implement new controller methods to retrieve fetch logs and latest successful fetches for users. Update routes to include these new endpoints. Modify the MyTischtennis model to support fetch logs and ensure proper logging of fetch operations in various services. Update frontend components to display fetch statistics, improving user experience and data visibility.
This commit is contained in:
Torsten Schulz (local)
2025-10-14 23:07:57 +02:00
parent 7549fb5730
commit 36bf99c013
10 changed files with 584 additions and 25 deletions

View File

@@ -142,6 +142,63 @@ class MyTischtennisController {
next(error);
}
}
/**
* Get fetch logs for current user
*/
async getFetchLogs(req, res, next) {
try {
const { userid: userIdOrEmail } = req.headers;
// Convert email to userId if needed
let userId = userIdOrEmail;
if (isNaN(userIdOrEmail)) {
const User = (await import('../models/User.js')).default;
const user = await User.findOne({ where: { email: userIdOrEmail } });
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
userId = user.id;
}
const fetchLogService = (await import('../services/myTischtennisFetchLogService.js')).default;
const logs = await fetchLogService.getFetchLogs(userId, {
limit: req.query.limit ? parseInt(req.query.limit) : 50,
fetchType: req.query.type
});
res.status(200).json({ logs });
} catch (error) {
next(error);
}
}
/**
* Get latest successful fetches for each type
*/
async getLatestFetches(req, res, next) {
try {
const { userid: userIdOrEmail } = req.headers;
// Convert email to userId if needed
let userId = userIdOrEmail;
if (isNaN(userIdOrEmail)) {
const User = (await import('../models/User.js')).default;
const user = await User.findOne({ where: { email: userIdOrEmail } });
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
userId = user.id;
}
const fetchLogService = (await import('../services/myTischtennisFetchLogService.js')).default;
const latestFetches = await fetchLogService.getLatestSuccessfulFetches(userId);
res.status(200).json({ latestFetches });
} catch (error) {
next(error);
}
}
}
export default new MyTischtennisController();

View File

@@ -314,35 +314,105 @@ class MyTischtennisUrlController {
}
// Fetch data for this specific team
const result = await autoFetchMatchResultsService.fetchTeamResults(
{
userId: account.userId,
email: account.email,
cookie: session.cookie,
accessToken: session.accessToken,
expiresAt: session.expiresAt,
getPassword: () => null // Not needed for manual fetch
},
team
);
const startTime = Date.now();
let matchResultsSuccess = false;
let tableUpdateSuccess = false;
let matchResultsCount = 0;
let tableUpdateCount = 0;
try {
const result = await autoFetchMatchResultsService.fetchTeamResults(
{
userId: account.userId,
email: account.email,
cookie: session.cookie,
accessToken: session.accessToken,
expiresAt: session.expiresAt,
getPassword: () => null // Not needed for manual fetch
},
team
);
matchResultsSuccess = true;
matchResultsCount = result.fetchedCount || 0;
// Log match results fetch
const fetchLogService = (await import('../services/myTischtennisFetchLogService.js')).default;
await fetchLogService.logFetch(
account.userId,
'match_results',
true,
`${matchResultsCount} Spielergebnisse erfolgreich abgerufen`,
{
recordsProcessed: matchResultsCount,
executionTime: Date.now() - startTime,
isAutomatic: false
}
);
} catch (error) {
console.error('Error fetching match results:', error);
const fetchLogService = (await import('../services/myTischtennisFetchLogService.js')).default;
await fetchLogService.logFetch(
account.userId,
'match_results',
false,
'Fehler beim Abrufen der Spielergebnisse',
{
errorDetails: error.message,
executionTime: Date.now() - startTime,
isAutomatic: false
}
);
}
// Also fetch and update league table data
let tableUpdateResult = null;
const tableStartTime = Date.now();
try {
await autoFetchMatchResultsService.fetchAndUpdateLeagueTable(account.userId, team.league.id);
tableUpdateResult = 'League table updated successfully';
tableUpdateSuccess = true;
tableUpdateCount = 1; // One table updated
console.log('✓ League table updated for league:', team.league.id);
// Log league table fetch
const fetchLogService = (await import('../services/myTischtennisFetchLogService.js')).default;
await fetchLogService.logFetch(
account.userId,
'league_table',
true,
'Ligatabelle erfolgreich aktualisiert',
{
recordsProcessed: tableUpdateCount,
executionTime: Date.now() - tableStartTime,
isAutomatic: false
}
);
} catch (error) {
console.error('Error fetching league table data:', error);
tableUpdateResult = 'League table update failed: ' + error.message;
// Log league table fetch failure
const fetchLogService = (await import('../services/myTischtennisFetchLogService.js')).default;
await fetchLogService.logFetch(
account.userId,
'league_table',
false,
'Fehler beim Aktualisieren der Ligatabelle',
{
errorDetails: error.message,
executionTime: Date.now() - tableStartTime,
isAutomatic: false
}
);
// Don't fail the entire request if table update fails
}
res.json({
success: true,
message: `${result.fetchedCount} Datensätze abgerufen und verarbeitet`,
message: `${matchResultsCount} Datensätze abgerufen und verarbeitet`,
data: {
fetchedCount: result.fetchedCount,
fetchedCount: matchResultsCount,
teamName: team.name,
tableUpdate: tableUpdateResult
}

View File

@@ -0,0 +1,20 @@
-- Create my_tischtennis_fetch_log table for tracking data fetches
CREATE TABLE IF NOT EXISTS my_tischtennis_fetch_log (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
fetch_type ENUM('ratings', 'match_results', 'league_table') NOT NULL COMMENT 'Type of data fetch',
success BOOLEAN NOT NULL DEFAULT FALSE,
message TEXT,
error_details TEXT,
records_processed INT NOT NULL DEFAULT 0 COMMENT 'Number of records processed',
execution_time INT COMMENT 'Execution time in milliseconds',
is_automatic BOOLEAN NOT NULL DEFAULT FALSE COMMENT 'Automatic or manual fetch',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES user(id) ON DELETE CASCADE ON UPDATE CASCADE,
INDEX idx_user_fetch_type_created (user_id, fetch_type, created_at),
INDEX idx_created_at (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@@ -0,0 +1,72 @@
import { DataTypes } from 'sequelize';
import sequelize from '../database.js';
import User from './User.js';
const MyTischtennisFetchLog = sequelize.define('MyTischtennisFetchLog', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true,
allowNull: false,
},
userId: {
type: DataTypes.INTEGER,
allowNull: false,
references: {
model: User,
key: 'id',
},
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
},
fetchType: {
type: DataTypes.ENUM('ratings', 'match_results', 'league_table'),
allowNull: false,
comment: 'Type of data fetch: ratings, match_results, or league_table'
},
success: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
},
message: {
type: DataTypes.TEXT,
allowNull: true,
},
errorDetails: {
type: DataTypes.TEXT,
allowNull: true,
},
recordsProcessed: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0,
comment: 'Number of records processed (e.g., players updated, matches fetched)'
},
executionTime: {
type: DataTypes.INTEGER,
allowNull: true,
comment: 'Execution time in milliseconds'
},
isAutomatic: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
comment: 'Whether this was an automatic or manual fetch'
},
}, {
underscored: true,
tableName: 'my_tischtennis_fetch_log',
timestamps: true,
indexes: [
{
fields: ['user_id', 'fetch_type', 'created_at']
},
{
fields: ['created_at']
}
]
});
export default MyTischtennisFetchLog;

View File

@@ -37,6 +37,7 @@ import OfficialCompetition from './OfficialCompetition.js';
import OfficialCompetitionMember from './OfficialCompetitionMember.js';
import MyTischtennis from './MyTischtennis.js';
import MyTischtennisUpdateHistory from './MyTischtennisUpdateHistory.js';
import MyTischtennisFetchLog from './MyTischtennisFetchLog.js';
// Official tournaments relations
OfficialTournament.hasMany(OfficialCompetition, { foreignKey: 'tournamentId', as: 'competitions' });
OfficialCompetition.belongsTo(OfficialTournament, { foreignKey: 'tournamentId', as: 'tournament' });
@@ -234,6 +235,9 @@ MyTischtennis.belongsTo(User, { foreignKey: 'userId', as: 'user' });
User.hasMany(MyTischtennisUpdateHistory, { foreignKey: 'userId', as: 'updateHistory' });
MyTischtennisUpdateHistory.belongsTo(User, { foreignKey: 'userId', as: 'user' });
User.hasMany(MyTischtennisFetchLog, { foreignKey: 'userId', as: 'fetchLogs' });
MyTischtennisFetchLog.belongsTo(User, { foreignKey: 'userId', as: 'user' });
export {
User,
Log,
@@ -273,4 +277,5 @@ export {
OfficialCompetitionMember,
MyTischtennis,
MyTischtennisUpdateHistory,
MyTischtennisFetchLog,
};

View File

@@ -29,6 +29,12 @@ router.get('/session', myTischtennisController.getSession);
// GET /api/mytischtennis/update-history - Get update ratings history
router.get('/update-history', myTischtennisController.getUpdateHistory);
// GET /api/mytischtennis/fetch-logs - Get fetch logs
router.get('/fetch-logs', myTischtennisController.getFetchLogs);
// GET /api/mytischtennis/latest-fetches - Get latest successful fetches
router.get('/latest-fetches', myTischtennisController.getLatestFetches);
// POST /api/mytischtennis/parse-url - Parse myTischtennis URL
router.post('/parse-url', myTischtennisUrlController.parseUrl);

View File

@@ -8,7 +8,7 @@ import {
DiaryNote, DiaryTag, MemberDiaryTag, DiaryDateTag, DiaryMemberNote, DiaryMemberTag,
PredefinedActivity, PredefinedActivityImage, DiaryDateActivity, DiaryMemberActivity, Match, League, Team, ClubTeam, TeamDocument, Group,
GroupActivity, Tournament, TournamentGroup, TournamentMatch, TournamentResult,
TournamentMember, Accident, UserToken, OfficialTournament, OfficialCompetition, OfficialCompetitionMember, MyTischtennis, MyTischtennisUpdateHistory
TournamentMember, Accident, UserToken, OfficialTournament, OfficialCompetition, OfficialCompetitionMember, MyTischtennis, MyTischtennisUpdateHistory, MyTischtennisFetchLog
} from './models/index.js';
import authRoutes from './routes/authRoutes.js';
import clubRoutes from './routes/clubRoutes.js';
@@ -189,6 +189,7 @@ app.get('*', (req, res) => {
await safeSync(UserToken);
await safeSync(MyTischtennis);
await safeSync(MyTischtennisUpdateHistory);
await safeSync(MyTischtennisFetchLog);
// Start scheduler service
schedulerService.start();

View File

@@ -145,9 +145,11 @@ class MemberService {
await checkAccess(userToken, clubId);
const user = await getUserByToken(userToken);
const startTime = Date.now();
const myTischtennisService = (await import('./myTischtennisService.js')).default;
const myTischtennisClient = (await import('../clients/myTischtennisClient.js')).default;
const fetchLogService = (await import('./myTischtennisFetchLogService.js')).default;
try {
// 1. myTischtennis-Session abrufen oder Login durchführen
@@ -296,6 +298,19 @@ class MemberService {
message += ` ${errors.length} Fehler beim Speichern.`;
}
// Log successful ratings fetch
await fetchLogService.logFetch(
user.id,
'ratings',
true,
message,
{
recordsProcessed: updated,
executionTime: Date.now() - startTime,
isAutomatic: false
}
);
return {
status: 200,
response: {
@@ -310,6 +325,20 @@ class MemberService {
};
} catch (error) {
console.error('[updateRatingsFromMyTischtennis] - Error:', error);
// Log failed ratings fetch
await fetchLogService.logFetch(
user.id,
'ratings',
false,
'Fehler beim Aktualisieren der Wertungen',
{
errorDetails: error.message,
executionTime: Date.now() - startTime,
isAutomatic: false
}
);
return {
status: 500,
response: {

View File

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

View File

@@ -51,6 +51,58 @@
<button class="btn-danger" @click="deleteAccount">Account trennen</button>
</div>
</div>
<!-- Fetch Statistics Section -->
<div class="info-section fetch-stats-section" v-if="account">
<h2>Datenabruf-Statistiken</h2>
<div v-if="loadingStats" class="loading-stats">Lade Statistiken...</div>
<div v-else-if="latestFetches" class="stats-grid">
<div class="stat-card">
<div class="stat-icon">📊</div>
<div class="stat-content">
<h3>Spielerwertungen</h3>
<div v-if="latestFetches.ratings">
<p class="stat-date">{{ formatDateRelative(latestFetches.ratings.lastFetch) }}</p>
<p class="stat-detail">{{ latestFetches.ratings.recordsProcessed }} Spieler aktualisiert</p>
<p class="stat-time" v-if="latestFetches.ratings.executionTime">{{ latestFetches.ratings.executionTime }}ms</p>
</div>
<p v-else class="stat-never">Noch nie abgerufen</p>
</div>
</div>
<div class="stat-card">
<div class="stat-icon">🏓</div>
<div class="stat-content">
<h3>Spielergebnisse</h3>
<div v-if="latestFetches.match_results">
<p class="stat-date">{{ formatDateRelative(latestFetches.match_results.lastFetch) }}</p>
<p class="stat-detail">{{ latestFetches.match_results.recordsProcessed }} Ergebnisse</p>
<p class="stat-time" v-if="latestFetches.match_results.executionTime">{{ latestFetches.match_results.executionTime }}ms</p>
</div>
<p v-else class="stat-never">Noch nie abgerufen</p>
</div>
</div>
<div class="stat-card">
<div class="stat-icon">📋</div>
<div class="stat-content">
<h3>Ligatabellen</h3>
<div v-if="latestFetches.league_table">
<p class="stat-date">{{ formatDateRelative(latestFetches.league_table.lastFetch) }}</p>
<p class="stat-detail">{{ latestFetches.league_table.recordsProcessed }} Teams</p>
<p class="stat-time" v-if="latestFetches.league_table.executionTime">{{ latestFetches.league_table.executionTime }}ms</p>
</div>
<p v-else class="stat-never">Noch nie abgerufen</p>
</div>
</div>
</div>
<button class="btn-secondary refresh-stats-btn" @click="loadLatestFetches">
🔄 Statistiken aktualisieren
</button>
</div>
</div>
<div v-else class="no-account">
@@ -141,13 +193,16 @@ export default {
resolveCallback: null
},
loading: true,
loadingStats: false,
account: null,
latestFetches: null,
showDialog: false,
showHistoryDialog: false
};
},
mounted() {
this.loadAccount();
this.loadLatestFetches();
},
methods: {
// Dialog Helper Methods
@@ -247,23 +302,34 @@ export default {
},
async deleteAccount() {
if (!confirm('Möchten Sie die Verknüpfung zum myTischtennis-Account wirklich trennen?')) {
return;
}
const confirmed = await this.showConfirm(
'Account trennen',
'Möchten Sie die Verknüpfung zum myTischtennis-Account wirklich trennen?',
'',
'danger'
);
if (!confirmed) return;
try {
await apiClient.delete('/mytischtennis/account');
this.account = null;
this.$store.dispatch('showMessage', {
text: 'myTischtennis-Account erfolgreich getrennt',
type: 'success'
});
this.showInfo('Erfolg', 'myTischtennis-Account erfolgreich getrennt', '', 'success');
} catch (error) {
console.error('Fehler beim Löschen des Accounts:', error);
this.$store.dispatch('showMessage', {
text: 'Fehler beim Trennen des Accounts',
type: 'error'
});
this.showInfo('Fehler', 'Fehler beim Trennen des Accounts', error.message, 'error');
}
},
async loadLatestFetches() {
this.loadingStats = true;
try {
const response = await apiClient.get('/mytischtennis/latest-fetches');
this.latestFetches = response.data.latestFetches;
} catch (error) {
console.error('Fehler beim Laden der Fetch-Statistiken:', error);
} finally {
this.loadingStats = false;
}
},
@@ -277,6 +343,31 @@ export default {
hour: '2-digit',
minute: '2-digit'
});
},
formatDateRelative(dateString) {
if (!dateString) return 'Nie';
const date = new Date(dateString);
const now = new Date();
const diffMs = now - date;
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return 'Gerade eben';
if (diffMins < 60) return `vor ${diffMins} Min.`;
if (diffHours < 24) return `vor ${diffHours} Std.`;
if (diffDays === 1) return 'Gestern';
if (diffDays < 7) return `vor ${diffDays} Tagen`;
return date.toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
}
};
@@ -403,6 +494,85 @@ h1 {
background-color: #545b62;
}
/* Fetch Statistics */
.fetch-stats-section {
margin-top: 2rem;
}
.loading-stats {
text-align: center;
padding: 2rem;
color: #666;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1.5rem;
margin-bottom: 1.5rem;
}
.stat-card {
background: #f8f9fa;
border-radius: 8px;
padding: 1.5rem;
display: flex;
align-items: flex-start;
gap: 1rem;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.stat-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
.stat-icon {
font-size: 2.5rem;
line-height: 1;
}
.stat-content {
flex: 1;
}
.stat-content h3 {
margin: 0 0 0.5rem 0;
font-size: 1rem;
color: #333;
font-weight: 600;
}
.stat-date {
font-weight: 600;
color: #28a745;
margin: 0.25rem 0;
}
.stat-detail {
font-size: 0.9rem;
color: #666;
margin: 0.25rem 0;
}
.stat-time {
font-size: 0.8rem;
color: #999;
margin: 0.25rem 0;
}
.stat-never {
font-style: italic;
color: #999;
margin: 0.25rem 0;
}
.refresh-stats-btn {
width: 100%;
margin-top: 1rem;
}
.btn-danger {
background-color: #dc3545;
color: white;