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