import { query, withTransaction } from '../db/client'; import { fetchAllMatches, fetchFinishedMatches } from './footballApi'; import { calculatePoints } from './pointsService'; import { FootballApiMatch } from '../types'; import { logger } from './logger'; /** * Synchronisiert alle WM-Spiele von football-data.org in die Datenbank. * Legt neue Spiele an und aktualisiert bestehende (Status + Score). * * Sollte täglich per Cron laufen, oder manuell getriggert werden. */ export const syncMatches = async (): Promise<{ total: number; created: number; updated: number; }> => { logger.info('Starting match sync...'); const startTime = Date.now(); let created = 0; let updated = 0; try { const apiMatches = await fetchAllMatches(); await withTransaction(async (client) => { for (const match of apiMatches) { const existing = await client.query<{ id: number; status: string }>( 'SELECT id, status FROM matches WHERE external_id = $1', [match.id] ); if (existing.rows.length === 0) { // Neues Spiel anlegen await client.query( `INSERT INTO matches ( external_id, utc_date, status, stage, group_name, home_team_id, away_team_id, home_team_name, away_team_name, home_team_short, away_team_short, home_team_crest, away_team_crest, score_home, score_away ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)`, [ match.id, match.utcDate, match.status, match.stage, match.group, match.homeTeam?.id ?? null, match.awayTeam?.id ?? null, match.homeTeam?.name ?? 'TBD', match.awayTeam?.name ?? 'TBD', match.homeTeam?.shortName || match.homeTeam?.tla || 'TBD', match.awayTeam?.shortName || match.awayTeam?.tla || 'TBD', match.homeTeam?.crest ?? null, match.awayTeam?.crest ?? null, match.score.fullTime.home, match.score.fullTime.away, ] ); created++; } else { // Bestehendes Spiel aktualisieren await client.query( `UPDATE matches SET status = $1, score_home = $2, score_away = $3, utc_date = $4 WHERE external_id = $5`, [ match.status, match.score.fullTime.home, match.score.fullTime.away, match.utcDate, match.id, ] ); updated++; } } }); // Sync-Log schreiben await query( `INSERT INTO sync_log (matches_total, matches_new, matches_upd, status) VALUES ($1, $2, $3, 'success')`, [apiMatches.length, created, updated] ); const duration = Date.now() - startTime; logger.info(`Match sync completed in ${duration}ms`, { total: apiMatches.length, created, updated, }); return { total: apiMatches.length, created, updated }; } catch (error) { // Fehler loggen await query( `INSERT INTO sync_log (matches_total, matches_new, matches_upd, status, error_msg) VALUES (0, 0, 0, 'error', $1)`, [(error as Error).message] ).catch(() => {}); // Fehler beim Fehler-Logging ignorieren logger.error('Match sync failed', { error }); throw error; } }; /** * Wertet Tipps für alle abgeschlossenen Spiele aus. * Berechnet Punkte und aktualisiert die tips-Tabelle. * Refreshed danach die Leaderboard Materialized View. * * Sollte nach jedem abgeschlossenen Spiel laufen. */ export const evaluateTips = async (): Promise<{ matchesEvaluated: number; tipsUpdated: number; }> => { logger.info('Starting tip evaluation...'); // Alle abgeschlossenen Spiele mit noch nicht ausgewerteten Tipps const pendingMatches = await query<{ match_id: number; score_home: number; score_away: number; }>( `SELECT DISTINCT m.id AS match_id, m.score_home, m.score_away FROM matches m JOIN tips t ON t.match_id = m.id WHERE m.status = 'FINISHED' AND m.score_home IS NOT NULL AND m.score_away IS NOT NULL AND t.points IS NULL` ); let tipsUpdated = 0; for (const match of pendingMatches) { // Alle Tipps für dieses Spiel const tips = await query<{ id: number; tip_home: number; tip_away: number; }>( 'SELECT id, tip_home, tip_away FROM tips WHERE match_id = $1 AND points IS NULL', [match.match_id] ); for (const tip of tips) { const { points } = calculatePoints( { home: tip.tip_home, away: tip.tip_away }, { home: match.score_home, away: match.score_away } ); await query('UPDATE tips SET points = $1 WHERE id = $2', [points, tip.id]); tipsUpdated++; } } // Leaderboard Materialized View aktualisieren if (tipsUpdated > 0) { await query('REFRESH MATERIALIZED VIEW CONCURRENTLY leaderboard'); logger.info(`Leaderboard refreshed after evaluating ${tipsUpdated} tips`); } return { matchesEvaluated: pendingMatches.length, tipsUpdated, }; };