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
+21
View File
@@ -0,0 +1,21 @@
-- Migration 002: Add team field to users table
ALTER TABLE users ADD COLUMN IF NOT EXISTS team TEXT;
-- Refresh leaderboard view to include team
DROP MATERIALIZED VIEW IF EXISTS leaderboard;
CREATE MATERIALIZED VIEW leaderboard AS
SELECT
u.id AS user_id,
u.full_name,
u.team,
RANK() OVER (ORDER BY COALESCE(SUM(t.points), 0) DESC) AS rank,
COALESCE(SUM(t.points), 0) AS total_points,
COUNT(t.id) FILTER (WHERE t.points IS NOT NULL) AS tips_count,
COUNT(t.id) FILTER (WHERE t.points = 3) AS exact_count,
COUNT(t.id) FILTER (WHERE t.points = 1) AS tendency_count
FROM users u
LEFT JOIN tips t ON t.user_id = u.id
GROUP BY u.id, u.full_name, u.team;
CREATE UNIQUE INDEX IF NOT EXISTS leaderboard_user_id_idx ON leaderboard (user_id);
+2
View File
@@ -13,6 +13,7 @@ import matchesRouter from './routes/matches';
import tipsRouter from './routes/tips';
import leaderboardRouter from './routes/leaderboard';
import adminRouter from './routes/admin';
import profileRouter from './routes/profile';
const app = express();
const PORT = parseInt(process.env.PORT ?? '3001');
@@ -116,6 +117,7 @@ app.use('/api/matches', matchesRouter);
app.use('/api/tips', tipsRouter);
app.use('/api/leaderboard', leaderboardRouter);
app.use('/api/admin', adminRouter);
app.use('/api/profile', profileRouter);
// ============================================================
// Frontend (React Build) statisches Serving
+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<{
+45
View File
@@ -0,0 +1,45 @@
import { query } from '../db/client';
import { StaffbaseTokenData } from '../types';
import { logger } from './logger';
/**
* Legt einen User an oder aktualisiert seinen Namen/Locale beim Login.
* Das Team wird über die department_team_mapping Tabelle ermittelt,
* falls noch kein manuelles Team gesetzt wurde.
*/
export async function upsertUser(user: StaffbaseTokenData): Promise<void> {
try {
await 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,
branch_id = EXCLUDED.branch_id,
updated_at = NOW()`,
[
user.sub,
user.name ?? 'Unbekannt',
user.locale ?? 'de_DE',
user.branchId ?? null,
user.role ?? 'viewer',
]
);
// Team über Matching-Tabelle setzen, falls noch kein manuelles Team gesetzt
if (user.branchId) {
await query(
`UPDATE users u
SET team = m.team_name
FROM department_team_mapping m
WHERE u.id = $1
AND u.team IS NULL
AND m.department_key = $2`,
[user.sub, user.branchId]
);
}
} catch (err) {
// Fehler beim Upsert soll die eigentliche Anfrage nicht blockieren
logger.error('Failed to upsert user', { error: err, userId: user.sub });
}
}
+3
View File
@@ -71,6 +71,7 @@ export interface DbTip {
export interface DbLeaderboardEntry {
user_id: string;
full_name: string;
team: string | null;
total_points: number;
tips_count: number;
exact_count: number;
@@ -141,6 +142,7 @@ export interface TipSubmitResponse {
export interface LeaderboardResponse {
entries: DbLeaderboardEntry[];
currentUserRank: number | null;
currentUserId: string | null;
totalParticipants: number;
lastUpdated: string;
}
@@ -148,6 +150,7 @@ export interface LeaderboardResponse {
export interface UserStatsResponse {
userId: string;
fullName: string;
team: string | null;
totalPoints: number;
rank: number | null;
tipsCount: number;