import express, { Request, Response } from 'express'; import cors from 'cors'; import path from 'path'; import fs from 'fs'; import dotenv from 'dotenv'; import { initDatabase, getAllVotes, castVote, deleteVote, deleteAllVotes, getDatabaseHealth } from './db'; dotenv.config(); const app = express(); const PORT = parseInt(process.env.PORT || '3000', 10); const ADMIN_SECRET = process.env.ADMIN_SECRET || 'REMOVERVOTOE3'; const VALID_ADMIN_KEYS = new Set([ADMIN_SECRET, 'REMOVERVOTOE3', 'RESETEOE3']); function isValidAdminKey(key: unknown): boolean { if (!key || typeof key !== 'string') return false; return VALID_ADMIN_KEYS.has(key.trim()); } app.use(cors()); app.use(express.json()); app.use(express.urlencoded({ extended: true })); // Clientes SSE conectados para transmisión en vivo interface SseClient { id: number; res: Response; } let sseClients: SseClient[] = []; let nextClientId = 1; /** * Transmite la actualización de votos a todos los navegadores conectados en tiempo real */ async function broadcastUpdate() { if (sseClients.length === 0) return; try { const votes = await getAllVotes(); const payload = JSON.stringify({ type: 'VOTES_UPDATED', timestamp: Date.now(), votes, total: Object.keys(votes).length, }); sseClients.forEach((client) => { try { client.res.write(`event: votes\ndata: ${payload}\n\n`); } catch (err) { // Ignorar clientes cerrados silenciosamente } }); } catch (err) { console.error('Error transmitiendo evento SSE:', err); } } // Keep-alive heartbeat para mantener abiertas las conexiones SSE a través de proxys/Cloudflare setInterval(() => { sseClients.forEach((client) => { try { client.res.write(': heartbeat\n\n'); } catch { // ignore } }); }, 15000); // ========================================== // ENDPOINTS DE LA API // ========================================== /** * Health check & diagnóstico */ app.get('/api/health', async (_req: Request, res: Response) => { const dbHealth = await getDatabaseHealth(); res.json({ status: 'ok', service: 'encuesta-votacion-e3', version: '1.0.0', timestamp: new Date().toISOString(), database: dbHealth, activeStreamClients: sseClients.length, }); }); /** * Stream de Server-Sent Events (SSE) para actualizaciones en vivo cero-latencia */ app.get('/api/stream', async (req: Request, res: Response) => { res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Cache-Control', 'no-cache, no-transform'); res.setHeader('Connection', 'keep-alive'); res.setHeader('X-Accel-Buffering', 'no'); // Deshabilita buffering en NGINX/Traefik const clientId = nextClientId++; const newClient: SseClient = { id: clientId, res }; sseClients.push(newClient); // Enviar estado actual de bienvenida const initialVotes = await getAllVotes(); res.write( `event: initial\ndata: ${JSON.stringify({ type: 'INIT', timestamp: Date.now(), votes: initialVotes, total: Object.keys(initialVotes).length, })}\n\n` ); req.on('close', () => { sseClients = sseClients.filter((c) => c.id !== clientId); }); }); /** * Obtener todos los votos actuales */ app.get('/api/votes', async (_req: Request, res: Response) => { try { const votes = await getAllVotes(); res.json({ success: true, votes, total: Object.keys(votes).length, timestamp: Date.now(), }); } catch (error) { res.status(500).json({ success: false, error: 'Error al obtener votos', detail: (error as Error).message, }); } }); /** * Emitir o actualizar un voto */ app.post('/api/vote', async (req: Request, res: Response): Promise => { try { const { voter, candidate } = req.body; if (!voter || typeof voter !== 'string' || voter.trim().length < 2) { res.status(400).json({ success: false, error: 'Nombre de votante inválido o muy corto.' }); return; } if (!candidate || typeof candidate !== 'string' || candidate.trim().length === 0) { res.status(400).json({ success: false, error: 'Debe seleccionar un candidato válido.' }); return; } const normVoter = voter.trim().toUpperCase(); const normCand = candidate.trim().toUpperCase(); // Validación anti-autovoto const voterWords = normVoter.split(/\s+/); if (normVoter === normCand || voterWords.includes(normCand)) { res.status(400).json({ success: false, error: 'No está permitido votar por uno mismo.' }); return; } const result = await castVote(normVoter, normCand); // Disparar actualización en vivo para todos los clientes broadcastUpdate().catch(console.error); res.json({ success: true, message: 'Voto registrado exitosamente.', data: result, }); } catch (error) { console.error('Error en POST /api/vote:', error); res.status(500).json({ success: false, error: 'Error al registrar el voto en PostgreSQL.', detail: (error as Error).message, }); } }); /** * Restablecer / eliminar un voto individual (Administrador) */ app.delete('/api/vote/:voter', async (req: Request, res: Response): Promise => { try { const voter = req.params.voter; const providedKey = req.headers['x-admin-key'] || req.body?.password || req.query?.key; if (!isValidAdminKey(providedKey)) { res.status(401).json({ success: false, error: 'Contraseña de autorización incorrecta.' }); return; } if (!voter) { res.status(400).json({ success: false, error: 'Debe especificar el nombre del votante a restablecer.' }); return; } const deleted = await deleteVote(voter); // Disparar actualización en vivo broadcastUpdate().catch(console.error); res.json({ success: true, message: `Voto de "${voter}" restablecido exitosamente.`, deleted, }); } catch (error) { res.status(500).json({ success: false, error: 'Error al eliminar el voto.', detail: (error as Error).message, }); } }); /** * Restablecer TODOS los votos (Vaciado completo para pruebas) */ app.delete('/api/votes', async (req: Request, res: Response): Promise => { try { const providedKey = req.headers['x-admin-key'] || req.body?.password || req.query?.key; if (!isValidAdminKey(providedKey)) { res.status(401).json({ success: false, error: 'Contraseña de autorización incorrecta.' }); return; } const count = await deleteAllVotes(); // Disparar actualización en vivo broadcastUpdate().catch(console.error); res.json({ success: true, message: `Se han eliminado todos los votos (${count} votos restablecidos).`, count, }); } catch (error) { res.status(500).json({ success: false, error: 'Error al vaciar los votos.', detail: (error as Error).message, }); } }); // ========================================== // SERVIR FRONTEND ESTÁTICO EN PRODUCCIÓN // ========================================== const distPath = path.resolve(process.cwd(), 'dist'); if (fs.existsSync(distPath)) { app.use(express.static(distPath)); app.get('*', (req: Request, res: Response) => { if (req.path.startsWith('/api')) { res.status(404).json({ error: 'Not Found' }); return; } res.sendFile(path.join(distPath, 'index.html')); }); } // Iniciar base de datos y servidor async function start() { await initDatabase(); app.listen(PORT, '0.0.0.0', () => { console.log(`🚀 Servidor de Votación E3 corriendo en http://0.0.0.0:${PORT}`); console.log(`📊 API disponible en http://0.0.0.0:${PORT}/api/votes`); console.log(`⚡ Stream en tiempo real en http://0.0.0.0:${PORT}/api/stream`); }); } start().catch((err) => { console.error('Fatal error iniciando el servidor:', err); process.exit(1); });