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
+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');