diff --git a/src/controllers/matchController.js b/src/controllers/matchController.js new file mode 100644 index 0000000..c5be4da --- /dev/null +++ b/src/controllers/matchController.js @@ -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.' }); +}; diff --git a/src/controllers/playerController.js b/src/controllers/playerController.js new file mode 100644 index 0000000..c5a6f8f --- /dev/null +++ b/src/controllers/playerController.js @@ -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 +};