feat: WM2026 Tippspiel - Initial Backend + Frontend

This commit is contained in:
Ronny Müller
2026-04-03 21:41:19 +02:00
commit 1c685b90a0
2507 changed files with 997210 additions and 0 deletions
BIN
View File
Binary file not shown.
+22
View File
@@ -0,0 +1,22 @@
# Staffbase Plugin Configuration
STAFFBASE_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----
<your Staffbase plugin public key here>
-----END PUBLIC KEY-----"
STAFFBASE_PLUGIN_SECRET=your_plugin_secret_here
# Database (Supabase)
DATABASE_URL=postgresql://postgres:[password]@db.[project-ref].supabase.co:5432/postgres
SUPABASE_URL=https://[project-ref].supabase.co
SUPABASE_SERVICE_ROLE_KEY=your_service_role_key_here
# football-data.org
FOOTBALL_API_KEY=your_football_data_api_key_here
FOOTBALL_API_BASE_URL=https://api.football-data.org/v4
# App Config
PORT=3001
NODE_ENV=development
CORS_ORIGIN=https://app.staffbase.com
# Plugin Base URL (where this backend is hosted)
PLUGIN_BASE_URL=https://your-app.railway.app
+6
View File
@@ -0,0 +1,6 @@
node_modules/
dist/
.env
.env.local
*.log
coverage/
+114
View File
@@ -0,0 +1,114 @@
# WM 2026 Tippspiel Backend Setup
## Voraussetzungen
- Node.js 20+
- npm oder pnpm
- Supabase Account (kostenlos)
- football-data.org API Key (kostenlos)
- Railway oder Render Account (Hosting)
---
## 1. Supabase einrichten
1. Projekt anlegen unter https://supabase.com
2. SQL Editor öffnen → `src/db/schema.sql` vollständig ausführen
3. Unter **Settings → Database → Connection string** die `DATABASE_URL` kopieren
4. Unter **Settings → API → service_role key** den Service Role Key kopieren
---
## 2. Staffbase Plugin Public Key
1. In Staffbase Studio: **Plugins → Neues Plugin anlegen**
2. Plugin-Typ: **External Web App (iFrame)**
3. Den angezeigten **Public Key (RSA)** kopieren
4. In `.env` als `STAFFBASE_PUBLIC_KEY` eintragen (mehrzeilig mit `\n` oder in Anführungszeichen)
---
## 3. football-data.org API Key
1. Registrieren unter https://www.football-data.org/client/register
2. API Key per E-Mail erhalten → in `.env` als `FOOTBALL_API_KEY` eintragen
---
## 4. Lokale Entwicklung
```bash
cd backend
npm install
cp .env.example .env
# .env mit echten Werten befüllen
npm run dev
# → Server läuft auf http://localhost:3001
# → Health Check: http://localhost:3001/health
```
---
## 5. Deployment auf Railway
```bash
# Railway CLI installieren
npm install -g @railway/cli
# Login
railway login
# Projekt erstellen und deployen
railway init
railway up
# Environment Variables setzen (einmalig)
railway variables set DATABASE_URL="..." \
STAFFBASE_PUBLIC_KEY="..." \
FOOTBALL_API_KEY="..." \
NODE_ENV=production \
CORS_ORIGIN="https://app.staffbase.com"
```
---
## 6. Ersten Sync durchführen
Nach dem Deployment:
```bash
# Spiele von football-data.org laden (als Editor-User via Staffbase)
POST /api/admin/sync
# Oder direkt via curl (mit gültigem JWT):
curl -X POST https://your-app.railway.app/api/admin/sync \
-H "Authorization: Bearer <staffbase_jwt>"
```
---
## API Übersicht
| Method | Endpoint | Beschreibung |
|--------|----------|--------------|
| GET | `/health` | Health Check |
| GET | `/api/matches` | Alle Spiele (mit eigenem Tipp) |
| GET | `/api/matches/:id` | Einzelnes Spiel |
| POST | `/api/tips` | Tipp abgeben / aktualisieren |
| GET | `/api/tips` | Eigene Tipps |
| GET | `/api/leaderboard` | Rangliste (Top 50) |
| GET | `/api/leaderboard/me` | Eigene Statistiken |
| POST | `/api/admin/sync` | Spiele synchronisieren (Editor) |
| POST | `/api/admin/evaluate` | Tipps auswerten (Editor) |
| GET | `/api/admin/stats` | Admin-Statistiken (Editor) |
---
## Punkte-System
| Ergebnis | Punkte |
|----------|--------|
| Exaktes Ergebnis | **3** |
| Richtige Tendenz (S/U/N) | **1** |
| Falsche Tendenz | **0** |
+2156
View File
File diff suppressed because it is too large Load Diff
+31
View File
@@ -0,0 +1,31 @@
{
"name": "wm2026-tippspiel-backend",
"version": "1.0.0",
"description": "WM 2026 Tippspiel Backend - GEALAN Staffbase Plugin",
"main": "dist/index.js",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"db:push": "node scripts/db-push.js",
"db:seed": "tsx scripts/seed.ts"
},
"dependencies": {
"@staffbase/staffbase-plugin-sdk": "^1.3.7",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.19.2",
"express-rate-limit": "^7.3.1",
"helmet": "^7.1.0",
"pg": "^8.12.0",
"winston": "^3.13.0"
},
"devDependencies": {
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/node": "^20.14.2",
"@types/pg": "^8.11.6",
"tsx": "^4.15.6",
"typescript": "^5.4.5"
}
}
BIN
View File
Binary file not shown.
+87
View File
@@ -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;
}
};
+4
View File
@@ -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;
+154
View File
@@ -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');
+165
View File
@@ -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;
+66
View File
@@ -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();
};
+123
View File
@@ -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();
};
+87
View File
@@ -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;
+129
View File
@@ -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;
+194
View File
@@ -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;
+163
View File
@@ -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;
+100
View File
@@ -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;
};
+21
View File
@@ -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()],
});
+87
View File
@@ -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,
};
};
+176
View File
@@ -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,
};
};
+214
View File
@@ -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;
+19
View File
@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}