feat: Ergebnis-Banner, Dev-Simulations-Panel & Spiele-Reset
- MatchCard: farbiger Ergebnis-Banner (Exakt/Tendenz/Falsch) ersetzt tipRow, passender Card-Glow je Ergebnis; Lucide-Icons (lucide-react) - MatchCard: Ändern-Button links, vertikal mittig zur Tipp-Box ausgerichtet - DevPanel: Simulationsmodus mit User-Switcher, Zeit- & Status-Presets - DevPanel: Reset-Section mit "Spiel zurücksetzen", "Alle Spiele" und "Tipps löschen" (3 Buttons, farblich differenziert) - Backend: dev.ts mit set-time, set-status, reset-tips, reset-match - reset-match stellt Original-Datum wieder her (original_utc_date Spalte) - set-time sichert Original-Datum per COALESCE beim ersten Aufruf - Supabase Migration: original_utc_date TIMESTAMPTZ zu matches hinzugefügt Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,7 @@ import tipsRouter from './routes/tips';
|
||||
import leaderboardRouter from './routes/leaderboard';
|
||||
import adminRouter from './routes/admin';
|
||||
import profileRouter from './routes/profile';
|
||||
import devRouter from './routes/dev';
|
||||
|
||||
const app = express();
|
||||
const PORT = parseInt(process.env.PORT ?? '3001');
|
||||
@@ -118,6 +119,9 @@ app.use('/api/tips', tipsRouter);
|
||||
app.use('/api/leaderboard', leaderboardRouter);
|
||||
app.use('/api/admin', adminRouter);
|
||||
app.use('/api/profile', profileRouter);
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
app.use('/api/dev', devRouter);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Frontend (React Build) – statisches Serving
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { query } from '../db/client';
|
||||
import { logger } from '../services/logger';
|
||||
|
||||
/**
|
||||
* Dev-only Routes — werden in Production blockiert
|
||||
* Erlaubt Daten-Manipulation für Testzwecke
|
||||
*/
|
||||
const router = Router();
|
||||
|
||||
// Sicherheitscheck: nur in Development verfügbar
|
||||
router.use((_req: Request, res: Response, next: Function) => {
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
res.status(404).json({ error: 'Not found' });
|
||||
return;
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/dev/match/:id/set-time
|
||||
* Setzt das Datum eines Spiels auf einen bestimmten Zeitpunkt
|
||||
* Body: { minutesFromNow: number } (negativ = Vergangenheit)
|
||||
*/
|
||||
router.post('/match/:id/set-time', async (req: Request, res: Response): Promise<void> => {
|
||||
const matchId = parseInt(req.params.id);
|
||||
const { minutesFromNow } = req.body as { minutesFromNow: number };
|
||||
|
||||
if (isNaN(matchId) || typeof minutesFromNow !== 'number') {
|
||||
res.status(400).json({ error: 'matchId und minutesFromNow erforderlich' });
|
||||
return;
|
||||
}
|
||||
|
||||
const newDate = new Date(Date.now() + minutesFromNow * 60 * 1000);
|
||||
|
||||
try {
|
||||
// Original-Datum beim ersten set-time sichern (nur wenn noch nicht gesetzt)
|
||||
await query(
|
||||
`UPDATE matches
|
||||
SET original_utc_date = COALESCE(original_utc_date, utc_date),
|
||||
utc_date = $1
|
||||
WHERE id = $2`,
|
||||
[newDate.toISOString(), matchId]
|
||||
);
|
||||
logger.info(`[DEV] Match ${matchId} utc_date gesetzt auf ${newDate.toISOString()}`);
|
||||
res.json({ success: true, matchId, newDate: newDate.toISOString(), minutesFromNow });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/dev/match/:id/set-status
|
||||
* Setzt den Status eines Spiels (z.B. FINISHED, IN_PLAY, TIMED)
|
||||
* Body: { status: string, scoreHome?: number, scoreAway?: number }
|
||||
*/
|
||||
router.post('/match/:id/set-status', async (req: Request, res: Response): Promise<void> => {
|
||||
const matchId = parseInt(req.params.id);
|
||||
const { status, scoreHome, scoreAway } = req.body as {
|
||||
status: string;
|
||||
scoreHome?: number;
|
||||
scoreAway?: number;
|
||||
};
|
||||
|
||||
const VALID_STATUSES = ['TIMED', 'SCHEDULED', 'IN_PLAY', 'PAUSED', 'FINISHED'];
|
||||
if (!VALID_STATUSES.includes(status)) {
|
||||
res.status(400).json({ error: `Status muss einer von: ${VALID_STATUSES.join(', ')} sein` });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await query(
|
||||
`UPDATE matches SET status = $1, score_home = $2, score_away = $3 WHERE id = $4`,
|
||||
[status, scoreHome ?? null, scoreAway ?? null, matchId]
|
||||
);
|
||||
logger.info(`[DEV] Match ${matchId} Status → ${status}`);
|
||||
res.json({ success: true, matchId, status, scoreHome, scoreAway });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/dev/reset-match
|
||||
* Setzt ein einzelnes Spiel zurück: Status → TIMED, Score → null, null
|
||||
* Body: { matchId?: number } — ohne matchId werden ALLE Spiele zurückgesetzt
|
||||
*/
|
||||
router.post('/reset-match', async (req: Request, res: Response): Promise<void> => {
|
||||
const { matchId } = req.body as { matchId?: number };
|
||||
try {
|
||||
if (matchId) {
|
||||
await query(
|
||||
`UPDATE matches
|
||||
SET status = 'TIMED',
|
||||
score_home = NULL,
|
||||
score_away = NULL,
|
||||
utc_date = COALESCE(original_utc_date, utc_date),
|
||||
original_utc_date = NULL
|
||||
WHERE id = $1`,
|
||||
[matchId]
|
||||
);
|
||||
logger.info(`[DEV] Match ${matchId} zurückgesetzt auf TIMED + Original-Datum`);
|
||||
res.json({ success: true, reset: 'single', matchId });
|
||||
} else {
|
||||
const result = await query<{ id: number }>(
|
||||
`UPDATE matches
|
||||
SET status = 'TIMED',
|
||||
score_home = NULL,
|
||||
score_away = NULL,
|
||||
utc_date = COALESCE(original_utc_date, utc_date),
|
||||
original_utc_date = NULL
|
||||
WHERE status IN ('IN_PLAY','PAUSED','FINISHED')
|
||||
OR original_utc_date IS NOT NULL
|
||||
RETURNING id`
|
||||
);
|
||||
logger.info(`[DEV] ${result.length} Spiele zurückgesetzt`);
|
||||
res.json({ success: true, reset: 'all', count: result.length });
|
||||
}
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/dev/reset
|
||||
* Setzt alle manipulierten Spiele zurück (löscht Dev-Tipps, setzt Status zurück)
|
||||
* Nur Spiele die durch Dev-User angelegt wurden
|
||||
*/
|
||||
router.post('/reset-tips', async (req: Request, res: Response): Promise<void> => {
|
||||
const { userId } = req.body as { userId?: string };
|
||||
try {
|
||||
const result = await query<{ count: string }>(
|
||||
`DELETE FROM tips WHERE user_id = $1 RETURNING id`,
|
||||
[userId ?? 'dev-user-001']
|
||||
);
|
||||
await query('REFRESH MATERIALIZED VIEW CONCURRENTLY leaderboard');
|
||||
res.json({ success: true, deletedTips: result.length });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
Reference in New Issue
Block a user