feat: Stadium Elite Design, Rangliste, Profil-Team, User-Upsert & n8n Cronjob

- MatchCard + TipModal: Uhrzeit statt VS zwischen Flaggen, Gruppe zentriert
- LeaderboardPage: Podium (2./1./3.), DU-Badge, Trend-Pfeile, Team-Zeile, CTA-Card
- AdminPage: Stadium Elite Redesign mit Result-Bar und Inline-Spinner
- ProfilePage: Team-Feld inline editierbar (PATCH /api/profile/team)
- User-Upsert beim ersten App-Aufruf (Matches-Route) statt erst beim Tipp
- DB Migration 002: team-Spalte in users, Leaderboard View aktualisiert
- Leaderboard-Refresh automatisch nach Tipps-Auswertung
- n8n Workflow angelegt: stündlicher Sync + Auswertung (ID: t3SDspIGDXwkfOt3)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ronny Mueller
2026-04-03 23:37:38 +02:00
parent e967f36f6c
commit e27a62a37b
20 changed files with 1515 additions and 297 deletions
+15
View File
@@ -47,6 +47,21 @@ router.post('/evaluate', async (_req: Request, res: Response): Promise<void> =>
}
});
/**
* POST /api/admin/refresh-leaderboard
* Materialized View manuell aktualisieren
*/
router.post('/refresh-leaderboard', async (_req: Request, res: Response): Promise<void> => {
try {
await query('REFRESH MATERIALIZED VIEW CONCURRENTLY leaderboard');
logger.info('Leaderboard refreshed manually');
res.json({ success: true, message: 'Leaderboard refreshed' });
} catch (error) {
logger.error('Failed to refresh leaderboard', { error });
res.status(500).json({ success: false, error: (error as Error).message });
}
});
/**
* GET /api/admin/stats
* Allgemeine Statistiken (für Admin-Dashboard)
+6 -2
View File
@@ -18,13 +18,14 @@ router.get('/', async (req: Request, res: Response): Promise<void> => {
query<{
user_id: string;
full_name: string;
team: string | null;
total_points: number;
tips_count: number;
exact_count: number;
tendency_count: number;
rank: number;
}>(
`SELECT user_id, full_name, total_points, tips_count,
`SELECT user_id, full_name, team, total_points, tips_count,
exact_count, tendency_count, rank
FROM leaderboard
ORDER BY rank ASC
@@ -51,6 +52,7 @@ router.get('/', async (req: Request, res: Response): Promise<void> => {
const response: LeaderboardResponse = {
entries,
currentUserRank,
currentUserId: userId,
totalParticipants: parseInt(metaRows[0]?.count ?? '0'),
lastUpdated: new Date().toISOString(),
};
@@ -73,13 +75,14 @@ router.get('/me', async (req: Request, res: Response): Promise<void> => {
const [leaderboardRows, tipsRows] = await Promise.all([
query<{
full_name: string;
team: string | null;
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
`SELECT full_name, team, total_points, tips_count, exact_count, tendency_count, rank
FROM leaderboard WHERE user_id = $1`,
[userId]
),
@@ -110,6 +113,7 @@ router.get('/me', async (req: Request, res: Response): Promise<void> => {
const response: UserStatsResponse = {
userId,
fullName: lb?.full_name ?? req.staffbaseUser!.name ?? 'Unbekannt',
team: lb?.team ?? null,
totalPoints: lb?.total_points ?? 0,
rank: lb?.rank ?? null,
tipsCount: lb?.tips_count ?? 0,
+4
View File
@@ -2,6 +2,7 @@ import { Router, Request, Response } from 'express';
import { query } from '../db/client';
import { MatchResponse } from '../types';
import { logger } from '../services/logger';
import { upsertUser } from '../services/userService';
const router = Router();
@@ -17,6 +18,9 @@ router.get('/', async (req: Request, res: Response): Promise<void> => {
const userId = req.staffbaseUser!.sub;
const { stage, group } = req.query;
// User beim ersten App-Aufruf automatisch anlegen / aktualisieren
await upsertUser(req.staffbaseUser!);
try {
let whereClause = '';
const params: unknown[] = [userId];
+37
View File
@@ -0,0 +1,37 @@
import { Router, Request, Response } from 'express';
import { query } from '../db/client';
import { logger } from '../services/logger';
const router = Router();
/**
* PATCH /api/profile/team
* Nutzer setzt sein Team manuell
*/
router.patch('/team', async (req: Request, res: Response): Promise<void> => {
const userId = req.staffbaseUser!.sub;
const { team } = req.body as { team: string };
if (typeof team !== 'string' || team.trim().length === 0) {
res.status(400).json({ error: 'team darf nicht leer sein' });
return;
}
if (team.trim().length > 80) {
res.status(400).json({ error: 'team darf maximal 80 Zeichen haben' });
return;
}
try {
await query(
`UPDATE users SET team = $1, updated_at = NOW() WHERE id = $2`,
[team.trim(), userId]
);
res.json({ success: true, team: team.trim() });
} catch (error) {
logger.error('Failed to update team', { error, userId });
res.status(500).json({ error: 'Internal server error' });
}
});
export default router;
+3 -16
View File
@@ -2,6 +2,7 @@ import { Router, Request, Response } from 'express';
import { query, withTransaction } from '../db/client';
import { TipSubmitRequest, TipSubmitResponse } from '../types';
import { logger } from '../services/logger';
import { upsertUser } from '../services/userService';
const router = Router();
@@ -72,22 +73,8 @@ router.post('/', async (req: Request, res: Response): Promise<void> => {
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',
]
);
// Sicherstellen, dass der User in der Datenbank existiert (Fallback)
await upsertUser(req.staffbaseUser!);
// Tipp anlegen oder aktualisieren (UPSERT)
const result = await client.query<{