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