new controllers

This commit is contained in:
MLH
2025-04-07 22:26:32 +02:00
parent 7a0bd064db
commit ba4a83f27d
2 changed files with 575 additions and 0 deletions

View File

@ -0,0 +1,375 @@
// src/controllers/matchController.js
const db = require('../db/db');
// Helper function to get player names for matches
const enrichMatchesWithPlayerNames = async (matches) => {
if (!matches || matches.length === 0) {
return [];
}
const playerIds = new Set();
matches.forEach(m => {
if (m.player1_id) playerIds.add(m.player1_id);
if (m.player2_id) playerIds.add(m.player2_id);
if (m.winner_id) playerIds.add(m.winner_id);
});
if (playerIds.size === 0) return matches;
const playerResult = await db.query(
'SELECT player_id, first_name, last_name FROM players WHERE player_id = ANY($1::uuid[])',
[Array.from(playerIds)]
);
const playerMap = new Map(playerResult.rows.map(p => [p.player_id, `${p.first_name} ${p.last_name}`]));
return matches.map(m => ({
...m,
player1_name: m.player1_id ? playerMap.get(m.player1_id) || 'Unbekannt' : null,
player2_name: m.player2_id ? playerMap.get(m.player2_id) || 'Unbekannt' : null,
winner_name: m.winner_id ? playerMap.get(m.winner_id) || 'Unbekannt' : null,
}));
};
// Get all matches, filtered by tournament_id (required) and optionally status
exports.getAllMatches = async (req, res, next) => {
const { tournamentId, status } = req.query;
if (!tournamentId) {
return res.status(400).json({ message: 'tournamentId query parameter ist erforderlich.' });
}
let query = `
SELECT m.*
FROM matches m
WHERE m.tournament_id = $1`;
const params = [tournamentId];
let paramIndex = 2;
if (status) {
query += ` AND m.status = $${paramIndex++}`;
params.push(status);
}
query += ' ORDER BY m.round, m.match_number_in_round, m.scheduled_time NULLS LAST, m.created_at';
try {
const result = await db.query(query, params);
const enrichedMatches = await enrichMatchesWithPlayerNames(result.rows);
res.json(enrichedMatches);
} catch (error) {
next(error);
}
};
// Get a single match by ID
exports.getMatchById = async (req, res, next) => {
const { id } = req.params;
try {
// Fetch match and associated sets
const matchResult = await db.query('SELECT * FROM matches WHERE match_id = $1', [id]);
if (matchResult.rows.length === 0) {
return res.status(404).json({ message: 'Spiel nicht gefunden.' });
}
const setsResult = await db.query('SELECT set_number, player1_score, player2_score FROM sets WHERE match_id = $1 ORDER BY set_number', [id]);
const match = matchResult.rows[0];
match.sets = setsResult.rows; // Add sets to the match object
// Enrich with player names
const [enrichedMatch] = await enrichMatchesWithPlayerNames([match]);
res.json(enrichedMatch);
} catch (error) {
next(error);
}
};
// Create a new match manually (Admin only)
exports.createMatch = async (req, res, next) => {
const {
tournament_id,
round,
match_number_in_round,
player1_id,
player2_id,
scheduled_time,
table_number,
status // Optional, defaults to 'scheduled'
} = req.body;
if (!tournament_id) {
return res.status(400).json({ message: 'tournament_id ist erforderlich.' });
}
if (player1_id && player1_id === player2_id) {
return res.status(400).json({ message: 'Spieler 1 und Spieler 2 dürfen nicht identisch sein.' });
}
try {
const result = await db.query(
`INSERT INTO matches (tournament_id, round, match_number_in_round, player1_id, player2_id, scheduled_time, table_number, status)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING *`,
[
tournament_id,
round || null,
match_number_in_round || null,
player1_id || null,
player2_id || null,
scheduled_time || null,
table_number || null,
status || 'scheduled'
]
);
const [enrichedMatch] = await enrichMatchesWithPlayerNames(result.rows);
res.status(201).json(enrichedMatch);
} catch (error) {
// Handle foreign key violation if tournament_id or player_ids are invalid
if (error.code === '23503') {
return res.status(400).json({ message: `Ungültige Turnier- oder Spieler-ID angegeben. (${error.detail})` });
}
next(error);
}
};
// Update match details (Admin only)
exports.updateMatch = async (req, res, next) => {
const { id } = req.params;
const {
round,
match_number_in_round,
player1_id,
player2_id,
scheduled_time,
status,
table_number,
referee_id,
// winner_id should generally be set via score update, but allow manual override if needed
winner_id
} = req.body;
const fieldsToUpdate = [];
const values = [];
let queryIndex = 1;
const addUpdate = (field, value) => {
// Allow explicitly setting fields to null
if (value !== undefined) {
fieldsToUpdate.push(`${field} = $${queryIndex++}`);
values.push(value === '' ? null : value); // Treat empty string as null
}
};
try {
// Prevent setting player1 and player2 to the same ID during update
const currentMatchRes = await db.query('SELECT player1_id, player2_id FROM matches WHERE match_id = $1', [id]);
if (currentMatchRes.rows.length === 0) {
return res.status(404).json({ message: 'Spiel nicht gefunden.' });
}
const currentMatch = currentMatchRes.rows[0];
const p1 = player1_id !== undefined ? player1_id : currentMatch.player1_id;
const p2 = player2_id !== undefined ? player2_id : currentMatch.player2_id;
if (p1 && p2 && p1 === p2) {
return res.status(400).json({ message: 'Spieler 1 und Spieler 2 dürfen nicht identisch sein.' });
}
addUpdate('round', round);
addUpdate('match_number_in_round', match_number_in_round);
addUpdate('player1_id', player1_id);
addUpdate('player2_id', player2_id);
addUpdate('scheduled_time', scheduled_time);
addUpdate('status', status);
addUpdate('table_number', table_number);
addUpdate('referee_id', referee_id);
addUpdate('winner_id', winner_id);
if (fieldsToUpdate.length === 0) {
return res.status(400).json({ message: 'Keine Daten zum Aktualisieren angegeben.' });
}
values.push(id); // Add match_id for WHERE clause
const updateQuery = `UPDATE matches SET ${fieldsToUpdate.join(', ')}, updated_at = CURRENT_TIMESTAMP WHERE match_id = $${queryIndex} RETURNING *`;
const result = await db.query(updateQuery, values);
if (result.rows.length === 0) {
// Should have been caught earlier, but as a safeguard
return res.status(404).json({ message: 'Spiel nicht gefunden.' });
}
const [enrichedMatch] = await enrichMatchesWithPlayerNames(result.rows);
res.json(enrichedMatch);
} catch (error) {
// Handle foreign key violation if player_ids/referee_id are invalid
if (error.code === '23503') {
return res.status(400).json({ message: `Ungültige Spieler- oder Schiedsrichter-ID angegeben. (${error.detail})` });
}
next(error);
}
};
// Delete a match (Admin only)
exports.deleteMatch = async (req, res, next) => {
const { id } = req.params;
try {
// ON DELETE CASCADE for 'sets' table should handle associated set scores
const result = await db.query('DELETE FROM matches WHERE match_id = $1 RETURNING match_id', [id]);
if (result.rowCount === 0) {
return res.status(404).json({ message: 'Spiel nicht gefunden.' });
}
res.status(204).send(); // No Content
} catch (error) {
next(error);
}
};
// Enter/Update scores for a match (Admin or Referee)
exports.updateMatchScore = async (req, res, next) => {
const { id: matchId } = req.params;
const { sets } = req.body; // Expecting an array: [{ set_number: 1, player1_score: 11, player2_score: 9 }, ...]
if (!Array.isArray(sets) || sets.length === 0) {
return res.status(400).json({ message: 'Ein Array von Satzergebnissen ist erforderlich.' });
}
// --- Validation ---
for (const set of sets) {
if (
set.set_number == null || set.player1_score == null || set.player2_score == null ||
isNaN(parseInt(set.set_number)) || isNaN(parseInt(set.player1_score)) || isNaN(parseInt(set.player2_score)) ||
parseInt(set.set_number) <= 0 || parseInt(set.player1_score) < 0 || parseInt(set.player2_score) < 0
) {
return res.status(400).json({ message: 'Ungültige Satzdaten. Jeder Satz benötigt set_number (>0), player1_score (>=0), player2_score (>=0).' });
}
}
const client = await db.pool.connect(); // Use a transaction
try {
await client.query('BEGIN'); // Start transaction
// 1. Fetch match details (including player IDs and tournament game type)
const matchRes = await client.query(`
SELECT m.match_id, m.player1_id, m.player2_id, m.status, t.game_type
FROM matches m
JOIN tournaments t ON m.tournament_id = t.tournament_id
WHERE m.match_id = $1
`, [matchId]);
if (matchRes.rows.length === 0) {
await client.query('ROLLBACK');
return res.status(404).json({ message: 'Spiel nicht gefunden.' });
}
const match = matchRes.rows[0];
if (!match.player1_id || !match.player2_id) {
await client.query('ROLLBACK');
return res.status(400).json({ message: 'Spiel kann nicht bewertet werden, da nicht beide Spieler zugewiesen sind.' });
}
// 2. Insert or Update Set Scores
// Use ON CONFLICT to handle cases where scores for a set are updated
for (const set of sets) {
await client.query(
`INSERT INTO sets (match_id, set_number, player1_score, player2_score)
VALUES ($1, $2, $3, $4)
ON CONFLICT (match_id, set_number)
DO UPDATE SET player1_score = EXCLUDED.player1_score,
player2_score = EXCLUDED.player2_score`,
[matchId, parseInt(set.set_number), parseInt(set.player1_score), parseInt(set.player2_score)]
);
}
// 3. Determine Winner (Simplified Logic - needs refinement based on actual rules)
// Fetch all sets for this match again after potential updates
const allSetsRes = await client.query('SELECT set_number, player1_score, player2_score FROM sets WHERE match_id = $1 ORDER BY set_number', [matchId]);
const allSets = allSetsRes.rows;
let player1SetsWon = 0;
let player2SetsWon = 0;
const pointsToWinSet = match.game_type === '21_points' ? 21 : 11; // Determine points needed per set
for (const s of allSets) {
// Basic win condition (must win by 2 unless score is high, e.g. 10-10 -> 12-10)
// This simplified logic doesn't handle deuce perfectly.
const p1Win = s.player1_score >= pointsToWinSet && s.player1_score >= s.player2_score + 2;
const p2Win = s.player2_score >= pointsToWinSet && s.player2_score >= s.player1_score + 2;
// Handle edge case like 11-10 vs 10-11 -> requires deuce logic
// TODO: Refine set win logic for edge cases and deuce
if (p1Win) {
player1SetsWon++;
} else if (p2Win) {
player2SetsWon++;
}
}
// Determine match winner based on sets won (e.g., first to 3 sets in best-of-5)
// TODO: Make "sets needed to win match" configurable per tournament type (KO/Group) or globally
const setsNeededToWinMatch = 3; // Assuming best of 5 for now
let winnerId = null;
let finalStatus = 'ongoing'; // Default status unless match is finished
if (player1SetsWon >= setsNeededToWinMatch) {
winnerId = match.player1_id;
finalStatus = 'finished';
} else if (player2SetsWon >= setsNeededToWinMatch) {
winnerId = match.player2_id;
finalStatus = 'finished';
}
// Optional: If all possible sets are played (e.g., 5 sets) and no one reached setsNeededToWinMatch (unlikely with correct logic), mark as finished?
// 4. Update Match Status and Winner
await client.query(
'UPDATE matches SET winner_id = $1, status = $2, updated_at = CURRENT_TIMESTAMP WHERE match_id = $3',
[winnerId, finalStatus, matchId]
);
await client.query('COMMIT'); // Commit transaction
// Fetch the updated match data to return
const updatedMatchResult = await db.query('SELECT * FROM matches WHERE match_id = $1', [matchId]);
const updatedSetsResult = await db.query('SELECT set_number, player1_score, player2_score FROM sets WHERE match_id = $1 ORDER BY set_number', [matchId]);
const finalMatchData = updatedMatchResult.rows[0];
finalMatchData.sets = updatedSetsResult.rows;
const [enrichedFinalMatch] = await enrichMatchesWithPlayerNames([finalMatchData]);
res.json({ message: 'Ergebnis erfolgreich gespeichert.', match: enrichedFinalMatch });
} catch (error) {
await client.query('ROLLBACK'); // Rollback transaction on error
next(error);
} finally {
client.release(); // Release client back to the pool
}
};
// --- Placeholder functions for other features ---
exports.exportMatchesPdf = async (req, res, next) => {
// TODO: Implement PDF export logic
// 1. Fetch match data (filtered by tournament, status, date range etc.)
// 2. Use 'pdfkit' library
// 3. Define PDF structure (header, table layout)
// 4. Add match data to the PDF table
// 5. Set response headers for PDF download
// 6. Pipe PDF stream to response
res.status(501).json({ message: 'Funktion PDF-Export noch nicht implementiert.' });
};
exports.generateBracket = async (req, res, next) => {
// TODO: Implement bracket generation logic (very complex)
// Requires: Tournament ID, list of registered/seeded players
// Logic depends heavily on tournament_type ('knockout' or 'group')
// - Knockout: Seeding, pairing logic, generating match records for each round.
// - Group: Group creation, round-robin match generation within groups.
// Needs to create match records in the 'matches' table.
const { tournamentId } = req.body;
if (!tournamentId) return res.status(400).json({ message: "tournamentId ist erforderlich." });
res.status(501).json({ message: 'Funktion Turnierbaum-Generierung noch nicht implementiert.' });
};

View File

@ -0,0 +1,200 @@
// src/controllers/playerController.js
const db = require('../db/db');
// Get all players - potentially with filtering/sorting later
exports.getAllPlayers = async (req, res, next) => {
// Basic filtering example (add more as needed)
const { search } = req.query;
let query = 'SELECT player_id, first_name, last_name, club, qttr_points, age_class FROM players';
const params = [];
if (search) {
query += ' WHERE first_name ILIKE $1 OR last_name ILIKE $1 OR club ILIKE $1';
params.push(`%${search}%`);
}
query += ' ORDER BY last_name, first_name'; // Default sorting
try {
const result = await db.query(query, params);
res.json(result.rows);
} catch (error) {
next(error);
}
};
// Get a single player by ID
exports.getPlayerById = async (req, res, next) => {
const { id } = req.params;
try {
const result = await db.query('SELECT player_id, first_name, last_name, club, qttr_points, age_class, created_at, updated_at FROM players WHERE player_id = $1', [id]);
if (result.rows.length === 0) {
return res.status(404).json({ message: 'Spieler nicht gefunden.' });
}
res.json(result.rows[0]);
} catch (error) {
next(error);
}
};
// Create a new player (Admin only)
exports.createPlayer = async (req, res, next) => {
const { first_name, last_name, club, qttr_points, age_class } = req.body;
// Basic validation
if (!first_name || !last_name) {
return res.status(400).json({ message: 'Vorname und Nachname sind erforderlich.' });
}
// Ensure qttr_points is a number or null
const qtt_numeric = qttr_points ? parseInt(qttr_points, 10) : null;
if (qttr_points && isNaN(qtt_numeric)) {
return res.status(400).json({ message: 'QTTR-Punkte müssen eine Zahl sein.' });
}
try {
const result = await db.query(
`INSERT INTO players (first_name, last_name, club, qttr_points, age_class)
VALUES ($1, $2, $3, $4, $5)
RETURNING player_id, first_name, last_name, club, qttr_points, age_class`,
[first_name, last_name, club || null, qtt_numeric, age_class || null]
);
res.status(201).json(result.rows[0]);
} catch (error) {
next(error);
}
};
// Update an existing player (Admin only)
exports.updatePlayer = async (req, res, next) => {
const { id } = req.params;
const { first_name, last_name, club, qttr_points, age_class } = req.body;
// Build query dynamically
const fieldsToUpdate = [];
const values = [];
let queryIndex = 1;
const addUpdate = (field, value) => {
// Allow explicitly setting fields to null or empty string (which becomes null for text fields)
if (value !== undefined) {
let processedValue = value;
if (field === 'qttr_points') {
processedValue = value ? parseInt(value, 10) : null;
if (value && isNaN(processedValue)) {
throw new Error('QTTR-Punkte müssen eine Zahl sein.');
}
} else {
// Treat empty strings as null for nullable text fields
processedValue = value === '' ? null : value;
}
fieldsToUpdate.push(`${field} = $${queryIndex++}`);
values.push(processedValue);
}
};
try {
// Validate required fields if they are being updated to empty
if (first_name === '') return res.status(400).json({ message: 'Vorname darf nicht leer sein.' });
if (last_name === '') return res.status(400).json({ message: 'Nachname darf nicht leer sein.' });
addUpdate('first_name', first_name);
addUpdate('last_name', last_name);
addUpdate('club', club);
addUpdate('qttr_points', qttr_points);
addUpdate('age_class', age_class);
if (fieldsToUpdate.length === 0) {
return res.status(400).json({ message: 'Keine Daten zum Aktualisieren angegeben.' });
}
values.push(id); // Add player_id for WHERE clause
const updateQuery = `UPDATE players SET ${fieldsToUpdate.join(', ')}, updated_at = CURRENT_TIMESTAMP WHERE player_id = $${queryIndex} RETURNING player_id, first_name, last_name, club, qttr_points, age_class`;
const result = await db.query(updateQuery, values);
if (result.rows.length === 0) {
return res.status(404).json({ message: 'Spieler nicht gefunden.' });
}
res.json(result.rows[0]);
} catch (error) {
// Handle potential validation errors from addUpdate
if (error.message.includes('QTTR-Punkte')) {
return res.status(400).json({ message: error.message });
}
next(error);
}
};
// Delete a player (Admin only)
exports.deletePlayer = async (req, res, next) => {
const { id } = req.params;
try {
// The database schema uses ON DELETE CASCADE/SET NULL for relationships,
// so deleting a player might automatically remove them from tournaments
// or set player references in matches to NULL. Review init.sql for details.
const result = await db.query('DELETE FROM players WHERE player_id = $1 RETURNING player_id', [id]);
if (result.rowCount === 0) {
return res.status(404).json({ message: 'Spieler nicht gefunden.' });
}
res.status(204).send(); // No Content
} catch (error) {
// Handle potential foreign key issues if not handled by CASCADE/SET NULL
if (error.code === '23503') {
console.warn(`Attempted to delete player ${id} which might still be referenced elsewhere unexpectedly.`);
return res.status(409).json({ message: 'Spieler konnte nicht gelöscht werden, da er noch in Verwendung ist (z.B. in einem aktiven Spiel). Überprüfen Sie die Datenbank-Constraints.' });
}
next(error);
}
};
// --- Placeholder functions for Import/Export ---
exports.importPlayers = async (req, res, next) => {
// TODO: Implement CSV import logic
// 1. Use 'multer' middleware in the route to handle file upload (e.g., req.file)
// 2. Read the file stream (req.file.buffer or req.file.path)
// 3. Use 'csv-parser' to parse the CSV data row by row
// 4. Validate each row's data
// 5. Insert valid player data into the database (consider bulk insert for performance)
// 6. Report success or errors (e.g., which rows failed)
console.log("Received file for import (implement logic):", req.file); // Example if using multer
res.status(501).json({ message: 'Funktion Spieler-Import noch nicht implementiert.' });
};
exports.exportPlayers = async (req, res, next) => {
// TODO: Implement CSV export logic
// 1. Fetch player data from the database (potentially filtered)
// 2. Use 'fast-csv' or similar to format data as CSV
// 3. Set response headers for CSV download:
// res.setHeader('Content-Type', 'text/csv');
// res.setHeader('Content-Disposition', 'attachment; filename="spieler_export.csv"');
// 4. Pipe the CSV stream to the response object (res)
try {
const players = await db.query('SELECT first_name, last_name, club, qttr_points, age_class FROM players ORDER BY last_name');
// Placeholder - just send JSON for now until CSV lib is added
if (players.rows.length === 0) {
return res.status(404).json({ message: "Keine Spieler zum Exportieren gefunden." });
}
// Actual implementation would use fast-csv here
res.setHeader('Content-Type', 'application/json'); // Change to text/csv for actual export
res.setHeader('Content-Disposition', 'attachment; filename="spieler_export.json"'); // Change filename
res.json(players.rows); // Send JSON as placeholder
// Example with fast-csv (requires installation and import):
// const csvStream = format({ headers: true });
// res.setHeader('Content-Type', 'text/csv');
// res.setHeader('Content-Disposition', 'attachment; filename="spieler_export.csv"');
// csvStream.pipe(res);
// players.rows.forEach(player => csvStream.write(player));
// csvStream.end();
} catch (error) {
next(error);
}
// res.status(501).json({ message: 'Funktion Spieler-Export noch nicht implementiert.' }); // Remove this line when implemented
};