feat: WM2026 Tippspiel - Initial Backend + Frontend
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
import { FootballApiMatch, FootballApiResponse } from '../types';
|
||||
import { logger } from './logger';
|
||||
|
||||
const BASE_URL = process.env.FOOTBALL_API_BASE_URL ?? 'https://api.football-data.org/v4';
|
||||
const API_KEY = process.env.FOOTBALL_API_KEY ?? '';
|
||||
|
||||
// WM 2026 Competition Code bei football-data.org
|
||||
const WC_2026_CODE = 'WC';
|
||||
|
||||
/**
|
||||
* Rate limiting: Free tier = 10 Requests/Minute
|
||||
* Wir tracken den letzten Request-Zeitpunkt
|
||||
*/
|
||||
const requestQueue: Array<() => void> = [];
|
||||
let lastRequestTime = 0;
|
||||
const MIN_REQUEST_INTERVAL = 6100; // ~10 req/min with safety buffer
|
||||
|
||||
const throttledFetch = async (url: string): Promise<Response> => {
|
||||
const now = Date.now();
|
||||
const timeSinceLastRequest = now - lastRequestTime;
|
||||
|
||||
if (timeSinceLastRequest < MIN_REQUEST_INTERVAL) {
|
||||
const waitTime = MIN_REQUEST_INTERVAL - timeSinceLastRequest;
|
||||
logger.debug(`Rate limiting: waiting ${waitTime}ms`);
|
||||
await new Promise(r => setTimeout(r, waitTime));
|
||||
}
|
||||
|
||||
lastRequestTime = Date.now();
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'X-Auth-Token': API_KEY,
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
});
|
||||
return response;
|
||||
} catch (error: unknown) {
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
logger.error('football-data.org fetch failed', { url, error: msg });
|
||||
throw new Error(`Netzwerkfehler beim Abruf von football-data.org: ${msg}`);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Alle Spiele der WM 2026 von football-data.org abrufen
|
||||
*/
|
||||
export const fetchAllMatches = async (): Promise<FootballApiMatch[]> => {
|
||||
const url = `${BASE_URL}/competitions/${WC_2026_CODE}/matches`;
|
||||
|
||||
logger.info('Fetching WC 2026 matches from football-data.org');
|
||||
|
||||
const response = await throttledFetch(url);
|
||||
|
||||
if (response.status === 429) {
|
||||
throw new Error('football-data.org rate limit exceeded');
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
throw new Error(
|
||||
`football-data.org API error: ${response.status} ${response.statusText} – ${body}`
|
||||
);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as FootballApiResponse;
|
||||
|
||||
logger.info(`Fetched ${data.matches.length} matches from football-data.org`);
|
||||
return data.matches;
|
||||
};
|
||||
|
||||
/**
|
||||
* Nur FINISHED Spiele abrufen (für Punkte-Berechnung)
|
||||
*/
|
||||
export const fetchFinishedMatches = async (): Promise<FootballApiMatch[]> => {
|
||||
const url = `${BASE_URL}/competitions/${WC_2026_CODE}/matches?status=FINISHED`;
|
||||
const response = await throttledFetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`football-data.org API error: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as FootballApiResponse;
|
||||
return data.matches;
|
||||
};
|
||||
|
||||
/**
|
||||
* Ein einzelnes Spiel abrufen (für Live-Updates)
|
||||
*/
|
||||
export const fetchMatch = async (matchId: number): Promise<FootballApiMatch> => {
|
||||
const url = `${BASE_URL}/matches/${matchId}`;
|
||||
const response = await throttledFetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`football-data.org API error: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { match: FootballApiMatch };
|
||||
return data.match;
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import winston from 'winston';
|
||||
|
||||
export const logger = winston.createLogger({
|
||||
level: process.env.NODE_ENV === 'production' ? 'info' : 'debug',
|
||||
format: winston.format.combine(
|
||||
winston.format.timestamp(),
|
||||
winston.format.errors({ stack: true }),
|
||||
process.env.NODE_ENV === 'production'
|
||||
? winston.format.json()
|
||||
: winston.format.combine(
|
||||
winston.format.colorize(),
|
||||
winston.format.printf(({ timestamp, level, message, ...meta }) => {
|
||||
const metaStr = Object.keys(meta).length
|
||||
? '\n' + JSON.stringify(meta, null, 2)
|
||||
: '';
|
||||
return `${timestamp} [${level}]: ${message}${metaStr}`;
|
||||
})
|
||||
)
|
||||
),
|
||||
transports: [new winston.transports.Console()],
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import { PointsCalculation, POINTS } from '../types';
|
||||
|
||||
interface Score {
|
||||
home: number;
|
||||
away: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Berechnet die Punkte für einen Tipp.
|
||||
*
|
||||
* Punkteregeln:
|
||||
* - Exaktes Ergebnis: 3 Punkte
|
||||
* - Richtige Tendenz: 1 Punkt (Sieg/Unentschieden/Niederlage korrekt)
|
||||
* - Falsche Tendenz: 0 Punkte
|
||||
*
|
||||
* @param tip - Der abgegebene Tipp
|
||||
* @param actual - Das tatsächliche Ergebnis
|
||||
*/
|
||||
export const calculatePoints = (
|
||||
tip: Score,
|
||||
actual: Score
|
||||
): PointsCalculation => {
|
||||
// Exaktes Ergebnis
|
||||
if (tip.home === actual.home && tip.away === actual.away) {
|
||||
return { result: 'exact', points: POINTS.EXACT };
|
||||
}
|
||||
|
||||
// Tendenz prüfen (Sieg Heim / Unentschieden / Sieg Auswärts)
|
||||
const tipTendency = getTendency(tip.home, tip.away);
|
||||
const actualTendency = getTendency(actual.home, actual.away);
|
||||
|
||||
if (tipTendency === actualTendency) {
|
||||
return { result: 'tendency', points: POINTS.TENDENCY };
|
||||
}
|
||||
|
||||
return { result: 'wrong', points: POINTS.WRONG };
|
||||
};
|
||||
|
||||
type Tendency = 'home' | 'draw' | 'away';
|
||||
|
||||
const getTendency = (home: number, away: number): Tendency => {
|
||||
if (home > away) return 'home';
|
||||
if (home < away) return 'away';
|
||||
return 'draw';
|
||||
};
|
||||
|
||||
/**
|
||||
* Berechnet Punkte für die gesamte Tipp-History eines Users.
|
||||
* Nützlich für Statistiken.
|
||||
*/
|
||||
export const calculateUserStats = (
|
||||
tips: Array<{
|
||||
tipHome: number;
|
||||
tipAway: number;
|
||||
actualHome: number | null;
|
||||
actualAway: number | null;
|
||||
points: number | null;
|
||||
}>
|
||||
) => {
|
||||
const evaluated = tips.filter(
|
||||
(t) => t.actualHome !== null && t.actualAway !== null
|
||||
);
|
||||
|
||||
const exactCount = evaluated.filter((t) => t.points === POINTS.EXACT).length;
|
||||
const tendencyCount = evaluated.filter(
|
||||
(t) => t.points === POINTS.TENDENCY
|
||||
).length;
|
||||
const wrongCount = evaluated.filter((t) => t.points === POINTS.WRONG).length;
|
||||
const totalPoints = evaluated.reduce((sum, t) => sum + (t.points ?? 0), 0);
|
||||
|
||||
const accuracy =
|
||||
evaluated.length > 0
|
||||
? Math.round(
|
||||
((exactCount + tendencyCount) / evaluated.length) * 100
|
||||
)
|
||||
: 0;
|
||||
|
||||
return {
|
||||
totalPoints,
|
||||
exactCount,
|
||||
tendencyCount,
|
||||
wrongCount,
|
||||
accuracy,
|
||||
evaluatedCount: evaluated.length,
|
||||
pendingCount: tips.length - evaluated.length,
|
||||
};
|
||||
};
|
||||
@@ -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