7702574904
- 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>
28 KiB
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