feat: WM2026 Tippspiel - Initial Backend + Frontend

This commit is contained in:
Ronny Müller
2026-04-03 21:41:19 +02:00
commit 1c685b90a0
2507 changed files with 997210 additions and 0 deletions
+87
View File
@@ -0,0 +1,87 @@
import { Router, Request, Response } from 'express';
import { requireEditor } from '../middleware/staffbaseAuth';
import { syncMatches, evaluateTips } from '../services/syncService';
import { query } from '../db/client';
import { checkDbConnection } from '../db/client';
import { logger } from '../services/logger';
const router = Router();
// Alle Admin-Routen erfordern Editor-Rolle
router.use(requireEditor);
/**
* POST /api/admin/sync
* Manueller Trigger: Spiele von football-data.org synchronisieren
*/
router.post('/sync', async (_req: Request, res: Response): Promise<void> => {
try {
logger.info('Manual sync triggered');
const result = await syncMatches();
res.json({ success: true, ...result });
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : String(error);
logger.error('Manual sync failed', { message: msg });
res.status(500).json({
success: false,
error: msg,
});
}
});
/**
* POST /api/admin/evaluate
* Manueller Trigger: Tipps für abgeschlossene Spiele auswerten
*/
router.post('/evaluate', async (_req: Request, res: Response): Promise<void> => {
try {
logger.info('Manual tip evaluation triggered');
const result = await evaluateTips();
res.json({ success: true, ...result });
} catch (error) {
logger.error('Manual evaluation failed', { error });
res.status(500).json({
success: false,
error: (error as Error).message,
});
}
});
/**
* GET /api/admin/stats
* Allgemeine Statistiken (für Admin-Dashboard)
*/
router.get('/stats', async (_req: Request, res: Response): Promise<void> => {
try {
const [matchStats, tipStats, syncStats] = await Promise.all([
query<{ total: string; finished: string; scheduled: string }>(
`SELECT
COUNT(*) AS total,
COUNT(*) FILTER (WHERE status = 'FINISHED') AS finished,
COUNT(*) FILTER (WHERE status IN ('SCHEDULED','TIMED')) AS scheduled
FROM matches`
),
query<{ total: string; evaluated: string; pending: string }>(
`SELECT
COUNT(*) AS total,
COUNT(*) FILTER (WHERE points IS NOT NULL) AS evaluated,
COUNT(*) FILTER (WHERE points IS NULL) AS pending
FROM tips`
),
query<{ synced_at: Date; status: string; matches_new: number }>(
'SELECT synced_at, status, matches_new FROM sync_log ORDER BY synced_at DESC LIMIT 1'
),
]);
res.json({
matches: matchStats[0],
tips: tipStats[0],
lastSync: syncStats[0] ?? null,
});
} catch (error) {
logger.error('Failed to fetch admin stats', { error });
res.status(500).json({ error: 'Internal server error' });
}
});
export default router;
+129
View File
@@ -0,0 +1,129 @@
import { Router, Request, Response } from 'express';
import { query } from '../db/client';
import { LeaderboardResponse, UserStatsResponse } from '../types';
import { logger } from '../services/logger';
const router = Router();
/**
* GET /api/leaderboard
* Aktuelle Rangliste aus der Materialized View
*/
router.get('/', async (req: Request, res: Response): Promise<void> => {
const userId = req.staffbaseUser!.sub;
const limit = Math.min(parseInt(req.query.limit as string) || 50, 100);
try {
const [entries, metaRows] = await Promise.all([
query<{
user_id: string;
full_name: string;
total_points: number;
tips_count: number;
exact_count: number;
tendency_count: number;
rank: number;
}>(
`SELECT user_id, full_name, total_points, tips_count,
exact_count, tendency_count, rank
FROM leaderboard
ORDER BY rank ASC
LIMIT $1`,
[limit]
),
query<{ count: string }>(
'SELECT COUNT(*) FROM leaderboard'
),
]);
// Aktuellen User-Rank ermitteln (auch wenn er nicht in den Top-N ist)
const userEntry = entries.find((e) => e.user_id === userId);
let currentUserRank: number | null = userEntry?.rank ?? null;
if (!userEntry) {
const userRankRows = await query<{ rank: number }>(
'SELECT rank FROM leaderboard WHERE user_id = $1',
[userId]
);
currentUserRank = userRankRows[0]?.rank ?? null;
}
const response: LeaderboardResponse = {
entries,
currentUserRank,
totalParticipants: parseInt(metaRows[0]?.count ?? '0'),
lastUpdated: new Date().toISOString(),
};
res.json(response);
} catch (error) {
logger.error('Failed to fetch leaderboard', { error });
res.status(500).json({ error: 'Internal server error' });
}
});
/**
* GET /api/leaderboard/me
* Statistiken des aktuellen Users
*/
router.get('/me', async (req: Request, res: Response): Promise<void> => {
const userId = req.staffbaseUser!.sub;
try {
const [leaderboardRows, tipsRows] = await Promise.all([
query<{
full_name: string;
total_points: number;
tips_count: number;
exact_count: number;
tendency_count: number;
rank: number | null;
}>(
`SELECT full_name, total_points, tips_count, exact_count, tendency_count, rank
FROM leaderboard WHERE user_id = $1`,
[userId]
),
query<{ count: string; wrong_count: string }>(
`SELECT
COUNT(*) FILTER (WHERE points IS NOT NULL) AS count,
COUNT(*) FILTER (WHERE points = 0) AS wrong_count
FROM tips
WHERE user_id = $1`,
[userId]
),
]);
const lb = leaderboardRows[0];
const tipsStats = tipsRows[0];
const evaluatedCount = parseInt(tipsStats?.count ?? '0');
const wrongCount = parseInt(tipsStats?.wrong_count ?? '0');
const accuracy =
evaluatedCount > 0
? Math.round(
(((lb?.exact_count ?? 0) + (lb?.tendency_count ?? 0)) /
evaluatedCount) *
100
)
: 0;
const response: UserStatsResponse = {
userId,
fullName: lb?.full_name ?? req.staffbaseUser!.name ?? 'Unbekannt',
totalPoints: lb?.total_points ?? 0,
rank: lb?.rank ?? null,
tipsCount: lb?.tips_count ?? 0,
exactCount: lb?.exact_count ?? 0,
tendencyCount: lb?.tendency_count ?? 0,
wrongCount,
accuracy,
};
res.json(response);
} catch (error) {
logger.error('Failed to fetch user stats', { error, userId });
res.status(500).json({ error: 'Internal server error' });
}
});
export default router;
+194
View File
@@ -0,0 +1,194 @@
import { Router, Request, Response } from 'express';
import { query } from '../db/client';
import { MatchResponse } from '../types';
import { logger } from '../services/logger';
const router = Router();
/**
* GET /api/matches
* Alle Spiele zurückgeben, mit dem Tipp des aktuellen Users (falls vorhanden)
*
* Query params:
* stage=GROUP_STAGE | ROUND_OF_16 | QUARTER_FINALS | SEMI_FINALS | FINAL
* group=GROUP_A ... GROUP_H
*/
router.get('/', async (req: Request, res: Response): Promise<void> => {
const userId = req.staffbaseUser!.sub;
const { stage, group } = req.query;
try {
let whereClause = '';
const params: unknown[] = [userId];
if (stage) {
params.push(stage);
whereClause += ` AND m.stage = $${params.length}`;
}
if (group) {
params.push(group);
whereClause += ` AND m.group_name = $${params.length}`;
}
const rows = await query<{
id: number;
external_id: number;
utc_date: Date;
status: string;
stage: string;
group_name: string | null;
home_team_name: string;
home_team_short: string;
home_team_crest: string | null;
away_team_name: string;
away_team_short: string;
away_team_crest: string | null;
score_home: number | null;
score_away: number | null;
tip_home: number | null;
tip_away: number | null;
tip_points: number | null;
}>(
`SELECT
m.id, m.external_id, m.utc_date, m.status, m.stage, m.group_name,
m.home_team_name, m.home_team_short, m.home_team_crest,
m.away_team_name, m.away_team_short, m.away_team_crest,
m.score_home, m.score_away,
t.tip_home, t.tip_away, t.points AS tip_points
FROM matches m
LEFT JOIN tips t ON t.match_id = m.id AND t.user_id = $1
WHERE 1=1 ${whereClause}
ORDER BY m.utc_date ASC`,
params
);
const now = new Date();
const matches: MatchResponse[] = rows.map((row) => {
const kickoff = new Date(row.utc_date);
const minutesUntilKickoff = Math.floor(
(kickoff.getTime() - now.getTime()) / 60000
);
// Tipps können bis 5 Minuten vor Anpfiff abgegeben werden
const tippable = minutesUntilKickoff > 5 && (row.status === 'SCHEDULED' || row.status === 'TIMED');
return {
id: row.id,
externalId: row.external_id,
utcDate: row.utc_date.toISOString(),
status: row.status as MatchResponse['status'],
stage: row.stage,
group: row.group_name,
homeTeam: {
name: row.home_team_name,
shortName: row.home_team_short,
crest: row.home_team_crest,
},
awayTeam: {
name: row.away_team_name,
shortName: row.away_team_short,
crest: row.away_team_crest,
},
score: {
home: row.score_home,
away: row.score_away,
},
userTip:
row.tip_home !== null
? {
home: row.tip_home,
away: row.tip_away!,
points: row.tip_points,
}
: null,
minutesUntilKickoff,
tippable,
};
});
res.json({ matches, count: matches.length });
} catch (error) {
logger.error('Failed to fetch matches', { error, userId });
res.status(500).json({ error: 'Internal server error' });
}
});
/**
* GET /api/matches/:id
* Ein einzelnes Spiel mit dem Tipp des Users
*/
router.get('/:id', async (req: Request, res: Response): Promise<void> => {
const userId = req.staffbaseUser!.sub;
const matchId = parseInt(req.params.id);
if (isNaN(matchId)) {
res.status(400).json({ error: 'Invalid match ID' });
return;
}
try {
const rows = await query<{
id: number;
external_id: number;
utc_date: Date;
status: string;
stage: string;
group_name: string | null;
home_team_name: string;
home_team_short: string;
home_team_crest: string | null;
away_team_name: string;
away_team_short: string;
away_team_crest: string | null;
score_home: number | null;
score_away: number | null;
tip_home: number | null;
tip_away: number | null;
tip_points: number | null;
}>(
`SELECT
m.id, m.external_id, m.utc_date, m.status, m.stage, m.group_name,
m.home_team_name, m.home_team_short, m.home_team_crest,
m.away_team_name, m.away_team_short, m.away_team_crest,
m.score_home, m.score_away,
t.tip_home, t.tip_away, t.points AS tip_points
FROM matches m
LEFT JOIN tips t ON t.match_id = m.id AND t.user_id = $1
WHERE m.id = $2`,
[userId, matchId]
);
if (rows.length === 0) {
res.status(404).json({ error: 'Match not found' });
return;
}
const row = rows[0];
const kickoff = new Date(row.utc_date);
const minutesUntilKickoff = Math.floor(
(kickoff.getTime() - Date.now()) / 60000
);
res.json({
id: row.id,
externalId: row.external_id,
utcDate: row.utc_date.toISOString(),
status: row.status,
stage: row.stage,
group: row.group_name,
homeTeam: { name: row.home_team_name, shortName: row.home_team_short, crest: row.home_team_crest },
awayTeam: { name: row.away_team_name, shortName: row.away_team_short, crest: row.away_team_crest },
score: { home: row.score_home, away: row.score_away },
userTip: row.tip_home !== null
? { home: row.tip_home, away: row.tip_away, points: row.tip_points }
: null,
minutesUntilKickoff,
tippable: minutesUntilKickoff > 5 && (row.status === 'SCHEDULED' || row.status === 'TIMED'),
});
} catch (error) {
logger.error('Failed to fetch match', { error, matchId });
res.status(500).json({ error: 'Internal server error' });
}
});
export default router;
+163
View File
@@ -0,0 +1,163 @@
import { Router, Request, Response } from 'express';
import { query, withTransaction } from '../db/client';
import { TipSubmitRequest, TipSubmitResponse } from '../types';
import { logger } from '../services/logger';
const router = Router();
/**
* POST /api/tips
* Tipp für ein Spiel abgeben oder aktualisieren (UPSERT)
*
* Body: { matchId: number, tipHome: number, tipAway: number }
*/
router.post('/', async (req: Request, res: Response): Promise<void> => {
const userId = req.staffbaseUser!.sub;
const { matchId, tipHome, tipAway } = req.body as TipSubmitRequest;
// Input-Validierung
if (
typeof matchId !== 'number' ||
typeof tipHome !== 'number' ||
typeof tipAway !== 'number' ||
tipHome < 0 ||
tipAway < 0 ||
tipHome > 99 ||
tipAway > 99 ||
!Number.isInteger(tipHome) ||
!Number.isInteger(tipAway)
) {
res.status(400).json({
error: 'Invalid tip',
message: 'tipHome and tipAway must be integers between 0 and 99',
});
return;
}
try {
await withTransaction(async (client) => {
// Prüfen ob das Spiel noch tippbar ist
const matchRows = await client.query<{
id: number;
utc_date: Date;
status: string;
}>(
'SELECT id, utc_date, status FROM matches WHERE id = $1',
[matchId]
);
if (matchRows.rows.length === 0) {
res.status(404).json({ error: 'Match not found' });
return;
}
const match = matchRows.rows[0];
const minutesUntilKickoff = Math.floor(
(new Date(match.utc_date).getTime() - Date.now()) / 60000
);
if (minutesUntilKickoff <= 5) {
res.status(409).json({
error: 'Tip deadline passed',
message: 'Tipps können nur bis 5 Minuten vor Anpfiff abgegeben werden.',
});
return;
}
if (match.status !== 'SCHEDULED' && match.status !== 'TIMED') {
res.status(409).json({
error: 'Match not tippable',
message: 'Für dieses Spiel können keine Tipps mehr abgegeben werden.',
});
return;
}
// Sicherstellen, dass der User in der Datenbank existiert
await client.query(
`INSERT INTO users (id, full_name, locale, branch_id, role)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (id) DO UPDATE SET
full_name = EXCLUDED.full_name,
locale = EXCLUDED.locale,
updated_at = NOW()`,
[
userId,
req.staffbaseUser!.name ?? 'Unbekannt',
req.staffbaseUser!.locale ?? 'de_DE',
req.staffbaseUser!.branch_id ?? null,
req.staffbaseUser!.role ?? 'viewer',
]
);
// Tipp anlegen oder aktualisieren (UPSERT)
const result = await client.query<{
tip_home: number;
tip_away: number;
}>(
`INSERT INTO tips (user_id, match_id, tip_home, tip_away)
VALUES ($1, $2, $3, $4)
ON CONFLICT (user_id, match_id) DO UPDATE SET
tip_home = EXCLUDED.tip_home,
tip_away = EXCLUDED.tip_away,
points = NULL,
updated_at = NOW()
RETURNING tip_home, tip_away`,
[userId, matchId, tipHome, tipAway]
);
const tip = result.rows[0];
const response: TipSubmitResponse = {
success: true,
tip: {
home: tip.tip_home,
away: tip.tip_away,
points: null,
},
message: `Tipp ${tipHome}:${tipAway} gespeichert!`,
};
logger.info('Tip saved', { userId, matchId, tipHome, tipAway });
res.status(201).json(response);
});
} catch (error) {
logger.error('Failed to save tip', { error, userId, matchId });
res.status(500).json({ error: 'Internal server error' });
}
});
/**
* GET /api/tips
* Alle Tipps des aktuellen Users
*/
router.get('/', async (req: Request, res: Response): Promise<void> => {
const userId = req.staffbaseUser!.sub;
try {
const tips = await query<{
match_id: number;
tip_home: number;
tip_away: number;
points: number | null;
utc_date: Date;
home_team_short: string;
away_team_short: string;
status: string;
}>(
`SELECT
t.match_id, t.tip_home, t.tip_away, t.points,
m.utc_date, m.home_team_short, m.away_team_short, m.status
FROM tips t
JOIN matches m ON m.id = t.match_id
WHERE t.user_id = $1
ORDER BY m.utc_date ASC`,
[userId]
);
res.json({ tips, count: tips.length });
} catch (error) {
logger.error('Failed to fetch tips', { error, userId });
res.status(500).json({ error: 'Internal server error' });
}
});
export default router;