feat: WM2026 Tippspiel - Initial Backend + Frontend
This commit is contained in:
Vendored
BIN
Binary file not shown.
@@ -0,0 +1,87 @@
|
||||
import { Pool, PoolClient } from 'pg';
|
||||
import { logger } from '../services/logger';
|
||||
|
||||
// Singleton Pool – wird beim ersten Import erstellt
|
||||
let pool: Pool | null = null;
|
||||
|
||||
export const getPool = (): Pool => {
|
||||
if (!pool) {
|
||||
const connectionString = process.env.DATABASE_URL;
|
||||
if (!connectionString) {
|
||||
throw new Error('DATABASE_URL environment variable is not set');
|
||||
}
|
||||
|
||||
pool = new Pool({
|
||||
connectionString,
|
||||
// Supabase: SSL erforderlich in Production
|
||||
ssl: process.env.NODE_ENV === 'production'
|
||||
? { rejectUnauthorized: false }
|
||||
: undefined,
|
||||
max: 10, // Max. Verbindungen im Pool
|
||||
idleTimeoutMillis: 30000,
|
||||
connectionTimeoutMillis: 5000,
|
||||
});
|
||||
|
||||
pool.on('error', (err) => {
|
||||
logger.error('Unexpected database pool error', { error: err });
|
||||
});
|
||||
|
||||
logger.info('Database pool initialized');
|
||||
}
|
||||
return pool;
|
||||
};
|
||||
|
||||
/**
|
||||
* Führt eine Query aus und gibt die Rows zurück.
|
||||
* Für einfache SELECT/INSERT ohne Transaction.
|
||||
*/
|
||||
export const query = async <T extends Record<string, unknown> = Record<string, unknown>>(
|
||||
text: string,
|
||||
params?: unknown[]
|
||||
): Promise<T[]> => {
|
||||
const start = Date.now();
|
||||
const result = await getPool().query<T>(text, params);
|
||||
const duration = Date.now() - start;
|
||||
|
||||
logger.debug('DB Query', {
|
||||
query: text.substring(0, 100),
|
||||
duration: `${duration}ms`,
|
||||
rows: result.rowCount,
|
||||
});
|
||||
|
||||
return result.rows;
|
||||
};
|
||||
|
||||
/**
|
||||
* Führt mehrere Queries in einer Transaction aus.
|
||||
* Rollback bei Fehler.
|
||||
*/
|
||||
export const withTransaction = async <T>(
|
||||
fn: (client: PoolClient) => Promise<T>
|
||||
): Promise<T> => {
|
||||
const client = await getPool().connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
const result = await fn(client);
|
||||
await client.query('COMMIT');
|
||||
return result;
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Health Check – prüft ob DB erreichbar ist
|
||||
*/
|
||||
export const checkDbConnection = async (): Promise<boolean> => {
|
||||
try {
|
||||
await query('SELECT 1');
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error('Database health check failed', { error });
|
||||
return false;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Migration 001: Team IDs auf nullable setzen
|
||||
-- KO-Runden-Spiele haben noch keine Teams eingetragen
|
||||
ALTER TABLE matches ALTER COLUMN home_team_id DROP NOT NULL;
|
||||
ALTER TABLE matches ALTER COLUMN away_team_id DROP NOT NULL;
|
||||
@@ -0,0 +1,154 @@
|
||||
-- ============================================================
|
||||
-- WM 2026 Tippspiel – Datenbankschema für Supabase / PostgreSQL
|
||||
-- ============================================================
|
||||
-- Ausführen in Supabase SQL Editor oder via psql:
|
||||
-- psql $DATABASE_URL -f schema.sql
|
||||
-- ============================================================
|
||||
|
||||
-- Extensions
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
|
||||
-- ============================================================
|
||||
-- USERS
|
||||
-- Staffbase userId (sub) als Primary Key
|
||||
-- Wird beim ersten Login automatisch angelegt
|
||||
-- ============================================================
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY, -- Staffbase userId (JWT sub)
|
||||
full_name TEXT NOT NULL DEFAULT '',
|
||||
locale TEXT NOT NULL DEFAULT 'de_DE',
|
||||
branch_id TEXT,
|
||||
role TEXT NOT NULL DEFAULT 'viewer',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- ============================================================
|
||||
-- MATCHES
|
||||
-- Gespiegelte Daten von football-data.org
|
||||
-- Wird per Cronjob synchronisiert
|
||||
-- ============================================================
|
||||
CREATE TABLE IF NOT EXISTS matches (
|
||||
id SERIAL PRIMARY KEY,
|
||||
external_id INTEGER UNIQUE NOT NULL, -- football-data.org Match ID
|
||||
utc_date TIMESTAMPTZ NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'SCHEDULED',
|
||||
stage TEXT NOT NULL, -- 'GROUP_STAGE', 'ROUND_OF_16', etc.
|
||||
group_name TEXT, -- 'GROUP_A', NULL für KO-Runden
|
||||
home_team_id INTEGER NOT NULL,
|
||||
away_team_id INTEGER NOT NULL,
|
||||
home_team_name TEXT NOT NULL,
|
||||
away_team_name TEXT NOT NULL,
|
||||
home_team_short TEXT NOT NULL DEFAULT '',
|
||||
away_team_short TEXT NOT NULL DEFAULT '',
|
||||
home_team_crest TEXT, -- URL zum Team-Logo
|
||||
away_team_crest TEXT,
|
||||
score_home INTEGER, -- NULL = noch nicht gespielt
|
||||
score_away INTEGER,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_matches_utc_date ON matches(utc_date);
|
||||
CREATE INDEX IF NOT EXISTS idx_matches_status ON matches(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_matches_stage ON matches(stage);
|
||||
|
||||
-- ============================================================
|
||||
-- TIPS
|
||||
-- Tipp eines Users für ein Spiel
|
||||
-- Unique Constraint: 1 Tipp pro User pro Spiel
|
||||
-- ============================================================
|
||||
CREATE TABLE IF NOT EXISTS tips (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
match_id INTEGER NOT NULL REFERENCES matches(id) ON DELETE CASCADE,
|
||||
tip_home SMALLINT NOT NULL CHECK (tip_home >= 0),
|
||||
tip_away SMALLINT NOT NULL CHECK (tip_away >= 0),
|
||||
points SMALLINT, -- NULL = Spiel noch nicht ausgewertet
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
CONSTRAINT tips_unique_user_match UNIQUE (user_id, match_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tips_user_id ON tips(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_tips_match_id ON tips(match_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_tips_points ON tips(points);
|
||||
|
||||
-- ============================================================
|
||||
-- LEADERBOARD (Materialized View)
|
||||
-- Wird nach jeder Punkte-Berechnung refreshed
|
||||
-- ============================================================
|
||||
CREATE MATERIALIZED VIEW IF NOT EXISTS leaderboard AS
|
||||
SELECT
|
||||
u.id AS user_id,
|
||||
u.full_name,
|
||||
COALESCE(SUM(t.points), 0)::INTEGER AS total_points,
|
||||
COUNT(t.id)::INTEGER AS tips_count,
|
||||
COUNT(CASE WHEN t.points = 3 THEN 1 END)::INTEGER AS exact_count,
|
||||
COUNT(CASE WHEN t.points = 1 THEN 1 END)::INTEGER AS tendency_count,
|
||||
RANK() OVER (
|
||||
ORDER BY COALESCE(SUM(t.points), 0) DESC,
|
||||
COUNT(CASE WHEN t.points = 3 THEN 1 END) DESC
|
||||
)::INTEGER AS rank
|
||||
FROM users u
|
||||
LEFT JOIN tips t ON t.user_id = u.id
|
||||
GROUP BY u.id, u.full_name
|
||||
ORDER BY total_points DESC, exact_count DESC, u.full_name;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_leaderboard_user_id
|
||||
ON leaderboard(user_id);
|
||||
|
||||
-- ============================================================
|
||||
-- SYNC LOG
|
||||
-- Protokolliert die football-data.org Sync-Läufe
|
||||
-- ============================================================
|
||||
CREATE TABLE IF NOT EXISTS sync_log (
|
||||
id SERIAL PRIMARY KEY,
|
||||
synced_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
matches_total INTEGER NOT NULL DEFAULT 0,
|
||||
matches_new INTEGER NOT NULL DEFAULT 0,
|
||||
matches_upd INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'success', -- 'success' | 'error'
|
||||
error_msg TEXT
|
||||
);
|
||||
|
||||
-- ============================================================
|
||||
-- TRIGGER: updated_at automatisch setzen
|
||||
-- ============================================================
|
||||
CREATE OR REPLACE FUNCTION set_updated_at()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER users_updated_at
|
||||
BEFORE UPDATE ON users
|
||||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||
|
||||
CREATE TRIGGER matches_updated_at
|
||||
BEFORE UPDATE ON matches
|
||||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||
|
||||
CREATE TRIGGER tips_updated_at
|
||||
BEFORE UPDATE ON tips
|
||||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||
|
||||
-- ============================================================
|
||||
-- ROW LEVEL SECURITY (Supabase)
|
||||
-- Wichtig: Verhindert direkten Frontend-Zugriff auf DB
|
||||
-- Alles läuft über den Backend-Service mit service_role key
|
||||
-- ============================================================
|
||||
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE matches ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE tips ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Service Role darf alles
|
||||
CREATE POLICY "service_role_all" ON users
|
||||
FOR ALL USING (auth.role() = 'service_role');
|
||||
CREATE POLICY "service_role_all" ON matches
|
||||
FOR ALL USING (auth.role() = 'service_role');
|
||||
CREATE POLICY "service_role_all" ON tips
|
||||
FOR ALL USING (auth.role() = 'service_role');
|
||||
@@ -0,0 +1,165 @@
|
||||
import 'dotenv/config';
|
||||
import express from 'express';
|
||||
import helmet from 'helmet';
|
||||
import cors from 'cors';
|
||||
import rateLimit from 'express-rate-limit';
|
||||
|
||||
import { staffbaseAuth as createStaffbaseAuth } from './middleware/staffbaseAuth';
|
||||
import { devAuth } from './middleware/devAuth';
|
||||
import { checkDbConnection } from './db/client';
|
||||
import { logger } from './services/logger';
|
||||
|
||||
import matchesRouter from './routes/matches';
|
||||
import tipsRouter from './routes/tips';
|
||||
import leaderboardRouter from './routes/leaderboard';
|
||||
import adminRouter from './routes/admin';
|
||||
|
||||
const app = express();
|
||||
const PORT = parseInt(process.env.PORT ?? '3001');
|
||||
|
||||
// ============================================================
|
||||
// Security Middleware
|
||||
// ============================================================
|
||||
app.use(
|
||||
helmet({
|
||||
// Staffbase lädt das Plugin in einem iFrame
|
||||
// X-Frame-Options muss daher SAMEORIGIN oder ALLOW-FROM erlauben
|
||||
frameguard: false,
|
||||
contentSecurityPolicy: {
|
||||
directives: {
|
||||
defaultSrc: ["'self'"],
|
||||
scriptSrc: ["'self'"],
|
||||
styleSrc: ["'self'", "'unsafe-inline'"], // für inline styles im Frontend
|
||||
imgSrc: ["'self'", 'data:', 'https://crests.football-data.org'],
|
||||
frameAncestors: ['https://app.staffbase.com', 'https://*.staffbase.com'],
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
// CORS – nur Staffbase Origins erlauben
|
||||
const allowedOrigins = (process.env.CORS_ORIGIN ?? 'https://app.staffbase.com')
|
||||
.split(',')
|
||||
.map((o) => o.trim());
|
||||
|
||||
app.use(
|
||||
cors({
|
||||
origin: (origin, callback) => {
|
||||
// Requests ohne Origin (z.B. direkte API-Calls) in Development erlauben
|
||||
if (!origin && process.env.NODE_ENV !== 'production') {
|
||||
return callback(null, true);
|
||||
}
|
||||
if (!origin || allowedOrigins.some((allowed) => origin.includes(allowed.replace('https://', '')))) {
|
||||
callback(null, true);
|
||||
} else {
|
||||
callback(new Error(`CORS blocked: ${origin}`));
|
||||
}
|
||||
},
|
||||
credentials: true,
|
||||
})
|
||||
);
|
||||
|
||||
// Body parsing
|
||||
app.use(express.json({ limit: '10kb' }));
|
||||
|
||||
// Rate limiting – API global
|
||||
app.use(
|
||||
'/api',
|
||||
rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 Minuten
|
||||
max: 200, // Max 200 Requests pro IP
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: { error: 'Too many requests', retryAfter: '15 minutes' },
|
||||
})
|
||||
);
|
||||
|
||||
// Tighter rate limit für Tipp-Abgabe
|
||||
app.use(
|
||||
'/api/tips',
|
||||
rateLimit({
|
||||
windowMs: 60 * 1000, // 1 Minute
|
||||
max: 20,
|
||||
message: { error: 'Too many tip submissions' },
|
||||
})
|
||||
);
|
||||
|
||||
// ============================================================
|
||||
// Health Check (ohne Auth – für Railway/Render/Uptime-Monitoring)
|
||||
// ============================================================
|
||||
app.get('/health', async (_req, res) => {
|
||||
const dbOk = await checkDbConnection();
|
||||
res.status(dbOk ? 200 : 503).json({
|
||||
status: dbOk ? 'ok' : 'degraded',
|
||||
db: dbOk ? 'connected' : 'unreachable',
|
||||
timestamp: new Date().toISOString(),
|
||||
version: process.env.npm_package_version ?? '1.0.0',
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Protected API Routes (alle erfordern Staffbase JWT)
|
||||
// ============================================================
|
||||
// Auth: In Development ohne JWT über devAuth, sonst Staffbase JWT
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
app.use('/api', (req, res, next) => {
|
||||
// Wenn JWT vorhanden → Staffbase Auth, sonst Dev-Mode
|
||||
if (req.query.jwt || req.headers.authorization?.startsWith('Bearer ')) {
|
||||
return createStaffbaseAuth()(req, res, next);
|
||||
}
|
||||
return devAuth(req, res, next);
|
||||
});
|
||||
} else {
|
||||
app.use('/api', createStaffbaseAuth());
|
||||
}
|
||||
app.use('/api/matches', matchesRouter);
|
||||
app.use('/api/tips', tipsRouter);
|
||||
app.use('/api/leaderboard', leaderboardRouter);
|
||||
app.use('/api/admin', adminRouter);
|
||||
|
||||
// ============================================================
|
||||
// Frontend (React Build) – statisches Serving
|
||||
// In Production liefert das Backend auch das Frontend aus
|
||||
// ============================================================
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const path = require('path') as typeof import('path');
|
||||
app.use(express.static(path.join(process.cwd(), 'public')));
|
||||
app.get('*', (_req, res) => {
|
||||
res.sendFile(path.join(process.cwd(), 'public', 'index.html'));
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Error Handler
|
||||
// ============================================================
|
||||
app.use(
|
||||
(
|
||||
err: Error,
|
||||
_req: express.Request,
|
||||
res: express.Response,
|
||||
_next: express.NextFunction
|
||||
) => {
|
||||
logger.error('Unhandled error', {
|
||||
message: err.message,
|
||||
stack: err.stack,
|
||||
name: err.name,
|
||||
});
|
||||
res.status(500).json({
|
||||
error: 'Internal server error',
|
||||
message: process.env.NODE_ENV === 'development' ? err.message : undefined,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
// ============================================================
|
||||
// Start Server
|
||||
// ============================================================
|
||||
app.listen(PORT, () => {
|
||||
logger.info(`🚀 WM 2026 Tippspiel Backend running on port ${PORT}`, {
|
||||
env: process.env.NODE_ENV,
|
||||
port: PORT,
|
||||
});
|
||||
});
|
||||
|
||||
export default app;
|
||||
@@ -0,0 +1,66 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { logger } from '../services/logger';
|
||||
|
||||
/**
|
||||
* Dev-Mode Authentifizierung
|
||||
*
|
||||
* Wenn NODE_ENV=development und kein Staffbase JWT vorhanden,
|
||||
* wird automatisch ein Mock-User eingeloggt.
|
||||
*
|
||||
* Mehrere Test-User über Query-Parameter wählbar:
|
||||
* ?devUser=1 → Ronny Mueller (du)
|
||||
* ?devUser=2 → Test User 2
|
||||
* ?devUser=3 → Test User 3
|
||||
*
|
||||
* Wird in Production komplett deaktiviert.
|
||||
*/
|
||||
|
||||
const DEV_USERS = [
|
||||
{
|
||||
sub: 'dev-user-001',
|
||||
name: 'Ronny Mueller',
|
||||
given_name: 'Ronny',
|
||||
family_name: 'Mueller',
|
||||
locale: 'de_DE',
|
||||
branch_id: 'gealan-main',
|
||||
role: 'editor',
|
||||
primary_email_address: 'ronny975@gmail.com',
|
||||
},
|
||||
{
|
||||
sub: 'dev-user-002',
|
||||
name: 'Max Mustermann',
|
||||
given_name: 'Max',
|
||||
family_name: 'Mustermann',
|
||||
locale: 'de_DE',
|
||||
branch_id: 'gealan-main',
|
||||
role: 'viewer',
|
||||
primary_email_address: 'max@gealan.de',
|
||||
},
|
||||
{
|
||||
sub: 'dev-user-003',
|
||||
name: 'Anna Schmidt',
|
||||
given_name: 'Anna',
|
||||
family_name: 'Schmidt',
|
||||
locale: 'de_DE',
|
||||
branch_id: 'gealan-werk2',
|
||||
role: 'viewer',
|
||||
primary_email_address: 'anna@gealan.de',
|
||||
},
|
||||
];
|
||||
|
||||
export const devAuth = (req: Request, res: Response, next: NextFunction): void => {
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
// In Production niemals Dev-Mode verwenden
|
||||
res.status(401).json({ error: 'Unauthorized' });
|
||||
return;
|
||||
}
|
||||
|
||||
const userIndex = parseInt((req.query.devUser as string) ?? '1') - 1;
|
||||
const mockUser = DEV_USERS[Math.min(Math.max(userIndex, 0), DEV_USERS.length - 1)];
|
||||
|
||||
req.staffbaseUser = mockUser;
|
||||
req.sbSSO = mockUser;
|
||||
|
||||
logger.debug('Dev-Mode: Mock user injected', { user: mockUser.name });
|
||||
next();
|
||||
};
|
||||
@@ -0,0 +1,123 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
// @ts-ignore – Staffbase SDK v1.x hat keine vollständigen TypeScript Typen
|
||||
import staffbaseSDK from '@staffbase/staffbase-plugin-sdk';
|
||||
// Im SDK v1.x ist `middleware` direkt die ssoMiddleWare-Funktion
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const { middleware: ssoMiddleWare } = require('@staffbase/staffbase-plugin-sdk');
|
||||
import { logger } from '../services/logger';
|
||||
|
||||
// ============================================================
|
||||
// Staffbase SDK Typen (v1.x)
|
||||
// Das SDK gibt req.sbSSO als SSOTokenData-Objekt zurück
|
||||
// ============================================================
|
||||
export interface SBSSOData {
|
||||
sub: string; // userId
|
||||
name?: string; // full name
|
||||
given_name?: string; // first name
|
||||
family_name?: string; // last name
|
||||
locale?: string; // z.B. "de_DE"
|
||||
branch_id?: string; // Branch/Abteilung
|
||||
role?: string; // "editor" | "viewer" etc.
|
||||
aud?: string; // Plugin-Audience
|
||||
exp?: number;
|
||||
iat?: number;
|
||||
primary_email_address?: string;
|
||||
username?: string;
|
||||
}
|
||||
|
||||
// Express Request um sbSSO-Feld erweitern
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
sbSSO?: SBSSOData;
|
||||
staffbaseUser?: SBSSOData; // Alias für komfortableren Zugriff
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Staffbase SSO Middleware (SDK v1.x)
|
||||
*
|
||||
* Das SDK validiert den JWT (?jwt= Query-Parameter) automatisch
|
||||
* und hängt die dekodierten User-Daten als req.sbSSO an.
|
||||
*
|
||||
* Wir wrappen das in unsere eigene Middleware, die:
|
||||
* 1. Das SDK aufruft
|
||||
* 2. req.staffbaseUser als bequemen Alias setzt
|
||||
* 3. Bei fehlendem/ungültigem Token 401 zurückgibt
|
||||
*/
|
||||
export const staffbaseAuth = (pluginSecret?: string, audience?: string) => {
|
||||
// SDK-Middleware initialisieren
|
||||
// Secret = RSA Public Key aus STAFFBASE_PUBLIC_KEY env var
|
||||
// Audience = Plugin-ID aus STAFFBASE_PLUGIN_ID env var (optional)
|
||||
const sdkSecret = pluginSecret ?? process.env.STAFFBASE_PUBLIC_KEY ?? '';
|
||||
const sdkAudience = audience ?? process.env.STAFFBASE_PLUGIN_ID ?? '';
|
||||
|
||||
const sdkMiddleware = ssoMiddleWare(sdkSecret, sdkAudience);
|
||||
|
||||
return (req: Request, res: Response, next: NextFunction): void => {
|
||||
// Token muss vorhanden sein (Query-Param oder Authorization Header)
|
||||
const hasToken =
|
||||
req.query.jwt ||
|
||||
req.headers.authorization?.startsWith('Bearer ');
|
||||
|
||||
if (!hasToken) {
|
||||
res.status(401).json({
|
||||
error: 'Unauthorized',
|
||||
message: 'Kein JWT-Token gefunden. Bitte über Staffbase aufrufen.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Authorization Header → in Query-Param umwandeln (SDK erwartet ?jwt=)
|
||||
if (!req.query.jwt && req.headers.authorization) {
|
||||
const token = req.headers.authorization.replace('Bearer ', '');
|
||||
(req.query as Record<string, string>).jwt = token;
|
||||
}
|
||||
|
||||
// SDK-Middleware aufrufen (setzt req.sbSSO bei Erfolg)
|
||||
sdkMiddleware(req, res, () => {
|
||||
if (!req.sbSSO || !req.sbSSO.sub) {
|
||||
logger.warn('JWT validation failed or token missing', {
|
||||
path: req.path,
|
||||
ip: req.ip,
|
||||
});
|
||||
res.status(401).json({
|
||||
error: 'Unauthorized',
|
||||
message: 'Ungültiger oder abgelaufener JWT-Token.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Alias setzen
|
||||
req.staffbaseUser = req.sbSSO;
|
||||
|
||||
logger.debug('Staffbase JWT validated', {
|
||||
userId: req.staffbaseUser.sub,
|
||||
name: req.staffbaseUser.name,
|
||||
role: req.staffbaseUser.role,
|
||||
});
|
||||
|
||||
next();
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Requires editor role for admin actions.
|
||||
* Must be used after staffbaseAuth middleware.
|
||||
*/
|
||||
export const requireEditor = (
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): void => {
|
||||
if (!req.staffbaseUser || req.staffbaseUser.role !== 'editor') {
|
||||
res.status(403).json({
|
||||
error: 'Forbidden',
|
||||
message: 'Editor-Rolle erforderlich.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
next();
|
||||
};
|
||||
@@ -0,0 +1,87 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { requireEditor } from '../middleware/staffbaseAuth';
|
||||
import { syncMatches, evaluateTips } from '../services/syncService';
|
||||
import { query } from '../db/client';
|
||||
import { checkDbConnection } from '../db/client';
|
||||
import { logger } from '../services/logger';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Alle Admin-Routen erfordern Editor-Rolle
|
||||
router.use(requireEditor);
|
||||
|
||||
/**
|
||||
* POST /api/admin/sync
|
||||
* Manueller Trigger: Spiele von football-data.org synchronisieren
|
||||
*/
|
||||
router.post('/sync', async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
logger.info('Manual sync triggered');
|
||||
const result = await syncMatches();
|
||||
res.json({ success: true, ...result });
|
||||
} catch (error: unknown) {
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
logger.error('Manual sync failed', { message: msg });
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: msg,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/admin/evaluate
|
||||
* Manueller Trigger: Tipps für abgeschlossene Spiele auswerten
|
||||
*/
|
||||
router.post('/evaluate', async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
logger.info('Manual tip evaluation triggered');
|
||||
const result = await evaluateTips();
|
||||
res.json({ success: true, ...result });
|
||||
} catch (error) {
|
||||
logger.error('Manual evaluation failed', { error });
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: (error as Error).message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/admin/stats
|
||||
* Allgemeine Statistiken (für Admin-Dashboard)
|
||||
*/
|
||||
router.get('/stats', async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const [matchStats, tipStats, syncStats] = await Promise.all([
|
||||
query<{ total: string; finished: string; scheduled: string }>(
|
||||
`SELECT
|
||||
COUNT(*) AS total,
|
||||
COUNT(*) FILTER (WHERE status = 'FINISHED') AS finished,
|
||||
COUNT(*) FILTER (WHERE status IN ('SCHEDULED','TIMED')) AS scheduled
|
||||
FROM matches`
|
||||
),
|
||||
query<{ total: string; evaluated: string; pending: string }>(
|
||||
`SELECT
|
||||
COUNT(*) AS total,
|
||||
COUNT(*) FILTER (WHERE points IS NOT NULL) AS evaluated,
|
||||
COUNT(*) FILTER (WHERE points IS NULL) AS pending
|
||||
FROM tips`
|
||||
),
|
||||
query<{ synced_at: Date; status: string; matches_new: number }>(
|
||||
'SELECT synced_at, status, matches_new FROM sync_log ORDER BY synced_at DESC LIMIT 1'
|
||||
),
|
||||
]);
|
||||
|
||||
res.json({
|
||||
matches: matchStats[0],
|
||||
tips: tipStats[0],
|
||||
lastSync: syncStats[0] ?? null,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch admin stats', { error });
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,129 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { query } from '../db/client';
|
||||
import { LeaderboardResponse, UserStatsResponse } from '../types';
|
||||
import { logger } from '../services/logger';
|
||||
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* GET /api/leaderboard
|
||||
* Aktuelle Rangliste aus der Materialized View
|
||||
*/
|
||||
router.get('/', async (req: Request, res: Response): Promise<void> => {
|
||||
const userId = req.staffbaseUser!.sub;
|
||||
const limit = Math.min(parseInt(req.query.limit as string) || 50, 100);
|
||||
|
||||
try {
|
||||
const [entries, metaRows] = await Promise.all([
|
||||
query<{
|
||||
user_id: string;
|
||||
full_name: string;
|
||||
total_points: number;
|
||||
tips_count: number;
|
||||
exact_count: number;
|
||||
tendency_count: number;
|
||||
rank: number;
|
||||
}>(
|
||||
`SELECT user_id, full_name, total_points, tips_count,
|
||||
exact_count, tendency_count, rank
|
||||
FROM leaderboard
|
||||
ORDER BY rank ASC
|
||||
LIMIT $1`,
|
||||
[limit]
|
||||
),
|
||||
query<{ count: string }>(
|
||||
'SELECT COUNT(*) FROM leaderboard'
|
||||
),
|
||||
]);
|
||||
|
||||
// Aktuellen User-Rank ermitteln (auch wenn er nicht in den Top-N ist)
|
||||
const userEntry = entries.find((e) => e.user_id === userId);
|
||||
let currentUserRank: number | null = userEntry?.rank ?? null;
|
||||
|
||||
if (!userEntry) {
|
||||
const userRankRows = await query<{ rank: number }>(
|
||||
'SELECT rank FROM leaderboard WHERE user_id = $1',
|
||||
[userId]
|
||||
);
|
||||
currentUserRank = userRankRows[0]?.rank ?? null;
|
||||
}
|
||||
|
||||
const response: LeaderboardResponse = {
|
||||
entries,
|
||||
currentUserRank,
|
||||
totalParticipants: parseInt(metaRows[0]?.count ?? '0'),
|
||||
lastUpdated: new Date().toISOString(),
|
||||
};
|
||||
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch leaderboard', { error });
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/leaderboard/me
|
||||
* Statistiken des aktuellen Users
|
||||
*/
|
||||
router.get('/me', async (req: Request, res: Response): Promise<void> => {
|
||||
const userId = req.staffbaseUser!.sub;
|
||||
|
||||
try {
|
||||
const [leaderboardRows, tipsRows] = await Promise.all([
|
||||
query<{
|
||||
full_name: string;
|
||||
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
|
||||
FROM leaderboard WHERE user_id = $1`,
|
||||
[userId]
|
||||
),
|
||||
query<{ count: string; wrong_count: string }>(
|
||||
`SELECT
|
||||
COUNT(*) FILTER (WHERE points IS NOT NULL) AS count,
|
||||
COUNT(*) FILTER (WHERE points = 0) AS wrong_count
|
||||
FROM tips
|
||||
WHERE user_id = $1`,
|
||||
[userId]
|
||||
),
|
||||
]);
|
||||
|
||||
const lb = leaderboardRows[0];
|
||||
const tipsStats = tipsRows[0];
|
||||
|
||||
const evaluatedCount = parseInt(tipsStats?.count ?? '0');
|
||||
const wrongCount = parseInt(tipsStats?.wrong_count ?? '0');
|
||||
const accuracy =
|
||||
evaluatedCount > 0
|
||||
? Math.round(
|
||||
(((lb?.exact_count ?? 0) + (lb?.tendency_count ?? 0)) /
|
||||
evaluatedCount) *
|
||||
100
|
||||
)
|
||||
: 0;
|
||||
|
||||
const response: UserStatsResponse = {
|
||||
userId,
|
||||
fullName: lb?.full_name ?? req.staffbaseUser!.name ?? 'Unbekannt',
|
||||
totalPoints: lb?.total_points ?? 0,
|
||||
rank: lb?.rank ?? null,
|
||||
tipsCount: lb?.tips_count ?? 0,
|
||||
exactCount: lb?.exact_count ?? 0,
|
||||
tendencyCount: lb?.tendency_count ?? 0,
|
||||
wrongCount,
|
||||
accuracy,
|
||||
};
|
||||
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch user stats', { error, userId });
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,194 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { query } from '../db/client';
|
||||
import { MatchResponse } from '../types';
|
||||
import { logger } from '../services/logger';
|
||||
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* GET /api/matches
|
||||
* Alle Spiele zurückgeben, mit dem Tipp des aktuellen Users (falls vorhanden)
|
||||
*
|
||||
* Query params:
|
||||
* stage=GROUP_STAGE | ROUND_OF_16 | QUARTER_FINALS | SEMI_FINALS | FINAL
|
||||
* group=GROUP_A ... GROUP_H
|
||||
*/
|
||||
router.get('/', async (req: Request, res: Response): Promise<void> => {
|
||||
const userId = req.staffbaseUser!.sub;
|
||||
const { stage, group } = req.query;
|
||||
|
||||
try {
|
||||
let whereClause = '';
|
||||
const params: unknown[] = [userId];
|
||||
|
||||
if (stage) {
|
||||
params.push(stage);
|
||||
whereClause += ` AND m.stage = $${params.length}`;
|
||||
}
|
||||
if (group) {
|
||||
params.push(group);
|
||||
whereClause += ` AND m.group_name = $${params.length}`;
|
||||
}
|
||||
|
||||
const rows = await query<{
|
||||
id: number;
|
||||
external_id: number;
|
||||
utc_date: Date;
|
||||
status: string;
|
||||
stage: string;
|
||||
group_name: string | null;
|
||||
home_team_name: string;
|
||||
home_team_short: string;
|
||||
home_team_crest: string | null;
|
||||
away_team_name: string;
|
||||
away_team_short: string;
|
||||
away_team_crest: string | null;
|
||||
score_home: number | null;
|
||||
score_away: number | null;
|
||||
tip_home: number | null;
|
||||
tip_away: number | null;
|
||||
tip_points: number | null;
|
||||
}>(
|
||||
`SELECT
|
||||
m.id, m.external_id, m.utc_date, m.status, m.stage, m.group_name,
|
||||
m.home_team_name, m.home_team_short, m.home_team_crest,
|
||||
m.away_team_name, m.away_team_short, m.away_team_crest,
|
||||
m.score_home, m.score_away,
|
||||
t.tip_home, t.tip_away, t.points AS tip_points
|
||||
FROM matches m
|
||||
LEFT JOIN tips t ON t.match_id = m.id AND t.user_id = $1
|
||||
WHERE 1=1 ${whereClause}
|
||||
ORDER BY m.utc_date ASC`,
|
||||
params
|
||||
);
|
||||
|
||||
const now = new Date();
|
||||
|
||||
const matches: MatchResponse[] = rows.map((row) => {
|
||||
const kickoff = new Date(row.utc_date);
|
||||
const minutesUntilKickoff = Math.floor(
|
||||
(kickoff.getTime() - now.getTime()) / 60000
|
||||
);
|
||||
// Tipps können bis 5 Minuten vor Anpfiff abgegeben werden
|
||||
const tippable = minutesUntilKickoff > 5 && (row.status === 'SCHEDULED' || row.status === 'TIMED');
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
externalId: row.external_id,
|
||||
utcDate: row.utc_date.toISOString(),
|
||||
status: row.status as MatchResponse['status'],
|
||||
stage: row.stage,
|
||||
group: row.group_name,
|
||||
homeTeam: {
|
||||
name: row.home_team_name,
|
||||
shortName: row.home_team_short,
|
||||
crest: row.home_team_crest,
|
||||
},
|
||||
awayTeam: {
|
||||
name: row.away_team_name,
|
||||
shortName: row.away_team_short,
|
||||
crest: row.away_team_crest,
|
||||
},
|
||||
score: {
|
||||
home: row.score_home,
|
||||
away: row.score_away,
|
||||
},
|
||||
userTip:
|
||||
row.tip_home !== null
|
||||
? {
|
||||
home: row.tip_home,
|
||||
away: row.tip_away!,
|
||||
points: row.tip_points,
|
||||
}
|
||||
: null,
|
||||
minutesUntilKickoff,
|
||||
tippable,
|
||||
};
|
||||
});
|
||||
|
||||
res.json({ matches, count: matches.length });
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch matches', { error, userId });
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/matches/:id
|
||||
* Ein einzelnes Spiel mit dem Tipp des Users
|
||||
*/
|
||||
router.get('/:id', async (req: Request, res: Response): Promise<void> => {
|
||||
const userId = req.staffbaseUser!.sub;
|
||||
const matchId = parseInt(req.params.id);
|
||||
|
||||
if (isNaN(matchId)) {
|
||||
res.status(400).json({ error: 'Invalid match ID' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const rows = await query<{
|
||||
id: number;
|
||||
external_id: number;
|
||||
utc_date: Date;
|
||||
status: string;
|
||||
stage: string;
|
||||
group_name: string | null;
|
||||
home_team_name: string;
|
||||
home_team_short: string;
|
||||
home_team_crest: string | null;
|
||||
away_team_name: string;
|
||||
away_team_short: string;
|
||||
away_team_crest: string | null;
|
||||
score_home: number | null;
|
||||
score_away: number | null;
|
||||
tip_home: number | null;
|
||||
tip_away: number | null;
|
||||
tip_points: number | null;
|
||||
}>(
|
||||
`SELECT
|
||||
m.id, m.external_id, m.utc_date, m.status, m.stage, m.group_name,
|
||||
m.home_team_name, m.home_team_short, m.home_team_crest,
|
||||
m.away_team_name, m.away_team_short, m.away_team_crest,
|
||||
m.score_home, m.score_away,
|
||||
t.tip_home, t.tip_away, t.points AS tip_points
|
||||
FROM matches m
|
||||
LEFT JOIN tips t ON t.match_id = m.id AND t.user_id = $1
|
||||
WHERE m.id = $2`,
|
||||
[userId, matchId]
|
||||
);
|
||||
|
||||
if (rows.length === 0) {
|
||||
res.status(404).json({ error: 'Match not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
const row = rows[0];
|
||||
const kickoff = new Date(row.utc_date);
|
||||
const minutesUntilKickoff = Math.floor(
|
||||
(kickoff.getTime() - Date.now()) / 60000
|
||||
);
|
||||
|
||||
res.json({
|
||||
id: row.id,
|
||||
externalId: row.external_id,
|
||||
utcDate: row.utc_date.toISOString(),
|
||||
status: row.status,
|
||||
stage: row.stage,
|
||||
group: row.group_name,
|
||||
homeTeam: { name: row.home_team_name, shortName: row.home_team_short, crest: row.home_team_crest },
|
||||
awayTeam: { name: row.away_team_name, shortName: row.away_team_short, crest: row.away_team_crest },
|
||||
score: { home: row.score_home, away: row.score_away },
|
||||
userTip: row.tip_home !== null
|
||||
? { home: row.tip_home, away: row.tip_away, points: row.tip_points }
|
||||
: null,
|
||||
minutesUntilKickoff,
|
||||
tippable: minutesUntilKickoff > 5 && (row.status === 'SCHEDULED' || row.status === 'TIMED'),
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch match', { error, matchId });
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,163 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { query, withTransaction } from '../db/client';
|
||||
import { TipSubmitRequest, TipSubmitResponse } from '../types';
|
||||
import { logger } from '../services/logger';
|
||||
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* POST /api/tips
|
||||
* Tipp für ein Spiel abgeben oder aktualisieren (UPSERT)
|
||||
*
|
||||
* Body: { matchId: number, tipHome: number, tipAway: number }
|
||||
*/
|
||||
router.post('/', async (req: Request, res: Response): Promise<void> => {
|
||||
const userId = req.staffbaseUser!.sub;
|
||||
const { matchId, tipHome, tipAway } = req.body as TipSubmitRequest;
|
||||
|
||||
// Input-Validierung
|
||||
if (
|
||||
typeof matchId !== 'number' ||
|
||||
typeof tipHome !== 'number' ||
|
||||
typeof tipAway !== 'number' ||
|
||||
tipHome < 0 ||
|
||||
tipAway < 0 ||
|
||||
tipHome > 99 ||
|
||||
tipAway > 99 ||
|
||||
!Number.isInteger(tipHome) ||
|
||||
!Number.isInteger(tipAway)
|
||||
) {
|
||||
res.status(400).json({
|
||||
error: 'Invalid tip',
|
||||
message: 'tipHome and tipAway must be integers between 0 and 99',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await withTransaction(async (client) => {
|
||||
// Prüfen ob das Spiel noch tippbar ist
|
||||
const matchRows = await client.query<{
|
||||
id: number;
|
||||
utc_date: Date;
|
||||
status: string;
|
||||
}>(
|
||||
'SELECT id, utc_date, status FROM matches WHERE id = $1',
|
||||
[matchId]
|
||||
);
|
||||
|
||||
if (matchRows.rows.length === 0) {
|
||||
res.status(404).json({ error: 'Match not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
const match = matchRows.rows[0];
|
||||
const minutesUntilKickoff = Math.floor(
|
||||
(new Date(match.utc_date).getTime() - Date.now()) / 60000
|
||||
);
|
||||
|
||||
if (minutesUntilKickoff <= 5) {
|
||||
res.status(409).json({
|
||||
error: 'Tip deadline passed',
|
||||
message: 'Tipps können nur bis 5 Minuten vor Anpfiff abgegeben werden.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (match.status !== 'SCHEDULED' && match.status !== 'TIMED') {
|
||||
res.status(409).json({
|
||||
error: 'Match not tippable',
|
||||
message: 'Für dieses Spiel können keine Tipps mehr abgegeben werden.',
|
||||
});
|
||||
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',
|
||||
]
|
||||
);
|
||||
|
||||
// Tipp anlegen oder aktualisieren (UPSERT)
|
||||
const result = await client.query<{
|
||||
tip_home: number;
|
||||
tip_away: number;
|
||||
}>(
|
||||
`INSERT INTO tips (user_id, match_id, tip_home, tip_away)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (user_id, match_id) DO UPDATE SET
|
||||
tip_home = EXCLUDED.tip_home,
|
||||
tip_away = EXCLUDED.tip_away,
|
||||
points = NULL,
|
||||
updated_at = NOW()
|
||||
RETURNING tip_home, tip_away`,
|
||||
[userId, matchId, tipHome, tipAway]
|
||||
);
|
||||
|
||||
const tip = result.rows[0];
|
||||
const response: TipSubmitResponse = {
|
||||
success: true,
|
||||
tip: {
|
||||
home: tip.tip_home,
|
||||
away: tip.tip_away,
|
||||
points: null,
|
||||
},
|
||||
message: `Tipp ${tipHome}:${tipAway} gespeichert!`,
|
||||
};
|
||||
|
||||
logger.info('Tip saved', { userId, matchId, tipHome, tipAway });
|
||||
res.status(201).json(response);
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to save tip', { error, userId, matchId });
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/tips
|
||||
* Alle Tipps des aktuellen Users
|
||||
*/
|
||||
router.get('/', async (req: Request, res: Response): Promise<void> => {
|
||||
const userId = req.staffbaseUser!.sub;
|
||||
|
||||
try {
|
||||
const tips = await query<{
|
||||
match_id: number;
|
||||
tip_home: number;
|
||||
tip_away: number;
|
||||
points: number | null;
|
||||
utc_date: Date;
|
||||
home_team_short: string;
|
||||
away_team_short: string;
|
||||
status: string;
|
||||
}>(
|
||||
`SELECT
|
||||
t.match_id, t.tip_home, t.tip_away, t.points,
|
||||
m.utc_date, m.home_team_short, m.away_team_short, m.status
|
||||
FROM tips t
|
||||
JOIN matches m ON m.id = t.match_id
|
||||
WHERE t.user_id = $1
|
||||
ORDER BY m.utc_date ASC`,
|
||||
[userId]
|
||||
);
|
||||
|
||||
res.json({ tips, count: tips.length });
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch tips', { error, userId });
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,100 @@
|
||||
import { FootballApiMatch, FootballApiResponse } from '../types';
|
||||
import { logger } from './logger';
|
||||
|
||||
const BASE_URL = process.env.FOOTBALL_API_BASE_URL ?? 'https://api.football-data.org/v4';
|
||||
const API_KEY = process.env.FOOTBALL_API_KEY ?? '';
|
||||
|
||||
// WM 2026 Competition Code bei football-data.org
|
||||
const WC_2026_CODE = 'WC';
|
||||
|
||||
/**
|
||||
* Rate limiting: Free tier = 10 Requests/Minute
|
||||
* Wir tracken den letzten Request-Zeitpunkt
|
||||
*/
|
||||
const requestQueue: Array<() => void> = [];
|
||||
let lastRequestTime = 0;
|
||||
const MIN_REQUEST_INTERVAL = 6100; // ~10 req/min with safety buffer
|
||||
|
||||
const throttledFetch = async (url: string): Promise<Response> => {
|
||||
const now = Date.now();
|
||||
const timeSinceLastRequest = now - lastRequestTime;
|
||||
|
||||
if (timeSinceLastRequest < MIN_REQUEST_INTERVAL) {
|
||||
const waitTime = MIN_REQUEST_INTERVAL - timeSinceLastRequest;
|
||||
logger.debug(`Rate limiting: waiting ${waitTime}ms`);
|
||||
await new Promise(r => setTimeout(r, waitTime));
|
||||
}
|
||||
|
||||
lastRequestTime = Date.now();
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'X-Auth-Token': API_KEY,
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
});
|
||||
return response;
|
||||
} catch (error: unknown) {
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
logger.error('football-data.org fetch failed', { url, error: msg });
|
||||
throw new Error(`Netzwerkfehler beim Abruf von football-data.org: ${msg}`);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Alle Spiele der WM 2026 von football-data.org abrufen
|
||||
*/
|
||||
export const fetchAllMatches = async (): Promise<FootballApiMatch[]> => {
|
||||
const url = `${BASE_URL}/competitions/${WC_2026_CODE}/matches`;
|
||||
|
||||
logger.info('Fetching WC 2026 matches from football-data.org');
|
||||
|
||||
const response = await throttledFetch(url);
|
||||
|
||||
if (response.status === 429) {
|
||||
throw new Error('football-data.org rate limit exceeded');
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
throw new Error(
|
||||
`football-data.org API error: ${response.status} ${response.statusText} – ${body}`
|
||||
);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as FootballApiResponse;
|
||||
|
||||
logger.info(`Fetched ${data.matches.length} matches from football-data.org`);
|
||||
return data.matches;
|
||||
};
|
||||
|
||||
/**
|
||||
* Nur FINISHED Spiele abrufen (für Punkte-Berechnung)
|
||||
*/
|
||||
export const fetchFinishedMatches = async (): Promise<FootballApiMatch[]> => {
|
||||
const url = `${BASE_URL}/competitions/${WC_2026_CODE}/matches?status=FINISHED`;
|
||||
const response = await throttledFetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`football-data.org API error: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as FootballApiResponse;
|
||||
return data.matches;
|
||||
};
|
||||
|
||||
/**
|
||||
* Ein einzelnes Spiel abrufen (für Live-Updates)
|
||||
*/
|
||||
export const fetchMatch = async (matchId: number): Promise<FootballApiMatch> => {
|
||||
const url = `${BASE_URL}/matches/${matchId}`;
|
||||
const response = await throttledFetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`football-data.org API error: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { match: FootballApiMatch };
|
||||
return data.match;
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import winston from 'winston';
|
||||
|
||||
export const logger = winston.createLogger({
|
||||
level: process.env.NODE_ENV === 'production' ? 'info' : 'debug',
|
||||
format: winston.format.combine(
|
||||
winston.format.timestamp(),
|
||||
winston.format.errors({ stack: true }),
|
||||
process.env.NODE_ENV === 'production'
|
||||
? winston.format.json()
|
||||
: winston.format.combine(
|
||||
winston.format.colorize(),
|
||||
winston.format.printf(({ timestamp, level, message, ...meta }) => {
|
||||
const metaStr = Object.keys(meta).length
|
||||
? '\n' + JSON.stringify(meta, null, 2)
|
||||
: '';
|
||||
return `${timestamp} [${level}]: ${message}${metaStr}`;
|
||||
})
|
||||
)
|
||||
),
|
||||
transports: [new winston.transports.Console()],
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import { PointsCalculation, POINTS } from '../types';
|
||||
|
||||
interface Score {
|
||||
home: number;
|
||||
away: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Berechnet die Punkte für einen Tipp.
|
||||
*
|
||||
* Punkteregeln:
|
||||
* - Exaktes Ergebnis: 3 Punkte
|
||||
* - Richtige Tendenz: 1 Punkt (Sieg/Unentschieden/Niederlage korrekt)
|
||||
* - Falsche Tendenz: 0 Punkte
|
||||
*
|
||||
* @param tip - Der abgegebene Tipp
|
||||
* @param actual - Das tatsächliche Ergebnis
|
||||
*/
|
||||
export const calculatePoints = (
|
||||
tip: Score,
|
||||
actual: Score
|
||||
): PointsCalculation => {
|
||||
// Exaktes Ergebnis
|
||||
if (tip.home === actual.home && tip.away === actual.away) {
|
||||
return { result: 'exact', points: POINTS.EXACT };
|
||||
}
|
||||
|
||||
// Tendenz prüfen (Sieg Heim / Unentschieden / Sieg Auswärts)
|
||||
const tipTendency = getTendency(tip.home, tip.away);
|
||||
const actualTendency = getTendency(actual.home, actual.away);
|
||||
|
||||
if (tipTendency === actualTendency) {
|
||||
return { result: 'tendency', points: POINTS.TENDENCY };
|
||||
}
|
||||
|
||||
return { result: 'wrong', points: POINTS.WRONG };
|
||||
};
|
||||
|
||||
type Tendency = 'home' | 'draw' | 'away';
|
||||
|
||||
const getTendency = (home: number, away: number): Tendency => {
|
||||
if (home > away) return 'home';
|
||||
if (home < away) return 'away';
|
||||
return 'draw';
|
||||
};
|
||||
|
||||
/**
|
||||
* Berechnet Punkte für die gesamte Tipp-History eines Users.
|
||||
* Nützlich für Statistiken.
|
||||
*/
|
||||
export const calculateUserStats = (
|
||||
tips: Array<{
|
||||
tipHome: number;
|
||||
tipAway: number;
|
||||
actualHome: number | null;
|
||||
actualAway: number | null;
|
||||
points: number | null;
|
||||
}>
|
||||
) => {
|
||||
const evaluated = tips.filter(
|
||||
(t) => t.actualHome !== null && t.actualAway !== null
|
||||
);
|
||||
|
||||
const exactCount = evaluated.filter((t) => t.points === POINTS.EXACT).length;
|
||||
const tendencyCount = evaluated.filter(
|
||||
(t) => t.points === POINTS.TENDENCY
|
||||
).length;
|
||||
const wrongCount = evaluated.filter((t) => t.points === POINTS.WRONG).length;
|
||||
const totalPoints = evaluated.reduce((sum, t) => sum + (t.points ?? 0), 0);
|
||||
|
||||
const accuracy =
|
||||
evaluated.length > 0
|
||||
? Math.round(
|
||||
((exactCount + tendencyCount) / evaluated.length) * 100
|
||||
)
|
||||
: 0;
|
||||
|
||||
return {
|
||||
totalPoints,
|
||||
exactCount,
|
||||
tendencyCount,
|
||||
wrongCount,
|
||||
accuracy,
|
||||
evaluatedCount: evaluated.length,
|
||||
pendingCount: tips.length - evaluated.length,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,176 @@
|
||||
import { query, withTransaction } from '../db/client';
|
||||
import { fetchAllMatches, fetchFinishedMatches } from './footballApi';
|
||||
import { calculatePoints } from './pointsService';
|
||||
import { FootballApiMatch } from '../types';
|
||||
import { logger } from './logger';
|
||||
|
||||
/**
|
||||
* Synchronisiert alle WM-Spiele von football-data.org in die Datenbank.
|
||||
* Legt neue Spiele an und aktualisiert bestehende (Status + Score).
|
||||
*
|
||||
* Sollte täglich per Cron laufen, oder manuell getriggert werden.
|
||||
*/
|
||||
export const syncMatches = async (): Promise<{
|
||||
total: number;
|
||||
created: number;
|
||||
updated: number;
|
||||
}> => {
|
||||
logger.info('Starting match sync...');
|
||||
const startTime = Date.now();
|
||||
|
||||
let created = 0;
|
||||
let updated = 0;
|
||||
|
||||
try {
|
||||
const apiMatches = await fetchAllMatches();
|
||||
|
||||
await withTransaction(async (client) => {
|
||||
for (const match of apiMatches) {
|
||||
const existing = await client.query<{ id: number; status: string }>(
|
||||
'SELECT id, status FROM matches WHERE external_id = $1',
|
||||
[match.id]
|
||||
);
|
||||
|
||||
if (existing.rows.length === 0) {
|
||||
// Neues Spiel anlegen
|
||||
await client.query(
|
||||
`INSERT INTO matches (
|
||||
external_id, utc_date, status, stage, group_name,
|
||||
home_team_id, away_team_id,
|
||||
home_team_name, away_team_name,
|
||||
home_team_short, away_team_short,
|
||||
home_team_crest, away_team_crest,
|
||||
score_home, score_away
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)`,
|
||||
[
|
||||
match.id,
|
||||
match.utcDate,
|
||||
match.status,
|
||||
match.stage,
|
||||
match.group,
|
||||
match.homeTeam?.id ?? null,
|
||||
match.awayTeam?.id ?? null,
|
||||
match.homeTeam?.name ?? 'TBD',
|
||||
match.awayTeam?.name ?? 'TBD',
|
||||
match.homeTeam?.shortName || match.homeTeam?.tla || 'TBD',
|
||||
match.awayTeam?.shortName || match.awayTeam?.tla || 'TBD',
|
||||
match.homeTeam?.crest ?? null,
|
||||
match.awayTeam?.crest ?? null,
|
||||
match.score.fullTime.home,
|
||||
match.score.fullTime.away,
|
||||
]
|
||||
);
|
||||
created++;
|
||||
} else {
|
||||
// Bestehendes Spiel aktualisieren
|
||||
await client.query(
|
||||
`UPDATE matches SET
|
||||
status = $1,
|
||||
score_home = $2,
|
||||
score_away = $3,
|
||||
utc_date = $4
|
||||
WHERE external_id = $5`,
|
||||
[
|
||||
match.status,
|
||||
match.score.fullTime.home,
|
||||
match.score.fullTime.away,
|
||||
match.utcDate,
|
||||
match.id,
|
||||
]
|
||||
);
|
||||
updated++;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Sync-Log schreiben
|
||||
await query(
|
||||
`INSERT INTO sync_log (matches_total, matches_new, matches_upd, status)
|
||||
VALUES ($1, $2, $3, 'success')`,
|
||||
[apiMatches.length, created, updated]
|
||||
);
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
logger.info(`Match sync completed in ${duration}ms`, {
|
||||
total: apiMatches.length,
|
||||
created,
|
||||
updated,
|
||||
});
|
||||
|
||||
return { total: apiMatches.length, created, updated };
|
||||
} catch (error) {
|
||||
// Fehler loggen
|
||||
await query(
|
||||
`INSERT INTO sync_log (matches_total, matches_new, matches_upd, status, error_msg)
|
||||
VALUES (0, 0, 0, 'error', $1)`,
|
||||
[(error as Error).message]
|
||||
).catch(() => {}); // Fehler beim Fehler-Logging ignorieren
|
||||
|
||||
logger.error('Match sync failed', { error });
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Wertet Tipps für alle abgeschlossenen Spiele aus.
|
||||
* Berechnet Punkte und aktualisiert die tips-Tabelle.
|
||||
* Refreshed danach die Leaderboard Materialized View.
|
||||
*
|
||||
* Sollte nach jedem abgeschlossenen Spiel laufen.
|
||||
*/
|
||||
export const evaluateTips = async (): Promise<{
|
||||
matchesEvaluated: number;
|
||||
tipsUpdated: number;
|
||||
}> => {
|
||||
logger.info('Starting tip evaluation...');
|
||||
|
||||
// Alle abgeschlossenen Spiele mit noch nicht ausgewerteten Tipps
|
||||
const pendingMatches = await query<{
|
||||
match_id: number;
|
||||
score_home: number;
|
||||
score_away: number;
|
||||
}>(
|
||||
`SELECT DISTINCT m.id AS match_id, m.score_home, m.score_away
|
||||
FROM matches m
|
||||
JOIN tips t ON t.match_id = m.id
|
||||
WHERE m.status = 'FINISHED'
|
||||
AND m.score_home IS NOT NULL
|
||||
AND m.score_away IS NOT NULL
|
||||
AND t.points IS NULL`
|
||||
);
|
||||
|
||||
let tipsUpdated = 0;
|
||||
|
||||
for (const match of pendingMatches) {
|
||||
// Alle Tipps für dieses Spiel
|
||||
const tips = await query<{
|
||||
id: number;
|
||||
tip_home: number;
|
||||
tip_away: number;
|
||||
}>(
|
||||
'SELECT id, tip_home, tip_away FROM tips WHERE match_id = $1 AND points IS NULL',
|
||||
[match.match_id]
|
||||
);
|
||||
|
||||
for (const tip of tips) {
|
||||
const { points } = calculatePoints(
|
||||
{ home: tip.tip_home, away: tip.tip_away },
|
||||
{ home: match.score_home, away: match.score_away }
|
||||
);
|
||||
|
||||
await query('UPDATE tips SET points = $1 WHERE id = $2', [points, tip.id]);
|
||||
tipsUpdated++;
|
||||
}
|
||||
}
|
||||
|
||||
// Leaderboard Materialized View aktualisieren
|
||||
if (tipsUpdated > 0) {
|
||||
await query('REFRESH MATERIALIZED VIEW CONCURRENTLY leaderboard');
|
||||
logger.info(`Leaderboard refreshed after evaluating ${tipsUpdated} tips`);
|
||||
}
|
||||
|
||||
return {
|
||||
matchesEvaluated: pendingMatches.length,
|
||||
tipsUpdated,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,214 @@
|
||||
// ============================================================
|
||||
// Staffbase JWT Payload
|
||||
// ============================================================
|
||||
export interface StaffbaseTokenData {
|
||||
/** Staffbase internal user ID */
|
||||
sub: string;
|
||||
/** Full name of the user */
|
||||
name?: string;
|
||||
/** User's preferred locale (e.g. "de_DE") */
|
||||
locale?: string;
|
||||
/** Branch/department ID */
|
||||
branchId?: string;
|
||||
/** User's role in the plugin (e.g. "editor", "viewer") */
|
||||
role?: string;
|
||||
/** Token issued at (unix timestamp) */
|
||||
iat?: number;
|
||||
/** Token expiry (unix timestamp) */
|
||||
exp?: number;
|
||||
/** Plugin instance ID */
|
||||
aud?: string;
|
||||
}
|
||||
|
||||
// Note: Express Request extensions (staffbaseUser, sbSSO) are declared
|
||||
// in src/middleware/staffbaseAuth.ts
|
||||
|
||||
// ============================================================
|
||||
// Database Models
|
||||
// ============================================================
|
||||
export interface DbUser {
|
||||
id: string; // = Staffbase userId (sub)
|
||||
full_name: string;
|
||||
locale: string;
|
||||
branch_id: string | null;
|
||||
role: string;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
export interface DbMatch {
|
||||
id: number;
|
||||
external_id: number; // football-data.org matchId
|
||||
utc_date: Date;
|
||||
status: MatchStatus;
|
||||
stage: string;
|
||||
group_name: string | null;
|
||||
home_team_id: number;
|
||||
away_team_id: number;
|
||||
home_team_name: string;
|
||||
away_team_name: string;
|
||||
home_team_short: string;
|
||||
away_team_short: string;
|
||||
home_team_crest: string | null;
|
||||
away_team_crest: string | null;
|
||||
score_home: number | null;
|
||||
score_away: number | null;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
export interface DbTip {
|
||||
id: number;
|
||||
user_id: string;
|
||||
match_id: number;
|
||||
tip_home: number;
|
||||
tip_away: number;
|
||||
points: number | null; // null = not yet evaluated
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
export interface DbLeaderboardEntry {
|
||||
user_id: string;
|
||||
full_name: string;
|
||||
total_points: number;
|
||||
tips_count: number;
|
||||
exact_count: number;
|
||||
tendency_count: number;
|
||||
rank: number;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// API Request/Response Types
|
||||
// ============================================================
|
||||
export type MatchStatus =
|
||||
| 'SCHEDULED'
|
||||
| 'TIMED'
|
||||
| 'IN_PLAY'
|
||||
| 'PAUSED'
|
||||
| 'FINISHED'
|
||||
| 'SUSPENDED'
|
||||
| 'POSTPONED'
|
||||
| 'CANCELLED'
|
||||
| 'AWARDED';
|
||||
|
||||
export interface MatchResponse {
|
||||
id: number;
|
||||
externalId: number;
|
||||
utcDate: string;
|
||||
status: MatchStatus;
|
||||
stage: string;
|
||||
group: string | null;
|
||||
homeTeam: TeamInfo;
|
||||
awayTeam: TeamInfo;
|
||||
score: ScoreInfo;
|
||||
userTip?: TipInfo | null;
|
||||
/** Minutes until kickoff (negative = past) */
|
||||
minutesUntilKickoff: number;
|
||||
/** Can the user still place a tip? */
|
||||
tippable: boolean;
|
||||
}
|
||||
|
||||
export interface TeamInfo {
|
||||
name: string;
|
||||
shortName: string;
|
||||
crest: string | null;
|
||||
}
|
||||
|
||||
export interface ScoreInfo {
|
||||
home: number | null;
|
||||
away: number | null;
|
||||
}
|
||||
|
||||
export interface TipInfo {
|
||||
home: number;
|
||||
away: number;
|
||||
points: number | null;
|
||||
}
|
||||
|
||||
export interface TipSubmitRequest {
|
||||
matchId: number;
|
||||
tipHome: number;
|
||||
tipAway: number;
|
||||
}
|
||||
|
||||
export interface TipSubmitResponse {
|
||||
success: boolean;
|
||||
tip: TipInfo;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface LeaderboardResponse {
|
||||
entries: DbLeaderboardEntry[];
|
||||
currentUserRank: number | null;
|
||||
totalParticipants: number;
|
||||
lastUpdated: string;
|
||||
}
|
||||
|
||||
export interface UserStatsResponse {
|
||||
userId: string;
|
||||
fullName: string;
|
||||
totalPoints: number;
|
||||
rank: number | null;
|
||||
tipsCount: number;
|
||||
exactCount: number;
|
||||
tendencyCount: number;
|
||||
wrongCount: number;
|
||||
accuracy: number;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// football-data.org API Types
|
||||
// ============================================================
|
||||
export interface FootballApiMatch {
|
||||
id: number;
|
||||
utcDate: string;
|
||||
status: MatchStatus;
|
||||
stage: string;
|
||||
group: string | null;
|
||||
homeTeam: {
|
||||
id: number;
|
||||
name: string;
|
||||
shortName: string;
|
||||
tla: string;
|
||||
crest: string;
|
||||
};
|
||||
awayTeam: {
|
||||
id: number;
|
||||
name: string;
|
||||
shortName: string;
|
||||
tla: string;
|
||||
crest: string;
|
||||
};
|
||||
score: {
|
||||
fullTime: { home: number | null; away: number | null };
|
||||
halfTime: { home: number | null; away: number | null };
|
||||
};
|
||||
}
|
||||
|
||||
export interface FootballApiResponse {
|
||||
matches: FootballApiMatch[];
|
||||
resultSet: {
|
||||
count: number;
|
||||
competitions: string;
|
||||
first: string;
|
||||
last: string;
|
||||
played: number;
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Points calculation
|
||||
// ============================================================
|
||||
export type PointsResult = 'exact' | 'tendency' | 'wrong' | 'pending';
|
||||
|
||||
export interface PointsCalculation {
|
||||
result: PointsResult;
|
||||
points: number;
|
||||
}
|
||||
|
||||
export const POINTS = {
|
||||
EXACT: 3,
|
||||
TENDENCY: 1,
|
||||
WRONG: 0,
|
||||
} as const;
|
||||
Reference in New Issue
Block a user