init
This commit is contained in:
13
.env
Normal file
13
.env
Normal file
@ -0,0 +1,13 @@
|
||||
# PostgreSQL Connection URL
|
||||
# Format: postgresql://<user>:<password>@<host>:<port>/<database>
|
||||
DATABASE_URL=postgresql://postgres:password@localhost:5432/tt_tournament
|
||||
|
||||
# JWT Secret Key (change this to a long, random, secure string)
|
||||
JWT_SECRET=YOUR_VERY_SECRET_RANDOM_KEY_HERE
|
||||
|
||||
# Port the application will run on
|
||||
PORT=3000
|
||||
|
||||
# Optional: Database Backup Configuration
|
||||
# BACKUP_PATH=/path/to/your/backup/directory
|
||||
# BACKUP_INTERVAL_MINUTES=1440 # e.g., 1440 for daily backups
|
13
.env.example
Normal file
13
.env.example
Normal file
@ -0,0 +1,13 @@
|
||||
# PostgreSQL Connection URL
|
||||
# Format: postgresql://<user>:<password>@<host>:<port>/<database>
|
||||
DATABASE_URL=postgresql://postgres:mysecretpassword@localhost:5432/tt_tournament
|
||||
|
||||
# JWT Secret Key (change this to a long, random, secure string)
|
||||
JWT_SECRET=YOUR_VERY_SECRET_RANDOM_KEY_HERE
|
||||
|
||||
# Port the application will run on
|
||||
PORT=3000
|
||||
|
||||
# Optional: Database Backup Configuration
|
||||
# BACKUP_PATH=/path/to/your/backup/directory
|
||||
# BACKUP_INTERVAL_MINUTES=1440 # e.g., 1440 for daily backups
|
5
.gitignore
vendored
5
.gitignore
vendored
@ -35,3 +35,8 @@ out
|
||||
.vscode-test
|
||||
|
||||
|
||||
npm-debug.log
|
||||
key.pem
|
||||
cert.pem
|
||||
*.pem
|
||||
backups/
|
2698
package-lock.json
generated
Normal file
2698
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
40
package.json
Normal file
40
package.json
Normal file
@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "tischtennis-turnierverwaltung",
|
||||
"version": "1.0.0",
|
||||
"description": "Anwendung zur Verwaltung von Tischtennisturnieren",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"dev": "nodemon server.js",
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"generate-cert": "node src/utils/generateCertificate.js"
|
||||
},
|
||||
"keywords": [
|
||||
"tischtennis",
|
||||
"turnier",
|
||||
"verwaltung",
|
||||
"express",
|
||||
"nodejs"
|
||||
],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"bcrypt": "^5.1.1",
|
||||
"cors": "^2.8.5",
|
||||
"csv-parser": "^3.2.0",
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^5.1.0",
|
||||
"fast-csv": "^5.0.2",
|
||||
"helmet": "^8.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"morgan": "^1.10.0",
|
||||
"multer": "^1.4.5-lts.2",
|
||||
"node-cron": "^3.0.3",
|
||||
"pdfkit": "^0.16.0",
|
||||
"pg": "^8.11.5",
|
||||
"ws": "^8.18.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.1.0"
|
||||
}
|
||||
}
|
130
public/admin.html
Normal file
130
public/admin.html
Normal file
@ -0,0 +1,130 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Admin-Bereich - Turnierverwaltung</title>
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<nav>
|
||||
<a href="/admin.html" class="active">Admin</a>
|
||||
<a href="/referee.html">Schiedsrichter</a>
|
||||
<a href="/spectator.html">Zuschauer</a>
|
||||
<a href="#" id="logout-button" style="float: right;">Logout</a>
|
||||
</nav>
|
||||
|
||||
<div class="container">
|
||||
<h1>Admin-Bereich</h1>
|
||||
<p id="welcome-message">Willkommen!</p>
|
||||
|
||||
<div id="login-section" class="auth-form">
|
||||
<h2>Login</h2>
|
||||
<form id="login-form">
|
||||
<div>
|
||||
<label for="login-username">Benutzername:</label>
|
||||
<input type="text" id="login-username" required>
|
||||
</div>
|
||||
<div>
|
||||
<label for="login-password">Passwort:</label>
|
||||
<input type="password" id="login-password" required>
|
||||
</div>
|
||||
<button type="submit">Login</button>
|
||||
<div id="login-error" class="error-message hidden"></div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div id="admin-content" class="hidden">
|
||||
|
||||
<section id="tournament-management" class="content-section">
|
||||
<div class="section-header">
|
||||
<h2>Turnierverwaltung</h2>
|
||||
<button id="show-add-tournament-form">Neues Turnier hinzufügen</button>
|
||||
</div>
|
||||
|
||||
<form id="tournament-form" class="hidden">
|
||||
<h3 id="tournament-form-title">Neues Turnier hinzufügen</h3>
|
||||
<input type="hidden" id="tournament-id"> <div>
|
||||
<label for="tournament-name">Name:</label>
|
||||
<input type="text" id="tournament-name" required>
|
||||
</div>
|
||||
<div>
|
||||
<label for="tournament-date">Datum:</label>
|
||||
<input type="date" id="tournament-date">
|
||||
</div>
|
||||
<div>
|
||||
<label for="tournament-location">Ort:</label>
|
||||
<input type="text" id="tournament-location">
|
||||
</div>
|
||||
<div>
|
||||
<label for="tournament-type">Turnier-Typ:</label>
|
||||
<select id="tournament-type" required>
|
||||
<option value="knockout">KO-System</option>
|
||||
<option value="group">Gruppenphase</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="tournament-game-type">Spiel-Typ (Punkte pro Satz):</label>
|
||||
<select id="tournament-game-type" required>
|
||||
<option value="11_points">11 Punkte</option>
|
||||
<option value="21_points">21 Punkte</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="tournament-description">Beschreibung:</label>
|
||||
<textarea id="tournament-description"></textarea>
|
||||
</div>
|
||||
<button type="submit" id="save-tournament-button">Speichern</button>
|
||||
<button type="button" id="cancel-tournament-button" class="secondary">Abbrechen</button>
|
||||
<div id="tournament-form-error" class="error-message hidden"></div>
|
||||
</form>
|
||||
|
||||
<div id="tournament-list-message" class="hidden"></div> <table id="tournament-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Datum</th>
|
||||
<th>Ort</th>
|
||||
<th>Typ</th>
|
||||
<th>Status</th>
|
||||
<th>Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="tournament-list">
|
||||
</tbody>
|
||||
</table>
|
||||
<p id="loading-tournaments" class="hidden">Lade Turniere...</p>
|
||||
</section>
|
||||
|
||||
<section id="player-management" class="content-section">
|
||||
<div class="section-header">
|
||||
<h2>Spielerverwaltung</h2>
|
||||
<button disabled>Neuen Spieler hinzufügen</button> </div>
|
||||
<p>Funktionalität für Spieler (hinzufügen, bearbeiten, löschen, importieren, exportieren) wird hier implementiert.</p>
|
||||
</section>
|
||||
|
||||
<section id="user-management" class="content-section">
|
||||
<div class="section-header">
|
||||
<h2>Benutzerverwaltung</h2>
|
||||
<button disabled>Neuen Benutzer hinzufügen</button> </div>
|
||||
<p>Funktionalität für Benutzer (Admins, Schiedsrichter hinzufügen/verwalten) wird hier implementiert.</p>
|
||||
</section>
|
||||
|
||||
<section id="match-management" class="content-section">
|
||||
<h2>Spielverwaltung & Ergebnisse</h2>
|
||||
<p>Funktionalität für Spiele (manuell hinzufügen, Ergebnisse eintragen, Turnierbaum generieren, exportieren) wird hier implementiert.</p>
|
||||
</section>
|
||||
|
||||
<section id="statistics-section" class="content-section">
|
||||
<h2>Statistiken</h2>
|
||||
<p>Anzeige von Statistiken (Spielergebnisse, Quoten etc.) wird hier implementiert.</p>
|
||||
</section>
|
||||
|
||||
<section id="settings-section" class="content-section">
|
||||
<h2>Einstellungen</h2>
|
||||
<p>Einstellungen (z.B. Backup-Intervall) werden hier implementiert.</p>
|
||||
</section>
|
||||
|
||||
</div> </div> <script src="/js/admin.js"></script>
|
||||
</body>
|
||||
</html>
|
177
public/css/style.css
Normal file
177
public/css/style.css
Normal file
@ -0,0 +1,177 @@
|
||||
/* public/css/style.css */
|
||||
/* Very basic styling - focus on function over form as requested */
|
||||
|
||||
body {
|
||||
font-family: sans-serif;
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
background-color: #f4f4f4;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
h1, h2, h3 {
|
||||
color: #0056b3; /* A blue tone */
|
||||
}
|
||||
|
||||
nav {
|
||||
background-color: #333;
|
||||
padding: 10px;
|
||||
margin-bottom: 20px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
nav a {
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
margin-right: 15px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 3px;
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
|
||||
nav a:hover, nav a.active {
|
||||
background-color: #555;
|
||||
}
|
||||
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: auto;
|
||||
background: #fff;
|
||||
padding: 25px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 5px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.auth-form, .content-section {
|
||||
margin-bottom: 30px;
|
||||
padding: 20px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 5px;
|
||||
background-color: #fdfdfd;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input[type="password"],
|
||||
input[type="email"],
|
||||
input[type="date"],
|
||||
input[type="number"],
|
||||
select,
|
||||
textarea {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
margin-bottom: 15px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
box-sizing: border-box; /* Include padding in width */
|
||||
}
|
||||
|
||||
textarea {
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
button {
|
||||
background-color: #007bff; /* Standard blue */
|
||||
color: white;
|
||||
padding: 10px 18px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 1em;
|
||||
transition: background-color 0.3s ease;
|
||||
margin-right: 5px; /* Spacing between buttons */
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background-color: #0056b3; /* Darker blue on hover */
|
||||
}
|
||||
|
||||
button.secondary {
|
||||
background-color: #6c757d; /* Grey */
|
||||
}
|
||||
button.secondary:hover {
|
||||
background-color: #5a6268;
|
||||
}
|
||||
|
||||
button.danger {
|
||||
background-color: #dc3545; /* Red */
|
||||
}
|
||||
button.danger:hover {
|
||||
background-color: #c82333;
|
||||
}
|
||||
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
th, td {
|
||||
border: 1px solid #ddd;
|
||||
padding: 10px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
th {
|
||||
background-color: #e9ecef; /* Light grey header */
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
tbody tr:nth-child(odd) {
|
||||
background-color: #f8f9fa; /* Slightly off-white for odd rows */
|
||||
}
|
||||
|
||||
tbody tr:hover {
|
||||
background-color: #e2e6ea; /* Highlight on hover */
|
||||
}
|
||||
|
||||
|
||||
.error-message {
|
||||
color: #dc3545; /* Red */
|
||||
background-color: #f8d7da;
|
||||
border: 1px solid #f5c6cb;
|
||||
padding: 10px;
|
||||
margin-top: 10px;
|
||||
margin-bottom: 15px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.success-message {
|
||||
color: #155724; /* Green */
|
||||
background-color: #d4edda;
|
||||
border: 1px solid #c3e6cb;
|
||||
padding: 10px;
|
||||
margin-top: 10px;
|
||||
margin-bottom: 15px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Basic layout for sections */
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.section-header h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Simple modal styling (if needed later) */
|
||||
/* .modal { */
|
||||
/* ... styles for modal background and container ... */
|
||||
/* } */
|
||||
|
396
public/js/admin.js
Normal file
396
public/js/admin.js
Normal file
@ -0,0 +1,396 @@
|
||||
// public/js/admin.js
|
||||
|
||||
// --- DOM Elements ---
|
||||
const loginSection = document.getElementById('login-section');
|
||||
const adminContent = document.getElementById('admin-content');
|
||||
const loginForm = document.getElementById('login-form');
|
||||
const loginUsernameInput = document.getElementById('login-username');
|
||||
const loginPasswordInput = document.getElementById('login-password');
|
||||
const loginError = document.getElementById('login-error');
|
||||
const logoutButton = document.getElementById('logout-button');
|
||||
const welcomeMessage = document.getElementById('welcome-message');
|
||||
|
||||
// Tournament elements
|
||||
const tournamentForm = document.getElementById('tournament-form');
|
||||
const tournamentFormTitle = document.getElementById('tournament-form-title');
|
||||
const tournamentIdInput = document.getElementById('tournament-id');
|
||||
const tournamentNameInput = document.getElementById('tournament-name');
|
||||
const tournamentDateInput = document.getElementById('tournament-date');
|
||||
const tournamentLocationInput = document.getElementById('tournament-location');
|
||||
const tournamentTypeInput = document.getElementById('tournament-type');
|
||||
const tournamentGameTypeInput = document.getElementById('tournament-game-type');
|
||||
const tournamentDescriptionInput = document.getElementById('tournament-description');
|
||||
const saveTournamentButton = document.getElementById('save-tournament-button');
|
||||
const cancelTournamentButton = document.getElementById('cancel-tournament-button');
|
||||
const tournamentFormError = document.getElementById('tournament-form-error');
|
||||
const showAddTournamentFormButton = document.getElementById('show-add-tournament-form');
|
||||
const tournamentList = document.getElementById('tournament-list');
|
||||
const tournamentTable = document.getElementById('tournament-table');
|
||||
const loadingTournaments = document.getElementById('loading-tournaments');
|
||||
const tournamentListMessage = document.getElementById('tournament-list-message');
|
||||
|
||||
|
||||
// --- State ---
|
||||
let authToken = localStorage.getItem('authToken'); // Store token in local storage
|
||||
let currentUser = JSON.parse(localStorage.getItem('currentUser')); // Store user info
|
||||
|
||||
// --- API Base URL ---
|
||||
// Use relative URL for API calls, assuming frontend and backend are on the same origin
|
||||
const API_BASE_URL = '/api';
|
||||
|
||||
// --- Utility Functions ---
|
||||
|
||||
// Function to display messages (error or success)
|
||||
const showMessage = (element, message, isError = true) => {
|
||||
element.textContent = message;
|
||||
element.className = isError ? 'error-message' : 'success-message'; // Use CSS classes
|
||||
element.classList.remove('hidden');
|
||||
// Optional: Auto-hide after a few seconds
|
||||
// setTimeout(() => element.classList.add('hidden'), 5000);
|
||||
};
|
||||
|
||||
// Function to hide messages
|
||||
const hideMessage = (element) => {
|
||||
element.classList.add('hidden');
|
||||
element.textContent = '';
|
||||
};
|
||||
|
||||
// Function to make authenticated API requests
|
||||
const fetchAPI = async (endpoint, options = {}) => {
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers, // Allow overriding headers
|
||||
};
|
||||
|
||||
// Add Authorization header if token exists
|
||||
if (authToken) {
|
||||
headers['Authorization'] = `Bearer ${authToken}`;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
|
||||
// Handle token expiration or invalid token
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
console.warn('Authentication error:', response.status);
|
||||
logout(); // Log out the user if token is invalid/expired
|
||||
throw new Error('Authentifizierung fehlgeschlagen oder Token abgelaufen.');
|
||||
}
|
||||
|
||||
// Check if response is OK (status code 200-299)
|
||||
if (!response.ok) {
|
||||
// Try to parse error message from response body
|
||||
let errorData;
|
||||
try {
|
||||
errorData = await response.json();
|
||||
} catch (parseError) {
|
||||
// If parsing fails, use status text
|
||||
errorData = { message: response.statusText };
|
||||
}
|
||||
console.error('API Error:', response.status, errorData);
|
||||
throw new Error(errorData.message || `HTTP-Fehler: ${response.status}`);
|
||||
}
|
||||
|
||||
// Handle responses with no content (e.g., DELETE 204)
|
||||
if (response.status === 204) {
|
||||
return null; // Or return a success indicator if needed
|
||||
}
|
||||
|
||||
// Parse JSON response body for other successful responses
|
||||
return await response.json();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Fetch API Error:', error);
|
||||
// Re-throw the error so it can be caught by the calling function
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// --- Authentication Functions ---
|
||||
|
||||
const handleLogin = async (event) => {
|
||||
event.preventDefault(); // Prevent default form submission
|
||||
hideMessage(loginError); // Hide previous errors
|
||||
|
||||
const username = loginUsernameInput.value.trim();
|
||||
const password = loginPasswordInput.value.trim();
|
||||
|
||||
if (!username || !password) {
|
||||
showMessage(loginError, 'Bitte Benutzername und Passwort eingeben.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await fetchAPI('/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
|
||||
if (data.token && data.user) {
|
||||
// Check if the logged-in user has the 'admin' role
|
||||
if (data.user.role !== 'admin') {
|
||||
showMessage(loginError, 'Zugriff verweigert. Nur Administratoren können sich hier anmelden.');
|
||||
return; // Prevent non-admins from logging in to the admin panel
|
||||
}
|
||||
|
||||
// Store token and user info
|
||||
authToken = data.token;
|
||||
currentUser = data.user;
|
||||
localStorage.setItem('authToken', authToken);
|
||||
localStorage.setItem('currentUser', JSON.stringify(currentUser));
|
||||
|
||||
// Update UI
|
||||
updateUIBasedOnAuthState();
|
||||
loadTournaments(); // Load initial data for admin
|
||||
|
||||
// Clear form
|
||||
loginForm.reset();
|
||||
|
||||
} else {
|
||||
// Should be caught by fetchAPI, but as a fallback
|
||||
showMessage(loginError, data.message || 'Login fehlgeschlagen.');
|
||||
}
|
||||
} catch (error) {
|
||||
showMessage(loginError, `Login fehlgeschlagen: ${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
authToken = null;
|
||||
currentUser = null;
|
||||
localStorage.removeItem('authToken');
|
||||
localStorage.removeItem('currentUser');
|
||||
updateUIBasedOnAuthState();
|
||||
// Clear any sensitive data displayed on the page
|
||||
tournamentList.innerHTML = ''; // Clear tournament list
|
||||
// Add similar clearing for other sections if needed
|
||||
};
|
||||
|
||||
// --- UI Update Functions ---
|
||||
|
||||
const updateUIBasedOnAuthState = () => {
|
||||
if (authToken && currentUser && currentUser.role === 'admin') {
|
||||
// Logged in as Admin
|
||||
loginSection.classList.add('hidden');
|
||||
adminContent.classList.remove('hidden');
|
||||
logoutButton.classList.remove('hidden');
|
||||
welcomeMessage.textContent = `Willkommen, ${currentUser.username}! (Admin)`;
|
||||
welcomeMessage.classList.remove('hidden');
|
||||
} else {
|
||||
// Logged out or not an Admin
|
||||
loginSection.classList.remove('hidden');
|
||||
adminContent.classList.add('hidden');
|
||||
logoutButton.classList.add('hidden');
|
||||
welcomeMessage.classList.add('hidden');
|
||||
// If logged in but not admin, show appropriate message
|
||||
if (currentUser && currentUser.role !== 'admin') {
|
||||
showMessage(loginError, 'Sie sind angemeldet, haben aber keine Admin-Berechtigung für diese Seite.');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// --- Tournament Functions ---
|
||||
|
||||
// Reset and hide the tournament form
|
||||
const resetAndHideTournamentForm = () => {
|
||||
tournamentForm.reset(); // Clear form fields
|
||||
tournamentIdInput.value = ''; // Clear hidden ID field
|
||||
tournamentFormTitle.textContent = 'Neues Turnier hinzufügen'; // Reset title
|
||||
saveTournamentButton.textContent = 'Speichern'; // Reset button text
|
||||
hideMessage(tournamentFormError); // Hide any previous form errors
|
||||
tournamentForm.classList.add('hidden'); // Hide the form
|
||||
};
|
||||
|
||||
// Show the tournament form for adding or editing
|
||||
const showTournamentForm = (tournament = null) => {
|
||||
resetAndHideTournamentForm(); // Start clean
|
||||
|
||||
if (tournament) {
|
||||
// Editing existing tournament
|
||||
tournamentFormTitle.textContent = 'Turnier bearbeiten';
|
||||
saveTournamentButton.textContent = 'Änderungen speichern';
|
||||
tournamentIdInput.value = tournament.tournament_id;
|
||||
tournamentNameInput.value = tournament.name || '';
|
||||
// Format date correctly for input type="date" (YYYY-MM-DD)
|
||||
tournamentDateInput.value = tournament.date ? tournament.date.split('T')[0] : '';
|
||||
tournamentLocationInput.value = tournament.location || '';
|
||||
tournamentTypeInput.value = tournament.tournament_type || 'knockout';
|
||||
tournamentGameTypeInput.value = tournament.game_type || '11_points';
|
||||
tournamentDescriptionInput.value = tournament.description || '';
|
||||
// Populate other fields as needed (max_players, status, etc.)
|
||||
} else {
|
||||
// Adding new tournament - title and button text are already set by reset
|
||||
}
|
||||
|
||||
tournamentForm.classList.remove('hidden'); // Show the form
|
||||
tournamentNameInput.focus(); // Focus the first field
|
||||
};
|
||||
|
||||
// Load tournaments from the API and display them
|
||||
const loadTournaments = async () => {
|
||||
if (!authToken) return; // Don't load if not logged in
|
||||
|
||||
loadingTournaments.classList.remove('hidden');
|
||||
tournamentList.innerHTML = ''; // Clear existing list
|
||||
hideMessage(tournamentListMessage);
|
||||
|
||||
try {
|
||||
const tournaments = await fetchAPI('/tournaments');
|
||||
|
||||
if (tournaments.length === 0) {
|
||||
tournamentList.innerHTML = '<tr><td colspan="6">Keine Turniere gefunden.</td></tr>';
|
||||
} else {
|
||||
tournaments.forEach(tournament => {
|
||||
const row = tournamentList.insertRow();
|
||||
row.dataset.id = tournament.tournament_id; // Store ID for easier access
|
||||
|
||||
// Format date for display (DD.MM.YYYY or leave empty)
|
||||
const displayDate = tournament.date ? new Date(tournament.date).toLocaleDateString('de-DE') : '-';
|
||||
|
||||
row.innerHTML = `
|
||||
<td>${tournament.name || '-'}</td>
|
||||
<td>${displayDate}</td>
|
||||
<td>${tournament.location || '-'}</td>
|
||||
<td>${tournament.tournament_type === 'knockout' ? 'KO-System' : 'Gruppenphase'}</td>
|
||||
<td>${tournament.status || 'Geplant'}</td>
|
||||
<td>
|
||||
<button class="edit-tournament">Bearbeiten</button>
|
||||
<button class="delete-tournament danger">Löschen</button>
|
||||
</td>
|
||||
`;
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
showMessage(tournamentListMessage, `Fehler beim Laden der Turniere: ${error.message}`);
|
||||
tournamentList.innerHTML = `<tr><td colspan="6">Fehler beim Laden der Daten.</td></tr>`; // Indicate error in table
|
||||
} finally {
|
||||
loadingTournaments.classList.add('hidden');
|
||||
}
|
||||
};
|
||||
|
||||
// Handle saving (creating or updating) a tournament
|
||||
const handleSaveTournament = async (event) => {
|
||||
event.preventDefault();
|
||||
hideMessage(tournamentFormError);
|
||||
|
||||
const tournamentId = tournamentIdInput.value;
|
||||
const isEditing = !!tournamentId; // Check if we are editing
|
||||
|
||||
// Gather data from form
|
||||
const tournamentData = {
|
||||
name: tournamentNameInput.value.trim(),
|
||||
date: tournamentDateInput.value || null, // Send null if empty
|
||||
location: tournamentLocationInput.value.trim() || null,
|
||||
tournament_type: tournamentTypeInput.value,
|
||||
game_type: tournamentGameTypeInput.value,
|
||||
description: tournamentDescriptionInput.value.trim() || null,
|
||||
// Add other fields (max_players, status) here if they are in the form
|
||||
};
|
||||
|
||||
// Basic validation
|
||||
if (!tournamentData.name) {
|
||||
showMessage(tournamentFormError, 'Turniername ist erforderlich.');
|
||||
return;
|
||||
}
|
||||
|
||||
const method = isEditing ? 'PUT' : 'POST';
|
||||
const endpoint = isEditing ? `/tournaments/${tournamentId}` : '/tournaments';
|
||||
|
||||
try {
|
||||
const savedTournament = await fetchAPI(endpoint, {
|
||||
method: method,
|
||||
body: JSON.stringify(tournamentData),
|
||||
});
|
||||
|
||||
showMessage(tournamentListMessage, `Turnier erfolgreich ${isEditing ? 'aktualisiert' : 'gespeichert'}.`, false); // Success message
|
||||
resetAndHideTournamentForm();
|
||||
loadTournaments(); // Refresh the list
|
||||
|
||||
} catch (error) {
|
||||
showMessage(tournamentFormError, `Fehler beim Speichern des Turniers: ${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle deleting a tournament
|
||||
const handleDeleteTournament = async (tournamentId, tournamentName) => {
|
||||
if (!tournamentId) return;
|
||||
|
||||
// Confirmation dialog
|
||||
if (!confirm(`Möchten Sie das Turnier "${tournamentName}" wirklich löschen? Alle zugehörigen Spiele und Daten gehen verloren!`)) {
|
||||
return; // User cancelled
|
||||
}
|
||||
|
||||
hideMessage(tournamentListMessage);
|
||||
|
||||
try {
|
||||
await fetchAPI(`/tournaments/${tournamentId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
showMessage(tournamentListMessage, `Turnier "${tournamentName}" erfolgreich gelöscht.`, false); // Success message
|
||||
loadTournaments(); // Refresh the list
|
||||
|
||||
} catch (error) {
|
||||
showMessage(tournamentListMessage, `Fehler beim Löschen des Turniers: ${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// --- Event Listeners ---
|
||||
|
||||
// Login form submission
|
||||
loginForm.addEventListener('submit', handleLogin);
|
||||
|
||||
// Logout button click
|
||||
logoutButton.addEventListener('click', logout);
|
||||
|
||||
// Show "Add Tournament" form button
|
||||
showAddTournamentFormButton.addEventListener('click', () => showTournamentForm());
|
||||
|
||||
// Cancel button in tournament form
|
||||
cancelTournamentButton.addEventListener('click', resetAndHideTournamentForm);
|
||||
|
||||
// Tournament form submission (Save/Update)
|
||||
tournamentForm.addEventListener('submit', handleSaveTournament);
|
||||
|
||||
// Event delegation for Edit and Delete buttons in the tournament list
|
||||
tournamentList.addEventListener('click', async (event) => {
|
||||
const target = event.target;
|
||||
const row = target.closest('tr'); // Find the table row
|
||||
if (!row) return; // Click wasn't inside a row
|
||||
|
||||
const tournamentId = row.dataset.id;
|
||||
if (!tournamentId) return; // Row doesn't have an ID
|
||||
|
||||
if (target.classList.contains('edit-tournament')) {
|
||||
// Edit button clicked
|
||||
hideMessage(tournamentListMessage); // Hide list messages when editing
|
||||
try {
|
||||
// Fetch the specific tournament data to pre-fill the form accurately
|
||||
const tournamentToEdit = await fetchAPI(`/tournaments/${tournamentId}`);
|
||||
showTournamentForm(tournamentToEdit);
|
||||
} catch (error) {
|
||||
showMessage(tournamentListMessage, `Fehler beim Laden der Turnierdaten zum Bearbeiten: ${error.message}`);
|
||||
}
|
||||
} else if (target.classList.contains('delete-tournament')) {
|
||||
// Delete button clicked
|
||||
const tournamentName = row.cells[0].textContent; // Get name from the first cell
|
||||
handleDeleteTournament(tournamentId, tournamentName);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// --- Initial Load ---
|
||||
|
||||
// Check authentication state and update UI on page load
|
||||
updateUIBasedOnAuthState();
|
||||
|
||||
// Load initial data if logged in as admin
|
||||
if (authToken && currentUser && currentUser.role === 'admin') {
|
||||
loadTournaments();
|
||||
// Load other data (players, users) here as well
|
||||
}
|
311
public/referee.html
Normal file
311
public/referee.html
Normal file
@ -0,0 +1,311 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Schiedsrichter-Bereich</title>
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<nav>
|
||||
<a href="/admin.html">Admin</a>
|
||||
<a href="/referee.html" class="active">Schiedsrichter</a>
|
||||
<a href="/spectator.html">Zuschauer</a>
|
||||
<a href="#" id="logout-button" style="float: right;" class="hidden">Logout</a>
|
||||
</nav>
|
||||
|
||||
<div class="container">
|
||||
<h1>Schiedsrichter-Bereich</h1>
|
||||
<p id="welcome-message" class="hidden">Willkommen!</p>
|
||||
|
||||
<div id="login-section" class="auth-form">
|
||||
<h2>Login (Schiedsrichter)</h2>
|
||||
<form id="login-form">
|
||||
<div>
|
||||
<label for="login-username">Benutzername:</label>
|
||||
<input type="text" id="login-username" required>
|
||||
</div>
|
||||
<div>
|
||||
<label for="login-password">Passwort:</label>
|
||||
<input type="password" id="login-password" required>
|
||||
</div>
|
||||
<button type="submit">Login</button>
|
||||
<div id="login-error" class="error-message hidden"></div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div id="referee-content" class="hidden">
|
||||
<section class="content-section">
|
||||
<h2>Spiel auswählen & Ergebnisse eintragen</h2>
|
||||
<p>Hier kann der Schiedsrichter ein ihm zugewiesenes Spiel auswählen und die Satzergebnisse eintragen.</p>
|
||||
|
||||
<div id="match-selection">
|
||||
<label for="select-match">Spiel auswählen:</label>
|
||||
<select id="select-match" disabled>
|
||||
<option value="">-- Bitte Spiel wählen --</option>
|
||||
</select>
|
||||
<p id="loading-matches" class="hidden">Lade Spiele...</p>
|
||||
</div>
|
||||
|
||||
<div id="score-entry" class="hidden">
|
||||
<h3>Ergebnisse für Spiel: <span id="match-details"></span></h3>
|
||||
<form id="score-form">
|
||||
<div id="set-inputs"></div>
|
||||
<button type="button" id="add-set-button">Satz hinzufügen</button>
|
||||
<button type="submit">Ergebnis speichern</button>
|
||||
<div id="score-error" class="error-message hidden"></div>
|
||||
<div id="score-success" class="success-message hidden"></div>
|
||||
</form>
|
||||
</div>
|
||||
<p><i>(Funktionalität zum Laden von Spielen und Speichern von Ergebnissen muss noch implementiert werden.)</i></p>
|
||||
</section>
|
||||
</div> </div> <script>
|
||||
// Basic JS for Referee Login/Logout (similar to admin.js but checks for 'referee' or 'admin' role)
|
||||
|
||||
const loginSectionRef = document.getElementById('login-section');
|
||||
const refereeContent = document.getElementById('referee-content');
|
||||
const loginFormRef = document.getElementById('login-form');
|
||||
const loginUsernameInputRef = document.getElementById('login-username');
|
||||
const loginPasswordInputRef = document.getElementById('login-password');
|
||||
const loginErrorRef = document.getElementById('login-error');
|
||||
const logoutButtonRef = document.getElementById('logout-button');
|
||||
const welcomeMessageRef = document.getElementById('welcome-message');
|
||||
const selectMatch = document.getElementById('select-match');
|
||||
const scoreEntry = document.getElementById('score-entry');
|
||||
const matchDetails = document.getElementById('match-details');
|
||||
const setInputs = document.getElementById('set-inputs');
|
||||
const addSetButton = document.getElementById('add-set-button');
|
||||
const scoreForm = document.getElementById('score-form');
|
||||
const scoreError = document.getElementById('score-error');
|
||||
const scoreSuccess = document.getElementById('score-success');
|
||||
const loadingMatches = document.getElementById('loading-matches');
|
||||
|
||||
|
||||
let authTokenRef = localStorage.getItem('authToken');
|
||||
let currentUserRef = JSON.parse(localStorage.getItem('currentUser'));
|
||||
const API_BASE_URL_REF = '/api'; // Consistent API base
|
||||
|
||||
const showMessageRef = (element, message, isError = true) => {
|
||||
element.textContent = message;
|
||||
element.className = isError ? 'error-message' : 'success-message';
|
||||
element.classList.remove('hidden');
|
||||
};
|
||||
|
||||
const hideMessageRef = (element) => {
|
||||
element.classList.add('hidden');
|
||||
element.textContent = '';
|
||||
};
|
||||
|
||||
|
||||
const fetchAPIRef = async (endpoint, options = {}) => {
|
||||
// Reusing fetchAPI logic requires it to be in a shared file or duplicated.
|
||||
// For simplicity here, we assume a similar function exists or reuse admin.js's if loaded globally (not recommended).
|
||||
// Here's a simplified version for demonstration:
|
||||
const headers = { 'Content-Type': 'application/json', ...options.headers };
|
||||
if (authTokenRef) {
|
||||
headers['Authorization'] = `Bearer ${authTokenRef}`;
|
||||
}
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL_REF}${endpoint}`, { ...options, headers });
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
logoutRef();
|
||||
throw new Error('Authentifizierung fehlgeschlagen.');
|
||||
}
|
||||
if (!response.ok) {
|
||||
let errorData;
|
||||
try { errorData = await response.json(); } catch { errorData = { message: response.statusText }; }
|
||||
throw new Error(errorData.message || `HTTP Error: ${response.status}`);
|
||||
}
|
||||
if (response.status === 204) return null;
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('API Fetch Error (Referee):', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const handleLoginRef = async (event) => {
|
||||
event.preventDefault();
|
||||
hideMessageRef(loginErrorRef);
|
||||
const username = loginUsernameInputRef.value.trim();
|
||||
const password = loginPasswordInputRef.value.trim();
|
||||
|
||||
if (!username || !password) {
|
||||
showMessageRef(loginErrorRef, 'Bitte Benutzername und Passwort eingeben.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await fetchAPIRef('/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
|
||||
// Allow 'admin' or 'referee' role
|
||||
if (data.token && data.user && (data.user.role === 'referee' || data.user.role === 'admin')) {
|
||||
authTokenRef = data.token;
|
||||
currentUserRef = data.user;
|
||||
localStorage.setItem('authToken', authTokenRef);
|
||||
localStorage.setItem('currentUser', JSON.stringify(currentUserRef));
|
||||
updateUIRef();
|
||||
loadRefereeMatches(); // Load matches for the referee
|
||||
loginFormRef.reset();
|
||||
} else if (data.user && data.user.role !== 'referee' && data.user.role !== 'admin') {
|
||||
showMessageRef(loginErrorRef, 'Zugriff verweigert. Nur Schiedsrichter oder Admins können sich hier anmelden.');
|
||||
} else {
|
||||
showMessageRef(loginErrorRef, data.message || 'Login fehlgeschlagen.');
|
||||
}
|
||||
} catch (error) {
|
||||
showMessageRef(loginErrorRef, `Login fehlgeschlagen: ${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
const logoutRef = () => {
|
||||
authTokenRef = null;
|
||||
currentUserRef = null;
|
||||
localStorage.removeItem('authToken');
|
||||
localStorage.removeItem('currentUser');
|
||||
updateUIRef();
|
||||
// Clear referee specific data
|
||||
selectMatch.innerHTML = '<option value="">-- Bitte Spiel wählen --</option>';
|
||||
selectMatch.disabled = true;
|
||||
scoreEntry.classList.add('hidden');
|
||||
};
|
||||
|
||||
const updateUIRef = () => {
|
||||
if (authTokenRef && currentUserRef && (currentUserRef.role === 'referee' || currentUserRef.role === 'admin')) {
|
||||
loginSectionRef.classList.add('hidden');
|
||||
refereeContent.classList.remove('hidden');
|
||||
logoutButtonRef.classList.remove('hidden');
|
||||
welcomeMessageRef.textContent = `Willkommen, ${currentUserRef.username}! (${currentUserRef.role})`;
|
||||
welcomeMessageRef.classList.remove('hidden');
|
||||
} else {
|
||||
loginSectionRef.classList.remove('hidden');
|
||||
refereeContent.classList.add('hidden');
|
||||
logoutButtonRef.classList.add('hidden');
|
||||
welcomeMessageRef.classList.add('hidden');
|
||||
if (currentUserRef) { // Logged in but wrong role
|
||||
showMessageRef(loginErrorRef, 'Sie sind angemeldet, haben aber keine Schiedsrichter-Berechtigung für diese Seite.');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// --- Referee Specific Functions (Placeholders) ---
|
||||
|
||||
const loadRefereeMatches = async () => {
|
||||
// TODO: Implement API call to fetch matches assigned to this referee (or all ongoing matches if admin)
|
||||
console.log("Lade Spiele für Schiedsrichter...");
|
||||
loadingMatches.classList.remove('hidden');
|
||||
selectMatch.disabled = true;
|
||||
try {
|
||||
// Example: Fetch matches (needs backend endpoint)
|
||||
// const matches = await fetchAPIRef('/matches?status=ongoing&assignee=me'); // Fictional endpoint
|
||||
const matches = []; // Placeholder
|
||||
|
||||
selectMatch.innerHTML = '<option value="">-- Bitte Spiel wählen --</option>'; // Reset
|
||||
if (matches.length === 0) {
|
||||
selectMatch.innerHTML += '<option value="" disabled>Keine aktiven Spiele gefunden</option>';
|
||||
} else {
|
||||
matches.forEach(match => {
|
||||
const option = document.createElement('option');
|
||||
option.value = match.match_id;
|
||||
// Construct a readable match description
|
||||
option.textContent = `Runde ${match.round || '?'} - Tisch ${match.table_number || '?'} (${match.player1_name || 'N/A'} vs ${match.player2_name || 'N/A'})`;
|
||||
selectMatch.appendChild(option);
|
||||
});
|
||||
selectMatch.disabled = false;
|
||||
}
|
||||
showMessageRef(document.getElementById('match-selection'), 'TODO: Lade zugewiesene/aktuelle Spiele.', false);
|
||||
|
||||
} catch (error) {
|
||||
showMessageRef(document.getElementById('match-selection'), `Fehler beim Laden der Spiele: ${error.message}`);
|
||||
} finally {
|
||||
loadingMatches.classList.add('hidden');
|
||||
}
|
||||
};
|
||||
|
||||
const displayScoreEntryForm = (matchId) => {
|
||||
// TODO: Fetch current scores for the selected match if they exist
|
||||
console.log("Zeige Formular für Match ID:", matchId);
|
||||
hideMessageRef(scoreError);
|
||||
hideMessageRef(scoreSuccess);
|
||||
|
||||
// Placeholder: Display match identifier
|
||||
matchDetails.textContent = `ID ${matchId.substring(0, 8)}...`; // Show partial ID
|
||||
|
||||
// TODO: Dynamically create input fields based on game type (best of 3/5 sets) and current scores
|
||||
setInputs.innerHTML = `
|
||||
<div>Satz 1: <input type="number" min="0" placeholder="P1"> - <input type="number" min="0" placeholder="P2"></div>
|
||||
<div>Satz 2: <input type="number" min="0" placeholder="P1"> - <input type="number" min="0" placeholder="P2"></div>
|
||||
<div>Satz 3: <input type="number" min="0" placeholder="P1"> - <input type="number" min="0" placeholder="P2"></div>
|
||||
`;
|
||||
scoreEntry.classList.remove('hidden');
|
||||
};
|
||||
|
||||
const handleAddSet = () => {
|
||||
// TODO: Add another row of set score inputs
|
||||
const setNumber = setInputs.children.length + 1;
|
||||
const div = document.createElement('div');
|
||||
div.innerHTML = `Satz ${setNumber}: <input type="number" min="0" placeholder="P1"> - <input type="number" min="0" placeholder="P2">`;
|
||||
setInputs.appendChild(div);
|
||||
};
|
||||
|
||||
const handleSaveScore = async (event) => {
|
||||
event.preventDefault();
|
||||
const matchId = selectMatch.value;
|
||||
if (!matchId) return;
|
||||
|
||||
hideMessageRef(scoreError);
|
||||
hideMessageRef(scoreSuccess);
|
||||
|
||||
// TODO: Collect scores from input fields
|
||||
const scores = [];
|
||||
const setDivs = setInputs.querySelectorAll('div');
|
||||
setDivs.forEach((div, index) => {
|
||||
const inputs = div.querySelectorAll('input[type="number"]');
|
||||
const score1 = inputs[0].value;
|
||||
const score2 = inputs[1].value;
|
||||
// Basic validation: Add scores only if both are entered
|
||||
if (score1 !== '' && score2 !== '') {
|
||||
scores.push({ set_number: index + 1, player1_score: parseInt(score1), player2_score: parseInt(score2) });
|
||||
}
|
||||
});
|
||||
|
||||
console.log("Speichere Ergebnis für Match", matchId, scores);
|
||||
|
||||
try {
|
||||
// TODO: Implement API call to save scores
|
||||
// await fetchAPIRef(`/matches/${matchId}/score`, { method: 'POST', body: JSON.stringify({ sets: scores }) });
|
||||
showMessageRef(scoreSuccess, 'Ergebnis erfolgreich gespeichert (TODO: Implement API Call).', false);
|
||||
// Optionally: Reload matches or update status
|
||||
} catch (error) {
|
||||
showMessageRef(scoreError, `Fehler beim Speichern: ${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// --- Event Listeners ---
|
||||
loginFormRef.addEventListener('submit', handleLoginRef);
|
||||
logoutButtonRef.addEventListener('click', logoutRef);
|
||||
selectMatch.addEventListener('change', (event) => {
|
||||
const selectedMatchId = event.target.value;
|
||||
if (selectedMatchId) {
|
||||
displayScoreEntryForm(selectedMatchId);
|
||||
} else {
|
||||
scoreEntry.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
addSetButton.addEventListener('click', handleAddSet);
|
||||
scoreForm.addEventListener('submit', handleSaveScore);
|
||||
|
||||
|
||||
// --- Initial Load ---
|
||||
updateUIRef();
|
||||
if (authTokenRef && currentUserRef && (currentUserRef.role === 'referee' || currentUserRef.role === 'admin')) {
|
||||
loadRefereeMatches();
|
||||
}
|
||||
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
235
public/spectator.html
Normal file
235
public/spectator.html
Normal file
@ -0,0 +1,235 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Turnierübersicht - Zuschauer</title>
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<nav>
|
||||
<a href="/admin.html">Admin</a>
|
||||
<a href="/referee.html">Schiedsrichter</a>
|
||||
<a href="/spectator.html" class="active">Zuschauer</a>
|
||||
</nav>
|
||||
|
||||
<div class="container">
|
||||
<h1>Turnierübersicht</h1>
|
||||
|
||||
<section class="content-section">
|
||||
<h2>Turnier auswählen</h2>
|
||||
<label for="select-tournament">Aktuelles/Vergangenes Turnier:</label>
|
||||
<select id="select-tournament" disabled>
|
||||
<option value="">-- Bitte Turnier wählen --</option>
|
||||
</select>
|
||||
<p id="loading-tournaments-spectator" class="hidden">Lade Turniere...</p>
|
||||
<div id="tournament-info" class="hidden">
|
||||
<h3 id="selected-tournament-name"></h3>
|
||||
<p id="selected-tournament-details"></p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div id="tournament-display" class="hidden">
|
||||
|
||||
<section class="content-section">
|
||||
<h2>Live Spiele / Aktuelle Runde</h2>
|
||||
<div id="live-matches-list">
|
||||
<p><i>Live-Spiele werden hier angezeigt (Implementierung erforderlich).</i></p>
|
||||
</div>
|
||||
<p id="loading-live-matches" class="hidden">Lade Live-Spiele...</p>
|
||||
</section>
|
||||
|
||||
<section class="content-section">
|
||||
<h2>Turnierbaum / Gruppenphase</h2>
|
||||
<div id="bracket-groups-visualization">
|
||||
<p><i>Visualisierung des Turnierbaums oder der Gruppenphase wird hier angezeigt (Implementierung erforderlich).</i></p>
|
||||
</div>
|
||||
<p id="loading-bracket" class="hidden">Lade Turnierbaum/Gruppen...</p>
|
||||
</section>
|
||||
|
||||
<section class="content-section">
|
||||
<h2>Alle Spiele (Zeitplan)</h2>
|
||||
<div id="all-matches-list">
|
||||
<p><i>Liste aller vergangenen und zukünftigen Spiele wird hier angezeigt (Implementierung erforderlich).</i></p>
|
||||
</div>
|
||||
<p id="loading-all-matches" class="hidden">Lade alle Spiele...</p>
|
||||
</section>
|
||||
|
||||
<section class="content-section">
|
||||
<h2>Spieler & Statistiken</h2>
|
||||
<div>
|
||||
<label for="filter-player">Spieler suchen/filtern:</label>
|
||||
<input type="text" id="filter-player" placeholder="Spielername eingeben...">
|
||||
</div>
|
||||
<div id="player-list">
|
||||
<p><i>Spielerliste und Profile werden hier angezeigt (Implementierung erforderlich).</i></p>
|
||||
</div>
|
||||
<p id="loading-players" class="hidden">Lade Spieler...</p>
|
||||
</section>
|
||||
|
||||
<section class="content-section">
|
||||
<h2>Hinweise & Benachrichtigungen</h2>
|
||||
<div id="notifications-area">
|
||||
<p><i>Wichtige Hinweise oder Push-Benachrichtigungen werden hier angezeigt (Implementierung erforderlich).</i></p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div> <div id="spectator-error" class="error-message hidden"></div>
|
||||
|
||||
</div> <script>
|
||||
// Basic JS for Spectator View
|
||||
|
||||
// --- DOM Elements ---
|
||||
const selectTournament = document.getElementById('select-tournament');
|
||||
const loadingTournamentsSpectator = document.getElementById('loading-tournaments-spectator');
|
||||
const tournamentInfo = document.getElementById('tournament-info');
|
||||
const selectedTournamentName = document.getElementById('selected-tournament-name');
|
||||
const selectedTournamentDetails = document.getElementById('selected-tournament-details');
|
||||
const tournamentDisplay = document.getElementById('tournament-display');
|
||||
const spectatorError = document.getElementById('spectator-error');
|
||||
// Add other display area elements (live matches list, bracket viz, etc.)
|
||||
|
||||
const API_BASE_URL_SPEC = '/api'; // Use relative path
|
||||
|
||||
const showMessageSpec = (element, message, isError = true) => {
|
||||
element.textContent = message;
|
||||
element.className = isError ? 'error-message' : 'success-message';
|
||||
element.classList.remove('hidden');
|
||||
};
|
||||
|
||||
const hideMessageSpec = (element) => {
|
||||
element.classList.add('hidden');
|
||||
element.textContent = '';
|
||||
};
|
||||
|
||||
|
||||
// --- Functions ---
|
||||
|
||||
// Fetch API (Simplified - assumes public endpoints or uses admin token if available for testing)
|
||||
// IMPORTANT: Spectator view should ideally hit public, non-authenticated endpoints.
|
||||
// For now, it might rely on the admin/referee being logged in in another tab,
|
||||
// or backend needs specific public routes. Let's assume public routes exist or reuse token if present.
|
||||
const fetchAPISpec = async (endpoint, options = {}) => {
|
||||
const headers = { 'Content-Type': 'application/json', ...options.headers };
|
||||
// Spectator view ideally shouldn't need a token, but we add it if present for now
|
||||
const token = localStorage.getItem('authToken');
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL_SPEC}${endpoint}`, { ...options, headers });
|
||||
if (!response.ok) {
|
||||
let errorData;
|
||||
try { errorData = await response.json(); } catch { errorData = { message: response.statusText }; }
|
||||
// Don't auto-logout spectator on auth errors
|
||||
throw new Error(errorData.message || `HTTP Error: ${response.status}`);
|
||||
}
|
||||
if (response.status === 204) return null;
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('API Fetch Error (Spectator):', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// Load available tournaments into the dropdown
|
||||
const loadAvailableTournaments = async () => {
|
||||
loadingTournamentsSpectator.classList.remove('hidden');
|
||||
selectTournament.disabled = true;
|
||||
hideMessageSpec(spectatorError);
|
||||
try {
|
||||
// Assuming /api/tournaments is accessible without strict auth for spectators
|
||||
// Or backend needs a specific public endpoint like /api/public/tournaments
|
||||
const tournaments = await fetchAPISpec('/tournaments'); // Using the authenticated route for now
|
||||
|
||||
selectTournament.innerHTML = '<option value="">-- Bitte Turnier wählen --</option>'; // Reset
|
||||
if (tournaments.length === 0) {
|
||||
selectTournament.innerHTML += '<option value="" disabled>Keine Turniere verfügbar</option>';
|
||||
} else {
|
||||
tournaments.forEach(t => {
|
||||
const option = document.createElement('option');
|
||||
option.value = t.tournament_id;
|
||||
option.textContent = `${t.name} (${t.date ? new Date(t.date).toLocaleDateString('de-DE') : 'Datum n.a.'})`;
|
||||
selectTournament.appendChild(option);
|
||||
});
|
||||
selectTournament.disabled = false;
|
||||
}
|
||||
} catch (error) {
|
||||
showMessageSpec(spectatorError, `Fehler beim Laden der Turniere: ${error.message}`);
|
||||
selectTournament.innerHTML += '<option value="" disabled>Fehler beim Laden</option>';
|
||||
} finally {
|
||||
loadingTournamentsSpectator.classList.add('hidden');
|
||||
}
|
||||
};
|
||||
|
||||
// Load details for the selected tournament
|
||||
const loadTournamentDetails = async (tournamentId) => {
|
||||
console.log("Lade Details für Turnier:", tournamentId);
|
||||
hideMessageSpec(spectatorError);
|
||||
tournamentInfo.classList.add('hidden');
|
||||
tournamentDisplay.classList.add('hidden'); // Hide match/player sections until loaded
|
||||
|
||||
// Show loading indicators for sections
|
||||
document.getElementById('loading-live-matches').classList.remove('hidden');
|
||||
document.getElementById('loading-bracket').classList.remove('hidden');
|
||||
document.getElementById('loading-all-matches').classList.remove('hidden');
|
||||
document.getElementById('loading-players').classList.remove('hidden');
|
||||
|
||||
|
||||
try {
|
||||
// 1. Fetch basic tournament info
|
||||
const tournament = await fetchAPISpec(`/tournaments/${tournamentId}`);
|
||||
selectedTournamentName.textContent = tournament.name;
|
||||
const dateStr = tournament.date ? new Date(tournament.date).toLocaleDateString('de-DE') : 'N/A';
|
||||
selectedTournamentDetails.textContent = `Ort: ${tournament.location || 'N/A'} | Datum: ${dateStr} | Typ: ${tournament.tournament_type}`;
|
||||
// TODO: Display description, logo etc.
|
||||
tournamentInfo.classList.remove('hidden');
|
||||
|
||||
// 2. Fetch related data (matches, players, etc.) - Implement these API calls
|
||||
// const liveMatches = await fetchAPISpec(`/matches?tournament=${tournamentId}&status=ongoing`);
|
||||
// const allMatches = await fetchAPISpec(`/matches?tournament=${tournamentId}`);
|
||||
// const players = await fetchAPISpec(`/players?tournament=${tournamentId}`); // Needs backend support
|
||||
// const bracketData = await fetchAPISpec(`/tournaments/${tournamentId}/bracket`); // Needs backend support
|
||||
|
||||
// TODO: Render the fetched data into the respective sections
|
||||
document.getElementById('live-matches-list').innerHTML = `<p><i>Live-Spiele für ${tournament.name} laden... (TODO)</i></p>`;
|
||||
document.getElementById('bracket-groups-visualization').innerHTML = `<p><i>Turnierbaum für ${tournament.name} laden... (TODO)</i></p>`;
|
||||
document.getElementById('all-matches-list').innerHTML = `<p><i>Alle Spiele für ${tournament.name} laden... (TODO)</i></p>`;
|
||||
document.getElementById('player-list').innerHTML = `<p><i>Spieler für ${tournament.name} laden... (TODO)</i></p>`;
|
||||
|
||||
|
||||
tournamentDisplay.classList.remove('hidden'); // Show the details sections
|
||||
|
||||
} catch (error) {
|
||||
showMessageSpec(spectatorError, `Fehler beim Laden der Turnierdetails: ${error.message}`);
|
||||
tournamentDisplay.classList.add('hidden'); // Hide details on error
|
||||
} finally {
|
||||
// Hide loading indicators
|
||||
document.getElementById('loading-live-matches').classList.add('hidden');
|
||||
document.getElementById('loading-bracket').classList.add('hidden');
|
||||
document.getElementById('loading-all-matches').classList.add('hidden');
|
||||
document.getElementById('loading-players').classList.add('hidden');
|
||||
}
|
||||
};
|
||||
|
||||
// --- Event Listeners ---
|
||||
selectTournament.addEventListener('change', (event) => {
|
||||
const selectedId = event.target.value;
|
||||
if (selectedId) {
|
||||
loadTournamentDetails(selectedId);
|
||||
} else {
|
||||
tournamentInfo.classList.add('hidden');
|
||||
tournamentDisplay.classList.add('hidden');
|
||||
hideMessageSpec(spectatorError);
|
||||
}
|
||||
});
|
||||
|
||||
// TODO: Add event listener for player filter input
|
||||
|
||||
// --- Initial Load ---
|
||||
loadAvailableTournaments();
|
||||
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
120
server.js
Normal file
120
server.js
Normal file
@ -0,0 +1,120 @@
|
||||
// Import necessary modules
|
||||
require('dotenv').config(); // Load environment variables from .env file first
|
||||
const express = require('express');
|
||||
const https = require('https');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const cors = require('cors');
|
||||
const morgan = require('morgan');
|
||||
const helmet = require('helmet');
|
||||
const { initializeDatabase } = require('./src/db/db');
|
||||
const { createInitialAdminUser } = require('./src/controllers/userController'); // Import function to create initial admin
|
||||
// const setupCronJobs = require('./src/utils/backupScheduler'); // Optional: For scheduled backups
|
||||
|
||||
// --- Middleware ---
|
||||
const errorHandler = require('./src/middleware/errorMiddleware');
|
||||
const { authenticateToken } = require('./src/middleware/authMiddleware'); // Import auth middleware
|
||||
|
||||
// --- Routers ---
|
||||
const authRoutes = require('./src/routes/authRoutes');
|
||||
const tournamentRoutes = require('./src/routes/tournamentRoutes');
|
||||
const playerRoutes = require('./src/routes/playerRoutes');
|
||||
const matchRoutes = require('./src/routes/matchRoutes');
|
||||
const userRoutes = require('./src/routes/userRoutes');
|
||||
// Add other routers as needed
|
||||
|
||||
// --- Configuration ---
|
||||
const PORT = process.env.PORT || 3000;
|
||||
|
||||
// --- Initialize Express App ---
|
||||
const app = express();
|
||||
|
||||
// --- Security Middleware ---
|
||||
app.use(helmet()); // Set various security HTTP headers
|
||||
app.use(cors()); // Enable Cross-Origin Resource Sharing
|
||||
|
||||
// --- Logging Middleware ---
|
||||
app.use(morgan('dev')); // Log HTTP requests in development format
|
||||
|
||||
// --- Body Parsing Middleware ---
|
||||
app.use(express.json()); // Parse JSON request bodies
|
||||
app.use(express.urlencoded({ extended: true })); // Parse URL-encoded request bodies
|
||||
|
||||
// --- Static Files ---
|
||||
// Serve static files (HTML, CSS, JS) from the 'public' directory
|
||||
app.use(express.static(path.join(__dirname, 'public')));
|
||||
|
||||
// --- API Routes ---
|
||||
app.use('/api/auth', authRoutes);
|
||||
// Apply authentication middleware to protected routes
|
||||
app.use('/api/tournaments', authenticateToken, tournamentRoutes);
|
||||
app.use('/api/players', authenticateToken, playerRoutes);
|
||||
app.use('/api/matches', authenticateToken, matchRoutes);
|
||||
app.use('/api/users', authenticateToken, userRoutes);
|
||||
// Add other protected API routes here
|
||||
|
||||
// --- Root Route (Optional Redirect or Info) ---
|
||||
app.get('/', (req, res) => {
|
||||
// Redirect to spectator view or show an info page
|
||||
res.redirect('/spectator.html');
|
||||
});
|
||||
|
||||
// --- Error Handling Middleware ---
|
||||
// This should be the last middleware added
|
||||
app.use(errorHandler);
|
||||
|
||||
// --- HTTPS Options ---
|
||||
// Load SSL certificate and key
|
||||
// Ensure key.pem and cert.pem are generated (e.g., using `npm run generate-cert`)
|
||||
let httpsOptions = {};
|
||||
try {
|
||||
httpsOptions = {
|
||||
key: fs.readFileSync(path.join(__dirname, 'key.pem')),
|
||||
cert: fs.readFileSync(path.join(__dirname, 'cert.pem')),
|
||||
};
|
||||
} catch (err) {
|
||||
console.error("---------------------------------------------------------");
|
||||
console.error("ERROR: Could not load SSL certificate files (key.pem, cert.pem).");
|
||||
console.error("Please generate them using 'npm run generate-cert'.");
|
||||
console.error("Falling back to HTTP (Not recommended for production).");
|
||||
console.error("---------------------------------------------------------");
|
||||
// Optionally exit if HTTPS is strictly required
|
||||
// process.exit(1);
|
||||
}
|
||||
|
||||
// --- Start Server ---
|
||||
const startServer = async () => {
|
||||
try {
|
||||
// Initialize Database (create tables if they don't exist)
|
||||
await initializeDatabase();
|
||||
console.log('Database initialized successfully.');
|
||||
|
||||
// Create initial admin user if no users exist
|
||||
await createInitialAdminUser();
|
||||
|
||||
// Optional: Schedule database backups
|
||||
// if (process.env.BACKUP_PATH && process.env.BACKUP_INTERVAL_MINUTES) {
|
||||
// setupCronJobs();
|
||||
// } else {
|
||||
// console.log('Database backup scheduling is not configured (BACKUP_PATH or BACKUP_INTERVAL_MINUTES missing in .env).');
|
||||
// }
|
||||
|
||||
// Start HTTPS server if certificates are loaded
|
||||
if (httpsOptions.key && httpsOptions.cert) {
|
||||
https.createServer(httpsOptions, app).listen(PORT, () => {
|
||||
console.log(`Server running in HTTPS mode on https://localhost:${PORT}`);
|
||||
});
|
||||
} else {
|
||||
// Fallback to HTTP if certificates are missing
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Server running in HTTP mode on http://localhost:${PORT}`);
|
||||
console.warn("WARNING: Running without HTTPS. Use 'npm run generate-cert' and ensure key.pem/cert.pem exist for HTTPS.");
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to start server:', error);
|
||||
process.exit(1); // Exit if server fails to start
|
||||
}
|
||||
};
|
||||
|
||||
startServer();
|
104
src/controllers/authController.js
Normal file
104
src/controllers/authController.js
Normal file
@ -0,0 +1,104 @@
|
||||
// src/controllers/authController.js
|
||||
const bcrypt = require('bcrypt');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const db = require('../db/db'); // Import database query function
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET;
|
||||
const SALT_ROUNDS = 10; // Cost factor for bcrypt hashing
|
||||
|
||||
// Register a new user
|
||||
exports.register = async (req, res, next) => {
|
||||
const { username, password, role } = req.body; // Role might be optional or restricted
|
||||
|
||||
// Basic validation
|
||||
if (!username || !password) {
|
||||
return res.status(400).json({ message: 'Benutzername und Passwort sind erforderlich.' });
|
||||
}
|
||||
|
||||
// TODO: Add more robust validation (e.g., password complexity, username format)
|
||||
|
||||
// Determine the role - default to 'spectator' if not provided or invalid
|
||||
// In a real scenario, only admins should be able to assign 'admin' or 'referee' roles.
|
||||
const validRoles = ['admin', 'referee', 'spectator'];
|
||||
const assignedRole = validRoles.includes(role) ? role : 'spectator';
|
||||
|
||||
// Check if user already exists
|
||||
try {
|
||||
const existingUser = await db.query('SELECT * FROM users WHERE username = $1', [username]);
|
||||
if (existingUser.rows.length > 0) {
|
||||
return res.status(409).json({ message: 'Benutzername bereits vergeben.' }); // Conflict
|
||||
}
|
||||
|
||||
// Hash the password
|
||||
const passwordHash = await bcrypt.hash(password, SALT_ROUNDS);
|
||||
|
||||
// Insert the new user into the database
|
||||
const newUser = await db.query(
|
||||
'INSERT INTO users (username, password_hash, role) VALUES ($1, $2, $3) RETURNING user_id, username, role, created_at',
|
||||
[username, passwordHash, assignedRole]
|
||||
);
|
||||
|
||||
res.status(201).json({
|
||||
message: 'Benutzer erfolgreich registriert.',
|
||||
user: newUser.rows[0],
|
||||
});
|
||||
} catch (error) {
|
||||
// Pass error to the central error handler
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
// Login a user
|
||||
exports.login = async (req, res, next) => {
|
||||
const { username, password } = req.body;
|
||||
|
||||
if (!username || !password) {
|
||||
return res.status(400).json({ message: 'Benutzername und Passwort sind erforderlich.' });
|
||||
}
|
||||
|
||||
try {
|
||||
// Find the user by username
|
||||
const result = await db.query('SELECT user_id, username, password_hash, role FROM users WHERE username = $1', [username]);
|
||||
const user = result.rows[0];
|
||||
|
||||
if (!user) {
|
||||
return res.status(401).json({ message: 'Ungültige Anmeldedaten.' }); // Unauthorized
|
||||
}
|
||||
|
||||
// Compare the provided password with the stored hash
|
||||
const isMatch = await bcrypt.compare(password, user.password_hash);
|
||||
|
||||
if (!isMatch) {
|
||||
return res.status(401).json({ message: 'Ungültige Anmeldedaten.' }); // Unauthorized
|
||||
}
|
||||
|
||||
// Passwords match, create JWT payload
|
||||
const payload = {
|
||||
userId: user.user_id,
|
||||
username: user.username,
|
||||
role: user.role,
|
||||
};
|
||||
|
||||
// Sign the JWT
|
||||
const token = jwt.sign(
|
||||
payload,
|
||||
JWT_SECRET,
|
||||
{ expiresIn: '1h' } // Token expires in 1 hour (adjust as needed)
|
||||
// Consider adding refresh tokens for better security/UX
|
||||
);
|
||||
|
||||
// Send the token back to the client
|
||||
res.json({
|
||||
message: 'Login erfolgreich.',
|
||||
token: token,
|
||||
user: { // Send back some user info (excluding password hash)
|
||||
userId: user.user_id,
|
||||
username: user.username,
|
||||
role: user.role
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
179
src/controllers/tournamentController.js
Normal file
179
src/controllers/tournamentController.js
Normal file
@ -0,0 +1,179 @@
|
||||
// src/controllers/tournamentController.js
|
||||
const db = require('../db/db');
|
||||
|
||||
// Get all tournaments
|
||||
exports.getAllTournaments = async (req, res, next) => {
|
||||
try {
|
||||
// Add filtering or pagination later if needed
|
||||
const result = await db.query('SELECT * FROM tournaments ORDER BY date DESC, created_at DESC');
|
||||
res.json(result.rows);
|
||||
} catch (error) {
|
||||
next(error); // Pass error to the error handler
|
||||
}
|
||||
};
|
||||
|
||||
// Get a single tournament by ID
|
||||
exports.getTournamentById = async (req, res, next) => {
|
||||
const { id } = req.params;
|
||||
try {
|
||||
const result = await db.query('SELECT * FROM tournaments WHERE tournament_id = $1', [id]);
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({ message: 'Turnier nicht gefunden.' });
|
||||
}
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
// Create a new tournament (Admin only)
|
||||
exports.createTournament = async (req, res, next) => {
|
||||
// Destructure expected fields from request body
|
||||
const {
|
||||
name,
|
||||
date,
|
||||
location,
|
||||
description,
|
||||
logo_url, // Handle logo upload separately if needed
|
||||
tournament_type,
|
||||
game_type,
|
||||
max_players,
|
||||
status
|
||||
} = req.body;
|
||||
|
||||
// Basic validation
|
||||
if (!name || !tournament_type || !game_type) {
|
||||
return res.status(400).json({ message: 'Name, Turnier-Typ und Spiel-Typ sind erforderlich.' });
|
||||
}
|
||||
|
||||
// Validate enum types
|
||||
const validTournamentTypes = ['knockout', 'group'];
|
||||
const validGameTypes = ['11_points', '21_points'];
|
||||
if (!validTournamentTypes.includes(tournament_type)) {
|
||||
return res.status(400).json({ message: `Ungültiger Turnier-Typ: ${tournament_type}` });
|
||||
}
|
||||
if (!validGameTypes.includes(game_type)) {
|
||||
return res.status(400).json({ message: `Ungültiger Spiel-Typ: ${game_type}` });
|
||||
}
|
||||
|
||||
// Get user ID from the authenticated request (set by authMiddleware)
|
||||
const created_by = req.user.userId;
|
||||
|
||||
try {
|
||||
const result = await db.query(
|
||||
`INSERT INTO tournaments (name, date, location, description, logo_url, tournament_type, game_type, max_players, status, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
RETURNING *`, // Return the newly created tournament
|
||||
[name, date || null, location, description, logo_url, tournament_type, game_type, max_players || null, status || 'planned', created_by]
|
||||
);
|
||||
res.status(201).json(result.rows[0]);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
// Update an existing tournament (Admin only)
|
||||
exports.updateTournament = async (req, res, next) => {
|
||||
const { id } = req.params;
|
||||
// Destructure fields that can be updated
|
||||
const {
|
||||
name,
|
||||
date,
|
||||
location,
|
||||
description,
|
||||
logo_url,
|
||||
tournament_type,
|
||||
game_type,
|
||||
max_players,
|
||||
status
|
||||
} = req.body;
|
||||
|
||||
// Build the update query dynamically based on provided fields
|
||||
const fieldsToUpdate = [];
|
||||
const values = [];
|
||||
let queryIndex = 1;
|
||||
|
||||
// Helper function to add field and value if value is provided
|
||||
const addUpdate = (field, value) => {
|
||||
if (value !== undefined) {
|
||||
// Validate enums if they are being updated
|
||||
if (field === 'tournament_type' && !['knockout', 'group'].includes(value)) {
|
||||
throw new Error(`Ungültiger Turnier-Typ: ${value}`);
|
||||
}
|
||||
if (field === 'game_type' && !['11_points', '21_points'].includes(value)) {
|
||||
throw new Error(`Ungültiger Spiel-Typ: ${value}`);
|
||||
}
|
||||
fieldsToUpdate.push(`${field} = $${queryIndex++}`);
|
||||
// Handle empty strings potentially being passed for nullable fields
|
||||
values.push(value === '' ? null : value);
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
addUpdate('name', name);
|
||||
addUpdate('date', date);
|
||||
addUpdate('location', location);
|
||||
addUpdate('description', description);
|
||||
addUpdate('logo_url', logo_url);
|
||||
addUpdate('tournament_type', tournament_type);
|
||||
addUpdate('game_type', game_type);
|
||||
addUpdate('max_players', max_players);
|
||||
addUpdate('status', status);
|
||||
|
||||
if (fieldsToUpdate.length === 0) {
|
||||
return res.status(400).json({ message: 'Keine Daten zum Aktualisieren angegeben.' });
|
||||
}
|
||||
|
||||
// Add the tournament ID for the WHERE clause
|
||||
values.push(id);
|
||||
|
||||
const updateQuery = `UPDATE tournaments SET ${fieldsToUpdate.join(', ')}, updated_at = CURRENT_TIMESTAMP WHERE tournament_id = $${queryIndex} RETURNING *`;
|
||||
|
||||
const result = await db.query(updateQuery, values);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({ message: 'Turnier nicht gefunden.' });
|
||||
}
|
||||
res.json(result.rows[0]);
|
||||
|
||||
} catch (error) {
|
||||
// Handle potential validation errors from the helper
|
||||
if (error.message.startsWith('Ungültiger')) {
|
||||
return res.status(400).json({ message: error.message });
|
||||
}
|
||||
next(error); // Pass other errors to the handler
|
||||
}
|
||||
};
|
||||
|
||||
// Delete a tournament (Admin only)
|
||||
exports.deleteTournament = async (req, res, next) => {
|
||||
const { id } = req.params;
|
||||
try {
|
||||
// ON DELETE CASCADE in the schema should handle related data (matches, tournament_players)
|
||||
const result = await db.query('DELETE FROM tournaments WHERE tournament_id = $1 RETURNING tournament_id', [id]);
|
||||
if (result.rowCount === 0) {
|
||||
return res.status(404).json({ message: 'Turnier nicht gefunden.' });
|
||||
}
|
||||
res.status(204).send(); // No Content, successful deletion
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
// --- Placeholder functions for other features ---
|
||||
|
||||
exports.importTournaments = async (req, res, next) => {
|
||||
// TODO: Implement CSV import logic using multer and csv-parser
|
||||
res.status(501).json({ message: 'Funktion noch nicht implementiert (Import).' });
|
||||
};
|
||||
|
||||
exports.exportTournaments = async (req, res, next) => {
|
||||
// TODO: Implement CSV export logic using fast-csv
|
||||
res.status(501).json({ message: 'Funktion noch nicht implementiert (Export).' });
|
||||
};
|
||||
|
||||
exports.addLogo = async (req, res, next) => {
|
||||
// TODO: Implement file upload logic using multer
|
||||
// Update the tournament's logo_url field
|
||||
res.status(501).json({ message: 'Funktion noch nicht implementiert (Logo Upload).' });
|
||||
};
|
174
src/controllers/userController.js
Normal file
174
src/controllers/userController.js
Normal file
@ -0,0 +1,174 @@
|
||||
// src/controllers/userController.js
|
||||
const db = require('../db/db');
|
||||
const bcrypt = require('bcrypt');
|
||||
|
||||
const SALT_ROUNDS = 10; // Must match authController
|
||||
|
||||
// Function to create the initial admin user if none exist
|
||||
exports.createInitialAdminUser = async () => {
|
||||
try {
|
||||
const result = await db.query('SELECT COUNT(*) FROM users');
|
||||
const userCount = parseInt(result.rows[0].count, 10);
|
||||
|
||||
if (userCount === 0) {
|
||||
console.log('No users found. Creating initial admin user...');
|
||||
const adminUsername = 'admin';
|
||||
const adminPassword = 'password'; // !!! CHANGE THIS IN PRODUCTION !!!
|
||||
const adminRole = 'admin';
|
||||
|
||||
const passwordHash = await bcrypt.hash(adminPassword, SALT_ROUNDS);
|
||||
|
||||
await db.query(
|
||||
'INSERT INTO users (username, password_hash, role) VALUES ($1, $2, $3)',
|
||||
[adminUsername, passwordHash, adminRole]
|
||||
);
|
||||
console.log(`Initial admin user created: username='${adminUsername}', password='${adminPassword}'.`);
|
||||
console.warn("!!! IMPORTANT: Change the default admin password immediately! !!!");
|
||||
} else {
|
||||
console.log('Users already exist in the database. Skipping initial admin creation.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error creating initial admin user:', error);
|
||||
// Decide if the application should exit or continue if this fails
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// --- User Management API Functions (Example) ---
|
||||
|
||||
// Get all users (Admin only)
|
||||
exports.getAllUsers = async (req, res, next) => {
|
||||
try {
|
||||
// Select relevant fields, exclude password_hash
|
||||
const result = await db.query('SELECT user_id, username, role, created_at FROM users ORDER BY username');
|
||||
res.json(result.rows);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
// Get a single user by ID (Admin only)
|
||||
exports.getUserById = async (req, res, next) => {
|
||||
const { id } = req.params;
|
||||
try {
|
||||
const result = await db.query('SELECT user_id, username, role, created_at FROM users WHERE user_id = $1', [id]);
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({ message: 'Benutzer nicht gefunden.' });
|
||||
}
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
// Create a new user (Admin only) - Similar to register but called by an admin
|
||||
exports.createUser = async (req, res, next) => {
|
||||
const { username, password, role } = req.body;
|
||||
|
||||
if (!username || !password || !role) {
|
||||
return res.status(400).json({ message: 'Benutzername, Passwort und Rolle sind erforderlich.' });
|
||||
}
|
||||
const validRoles = ['admin', 'referee', 'spectator'];
|
||||
if (!validRoles.includes(role)) {
|
||||
return res.status(400).json({ message: 'Ungültige Rolle angegeben.' });
|
||||
}
|
||||
|
||||
try {
|
||||
// Check if username exists
|
||||
const existingUser = await db.query('SELECT 1 FROM users WHERE username = $1', [username]);
|
||||
if (existingUser.rows.length > 0) {
|
||||
return res.status(409).json({ message: 'Benutzername bereits vergeben.' });
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(password, SALT_ROUNDS);
|
||||
const newUser = await db.query(
|
||||
'INSERT INTO users (username, password_hash, role) VALUES ($1, $2, $3) RETURNING user_id, username, role, created_at',
|
||||
[username, passwordHash, role]
|
||||
);
|
||||
res.status(201).json(newUser.rows[0]);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
// Update a user (Admin only) - e.g., change role or reset password
|
||||
exports.updateUser = async (req, res, next) => {
|
||||
const { id } = req.params;
|
||||
const { username, role, password } = req.body; // Allow updating username, role, password
|
||||
|
||||
// Basic validation
|
||||
if (!username && !role && !password) {
|
||||
return res.status(400).json({ message: 'Keine Daten zum Aktualisieren angegeben.' });
|
||||
}
|
||||
|
||||
const fieldsToUpdate = [];
|
||||
const values = [];
|
||||
let queryIndex = 1;
|
||||
|
||||
if (username) {
|
||||
fieldsToUpdate.push(`username = $${queryIndex++}`);
|
||||
values.push(username);
|
||||
}
|
||||
if (role) {
|
||||
const validRoles = ['admin', 'referee', 'spectator'];
|
||||
if (!validRoles.includes(role)) {
|
||||
return res.status(400).json({ message: 'Ungültige Rolle angegeben.' });
|
||||
}
|
||||
fieldsToUpdate.push(`role = $${queryIndex++}`);
|
||||
values.push(role);
|
||||
}
|
||||
if (password) {
|
||||
// Hash new password if provided
|
||||
try {
|
||||
const passwordHash = await bcrypt.hash(password, SALT_ROUNDS);
|
||||
fieldsToUpdate.push(`password_hash = $${queryIndex++}`);
|
||||
values.push(passwordHash);
|
||||
} catch (hashError) {
|
||||
return next(hashError); // Pass hashing error
|
||||
}
|
||||
}
|
||||
|
||||
// Add the user ID for the WHERE clause
|
||||
values.push(id);
|
||||
|
||||
if (fieldsToUpdate.length === 0) {
|
||||
return res.status(400).json({ message: 'Keine gültigen Felder zum Aktualisieren angegeben.' });
|
||||
}
|
||||
|
||||
const updateQuery = `UPDATE users SET ${fieldsToUpdate.join(', ')}, updated_at = CURRENT_TIMESTAMP WHERE user_id = $${queryIndex} RETURNING user_id, username, role, updated_at`;
|
||||
|
||||
try {
|
||||
const result = await db.query(updateQuery, values);
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({ message: 'Benutzer nicht gefunden.' });
|
||||
}
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
// Handle potential unique constraint violation for username
|
||||
if (error.code === '23505' && error.constraint === 'users_username_key') {
|
||||
return res.status(409).json({ message: 'Benutzername bereits vergeben.' });
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// Delete a user (Admin only)
|
||||
exports.deleteUser = async (req, res, next) => {
|
||||
const { id } = req.params;
|
||||
|
||||
// Optional: Prevent admin from deleting themselves?
|
||||
// if (req.user.userId === id) {
|
||||
// return res.status(403).json({ message: "Admins können sich nicht selbst löschen." });
|
||||
// }
|
||||
|
||||
try {
|
||||
const result = await db.query('DELETE FROM users WHERE user_id = $1 RETURNING user_id', [id]);
|
||||
if (result.rowCount === 0) {
|
||||
return res.status(404).json({ message: 'Benutzer nicht gefunden.' });
|
||||
}
|
||||
res.status(204).send(); // No Content
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
46
src/db/db.js
Normal file
46
src/db/db.js
Normal file
@ -0,0 +1,46 @@
|
||||
// src/db/db.js
|
||||
const { Pool } = require('pg');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Create a new PostgreSQL connection pool
|
||||
// It reads connection details from environment variables:
|
||||
// PGUSER, PGHOST, PGDATABASE, PGPASSWORD, PGPORT
|
||||
// Or from DATABASE_URL environment variable.
|
||||
const pool = new Pool({
|
||||
connectionString: process.env.DATABASE_URL,
|
||||
// Optional: Add SSL configuration if required by your PostgreSQL server
|
||||
// ssl: {
|
||||
// rejectUnauthorized: false // Necessary for self-signed certificates or some cloud providers
|
||||
// }
|
||||
});
|
||||
|
||||
// Function to execute the initialization script
|
||||
const initializeDatabase = async () => {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
console.log('Checking database schema...');
|
||||
// Read the init.sql file
|
||||
const initSqlPath = path.join(__dirname, 'init.sql');
|
||||
const initSql = fs.readFileSync(initSqlPath, 'utf8');
|
||||
|
||||
// Execute the SQL script
|
||||
// This script uses "CREATE TABLE IF NOT EXISTS" and "CREATE TYPE IF NOT EXISTS"
|
||||
// so it's safe to run multiple times.
|
||||
await client.query(initSql);
|
||||
console.log('Database schema check/initialization complete.');
|
||||
|
||||
} catch (err) {
|
||||
console.error('Error during database initialization:', err.stack);
|
||||
throw err; // Re-throw error to be caught by server start logic
|
||||
} finally {
|
||||
client.release(); // Release the client back to the pool
|
||||
}
|
||||
};
|
||||
|
||||
// Export the pool and the initialization function
|
||||
module.exports = {
|
||||
query: (text, params) => pool.query(text, params), // Method to run queries
|
||||
pool, // Export the pool itself if needed elsewhere
|
||||
initializeDatabase,
|
||||
};
|
215
src/db/init.sql
Normal file
215
src/db/init.sql
Normal file
@ -0,0 +1,215 @@
|
||||
-- Database Initialization Script for Tischtennis-Turnierverwaltung
|
||||
|
||||
-- Enable UUID generation
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
|
||||
-- User Roles Enum (optional but good practice)
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE user_role AS ENUM ('admin', 'referee', 'spectator');
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
|
||||
-- Users Table
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
user_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
username VARCHAR(50) UNIQUE NOT NULL,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
role user_role NOT NULL DEFAULT 'spectator', -- Default role
|
||||
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Function to update 'updated_at' timestamp
|
||||
CREATE OR REPLACE FUNCTION trigger_set_timestamp()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Trigger for users table
|
||||
DO $$ BEGIN
|
||||
CREATE TRIGGER set_timestamp_users
|
||||
BEFORE UPDATE ON users
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION trigger_set_timestamp();
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null; -- Ignore if trigger already exists
|
||||
END $$;
|
||||
|
||||
|
||||
-- Tournament Type Enum (KO or Group)
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE tournament_type AS ENUM ('knockout', 'group');
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
|
||||
-- Game Type Enum (Points per set)
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE game_type AS ENUM ('11_points', '21_points');
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
|
||||
|
||||
-- Tournaments Table
|
||||
CREATE TABLE IF NOT EXISTS tournaments (
|
||||
tournament_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
name VARCHAR(255) NOT NULL,
|
||||
date DATE,
|
||||
location VARCHAR(255),
|
||||
description TEXT,
|
||||
logo_url VARCHAR(255), -- URL or path to the logo file
|
||||
tournament_type tournament_type NOT NULL DEFAULT 'knockout',
|
||||
game_type game_type NOT NULL DEFAULT '11_points',
|
||||
max_players INTEGER,
|
||||
status VARCHAR(50) DEFAULT 'planned', -- e.g., planned, running, finished, cancelled
|
||||
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by UUID REFERENCES users(user_id) ON DELETE SET NULL -- Link to the admin who created it
|
||||
);
|
||||
|
||||
-- Trigger for tournaments table
|
||||
DO $$ BEGIN
|
||||
CREATE TRIGGER set_timestamp_tournaments
|
||||
BEFORE UPDATE ON tournaments
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION trigger_set_timestamp();
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
|
||||
|
||||
-- Players Table
|
||||
CREATE TABLE IF NOT EXISTS players (
|
||||
player_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
first_name VARCHAR(100) NOT NULL,
|
||||
last_name VARCHAR(100) NOT NULL,
|
||||
club VARCHAR(150),
|
||||
qttr_points INTEGER,
|
||||
age_class VARCHAR(50), -- e.g., U18, Herren A, Damen
|
||||
-- Add more fields as needed (e.g., date_of_birth, gender)
|
||||
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Trigger for players table
|
||||
DO $$ BEGIN
|
||||
CREATE TRIGGER set_timestamp_players
|
||||
BEFORE UPDATE ON players
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION trigger_set_timestamp();
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
|
||||
|
||||
-- Tournament Players Table (Many-to-Many relationship)
|
||||
-- Links players to specific tournaments they are participating in
|
||||
CREATE TABLE IF NOT EXISTS tournament_players (
|
||||
tournament_player_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
tournament_id UUID NOT NULL REFERENCES tournaments(tournament_id) ON DELETE CASCADE,
|
||||
player_id UUID NOT NULL REFERENCES players(player_id) ON DELETE CASCADE,
|
||||
seed INTEGER, -- Optional seeding position
|
||||
group_name VARCHAR(50), -- For group stage
|
||||
status VARCHAR(50) DEFAULT 'registered', -- e.g., registered, active, eliminated
|
||||
UNIQUE (tournament_id, player_id), -- Ensure a player is only added once per tournament
|
||||
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
|
||||
-- Matches Table
|
||||
CREATE TABLE IF NOT EXISTS matches (
|
||||
match_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
tournament_id UUID NOT NULL REFERENCES tournaments(tournament_id) ON DELETE CASCADE,
|
||||
round INTEGER, -- Round number (e.g., 1, 2, Quarterfinal=3, Semifinal=4, Final=5)
|
||||
match_number_in_round INTEGER, -- Order within the round
|
||||
player1_id UUID REFERENCES players(player_id) ON DELETE SET NULL, -- Reference to player 1
|
||||
player2_id UUID REFERENCES players(player_id) ON DELETE SET NULL, -- Reference to player 2
|
||||
winner_id UUID REFERENCES players(player_id) ON DELETE SET NULL, -- Determined after match completion
|
||||
scheduled_time TIMESTAMPTZ,
|
||||
status VARCHAR(50) DEFAULT 'scheduled', -- e.g., scheduled, ongoing, finished, postponed
|
||||
table_number VARCHAR(20), -- Which table the match is played on
|
||||
referee_id UUID REFERENCES users(user_id) ON DELETE SET NULL, -- Assigned referee
|
||||
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
||||
-- Add constraints or logic to ensure player1_id != player2_id if needed
|
||||
);
|
||||
|
||||
-- Trigger for matches table
|
||||
DO $$ BEGIN
|
||||
CREATE TRIGGER set_timestamp_matches
|
||||
BEFORE UPDATE ON matches
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION trigger_set_timestamp();
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
|
||||
|
||||
-- Sets Table (Stores individual set scores for each match)
|
||||
CREATE TABLE IF NOT EXISTS sets (
|
||||
set_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
match_id UUID NOT NULL REFERENCES matches(match_id) ON DELETE CASCADE,
|
||||
set_number INTEGER NOT NULL CHECK (set_number > 0), -- e.g., 1, 2, 3, 4, 5
|
||||
player1_score INTEGER NOT NULL CHECK (player1_score >= 0),
|
||||
player2_score INTEGER NOT NULL CHECK (player2_score >= 0),
|
||||
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE (match_id, set_number) -- Ensure set numbers are unique per match
|
||||
);
|
||||
|
||||
|
||||
-- Notifications Table (For push notifications or general announcements)
|
||||
CREATE TABLE IF NOT EXISTS notifications (
|
||||
notification_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
tournament_id UUID REFERENCES tournaments(tournament_id) ON DELETE CASCADE, -- Optional: Link to a specific tournament
|
||||
message TEXT NOT NULL,
|
||||
type VARCHAR(50) DEFAULT 'info', -- e.g., info, warning, match_start, result_update
|
||||
is_sticky BOOLEAN DEFAULT FALSE, -- If true, needs manual dismissal
|
||||
target_audience VARCHAR(50) DEFAULT 'all', -- e.g., all, spectators, referees, specific_player_id
|
||||
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at TIMESTAMPTZ -- Optional: Auto-hide after this time
|
||||
);
|
||||
|
||||
|
||||
-- Add Indexes for performance on frequently queried columns
|
||||
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
|
||||
CREATE INDEX IF NOT EXISTS idx_tournaments_date ON tournaments(date);
|
||||
CREATE INDEX IF NOT EXISTS idx_players_last_name ON players(last_name);
|
||||
CREATE INDEX IF NOT EXISTS idx_tournament_players_tournament ON tournament_players(tournament_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_tournament_players_player ON tournament_players(player_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_matches_tournament ON matches(tournament_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_matches_status ON matches(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_sets_match ON sets(match_id);
|
||||
|
||||
|
||||
-- Optional: Table for storing settings like backup interval
|
||||
CREATE TABLE IF NOT EXISTS application_settings (
|
||||
setting_key VARCHAR(100) PRIMARY KEY,
|
||||
setting_value TEXT,
|
||||
description TEXT,
|
||||
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Trigger for application_settings table
|
||||
DO $$ BEGIN
|
||||
CREATE TRIGGER set_timestamp_application_settings
|
||||
BEFORE UPDATE ON application_settings
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION trigger_set_timestamp();
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
|
||||
-- Example: Insert default backup setting if not present
|
||||
-- INSERT INTO application_settings (setting_key, setting_value, description)
|
||||
-- VALUES ('backup_interval_minutes', '1440', 'Interval for automated database backups in minutes.')
|
||||
-- ON CONFLICT (setting_key) DO NOTHING;
|
||||
|
||||
|
||||
-- Initial Data (Example: Add first admin user - handled by application logic now)
|
||||
-- Consider adding default age classes or other lookup data here if needed.
|
||||
|
58
src/middleware/authMiddleware.js
Normal file
58
src/middleware/authMiddleware.js
Normal file
@ -0,0 +1,58 @@
|
||||
// src/middleware/authMiddleware.js
|
||||
const jwt = require('jsonwebtoken');
|
||||
const JWT_SECRET = process.env.JWT_SECRET;
|
||||
|
||||
// Middleware to verify JWT token
|
||||
const authenticateToken = (req, res, next) => {
|
||||
// Get token from the Authorization header ('Bearer TOKEN')
|
||||
const authHeader = req.headers['authorization'];
|
||||
const token = authHeader && authHeader.split(' ')[1];
|
||||
|
||||
if (token == null) {
|
||||
// No token provided
|
||||
return res.status(401).json({ message: 'Zugriff verweigert. Kein Token bereitgestellt.' }); // Unauthorized
|
||||
}
|
||||
|
||||
// Verify the token
|
||||
jwt.verify(token, JWT_SECRET, (err, user) => {
|
||||
if (err) {
|
||||
console.error("JWT Verification Error:", err.message);
|
||||
if (err.name === 'TokenExpiredError') {
|
||||
return res.status(401).json({ message: 'Token abgelaufen.' });
|
||||
}
|
||||
return res.status(403).json({ message: 'Zugriff verweigert. Ungültiges Token.' }); // Forbidden
|
||||
}
|
||||
|
||||
// Token is valid, attach user payload (with id and role) to the request object
|
||||
req.user = user; // Contains { userId, username, role } from the JWT payload
|
||||
next(); // Proceed to the next middleware or route handler
|
||||
});
|
||||
};
|
||||
|
||||
// Middleware to check for specific roles (e.g., 'admin')
|
||||
// Usage: router.post('/', authenticateToken, authorizeRole('admin'), (req, res) => { ... });
|
||||
const authorizeRole = (requiredRole) => {
|
||||
return (req, res, next) => {
|
||||
if (!req.user || req.user.role !== requiredRole) {
|
||||
return res.status(403).json({ message: `Zugriff verweigert. Rolle '${requiredRole}' erforderlich.` }); // Forbidden
|
||||
}
|
||||
next(); // User has the required role
|
||||
};
|
||||
};
|
||||
|
||||
// Middleware to check for multiple roles (e.g., 'admin' or 'referee')
|
||||
const authorizeRoles = (...allowedRoles) => {
|
||||
return (req, res, next) => {
|
||||
if (!req.user || !allowedRoles.includes(req.user.role)) {
|
||||
return res.status(403).json({ message: `Zugriff verweigert. Erforderliche Rollen: ${allowedRoles.join(' oder ')}.` }); // Forbidden
|
||||
}
|
||||
next(); // User has one of the allowed roles
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
module.exports = {
|
||||
authenticateToken,
|
||||
authorizeRole,
|
||||
authorizeRoles
|
||||
};
|
33
src/middleware/errorMiddleware.js
Normal file
33
src/middleware/errorMiddleware.js
Normal file
@ -0,0 +1,33 @@
|
||||
// src/middleware/errorMiddleware.js
|
||||
|
||||
// Basic error handling middleware
|
||||
// Catches errors passed via next(error) from route handlers or other middleware
|
||||
const errorHandler = (err, req, res, next) => {
|
||||
console.error("Unhandled Error:", err.stack || err); // Log the full error stack
|
||||
|
||||
// Default error status and message
|
||||
let statusCode = err.statusCode || 500; // Use specific status code if available, otherwise default to 500
|
||||
let message = err.message || 'Interner Serverfehler.';
|
||||
|
||||
// Handle specific error types if needed
|
||||
if (err.name === 'ValidationError') { // Example for a potential validation error library
|
||||
statusCode = 400;
|
||||
message = `Validierungsfehler: ${err.message}`;
|
||||
} else if (err.code === '23505') { // Handle PostgreSQL unique violation errors
|
||||
statusCode = 409; // Conflict
|
||||
message = `Konflikt: Ein Eintrag mit diesen Daten existiert bereits. (${err.detail})`;
|
||||
} else if (err.code === '23503') { // Handle PostgreSQL foreign key violation
|
||||
statusCode = 400; // Bad Request or Conflict (409) might be appropriate
|
||||
message = `Referenzfehler: Der verknüpfte Eintrag existiert nicht. (${err.detail})`;
|
||||
}
|
||||
// Add more specific error handling as needed
|
||||
|
||||
// Send the error response
|
||||
res.status(statusCode).json({
|
||||
message: message,
|
||||
// Optionally include stack trace in development environment
|
||||
stack: process.env.NODE_ENV === 'development' ? err.stack : undefined,
|
||||
});
|
||||
};
|
||||
|
||||
module.exports = errorHandler;
|
25
src/routes/authRoutes.js
Normal file
25
src/routes/authRoutes.js
Normal file
@ -0,0 +1,25 @@
|
||||
// src/routes/authRoutes.js
|
||||
const express = require('express');
|
||||
const authController = require('../controllers/authController');
|
||||
// Import role authorization if needed for registration logic later
|
||||
// const { authorizeRole } = require('../middleware/authMiddleware');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// POST /api/auth/register - Register a new user
|
||||
// Initially, anyone might register, or you might restrict this later.
|
||||
// The createInitialAdminUser function handles the very first admin.
|
||||
// You might want to add authorizeRole('admin') here later so only admins can create users.
|
||||
router.post('/register', authController.register);
|
||||
|
||||
// POST /api/auth/login - Login a user
|
||||
router.post('/login', authController.login);
|
||||
|
||||
// Example of a protected route to test authentication
|
||||
// GET /api/auth/profile - Get current user's profile (requires login)
|
||||
// router.get('/profile', authenticateToken, (req, res) => {
|
||||
// // req.user is populated by authenticateToken middleware
|
||||
// res.json({ user: req.user });
|
||||
// });
|
||||
|
||||
module.exports = router;
|
41
src/routes/matchRoutes.js
Normal file
41
src/routes/matchRoutes.js
Normal file
@ -0,0 +1,41 @@
|
||||
// src/routes/matchRoutes.js
|
||||
const express = require('express');
|
||||
const { authorizeRole, authorizeRoles } = require('../middleware/authMiddleware');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// --- Placeholder Controller Functions (replace with actual controller) ---
|
||||
const placeholderController = (feature) => (req, res) => {
|
||||
res.status(501).json({ message: `Funktion '${feature}' noch nicht implementiert.` });
|
||||
};
|
||||
|
||||
// --- Routes ---
|
||||
|
||||
// GET /api/matches - Get all matches (e.g., for a specific tournament via query param)
|
||||
// Accessible by all logged-in users
|
||||
router.get('/', placeholderController('getAllMatches')); // Add query param filtering later
|
||||
|
||||
// GET /api/matches/:id - Get details of a specific match
|
||||
// Accessible by all logged-in users
|
||||
router.get('/:id', placeholderController('getMatchById'));
|
||||
|
||||
// POST /api/matches - Manually add a match (Admin only)
|
||||
router.post('/', authorizeRole('admin'), placeholderController('createMatch'));
|
||||
|
||||
// PUT /api/matches/:id - Update match details (e.g., schedule, table) (Admin only)
|
||||
router.put('/:id', authorizeRole('admin'), placeholderController('updateMatch'));
|
||||
|
||||
// DELETE /api/matches/:id - Delete a match (Admin only)
|
||||
router.delete('/:id', authorizeRole('admin'), placeholderController('deleteMatch'));
|
||||
|
||||
// POST /api/matches/:id/score - Enter/Update scores for a match (Admin or Referee)
|
||||
router.post('/:id/score', authorizeRoles('admin', 'referee'), placeholderController('updateMatchScore'));
|
||||
|
||||
// GET /api/matches/export/pdf - Export matches (current, future, past) as PDF (Admin only)
|
||||
router.get('/export/pdf', authorizeRole('admin'), placeholderController('exportMatchesPdf'));
|
||||
|
||||
// POST /api/matches/generate-bracket - Generate tournament bracket (Admin only)
|
||||
// Requires tournament ID and potentially other parameters in the body
|
||||
router.post('/generate-bracket', authorizeRole('admin'), placeholderController('generateBracket'));
|
||||
|
||||
module.exports = router;
|
36
src/routes/playerRoutes.js
Normal file
36
src/routes/playerRoutes.js
Normal file
@ -0,0 +1,36 @@
|
||||
// src/routes/playerRoutes.js
|
||||
const express = require('express');
|
||||
const { authorizeRole, authorizeRoles } = require('../middleware/authMiddleware');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// --- Placeholder Controller Functions (replace with actual controller) ---
|
||||
const placeholderController = (feature) => (req, res) => {
|
||||
res.status(501).json({ message: `Funktion '${feature}' noch nicht implementiert.` });
|
||||
};
|
||||
|
||||
// --- Routes ---
|
||||
|
||||
// GET /api/players - Get all players (accessible by admin, maybe referee?)
|
||||
router.get('/', authorizeRoles('admin', 'referee'), placeholderController('getAllPlayers'));
|
||||
|
||||
// GET /api/players/:id - Get a specific player (accessible by admin, maybe referee?)
|
||||
router.get('/:id', authorizeRoles('admin', 'referee'), placeholderController('getPlayerById'));
|
||||
|
||||
// POST /api/players - Add a new player (Admin only)
|
||||
router.post('/', authorizeRole('admin'), placeholderController('createPlayer'));
|
||||
|
||||
// PUT /api/players/:id - Update a player (Admin only)
|
||||
router.put('/:id', authorizeRole('admin'), placeholderController('updatePlayer'));
|
||||
|
||||
// DELETE /api/players/:id - Remove a player (Admin only)
|
||||
router.delete('/:id', authorizeRole('admin'), placeholderController('deletePlayer'));
|
||||
|
||||
// POST /api/players/import - Import players from CSV (Admin only)
|
||||
router.post('/import', authorizeRole('admin'), placeholderController('importPlayers'));
|
||||
|
||||
// GET /api/players/export - Export players to CSV (Admin only)
|
||||
router.get('/export', authorizeRole('admin'), placeholderController('exportPlayers'));
|
||||
|
||||
|
||||
module.exports = router;
|
38
src/routes/tournamentRoutes.js
Normal file
38
src/routes/tournamentRoutes.js
Normal file
@ -0,0 +1,38 @@
|
||||
// src/routes/tournamentRoutes.js
|
||||
const express = require('express');
|
||||
const tournamentController = require('../controllers/tournamentController');
|
||||
const { authorizeRole } = require('../middleware/authMiddleware'); // Import role authorization
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// --- Public routes (accessible by anyone logged in) ---
|
||||
// GET /api/tournaments - Get all tournaments
|
||||
router.get('/', tournamentController.getAllTournaments);
|
||||
|
||||
// GET /api/tournaments/:id - Get a specific tournament
|
||||
router.get('/:id', tournamentController.getTournamentById);
|
||||
|
||||
|
||||
// --- Admin only routes ---
|
||||
// POST /api/tournaments - Create a new tournament
|
||||
router.post('/', authorizeRole('admin'), tournamentController.createTournament);
|
||||
|
||||
// PUT /api/tournaments/:id - Update a tournament
|
||||
router.put('/:id', authorizeRole('admin'), tournamentController.updateTournament);
|
||||
|
||||
// DELETE /api/tournaments/:id - Delete a tournament
|
||||
router.delete('/:id', authorizeRole('admin'), tournamentController.deleteTournament);
|
||||
|
||||
// --- Placeholder routes for future implementation (Admin only) ---
|
||||
|
||||
// POST /api/tournaments/import - Import tournaments from CSV
|
||||
router.post('/import', authorizeRole('admin'), tournamentController.importTournaments);
|
||||
|
||||
// GET /api/tournaments/export - Export tournaments to CSV
|
||||
router.get('/export', authorizeRole('admin'), tournamentController.exportTournaments);
|
||||
|
||||
// POST /api/tournaments/:id/logo - Add/Update logo for a tournament
|
||||
router.post('/:id/logo', authorizeRole('admin'), tournamentController.addLogo);
|
||||
|
||||
|
||||
module.exports = router;
|
26
src/routes/userRoutes.js
Normal file
26
src/routes/userRoutes.js
Normal file
@ -0,0 +1,26 @@
|
||||
// src/routes/userRoutes.js
|
||||
const express = require('express');
|
||||
const userController = require('../controllers/userController');
|
||||
const { authorizeRole } = require('../middleware/authMiddleware'); // Only admins manage users
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// All user management routes require admin privileges
|
||||
router.use(authorizeRole('admin'));
|
||||
|
||||
// GET /api/users - Get all users
|
||||
router.get('/', userController.getAllUsers);
|
||||
|
||||
// POST /api/users - Create a new user
|
||||
router.post('/', userController.createUser);
|
||||
|
||||
// GET /api/users/:id - Get a single user by ID
|
||||
router.get('/:id', userController.getUserById);
|
||||
|
||||
// PUT /api/users/:id - Update a user
|
||||
router.put('/:id', userController.updateUser);
|
||||
|
||||
// DELETE /api/users/:id - Delete a user
|
||||
router.delete('/:id', userController.deleteUser);
|
||||
|
||||
module.exports = router;
|
43
src/utils/generateCertificate.js
Normal file
43
src/utils/generateCertificate.js
Normal file
@ -0,0 +1,43 @@
|
||||
// src/utils/generateCertificate.js
|
||||
const child_process = require('child_process');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const keyPath = path.join(__dirname, '..', '..', 'key.pem'); // Place in root directory
|
||||
const certPath = path.join(__dirname, '..', '..', 'cert.pem'); // Place in root directory
|
||||
|
||||
// Check if files already exist
|
||||
if (fs.existsSync(keyPath) && fs.existsSync(certPath)) {
|
||||
console.log('Certificate files (key.pem, cert.pem) already exist. Skipping generation.');
|
||||
console.log('Delete the files if you want to regenerate them.');
|
||||
process.exit(0); // Exit successfully
|
||||
}
|
||||
|
||||
// OpenSSL command to generate a self-signed certificate
|
||||
// -x509: Output a self-signed certificate instead of a certificate request.
|
||||
// -nodes: Don't encrypt the private key.
|
||||
// -days 365: Validity period.
|
||||
// -newkey rsa:2048: Generate a new RSA key of 2048 bits.
|
||||
// -keyout: Path to write the private key.
|
||||
// -out: Path to write the certificate.
|
||||
// -subj: Subject information (avoids interactive prompts). Customize as needed.
|
||||
const opensslCommand = `openssl req -x509 -nodes -days 365 -newkey rsa:2048 -keyout "${keyPath}" -out "${certPath}" -subj "/C=DE/ST=Niedersachsen/L=Hannover/O=TT Tournament Dev/OU=Development/CN=localhost"`;
|
||||
|
||||
console.log('Generating self-signed certificate...');
|
||||
console.log(`Executing: ${opensslCommand}`);
|
||||
|
||||
try {
|
||||
// Execute the command synchronously
|
||||
child_process.execSync(opensslCommand, { stdio: 'inherit' }); // Show output/errors
|
||||
console.log('Successfully generated key.pem and cert.pem');
|
||||
console.log(`Key file: ${keyPath}`);
|
||||
console.log(`Certificate file: ${certPath}`);
|
||||
console.log('\nNote: Your browser will likely show a security warning for this self-signed certificate.');
|
||||
} catch (error) {
|
||||
console.error('\n-----------------------------------------');
|
||||
console.error('Error generating certificate:', error.message);
|
||||
console.error('-----------------------------------------');
|
||||
console.error('Please ensure OpenSSL is installed and accessible in your system PATH.');
|
||||
console.error('Command failed:', opensslCommand);
|
||||
process.exit(1); // Exit with error code
|
||||
}
|
Reference in New Issue
Block a user