feat: WM2026 Tippspiel - Initial Backend + Frontend
This commit is contained in:
@@ -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');
|
||||
Reference in New Issue
Block a user