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:
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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;
|
||||
@@ -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<{
|
||||
|
||||
Reference in New Issue
Block a user