177 lines
5.2 KiB
TypeScript
177 lines
5.2 KiB
TypeScript
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,
|
|
};
|
|
};
|