diff --git a/backend/controllers/myTischtennisController.js b/backend/controllers/myTischtennisController.js index 7afe872..0a298be 100644 --- a/backend/controllers/myTischtennisController.js +++ b/backend/controllers/myTischtennisController.js @@ -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(); diff --git a/backend/controllers/myTischtennisUrlController.js b/backend/controllers/myTischtennisUrlController.js index a38bd92..5453d86 100644 --- a/backend/controllers/myTischtennisUrlController.js +++ b/backend/controllers/myTischtennisUrlController.js @@ -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 } diff --git a/backend/migrations/create_my_tischtennis_fetch_log.sql b/backend/migrations/create_my_tischtennis_fetch_log.sql new file mode 100644 index 0000000..a37f271 --- /dev/null +++ b/backend/migrations/create_my_tischtennis_fetch_log.sql @@ -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; + diff --git a/backend/models/MyTischtennisFetchLog.js b/backend/models/MyTischtennisFetchLog.js new file mode 100644 index 0000000..0197fa8 --- /dev/null +++ b/backend/models/MyTischtennisFetchLog.js @@ -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; + diff --git a/backend/models/index.js b/backend/models/index.js index b82ca12..c9b08f6 100644 --- a/backend/models/index.js +++ b/backend/models/index.js @@ -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, }; diff --git a/backend/routes/myTischtennisRoutes.js b/backend/routes/myTischtennisRoutes.js index 226bb5c..0e42c65 100644 --- a/backend/routes/myTischtennisRoutes.js +++ b/backend/routes/myTischtennisRoutes.js @@ -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); diff --git a/backend/server.js b/backend/server.js index 905d702..9d32e55 100644 --- a/backend/server.js +++ b/backend/server.js @@ -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(); diff --git a/backend/services/memberService.js b/backend/services/memberService.js index 10dab03..712b35a 100644 --- a/backend/services/memberService.js +++ b/backend/services/memberService.js @@ -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: { diff --git a/backend/services/myTischtennisFetchLogService.js b/backend/services/myTischtennisFetchLogService.js new file mode 100644 index 0000000..563a13f --- /dev/null +++ b/backend/services/myTischtennisFetchLogService.js @@ -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(); + diff --git a/frontend/src/views/MyTischtennisAccount.vue b/frontend/src/views/MyTischtennisAccount.vue index 7c7fdc6..bcce970 100644 --- a/frontend/src/views/MyTischtennisAccount.vue +++ b/frontend/src/views/MyTischtennisAccount.vue @@ -51,6 +51,58 @@ + + +
+

Datenabruf-Statistiken

+ +
Lade Statistiken...
+ +
+
+
📊
+
+

Spielerwertungen

+
+

{{ formatDateRelative(latestFetches.ratings.lastFetch) }}

+

{{ latestFetches.ratings.recordsProcessed }} Spieler aktualisiert

+

{{ latestFetches.ratings.executionTime }}ms

+
+

Noch nie abgerufen

+
+
+ +
+
🏓
+
+

Spielergebnisse

+
+

{{ formatDateRelative(latestFetches.match_results.lastFetch) }}

+

{{ latestFetches.match_results.recordsProcessed }} Ergebnisse

+

{{ latestFetches.match_results.executionTime }}ms

+
+

Noch nie abgerufen

+
+
+ +
+
📋
+
+

Ligatabellen

+
+

{{ formatDateRelative(latestFetches.league_table.lastFetch) }}

+

{{ latestFetches.league_table.recordsProcessed }} Teams

+

{{ latestFetches.league_table.executionTime }}ms

+
+

Noch nie abgerufen

+
+
+
+ + +
@@ -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;