Files
meldestelle/docs/06_Frontend/FIGMA/Vision_03/docs/BACKEND.md
T
stefan 7702574904 feat(ui): introduce PferdReiterEingabe, NennungenTabelle, and NennungsMaske components
- Added `PferdReiterEingabe` for horse and rider selection with search functionality and keyboard navigation.
- Implemented `NennungenTabelle` to display filtered registrations based on selected horse or rider.
- Introduced `NennungsMaske` to combine search, table, and competition views for streamlined user interaction.
- Extended types with `Veranstalter` interface and mock data for better context and integration.
- Documented ÖTO-compliant tournament structure for frontend reference.

Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
2026-03-24 13:49:21 +01:00

28 KiB

Backend-Dokumentation - Backend Developer

🎯 Backend-Übersicht

Diese Dokumentation beschreibt die Backend-Anforderungen und die empfohlene Implementierung für die Turnierverwaltungs-Anwendung.

Aktueller Status: Prototyp ohne Backend (Mock Data)
Geplant: REST API / GraphQL mit PostgreSQL


📊 Datenbank-Schema

ER-Diagramm

┌─────────────────────────┐
│      Veranstalter       │
├─────────────────────────┤
│ id (PK)                 │
│ name                    │
│ adresse                 │
│ plz                     │
│ ort                     │
│ land                    │
│ telefon                 │
│ email                   │
│ website                 │
│ vereinsnummer           │
│ created_at              │
│ updated_at              │
└───────────┬─────────────┘
            │ 1:n
            │
┌───────────▼─────────────┐
│      Veranstaltung      │
├─────────────────────────┤
│ id (PK)                 │
│ veranstalter_id (FK)    │
│ name                    │
│ ort                     │
│ start_datum             │
│ end_datum               │
│ status                  │ → enum: 'geplant', 'laufend', 'abgeschlossen'
│ beschreibung            │
│ created_at              │
│ updated_at              │
└───────────┬─────────────┘
            │ 1:n
            │
┌───────────▼─────────────┐
│        Turnier          │
├─────────────────────────┤
│ id (PK)                 │
│ veranstaltung_id (FK)   │
│ nr                      │ → z.B. "A", "B", "1"
│ name                    │
│ zns_daten               │
│ oeto_typ                │ → enum: 'national', 'international'
│ fei_typ                 │
│ titel                   │
│ sub_titel               │
│ sponsoren               │ → JSONB
│ start_datum             │
│ end_datum               │
│ created_at              │
│ updated_at              │
└───────────┬─────────────┘
            │ 1:n
            │
┌───────────▼─────────────┐
│         Bewerb          │
├─────────────────────────┤
│ id (PK)                 │
│ turnier_id (FK)         │
│ nr                      │
│ name                    │
│ klasse                  │
│ tag                     │
│ datum                   │
│ beginn                  │
│ platz                   │
│ typ                     │
│ richter                 │
│ max_teilnehmer          │
│ startgebuehr            │
│ preisgeld               │
│ created_at              │
│ updated_at              │
└─────────────────────────┘

┌─────────────────────────┐
│         Reiter          │
├─────────────────────────┤
│ id (PK)                 │
│ vorname                 │
│ nachname                │
│ geburtsdatum            │
│ adresse                 │
│ plz                     │
│ ort                     │
│ land                    │
│ telefon                 │
│ email                   │
│ verein                  │
│ lizenznummer            │
│ foto_url                │
│ created_at              │
│ updated_at              │
└───────────┬─────────────┘
            │
            │ n:m
            │
┌───────────▼─────────────┐
│        Nennung          │
├─────────────────────────┤
│ id (PK)                 │
│ turnier_id (FK)         │
│ bewerb_id (FK)          │
│ reiter_id (FK)          │
│ pferd_id (FK)           │
│ startnummer             │
│ startwunsch             │ → enum: 'vorne', 'hinten', null
│ status                  │ → enum: 'offen', 'bestätigt', 'gestartet', 'abgeschlossen'
│ nenngeld_bezahlt        │ → boolean
│ startgebuehr_bezahlt    │ → boolean
│ created_at              │
│ updated_at              │
└───────────┬─────────────┘
            │
            │ n:m
            │
┌───────────▼─────────────┐
│         Pferd           │
├─────────────────────────┤
│ id (PK)                 │
│ name                    │
│ geschlecht              │ → enum: 'Hengst', 'Stute', 'Wallach'
│ geburtsjahr             │
│ rasse                   │
│ farbe                   │
│ abstammung_vater        │
│ abstammung_mutter       │
│ besitzer                │
│ lebensnummer            │
│ foto_url                │
│ created_at              │
│ updated_at              │
└─────────────────────────┘

┌─────────────────────────┐
│       Buchung           │
├─────────────────────────┤
│ id (PK)                 │
│ turnier_id (FK)         │
│ reiter_id (FK)          │
│ pferd_id (FK)           │
│ nennung_id (FK)         │ → optional
│ buchungstext            │
│ soll                    │ → decimal(10,2)
│ haben                   │ → decimal(10,2)
│ saldo                   │ → decimal(10,2)
│ zahlungsart             │ → enum: 'bar', 'scheck', 'bankomat', 'kreditkarte'
│ status                  │ → enum: 'offen', 'bezahlt', 'storniert'
│ rechnung_gedruckt       │ → boolean
│ datum                   │
│ created_at              │
│ updated_at              │
└─────────────────────────┘

┌─────────────────────────┐
│        Artikel          │
├─────────────────────────┤
│ id (PK)                 │
│ turnier_id (FK)         │
│ kategorie               │ → 'nennung', 'box', 'zusatzleistung', 'gebühr'
│ name                    │
│ beschreibung            │
│ preis                   │ → decimal(10,2)
│ einheit                 │ → z.B. 'pro Pferd', 'pro Tag', 'pro Bewerb'
│ created_at              │
│ updated_at              │
└─────────────────────────┘

┌─────────────────────────┐
│         User            │
├─────────────────────────┤
│ id (PK)                 │
│ username                │
│ email                   │
│ password_hash           │
│ role                    │ → enum: 'admin', 'organizer', 'secretary'
│ veranstalter_id (FK)    │ → optional
│ last_login              │
│ created_at              │
│ updated_at              │
└─────────────────────────┘

🗄️ PostgreSQL Schema (SQL)

-- Veranstalter Table
CREATE TABLE veranstalter (
  id SERIAL PRIMARY KEY,
  name VARCHAR(255) NOT NULL,
  adresse VARCHAR(255),
  plz VARCHAR(20),
  ort VARCHAR(100),
  land VARCHAR(100) DEFAULT 'Österreich',
  telefon VARCHAR(50),
  email VARCHAR(255),
  website VARCHAR(255),
  vereinsnummer VARCHAR(50),
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- Veranstaltung Table
CREATE TABLE veranstaltung (
  id SERIAL PRIMARY KEY,
  veranstalter_id INTEGER NOT NULL REFERENCES veranstalter(id) ON DELETE CASCADE,
  name VARCHAR(255) NOT NULL,
  ort VARCHAR(100),
  start_datum DATE NOT NULL,
  end_datum DATE NOT NULL,
  status VARCHAR(20) DEFAULT 'geplant' CHECK (status IN ('geplant', 'laufend', 'abgeschlossen')),
  beschreibung TEXT,
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  CONSTRAINT check_dates CHECK (end_datum >= start_datum)
);

-- Turnier Table
CREATE TABLE turnier (
  id SERIAL PRIMARY KEY,
  veranstaltung_id INTEGER NOT NULL REFERENCES veranstaltung(id) ON DELETE CASCADE,
  nr VARCHAR(10) NOT NULL,
  name VARCHAR(255),
  zns_daten VARCHAR(255),
  oeto_typ VARCHAR(20) CHECK (oeto_typ IN ('national', 'international')),
  fei_typ VARCHAR(50),
  titel VARCHAR(255),
  sub_titel VARCHAR(255),
  sponsoren JSONB,
  start_datum DATE,
  end_datum DATE,
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  UNIQUE(veranstaltung_id, nr)
);

-- Bewerb Table
CREATE TABLE bewerb (
  id SERIAL PRIMARY KEY,
  turnier_id INTEGER NOT NULL REFERENCES turnier(id) ON DELETE CASCADE,
  nr VARCHAR(10) NOT NULL,
  name VARCHAR(255) NOT NULL,
  klasse VARCHAR(50),
  tag INTEGER,
  datum DATE,
  beginn TIME,
  platz VARCHAR(100),
  typ VARCHAR(50),
  richter VARCHAR(255),
  max_teilnehmer INTEGER,
  startgebuehr DECIMAL(10,2),
  preisgeld DECIMAL(10,2),
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  UNIQUE(turnier_id, nr)
);

-- Reiter Table
CREATE TABLE reiter (
  id SERIAL PRIMARY KEY,
  vorname VARCHAR(100) NOT NULL,
  nachname VARCHAR(100) NOT NULL,
  geburtsdatum DATE,
  adresse VARCHAR(255),
  plz VARCHAR(20),
  ort VARCHAR(100),
  land VARCHAR(100) DEFAULT 'Österreich',
  telefon VARCHAR(50),
  email VARCHAR(255),
  verein VARCHAR(255),
  lizenznummer VARCHAR(50),
  foto_url VARCHAR(500),
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- Pferd Table
CREATE TABLE pferd (
  id SERIAL PRIMARY KEY,
  name VARCHAR(255) NOT NULL,
  geschlecht VARCHAR(20) CHECK (geschlecht IN ('Hengst', 'Stute', 'Wallach')),
  geburtsjahr INTEGER,
  rasse VARCHAR(100),
  farbe VARCHAR(50),
  abstammung_vater VARCHAR(255),
  abstammung_mutter VARCHAR(255),
  besitzer VARCHAR(255),
  lebensnummer VARCHAR(50) UNIQUE,
  foto_url VARCHAR(500),
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- Nennung Table (Join-Table mit zusätzlichen Feldern)
CREATE TABLE nennung (
  id SERIAL PRIMARY KEY,
  turnier_id INTEGER NOT NULL REFERENCES turnier(id) ON DELETE CASCADE,
  bewerb_id INTEGER NOT NULL REFERENCES bewerb(id) ON DELETE CASCADE,
  reiter_id INTEGER NOT NULL REFERENCES reiter(id) ON DELETE CASCADE,
  pferd_id INTEGER NOT NULL REFERENCES pferd(id) ON DELETE CASCADE,
  startnummer INTEGER,
  startwunsch VARCHAR(20) CHECK (startwunsch IN ('vorne', 'hinten')),
  status VARCHAR(20) DEFAULT 'offen' CHECK (status IN ('offen', 'bestätigt', 'gestartet', 'abgeschlossen')),
  nenngeld_bezahlt BOOLEAN DEFAULT FALSE,
  startgebuehr_bezahlt BOOLEAN DEFAULT FALSE,
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  UNIQUE(bewerb_id, reiter_id, pferd_id)
);

-- Buchung Table
CREATE TABLE buchung (
  id SERIAL PRIMARY KEY,
  turnier_id INTEGER NOT NULL REFERENCES turnier(id) ON DELETE CASCADE,
  reiter_id INTEGER REFERENCES reiter(id) ON DELETE SET NULL,
  pferd_id INTEGER REFERENCES pferd(id) ON DELETE SET NULL,
  nennung_id INTEGER REFERENCES nennung(id) ON DELETE SET NULL,
  buchungstext VARCHAR(500) NOT NULL,
  soll DECIMAL(10,2) DEFAULT 0.00,
  haben DECIMAL(10,2) DEFAULT 0.00,
  saldo DECIMAL(10,2) GENERATED ALWAYS AS (soll - haben) STORED,
  zahlungsart VARCHAR(20) CHECK (zahlungsart IN ('bar', 'scheck', 'bankomat', 'kreditkarte')),
  status VARCHAR(20) DEFAULT 'offen' CHECK (status IN ('offen', 'bezahlt', 'storniert')),
  rechnung_gedruckt BOOLEAN DEFAULT FALSE,
  datum DATE DEFAULT CURRENT_DATE,
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- Artikel Table
CREATE TABLE artikel (
  id SERIAL PRIMARY KEY,
  turnier_id INTEGER NOT NULL REFERENCES turnier(id) ON DELETE CASCADE,
  kategorie VARCHAR(50) CHECK (kategorie IN ('nennung', 'box', 'zusatzleistung', 'gebühr')),
  name VARCHAR(255) NOT NULL,
  beschreibung TEXT,
  preis DECIMAL(10,2) NOT NULL,
  einheit VARCHAR(50),
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- User Table
CREATE TABLE users (
  id SERIAL PRIMARY KEY,
  username VARCHAR(100) UNIQUE NOT NULL,
  email VARCHAR(255) UNIQUE NOT NULL,
  password_hash VARCHAR(255) NOT NULL,
  role VARCHAR(20) DEFAULT 'secretary' CHECK (role IN ('admin', 'organizer', 'secretary')),
  veranstalter_id INTEGER REFERENCES veranstalter(id) ON DELETE SET NULL,
  last_login TIMESTAMP,
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- Indexes für Performance
CREATE INDEX idx_veranstaltung_veranstalter ON veranstaltung(veranstalter_id);
CREATE INDEX idx_turnier_veranstaltung ON turnier(veranstaltung_id);
CREATE INDEX idx_bewerb_turnier ON bewerb(turnier_id);
CREATE INDEX idx_nennung_turnier ON nennung(turnier_id);
CREATE INDEX idx_nennung_reiter ON nennung(reiter_id);
CREATE INDEX idx_nennung_pferd ON nennung(pferd_id);
CREATE INDEX idx_buchung_turnier ON buchung(turnier_id);
CREATE INDEX idx_buchung_reiter ON buchung(reiter_id);

-- Trigger für updated_at
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
   NEW.updated_at = CURRENT_TIMESTAMP;
   RETURN NEW;
END;
$$ language 'plpgsql';

CREATE TRIGGER update_veranstalter_updated_at BEFORE UPDATE ON veranstalter 
  FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_veranstaltung_updated_at BEFORE UPDATE ON veranstaltung 
  FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_turnier_updated_at BEFORE UPDATE ON turnier 
  FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();

🔌 REST API Endpoints

Authentication

POST   /api/auth/login
POST   /api/auth/logout
POST   /api/auth/refresh
GET    /api/auth/me

Veranstalter

GET    /api/veranstalter
POST   /api/veranstalter
GET    /api/veranstalter/:id
PUT    /api/veranstalter/:id
DELETE /api/veranstalter/:id

Veranstaltungen

GET    /api/veranstaltungen
POST   /api/veranstaltungen
GET    /api/veranstaltungen/:id
PUT    /api/veranstaltungen/:id
DELETE /api/veranstaltungen/:id
GET    /api/veranstaltungen/:id/turniere

Turniere

GET    /api/turniere
POST   /api/turniere
GET    /api/turniere/:id
PUT    /api/turniere/:id
DELETE /api/turniere/:id
GET    /api/turniere/:id/bewerbe
GET    /api/turniere/:id/nennungen
GET    /api/turniere/:id/buchungen
GET    /api/turniere/:id/artikel

Bewerbe

GET    /api/bewerbe
POST   /api/bewerbe
GET    /api/bewerbe/:id
PUT    /api/bewerbe/:id
DELETE /api/bewerbe/:id
GET    /api/bewerbe/:id/nennungen

Reiter

GET    /api/reiter?search=:query
POST   /api/reiter
GET    /api/reiter/:id
PUT    /api/reiter/:id
DELETE /api/reiter/:id
GET    /api/reiter/:id/pferde        # Pferde dieses Reiters
GET    /api/reiter/:id/nennungen     # Nennungen dieses Reiters
GET    /api/reiter/:id/buchungen     # Buchungen dieses Reiters

Pferde

GET    /api/pferde?search=:query
POST   /api/pferde
GET    /api/pferde/:id
PUT    /api/pferde/:id
DELETE /api/pferde/:id
GET    /api/pferde/:id/reiter        # Reiter dieses Pferdes
GET    /api/pferde/:id/nennungen     # Nennungen dieses Pferdes

Nennungen

GET    /api/nennungen?turnierId=:id&reiterId=:id&pferdId=:id
POST   /api/nennungen
GET    /api/nennungen/:id
PUT    /api/nennungen/:id
DELETE /api/nennungen/:id

Buchungen

GET    /api/buchungen?turnierId=:id&reiterId=:id
POST   /api/buchungen
PUT    /api/buchungen/:id
DELETE /api/buchungen/:id
POST   /api/buchungen/batch          # Batch-Buchungen
GET    /api/buchungen/:id/rechnung   # PDF-Rechnung

📝 API Response Formats

Success Response

{
  "success": true,
  "data": {
    "id": 1,
    "name": "Frühjahrsturnier 2026"
  }
}

Error Response

{
  "success": false,
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Name ist erforderlich",
    "details": {
      "field": "name",
      "value": ""
    }
  }
}

Pagination Response

{
  "success": true,
  "data": [...],
  "pagination": {
    "page": 1,
    "limit": 20,
    "total": 150,
    "totalPages": 8
  }
}

🔐 Authentication Flow

JWT Token-basierte Authentifizierung

// 1. Login
POST /api/auth/login
Body: { username: 'admin', password: 'Admin#1234' }

Response:
{
  "success": true,
  "data": {
    "accessToken": "eyJhbGciOiJIUzI1NiIsInR...",
    "refreshToken": "eyJhbGciOiJIUzI1NiIsInR...",
    "user": {
      "id": 1,
      "username": "admin",
      "role": "admin"
    }
  }
}

// 2. Geschützte API-Calls
GET /api/turniere/1
Headers: {
  Authorization: 'Bearer eyJhbGciOiJIUzI1NiIsInR...'
}

// 3. Token Refresh
POST /api/auth/refresh
Body: { refreshToken: "eyJhbGciOiJIUzI1NiIsInR..." }

Password Hashing

import bcrypt from 'bcrypt';

// Hash-Password beim Erstellen
const passwordHash = await bcrypt.hash(password, 10);

// Verify beim Login
const isValid = await bcrypt.compare(password, user.password_hash);

🛠️ Backend Implementation (Node.js + Express)

Project Structure

/backend
├── /src
│   ├── /config
│   │   ├── database.ts        # PostgreSQL Connection
│   │   ├── env.ts             # Environment Variables
│   │   └── logger.ts          # Winston Logger
│   │
│   ├── /middleware
│   │   ├── auth.ts            # JWT Authentication
│   │   ├── errorHandler.ts   # Global Error Handler
│   │   ├── validation.ts      # Request Validation
│   │   └── rateLimiter.ts     # Rate Limiting
│   │
│   ├── /models
│   │   ├── Veranstalter.ts
│   │   ├── Veranstaltung.ts
│   │   ├── Turnier.ts
│   │   ├── Bewerb.ts
│   │   ├── Reiter.ts
│   │   ├── Pferd.ts
│   │   ├── Nennung.ts
│   │   ├── Buchung.ts
│   │   └── User.ts
│   │
│   ├── /controllers
│   │   ├── authController.ts
│   │   ├── veranstalterController.ts
│   │   ├── veranstaltungController.ts
│   │   ├── turnierController.ts
│   │   ├── bewerbController.ts
│   │   ├── reiterController.ts
│   │   ├── pferdController.ts
│   │   ├── nennungController.ts
│   │   └── buchungController.ts
│   │
│   ├── /routes
│   │   ├── auth.ts
│   │   ├── veranstalter.ts
│   │   ├── veranstaltung.ts
│   │   ├── turnier.ts
│   │   ├── bewerb.ts
│   │   ├── reiter.ts
│   │   ├── pferd.ts
│   │   ├── nennung.ts
│   │   └── buchung.ts
│   │
│   ├── /services
│   │   ├── authService.ts
│   │   ├── emailService.ts
│   │   ├── pdfService.ts       # PDF-Generierung
│   │   └── excelService.ts     # Excel-Export
│   │
│   ├── /utils
│   │   ├── validators.ts
│   │   ├── helpers.ts
│   │   └── constants.ts
│   │
│   ├── app.ts                  # Express App Setup
│   └── server.ts               # Server Entry Point
│
├── /tests
│   ├── /unit
│   └── /integration
│
├── .env
├── .env.example
├── package.json
├── tsconfig.json
└── README.md

Example: Turnier Controller

// src/controllers/turnierController.ts
import { Request, Response, NextFunction } from 'express';
import { pool } from '../config/database';

export const getTurniere = async (
  req: Request,
  res: Response,
  next: NextFunction
) => {
  try {
    const { veranstaltungId } = req.query;
    
    let query = `
      SELECT t.*, v.name as veranstaltung_name
      FROM turnier t
      JOIN veranstaltung v ON t.veranstaltung_id = v.id
    `;
    
    const params: any[] = [];
    
    if (veranstaltungId) {
      query += ' WHERE t.veranstaltung_id = $1';
      params.push(veranstaltungId);
    }
    
    const result = await pool.query(query, params);
    
    res.json({
      success: true,
      data: result.rows
    });
  } catch (error) {
    next(error);
  }
};

export const createTurnier = async (
  req: Request,
  res: Response,
  next: NextFunction
) => {
  try {
    const {
      veranstaltungId,
      nr,
      name,
      znsDaten,
      oetoTyp,
      feiTyp,
      titel,
      subTitel,
      sponsoren
    } = req.body;
    
    // Validation
    if (!veranstaltungId || !nr) {
      return res.status(400).json({
        success: false,
        error: {
          code: 'VALIDATION_ERROR',
          message: 'Veranstaltung und Nummer sind erforderlich'
        }
      });
    }
    
    const query = `
      INSERT INTO turnier (
        veranstaltung_id, nr, name, zns_daten, oeto_typ, 
        fei_typ, titel, sub_titel, sponsoren
      )
      VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
      RETURNING *
    `;
    
    const result = await pool.query(query, [
      veranstaltungId, nr, name, znsDaten, oetoTyp,
      feiTyp, titel, subTitel, JSON.stringify(sponsoren)
    ]);
    
    res.status(201).json({
      success: true,
      data: result.rows[0]
    });
  } catch (error) {
    next(error);
  }
};

export const getTurnierById = async (
  req: Request,
  res: Response,
  next: NextFunction
) => {
  try {
    const { id } = req.params;
    
    const query = `
      SELECT t.*, 
        v.name as veranstaltung_name,
        (SELECT COUNT(*) FROM bewerb WHERE turnier_id = t.id) as anzahl_bewerbe,
        (SELECT COUNT(*) FROM nennung WHERE turnier_id = t.id) as anzahl_nennungen
      FROM turnier t
      JOIN veranstaltung v ON t.veranstaltung_id = v.id
      WHERE t.id = $1
    `;
    
    const result = await pool.query(query, [id]);
    
    if (result.rows.length === 0) {
      return res.status(404).json({
        success: false,
        error: {
          code: 'NOT_FOUND',
          message: 'Turnier nicht gefunden'
        }
      });
    }
    
    res.json({
      success: true,
      data: result.rows[0]
    });
  } catch (error) {
    next(error);
  }
};

Example: Auth Middleware

// src/middleware/auth.ts
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';

interface JWTPayload {
  userId: number;
  role: string;
}

export const authenticate = (
  req: Request,
  res: Response,
  next: NextFunction
) => {
  try {
    const token = req.headers.authorization?.replace('Bearer ', '');
    
    if (!token) {
      return res.status(401).json({
        success: false,
        error: {
          code: 'UNAUTHORIZED',
          message: 'Kein Token vorhanden'
        }
      });
    }
    
    const decoded = jwt.verify(
      token,
      process.env.JWT_SECRET!
    ) as JWTPayload;
    
    req.user = decoded;
    next();
  } catch (error) {
    return res.status(401).json({
      success: false,
      error: {
        code: 'INVALID_TOKEN',
        message: 'Ungültiges Token'
      }
    });
  }
};

export const authorize = (...roles: string[]) => {
  return (req: Request, res: Response, next: NextFunction) => {
    if (!req.user || !roles.includes(req.user.role)) {
      return res.status(403).json({
        success: false,
        error: {
          code: 'FORBIDDEN',
          message: 'Keine Berechtigung'
        }
      });
    }
    next();
  };
};

📊 Supabase Alternative

Vorteile:

  • Schnelle Setup-Zeit
  • Integrierte Authentication
  • Real-Time Subscriptions
  • Storage für Dateien
  • Row Level Security

Setup:

// lib/supabase.ts
import { createClient } from '@supabase/supabase-js';

export const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);

// Beispiel: Turniere abrufen
const { data, error } = await supabase
  .from('turnier')
  .select(`
    *,
    veranstaltung (
      name,
      veranstalter (name)
    ),
    bewerbe (*)
  `)
  .eq('id', turnierId);

Row Level Security (RLS):

-- Nur Admins und Organizer können Turniere erstellen
CREATE POLICY "Nur Admins/Organizer können Turniere erstellen"
  ON turnier FOR INSERT
  TO authenticated
  USING (
    EXISTS (
      SELECT 1 FROM users
      WHERE users.id = auth.uid()
      AND users.role IN ('admin', 'organizer')
    )
  );

🧪 Testing

Unit Tests (Jest)

// tests/unit/turnierController.test.ts
import { createTurnier } from '../../src/controllers/turnierController';

describe('TurnierController', () => {
  describe('createTurnier', () => {
    it('should create a new turnier', async () => {
      const req = {
        body: {
          veranstaltungId: 1,
          nr: 'A',
          name: 'Dressur'
        }
      };
      
      const res = {
        status: jest.fn().mockReturnThis(),
        json: jest.fn()
      };
      
      await createTurnier(req as any, res as any, jest.fn());
      
      expect(res.status).toHaveBeenCalledWith(201);
      expect(res.json).toHaveBeenCalledWith(
        expect.objectContaining({
          success: true,
          data: expect.any(Object)
        })
      );
    });
  });
});

Integration Tests (Supertest)

// tests/integration/turnier.test.ts
import request from 'supertest';
import app from '../../src/app';

describe('Turnier API', () => {
  let authToken: string;
  
  beforeAll(async () => {
    // Login
    const res = await request(app)
      .post('/api/auth/login')
      .send({ username: 'admin', password: 'Admin#1234' });
    
    authToken = res.body.data.accessToken;
  });
  
  it('GET /api/turniere should return all turniere', async () => {
    const res = await request(app)
      .get('/api/turniere')
      .set('Authorization', `Bearer ${authToken}`);
    
    expect(res.status).toBe(200);
    expect(res.body.success).toBe(true);
    expect(Array.isArray(res.body.data)).toBe(true);
  });
  
  it('POST /api/turniere should create a turnier', async () => {
    const res = await request(app)
      .post('/api/turniere')
      .set('Authorization', `Bearer ${authToken}`)
      .send({
        veranstaltungId: 1,
        nr: 'TEST',
        name: 'Test Turnier'
      });
    
    expect(res.status).toBe(201);
    expect(res.body.success).toBe(true);
    expect(res.body.data.nr).toBe('TEST');
  });
});

📄 Environment Variables

# .env
NODE_ENV=development
PORT=3000

# Database
DATABASE_URL=postgresql://user:password@localhost:5432/turnierverwaltung
DB_HOST=localhost
DB_PORT=5432
DB_USER=postgres
DB_PASSWORD=your_password
DB_NAME=turnierverwaltung

# JWT
JWT_SECRET=your_super_secret_jwt_key_here
JWT_EXPIRES_IN=1h
JWT_REFRESH_EXPIRES_IN=7d

# Email (optional)
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USER=your_email@gmail.com
SMTP_PASS=your_password

# File Upload
MAX_FILE_SIZE=5242880  # 5MB
UPLOAD_DIR=./uploads

# Supabase (alternative)
SUPABASE_URL=https://your-project.supabase.co
SUPABASE_ANON_KEY=your_anon_key
SUPABASE_SERVICE_KEY=your_service_key

🚀 Deployment

Docker Setup

# Dockerfile
FROM node:18-alpine

WORKDIR /app

COPY package*.json ./
RUN npm install --production

COPY . .

RUN npm run build

EXPOSE 3000

CMD ["node", "dist/server.js"]
# docker-compose.yml
version: '3.8'

services:
  backend:
    build: .
    ports:
      - "3000:3000"
    environment:
      - NODE_ENV=production
      - DATABASE_URL=postgresql://postgres:password@db:5432/turnierverwaltung
    depends_on:
      - db
  
  db:
    image: postgres:15-alpine
    environment:
      - POSTGRES_USER=postgres
      - POSTGRES_PASSWORD=password
      - POSTGRES_DB=turnierverwaltung
    volumes:
      - postgres_data:/var/lib/postgresql/data
    ports:
      - "5432:5432"

volumes:
  postgres_data:

Dokumentiert von: Backend Developer
Version: 1.0
Datum: 2026-03-24