feat: WM2026 Tippspiel - Initial Backend + Frontend
This commit is contained in:
@@ -0,0 +1,176 @@
|
||||
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,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user